enodia-sentinal/enodia_sentinel/web.py
Luna 34bc09041a 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>
2026-05-31 22:27:50 -07:00

248 lines
8 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only web dashboard, served by the stdlib http.server (zero deps).
Exposes a small JSON API over the log directory plus a single self-contained
page. It is deliberately read-only — no actions, no writes — because it serves
sensitive forensic data. By default it binds the host's Tailscale address and
requires a bearer token; on loopback the token is optional.
"""
from __future__ import annotations
import hmac
import json
import os
import re
import secrets
import subprocess
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from . import __version__
from .config import Config
_STATIC = Path(__file__).parent / "static"
_ALERT_NAME = re.compile(r"^alert-\d{8}-\d{6}\.(json|log)$")
# --- network helpers -------------------------------------------------------
def detect_tailscale_ip() -> str | None:
"""Best-effort Tailscale IPv4 of this host."""
try:
out = subprocess.run(["tailscale", "ip", "-4"], capture_output=True,
text=True, timeout=4).stdout.strip().splitlines()
if out and out[0]:
return out[0].strip()
except (OSError, subprocess.SubprocessError):
pass
try:
out = subprocess.run(["ip", "-o", "-4", "addr", "show", "tailscale0"],
capture_output=True, text=True, timeout=4).stdout
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", out)
if m:
return m.group(1)
except (OSError, subprocess.SubprocessError):
pass
return None
def resolve_bind(cfg: Config) -> str:
if cfg.web_bind:
return cfg.web_bind
return detect_tailscale_ip() or "127.0.0.1"
def is_loopback(addr: str) -> bool:
return addr.startswith("127.") or addr in ("::1", "localhost")
def ensure_token(cfg: Config) -> str:
if cfg.web_token:
return cfg.web_token
path = cfg.log_dir / ".web_token"
try:
if path.is_file():
tok = path.read_text().strip()
if tok:
return tok
except OSError:
pass
tok = secrets.token_urlsafe(24)
try:
cfg.log_dir.mkdir(parents=True, exist_ok=True)
path.write_text(tok)
path.chmod(0o600)
except OSError:
pass
return tok
# --- data layer (pure, testable) ------------------------------------------
def list_alerts(cfg: Config, limit: int = 200) -> list[dict]:
out = []
for p in sorted(cfg.log_dir.glob("alert-*.json"), reverse=True)[:limit]:
try:
d = json.loads(p.read_text())
except (OSError, ValueError):
continue
out.append({
"name": p.with_suffix(".log").name,
"time": d.get("time", ""),
"host": d.get("host", ""),
"severity": d.get("severity", ""),
"signatures": sorted({a.get("signature", "") for a in d.get("alerts", [])}),
"sids": sorted({a.get("sid", 0) for a in d.get("alerts", []) if a.get("sid")}),
"count": len(d.get("alerts", [])),
})
return out
def get_alert(cfg: Config, name: str) -> dict | None:
if not _ALERT_NAME.match(name):
return None
base = cfg.log_dir / name
jpath = base.with_suffix(".json")
try:
data = {"json": json.loads(jpath.read_text())} if jpath.is_file() else {}
lpath = base.with_suffix(".log")
if lpath.is_file():
data["text"] = lpath.read_text()
return data or None
except (OSError, ValueError):
return None
def tail_events(cfg: Config, limit: int = 100) -> list[str]:
try:
lines = cfg.events_log.read_text().splitlines()
except OSError:
return []
return lines[-limit:]
def daemon_status(cfg: Config) -> dict:
pid_alive = False
pidfile = cfg.log_dir / "sentinel.pid"
try:
pid = int(pidfile.read_text().strip())
os.kill(pid, 0)
pid_alive = True
except (OSError, ValueError):
pid_alive = False
alerts = list_alerts(cfg, limit=10000)
counts: dict[str, int] = {}
for a in alerts:
counts[a["severity"]] = counts.get(a["severity"], 0) + 1
ebpf = "unknown"
for line in reversed(tail_events(cfg, 200)):
if "eBPF exec monitor:" in line:
ebpf = line.split("eBPF exec monitor:", 1)[1].strip()
break
from .selfprotect import heartbeat_age
age = heartbeat_age(cfg)
return {
"version": __version__,
"running": pid_alive,
"total_alerts": len(alerts),
"counts": counts,
"last_alert": alerts[0]["time"] if alerts else None,
"ebpf": ebpf,
"host": os.uname().nodename,
"heartbeat_age": age,
"heartbeat_stale": (age is not None and age > cfg.heartbeat_max_age),
}
# --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
server_version = "EnodiaSentinel"
def log_message(self, *a): # quiet by default
pass
def _authorized(self) -> bool:
token = self.server.token # type: ignore[attr-defined]
if not token:
return True
supplied = ""
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "):
supplied = auth[7:]
if not supplied:
supplied = parse_qs(urlparse(self.path).query).get("token", [""])[0]
return hmac.compare_digest(supplied, token)
def _json(self, obj, status=200):
body = json.dumps(obj).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlparse(self.path).path
if not self._authorized():
self._json({"error": "unauthorized"}, 401)
return
cfg: Config = self.server.cfg # type: ignore[attr-defined]
if path in ("/", "/index.html", "/dashboard.html"):
self._serve_static("dashboard.html", "text/html; charset=utf-8")
elif path == "/api/status":
self._json(daemon_status(cfg))
elif path == "/api/alerts":
self._json(list_alerts(cfg))
elif path.startswith("/api/alerts/"):
alert = get_alert(cfg, path.rsplit("/", 1)[-1])
self._json(alert if alert else {"error": "not found"},
200 if alert else 404)
elif path == "/api/events":
self._json({"events": tail_events(cfg)})
else:
self._json({"error": "not found"}, 404)
def _serve_static(self, name, ctype):
try:
body = (_STATIC / name).read_bytes()
except OSError:
self._json({"error": "missing asset"}, 500)
return
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]:
"""Construct the server (without serving). Returns (httpd, bind, token)."""
bind = resolve_bind(cfg)
token = cfg.web_token
if not is_loopback(bind) and not token:
token = ensure_token(cfg)
httpd = ThreadingHTTPServer((bind, cfg.web_port), _Handler)
httpd.cfg = cfg # type: ignore[attr-defined]
httpd.token = token # type: ignore[attr-defined]
return httpd, bind, token
def serve(cfg: Config) -> None:
httpd, bind, token = build_server(cfg)
suffix = f"/?token={token}" if token else "/"
url = f"http://{bind}:{cfg.web_port}{suffix}"
msg = f"{time.strftime('%FT%T%z')} dashboard serving at {url}"
print(msg, flush=True)
try:
with open(cfg.events_log, "a") as fh:
fh.write(msg + "\n")
except OSError:
pass
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()