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 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-19 02:27:19 -07:00
parent 11d91a40b4
commit 69798b26e4
7 changed files with 177 additions and 6 deletions

View file

@ -151,6 +151,10 @@ posture_sshd_config = "/etc/ssh/sshd_config"
posture_sudoers = "/etc/sudoers" posture_sudoers = "/etc/sudoers"
posture_sudoers_dir = "/etc/sudoers.d" posture_sudoers_dir = "/etc/sudoers.d"
# posture_path = [] # PATH dirs to audit; empty = safe default set # 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 ---------------------------------------------------- # --- eBPF event layer ----------------------------------------------------
# Event-driven eBPF monitors: catch short-lived activity the poll loop misses. # Event-driven eBPF monitors: catch short-lived activity the poll loop misses.

View file

@ -262,6 +262,11 @@ attack, as opposed to an attack in progress:
group/world-writable sudoers files. group/world-writable sudoers files.
- World-writable or non-root-owned `PATH` directories (binary-hijack vectors). - World-writable or non-root-owned `PATH` directories (binary-hijack vectors).
- Loose permissions on sensitive files (`/etc/shadow`, `/etc/passwd`, ...). - 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`). - Downgraded package-signature policy (`SigLevel`).
Findings are advisory and reuse the standard alert JSON shape (`--json`). This Findings are advisory and reuse the standard alert JSON shape (`--json`). This

View file

@ -31,9 +31,9 @@ work that is bigger than single alerts.
- ✅ Add `enodia-sentinel incident list/show/export`. - ✅ Add `enodia-sentinel incident list/show/export`.
- ✅ Add host posture checks (`enodia-sentinel posture check`): - ✅ Add host posture checks (`enodia-sentinel posture check`):
SSH root/password/empty-password login, passwordless and writable sudoers, SSH root/password/empty-password login, passwordless and writable sudoers,
world-writable PATH components, loose sensitive-file permissions, and disabled world-writable PATH components, loose sensitive-file permissions, disabled
package signatures. Still to come: unexpected enabled services and unsafe package signatures, and enabled systemd units with writable unit files or
systemd unit settings. `ExecStart` binaries in writable/unsafe paths.
- ✅ Add a machine-readable `status --json` command for automation. - ✅ Add a machine-readable `status --json` command for automation.
- ✅ Update the red-team harness so every current detection has a named drill and - ✅ 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. expected `sid` (`sentinel-redteam --list`), with a PASS/MISS verification pass.

View file

@ -94,7 +94,7 @@ detector.
## Runbook 2 — Persistence tampering ## Runbook 2 — Persistence tampering
**Triggers:** `persistence` (sid 100015), `fim_modified` on a watched persistence **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 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. sudo/account config — how an attacker survives a reboot.

View file

@ -133,6 +133,7 @@ class Config:
posture_sudoers: str = "/etc/sudoers" posture_sudoers: str = "/etc/sudoers"
posture_sudoers_dir: str = "/etc/sudoers.d" posture_sudoers_dir: str = "/etc/sudoers.d"
posture_path: tuple[str, ...] = () # PATH dirs to audit ('' = safe default set) 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 # eBPF on-ramp
capture_execve_bpftrace: bool = False capture_execve_bpftrace: bool = False

View file

@ -19,13 +19,16 @@ from __future__ import annotations
import glob import glob
import os import os
import re
import stat import stat
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from typing import NamedTuple
from .alert import Alert, Severity from .alert import Alert, Severity
from .config import Config 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_ROOT_LOGIN = 100040
SID_SSH_PASSWORD_AUTH = 100041 SID_SSH_PASSWORD_AUTH = 100041
SID_SSH_EMPTY_PASSWORD = 100042 SID_SSH_EMPTY_PASSWORD = 100042
@ -34,6 +37,8 @@ SID_SUDO_NOAUTH = 100044
SID_SUDOERS_WRITABLE = 100045 SID_SUDOERS_WRITABLE = 100045
SID_PATH_WRITABLE = 100046 SID_PATH_WRITABLE = 100046
SID_SENSITIVE_FILE_PERM = 100047 SID_SENSITIVE_FILE_PERM = 100047
SID_SYSTEMD_UNIT_WRITABLE = 100050
SID_SYSTEMD_EXEC_UNSAFE = 100051
CLASSTYPE = "host-posture" CLASSTYPE = "host-posture"
@ -42,6 +47,11 @@ _DEFAULT_PATH_DIRS = (
"/sbin", "/bin", "/root/bin", "/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 # (path, world-readable severity, group/world-writable severity). None = skip
# that rule for this file. Keyed by the kind of secret each file holds. # that rule for this file. Keyed by the kind of secret each file holds.
_SENSITIVE_FILES: tuple[tuple[str, Severity | None, Severity | None], ...] = ( _SENSITIVE_FILES: tuple[tuple[str, Severity | None, Severity | None], ...] = (
@ -281,6 +291,108 @@ def read_sensitive_files(
return out 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 -------------------------------------------------------- # --- orchestration --------------------------------------------------------
def run(cfg: Config) -> Iterator[Alert]: 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 path_findings(read_path_dirs(path_dirs))
yield from sensitive_file_findings(read_sensitive_files()) 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. # Package-signature policy is a posture question too; reuse the tamper check.
from . import pkgdb from . import pkgdb
sl = pkgdb.siglevel_alert() sl = pkgdb.siglevel_alert()

View file

@ -103,6 +103,50 @@ class TestSensitiveFiles(unittest.TestCase):
list(posture.sensitive_file_findings([("/etc/motd", 0o100666)])), []) 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): class TestRunIntegration(unittest.TestCase):
"""Drive run() with every reader monkeypatched to known facts.""" """Drive run() with every reader monkeypatched to known facts."""
@ -110,7 +154,7 @@ class TestRunIntegration(unittest.TestCase):
self.cfg = Config() self.cfg = Config()
self._saved = {n: getattr(posture, n) for n in self._saved = {n: getattr(posture, n) for n in
("read_sshd_lines", "read_sudoers", "read_path_dirs", ("read_sshd_lines", "read_sudoers", "read_path_dirs",
"read_sensitive_files")} "read_sensitive_files", "read_systemd_units")}
import enodia_sentinel.pkgdb as pkgdb import enodia_sentinel.pkgdb as pkgdb
self._saved_sig = pkgdb.siglevel_alert self._saved_sig = pkgdb.siglevel_alert
pkgdb.siglevel_alert = lambda *a, **k: None 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_sudoers = lambda m, d: [("/etc/sudoers", "root ALL=(ALL) ALL\n", 0o440)]
posture.read_path_dirs = lambda dirs: [("/usr/bin", 0o40755, 0)] posture.read_path_dirs = lambda dirs: [("/usr/bin", 0o40755, 0)]
posture.read_sensitive_files = lambda *a, **k: [("/etc/shadow", 0o100600)] posture.read_sensitive_files = lambda *a, **k: [("/etc/shadow", 0o100600)]
posture.read_systemd_units = lambda *a, **k: []
self.assertEqual(list(posture.run(self.cfg)), []) self.assertEqual(list(posture.run(self.cfg)), [])
def test_messy_host_aggregates_findings(self): def test_messy_host_aggregates_findings(self):
@ -135,6 +180,7 @@ class TestRunIntegration(unittest.TestCase):
("/etc/sudoers", "bob ALL=(ALL) NOPASSWD: ALL\n", 0o440)] ("/etc/sudoers", "bob ALL=(ALL) NOPASSWD: ALL\n", 0o440)]
posture.read_path_dirs = lambda dirs: [("/usr/local/bin", 0o40777, 0)] posture.read_path_dirs = lambda dirs: [("/usr/local/bin", 0o40777, 0)]
posture.read_sensitive_files = lambda *a, **k: [("/etc/shadow", 0o100644)] 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)} sigs = {f.signature for f in posture.run(self.cfg)}
self.assertSetEqual( self.assertSetEqual(
sigs, sigs,