enodia-sentinal/enodia_sentinel/incident.py

213 lines
8 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""Incident grouping — collapse related alerts into one incident.
A single intrusion usually trips several signatures: a web service spawns a
shell (`reverse_shell`), the shell opens a port (`new_listener`) and writes
persistence (`persistence`). Reported as three unrelated alerts that is noise;
reported as one incident it is a story.
Grouping is **process-lineage first, time second**. Each alert batch carries the
PIDs it flagged; we walk their ancestry (`/proc` PPid chain) into a *lineage
set*. Two batches join the same incident when their lineage sets intersect —
i.e. they share a process or a common ancestor (the web server, the SSH session).
Batches with no live PID (FIM drift, package tamper, hidden modules) can't be
placed by lineage, so they fall back to joining the most recently active open
incident within a time window. Distinct process trees stay distinct incidents.
The incident index is a JSON file in ``log_dir`` updated as snapshots are
captured; ``incident_id`` is added to each snapshot report (the per-alert JSON
schema is untouched). The matching/lineage cores are pure functions so they test
without ``/proc`` or a daemon.
"""
from __future__ import annotations
import json
import os
import threading
import time
from collections.abc import Callable, Iterable
from datetime import datetime
from . import schemas
from .alert import Alert, Severity
from .config import Config
INDEX_NAME = "incidents.json"
MAX_INCIDENTS = 1000 # trim the index to the most recent N on save
_DEFAULT_EXCLUDE = frozenset({0, 1}) # never correlate via pid 0 or init
# The index read-modify-write happens from capture threads (sweep + eBPF), so
# serialize it within this process.
_LOCK = threading.RLock()
# --- pure cores (tested without /proc or a daemon) ------------------------
def lineage_of(
pids: Iterable[int],
parent_of: Callable[[int], int],
depth: int = 8,
exclude: frozenset[int] = _DEFAULT_EXCLUDE,
) -> set[int]:
"""Flagged PIDs plus their ancestors, walking PPid via ``parent_of``.
``parent_of(pid)`` returns the parent PID (0 when unknown/root). Stops at
``exclude`` (pid 0/1 by default, so everything doesn't correlate through
init) and at ``depth`` ancestors.
"""
seen: set[int] = set()
for start in pids:
cur = start
steps = 0
while cur and cur not in exclude and steps <= depth:
if cur in seen:
break
seen.add(cur)
cur = parent_of(cur)
steps += 1
return seen
def _sev_rank(name: str) -> int:
try:
return int(Severity[name])
except KeyError:
return 0
def max_severity(a: str, b: str) -> str:
return a if _sev_rank(a) >= _sev_rank(b) else b
def assign(index: dict, lineage: set[int], when: float, host: str,
window: int) -> str | None:
"""Return the id of an open incident this batch belongs to, or None.
Lineage-bearing batches join an open incident whose lineage they intersect.
PID-less batches (empty lineage) join the most recently active open incident.
"Open" means last activity is within ``window`` seconds. Ties and PID-less
matches resolve to the most recently active incident.
"""
best: str | None = None
best_ts = -1.0
for inc in index.values():
if inc.get("host") != host:
continue
if (when - inc.get("last_ts", 0.0)) > window:
continue # closed
if lineage:
if not (set(inc.get("lineage", ())) & lineage):
continue
# else: PID-less batch — eligible to join any open incident on this host
if inc["last_ts"] > best_ts:
best, best_ts = inc["id"], inc["last_ts"]
return best
# --- index persistence ----------------------------------------------------
def index_path(cfg: Config):
return cfg.log_dir / INDEX_NAME
def load_index(cfg: Config) -> dict:
try:
data = json.loads(index_path(cfg).read_text())
if not isinstance(data, dict):
return {}
for inc in data.values():
if isinstance(inc, dict):
inc.setdefault("schema", schemas.INCIDENT_V1)
inc.setdefault("correlations", [])
return data
except (OSError, ValueError):
return {}
def save_index(cfg: Config, index: dict) -> None:
# Keep only the most recent incidents so the file stays bounded.
if len(index) > MAX_INCIDENTS:
keep = sorted(index.values(), key=lambda i: i.get("last_ts", 0.0),
reverse=True)[:MAX_INCIDENTS]
index = {i["id"]: i for i in keep}
tmp = index_path(cfg).with_suffix(".json.tmp")
tmp.write_text(json.dumps(index, indent=2))
os.replace(tmp, index_path(cfg))
try:
index_path(cfg).chmod(0o640)
except OSError:
pass
def _new_id(when: float) -> str:
stamp = datetime.fromtimestamp(when).astimezone().strftime("%Y%m%d-%H%M%S")
return f"inc-{stamp}-{os.urandom(2).hex()}"
# --- the recorder (called from snapshot.capture) --------------------------
def record(cfg: Config, snapshot_name: str, alerts: list[Alert],
lineage: set[int], when: float, host: str) -> str | None:
"""Attach this snapshot to an incident (new or existing); return its id.
Best-effort: any failure returns None so a snapshot is never lost to an
incident-index problem.
"""
if not getattr(cfg, "incident_tracking", True):
return None
try:
with _LOCK:
index = load_index(cfg)
iid = assign(index, lineage, when, host, cfg.incident_window)
iso = datetime.fromtimestamp(when).astimezone().isoformat()
severity = str(max((a.severity for a in alerts), default=Severity.HIGH))
sigs = list(dict.fromkeys(a.signature for a in alerts))
sids = list(dict.fromkeys(a.sid for a in alerts if a.sid))
pids = sorted({p for a in alerts for p in a.pids})
if iid is None:
iid = _new_id(when)
index[iid] = {
"schema": schemas.INCIDENT_V1,
"id": iid, "host": host,
"first_ts": when, "last_ts": when,
"first_seen": iso, "last_seen": iso,
"severity": severity,
"signatures": [], "sids": [], "pids": [],
"lineage": [], "snapshots": [], "alert_count": 0,
"correlations": [],
}
inc = index[iid]
inc.setdefault("schema", schemas.INCIDENT_V1)
inc["last_ts"] = when
inc["last_seen"] = iso
inc["severity"] = max_severity(inc.get("severity", severity), severity)
inc["signatures"] = list(dict.fromkeys(inc["signatures"] + sigs))
inc["sids"] = list(dict.fromkeys(inc["sids"] + sids))
inc["pids"] = sorted(set(inc["pids"]) | set(pids))
inc["lineage"] = sorted(set(inc["lineage"]) | lineage)
inc["snapshots"].append(snapshot_name)
inc["alert_count"] += len(alerts)
from . import correlation
inc["correlations"] = correlation.correlate(inc)
for corr in inc["correlations"]:
if corr["sid"] not in inc["sids"]:
inc["sids"].append(corr["sid"])
inc["severity"] = max_severity(
inc.get("severity", severity),
max((c.get("severity", severity) for c in inc["correlations"]),
default=severity),
)
save_index(cfg, index)
return iid
except Exception:
return None
def lineage_from_state(pids: Iterable[int], state, cfg: Config) -> set[int]:
"""Compute a lineage set for ``pids`` using a live SystemState for PPids."""
def parent_of(pid: int) -> int:
proc = state.process(pid)
return proc.ppid if proc else 0
return lineage_of(pids, parent_of,
depth=getattr(cfg, "incident_lineage_depth", 8))