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
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -63,6 +63,13 @@ Implemented investigation/response state:
|
|||
- `rules docs` emits generated Markdown rule docs; `docs/RULES.md` is the
|
||||
checked-in built-in rule reference with match fields, false positives, and
|
||||
drill guidance.
|
||||
- `baseline accept/revoke/list` reconciles legitimate FIM/package/listener/SUID
|
||||
drift: an operator accepts an identified item with a mandatory reason, stored
|
||||
fingerprint-bound and audited in `reconciliation.json`. Acks suppress the
|
||||
alert only while live state matches; content kinds (fim/pkgfile) re-alert on
|
||||
further change, identity kinds (listener/suid) retire on TTL or revoke. The
|
||||
filter runs at the daemon chokepoint, `fim-check`, and the dashboard
|
||||
integrity API.
|
||||
- IPS behavior is currently explicit workflow: posture hardening plus reviewed
|
||||
dry-run containment actions. There is no automatic inline blocking or silent
|
||||
host mutation yet.
|
||||
|
|
@ -70,7 +77,6 @@ Implemented investigation/response state:
|
|||
Near-term open work:
|
||||
|
||||
- Audited `--apply` response execution with tests and rollback notes.
|
||||
- Baseline reconciliation for legitimate FIM/package/listener/SUID drift.
|
||||
- Fixture or red-team drill coverage for every built-in SID, including
|
||||
event-only and future correlation SIDs.
|
||||
- More event sources and correlation across exec, network, persistence, FIM,
|
||||
|
|
@ -123,6 +129,8 @@ Start with:
|
|||
promiscuous interfaces, known LKM rootkit module names, and kernel/module
|
||||
taint.
|
||||
- `posture.py` performs advisory host hygiene checks.
|
||||
- `reconcile.py` owns the acknowledged-drift store and the `filter_alerts`
|
||||
chokepoint that suppresses operator-accepted FIM/package/listener/SUID drift.
|
||||
|
||||
## Current Threat Mapping
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Project docs:
|
|||
- [Packaging guide](docs/PACKAGING.md) — Arch package layout plus Debian/RPM
|
||||
source-install paths.
|
||||
- [JSON schemas](docs/SCHEMAS.md) — stable v1 contracts for alerts, incidents,
|
||||
status, response plans, and response audit records.
|
||||
status, response plans, response audit records, and reconciliation acks.
|
||||
- [IR runbooks](docs/RUNBOOKS.md) — confirm/preserve/contain/recover playbooks
|
||||
per alert: reverse shells, persistence, trojaned binaries, rootkits, tampering.
|
||||
- [Rule reference](docs/RULES.md) — generated event-rule documentation: SID,
|
||||
|
|
|
|||
|
|
@ -168,6 +168,12 @@ exec_rules_file = ""
|
|||
# Add a 3s bpftrace execve trace to each snapshot (requires the bpftrace pkg).
|
||||
capture_execve_bpftrace = false
|
||||
|
||||
# --- baseline reconciliation ---------------------------------------------
|
||||
# Store for acknowledged drift accepted via `baseline accept` (FIM/package/
|
||||
# listener/SUID). Each ack is fingerprint-bound and audited; content kinds
|
||||
# re-alert if the file changes again. Empty = log_dir/reconciliation.json.
|
||||
reconcile_store = ""
|
||||
|
||||
# --- retention -----------------------------------------------------------
|
||||
max_snapshots = 300 # auto-delete oldest beyond this count (0 = no cap)
|
||||
max_snapshot_age_days = 60 # auto-delete older than this (0 = no age cap)
|
||||
|
|
|
|||
|
|
@ -59,6 +59,39 @@ helpers.
|
|||
|
||||
Do not run this to silence unexplained findings.
|
||||
|
||||
### `baseline accept` / `revoke` / `list`
|
||||
|
||||
```bash
|
||||
enodia-sentinel baseline accept <kind> <target> --reason "..." [--expires 7d] [--force]
|
||||
enodia-sentinel baseline revoke <kind> <target>
|
||||
enodia-sentinel baseline list [--stale] [--json]
|
||||
```
|
||||
|
||||
Baseline *reconciliation*: accept a specific, identified drift item with a
|
||||
mandatory reason instead of silently folding it into a baseline. `<kind>` is one
|
||||
of `fim`, `pkgfile`, `listener`, or `suid`; `<target>` is a path (FIM/package/
|
||||
SUID) or a `port/comm` key (listener).
|
||||
|
||||
An acknowledgement suppresses that one alert **only while the live state still
|
||||
matches the accepted fingerprint**. `fim` and `pkgfile` acks are content-bound:
|
||||
when the file changes again the ack goes *stale* and the alert returns. `listener`
|
||||
and `suid` acks are identity-bound, so only `--expires` (TTL) or an explicit
|
||||
`revoke` retires them. Each record stores who accepted it, when, why, and the
|
||||
fingerprint, in `log_dir/reconciliation.json` (override with `reconcile_store`).
|
||||
|
||||
- `accept` refuses to record drift it cannot observe live (e.g. a listener not
|
||||
currently bound); pass `--force` to accept anyway — required for accepting a
|
||||
*removed* file. Omitting `--reason` is a hard error.
|
||||
- `--expires` accepts `Nd` / `Nh` / `Nm` (days/hours/minutes).
|
||||
- `revoke` exits `1` if the target isn't present.
|
||||
- `list` shows every ack; `--stale` filters to stale/expired ones. It exits `1`
|
||||
whenever any acknowledgement is stale (a scriptable drift check) and re-derives
|
||||
FIM staleness against live files.
|
||||
|
||||
Reconciled drift is dropped at the daemon detection chokepoint, hidden from
|
||||
`fim-check`, and summarised in the dashboard integrity view, so an acknowledged
|
||||
item stops alerting everywhere until its fingerprint changes or its TTL lapses.
|
||||
|
||||
## Detection and Integrity Commands
|
||||
|
||||
### `list-detectors`
|
||||
|
|
|
|||
|
|
@ -145,6 +145,13 @@ Baselines are useful only if they represent a known-good state.
|
|||
- Use `fim-update` only after confirming changes are legitimate. Package hooks
|
||||
already run it for normal package transactions.
|
||||
- Keep a copy of important anchors off-box when possible.
|
||||
- Prefer `baseline accept <kind> <target> --reason "..."` over rebuilding a whole
|
||||
baseline when only one item changed legitimately (an edited config, a new
|
||||
service port, an installed SUID helper). Unlike a rebuild, the acceptance is
|
||||
per-item, carries a recorded reason, is fingerprint-bound (FIM/package items
|
||||
re-alert if the file changes *again*), and can be time-boxed with `--expires`.
|
||||
Review outstanding acceptances with `baseline list`; it exits non-zero and
|
||||
marks rows `stale` when an accepted item has drifted again or its TTL lapsed.
|
||||
|
||||
## Suggested Hardening
|
||||
|
||||
|
|
|
|||
|
|
@ -71,8 +71,11 @@ Purpose: move from "tell me" to "help me act" without unsafe automation.
|
|||
semantics are designed for pacman, dpkg, and rpm.
|
||||
- Require explicit `--apply` for changes; default to read-only plans.
|
||||
- Extend response audit logs to any future state-changing `--apply` workflow.
|
||||
- Add baseline reconciliation:
|
||||
accept legitimate FIM/package/listener/SUID changes with a recorded reason.
|
||||
- ✅ Add baseline reconciliation: accept legitimate FIM/package/listener/SUID
|
||||
drift with a mandatory reason via `baseline accept/revoke/list`. Acks are
|
||||
fingerprint-bound (content kinds re-alert on further change), support an
|
||||
optional TTL, and are filtered at the daemon chokepoint, `fim-check`, and the
|
||||
dashboard integrity API.
|
||||
- Add richer false-positive suppression suggestions that can emit TOML snippets
|
||||
but never modify config automatically.
|
||||
- Add recovery checks:
|
||||
|
|
|
|||
|
|
@ -147,3 +147,33 @@ Required fields:
|
|||
| `apply_supported` | boolean | Whether the plan supports apply. |
|
||||
| `action_count` | integer | Number of proposed actions. |
|
||||
| `plan_path` | string | Persisted plan artifact path. |
|
||||
|
||||
## `enodia.reconcile.v1`
|
||||
|
||||
Acknowledged-drift store written by `baseline accept/revoke/list`
|
||||
(`log_dir/reconciliation.json`, overridable via `reconcile_store`) and emitted
|
||||
by `baseline list --json`.
|
||||
|
||||
Top-level fields:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `schema` | string | `enodia.reconcile.v1`. |
|
||||
| `records` | array | Acknowledgement records (below). |
|
||||
|
||||
Each record:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `kind` | string | `fim`, `pkgfile`, `listener`, or `suid`. |
|
||||
| `target` | string | Path, or `port/comm` key for listeners. |
|
||||
| `fingerprint` | object | Kind-specific identity compared each sweep. |
|
||||
| `reason` | string | Mandatory operator justification. |
|
||||
| `actor` | string | Who recorded the acceptance. |
|
||||
| `accepted_at` | string | ISO-8601 UTC acceptance time. |
|
||||
| `status` | string | `ok` or `stale`. |
|
||||
| `expires_at` | string | ISO-8601 UTC TTL; absent when no TTL. |
|
||||
|
||||
The dashboard `enodia.integrity.v1` payload also carries a `reconciliation`
|
||||
summary object (`total`, `ok`, `stale`, `stale_items`) and a `reconciliation`
|
||||
entry under `checks`.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
343
enodia_sentinel/reconcile.py
Normal file
343
enodia_sentinel/reconcile.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
218
tests/test_reconcile.py
Normal file
218
tests/test_reconcile.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tests for baseline reconciliation: fingerprints, store I/O, alert filtering."""
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import reconcile
|
||||
from enodia_sentinel.alert import Alert, Severity
|
||||
from enodia_sentinel.config import Config
|
||||
|
||||
|
||||
def _cfg(tmp: Path) -> Config:
|
||||
cfg = Config()
|
||||
cfg.log_dir = tmp
|
||||
return cfg
|
||||
|
||||
|
||||
def _alert(key: str, sig: str = "x") -> Alert:
|
||||
return Alert(Severity.HIGH, sig, key, "detail")
|
||||
|
||||
|
||||
class TestAlertIdentity(unittest.TestCase):
|
||||
def test_fim_modified_added_removed_map_to_fim(self):
|
||||
for prefix in ("fim:mod:", "fim:add:", "fim:del:"):
|
||||
kind, target = reconcile.alert_identity(prefix + "/etc/hosts")
|
||||
self.assertEqual((kind, target), ("fim", "/etc/hosts"))
|
||||
|
||||
def test_pkgfile_key(self):
|
||||
self.assertEqual(reconcile.alert_identity("fim:pkg:/usr/bin/ssh"),
|
||||
("pkgfile", "/usr/bin/ssh"))
|
||||
|
||||
def test_listener_key(self):
|
||||
self.assertEqual(reconcile.alert_identity("lis:8080/nginx"),
|
||||
("listener", "8080/nginx"))
|
||||
|
||||
def test_suid_key(self):
|
||||
self.assertEqual(reconcile.alert_identity("suid:/usr/bin/sudo"),
|
||||
("suid", "/usr/bin/sudo"))
|
||||
|
||||
def test_unknown_key_is_passthrough(self):
|
||||
self.assertEqual(reconcile.alert_identity("rsh:1234"), (None, None))
|
||||
|
||||
|
||||
class TestParseDuration(unittest.TestCase):
|
||||
def test_days_hours_minutes(self):
|
||||
self.assertEqual(reconcile.parse_duration("7d"), timedelta(days=7))
|
||||
self.assertEqual(reconcile.parse_duration("12h"), timedelta(hours=12))
|
||||
self.assertEqual(reconcile.parse_duration("30m"), timedelta(minutes=30))
|
||||
|
||||
def test_invalid_raises(self):
|
||||
for bad in ("", "7", "d", "7x", "-1d", "1.5h"):
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.parse_duration(bad)
|
||||
|
||||
|
||||
class TestBuildFingerprint(unittest.TestCase):
|
||||
def test_fim_extracts_content_fields_only(self):
|
||||
live = {"mode": 33188, "uid": 0, "gid": 0, "size": 12,
|
||||
"sha256": "abc123"}
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/hosts", live)
|
||||
self.assertEqual(fp, {"sha256": "abc123", "mode": 33188,
|
||||
"uid": 0, "gid": 0})
|
||||
|
||||
def test_fim_absent_target_is_all_none(self):
|
||||
fp = reconcile.build_fingerprint("fim", "/gone", None)
|
||||
self.assertEqual(fp, {"sha256": None, "mode": None,
|
||||
"uid": None, "gid": None})
|
||||
|
||||
def test_pkgfile_sorts_reasons(self):
|
||||
fp = reconcile.build_fingerprint(
|
||||
"pkgfile", "/usr/bin/ssh", ["size mismatch", "checksum mismatch"])
|
||||
self.assertEqual(fp, {"reasons": ["checksum mismatch", "size mismatch"]})
|
||||
|
||||
def test_listener_and_suid_are_identity(self):
|
||||
self.assertEqual(
|
||||
reconcile.build_fingerprint("listener", "8080/nginx", None),
|
||||
{"key": "8080/nginx"})
|
||||
self.assertEqual(
|
||||
reconcile.build_fingerprint("suid", "/usr/bin/sudo", None),
|
||||
{"path": "/usr/bin/sudo"})
|
||||
|
||||
|
||||
class TestStoreRoundTrip(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_missing_file_is_empty_store(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
self.assertEqual(store.list(), [])
|
||||
|
||||
def test_corrupt_json_fails_closed_to_empty(self):
|
||||
reconcile.ReconcileStore.store_path(self.cfg).write_text("{not json")
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
self.assertEqual(store.list(), [])
|
||||
|
||||
def test_accept_persists_and_reloads(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/hosts",
|
||||
{"sha256": "a", "mode": 1, "uid": 0, "gid": 0})
|
||||
store.accept("fim", "/etc/hosts", fp, "added host entry", "luna")
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
again = reconcile.ReconcileStore.load(self.cfg)
|
||||
recs = again.list()
|
||||
self.assertEqual(len(recs), 1)
|
||||
self.assertEqual(recs[0].target, "/etc/hosts")
|
||||
self.assertEqual(recs[0].reason, "added host entry")
|
||||
self.assertEqual(recs[0].actor, "luna")
|
||||
self.assertEqual(recs[0].status, "ok")
|
||||
|
||||
def test_accept_existing_updates_returns_true(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
fp = {"key": "8080/nginx"}
|
||||
self.assertFalse(store.accept("listener", "8080/nginx", fp, "r1", "luna"))
|
||||
self.assertTrue(store.accept("listener", "8080/nginx", fp, "r2", "luna"))
|
||||
self.assertEqual(len(store.list()), 1)
|
||||
|
||||
def test_revoke_removes_and_reports_missing(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
store.accept("suid", "/usr/bin/sudo", {"path": "/usr/bin/sudo"}, "r", "luna")
|
||||
self.assertTrue(store.revoke("suid", "/usr/bin/sudo"))
|
||||
self.assertFalse(store.revoke("suid", "/usr/bin/sudo"))
|
||||
self.assertEqual(store.list(), [])
|
||||
|
||||
|
||||
class TestFilterAlerts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
self.store = reconcile.ReconcileStore.load(self.cfg)
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_unacked_alert_passes_through(self):
|
||||
out = self.store.filter_alerts([_alert("fim:mod:/etc/hosts")], {})
|
||||
self.assertEqual(len(out), 1)
|
||||
|
||||
def test_matching_fim_ack_suppresses(self):
|
||||
live = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0, "size": 9}
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/hosts", live)
|
||||
self.store.accept("fim", "/etc/hosts", fp, "edit", "luna")
|
||||
out = self.store.filter_alerts(
|
||||
[_alert("fim:mod:/etc/hosts")], {"fim": {"/etc/hosts": live}})
|
||||
self.assertEqual(out, [])
|
||||
|
||||
def test_changed_fim_passes_and_marks_stale(self):
|
||||
accepted = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0}
|
||||
self.store.accept("fim", "/etc/hosts", accepted, "edit", "luna")
|
||||
changed = {"sha256": "DIFFERENT", "mode": 33188, "uid": 0, "gid": 0}
|
||||
out = self.store.filter_alerts(
|
||||
[_alert("fim:mod:/etc/hosts")], {"fim": {"/etc/hosts": changed}})
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(self.store.list()[0].status, "stale")
|
||||
|
||||
def test_accepted_removal_stays_suppressed_when_absent(self):
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/gone", None)
|
||||
self.store.accept("fim", "/etc/gone", fp, "decommissioned", "luna")
|
||||
out = self.store.filter_alerts(
|
||||
[_alert("fim:del:/etc/gone")], {"fim": {}})
|
||||
self.assertEqual(out, [])
|
||||
|
||||
def test_expired_ttl_passes_and_marks_stale(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
self.store.accept("listener", "8080/nginx", {"key": "8080/nginx"},
|
||||
"temp", "luna", expires_at=past)
|
||||
out = self.store.filter_alerts([_alert("lis:8080/nginx")], {})
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(self.store.list()[0].status, "stale")
|
||||
|
||||
def test_listener_ack_suppresses_within_ttl(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
self.store.accept("listener", "8080/nginx", {"key": "8080/nginx"},
|
||||
"temp", "luna", expires_at=future)
|
||||
out = self.store.filter_alerts([_alert("lis:8080/nginx")], {})
|
||||
self.assertEqual(out, [])
|
||||
|
||||
|
||||
class TestListAndStale(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_list_stale_only_filters(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
future = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
store.accept("listener", "1/a", {"key": "1/a"}, "ok", "luna",
|
||||
expires_at=future)
|
||||
store.accept("listener", "2/b", {"key": "2/b"}, "exp", "luna",
|
||||
expires_at=past)
|
||||
stale = store.list(stale_only=True)
|
||||
self.assertEqual([r.target for r in stale], ["2/b"])
|
||||
|
||||
def test_list_persists_stale_transition(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
store.accept("listener", "2/b", {"key": "2/b"}, "exp", "luna",
|
||||
expires_at=past)
|
||||
store.list() # triggers TTL re-evaluation + lazy write-back
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
reloaded = reconcile.ReconcileStore.load(self.cfg)
|
||||
self.assertEqual(reloaded.list()[0].status, "stale")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
125
tests/test_reconcile_cli.py
Normal file
125
tests/test_reconcile_cli.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tests for the `baseline accept/revoke/list` CLI surface."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import reconcile
|
||||
from enodia_sentinel.cli import main
|
||||
|
||||
|
||||
def _run(*argv) -> tuple[int, str, str]:
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
code = main(list(argv))
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
|
||||
class BaselineCLITest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self._old = os.environ.get("ENODIA_LOG_DIR")
|
||||
os.environ["ENODIA_LOG_DIR"] = self.d.name
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def tearDown(self):
|
||||
if self._old is None:
|
||||
os.environ.pop("ENODIA_LOG_DIR", None)
|
||||
else:
|
||||
os.environ["ENODIA_LOG_DIR"] = self._old
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def _store(self):
|
||||
from enodia_sentinel.config import Config
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
return reconcile.ReconcileStore.load(Config.load())
|
||||
|
||||
def test_accept_fim_path_round_trips(self):
|
||||
f = Path(self.d.name) / "watched.conf"
|
||||
f.write_text("hello")
|
||||
code, _out, _err = _run("baseline", "accept", "fim", str(f),
|
||||
"--reason", "operator edit")
|
||||
self.assertEqual(code, 0)
|
||||
recs = self._store().list()
|
||||
self.assertEqual(len(recs), 1)
|
||||
self.assertEqual(recs[0].kind, "fim")
|
||||
self.assertEqual(recs[0].reason, "operator edit")
|
||||
self.assertIn("sha256", recs[0].fingerprint)
|
||||
|
||||
def test_accept_requires_reason(self):
|
||||
f = Path(self.d.name) / "x"
|
||||
f.write_text("y")
|
||||
code, _out, err = _run("baseline", "accept", "fim", str(f))
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("reason", err.lower())
|
||||
|
||||
def test_accept_missing_live_data_requires_force(self):
|
||||
code, _out, err = _run("baseline", "accept", "listener", "65000/ghost",
|
||||
"--reason", "temp")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("force", err.lower())
|
||||
self.assertEqual(self._store().list(), [])
|
||||
|
||||
def test_accept_force_overrides_missing_live_data(self):
|
||||
code, _out, _err = _run("baseline", "accept", "listener",
|
||||
"65000/ghost", "--reason", "temp", "--force")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(len(self._store().list()), 1)
|
||||
|
||||
def test_accept_invalid_expires_errors(self):
|
||||
code, _out, err = _run("baseline", "accept", "listener", "1/a",
|
||||
"--reason", "r", "--force", "--expires", "soon")
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("duration", err.lower())
|
||||
|
||||
def test_revoke_removes_then_reports_missing(self):
|
||||
_run("baseline", "accept", "listener", "1/a", "--reason", "r", "--force")
|
||||
code, _o, _e = _run("baseline", "revoke", "listener", "1/a")
|
||||
self.assertEqual(code, 0)
|
||||
code2, _o2, err2 = _run("baseline", "revoke", "listener", "1/a")
|
||||
self.assertEqual(code2, 1)
|
||||
self.assertIn("not found", err2.lower())
|
||||
|
||||
def test_list_json_emits_records(self):
|
||||
f = Path(self.d.name) / "c.conf"
|
||||
f.write_text("z")
|
||||
_run("baseline", "accept", "fim", str(f), "--reason", "edit")
|
||||
code, out, _err = _run("baseline", "list", "--json")
|
||||
self.assertEqual(code, 0)
|
||||
data = json.loads(out)
|
||||
self.assertEqual(len(data["records"]), 1)
|
||||
self.assertEqual(data["records"][0]["kind"], "fim")
|
||||
|
||||
def test_list_stale_exit_code(self):
|
||||
# no acks -> exit 0
|
||||
code, _o, _e = _run("baseline", "list", "--stale")
|
||||
self.assertEqual(code, 0)
|
||||
# an expired ack -> exit 1
|
||||
_run("baseline", "accept", "listener", "1/a", "--reason", "r",
|
||||
"--force", "--expires", "0m")
|
||||
code2, _o2, _e2 = _run("baseline", "list", "--stale")
|
||||
self.assertEqual(code2, 1)
|
||||
|
||||
def test_bare_baseline_still_builds(self):
|
||||
# The legacy `baseline` (no action) must keep routing to the rebuild
|
||||
# path. Stub the expensive filesystem scan; we only assert the dispatch.
|
||||
from enodia_sentinel import cli
|
||||
called = []
|
||||
orig = cli.Sentinel.build_baselines
|
||||
cli.Sentinel.build_baselines = lambda self: called.append(True)
|
||||
try:
|
||||
code, out, _err = _run("baseline")
|
||||
finally:
|
||||
cli.Sentinel.build_baselines = orig
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(called)
|
||||
self.assertIn("Baselines written", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
137
tests/test_reconcile_integration.py
Normal file
137
tests/test_reconcile_integration.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Reconciliation wired into the daemon chokepoint, fim-check, and the web API."""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import fim, reconcile, web
|
||||
from enodia_sentinel.alert import Alert, Severity
|
||||
from enodia_sentinel.cli import _cmd_fim_check
|
||||
from enodia_sentinel.config import Config
|
||||
from enodia_sentinel.daemon import Sentinel
|
||||
|
||||
|
||||
def _cfg(tmp: Path) -> Config:
|
||||
cfg = Config()
|
||||
cfg.log_dir = tmp
|
||||
return cfg
|
||||
|
||||
|
||||
class DaemonChokepointTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def _accept(self, kind, target, fp, **kw):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
store.accept(kind, target, fp, "reason", "luna", **kw)
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_unacked_alert_passes_through(self):
|
||||
s = Sentinel(self.cfg)
|
||||
a = Alert(Severity.HIGH, "new_listener", "lis:8080/nginx", "d")
|
||||
self.assertEqual(len(s.fresh_alerts([a], time.time())), 1)
|
||||
|
||||
def test_acked_listener_is_dropped(self):
|
||||
self._accept("listener", "8080/nginx", {"key": "8080/nginx"})
|
||||
s = Sentinel(self.cfg)
|
||||
a = Alert(Severity.HIGH, "new_listener", "lis:8080/nginx", "d")
|
||||
self.assertEqual(s.fresh_alerts([a], time.time()), [])
|
||||
|
||||
def test_acked_fim_dropped_only_while_fingerprint_matches(self):
|
||||
live = {"sha256": "a", "mode": 33188, "uid": 0, "gid": 0, "size": 4}
|
||||
fp = reconcile.build_fingerprint("fim", "/etc/hosts", live)
|
||||
self._accept("fim", "/etc/hosts", fp)
|
||||
s = Sentinel(self.cfg)
|
||||
a = Alert(Severity.HIGH, "fim_modified", "fim:mod:/etc/hosts", "d")
|
||||
# matching live state -> suppressed
|
||||
self.assertEqual(
|
||||
s.fresh_alerts([a], time.time(), {"fim": {"/etc/hosts": live}}), [])
|
||||
# changed again -> re-alerts
|
||||
s2 = Sentinel(self.cfg)
|
||||
changed = dict(live, sha256="b")
|
||||
self.assertEqual(
|
||||
len(s2.fresh_alerts([a], time.time(), {"fim": {"/etc/hosts": changed}})), 1)
|
||||
|
||||
|
||||
class FimCheckSuppressionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self.d.name)
|
||||
self.watch = self.tmp / "watched"
|
||||
self.watch.mkdir()
|
||||
self.cfg = _cfg(self.tmp)
|
||||
self.cfg.fim_paths = (str(self.watch),)
|
||||
self.cfg.fim_pkg_verify = False
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def _baseline(self):
|
||||
# Seed a baseline directly so the command doesn't shell out to pacman.
|
||||
base = fim.scan_paths(self.cfg.fim_path_list())
|
||||
self.cfg.fim_baseline.write_text(__import__("json").dumps(base))
|
||||
|
||||
def _fim_check(self):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
code = _cmd_fim_check(self.cfg, packages=False)
|
||||
return code, out.getvalue()
|
||||
|
||||
def test_modified_path_reported_then_suppressed_after_accept(self):
|
||||
f = self.watch / "app.conf"
|
||||
f.write_text("v1")
|
||||
self._baseline()
|
||||
f.write_text("v2-tampered")
|
||||
code, out = self._fim_check()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn(str(f), out)
|
||||
# accept the drift -> the path drops out of fim-check
|
||||
live = fim.scan_paths([str(f)]).get(str(f))
|
||||
fp = reconcile.build_fingerprint("fim", str(f), live)
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
store.accept("fim", str(f), fp, "rolled out v2", "luna")
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
code2, out2 = self._fim_check()
|
||||
self.assertEqual(code2, 0)
|
||||
self.assertNotIn(str(f), out2)
|
||||
|
||||
|
||||
class WebReconciliationBlockTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.TemporaryDirectory()
|
||||
self.cfg = _cfg(Path(self.d.name))
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def tearDown(self):
|
||||
self.d.cleanup()
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
|
||||
def test_integrity_report_has_reconciliation_summary(self):
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
past = reconcile._utcnow() - __import__("datetime").timedelta(days=1)
|
||||
store.accept("listener", "1/a", {"key": "1/a"}, "ok now", "luna",
|
||||
expires_at=reconcile._utcnow() + __import__("datetime").timedelta(days=1))
|
||||
store.accept("listener", "2/b", {"key": "2/b"}, "expired", "luna",
|
||||
expires_at=past)
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
report = web.integrity_report(self.cfg)
|
||||
recon = report["reconciliation"]
|
||||
self.assertEqual(recon["total"], 2)
|
||||
self.assertEqual(recon["stale"], 1)
|
||||
self.assertEqual(report["checks"]["reconciliation"], "review")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -9,7 +9,7 @@ from io import StringIO
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from enodia_sentinel import incident, respond, schemas, snapshot, web
|
||||
from enodia_sentinel import incident, reconcile, respond, schemas, snapshot, web
|
||||
from enodia_sentinel.alert import Alert, Severity
|
||||
from enodia_sentinel.cli import main
|
||||
from enodia_sentinel.config import Config
|
||||
|
|
@ -77,6 +77,27 @@ class TestSchemaContracts(unittest.TestCase):
|
|||
"pids": list,
|
||||
})
|
||||
|
||||
def test_reconcile_v1_contract(self):
|
||||
self.assertEqual(schemas.RECONCILE_V1, "enodia.reconcile.v1")
|
||||
store = reconcile.ReconcileStore.load(self.cfg)
|
||||
store.accept("fim", "/etc/hosts",
|
||||
{"sha256": "a", "mode": 1, "uid": 0, "gid": 0},
|
||||
"operator edit", "luna")
|
||||
reconcile.ReconcileStore.invalidate_cache()
|
||||
data = json.loads(
|
||||
reconcile.ReconcileStore.store_path(self.cfg).read_text())
|
||||
self.assertEqual(data["schema"], schemas.RECONCILE_V1)
|
||||
_assert_types(self, data, {"schema": str, "records": list})
|
||||
_assert_types(self, data["records"][0], {
|
||||
"kind": str,
|
||||
"target": str,
|
||||
"fingerprint": dict,
|
||||
"reason": str,
|
||||
"actor": str,
|
||||
"accepted_at": str,
|
||||
"status": str,
|
||||
})
|
||||
|
||||
def test_alert_snapshot_v1_contract(self):
|
||||
with patch("enodia_sentinel.snapshot._run", return_value=""):
|
||||
path = snapshot.capture([_alert()], self._State(), self.cfg)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue