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

View file

@ -92,6 +92,16 @@ rootcheck_enabled = true
rootcheck_interval = 300 # seconds between cross-view sweeps rootcheck_interval = 300 # seconds between cross-view sweeps
rootcheck_pid_cap = 65536 # upper PID to brute-force via kill(0) 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 ---------------------------------------------------- # --- eBPF event layer ----------------------------------------------------
# Event-driven execve monitor: catches short-lived processes the poll loop # Event-driven execve monitor: catches short-lived processes the poll loop
# misses, matched against the Snort-style rule engine. Requires python-bpfcc # misses, matched against the Snort-style rule engine. Requires python-bpfcc

View file

@ -155,6 +155,31 @@ Exit code:
- `0`: sampled files match and signature policy is not downgraded. - `0`: sampled files match and signature policy is not downgraded.
- `1`: a signed-package mismatch or insecure signature setting was found. - `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 ## Evidence and Operator Commands
### `web` ### `web`
@ -217,7 +242,6 @@ enodia-sentinel status --json
enodia-sentinel incident list enodia-sentinel incident list
enodia-sentinel incident show <incident-id> enodia-sentinel incident show <incident-id>
enodia-sentinel incident export <incident-id> enodia-sentinel incident export <incident-id>
enodia-sentinel posture check
enodia-sentinel respond plan <incident-id> enodia-sentinel respond plan <incident-id>
enodia-sentinel respond apply <plan-id> enodia-sentinel respond apply <plan-id>
``` ```

View file

@ -51,6 +51,7 @@ enodia-sentinel fim-check
enodia-sentinel pkgdb-check enodia-sentinel pkgdb-check
enodia-sentinel pkgdb-verify --sample 100 enodia-sentinel pkgdb-verify --sample 100
enodia-sentinel rootcheck enodia-sentinel rootcheck
enodia-sentinel posture check
enodia-sentinel triage enodia-sentinel triage
``` ```
@ -62,6 +63,9 @@ Expected healthy output:
- `pkgdb-check`: package DB consistent with anchor. - `pkgdb-check`: package DB consistent with anchor.
- `pkgdb-verify`: sampled files match signed cache packages. - `pkgdb-verify`: sampled files match signed cache packages.
- `rootcheck`: no hidden processes, modules, ports, or sniffers. - `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 ## Alert Workflow

View file

@ -26,10 +26,11 @@ work that is bigger than single alerts.
alert time, process ancestry, sockets, persistence writes, FIM diffs, package alert time, process ancestry, sockets, persistence writes, FIM diffs, package
transactions, and rootcheck findings. transactions, and rootcheck findings.
- Add `enodia-sentinel incident list/show/export`. - Add `enodia-sentinel incident list/show/export`.
- Add host posture checks: - ✅ Add host posture checks (`enodia-sentinel posture check`):
SSH password login, root SSH login, permissive sudoers entries, unexpected SSH root/password/empty-password login, passwordless and writable sudoers,
enabled services, world-writable PATH components, disabled package signatures, world-writable PATH components, loose sensitive-file permissions, and disabled
and unsafe systemd unit settings. package signatures. Still to come: unexpected enabled services and unsafe
systemd unit settings.
- 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`. expected `sid`.

View file

@ -92,7 +92,7 @@ When a fresh alert survives cooldown, Sentinel writes:
| Interface | Role | | 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`. | | Logs | Durable local evidence under `/var/log/enodia-sentinel`. |
| Dashboard | Read-only web view for status, alert browsing, and snapshot inspection. | | Dashboard | Read-only web view for status, alert browsing, and snapshot inspection. |
| Push | ntfy, Pushover, and generic webhook notifications. | | Push | ntfy, Pushover, and generic webhook notifications. |

View file

@ -75,6 +75,11 @@ def main(argv: list[str] | None = None) -> int:
help="packages to verify (0 = config default; rotates)") help="packages to verify (0 = config default; rotates)")
sub.add_parser("rootcheck", sub.add_parser("rootcheck",
help="anti-rootkit cross-view: hidden procs/modules/ports") 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", wd = sub.add_parser("watchdog",
help="poll a remote dashboard and push if Sentinel is silent") help="poll a remote dashboard and push if Sentinel is silent")
wd.add_argument("--url", required=True, help="dashboard base URL") 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) return _cmd_pkgdb_verify(cfg, args.sample)
if args.cmd == "rootcheck": if args.cmd == "rootcheck":
return _cmd_rootcheck(cfg) return _cmd_rootcheck(cfg)
if args.cmd == "posture":
return _cmd_posture(cfg, args.json)
if args.cmd == "watchdog": if args.cmd == "watchdog":
return _cmd_watchdog(cfg, args.url, args.token, args.max_age) return _cmd_watchdog(cfg, args.url, args.token, args.max_age)
return _cmd_run(cfg) return _cmd_run(cfg)
@ -171,6 +178,22 @@ def _cmd_rootcheck(cfg: Config) -> int:
return 1 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: def _cmd_fim_check(cfg: Config, packages: bool) -> int:
from . import fim from . import fim
sentinel = Sentinel(cfg) sentinel = Sentinel(cfg)

View file

@ -83,6 +83,12 @@ class Config:
rootcheck_interval: int = 300 # seconds between cross-view sweeps rootcheck_interval: int = 300 # seconds between cross-view sweeps
rootcheck_pid_cap: int = 65536 # upper PID to brute-force via kill(0) 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 # eBPF on-ramp
capture_execve_bpftrace: bool = False capture_execve_bpftrace: bool = False
# Event-driven eBPF execve monitor (catches short-lived processes the poll # Event-driven eBPF execve monitor (catches short-lived processes the poll

300
enodia_sentinel/posture.py Normal file
View file

@ -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

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()