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>
192 lines
8.4 KiB
Python
192 lines
8.4 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""Host posture tests — pure evaluators driven by injected content/stat facts,
|
|
plus a run() integration test with every reader monkeypatched, so nothing here
|
|
needs root or a live /etc."""
|
|
import unittest
|
|
|
|
from enodia_sentinel import posture
|
|
from enodia_sentinel.alert import Severity
|
|
from enodia_sentinel.config import Config
|
|
|
|
|
|
class TestSshd(unittest.TestCase):
|
|
def test_resolve_is_first_wins_and_skips_comments(self):
|
|
eff = posture.resolve_sshd([
|
|
"# a comment",
|
|
"PasswordAuthentication no",
|
|
"PasswordAuthentication yes", # later value ignored (sshd first-wins)
|
|
"PermitRootLogin yes # inline comment",
|
|
])
|
|
self.assertEqual(eff["passwordauthentication"], "no")
|
|
self.assertEqual(eff["permitrootlogin"], "yes")
|
|
|
|
def test_root_login_yes_is_high(self):
|
|
finds = list(posture.sshd_findings({"permitrootlogin": "yes"}))
|
|
sig = {f.signature: f for f in finds}
|
|
self.assertIn("ssh_root_login", sig)
|
|
self.assertEqual(sig["ssh_root_login"].severity, Severity.HIGH)
|
|
|
|
def test_prohibit_password_root_login_is_clean(self):
|
|
finds = [f.signature for f in
|
|
posture.sshd_findings({"permitrootlogin": "prohibit-password",
|
|
"passwordauthentication": "no"})]
|
|
self.assertNotIn("ssh_root_login", finds)
|
|
|
|
def test_password_auth_default_yes_flagged(self):
|
|
# Unset PasswordAuthentication defaults to yes, so absence is a finding.
|
|
finds = {f.signature: f for f in posture.sshd_findings({})}
|
|
self.assertIn("ssh_password_auth", finds)
|
|
self.assertIn("default", finds["ssh_password_auth"].detail)
|
|
|
|
def test_empty_password_is_critical(self):
|
|
finds = {f.signature: f for f in
|
|
posture.sshd_findings({"permitemptypasswords": "yes",
|
|
"passwordauthentication": "no"})}
|
|
self.assertEqual(finds["ssh_empty_password"].severity, Severity.CRITICAL)
|
|
|
|
|
|
class TestSudoers(unittest.TestCase):
|
|
def test_nopasswd_flagged(self):
|
|
finds = list(posture.sudoers_findings([
|
|
("/etc/sudoers", "alice ALL=(ALL) NOPASSWD: ALL\n", 0o440)]))
|
|
self.assertEqual([f.signature for f in finds], ["sudo_nopasswd"])
|
|
self.assertIn("alice", finds[0].detail)
|
|
|
|
def test_commented_nopasswd_ignored(self):
|
|
finds = list(posture.sudoers_findings([
|
|
("/etc/sudoers", "# alice ALL=(ALL) NOPASSWD: ALL\n", 0o440)]))
|
|
self.assertEqual(finds, [])
|
|
|
|
def test_writable_sudoers_file_flagged_high(self):
|
|
finds = {f.signature: f for f in posture.sudoers_findings([
|
|
("/etc/sudoers.d/bad", "root ALL=(ALL) ALL\n", 0o646)])}
|
|
self.assertEqual(finds["sudoers_writable"].severity, Severity.HIGH)
|
|
|
|
def test_no_authenticate_default_flagged(self):
|
|
finds = [f.signature for f in posture.sudoers_findings([
|
|
("/etc/sudoers", "Defaults !authenticate\n", 0o440)])]
|
|
self.assertIn("sudo_no_authenticate", finds)
|
|
|
|
|
|
class TestPath(unittest.TestCase):
|
|
def test_world_writable_path_dir_is_high(self):
|
|
finds = {f.signature: f for f in
|
|
posture.path_findings([("/usr/local/bin", 0o40777, 0)])}
|
|
self.assertEqual(finds["path_world_writable"].severity, Severity.HIGH)
|
|
|
|
def test_nonroot_owner_is_medium(self):
|
|
finds = {f.signature: f for f in
|
|
posture.path_findings([("/opt/bin", 0o40755, 1000)])}
|
|
self.assertEqual(finds["path_nonroot_owner"].severity, Severity.MEDIUM)
|
|
|
|
def test_clean_root_owned_dir_no_finding(self):
|
|
self.assertEqual(list(posture.path_findings([("/usr/bin", 0o40755, 0)])), [])
|
|
|
|
|
|
class TestSensitiveFiles(unittest.TestCase):
|
|
def test_world_readable_shadow_is_high(self):
|
|
finds = {f.signature: f for f in
|
|
posture.sensitive_file_findings([("/etc/shadow", 0o100644)])}
|
|
self.assertEqual(finds["sensitive_file_readable"].severity, Severity.HIGH)
|
|
|
|
def test_world_writable_passwd_is_critical(self):
|
|
finds = {f.signature: f for f in
|
|
posture.sensitive_file_findings([("/etc/passwd", 0o100666)])}
|
|
self.assertEqual(finds["sensitive_file_writable"].severity, Severity.CRITICAL)
|
|
|
|
def test_normal_perms_clean(self):
|
|
facts = [("/etc/shadow", 0o100600), ("/etc/passwd", 0o100644)]
|
|
self.assertEqual(list(posture.sensitive_file_findings(facts)), [])
|
|
|
|
def test_unknown_path_ignored(self):
|
|
self.assertEqual(
|
|
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."""
|
|
|
|
def setUp(self):
|
|
self.cfg = Config()
|
|
self._saved = {n: getattr(posture, n) for n in
|
|
("read_sshd_lines", "read_sudoers", "read_path_dirs",
|
|
"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
|
|
|
|
def tearDown(self):
|
|
for n, fn in self._saved.items():
|
|
setattr(posture, n, fn)
|
|
import enodia_sentinel.pkgdb as pkgdb
|
|
pkgdb.siglevel_alert = self._saved_sig
|
|
|
|
def test_clean_host_yields_nothing(self):
|
|
posture.read_sshd_lines = lambda p: ["PermitRootLogin no",
|
|
"PasswordAuthentication no"]
|
|
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):
|
|
posture.read_sshd_lines = lambda p: ["PermitRootLogin yes"]
|
|
posture.read_sudoers = lambda m, d: [
|
|
("/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,
|
|
{"ssh_root_login", "ssh_password_auth", "sudo_nopasswd",
|
|
"path_world_writable", "sensitive_file_readable"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|