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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <incident-id>
|
||||
enodia-sentinel incident export <incident-id>
|
||||
enodia-sentinel posture check
|
||||
enodia-sentinel respond plan <incident-id>
|
||||
enodia-sentinel respond apply <plan-id>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
300
enodia_sentinel/posture.py
Normal 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
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