diff --git a/docs/superpowers/specs/2026-06-19-baseline-reconciliation-design.md b/docs/superpowers/specs/2026-06-19-baseline-reconciliation-design.md new file mode 100644 index 0000000..62eab71 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-baseline-reconciliation-design.md @@ -0,0 +1,238 @@ +# Baseline Reconciliation Design + +**Date:** 2026-06-19 +**Status:** Approved for implementation +**Roadmap:** v0.9 — "Add baseline reconciliation: accept legitimate FIM/package/listener/SUID changes with a recorded reason." + +--- + +## Problem + +The daemon re-alerts on every sweep for legitimate drift — a config file the operator edited, a new service started after boot, a SUID binary installed by a package manager. Today the only remedies are: + +- Rebuild baselines (`enodia-sentinel baseline` / `fim-update`), which silently folds the change in with no record of why. +- Add permanent config allowlists (`listener_allow_ports`, etc.), which carry no per-item reason or audit trail. + +Neither gives re-alert-on-change semantics: once drift is absorbed it stays absorbed, even if the file is edited a second time. + +--- + +## Goals + +1. Let operators accept specific, identified drift items with a mandatory reason. +2. Suppress alerts for those items only while the live state still matches the accepted fingerprint. +3. Re-alert and mark the acknowledgement stale when the item changes again or its TTL expires. +4. Keep a full audit trail (who, when, why, what fingerprint) in a single queryable store. +5. Leave existing baseline files and detector code untouched. + +--- + +## Non-goals + +- Auto-accepting drift without operator input. +- Modifying config files on behalf of the operator (that belongs to the FP-suppression bullet). +- Covering eBPF-only signatures that have no baseline concept (reverse shell, memory obfuscation, etc.). + +--- + +## Data Model + +**Store file:** `log_dir/reconciliation.json` (overridable via `reconcile_store` config key). +Written atomically: `reconciliation.json.tmp` → rename. Loaded with a file-mtime cache — the daemon never re-reads unless the file changed. + +### Record schema + +```json +{ + "kind": "fim", + "target": "/etc/hosts", + "fingerprint": {"sha256": "abc123", "mode": 33188, "uid": 0, "gid": 0}, + "reason": "added internal host entry", + "actor": "luna", + "accepted_at": "2026-06-19T14:00:00+00:00", + "expires_at": "2026-06-26T14:00:00+00:00", + "status": "ok" +} +``` + +| Field | Type | Notes | +|---|---|---| +| `kind` | string | `fim`, `pkgfile`, `listener`, `suid` | +| `target` | string | Path or `port/comm` key | +| `fingerprint` | object | Kind-specific; see below | +| `reason` | string | Mandatory, operator-supplied | +| `actor` | string | `os.getlogin()` / `$USER` / `"unknown"` | +| `accepted_at` | ISO-8601 UTC | Set at accept time | +| `expires_at` | ISO-8601 UTC or absent | Absent = no TTL | +| `status` | string | `"ok"` or `"stale"` | + +### Fingerprints by kind + +| Kind | Fingerprint fields | Ack goes stale when… | +|---|---|---| +| `fim` | `{sha256, mode, uid, gid}` | Any field differs from the live FIM scan entry | +| `pkgfile` | `{reasons: ["checksum mismatch", ...]}` | The set of pacman-reported flags for that path changes | +| `listener` | `{key: "8080/nginx"}` | TTL expires or operator revokes (a different port/comm is a distinct ack, not a stale one) | +| `suid` | `{path: "/usr/bin/sudo"}` | TTL expires or operator revokes | + +For `fim` and `pkgfile`, content changes trigger stale automatically. For `listener` and `suid`, the fingerprint is the identity key itself — a different port/comm or path produces a new unrelated alert rather than staleing an existing ack. TTL and explicit revoke are the only staleing mechanisms for those two kinds. + +### Stale transitions + +The filter transitions `ok → stale` in-memory when: +- Fingerprint check fails (live state diverged). +- Current time is past `expires_at`. + +The stale flag is written back to the store lazily — on the next `baseline list`, `accept`, or `revoke` call — to avoid disk writes on every daemon sweep. + +--- + +## Architecture + +### New module: `enodia_sentinel/reconcile.py` + +Owns all store I/O, fingerprint building, and filter logic. No other module is aware of ack semantics. + +``` +ReconcileStore + .load(cfg) -> ReconcileStore # mtime-cached + .accept(kind, target, fp, reason, actor, expires_at) + .revoke(kind, target) + .list(stale_only=False) -> list[Record] + .filter_alerts(alerts, live_fps) -> list[Alert] + .flush_stale() # write pending stale transitions + +build_fingerprint(kind, target, live_data) -> dict +``` + +`filter_alerts` is a method on `ReconcileStore`; called as `store.filter_alerts(alerts, live_fps)`. It is pure given the already-loaded store and a live fingerprint dict — easy to unit test without filesystem access. + +### Integration points (three, minimal) + +**1. Daemon sweep and eBPF path — `daemon.py:fresh_alerts()`** + +```python +store = ReconcileStore.load(self.cfg) +live_fps = _build_live_fps(state) # pulls from already-computed state +alerts = store.filter_alerts(alerts, live_fps) +``` + +`_build_live_fps` is a small helper that assembles the fingerprint dict from `state.fim_baseline`, `state.listener_baseline`, `state.suid_baseline`, and the last pkgfile verify results already held on `self`. + +**2. CLI read views — `cli.py`** + +`fim check` wraps its diff alerts through `filter_alerts` before printing. The three new `baseline` subcommands call `ReconcileStore` methods directly. + +**3. Dashboard APIs — `web.py`** + +`/api/integrity` and alert lists wrap outbound alert lists through `filter_alerts`. No new endpoints needed; acknowledged items simply don't appear (or appear annotated — see `baseline list`). + +### Config + +```python +reconcile_store: str = "" # empty = log_dir/reconciliation.json +``` + +Added to `config.py` alongside other `log_dir`-derived paths. Also added to `config/enodia-sentinel.toml` in the paths/logging block (not the posture block) with a comment. + +### File layout + +``` +enodia_sentinel/reconcile.py new +tests/test_reconcile.py new +enodia_sentinel/config.py +1 field +enodia_sentinel/daemon.py filter wired into fresh_alerts() +enodia_sentinel/cli.py baseline accept/revoke/list subcommands +enodia_sentinel/web.py filter on /api/integrity alert lists +docs/COMMAND_REFERENCE.md new baseline accept/revoke/list section +docs/ROADMAP.md mark v0.9 reconciliation bullet ✅ +config/enodia-sentinel.toml +reconcile_store knob +``` + +No changes to `fim.py`, detector modules, or existing baseline JSON files. + +--- + +## CLI + +### `baseline accept` + +``` +enodia-sentinel baseline accept --reason "..." [--expires ] +``` + +- ``: `fim` | `pkgfile` | `listener` | `suid` +- ``: path or `port/comm` key +- `--reason`: mandatory; hard error to omit +- `--expires`: optional; accepts `Nd`, `Nh`, `Nm` (days/hours/minutes) + +**Guards:** +- If the target has no live data (path not in current FIM scan, listener not currently seen, etc.), prints a warning and requires `--force` to proceed. Prevents accepting something that can't be fingerprinted. +- If an `ok` ack already exists for this target, updates it (new fingerprint, reason, timestamp) and prints a notice. + +### `baseline revoke` + +``` +enodia-sentinel baseline revoke +``` + +Removes the record from the store. Prints "not found" and exits `1` if the target isn't present. + +### `baseline list` + +``` +enodia-sentinel baseline list [--stale] [--json] +``` + +Text output: + +``` +KIND TARGET STATUS ACCEPTED EXPIRES REASON +fim /etc/hosts ok 2026-06-19 14:00 2026-06-26 added internal host +listener 8080/nginx ok 2026-06-19 15:00 - nginx added +fim /etc/cron.d/backup stale 2026-06-18 10:00 - scheduled job +``` + +`--stale` filters to stale/expired records only. Exit code `1` if any stale acks exist (scriptable check). + +--- + +## Error Handling + +| Scenario | Behaviour | +|---|---| +| Store file missing | Treated as empty store; no acks applied | +| Store file corrupt (bad JSON) | Fails closed: no acks applied, daemon logs warning, continues alerting normally | +| Atomic write failure | Old file untouched; error reported to operator | +| `accept` with no live fingerprint data | Warning printed; requires `--force` | +| `revoke` on missing target | Prints "not found", exits `1` | +| `expires` parse error | Hard error with usage hint | + +--- + +## Testing + +**`tests/test_reconcile.py`** (new): + +- `build_fingerprint` for each kind — correct dict shape and field names +- `filter_alerts`: ack matches live fingerprint → alert suppressed +- `filter_alerts`: fingerprint mismatch → alert passes through, ack marked stale in-memory +- `filter_alerts`: expired TTL → alert passes through +- `ReconcileStore.load` on missing file → empty store, no exception +- `ReconcileStore.load` on corrupt JSON → empty store, no exception +- `baseline accept` CLI round-trip: accept a FIM path → store written → `filter_alerts` suppresses it +- `baseline list --stale` exits `1` when stale acks present, `0` when none +- `baseline revoke` removes record; second revoke exits `1` + +**Existing test updates:** + +- `tests/test_daemon.py` (or integration): `fresh_alerts` with a matching ack → alert dropped; with mismatched fingerprint → alert passes +- `tests/test_fim.py`: `fim check` CLI output suppresses acked paths + +--- + +## Docs to update + +- `docs/COMMAND_REFERENCE.md` — new `baseline accept/revoke/list` section +- `docs/ROADMAP.md` — mark v0.9 reconciliation bullet ✅ +- `config/enodia-sentinel.toml` — `reconcile_store` knob with comment