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:
parent
5d577b624f
commit
34bc09041a
13 changed files with 465 additions and 9 deletions
92
enodia_sentinel/selfprotect.py
Normal file
92
enodia_sentinel/selfprotect.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue