diff --git a/CLAUDE.md b/CLAUDE.md index 1d8da61..a925cd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,13 @@ Implemented investigation/response state: - `rules docs` emits generated Markdown rule docs; `docs/RULES.md` is the checked-in built-in rule reference with match fields, false positives, and drill guidance. +- `baseline accept/revoke/list` reconciles legitimate FIM/package/listener/SUID + drift: an operator accepts an identified item with a mandatory reason, stored + fingerprint-bound and audited in `reconciliation.json`. Acks suppress the + alert only while live state matches; content kinds (fim/pkgfile) re-alert on + further change, identity kinds (listener/suid) retire on TTL or revoke. The + filter runs at the daemon chokepoint, `fim-check`, and the dashboard + integrity API. - IPS behavior is currently explicit workflow: posture hardening plus reviewed dry-run containment actions. There is no automatic inline blocking or silent host mutation yet. @@ -70,7 +77,6 @@ Implemented investigation/response state: Near-term open work: - Audited `--apply` response execution with tests and rollback notes. -- Baseline reconciliation for legitimate FIM/package/listener/SUID drift. - Fixture or red-team drill coverage for every built-in SID, including event-only and future correlation SIDs. - More event sources and correlation across exec, network, persistence, FIM, @@ -123,6 +129,8 @@ Start with: promiscuous interfaces, known LKM rootkit module names, and kernel/module taint. - `posture.py` performs advisory host hygiene checks. +- `reconcile.py` owns the acknowledged-drift store and the `filter_alerts` + chokepoint that suppresses operator-accepted FIM/package/listener/SUID drift. ## Current Threat Mapping diff --git a/README.md b/README.md index 4a6965d..3c20457 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Project docs: - [Packaging guide](docs/PACKAGING.md) — Arch package layout plus Debian/RPM source-install paths. - [JSON schemas](docs/SCHEMAS.md) — stable v1 contracts for alerts, incidents, - status, response plans, and response audit records. + status, response plans, response audit records, and reconciliation acks. - [IR runbooks](docs/RUNBOOKS.md) — confirm/preserve/contain/recover playbooks per alert: reverse shells, persistence, trojaned binaries, rootkits, tampering. - [Rule reference](docs/RULES.md) — generated event-rule documentation: SID, diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index faf074c..2277d04 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -168,6 +168,12 @@ exec_rules_file = "" # Add a 3s bpftrace execve trace to each snapshot (requires the bpftrace pkg). capture_execve_bpftrace = false +# --- baseline reconciliation --------------------------------------------- +# Store for acknowledged drift accepted via `baseline accept` (FIM/package/ +# listener/SUID). Each ack is fingerprint-bound and audited; content kinds +# re-alert if the file changes again. Empty = log_dir/reconciliation.json. +reconcile_store = "" + # --- retention ----------------------------------------------------------- max_snapshots = 300 # auto-delete oldest beyond this count (0 = no cap) max_snapshot_age_days = 60 # auto-delete older than this (0 = no age cap) diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 282f991..3e873a6 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -59,6 +59,39 @@ helpers. Do not run this to silence unexplained findings. +### `baseline accept` / `revoke` / `list` + +```bash +enodia-sentinel baseline accept --reason "..." [--expires 7d] [--force] +enodia-sentinel baseline revoke +enodia-sentinel baseline list [--stale] [--json] +``` + +Baseline *reconciliation*: accept a specific, identified drift item with a +mandatory reason instead of silently folding it into a baseline. `` is one +of `fim`, `pkgfile`, `listener`, or `suid`; `` is a path (FIM/package/ +SUID) or a `port/comm` key (listener). + +An acknowledgement suppresses that one alert **only while the live state still +matches the accepted fingerprint**. `fim` and `pkgfile` acks are content-bound: +when the file changes again the ack goes *stale* and the alert returns. `listener` +and `suid` acks are identity-bound, so only `--expires` (TTL) or an explicit +`revoke` retires them. Each record stores who accepted it, when, why, and the +fingerprint, in `log_dir/reconciliation.json` (override with `reconcile_store`). + +- `accept` refuses to record drift it cannot observe live (e.g. a listener not + currently bound); pass `--force` to accept anyway — required for accepting a + *removed* file. Omitting `--reason` is a hard error. +- `--expires` accepts `Nd` / `Nh` / `Nm` (days/hours/minutes). +- `revoke` exits `1` if the target isn't present. +- `list` shows every ack; `--stale` filters to stale/expired ones. It exits `1` + whenever any acknowledgement is stale (a scriptable drift check) and re-derives + FIM staleness against live files. + +Reconciled drift is dropped at the daemon detection chokepoint, hidden from +`fim-check`, and summarised in the dashboard integrity view, so an acknowledged +item stops alerting everywhere until its fingerprint changes or its TTL lapses. + ## Detection and Integrity Commands ### `list-detectors` diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 8ac834c..2f290ec 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -145,6 +145,13 @@ Baselines are useful only if they represent a known-good state. - Use `fim-update` only after confirming changes are legitimate. Package hooks already run it for normal package transactions. - Keep a copy of important anchors off-box when possible. +- Prefer `baseline accept --reason "..."` over rebuilding a whole + baseline when only one item changed legitimately (an edited config, a new + service port, an installed SUID helper). Unlike a rebuild, the acceptance is + per-item, carries a recorded reason, is fingerprint-bound (FIM/package items + re-alert if the file changes *again*), and can be time-boxed with `--expires`. + Review outstanding acceptances with `baseline list`; it exits non-zero and + marks rows `stale` when an accepted item has drifted again or its TTL lapsed. ## Suggested Hardening diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 40dd983..d069081 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,8 +71,11 @@ Purpose: move from "tell me" to "help me act" without unsafe automation. semantics are designed for pacman, dpkg, and rpm. - Require explicit `--apply` for changes; default to read-only plans. - Extend response audit logs to any future state-changing `--apply` workflow. -- Add baseline reconciliation: - accept legitimate FIM/package/listener/SUID changes with a recorded reason. +- ✅ Add baseline reconciliation: accept legitimate FIM/package/listener/SUID + drift with a mandatory reason via `baseline accept/revoke/list`. Acks are + fingerprint-bound (content kinds re-alert on further change), support an + optional TTL, and are filtered at the daemon chokepoint, `fim-check`, and the + dashboard integrity API. - Add richer false-positive suppression suggestions that can emit TOML snippets but never modify config automatically. - Add recovery checks: diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md index 46d388c..e51ccf8 100644 --- a/docs/SCHEMAS.md +++ b/docs/SCHEMAS.md @@ -147,3 +147,33 @@ Required fields: | `apply_supported` | boolean | Whether the plan supports apply. | | `action_count` | integer | Number of proposed actions. | | `plan_path` | string | Persisted plan artifact path. | + +## `enodia.reconcile.v1` + +Acknowledged-drift store written by `baseline accept/revoke/list` +(`log_dir/reconciliation.json`, overridable via `reconcile_store`) and emitted +by `baseline list --json`. + +Top-level fields: + +| Field | Type | Meaning | +|---|---|---| +| `schema` | string | `enodia.reconcile.v1`. | +| `records` | array | Acknowledgement records (below). | + +Each record: + +| Field | Type | Meaning | +|---|---|---| +| `kind` | string | `fim`, `pkgfile`, `listener`, or `suid`. | +| `target` | string | Path, or `port/comm` key for listeners. | +| `fingerprint` | object | Kind-specific identity compared each sweep. | +| `reason` | string | Mandatory operator justification. | +| `actor` | string | Who recorded the acceptance. | +| `accepted_at` | string | ISO-8601 UTC acceptance time. | +| `status` | string | `ok` or `stale`. | +| `expires_at` | string | ISO-8601 UTC TTL; absent when no TTL. | + +The dashboard `enodia.integrity.v1` payload also carries a `reconciliation` +summary object (`total`, `ok`, `stale`, `stale_items`) and a `reconciliation` +entry under `checks`. diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 058a695..0a6e540 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -33,6 +33,119 @@ def _cmd_baseline(cfg: Config) -> int: return 0 +def _live_fingerprint(cfg: Config, kind: str, target: str) -> tuple[dict, bool]: + """Build a fingerprint from the *current* live state and report whether the + target was actually observed (so accept can warn before blindly recording).""" + import os + import stat + + from . import fim, reconcile + from .system import SystemState + + if kind == "fim": + entry = fim.scan_paths([target]).get(target) + return reconcile.build_fingerprint("fim", target, entry), entry is not None + if kind == "pkgfile": + reasons = [r for _pkg, p, r in fim.pacman_verify() if p == target] + return reconcile.build_fingerprint("pkgfile", target, reasons), bool(reasons) + if kind == "listener": + present = target in SystemState().listener_keys() + return reconcile.build_fingerprint("listener", target, None), present + if kind == "suid": + try: + mode = os.lstat(target).st_mode + present = bool(mode & (stat.S_ISUID | stat.S_ISGID)) + except OSError: + present = False + return reconcile.build_fingerprint("suid", target, None), present + raise ValueError(kind) + + +def _cmd_reconcile(cfg: Config, args) -> int: + from . import reconcile + store = reconcile.ReconcileStore.load(cfg) + if args.action == "accept": + return _reconcile_accept(cfg, store, args) + if args.action == "revoke": + return _reconcile_revoke(store, args) + return _reconcile_list(cfg, store, args) + + +def _reconcile_accept(cfg: Config, store, args) -> int: + from . import reconcile + if args.kind not in reconcile.KINDS: + print(f"error: kind must be one of {', '.join(reconcile.KINDS)}", + file=sys.stderr) + return 2 + if not args.target: + print("error: 'baseline accept' needs a target (path or port/comm)", + file=sys.stderr) + return 2 + if not args.reason: + print("error: 'baseline accept' requires --reason", file=sys.stderr) + return 2 + expires_at = None + if args.expires: + try: + expires_at = reconcile._utcnow() + reconcile.parse_duration(args.expires) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + fingerprint, present = _live_fingerprint(cfg, args.kind, args.target) + if not present and not args.force: + print(f"warning: no live {args.kind} state for {args.target!r}; it cannot " + f"be fingerprinted now. Re-run with --force to accept anyway.", + file=sys.stderr) + return 1 + existed = store.accept(args.kind, args.target, fingerprint, args.reason, + reconcile.current_actor(), expires_at=expires_at) + verb = "updated" if existed else "accepted" + ttl = f" (expires {expires_at:%Y-%m-%d %H:%M} UTC)" if expires_at else "" + print(f"{verb} {args.kind} {args.target}{ttl}") + return 0 + + +def _reconcile_revoke(store, args) -> int: + from . import reconcile + if args.kind not in reconcile.KINDS or not args.target: + print("error: 'baseline revoke' needs a kind and target", file=sys.stderr) + return 2 + if store.revoke(args.kind, args.target): + print(f"revoked {args.kind} {args.target}") + return 0 + print(f"error: not found: {args.kind} {args.target}", file=sys.stderr) + return 1 + + +def _reconcile_list(cfg: Config, store, args) -> int: + from . import fim, reconcile + + # Re-derive FIM staleness against live state (cheap — only the acked paths). + fim_targets = [r.target for r in store.list() if r.kind == "fim"] + live_fps = {"fim": fim.scan_paths(fim_targets)} if fim_targets else {} + records = store.list(stale_only=args.stale, live_fps=live_fps) + any_stale = any(r.status == "stale" for r in store.list()) + + if args.json: + import json + print(json.dumps({"schema": schemas.RECONCILE_V1, + "records": [r.to_dict() for r in records]}, indent=2)) + return 1 if any_stale else 0 + + if not records: + print("No acknowledged drift." if not args.stale + else "No stale acknowledgements.") + return 1 if any_stale else 0 + print(f"{'KIND':<9} {'TARGET':<28} {'STATUS':<6} {'ACCEPTED':<17} " + f"{'EXPIRES':<11} REASON") + for r in records: + accepted = r.accepted_at[:16].replace("T", " ") + expires = r.expires_at[:10] if r.expires_at else "-" + print(f"{r.kind:<9} {r.target:<28} {r.status:<6} {accepted:<17} " + f"{expires:<11} {r.reason}") + return 1 if any_stale else 0 + + def _cmd_check(cfg: Config) -> int: # One-shot: arm everything immediately, force the SUID scan. sentinel = Sentinel(cfg) @@ -60,7 +173,25 @@ def main(argv: list[str] | None = None) -> int: sub = parser.add_subparsers(dest="cmd") sub.add_parser("run", help="run the daemon loop (default)") sub.add_parser("check", help="run detectors once and print alerts") - sub.add_parser("baseline", help="rebuild listener/SUID baselines") + bl = sub.add_parser( + "baseline", + help="rebuild baselines (default), or accept/revoke/list drift") + bl.add_argument("action", nargs="?", default="build", + choices=["build", "accept", "revoke", "list"], + help="build (default) rebuilds listener/SUID baselines; " + "accept/revoke/list manage acknowledged drift") + bl.add_argument("kind", nargs="?", + help="drift kind for accept/revoke: fim|pkgfile|listener|suid") + bl.add_argument("target", nargs="?", + help="path, or port/comm key, to accept/revoke") + bl.add_argument("--reason", help="why the drift is acceptable (accept)") + bl.add_argument("--expires", help="optional TTL for accept: e.g. 7d, 12h, 30m") + bl.add_argument("--force", action="store_true", + help="accept even when no live fingerprint can be read") + bl.add_argument("--stale", action="store_true", + help="list only stale/expired acknowledgements") + bl.add_argument("--json", action="store_true", + help="emit acknowledgements as JSON (list)") sub.add_parser("list-detectors", help="list available detectors") rules = sub.add_parser("rules", help="list/show/test/docs built-in and configured rules") @@ -124,7 +255,9 @@ def main(argv: list[str] | None = None) -> int: if args.cmd == "rules": return _cmd_rules(cfg, args.action, args.target, args.json) if args.cmd == "baseline": - return _cmd_baseline(cfg) + if args.action == "build": + return _cmd_baseline(cfg) + return _cmd_reconcile(cfg, args) if args.cmd == "check": return _cmd_check(cfg) if args.cmd == "web": @@ -472,18 +605,29 @@ def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> in def _cmd_fim_check(cfg: Config, packages: bool) -> int: - from . import fim + from . import fim, reconcile sentinel = Sentinel(cfg) sentinel.load_fim_baseline() current = fim.scan_paths(cfg.fim_path_list()) d = fim.diff(sentinel.fim_baseline, current) - changed = len(d["added"]) + len(d["removed"]) + len(d["modified"]) + # Hide drift the operator has acknowledged while it still matches the + # accepted fingerprint (`current` is the live fingerprint source). + store = reconcile.ReconcileStore.load(cfg) + surviving = {a.key for a in + store.filter_alerts(list(fim.diff_alerts(d)), {"fim": current})} + changed = 0 for path, changes in d["modified"]: - print(f"[MODIFIED] {path} ({', '.join(changes)})") + if f"fim:mod:{path}" in surviving: + print(f"[MODIFIED] {path} ({', '.join(changes)})") + changed += 1 for path in d["added"]: - print(f"[ADDED] {path}") + if f"fim:add:{path}" in surviving: + print(f"[ADDED] {path}") + changed += 1 for path in d["removed"]: - print(f"[REMOVED] {path}") + if f"fim:del:{path}" in surviving: + print(f"[REMOVED] {path}") + changed += 1 if not changed: print("FIM: no changes against baseline.") if packages: diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index 9b4acb5..03db823 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -172,6 +172,9 @@ class Config: # paths log_dir: Path = Path("/var/log/enodia-sentinel") + # Baseline-reconciliation acknowledgement store (accepted FIM/package/ + # listener/SUID drift). Empty = log_dir/reconciliation.json. + reconcile_store: str = "" # ---- derived paths --------------------------------------------------- @property diff --git a/enodia_sentinel/daemon.py b/enodia_sentinel/daemon.py index 09c6efc..8be760a 100644 --- a/enodia_sentinel/daemon.py +++ b/enodia_sentinel/daemon.py @@ -147,8 +147,10 @@ class Sentinel: current = scan_paths(self.cfg.fim_path_list()) # NB: the baseline is NOT updated here — only `fim-update` / the pacman # hook refreshes it, so a change stays flagged until acknowledged. + # `current` doubles as the live fingerprint source for reconciliation. + live_fps = {"fim": current} for alert in diff_alerts(diff(self.fim_baseline, current)): - self._on_exec_alert(alert) + self._on_exec_alert(alert, live_fps) def _maybe_pkg_verify(self, now: float) -> None: if not self.cfg.fim_pkg_verify: @@ -162,9 +164,16 @@ class Sentinel: self._fim_pkg_thread.start() def _pkg_verify(self) -> None: - from .fim import pacman_verify_alerts - for alert in pacman_verify_alerts(): - self._on_exec_alert(alert) + from . import fim + hits = fim.pacman_verify() + # Group every reported reason per path so a reconciliation ack binds to + # the full set, not a single line. + live: dict[str, list[str]] = {} + for _pkg, path, reason in hits: + live.setdefault(path, []).append(reason) + live_fps = {"pkgfile": live} + for pkg, path, reason in hits: + self._on_exec_alert(fim.pkg_alert(pkg, path, reason), live_fps) # -- package signature verification (Layer 2, off the loop thread) ------ def _maybe_pkgdb_verify(self, now: float) -> None: @@ -225,8 +234,19 @@ class Sentinel: self.last_persist_scan = now return alerts - def fresh_alerts(self, alerts: list[Alert], now: float) -> list[Alert]: - """Drop alerts whose dedup key is still within cooldown (thread-safe).""" + def fresh_alerts(self, alerts: list[Alert], now: float, + live_fps: dict | None = None) -> list[Alert]: + """Drop alerts that an operator has acknowledged (baseline + reconciliation), then those still within cooldown (thread-safe). + + Reconciliation runs first so an acknowledged item never even consumes a + cooldown slot. ``live_fps`` carries current fingerprints for content + kinds (e.g. ``{"fim": {path: entry}}``); identity kinds need none. + """ + if alerts: + from .reconcile import ReconcileStore + alerts = ReconcileStore.load(self.cfg).filter_alerts( + alerts, live_fps or {}) out = [] with self._cooldown_lock: for a in alerts: @@ -236,9 +256,11 @@ class Sentinel: out.append(a) return out - def _on_exec_alert(self, alert: Alert) -> None: - """Callback for eBPF monitors — same dedup + capture path.""" - fresh = self.fresh_alerts([alert], time.time()) + def _on_exec_alert(self, alert: Alert, live_fps: dict | None = None) -> None: + """Callback for eBPF monitors and async scanners — same dedup + capture + path. ``live_fps`` lets content-kind scanners (FIM, package verify) feed + the reconciliation filter their current fingerprints.""" + fresh = self.fresh_alerts([alert], time.time(), live_fps) if fresh: threading.Thread( target=self._capture, args=(fresh,), daemon=True diff --git a/enodia_sentinel/fim.py b/enodia_sentinel/fim.py index 98bdece..12867bb 100644 --- a/enodia_sentinel/fim.py +++ b/enodia_sentinel/fim.py @@ -170,12 +170,16 @@ def pacman_verify(timeout: int = 600) -> list[tuple[str, str, str]]: return parse_pacman_verify(r.stdout + "\n" + r.stderr) +def pkg_alert(pkg: str, path: str, reason: str) -> Alert: + return Alert( + severity=Severity.CRITICAL, + signature="fim_pkg_modified", + key=f"fim:pkg:{path}", + detail=f"package file altered ({pkg}: {reason}): {path}", + sid=SID_PKG, classtype="integrity-violation", + ) + + def pacman_verify_alerts(timeout: int = 600) -> Iterator[Alert]: for pkg, path, reason in pacman_verify(timeout): - yield Alert( - severity=Severity.CRITICAL, - signature="fim_pkg_modified", - key=f"fim:pkg:{path}", - detail=f"package file altered ({pkg}: {reason}): {path}", - sid=SID_PKG, classtype="integrity-violation", - ) + yield pkg_alert(pkg, path, reason) diff --git a/enodia_sentinel/reconcile.py b/enodia_sentinel/reconcile.py new file mode 100644 index 0000000..7848cdd --- /dev/null +++ b/enodia_sentinel/reconcile.py @@ -0,0 +1,343 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Baseline reconciliation — accept legitimate drift with an audited reason. + +The daemon re-alerts on every sweep for drift it cannot distinguish from an +attack: a config file the operator edited, a service that started after boot, a +package-installed SUID binary. Rebuilding baselines silently folds the change in +with no record of *why*; permanent config allowlists carry no per-item reason +and never expire. + +This module is the missing middle ground. An operator *accepts* a specific, +identified drift item with a mandatory reason; the acknowledgement suppresses +that one alert **only while the live state still matches the accepted +fingerprint**. The moment the item changes again (or its TTL expires) the ack +goes stale and the alert returns. Every acceptance records who, when, why, and +the exact fingerprint, in a single queryable store. + +No other module is aware of ack semantics: detectors keep emitting alerts and +``ReconcileStore.filter_alerts`` drops the acknowledged ones at a chokepoint. + +Scope: ``fim``/``pkgfile`` acks are content-bound (they go stale automatically +when the file changes); ``listener``/``suid`` acks are identity-bound (a +different port/comm or path is a new, unrelated alert, so only TTL expiry or an +explicit revoke staleness them). eBPF-only signatures with no baseline concept +are out of scope. +""" +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from . import schemas +from .alert import Alert +from .config import Config + +KINDS = ("fim", "pkgfile", "listener", "suid") + +# Map an alert dedup key back to the (kind, target) an ack is filed under. The +# prefixes are the stable keys minted by the FIM detector and the +# new_listener/new_suid detectors. +_KEY_PREFIXES = ( + ("fim:pkg:", "pkgfile"), + ("fim:mod:", "fim"), + ("fim:add:", "fim"), + ("fim:del:", "fim"), + ("lis:", "listener"), + ("suid:", "suid"), +) + + +def alert_identity(key: str) -> tuple[str | None, str | None]: + """Resolve an alert key to ``(kind, target)``, or ``(None, None)`` if the + signature has no baseline concept and can never be acknowledged.""" + for prefix, kind in _KEY_PREFIXES: + if key.startswith(prefix): + return kind, key[len(prefix):] + return None, None + + +_DURATION_RE = re.compile(r"^(\d+)([dhm])$") + + +def parse_duration(text: str) -> timedelta: + """Parse ``7d`` / ``12h`` / ``30m`` into a timedelta. Raises ValueError.""" + m = _DURATION_RE.match(text.strip()) + if not m: + raise ValueError( + f"invalid duration {text!r}; use forms like 7d, 12h, 30m") + n = int(m.group(1)) + unit = m.group(2) + return {"d": timedelta(days=n), + "h": timedelta(hours=n), + "m": timedelta(minutes=n)}[unit] + + +def build_fingerprint(kind: str, target: str, live) -> dict: + """Build the kind-specific fingerprint compared on each sweep. + + ``live`` is the live observation for the target: a FIM scan entry dict for + ``fim`` (``None`` when the path is absent), a list of pacman reason strings + for ``pkgfile``, and ignored for the identity kinds. + """ + if kind == "fim": + entry = live or {} + return {k: entry.get(k) for k in ("sha256", "mode", "uid", "gid")} + if kind == "pkgfile": + return {"reasons": sorted(live or [])} + if kind == "listener": + return {"key": target} + if kind == "suid": + return {"path": target} + raise ValueError(f"unknown reconciliation kind: {kind}") + + +def current_actor() -> str: + """Best-effort operator identity for the audit record.""" + try: + return os.getlogin() + except OSError: + return (os.environ.get("SUDO_USER") or os.environ.get("USER") + or os.environ.get("LOGNAME") or "unknown") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +@dataclass +class Record: + kind: str + target: str + fingerprint: dict + reason: str + actor: str + accepted_at: str + expires_at: str | None = None + status: str = "ok" + + @property + def identity(self) -> tuple[str, str]: + return (self.kind, self.target) + + def expiry(self) -> datetime | None: + if not self.expires_at: + return None + try: + return datetime.fromisoformat(self.expires_at) + except ValueError: + return None + + def to_dict(self) -> dict: + d = { + "kind": self.kind, + "target": self.target, + "fingerprint": self.fingerprint, + "reason": self.reason, + "actor": self.actor, + "accepted_at": self.accepted_at, + "status": self.status, + } + if self.expires_at: + d["expires_at"] = self.expires_at + return d + + @classmethod + def from_dict(cls, d: dict) -> "Record": + return cls( + kind=d["kind"], + target=d["target"], + fingerprint=d.get("fingerprint") or {}, + reason=d.get("reason", ""), + actor=d.get("actor", "unknown"), + accepted_at=d.get("accepted_at", ""), + expires_at=d.get("expires_at"), + status=d.get("status", "ok"), + ) + + +class ReconcileStore: + """Owns the acknowledgement store: I/O, fingerprint checks, alert filtering. + + Loaded through an mtime cache so the daemon never re-reads or re-parses an + unchanged file. Fails closed: a missing or corrupt store applies no acks and + every alert flows through normally. + """ + + # path -> (mtime, parsed record dicts). Shared so repeated loads of an + # unchanged file skip the disk read and JSON parse entirely. + _CACHE: dict[str, tuple[float, list[dict]]] = {} + + def __init__(self, cfg: Config, records: dict[tuple[str, str], Record]): + self.cfg = cfg + self._records = records + self._dirty = False + + # -- locating / loading ------------------------------------------------ + @staticmethod + def store_path(cfg: Config) -> Path: + return Path(cfg.reconcile_store) if cfg.reconcile_store \ + else cfg.log_dir / "reconciliation.json" + + @classmethod + def invalidate_cache(cls) -> None: + cls._CACHE.clear() + + @classmethod + def load(cls, cfg: Config) -> "ReconcileStore": + path = cls.store_path(cfg) + try: + mtime = path.stat().st_mtime + except OSError: + return cls(cfg, {}) + cached = cls._CACHE.get(str(path)) + if cached and cached[0] == mtime: + raw = cached[1] + else: + raw = cls._read_records(path) + cls._CACHE[str(path)] = (mtime, raw) + records: dict[tuple[str, str], Record] = {} + for d in raw: + try: + rec = Record.from_dict(d) + except (KeyError, TypeError): + continue + records[rec.identity] = rec + return cls(cfg, records) + + @staticmethod + def _read_records(path: Path) -> list[dict]: + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + return [] # fail closed: corrupt store applies no acks + recs = data.get("records") if isinstance(data, dict) else None + return recs if isinstance(recs, list) else [] + + # -- mutation ---------------------------------------------------------- + def accept(self, kind: str, target: str, fingerprint: dict, reason: str, + actor: str, expires_at: datetime | None = None) -> bool: + """Record (or update) an acceptance. Returns True if one already existed.""" + existed = (kind, target) in self._records + self._records[(kind, target)] = Record( + kind=kind, target=target, fingerprint=fingerprint, + reason=reason, actor=actor, accepted_at=_iso(_utcnow()), + expires_at=_iso(expires_at) if expires_at else None, status="ok", + ) + self._write() + return existed + + def revoke(self, kind: str, target: str) -> bool: + """Drop an acceptance. Returns False if the target wasn't present.""" + if (kind, target) in self._records: + del self._records[(kind, target)] + self._write() + return True + return False + + # -- queries ----------------------------------------------------------- + def list(self, stale_only: bool = False, live_fps: dict | None = None, + now: datetime | None = None) -> list[Record]: + """All records, re-evaluating staleness against TTL and any supplied + live state. Persists newly detected transitions (the lazy write-back). + + TTL expiry is always authoritative. A content-bound (fim/pkgfile) ack is + only re-checked when ``live_fps`` actually carries that kind's data; with + no live data the command didn't look, so the persisted status stands + rather than being fabricated stale. + """ + now = now or _utcnow() + live_fps = live_fps or {} + for rec in self._records.values(): + self._refresh_listed(rec, live_fps, now) + self.flush_stale() + out = [r for r in self._records.values() + if not stale_only or r.status == "stale"] + return sorted(out, key=lambda r: (r.kind, r.target)) + + # -- filtering (the daemon/web/CLI chokepoint) ------------------------- + def filter_alerts(self, alerts: list[Alert], live_fps: dict | None = None, + now: datetime | None = None) -> list[Alert]: + """Drop alerts that a still-valid acknowledgement covers. + + Acks whose live fingerprint diverged or whose TTL lapsed are marked + stale in memory (written back lazily) and their alert flows through. + """ + live_fps = live_fps or {} + now = now or _utcnow() + out: list[Alert] = [] + for a in alerts: + kind, target = alert_identity(a.key) + rec = self._records.get((kind, target)) if kind else None + if rec is None or not self._refresh(rec, live_fps, now): + out.append(a) + return out + + # -- staleness --------------------------------------------------------- + def _refresh(self, rec: Record, live_fps: dict, now: datetime) -> bool: + """Re-evaluate one record; update its cached status. Returns True if the + ack is still valid (alert should be suppressed).""" + valid = self._is_valid(rec, live_fps, now) + status = "ok" if valid else "stale" + if rec.status != status: + rec.status = status + self._dirty = True + return valid + + @staticmethod + def _is_valid(rec: Record, live_fps: dict, now: datetime) -> bool: + expiry = rec.expiry() + if expiry and now >= expiry: + return False + if rec.kind in ("fim", "pkgfile"): + live = (live_fps.get(rec.kind) or {}).get(rec.target) + return build_fingerprint(rec.kind, rec.target, live) == rec.fingerprint + return True # identity kinds: presence + unexpired is enough + + def _refresh_listed(self, rec: Record, live_fps: dict, + now: datetime) -> None: + """Status re-evaluation for ``list``: TTL is authoritative, but a + content kind with no supplied live data keeps its persisted status.""" + expiry = rec.expiry() + if expiry and now >= expiry: + status = "stale" + elif rec.kind in ("fim", "pkgfile"): + if rec.kind not in live_fps: + return # not checked this run; leave the recorded status as-is + live = (live_fps.get(rec.kind) or {}).get(rec.target) + status = "ok" if build_fingerprint( + rec.kind, rec.target, live) == rec.fingerprint else "stale" + else: + status = "ok" # identity kind, unexpired + if rec.status != status: + rec.status = status + self._dirty = True + + # -- persistence ------------------------------------------------------- + def flush_stale(self) -> None: + """Write pending in-memory stale transitions back to disk, if any.""" + if self._dirty: + self._write() + + def _write(self) -> None: + path = self.store_path(self.cfg) + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema": schemas.RECONCILE_V1, + "records": [r.to_dict() for r in + sorted(self._records.values(), + key=lambda r: (r.kind, r.target))], + } + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(json.dumps(payload, indent=2)) + os.replace(tmp, path) + self._dirty = False + # Drop the cache entry so the next load re-reads the file we just wrote. + self._CACHE.pop(str(path), None) diff --git a/enodia_sentinel/schemas.py b/enodia_sentinel/schemas.py index 0ac60c1..3cfc674 100644 --- a/enodia_sentinel/schemas.py +++ b/enodia_sentinel/schemas.py @@ -15,3 +15,4 @@ STATUS_V1 = "enodia.status.v1" INTEGRITY_V1 = "enodia.integrity.v1" RESPONSE_PLAN_V1 = "enodia.response.plan.v1" RESPONSE_AUDIT_V1 = "enodia.response.audit.v1" +RECONCILE_V1 = "enodia.reconcile.v1" diff --git a/enodia_sentinel/web.py b/enodia_sentinel/web.py index 2d8b909..d7e51bd 100644 --- a/enodia_sentinel/web.py +++ b/enodia_sentinel/web.py @@ -372,12 +372,14 @@ def integrity_report(cfg: Config, status: dict | None = None, ), } keyring_present = pkgdb.keyring_present() + reconciliation = _reconciliation_summary(cfg) checks = { "watchdog": "ok" if watchdog_ok else "review", "fim_baseline": fim_state["status"], "pkgdb_anchor": pkg_state["status"], "pacman_siglevel": "review" if sig_alert else "ok", "pacman_keyring": "ok" if keyring_present else "missing", + "reconciliation": "review" if reconciliation["stale"] else "ok", } overall = "ok" if any(v in ("review", "missing", "unreadable") for v in checks.values()): @@ -413,10 +415,30 @@ def integrity_report(cfg: Config, status: dict | None = None, "missing": len(watched) - present, "paths": watched, }, + "reconciliation": reconciliation, "read_only": True, } +def _reconciliation_summary(cfg: Config) -> dict: + """Acknowledged-drift counts for the dashboard. Read-only: this evaluates + TTL expiry and persisted status, but does not run live FIM/package scans + from the web request path (consistent with the rest of integrity_report).""" + from . import reconcile + records = reconcile.ReconcileStore.load(cfg).list() + stale = [r for r in records if r.status == "stale"] + return { + "total": len(records), + "ok": len(records) - len(stale), + "stale": len(stale), + "stale_items": [ + {"kind": r.kind, "target": r.target, "reason": r.reason, + "accepted_at": r.accepted_at, "expires_at": r.expires_at} + for r in stale + ], + } + + # --- HTTP layer ------------------------------------------------------------ class _Handler(BaseHTTPRequestHandler): diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py new file mode 100644 index 0000000..6fad39d --- /dev/null +++ b/tests/test_reconcile.py @@ -0,0 +1,218 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for baseline reconciliation: fingerprints, store I/O, alert filtering.""" +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from enodia_sentinel import reconcile +from enodia_sentinel.alert import Alert, Severity +from enodia_sentinel.config import Config + + +def _cfg(tmp: Path) -> Config: + cfg = Config() + cfg.log_dir = tmp + return cfg + + +def _alert(key: str, sig: str = "x") -> Alert: + return Alert(Severity.HIGH, sig, key, "detail") + + +class TestAlertIdentity(unittest.TestCase): + def test_fim_modified_added_removed_map_to_fim(self): + for prefix in ("fim:mod:", "fim:add:", "fim:del:"): + kind, target = reconcile.alert_identity(prefix + "/etc/hosts") + self.assertEqual((kind, target), ("fim", "/etc/hosts")) + + def test_pkgfile_key(self): + self.assertEqual(reconcile.alert_identity("fim:pkg:/usr/bin/ssh"), + ("pkgfile", "/usr/bin/ssh")) + + def test_listener_key(self): + self.assertEqual(reconcile.alert_identity("lis:8080/nginx"), + ("listener", "8080/nginx")) + + def test_suid_key(self): + self.assertEqual(reconcile.alert_identity("suid:/usr/bin/sudo"), + ("suid", "/usr/bin/sudo")) + + def test_unknown_key_is_passthrough(self): + self.assertEqual(reconcile.alert_identity("rsh:1234"), (None, None)) + + +class TestParseDuration(unittest.TestCase): + def test_days_hours_minutes(self): + self.assertEqual(reconcile.parse_duration("7d"), timedelta(days=7)) + self.assertEqual(reconcile.parse_duration("12h"), timedelta(hours=12)) + self.assertEqual(reconcile.parse_duration("30m"), timedelta(minutes=30)) + + def test_invalid_raises(self): + for bad in ("", "7", "d", "7x", "-1d", "1.5h"): + with self.assertRaises(ValueError): + reconcile.parse_duration(bad) + + +class TestBuildFingerprint(unittest.TestCase): + def test_fim_extracts_content_fields_only(self): + live = {"mode": 33188, "uid": 0, "gid": 0, "size": 12, + "sha256": "abc123"} + fp = reconcile.build_fingerprint("fim", "/etc/hosts", live) + self.assertEqual(fp, {"sha256": "abc123", "mode": 33188, + "uid": 0, "gid": 0}) + + def test_fim_absent_target_is_all_none(self): + fp = reconcile.build_fingerprint("fim", "/gone", None) + self.assertEqual(fp, {"sha256": None, "mode": None, + "uid": None, "gid": None}) + + def test_pkgfile_sorts_reasons(self): + fp = reconcile.build_fingerprint( + "pkgfile", "/usr/bin/ssh", ["size mismatch", "checksum mismatch"]) + self.assertEqual(fp, {"reasons": ["checksum mismatch", "size mismatch"]}) + + def test_listener_and_suid_are_identity(self): + self.assertEqual( + reconcile.build_fingerprint("listener", "8080/nginx", None), + {"key": "8080/nginx"}) + self.assertEqual( + reconcile.build_fingerprint("suid", "/usr/bin/sudo", None), + {"path": "/usr/bin/sudo"}) + + +class TestStoreRoundTrip(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self.cfg = _cfg(Path(self.d.name)) + + def tearDown(self): + self.d.cleanup() + reconcile.ReconcileStore.invalidate_cache() + + def test_missing_file_is_empty_store(self): + store = reconcile.ReconcileStore.load(self.cfg) + self.assertEqual(store.list(), []) + + def test_corrupt_json_fails_closed_to_empty(self): + reconcile.ReconcileStore.store_path(self.cfg).write_text("{not json") + reconcile.ReconcileStore.invalidate_cache() + store = reconcile.ReconcileStore.load(self.cfg) + self.assertEqual(store.list(), []) + + def test_accept_persists_and_reloads(self): + store = reconcile.ReconcileStore.load(self.cfg) + fp = reconcile.build_fingerprint("fim", "/etc/hosts", + {"sha256": "a", "mode": 1, "uid": 0, "gid": 0}) + store.accept("fim", "/etc/hosts", fp, "added host entry", "luna") + reconcile.ReconcileStore.invalidate_cache() + again = reconcile.ReconcileStore.load(self.cfg) + recs = again.list() + self.assertEqual(len(recs), 1) + self.assertEqual(recs[0].target, "/etc/hosts") + self.assertEqual(recs[0].reason, "added host entry") + self.assertEqual(recs[0].actor, "luna") + self.assertEqual(recs[0].status, "ok") + + def test_accept_existing_updates_returns_true(self): + store = reconcile.ReconcileStore.load(self.cfg) + fp = {"key": "8080/nginx"} + self.assertFalse(store.accept("listener", "8080/nginx", fp, "r1", "luna")) + self.assertTrue(store.accept("listener", "8080/nginx", fp, "r2", "luna")) + self.assertEqual(len(store.list()), 1) + + def test_revoke_removes_and_reports_missing(self): + store = reconcile.ReconcileStore.load(self.cfg) + store.accept("suid", "/usr/bin/sudo", {"path": "/usr/bin/sudo"}, "r", "luna") + self.assertTrue(store.revoke("suid", "/usr/bin/sudo")) + self.assertFalse(store.revoke("suid", "/usr/bin/sudo")) + self.assertEqual(store.list(), []) + + +class TestFilterAlerts(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self.cfg = _cfg(Path(self.d.name)) + self.store = reconcile.ReconcileStore.load(self.cfg) + + def tearDown(self): + self.d.cleanup() + reconcile.ReconcileStore.invalidate_cache() + + def test_unacked_alert_passes_through(self): + out = self.store.filter_alerts([_alert("fim:mod:/etc/hosts")], {}) + self.assertEqual(len(out), 1) + + def test_matching_fim_ack_suppresses(self): + live = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0, "size": 9} + fp = reconcile.build_fingerprint("fim", "/etc/hosts", live) + self.store.accept("fim", "/etc/hosts", fp, "edit", "luna") + out = self.store.filter_alerts( + [_alert("fim:mod:/etc/hosts")], {"fim": {"/etc/hosts": live}}) + self.assertEqual(out, []) + + def test_changed_fim_passes_and_marks_stale(self): + accepted = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0} + self.store.accept("fim", "/etc/hosts", accepted, "edit", "luna") + changed = {"sha256": "DIFFERENT", "mode": 33188, "uid": 0, "gid": 0} + out = self.store.filter_alerts( + [_alert("fim:mod:/etc/hosts")], {"fim": {"/etc/hosts": changed}}) + self.assertEqual(len(out), 1) + self.assertEqual(self.store.list()[0].status, "stale") + + def test_accepted_removal_stays_suppressed_when_absent(self): + fp = reconcile.build_fingerprint("fim", "/etc/gone", None) + self.store.accept("fim", "/etc/gone", fp, "decommissioned", "luna") + out = self.store.filter_alerts( + [_alert("fim:del:/etc/gone")], {"fim": {}}) + self.assertEqual(out, []) + + def test_expired_ttl_passes_and_marks_stale(self): + past = datetime.now(timezone.utc) - timedelta(hours=1) + self.store.accept("listener", "8080/nginx", {"key": "8080/nginx"}, + "temp", "luna", expires_at=past) + out = self.store.filter_alerts([_alert("lis:8080/nginx")], {}) + self.assertEqual(len(out), 1) + self.assertEqual(self.store.list()[0].status, "stale") + + def test_listener_ack_suppresses_within_ttl(self): + future = datetime.now(timezone.utc) + timedelta(days=1) + self.store.accept("listener", "8080/nginx", {"key": "8080/nginx"}, + "temp", "luna", expires_at=future) + out = self.store.filter_alerts([_alert("lis:8080/nginx")], {}) + self.assertEqual(out, []) + + +class TestListAndStale(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self.cfg = _cfg(Path(self.d.name)) + + def tearDown(self): + self.d.cleanup() + reconcile.ReconcileStore.invalidate_cache() + + def test_list_stale_only_filters(self): + store = reconcile.ReconcileStore.load(self.cfg) + future = datetime.now(timezone.utc) + timedelta(days=1) + past = datetime.now(timezone.utc) - timedelta(days=1) + store.accept("listener", "1/a", {"key": "1/a"}, "ok", "luna", + expires_at=future) + store.accept("listener", "2/b", {"key": "2/b"}, "exp", "luna", + expires_at=past) + stale = store.list(stale_only=True) + self.assertEqual([r.target for r in stale], ["2/b"]) + + def test_list_persists_stale_transition(self): + store = reconcile.ReconcileStore.load(self.cfg) + past = datetime.now(timezone.utc) - timedelta(days=1) + store.accept("listener", "2/b", {"key": "2/b"}, "exp", "luna", + expires_at=past) + store.list() # triggers TTL re-evaluation + lazy write-back + reconcile.ReconcileStore.invalidate_cache() + reloaded = reconcile.ReconcileStore.load(self.cfg) + self.assertEqual(reloaded.list()[0].status, "stale") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reconcile_cli.py b/tests/test_reconcile_cli.py new file mode 100644 index 0000000..f4b615c --- /dev/null +++ b/tests/test_reconcile_cli.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for the `baseline accept/revoke/list` CLI surface.""" +import contextlib +import io +import json +import os +import tempfile +import unittest +from pathlib import Path + +from enodia_sentinel import reconcile +from enodia_sentinel.cli import main + + +def _run(*argv) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = main(list(argv)) + return code, out.getvalue(), err.getvalue() + + +class BaselineCLITest(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self._old = os.environ.get("ENODIA_LOG_DIR") + os.environ["ENODIA_LOG_DIR"] = self.d.name + reconcile.ReconcileStore.invalidate_cache() + + def tearDown(self): + if self._old is None: + os.environ.pop("ENODIA_LOG_DIR", None) + else: + os.environ["ENODIA_LOG_DIR"] = self._old + self.d.cleanup() + reconcile.ReconcileStore.invalidate_cache() + + def _store(self): + from enodia_sentinel.config import Config + reconcile.ReconcileStore.invalidate_cache() + return reconcile.ReconcileStore.load(Config.load()) + + def test_accept_fim_path_round_trips(self): + f = Path(self.d.name) / "watched.conf" + f.write_text("hello") + code, _out, _err = _run("baseline", "accept", "fim", str(f), + "--reason", "operator edit") + self.assertEqual(code, 0) + recs = self._store().list() + self.assertEqual(len(recs), 1) + self.assertEqual(recs[0].kind, "fim") + self.assertEqual(recs[0].reason, "operator edit") + self.assertIn("sha256", recs[0].fingerprint) + + def test_accept_requires_reason(self): + f = Path(self.d.name) / "x" + f.write_text("y") + code, _out, err = _run("baseline", "accept", "fim", str(f)) + self.assertEqual(code, 2) + self.assertIn("reason", err.lower()) + + def test_accept_missing_live_data_requires_force(self): + code, _out, err = _run("baseline", "accept", "listener", "65000/ghost", + "--reason", "temp") + self.assertEqual(code, 1) + self.assertIn("force", err.lower()) + self.assertEqual(self._store().list(), []) + + def test_accept_force_overrides_missing_live_data(self): + code, _out, _err = _run("baseline", "accept", "listener", + "65000/ghost", "--reason", "temp", "--force") + self.assertEqual(code, 0) + self.assertEqual(len(self._store().list()), 1) + + def test_accept_invalid_expires_errors(self): + code, _out, err = _run("baseline", "accept", "listener", "1/a", + "--reason", "r", "--force", "--expires", "soon") + self.assertEqual(code, 2) + self.assertIn("duration", err.lower()) + + def test_revoke_removes_then_reports_missing(self): + _run("baseline", "accept", "listener", "1/a", "--reason", "r", "--force") + code, _o, _e = _run("baseline", "revoke", "listener", "1/a") + self.assertEqual(code, 0) + code2, _o2, err2 = _run("baseline", "revoke", "listener", "1/a") + self.assertEqual(code2, 1) + self.assertIn("not found", err2.lower()) + + def test_list_json_emits_records(self): + f = Path(self.d.name) / "c.conf" + f.write_text("z") + _run("baseline", "accept", "fim", str(f), "--reason", "edit") + code, out, _err = _run("baseline", "list", "--json") + self.assertEqual(code, 0) + data = json.loads(out) + self.assertEqual(len(data["records"]), 1) + self.assertEqual(data["records"][0]["kind"], "fim") + + def test_list_stale_exit_code(self): + # no acks -> exit 0 + code, _o, _e = _run("baseline", "list", "--stale") + self.assertEqual(code, 0) + # an expired ack -> exit 1 + _run("baseline", "accept", "listener", "1/a", "--reason", "r", + "--force", "--expires", "0m") + code2, _o2, _e2 = _run("baseline", "list", "--stale") + self.assertEqual(code2, 1) + + def test_bare_baseline_still_builds(self): + # The legacy `baseline` (no action) must keep routing to the rebuild + # path. Stub the expensive filesystem scan; we only assert the dispatch. + from enodia_sentinel import cli + called = [] + orig = cli.Sentinel.build_baselines + cli.Sentinel.build_baselines = lambda self: called.append(True) + try: + code, out, _err = _run("baseline") + finally: + cli.Sentinel.build_baselines = orig + self.assertEqual(code, 0) + self.assertTrue(called) + self.assertIn("Baselines written", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reconcile_integration.py b/tests/test_reconcile_integration.py new file mode 100644 index 0000000..a957547 --- /dev/null +++ b/tests/test_reconcile_integration.py @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Reconciliation wired into the daemon chokepoint, fim-check, and the web API.""" +import contextlib +import io +import os +import tempfile +import time +import unittest +from pathlib import Path + +from enodia_sentinel import fim, reconcile, web +from enodia_sentinel.alert import Alert, Severity +from enodia_sentinel.cli import _cmd_fim_check +from enodia_sentinel.config import Config +from enodia_sentinel.daemon import Sentinel + + +def _cfg(tmp: Path) -> Config: + cfg = Config() + cfg.log_dir = tmp + return cfg + + +class DaemonChokepointTest(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self.cfg = _cfg(Path(self.d.name)) + reconcile.ReconcileStore.invalidate_cache() + + def tearDown(self): + self.d.cleanup() + reconcile.ReconcileStore.invalidate_cache() + + def _accept(self, kind, target, fp, **kw): + store = reconcile.ReconcileStore.load(self.cfg) + store.accept(kind, target, fp, "reason", "luna", **kw) + reconcile.ReconcileStore.invalidate_cache() + + def test_unacked_alert_passes_through(self): + s = Sentinel(self.cfg) + a = Alert(Severity.HIGH, "new_listener", "lis:8080/nginx", "d") + self.assertEqual(len(s.fresh_alerts([a], time.time())), 1) + + def test_acked_listener_is_dropped(self): + self._accept("listener", "8080/nginx", {"key": "8080/nginx"}) + s = Sentinel(self.cfg) + a = Alert(Severity.HIGH, "new_listener", "lis:8080/nginx", "d") + self.assertEqual(s.fresh_alerts([a], time.time()), []) + + def test_acked_fim_dropped_only_while_fingerprint_matches(self): + live = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0, "size": 4} + fp = reconcile.build_fingerprint("fim", "/etc/hosts", live) + self._accept("fim", "/etc/hosts", fp) + s = Sentinel(self.cfg) + a = Alert(Severity.HIGH, "fim_modified", "fim:mod:/etc/hosts", "d") + # matching live state -> suppressed + self.assertEqual( + s.fresh_alerts([a], time.time(), {"fim": {"/etc/hosts": live}}), []) + # changed again -> re-alerts + s2 = Sentinel(self.cfg) + changed = dict(live, sha256="b") + self.assertEqual( + len(s2.fresh_alerts([a], time.time(), {"fim": {"/etc/hosts": changed}})), 1) + + +class FimCheckSuppressionTest(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self.tmp = Path(self.d.name) + self.watch = self.tmp / "watched" + self.watch.mkdir() + self.cfg = _cfg(self.tmp) + self.cfg.fim_paths = (str(self.watch),) + self.cfg.fim_pkg_verify = False + reconcile.ReconcileStore.invalidate_cache() + + def tearDown(self): + self.d.cleanup() + reconcile.ReconcileStore.invalidate_cache() + + def _baseline(self): + # Seed a baseline directly so the command doesn't shell out to pacman. + base = fim.scan_paths(self.cfg.fim_path_list()) + self.cfg.fim_baseline.write_text(__import__("json").dumps(base)) + + def _fim_check(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = _cmd_fim_check(self.cfg, packages=False) + return code, out.getvalue() + + def test_modified_path_reported_then_suppressed_after_accept(self): + f = self.watch / "app.conf" + f.write_text("v1") + self._baseline() + f.write_text("v2-tampered") + code, out = self._fim_check() + self.assertEqual(code, 1) + self.assertIn(str(f), out) + # accept the drift -> the path drops out of fim-check + live = fim.scan_paths([str(f)]).get(str(f)) + fp = reconcile.build_fingerprint("fim", str(f), live) + store = reconcile.ReconcileStore.load(self.cfg) + store.accept("fim", str(f), fp, "rolled out v2", "luna") + reconcile.ReconcileStore.invalidate_cache() + code2, out2 = self._fim_check() + self.assertEqual(code2, 0) + self.assertNotIn(str(f), out2) + + +class WebReconciliationBlockTest(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self.cfg = _cfg(Path(self.d.name)) + reconcile.ReconcileStore.invalidate_cache() + + def tearDown(self): + self.d.cleanup() + reconcile.ReconcileStore.invalidate_cache() + + def test_integrity_report_has_reconciliation_summary(self): + store = reconcile.ReconcileStore.load(self.cfg) + past = reconcile._utcnow() - __import__("datetime").timedelta(days=1) + store.accept("listener", "1/a", {"key": "1/a"}, "ok now", "luna", + expires_at=reconcile._utcnow() + __import__("datetime").timedelta(days=1)) + store.accept("listener", "2/b", {"key": "2/b"}, "expired", "luna", + expires_at=past) + reconcile.ReconcileStore.invalidate_cache() + report = web.integrity_report(self.cfg) + recon = report["reconciliation"] + self.assertEqual(recon["total"], 2) + self.assertEqual(recon["stale"], 1) + self.assertEqual(report["checks"]["reconciliation"], "review") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_schema_contracts.py b/tests/test_schema_contracts.py index d9c9bb7..a1afa46 100644 --- a/tests/test_schema_contracts.py +++ b/tests/test_schema_contracts.py @@ -9,7 +9,7 @@ from io import StringIO from pathlib import Path from unittest.mock import patch -from enodia_sentinel import incident, respond, schemas, snapshot, web +from enodia_sentinel import incident, reconcile, respond, schemas, snapshot, web from enodia_sentinel.alert import Alert, Severity from enodia_sentinel.cli import main from enodia_sentinel.config import Config @@ -77,6 +77,27 @@ class TestSchemaContracts(unittest.TestCase): "pids": list, }) + def test_reconcile_v1_contract(self): + self.assertEqual(schemas.RECONCILE_V1, "enodia.reconcile.v1") + store = reconcile.ReconcileStore.load(self.cfg) + store.accept("fim", "/etc/hosts", + {"sha256": "a", "mode": 1, "uid": 0, "gid": 0}, + "operator edit", "luna") + reconcile.ReconcileStore.invalidate_cache() + data = json.loads( + reconcile.ReconcileStore.store_path(self.cfg).read_text()) + self.assertEqual(data["schema"], schemas.RECONCILE_V1) + _assert_types(self, data, {"schema": str, "records": list}) + _assert_types(self, data["records"][0], { + "kind": str, + "target": str, + "fingerprint": dict, + "reason": str, + "actor": str, + "accepted_at": str, + "status": str, + }) + def test_alert_snapshot_v1_contract(self): with patch("enodia_sentinel.snapshot._run", return_value=""): path = snapshot.capture([_alert()], self._State(), self.cfg)