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:

View file

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

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

View file

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

View file

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

View file

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

View file

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