enodia-sentinal/enodia_sentinel/correlation.py

66 lines
2 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only incident correlation rules.
Correlation does not hide the raw alerts or mutate the host. It adds a small
``correlations`` list to an incident when the incident's existing evidence
matches a higher-confidence story.
"""
from __future__ import annotations
from dataclasses import dataclass
from .alert import Severity
SID_MULTI_STAGE_INTRUSION = 100080
@dataclass(frozen=True)
class CorrelationRule:
sid: int
signature: str
classtype: str
severity: Severity
summary: str
required_any: tuple[set[str], ...]
max_window: int
DEFAULT_RULES: tuple[CorrelationRule, ...] = (
CorrelationRule(
sid=SID_MULTI_STAGE_INTRUSION,
signature="correlation.multi_stage_intrusion",
classtype="multi-stage-intrusion",
severity=Severity.CRITICAL,
summary=(
"Web/database service spawned a shell and the same incident showed "
"suspicious egress or an unusual listener within the correlation window"
),
required_any=(
{"exec_rule.web-rce"},
{"host_rule.suspicious-egress", "host_rule.suspicious-listener"},
),
max_window=600,
),
)
def correlate(incident: dict, rules: tuple[CorrelationRule, ...] = DEFAULT_RULES) -> list[dict]:
"""Return correlation records matched by an incident index entry."""
signatures = set(incident.get("signatures") or [])
duration = float(incident.get("last_ts", 0.0)) - float(incident.get("first_ts", 0.0))
out: list[dict] = []
for rule in rules:
if duration > rule.max_window:
continue
if not all(signatures & choices for choices in rule.required_any):
continue
out.append({
"sid": rule.sid,
"signature": rule.signature,
"classtype": rule.classtype,
"severity": str(rule.severity),
"summary": rule.summary,
"window_seconds": rule.max_window,
"matched_signatures": sorted(signatures),
})
return out