# 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"