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 }) {
: null}
diff --git a/frontend/src/components/IncidentsPanel.test.jsx b/frontend/src/components/IncidentsPanel.test.jsx
index 1a8adcc..584b9e9 100644
--- a/frontend/src/components/IncidentsPanel.test.jsx
+++ b/frontend/src/components/IncidentsPanel.test.jsx
@@ -25,13 +25,21 @@ const incident = {
describe("IncidentsPanel", () => {
it("renders an empty state", async () => {
- const api = { getIncidents: vi.fn().mockResolvedValue({ items: [] }), getIncident: vi.fn() };
+ const api = { getIncidents: vi.fn().mockResolvedValue({ items: [] }), getIncident: vi.fn(), getIncidentSummary: vi.fn() };
render();
- expect(await screen.findByText("No correlated incidents")).toBeTruthy();
+ expect(await screen.findByText("No correlated incidents in this view")).toBeTruthy();
});
it("renders incident details, evidence, timeline, and masks sensitive mock data", async () => {
- const api = { getIncidents: vi.fn().mockResolvedValue({ items: [incident] }), getIncident: vi.fn().mockResolvedValue({ incident }) };
+ const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
+ Object.defineProperty(navigator, "clipboard", { value: clipboard, configurable: true });
+ const api = {
+ getIncidents: vi.fn().mockResolvedValue({ items: [incident] }),
+ getIncident: vi.fn().mockResolvedValue({ incident }),
+ getIncidentSummary: vi.fn().mockResolvedValue({
+ markdown: "# Possible Beaconing\n\n- **Severity:** high\n\n## Evidence\n\n- token=export-secret\n\n## Timeline\n\n- Authorization: Bearer timeline-export-secret",
+ }),
+ };
render();
await screen.findAllByText("Possible Beaconing");
await waitFor(() => expect(screen.getByText("Evidence")).toBeTruthy());
@@ -43,5 +51,15 @@ describe("IncidentsPanel", () => {
expect(screen.getAllByText("high").length).toBeGreaterThan(0);
fireEvent.click(screen.getAllByRole("button", { name: "Refresh incidents" }).at(-1));
await waitFor(() => expect(api.getIncidents).toHaveBeenCalledTimes(2));
+ fireEvent.click(screen.getByRole("button", { name: "Generate Markdown" }));
+ const summary = await screen.findByLabelText("Incident Markdown summary");
+ expect(summary.value).toContain("# Possible Beaconing");
+ expect(summary.value).toContain("## Evidence");
+ expect(summary.value).toContain("## Timeline");
+ expect(summary.value).not.toContain("export-secret");
+ expect(summary.value).not.toContain("timeline-export-secret");
+ fireEvent.click(screen.getByRole("button", { name: "Copy Markdown" }));
+ await waitFor(() => expect(clipboard.writeText).toHaveBeenCalledTimes(1));
+ expect(await screen.findByText("Copied")).toBeTruthy();
});
});
diff --git a/frontend/src/hooks/useApiClient.js b/frontend/src/hooks/useApiClient.js
index b4160ec..c60ea45 100644
--- a/frontend/src/hooks/useApiClient.js
+++ b/frontend/src/hooks/useApiClient.js
@@ -85,6 +85,7 @@ export function useApiClient(localToken) {
return request(`/incidents${query ? `?${query}` : ""}`);
},
getIncident: (id) => request(`/incidents/${encodeURIComponent(id)}`),
+ getIncidentSummary: (id) => request(`/incidents/${encodeURIComponent(id)}/summary`),
getAgentsOverview: () => request("/agents/overview"),
getAgentAlertsSummary: () => request("/agents/alerts/summary"),
getAgentRiskSummary: () => request("/agents/risk/summary"),
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index e2894c5..281e3f8 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1850,6 +1850,16 @@ a {
min-width: 150px;
}
+.incident-empty-state {
+ max-width: 620px;
+ margin: 12px auto 0;
+ padding: 28px;
+}
+
+.incident-empty-state .eyebrow {
+ margin-bottom: 6px;
+}
+
.incidents-layout {
display: grid;
grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.4fr);
@@ -1903,6 +1913,43 @@ a {
font-style: normal;
}
+.incident-severity {
+ align-items: center;
+ gap: 6px;
+ min-width: 72px;
+ justify-content: center;
+ padding: 5px 8px;
+ border-width: 1px;
+ border-style: solid;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.incident-severity::before {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: currentColor;
+ content: "";
+}
+
+.incident-severity.severity-low {
+ border-color: color-mix(in srgb, var(--accent) 45%, transparent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+
+.incident-severity.severity-medium {
+ border-color: color-mix(in srgb, var(--warn) 55%, transparent);
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
+}
+
+.incident-severity.severity-high,
+.incident-severity.severity-critical {
+ border-color: color-mix(in srgb, var(--danger) 55%, transparent);
+ background: color-mix(in srgb, var(--danger) 12%, transparent);
+}
+
.incident-row em {
grid-column: 2;
font-size: 12px;
@@ -1928,6 +1975,30 @@ a {
margin: 0;
}
+.incident-time-strip {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.incident-time-strip span {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ background: color-mix(in srgb, var(--panel-strong) 80%, transparent);
+}
+
+.incident-time-strip small {
+ color: var(--muted);
+}
+
+.incident-time-strip strong {
+ overflow-wrap: anywhere;
+ font-size: 12px;
+}
+
.incident-detail section {
display: grid;
gap: 8px;
@@ -1943,6 +2014,40 @@ a {
text-align: center;
}
+.incident-export {
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+}
+
+.incident-export-head,
+.incident-export-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.incident-export-head p,
+.incident-copy-state {
+ margin: 4px 0 0;
+}
+
+.incident-export textarea {
+ width: 100%;
+ min-width: 0;
+ resize: vertical;
+ font-family: Consolas, "Courier New", monospace;
+ font-size: 12px;
+ line-height: 1.55;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.incident-copy-state {
+ color: var(--accent);
+ font-size: 12px;
+}
+
.flow-attribution-summary {
min-width: 0;
}
@@ -3203,4 +3308,33 @@ td {
.stat-card {
padding: 0;
}
+
+ .incidents-workspace {
+ gap: 12px;
+ }
+
+ .incident-detail,
+ .incident-empty-state {
+ padding: 12px;
+ }
+
+ .incident-row {
+ padding: 10px;
+ }
+
+ .incident-detail-head,
+ .incident-export-head,
+ .incident-export-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .incident-export-actions button {
+ width: 100%;
+ }
+
+ .incident-time-strip,
+ .incident-related {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/tests/test_incident_correlation.py b/tests/test_incident_correlation.py
index 9488d82..f175cc6 100644
--- a/tests/test_incident_correlation.py
+++ b/tests/test_incident_correlation.py
@@ -8,7 +8,10 @@
from backend.app.main import app, sniffer_service
from backend.app.security import require_local_token, require_trusted_client
-from backend.app.services.incident_correlation import IncidentCorrelationEngine
+from backend.app.services.incident_correlation import (
+ IncidentCorrelationEngine,
+ incident_markdown_summary,
+)
def signal(index: int = 1, **overrides):
@@ -106,6 +109,42 @@ def test_malformed_signal_is_ignored_and_metrics_are_recorded(self):
self.assertEqual(metrics["signals_ignored_total"], 1)
self.assertNotIn("secret", str(metrics))
+ def test_markdown_summary_contains_context_and_redacts_sensitive_values(self):
+ markdown = incident_markdown_summary(
+ {
+ "title": "Possible Beaconing",
+ "severity": "high",
+ "confidence": "medium",
+ "status": "open",
+ "first_seen": "2026-07-15T10:00:00Z",
+ "last_seen": "2026-07-15T10:05:00Z",
+ "source_hosts": ["10.0.0.5"],
+ "applications": ["browser.exe"],
+ "services": ["Unknown encrypted destination"],
+ "domains": ["unknown.example"],
+ "evidence": ["Authorization: Bearer markdown-secret"],
+ "correlation_reasons": ["Repeated encrypted destination"],
+ "recommended_investigation_steps": ["Review the destination."],
+ "false_positive_notes": ["Background update traffic."],
+ "timeline": [
+ {
+ "timestamp": "2026-07-15T10:01:00Z",
+ "severity": "high",
+ "summary": "Cookie: session=timeline-secret",
+ "source": "alert",
+ }
+ ],
+ }
+ )
+ self.assertIn("# Possible Beaconing", markdown)
+ self.assertIn("**Severity:** high", markdown)
+ self.assertIn("## Evidence", markdown)
+ self.assertIn("## Timeline", markdown)
+ self.assertIn("## Correlation Reasons", markdown)
+ self.assertNotIn("markdown-secret", markdown)
+ self.assertNotIn("timeline-secret", markdown)
+ self.assertIn("[REDACTED]", markdown)
+
class IncidentApiTests(unittest.TestCase):
def setUp(self):
@@ -131,6 +170,18 @@ def test_list_detail_and_monitoring_are_redacted(self):
self.assertIn("incidents", monitoring.json())
self.assertNotIn("api-secret", listing.text + detail.text + monitoring.text)
+ def test_summary_endpoint_returns_redacted_markdown(self):
+ with patch.object(sniffer_service, "_incident_correlation", self.engine):
+ response = self.client.get(
+ f"/api/incidents/{self.incident['incident_id']}/summary"
+ )
+ self.assertEqual(response.status_code, 200)
+ markdown = response.json()["markdown"]
+ self.assertIn("# Possible Beaconing", markdown)
+ self.assertIn("## Evidence", markdown)
+ self.assertIn("## Timeline", markdown)
+ self.assertNotIn("api-secret", markdown)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_release_readiness.py b/tests/test_release_readiness.py
index 3f5ab39..14ffe73 100644
--- a/tests/test_release_readiness.py
+++ b/tests/test_release_readiness.py
@@ -137,6 +137,9 @@ def test_incident_correlation_docs_and_boundaries_are_explicit(self):
self.assertIn("IncidentCorrelationEngine", architecture)
self.assertIn("NETBOT_INCIDENT_MAX_OPEN", incident_docs)
self.assertIn("single weak signal does not create", incident_lower)
+ self.assertIn("Redacted Summary Export", incident_docs)
+ self.assertIn("Generate Markdown", incident_docs)
+ self.assertIn("read-only text area", incident_lower)
for boundary in [
"raw payloads",
"credentials",