Re-architect Sentinel as a zero-dependency Python package
Port the bash prototype to a structured, testable Python codebase while
preserving the same control loop and the seven detection signatures. The bash
implementation stays in src/ as the regression oracle.
Highlights:
- enodia_sentinel/ package (stdlib only — no runtime deps):
- detectors/ : one pure function per signature, detect(state, cfg)->Alerts
- system.py : SystemState — one cached /proc + ss snapshot per sweep,
fully injectable so detectors are unit-testable
- daemon.py : sweep loop, in-process cooldown dedup, threaded snapshot
capture, and a SUID filesystem scan moved OFF the loop
thread onto a slow background cadence
- snapshot.py: forensic text + JSON sidecar with per-signature IR guidance
- config.py : dataclass config via TOML + env overrides
- netutil.py : public-IP / CIDR logic via stdlib ipaddress
- tests/ : 25 stdlib-unittest cases (no root, no /proc, no ss needed)
- TOML config, launcher wrapper, Makefile (pip-free install), hardened
systemd unit (env-resolved ExecStart), updated PKGBUILD, rewritten README
Performance: per-sweep cost ~200 ms (shared cached state); the multi-second
SUID walk no longer blocks detection. scandir-based walk replaces os.walk.
Verified on Arch: all 7 detectors fire on red-team drills (reverse_shell,
ld_preload, deleted_exe, new_listener, new_suid confirmed live end-to-end),
no false positives on a clean sweep, 25/25 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
45f8acb24a
commit
28d67a1360
28 changed files with 1783 additions and 133 deletions
144
enodia_sentinel/daemon.py
Normal file
144
enodia_sentinel/daemon.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""The detection daemon: sweep loop, cooldown dedup, baseline management.
|
||||
|
||||
Unlike the bash prototype, all loop state (cooldowns, last-scan timestamps,
|
||||
baselines) lives in this object — no subshell-state surprises — and the
|
||||
expensive filesystem-wide SUID scan is gated to its own slow cadence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import detectors, snapshot
|
||||
from .alert import Alert
|
||||
from .config import Config
|
||||
from .system import SystemState, scan_suid_binaries
|
||||
|
||||
|
||||
class Sentinel:
|
||||
def __init__(self, cfg: Config) -> None:
|
||||
self.cfg = cfg
|
||||
self.start_time = time.time()
|
||||
self.cooldowns: dict[str, float] = {}
|
||||
self.last_persist_scan = self.start_time
|
||||
self.listener_baseline: set[str] = set()
|
||||
self.suid_baseline: set[str] = set()
|
||||
# SUID scan runs off the loop thread; the loop reads the latest result.
|
||||
self._suid_current: list[str] | None = None
|
||||
self._suid_thread: threading.Thread | None = None
|
||||
self._last_suid_scan = 0.0
|
||||
self._last_suid_baseline_refresh = self.start_time
|
||||
self._stop = threading.Event()
|
||||
|
||||
# -- baselines ---------------------------------------------------------
|
||||
def build_baselines(self) -> None:
|
||||
self.listener_baseline = SystemState().listener_keys()
|
||||
self.suid_baseline = set(scan_suid_binaries(
|
||||
extra_dirs=self.cfg.suid_scan_extra_dirs))
|
||||
self.cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._save(self.cfg.listener_baseline, sorted(self.listener_baseline))
|
||||
self._save(self.cfg.suid_baseline, sorted(self.suid_baseline))
|
||||
|
||||
def load_baselines(self) -> None:
|
||||
self.listener_baseline = set(self._load(self.cfg.listener_baseline))
|
||||
self.suid_baseline = set(self._load(self.cfg.suid_baseline))
|
||||
|
||||
@staticmethod
|
||||
def _save(path: Path, data: list[str]) -> None:
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
@staticmethod
|
||||
def _load(path: Path) -> list[str]:
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
# -- SUID scan (off the loop thread) -----------------------------------
|
||||
def _maybe_scan_suid(self, now: float) -> None:
|
||||
if self._suid_thread and self._suid_thread.is_alive():
|
||||
return
|
||||
if (now - self._last_suid_scan) < self.cfg.suid_scan_interval:
|
||||
return
|
||||
self._last_suid_scan = now
|
||||
self._suid_thread = threading.Thread(target=self._scan_suid, daemon=True)
|
||||
self._suid_thread.start()
|
||||
|
||||
def _scan_suid(self) -> None:
|
||||
result = scan_suid_binaries(extra_dirs=self.cfg.suid_scan_extra_dirs)
|
||||
self._suid_current = result
|
||||
# Periodically fold the current state into the baseline so legitimately
|
||||
# installed SUID binaries stop alerting after a while.
|
||||
if (time.time() - self._last_suid_baseline_refresh) >= self.cfg.suid_refresh:
|
||||
self.suid_baseline = set(result)
|
||||
self._last_suid_baseline_refresh = time.time()
|
||||
self._save(self.cfg.suid_baseline, sorted(self.suid_baseline))
|
||||
|
||||
# -- one sweep ---------------------------------------------------------
|
||||
def sweep(self, *, force_suid: bool = False) -> list[Alert]:
|
||||
now = time.time()
|
||||
armed = (now - self.start_time) >= self.cfg.baseline_grace
|
||||
|
||||
if force_suid:
|
||||
suid_binaries = scan_suid_binaries(
|
||||
extra_dirs=self.cfg.suid_scan_extra_dirs)
|
||||
else:
|
||||
suid_binaries = self._suid_current # latest async result (may be None)
|
||||
|
||||
state = SystemState(
|
||||
listener_baseline=self.listener_baseline if armed or force_suid else None,
|
||||
suid_baseline=self.suid_baseline,
|
||||
suid_binaries=suid_binaries if armed or force_suid else None,
|
||||
persist_since=self.last_persist_scan if armed or force_suid else None,
|
||||
)
|
||||
alerts = list(detectors.run_all(state, self.cfg))
|
||||
self.last_persist_scan = now
|
||||
return alerts
|
||||
|
||||
def fresh_alerts(self, alerts: list[Alert], now: float) -> list[Alert]:
|
||||
"""Drop alerts whose dedup key is still within cooldown."""
|
||||
out = []
|
||||
for a in alerts:
|
||||
prev = self.cooldowns.get(a.key, 0.0)
|
||||
if (now - prev) >= self.cfg.cooldown:
|
||||
self.cooldowns[a.key] = now
|
||||
out.append(a)
|
||||
return out
|
||||
|
||||
# -- main loop ---------------------------------------------------------
|
||||
def run(self) -> None:
|
||||
self.cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.cfg.events_log, "a") as fh:
|
||||
fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n")
|
||||
self.build_baselines()
|
||||
snapshot.prune(self.cfg)
|
||||
sweeps = 0
|
||||
while not self._stop.is_set():
|
||||
now = time.time()
|
||||
if (now - self.start_time) >= self.cfg.baseline_grace:
|
||||
self._maybe_scan_suid(now)
|
||||
alerts = self.sweep()
|
||||
fresh = self.fresh_alerts(alerts, now)
|
||||
if fresh:
|
||||
# capture off the loop thread so a slow snapshot never stalls
|
||||
# detection; the SystemState used for forensics is rebuilt fresh
|
||||
# inside the thread for accuracy.
|
||||
threading.Thread(
|
||||
target=self._capture, args=(fresh,), daemon=True
|
||||
).start()
|
||||
sweeps += 1
|
||||
if sweeps % 20 == 0:
|
||||
snapshot.prune(self.cfg)
|
||||
self._stop.wait(self.cfg.sample_interval)
|
||||
|
||||
def _capture(self, alerts: list[Alert]) -> None:
|
||||
try:
|
||||
snapshot.capture(alerts, SystemState(), self.cfg)
|
||||
except Exception as exc: # never let a capture crash the daemon
|
||||
with open(self.cfg.events_log, "a") as fh:
|
||||
fh.write(f"{time.strftime('%FT%T%z')} capture error: {exc!r}\n")
|
||||
|
||||
def stop(self, *_a) -> None:
|
||||
self._stop.set()
|
||||
Loading…
Add table
Add a link
Reference in a new issue