Add typed host event egress rule

This commit is contained in:
Luna 2026-07-09 06:04:30 -07:00
parent 0b010df514
commit 3b037646d2
15 changed files with 360 additions and 31 deletions

View file

@ -0,0 +1,41 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Typed host event shared by non-exec, non-syscall event rules."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class HostEvent:
event: str
pid: int
ppid: int
uid: int
comm: str
parent_comm: str = ""
path: str = ""
argv: tuple[str, ...] = field(default_factory=tuple)
peer_ip: str = ""
peer_port: int = 0
local_ip: str = ""
local_port: int = 0
@property
def argv_str(self) -> str:
return " ".join((self.path, *self.argv)).strip()
def to_dict(self) -> dict:
return {
"event": self.event,
"pid": self.pid,
"ppid": self.ppid,
"uid": self.uid,
"comm": self.comm,
"parent_comm": self.parent_comm,
"path": self.path,
"argv": list(self.argv),
"peer_ip": self.peer_ip,
"peer_port": self.peer_port,
"local_ip": self.local_ip,
"local_port": self.local_port,
}

View file

@ -0,0 +1,102 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Declarative rules for typed host events beyond exec/syscall telemetry."""
from __future__ import annotations
import re
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from ..alert import Alert, Severity
from ..netutil import is_public_ip
from .host_event import HostEvent
_INTERPRETERS = frozenset(
"sh bash dash zsh ksh ash python python2 python3 perl ruby php lua "
"node nodejs nc ncat netcat socat curl wget fetch".split()
)
_COMMON_PUBLIC_PORTS = frozenset({22, 53, 80, 123, 443, 853})
@dataclass(frozen=True)
class HostRule:
sid: int
msg: str
severity: Severity
classtype: str
events: frozenset[str]
comm: frozenset[str] = frozenset()
parent_comm: frozenset[str] = frozenset()
peer_public: bool | None = None
peer_ports: frozenset[int] = frozenset()
peer_port_exclude: frozenset[int] = frozenset()
argv_regex: str | None = None
def __post_init__(self) -> None:
if not self.events:
raise ValueError(f"rule sid={self.sid} has no event types")
if not any((
self.comm, self.parent_comm, self.peer_public is not None,
self.peer_ports, self.peer_port_exclude, self.argv_regex,
)):
raise ValueError(f"rule sid={self.sid} has no match conditions")
if self.argv_regex is not None:
object.__setattr__(self, "_argv_re",
re.compile(self.argv_regex, re.IGNORECASE))
else:
object.__setattr__(self, "_argv_re", None)
def matches(self, ev: HostEvent) -> bool:
if ev.event not in self.events:
return False
if self.comm and ev.comm not in self.comm:
return False
if self.parent_comm and ev.parent_comm not in self.parent_comm:
return False
if self.peer_public is not None and is_public_ip(ev.peer_ip) != self.peer_public:
return False
if self.peer_ports and ev.peer_port not in self.peer_ports:
return False
if self.peer_port_exclude and ev.peer_port in self.peer_port_exclude:
return False
if self._argv_re is not None and not self._argv_re.search(ev.argv_str):
return False
return True
def to_alert(self, ev: HostEvent) -> Alert:
return Alert(
severity=self.severity,
signature=f"host_rule.{self.classtype}",
key=f"host:{self.sid}:{ev.event}:{ev.pid}:{ev.peer_ip}:{ev.peer_port}",
detail=(
f"sid={self.sid} {self.msg} - pid={ev.pid} ppid={ev.ppid} "
f"comm={ev.comm} event={ev.event} peer={ev.peer_ip}:{ev.peer_port}"
),
pids=(ev.pid,),
sid=self.sid,
classtype=self.classtype,
)
DEFAULT_HOST_RULES: tuple[HostRule, ...] = (
HostRule(
sid=100067,
msg="Interpreter connected to an unusual public port",
severity=Severity.HIGH,
classtype="suspicious-egress",
events=frozenset({"tcp_connect"}),
comm=_INTERPRETERS,
peer_public=True,
peer_port_exclude=_COMMON_PUBLIC_PORTS,
),
)
class HostRuleEngine:
def __init__(self, rules: Iterable[HostRule] | None = None) -> None:
self.rules = list(rules if rules is not None else DEFAULT_HOST_RULES)
def match(self, ev: HostEvent) -> Iterator[Alert]:
for rule in self.rules:
if rule.matches(ev):
yield rule.to_alert(ev)

View file

@ -9,6 +9,8 @@ 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 (
@ -22,6 +24,7 @@ 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
@ -30,6 +33,10 @@ def list_rules(cfg: Config) -> list[dict[str, Any]]:
_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"])))
@ -45,8 +52,8 @@ def render_markdown(cfg: Config) -> str:
lines = [
"# Enodia Sentinel Event Rule Reference",
"",
"Generated from the active exec/syscall rule defaults plus any configured "
"`exec_rules_file` entries.",
"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.",
@ -98,7 +105,9 @@ def test_event(cfg: Config, event: dict[str, Any]) -> list[Alert]:
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")
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]:
@ -141,6 +150,34 @@ def _syscall_rule_record(rule: SyscallRule, origin: str) -> dict[str, Any]:
}
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": [
@ -219,6 +256,13 @@ _RULE_DOCS: dict[int, dict[str, Any]] = {
],
"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.",
},
}
@ -246,6 +290,11 @@ def _event_kind(event: dict[str, Any]) -> str:
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:
@ -283,6 +332,23 @@ def _syscall_event(event: dict[str, Any]) -> SyscallEvent:
)
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)

View file

@ -17,6 +17,7 @@ from .detectors import (
stealth_network,
)
from .events.rules import DEFAULT_EXEC_RULES
from .events.host_rules import DEFAULT_HOST_RULES
from .events.syscall_rules import DEFAULT_SYSCALL_RULES
@ -42,6 +43,8 @@ BUILTIN_SIDS: tuple[SidInfo, ...] = (
"enodia_sentinel.events.rules"),
*_rule_rows(DEFAULT_SYSCALL_RULES, "syscall_rule",
"enodia_sentinel.events.syscall_rules"),
*_rule_rows(DEFAULT_HOST_RULES, "host_rule",
"enodia_sentinel.events.host_rules"),
SidInfo(100010, "reverse_shell", "detector", f"{_DETECTORS}.reverse_shell"),
SidInfo(100011, "ld_preload", "detector", f"{_DETECTORS}.ld_preload"),
SidInfo(100012, "deleted_exe", "detector", f"{_DETECTORS}.deleted_exe"),