diff --git a/backend/app/main.py b/backend/app/main.py index d15f808..9151c36 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -288,6 +288,18 @@ def api_incident_detail( return {"incident": incident} +@app.get("/api/incidents/{incident_id}/summary") +def api_incident_summary( + incident_id: str, + _: None = Depends(require_trusted_client), + __: None = Depends(require_local_token), +) -> dict[str, str]: + markdown = sniffer_service.incident_summary_markdown(incident_id) + if markdown is None: + raise HTTPException(status_code=404, detail="Incident not found") + return {"incident_id": incident_id, "markdown": markdown} + + def _agent_headers( agent_id: str = Header("", alias="X-NetBot-Agent-Id"), agent_token: str = Header("", alias="X-NetBot-Agent-Token"), diff --git a/backend/app/services/incident_correlation.py b/backend/app/services/incident_correlation.py index 1293987..814754c 100644 --- a/backend/app/services/incident_correlation.py +++ b/backend/app/services/incident_correlation.py @@ -96,6 +96,71 @@ def _list(value: Any, limit: int = 20) -> list[str]: return list(dict.fromkeys(_string(item) for item in rows if _string(item)))[:limit] +def _markdown_text(value: Any, limit: int = 500) -> str: + return ( + _string(value, limit).replace("\r", " ").replace("\n", " ") or "Not available" + ) + + +def _markdown_list(title: str, values: Any, *, limit: int = 100) -> list[str]: + items = _list(values, limit) + lines = [f"## {title}", ""] + lines.extend(f"- {_markdown_text(item)}" for item in items) + if not items: + lines.append("- Not available") + lines.append("") + return lines + + +def incident_markdown_summary(incident: dict[str, Any]) -> str: + """Build a bounded, centrally redacted, read-only incident summary.""" + + safe = redact_sensitive_data( + deepcopy(incident if isinstance(incident, dict) else {}) + ) + lines = [ + f"# {_markdown_text(safe.get('title'), 200)}", + "", + f"- **Severity:** {_markdown_text(safe.get('severity'), 30)}", + f"- **Confidence:** {_markdown_text(safe.get('confidence'), 30)}", + f"- **Status:** {_markdown_text(safe.get('status'), 30)}", + f"- **First seen:** {_markdown_text(safe.get('first_seen'), 100)}", + f"- **Last seen:** {_markdown_text(safe.get('last_seen'), 100)}", + "", + ] + for title, key in ( + ("Source Hosts", "source_hosts"), + ("Applications", "applications"), + ("Services", "services"), + ("Domains", "domains"), + ("Evidence", "evidence"), + ("Correlation Reasons", "correlation_reasons"), + ("Recommended Investigation Steps", "recommended_investigation_steps"), + ("False Positive Notes", "false_positive_notes"), + ): + lines.extend(_markdown_list(title, safe.get(key), limit=100)) + lines.extend(["## Timeline", ""]) + timeline = safe.get("timeline") if isinstance(safe.get("timeline"), list) else [] + for event in timeline[-100:]: + if not isinstance(event, dict): + continue + timestamp = _markdown_text(event.get("timestamp"), 100) + summary = _markdown_text(event.get("summary"), 500) + source = _markdown_text(event.get("source"), 80) + severity = _markdown_text(event.get("severity"), 30) + lines.append(f"- **{timestamp}** [{severity}] {summary} ({source})") + if not timeline: + lines.append("- Not available") + lines.extend( + [ + "", + "> Redacted, read-only summary generated by NetBotPro. No response action was performed.", + "", + ] + ) + return redact_sensitive_text("\n".join(lines)) + + class IncidentCorrelationEngine: """Bounded, deterministic correlation over already-derived metadata signals.""" @@ -677,4 +742,9 @@ def _public(incident: dict[str, Any]) -> dict[str, Any]: return redact_sensitive_data(deepcopy(incident)) -__all__ = ["IncidentCorrelationEngine", "INCIDENT_TYPES", "SEVERITIES"] +__all__ = [ + "IncidentCorrelationEngine", + "INCIDENT_TYPES", + "SEVERITIES", + "incident_markdown_summary", +] diff --git a/backend/app/services/sniffer_service.py b/backend/app/services/sniffer_service.py index 6cfb5da..e122173 100644 --- a/backend/app/services/sniffer_service.py +++ b/backend/app/services/sniffer_service.py @@ -12,7 +12,10 @@ from backend.app.services.event_bus import EventBus from backend.app.services.flow_service import FlowService from backend.app.services.flow_worker_pool import FlowWorkerPool -from backend.app.services.incident_correlation import IncidentCorrelationEngine +from backend.app.services.incident_correlation import ( + IncidentCorrelationEngine, + incident_markdown_summary, +) from backend.app.services.live_ring_buffer import LiveRingBuffer from backend.app.services.packet_queue import BoundedPacketQueue from backend.app.services.service_attribution import ServiceAttributionEngine @@ -404,6 +407,10 @@ def list_incidents(self, **filters: Any) -> dict[str, Any]: def get_incident(self, incident_id: str) -> dict[str, Any] | None: return self._incident_correlation.get_incident(incident_id) + def incident_summary_markdown(self, incident_id: str) -> str | None: + incident = self.get_incident(incident_id) + return incident_markdown_summary(incident) if incident else None + def recent_live_records( self, category: str = "all", diff --git a/docs/INCIDENT_CORRELATION.md b/docs/INCIDENT_CORRELATION.md index c6ad03f..e39633e 100644 --- a/docs/INCIDENT_CORRELATION.md +++ b/docs/INCIDENT_CORRELATION.md @@ -55,6 +55,28 @@ Timeline entries contain only a timestamp, event type, redacted summary, source, and related safe identifiers. Entries are timestamp-sorted and capped by `NETBOT_INCIDENT_MAX_SIGNALS_PER_INCIDENT`. +## Reading An Incident + +Start with severity to prioritize review, then use confidence to understand how +strongly the evidence belongs together. Compare first and last seen to establish +the activity window. Source hosts, applications, services, and domains provide +scope, while evidence and correlation reasons explain why the signals were grouped. +The timeline shows their order. Recommended investigation steps are manual guidance; +false-positive notes highlight common benign explanations that should be checked. + +## Redacted Summary Export + +Select an incident and choose **Generate Markdown** in the Incident summary section. +NetBotPro requests a fresh summary from the protected local API and displays it in +a read-only text area. Use **Copy Markdown** to place that text on the clipboard for +an authorized ticket, investigation note, or handoff. + +The export is generated in memory and is not persisted by NetBotPro. It contains +only bounded incident fields and passes through central redaction. Raw payloads, +credentials, cookies, authorization headers, sessions, and secrets are excluded or +masked. Generating or copying a summary performs no response action and does not +change incident state. + ## Configuration | Variable | Default | Purpose | diff --git a/frontend/src/components/IncidentsPanel.jsx b/frontend/src/components/IncidentsPanel.jsx index 7f7740d..a2a7b12 100644 --- a/frontend/src/components/IncidentsPanel.jsx +++ b/frontend/src/components/IncidentsPanel.jsx @@ -2,8 +2,10 @@ import { useEffect, useState } from "react"; function safeText(value) { return String(value || "") + .replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]+/gi, "[REDACTED_HEADER]") .replace(/\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]+/gi, "[REDACTED]") - .replace(/\b(password|token|api_key|secret|session)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]"); + .replace(/\b(password|token|api_key|secret|session)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]") + .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "[REDACTED_JWT]"); } function TextList({ title, items = [] }) { @@ -22,6 +24,9 @@ export function IncidentsPanel({ api }) { const [severity, setSeverity] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [markdown, setMarkdown] = useState(""); + const [exporting, setExporting] = useState(false); + const [copyState, setCopyState] = useState(""); async function load(nextStatus = status, nextSeverity = severity) { if (!api) return; @@ -31,7 +36,11 @@ export function IncidentsPanel({ api }) { const result = await api.getIncidents({ status: nextStatus, severity: nextSeverity, limit: 200 }); const nextItems = result.items || []; setItems(nextItems); - if (!selected && nextItems[0]) await selectIncident(nextItems[0].incident_id); + if (!nextItems.length) { + setSelected(null); + } else if (!selected || !nextItems.some((item) => item.incident_id === selected.incident_id)) { + await selectIncident(nextItems[0].incident_id); + } } catch (err) { setError(err?.message || "Unable to load incidents"); } finally { @@ -42,6 +51,8 @@ export function IncidentsPanel({ api }) { async function selectIncident(id) { if (!api || !id) return; try { + setMarkdown(""); + setCopyState(""); const result = await api.getIncident(id); setSelected(result.incident || null); } catch (err) { @@ -49,6 +60,30 @@ export function IncidentsPanel({ api }) { } } + async function generateSummary() { + if (!api || !selected?.incident_id) return; + setExporting(true); + setCopyState(""); + try { + const result = await api.getIncidentSummary(selected.incident_id); + setMarkdown(safeText(result.markdown || "")); + } catch (err) { + setError(err?.message || "Unable to generate incident summary"); + } finally { + setExporting(false); + } + } + + async function copySummary() { + if (!markdown) return; + try { + await navigator.clipboard.writeText(markdown); + setCopyState("Copied"); + } catch { + setCopyState("Select the summary text and copy it manually."); + } + } + useEffect(() => { load(); }, [api]); return ( @@ -63,12 +98,12 @@ export function IncidentsPanel({ api }) { {error ?

{safeText(error)}

: null} - {!loading && !items.length ?

No correlated incidents

The engine waits for multiple related signals before creating an incident.

: null} + {!loading && !items.length ?

Incident queue clear

No correlated incidents in this view

NetBotPro creates an incident only after multiple related signals meet the correlation threshold. Adjust the filters or continue monitoring.

: null} {items.length ?
{items.map((incident) => ( @@ -76,11 +111,12 @@ export function IncidentsPanel({ api }) {