enodia-sentinal/enodia_sentinel/events/host_rules.py

119 lines
4.2 KiB
Python

# 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()
local_ports: frozenset[int] = frozenset()
local_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.local_ports,
self.local_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.local_ports and ev.local_port not in self.local_ports:
return False
if self.local_port_exclude and ev.local_port in self.local_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} "
f"local={ev.local_ip}:{ev.local_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,
),
HostRule(
sid=100068,
msg="Interpreter opened a listener on an unusual local port",
severity=Severity.HIGH,
classtype="suspicious-listener",
events=frozenset({"listen"}),
comm=_INTERPRETERS,
local_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)