Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SysAudit

CI License: MIT Python 3.10+

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-cve turns off. Everything else stays local, and the report is a file in the directory you ran it from.

sysaudit run

Install

pip install sysaudit-cli

The 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]"

Use

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 version

The 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.

Options

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

What it checks

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.

Privileges

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 run

Windows 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.

CVE matching

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.

Scoring

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.

The report

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.

In a pipeline

sysaudit run --quiet -f json -o audit.json
jq -e '.counts.critical == 0 and .counts.high == 0' audit.json

There 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 finish

That 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.

One set of checks, three operating systems

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.

Layout

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.

Tests

pytest -q

81 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.

License

MIT, see LICENSE.

About

Automated machine security audit tool for Windows, Linux and macOS.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages