From a7129e5666476cec7f18e34d9abfe032b6567d56 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 12 Jun 2026 02:39:53 -0700 Subject: [PATCH] Add HTTPS management console and response planning --- README.md | 22 +- config/enodia-sentinel.toml | 5 + docs/COMMAND_REFERENCE.md | 38 ++- docs/OPERATIONS.md | 28 +- docs/ROADMAP.md | 2 +- docs/RUNBOOKS.md | 12 +- docs/THREAT_MODEL.md | 5 +- enodia_sentinel/cli.py | 55 +++- enodia_sentinel/config.py | 2 + enodia_sentinel/respond.py | 272 +++++++++++++++++++ enodia_sentinel/selfprotect.py | 7 +- enodia_sentinel/static/dashboard.html | 358 +++++++++++++++++--------- enodia_sentinel/web.py | 129 +++++++++- systemd/enodia-sentinel-web.service | 5 +- tests/test_respond.py | 111 ++++++++ tests/test_web.py | 36 ++- 16 files changed, 927 insertions(+), 160 deletions(-) create mode 100644 enodia_sentinel/respond.py create mode 100644 tests/test_respond.py diff --git a/README.md b/README.md index af00d81..976604c 100644 --- a/README.md +++ b/README.md @@ -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://: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/`, - `/api/events`. + `https://: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/`, + `/api/alerts`, `/api/alerts/`, `/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 --max-age 120 +enodia-sentinel watchdog --url https://100.x.x.x:8787 --token --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 + diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 1314893..5d957d2 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -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 = "" diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 11bb913..04b3892 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -210,6 +210,28 @@ Exit code: ## Evidence and Operator Commands +### `respond` + +```bash +enodia-sentinel respond plan +enodia-sentinel respond plan --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 --max-age 120 +enodia-sentinel watchdog --url https://100.x.y.z:8787 --token --max-age 120 +enodia-sentinel watchdog --url https://100.x.y.z:8787 --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 enodia-sentinel respond apply ``` -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. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index e2676e3..8430805 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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 + ``` + + 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, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 320ab1d..7e06a5b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 `. - Add dry-run first actions: kill process, stop/disable service, block remote IP, quarantine file, restore diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md index aebf3d1..1483a77 100644 --- a/docs/RUNBOOKS.md +++ b/docs/RUNBOOKS.md @@ -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 + ``` + +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://:8787 --token --max-age 120 + enodia-sentinel watchdog --url https://:8787 --token --max-age 120 ``` Exit 1 means down, unreachable, or stale. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 4cad961..071d22b 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -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 diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index ee0833e..2bc1da2 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -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) diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index 1e7d1ad..1e4a622 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -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") diff --git a/enodia_sentinel/respond.py b/enodia_sentinel/respond.py new file mode 100644 index 0000000..55a05f3 --- /dev/null +++ b/enodia_sentinel/respond.py @@ -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"(? 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"(? 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 diff --git a/enodia_sentinel/selfprotect.py b/enodia_sentinel/selfprotect.py index 4f71a10..a388fd6 100644 --- a/enodia_sentinel/selfprotect.py +++ b/enodia_sentinel/selfprotect.py @@ -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 diff --git a/enodia_sentinel/static/dashboard.html b/enodia_sentinel/static/dashboard.html index 71b828d..ffe4afc 100644 --- a/enodia_sentinel/static/dashboard.html +++ b/enodia_sentinel/static/dashboard.html @@ -7,156 +7,278 @@ Enodia Sentinel
-

ENODIA SENTINEL

- - +

Enodia Sentinel

+ connecting + HTTPS self-signed + host unknown - - + eBPF unknown +
-
+
+ -
-
loading…
-
Select an alert to view its forensic snapshot.
-
+
+
+
+ + + +
+
+ + +
-
- connecting… - - -
+
+
Dry-run Response Planread-only
+
Select an incident to generate a plan.
+
+
diff --git a/enodia_sentinel/web.py b/enodia_sentinel/web.py index 16b9960..832bdaf 100644 --- a/enodia_sentinel/web.py +++ b/enodia_sentinel/web.py @@ -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: diff --git a/systemd/enodia-sentinel-web.service b/systemd/enodia-sentinel-web.service index e046f54..e1e247d 100644 --- a/systemd/enodia-sentinel-web.service +++ b/systemd/enodia-sentinel-web.service @@ -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 diff --git a/tests/test_respond.py b/tests/test_respond.py new file mode 100644 index 0000000..bf4c2fb --- /dev/null +++ b/tests/test_respond.py @@ -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() diff --git a/tests/test_web.py b/tests/test_web.py index 5316227..534595f 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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: