Add event-driven memory syscall telemetry
This commit is contained in:
parent
893409b549
commit
a51478fa22
18 changed files with 589 additions and 32 deletions
|
|
@ -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.
|
||||
|
|
|
|||
18
README.md
18
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
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
229
enodia_sentinel/events/bcc_syscall_source.py
Normal file
229
enodia_sentinel/events/bcc_syscall_source.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
32
enodia_sentinel/events/syscall_event.py
Normal file
32
enodia_sentinel/events/syscall_event.py
Normal 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,
|
||||
}
|
||||
128
enodia_sentinel/events/syscall_rules.py
Normal file
128
enodia_sentinel/events/syscall_rules.py
Normal 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)
|
||||
|
|
@ -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])=>{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
71
tests/test_syscall_rules.py
Normal file
71
tests/test_syscall_rules.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue