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>
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
# 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"
|