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>
This commit is contained in:
Luna 2026-05-31 07:16:53 -07:00
parent 586f74b929
commit 0eb5077551
24 changed files with 734 additions and 38 deletions

View file

@ -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.2.0"
__version__ = "0.3.0"

View file

@ -29,11 +29,18 @@ class Alert:
key: str
detail: str
pids: tuple[int, ...] = field(default_factory=tuple)
# Snort-style metadata: a stable signature id and a category. Every
# detection in Enodia has an `sid` so it can be referenced, tuned, and
# tracked across revisions the way a Snort/Suricata rule is.
sid: int = 0
classtype: str = "uncategorized"
def to_dict(self) -> dict:
return {
"sid": self.sid,
"severity": str(self.severity),
"signature": self.signature,
"classtype": self.classtype,
"key": self.key,
"detail": self.detail,
"pids": list(self.pids),

View file

@ -58,6 +58,10 @@ class Config:
# eBPF on-ramp
capture_execve_bpftrace: bool = False
# Event-driven eBPF execve monitor (catches short-lived processes the poll
# loop misses). Degrades gracefully to polling if bcc/root/BTF unavailable.
ebpf_exec_monitor: bool = True
exec_rules_file: str = "" # optional extra Snort-style rules (TOML)
# retention
max_snapshots: int = 300

View file

@ -23,6 +23,10 @@ class Sentinel:
self.cfg = cfg
self.start_time = time.time()
self.cooldowns: dict[str, float] = {}
# Cooldowns are touched by both the sweep loop and the eBPF event
# thread, so guard them.
self._cooldown_lock = threading.Lock()
self._exec_monitor = None
self.last_persist_scan = self.start_time
self.listener_baseline: set[str] = set()
self.suid_baseline: set[str] = set()
@ -99,15 +103,24 @@ class Sentinel:
return alerts
def fresh_alerts(self, alerts: list[Alert], now: float) -> list[Alert]:
"""Drop alerts whose dedup key is still within cooldown."""
"""Drop alerts whose dedup key is still within cooldown (thread-safe)."""
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)
with self._cooldown_lock:
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
def _on_exec_alert(self, alert: Alert) -> None:
"""Callback for the eBPF exec monitor — same dedup + capture path."""
fresh = self.fresh_alerts([alert], time.time())
if fresh:
threading.Thread(
target=self._capture, args=(fresh,), daemon=True
).start()
# -- main loop ---------------------------------------------------------
def run(self) -> None:
self.cfg.log_dir.mkdir(parents=True, exist_ok=True)
@ -115,6 +128,7 @@ class Sentinel:
fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n")
self.build_baselines()
snapshot.prune(self.cfg)
self._start_exec_monitor()
sweeps = 0
while not self._stop.is_set():
now = time.time()
@ -134,6 +148,18 @@ class Sentinel:
snapshot.prune(self.cfg)
self._stop.wait(self.cfg.sample_interval)
def _start_exec_monitor(self) -> None:
if not self.cfg.ebpf_exec_monitor:
return
from .events.monitor import ExecMonitor
self._exec_monitor = ExecMonitor(self.cfg, self._on_exec_alert)
ok, reason = self._exec_monitor.start()
with open(self.cfg.events_log, "a") as fh:
status = "enabled" if ok else f"disabled ({reason})"
fh.write(f"{time.strftime('%FT%T%z')} eBPF exec monitor: {status}\n")
if not ok:
self._exec_monitor = None
def _capture(self, alerts: list[Alert]) -> None:
try:
snapshot.capture(alerts, SystemState(), self.cfg)
@ -143,3 +169,5 @@ class Sentinel:
def stop(self, *_a) -> None:
self._stop.set()
if self._exec_monitor is not None:
self._exec_monitor.stop()

View file

@ -37,4 +37,6 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
key=f"del:{proc.pid}",
detail=f"pid={proc.pid} comm={proc.comm} exe=[{exe}]",
pids=(proc.pid,),
sid=100012,
classtype="fileless-execution",
)

View file

@ -33,4 +33,6 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
"(interpreter to public IP)"
),
pids=(s.pid,) if s.pid else (),
sid=100016,
classtype="c2-exfil",
)

View file

@ -31,6 +31,8 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
signature="ld_preload",
key="ldp:global",
detail=f"/etc/ld.so.preload is non-empty: [{contents.replace(chr(10), ' ')}]",
sid=100011,
classtype="rootkit-preload",
)
for proc in state.processes:
@ -44,4 +46,6 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
key=f"ldp:{proc.pid}",
detail=f"pid={proc.pid} comm={proc.comm} LD_PRELOAD=[{pre}]",
pids=(proc.pid,),
sid=100011,
classtype="rootkit-preload",
)

View file

@ -31,4 +31,6 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
key=f"lis:{key}",
detail=f"new listening socket {s.local} by comm={comm}",
pids=(s.pid,) if s.pid else (),
sid=100013,
classtype="backdoor-listener",
)

View file

@ -33,4 +33,6 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
("SUID/SGID binary in writable dir: " if hot
else "new SUID/SGID binary: ") + path
),
sid=100014,
classtype="privilege-escalation",
)

View file

@ -40,4 +40,6 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
signature="persistence",
key=f"persist:{path}",
detail=f"persistence file modified: {path}",
sid=100015,
classtype="persistence",
)

View file

@ -35,4 +35,6 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
f"peer=[{peer}] cmd=[{proc.cmdline[:90]}]"
),
pids=(proc.pid,),
sid=100010,
classtype="c2-reverse-shell",
)

View file

@ -0,0 +1,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Event-driven detection layer.
Where the poll-based detectors sweep system state every few seconds, this layer
reacts to kernel events the instant they happen so a process that executes and
exits between two sweeps (fileless droppers, short-lived reverse shells) is still
caught. Events come from eBPF (``bcc``) and are matched against a declarative,
Snort-style rule set.
"""

View file

@ -0,0 +1,139 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""eBPF execve event source, built on bcc.
Loads a small eBPF program that fires on every ``execve``, capturing the
executed path, the first couple of arguments, the uid, and the parent. Events
are delivered to user space over a perf buffer and decoded into ``ExecEvent``.
This is intentionally fail-safe: if bcc isn't installed, the kernel lacks BPF,
or we aren't root, ``available()`` returns False and the daemon simply runs with
its poll-based detectors. A broken probe must never take the daemon down.
"""
from __future__ import annotations
import os
from collections.abc import Callable
from .exec_event import ExecEvent
# eBPF C. bcc rewrites pointer dereferences (e.g. task->real_parent->tgid) into
# bpf_probe_read calls, so this stays close to bcc's own execsnoop example.
_BPF_PROGRAM = r"""
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#define ARGLEN 160
struct data_t {
u32 pid;
u32 ppid;
u32 uid;
char parent_comm[TASK_COMM_LEN];
char filename[ARGLEN];
char arg1[ARGLEN];
char arg2[ARGLEN];
};
BPF_PERF_OUTPUT(events);
int syscall__execve(struct pt_regs *ctx,
const char __user *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
struct data_t data = {};
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
data.pid = bpf_get_current_pid_tgid() >> 32;
data.ppid = task->real_parent->tgid;
data.uid = bpf_get_current_uid_gid() & 0xffffffff;
bpf_get_current_comm(&data.parent_comm, sizeof(data.parent_comm));
bpf_probe_read_user_str(&data.filename, sizeof(data.filename), filename);
const char __user *argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), &__argv[1]);
if (argp)
bpf_probe_read_user_str(&data.arg1, sizeof(data.arg1), argp);
argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), &__argv[2]);
if (argp)
bpf_probe_read_user_str(&data.arg2, sizeof(data.arg2), argp);
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
"""
def available() -> tuple[bool, str]:
"""Return (ok, reason). ok=True means the probe can be loaded."""
if os.geteuid() != 0:
return False, "not running as root (BPF requires CAP_BPF/root)"
try:
import bcc # noqa: F401
except ImportError:
return False, "python bcc (python-bpfcc) not installed"
if not os.path.exists("/sys/kernel/btf/vmlinux") and not os.path.isdir(
"/lib/modules/%s/build" % os.uname().release
):
return False, "no kernel BTF or headers for BPF compilation"
return True, "ok"
class BccExecSource:
"""Loads the eBPF probe and pumps decoded ExecEvents to a callback."""
def __init__(self, on_event: Callable[[ExecEvent], None]) -> None:
self._on_event = on_event
self._bpf = None
self._running = False
def start(self) -> None:
from bcc import BPF
self._bpf = BPF(text=_BPF_PROGRAM)
fnname = self._bpf.get_syscall_fnname("execve")
self._bpf.attach_kprobe(event=fnname, fn_name="syscall__execve")
self._bpf["events"].open_perf_buffer(self._handle, page_cnt=64)
self._running = True
def poll(self, timeout_ms: int = 200) -> None:
if self._bpf is not None:
self._bpf.perf_buffer_poll(timeout=timeout_ms)
def stop(self) -> None:
self._running = False
if self._bpf is not None:
try:
self._bpf.cleanup()
except Exception:
pass
self._bpf = None
@property
def running(self) -> bool:
return self._running
def _handle(self, cpu, data, size) -> None:
event = self._bpf["events"].event(data)
argv = tuple(
a for a in (_decode(event.arg1), _decode(event.arg2)) if a
)
ev = ExecEvent(
pid=event.pid,
ppid=event.ppid,
uid=event.uid,
parent_comm=_decode(event.parent_comm),
filename=_decode(event.filename),
argv=argv,
)
try:
self._on_event(ev)
except Exception:
# one bad event must not kill the poll loop
pass
def _decode(raw) -> str:
if isinstance(raw, bytes):
return raw.split(b"\x00", 1)[0].decode("utf-8", "replace")
return str(raw)

View file

@ -0,0 +1,43 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""The execve event — one process-execution observed by the kernel probe."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ExecEvent:
"""A single ``execve``, as seen at the moment of execution.
Captured by the eBPF probe (so it exists even for processes that exit before
a poll). ``parent_comm`` is the caller; ``filename`` is the program being
executed; ``argv`` is as much of the argument vector as the probe captured.
"""
pid: int
ppid: int
uid: int
parent_comm: str
filename: str
argv: tuple[str, ...] = field(default_factory=tuple)
@property
def exec_comm(self) -> str:
"""Basename of the executed program (its post-exec ``comm``)."""
return os.path.basename(self.filename)
@property
def argv_str(self) -> str:
"""Full command line for regex matching (filename + captured args)."""
return " ".join((self.filename, *self.argv)).strip()
def to_dict(self) -> dict:
return {
"pid": self.pid,
"ppid": self.ppid,
"uid": self.uid,
"parent_comm": self.parent_comm,
"filename": self.filename,
"argv": list(self.argv),
}

View file

@ -0,0 +1,55 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Runs the eBPF exec source on a thread and routes events through the engine."""
from __future__ import annotations
import threading
from collections.abc import Callable
from ..alert import Alert
from ..config import Config
from . import bcc_source
from .exec_event import ExecEvent
from .rules import ExecRuleEngine
class ExecMonitor:
"""Owns the event source + rule engine; emits Alerts via ``on_alert``.
``start()`` is best-effort: it returns a (started, reason) pair and never
raises, so the daemon can log the reason and carry on with polling.
"""
def __init__(self, cfg: Config, on_alert: Callable[[Alert], None]) -> None:
self.cfg = cfg
self._on_alert = on_alert
self.engine = ExecRuleEngine.load(cfg.exec_rules_file)
self._source = bcc_source.BccExecSource(self._on_event)
self._thread: threading.Thread | None = None
self._stop = threading.Event()
def start(self) -> tuple[bool, str]:
ok, reason = bcc_source.available()
if not ok:
return False, reason
try:
self._source.start()
except Exception as exc: # compilation/attach/permission failure
return False, f"probe load failed: {exc!r}"
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
return True, "ok"
def _run(self) -> None:
while not self._stop.is_set():
try:
self._source.poll(timeout_ms=200)
except Exception:
break
def _on_event(self, ev: ExecEvent) -> None:
for alert in self.engine.match(ev):
self._on_alert(alert)
def stop(self) -> None:
self._stop.set()
self._source.stop()

View file

@ -0,0 +1,177 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""A declarative, Snort-style rule engine for execve events.
Detection logic lives in *data*, not code. Each ``ExecRule`` is the host-event
analogue of a Snort rule: a stable ``sid``, a human ``msg``, a ``classtype`` and
``severity``, plus match conditions on the executed path, the program, the
parent process, and the argument vector. The engine just walks the rules.
A rule fires when **every condition it specifies** matches (AND); unspecified
conditions are wildcards. A rule with no conditions is rejected at load time so
it can't match everything by accident.
Default rules ship below; operators can add more via a TOML file
(``[[exec_rules]]`` tables) referenced by ``exec_rules_file`` in the config.
"""
from __future__ import annotations
import re
import tomllib
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
from ..alert import Alert, Severity
from .exec_event import ExecEvent
_SEVERITY = {
"MEDIUM": Severity.MEDIUM,
"HIGH": Severity.HIGH,
"CRITICAL": Severity.CRITICAL,
}
@dataclass(frozen=True)
class ExecRule:
sid: int
msg: str
severity: Severity
classtype: str
# Conditions (all that are set must match):
path_prefixes: tuple[str, ...] = () # filename startswith any
exec_comm: frozenset[str] = frozenset() # basename(filename) in set
parent_comm: frozenset[str] = frozenset() # caller comm in set
argv_regex: str | None = None # re.search over argv_str
parent_exclude: frozenset[str] = frozenset() # never fire for these parents
def __post_init__(self) -> None:
if not any((self.path_prefixes, self.exec_comm,
self.parent_comm, self.argv_regex)):
raise ValueError(f"rule sid={self.sid} has no match conditions")
if self.argv_regex is not None:
# compile once; store on the frozen instance via object.__setattr__
object.__setattr__(self, "_argv_re", re.compile(self.argv_regex,
re.IGNORECASE))
else:
object.__setattr__(self, "_argv_re", None)
def matches(self, ev: ExecEvent) -> bool:
if ev.parent_comm in self.parent_exclude:
return False
if self.path_prefixes and not ev.filename.startswith(self.path_prefixes):
return False
if self.exec_comm and ev.exec_comm not in self.exec_comm:
return False
if self.parent_comm and ev.parent_comm not in self.parent_comm:
return False
if self._argv_re is not None and not self._argv_re.search(ev.argv_str):
return False
return True
def to_alert(self, ev: ExecEvent) -> Alert:
return Alert(
severity=self.severity,
signature=f"exec_rule.{self.classtype}",
key=f"exec:{self.sid}:{ev.pid}",
detail=(
f"sid={self.sid} {self.msg} — pid={ev.pid} ppid={ev.ppid} "
f"parent={ev.parent_comm} exec={ev.filename} "
f"argv=[{ev.argv_str[:120]}]"
),
pids=(ev.pid,),
sid=self.sid,
classtype=self.classtype,
)
# --- shipped default rules -------------------------------------------------
# SIDs 100001100999 are reserved for Enodia's built-in exec rules.
_INTERP = frozenset(
"sh bash dash zsh ksh ash python python2 python3 perl ruby php lua "
"nc ncat netcat socat".split()
)
_WEB_DB_SERVERS = frozenset(
"nginx apache apache2 httpd php-fpm php php7 php8 lighttpd caddy "
"node nodejs tomcat catalina mysqld mariadbd postgres redis-server".split()
)
DEFAULT_EXEC_RULES: tuple[ExecRule, ...] = (
ExecRule(
sid=100001,
msg="Program executed from a world-writable directory",
severity=Severity.CRITICAL,
classtype="fileless-execution",
path_prefixes=("/tmp/", "/dev/shm/", "/var/tmp/"),
),
ExecRule(
sid=100002,
msg="Reverse-shell command pattern in execve arguments",
severity=Severity.CRITICAL,
classtype="c2-reverse-shell",
argv_regex=(
r"/dev/(tcp|udp)/"
r"|\b(ba|da|z)?sh\b[^|]*\s-i\b"
r"|\bn(c|cat|etcat)\b.*\s-e\b"
r"|\bsocat\b.*\bexec"
r"|python[0-9.]*\b.*(pty\.spawn|socket\.socket)"
r"|perl\b.*\bSocket\b"
),
),
ExecRule(
sid=100003,
msg="Web/DB service spawned a shell or interpreter (possible RCE/webshell)",
severity=Severity.CRITICAL,
classtype="web-rce",
parent_comm=_WEB_DB_SERVERS,
exec_comm=_INTERP,
),
ExecRule(
sid=100004,
msg="Download piped directly to a shell (ingress tool transfer)",
severity=Severity.HIGH,
classtype="ingress-tool-transfer",
argv_regex=r"\b(curl|wget|fetch)\b.*\|\s*(ba|da|z)?sh\b",
),
)
class ExecRuleEngine:
"""Holds a rule set and matches events against it."""
def __init__(self, rules: Iterable[ExecRule] | None = None) -> None:
self.rules: list[ExecRule] = list(
rules if rules is not None else DEFAULT_EXEC_RULES
)
def match(self, ev: ExecEvent) -> Iterator[Alert]:
for rule in self.rules:
if rule.matches(ev):
yield rule.to_alert(ev)
@classmethod
def load(cls, path: str | Path | None) -> "ExecRuleEngine":
rules = list(DEFAULT_EXEC_RULES)
if path and Path(path).is_file():
rules.extend(_load_toml_rules(Path(path)))
return cls(rules)
def _load_toml_rules(path: Path) -> list[ExecRule]:
with open(path, "rb") as fh:
data = tomllib.load(fh)
out: list[ExecRule] = []
for r in data.get("exec_rules", []):
out.append(ExecRule(
sid=int(r["sid"]),
msg=r.get("msg", ""),
severity=_SEVERITY.get(str(r.get("severity", "HIGH")).upper(),
Severity.HIGH),
classtype=r.get("classtype", "uncategorized"),
path_prefixes=tuple(r.get("path_prefixes", ())),
exec_comm=frozenset(r.get("exec_comm", ())),
parent_comm=frozenset(r.get("parent_comm", ())),
argv_regex=r.get("argv_regex"),
parent_exclude=frozenset(r.get("parent_exclude", ())),
))
return out

View file

@ -61,6 +61,20 @@ RESPONSES: dict[str, str] = {
}
_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)
@ -105,12 +119,13 @@ def _format_text(report: dict, extras: dict[str, str]) -> str:
L.append("")
L.append("## Triggering detections")
for a in report["alerts"]:
L.append(f" [{a['severity']}] {a['signature']}{a['detail']}")
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" {RESPONSES.get(sig, 'Review the captured context manually.')}")
L.append(f" {_response_for(sig)}")
L.append("")
L.append("## Flagged process detail")
if report["processes"]: