enodia-sentinal/enodia_sentinel/selfprotect.py

95 lines
3.5 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,
verify_tls: bool = True) -> dict | None:
"""Fetch a remote dashboard's /api/status. None if unreachable."""
import json
import ssl
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:
ctx = None if verify_tls else ssl._create_unverified_context()
with urllib.request.urlopen(req, timeout=timeout, context=ctx) 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"