diff --git a/Makefile b/Makefile index d099fb0..7b9a252 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ install: install -Dm755 packaging/enodia-sentinel.wrapper $(DESTDIR)$(BINDIR)/enodia-sentinel install -Dm755 src/sentinel-redteam $(DESTDIR)$(BINDIR)/sentinel-redteam install -Dm644 systemd/enodia-sentinel.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service + install -Dm644 systemd/enodia-sentinel-web.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service @if [ ! -e "$(DESTDIR)$(CONFDIR)/enodia-sentinel.toml" ]; then \ install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml; \ echo "Installed default config at $(CONFDIR)/enodia-sentinel.toml"; \ @@ -30,6 +31,7 @@ uninstall: rm -f $(DESTDIR)$(BINDIR)/enodia-sentinel rm -f $(DESTDIR)$(BINDIR)/sentinel-redteam rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service + rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service rm -f $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new @echo "Uninstalled. Config and logs preserved." @@ -56,6 +58,9 @@ check: baseline: python3 -m enodia_sentinel.cli baseline +web: + python3 -m enodia_sentinel.cli web + drill: ./src/sentinel-redteam diff --git a/README.md b/README.md index 58d6517..05eadda 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,11 @@ enodia_sentinel/ ├── config.py dataclass config (TOML + env overrides) ├── netutil.py public-IP / CIDR logic (stdlib ipaddress) ├── alert.py Alert / Severity (with Snort-style sid + classtype) +├── web.py read-only dashboard: stdlib http server + JSON API + auth +├── static/ the self-contained dashboard SPA ├── detectors/ poll detectors — one module per signature, each a pure │ function: detect(state, cfg) -> Iterable[Alert] +├── notify/ outbound push — ntfy / Pushover / webhook backends └── events/ event-driven layer (eBPF) ├── bcc_source.py real eBPF execve probe loaded via bcc ├── exec_event.py the ExecEvent type @@ -191,6 +194,48 @@ enodia-sentinel.service`. Every key is optional. Highlights: | `capture_execve_bpftrace` | false | add a bpftrace execve trace to snapshots | | `notify_users` | [] | desktop notify-send targets | +## Web dashboard + +A read-only 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 +# or as a service: +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. +- **Bearer-token auth** (constant-time check); the token is auto-generated and + saved on first run and printed in the startup line. Open + `http://:8787/?token=…`. +- **Read-only**: severity cards, a live alert list, and the full forensic + snapshot per alert. No actions, no writes — minimal attack surface for + sensitive data. JSON API at `/api/status`, `/api/alerts`, `/api/alerts/`, + `/api/events`. + +## Phone push notifications + +When an alert at/above `notify_min_severity` fires, Sentinel pushes to whichever +backends you've configured (all via stdlib `urllib`, no SDKs): + +| Backend | Enable by setting | Notes | +|---|---|---| +| **ntfy** | `notify_ntfy_url` + `notify_ntfy_topic` | open-source, self-hostable, free apps | +| **Pushover** | `notify_pushover_token` + `_user` | polished, reliable | +| **Webhook** | `notify_webhook_url` | generic JSON POST (Discord/Slack/your own) | + +Severity maps to each service's priority (a CRITICAL is an urgent ntfy push / a +high-priority Pushover). Sends happen on worker threads and swallow their own +errors — a flaky notifier never stalls detection. + +```toml +notify_min_severity = "HIGH" +notify_ntfy_url = "https://ntfy.sh" +notify_ntfy_topic = "enodia-7Hq2x" # keep this secret — it's the access control +``` + ## Security model Sentinel runs as root because it must read every process's `/proc`, the full @@ -237,6 +282,9 @@ regression suite for both. ## Project status +v0.4 — adds a read-only **web dashboard** (stdlib server, Tailscale-bound, +token-auth) 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 + classtypes on every detection, fail-safe degradation to poll-only, and an diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index aab5544..697e07d 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -63,6 +63,34 @@ capture_execve_bpftrace = false max_snapshots = 300 # auto-delete oldest beyond this count (0 = no cap) max_snapshot_age_days = 60 # auto-delete older than this (0 = no age cap) -# --- notifications ------------------------------------------------------- +# --- notifications: desktop --------------------------------------------- notify_users = [] # e.g. ["luna"] — desktop notify-send on alert notify_urgency = "critical" # low / normal / critical + +# --- notifications: phone push ------------------------------------------ +# A backend turns on when its required keys are set. Only alerts at or above +# notify_min_severity are pushed. +notify_min_severity = "HIGH" # MEDIUM / HIGH / CRITICAL + +# ntfy (open-source, self-hostable). Install the ntfy app and subscribe to the +# topic; keep the topic name secret — it is the only access control on ntfy.sh. +notify_ntfy_url = "" # e.g. "https://ntfy.sh" or your own server +notify_ntfy_topic = "" # e.g. "enodia-" +notify_ntfy_token = "" # optional Bearer token (protected topics) + +# Pushover +notify_pushover_token = "" # application token +notify_pushover_user = "" # user/group key + +# Generic webhook — receives the alert JSON via POST (Discord/Slack/your own). +notify_webhook_url = "" + +# Optional dashboard base URL embedded in pushes (e.g. your Tailscale URL). +dashboard_url = "" + +# --- web dashboard (read-only) ------------------------------------------ +web_bind = "" # "" = auto-detect Tailscale IP; or an explicit address +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. diff --git a/enodia_sentinel/__init__.py b/enodia_sentinel/__init__.py index e81c859..b196008 100644 --- a/enodia_sentinel/__init__.py +++ b/enodia_sentinel/__init__.py @@ -6,4 +6,4 @@ captures a forensic snapshot with incident-response guidance whenever a known attack signature appears. The Python re-architecture of the bash v0 prototype. """ -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 1aa9ae5..7de2d78 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -61,6 +61,7 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("check", help="run detectors once and print alerts") sub.add_parser("baseline", help="rebuild listener/SUID baselines") sub.add_parser("list-detectors", help="list available detectors") + sub.add_parser("web", help="serve the read-only dashboard") args = parser.parse_args(argv) cfg = Config.load(args.config) @@ -74,6 +75,10 @@ def main(argv: list[str] | None = None) -> int: return _cmd_baseline(cfg) if args.cmd == "check": return _cmd_check(cfg) + if args.cmd == "web": + from .web import serve + serve(cfg) + return 0 return _cmd_run(cfg) diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index 06f8236..1eb13cd 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -67,9 +67,24 @@ class Config: max_snapshots: int = 300 max_snapshot_age_days: int = 60 - # notifications + # notifications — desktop notify_users: tuple[str, ...] = () notify_urgency: str = "critical" + # notifications — phone push (a backend is enabled when its required keys + # are set). Only alerts at/above notify_min_severity are pushed. + notify_min_severity: str = "HIGH" + notify_ntfy_url: str = "" # e.g. "https://ntfy.sh" or self-hosted base + notify_ntfy_topic: str = "" # topic name (required to enable ntfy) + notify_ntfy_token: str = "" # optional Bearer token for protected topics + notify_pushover_token: str = "" # Pushover application token + notify_pushover_user: str = "" # Pushover user/group key + notify_webhook_url: str = "" # generic JSON POST target + dashboard_url: str = "" # optional base URL embedded in pushes + + # web dashboard (read-only) + web_bind: str = "" # "" = auto-detect Tailscale IP, else explicit + web_port: int = 8787 + web_token: str = "" # bearer token; auto-generated if empty + non-local # paths log_dir: Path = Path("/var/log/enodia-sentinel") diff --git a/enodia_sentinel/daemon.py b/enodia_sentinel/daemon.py index 46ad6c4..685af1d 100644 --- a/enodia_sentinel/daemon.py +++ b/enodia_sentinel/daemon.py @@ -8,6 +8,7 @@ expensive filesystem-wide SUID scan is gated to its own slow cadence. from __future__ import annotations import json +import os import threading import time from pathlib import Path @@ -124,6 +125,10 @@ class Sentinel: # -- main loop --------------------------------------------------------- def run(self) -> None: self.cfg.log_dir.mkdir(parents=True, exist_ok=True) + try: + (self.cfg.log_dir / "sentinel.pid").write_text(str(os.getpid())) + except OSError: + pass with open(self.cfg.events_log, "a") as fh: fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n") self.build_baselines() @@ -150,6 +155,9 @@ class Sentinel: def _start_exec_monitor(self) -> None: if not self.cfg.ebpf_exec_monitor: + with open(self.cfg.events_log, "a") as fh: + fh.write(f"{time.strftime('%FT%T%z')} " + "eBPF exec monitor: off (disabled in config)\n") return from .events.monitor import ExecMonitor self._exec_monitor = ExecMonitor(self.cfg, self._on_exec_alert) diff --git a/enodia_sentinel/notify/__init__.py b/enodia_sentinel/notify/__init__.py new file mode 100644 index 0000000..0192728 --- /dev/null +++ b/enodia_sentinel/notify/__init__.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Outbound notifications: desktop + phone push (ntfy / Pushover / webhook). + +A backend is *enabled* when its required config keys are present, so turning one +on is purely a matter of configuration. Phone pushes are gated by +``notify_min_severity``. Everything sends on a worker thread and swallows its +own errors — a flaky notifier must never stall or crash detection. +""" +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from datetime import datetime + +from ..alert import Alert, Severity +from ..config import Config +from . import backends + +_SEV = {"MEDIUM": Severity.MEDIUM, "HIGH": Severity.HIGH, + "CRITICAL": Severity.CRITICAL} + +_PUSH_BACKENDS = (backends.Ntfy, backends.Pushover, backends.Webhook) + + +@dataclass(frozen=True) +class Notification: + severity: Severity + host: str + signatures: tuple[str, ...] + sids: tuple[int, ...] + snapshot_name: str + count: int + time: str = field(default_factory=lambda: datetime.now().astimezone().isoformat()) + dashboard_url: str = "" + + @classmethod + def from_alerts(cls, alerts: list[Alert], host: str, snapshot_name: str, + dashboard_url: str = "") -> "Notification": + sev = max((a.severity for a in alerts), default=Severity.HIGH) + sigs = tuple(dict.fromkeys(a.signature for a in alerts)) + sids = tuple(dict.fromkeys(a.sid for a in alerts if a.sid)) + return cls(severity=sev, host=host, signatures=sigs, sids=sids, + snapshot_name=snapshot_name, count=len(alerts), + dashboard_url=dashboard_url) + + def title(self) -> str: + return f"Enodia: {self.severity} on {self.host}" + + def message(self) -> str: + lines = [f"{self.count} detection(s): {', '.join(self.signatures)}"] + if self.sids: + lines.append("sids: " + ", ".join(str(s) for s in self.sids)) + lines.append(f"snapshot: {self.snapshot_name}") + if self.dashboard_url: + lines.append(self.dashboard_url.rstrip("/") + "/") + return "\n".join(lines) + + def to_dict(self) -> dict: + return { + "time": self.time, "host": self.host, + "severity": str(self.severity), "signatures": list(self.signatures), + "sids": list(self.sids), "snapshot": self.snapshot_name, + "count": self.count, + } + + +def min_severity(cfg: Config) -> Severity: + return _SEV.get(cfg.notify_min_severity.upper(), Severity.HIGH) + + +def enabled_backends(cfg: Config) -> list: + return [b for b in _PUSH_BACKENDS if b.enabled(cfg)] + + +def dispatch(cfg: Config, notif: Notification) -> None: + """Fire all enabled phone-push backends (on worker threads, best-effort).""" + if notif.severity < min_severity(cfg): + return + for backend in enabled_backends(cfg): + threading.Thread(target=_safe_send, args=(backend, cfg, notif), + daemon=True).start() + + +def _safe_send(backend, cfg: Config, notif: Notification) -> None: + try: + backend.send(cfg, notif) + except Exception: + pass diff --git a/enodia_sentinel/notify/backends.py b/enodia_sentinel/notify/backends.py new file mode 100644 index 0000000..dcea83a --- /dev/null +++ b/enodia_sentinel/notify/backends.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Phone-push backends. Each separates a pure ``build()`` (testable, no network) +from ``send()`` (does the request), so request construction is unit-tested +without ever hitting the wire.""" +from __future__ import annotations + +import json +import urllib.parse +import urllib.request + +from ..alert import Severity +from ..config import Config + +_TIMEOUT = 6 + +# Severity -> per-service priority/tags. +_NTFY_PRIORITY = {Severity.MEDIUM: "3", Severity.HIGH: "4", Severity.CRITICAL: "5"} +_NTFY_TAGS = {Severity.MEDIUM: "mag", Severity.HIGH: "warning", + Severity.CRITICAL: "rotating_light"} +_PUSHOVER_PRIORITY = {Severity.MEDIUM: "-1", Severity.HIGH: "0", Severity.CRITICAL: "1"} + + +def _send(req: urllib.request.Request) -> bool: + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + return 200 <= resp.status < 300 + + +class Ntfy: + name = "ntfy" + + @staticmethod + def enabled(cfg: Config) -> bool: + return bool(cfg.notify_ntfy_url and cfg.notify_ntfy_topic) + + @staticmethod + def build(cfg: Config, notif) -> urllib.request.Request: + url = f"{cfg.notify_ntfy_url.rstrip('/')}/{cfg.notify_ntfy_topic}" + headers = { + "Title": notif.title(), + "Priority": _NTFY_PRIORITY.get(notif.severity, "4"), + "Tags": _NTFY_TAGS.get(notif.severity, "warning"), + } + if cfg.notify_ntfy_token: + headers["Authorization"] = f"Bearer {cfg.notify_ntfy_token}" + return urllib.request.Request( + url, data=notif.message().encode("utf-8"), + headers=headers, method="POST") + + @classmethod + def send(cls, cfg: Config, notif) -> bool: + return _send(cls.build(cfg, notif)) + + +class Pushover: + name = "pushover" + + @staticmethod + def enabled(cfg: Config) -> bool: + return bool(cfg.notify_pushover_token and cfg.notify_pushover_user) + + @staticmethod + def build(cfg: Config, notif) -> urllib.request.Request: + form = urllib.parse.urlencode({ + "token": cfg.notify_pushover_token, + "user": cfg.notify_pushover_user, + "title": notif.title(), + "message": notif.message(), + "priority": _PUSHOVER_PRIORITY.get(notif.severity, "0"), + }).encode("utf-8") + return urllib.request.Request( + "https://api.pushover.net/1/messages.json", data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST") + + @classmethod + def send(cls, cfg: Config, notif) -> bool: + return _send(cls.build(cfg, notif)) + + +class Webhook: + name = "webhook" + + @staticmethod + def enabled(cfg: Config) -> bool: + return bool(cfg.notify_webhook_url) + + @staticmethod + def build(cfg: Config, notif) -> urllib.request.Request: + body = json.dumps({ + "title": notif.title(), + "message": notif.message(), + **notif.to_dict(), + }).encode("utf-8") + return urllib.request.Request( + cfg.notify_webhook_url, data=body, + headers={"Content-Type": "application/json"}, method="POST") + + @classmethod + def send(cls, cfg: Config, notif) -> bool: + return _send(cls.build(cfg, notif)) diff --git a/enodia_sentinel/snapshot.py b/enodia_sentinel/snapshot.py index 625340e..92bd3ba 100644 --- a/enodia_sentinel/snapshot.py +++ b/enodia_sentinel/snapshot.py @@ -220,6 +220,14 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path: f"{base.with_suffix('.log')} — signatures: {sigs}\n") _notify(cfg, f"{severity}: {sigs}") + + # Phone push (ntfy / Pushover / webhook), gated by notify_min_severity. + from . import notify + notif = notify.Notification.from_alerts( + alerts, host=report["host"], + snapshot_name=base.with_suffix(".log").name, + dashboard_url=cfg.dashboard_url) + notify.dispatch(cfg, notif) return base.with_suffix(".log") diff --git a/enodia_sentinel/static/dashboard.html b/enodia_sentinel/static/dashboard.html new file mode 100644 index 0000000..9981018 --- /dev/null +++ b/enodia_sentinel/static/dashboard.html @@ -0,0 +1,158 @@ + + + + + + +Enodia Sentinel + + + +
+

ENODIA SENTINEL

+ + + + + +
+ +
+ +
+
loading…
+
Select an alert to view its forensic snapshot.
+
+ +
+ connecting… + + +
+ + + + diff --git a/enodia_sentinel/web.py b/enodia_sentinel/web.py new file mode 100644 index 0000000..fcf3cc1 --- /dev/null +++ b/enodia_sentinel/web.py @@ -0,0 +1,244 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Read-only 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. +""" +from __future__ import annotations + +import hmac +import json +import os +import re +import secrets +import subprocess +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from . import __version__ +from .config import Config + +_STATIC = Path(__file__).parent / "static" +_ALERT_NAME = re.compile(r"^alert-\d{8}-\d{6}\.(json|log)$") + + +# --- network helpers ------------------------------------------------------- + +def detect_tailscale_ip() -> str | None: + """Best-effort Tailscale IPv4 of this host.""" + try: + out = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, + text=True, timeout=4).stdout.strip().splitlines() + if out and out[0]: + return out[0].strip() + except (OSError, subprocess.SubprocessError): + pass + try: + out = subprocess.run(["ip", "-o", "-4", "addr", "show", "tailscale0"], + capture_output=True, text=True, timeout=4).stdout + m = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", out) + if m: + return m.group(1) + except (OSError, subprocess.SubprocessError): + pass + return None + + +def resolve_bind(cfg: Config) -> str: + if cfg.web_bind: + return cfg.web_bind + return detect_tailscale_ip() or "127.0.0.1" + + +def is_loopback(addr: str) -> bool: + return addr.startswith("127.") or addr in ("::1", "localhost") + + +def ensure_token(cfg: Config) -> str: + if cfg.web_token: + return cfg.web_token + path = cfg.log_dir / ".web_token" + try: + if path.is_file(): + tok = path.read_text().strip() + if tok: + return tok + except OSError: + pass + tok = secrets.token_urlsafe(24) + try: + cfg.log_dir.mkdir(parents=True, exist_ok=True) + path.write_text(tok) + path.chmod(0o600) + except OSError: + pass + return tok + + +# --- data layer (pure, testable) ------------------------------------------ + +def list_alerts(cfg: Config, limit: int = 200) -> list[dict]: + out = [] + for p in sorted(cfg.log_dir.glob("alert-*.json"), reverse=True)[:limit]: + try: + d = json.loads(p.read_text()) + except (OSError, ValueError): + continue + out.append({ + "name": p.with_suffix(".log").name, + "time": d.get("time", ""), + "host": d.get("host", ""), + "severity": d.get("severity", ""), + "signatures": sorted({a.get("signature", "") for a in d.get("alerts", [])}), + "sids": sorted({a.get("sid", 0) for a in d.get("alerts", []) if a.get("sid")}), + "count": len(d.get("alerts", [])), + }) + return out + + +def get_alert(cfg: Config, name: str) -> dict | None: + if not _ALERT_NAME.match(name): + return None + base = cfg.log_dir / name + jpath = base.with_suffix(".json") + try: + data = {"json": json.loads(jpath.read_text())} if jpath.is_file() else {} + lpath = base.with_suffix(".log") + if lpath.is_file(): + data["text"] = lpath.read_text() + return data or None + except (OSError, ValueError): + return None + + +def tail_events(cfg: Config, limit: int = 100) -> list[str]: + try: + lines = cfg.events_log.read_text().splitlines() + except OSError: + return [] + return lines[-limit:] + + +def daemon_status(cfg: Config) -> dict: + pid_alive = False + pidfile = cfg.log_dir / "sentinel.pid" + try: + pid = int(pidfile.read_text().strip()) + os.kill(pid, 0) + pid_alive = True + except (OSError, ValueError): + pid_alive = False + alerts = list_alerts(cfg, limit=10000) + counts: dict[str, int] = {} + for a in alerts: + counts[a["severity"]] = counts.get(a["severity"], 0) + 1 + ebpf = "unknown" + for line in reversed(tail_events(cfg, 200)): + if "eBPF exec monitor:" in line: + ebpf = line.split("eBPF exec monitor:", 1)[1].strip() + break + return { + "version": __version__, + "running": pid_alive, + "total_alerts": len(alerts), + "counts": counts, + "last_alert": alerts[0]["time"] if alerts else None, + "ebpf": ebpf, + "host": os.uname().nodename, + } + + +# --- HTTP layer ------------------------------------------------------------ + +class _Handler(BaseHTTPRequestHandler): + server_version = "EnodiaSentinel" + + def log_message(self, *a): # quiet by default + pass + + def _authorized(self) -> bool: + token = self.server.token # type: ignore[attr-defined] + if not token: + return True + supplied = "" + auth = self.headers.get("Authorization", "") + if auth.startswith("Bearer "): + supplied = auth[7:] + if not supplied: + supplied = parse_qs(urlparse(self.path).query).get("token", [""])[0] + return hmac.compare_digest(supplied, token) + + def _json(self, obj, status=200): + body = json.dumps(obj).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = urlparse(self.path).path + if not self._authorized(): + self._json({"error": "unauthorized"}, 401) + return + cfg: Config = self.server.cfg # type: ignore[attr-defined] + + if path in ("/", "/index.html", "/dashboard.html"): + self._serve_static("dashboard.html", "text/html; charset=utf-8") + elif path == "/api/status": + self._json(daemon_status(cfg)) + elif path == "/api/alerts": + self._json(list_alerts(cfg)) + elif path.startswith("/api/alerts/"): + alert = get_alert(cfg, path.rsplit("/", 1)[-1]) + self._json(alert if alert else {"error": "not found"}, + 200 if alert else 404) + elif path == "/api/events": + self._json({"events": tail_events(cfg)}) + else: + self._json({"error": "not found"}, 404) + + def _serve_static(self, name, ctype): + try: + body = (_STATIC / name).read_bytes() + except OSError: + self._json({"error": "missing asset"}, 500) + return + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]: + """Construct the server (without serving). Returns (httpd, bind, token).""" + bind = resolve_bind(cfg) + token = cfg.web_token + if not is_loopback(bind) and not token: + token = ensure_token(cfg) + httpd = ThreadingHTTPServer((bind, cfg.web_port), _Handler) + httpd.cfg = cfg # type: ignore[attr-defined] + httpd.token = token # 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}" + print(msg, flush=True) + try: + with open(cfg.events_log, "a") as fh: + fh.write(msg + "\n") + except OSError: + pass + try: + httpd.serve_forever() + except KeyboardInterrupt: + httpd.shutdown() diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index a763e37..1159da2 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Enodia pkgname=enodia-sentinel -pkgver=0.3.0 +pkgver=0.4.0 pkgrel=1 pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering" arch=('any') @@ -22,6 +22,7 @@ package() { install -Dm755 packaging/enodia-sentinel.wrapper "$pkgdir/usr/bin/enodia-sentinel" install -Dm755 src/sentinel-redteam "$pkgdir/usr/bin/sentinel-redteam" install -Dm644 systemd/enodia-sentinel.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel.service" + install -Dm644 systemd/enodia-sentinel-web.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel-web.service" install -Dm644 config/enodia-sentinel.toml "$pkgdir/etc/enodia-sentinel.toml" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -dm750 "$pkgdir/var/log/enodia-sentinel" diff --git a/pyproject.toml b/pyproject.toml index 26750cd..24a9eaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "enodia-sentinel" -version = "0.3.0" +version = "0.4.0" description = "Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering" readme = "README.md" requires-python = ">=3.11" @@ -29,3 +29,6 @@ dev = ["pytest"] [tool.setuptools.packages.find] include = ["enodia_sentinel*"] + +[tool.setuptools.package-data] +enodia_sentinel = ["static/*.html"] diff --git a/systemd/enodia-sentinel-web.service b/systemd/enodia-sentinel-web.service new file mode 100644 index 0000000..e046f54 --- /dev/null +++ b/systemd/enodia-sentinel-web.service @@ -0,0 +1,33 @@ +[Unit] +Description=Enodia Sentinel - read-only web dashboard +After=network-online.target enodia-sentinel.service +Wants=network-online.target +Documentation=https://github.com/Enodia/enodia-sentinel + +[Service] +Type=simple +ExecStart=/usr/bin/env enodia-sentinel web +Restart=on-failure +RestartSec=5 + +# The dashboard only READS the log dir and binds a port. No special privileges: +# 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. +ReadWritePaths=/var/log/enodia-sentinel +ProtectHome=yes +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +MemoryDenyWriteExecute=yes +LockPersonality=yes +RestrictNamespaces=yes +CapabilityBoundingSet= +AmbientCapabilities= + +[Install] +WantedBy=multi-user.target diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..56111c0 --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for notification request construction (no network).""" +import json +import unittest + +from enodia_sentinel.alert import Alert, Severity +from enodia_sentinel.config import Config +from enodia_sentinel.notify import Notification, backends, enabled_backends, min_severity + + +def notif(sev=Severity.CRITICAL): + alerts = [Alert(sev, "reverse_shell", "rsh:1", "detail", (1,), 100010, + "c2-reverse-shell")] + return Notification.from_alerts(alerts, host="woofbox", + snapshot_name="alert-x.log", + dashboard_url="https://sentinel.example") + + +class TestNotification(unittest.TestCase): + def test_summary_fields(self): + n = notif() + self.assertEqual(n.severity, Severity.CRITICAL) + self.assertIn("reverse_shell", n.signatures) + self.assertIn(100010, n.sids) + self.assertIn("woofbox", n.title()) + self.assertIn("alert-x.log", n.message()) + self.assertIn("sentinel.example", n.message()) + + +class TestEnable(unittest.TestCase): + def test_backends_off_by_default(self): + self.assertEqual(enabled_backends(Config()), []) + + def test_ntfy_enabled_when_configured(self): + c = Config() + c.notify_ntfy_url = "https://ntfy.sh" + c.notify_ntfy_topic = "enodia-secret" + self.assertIn(backends.Ntfy, enabled_backends(c)) + + def test_min_severity(self): + c = Config() + c.notify_min_severity = "CRITICAL" + self.assertEqual(min_severity(c), Severity.CRITICAL) + + +class TestNtfy(unittest.TestCase): + def test_build(self): + c = Config() + c.notify_ntfy_url = "https://ntfy.sh/" + c.notify_ntfy_topic = "enodia-secret" + c.notify_ntfy_token = "tok_123" + req = backends.Ntfy.build(c, notif(Severity.CRITICAL)) + self.assertEqual(req.full_url, "https://ntfy.sh/enodia-secret") + self.assertEqual(req.get_method(), "POST") + self.assertEqual(req.headers["Priority"], "5") + self.assertEqual(req.headers["Authorization"], "Bearer tok_123") + self.assertIn(b"reverse_shell", req.data) + + +class TestPushover(unittest.TestCase): + def test_build(self): + c = Config() + c.notify_pushover_token = "app" + c.notify_pushover_user = "usr" + req = backends.Pushover.build(c, notif(Severity.HIGH)) + self.assertIn("api.pushover.net", req.full_url) + body = req.data.decode() + self.assertIn("token=app", body) + self.assertIn("user=usr", body) + self.assertIn("priority=0", body) + + +class TestWebhook(unittest.TestCase): + def test_build_json(self): + c = Config() + c.notify_webhook_url = "https://hook.example/x" + req = backends.Webhook.build(c, notif()) + payload = json.loads(req.data) + self.assertEqual(payload["host"], "woofbox") + self.assertEqual(payload["severity"], "CRITICAL") + self.assertEqual(req.headers["Content-type"], "application/json") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..5316227 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for the read-only web dashboard: data layer + auth (real server).""" +import json +import tempfile +import threading +import unittest +import urllib.error +import urllib.request +from pathlib import Path + +from enodia_sentinel import web +from enodia_sentinel.config import Config + + +def _make_cfg(tmp: Path) -> Config: + c = Config() + c.log_dir = tmp + return c + + +def _write_alert(tmp: Path, name: str, severity: str, sigs): + alerts = [{"signature": s, "sid": 100010, "severity": severity} for s in sigs] + (tmp / f"{name}.json").write_text(json.dumps({ + "time": "2026-05-31T00:00:00-07:00", "host": "woofbox", + "severity": severity, "alerts": alerts, + })) + (tmp / f"{name}.log").write_text(f"=== ENODIA SENTINEL ALERT ===\n{severity}\n") + + +class TestDataLayer(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.tmp = Path(self.dir.name) + self.cfg = _make_cfg(self.tmp) + + def tearDown(self): + self.dir.cleanup() + + def test_list_and_status(self): + _write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["reverse_shell"]) + _write_alert(self.tmp, "alert-20260531-000002", "HIGH", ["new_listener"]) + alerts = web.list_alerts(self.cfg) + self.assertEqual(len(alerts), 2) + self.assertEqual(alerts[0]["name"], "alert-20260531-000002.log") # newest first + st = web.daemon_status(self.cfg) + self.assertEqual(st["total_alerts"], 2) + self.assertEqual(st["counts"]["CRITICAL"], 1) + self.assertFalse(st["running"]) # no live pidfile + + def test_get_alert_and_traversal(self): + _write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["x"]) + got = web.get_alert(self.cfg, "alert-20260531-000001.log") + self.assertIn("text", got) + self.assertIn("json", got) + # path traversal / bad names rejected + self.assertIsNone(web.get_alert(self.cfg, "../../etc/passwd")) + self.assertIsNone(web.get_alert(self.cfg, "events.log")) + + def test_tail_events(self): + (self.tmp / "events.log").write_text("l1\nl2\nl3\n") + self.assertEqual(web.tail_events(self.cfg, 2), ["l2", "l3"]) + + +class TestNetworkHelpers(unittest.TestCase): + def test_is_loopback(self): + self.assertTrue(web.is_loopback("127.0.0.1")) + self.assertFalse(web.is_loopback("100.64.1.2")) + + def test_resolve_bind_explicit(self): + c = Config() + c.web_bind = "100.64.1.2" + self.assertEqual(web.resolve_bind(c), "100.64.1.2") + + def test_ensure_token_persists(self): + with tempfile.TemporaryDirectory() as d: + c = _make_cfg(Path(d)) + t1 = web.ensure_token(c) + t2 = web.ensure_token(c) + self.assertTrue(t1) + self.assertEqual(t1, t2) # stable across calls + + +class TestAuth(unittest.TestCase): + """Spin up the real server on loopback and check token enforcement.""" + + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.tmp = Path(self.dir.name) + _write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["reverse_shell"]) + self.cfg = _make_cfg(self.tmp) + self.cfg.web_bind = "127.0.0.1" + self.cfg.web_port = 0 # ephemeral + self.cfg.web_token = "secret-token" + self.httpd, _bind, _tok = web.build_server(self.cfg) + self.port = self.httpd.server_address[1] + self.t = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.t.start() + + def tearDown(self): + self.httpd.shutdown() + self.dir.cleanup() + + def _get(self, path, token=None): + url = f"http://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) + + def test_unauthorized_without_token(self): + with self.assertRaises(urllib.error.HTTPError) as cm: + self._get("/api/status") + self.assertEqual(cm.exception.code, 401) + + def test_authorized_with_token(self): + resp = self._get("/api/status", token="secret-token") + data = json.loads(resp.read()) + self.assertEqual(data["total_alerts"], 1) + + def test_token_via_query_param(self): + resp = self._get("/api/alerts?token=secret-token") + self.assertEqual(resp.status, 200) + + +if __name__ == "__main__": + unittest.main()