Add baseline reconciliation: accept audited drift with a reason

Implements the v0.9 roadmap item from the approved design spec. Operators
accept a specific FIM/package/listener/SUID drift item with a mandatory
reason; the ack suppresses that one alert only while the live state still
matches the recorded fingerprint. Content kinds (fim/pkgfile) re-alert on
further change; identity kinds (listener/suid) retire on TTL or revoke.

- reconcile.py: ReconcileStore (mtime-cached, fails closed on missing/corrupt
  store), fingerprint builders, and the filter_alerts chokepoint.
- CLI: baseline accept/revoke/list with --reason/--expires/--force/--stale/--json.
- Wired at the daemon sweep + eBPF chokepoint, fim-check, and /api/integrity.
- RECONCILE_V1 schema, config knob, COMMAND_REFERENCE/SCHEMAS/OPERATIONS/ROADMAP
  docs, and reconcile unit/CLI/integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-27 10:33:55 -07:00
parent 3d2047fde2
commit dbbe35b3fc
18 changed files with 1155 additions and 28 deletions

View file

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