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:
parent
6ff2087329
commit
9ebc355936
9 changed files with 520 additions and 6 deletions
146
tests/test_posture.py
Normal file
146
tests/test_posture.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue