Add HTTPS management console and response planning

This commit is contained in:
Luna 2026-06-12 02:39:53 -07:00
parent a56d72edd6
commit a7129e5666
16 changed files with 927 additions and 160 deletions

View file

@ -89,12 +89,20 @@ def main(argv: list[str] | None = None) -> int:
po.add_argument("action", nargs="?", default="check", choices=["check"],
help="posture action (default: check)")
po.add_argument("--json", action="store_true", help="emit findings as JSON")
rsp = sub.add_parser("respond",
help="build dry-run response plans for incidents")
rsp.add_argument("action", nargs="?", default="plan", choices=["plan"],
help="response action (default: plan)")
rsp.add_argument("id", nargs="?", help="incident id")
rsp.add_argument("--json", action="store_true", help="emit plan as JSON")
wd = sub.add_parser("watchdog",
help="poll a remote dashboard and push if Sentinel is silent")
wd.add_argument("--url", required=True, help="dashboard base URL")
wd.add_argument("--token", default="", help="dashboard bearer token")
wd.add_argument("--max-age", type=int, default=120,
help="heartbeat staleness threshold (seconds)")
wd.add_argument("--insecure-tls", action="store_true",
help="allow self-signed/untrusted dashboard certificates")
args = parser.parse_args(argv)
cfg = Config.load(args.config)
@ -138,15 +146,20 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_incident(cfg, args.action, args.id, args.json)
if args.cmd == "posture":
return _cmd_posture(cfg, args.json)
if args.cmd == "respond":
return _cmd_respond(cfg, args.action, args.id, args.json)
if args.cmd == "watchdog":
return _cmd_watchdog(cfg, args.url, args.token, args.max_age)
return _cmd_watchdog(cfg, args.url, args.token, args.max_age,
not args.insecure_tls)
return _cmd_run(cfg)
def _cmd_watchdog(cfg: Config, url: str, token: str, max_age: int) -> int:
def _cmd_watchdog(cfg: Config, url: str, token: str, max_age: int,
verify_tls: bool = True) -> int:
from . import notify
from .selfprotect import poll_status, watchdog_verdict
ok, msg = watchdog_verdict(poll_status(url, token), max_age)
ok, msg = watchdog_verdict(poll_status(url, token, verify_tls=verify_tls),
max_age)
print(("OK: " if ok else "ALERT: ") + msg)
if not ok:
n = notify.Notification(
@ -328,6 +341,42 @@ def _cmd_posture(cfg: Config, as_json: bool) -> int:
return 1
def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> int:
import json
from . import respond
if action != "plan":
print(f"error: unsupported respond action: {action}", file=sys.stderr)
return 2
if not iid:
print("error: 'respond plan' needs an incident id "
"(see 'incident list')", file=sys.stderr)
return 2
bundle = respond.load_bundle(cfg, iid)
if bundle is None:
print(f"error: no such incident: {iid}", file=sys.stderr)
return 1
plan = respond.build_plan(bundle, cfg)
if as_json:
print(json.dumps(plan, indent=2))
return 0
summary = plan["summary"]
print(f"Response plan {plan['plan_id']} [{summary['severity']}]")
print(f" incident: {plan['incident_id']}")
print(f" mode: {plan['mode']} (no commands executed)")
print(f" signatures: {', '.join(summary['signatures']) or ''}")
print(f" snapshots: {summary['snapshot_count']}")
print(" actions:")
for a in plan["actions"]:
cmd = " ".join(a["command"])
print(f" {a['id']} [{a['risk']}] {a['title']}")
print(f" {cmd}")
print(f" reason: {a['reason']}")
return 0
def _cmd_fim_check(cfg: Config, packages: bool) -> int:
from . import fim
sentinel = Sentinel(cfg)

View file

@ -123,6 +123,8 @@ class Config:
web_bind: str = "" # "" = auto-detect Tailscale IP, else explicit
web_port: int = 8787
web_token: str = "" # bearer token; auto-generated if empty + non-local
web_tls_cert: str = "" # "" = auto-generate self-signed cert under log_dir
web_tls_key: str = "" # "" = auto-generate private key under log_dir
# paths
log_dir: Path = Path("/var/log/enodia-sentinel")

272
enodia_sentinel/respond.py Normal file
View file

@ -0,0 +1,272 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Dry-run response planning for incidents.
The response layer starts read-only: turn an incident's captured evidence into
an explicit plan an operator can review. No action is executed here. Future
``--apply`` support should consume this schema and write an audit log.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from .config import Config
from .netutil import is_public_ip
@dataclass(frozen=True)
class ResponseAction:
id: str
category: str
title: str
command: tuple[str, ...]
reason: str
risk: str = "medium"
reversible: bool = True
requires_review: bool = True
targets: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"id": self.id,
"category": self.category,
"title": self.title,
"command": list(self.command),
"reason": self.reason,
"risk": self.risk,
"reversible": self.reversible,
"requires_review": self.requires_review,
"targets": self.targets,
}
def load_bundle(cfg: Config, incident_id: str) -> dict | None:
"""Load an incident record and its still-retained snapshot reports."""
from . import incident
inc = incident.load_index(cfg).get(incident_id)
if inc is None:
return None
snaps = []
for name in inc.get("snapshots", []):
path = (cfg.log_dir / name).with_suffix(".json")
try:
snaps.append(json.loads(path.read_text()))
except (OSError, ValueError):
continue
return {"incident": inc, "snapshots": snaps}
def build_plan(bundle: dict, cfg: Config) -> dict:
inc = bundle["incident"]
snapshots = bundle.get("snapshots", [])
incident_id = inc["id"]
actions: list[ResponseAction] = []
def add(action: ResponseAction) -> None:
if (action.category, action.command, tuple(sorted(action.targets.items()))) in seen:
return
seen.add((action.category, action.command, tuple(sorted(action.targets.items()))))
actions.append(action)
seen: set[tuple] = set()
evidence_name = f"enodia-evidence-{incident_id}.tgz"
add(ResponseAction(
id="A001",
category="evidence",
title="Freeze Sentinel evidence bundle",
command=("tar", "-C", str(cfg.log_dir.parent), "-czf",
f"/tmp/{evidence_name}", cfg.log_dir.name),
reason="Preserve snapshots, event logs, baselines, and incident index before containment.",
risk="low",
reversible=False,
targets={"path": f"/tmp/{evidence_name}"},
))
alerts = _alerts(snapshots)
procs = _processes(snapshots)
sigs = {a.get("signature", "") for a in alerts}
for proc in procs.values():
pid = proc.get("pid")
if not isinstance(pid, int):
continue
add(ResponseAction(
id="",
category="process",
title=f"Freeze suspicious process {pid}",
command=("kill", "-STOP", str(pid)),
reason="Pause the process so /proc state can be inspected before termination.",
risk="medium",
targets={"pid": pid, "comm": proc.get("comm", "")},
))
add(ResponseAction(
id="",
category="process",
title=f"Terminate suspicious process {pid}",
command=("kill", "-TERM", str(pid)),
reason="Stop a process directly tied to the incident after evidence is captured.",
risk="high",
targets={"pid": pid, "comm": proc.get("comm", "")},
))
for ip in sorted(_public_ips(alerts)):
add(ResponseAction(
id="",
category="network",
title=f"Block outbound traffic to {ip}",
command=("nft", "add", "rule", "inet", "filter", "output",
"ip", "daddr", ip, "drop"),
reason="Contain suspicious C2/egress while the incident is investigated.",
risk="medium",
targets={"ip": ip},
))
for unit in sorted(_systemd_units(alerts)):
add(ResponseAction(
id="",
category="service",
title=f"Stop and disable {unit}",
command=("systemctl", "disable", "--now", unit),
reason="Disable persistence from a modified systemd unit.",
risk="high",
targets={"unit": unit},
))
for path, sig in sorted(_alert_paths(alerts)):
if _is_quarantine_candidate(path, sig):
add(ResponseAction(
id="",
category="file",
title=f"Quarantine suspicious file {path}",
command=("mv", "--", path, f"{path}.enodia-quarantine"),
reason="Move a suspicious added/SUID/preload artifact out of its execution path.",
risk="high",
targets={"path": path},
))
if sig in {"fim_pkg_modified", "pkg_signature_mismatch"}:
add(ResponseAction(
id="",
category="recovery",
title=f"Restore package-owned file {path}",
command=("pacman", "-Qo", path),
reason="Identify the owning package before reinstalling it from trusted media.",
risk="low",
targets={"path": path},
))
if {"fim_modified", "fim_pkg_modified", "pkg_signature_mismatch",
"pkgdb_tamper"} & sigs:
add(ResponseAction(
id="",
category="verification",
title="Re-run integrity checks after containment",
command=("enodia-sentinel", "fim-check", "--packages"),
reason="Confirm package-owned and FIM-monitored files match expected state.",
risk="low",
))
if any(s.startswith("rootkit_") or s in {"new_listener", "promiscuous_interface"}
for s in sigs):
add(ResponseAction(
id="",
category="verification",
title="Re-run rootcheck after containment",
command=("enodia-sentinel", "rootcheck"),
reason="Confirm cross-view hidden process/module/port findings are gone.",
risk="low",
))
numbered = []
for i, action in enumerate(actions, 1):
d = action.to_dict()
d["id"] = action.id or f"A{i:03d}"
numbered.append(d)
return {
"schema": "enodia.response.plan.v1",
"plan_id": f"plan-{incident_id}",
"incident_id": incident_id,
"created_at": datetime.now().astimezone().isoformat(),
"mode": "dry-run",
"apply_supported": False,
"summary": {
"severity": inc.get("severity", "?"),
"signatures": inc.get("signatures", []),
"snapshot_count": len(snapshots),
"action_count": len(numbered),
},
"actions": numbered,
"notes": [
"This plan is read-only; Sentinel did not execute any command.",
"Preserve evidence before stopping processes, services, or moving files.",
"Review each target against live state because snapshots can be stale.",
],
}
def _alerts(snapshots: list[dict]) -> list[dict]:
return [a for snap in snapshots for a in snap.get("alerts", [])]
def _processes(snapshots: list[dict]) -> dict[int, dict]:
out = {}
for snap in snapshots:
for proc in snap.get("processes", []):
pid = proc.get("pid")
if isinstance(pid, int):
out[pid] = proc
return out
_IP_RE = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])")
def _public_ips(alerts: list[dict]) -> set[str]:
ips = set()
for alert in alerts:
for ip in _IP_RE.findall(alert.get("detail", "")):
if is_public_ip(ip):
ips.add(ip)
return ips
def _paths(alerts: list[dict]) -> set[str]:
return {path for path, _sig in _alert_paths(alerts)}
def _alert_paths(alerts: list[dict]) -> set[tuple[str, str]]:
paths = set()
for alert in alerts:
detail = alert.get("detail", "")
key = alert.get("key", "")
for text in (detail, key):
for token in re.findall(r"(?<![\w.])/[^\s,\])]+", text):
paths.add((token.rstrip(":"), alert.get("signature", "")))
return paths
def _systemd_units(alerts: list[dict]) -> set[str]:
units = set()
for path in _paths(alerts):
p = Path(path)
if "/systemd/system/" in path and p.name.endswith((".service", ".timer", ".socket")):
units.add(p.name)
return units
def _is_quarantine_candidate(path: str, sig: str) -> bool:
if sig == "new_suid":
return True
if sig == "fim_added":
return True
if sig in {"ld_preload", "deleted_exe", "fim_added"} and path.startswith(
("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/")):
return True
if path == "/etc/ld.so.preload":
return False
return False

View file

@ -64,9 +64,11 @@ def heartbeat_age(cfg: Config) -> float | None:
# --- external dead-man's-switch watcher -----------------------------------
def poll_status(url: str, token: str = "", timeout: int = 8) -> dict | None:
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"
@ -74,7 +76,8 @@ def poll_status(url: str, token: str = "", timeout: int = 8) -> dict | None:
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
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

View file

@ -7,156 +7,278 @@
<title>Enodia Sentinel</title>
<style>
:root{
--bg:#0b0e14; --panel:#11151f; --panel2:#161b27; --line:#222a3a;
--fg:#cdd6e4; --dim:#7c8aa5; --accent:#5ad1c8;
--crit:#ff5370; --high:#ffb454; --med:#73d0ff;
--bg:#0a0c0f; --ink:#d8dee7; --muted:#8590a3; --soft:#aeb7c7;
--panel:#11151b; --panel2:#151b23; --rail:#0d1117; --line:#26313f;
--accent:#22c7a9; --amber:#f2b84b; --red:#f06476; --blue:#61a8ff;
--ok:#56d364; --shadow:0 18px 48px rgba(0,0,0,.28);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace}
header{display:flex;align-items:center;gap:14px;padding:14px 20px;
border-bottom:1px solid var(--line);background:var(--panel)}
header h1{font-size:16px;margin:0;letter-spacing:.18em;color:var(--accent)}
header .host{color:var(--dim)}
.dot{width:10px;height:10px;border-radius:50%;background:#444;display:inline-block}
.dot.up{background:#46d160;box-shadow:0 0 8px #46d160}
.dot.down{background:var(--crit)}
body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.45 "Aptos Mono","IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace}
body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.18;
background-image:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px);
background-size:28px 28px}
button{font:inherit;color:inherit;background:var(--panel2);border:1px solid var(--line);
border-radius:6px;padding:8px 10px;cursor:pointer}
button:hover{border-color:var(--accent);color:#fff}
header{height:58px;display:flex;align-items:center;gap:16px;padding:0 18px;
border-bottom:1px solid var(--line);background:rgba(13,17,23,.96);position:relative;z-index:2}
h1{font-size:15px;margin:0;color:#fff;letter-spacing:.16em;text-transform:uppercase}
.pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);
border-radius:999px;padding:4px 9px;color:var(--soft);font-size:12px}
.dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 12px var(--red)}
.dot.up{background:var(--ok);box-shadow:0 0 12px var(--ok)}
.spacer{flex:1}
.meta{color:var(--dim);font-size:12px}
.cards{display:flex;gap:12px;padding:16px 20px;flex-wrap:wrap}
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
padding:12px 16px;min-width:120px}
.card .n{font-size:26px;font-weight:700}
.card .l{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.1em}
.card.crit .n{color:var(--crit)} .card.high .n{color:var(--high)}
.card.med .n{color:var(--med)}
main{display:grid;grid-template-columns:minmax(340px,460px) 1fr;gap:0;
border-top:1px solid var(--line);height:calc(100vh - 180px)}
.list,.detail{overflow:auto;height:100%}
.list{border-right:1px solid var(--line)}
.row{padding:10px 16px;border-bottom:1px solid var(--line);cursor:pointer}
.row:hover{background:var(--panel2)} .row.sel{background:var(--panel2);
border-left:3px solid var(--accent);padding-left:13px}
.row .top{display:flex;justify-content:space-between;gap:8px}
.sev{font-weight:700;font-size:11px;padding:1px 7px;border-radius:4px}
.sev.CRITICAL{color:var(--crit);border:1px solid var(--crit)}
.sev.HIGH{color:var(--high);border:1px solid var(--high)}
.sev.MEDIUM{color:var(--med);border:1px solid var(--med)}
.row .sigs{color:var(--fg);margin-top:4px;font-size:13px}
.row .t{color:var(--dim);font-size:11px}
.row .sids{color:var(--dim);font-size:11px}
.detail{padding:18px 22px}
.detail pre{white-space:pre-wrap;word-break:break-word;margin:0;
color:#b9c4d8;font-size:12.5px}
.empty{color:var(--dim);padding:40px;text-align:center}
.err{color:var(--crit);padding:30px;text-align:center}
footer{border-top:1px solid var(--line);background:var(--panel);
padding:8px 20px;color:var(--dim);font-size:11px;display:flex;gap:18px}
a{color:var(--accent)}
.tls{color:var(--accent)}
.layout{display:grid;grid-template-columns:330px minmax(420px,1fr) 390px;
height:calc(100vh - 58px);position:relative;z-index:1}
aside,.work,.plan{min-height:0;overflow:auto}
aside{background:rgba(13,17,23,.9);border-right:1px solid var(--line)}
.work{background:linear-gradient(180deg,#0f1319,#0b0d11);padding:18px}
.plan{background:rgba(17,21,27,.92);border-left:1px solid var(--line);padding:16px}
.section-title{display:flex;align-items:center;justify-content:space-between;
padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em;
text-transform:uppercase}
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}
.metric{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}
.metric b{display:block;font-size:24px;line-height:1;color:#fff}
.metric span{display:block;color:var(--muted);font-size:11px;margin-top:8px;text-transform:uppercase}
.metric.crit b{color:var(--red)} .metric.high b{color:var(--amber)} .metric.med b{color:var(--blue)}
.list{padding:0 8px 14px}
.item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px;
cursor:pointer;border-radius:7px;margin-bottom:4px}
.item:hover{background:var(--panel)}
.item.sel{background:var(--panel2);border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
.item .top{display:flex;align-items:center;gap:8px;justify-content:space-between}
.id{font-size:12px;color:#fff;word-break:break-word}
.time,.fine{color:var(--muted);font-size:11px}
.tags{margin-top:7px;display:flex;gap:6px;flex-wrap:wrap}
.tag{font-size:11px;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--soft)}
.sev{font-weight:700;font-size:11px;border-radius:4px;padding:2px 7px;border:1px solid var(--line)}
.sev.CRITICAL{color:var(--red);border-color:var(--red)}
.sev.HIGH{color:var(--amber);border-color:var(--amber)}
.sev.MEDIUM{color:var(--blue);border-color:var(--blue)}
.tabs{display:flex;gap:6px;margin-bottom:14px}
.tabs button{padding:7px 11px}
.tabs button.active{background:var(--accent);border-color:var(--accent);color:#00110e}
.card{background:rgba(17,21,27,.9);border:1px solid var(--line);border-radius:8px;
padding:14px;margin-bottom:14px;box-shadow:var(--shadow)}
h2{font-size:18px;margin:0 0 8px;color:#fff;letter-spacing:0}
h3{font-size:12px;margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}
.kv{display:grid;grid-template-columns:120px 1fr;gap:8px;margin-top:10px}
.kv div:nth-child(odd){color:var(--muted)}
.timeline{display:grid;gap:8px}
.event{border-left:2px solid var(--accent);padding:8px 10px;background:rgba(255,255,255,.03)}
.event b{display:block;color:#fff}
.event code,.cmd{display:block;margin-top:6px;padding:8px;background:#080a0d;
border:1px solid var(--line);border-radius:6px;color:#cbd5e1;white-space:pre-wrap;word-break:break-word}
.action{border:1px solid var(--line);border-radius:8px;background:#0d1117;padding:11px;margin-bottom:10px}
.action .head{display:flex;gap:8px;align-items:center;justify-content:space-between}
.risk{font-size:10px;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--muted)}
.risk.high{color:var(--red);border-color:var(--red)}
.risk.medium{color:var(--amber);border-color:var(--amber)}
.risk.low{color:var(--accent);border-color:var(--accent)}
pre{margin:0;white-space:pre-wrap;word-break:break-word;color:#c8d1df;font-size:12px}
.empty,.err{padding:30px;color:var(--muted);text-align:center}
.err{color:var(--red)}
@media (max-width:1100px){
.layout{grid-template-columns:300px 1fr}
.plan{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line);height:44vh}
}
@media (max-width:760px){
header{height:auto;min-height:58px;flex-wrap:wrap;padding:12px}
.layout{display:block;height:auto}
aside,.work,.plan{height:auto;max-height:none}
.metrics{grid-template-columns:repeat(2,1fr)}
}
</style>
</head>
<body>
<header>
<h1>ENODIA&nbsp;SENTINEL</h1>
<span class="dot" id="dot"></span>
<span class="host" id="host"></span>
<h1>Enodia Sentinel</h1>
<span class="pill"><span class="dot" id="dot"></span><span id="daemon">connecting</span></span>
<span class="pill tls">HTTPS self-signed</span>
<span class="pill" id="host">host unknown</span>
<span class="spacer"></span>
<span class="meta" id="ebpf"></span>
<span class="meta" id="ver"></span>
<span class="pill" id="ebpf">eBPF unknown</span>
<button id="refreshBtn" title="Refresh console">Refresh</button>
</header>
<div class="cards" id="cards"></div>
<div class="layout">
<aside>
<div class="section-title"><span>Incidents</span><span id="incidentCount">0</span></div>
<div class="list" id="incidents"><div class="empty">loading incidents</div></div>
<div class="section-title"><span>Recent Alerts</span><span id="alertCount">0</span></div>
<div class="list" id="alerts"></div>
</aside>
<main>
<div class="list" id="list"><div class="empty">loading…</div></div>
<div class="detail" id="detail"><div class="empty">Select an alert to view its forensic snapshot.</div></div>
</main>
<main class="work">
<div class="metrics" id="metrics"></div>
<div class="tabs">
<button class="active" data-tab="incident">Incident</button>
<button data-tab="alerts">Alerts</button>
<button data-tab="events">Events</button>
</div>
<section id="incidentTab"></section>
<section id="alertsTab" hidden></section>
<section id="eventsTab" hidden></section>
</main>
<footer>
<span id="status">connecting…</span>
<span class="spacer" style="flex:1"></span>
<span id="refreshed"></span>
</footer>
<section class="plan">
<div class="section-title"><span>Dry-run Response Plan</span><span id="planMode">read-only</span></div>
<div id="plan"><div class="empty">Select an incident to generate a plan.</div></div>
</section>
</div>
<script>
const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
let selected = null;
const state = {incidents:[], alerts:[], selected:null, incident:null, plan:null, tab:"incident"};
async function api(path){
const r = await fetch(path, {headers:HDR});
if(r.status===401) throw new Error("unauthorized — bad or missing ?token");
const r = await fetch(path,{headers:HDR});
if(r.status===401) throw new Error("unauthorized - open with ?token=YOUR_TOKEN");
if(!r.ok) throw new Error("HTTP "+r.status);
return r.json();
}
function el(tag, cls, txt){const e=document.createElement(tag);
if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e;}
function el(tag, cls, txt){const e=document.createElement(tag); if(cls)e.className=cls;
if(txt!==undefined)e.textContent=txt; return e}
function sev(s){return el("span","sev "+(s||""),s||"?")}
function tags(xs){const box=el("div","tags"); (xs||[]).forEach(x=>box.append(el("span","tag",x))); return box}
function fmt(t){return (t||"?").replace("T"," ").slice(0,19)}
async function refresh(){
try{
const st = await api("/api/status");
document.getElementById("dot").className = "dot "+(st.running?"up":"down");
document.getElementById("host").textContent = st.host || "";
document.getElementById("ver").textContent = "v"+st.version;
document.getElementById("ebpf").textContent = "eBPF: "+(st.ebpf||"?");
let hb = "";
if (st.heartbeat_age != null)
hb = st.heartbeat_stale
? ` ⚠ heartbeat STALE (${Math.round(st.heartbeat_age)}s) — possible tampering`
: ` ♥ ${Math.round(st.heartbeat_age)}s ago`;
const st_el = document.getElementById("status");
st_el.textContent = (st.running ? "daemon running" : "daemon NOT running") + hb;
st_el.style.color = (st.running && !st.heartbeat_stale) ? "" : "var(--crit)";
const c = st.counts||{};
const cards = document.getElementById("cards"); cards.innerHTML="";
for(const [lbl,key] of [["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"]]){
const card=el("div","card "+key);
card.append(el("div","n",String(c[lbl]||0))); card.append(el("div","l",lbl));
cards.append(card);
}
const tot=el("div","card"); tot.append(el("div","n",String(st.total_alerts||0)));
tot.append(el("div","l","total alerts")); cards.append(tot);
const [status, incidents, alerts, events] = await Promise.all([
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/events")
]);
state.incidents = incidents; state.alerts = alerts; state.events = events.events || [];
renderStatus(status); renderLists();
if(!state.selected && incidents.length) await openIncident(incidents[0].id);
else if(state.selected) await openIncident(state.selected, false);
renderTab(); renderEvents();
}catch(e){ showError(e.message); }
}
const alerts = await api("/api/alerts");
const list = document.getElementById("list"); list.innerHTML="";
if(!alerts.length){ list.append(el("div","empty","No alerts captured.")); }
for(const a of alerts){
const row=el("div","row"); row.dataset.name=a.name;
if(a.name===selected) row.classList.add("sel");
const top=el("div","top");
top.append(el("span","sev "+a.severity, a.severity));
top.append(el("span","t", (a.time||"").replace("T"," ").slice(0,19)));
row.append(top);
row.append(el("div","sigs", a.signatures.join(", ")));
if(a.sids.length) row.append(el("div","sids","sid: "+a.sids.join(", ")));
row.onclick=()=>openAlert(a.name);
list.append(row);
}
document.getElementById("refreshed").textContent =
"updated "+new Date().toLocaleTimeString();
}catch(e){
document.getElementById("status").textContent = "error: "+e.message;
if(String(e.message).includes("unauthorized"))
document.getElementById("detail").innerHTML =
'<div class="err">Unauthorized. Open this page with ?token=YOUR_TOKEN</div>';
}
function renderStatus(st){
document.getElementById("dot").className = "dot "+(st.running && !st.heartbeat_stale ? "up" : "");
let hb = st.heartbeat_age == null ? "no heartbeat" : Math.round(st.heartbeat_age)+"s heartbeat";
document.getElementById("daemon").textContent = (st.running ? "running" : "not running")+" / "+hb;
document.getElementById("host").textContent = st.host+" / v"+st.version;
document.getElementById("ebpf").textContent = "eBPF "+(st.ebpf || "unknown");
const counts = st.counts || {};
const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""]].forEach(([k,c])=>{
const m=el("div","metric "+c); m.append(el("b",null,String(k==="TOTAL" ? st.total_alerts||0 : counts[k]||0)));
m.append(el("span",null,k==="TOTAL" ? "alerts" : k)); metrics.append(m);
});
}
function renderLists(){
document.getElementById("incidentCount").textContent = state.incidents.length;
document.getElementById("alertCount").textContent = state.alerts.length;
const incBox = document.getElementById("incidents"); incBox.innerHTML = "";
if(!state.incidents.length) incBox.append(el("div","empty","No incidents recorded."));
state.incidents.forEach(i=>{
const row=el("div","item"); row.classList.toggle("sel", i.id===state.selected);
row.onclick=()=>openIncident(i.id);
const top=el("div","top"); top.append(el("span","id",i.id)); top.append(sev(i.severity));
row.append(top); row.append(el("div","time",fmt(i.last_seen)));
row.append(tags(i.signatures)); incBox.append(row);
});
const alertBox = document.getElementById("alerts"); alertBox.innerHTML = "";
state.alerts.slice(0,12).forEach(a=>{
const row=el("div","item"); row.onclick=()=>openAlert(a.name);
const top=el("div","top"); top.append(el("span","id",a.name)); top.append(sev(a.severity));
row.append(top); row.append(el("div","time",fmt(a.time))); row.append(tags(a.signatures));
alertBox.append(row);
});
}
async function openIncident(id, mark=true){
state.selected = id;
if(mark) renderLists();
try{
const [inc, plan] = await Promise.all([
api("/api/incidents/"+encodeURIComponent(id)),
api("/api/respond/plan/"+encodeURIComponent(id))
]);
state.incident = inc; state.plan = plan;
renderIncident(); renderPlan();
}catch(e){ document.getElementById("incidentTab").innerHTML='<div class="err">'+e.message+'</div>'; }
}
async function openAlert(name){
selected = name;
document.querySelectorAll(".row").forEach(r=>
r.classList.toggle("sel", r.dataset.name===name));
const d = document.getElementById("detail");
d.innerHTML='<div class="empty">loading…</div>';
state.tab = "alerts"; setTabButtons();
const box = document.getElementById("alertsTab");
box.hidden = false; document.getElementById("incidentTab").hidden = true; document.getElementById("eventsTab").hidden = true;
box.innerHTML='<div class="empty">loading snapshot</div>';
try{
const a = await api("/api/alerts/"+encodeURIComponent(name));
d.innerHTML="";
const pre=el("pre",null,a.text || JSON.stringify(a.json,null,2));
d.append(pre);
}catch(e){ d.innerHTML='<div class="err">'+e.message+'</div>'; }
box.innerHTML = '<div class="card"><h3>'+name+'</h3><pre></pre></div>';
box.querySelector("pre").textContent = a.text || JSON.stringify(a.json,null,2);
}catch(e){ box.innerHTML='<div class="err">'+e.message+'</div>'; }
}
function renderIncident(){
const box = document.getElementById("incidentTab"); const data = state.incident;
if(!data){ box.innerHTML='<div class="empty">No incident selected.</div>'; return; }
const i = data.incident;
box.innerHTML = "";
const card = el("div","card");
card.append(el("h2",null,i.id)); card.append(tags(i.signatures));
const kv = el("div","kv");
[["Severity",i.severity],["First seen",fmt(i.first_seen)],["Last seen",fmt(i.last_seen)],
["Alert count",String(i.alert_count||0)],["PIDs",(i.pids||[]).join(", ")||"-"],["SIDs",(i.sids||[]).join(", ")||"-"]]
.forEach(([k,v])=>{kv.append(el("div",null,k)); kv.append(el("div",null,v));});
card.append(kv); box.append(card);
const tl = el("div","card"); tl.append(el("h3",null,"Timeline"));
const list = el("div","timeline");
(data.timeline||[]).forEach(r=>{
const ev = el("div","event"); ev.append(el("b",null,fmt(r.time)+" ["+(r.severity||"?")+"]"));
ev.append(el("div","fine",(r.signatures||[]).join(", ") || r.snapshot));
ev.append(el("code",null,r.snapshot)); list.append(ev);
});
tl.append(list); box.append(tl);
}
function renderPlan(){
const box = document.getElementById("plan"); const plan = state.plan;
if(!plan){ box.innerHTML='<div class="empty">No response plan available.</div>'; return; }
document.getElementById("planMode").textContent = plan.mode + " / no apply";
box.innerHTML = "";
(plan.actions||[]).forEach(a=>{
const row = el("div","action");
const head = el("div","head"); head.append(el("b",null,a.id+" "+a.title));
head.append(el("span","risk "+a.risk,a.risk)); row.append(head);
row.append(el("code","cmd",(a.command||[]).join(" ")));
row.append(el("div","fine",a.reason)); box.append(row);
});
}
function renderTab(){
setTabButtons();
document.getElementById("incidentTab").hidden = state.tab !== "incident";
document.getElementById("alertsTab").hidden = state.tab !== "alerts";
document.getElementById("eventsTab").hidden = state.tab !== "events";
if(state.tab === "incident") renderIncident();
if(state.tab === "events") renderEvents();
}
function setTabButtons(){
document.querySelectorAll(".tabs button").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab));
}
function renderEvents(){
const box = document.getElementById("eventsTab");
box.innerHTML = '<div class="card"><h3>Event tail</h3><pre></pre></div>';
box.querySelector("pre").textContent = (state.events||[]).join("\n") || "No events.";
}
function showError(msg){
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
}
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; renderTab();});
document.getElementById("refreshBtn").onclick=refresh;
refresh();
setInterval(refresh, 10000);
</script>

View file

@ -1,23 +1,27 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only web dashboard, served by the stdlib http.server (zero deps).
"""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. By default it binds the host's Tailscale address and
requires a bearer token; on loopback the token is optional.
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, urlparse
from urllib.parse import parse_qs, unquote, urlparse
from . import __version__
from .config import Config
@ -79,6 +83,53 @@ def ensure_token(cfg: Config) -> str:
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]:
@ -156,6 +207,51 @@ def daemon_status(cfg: Config) -> dict:
}
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 {"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)
# --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
@ -203,6 +299,18 @@ class _Handler(BaseHTTPRequestHandler):
200 if alert else 404)
elif path == "/api/events":
self._json({"events": tail_events(cfg)})
elif path == "/api/incidents":
self._json(list_incidents(cfg))
elif path.startswith("/api/incidents/"):
iid = unquote(path.rsplit("/", 1)[-1])
inc = get_incident(cfg, iid)
self._json(inc if inc else {"error": "not found"},
200 if inc else 404)
elif path.startswith("/api/respond/plan/"):
iid = unquote(path.rsplit("/", 1)[-1])
plan = response_plan(cfg, iid)
self._json(plan if plan else {"error": "not found"},
200 if plan else 404)
else:
self._json({"error": "not found"}, 404)
@ -220,22 +328,29 @@ class _Handler(BaseHTTPRequestHandler):
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]:
"""Construct the server (without serving). Returns (httpd, bind, token)."""
"""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"http://{bind}:{cfg.web_port}{suffix}"
msg = f"{time.strftime('%FT%T%z')} dashboard serving at {url}"
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: