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