Add incident grouping (roadmap v0.8: incident list/show/export)
Collapse related alerts into one incident, process-lineage first with a time-window fallback. Each alert batch's flagged PIDs are walked up the /proc PPid chain into a lineage set (excluding pid 0/1 so everything doesn't correlate through init); batches whose lineage sets intersect — sharing a process or a common ancestor like the web server or SSH session — join the same incident. PID-less batches (FIM drift, package tamper, hidden modules) fall back to the most recently active open incident within incident_window. snapshot.capture now computes lineage and records each snapshot into a JSON incident index (incidents.json), writing incident_id into both the report JSON (report level, so the per-alert schema is untouched) and the text header. New commands: incident list incidents newest-first, with signatures incident show <id> summary + time-ordered snapshot timeline incident export <id> JSON bundle: record + inlined snapshots The lineage/assign cores are pure functions; record() serializes the index under a lock (capture runs from sweep + eBPF threads) and is best-effort so an index problem never loses a snapshot. 17 new tests (lineage, assign, record, and an end-to-end capture→group→CLI path). Config: incident_tracking / incident_window / incident_lineage_depth. Docs + sample config updated; closes the last v0.8 roadmap item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4015ec872b
commit
a56d72edd6
9 changed files with 545 additions and 13 deletions
|
|
@ -77,6 +77,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
help="anti-rootkit cross-view: hidden procs/modules/ports")
|
||||
st = sub.add_parser("status", help="print daemon health/alert summary")
|
||||
st.add_argument("--json", action="store_true", help="emit status as JSON")
|
||||
inc = sub.add_parser("incident",
|
||||
help="list/show/export incidents (grouped alerts)")
|
||||
inc.add_argument("action", nargs="?", default="list",
|
||||
choices=["list", "show", "export"],
|
||||
help="incident action (default: list)")
|
||||
inc.add_argument("id", nargs="?", help="incident id (for show/export)")
|
||||
inc.add_argument("--json", action="store_true", help="emit as JSON")
|
||||
po = sub.add_parser("posture",
|
||||
help="audit host config hygiene (SSH, sudo, PATH, perms)")
|
||||
po.add_argument("action", nargs="?", default="check", choices=["check"],
|
||||
|
|
@ -127,6 +134,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
return _cmd_rootcheck(cfg)
|
||||
if args.cmd == "status":
|
||||
return _cmd_status(cfg, args.json)
|
||||
if args.cmd == "incident":
|
||||
return _cmd_incident(cfg, args.action, args.id, args.json)
|
||||
if args.cmd == "posture":
|
||||
return _cmd_posture(cfg, args.json)
|
||||
if args.cmd == "watchdog":
|
||||
|
|
@ -207,6 +216,102 @@ def _cmd_status(cfg: Config, as_json: bool) -> int:
|
|||
return 0 if (s["running"] and not s["heartbeat_stale"]) else 1
|
||||
|
||||
|
||||
def _load_snapshot_report(cfg: Config, log_name: str) -> dict | None:
|
||||
import json
|
||||
p = (cfg.log_dir / log_name).with_suffix(".json")
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _incident_timeline(cfg: Config, inc: dict) -> list[dict]:
|
||||
"""Ordered per-snapshot view: time, severity, signatures, pids."""
|
||||
rows = []
|
||||
for name in inc.get("snapshots", []):
|
||||
rep = _load_snapshot_report(cfg, name)
|
||||
if rep is None:
|
||||
rows.append({"snapshot": name, "time": "?", "missing": True})
|
||||
continue
|
||||
rows.append({
|
||||
"snapshot": name,
|
||||
"time": rep.get("time", "?"),
|
||||
"severity": rep.get("severity", "?"),
|
||||
"signatures": list(dict.fromkeys(
|
||||
a["signature"] for a in rep.get("alerts", []))),
|
||||
"pids": rep.get("processes") and
|
||||
[d["pid"] for d in rep["processes"]] or [],
|
||||
})
|
||||
rows.sort(key=lambda r: r.get("time", ""))
|
||||
return rows
|
||||
|
||||
|
||||
def _cmd_incident(cfg: Config, action: str, iid: str | None, as_json: bool) -> int:
|
||||
import json
|
||||
|
||||
from . import incident
|
||||
index = incident.load_index(cfg)
|
||||
|
||||
if action == "list":
|
||||
incs = sorted(index.values(), key=lambda i: i.get("last_ts", 0.0),
|
||||
reverse=True)
|
||||
if as_json:
|
||||
print(json.dumps(incs, indent=2))
|
||||
return 0
|
||||
if not incs:
|
||||
print("No incidents recorded.")
|
||||
return 0
|
||||
print(f"{'INCIDENT':<28} {'LAST SEEN':<26} {'SEV':<9} {'#':>3} SIGNATURES")
|
||||
for i in incs:
|
||||
sigs = ", ".join(i.get("signatures", []))
|
||||
print(f"{i['id']:<28} {i.get('last_seen', '?'):<26} "
|
||||
f"{i.get('severity', '?'):<9} {i.get('alert_count', 0):>3} {sigs}")
|
||||
return 0
|
||||
|
||||
# show / export both need a resolved incident
|
||||
if not iid:
|
||||
print(f"error: 'incident {action}' needs an incident id "
|
||||
f"(see 'incident list')", file=sys.stderr)
|
||||
return 2
|
||||
inc = index.get(iid)
|
||||
if inc is None:
|
||||
print(f"error: no such incident: {iid}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if action == "export":
|
||||
bundle = {
|
||||
"incident": inc,
|
||||
"snapshots": [r for name in inc.get("snapshots", [])
|
||||
if (r := _load_snapshot_report(cfg, name)) is not None],
|
||||
}
|
||||
print(json.dumps(bundle, indent=2))
|
||||
return 0
|
||||
|
||||
# action == "show"
|
||||
timeline = _incident_timeline(cfg, inc)
|
||||
if as_json:
|
||||
print(json.dumps({"incident": inc, "timeline": timeline}, indent=2))
|
||||
return 0
|
||||
print(f"Incident {inc['id']} [{inc.get('severity', '?')}] on {inc.get('host', '?')}")
|
||||
print(f" first seen: {inc.get('first_seen', '?')}")
|
||||
print(f" last seen: {inc.get('last_seen', '?')}")
|
||||
print(f" signatures: {', '.join(inc.get('signatures', [])) or '—'}")
|
||||
print(f" sids: {', '.join(map(str, inc.get('sids', []))) or '—'}")
|
||||
print(f" pids: {', '.join(map(str, inc.get('pids', []))) or '—'}")
|
||||
print(f" snapshots: {inc.get('alert_count', 0)} alert(s) over "
|
||||
f"{len(inc.get('snapshots', []))} capture(s)")
|
||||
print(" timeline:")
|
||||
for r in timeline:
|
||||
if r.get("missing"):
|
||||
print(f" {r['time']:<26} {r['snapshot']} (snapshot pruned)")
|
||||
continue
|
||||
sigs = ", ".join(r["signatures"])
|
||||
pids = (" pids=" + ",".join(map(str, r["pids"]))) if r["pids"] else ""
|
||||
print(f" {r['time']:<26} [{r['severity']}] {sigs}{pids}")
|
||||
print(f" → {r['snapshot']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_posture(cfg: Config, as_json: bool) -> int:
|
||||
from . import posture
|
||||
findings = sorted(posture.run(cfg), key=lambda x: -x.severity)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ class Config:
|
|||
rootcheck_interval: int = 300 # seconds between cross-view sweeps
|
||||
rootcheck_pid_cap: int = 65536 # upper PID to brute-force via kill(0)
|
||||
|
||||
# incident grouping (collapse related alerts by process lineage, then time)
|
||||
incident_tracking: bool = True
|
||||
incident_window: int = 1800 # s an incident stays open for new alerts
|
||||
incident_lineage_depth: int = 8 # ancestors walked when correlating
|
||||
|
||||
# host posture (config hygiene; advisory, command-driven, never blocks startup)
|
||||
posture_sshd_config: str = "/etc/ssh/sshd_config"
|
||||
posture_sudoers: str = "/etc/sudoers"
|
||||
|
|
|
|||
193
enodia_sentinel/incident.py
Normal file
193
enodia_sentinel/incident.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# 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 .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())
|
||||
return data if isinstance(data, dict) else {}
|
||||
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] = {
|
||||
"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,
|
||||
}
|
||||
inc = index[iid]
|
||||
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)
|
||||
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))
|
||||
|
|
@ -116,6 +116,8 @@ def _format_text(report: dict, extras: dict[str, str]) -> str:
|
|||
L.append(f"Time: {report['time']}")
|
||||
L.append(f"Host: {report['host']}")
|
||||
L.append(f"Severity: {report['severity']}")
|
||||
if report.get("incident_id"):
|
||||
L.append(f"Incident: {report['incident_id']}")
|
||||
L.append("")
|
||||
L.append("## Triggering detections")
|
||||
for a in report["alerts"]:
|
||||
|
|
@ -161,11 +163,20 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
|
|||
|
||||
severity = max((a.severity for a in alerts), default=Severity.HIGH)
|
||||
pids: list[int] = sorted({p for a in alerts for p in a.pids})
|
||||
host = os.uname().nodename
|
||||
|
||||
# Group this batch into an incident by process lineage (then time). Additive:
|
||||
# the per-alert JSON schema is unchanged; incident_id sits at report level.
|
||||
from . import incident
|
||||
lineage = incident.lineage_from_state(pids, state, cfg)
|
||||
incident_id = incident.record(
|
||||
cfg, base.with_suffix(".log").name, alerts, lineage, now.timestamp(), host)
|
||||
|
||||
report = {
|
||||
"time": now.isoformat(),
|
||||
"host": os.uname().nodename,
|
||||
"host": host,
|
||||
"severity": str(severity),
|
||||
"incident_id": incident_id,
|
||||
"alerts": [a.to_dict() for a in alerts],
|
||||
"processes": [_pid_detail(state, p) for p in pids],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue