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:
parent
11d91a40b4
commit
69798b26e4
7 changed files with 177 additions and 6 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue