From 9ebc3559366780b138db60b70788c5bef30bd72e Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 10 Jun 2026 05:15:06 -0700 Subject: [PATCH] Add host posture checks (roadmap v0.8: posture check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/enodia-sentinel.toml | 10 ++ docs/COMMAND_REFERENCE.md | 26 +++- docs/OPERATIONS.md | 4 + docs/ROADMAP.md | 9 +- docs/SPECIFICATION.md | 2 +- enodia_sentinel/cli.py | 23 +++ enodia_sentinel/config.py | 6 + enodia_sentinel/posture.py | 300 ++++++++++++++++++++++++++++++++++++ tests/test_posture.py | 146 ++++++++++++++++++ 9 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 enodia_sentinel/posture.py create mode 100644 tests/test_posture.py diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index c2615ce..ecad329 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -92,6 +92,16 @@ rootcheck_enabled = true rootcheck_interval = 300 # seconds between cross-view sweeps rootcheck_pid_cap = 65536 # upper PID to brute-force via kill(0) +# --- host posture -------------------------------------------------------- +# Config-hygiene audit run on demand (`enodia-sentinel posture check`), not in +# the daemon loop: root SSH login, password auth, passwordless sudo, world- +# writable PATH dirs, loose perms on sensitive files, and package-signature +# policy. Paths are overridable mostly for testing on non-standard hosts. +posture_sshd_config = "/etc/ssh/sshd_config" +posture_sudoers = "/etc/sudoers" +posture_sudoers_dir = "/etc/sudoers.d" +# posture_path = [] # PATH dirs to audit; empty = safe default set + # --- eBPF event layer ---------------------------------------------------- # Event-driven execve monitor: catches short-lived processes the poll loop # misses, matched against the Snort-style rule engine. Requires python-bpfcc diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 5be394e..89bca23 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -155,6 +155,31 @@ Exit code: - `0`: sampled files match and signature policy is not downgraded. - `1`: a signed-package mismatch or insecure signature setting was found. +### `posture check` + +```bash +enodia-sentinel posture check +enodia-sentinel posture check --json +``` + +Audits host configuration hygiene — the conditions that make a host *easy* to +attack, as opposed to an attack in progress: + +- SSH: root login, password authentication, and empty-password logins. +- sudo: passwordless (`NOPASSWD`) rules, disabled authentication, and + group/world-writable sudoers files. +- World-writable or non-root-owned `PATH` directories (binary-hijack vectors). +- Loose permissions on sensitive files (`/etc/shadow`, `/etc/passwd`, ...). +- Downgraded package-signature policy (`SigLevel`). + +Findings are advisory and reuse the standard alert JSON shape (`--json`). This +command does not run inside the daemon and never blocks startup. + +Exit code: + +- `0`: no posture findings. +- `1`: one or more findings. + ## Evidence and Operator Commands ### `web` @@ -217,7 +242,6 @@ enodia-sentinel status --json enodia-sentinel incident list enodia-sentinel incident show enodia-sentinel incident export -enodia-sentinel posture check enodia-sentinel respond plan enodia-sentinel respond apply ``` diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 112f5dc..0093f5f 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -51,6 +51,7 @@ enodia-sentinel fim-check enodia-sentinel pkgdb-check enodia-sentinel pkgdb-verify --sample 100 enodia-sentinel rootcheck +enodia-sentinel posture check enodia-sentinel triage ``` @@ -62,6 +63,9 @@ Expected healthy output: - `pkgdb-check`: package DB consistent with anchor. - `pkgdb-verify`: sampled files match signed cache packages. - `rootcheck`: no hidden processes, modules, ports, or sniffers. +- `posture check`: no SSH/sudo/PATH/permission/signature hygiene findings, or + only ones you have consciously accepted (e.g. password auth on a host that + needs it). ## Alert Workflow diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index efea9d7..814b826 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,10 +26,11 @@ work that is bigger than single alerts. alert time, process ancestry, sockets, persistence writes, FIM diffs, package transactions, and rootcheck findings. - Add `enodia-sentinel incident list/show/export`. -- Add host posture checks: - SSH password login, root SSH login, permissive sudoers entries, unexpected - enabled services, world-writable PATH components, disabled package signatures, - and unsafe systemd unit settings. +- ✅ Add host posture checks (`enodia-sentinel posture check`): + SSH root/password/empty-password login, passwordless and writable sudoers, + world-writable PATH components, loose sensitive-file permissions, and disabled + package signatures. Still to come: unexpected enabled services and unsafe + systemd unit settings. - Add a machine-readable `status --json` command for automation. - Update the red-team harness so every current detection has a named drill and expected `sid`. diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 75e60bb..5577f9b 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -92,7 +92,7 @@ When a fresh alert survives cooldown, Sentinel writes: | Interface | Role | |---|---| -| CLI | Run daemon, one-shot checks, baseline management, FIM checks, package DB checks, rootcheck, triage, watchdog. | +| CLI | Run daemon, one-shot checks, baseline management, FIM checks, package DB checks, rootcheck, posture audit, triage, watchdog. | | Logs | Durable local evidence under `/var/log/enodia-sentinel`. | | Dashboard | Read-only web view for status, alert browsing, and snapshot inspection. | | Push | ntfy, Pushover, and generic webhook notifications. | diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 8063a7a..f8f994f 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -75,6 +75,11 @@ def main(argv: list[str] | None = None) -> int: help="packages to verify (0 = config default; rotates)") sub.add_parser("rootcheck", help="anti-rootkit cross-view: hidden procs/modules/ports") + po = sub.add_parser("posture", + help="audit host config hygiene (SSH, sudo, PATH, perms)") + po.add_argument("action", nargs="?", default="check", choices=["check"], + help="posture action (default: check)") + po.add_argument("--json", action="store_true", help="emit findings as JSON") wd = sub.add_parser("watchdog", help="poll a remote dashboard and push if Sentinel is silent") wd.add_argument("--url", required=True, help="dashboard base URL") @@ -118,6 +123,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_pkgdb_verify(cfg, args.sample) if args.cmd == "rootcheck": return _cmd_rootcheck(cfg) + if args.cmd == "posture": + return _cmd_posture(cfg, args.json) if args.cmd == "watchdog": return _cmd_watchdog(cfg, args.url, args.token, args.max_age) return _cmd_run(cfg) @@ -171,6 +178,22 @@ def _cmd_rootcheck(cfg: Config) -> int: return 1 +def _cmd_posture(cfg: Config, as_json: bool) -> int: + from . import posture + findings = sorted(posture.run(cfg), key=lambda x: -x.severity) + if as_json: + import json + print(json.dumps([f.to_dict() for f in findings], indent=2)) + return 1 if findings else 0 + if not findings: + print("Posture: no findings — SSH, sudo, PATH, file perms, and package " + "signature policy look sound.") + return 0 + for a in findings: + print(f"[{a.severity}] {a.signature:<24} {a.detail}") + return 1 + + def _cmd_fim_check(cfg: Config, packages: bool) -> int: from . import fim sentinel = Sentinel(cfg) diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index 568e9e4..aa138ef 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -83,6 +83,12 @@ class Config: rootcheck_interval: int = 300 # seconds between cross-view sweeps rootcheck_pid_cap: int = 65536 # upper PID to brute-force via kill(0) + # host posture (config hygiene; advisory, command-driven, never blocks startup) + posture_sshd_config: str = "/etc/ssh/sshd_config" + posture_sudoers: str = "/etc/sudoers" + posture_sudoers_dir: str = "/etc/sudoers.d" + posture_path: tuple[str, ...] = () # PATH dirs to audit ('' = safe default set) + # eBPF on-ramp capture_execve_bpftrace: bool = False # Event-driven eBPF execve monitor (catches short-lived processes the poll diff --git a/enodia_sentinel/posture.py b/enodia_sentinel/posture.py new file mode 100644 index 0000000..5f9ed7f --- /dev/null +++ b/enodia_sentinel/posture.py @@ -0,0 +1,300 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Host posture checks — config hygiene that *precedes* an incident. + +Where the detectors answer "is something attacking right now?", posture answers +"is this host set up to be easy to attack?": root SSH logins, password auth, +passwordless sudo, world-writable ``PATH`` directories, loose permissions on +sensitive files, and a downgraded package-signature policy. + +These are advisory findings, not live detections, so posture is a command +(``enodia-sentinel posture check``), not a daemon detector — it never blocks +startup. Findings reuse the ``Alert`` type so they flow through the same JSON +and triage machinery as everything else. + +Every check is split into a pure evaluator (operates on already-read content or +``stat`` facts, so tests need no root and no live ``/etc``) plus a thin system +reader that gathers that content. +""" +from __future__ import annotations + +import glob +import os +import stat +from collections.abc import Iterable, Iterator + +from .alert import Alert, Severity +from .config import Config + +# --- SID allocation (host-posture: 100040-100049) ------------------------- +SID_SSH_ROOT_LOGIN = 100040 +SID_SSH_PASSWORD_AUTH = 100041 +SID_SSH_EMPTY_PASSWORD = 100042 +SID_SUDO_NOPASSWD = 100043 +SID_SUDO_NOAUTH = 100044 +SID_SUDOERS_WRITABLE = 100045 +SID_PATH_WRITABLE = 100046 +SID_SENSITIVE_FILE_PERM = 100047 + +CLASSTYPE = "host-posture" + +_DEFAULT_PATH_DIRS = ( + "/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", + "/sbin", "/bin", "/root/bin", +) + +# (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], ...] = ( + ("/etc/shadow", Severity.HIGH, Severity.CRITICAL), + ("/etc/gshadow", Severity.HIGH, Severity.CRITICAL), + ("/etc/passwd", None, Severity.CRITICAL), + ("/etc/group", None, Severity.CRITICAL), + ("/etc/ssh/sshd_config", None, Severity.HIGH), +) + + +# --- SSH ------------------------------------------------------------------ + +def resolve_sshd(lines: Iterable[str]) -> dict[str, str]: + """Reduce already-Include-expanded sshd_config lines to effective values. + + sshd uses the *first* obtained value for each keyword, so we keep the first + occurrence and ignore later ones. Keys are lowercased; ``Include`` lines are + assumed already expanded by the reader and are skipped here. + """ + effective: dict[str, str] = {} + for raw in lines: + line = raw.split("#", 1)[0].strip() + if not line: + continue + parts = line.split(None, 1) + key = parts[0].lower() + if key == "include": + continue + value = parts[1].strip() if len(parts) > 1 else "" + effective.setdefault(key, value) + return effective + + +def sshd_findings(effective: dict[str, str]) -> Iterator[Alert]: + """Posture findings from effective sshd directives (OpenSSH defaults aware).""" + # PermitRootLogin: modern default is prohibit-password, which is fine; only + # an explicit `yes` (password root login) is a finding. + root_login = effective.get("permitrootlogin", "prohibit-password").lower() + if root_login == "yes": + yield Alert( + severity=Severity.HIGH, signature="ssh_root_login", + key="posture:ssh:rootlogin", + detail="sshd PermitRootLogin=yes — root can log in over SSH with a password", + sid=SID_SSH_ROOT_LOGIN, classtype=CLASSTYPE) + + # PasswordAuthentication default is yes, so an unset value is still a finding. + pw_auth = effective.get("passwordauthentication", "yes").lower() + if pw_auth == "yes": + how = "enabled" if "passwordauthentication" in effective else "enabled (compiled-in default)" + yield Alert( + severity=Severity.MEDIUM, signature="ssh_password_auth", + key="posture:ssh:passwordauth", + detail=f"sshd PasswordAuthentication {how} — keys-only login is safer " + "against credential brute force", + sid=SID_SSH_PASSWORD_AUTH, classtype=CLASSTYPE) + + if effective.get("permitemptypasswords", "no").lower() == "yes": + yield Alert( + severity=Severity.CRITICAL, signature="ssh_empty_password", + key="posture:ssh:emptypw", + detail="sshd PermitEmptyPasswords=yes — accounts with empty passwords " + "can log in over SSH", + sid=SID_SSH_EMPTY_PASSWORD, classtype=CLASSTYPE) + + +def read_sshd_lines(path: str, _depth: int = 0, _seen: set[str] | None = None) -> list[str]: + """Read sshd_config, expanding ``Include`` globs in place (sshd order). + + Drop-in files are read in lexical order at the point the Include appears. + A visited set and depth cap guard against include loops. + """ + seen = _seen if _seen is not None else set() + real = os.path.realpath(path) + if _depth > 16 or real in seen: + return [] + seen.add(real) + out: list[str] = [] + try: + with open(path, encoding="utf-8", errors="replace") as fh: + raw_lines = fh.readlines() + except OSError: + return out + for raw in raw_lines: + stripped = raw.split("#", 1)[0].strip() + if stripped and stripped.split(None, 1)[0].lower() == "include": + pattern = stripped.split(None, 1)[1].strip() if " " in stripped else "" + if pattern and not os.path.isabs(pattern): + pattern = os.path.join("/etc/ssh", pattern) + for inc in sorted(glob.glob(pattern)): + out.extend(read_sshd_lines(inc, _depth + 1, seen)) + else: + out.append(raw) + return out + + +# --- sudoers -------------------------------------------------------------- + +def sudoers_findings(files: Iterable[tuple[str, str, int]]) -> Iterator[Alert]: + """Findings from sudoers files, each given as (name, text, st_mode). + + Flags passwordless sudo (NOPASSWD), disabled auth (!authenticate), and any + sudoers file that is group/world-writable (which sudo itself would refuse, + making it a strong tamper signal). + """ + for name, text, mode in files: + if mode & 0o022: + yield Alert( + severity=Severity.HIGH, signature="sudoers_writable", + key=f"posture:sudo:writable:{name}", + detail=f"sudoers file is group/world-writable ({stat.filemode(mode)}): {name}", + sid=SID_SUDOERS_WRITABLE, classtype=CLASSTYPE) + for raw in text.splitlines(): + # In sudoers, '#include'/'#includedir' are directives, not comments; + # everything else after '#' is a comment. + token0 = raw.strip().split(None, 1)[0] if raw.strip() else "" + line = raw if token0 in ("#include", "#includedir") else raw.split("#", 1)[0] + line = line.strip() + if not line: + continue + if "NOPASSWD" in line: + yield Alert( + severity=Severity.MEDIUM, signature="sudo_nopasswd", + key=f"posture:sudo:nopasswd:{name}:{line}", + detail=f"passwordless sudo rule in {name}: {line}", + sid=SID_SUDO_NOPASSWD, classtype=CLASSTYPE) + if line.lower().startswith("defaults") and "!authenticate" in line.lower(): + yield Alert( + severity=Severity.MEDIUM, signature="sudo_no_authenticate", + key=f"posture:sudo:noauth:{name}", + detail=f"sudo authentication disabled (!authenticate) in {name}", + sid=SID_SUDO_NOAUTH, classtype=CLASSTYPE) + + +def read_sudoers(main: str, sudoers_dir: str) -> list[tuple[str, str, int]]: + out: list[tuple[str, str, int]] = [] + paths = [main] + try: + # sudo ignores backup/dotfiles in sudoers.d (names with '.' or ending '~'). + for entry in sorted(os.listdir(sudoers_dir)): + if entry.startswith(".") or entry.endswith("~") or "." in entry: + continue + paths.append(os.path.join(sudoers_dir, entry)) + except OSError: + pass + for p in paths: + try: + st = os.stat(p) + if not stat.S_ISREG(st.st_mode): + continue + with open(p, encoding="utf-8", errors="replace") as fh: + out.append((p, fh.read(), st.st_mode)) + except OSError: + continue + return out + + +# --- PATH directories ----------------------------------------------------- + +def path_findings(dirs: Iterable[tuple[str, int, int]]) -> Iterator[Alert]: + """Findings from PATH directories, each given as (path, st_mode, st_uid). + + A world-writable ``PATH`` dir lets any user drop a binary that root may run; + a non-root-owned one is a weaker but real hijack vector. + """ + for path, mode, uid in dirs: + if mode & 0o002: + yield Alert( + severity=Severity.HIGH, signature="path_world_writable", + key=f"posture:path:writable:{path}", + detail=f"PATH directory is world-writable ({stat.filemode(mode)}) — " + f"any user can plant a binary root may execute: {path}", + sid=SID_PATH_WRITABLE, classtype=CLASSTYPE) + elif uid != 0: + yield Alert( + severity=Severity.MEDIUM, signature="path_nonroot_owner", + key=f"posture:path:owner:{path}", + detail=f"PATH directory not owned by root (uid {uid}): {path}", + sid=SID_PATH_WRITABLE, classtype=CLASSTYPE) + + +def read_path_dirs(dirs: Iterable[str]) -> list[tuple[str, int, int]]: + out: list[tuple[str, int, int]] = [] + seen: set[str] = set() + for d in dirs: + if not d or d in seen: + continue + seen.add(d) + try: + st = os.stat(d) + except OSError: + continue + if stat.S_ISDIR(st.st_mode): + out.append((d, st.st_mode, st.st_uid)) + return out + + +# --- sensitive file permissions ------------------------------------------- + +def sensitive_file_findings( + facts: Iterable[tuple[str, int]], + policy: Iterable[tuple[str, Severity | None, Severity | None]] = _SENSITIVE_FILES, +) -> Iterator[Alert]: + """Findings from sensitive files, each given as (path, st_mode), against a + policy of (path, world_readable_sev, writable_sev).""" + rules = {p: (rsev, wsev) for p, rsev, wsev in policy} + for path, mode in facts: + rule = rules.get(path) + if rule is None: + continue + rsev, wsev = rule + if wsev is not None and mode & 0o022: + yield Alert( + severity=wsev, signature="sensitive_file_writable", + key=f"posture:file:writable:{path}", + detail=f"sensitive file is group/world-writable ({stat.filemode(mode)}): {path}", + sid=SID_SENSITIVE_FILE_PERM, classtype=CLASSTYPE) + if rsev is not None and mode & 0o004: + yield Alert( + severity=rsev, signature="sensitive_file_readable", + key=f"posture:file:readable:{path}", + detail=f"sensitive file is world-readable ({stat.filemode(mode)}): {path}", + sid=SID_SENSITIVE_FILE_PERM, classtype=CLASSTYPE) + + +def read_sensitive_files( + policy: Iterable[tuple[str, Severity | None, Severity | None]] = _SENSITIVE_FILES, +) -> list[tuple[str, int]]: + out: list[tuple[str, int]] = [] + for path, _r, _w in policy: + try: + st = os.stat(path) + except OSError: + continue + if stat.S_ISREG(st.st_mode): + out.append((path, st.st_mode)) + return out + + +# --- orchestration -------------------------------------------------------- + +def run(cfg: Config) -> Iterator[Alert]: + """Run every posture check and yield findings. Each reader fails closed + (missing file → no finding), so a non-Arch or minimal host degrades quietly.""" + yield from sshd_findings(resolve_sshd(read_sshd_lines(cfg.posture_sshd_config))) + yield from sudoers_findings(read_sudoers(cfg.posture_sudoers, cfg.posture_sudoers_dir)) + + path_dirs = cfg.posture_path or _DEFAULT_PATH_DIRS + yield from path_findings(read_path_dirs(path_dirs)) + yield from sensitive_file_findings(read_sensitive_files()) + + # Package-signature policy is a posture question too; reuse the tamper check. + from . import pkgdb + sl = pkgdb.siglevel_alert() + if sl is not None: + yield sl diff --git a/tests/test_posture.py b/tests/test_posture.py new file mode 100644 index 0000000..d012508 --- /dev/null +++ b/tests/test_posture.py @@ -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()