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

@ -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()