Add tamper-evidence: package-DB integrity, self-integrity, dead-man's switch

Answers two hard questions: "what guards the hashes FIM trusts?" and "how do we
make the sensor itself hard to silently disable?" — within the honest limit that
a root attacker who shares your privileges can't be fully stopped on-box, only
made loud.

- pkgdb.py: guards the package DB that `pacman -Qkk` trusts. Anchors a
  fingerprint of /var/lib/pacman/local (refreshed ONLY by the pacman hook) and
  cross-checks pacman.log; a DB change with no logged transaction is flagged
  pkgdb_tamper (CRITICAL, sid 100021) — catches an attacker rewriting a stored
  checksum to mask a modified binary. Verified end-to-end against a simulated
  hash-overwrite.
- selfprotect.py: Sentinel's own binaries/config/units/hook are always in the
  FIM watch set (self-integrity); a heartbeat is written each loop; an external
  `watchdog` command polls a remote dashboard and pushes if the sensor goes
  silent or unreachable — silence becomes the alarm.
- daemon: heartbeat + slow-cadence pkgdb check; DB anchor re-anchored in
  build_fim_baseline so the pacman hook refreshes it after each transaction
- web: /api/status now reports heartbeat_age/stale; dashboard shows it
- cli: pkgdb-check, watchdog (--url/--token/--max-age, bypasses min-severity)
- config: pkgdb_verify/_interval, heartbeat_max_age
- README: threat model (trust-anchor problem) + hardening layers (immutability,
  signed-package + external anchor); reframes "anti-rootkit" as tamper-evidence
- tests: +8 (DB fingerprint, anchor/transaction logic, pacman.log parse,
  heartbeat + watchdog verdicts). 80/80 pass

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-31 22:27:50 -07:00
parent 5d577b624f
commit 34bc09041a
13 changed files with 465 additions and 9 deletions

View file

@ -42,6 +42,7 @@ class Sentinel:
self._last_fim_scan = 0.0
self._fim_pkg_thread: threading.Thread | None = None
self._last_fim_pkg = 0.0
self._last_pkgdb = 0.0
self._stop = threading.Event()
# -- baselines ---------------------------------------------------------
@ -90,12 +91,30 @@ class Sentinel:
# -- FIM (off the loop thread) -----------------------------------------
def build_fim_baseline(self) -> int:
from . import pkgdb
from .fim import scan_paths
self.fim_baseline = scan_paths(self.cfg.fim_path_list())
self.cfg.log_dir.mkdir(parents=True, exist_ok=True)
self.cfg.fim_baseline.write_text(json.dumps(self.fim_baseline))
# Re-anchor the package DB here too, so the pacman hook (which calls
# fim-update) refreshes both after every legitimate transaction.
pkgdb.save_anchor(self.cfg)
return len(self.fim_baseline)
def _maybe_check_pkgdb(self, now: float) -> None:
if not self.cfg.pkgdb_verify:
return
if (now - self._last_pkgdb) < self.cfg.pkgdb_interval:
return
self._last_pkgdb = now
threading.Thread(target=self._check_pkgdb, daemon=True).start()
def _check_pkgdb(self) -> None:
from . import pkgdb
alert = pkgdb.check(self.cfg)
if alert:
self._on_exec_alert(alert)
def load_fim_baseline(self) -> None:
try:
self.fim_baseline = json.loads(self.cfg.fim_baseline.read_text())
@ -195,10 +214,13 @@ class Sentinel:
sweeps = 0
while not self._stop.is_set():
now = time.time()
from .selfprotect import write_heartbeat
write_heartbeat(self.cfg) # dead-man's switch
if (now - self.start_time) >= self.cfg.baseline_grace:
self._maybe_scan_suid(now)
self._maybe_scan_fim(now)
self._maybe_pkg_verify(now)
self._maybe_check_pkgdb(now)
alerts = self.sweep()
fresh = self.fresh_alerts(alerts, now)
if fresh: