# 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}) _PERSISTENCE_PREFIXES = ( "/etc/cron.d/", "/etc/crontab", "/etc/systemd/system/", "/etc/ld.so.preload", "/root/.ssh/authorized_keys", "/etc/sudoers", "/etc/sudoers.d/", ) @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() path_prefixes: tuple[str, ...] = () target_uids: frozenset[int] = frozenset() target_gids: 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.path_prefixes, self.target_uids, self.target_gids, 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.path_prefixes and not any( ev.path == prefix or ev.path.startswith(prefix) for prefix in self.path_prefixes ): return False if self.target_uids and ev.target_uid not in self.target_uids: return False if self.target_gids and ev.target_gid not in self.target_gids: 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}:" f"{ev.peer_ip}:{ev.peer_port}:{ev.local_ip}:{ev.local_port}:" f"{ev.path}:{ev.target_uid}:{ev.target_gid}:{ev.mode}" ), 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} path={ev.path} " f"target_uid={ev.target_uid} target_gid={ev.target_gid} " f"mode={oct(ev.mode) if ev.mode else '-'}" ), 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, ), HostRule( sid=100069, msg="Interpreter wrote to a persistence path", severity=Severity.HIGH, classtype="persistence-write", events=frozenset({"file_write"}), comm=_INTERPRETERS, path_prefixes=_PERSISTENCE_PREFIXES, ), HostRule( sid=100070, msg="Permissions or ownership changed on a persistence path", severity=Severity.HIGH, classtype="persistence-permission-change", events=frozenset({"chmod", "chown"}), path_prefixes=_PERSISTENCE_PREFIXES, ), HostRule( sid=100071, msg="Interpreter requested a root UID transition", severity=Severity.HIGH, classtype="privilege-transition", events=frozenset({"setuid"}), comm=_INTERPRETERS, target_uids=frozenset({0}), ), HostRule( sid=100072, msg="Interpreter requested a root GID transition", severity=Severity.HIGH, classtype="privilege-transition", events=frozenset({"setgid"}), comm=_INTERPRETERS, target_gids=frozenset({0}), ), ) 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)