Adds sidecar-only, --state-dir-gated FIM, package-DB-anchor, and rootcheck
engines to the Go migration sidecar, wired into agent.Sweep/Initialize
behind the existing baseline lifecycle. Adds a read-only, schema-checked
Python dashboard consumer (go_sidecar_state_dir, /api/go-sidecar/*, Sidecar
tab) that never starts, stops, or mutates the Go sidecar's state.
Also fixes drift found while reconciling this work: enodia_sentinel/web.py
had reinvented local schema constants instead of using the canonical
enodia_sentinel/schemas.py catalog (now registers enodia.go.sidecar.v1
there and reuses ALERT_SNAPSHOT_V1/INCIDENT_V1); docs/RULES.md had drifted
from what `rules docs` actually generates for SIDs 100069-100078 (ruleops.py
was missing metadata for four SIDs and had stale drill text for four more),
now back in sync with a regression test pinning them together.
Updates CLAUDE.md, README.md, go-agent/README.md, and docs/{ROADMAP,
GO_PORT_HANDOFF,SURICATA_ASSIMILATION,THREAT_MODEL,OPERATIONS,SCHEMAS,
COMMAND_REFERENCE}.md to reflect the landed work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQivSKBqQJsayz1xcYqWzZ
777 lines
28 KiB
Python
777 lines
28 KiB
Python
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
"""Read-only HTTPS 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. TLS is mandatory, using configured certificates or an
|
|
auto-generated self-signed certificate under ``log_dir``. 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 ipaddress
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import ssl
|
|
import subprocess
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
|
|
from . import __version__
|
|
from . import schemas
|
|
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
|
|
|
|
|
|
def tls_paths(cfg: Config) -> tuple[Path, Path]:
|
|
cert = Path(cfg.web_tls_cert) if cfg.web_tls_cert else cfg.log_dir / "web-selfsigned.crt"
|
|
key = Path(cfg.web_tls_key) if cfg.web_tls_key else cfg.log_dir / "web-selfsigned.key"
|
|
return cert, key
|
|
|
|
|
|
def _san_for(bind: str) -> str:
|
|
names = ["DNS:localhost", "IP:127.0.0.1"]
|
|
host = os.uname().nodename
|
|
if host:
|
|
names.append(f"DNS:{host}")
|
|
try:
|
|
ipaddress.ip_address(bind)
|
|
names.append(f"IP:{bind}")
|
|
except ValueError:
|
|
if bind and bind != "localhost":
|
|
names.append(f"DNS:{bind}")
|
|
return ",".join(dict.fromkeys(names))
|
|
|
|
|
|
def ensure_tls_cert(cfg: Config, bind: str) -> tuple[Path, Path]:
|
|
"""Return usable cert/key paths, generating a self-signed pair if absent."""
|
|
cert, key = tls_paths(cfg)
|
|
if cert.is_file() and key.is_file():
|
|
return cert, key
|
|
cert.parent.mkdir(parents=True, exist_ok=True)
|
|
key.parent.mkdir(parents=True, exist_ok=True)
|
|
subj = f"/CN={bind if bind else 'localhost'}"
|
|
cmd = [
|
|
"openssl", "req", "-x509", "-newkey", "rsa:3072", "-nodes",
|
|
"-sha256", "-days", "365",
|
|
"-keyout", str(key), "-out", str(cert),
|
|
"-subj", subj,
|
|
"-addext", f"subjectAltName={_san_for(bind)}",
|
|
]
|
|
try:
|
|
subprocess.run(cmd, capture_output=True, text=True, timeout=20, check=True)
|
|
key.chmod(0o600)
|
|
cert.chmod(0o644)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise RuntimeError(
|
|
"TLS is mandatory for the web dashboard, but no certificate/key "
|
|
"were available and self-signed generation with openssl failed"
|
|
) from exc
|
|
return cert, key
|
|
|
|
|
|
# --- 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:]
|
|
|
|
|
|
# --- isolated Go sidecar evidence (read-only migration consumer) ----------
|
|
|
|
def _go_sidecar_dir(cfg: Config) -> Path | None:
|
|
# This is an opt-in evidence boundary, not an alternate runtime root. The
|
|
# web process only derives paths beneath it and never creates or mutates it.
|
|
raw = cfg.go_sidecar_state_dir.strip()
|
|
return Path(raw) if raw else None
|
|
|
|
|
|
def go_sidecar_status(cfg: Config, now: float | None = None) -> dict:
|
|
"""Describe the separately configured Go evidence tree without touching it.
|
|
|
|
The Python daemon remains authoritative. This endpoint deliberately exposes
|
|
only whether the optional migration sidecar is configured and whether its
|
|
retained heartbeat is fresh; it never starts, stops, or probes the service.
|
|
"""
|
|
directory = _go_sidecar_dir(cfg)
|
|
report = {
|
|
"schema": schemas.GO_SIDECAR_V1,
|
|
"read_only": True,
|
|
"configured": directory is not None,
|
|
"state_dir": str(directory) if directory else "",
|
|
"available": bool(directory and directory.is_dir()),
|
|
"heartbeat": {"status": "not-configured"},
|
|
}
|
|
if directory is None:
|
|
return report
|
|
heartbeat = directory / "heartbeat"
|
|
try:
|
|
timestamp = int(heartbeat.read_text().strip())
|
|
except (OSError, ValueError):
|
|
report["heartbeat"] = {"status": "missing"}
|
|
return report
|
|
ts = time.time() if now is None else now
|
|
age = max(0.0, ts - timestamp)
|
|
report["heartbeat"] = {
|
|
"status": "fresh" if age <= cfg.heartbeat_max_age else "stale",
|
|
"timestamp": timestamp,
|
|
"age_seconds": age,
|
|
"max_age_seconds": cfg.heartbeat_max_age,
|
|
}
|
|
return report
|
|
|
|
|
|
def _go_snapshot_report(path: Path) -> dict | None:
|
|
# Sidecar files are retained forensic input, not trusted application data.
|
|
# Require the expected schema before presenting an arbitrary JSON document
|
|
# as a Sentinel alert snapshot.
|
|
try:
|
|
report = json.loads(path.read_text())
|
|
except (OSError, ValueError):
|
|
return None
|
|
if not isinstance(report, dict) or report.get("schema") != schemas.ALERT_SNAPSHOT_V1:
|
|
return None
|
|
return report
|
|
|
|
|
|
def list_go_sidecar_alerts(cfg: Config, limit: int = 200) -> list[dict]:
|
|
directory = _go_sidecar_dir(cfg)
|
|
if directory is None:
|
|
return []
|
|
alerts = []
|
|
for path in sorted(directory.glob("alert-*.json"), reverse=True)[:limit]:
|
|
report = _go_snapshot_report(path)
|
|
if report is None:
|
|
continue
|
|
records = report.get("alerts", [])
|
|
if not isinstance(records, list):
|
|
continue
|
|
alerts.append({
|
|
"name": path.with_suffix(".log").name,
|
|
"time": report.get("time", ""),
|
|
"host": report.get("host", ""),
|
|
"severity": report.get("severity", ""),
|
|
"signatures": sorted({a.get("signature", "") for a in records
|
|
if isinstance(a, dict)}),
|
|
"sids": sorted({a.get("sid", 0) for a in records
|
|
if isinstance(a, dict) and a.get("sid")}),
|
|
"count": len(records),
|
|
"source": "go-sidecar",
|
|
})
|
|
return alerts
|
|
|
|
|
|
def get_go_sidecar_alert(cfg: Config, name: str) -> dict | None:
|
|
if not isinstance(name, str) or not _ALERT_NAME.match(name):
|
|
return None
|
|
directory = _go_sidecar_dir(cfg)
|
|
if directory is None:
|
|
return None
|
|
base = directory / name
|
|
report = _go_snapshot_report(base.with_suffix(".json"))
|
|
if report is None:
|
|
return None
|
|
data = {"json": report, "source": "go-sidecar"}
|
|
try:
|
|
if base.is_file():
|
|
data["text"] = base.read_text()
|
|
except OSError:
|
|
pass
|
|
return data
|
|
|
|
|
|
def go_sidecar_events(cfg: Config, limit: int = 100) -> dict:
|
|
"""Return valid retained envelopes, failing closed if either segment corrupts."""
|
|
directory = _go_sidecar_dir(cfg)
|
|
result = {"events": [], "source": "go-sidecar", "error": None}
|
|
if directory is None:
|
|
return result
|
|
# Match the Go reader's chronology: the rotated segment precedes its active
|
|
# successor. Do not return a partial stream after corruption, because that
|
|
# could make an incomplete evidence chain look authoritative.
|
|
rows = []
|
|
for path in (directory / "events.jsonl.1", directory / "events.jsonl"):
|
|
try:
|
|
lines = path.read_text().splitlines()
|
|
except FileNotFoundError:
|
|
continue
|
|
except OSError as exc:
|
|
result["error"] = f"cannot read retained events: {exc}"
|
|
return result
|
|
for line in lines:
|
|
if not line:
|
|
continue
|
|
try:
|
|
row = json.loads(line)
|
|
except ValueError:
|
|
result["error"] = "retained event log is corrupt"
|
|
return result
|
|
if not isinstance(row, dict):
|
|
result["error"] = "retained event log contains a non-object record"
|
|
return result
|
|
rows.append(row)
|
|
result["events"] = rows[-max(0, limit):] if limit else []
|
|
return result
|
|
|
|
|
|
def list_go_sidecar_incidents(cfg: Config) -> list[dict]:
|
|
directory = _go_sidecar_dir(cfg)
|
|
if directory is None:
|
|
return []
|
|
try:
|
|
index = json.loads((directory / "incidents.json").read_text())
|
|
except (OSError, ValueError):
|
|
return []
|
|
if not isinstance(index, dict):
|
|
return []
|
|
# Atomic index replacement protects writers, but retained files can still
|
|
# be stale or manually damaged; schema filtering keeps this read-only view
|
|
# from interpreting unrelated JSON as an incident.
|
|
records = [item for item in index.values()
|
|
if isinstance(item, dict) and item.get("schema") == schemas.INCIDENT_V1]
|
|
|
|
def last_timestamp(item: dict) -> float:
|
|
try:
|
|
return float(item.get("last_ts", 0.0))
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
return sorted(records, key=last_timestamp, reverse=True)
|
|
|
|
|
|
def get_go_sidecar_incident(cfg: Config, incident_id: str) -> dict | None:
|
|
incident = next((item for item in list_go_sidecar_incidents(cfg)
|
|
if item.get("id") == incident_id), None)
|
|
if incident is None:
|
|
return None
|
|
snapshots, timeline = [], []
|
|
for name in incident.get("snapshots", []):
|
|
if not isinstance(name, str):
|
|
timeline.append({"snapshot": str(name), "time": "?", "missing": True})
|
|
continue
|
|
alert = get_go_sidecar_alert(cfg, name)
|
|
if alert is None:
|
|
timeline.append({"snapshot": name, "time": "?", "missing": True})
|
|
continue
|
|
report = alert["json"]
|
|
snapshots.append(report)
|
|
timeline.append({
|
|
"snapshot": name,
|
|
"time": report.get("time", "?"),
|
|
"severity": report.get("severity", "?"),
|
|
"signatures": list(dict.fromkeys(
|
|
row.get("signature", "") for row in report.get("alerts", [])
|
|
if isinstance(row, dict))),
|
|
"pids": [row.get("pid") for row in report.get("processes", [])
|
|
if isinstance(row, dict) and isinstance(row.get("pid"), int)],
|
|
})
|
|
timeline.sort(key=lambda row: row.get("time", ""))
|
|
return {"schema": schemas.INCIDENT_VIEW_V1, "incident": incident,
|
|
"timeline": timeline, "snapshots": snapshots, "source": "go-sidecar"}
|
|
|
|
|
|
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_exec = "unknown"
|
|
ebpf_syscall = "unknown"
|
|
for line in reversed(tail_events(cfg, 200)):
|
|
if ebpf_exec == "unknown" and "eBPF exec monitor:" in line:
|
|
ebpf_exec = line.split("eBPF exec monitor:", 1)[1].strip()
|
|
if ebpf_syscall == "unknown" and "eBPF syscall monitor:" in line:
|
|
ebpf_syscall = line.split("eBPF syscall monitor:", 1)[1].strip()
|
|
if ebpf_exec != "unknown" and ebpf_syscall != "unknown":
|
|
break
|
|
from .selfprotect import heartbeat_age
|
|
age = heartbeat_age(cfg)
|
|
return {
|
|
"schema": schemas.STATUS_V1,
|
|
"version": __version__,
|
|
"running": pid_alive,
|
|
"total_alerts": len(alerts),
|
|
"counts": counts,
|
|
"last_alert": alerts[0]["time"] if alerts else None,
|
|
"ebpf": ebpf_exec,
|
|
"ebpf_exec": ebpf_exec,
|
|
"ebpf_syscall": ebpf_syscall,
|
|
"host": os.uname().nodename,
|
|
"heartbeat_age": age,
|
|
"heartbeat_stale": (age is not None and age > cfg.heartbeat_max_age),
|
|
}
|
|
|
|
|
|
def list_incidents(cfg: Config) -> list[dict]:
|
|
from . import incident
|
|
|
|
return sorted(incident.load_index(cfg).values(),
|
|
key=lambda i: i.get("last_ts", 0.0), reverse=True)
|
|
|
|
|
|
def get_incident(cfg: Config, incident_id: str) -> dict | None:
|
|
from . import incident
|
|
|
|
inc = incident.load_index(cfg).get(incident_id)
|
|
if inc is None:
|
|
return None
|
|
snapshots = []
|
|
timeline = []
|
|
for name in inc.get("snapshots", []):
|
|
path = (cfg.log_dir / name).with_suffix(".json")
|
|
try:
|
|
report = json.loads(path.read_text())
|
|
except (OSError, ValueError):
|
|
timeline.append({"snapshot": name, "time": "?", "missing": True})
|
|
continue
|
|
snapshots.append(report)
|
|
timeline.append({
|
|
"snapshot": name,
|
|
"time": report.get("time", "?"),
|
|
"severity": report.get("severity", "?"),
|
|
"signatures": list(dict.fromkeys(
|
|
a.get("signature", "") for a in report.get("alerts", []))),
|
|
"pids": [p.get("pid") for p in report.get("processes", [])
|
|
if isinstance(p.get("pid"), int)],
|
|
})
|
|
timeline.sort(key=lambda r: r.get("time", ""))
|
|
return {
|
|
"schema": schemas.INCIDENT_VIEW_V1,
|
|
"incident": inc,
|
|
"timeline": timeline,
|
|
"snapshots": snapshots,
|
|
}
|
|
|
|
|
|
def response_plan(cfg: Config, incident_id: str) -> dict | None:
|
|
from . import respond
|
|
|
|
bundle = respond.load_bundle(cfg, incident_id)
|
|
if bundle is None:
|
|
return None
|
|
return respond.build_plan(bundle, cfg)
|
|
|
|
|
|
def posture_report(cfg: Config, runner=None) -> dict:
|
|
"""Return advisory host posture findings for the management console."""
|
|
if runner is None:
|
|
from . import posture
|
|
runner = posture.run
|
|
findings = sorted(runner(cfg), key=lambda a: (-a.severity, a.signature))
|
|
counts: dict[str, int] = {}
|
|
for f in findings:
|
|
sev = str(f.severity)
|
|
counts[sev] = counts.get(sev, 0) + 1
|
|
return {
|
|
"count": len(findings),
|
|
"counts": counts,
|
|
"findings": [f.to_dict() for f in findings],
|
|
}
|
|
|
|
|
|
def rules_catalog(cfg: Config) -> dict:
|
|
"""Return active event-rule metadata for the management console."""
|
|
from . import ruleops
|
|
|
|
rules = ruleops.list_rules(cfg)
|
|
by_event: dict[str, int] = {}
|
|
by_severity: dict[str, int] = {}
|
|
by_origin: dict[str, int] = {}
|
|
for r in rules:
|
|
by_event[r["event"]] = by_event.get(r["event"], 0) + 1
|
|
by_severity[r["severity"]] = by_severity.get(r["severity"], 0) + 1
|
|
by_origin[r["origin"]] = by_origin.get(r["origin"], 0) + 1
|
|
return {
|
|
"count": len(rules),
|
|
"by_event": by_event,
|
|
"by_severity": by_severity,
|
|
"by_origin": by_origin,
|
|
"rules": rules,
|
|
}
|
|
|
|
|
|
def _json_file_state(path: Path, now: float) -> dict:
|
|
try:
|
|
st = path.stat()
|
|
except OSError:
|
|
return {
|
|
"path": str(path),
|
|
"exists": False,
|
|
"status": "missing",
|
|
"mtime": None,
|
|
"age": None,
|
|
"count": None,
|
|
}
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except (OSError, ValueError):
|
|
return {
|
|
"path": str(path),
|
|
"exists": True,
|
|
"status": "unreadable",
|
|
"mtime": st.st_mtime,
|
|
"age": max(0.0, now - st.st_mtime),
|
|
"count": None,
|
|
}
|
|
count = len(data) if isinstance(data, dict) else None
|
|
return {
|
|
"path": str(path),
|
|
"exists": True,
|
|
"status": "ok",
|
|
"mtime": st.st_mtime,
|
|
"age": max(0.0, now - st.st_mtime),
|
|
"count": count,
|
|
}
|
|
|
|
|
|
def integrity_report(cfg: Config, status: dict | None = None,
|
|
now: float | None = None) -> dict:
|
|
"""Return dashboard-readable integrity and watchdog state.
|
|
|
|
This is intentionally a summary of existing anchors and heartbeats. It does
|
|
not run live FIM or package verification work from the web request path.
|
|
"""
|
|
from . import pkgdb
|
|
from . import assurance
|
|
from .selfprotect import SELF_PATHS, heartbeat_path, watchdog_verdict
|
|
|
|
ts = time.time() if now is None else now
|
|
status = status or daemon_status(cfg)
|
|
watchdog_ok, watchdog_message = watchdog_verdict(status, cfg.heartbeat_max_age)
|
|
anchor = pkgdb.load_anchor(cfg)
|
|
anchor_path = pkgdb._anchor_path(cfg)
|
|
anchor_time = anchor.get("time") if isinstance(anchor, dict) else None
|
|
sig_alert = pkgdb.siglevel_alert()
|
|
watched = []
|
|
for raw in SELF_PATHS:
|
|
path = Path(raw)
|
|
watched.append({"path": raw, "exists": path.exists()})
|
|
present = sum(1 for item in watched if item["exists"])
|
|
fim_state = _json_file_state(cfg.fim_baseline, ts)
|
|
pkg_state = {
|
|
"path": str(anchor_path),
|
|
"exists": bool(anchor),
|
|
"status": "ok" if anchor else "missing",
|
|
"time": anchor_time,
|
|
"age": max(0.0, ts - anchor_time) if isinstance(anchor_time, (int, float)) else None,
|
|
"fingerprint_prefix": (
|
|
str(anchor.get("fingerprint", ""))[:16]
|
|
if isinstance(anchor, dict) else ""
|
|
),
|
|
}
|
|
keyring_present = pkgdb.keyring_present()
|
|
reconciliation = _reconciliation_summary(cfg)
|
|
hash_chain = assurance.summary(cfg)
|
|
checks = {
|
|
"watchdog": "ok" if watchdog_ok else "review",
|
|
"fim_baseline": fim_state["status"],
|
|
"pkgdb_anchor": pkg_state["status"],
|
|
"hash_chain": "ok" if hash_chain["exists"] else "missing",
|
|
"pacman_siglevel": "review" if sig_alert else "ok",
|
|
"pacman_keyring": "ok" if keyring_present else "missing",
|
|
"reconciliation": "review" if reconciliation["stale"] else "ok",
|
|
}
|
|
overall = "ok"
|
|
if any(v in ("review", "missing", "unreadable") for v in checks.values()):
|
|
overall = "review"
|
|
if not watchdog_ok:
|
|
overall = "critical"
|
|
return {
|
|
"schema": schemas.INTEGRITY_V1,
|
|
"generated_at": ts,
|
|
"status": overall,
|
|
"checks": checks,
|
|
"watchdog": {
|
|
"ok": watchdog_ok,
|
|
"message": watchdog_message,
|
|
"heartbeat_path": str(heartbeat_path(cfg)),
|
|
"heartbeat_age": status.get("heartbeat_age"),
|
|
"heartbeat_stale": status.get("heartbeat_stale", False),
|
|
"max_age": cfg.heartbeat_max_age,
|
|
"daemon_running": status.get("running", False),
|
|
},
|
|
"anchors": {
|
|
"fim_baseline": fim_state,
|
|
"pkgdb": pkg_state,
|
|
"pacman": {
|
|
"keyring_present": keyring_present,
|
|
"siglevel_status": "review" if sig_alert else "ok",
|
|
"siglevel_alert": sig_alert.to_dict() if sig_alert else None,
|
|
},
|
|
},
|
|
"sentinel_footprint": {
|
|
"configured": len(watched),
|
|
"present": present,
|
|
"missing": len(watched) - present,
|
|
"paths": watched,
|
|
},
|
|
"reconciliation": reconciliation,
|
|
"hash_chain": hash_chain,
|
|
"read_only": True,
|
|
}
|
|
|
|
|
|
def _reconciliation_summary(cfg: Config) -> dict:
|
|
"""Acknowledged-drift counts for the dashboard. Read-only: it evaluates TTL
|
|
expiry and persisted status in memory but never writes the store
|
|
(``persist=False``), and does not run live FIM/package scans from the web
|
|
request path (consistent with the rest of integrity_report)."""
|
|
from . import reconcile
|
|
records = reconcile.ReconcileStore.load(cfg).list(persist=False)
|
|
stale = [r for r in records if r.status == "stale"]
|
|
return {
|
|
"total": len(records),
|
|
"ok": len(records) - len(stale),
|
|
"stale": len(stale),
|
|
"stale_items": [
|
|
{"kind": r.kind, "target": r.target, "reason": r.reason,
|
|
"accepted_at": r.accepted_at, "expires_at": r.expires_at}
|
|
for r in stale
|
|
],
|
|
}
|
|
|
|
|
|
# --- 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 _send_body(self, body: bytes, ctype: str, status=200, send_body=True):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
if send_body:
|
|
self.wfile.write(body)
|
|
|
|
def _json(self, obj, status=200, send_body=True):
|
|
body = json.dumps(obj).encode("utf-8")
|
|
self._send_body(body, "application/json", status, send_body)
|
|
|
|
def _json_or_not_found(self, obj, send_body=True):
|
|
self._json(obj if obj else {"error": "not found"},
|
|
200 if obj else 404, send_body=send_body)
|
|
|
|
def do_GET(self):
|
|
self._handle_get(send_body=True)
|
|
|
|
def do_HEAD(self):
|
|
self._handle_get(send_body=False)
|
|
|
|
def _handle_get(self, send_body=True):
|
|
path = urlparse(self.path).path
|
|
if not self._authorized():
|
|
self._json({"error": "unauthorized"}, 401, send_body=send_body)
|
|
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",
|
|
send_body=send_body)
|
|
elif path == "/api/status":
|
|
self._json(daemon_status(cfg), send_body=send_body)
|
|
elif path == "/api/alerts":
|
|
self._json(list_alerts(cfg), send_body=send_body)
|
|
elif path.startswith("/api/alerts/"):
|
|
alert = get_alert(cfg, path.rsplit("/", 1)[-1])
|
|
self._json_or_not_found(alert, send_body=send_body)
|
|
elif path == "/api/events":
|
|
self._json({"events": tail_events(cfg)}, send_body=send_body)
|
|
elif path == "/api/go-sidecar/status":
|
|
self._json(go_sidecar_status(cfg), send_body=send_body)
|
|
elif path == "/api/go-sidecar/alerts":
|
|
self._json(list_go_sidecar_alerts(cfg), send_body=send_body)
|
|
elif path.startswith("/api/go-sidecar/alerts/"):
|
|
alert = get_go_sidecar_alert(cfg, path.rsplit("/", 1)[-1])
|
|
self._json_or_not_found(alert, send_body=send_body)
|
|
elif path == "/api/go-sidecar/events":
|
|
self._json(go_sidecar_events(cfg), send_body=send_body)
|
|
elif path == "/api/go-sidecar/incidents":
|
|
self._json(list_go_sidecar_incidents(cfg), send_body=send_body)
|
|
elif path.startswith("/api/go-sidecar/incidents/"):
|
|
iid = unquote(path.rsplit("/", 1)[-1])
|
|
report = get_go_sidecar_incident(cfg, iid)
|
|
self._json_or_not_found(report, send_body=send_body)
|
|
elif path == "/api/posture":
|
|
self._json(posture_report(cfg), send_body=send_body)
|
|
elif path == "/api/rules":
|
|
self._json(rules_catalog(cfg), send_body=send_body)
|
|
elif path == "/api/integrity":
|
|
self._json(integrity_report(cfg), send_body=send_body)
|
|
elif path == "/api/incidents":
|
|
self._json(list_incidents(cfg), send_body=send_body)
|
|
elif path.startswith("/api/incidents/"):
|
|
iid = unquote(path.rsplit("/", 1)[-1])
|
|
inc = get_incident(cfg, iid)
|
|
self._json_or_not_found(inc, send_body=send_body)
|
|
elif path.startswith("/api/respond/plan/"):
|
|
iid = unquote(path.rsplit("/", 1)[-1])
|
|
plan = response_plan(cfg, iid)
|
|
self._json_or_not_found(plan, send_body=send_body)
|
|
else:
|
|
self._json({"error": "not found"}, 404, send_body=send_body)
|
|
|
|
def _serve_static(self, name, ctype, send_body=True):
|
|
try:
|
|
body = (_STATIC / name).read_bytes()
|
|
except OSError:
|
|
self._json({"error": "missing asset"}, 500, send_body=send_body)
|
|
return
|
|
self._send_body(body, ctype, send_body=send_body)
|
|
|
|
|
|
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]:
|
|
"""Construct the HTTPS server. Returns (httpd, bind, token)."""
|
|
bind = resolve_bind(cfg)
|
|
token = cfg.web_token
|
|
if not is_loopback(bind) and not token:
|
|
token = ensure_token(cfg)
|
|
cert, key = ensure_tls_cert(cfg, bind)
|
|
httpd = ThreadingHTTPServer((bind, cfg.web_port), _Handler)
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
ctx.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
|
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
|
httpd.cfg = cfg # type: ignore[attr-defined]
|
|
httpd.token = token # type: ignore[attr-defined]
|
|
httpd.tls_cert = cert # 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"https://{bind}:{cfg.web_port}{suffix}"
|
|
msg = (f"{time.strftime('%FT%T%z')} dashboard serving at {url} "
|
|
f"(TLS cert: {getattr(httpd, 'tls_cert', '')})")
|
|
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()
|