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 ## Web dashboard
A read-only console, served by the stdlib `http.server` (no Flask, no JS A read-only HTTPS management console, served by the stdlib `http.server` (no
framework, no CDN — one self-contained page): Flask, no JS framework, no CDN — one self-contained page):
```bash ```bash
enodia-sentinel web # serves on the Tailscale IP by default 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 - **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. 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 - **Bearer-token auth** (constant-time check); the token is auto-generated and
saved on first run and printed in the startup line. Open saved on first run and printed in the startup line. Open
`http://<tailscale-ip>:8787/?token=…`. `https://<tailscale-ip>:8787/?token=…`.
- **Read-only**: severity cards, a live alert list, and the full forensic - **Read-only management**: incidents, timelines, alert inventory, event tail,
snapshot per alert. No actions, no writes — minimal attack surface for and dry-run response plans. No commands are executed from the browser. JSON
sensitive data. JSON API at `/api/status`, `/api/alerts`, `/api/alerts/<id>`, API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/events`. `/api/alerts`, `/api/alerts/<id>`, `/api/events`.
## Phone push notifications ## Phone push notifications
@ -301,7 +304,7 @@ exposes its age. Run an external watcher on another host (over Tailscale) and
```bash ```bash
# on a SEPARATE machine — alerts you if the sensor or the whole box goes dark # 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):** **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. via package-ownership provenance.
v0.4 — adds a read-only **web dashboard** (stdlib server, Tailscale-bound, 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 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 + 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 web_token = "" # bearer token; auto-generated + saved if empty on a
# non-loopback bind. Set explicitly to keep the unit # non-loopback bind. Set explicitly to keep the unit
# fully read-only. # 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 ## 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` ### `status`
```bash ```bash
@ -235,6 +257,9 @@ enodia-sentinel web
Starts the read-only dashboard. By default it binds to the Tailscale interface Starts the read-only dashboard. By default it binds to the Tailscale interface
when available and uses bearer-token authentication for non-loopback binds. 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`. Expected use: `enodia-sentinel-web.service`.
@ -251,12 +276,14 @@ noise but never edits config automatically.
### `watchdog` ### `watchdog`
```bash ```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 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 stale heartbeat. Run this from a separate host. Use `--insecure-tls` only for
does not prove the protected host is alive. 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: Exit code:
@ -283,9 +310,8 @@ does not require pip or a virtualenv.
The roadmap reserves these command shapes: The roadmap reserves these command shapes:
```bash ```bash
enodia-sentinel respond plan <incident-id>
enodia-sentinel respond apply <plan-id> enodia-sentinel respond apply <plan-id>
``` ```
These should be introduced with stable JSON schemas and tests before they are State-changing response commands should be introduced with stable JSON schemas,
documented as supported. 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 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 6. Configure one off-box notification or watchdog path. A local-only alerting
path is not enough if the whole host goes silent. path is not enough if the whole host goes silent.
@ -76,29 +80,38 @@ When Sentinel fires:
and parent process. and parent process.
3. Run `enodia-sentinel triage` to separate known benign listener noise from 3. Run `enodia-sentinel triage` to separate known benign listener noise from
findings that need review. 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 ```bash
enodia-sentinel fim-check --packages enodia-sentinel fim-check --packages
enodia-sentinel pkgdb-verify --sample 200 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 ```bash
enodia-sentinel rootcheck enodia-sentinel rootcheck
enodia-sentinel pkgdb-check enodia-sentinel pkgdb-check
``` ```
6. Preserve evidence before changing state: 7. Preserve evidence before changing state:
```bash ```bash
cp -a /var/log/enodia-sentinel /tmp/enodia-sentinel-evidence cp -a /var/log/enodia-sentinel /tmp/enodia-sentinel-evidence
``` ```
7. Contain manually for now, following the matching playbook in 8. Contain manually for now, following the matching playbook in
[RUNBOOKS.md](RUNBOOKS.md). Future response commands should produce a dry-run [RUNBOOKS.md](RUNBOOKS.md). The response plan gives reviewed commands, but
plan first. applying them remains an explicit operator action.
## Common Findings ## 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. - Keep the dashboard bound to Tailscale or loopback, not a public interface.
- Set `web_token` explicitly for fully read-only service operation. - 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. - Enable at least one push backend.
- Run `watchdog` from another machine. - Run `watchdog` from another machine.
- Consider `chattr +i` for Sentinel binaries, systemd units, config, baselines, - 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. 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>`. `enodia-sentinel respond plan <incident-id>`.
- Add dry-run first actions: - Add dry-run first actions:
kill process, stop/disable service, block remote IP, quarantine file, restore 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. alerts, when was the last.
3. **Triage known noise.** `enodia-sentinel triage` separates likely false 3. **Triage known noise.** `enodia-sentinel triage` separates likely false
positives from findings that need a human. 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 ```bash
tar -C /var/log -czf /tmp/enodia-evidence-$(date +%s).tgz enodia-sentinel 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): - From the **watchdog host** (never the protected host):
```bash ```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. Exit 1 means down, unreachable, or stale.

View file

@ -75,7 +75,8 @@ Sentinel assumes:
package signature policy. package signature policy.
- `pacman -Qkk` trusts the local package database; it is useful but not an - `pacman -Qkk` trusts the local package database; it is useful but not an
independent root of trust. 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. - The current product does not perform automatic containment.
## Security Controls Already Present ## Security Controls Already Present
@ -90,7 +91,7 @@ Sentinel assumes:
| Signed-package verification | Checks files against package manifests independent of the local DB. | | Signed-package verification | Checks files against package manifests independent of the local DB. |
| Rootcheck | Finds common hiding artifacts by comparing independent views. | | Rootcheck | Finds common hiding artifacts by comparing independent views. |
| Heartbeat + watchdog | Makes a silent sensor observable from another machine. | | 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 ## 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"], po.add_argument("action", nargs="?", default="check", choices=["check"],
help="posture action (default: check)") help="posture action (default: check)")
po.add_argument("--json", action="store_true", help="emit findings as JSON") 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", wd = sub.add_parser("watchdog",
help="poll a remote dashboard and push if Sentinel is silent") help="poll a remote dashboard and push if Sentinel is silent")
wd.add_argument("--url", required=True, help="dashboard base URL") wd.add_argument("--url", required=True, help="dashboard base URL")
wd.add_argument("--token", default="", help="dashboard bearer token") wd.add_argument("--token", default="", help="dashboard bearer token")
wd.add_argument("--max-age", type=int, default=120, wd.add_argument("--max-age", type=int, default=120,
help="heartbeat staleness threshold (seconds)") 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) args = parser.parse_args(argv)
cfg = Config.load(args.config) 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) return _cmd_incident(cfg, args.action, args.id, args.json)
if args.cmd == "posture": if args.cmd == "posture":
return _cmd_posture(cfg, args.json) return _cmd_posture(cfg, args.json)
if args.cmd == "respond":
return _cmd_respond(cfg, args.action, args.id, args.json)
if args.cmd == "watchdog": 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) 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 . import notify
from .selfprotect import poll_status, watchdog_verdict 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) print(("OK: " if ok else "ALERT: ") + msg)
if not ok: if not ok:
n = notify.Notification( n = notify.Notification(
@ -328,6 +341,42 @@ def _cmd_posture(cfg: Config, as_json: bool) -> int:
return 1 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: def _cmd_fim_check(cfg: Config, packages: bool) -> int:
from . import fim from . import fim
sentinel = Sentinel(cfg) sentinel = Sentinel(cfg)

View file

@ -123,6 +123,8 @@ class Config:
web_bind: str = "" # "" = auto-detect Tailscale IP, else explicit web_bind: str = "" # "" = auto-detect Tailscale IP, else explicit
web_port: int = 8787 web_port: int = 8787
web_token: str = "" # bearer token; auto-generated if empty + non-local 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 # paths
log_dir: Path = Path("/var/log/enodia-sentinel") 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 ----------------------------------- # --- 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.""" """Fetch a remote dashboard's /api/status. None if unreachable."""
import json import json
import ssl
import urllib.request import urllib.request
base = url.rstrip("/") base = url.rstrip("/")
api = base if base.endswith("/api/status") else base + "/api/status" 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: if token:
req.add_header("Authorization", f"Bearer {token}") req.add_header("Authorization", f"Bearer {token}")
try: 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()) return json.loads(resp.read())
except Exception: except Exception:
return None return None

View file

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

View file

@ -1,23 +1,27 @@
# SPDX-License-Identifier: GPL-3.0-or-later # 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 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 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 sensitive forensic data. TLS is mandatory, using configured certificates or an
requires a bearer token; on loopback the token is optional. 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 from __future__ import annotations
import hmac import hmac
import ipaddress
import json import json
import os import os
import re import re
import secrets import secrets
import ssl
import subprocess import subprocess
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, unquote, urlparse
from . import __version__ from . import __version__
from .config import Config from .config import Config
@ -79,6 +83,53 @@ def ensure_token(cfg: Config) -> str:
return tok 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) ------------------------------------------ # --- data layer (pure, testable) ------------------------------------------
def list_alerts(cfg: Config, limit: int = 200) -> list[dict]: 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 ------------------------------------------------------------ # --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
@ -203,6 +299,18 @@ class _Handler(BaseHTTPRequestHandler):
200 if alert else 404) 200 if alert else 404)
elif path == "/api/events": elif path == "/api/events":
self._json({"events": tail_events(cfg)}) 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: else:
self._json({"error": "not found"}, 404) self._json({"error": "not found"}, 404)
@ -220,22 +328,29 @@ class _Handler(BaseHTTPRequestHandler):
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]: 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) bind = resolve_bind(cfg)
token = cfg.web_token token = cfg.web_token
if not is_loopback(bind) and not token: if not is_loopback(bind) and not token:
token = ensure_token(cfg) token = ensure_token(cfg)
cert, key = ensure_tls_cert(cfg, bind)
httpd = ThreadingHTTPServer((bind, cfg.web_port), _Handler) 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.cfg = cfg # type: ignore[attr-defined]
httpd.token = token # type: ignore[attr-defined] httpd.token = token # type: ignore[attr-defined]
httpd.tls_cert = cert # type: ignore[attr-defined]
return httpd, bind, token return httpd, bind, token
def serve(cfg: Config) -> None: def serve(cfg: Config) -> None:
httpd, bind, token = build_server(cfg) httpd, bind, token = build_server(cfg)
suffix = f"/?token={token}" if token else "/" suffix = f"/?token={token}" if token else "/"
url = f"http://{bind}:{cfg.web_port}{suffix}" url = f"https://{bind}:{cfg.web_port}{suffix}"
msg = f"{time.strftime('%FT%T%z')} dashboard serving at {url}" msg = (f"{time.strftime('%FT%T%z')} dashboard serving at {url} "
f"(TLS cert: {getattr(httpd, 'tls_cert', '')})")
print(msg, flush=True) print(msg, flush=True)
try: try:
with open(cfg.events_log, "a") as fh: 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 # 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. # it runs as root with an otherwise minimal surface and no capabilities.
ProtectSystem=strict ProtectSystem=strict
# Writable only so it can persist an auto-generated token; set web_token in the # Writable so it can persist an auto-generated token and self-signed TLS
# config to keep this purely read-only. # 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 ReadWritePaths=/var/log/enodia-sentinel
ProtectHome=yes ProtectHome=yes
NoNewPrivileges=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 # SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the read-only web dashboard: data layer + auth (real server).""" """Tests for the read-only web dashboard: data layer + auth (real server)."""
import json import json
import ssl
import tempfile import tempfile
import threading import threading
import unittest import unittest
@ -9,6 +10,8 @@ import urllib.request
from pathlib import Path from pathlib import Path
from enodia_sentinel import web from enodia_sentinel import web
from enodia_sentinel import incident
from enodia_sentinel.alert import Alert, Severity
from enodia_sentinel.config import Config 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") (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): class TestDataLayer(unittest.TestCase):
def setUp(self): def setUp(self):
self.dir = tempfile.TemporaryDirectory() self.dir = tempfile.TemporaryDirectory()
@ -60,6 +68,20 @@ class TestDataLayer(unittest.TestCase):
(self.tmp / "events.log").write_text("l1\nl2\nl3\n") (self.tmp / "events.log").write_text("l1\nl2\nl3\n")
self.assertEqual(web.tail_events(self.cfg, 2), ["l2", "l3"]) 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): class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self): def test_is_loopback(self):
@ -79,6 +101,13 @@ class TestNetworkHelpers(unittest.TestCase):
self.assertTrue(t1) self.assertTrue(t1)
self.assertEqual(t1, t2) # stable across calls 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): class TestAuth(unittest.TestCase):
"""Spin up the real server on loopback and check token enforcement.""" """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_port = 0 # ephemeral
self.cfg.web_token = "secret-token" self.cfg.web_token = "secret-token"
self.httpd, _bind, _tok = web.build_server(self.cfg) 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.port = self.httpd.server_address[1]
self.t = threading.Thread(target=self.httpd.serve_forever, daemon=True) self.t = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.t.start() self.t.start()
self.ctx = ssl._create_unverified_context()
def tearDown(self): def tearDown(self):
self.httpd.shutdown() self.httpd.shutdown()
self.httpd.server_close()
self.dir.cleanup() self.dir.cleanup()
def _get(self, path, token=None): 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) req = urllib.request.Request(url)
if token: if token:
req.add_header("Authorization", f"Bearer {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): def test_unauthorized_without_token(self):
with self.assertRaises(urllib.error.HTTPError) as cm: with self.assertRaises(urllib.error.HTTPError) as cm: