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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue