Skip to content
Threats Guide

post_incident_report.py

Post-incident analysis report generator

  • other
  • python
  • 127 lines
  • may need admin rights

Run security tools only on systems you own or have written permission to test.

Run it

From the repository root, after pip install -r tools/requirements.txt
cd 06-incident-response/post-incident
python3 post_incident_report.py --help

Source

View on GitHub
#!/usr/bin/env python3"""Post-incident analysis report generatorLocation: 06-incident-response/post-incident/post_incident_report.pyReads an incident record (JSON) and produces a Markdown report with:  * the timeline, sorted by time  * response metrics: time to detect, contain, eradicate, recover (and total duration)  * data-quality warnings (missing phases, events out of order, no owners on action items)  * root cause, impact, lessons learned and action itemsPhases follow the NIST SP 800-61 lifecycle: detection, containment, eradication, recovery.Blameless by design: describe systems and decisions, not people's faults.Exit codes: 0 report written, 2 input problem.""" import argparseimport jsonimport sysfrom datetime import datetime, timedelta PHASES = ["start", "detected", "contained", "eradicated", "recovered", "closed"]  def parse_time(text):    return datetime.fromisoformat(text.replace("Z", "+00:00"))  def human(delta):    if delta is None:        return "not recorded"    seconds = int(delta.total_seconds())    if seconds < 0:        return "negative (check timestamps)"    days, rest = divmod(seconds, 86400)    hours, rest = divmod(rest, 3600)    minutes = rest // 60    return " ".join(p for p in (f"{days}d" if days else "", f"{hours}h" if hours else "", f"{minutes}m") if p) or "0m"  def analyse(incident):    problems = []    events = []    for index, event in enumerate(incident.get("events", []), 1):        try:            events.append({**event, "_t": parse_time(event["time"])})        except (KeyError, ValueError):            problems.append(f"event {index} has a missing or invalid ISO-8601 time: {event.get('time')!r}")    events.sort(key=lambda e: e["_t"])     first = {}    for event in events:        first.setdefault(event.get("type", "note"), event["_t"])     for phase in PHASES[:5]:        if phase not in first:            problems.append(f"no '{phase}' event recorded, so related metrics are unavailable")     seen = [first[p] for p in PHASES if p in first]    if seen != sorted(seen):        problems.append("lifecycle events are out of order (for example 'contained' before 'detected')")     def gap(a, b):        return first[b] - first[a] if a in first and b in first else None     metrics = {        "Time to detect (start to detected)": gap("start", "detected"),        "Time to contain (detected to contained)": gap("detected", "contained"),        "Time to eradicate (contained to eradicated)": gap("contained", "eradicated"),        "Time to recover (detected to recovered)": gap("detected", "recovered"),        "Total duration (start to closed)": gap("start", "closed") or gap("start", "recovered"),    }    for item in incident.get("action_items", []):        if not item.get("owner") or not item.get("due"):            problems.append(f"action item without owner or due date: {item.get('item', '?')}")    return events, metrics, problems  def render(incident, events, metrics, problems):    lines = [f"# Post-incident report: {incident.get('title', 'Untitled incident')}", ""]    lines += [f"- **Severity:** {incident.get('severity', 'not set')}",              f"- **Detected by:** {incident.get('detected_by', 'not set')}",              f"- **Report generated:** {datetime.now().strftime('%Y-%m-%d %H:%M')}", ""]     lines += ["## Summary", "", incident.get("summary", "_Add a two to three sentence summary._"), ""]    lines += ["## Timeline", "", "| Time | Phase | What happened |", "|---|---|---|"]    for event in events:        lines.append(f"| {event['time']} | {event.get('type', 'note')} | {event.get('description', '')} |")    lines += ["", "## Response metrics", "", "| Metric | Value |", "|---|---|"]    lines += [f"| {name} | {human(value)} |" for name, value in metrics.items()]     lines += ["", "## Root cause", "", incident.get("root_cause", "_Not yet determined._"), ""]    lines += ["## Impact", "", incident.get("impact", "_Not yet assessed._"), ""]    for heading, key in (("What went well", "what_went_well"), ("What went wrong", "what_went_wrong")):        lines += [f"## {heading}", ""]        lines += [f"- {entry}" for entry in incident.get(key, [])] or ["_Nothing recorded._"]        lines.append("")    lines += ["## Action items", "", "| Action | Owner | Due |", "|---|---|---|"]    for item in incident.get("action_items", []):        lines.append(f"| {item.get('item', '')} | {item.get('owner', 'UNASSIGNED')} | {item.get('due', 'NO DATE')} |")    if problems:        lines += ["", "## Data-quality warnings", ""] + [f"- {p}" for p in problems]    return "\n".join(lines) + "\n"  def main():    parser = argparse.ArgumentParser(description="Generate a post-incident report from a JSON incident record")    parser.add_argument("-i", "--input", required=True, help="incident JSON file")    parser.add_argument("-o", "--output", default="post_incident_report.md")    args = parser.parse_args()    try:        incident = json.load(open(args.input))    except (OSError, ValueError) as exc:        print(f"[x] Cannot read {args.input}: {exc}")        return 2    events, metrics, problems = analyse(incident)    open(args.output, "w").write(render(incident, events, metrics, problems))    print(f"[+] Report written to {args.output}")    for problem in problems:        print(f"[!] {problem}")    return 0  if __name__ == "__main__":    sys.exit(main())