Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
72 changes: 71 additions & 1 deletion backend/app/services/incident_correlation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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",
]
9 changes: 8 additions & 1 deletion backend/app/services/sniffer_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions docs/INCIDENT_CORRELATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
55 changes: 48 additions & 7 deletions frontend/src/components/IncidentsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [] }) {
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -42,13 +51,39 @@ 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) {
setError(err?.message || "Unable to load incident details");
}
}

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 (
Expand All @@ -63,31 +98,37 @@ export function IncidentsPanel({ api }) {
<button type="button" className="primary" disabled={loading} onClick={() => load()}>{loading ? "Loading..." : "Refresh incidents"}</button>
</div>
{error ? <p className="error">{safeText(error)}</p> : null}
{!loading && !items.length ? <div className="empty-state"><h3>No correlated incidents</h3><p className="muted">The engine waits for multiple related signals before creating an incident.</p></div> : null}
{!loading && !items.length ? <div className="empty-state incident-empty-state"><p className="eyebrow">Incident queue clear</p><h3>No correlated incidents in this view</h3><p className="muted">NetBotPro creates an incident only after multiple related signals meet the correlation threshold. Adjust the filters or continue monitoring.</p></div> : null}
{items.length ? <div className="incidents-layout">
<div className="incident-list" aria-label="Incident list">
{items.map((incident) => (
<button key={incident.incident_id} type="button" className={`incident-row ${selected?.incident_id === incident.incident_id ? "incident-row-selected" : ""}`} onClick={() => selectIncident(incident.incident_id)}>
<span className={`severity-pill severity-${incident.severity}`}>{incident.severity}</span>
<span className={`severity-pill incident-severity severity-${incident.severity}`} aria-label={`Severity ${incident.severity}`}>{incident.severity}</span>
<span><strong>{safeText(incident.title)}</strong><small>{safeText(incident.source_hosts?.join(", ") || "Unknown source")} | {safeText(incident.services?.join(", ") || "Unattributed service")}</small></span>
<em>{incident.confidence} confidence</em>
</button>
))}
</div>
<aside className="incident-detail">
{!selected ? <p className="muted">Select an incident to review its evidence.</p> : <>
<div className="incident-detail-head"><div><p className="eyebrow">{safeText(selected.type).replaceAll("_", " ")}</p><h3>{safeText(selected.title)}</h3></div><span className={`severity-pill severity-${selected.severity}`}>{selected.severity}</span></div>
<div className="incident-detail-head"><div><p className="eyebrow">{safeText(selected.type).replaceAll("_", " ")}</p><h3>{safeText(selected.title)}</h3></div><span className={`severity-pill incident-severity severity-${selected.severity}`} aria-label={`Severity ${selected.severity}`}>{selected.severity}</span></div>
<div className="incident-time-strip"><span><small>First seen</small><strong>{safeText(selected.first_seen)}</strong></span><span><small>Last seen</small><strong>{safeText(selected.last_seen)}</strong></span></div>
<dl className="flow-metadata">
<div><dt>Status</dt><dd>{safeText(selected.status)}</dd></div><div><dt>Confidence</dt><dd>{safeText(selected.confidence)}</dd></div>
<div><dt>Signals</dt><dd>{selected.signal_count || 0}</dd></div><div><dt>Last seen</dt><dd>{safeText(selected.last_seen)}</dd></div>
<div><dt>Applications</dt><dd>{safeText(selected.applications?.join(", ") || "Unknown")}</dd></div><div><dt>Domains</dt><dd>{safeText(selected.domains?.join(", ") || "Unavailable")}</dd></div>
<div><dt>Signals</dt><dd>{selected.signal_count || 0}</dd></div><div><dt>Source hosts</dt><dd>{safeText(selected.source_hosts?.join(", ") || "Unknown")}</dd></div>
<div><dt>Applications</dt><dd>{safeText(selected.applications?.join(", ") || "Unknown")}</dd></div><div><dt>Services / domains</dt><dd>{safeText([...(selected.services || []), ...(selected.domains || [])].join(", ") || "Unavailable")}</dd></div>
</dl>
<TextList title="Evidence" items={selected.evidence} />
<TextList title="Correlation reasons" items={selected.correlation_reasons} />
<TextList title="Recommended investigation" items={selected.recommended_investigation_steps} />
<TextList title="False-positive notes" items={selected.false_positive_notes} />
<section><h4>Timeline</h4><div className="flow-timeline">{(selected.timeline || []).map((event, index) => <article key={`${event.timestamp}-${index}`}><span className={`flow-event-dot flow-event-${event.severity || "info"}`} /><div><strong>{safeText(event.summary)}</strong><small>{safeText(event.source)} | {safeText(event.timestamp)}</small></div></article>)}</div></section>
<section className="incident-related"><span>{selected.related_flows?.length || 0} flows</span><span>{selected.related_alerts?.length || 0} alerts</span><span>{selected.related_agents?.length || 0} agents</span></section>
<section className="incident-export">
<div className="incident-export-head"><div><h4>Incident summary</h4><p className="muted">Redacted Markdown for tickets, notes, or handoff.</p></div><div className="incident-export-actions"><button type="button" className="secondary" disabled={exporting} onClick={generateSummary}>{exporting ? "Generating..." : markdown ? "Refresh Markdown" : "Generate Markdown"}</button>{markdown ? <button type="button" className="primary" onClick={copySummary}>Copy Markdown</button> : null}</div></div>
{markdown ? <textarea aria-label="Incident Markdown summary" readOnly rows="14" value={markdown} onFocus={(event) => event.target.select()} /> : null}
{copyState ? <p className="incident-copy-state" role="status">{copyState}</p> : null}
</section>
</>}
</aside>
</div> : null}
Expand Down
24 changes: 21 additions & 3 deletions frontend/src/components/IncidentsPanel.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<IncidentsPanel api={api} />);
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(<IncidentsPanel api={api} />);
await screen.findAllByText("Possible Beaconing");
await waitFor(() => expect(screen.getByText("Evidence")).toBeTruthy());
Expand All @@ -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();
});
});
1 change: 1 addition & 0 deletions frontend/src/hooks/useApiClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading