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 -m system,network # pick your modules
sysaudit run --no-cve # skip the OSV lookup, much faster
sysaudit run --plain # no colour, greppable
sysaudit run --quiet # write the report, say nothing
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|json |
HTML to read, JSON to feed something else |
-m, --modules a,b |
run a subset: system, network, auth, files |
--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 |
| module | needs | what it looks at |
|---|---|---|
system |
some checks need root | OS and kernel, running services, scheduled jobs, escalation paths, patch level |
network |
some checks need root | listening sockets, firewall state, established connections, interfaces |
auth |
most checks need root | accounts, SSH keys, sudo rules, login history, password policy |
files |
some checks need root | permissions, world-writable paths, recent changes, shell history |
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.
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. OSV wants one request per package and a typical Debian box has
well over a thousand installed, so the list is capped at 100 and the requests run ten at a time.
That cap is why --no-cve exists, and why the "no CVEs found" finding says how many packages
were actually checked rather than implying it checked them all.
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 come from the advisory's own rating when it has one, and otherwise from the CVSS v3
base score mapped onto the same four levels the rest of the tool uses. 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.
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.
sysaudit run --quiet -f json -o audit.json
jq -e '.counts.critical == 0 and .counts.high == 0' audit.jsonThere is no --fail-on yet, so the gate is a line of jq. 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:
jq '.modules[] | select(.failed)' audit.json # anything that did not finishThat last one is worth wiring in. A module that crashed still produces a report, and the score in it is optimistic by exactly the checks that never ran, so a green build on a crashed module is the failure mode to watch for. This project's own CI does the same thing after it audits the runner.
Exit codes: 0 when a report was written, 1 for a module name that does not exist, 2 for a
flag that does not. 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
host.py platform detection, and every call that leaves the process
models.py findings, module runs, the report
scoring.py findings to a number
render.py everything the terminal prints
report/ the standalone HTML report and its stylesheet
scripts/demo.py regenerates the capture at the top of this file
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.
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. files.py is the smallest of
the four.
pytest -q81 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, and the handful of places where an unreadable answer must not turn into a pass.
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.