A security audit of the machine you are sitting at. Run it and you get a score out of 100 and one HTML file listing everything it did not like, worst first, each finding carrying the steps and the commands that fix it on the system the audit ran on.
No server, no dashboard, no account, nothing left running afterwards. Linux, macOS and Windows, out of one set of checks.
It reads, and it changes nothing. The only thing that leaves the machine is the list of package names and versions the CVE module sends to OSV.dev, which is what
--no-cveturns off. Everything else stays local, and the report is a file in the directory you ran it from.
pip install sysaudit-cliThe distribution is sysaudit-cli because sysaudit was taken on PyPI. The command it installs
is still sysaudit.
From source:
git clone https://github.com/Xyness/SysAudit.git
cd SysAudit
pip install -e ".[dev]"sysaudit run # full audit, HTML report
sysaudit run --details # and print the fixes in the terminal
sysaudit run -o report.html # pick the filename
sysaudit run -f json -o audit.json # machine readable
sysaudit run -f md -o audit.md # to paste into a ticket
sysaudit run -m system,network # pick your modules
sysaudit run --no-cve # skip the OSV lookup, much faster
sysaudit run --plain # no colour, greppable
sysaudit run --lang fr # force a language; it picks one on its own
sysaudit run --quiet # write the report, say nothing
sysaudit run --fail-on high --strict # exit 3 if it is that bad, for CI
sysaudit run --profile server # what shape of machine this is
sysaudit run --baseline sysaudit-baseline.toml # findings you have decided to live with
sysaudit diff monday.json friday.json # what moved since last time
sysaudit merge hosts/*.json # several machines at once
sysaudit baseline audit.json # write that baseline from a report
sysaudit fix audit.json # a script of the fixes, all of it commented
sysaudit explain SYS-011 # what one check looks for
sysaudit modules # what runs, and what it needs
sysaudit versionThe audit runs its modules concurrently and the terminal draws them as they finish, so a slow one does not hold the others up. Everything ends up in the same report either way.
| flag | does |
|---|---|
-o, --output PATH |
where to write the report. Defaults to sysaudit_report_<date>.html |
-f, --format html|md|json |
HTML to read, Markdown to paste, JSON to feed something else |
-m, --modules a,b |
a subset: system, network, auth, files, hardening, secrets, containers |
--no-cve |
skip the OSV.dev lookup, which is most of the runtime |
-d, --details |
print each finding's fix under it instead of only in the report |
--plain |
no colour, no spinner, one line per event |
-q, --quiet |
no terminal output at all |
--fail-on SEVERITY |
exit 3 on a finding at that level or worse |
--min-score N |
exit 3 when the score is below N |
--strict |
exit 3 when a module did not finish, whatever the score says |
-c, --config PATH |
config file. Defaults to sysaudit.toml if there is one |
--baseline PATH |
a file of accepted findings, on top of the config |
--profile NAME |
default, workstation, server or hardened |
--no-config |
ignore any config file and audit with the defaults |
--lang auto|en|fr |
what to print in. Defaults to the machine's own language |
| module | needs | what it looks at |
|---|---|---|
system |
some checks need root | OS and kernel, running services, what starts on its own, escalation paths, patch level |
network |
some checks need root | listening sockets, firewall state, established connections, interfaces |
auth |
most checks need root | accounts, SSH keys and the sshd config, sudo rules, login history, password policy |
files |
some checks need root | permissions, world-writable paths, recent changes, shell history |
hardening |
some checks need root | kernel settings, mandatory access control, secure boot, trust store, logging |
secrets |
root to see other users' | private keys, cloud credentials, tokens in dotfiles and in history |
containers |
some checks need root | the docker socket, who can reach it, and what the containers can reach |
cve |
network | installed packages matched against OSV.dev |
A few of these are worth explaining.
Listening ports. What is being looked for is the binding, not the port. A service on
127.0.0.1 is a service nobody outside the machine can reach, and the same service on 0.0.0.0
is an open door, so the finding is about the address. Six ports are expected to be on every
interface and are not reported as exposed: 22, 53, 80, 443, 631 and 5353. On Windows the seven
management ports — RPC, the three NetBIOS ones, SMB and both WinRM ports — get one finding
between them rather than seven, because a stock Windows listens on all of them out of the box
and always will, and listing them one by one buries the ports that say something about this
particular machine. The dynamic RPC range above 49152 is ignored for the same reason.
Firewall. On Windows the answer is per profile. Domain, private and public are each read,
and public off while the other two are on is a different finding from all three off: high
against critical. The state comes out of the registry rather than out of Get-NetFirewallProfile,
because that cmdlet lives in a module trimmed server installs do not always have, and the
registry is always there. When nothing answers at all the report says the state is unknown,
which is the one thing it must not report as enabled.
Escalation paths. The same question on three systems, with three different answers. On Linux
it is SUID: 25 binaries ship with a distribution and are expected, and anything else is reported
as a lead rather than a finding, with the first step being to ask the package manager who owns
it. On Windows it is three classics. A service registered as C:\Program Files\App\svc.exe
without quotes makes Windows try C:\Program.exe first, so anyone who can write to C:\ owns a
service that usually runs as SYSTEM. AlwaysInstallElevated is only reported when both halves of
it are set, because that is the combination that lets any user install an MSI as SYSTEM. And a
directory under Program Files that a standard user can write into is the Windows shape of the
world-writable check: somewhere an installer or a service will later run something from.
Containers. Its own module rather than a few checks in system, because the boundary it is
about does not exist anywhere else in the tool. Everything else asks what a user on this machine
can reach; this asks what something inside a container can reach, and on a default installation
the answer is more than most people expect.
Access to the container socket is root on the host, not root in a container: the daemon runs as
root and will mount any path into a container for whoever asks. So membership of the docker
group is a second root account with no password and no sudo entry, and the finding says that
rather than calling it a permissions issue. The same reasoning sets the levels on the daemon
listening over TCP — the API has no authentication of its own, so a port with no client
certificate in front of it is remote root — and on the containers themselves, where privileged
and a mounted socket are not hardening questions but the host.
Nothing here fires on a machine without a container runtime. That is not a clean result and it is not reported as one; there is simply nothing to look at.
The trust store. A certificate authority added to this machine can sign a certificate for any site on the internet that the machine will then accept. The list of them should be short and known. The check reports the ones added locally rather than shipped with the system, and raises the level when one is named after a product whose job is to read TLS — a corporate proxy, or a developer proxy left trusted after an afternoon of debugging. It is high rather than critical because a great many companies do install their own authority on purpose, and the finding says so: the problem is when nobody can tell you which of the two it is.
Whether there would be anything to read afterwards. journald keeps the log in memory unless
/var/log/journal exists, so on a machine without it everything is gone at the next reboot,
including the reboot somebody used to end whatever they were doing. That, and auditd installed
but not running, come out as one low finding: neither stops anything, and both decide whether the
question "what happened" has an answer.
Secrets. The rest of the tool asks whether a door is open. This module asks what is sitting behind it: an ssh key with no passphrase, a cloud credentials file the whole machine can read, a token in a dotfile, a password typed on a command line two months ago and still in the history. None of it is a way in on its own, and all of it is what a way in is for.
It reports where a secret is and what kind it is. It never reports what it is. No value read out of any file reaches the report, the JSON or the terminal — only paths, kinds, permissions, line numbers and counts. A report that quoted the key it found would be a more dangerous file than the one it was warning about, and this is a file people email to each other.
Whether an ssh key has a passphrase is three questions rather than one. A PKCS#8 file says so in
its own header. A traditional PEM key carries a Proc-Type line when it is encrypted and nothing
when it is not. The OpenSSH format says nothing in text either way — the cipher name is inside
the base64, and a key with no passphrase names it none — so the first hundred bytes of the body
are decoded to read that name, and the key material itself is never read at all.
The history scan is the part where precision decides whether the module is worth having. A secret
scanner that cries wolf gets switched off, and then it is not a secret scanner. So a bare -p is
mkdir and docker long before it is a password, and the short form only counts after a command
that actually takes one that way. PASSWORD=$SECRET and curl -u "$USER:$PASS" are the habits
this finding is asking for, so they are excluded rather than reported. What is left is a shape
rather than a fact, and the finding says so.
The walk over home directories stops at four levels and at four thousand directories, and skips
node_modules, virtualenvs and package caches — a key inside one of those came with a package
and is a different problem. When it does stop early it says which directory it was in, rather
than reporting a partial answer as a complete one.
The one finding here that is not about something being wrong is the inventory: the list of credential files on the machine, filed as informational. It answers, in advance, the question people ask after a laptop is stolen.
Hardening. Every other module looks for something that is wrong. This one looks for something that is missing, which is a different question and reads differently: nothing it finds is a way in on its own. Each one is a wall that was never built, and they matter on the day something else has already gone wrong. That is why almost all of it is medium and below — a machine with all of it switched off is not compromised, it is a machine where the first mistake goes further than it had to.
On Linux that is the kernel settings — ASLR, ptrace_scope, the protected symlink and hardlink
flags, the ones that decide what an unprivileged process can read about the kernel — and whether
SELinux or AppArmor is actually enforcing anything rather than merely loaded. The settings are
read out of /proc/sys rather than through sysctl, which is a file read instead of a process
and answers the same on a container image that never installed the binary. They come out as one
finding rather than eight, because eight small settings must not weigh as much as a firewall that
is off, and the level depends on which of them are in it: ptrace_scope is the one an attacker
uses the same afternoon rather than in combination with a second bug.
On Windows it is what stands between a process running as SYSTEM and everybody's password — WDigest, LSA protection, Credential Guard — plus SMBv1 and LLMNR, and whether PowerShell writes down what it runs. LLMNR is the one where absence is the risky state: no policy value means multicast name resolution is on, because that is what Windows does when nobody has said otherwise, so the check treats a missing value as enabled rather than as unknown.
On macOS it is SIP and Gatekeeper, and the finding is worded around the fact that neither of them can be turned off by accident. Something did it on purpose, and that is the question worth asking rather than how to turn it back on.
What was not read. Several checks can only see part of the machine without privileges, and
the ones where that matters say so rather than reporting what they saw as everything there was.
/etc/sudoers is mode 440, so an unprivileged run finds no NOPASSWD rule on every machine
ever built; it now reports that it read none rather than that it found none. The password policy
does the same when the file or the provider does not answer. Secure Boot on a UEFI machine says
unknown rather than staying quiet, because unknown is not off and is not on. And the secrets
module names the home directories it could not open, since a process without root can read
exactly one of them: its own.
A search that was stopped. The two checks that walk the filesystem — setuid binaries, and world-writable files — are given a clock, so that one slow mount cannot hold up the whole audit. When that clock runs out the command comes back with nothing, which is exactly what it comes back with on a machine that has nothing to find. Only one of those is a pass, so the walk reports which directories it never got to the end of, and says that what is under them was not checked.
The ssh daemon's configuration. Not its permissions — those are checked with the other sensitive files — but what it says. Root login, empty passwords, password authentication at all, and the algorithms it was told to keep offering after OpenSSH stopped enabling them.
The configuration is read from sshd -T where that works, and from the file where it does not,
and the report says which of the two it was. The difference is the whole check:
PasswordAuthentication defaults to yes, so a file that does not mention it and a file that
turns it off read identically and behave the opposite way. sshd -T prints what the daemon will
actually use with the includes followed and the defaults filled in, and it needs to read the host
keys, which is why it needs root.
Reading the file directly, the Include directives are followed and the Match blocks are not:
a setting under a Match applies to some users and not others, and reporting one as though it
were global says the opposite of what the file says. Where a drop-in and the main file both set
something, the first one sshd would reach wins, which is why a cloud image puts its Include at
the top.
What starts on its own. Cron and the Task Scheduler are the two places people look, which is
most of the reason they are not the two places things hide. Alongside them: desktop autostart
entries, systemd user units, /etc/rc.local, launch agents and launch daemons on macOS, and the
registry Run keys and Startup folders on Windows. The plists are read with plistlib rather than
searched as text, because half of them are binary and grepping one finds nothing.
Two things are screened for. A command line with one of the same words the scheduled-jobs check
looks for, and a program that runs from a temporary or download directory, which nothing that was
installed does. Program Files and the rest of AppData are deliberately not on that list: half
the desktop software on Windows autostarts out of AppData\Local\Programs, and flagging all of
it would bury the one entry that matters under forty that do not.
Code loaded into other people's processes. /etc/ld.so.preload on Linux, AppInit_DLLs and
Winlogon's Shell and Userinit on Windows. Every one of these has a legitimate use and it is
rare, and every one of them is a classic, and almost every machine has none of them at all. The
finding is that something is there, and the report says plainly that which of the two it is
depends on the library and that you need to know.
Failed logins. On Windows, reading the security event log takes administrator rights, and a denied read counts zero events rather than refusing. Zero failures on an unprivileged run is therefore reported as a log that could not be read, and not as a machine nobody has tried to get into. The same rule runs through the whole tool: a check that could not run says so, because the alternative is a clean result that was never earned.
Scheduled jobs. Screened by what is on the command line: encoded PowerShell, hidden windows,
base64 decoding, remote downloads, certutil, bitsadmin, mshta, regsvr32. Bare curl and
wget are deliberately not on the Windows list, because curl.exe ships with Windows now and
flagging it would cost more in false alarms than it is worth.
A lot of the interesting checks need root: reading /etc/shadow, pulling failed login attempts,
walking system directories. Without it the audit still runs, skips what it cannot reach and lists
each skipped check as informational, so an unprivileged score is optimistic by construction. For
the real picture:
sudo sysaudit runWindows is the same story from an elevated PowerShell. The security event log and the BitLocker status are the two you lose without it, and the run says on screen when it is going without.
Installed packages go to OSV.dev as (name, version, ecosystem): dpkg and
rpm on Linux, Homebrew on macOS. They go in batches through querybatch, which answers a whole
list in one request, so a Debian box with 2400 packages costs ten requests rather than 2400 and
every installed package is checked rather than the first hundred.
The batch answer carries advisory ids and nothing else, so the severities are a second round of
requests, one per distinct advisory. Advisories cover several packages at once far more often
than not, so the ids are deduplicated first, and a machine that matches nothing never makes that
second call at all. --no-cve is now about the network rather than about the runtime.
Windows gets a finding saying the module has nothing to report. OSV indexes distribution packages and language ecosystems and has no ecosystem for Windows software, so an empty result there would be an absence of an answer dressed up as a good one.
Severities. The advisory's own rating wins where it has one. Otherwise the base score is
computed from the CVSS vector, because a vector is what OSV actually stores in that field: the
string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, not a number. v3.0, v3.1 and v2 are
computed in cvss.py against the formulas in their own specifications, rounding included. v4 is
not, because v4 scores off a published lookup table rather than off a formula, and a guess at it
would be worse than no number.
Which leaves advisories that nothing can rate, and those get a finding of their own saying so. Filing them at the bottom of the scale would be the same mistake one level up: unrated is not low, and one of them could be a remote root hole.
The findings are filed under system, since that is where the packages live, which is why the
cve row in the module table has no score of its own.
Score = 100 - (sum of penalties)
CRITICAL : -20 each (capped at -40)
HIGH : -10 each (capped at -30)
MEDIUM : -5 each (capped at -20)
LOW : -2 each (capped at -10)
The caps are there so one talkative check cannot floor the score on its own: twenty world-writable files in a temp directory is one problem, not twenty. The same formula runs per module for the breakdown, so the module table tells you where the damage is.
It is a house scale, not CVSS, and it is not calibrated against anything. What it is good for is comparing the same machine to itself after you have fixed something. A score is also only worth whatever the checks behind it could see, so a module that failed shows a dash instead of the 100 it would otherwise score for the checks it never ran, and a platform that is not Linux, macOS or Windows gets told its score means nothing rather than being handed a clean 100.
sysaudit explain NET-005It prints what that check can say, at what level, with the steps and the commands, without running anything or needing a report.
There is no second list behind it. explain parses the module sources and reports what the check
would produce if it fired, so a catalogue that has drifted out of step with the code is not
something that can happen here. The same parse is what the test suite compares the translation
catalogue against, because two implementations of the same read would eventually disagree.
Several checks answer more than one way — NET-005 is the firewall and it has six — so explain
prints them as what they are rather than merging them into a description of the id. Where the
level depends on what was found, it says the ceiling and then says what else it can be.
References. Findings carry ATT&CK technique ids where the mapping is a fact: T1611 on a
container that can reach the host, T1552.003 on a shell history, T1553.004 on a root
certificate. They are there because they are stable, checkable, and say what somebody does with
the thing rather than restating the finding.
CIS and ANSSI control numbers are deliberately absent. Citing a control number that turns out to be the wrong one is worse than citing none, and adding them properly is work for somebody who can check each one against the benchmark rather than from memory. The field takes them whenever that happens.
sysaudit run -f json -o audit.json
sysaudit fix audit.json --severity highIt writes sysaudit_fix.sh, or .ps1 when the report came from Windows — the commands in a
report were chosen for the machine it came from, so the script follows the report rather than
whatever is generating it.
Every command in it is commented out, and not as a formality. This tool does not know which of the commands under a finding is the one that tells you something and which one restarts the ssh daemon you are connected over, and some of them carry a placeholder that has to be filled in first. Deciding that is the work; a script that had already decided it would be a script somebody runs without reading. What this saves is the typing.
So the promise at the top still holds all the way through: it reads, and it changes nothing. The file it writes runs nothing either until somebody goes through it.
Informational findings are left out, because their commands are there to help you look rather than to change anything, and accepted findings are left out because somebody already decided about those.
One .html file, written to be read rather than clicked through: no JavaScript, nothing
collapsed, the whole thing on the page at once. It opens with the score, what each severity is
actually asking of you, and the per-module breakdown, then lists the findings worst first.
Every finding that can be fixed carries the fix rather than a hint at one: a sentence saying what to do, the steps in order, and the commands, chosen for the system the audit ran on. Where there is one obvious value the command uses it, so the diagnostic line is runnable as printed. Raw evidence sits behind a disclosure triangle so it stays out of the way until you want it.
The CSS is inlined and there is nothing to fetch, so the file opens offline, prints, and survives
being emailed to somebody. -f json gives you the same data, steps and commands included,
without the presentation layer.
The tool reads the machine's language and prints in it. LC_ALL, LC_MESSAGES, LANG and
LANGUAGE in that order, then the Windows UI language, which is where it has to come from because
Windows sets none of those. SYSAUDIT_LANG comes first of all, so one run can be forced without
changing the environment every other command in the shell inherits, and --lang overrides
everything. English and French so far.
C and POSIX are not treated as English. They are the absence of a preference, and reading one
as a language would stop the search before it reached a variable that named one.
The JSON stays English. The terminal and the HTML report are for a person and they translate;
the JSON is the machine-readable half, and a config file, a diff and a pipeline all key on text
in it. A report that changed language with the machine's locale would quietly stop matching.
Translation therefore happens on the way out rather than in the model, and the commands in a
finding are never touched at all: a command is the same in every language, and translating one
would be the worst thing this could do.
The catalogue is keyed by the English text as it is written in the source, which keeps the checks
readable — a finding says what it says, in place, with nothing to follow — and which means a
reworded finding can strand its translation. scripts/extract_strings.py reads the module sources
with ast and the test suite compares what it finds against what the catalogue holds, so that is
a failing build rather than a sentence that quietly reverts to English. A sentence with a number in
it is stored as a template: the English one is compiled to a regex, matched against what the check
actually produced, and the captured pieces are put back into the French. Anything that does not
match comes back as it went in, which is English, and is correct.
Every machine has a finding on it that is the job rather than a problem: the web server listening on 443, the build directory everyone writes to. Without somewhere to say so, the third run is the one where people stop opening the report, and a score that cannot move is a score nobody reads.
So a finding can be accepted, in sysaudit.toml:
[[accepted]]
id = "NET-002"
title = "Ports exposed on all interfaces"
reason = "this box is the public web server, that is the job"
expires = 2027-01-01An accepted finding stops counting towards the score. That is the only thing it stops doing. It is still in the report, in a section of its own, with the reason printed next to it and the date the acceptance runs out — because a finding that disappeared would be a config file that edits reality, and the next person to read the report would have no way of knowing what had been decided for them.
The date is the part that matters. When it passes, the finding comes back on its own and starts
counting again, and the report says why it reappeared rather than letting it look like something
new. expires = "never" is available and is spelt out rather than left implied, because an
exception nobody has to renew is an exception nobody remembers making.
An entry with a title accepts one variant of a check; an entry without one accepts all of them.
That distinction is NET-005 again: the firewall being off on purpose and the firewall being
unreadable are not the same decision.
sysaudit run -f json -o audit.json
sysaudit baseline audit.json # writes sysaudit-baseline.toml
sysaudit run --baseline sysaudit-baseline.tomlsysaudit baseline writes one entry per finding, dated ninety days out, with every reason left
empty. That is deliberate: a generated baseline is a list of everything that was wrong on the day
it was generated, and turning that into a list of things somebody decided to live with is the
work. Writing "accepted automatically" into all of them would only hide that the work had not
been done. It also says on the way out how many high and critical findings you have just accepted.
sysaudit.toml in the working directory, or ~/.config/sysaudit/config.toml, or wherever
--config points. --no-config ignores all of it.
[profile]
name = "server"
[network]
expected_ports = [22, 80, 443, 5432]
connection_threshold = 300
[auth]
failed_login_threshold = 500
[files]
ignore_paths = ["/srv/build", "/var/tmp/*.log"]
[severity]
"AUTH-011" = "low"A profile is a named set of the same values you could write by hand. default is what the tool
has always used, server expects 22, 53, 80 and 443, workstation expects neither ssh nor a web
server — a laptop answering on 22 is worth a sentence — and hardened expects nothing and asks
about everything reachable.
The two counts move with the profile for the same reason the ports do. A server on the internet legitimately holds hundreds of established connections and collects thousands of failed ssh logins a week, and a laptop doing either is worth a sentence; one threshold for both shapes is either deaf on one or shrill on the other.
ignore_paths takes globs, and a path with no wildcard also covers everything under it.
severity moves one finding id up or down the scale for this machine, which is the honest way to
say "yes, and here it matters less" without pretending the check did not fire.
Everything the file is doing to a run is printed before the audit starts rather than explained afterwards, because a score that a file moved without saying so is worse than no score.
There is still no server and no agent. Each machine writes its own JSON the way it always did,
and merge reads a pile of them:
sysaudit merge hosts/*.json # a table, in the terminal
sysaudit merge hosts/*.json -f html -o fleet.html
sysaudit merge hosts/*.json --fail-on high # for a pipeline over a fleetWhat it adds over reading them one at a time is the column you cannot see from inside one report:
which findings are on every machine rather than on this one. Those are the ones where the decision
was made the same way everywhere, and where fixing it once at the source is worth more than fixing
it once per host. The other half of that list — what is wrong on exactly one machine — is in the
JSON as only_here.
Two things it is careful about. Two reports of the same host are one machine: the newer wins and the fact is reported, because counting a machine twice gets the arithmetic wrong in the direction that looks better. And a report much older than the others describes a machine as it was rather than as it is, so it is named rather than quietly averaged in. Staleness is measured against the newest report in the pile rather than against the clock, so a fleet audited last month still reads correctly when you open it this month.
The score is a house scale, it is not calibrated against anything, and the thing it is actually
good for is comparing a machine to itself. That is diff:
sysaudit run --quiet -f json -o monday.json
# fix things
sysaudit run --quiet -f json -o friday.json
sysaudit diff monday.json friday.jsonTwo files in. Nothing is kept between them and there is no history file: the reports are the history.
One caveat, once. A report written before 1.1.0 is not comparable with one written after it. The CVSS parser used to read 0.0 out of every vector and file the result as low, so a machine that has not changed scores differently either side of that fix. Diff a fresh pair.
Findings are matched on the id and the title together rather than on the id alone, because
NET-005 is the firewall check and it has five different things it can say, so the same id
across two runs can mean opposite things. A finding whose text moved but whose identity did not
comes out as changed rather than as unchanged, which is how twelve world-writable files becoming
two stays visible instead of reading as no progress at all.
What it will not do is call something fixed that merely stopped being looked for. A module that
crashed, or that only ran on one side because one of the two runs had --no-cve, has all of its
findings held out of the comparison and named as not compared. That is most of the reason to
write a diff rather than to eyeball two reports: the mistake is easy to make and it reads as
progress.
--fail-on on a diff gates on what got worse rather than on what is wrong, which is the gate you
want on a machine with a standing problem you have already decided to live with. -f json gives
the same thing without the presentation layer, and -o writes it to a file.
sysaudit run --quiet -f json -o audit.json --fail-on high --strictExit 3 means the report was written and did not pass. --fail-on takes a severity and fires on
that level or worse, --min-score fires on the number, and --strict fires when a module did
not finish. That last one is the failure mode worth catching: a module that stopped early
reported none of what it looks at, so the score above it is optimistic by exactly those checks,
and a green build on a crashed module is the one result that means nothing. This project's own CI
runs with --strict after it audits the runner.
The reasons go to stderr, so --quiet still says why the build failed, and the report is on disk
either way — a failed build leaves behind the file explaining itself.
The JSON carries counts, score, score_breakdown, every finding with its steps and commands,
and a modules list saying how long each module took and whether it survived. For a gate the
flags do not cover, it is all still there:
jq '.modules[] | select(.failed)' audit.json # anything that did not finishExit codes: 0 when a report was written and passed, 1 for a file or a module name that does
not exist, 2 for a flag or a value that does not, 3 when the report was written and the gate
failed. Output is plain and uncoloured whenever it is not going to a terminal, and --plain
forces that anywhere.
Every external command goes through one wrapper in host.py that returns an empty string rather
than raising when the binary is not there, when there is no permission, or when it times out.
Every check is written to cope with an empty answer, and that is what makes the same file safe to
point at three operating systems.
On Windows it goes through PowerShell and reads JSON back rather than reading what the command
printed, because that text is translated: netsh says State ON on an English machine and
something else entirely on a French one, and a check that greps for ON quietly reports the
wrong thing on half the planet. For the same reason the well-known accounts and groups are
matched by SID and not by name — Administrators is Administrateurs on a French install and
Administratoren on a German one, and Everyone is not called Everyone either.
The report is written as UTF-8 whatever the locale says, which is not a detail: a report written
in the locale encoding while declaring UTF-8 in its own header turns an accented machine name
into a row of question marks. The hostname itself comes out of the environment on Windows rather
than out of gethostname(), which answers in the ANSI code page.
sysaudit/
cli.py command surface
engine.py runs the modules concurrently, assembles the report, scores it
modules/ one file per area, or a package where one file was not enough
host.py platform detection, and every call that leaves the process
models.py findings, module runs, the report
scoring.py findings to a number
catalogue.py the checks, read back out of themselves
config.py sysaudit.toml, and what it is allowed to change
compare.py one report against another
fleet.py several reports at once
remediation.py a report into a script that runs nothing
gate.py a report into an exit code
cvss.py CVSS vectors into base scores
i18n.py the language the machine is set to, and the catalogue lookup
locales/ one file per language, keyed by the English text
render.py everything the terminal prints
report/ the standalone HTML and Markdown reports, and the stylesheet
scripts/demo.py regenerates the capture at the top of this file
scripts/extract_strings.py
every translatable string, read out of the module sources
Modules run concurrently and independently. Inside each of the four that read the machine the checks run in their own threads, since almost all of them are waiting on a subprocess rather than on the CPU; the CVE lookup is the exception, and it is waiting on the network, so it stays async the whole way down. A module that raises does not take the report with it: the failure becomes a finding of its own, the row in the module table turns red, and the rest carries on.
system and auth are packages rather than files, because the checks in them outgrew one.
Nothing about that is visible from outside — the findings still carry module="system", -m auth
still selects it, the score breakdown still has one line each — and the package answers for any
name in it, so which file a check lives in is not something a caller has to know.
Adding one means dropping a file in sysaudit/modules/, exposing
async def run(findings: list[Finding]) -> None, giving it a SUMMARY and a PRIVILEGE for the
catalogue, and registering it in ALL_MODULES in engine.py. Add the name to MODULES in
scoring.py too if it should get its own line in the breakdown, and the strings it prints to
locales/fr.py, which the test suite will insist on. files.py is the smallest of
the four.
pytest -q394 tests, all offline, and none of them running a real check against the host. The Windows half of the tool is the most tested part and the least likely to be run by hand, so those checks are driven by a stub that answers PowerShell expressions with canned JSON, which means the whole Windows surface runs on the Linux runner too. The rest goes to the scoring caps, the UTF-8 round trip, the CVSS arithmetic against the worked examples in the specification, and the handful of places where an unreadable answer must not turn into a pass — including the one in the diff, where a module that crashed must not read as an afternoon of fixes.
CI runs the suite on Linux across 3.10, 3.11 and 3.12, and on macOS and Windows on 3.12. Then it
audits the runner itself and fails the build if any module produced an error finding, which is
how a check that crashes on one platform gets caught rather than quietly skipped. A second job
builds the sdist and the wheel and runs twine check over them.
MIT, see LICENSE.