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

@ -234,8 +234,8 @@ enodia-sentinel.service`. Every key is optional. Highlights:
## Web dashboard
A read-only console, served by the stdlib `http.server` (no Flask, no JS
framework, no CDN — one self-contained page):
A read-only HTTPS management console, served by the stdlib `http.server` (no
Flask, no JS framework, no CDN — one self-contained page):
```bash
enodia-sentinel web # serves on the Tailscale IP by default
@ -245,13 +245,16 @@ sudo systemctl enable --now enodia-sentinel-web
- **Bound to your Tailscale interface** by default (auto-detected), so it's
reachable from your phone/laptop on the tailnet but not the LAN or internet.
- **TLS is mandatory.** If `web_tls_cert` / `web_tls_key` are unset, Sentinel
auto-generates a self-signed certificate under `log_dir`. Add a browser
exception for now, or point config at a local/private CA certificate.
- **Bearer-token auth** (constant-time check); the token is auto-generated and
saved on first run and printed in the startup line. Open
`http://<tailscale-ip>:8787/?token=…`.
- **Read-only**: severity cards, a live alert list, and the full forensic
snapshot per alert. No actions, no writes — minimal attack surface for
sensitive data. JSON API at `/api/status`, `/api/alerts`, `/api/alerts/<id>`,
`/api/events`.
`https://<tailscale-ip>:8787/?token=…`.
- **Read-only management**: incidents, timelines, alert inventory, event tail,
and dry-run response plans. No commands are executed from the browser. JSON
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/alerts`, `/api/alerts/<id>`, `/api/events`.
## Phone push notifications
@ -301,7 +304,7 @@ exposes its age. Run an external watcher on another host (over Tailscale) and
```bash
# on a SEPARATE machine — alerts you if the sensor or the whole box goes dark
enodia-sentinel watchdog --url http://100.x.x.x:8787 --token <T> --max-age 120
enodia-sentinel watchdog --url https://100.x.x.x:8787 --token <T> --max-age 120 --insecure-tls
```
**Hardening (the layers beyond on-box detection):**
@ -497,7 +500,8 @@ verification, auto-refreshed by a pacman hook) and **false-positive triage**
via package-ownership provenance.
v0.4 — adds a read-only **web dashboard** (stdlib server, Tailscale-bound,
token-auth) and **phone push** (ntfy / Pushover / webhook), both zero-dependency.
token-auth; now HTTPS-only) and **phone push** (ntfy / Pushover / webhook),
both zero-dependency.
v0.3 — adds the event-driven **eBPF layer**: a real `bcc` execve probe feeding a
Snort-style declarative rule engine (4 default rules), stable signature IDs +

View file

@ -158,3 +158,8 @@ web_port = 8787
web_token = "" # bearer token; auto-generated + saved if empty on a
# non-loopback bind. Set explicitly to keep the unit
# fully read-only.
# HTTPS is mandatory. If these are blank, the dashboard auto-generates a
# self-signed cert/key under log_dir. Add a browser exception for that cert, or
# point these at your own local/private CA certificate.
web_tls_cert = ""
web_tls_key = ""

View file

@ -210,6 +210,28 @@ Exit code:
## Evidence and Operator Commands
### `respond`
```bash
enodia-sentinel respond plan <incident-id>
enodia-sentinel respond plan <incident-id> --json
```
Builds a dry-run response plan from an incident's retained JSON snapshots. The
plan may include evidence preservation, process freeze/terminate candidates,
outbound IP blocks, systemd unit disablement, suspicious-file quarantine,
package-restore lookup, and follow-up verification checks.
This command is intentionally read-only: it prints commands for operator review
and never executes them. The JSON schema is `enodia.response.plan.v1` and marks
`apply_supported: false` until an audited apply workflow exists.
Exit code:
- `0`: plan generated.
- `1`: unknown incident id.
- `2`: missing incident id.
### `status`
```bash
@ -235,6 +257,9 @@ enodia-sentinel web
Starts the read-only dashboard. By default it binds to the Tailscale interface
when available and uses bearer-token authentication for non-loopback binds.
HTTPS is mandatory. If `web_tls_cert` / `web_tls_key` are not configured,
Sentinel creates a self-signed pair under `log_dir`; add a browser exception for
that certificate or replace it with a private/local CA certificate.
Expected use: `enodia-sentinel-web.service`.
@ -251,12 +276,14 @@ noise but never edits config automatically.
### `watchdog`
```bash
enodia-sentinel watchdog --url http://100.x.y.z:8787 --token <token> --max-age 120
enodia-sentinel watchdog --url https://100.x.y.z:8787 --token <token> --max-age 120
enodia-sentinel watchdog --url https://100.x.y.z:8787 --token <token> --insecure-tls
```
Polls a remote dashboard and alerts if the sensor is down, unreachable, or has a
stale heartbeat. Run this from a separate host. A watchdog on the same machine
does not prove the protected host is alive.
stale heartbeat. Run this from a separate host. Use `--insecure-tls` only for
the built-in self-signed certificate while you have no local/private CA trust
path. A watchdog on the same machine does not prove the protected host is alive.
Exit code:
@ -283,9 +310,8 @@ does not require pip or a virtualenv.
The roadmap reserves these command shapes:
```bash
enodia-sentinel respond plan <incident-id>
enodia-sentinel respond apply <plan-id>
```
These should be introduced with stable JSON schemas and tests before they are
documented as supported.
State-changing response commands should be introduced with stable JSON schemas,
audit logs, and tests before they are documented as supported.

View file

@ -38,6 +38,10 @@ deliberately practical: what to run, what to expect, and what to check next.
sudo systemctl enable --now enodia-sentinel-web
```
Open the `https://...` URL from the service log. The default certificate is
self-signed, so add a browser exception or configure `web_tls_cert` and
`web_tls_key` with your own local/private CA certificate.
6. Configure one off-box notification or watchdog path. A local-only alerting
path is not enough if the whole host goes silent.
@ -76,29 +80,38 @@ When Sentinel fires:
and parent process.
3. Run `enodia-sentinel triage` to separate known benign listener noise from
findings that need review.
4. If the alert involves a binary, run:
4. Build a read-only response plan from the grouped incident:
```bash
enodia-sentinel incident list
enodia-sentinel respond plan <incident-id>
```
Review the plan before acting; Sentinel does not execute containment
commands.
5. If the alert involves a binary, run:
```bash
enodia-sentinel fim-check --packages
enodia-sentinel pkgdb-verify --sample 200
```
5. If the alert involves hiding or tampering, run:
6. If the alert involves hiding or tampering, run:
```bash
enodia-sentinel rootcheck
enodia-sentinel pkgdb-check
```
6. Preserve evidence before changing state:
7. Preserve evidence before changing state:
```bash
cp -a /var/log/enodia-sentinel /tmp/enodia-sentinel-evidence
```
7. Contain manually for now, following the matching playbook in
[RUNBOOKS.md](RUNBOOKS.md). Future response commands should produce a dry-run
plan first.
8. Contain manually for now, following the matching playbook in
[RUNBOOKS.md](RUNBOOKS.md). The response plan gives reviewed commands, but
applying them remains an explicit operator action.
## Common Findings
@ -129,6 +142,9 @@ Baselines are useful only if they represent a known-good state.
- Keep the dashboard bound to Tailscale or loopback, not a public interface.
- Set `web_token` explicitly for fully read-only service operation.
- Keep HTTPS enabled. The built-in self-signed certificate is acceptable for a
single host after you add an exception; use a private/local CA for cleaner
browser trust.
- Enable at least one push backend.
- Run `watchdog` from another machine.
- Consider `chattr +i` for Sentinel binaries, systemd units, config, baselines,

View file

@ -49,7 +49,7 @@ Exit criteria:
Purpose: move from "tell me" to "help me act" without unsafe automation.
- Add response plan generation:
- Add response plan generation:
`enodia-sentinel respond plan <incident-id>`.
- Add dry-run first actions:
kill process, stop/disable service, block remote IP, quarantine file, restore

View file

@ -21,7 +21,15 @@ For any alert, before changing anything:
alerts, when was the last.
3. **Triage known noise.** `enodia-sentinel triage` separates likely false
positives from findings that need a human.
4. **Preserve evidence before you touch anything:**
4. **Build a dry-run response plan.** Find the incident and review the proposed
actions:
```bash
enodia-sentinel incident list
enodia-sentinel respond plan <incident-id>
```
5. **Preserve evidence before you touch anything:**
```bash
tar -C /var/log -czf /tmp/enodia-evidence-$(date +%s).tgz enodia-sentinel
@ -208,7 +216,7 @@ edited Sentinel is itself an alert.
- From the **watchdog host** (never the protected host):
```bash
enodia-sentinel watchdog --url http://<host>:8787 --token <token> --max-age 120
enodia-sentinel watchdog --url https://<host>:8787 --token <token> --max-age 120
```
Exit 1 means down, unreachable, or stale.

View file

@ -75,7 +75,8 @@ Sentinel assumes:
package signature policy.
- `pacman -Qkk` trusts the local package database; it is useful but not an
independent root of trust.
- The dashboard is read-only by design; it is not a remote response console.
- The dashboard is read-only by design; it can display dry-run response plans
but does not execute containment commands.
- The current product does not perform automatic containment.
## Security Controls Already Present
@ -90,7 +91,7 @@ Sentinel assumes:
| Signed-package verification | Checks files against package manifests independent of the local DB. |
| Rootcheck | Finds common hiding artifacts by comparing independent views. |
| Heartbeat + watchdog | Makes a silent sensor observable from another machine. |
| Read-only dashboard | Exposes evidence without adding a remote write path. |
| HTTPS read-only dashboard | Exposes evidence and dry-run plans without adding a remote write path. |
## Response Safety

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 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");
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 [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 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 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);
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);
});
}
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 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:

View file

@ -14,8 +14,9 @@ RestartSec=5
# lock it down hard. It does not need root, but reads root-owned snapshots, so
# it runs as root with an otherwise minimal surface and no capabilities.
ProtectSystem=strict
# Writable only so it can persist an auto-generated token; set web_token in the
# config to keep this purely read-only.
# Writable so it can persist an auto-generated token and self-signed TLS
# certificate/key. Set web_token, web_tls_cert, and web_tls_key in config to
# keep this service purely read-only after provisioning.
ReadWritePaths=/var/log/enodia-sentinel
ProtectHome=yes
NoNewPrivileges=yes

111
tests/test_respond.py Normal file
View file

@ -0,0 +1,111 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import io
import json
import os
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from enodia_sentinel import incident, respond
from enodia_sentinel.alert import Alert, Severity
from enodia_sentinel.cli import main
from enodia_sentinel.config import Config
def _alert(sig, detail, pids=(), sid=1, sev=Severity.HIGH):
return Alert(severity=sev, signature=sig, key=f"k:{sig}",
detail=detail, pids=tuple(pids), sid=sid, classtype="test")
class TestRespondPlan(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.tmp = Path(self.dir.name)
self.cfg = Config()
self.cfg.log_dir = self.tmp
self.iid = incident.record(self.cfg, "alert-1.log", [
_alert("reverse_shell",
"pid=4242 comm=bash stdio=net-socket peer=[8.8.8.8:4444]",
pids=[4242], sid=100010, sev=Severity.CRITICAL),
_alert("persistence",
"persistence file modified: /etc/systemd/system/bad.service",
sid=100015),
_alert("new_suid",
"SUID/SGID binary in writable dir: /tmp/suidsh",
sid=100014, sev=Severity.CRITICAL),
], lineage={4242, 100}, when=1000.0, host="h")
(self.tmp / "alert-1.json").write_text(json.dumps({
"time": "2026-06-10T10:00:00-07:00",
"host": "h",
"severity": "CRITICAL",
"incident_id": self.iid,
"alerts": [
{"signature": "reverse_shell", "sid": 100010,
"detail": "pid=4242 comm=bash stdio=net-socket peer=[8.8.8.8:4444]",
"pids": [4242]},
{"signature": "persistence", "sid": 100015,
"detail": "persistence file modified: /etc/systemd/system/bad.service",
"pids": []},
{"signature": "new_suid", "sid": 100014,
"detail": "SUID/SGID binary in writable dir: /tmp/suidsh",
"pids": []},
],
"processes": [{"pid": 4242, "comm": "bash"}],
}))
def tearDown(self):
self.dir.cleanup()
def test_builds_dry_run_plan_from_incident(self):
bundle = respond.load_bundle(self.cfg, self.iid)
plan = respond.build_plan(bundle, self.cfg)
self.assertEqual(plan["mode"], "dry-run")
self.assertFalse(plan["apply_supported"])
commands = [a["command"] for a in plan["actions"]]
self.assertIn(["kill", "-STOP", "4242"], commands)
self.assertIn(["kill", "-TERM", "4242"], commands)
self.assertIn(["nft", "add", "rule", "inet", "filter", "output",
"ip", "daddr", "8.8.8.8", "drop"], commands)
self.assertIn(["systemctl", "disable", "--now", "bad.service"], commands)
self.assertIn(["mv", "--", "/tmp/suidsh",
"/tmp/suidsh.enodia-quarantine"], commands)
self.assertNotIn(["mv", "--", "/etc/systemd/system/bad.service",
"/etc/systemd/system/bad.service.enodia-quarantine"],
commands)
self.assertEqual(plan["actions"][0]["category"], "evidence")
def test_cli_json(self):
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
try:
buf = io.StringIO()
with redirect_stdout(buf):
code = main(["respond", "plan", self.iid, "--json"])
self.assertEqual(code, 0)
plan = json.loads(buf.getvalue())
self.assertEqual(plan["incident_id"], self.iid)
self.assertGreaterEqual(plan["summary"]["action_count"], 5)
finally:
os.environ.pop("ENODIA_LOG_DIR", None)
def test_cli_requires_incident_id(self):
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
try:
with redirect_stdout(io.StringIO()):
code = main(["respond", "plan"])
self.assertEqual(code, 2)
finally:
os.environ.pop("ENODIA_LOG_DIR", None)
def test_unknown_incident(self):
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
try:
with redirect_stdout(io.StringIO()):
code = main(["respond", "plan", "inc-nope"])
self.assertEqual(code, 1)
finally:
os.environ.pop("ENODIA_LOG_DIR", None)
if __name__ == "__main__":
unittest.main()

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the read-only web dashboard: data layer + auth (real server)."""
import json
import ssl
import tempfile
import threading
import unittest
@ -9,6 +10,8 @@ import urllib.request
from pathlib import Path
from enodia_sentinel import web
from enodia_sentinel import incident
from enodia_sentinel.alert import Alert, Severity
from enodia_sentinel.config import Config
@ -27,6 +30,11 @@ def _write_alert(tmp: Path, name: str, severity: str, sigs):
(tmp / f"{name}.log").write_text(f"=== ENODIA SENTINEL ALERT ===\n{severity}\n")
def _incident_alert(sig: str, pids=()):
return Alert(Severity.CRITICAL, sig, f"k:{sig}", f"{sig} detail",
tuple(pids), sid=100010, classtype="test")
class TestDataLayer(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
@ -60,6 +68,20 @@ class TestDataLayer(unittest.TestCase):
(self.tmp / "events.log").write_text("l1\nl2\nl3\n")
self.assertEqual(web.tail_events(self.cfg, 2), ["l2", "l3"])
def test_incident_and_response_plan_data(self):
_write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["reverse_shell"])
iid = incident.record(self.cfg, "alert-20260531-000001.log",
[_incident_alert("reverse_shell", [4242])],
lineage={4242}, when=1000.0, host="h")
incs = web.list_incidents(self.cfg)
self.assertEqual(incs[0]["id"], iid)
inc = web.get_incident(self.cfg, iid)
self.assertEqual(inc["incident"]["id"], iid)
self.assertEqual(len(inc["timeline"]), 1)
plan = web.response_plan(self.cfg, iid)
self.assertEqual(plan["incident_id"], iid)
self.assertEqual(plan["mode"], "dry-run")
class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self):
@ -79,6 +101,13 @@ class TestNetworkHelpers(unittest.TestCase):
self.assertTrue(t1)
self.assertEqual(t1, t2) # stable across calls
def test_self_signed_tls_material_is_created(self):
with tempfile.TemporaryDirectory() as d:
c = _make_cfg(Path(d))
cert, key = web.ensure_tls_cert(c, "127.0.0.1")
self.assertTrue(cert.is_file())
self.assertTrue(key.is_file())
class TestAuth(unittest.TestCase):
"""Spin up the real server on loopback and check token enforcement."""
@ -92,20 +121,23 @@ class TestAuth(unittest.TestCase):
self.cfg.web_port = 0 # ephemeral
self.cfg.web_token = "secret-token"
self.httpd, _bind, _tok = web.build_server(self.cfg)
self.assertTrue(Path(self.httpd.tls_cert).is_file())
self.port = self.httpd.server_address[1]
self.t = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.t.start()
self.ctx = ssl._create_unverified_context()
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
self.dir.cleanup()
def _get(self, path, token=None):
url = f"http://127.0.0.1:{self.port}{path}"
url = f"https://127.0.0.1:{self.port}{path}"
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"Bearer {token}")
return urllib.request.urlopen(req, timeout=4)
return urllib.request.urlopen(req, timeout=4, context=self.ctx)
def test_unauthorized_without_token(self):
with self.assertRaises(urllib.error.HTTPError) as cm: