Add event rule inspection commands

This commit is contained in:
Luna 2026-06-15 05:36:58 -07:00
parent 1f05923e0f
commit 5db88d285e
7 changed files with 417 additions and 5 deletions

View file

@ -61,6 +61,14 @@ def main(argv: list[str] | None = None) -> int:
sub.add_parser("check", help="run detectors once and print alerts")
sub.add_parser("baseline", help="rebuild listener/SUID baselines")
sub.add_parser("list-detectors", help="list available detectors")
rules = sub.add_parser("rules",
help="list/show/test built-in and configured rules")
rules.add_argument("action", nargs="?", default="list",
choices=["list", "show", "test"],
help="rule action (default: list)")
rules.add_argument("target", nargs="?",
help="sid for show, event JSON path for test, or '-'")
rules.add_argument("--json", action="store_true", help="emit JSON")
sub.add_parser("web", help="serve the read-only dashboard")
sub.add_parser("triage", help="classify captured alerts as likely-FP vs review")
sub.add_parser("fim-baseline", help="build the file-integrity baseline")
@ -112,6 +120,8 @@ def main(argv: list[str] | None = None) -> int:
mark = "on " if cfg.enabled(det.name) else "off"
print(f" [{mark}] {det.name}")
return 0
if args.cmd == "rules":
return _cmd_rules(cfg, args.action, args.target, args.json)
if args.cmd == "baseline":
return _cmd_baseline(cfg)
if args.cmd == "check":
@ -154,6 +164,74 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_run(cfg)
def _cmd_rules(cfg: Config, action: str, target: str | None,
as_json: bool) -> int:
import json
from . import ruleops
if action == "list":
rules = ruleops.list_rules(cfg)
if as_json:
print(json.dumps(rules, indent=2))
return 0
print(f"{'SID':<7} {'EVENT':<8} {'ORIGIN':<10} {'SEV':<9} "
f"{'CLASSTYPE':<24} MESSAGE")
for r in rules:
print(f"{r['sid']:<7} {r['event']:<8} {r['origin']:<10} "
f"{r['severity']:<9} {r['classtype']:<24} {r['msg']}")
return 0
if action == "show":
if not target:
print("error: 'rules show' needs a sid", file=sys.stderr)
return 2
try:
sid = int(target)
except ValueError:
print(f"error: invalid sid: {target}", file=sys.stderr)
return 2
rule = ruleops.find_rule(cfg, sid)
if rule is None:
print(f"error: no such rule sid: {sid}", file=sys.stderr)
return 1
if as_json:
print(json.dumps(rule, indent=2))
return 0
print(f"Rule {rule['sid']} [{rule['severity']}] {rule['signature']}")
print(f" event: {rule['event']}")
print(f" origin: {rule['origin']}")
print(f" classtype: {rule['classtype']}")
print(f" message: {rule['msg']}")
print(" conditions:")
for key, value in rule["conditions"].items():
print(f" {key}: {value}")
return 0
if action == "test":
if not target:
print("error: 'rules test' needs an event JSON path or '-'",
file=sys.stderr)
return 2
try:
alerts = ruleops.test_event(cfg, ruleops.load_event_json(target))
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"error: cannot test event: {exc}", file=sys.stderr)
return 2
if as_json:
print(json.dumps([a.to_dict() for a in alerts], indent=2))
return 0 if alerts else 1
if not alerts:
print("No rules matched.")
return 1
for a in alerts:
print(f"[{a.severity}] sid={a.sid} {a.signature} {a.detail}")
return 0
print(f"error: unsupported rules action: {action}", file=sys.stderr)
return 2
def _cmd_watchdog(cfg: Config, url: str, token: str, max_age: int,
verify_tls: bool = True) -> int:
from . import notify

145
enodia_sentinel/ruleops.py Normal file
View file

@ -0,0 +1,145 @@
# 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.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}
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
)
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 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)))
raise ValueError("event JSON must be an exec or syscall 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 _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 "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 _to_int(value: Any) -> int:
if isinstance(value, str):
return int(value, 0)
return int(value)