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>
343 lines
13 KiB
Python
343 lines
13 KiB
Python
# 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)
|