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

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

View file

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

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