Add HTTPS management console and response planning
This commit is contained in:
parent
a56d72edd6
commit
a7129e5666
16 changed files with 927 additions and 160 deletions
22
README.md
22
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://<tailscale-ip>:8787/?token=…`.
|
||||
- **Read-only**: severity cards, a live alert list, and the full forensic
|
||||
snapshot per alert. No actions, no writes — minimal attack surface for
|
||||
sensitive data. JSON API at `/api/status`, `/api/alerts`, `/api/alerts/<id>`,
|
||||
`/api/events`.
|
||||
`https://<tailscale-ip>:8787/?token=…`.
|
||||
- **Read-only management**: incidents, timelines, alert inventory, event tail,
|
||||
and dry-run response plans. No commands are executed from the browser. JSON
|
||||
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
|
||||
`/api/alerts`, `/api/alerts/<id>`, `/api/events`.
|
||||
|
||||
## Phone push notifications
|
||||
|
||||
|
|
@ -301,7 +304,7 @@ exposes its age. Run an external watcher on another host (over Tailscale) and
|
|||
|
||||
```bash
|
||||
# on a SEPARATE machine — alerts you if the sensor or the whole box goes dark
|
||||
enodia-sentinel watchdog --url http://100.x.x.x:8787 --token <T> --max-age 120
|
||||
enodia-sentinel watchdog --url https://100.x.x.x:8787 --token <T> --max-age 120 --insecure-tls
|
||||
```
|
||||
|
||||
**Hardening (the layers beyond on-box detection):**
|
||||
|
|
@ -497,7 +500,8 @@ verification, auto-refreshed by a pacman hook) and **false-positive triage**
|
|||
via package-ownership provenance.
|
||||
|
||||
v0.4 — adds a read-only **web dashboard** (stdlib server, Tailscale-bound,
|
||||
token-auth) and **phone push** (ntfy / Pushover / webhook), both zero-dependency.
|
||||
token-auth; now HTTPS-only) and **phone push** (ntfy / Pushover / webhook),
|
||||
both zero-dependency.
|
||||
|
||||
v0.3 — adds the event-driven **eBPF layer**: a real `bcc` execve probe feeding a
|
||||
Snort-style declarative rule engine (4 default rules), stable signature IDs +
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -210,6 +210,28 @@ Exit code:
|
|||
|
||||
## Evidence and Operator Commands
|
||||
|
||||
### `respond`
|
||||
|
||||
```bash
|
||||
enodia-sentinel respond plan <incident-id>
|
||||
enodia-sentinel respond plan <incident-id> --json
|
||||
```
|
||||
|
||||
Builds a dry-run response plan from an incident's retained JSON snapshots. The
|
||||
plan may include evidence preservation, process freeze/terminate candidates,
|
||||
outbound IP blocks, systemd unit disablement, suspicious-file quarantine,
|
||||
package-restore lookup, and follow-up verification checks.
|
||||
|
||||
This command is intentionally read-only: it prints commands for operator review
|
||||
and never executes them. The JSON schema is `enodia.response.plan.v1` and marks
|
||||
`apply_supported: false` until an audited apply workflow exists.
|
||||
|
||||
Exit code:
|
||||
|
||||
- `0`: plan generated.
|
||||
- `1`: unknown incident id.
|
||||
- `2`: missing incident id.
|
||||
|
||||
### `status`
|
||||
|
||||
```bash
|
||||
|
|
@ -235,6 +257,9 @@ enodia-sentinel web
|
|||
|
||||
Starts the read-only dashboard. By default it binds to the Tailscale interface
|
||||
when available and uses bearer-token authentication for non-loopback binds.
|
||||
HTTPS is mandatory. If `web_tls_cert` / `web_tls_key` are not configured,
|
||||
Sentinel creates a self-signed pair under `log_dir`; add a browser exception for
|
||||
that certificate or replace it with a private/local CA certificate.
|
||||
|
||||
Expected use: `enodia-sentinel-web.service`.
|
||||
|
||||
|
|
@ -251,12 +276,14 @@ noise but never edits config automatically.
|
|||
### `watchdog`
|
||||
|
||||
```bash
|
||||
enodia-sentinel watchdog --url http://100.x.y.z:8787 --token <token> --max-age 120
|
||||
enodia-sentinel watchdog --url https://100.x.y.z:8787 --token <token> --max-age 120
|
||||
enodia-sentinel watchdog --url https://100.x.y.z:8787 --token <token> --insecure-tls
|
||||
```
|
||||
|
||||
Polls a remote dashboard and alerts if the sensor is down, unreachable, or has a
|
||||
stale heartbeat. Run this from a separate host. A watchdog on the same machine
|
||||
does not prove the protected host is alive.
|
||||
stale heartbeat. Run this from a separate host. Use `--insecure-tls` only for
|
||||
the built-in self-signed certificate while you have no local/private CA trust
|
||||
path. A watchdog on the same machine does not prove the protected host is alive.
|
||||
|
||||
Exit code:
|
||||
|
||||
|
|
@ -283,9 +310,8 @@ does not require pip or a virtualenv.
|
|||
The roadmap reserves these command shapes:
|
||||
|
||||
```bash
|
||||
enodia-sentinel respond plan <incident-id>
|
||||
enodia-sentinel respond apply <plan-id>
|
||||
```
|
||||
|
||||
These should be introduced with stable JSON schemas and tests before they are
|
||||
documented as supported.
|
||||
State-changing response commands should be introduced with stable JSON schemas,
|
||||
audit logs, and tests before they are documented as supported.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ deliberately practical: what to run, what to expect, and what to check next.
|
|||
sudo systemctl enable --now enodia-sentinel-web
|
||||
```
|
||||
|
||||
Open the `https://...` URL from the service log. The default certificate is
|
||||
self-signed, so add a browser exception or configure `web_tls_cert` and
|
||||
`web_tls_key` with your own local/private CA certificate.
|
||||
|
||||
6. Configure one off-box notification or watchdog path. A local-only alerting
|
||||
path is not enough if the whole host goes silent.
|
||||
|
||||
|
|
@ -76,29 +80,38 @@ When Sentinel fires:
|
|||
and parent process.
|
||||
3. Run `enodia-sentinel triage` to separate known benign listener noise from
|
||||
findings that need review.
|
||||
4. If the alert involves a binary, run:
|
||||
4. Build a read-only response plan from the grouped incident:
|
||||
|
||||
```bash
|
||||
enodia-sentinel incident list
|
||||
enodia-sentinel respond plan <incident-id>
|
||||
```
|
||||
|
||||
Review the plan before acting; Sentinel does not execute containment
|
||||
commands.
|
||||
5. If the alert involves a binary, run:
|
||||
|
||||
```bash
|
||||
enodia-sentinel fim-check --packages
|
||||
enodia-sentinel pkgdb-verify --sample 200
|
||||
```
|
||||
|
||||
5. If the alert involves hiding or tampering, run:
|
||||
6. If the alert involves hiding or tampering, run:
|
||||
|
||||
```bash
|
||||
enodia-sentinel rootcheck
|
||||
enodia-sentinel pkgdb-check
|
||||
```
|
||||
|
||||
6. Preserve evidence before changing state:
|
||||
7. Preserve evidence before changing state:
|
||||
|
||||
```bash
|
||||
cp -a /var/log/enodia-sentinel /tmp/enodia-sentinel-evidence
|
||||
```
|
||||
|
||||
7. Contain manually for now, following the matching playbook in
|
||||
[RUNBOOKS.md](RUNBOOKS.md). Future response commands should produce a dry-run
|
||||
plan first.
|
||||
8. Contain manually for now, following the matching playbook in
|
||||
[RUNBOOKS.md](RUNBOOKS.md). The response plan gives reviewed commands, but
|
||||
applying them remains an explicit operator action.
|
||||
|
||||
## Common Findings
|
||||
|
||||
|
|
@ -129,6 +142,9 @@ Baselines are useful only if they represent a known-good state.
|
|||
|
||||
- Keep the dashboard bound to Tailscale or loopback, not a public interface.
|
||||
- Set `web_token` explicitly for fully read-only service operation.
|
||||
- Keep HTTPS enabled. The built-in self-signed certificate is acceptable for a
|
||||
single host after you add an exception; use a private/local CA for cleaner
|
||||
browser trust.
|
||||
- Enable at least one push backend.
|
||||
- Run `watchdog` from another machine.
|
||||
- Consider `chattr +i` for Sentinel binaries, systemd units, config, baselines,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ Exit criteria:
|
|||
|
||||
Purpose: move from "tell me" to "help me act" without unsafe automation.
|
||||
|
||||
- Add response plan generation:
|
||||
- ✅ Add response plan generation:
|
||||
`enodia-sentinel respond plan <incident-id>`.
|
||||
- Add dry-run first actions:
|
||||
kill process, stop/disable service, block remote IP, quarantine file, restore
|
||||
|
|
|
|||
|
|
@ -21,7 +21,15 @@ For any alert, before changing anything:
|
|||
alerts, when was the last.
|
||||
3. **Triage known noise.** `enodia-sentinel triage` separates likely false
|
||||
positives from findings that need a human.
|
||||
4. **Preserve evidence before you touch anything:**
|
||||
4. **Build a dry-run response plan.** Find the incident and review the proposed
|
||||
actions:
|
||||
|
||||
```bash
|
||||
enodia-sentinel incident list
|
||||
enodia-sentinel respond plan <incident-id>
|
||||
```
|
||||
|
||||
5. **Preserve evidence before you touch anything:**
|
||||
|
||||
```bash
|
||||
tar -C /var/log -czf /tmp/enodia-evidence-$(date +%s).tgz enodia-sentinel
|
||||
|
|
@ -208,7 +216,7 @@ edited Sentinel is itself an alert.
|
|||
- From the **watchdog host** (never the protected host):
|
||||
|
||||
```bash
|
||||
enodia-sentinel watchdog --url http://<host>:8787 --token <token> --max-age 120
|
||||
enodia-sentinel watchdog --url https://<host>:8787 --token <token> --max-age 120
|
||||
```
|
||||
|
||||
Exit 1 means down, unreachable, or stale.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@ class Config:
|
|||
web_bind: str = "" # "" = auto-detect Tailscale IP, else explicit
|
||||
web_port: int = 8787
|
||||
web_token: str = "" # bearer token; auto-generated if empty + non-local
|
||||
web_tls_cert: str = "" # "" = auto-generate self-signed cert under log_dir
|
||||
web_tls_key: str = "" # "" = auto-generate private key under log_dir
|
||||
|
||||
# paths
|
||||
log_dir: Path = Path("/var/log/enodia-sentinel")
|
||||
|
|
|
|||
272
enodia_sentinel/respond.py
Normal file
272
enodia_sentinel/respond.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,156 +7,278 @@
|
|||
<title>Enodia Sentinel</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0e14; --panel:#11151f; --panel2:#161b27; --line:#222a3a;
|
||||
--fg:#cdd6e4; --dim:#7c8aa5; --accent:#5ad1c8;
|
||||
--crit:#ff5370; --high:#ffb454; --med:#73d0ff;
|
||||
--bg:#0a0c0f; --ink:#d8dee7; --muted:#8590a3; --soft:#aeb7c7;
|
||||
--panel:#11151b; --panel2:#151b23; --rail:#0d1117; --line:#26313f;
|
||||
--accent:#22c7a9; --amber:#f2b84b; --red:#f06476; --blue:#61a8ff;
|
||||
--ok:#56d364; --shadow:0 18px 48px rgba(0,0,0,.28);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.5 ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace}
|
||||
header{display:flex;align-items:center;gap:14px;padding:14px 20px;
|
||||
border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
header h1{font-size:16px;margin:0;letter-spacing:.18em;color:var(--accent)}
|
||||
header .host{color:var(--dim)}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:#444;display:inline-block}
|
||||
.dot.up{background:#46d160;box-shadow:0 0 8px #46d160}
|
||||
.dot.down{background:var(--crit)}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font:14px/1.45 "Aptos Mono","IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace}
|
||||
body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.18;
|
||||
background-image:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px);
|
||||
background-size:28px 28px}
|
||||
button{font:inherit;color:inherit;background:var(--panel2);border:1px solid var(--line);
|
||||
border-radius:6px;padding:8px 10px;cursor:pointer}
|
||||
button:hover{border-color:var(--accent);color:#fff}
|
||||
header{height:58px;display:flex;align-items:center;gap:16px;padding:0 18px;
|
||||
border-bottom:1px solid var(--line);background:rgba(13,17,23,.96);position:relative;z-index:2}
|
||||
h1{font-size:15px;margin:0;color:#fff;letter-spacing:.16em;text-transform:uppercase}
|
||||
.pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);
|
||||
border-radius:999px;padding:4px 9px;color:var(--soft);font-size:12px}
|
||||
.dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 12px var(--red)}
|
||||
.dot.up{background:var(--ok);box-shadow:0 0 12px var(--ok)}
|
||||
.spacer{flex:1}
|
||||
.meta{color:var(--dim);font-size:12px}
|
||||
.cards{display:flex;gap:12px;padding:16px 20px;flex-wrap:wrap}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
|
||||
padding:12px 16px;min-width:120px}
|
||||
.card .n{font-size:26px;font-weight:700}
|
||||
.card .l{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.1em}
|
||||
.card.crit .n{color:var(--crit)} .card.high .n{color:var(--high)}
|
||||
.card.med .n{color:var(--med)}
|
||||
main{display:grid;grid-template-columns:minmax(340px,460px) 1fr;gap:0;
|
||||
border-top:1px solid var(--line);height:calc(100vh - 180px)}
|
||||
.list,.detail{overflow:auto;height:100%}
|
||||
.list{border-right:1px solid var(--line)}
|
||||
.row{padding:10px 16px;border-bottom:1px solid var(--line);cursor:pointer}
|
||||
.row:hover{background:var(--panel2)} .row.sel{background:var(--panel2);
|
||||
border-left:3px solid var(--accent);padding-left:13px}
|
||||
.row .top{display:flex;justify-content:space-between;gap:8px}
|
||||
.sev{font-weight:700;font-size:11px;padding:1px 7px;border-radius:4px}
|
||||
.sev.CRITICAL{color:var(--crit);border:1px solid var(--crit)}
|
||||
.sev.HIGH{color:var(--high);border:1px solid var(--high)}
|
||||
.sev.MEDIUM{color:var(--med);border:1px solid var(--med)}
|
||||
.row .sigs{color:var(--fg);margin-top:4px;font-size:13px}
|
||||
.row .t{color:var(--dim);font-size:11px}
|
||||
.row .sids{color:var(--dim);font-size:11px}
|
||||
.detail{padding:18px 22px}
|
||||
.detail pre{white-space:pre-wrap;word-break:break-word;margin:0;
|
||||
color:#b9c4d8;font-size:12.5px}
|
||||
.empty{color:var(--dim);padding:40px;text-align:center}
|
||||
.err{color:var(--crit);padding:30px;text-align:center}
|
||||
footer{border-top:1px solid var(--line);background:var(--panel);
|
||||
padding:8px 20px;color:var(--dim);font-size:11px;display:flex;gap:18px}
|
||||
a{color:var(--accent)}
|
||||
.tls{color:var(--accent)}
|
||||
.layout{display:grid;grid-template-columns:330px minmax(420px,1fr) 390px;
|
||||
height:calc(100vh - 58px);position:relative;z-index:1}
|
||||
aside,.work,.plan{min-height:0;overflow:auto}
|
||||
aside{background:rgba(13,17,23,.9);border-right:1px solid var(--line)}
|
||||
.work{background:linear-gradient(180deg,#0f1319,#0b0d11);padding:18px}
|
||||
.plan{background:rgba(17,21,27,.92);border-left:1px solid var(--line);padding:16px}
|
||||
.section-title{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em;
|
||||
text-transform:uppercase}
|
||||
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}
|
||||
.metric{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}
|
||||
.metric b{display:block;font-size:24px;line-height:1;color:#fff}
|
||||
.metric span{display:block;color:var(--muted);font-size:11px;margin-top:8px;text-transform:uppercase}
|
||||
.metric.crit b{color:var(--red)} .metric.high b{color:var(--amber)} .metric.med b{color:var(--blue)}
|
||||
.list{padding:0 8px 14px}
|
||||
.item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px;
|
||||
cursor:pointer;border-radius:7px;margin-bottom:4px}
|
||||
.item:hover{background:var(--panel)}
|
||||
.item.sel{background:var(--panel2);border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
|
||||
.item .top{display:flex;align-items:center;gap:8px;justify-content:space-between}
|
||||
.id{font-size:12px;color:#fff;word-break:break-word}
|
||||
.time,.fine{color:var(--muted);font-size:11px}
|
||||
.tags{margin-top:7px;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.tag{font-size:11px;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--soft)}
|
||||
.sev{font-weight:700;font-size:11px;border-radius:4px;padding:2px 7px;border:1px solid var(--line)}
|
||||
.sev.CRITICAL{color:var(--red);border-color:var(--red)}
|
||||
.sev.HIGH{color:var(--amber);border-color:var(--amber)}
|
||||
.sev.MEDIUM{color:var(--blue);border-color:var(--blue)}
|
||||
.tabs{display:flex;gap:6px;margin-bottom:14px}
|
||||
.tabs button{padding:7px 11px}
|
||||
.tabs button.active{background:var(--accent);border-color:var(--accent);color:#00110e}
|
||||
.card{background:rgba(17,21,27,.9);border:1px solid var(--line);border-radius:8px;
|
||||
padding:14px;margin-bottom:14px;box-shadow:var(--shadow)}
|
||||
h2{font-size:18px;margin:0 0 8px;color:#fff;letter-spacing:0}
|
||||
h3{font-size:12px;margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}
|
||||
.kv{display:grid;grid-template-columns:120px 1fr;gap:8px;margin-top:10px}
|
||||
.kv div:nth-child(odd){color:var(--muted)}
|
||||
.timeline{display:grid;gap:8px}
|
||||
.event{border-left:2px solid var(--accent);padding:8px 10px;background:rgba(255,255,255,.03)}
|
||||
.event b{display:block;color:#fff}
|
||||
.event code,.cmd{display:block;margin-top:6px;padding:8px;background:#080a0d;
|
||||
border:1px solid var(--line);border-radius:6px;color:#cbd5e1;white-space:pre-wrap;word-break:break-word}
|
||||
.action{border:1px solid var(--line);border-radius:8px;background:#0d1117;padding:11px;margin-bottom:10px}
|
||||
.action .head{display:flex;gap:8px;align-items:center;justify-content:space-between}
|
||||
.risk{font-size:10px;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--muted)}
|
||||
.risk.high{color:var(--red);border-color:var(--red)}
|
||||
.risk.medium{color:var(--amber);border-color:var(--amber)}
|
||||
.risk.low{color:var(--accent);border-color:var(--accent)}
|
||||
pre{margin:0;white-space:pre-wrap;word-break:break-word;color:#c8d1df;font-size:12px}
|
||||
.empty,.err{padding:30px;color:var(--muted);text-align:center}
|
||||
.err{color:var(--red)}
|
||||
@media (max-width:1100px){
|
||||
.layout{grid-template-columns:300px 1fr}
|
||||
.plan{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line);height:44vh}
|
||||
}
|
||||
@media (max-width:760px){
|
||||
header{height:auto;min-height:58px;flex-wrap:wrap;padding:12px}
|
||||
.layout{display:block;height:auto}
|
||||
aside,.work,.plan{height:auto;max-height:none}
|
||||
.metrics{grid-template-columns:repeat(2,1fr)}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ENODIA SENTINEL</h1>
|
||||
<span class="dot" id="dot"></span>
|
||||
<span class="host" id="host">—</span>
|
||||
<h1>Enodia Sentinel</h1>
|
||||
<span class="pill"><span class="dot" id="dot"></span><span id="daemon">connecting</span></span>
|
||||
<span class="pill tls">HTTPS self-signed</span>
|
||||
<span class="pill" id="host">host unknown</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="meta" id="ebpf"></span>
|
||||
<span class="meta" id="ver"></span>
|
||||
<span class="pill" id="ebpf">eBPF unknown</span>
|
||||
<button id="refreshBtn" title="Refresh console">Refresh</button>
|
||||
</header>
|
||||
|
||||
<div class="cards" id="cards"></div>
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="section-title"><span>Incidents</span><span id="incidentCount">0</span></div>
|
||||
<div class="list" id="incidents"><div class="empty">loading incidents</div></div>
|
||||
<div class="section-title"><span>Recent Alerts</span><span id="alertCount">0</span></div>
|
||||
<div class="list" id="alerts"></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="list" id="list"><div class="empty">loading…</div></div>
|
||||
<div class="detail" id="detail"><div class="empty">Select an alert to view its forensic snapshot.</div></div>
|
||||
</main>
|
||||
<main class="work">
|
||||
<div class="metrics" id="metrics"></div>
|
||||
<div class="tabs">
|
||||
<button class="active" data-tab="incident">Incident</button>
|
||||
<button data-tab="alerts">Alerts</button>
|
||||
<button data-tab="events">Events</button>
|
||||
</div>
|
||||
<section id="incidentTab"></section>
|
||||
<section id="alertsTab" hidden></section>
|
||||
<section id="eventsTab" hidden></section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span id="status">connecting…</span>
|
||||
<span class="spacer" style="flex:1"></span>
|
||||
<span id="refreshed"></span>
|
||||
</footer>
|
||||
<section class="plan">
|
||||
<div class="section-title"><span>Dry-run Response Plan</span><span id="planMode">read-only</span></div>
|
||||
<div id="plan"><div class="empty">Select an incident to generate a plan.</div></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
||||
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
|
||||
let selected = null;
|
||||
const state = {incidents:[], alerts:[], selected:null, incident:null, plan:null, tab:"incident"};
|
||||
|
||||
async function api(path){
|
||||
const r = await fetch(path, {headers:HDR});
|
||||
if(r.status===401) throw new Error("unauthorized — bad or missing ?token");
|
||||
const r = await fetch(path,{headers:HDR});
|
||||
if(r.status===401) throw new Error("unauthorized - open with ?token=YOUR_TOKEN");
|
||||
if(!r.ok) throw new Error("HTTP "+r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function el(tag, cls, txt){const e=document.createElement(tag);
|
||||
if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e;}
|
||||
function el(tag, cls, txt){const e=document.createElement(tag); if(cls)e.className=cls;
|
||||
if(txt!==undefined)e.textContent=txt; return e}
|
||||
function sev(s){return el("span","sev "+(s||""),s||"?")}
|
||||
function tags(xs){const box=el("div","tags"); (xs||[]).forEach(x=>box.append(el("span","tag",x))); return box}
|
||||
function fmt(t){return (t||"?").replace("T"," ").slice(0,19)}
|
||||
|
||||
async function refresh(){
|
||||
try{
|
||||
const st = await api("/api/status");
|
||||
document.getElementById("dot").className = "dot "+(st.running?"up":"down");
|
||||
document.getElementById("host").textContent = st.host || "";
|
||||
document.getElementById("ver").textContent = "v"+st.version;
|
||||
document.getElementById("ebpf").textContent = "eBPF: "+(st.ebpf||"?");
|
||||
let hb = "";
|
||||
if (st.heartbeat_age != null)
|
||||
hb = st.heartbeat_stale
|
||||
? ` ⚠ heartbeat STALE (${Math.round(st.heartbeat_age)}s) — possible tampering`
|
||||
: ` ♥ ${Math.round(st.heartbeat_age)}s ago`;
|
||||
const st_el = document.getElementById("status");
|
||||
st_el.textContent = (st.running ? "daemon running" : "daemon NOT running") + hb;
|
||||
st_el.style.color = (st.running && !st.heartbeat_stale) ? "" : "var(--crit)";
|
||||
const c = st.counts||{};
|
||||
const cards = document.getElementById("cards"); cards.innerHTML="";
|
||||
for(const [lbl,key] of [["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"]]){
|
||||
const card=el("div","card "+key);
|
||||
card.append(el("div","n",String(c[lbl]||0))); card.append(el("div","l",lbl));
|
||||
cards.append(card);
|
||||
}
|
||||
const tot=el("div","card"); tot.append(el("div","n",String(st.total_alerts||0)));
|
||||
tot.append(el("div","l","total alerts")); cards.append(tot);
|
||||
const [status, incidents, alerts, events] = await Promise.all([
|
||||
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/events")
|
||||
]);
|
||||
state.incidents = incidents; state.alerts = alerts; state.events = events.events || [];
|
||||
renderStatus(status); renderLists();
|
||||
if(!state.selected && incidents.length) await openIncident(incidents[0].id);
|
||||
else if(state.selected) await openIncident(state.selected, false);
|
||||
renderTab(); renderEvents();
|
||||
}catch(e){ showError(e.message); }
|
||||
}
|
||||
|
||||
const alerts = await api("/api/alerts");
|
||||
const list = document.getElementById("list"); list.innerHTML="";
|
||||
if(!alerts.length){ list.append(el("div","empty","No alerts captured.")); }
|
||||
for(const a of alerts){
|
||||
const row=el("div","row"); row.dataset.name=a.name;
|
||||
if(a.name===selected) row.classList.add("sel");
|
||||
const top=el("div","top");
|
||||
top.append(el("span","sev "+a.severity, a.severity));
|
||||
top.append(el("span","t", (a.time||"").replace("T"," ").slice(0,19)));
|
||||
row.append(top);
|
||||
row.append(el("div","sigs", a.signatures.join(", ")));
|
||||
if(a.sids.length) row.append(el("div","sids","sid: "+a.sids.join(", ")));
|
||||
row.onclick=()=>openAlert(a.name);
|
||||
list.append(row);
|
||||
}
|
||||
document.getElementById("refreshed").textContent =
|
||||
"updated "+new Date().toLocaleTimeString();
|
||||
}catch(e){
|
||||
document.getElementById("status").textContent = "error: "+e.message;
|
||||
if(String(e.message).includes("unauthorized"))
|
||||
document.getElementById("detail").innerHTML =
|
||||
'<div class="err">Unauthorized. Open this page with ?token=YOUR_TOKEN</div>';
|
||||
}
|
||||
function renderStatus(st){
|
||||
document.getElementById("dot").className = "dot "+(st.running && !st.heartbeat_stale ? "up" : "");
|
||||
let hb = st.heartbeat_age == null ? "no heartbeat" : Math.round(st.heartbeat_age)+"s heartbeat";
|
||||
document.getElementById("daemon").textContent = (st.running ? "running" : "not running")+" / "+hb;
|
||||
document.getElementById("host").textContent = st.host+" / v"+st.version;
|
||||
document.getElementById("ebpf").textContent = "eBPF "+(st.ebpf || "unknown");
|
||||
const counts = st.counts || {};
|
||||
const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
|
||||
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""]].forEach(([k,c])=>{
|
||||
const m=el("div","metric "+c); m.append(el("b",null,String(k==="TOTAL" ? st.total_alerts||0 : counts[k]||0)));
|
||||
m.append(el("span",null,k==="TOTAL" ? "alerts" : k)); metrics.append(m);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLists(){
|
||||
document.getElementById("incidentCount").textContent = state.incidents.length;
|
||||
document.getElementById("alertCount").textContent = state.alerts.length;
|
||||
const incBox = document.getElementById("incidents"); incBox.innerHTML = "";
|
||||
if(!state.incidents.length) incBox.append(el("div","empty","No incidents recorded."));
|
||||
state.incidents.forEach(i=>{
|
||||
const row=el("div","item"); row.classList.toggle("sel", i.id===state.selected);
|
||||
row.onclick=()=>openIncident(i.id);
|
||||
const top=el("div","top"); top.append(el("span","id",i.id)); top.append(sev(i.severity));
|
||||
row.append(top); row.append(el("div","time",fmt(i.last_seen)));
|
||||
row.append(tags(i.signatures)); incBox.append(row);
|
||||
});
|
||||
const alertBox = document.getElementById("alerts"); alertBox.innerHTML = "";
|
||||
state.alerts.slice(0,12).forEach(a=>{
|
||||
const row=el("div","item"); row.onclick=()=>openAlert(a.name);
|
||||
const top=el("div","top"); top.append(el("span","id",a.name)); top.append(sev(a.severity));
|
||||
row.append(top); row.append(el("div","time",fmt(a.time))); row.append(tags(a.signatures));
|
||||
alertBox.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function openIncident(id, mark=true){
|
||||
state.selected = id;
|
||||
if(mark) renderLists();
|
||||
try{
|
||||
const [inc, plan] = await Promise.all([
|
||||
api("/api/incidents/"+encodeURIComponent(id)),
|
||||
api("/api/respond/plan/"+encodeURIComponent(id))
|
||||
]);
|
||||
state.incident = inc; state.plan = plan;
|
||||
renderIncident(); renderPlan();
|
||||
}catch(e){ document.getElementById("incidentTab").innerHTML='<div class="err">'+e.message+'</div>'; }
|
||||
}
|
||||
|
||||
async function openAlert(name){
|
||||
selected = name;
|
||||
document.querySelectorAll(".row").forEach(r=>
|
||||
r.classList.toggle("sel", r.dataset.name===name));
|
||||
const d = document.getElementById("detail");
|
||||
d.innerHTML='<div class="empty">loading…</div>';
|
||||
state.tab = "alerts"; setTabButtons();
|
||||
const box = document.getElementById("alertsTab");
|
||||
box.hidden = false; document.getElementById("incidentTab").hidden = true; document.getElementById("eventsTab").hidden = true;
|
||||
box.innerHTML='<div class="empty">loading snapshot</div>';
|
||||
try{
|
||||
const a = await api("/api/alerts/"+encodeURIComponent(name));
|
||||
d.innerHTML="";
|
||||
const pre=el("pre",null,a.text || JSON.stringify(a.json,null,2));
|
||||
d.append(pre);
|
||||
}catch(e){ d.innerHTML='<div class="err">'+e.message+'</div>'; }
|
||||
box.innerHTML = '<div class="card"><h3>'+name+'</h3><pre></pre></div>';
|
||||
box.querySelector("pre").textContent = a.text || JSON.stringify(a.json,null,2);
|
||||
}catch(e){ box.innerHTML='<div class="err">'+e.message+'</div>'; }
|
||||
}
|
||||
|
||||
function renderIncident(){
|
||||
const box = document.getElementById("incidentTab"); const data = state.incident;
|
||||
if(!data){ box.innerHTML='<div class="empty">No incident selected.</div>'; return; }
|
||||
const i = data.incident;
|
||||
box.innerHTML = "";
|
||||
const card = el("div","card");
|
||||
card.append(el("h2",null,i.id)); card.append(tags(i.signatures));
|
||||
const kv = el("div","kv");
|
||||
[["Severity",i.severity],["First seen",fmt(i.first_seen)],["Last seen",fmt(i.last_seen)],
|
||||
["Alert count",String(i.alert_count||0)],["PIDs",(i.pids||[]).join(", ")||"-"],["SIDs",(i.sids||[]).join(", ")||"-"]]
|
||||
.forEach(([k,v])=>{kv.append(el("div",null,k)); kv.append(el("div",null,v));});
|
||||
card.append(kv); box.append(card);
|
||||
const tl = el("div","card"); tl.append(el("h3",null,"Timeline"));
|
||||
const list = el("div","timeline");
|
||||
(data.timeline||[]).forEach(r=>{
|
||||
const ev = el("div","event"); ev.append(el("b",null,fmt(r.time)+" ["+(r.severity||"?")+"]"));
|
||||
ev.append(el("div","fine",(r.signatures||[]).join(", ") || r.snapshot));
|
||||
ev.append(el("code",null,r.snapshot)); list.append(ev);
|
||||
});
|
||||
tl.append(list); box.append(tl);
|
||||
}
|
||||
|
||||
function renderPlan(){
|
||||
const box = document.getElementById("plan"); const plan = state.plan;
|
||||
if(!plan){ box.innerHTML='<div class="empty">No response plan available.</div>'; return; }
|
||||
document.getElementById("planMode").textContent = plan.mode + " / no apply";
|
||||
box.innerHTML = "";
|
||||
(plan.actions||[]).forEach(a=>{
|
||||
const row = el("div","action");
|
||||
const head = el("div","head"); head.append(el("b",null,a.id+" "+a.title));
|
||||
head.append(el("span","risk "+a.risk,a.risk)); row.append(head);
|
||||
row.append(el("code","cmd",(a.command||[]).join(" ")));
|
||||
row.append(el("div","fine",a.reason)); box.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTab(){
|
||||
setTabButtons();
|
||||
document.getElementById("incidentTab").hidden = state.tab !== "incident";
|
||||
document.getElementById("alertsTab").hidden = state.tab !== "alerts";
|
||||
document.getElementById("eventsTab").hidden = state.tab !== "events";
|
||||
if(state.tab === "incident") renderIncident();
|
||||
if(state.tab === "events") renderEvents();
|
||||
}
|
||||
function setTabButtons(){
|
||||
document.querySelectorAll(".tabs button").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab));
|
||||
}
|
||||
function renderEvents(){
|
||||
const box = document.getElementById("eventsTab");
|
||||
box.innerHTML = '<div class="card"><h3>Event tail</h3><pre></pre></div>';
|
||||
box.querySelector("pre").textContent = (state.events||[]).join("\n") || "No events.";
|
||||
}
|
||||
function showError(msg){
|
||||
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
|
||||
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
|
||||
}
|
||||
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; renderTab();});
|
||||
document.getElementById("refreshBtn").onclick=refresh;
|
||||
refresh();
|
||||
setInterval(refresh, 10000);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ RestartSec=5
|
|||
# lock it down hard. It does not need root, but reads root-owned snapshots, so
|
||||
# it runs as root with an otherwise minimal surface and no capabilities.
|
||||
ProtectSystem=strict
|
||||
# Writable only so it can persist an auto-generated token; set web_token in the
|
||||
# config to keep this purely read-only.
|
||||
# Writable so it can persist an auto-generated token and self-signed TLS
|
||||
# certificate/key. Set web_token, web_tls_cert, and web_tls_key in config to
|
||||
# keep this service purely read-only after provisioning.
|
||||
ReadWritePaths=/var/log/enodia-sentinel
|
||||
ProtectHome=yes
|
||||
NoNewPrivileges=yes
|
||||
|
|
|
|||
111
tests/test_respond.py
Normal file
111
tests/test_respond.py
Normal 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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue