Repository navigation
refactor(security): harden path classification and failure handling in systemd_service_auditor - #5
Conversation
…n systemd_service_auditor Audit of python_scripts/systemd_service_auditor.py: - Compare FragmentPath by path component instead of a raw prefix, so /lib/systemd/system-evil/x.service is no longer treated as standard - Match high-risk directories by component, so /opt/homegrown no longer matches /home - Resolve symlinks before classifying, avoiding false positives on the very common /etc/systemd/system -> /lib/systemd/system links - Bound every systemctl call with a timeout and fail loudly on a non-zero exit instead of reporting a false all-clear - Validate unit names before they are handed back to a subprocess and stop swallowing per-unit exceptions - Add argparse (--json, --timeout, --standard-path, --no-resolve-symlinks) and an exit-code contract for CI use - Add tests/test_systemd_service_auditor.py
krishsharma-code
left a comment
There was a problem hiding this comment.
Self-review of python_scripts/systemd_service_auditor.py (submitted as COMMENT because GitHub blocks self-approval on your own PR).
Scope reviewed: the whole script as it stood on main, against four axes — security boundaries, memory efficiency, edge-case/failure handling, and docstring/comment clarity. Line references below are to the new version.
What was found (3 genuine defects, 4 robustness gaps):
path.startswith('/lib/systemd/system')treated/lib/systemd/system-evil/backdoor.serviceas a standard location — a false negative that hides exactly the persistence mechanism the tool exists to catch.p in pathfor/tmp,/homeetc. matched substrings, so/opt/homegrown/app.servicewas reported HIGH RISK — a false positive that trains the operator to ignore the alert.- Symlinked units (
/etc/systemd/system/foo.service->/lib/systemd/system/foo.service) were reported as non-standard. On a stock Debian/Ubuntu box that is most of the enabled units. except Exception: continueswallowed every per-unit failure with no record.- A non-zero
systemctlexit produced empty stdout, which fell through to "All active systemd services are running from standard locations" — a false all-clear on the failure path. - No timeout on any
systemctlcall. - Unit names taken from stdout were passed straight back into a second subprocess without validation.
What changed and why: component-based _is_within() comparison (1, 2), os.path.realpath before classification with --no-resolve-symlinks to opt out (3), AuditError plus AuditReport.errors so failures are reported rather than hidden (4, 5), timeout= on every call with TimeoutExpired translated (6), SERVICE_NAME_RE validation before re-invoking systemctl (7). Plus argparse, --json, and a 0/1/2 exit-code contract so the script can be a CI gate, a full module docstring documenting the trust boundary, and 36 new tests.
Not changed on purpose: output text is preserved line-for-line for existing findings, and audit_systemd_services() still prints and returns a report, so anyone importing or grepping the old output keeps working. Still read-only — no state changes anywhere.
Verification: py_compile clean, flake8 strict set clean, pytest tests -q -> 127 passed, --help exercised, and the no-systemd path confirmed to exit 2 with a clear message instead of a false clean run.
Follow-up (not in this PR): unit-file ownership and permission checks (--property=FragmentPath gives the path but not who owns it), and a --units-file mode for auditing a mounted image without systemd running.
| #: Unit names systemd accepts: alphanumerics plus ``: _ . @ -`` and the | ||
| #: ``\x2d``-style escapes some units use. Anything else is not passed on to a | ||
| #: subprocess. | ||
| SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9_.@:\\-]+$") |
There was a problem hiding this comment.
This regex is the trust boundary for anything that comes back from systemctl output and then gets handed to a second subprocess. Worth keeping deliberately narrow: it allows A-Za-z0-9_.@:\- and nothing else, so a name containing a space, ;, /, $ or a newline never reaches argv. argv usage means there was no shell-injection path even before this change, but validating here also protects the systemctl show call from being handed a name that systemd would interpret as a different unit (e.g. --foo style arguments).
| # systemctl helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _run_systemctl(args: list[str], timeout: float = DEFAULT_TIMEOUT) -> str: |
There was a problem hiding this comment.
All three failure modes now collapse into one exception type with an actionable message, and timeout= is set on every call. Previously capture_output=True with no timeout meant a wedged dbus/systemd --user would hang the audit indefinitely, and check=False (the default) meant a non-zero exit returned empty stdout that downstream code read as 'no services'. Translating OSError as well covers the permission-denied-on-the-binary case, which is a plausible sudo misconfiguration.
| return completed.stdout | ||
|
|
||
|
|
||
| def list_running_services(timeout: float = DEFAULT_TIMEOUT) -> list[str]: |
There was a problem hiding this comment.
--plain is the fix for the parsing hazard: without it, systemd decorates failed units with a leading ● and may colourise, so line.split()[0] could yield ● instead of the unit name. lstrip handles the bullet for older systemd that ignores --plain, and blank lines are skipped before indexing — the old code raised IndexError on an empty line.
| # Classification | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _is_within(path: str, directory: str) -> bool: |
There was a problem hiding this comment.
This is the core fix. Comparing normalised components means /lib/systemd/system-evil/x.service is not inside /lib/systemd/system, while /lib/systemd/system/x.service and /lib/systemd/system itself both are. The path == directory arm matters for the (unusual) case of FragmentPath pointing at a directory.
| if not fragment_path: | ||
| return Finding(service, "", "missing-fragment-path", SEVERITY_INFO) | ||
|
|
||
| path = os.path.realpath(fragment_path) if resolve_symlinks else fragment_path |
There was a problem hiding this comment.
Resolving before classifying removes the largest source of false positives: /etc/systemd/system/*.service is normally a symlink into /lib/systemd/system/. Note this deliberately resolves the reported path, not the trust decision — if an attacker can symlink from /etc/systemd/system into /tmp, the resolved target still lands in the high-risk branch, which is the behaviour we want.
|
|
||
| except Exception: | ||
| fragment_path = get_fragment_path(service, timeout=timeout) | ||
| except (AuditError, ValueError) as exc: |
There was a problem hiding this comment.
Per-unit failures are now recorded rather than discarded. One unreadable unit no longer aborts the scan, and it also no longer disappears — the error is printed and included in --json output, so an operator can tell 'nothing found' apart from 'nothing could be read'.
| else: | ||
| print_report(report) | ||
|
|
||
| return 1 if report.has_problems else 0 |
There was a problem hiding this comment.
The exit-code contract (0 clean / 1 findings / 2 could not run) is what makes this usable in CI or a cron wrapper. 2 for 'the audit itself failed' is deliberately distinct from 1 for 'findings present', so a wrapper can alert differently on a broken audit than on a real hit.
| assert auditor.classify_path("a.service", "/usr/lib/systemd/system/a.service") is None | ||
|
|
||
|
|
||
| def test_classify_prefix_boundary_is_not_standard(): |
There was a problem hiding this comment.
This test is the regression guard for defect #1 — it fails against the old startswith implementation. Same for test_classify_substring_is_not_high_risk and test_classify_sibling_of_home_is_not_high_risk, which pin the /opt/homegrown and /home2 false positives.
| assert report.has_problems is False | ||
|
|
||
|
|
||
| def test_audit_services_records_error_and_continues(fake): |
There was a problem hiding this comment.
The fake systemctl injects at the _run_systemctl seam, so these tests exercise real control flow without needing systemd. Failure translation (missing binary, timeout, non-zero exit, OSError) is covered separately at the subprocess.run seam in test_run_systemctl_translates_*.
Summary
Audit of
python_scripts/systemd_service_auditor.py, the script that flags running systemd services whose unit files live outside the standard systemd directories. The audit found three genuine correctness defects that make the tool report false results in both directions, plus missing failure handling and no way to consume the output programmatically.Linked Issue
Closes #
Type of Change
What Changed
str.startswith('/lib/systemd/system')accepted/lib/systemd/system-evil/backdoor.serviceas a standard location._is_within()compares normalised components (path == dirorpath.startswith(dir + os.sep)), so a sibling directory with a shared prefix can no longer masquerade as a trusted one.'/home' in pathtest flagged/opt/homegrown/app.serviceas high risk and missed nothing it should have caught. Same fix, applied to/tmp,/var/tmp,/dev/shm,/home./etc/systemd/system/foo.serviceis usually a symlink into/lib/systemd/system/; the old code reported every such unit as non-standard.--no-resolve-symlinksrestores the raw behaviour when needed.except Exception: continuehid a missing binary, a dead bus and a per-unit failure alike, and a non-zero exit produced an empty unit list that was reported as "all services standard". Failures now raiseAuditError(exit 2) or land inAuditReport.errorsand are printed.--timeout, default 15s) so a hung bus cannot wedge the audit forever.SERVICE_NAME_REbefore being passed to a second subprocess. argv is used throughout, so there was no shell injection, but unvalidated names are no longer forwarded.--json,--standard-path(repeatable),--no-resolve-symlinks, and an exit-code contract (0 clean / 1 findings / 2 could not run) for CI.Finding/AuditReportdataclasses, type hints.tests/test_systemd_service_auditor.py— 36 tests covering output parsing, name validation, component boundaries, symlink resolution, failure handling and the CLI contract.audit_systemd_services()is kept as the historical entry point, so existing imports and the README description still hold.New or Modified Scripts
python_scripts/systemd_service_auditor.pytests/test_systemd_service_auditor.pySafety Review
--dry-run(n/a — the script is read-only)Testing Checklist
python3 -m py_compile python_scripts/*.pypassesflake8 python_scripts --select=E9,F63,F7,F82,F821,F823 --max-line-length=120passesfor f in bash_scripts/*.sh; do bash -n "$f"; donepasses (untouched)shellcheck bash_scripts/*.shreports no errors (advisory, untouched)python3 -m pytest tests -q— 127 passed--helpoutput[!] Error running systemctl: systemctl not foundDocumentation
README.mdalready indexessystemd_service_auditor.py; description unchangedCode Quality Checklist
Additional Notes
This is a self-review; GitHub blocks self-approval, so the formal review is submitted with
event: COMMENTand carries the line-level findings.Follow-up not in this PR: the script still only reads
FragmentPath. A later pass could also compare unit-file ownership/permissions, which is the other half of the same persistence story.