Add host posture checks (roadmap v0.8: posture check)

Add `enodia-sentinel posture check` — a config-hygiene audit that finds
the conditions making a host easy to attack, complementing the live
detectors. Checks: SSH root/password/empty-password login (Include-aware,
first-wins sshd resolution), passwordless and group/world-writable
sudoers, world-writable or non-root PATH directories, loose permissions
on sensitive files (/etc/shadow, /etc/passwd, ...), and downgraded
package-signature policy (reusing pkgdb.siglevel_alert).

Findings reuse the Alert type, so they serialize through the existing
JSON/triage pipeline (`posture check --json`). Each check is a pure
evaluator over injected content/stat facts plus a thin system reader, so
the 18 new tests need no root or live /etc. Posture is command-driven and
never runs in the daemon loop, per the v0.8 exit criterion.

SIDs 100040-100047 (classtype host-posture). Sample config and docs
(COMMAND_REFERENCE, OPERATIONS, ROADMAP, SPECIFICATION) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-10 05:15:06 -07:00
parent 6ff2087329
commit 9ebc355936
9 changed files with 520 additions and 6 deletions

146
tests/test_posture.py Normal file
View file

@ -0,0 +1,146 @@
# 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 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")}
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)]
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)]
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()