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:
parent
3d2047fde2
commit
dbbe35b3fc
18 changed files with 1155 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue