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:
parent
586f74b929
commit
0eb5077551
24 changed files with 734 additions and 38 deletions
177
enodia_sentinel/events/rules.py
Normal file
177
enodia_sentinel/events/rules.py
Normal 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 100001–100999 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue