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

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