enodia-sentinal/enodia_sentinel/snapshot.py
Luna 0eb5077551 Add event-driven eBPF execve layer with a Snort-style rule engine
Closes polling's blind spot (processes that exit between sweeps) with a real
eBPF probe and a declarative, data-driven detection engine — inspired by Snort
(rule language, signature IDs) and OSSEC (host-IDS framing).

New events/ subpackage:
- bcc_source.py : eBPF C tracing execve (filename, argv[1..2], ppid, uid,
                  parent comm) over a perf buffer, loaded via bcc; lazy import
                  + available()/try-except so it fails closed to poll-only when
                  bcc/root/BTF are absent — a broken probe never downs the daemon
- exec_event.py : the ExecEvent type
- rules.py      : ExecRule (sid/msg/severity/classtype + path/exec/parent/argv
                  conditions) and ExecRuleEngine; 4 shipped rules (fileless exec
                  100001, reverse-shell argv 100002, web/DB→shell RCE 100003,
                  curl|sh 100004); operators add more via exec_rules_file TOML
- monitor.py    : runs the source on a thread, routes events through the engine

Integration:
- daemon starts the monitor, shares a lock-guarded cooldown with the sweep
  loop, and feeds event alerts into the same snapshot pipeline
- Alert gains Snort-style sid + classtype; retrofitted onto all 7 poll
  detectors; snapshots and JSON now carry them
- config: ebpf_exec_monitor (default on, degrades), exec_rules_file
- systemd: opt-in ebpf.conf drop-in (relaxes MemoryDenyWriteExecute + widens
  caps for bcc's JIT) so the base unit stays hardened for poll-only
- sentinel-redteam: ebpf_exec drill (short-lived /tmp exec + /dev/tcp argv the
  poller can't see); footer now uses the Python CLI

Tests: +14 cases for the rule engine (each default rule match/non-match, rule
validation, parent-exclude). 39/39 pass. Graceful non-root degradation verified.

NOTE: the eBPF C follows bcc's execsnoop pattern but could not be run here
(BPF needs root); it wants a root smoke-test on a real host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 07:16:53 -07:00

263 lines
9.9 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""Forensic snapshot writer.
When detections fire, this captures a timestamped report — both a human-readable
``.log`` and a structured ``.json`` sidecar — with per-signature incident-
response guidance, the flagged processes' /proc detail, the process tree,
sockets, recent file changes, and auth events.
"""
from __future__ import annotations
import json
import os
import subprocess
import time
from datetime import datetime
from pathlib import Path
from .alert import Alert, Severity
from .config import Config
from .system import Process, SystemState
RESPONSES: dict[str, str] = {
"reverse_shell": (
"A shell/interpreter has a network socket wired to its stdin/stdout — "
"the canonical reverse-shell signature. RESPONSE: identify the peer IP, "
"inspect the parent process (web/db parent = RCE), preserve /proc/<pid> "
"before killing if you can, then kill the pid."
),
"ld_preload": (
"Library-injection detected (ld.so.preload or LD_PRELOAD from a writable "
"path) — a userland rootkit / credential-theft hook. RESPONSE: capture "
"the named .so and hash it, check package ownership, inspect the process "
"tree, and assume host compromise until cleared."
),
"deleted_exe": (
"A process is executing from a deleted or memfd-backed binary — fileless "
"malware that left no file on disk. RESPONSE: dump /proc/<pid>/exe to "
"recover the binary BEFORE killing, capture maps and sockets."
),
"new_listener": (
"A listening socket appeared that wasn't present at baseline — possible "
"backdoor/bind shell. RESPONSE: identify the binary, confirm it's an "
"expected service, check for matching inbound connections."
),
"new_suid": (
"A new SUID/SGID binary appeared (critical if in a writable dir) — a "
"privilege-escalation persistence trick. RESPONSE: verify package "
"ownership; an unowned SUID binary in /tmp or /home is almost never "
"legitimate."
),
"persistence": (
"A persistence-relevant file (cron, systemd unit, authorized_keys, shell "
"rc) was modified. RESPONSE: diff against backup/version control, review "
"the change, and check auth logs for who made it."
),
"egress": (
"An interpreter is holding an outbound connection to a public IP — "
"possible C2 beacon or exfil. RESPONSE: resolve/geolocate the peer, check "
"reputation, inspect the process and its parentage."
),
}
_EXEC_RESPONSE = (
"An eBPF rule matched a process execution as it happened (caught even if the "
"process has since exited). RESPONSE: review the parent process and the full "
"command line, correlate with the captured sockets, and pivot on the parent "
"if it's a network-facing service."
)
def _response_for(signature: str) -> str:
if signature.startswith("exec_rule."):
return _EXEC_RESPONSE
return RESPONSES.get(signature, "Review the captured context manually.")
def _run(cmd: list[str], timeout: int = 8) -> str:
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return res.stdout
except (OSError, subprocess.SubprocessError) as exc:
return f" ({' '.join(cmd)} failed: {exc})\n"
def _pid_detail(state: SystemState, pid: int) -> dict:
proc = state.process(pid) or Process(pid)
fds: dict[str, str] = {}
fd_dir = f"/proc/{pid}/fd"
try:
for name in sorted(os.listdir(fd_dir), key=lambda x: int(x) if x.isdigit() else 0):
try:
fds[name] = os.readlink(os.path.join(fd_dir, name))
except OSError:
continue
except OSError:
pass
parent = state.process(proc.ppid)
return {
"pid": pid,
"comm": proc.comm,
"exe": proc.exe,
"cwd": proc.cwd,
"cmdline": proc.cmdline,
"ppid": proc.ppid,
"ppid_comm": parent.comm if parent else "",
"uid": proc.uid,
"ld_preload": proc.environ.get("LD_PRELOAD", ""),
"fds": dict(list(fds.items())[:40]),
}
def _format_text(report: dict, extras: dict[str, str]) -> str:
L: list[str] = []
L.append("=== ENODIA SENTINEL ALERT ===")
L.append(f"Time: {report['time']}")
L.append(f"Host: {report['host']}")
L.append(f"Severity: {report['severity']}")
L.append("")
L.append("## Triggering detections")
for a in report["alerts"]:
L.append(f" [{a['severity']}] sid:{a.get('sid', 0)} "
f"{a['signature']} ({a.get('classtype', '?')}) — {a['detail']}")
L.append("")
L.append("## Response guidance")
for sig in dict.fromkeys(a["signature"] for a in report["alerts"]):
L.append(f"{sig}:")
L.append(f" {_response_for(sig)}")
L.append("")
L.append("## Flagged process detail")
if report["processes"]:
for d in report["processes"]:
L.append(f"### pid {d['pid']}")
L.append(f" comm: {d['comm']}")
L.append(f" exe: {d['exe']}")
L.append(f" cwd: {d['cwd']}")
L.append(f" cmdline: {d['cmdline']}")
L.append(f" ppid: {d['ppid']} ({d['ppid_comm']})")
L.append(f" uid: {d['uid']}")
L.append(f" LD_PRELOAD env: {d['ld_preload']}")
L.append(" open fds:")
for fd, tgt in d["fds"].items():
L.append(f" {fd} -> {tgt}")
L.append("")
else:
L.append(" (no specific pid in alerts)")
L.append("")
for title, body in extras.items():
L.append(f"## {title}")
L.append(body.rstrip("\n"))
L.append("")
return "\n".join(L) + "\n"
def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
"""Write text + JSON snapshot, append events.log, and notify. Returns path."""
cfg.log_dir.mkdir(parents=True, exist_ok=True)
now = datetime.now().astimezone()
stamp = now.strftime("%Y%m%d-%H%M%S")
base = cfg.log_dir / f"alert-{stamp}"
severity = max((a.severity for a in alerts), default=Severity.HIGH)
pids: list[int] = sorted({p for a in alerts for p in a.pids})
report = {
"time": now.isoformat(),
"host": os.uname().nodename,
"severity": str(severity),
"alerts": [a.to_dict() for a in alerts],
"processes": [_pid_detail(state, p) for p in pids],
}
cutoff = int(time.time()) - 3600
recent_files = []
for root in cfg.watch_persistence:
try:
if os.path.isfile(root) and os.lstat(root).st_mtime > cutoff:
recent_files.append(root)
except OSError:
pass
ps_out = _run(["ps", "-eo", "pid,ppid,user,etimes,pcpu,pmem,stat,comm",
"--sort=-pcpu"])
extras = {
"Process tree (top by CPU)": "\n".join(ps_out.splitlines()[:40]),
"Sockets (ss -tanp)": "\n".join(_run(["ss", "-tanp"]).splitlines()[:80]),
"/etc/ld.so.preload": (
Path("/etc/ld.so.preload").read_text()
if Path("/etc/ld.so.preload").is_file()
and Path("/etc/ld.so.preload").stat().st_size
else " (empty — good)"
),
"Recently modified watched files (1h)": "\n".join(recent_files) or " (none)",
"Loaded kernel modules (head)": "\n".join(_run(["lsmod"]).splitlines()[:15]),
"Logins": _run(["who"]) + "--\n" + "\n".join(_run(["last", "-n", "5"]).splitlines()[:5]),
"Auth events (authpriv, 10m)": "\n".join(_run(
["journalctl", "--since", "10 minutes ago",
"--facility=authpriv", "--no-pager"]).splitlines()[-20:]),
}
if cfg.capture_execve_bpftrace:
extras["bpftrace execve (3s)"] = _run([
"bpftrace", "-e",
"tracepoint:syscalls:sys_enter_execve { "
"printf(\"%d %s %s\\n\", pid, comm, str(args->filename)); } "
"interval:s:3 { exit(); }",
], timeout=6)
text = _format_text(report, extras)
base.with_suffix(".log").write_text(text)
base.with_suffix(".json").write_text(json.dumps(report, indent=2))
try:
base.with_suffix(".log").chmod(0o640)
base.with_suffix(".json").chmod(0o640)
except OSError:
pass
sigs = ", ".join(dict.fromkeys(a.signature for a in alerts))
with open(cfg.events_log, "a") as fh:
fh.write(f"{now.isoformat()} [{severity}] captured "
f"{base.with_suffix('.log')} — signatures: {sigs}\n")
_notify(cfg, f"{severity}: {sigs}")
return base.with_suffix(".log")
def _notify(cfg: Config, body: str) -> None:
for user in cfg.notify_users:
try:
uid = int(subprocess.run(["id", "-u", user], capture_output=True,
text=True, timeout=3).stdout.strip())
except (OSError, subprocess.SubprocessError, ValueError):
continue
env = dict(os.environ,
DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{uid}/bus")
try:
subprocess.Popen(
["sudo", "-u", user, "notify-send", "-u", cfg.notify_urgency,
"-a", "enodia-sentinel", "⚠ Enodia Sentinel alert", body],
env=env)
except OSError:
pass
def prune(cfg: Config) -> None:
snaps = sorted(cfg.log_dir.glob("alert-*.log"),
key=lambda p: p.stat().st_mtime, reverse=True)
if cfg.max_snapshot_age_days > 0:
cutoff = time.time() - cfg.max_snapshot_age_days * 86400
for p in list(snaps):
if p.stat().st_mtime < cutoff:
_remove_pair(p)
snaps.remove(p)
if cfg.max_snapshots > 0:
for p in snaps[cfg.max_snapshots:]:
_remove_pair(p)
def _remove_pair(log_path: Path) -> None:
for p in (log_path, log_path.with_suffix(".json")):
try:
p.unlink()
except OSError:
pass