Add typed host event egress rule
This commit is contained in:
parent
0b010df514
commit
3b037646d2
15 changed files with 360 additions and 31 deletions
41
enodia_sentinel/events/host_event.py
Normal file
41
enodia_sentinel/events/host_event.py
Normal 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,
|
||||
}
|
||||
102
enodia_sentinel/events/host_rules.py
Normal file
102
enodia_sentinel/events/host_rules.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue