Add event-driven memory syscall telemetry

This commit is contained in:
Luna 2026-06-13 05:45:30 -07:00
parent 893409b549
commit a51478fa22
18 changed files with 589 additions and 32 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <uapi/linux/ptrace.h>
#include <linux/sched.h>
#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

View file

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

View file

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

View file

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

View file

@ -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])=>{

View file

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

View file

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

View file

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