From a51478fa22b7ffcd7b72a3da9238b025fa978066 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 13 Jun 2026 05:45:30 -0700 Subject: [PATCH] Add event-driven memory syscall telemetry --- CLAUDE.md | 2 +- README.md | 18 +- config/enodia-sentinel.toml | 6 +- docs/COMMAND_REFERENCE.md | 4 +- docs/ROADMAP.md | 3 + docs/SPECIFICATION.md | 10 +- docs/THREAT_MODEL.md | 2 +- enodia_sentinel/config.py | 7 +- enodia_sentinel/daemon.py | 25 +- enodia_sentinel/events/__init__.py | 8 +- enodia_sentinel/events/bcc_syscall_source.py | 229 +++++++++++++++++++ enodia_sentinel/events/monitor.py | 49 +++- enodia_sentinel/events/syscall_event.py | 32 +++ enodia_sentinel/events/syscall_rules.py | 128 +++++++++++ enodia_sentinel/static/dashboard.html | 4 +- enodia_sentinel/web.py | 14 +- tests/test_syscall_rules.py | 71 ++++++ tests/test_web.py | 9 + 18 files changed, 589 insertions(+), 32 deletions(-) create mode 100644 enodia_sentinel/events/bcc_syscall_source.py create mode 100644 enodia_sentinel/events/syscall_event.py create mode 100644 enodia_sentinel/events/syscall_rules.py create mode 100644 tests/test_syscall_rules.py diff --git a/CLAUDE.md b/CLAUDE.md index 3c1ae27..38901f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Start with: - `daemon.py` runs sweep/cooldown/background tasks. - `system.py` builds one cached injectable view of processes and sockets. - `detectors/` contains pure poll detectors. -- `events/` contains optional eBPF exec monitoring and declarative rules. +- `events/` contains optional eBPF exec/syscall monitoring and declarative rules. - `snapshot.py` writes forensic `.log` and `.json` evidence. - `incident.py` groups snapshots by process lineage and time window. - `respond.py` builds read-only response plans from incident evidence. diff --git a/README.md b/README.md index 77a2b5f..d645a2d 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,14 @@ 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. +Shipped exec 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`). The syscall stream adds short-lived memory/anti-analysis +coverage for RWX `mprotect`/`mmap` (`100060`/`100061`), `memfd_create` +(`100062`), sensitive `ptrace` (`100063`), seccomp hardening (`100064`), +cross-process memory access (`100065`), and memory locking (`100066`). +Operators add custom exec rules 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 @@ -237,6 +241,8 @@ enodia-sentinel.service`. Every key is optional. Highlights: | `suid_hot_dirs` | /tmp … | dirs where a SUID binary is CRITICAL | | `suid_scan_extra_dirs` | /tmp … | writable mounts always scanned (tmpfs-safe) | | `capture_execve_bpftrace` | false | add a bpftrace execve trace to snapshots | +| `ebpf_exec_monitor` | true | optional execve event monitor | +| `ebpf_syscall_monitor` | true | optional memory/anti-analysis syscall monitor | | `notify_users` | [] | desktop notify-send targets | | `pkgdb_pkgverify` | false | verify on-disk files against signed cache packages | | `pkgdb_pkgverify_sample` | 40 | packages verified per pass (rotates over time) | @@ -489,7 +495,9 @@ 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 +# confirm: +grep 'eBPF exec monitor' /var/log/enodia-sentinel/events.log +grep 'eBPF syscall monitor' /var/log/enodia-sentinel/events.log ``` The drop-in relaxes `MemoryDenyWriteExecute` and widens the capability set — a diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 34d93c8..91ee68d 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -153,10 +153,10 @@ posture_sudoers_dir = "/etc/sudoers.d" # posture_path = [] # PATH dirs to audit; empty = safe default set # --- 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. +# Event-driven eBPF monitors: catch short-lived activity the poll loop misses. +# Requires python-bpfcc and root; degrades gracefully to polling otherwise. ebpf_exec_monitor = true +ebpf_syscall_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 = "" diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 7496013..4356ecb 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -22,8 +22,8 @@ enodia-sentinel run ``` Starts the daemon loop. This is the command used by the systemd service. It -builds or loads baselines, starts optional event monitoring, writes heartbeats, -runs detector sweeps, captures snapshots, and sends notifications. +builds or loads baselines, starts optional exec/syscall event monitoring, writes +heartbeats, runs detector sweeps, captures snapshots, and sends notifications. Expected use: systemd, not an interactive shell. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a3aa048..b25093f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,6 +43,9 @@ work that is bigger than single alerts. - ✅ Add process-hiding and memory-obfuscation coverage: `/proc` vs `ps` cross-view checks plus memory-map scans for hide libraries, RWX memory, and executable anonymous/memfd/deleted mappings. +- ✅ Add event-driven memory telemetry for short-lived `mprotect`/`mmap` RWX, + `memfd_create`, `ptrace`, seccomp, cross-process memory access, and memory + locking syscalls. Exit criteria: diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 25edb3f..e470bd8 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -64,10 +64,12 @@ against cached process and socket data: ### Event Detection -An optional bcc eBPF `execve` monitor feeds a declarative rule engine. This -closes the polling gap for short-lived commands and supports custom TOML rules -without code changes. Failure is fail-safe: if the probe cannot load, polling -continues. +Optional bcc eBPF monitors feed event rule engines. The `execve` stream closes +the polling gap for short-lived commands and supports custom TOML rules without +code changes. The syscall stream watches short-lived memory and anti-analysis +behavior: RWX `mprotect`/`mmap`, `memfd_create`, sensitive `ptrace`, seccomp, +cross-process memory access, and memory locking. Failure is fail-safe: if a +probe cannot load, polling continues. ### Integrity and Tamper-Evidence diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 5d2cd6a..2cf8a94 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -29,7 +29,7 @@ Sentinel is designed to help against: | Fileless or short-lived execution | Deleted executable, memfd payload, fast `curl|sh` | Detect deleted executables and eBPF exec rules where available. | | Package/file tampering | Trojaned binary, rewritten package DB checksums | Detect FIM drift, package DB tamper, and signed-package mismatches. | | Common rootkit hiding | LD_PRELOAD tricks, process-tool hiding, `/proc` hiding, module-list hiding, hidden TCP/UDP/raw/SCTP/packet sockets, tainted modules | Detect LD_PRELOAD, `/proc` vs `ps` disagreement, mapped hide libraries, cross-view inconsistencies, known LKM names, raw ICMP/SCTP-style sockets, unusual protocol families, and kernel/module taint. | -| Memory-resident payloads | Encrypted heap/code, packed memfd stages, RWX shellcode, deleted mapped payloads | Detect executable anonymous/memfd/deleted mappings and writable+executable pages. | +| Memory-resident payloads | Encrypted heap/code, packed memfd stages, RWX shellcode, deleted mapped payloads | Detect executable anonymous/memfd/deleted mappings and writable+executable pages; optional eBPF syscall telemetry catches short-lived RWX transitions and memfd staging. | | Sensor tampering | Stop daemon, edit config, remove hook, modify baseline | Detect self-integrity changes and stale heartbeat via external watchdog. | ## Trust Boundaries diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index c397945..0699eb8 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -136,9 +136,12 @@ 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. + # Event-driven eBPF monitors (catch short-lived activity the poll loop + # misses). Degrade gracefully to polling if bcc/root/BTF unavailable. ebpf_exec_monitor: bool = True + # Event-driven syscall telemetry for short-lived memory/anti-analysis + # behavior: memfd_create, mmap/mprotect RWX, ptrace, seccomp, process_vm_*. + ebpf_syscall_monitor: bool = True exec_rules_file: str = "" # optional extra Snort-style rules (TOML) # retention diff --git a/enodia_sentinel/daemon.py b/enodia_sentinel/daemon.py index 14865a8..09c6efc 100644 --- a/enodia_sentinel/daemon.py +++ b/enodia_sentinel/daemon.py @@ -24,10 +24,11 @@ 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. + # Cooldowns are touched by the sweep loop and eBPF event threads, so + # guard them. self._cooldown_lock = threading.Lock() self._exec_monitor = None + self._syscall_monitor = None self.last_persist_scan = self.start_time self.listener_baseline: set[str] = set() self.suid_baseline: set[str] = set() @@ -236,7 +237,7 @@ class Sentinel: return out def _on_exec_alert(self, alert: Alert) -> None: - """Callback for the eBPF exec monitor — same dedup + capture path.""" + """Callback for eBPF monitors — same dedup + capture path.""" fresh = self.fresh_alerts([alert], time.time()) if fresh: threading.Thread( @@ -256,6 +257,7 @@ class Sentinel: self.load_fim_baseline() snapshot.prune(self.cfg) self._start_exec_monitor() + self._start_syscall_monitor() sweeps = 0 while not self._stop.is_set(): now = time.time() @@ -297,6 +299,21 @@ class Sentinel: if not ok: self._exec_monitor = None + def _start_syscall_monitor(self) -> None: + if not self.cfg.ebpf_syscall_monitor: + with open(self.cfg.events_log, "a") as fh: + fh.write(f"{time.strftime('%FT%T%z')} " + "eBPF syscall monitor: off (disabled in config)\n") + return + from .events.monitor import SyscallMonitor + self._syscall_monitor = SyscallMonitor(self.cfg, self._on_exec_alert) + ok, reason = self._syscall_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 syscall monitor: {status}\n") + if not ok: + self._syscall_monitor = None + def _capture(self, alerts: list[Alert]) -> None: try: snapshot.capture(alerts, SystemState(), self.cfg) @@ -308,3 +325,5 @@ class Sentinel: self._stop.set() if self._exec_monitor is not None: self._exec_monitor.stop() + if self._syscall_monitor is not None: + self._syscall_monitor.stop() diff --git a/enodia_sentinel/events/__init__.py b/enodia_sentinel/events/__init__.py index 8d448e2..3192130 100644 --- a/enodia_sentinel/events/__init__.py +++ b/enodia_sentinel/events/__init__.py @@ -2,8 +2,8 @@ """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. +reacts to kernel events the instant they happen — so a process that executes, +decrypts memory, changes page protections, or exits between two sweeps is still +caught. Events come from eBPF (``bcc``) and are matched against declarative, +Snort-style rule sets. """ diff --git a/enodia_sentinel/events/bcc_syscall_source.py b/enodia_sentinel/events/bcc_syscall_source.py new file mode 100644 index 0000000..5b48a19 --- /dev/null +++ b/enodia_sentinel/events/bcc_syscall_source.py @@ -0,0 +1,229 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""eBPF source for security-relevant syscall telemetry.""" +from __future__ import annotations + +from collections.abc import Callable + +from .bcc_source import _decode, available +from .syscall_event import SyscallEvent + +_BPF_PROGRAM = r""" +#include +#include + +#define TEXTLEN 80 +#define SYS_MPROTECT 1 +#define SYS_MMAP 2 +#define SYS_MEMFD_CREATE 3 +#define SYS_PTRACE 4 +#define SYS_PRCTL 5 +#define SYS_SECCOMP 6 +#define SYS_PROCESS_VM_READV 7 +#define SYS_PROCESS_VM_WRITEV 8 +#define SYS_MLOCK 9 +#define SYS_MLOCK2 10 +#define SYS_MLOCKALL 11 + +struct data_t { + u32 pid; + u32 ppid; + u32 uid; + char comm[TASK_COMM_LEN]; + u32 syscall_id; + u64 arg0; + u64 arg1; + u64 arg2; + u64 arg3; + u64 arg4; + u64 arg5; + char text[TEXTLEN]; +}; +BPF_PERF_OUTPUT(events); + +static int submit(struct pt_regs *ctx, u32 syscall_id, + u64 a0, u64 a1, u64 a2, u64 a3, u64 a4, u64 a5, + const char __user *textp) +{ + 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.comm, sizeof(data.comm)); + data.syscall_id = syscall_id; + data.arg0 = a0; + data.arg1 = a1; + data.arg2 = a2; + data.arg3 = a3; + data.arg4 = a4; + data.arg5 = a5; + if (textp) + bpf_probe_read_user_str(&data.text, sizeof(data.text), textp); + events.perf_submit(ctx, &data, sizeof(data)); + return 0; +} + +int syscall__mprotect(struct pt_regs *ctx, unsigned long start, unsigned long len, + unsigned long prot) +{ + return submit(ctx, SYS_MPROTECT, start, len, prot, 0, 0, 0, 0); +} + +int syscall__mmap(struct pt_regs *ctx, unsigned long addr, unsigned long len, + unsigned long prot, unsigned long flags, unsigned long fd, + unsigned long off) +{ + return submit(ctx, SYS_MMAP, addr, len, prot, flags, fd, off, 0); +} + +int syscall__memfd_create(struct pt_regs *ctx, const char __user *name, + unsigned int flags) +{ + return submit(ctx, SYS_MEMFD_CREATE, flags, 0, 0, 0, 0, 0, name); +} + +int syscall__ptrace(struct pt_regs *ctx, long request, long pid, + unsigned long addr, unsigned long data) +{ + return submit(ctx, SYS_PTRACE, request, pid, addr, data, 0, 0, 0); +} + +int syscall__prctl(struct pt_regs *ctx, int option, unsigned long arg2, + unsigned long arg3, unsigned long arg4, unsigned long arg5) +{ + return submit(ctx, SYS_PRCTL, option, arg2, arg3, arg4, arg5, 0, 0); +} + +int syscall__seccomp(struct pt_regs *ctx, unsigned int operation, + unsigned int flags, const void __user *args) +{ + return submit(ctx, SYS_SECCOMP, operation, flags, (u64)args, 0, 0, 0, 0); +} + +int syscall__process_vm_readv(struct pt_regs *ctx, int pid, + const void __user *lvec, + unsigned long liovcnt, + const void __user *rvec, + unsigned long riovcnt, + unsigned long flags) +{ + return submit(ctx, SYS_PROCESS_VM_READV, pid, liovcnt, riovcnt, flags, 0, 0, 0); +} + +int syscall__process_vm_writev(struct pt_regs *ctx, int pid, + const void __user *lvec, + unsigned long liovcnt, + const void __user *rvec, + unsigned long riovcnt, + unsigned long flags) +{ + return submit(ctx, SYS_PROCESS_VM_WRITEV, pid, liovcnt, riovcnt, flags, 0, 0, 0); +} + +int syscall__mlock(struct pt_regs *ctx, const void __user *addr, unsigned long len) +{ + return submit(ctx, SYS_MLOCK, (u64)addr, len, 0, 0, 0, 0, 0); +} + +int syscall__mlock2(struct pt_regs *ctx, const void __user *addr, unsigned long len, + int flags) +{ + return submit(ctx, SYS_MLOCK2, (u64)addr, len, flags, 0, 0, 0, 0); +} + +int syscall__mlockall(struct pt_regs *ctx, int flags) +{ + return submit(ctx, SYS_MLOCKALL, flags, 0, 0, 0, 0, 0, 0); +} +""" + +_PROBES = { + "mprotect": "syscall__mprotect", + "mmap": "syscall__mmap", + "memfd_create": "syscall__memfd_create", + "ptrace": "syscall__ptrace", + "prctl": "syscall__prctl", + "seccomp": "syscall__seccomp", + "process_vm_readv": "syscall__process_vm_readv", + "process_vm_writev": "syscall__process_vm_writev", + "mlock": "syscall__mlock", + "mlock2": "syscall__mlock2", + "mlockall": "syscall__mlockall", +} + +_SYSCALL_NAMES = { + 1: "mprotect", + 2: "mmap", + 3: "memfd_create", + 4: "ptrace", + 5: "prctl", + 6: "seccomp", + 7: "process_vm_readv", + 8: "process_vm_writev", + 9: "mlock", + 10: "mlock2", + 11: "mlockall", +} + + +class BccSyscallSource: + def __init__(self, on_event: Callable[[SyscallEvent], None]) -> None: + self._on_event = on_event + self._bpf = None + self._running = False + self.attach_errors: list[str] = [] + + def start(self) -> None: + from bcc import BPF + + self._bpf = BPF(text=_BPF_PROGRAM) + attached = 0 + for syscall, fn_name in _PROBES.items(): + try: + event = self._bpf.get_syscall_fnname(syscall) + self._bpf.attach_kprobe(event=event, fn_name=fn_name) + attached += 1 + except Exception as exc: + self.attach_errors.append(f"{syscall}: {exc!r}") + if attached == 0: + raise RuntimeError("no syscall probes attached") + 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) + ev = SyscallEvent( + pid=event.pid, + ppid=event.ppid, + uid=event.uid, + comm=_decode(event.comm), + syscall=_SYSCALL_NAMES.get(int(event.syscall_id), "unknown"), + arg0=int(event.arg0), + arg1=int(event.arg1), + arg2=int(event.arg2), + arg3=int(event.arg3), + arg4=int(event.arg4), + arg5=int(event.arg5), + text=_decode(event.text), + ) + try: + self._on_event(ev) + except Exception: + pass diff --git a/enodia_sentinel/events/monitor.py b/enodia_sentinel/events/monitor.py index 3b4bb02..36caceb 100644 --- a/enodia_sentinel/events/monitor.py +++ b/enodia_sentinel/events/monitor.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: GPL-3.0-or-later -"""Runs the eBPF exec source on a thread and routes events through the engine.""" +"""Runs optional eBPF event sources on threads and routes them through rules.""" from __future__ import annotations import threading @@ -7,9 +7,11 @@ from collections.abc import Callable from ..alert import Alert from ..config import Config -from . import bcc_source +from . import bcc_source, bcc_syscall_source from .exec_event import ExecEvent from .rules import ExecRuleEngine +from .syscall_event import SyscallEvent +from .syscall_rules import SyscallRuleEngine class ExecMonitor: @@ -53,3 +55,46 @@ class ExecMonitor: def stop(self) -> None: self._stop.set() self._source.stop() + + +class SyscallMonitor: + """Owns the syscall event source + rule engine; emits Alerts via callback.""" + + def __init__(self, cfg: Config, on_alert: Callable[[Alert], None]) -> None: + self.cfg = cfg + self._on_alert = on_alert + self.engine = SyscallRuleEngine() + self._source = bcc_syscall_source.BccSyscallSource(self._on_event) + self._thread: threading.Thread | None = None + self._stop = threading.Event() + + def start(self) -> tuple[bool, str]: + ok, reason = bcc_syscall_source.available() + if not ok: + return False, reason + try: + self._source.start() + except Exception as exc: + return False, f"probe load failed: {exc!r}" + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + reason = "ok" + if self._source.attach_errors: + reason = "ok (some probes unavailable: " + "; ".join( + self._source.attach_errors[:4]) + ")" + return True, reason + + 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: SyscallEvent) -> 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/syscall_event.py b/enodia_sentinel/events/syscall_event.py new file mode 100644 index 0000000..f83b27a --- /dev/null +++ b/enodia_sentinel/events/syscall_event.py @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Security-relevant syscall event captured by the optional eBPF layer.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SyscallEvent: + pid: int + ppid: int + uid: int + comm: str + syscall: str + arg0: int = 0 + arg1: int = 0 + arg2: int = 0 + arg3: int = 0 + arg4: int = 0 + arg5: int = 0 + text: str = "" + + def to_dict(self) -> dict: + return { + "pid": self.pid, + "ppid": self.ppid, + "uid": self.uid, + "comm": self.comm, + "syscall": self.syscall, + "args": [self.arg0, self.arg1, self.arg2, self.arg3, self.arg4, self.arg5], + "text": self.text, + } diff --git a/enodia_sentinel/events/syscall_rules.py b/enodia_sentinel/events/syscall_rules.py new file mode 100644 index 0000000..0ef7f41 --- /dev/null +++ b/enodia_sentinel/events/syscall_rules.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Default rules for security-relevant syscall telemetry.""" +from __future__ import annotations + +from collections.abc import Callable, Iterable, Iterator +from dataclasses import dataclass + +from ..alert import Alert, Severity +from .syscall_event import SyscallEvent + +PROT_WRITE = 0x2 +PROT_EXEC = 0x4 +PTRACE_TRACEME = 0 +PTRACE_ATTACH = 16 +PTRACE_SEIZE = 0x4206 +PR_SET_SECCOMP = 22 + + +Predicate = Callable[[SyscallEvent], bool] + + +@dataclass(frozen=True) +class SyscallRule: + sid: int + msg: str + severity: Severity + classtype: str + syscalls: frozenset[str] + predicate: Predicate + + def matches(self, ev: SyscallEvent) -> bool: + return ev.syscall in self.syscalls and self.predicate(ev) + + def to_alert(self, ev: SyscallEvent) -> Alert: + detail = ( + f"sid={self.sid} {self.msg} — pid={ev.pid} ppid={ev.ppid} " + f"comm={ev.comm} syscall={ev.syscall} " + f"args=[{ev.arg0:#x}, {ev.arg1:#x}, {ev.arg2:#x}, {ev.arg3:#x}]" + ) + if ev.text: + detail += f" text=[{ev.text[:80]}]" + return Alert( + severity=self.severity, + signature=f"syscall_rule.{self.classtype}", + key=f"syscall:{self.sid}:{ev.pid}:{ev.syscall}:{ev.arg0:x}:{ev.arg2:x}", + detail=detail, + pids=(ev.pid,), + sid=self.sid, + classtype=self.classtype, + ) + + +def _prot_wx(prot: int) -> bool: + return bool((prot & PROT_WRITE) and (prot & PROT_EXEC)) + + +def _ptrace_sensitive(request: int) -> bool: + return request in {PTRACE_TRACEME, PTRACE_ATTACH, PTRACE_SEIZE} + + +DEFAULT_SYSCALL_RULES: tuple[SyscallRule, ...] = ( + SyscallRule( + sid=100060, + msg="mprotect made memory writable and executable", + severity=Severity.CRITICAL, + classtype="memory-obfuscation", + syscalls=frozenset({"mprotect"}), + predicate=lambda ev: _prot_wx(ev.arg2), + ), + SyscallRule( + sid=100061, + msg="mmap requested writable and executable memory", + severity=Severity.CRITICAL, + classtype="memory-obfuscation", + syscalls=frozenset({"mmap"}), + predicate=lambda ev: _prot_wx(ev.arg2), + ), + SyscallRule( + sid=100062, + msg="memfd_create used for anonymous in-memory file staging", + severity=Severity.MEDIUM, + classtype="fileless-execution", + syscalls=frozenset({"memfd_create"}), + predicate=lambda _ev: True, + ), + SyscallRule( + sid=100063, + msg="ptrace anti-debug or attach operation observed", + severity=Severity.HIGH, + classtype="anti-analysis", + syscalls=frozenset({"ptrace"}), + predicate=lambda ev: _ptrace_sensitive(ev.arg0), + ), + SyscallRule( + sid=100064, + msg="seccomp sandboxing call observed (possible anti-analysis hardening)", + severity=Severity.MEDIUM, + classtype="anti-analysis", + syscalls=frozenset({"prctl", "seccomp"}), + predicate=lambda ev: ev.syscall == "seccomp" or ev.arg0 == PR_SET_SECCOMP, + ), + SyscallRule( + sid=100065, + msg="cross-process memory read/write syscall observed", + severity=Severity.HIGH, + classtype="credential-access", + syscalls=frozenset({"process_vm_readv", "process_vm_writev"}), + predicate=lambda _ev: True, + ), + SyscallRule( + sid=100066, + msg="memory locking syscall observed (possible protected in-memory payload)", + severity=Severity.MEDIUM, + classtype="memory-obfuscation", + syscalls=frozenset({"mlock", "mlock2", "mlockall"}), + predicate=lambda _ev: True, + ), +) + + +class SyscallRuleEngine: + def __init__(self, rules: Iterable[SyscallRule] | None = None) -> None: + self.rules = list(rules if rules is not None else DEFAULT_SYSCALL_RULES) + + def match(self, ev: SyscallEvent) -> Iterator[Alert]: + for rule in self.rules: + if rule.matches(ev): + yield rule.to_alert(ev) diff --git a/enodia_sentinel/static/dashboard.html b/enodia_sentinel/static/dashboard.html index 72a7b56..b9b8bd8 100644 --- a/enodia_sentinel/static/dashboard.html +++ b/enodia_sentinel/static/dashboard.html @@ -169,7 +169,9 @@ function renderStatus(st){ let hb = st.heartbeat_age == null ? "no heartbeat" : Math.round(st.heartbeat_age)+"s heartbeat"; document.getElementById("daemon").textContent = (st.running ? "running" : "not running")+" / "+hb; document.getElementById("host").textContent = st.host+" / v"+st.version; - document.getElementById("ebpf").textContent = "eBPF "+(st.ebpf || "unknown"); + const exec = st.ebpf_exec || st.ebpf || "unknown"; + const syscall = st.ebpf_syscall || "unknown"; + document.getElementById("ebpf").textContent = "eBPF exec "+exec+" / syscall "+syscall; const counts = st.counts || {}; const metrics = document.getElementById("metrics"); metrics.innerHTML = ""; [["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""],["POSTURE","posture"]].forEach(([k,c])=>{ diff --git a/enodia_sentinel/web.py b/enodia_sentinel/web.py index ac2d01f..a9db8d3 100644 --- a/enodia_sentinel/web.py +++ b/enodia_sentinel/web.py @@ -187,10 +187,14 @@ def daemon_status(cfg: Config) -> dict: counts: dict[str, int] = {} for a in alerts: counts[a["severity"]] = counts.get(a["severity"], 0) + 1 - ebpf = "unknown" + ebpf_exec = "unknown" + ebpf_syscall = "unknown" for line in reversed(tail_events(cfg, 200)): - if "eBPF exec monitor:" in line: - ebpf = line.split("eBPF exec monitor:", 1)[1].strip() + if ebpf_exec == "unknown" and "eBPF exec monitor:" in line: + ebpf_exec = line.split("eBPF exec monitor:", 1)[1].strip() + if ebpf_syscall == "unknown" and "eBPF syscall monitor:" in line: + ebpf_syscall = line.split("eBPF syscall monitor:", 1)[1].strip() + if ebpf_exec != "unknown" and ebpf_syscall != "unknown": break from .selfprotect import heartbeat_age age = heartbeat_age(cfg) @@ -200,7 +204,9 @@ def daemon_status(cfg: Config) -> dict: "total_alerts": len(alerts), "counts": counts, "last_alert": alerts[0]["time"] if alerts else None, - "ebpf": ebpf, + "ebpf": ebpf_exec, + "ebpf_exec": ebpf_exec, + "ebpf_syscall": ebpf_syscall, "host": os.uname().nodename, "heartbeat_age": age, "heartbeat_stale": (age is not None and age > cfg.heartbeat_max_age), diff --git a/tests/test_syscall_rules.py b/tests/test_syscall_rules.py new file mode 100644 index 0000000..4e20a5d --- /dev/null +++ b/tests/test_syscall_rules.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for event-driven syscall rules; pure, no BPF needed.""" +import unittest + +from enodia_sentinel.alert import Severity +from enodia_sentinel.events.syscall_event import SyscallEvent +from enodia_sentinel.events.syscall_rules import SyscallRuleEngine + + +def ev(syscall, arg0=0, arg1=0, arg2=0, arg3=0, text="", pid=10): + return SyscallEvent( + pid=pid, ppid=1, uid=1000, comm="hoxha", syscall=syscall, + arg0=arg0, arg1=arg1, arg2=arg2, arg3=arg3, text=text, + ) + + +class TestSyscallEvent(unittest.TestCase): + def test_json_shape(self): + d = ev("memfd_create", arg0=1, text="stage").to_dict() + self.assertEqual(d["syscall"], "memfd_create") + self.assertEqual(d["args"][0], 1) + self.assertEqual(d["text"], "stage") + + +class TestDefaultSyscallRules(unittest.TestCase): + def setUp(self): + self.engine = SyscallRuleEngine() + + def sids(self, event): + return {a.sid for a in self.engine.match(event)} + + def alert(self, event): + return next(iter(self.engine.match(event))) + + def test_mprotect_rwx_alerts(self): + self.assertIn(100060, self.sids(ev("mprotect", arg2=0x6))) + + def test_mprotect_read_exec_ignored(self): + self.assertNotIn(100060, self.sids(ev("mprotect", arg2=0x5))) + + def test_mmap_rwx_alerts(self): + self.assertIn(100061, self.sids(ev("mmap", arg2=0x7))) + + def test_memfd_create_alerts(self): + alert = self.alert(ev("memfd_create", text="payload")) + self.assertEqual(alert.sid, 100062) + self.assertEqual(alert.severity, Severity.MEDIUM) + self.assertIn("payload", alert.detail) + + def test_ptrace_sensitive_requests_alert(self): + self.assertIn(100063, self.sids(ev("ptrace", arg0=16))) + self.assertIn(100063, self.sids(ev("ptrace", arg0=0x4206))) + + def test_ptrace_other_request_ignored(self): + self.assertNotIn(100063, self.sids(ev("ptrace", arg0=3))) + + def test_seccomp_alerts_from_prctl_or_syscall(self): + self.assertIn(100064, self.sids(ev("prctl", arg0=22))) + self.assertIn(100064, self.sids(ev("seccomp"))) + + def test_process_vm_alerts(self): + self.assertIn(100065, self.sids(ev("process_vm_readv", arg0=4242))) + self.assertIn(100065, self.sids(ev("process_vm_writev", arg0=4242))) + + def test_mlock_alerts(self): + self.assertIn(100066, self.sids(ev("mlock"))) + self.assertIn(100066, self.sids(ev("mlockall"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web.py b/tests/test_web.py index b04aee2..0d23acb 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -68,6 +68,15 @@ class TestDataLayer(unittest.TestCase): (self.tmp / "events.log").write_text("l1\nl2\nl3\n") self.assertEqual(web.tail_events(self.cfg, 2), ["l2", "l3"]) + def test_status_reports_exec_and_syscall_ebpf(self): + (self.tmp / "events.log").write_text( + "2026 eBPF exec monitor: enabled\n" + "2026 eBPF syscall monitor: disabled (not running as root)\n" + ) + st = web.daemon_status(self.cfg) + self.assertEqual(st["ebpf_exec"], "enabled") + self.assertEqual(st["ebpf_syscall"], "disabled (not running as root)") + def test_incident_and_response_plan_data(self): _write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["reverse_shell"]) iid = incident.record(self.cfg, "alert-20260531-000001.log",