From 69798b26e401a7a15e24041056daf8d3d58c9f3c Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 19 Jun 2026 02:27:19 -0700 Subject: [PATCH] Add systemd unit posture checks Close the remaining v0.8 posture bullet: flag enabled systemd units with group/world-writable unit files (systemd_unit_writable, sid 100050) and units whose ExecStart binary runs from a writable/unsafe path such as /tmp, /dev/shm, /var/tmp, /home, or /run/user (systemd_exec_unsafe_path, sid 100051). - posture.systemd_findings/parse_systemctl_show/read_systemd_units, wired into posture.run() behind the new posture_systemd config knob. - read_systemd_units shells out to systemctl with timeouts and fails closed on non-systemd hosts; the dashboard /api/posture surfaces findings for free. - Unit tests for the parser and evaluator; TestRunIntegration patches the new reader so run() never touches live systemctl. - Docs: COMMAND_REFERENCE posture list, ROADMAP v0.8 bullet, RUNBOOKS Runbook 2 triggers, config TOML knob. Co-Authored-By: Claude Opus 4.8 --- config/enodia-sentinel.toml | 4 ++ docs/COMMAND_REFERENCE.md | 5 ++ docs/ROADMAP.md | 6 +- docs/RUNBOOKS.md | 2 +- enodia_sentinel/config.py | 1 + enodia_sentinel/posture.py | 117 +++++++++++++++++++++++++++++++++++- tests/test_posture.py | 48 ++++++++++++++- 7 files changed, 177 insertions(+), 6 deletions(-) diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 91ee68d..faf074c 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -151,6 +151,10 @@ posture_sshd_config = "/etc/ssh/sshd_config" posture_sudoers = "/etc/sudoers" posture_sudoers_dir = "/etc/sudoers.d" # posture_path = [] # PATH dirs to audit; empty = safe default set +# Audit enabled systemd service units (writable unit files, ExecStart binaries +# in writable/unsafe paths). Shells out to systemctl with timeouts and fails +# closed on non-systemd hosts; set false to skip the systemctl calls entirely. +posture_systemd = true # --- eBPF event layer ---------------------------------------------------- # Event-driven eBPF monitors: catch short-lived activity the poll loop misses. diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index f41c791..282f991 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -262,6 +262,11 @@ attack, as opposed to an attack in progress: group/world-writable sudoers files. - World-writable or non-root-owned `PATH` directories (binary-hijack vectors). - Loose permissions on sensitive files (`/etc/shadow`, `/etc/passwd`, ...). +- Enabled systemd units with group/world-writable unit files, or whose + `ExecStart` binary runs from a writable/unsafe path (`/tmp`, `/dev/shm`, + `/var/tmp`, `/home`, `/run/user`) — a common persistence/hijack vector. + Shells out to `systemctl` with timeouts and fails closed on non-systemd + hosts; disable with `posture_systemd = false`. - Downgraded package-signature policy (`SigLevel`). Findings are advisory and reuse the standard alert JSON shape (`--json`). This diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 143ffd9..40dd983 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -31,9 +31,9 @@ work that is bigger than single alerts. - ✅ Add `enodia-sentinel incident list/show/export`. - ✅ Add host posture checks (`enodia-sentinel posture check`): SSH root/password/empty-password login, passwordless and writable sudoers, - world-writable PATH components, loose sensitive-file permissions, and disabled - package signatures. Still to come: unexpected enabled services and unsafe - systemd unit settings. + world-writable PATH components, loose sensitive-file permissions, disabled + package signatures, and enabled systemd units with writable unit files or + `ExecStart` binaries in writable/unsafe paths. - ✅ Add a machine-readable `status --json` command for automation. - ✅ Update the red-team harness so every current detection has a named drill and expected `sid` (`sentinel-redteam --list`), with a PASS/MISS verification pass. diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md index 4b1eacb..acd92e5 100644 --- a/docs/RUNBOOKS.md +++ b/docs/RUNBOOKS.md @@ -94,7 +94,7 @@ detector. ## Runbook 2 — Persistence tampering **Triggers:** `persistence` (sid 100015), `fim_modified` on a watched persistence -path. +path, `systemd_unit_writable` (sid 100050), `systemd_exec_unsafe_path` (sid 100051). A change to cron, a systemd unit, an SSH `authorized_keys`, a shell rc file, or sudo/account config — how an attacker survives a reboot. diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index 0699eb8..9b4acb5 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -133,6 +133,7 @@ class Config: posture_sudoers: str = "/etc/sudoers" posture_sudoers_dir: str = "/etc/sudoers.d" posture_path: tuple[str, ...] = () # PATH dirs to audit ('' = safe default set) + posture_systemd: bool = True # audit enabled systemd units (shells out to systemctl) # eBPF on-ramp capture_execve_bpftrace: bool = False diff --git a/enodia_sentinel/posture.py b/enodia_sentinel/posture.py index 5f9ed7f..64e6348 100644 --- a/enodia_sentinel/posture.py +++ b/enodia_sentinel/posture.py @@ -19,13 +19,16 @@ from __future__ import annotations import glob import os +import re import stat from collections.abc import Iterable, Iterator +from typing import NamedTuple from .alert import Alert, Severity from .config import Config -# --- SID allocation (host-posture: 100040-100049) ------------------------- +# --- SID allocation (host-posture: 100040-100047, 100050-100051; +# 100048-100049 belong to the memory_obfuscation detector) ------------- SID_SSH_ROOT_LOGIN = 100040 SID_SSH_PASSWORD_AUTH = 100041 SID_SSH_EMPTY_PASSWORD = 100042 @@ -34,6 +37,8 @@ SID_SUDO_NOAUTH = 100044 SID_SUDOERS_WRITABLE = 100045 SID_PATH_WRITABLE = 100046 SID_SENSITIVE_FILE_PERM = 100047 +SID_SYSTEMD_UNIT_WRITABLE = 100050 +SID_SYSTEMD_EXEC_UNSAFE = 100051 CLASSTYPE = "host-posture" @@ -42,6 +47,11 @@ _DEFAULT_PATH_DIRS = ( "/sbin", "/bin", "/root/bin", ) +# An enabled service whose ExecStart binary lives under one of these is a +# persistence/hijack red flag — these are user- or world-writable locations a +# system service should never run from. +_UNSAFE_EXEC_PREFIXES = ("/tmp/", "/dev/shm/", "/var/tmp/", "/home/", "/run/user/") + # (path, world-readable severity, group/world-writable severity). None = skip # that rule for this file. Keyed by the kind of secret each file holds. _SENSITIVE_FILES: tuple[tuple[str, Severity | None, Severity | None], ...] = ( @@ -281,6 +291,108 @@ def read_sensitive_files( return out +# --- systemd units -------------------------------------------------------- + +class UnitFact(NamedTuple): + name: str + fragment_path: str + fragment_mode: int # st_mode of the unit file (0 if unknown) + enabled: bool + exec_paths: tuple[str, ...] # ExecStart binary paths + + +def systemd_findings(units: Iterable[UnitFact]) -> Iterator[Alert]: + """Findings from enabled systemd units. + + Flags a unit file anyone can rewrite (group/world-writable — what root runs + at boot becomes attacker-controlled) and an enabled service whose ExecStart + binary lives in a writable/unsafe directory (a classic systemd persistence + trick). + """ + for u in units: + if u.fragment_path and (u.fragment_mode & 0o022): + yield Alert( + severity=Severity.HIGH, signature="systemd_unit_writable", + key=f"posture:systemd:writable:{u.name}", + detail=f"systemd unit file is group/world-writable " + f"({stat.filemode(u.fragment_mode)}): {u.fragment_path} ({u.name})", + sid=SID_SYSTEMD_UNIT_WRITABLE, classtype=CLASSTYPE) + for path in u.exec_paths: + if path.startswith(_UNSAFE_EXEC_PREFIXES): + yield Alert( + severity=Severity.HIGH, signature="systemd_exec_unsafe_path", + key=f"posture:systemd:exec:{u.name}:{path}", + detail=f"enabled systemd unit {u.name} runs from a writable/unsafe " + f"path: {path}", + sid=SID_SYSTEMD_EXEC_UNSAFE, classtype=CLASSTYPE) + + +_EXEC_PATH_RE = re.compile(r"path=(\S+)") + + +def parse_systemctl_show(text: str) -> list[tuple[str, str, bool, tuple[str, ...]]]: + """Parse ``systemctl show`` output into (name, fragment_path, enabled, execs). + + Pure: blocks are separated by blank lines; each carries ``Id``, + ``FragmentPath``, ``UnitFileState``, and zero or more ``ExecStart`` lines + (whose ``path=`` field is the resolved binary). No filesystem access. + """ + out: list[tuple[str, str, bool, tuple[str, ...]]] = [] + for block in text.split("\n\n"): + props: dict[str, str] = {} + execs: list[str] = [] + for line in block.splitlines(): + key, _, val = line.partition("=") + if key == "ExecStart": + m = _EXEC_PATH_RE.search(val) + if m: + execs.append(m.group(1)) + else: + props.setdefault(key, val) + name = props.get("Id", "") + if not name: + continue + out.append((name, props.get("FragmentPath", ""), + props.get("UnitFileState", "") == "enabled", tuple(execs))) + return out + + +def read_systemd_units(systemctl: str = "systemctl") -> list[UnitFact]: + """Gather enabled systemd service units as UnitFacts (fails closed). + + Non-systemd or restricted hosts (no ``systemctl``) yield no facts. + """ + import subprocess + try: + listing = subprocess.run( + [systemctl, "list-unit-files", "--type=service", + "--state=enabled", "--no-legend", "--no-pager"], + capture_output=True, text=True, timeout=8).stdout + except (OSError, subprocess.SubprocessError): + return [] + names = [line.split()[0] for line in listing.splitlines() if line.split()] + if not names: + return [] + try: + shown = subprocess.run( + [systemctl, "show", "--no-pager", "--property=Id", + "--property=FragmentPath", "--property=ExecStart", + "--property=UnitFileState", *names], + capture_output=True, text=True, timeout=12).stdout + except (OSError, subprocess.SubprocessError): + return [] + facts: list[UnitFact] = [] + for name, frag, enabled, execs in parse_systemctl_show(shown): + mode = 0 + if frag: + try: + mode = os.stat(frag).st_mode + except OSError: + mode = 0 + facts.append(UnitFact(name, frag, mode, enabled, execs)) + return facts + + # --- orchestration -------------------------------------------------------- def run(cfg: Config) -> Iterator[Alert]: @@ -293,6 +405,9 @@ def run(cfg: Config) -> Iterator[Alert]: yield from path_findings(read_path_dirs(path_dirs)) yield from sensitive_file_findings(read_sensitive_files()) + if cfg.posture_systemd: + yield from systemd_findings(read_systemd_units()) + # Package-signature policy is a posture question too; reuse the tamper check. from . import pkgdb sl = pkgdb.siglevel_alert() diff --git a/tests/test_posture.py b/tests/test_posture.py index d012508..6a7a711 100644 --- a/tests/test_posture.py +++ b/tests/test_posture.py @@ -103,6 +103,50 @@ class TestSensitiveFiles(unittest.TestCase): list(posture.sensitive_file_findings([("/etc/motd", 0o100666)])), []) +class TestSystemd(unittest.TestCase): + SHOW = ( + "Id=clean.service\n" + "FragmentPath=/usr/lib/systemd/system/clean.service\n" + "ExecStart={ path=/usr/bin/cleand ; argv[]=/usr/bin/cleand }\n" + "UnitFileState=enabled\n" + "\n" + "Id=evil.service\n" + "FragmentPath=/etc/systemd/system/evil.service\n" + "ExecStart={ path=/tmp/payload ; argv[]=/tmp/payload --daemon }\n" + "UnitFileState=enabled\n" + ) + + def test_parse_systemctl_show(self): + parsed = posture.parse_systemctl_show(self.SHOW) + self.assertEqual(parsed, [ + ("clean.service", "/usr/lib/systemd/system/clean.service", + True, ("/usr/bin/cleand",)), + ("evil.service", "/etc/systemd/system/evil.service", + True, ("/tmp/payload",)), + ]) + + def test_writable_unit_file_is_high(self): + units = [posture.UnitFact("foo.service", "/etc/systemd/system/foo.service", + 0o100664, True, ("/usr/bin/foo",))] + findings = list(posture.systemd_findings(units)) + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].signature, "systemd_unit_writable") + self.assertEqual(findings[0].severity, Severity.HIGH) + + def test_exec_from_unsafe_path_is_high(self): + units = [posture.UnitFact("evil.service", "/etc/systemd/system/evil.service", + 0o100644, True, ("/tmp/payload",))] + findings = list(posture.systemd_findings(units)) + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].signature, "systemd_exec_unsafe_path") + self.assertEqual(findings[0].severity, Severity.HIGH) + + def test_clean_unit_yields_nothing(self): + units = [posture.UnitFact("clean.service", "/usr/lib/systemd/system/clean.service", + 0o100644, True, ("/usr/bin/cleand",))] + self.assertEqual(list(posture.systemd_findings(units)), []) + + class TestRunIntegration(unittest.TestCase): """Drive run() with every reader monkeypatched to known facts.""" @@ -110,7 +154,7 @@ class TestRunIntegration(unittest.TestCase): self.cfg = Config() self._saved = {n: getattr(posture, n) for n in ("read_sshd_lines", "read_sudoers", "read_path_dirs", - "read_sensitive_files")} + "read_sensitive_files", "read_systemd_units")} import enodia_sentinel.pkgdb as pkgdb self._saved_sig = pkgdb.siglevel_alert pkgdb.siglevel_alert = lambda *a, **k: None @@ -127,6 +171,7 @@ class TestRunIntegration(unittest.TestCase): posture.read_sudoers = lambda m, d: [("/etc/sudoers", "root ALL=(ALL) ALL\n", 0o440)] posture.read_path_dirs = lambda dirs: [("/usr/bin", 0o40755, 0)] posture.read_sensitive_files = lambda *a, **k: [("/etc/shadow", 0o100600)] + posture.read_systemd_units = lambda *a, **k: [] self.assertEqual(list(posture.run(self.cfg)), []) def test_messy_host_aggregates_findings(self): @@ -135,6 +180,7 @@ class TestRunIntegration(unittest.TestCase): ("/etc/sudoers", "bob ALL=(ALL) NOPASSWD: ALL\n", 0o440)] posture.read_path_dirs = lambda dirs: [("/usr/local/bin", 0o40777, 0)] posture.read_sensitive_files = lambda *a, **k: [("/etc/shadow", 0o100644)] + posture.read_systemd_units = lambda *a, **k: [] sigs = {f.signature for f in posture.run(self.cfg)} self.assertSetEqual( sigs,