Add read-only web dashboard and phone push notifications
Both zero-dependency (stdlib http.server + urllib), consistent with the project's no-dependency-tree stance for a security daemon. Web dashboard (enodia_sentinel/web.py + static/dashboard.html): - read-only JSON API over the log dir: /api/status, /api/alerts, /api/alerts/<id>, /api/events - bearer-token auth (constant-time; header or ?token=), required on non- loopback binds, auto-generated + persisted (0600) when unset - binds the host's Tailscale IP by default (auto-detected), reachable from the tailnet but not the LAN/internet - self-contained dark SPA: severity cards, live alert list, full snapshot viewer; 10s auto-refresh - path-traversal-safe alert lookup; `enodia-sentinel web` subcommand; daemon now writes a pidfile so the dashboard can show live status - hardened enodia-sentinel-web.service (read-only, no caps) Phone push (enodia_sentinel/notify/): - pluggable backends — ntfy, Pushover, generic webhook — each separating a pure build() (unit-tested, no network) from send() - a backend turns on when its config keys are set; pushes gated by notify_min_severity; severity → per-service priority/tags - fired from snapshot.capture on worker threads, errors swallowed - desktop notify-send retained Tests: +16 (9 web incl. a real-server 401/200 auth test, 7 notify request-build cases). 55/55 pass. Live end-to-end verified: daemon → alert → API → page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0eb5077551
commit
c00fff224c
17 changed files with 960 additions and 5 deletions
5
Makefile
5
Makefile
|
|
@ -15,6 +15,7 @@ install:
|
||||||
install -Dm755 packaging/enodia-sentinel.wrapper $(DESTDIR)$(BINDIR)/enodia-sentinel
|
install -Dm755 packaging/enodia-sentinel.wrapper $(DESTDIR)$(BINDIR)/enodia-sentinel
|
||||||
install -Dm755 src/sentinel-redteam $(DESTDIR)$(BINDIR)/sentinel-redteam
|
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.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 \
|
@if [ ! -e "$(DESTDIR)$(CONFDIR)/enodia-sentinel.toml" ]; then \
|
||||||
install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml; \
|
install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml; \
|
||||||
echo "Installed default config at $(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)/enodia-sentinel
|
||||||
rm -f $(DESTDIR)$(BINDIR)/sentinel-redteam
|
rm -f $(DESTDIR)$(BINDIR)/sentinel-redteam
|
||||||
rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service
|
rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service
|
||||||
|
rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service
|
||||||
rm -f $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new
|
rm -f $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new
|
||||||
@echo "Uninstalled. Config and logs preserved."
|
@echo "Uninstalled. Config and logs preserved."
|
||||||
|
|
||||||
|
|
@ -56,6 +58,9 @@ check:
|
||||||
baseline:
|
baseline:
|
||||||
python3 -m enodia_sentinel.cli baseline
|
python3 -m enodia_sentinel.cli baseline
|
||||||
|
|
||||||
|
web:
|
||||||
|
python3 -m enodia_sentinel.cli web
|
||||||
|
|
||||||
drill:
|
drill:
|
||||||
./src/sentinel-redteam
|
./src/sentinel-redteam
|
||||||
|
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -72,8 +72,11 @@ enodia_sentinel/
|
||||||
├── config.py dataclass config (TOML + env overrides)
|
├── config.py dataclass config (TOML + env overrides)
|
||||||
├── netutil.py public-IP / CIDR logic (stdlib ipaddress)
|
├── netutil.py public-IP / CIDR logic (stdlib ipaddress)
|
||||||
├── alert.py Alert / Severity (with Snort-style sid + classtype)
|
├── 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
|
├── detectors/ poll detectors — one module per signature, each a pure
|
||||||
│ function: detect(state, cfg) -> Iterable[Alert]
|
│ function: detect(state, cfg) -> Iterable[Alert]
|
||||||
|
├── notify/ outbound push — ntfy / Pushover / webhook backends
|
||||||
└── events/ event-driven layer (eBPF)
|
└── events/ event-driven layer (eBPF)
|
||||||
├── bcc_source.py real eBPF execve probe loaded via bcc
|
├── bcc_source.py real eBPF execve probe loaded via bcc
|
||||||
├── exec_event.py the ExecEvent type
|
├── 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 |
|
| `capture_execve_bpftrace` | false | add a bpftrace execve trace to snapshots |
|
||||||
| `notify_users` | [] | desktop notify-send targets |
|
| `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://<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`.
|
||||||
|
|
||||||
|
## 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
|
## Security model
|
||||||
|
|
||||||
Sentinel runs as root because it must read every process's `/proc`, the full
|
Sentinel runs as root because it must read every process's `/proc`, the full
|
||||||
|
|
@ -237,6 +282,9 @@ regression suite for both.
|
||||||
|
|
||||||
## Project status
|
## 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
|
v0.3 — adds the event-driven **eBPF layer**: a real `bcc` execve probe feeding a
|
||||||
Snort-style declarative rule engine (4 default rules), stable signature IDs +
|
Snort-style declarative rule engine (4 default rules), stable signature IDs +
|
||||||
classtypes on every detection, fail-safe degradation to poll-only, and an
|
classtypes on every detection, fail-safe degradation to poll-only, and an
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,34 @@ capture_execve_bpftrace = false
|
||||||
max_snapshots = 300 # auto-delete oldest beyond this count (0 = no cap)
|
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)
|
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_users = [] # e.g. ["luna"] — desktop notify-send on alert
|
||||||
notify_urgency = "critical" # low / normal / critical
|
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-<random>"
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
attack signature appears. The Python re-architecture of the bash v0 prototype.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.4.0"
|
||||||
|
|
|
||||||
|
|
@ -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("check", help="run detectors once and print alerts")
|
||||||
sub.add_parser("baseline", help="rebuild listener/SUID baselines")
|
sub.add_parser("baseline", help="rebuild listener/SUID baselines")
|
||||||
sub.add_parser("list-detectors", help="list available detectors")
|
sub.add_parser("list-detectors", help="list available detectors")
|
||||||
|
sub.add_parser("web", help="serve the read-only dashboard")
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
cfg = Config.load(args.config)
|
cfg = Config.load(args.config)
|
||||||
|
|
@ -74,6 +75,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
return _cmd_baseline(cfg)
|
return _cmd_baseline(cfg)
|
||||||
if args.cmd == "check":
|
if args.cmd == "check":
|
||||||
return _cmd_check(cfg)
|
return _cmd_check(cfg)
|
||||||
|
if args.cmd == "web":
|
||||||
|
from .web import serve
|
||||||
|
serve(cfg)
|
||||||
|
return 0
|
||||||
return _cmd_run(cfg)
|
return _cmd_run(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,24 @@ class Config:
|
||||||
max_snapshots: int = 300
|
max_snapshots: int = 300
|
||||||
max_snapshot_age_days: int = 60
|
max_snapshot_age_days: int = 60
|
||||||
|
|
||||||
# notifications
|
# notifications — desktop
|
||||||
notify_users: tuple[str, ...] = ()
|
notify_users: tuple[str, ...] = ()
|
||||||
notify_urgency: str = "critical"
|
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
|
# paths
|
||||||
log_dir: Path = Path("/var/log/enodia-sentinel")
|
log_dir: Path = Path("/var/log/enodia-sentinel")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ expensive filesystem-wide SUID scan is gated to its own slow cadence.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -124,6 +125,10 @@ class Sentinel:
|
||||||
# -- main loop ---------------------------------------------------------
|
# -- main loop ---------------------------------------------------------
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
self.cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
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:
|
with open(self.cfg.events_log, "a") as fh:
|
||||||
fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n")
|
fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n")
|
||||||
self.build_baselines()
|
self.build_baselines()
|
||||||
|
|
@ -150,6 +155,9 @@ class Sentinel:
|
||||||
|
|
||||||
def _start_exec_monitor(self) -> None:
|
def _start_exec_monitor(self) -> None:
|
||||||
if not self.cfg.ebpf_exec_monitor:
|
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
|
return
|
||||||
from .events.monitor import ExecMonitor
|
from .events.monitor import ExecMonitor
|
||||||
self._exec_monitor = ExecMonitor(self.cfg, self._on_exec_alert)
|
self._exec_monitor = ExecMonitor(self.cfg, self._on_exec_alert)
|
||||||
|
|
|
||||||
88
enodia_sentinel/notify/__init__.py
Normal file
88
enodia_sentinel/notify/__init__.py
Normal file
|
|
@ -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
|
||||||
100
enodia_sentinel/notify/backends.py
Normal file
100
enodia_sentinel/notify/backends.py
Normal file
|
|
@ -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))
|
||||||
|
|
@ -220,6 +220,14 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
|
||||||
f"{base.with_suffix('.log')} — signatures: {sigs}\n")
|
f"{base.with_suffix('.log')} — signatures: {sigs}\n")
|
||||||
|
|
||||||
_notify(cfg, f"{severity}: {sigs}")
|
_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")
|
return base.with_suffix(".log")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
158
enodia_sentinel/static/dashboard.html
Normal file
158
enodia_sentinel/static/dashboard.html
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
*{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)}
|
||||||
|
.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)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>ENODIA SENTINEL</h1>
|
||||||
|
<span class="dot" id="dot"></span>
|
||||||
|
<span class="host" id="host">—</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="meta" id="ebpf"></span>
|
||||||
|
<span class="meta" id="ver"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="cards" id="cards"></div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span id="status">connecting…</span>
|
||||||
|
<span class="spacer" style="flex:1"></span>
|
||||||
|
<span id="refreshed"></span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
||||||
|
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
|
||||||
|
let selected = null;
|
||||||
|
|
||||||
|
async function api(path){
|
||||||
|
const r = await fetch(path, {headers:HDR});
|
||||||
|
if(r.status===401) throw new Error("unauthorized — bad or missing ?token");
|
||||||
|
if(!r.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;}
|
||||||
|
|
||||||
|
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||"?");
|
||||||
|
document.getElementById("status").textContent =
|
||||||
|
st.running ? "daemon running" : "daemon NOT running";
|
||||||
|
const c = st.counts||{};
|
||||||
|
const cards = document.getElementById("cards"); cards.innerHTML="";
|
||||||
|
for(const [lbl,key] of [["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"]]){
|
||||||
|
const card=el("div","card "+key);
|
||||||
|
card.append(el("div","n",String(c[lbl]||0))); card.append(el("div","l",lbl));
|
||||||
|
cards.append(card);
|
||||||
|
}
|
||||||
|
const tot=el("div","card"); tot.append(el("div","n",String(st.total_alerts||0)));
|
||||||
|
tot.append(el("div","l","total alerts")); cards.append(tot);
|
||||||
|
|
||||||
|
const alerts = await api("/api/alerts");
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>';
|
||||||
|
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>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
244
enodia_sentinel/web.py
Normal file
244
enodia_sentinel/web.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Maintainer: Enodia
|
# Maintainer: Enodia
|
||||||
pkgname=enodia-sentinel
|
pkgname=enodia-sentinel
|
||||||
pkgver=0.3.0
|
pkgver=0.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
|
pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|
@ -22,6 +22,7 @@ package() {
|
||||||
install -Dm755 packaging/enodia-sentinel.wrapper "$pkgdir/usr/bin/enodia-sentinel"
|
install -Dm755 packaging/enodia-sentinel.wrapper "$pkgdir/usr/bin/enodia-sentinel"
|
||||||
install -Dm755 src/sentinel-redteam "$pkgdir/usr/bin/sentinel-redteam"
|
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.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 config/enodia-sentinel.toml "$pkgdir/etc/enodia-sentinel.toml"
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
install -dm750 "$pkgdir/var/log/enodia-sentinel"
|
install -dm750 "$pkgdir/var/log/enodia-sentinel"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "enodia-sentinel"
|
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"
|
description = "Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
@ -29,3 +29,6 @@ dev = ["pytest"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["enodia_sentinel*"]
|
include = ["enodia_sentinel*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
enodia_sentinel = ["static/*.html"]
|
||||||
|
|
|
||||||
33
systemd/enodia-sentinel-web.service
Normal file
33
systemd/enodia-sentinel-web.service
Normal file
|
|
@ -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
|
||||||
85
tests/test_notify.py
Normal file
85
tests/test_notify.py
Normal file
|
|
@ -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()
|
||||||
126
tests/test_web.py
Normal file
126
tests/test_web.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue