235 lines
8.3 KiB
Python
235 lines
8.3 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})
|
|
_PERSISTENCE_PREFIXES = (
|
|
"/etc/cron.d/", "/etc/crontab", "/etc/systemd/system/",
|
|
"/etc/ld.so.preload", "/root/.ssh/authorized_keys",
|
|
"/etc/sudoers", "/etc/sudoers.d/",
|
|
)
|
|
_WRITABLE_RUNTIME_PREFIXES = ("/tmp/", "/var/tmp/", "/dev/shm/", "/run/user/")
|
|
_SENSITIVE_CAPABILITIES = frozenset({
|
|
"CAP_SYS_ADMIN",
|
|
"CAP_SYS_MODULE",
|
|
"CAP_SYS_PTRACE",
|
|
"CAP_DAC_READ_SEARCH",
|
|
"CAP_NET_ADMIN",
|
|
"CAP_NET_RAW",
|
|
})
|
|
|
|
|
|
@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()
|
|
capabilities_any: frozenset[str] = frozenset()
|
|
module_names: frozenset[str] = 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.capabilities_any, self.module_names,
|
|
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.capabilities_any and not (
|
|
{cap.upper() for cap in ev.capabilities} & self.capabilities_any
|
|
):
|
|
return False
|
|
if self.module_names and ev.module_name not in self.module_names:
|
|
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}:"
|
|
f"{','.join(ev.capabilities)}:{ev.module_name}"
|
|
),
|
|
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 '-'} "
|
|
f"capabilities={','.join(ev.capabilities) or '-'} "
|
|
f"module={ev.module_name or '-'}"
|
|
),
|
|
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=100073,
|
|
msg="Interpreter bound an unusual local port",
|
|
severity=Severity.HIGH,
|
|
classtype="suspicious-bind",
|
|
events=frozenset({"bind"}),
|
|
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}),
|
|
),
|
|
HostRule(
|
|
sid=100076,
|
|
msg="Interpreter accepted inbound traffic on an unusual local port",
|
|
severity=Severity.HIGH,
|
|
classtype="suspicious-accept",
|
|
events=frozenset({"accept"}),
|
|
comm=_INTERPRETERS,
|
|
peer_public=True,
|
|
local_port_exclude=_COMMON_PUBLIC_PORTS,
|
|
),
|
|
HostRule(
|
|
sid=100077,
|
|
msg="Interpreter requested sensitive Linux capabilities",
|
|
severity=Severity.HIGH,
|
|
classtype="capability-escalation",
|
|
events=frozenset({"capset"}),
|
|
comm=_INTERPRETERS,
|
|
capabilities_any=_SENSITIVE_CAPABILITIES,
|
|
),
|
|
HostRule(
|
|
sid=100078,
|
|
msg="Kernel module load requested from a writable runtime path",
|
|
severity=Severity.CRITICAL,
|
|
classtype="suspicious-module-load",
|
|
events=frozenset({"module_load"}),
|
|
path_prefixes=_WRITABLE_RUNTIME_PREFIXES,
|
|
),
|
|
)
|
|
|
|
|
|
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)
|