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:
Luna 2026-06-10 17:52:42 -07:00
parent 4015ec872b
commit a56d72edd6
9 changed files with 545 additions and 13 deletions

View file

@ -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)