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

@ -0,0 +1,92 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tamper-evidence for Sentinel itself.
An attacker's first move against a security tool is to disable or blind it. We
can't make the agent un-killable on a box where the attacker is root — but we
can make killing or modifying it *loud*:
* **self-integrity** Sentinel's own binaries, config, units, and pacman hook
are added to the FIM watch set, so tampering with the watchdog trips the
watchdog.
* **dead-man's switch** — the daemon writes a heartbeat every loop; an external
watcher (on another host, over Tailscale) polls the dashboard and alerts if
the heartbeat goes stale or the dashboard becomes unreachable. Silence is the
alarm.
The durable posture is tamper-*evidence*, not stealth: don't hide, be
un-hideable. For real teeth, make these files immutable (`chattr +i`) and keep
the heartbeat watcher on a separate machine.
"""
from __future__ import annotations
import time
from .config import Config
# Sentinel's own on-disk footprint (both /usr and /usr/local installs); missing
# paths are simply skipped by the FIM scanner.
SELF_PATHS = (
"/usr/bin/enodia-sentinel", "/usr/local/bin/enodia-sentinel",
"/usr/bin/sentinel-redteam", "/usr/local/bin/sentinel-redteam",
"/usr/lib/enodia-sentinel", "/usr/local/lib/enodia-sentinel",
"/etc/enodia-sentinel.toml",
"/etc/systemd/system/enodia-sentinel.service",
"/etc/systemd/system/enodia-sentinel-web.service",
"/usr/lib/systemd/system/enodia-sentinel.service",
"/usr/lib/systemd/system/enodia-sentinel-web.service",
"/etc/pacman.d/hooks/enodia-sentinel-fim.hook",
"/usr/share/libalpm/hooks/enodia-sentinel-fim.hook",
)
def heartbeat_path(cfg: Config):
return cfg.log_dir / "heartbeat"
def write_heartbeat(cfg: Config) -> None:
try:
heartbeat_path(cfg).write_text(str(int(time.time())))
except OSError:
pass
def read_heartbeat(cfg: Config) -> int | None:
try:
return int(heartbeat_path(cfg).read_text().strip())
except (OSError, ValueError):
return None
def heartbeat_age(cfg: Config) -> float | None:
hb = read_heartbeat(cfg)
return None if hb is None else max(0.0, time.time() - hb)
# --- external dead-man's-switch watcher -----------------------------------
def poll_status(url: str, token: str = "", timeout: int = 8) -> dict | None:
"""Fetch a remote dashboard's /api/status. None if unreachable."""
import json
import urllib.request
base = url.rstrip("/")
api = base if base.endswith("/api/status") else base + "/api/status"
req = urllib.request.Request(api)
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())
except Exception:
return None
def watchdog_verdict(status: dict | None, max_age: float) -> tuple[bool, str]:
"""Return (ok, message). Runs on the *watcher* side, against poll_status()."""
if status is None:
return False, "Sentinel UNREACHABLE — dashboard down or host offline"
if not status.get("running"):
return False, "Sentinel daemon NOT running on the target host"
age = status.get("heartbeat_age")
if age is not None and age > max_age:
return False, f"Sentinel heartbeat STALE ({int(age)}s > {int(max_age)}s)"
return True, "ok"