# 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