95 lines
3.5 KiB
Python
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"
|