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

@ -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