From 31f676145ec01b64329fcf5acb9cbaa37bb914dd Mon Sep 17 00:00:00 2001 From: kaushik-kumaran Date: Sat, 18 Jul 2026 17:35:19 -0500 Subject: [PATCH 1/2] Correlate cross-agent incident evidence --- backend/src/main.py | 44 +++++++++++++++++++++++++++++++++++ backend/tests/test_main.py | 14 ++++++++++- dashboard/src/App.tsx | 20 ++++++++++++---- dashboard/src/readability.css | 2 ++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index 3cb7f47..7c05845 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -51,6 +51,45 @@ def _severity(finding: dict) -> str: return str(finding.get("severity") or finding.get("payload", {}).get("severity") or "info").lower() +def _provenance(finding: dict) -> str: + payload = finding.get("payload", {}) + explicit = payload.get("provenance") or payload.get("execution_mode") or payload.get("domain") + if explicit in {"chaos_mesh", "live_chaos"}: + return "live_chaos" + if explicit in {"simulator", "synthetic"}: + return "simulator" + return "replayed" if finding.get("replayed") else "observed" + + +def _correlated_incidents(timeline: list[dict]) -> list[dict]: + """Build cases only from an explicit ID shared by both specialist agents.""" + groups: dict[str, list[dict]] = {} + for item in timeline: + correlation_id = str(item.get("correlation_id") or "").strip() + if correlation_id: + groups.setdefault(correlation_id, []).append(item) + + severity_rank = {"critical": 5, "high": 4, "medium": 3, "med": 3, "low": 2, "info": 1} + incidents = [] + for correlation_id, evidence in groups.items(): + sources = {_source(item) for item in evidence} + if not {"argus", "phoenix"}.issubset(sources): + continue + ordered = sorted(evidence, key=lambda item: str(item.get("timestamp") or "")) + severity = max((_severity(item) for item in evidence), key=lambda value: severity_rank.get(value, 0)) + terminal = next((item for item in reversed(ordered) if item.get("outcome")), None) + entity = next((item.get("entity_name") for item in ordered if item.get("entity_name")), None) + incidents.append({ + "incident_id": f"corr:{correlation_id}", "correlation_id": correlation_id, + "title": f"Argus → Phoenix lifecycle{f' for {entity}' if entity else ''}", + "status": "resolved" if terminal else "open", "severity": severity, + "started_at": ordered[0].get("timestamp"), "updated_at": ordered[-1].get("timestamp"), + "sources": sorted(sources), "evidence_count": len(ordered), "timeline": ordered, + "provenance": sorted({str(item.get("provenance") or "observed") for item in ordered}), + }) + return sorted(incidents, key=lambda item: str(item.get("updated_at") or ""), reverse=True) + + async def _findings_for_entities(nodes: list[dict]) -> list[dict]: # SOG currently exposes findings per entity. Keep concurrency bounded so a # large topology does not overwhelm Redis, while avoiding a judge-facing @@ -148,8 +187,13 @@ async def _build_overview_uncached() -> dict: or row.get("payload", {}).get("alertname") or row.get("type", "Operational finding"), "payload": row.get("payload", {}), "replayed": bool(row.get("replayed")), + "provenance": _provenance(row), } for row in findings[:80]] + correlated = _correlated_incidents(timeline) + existing_ids = {str(item.get("correlation_id") or item.get("incident_id") or "") for item in incidents} + incidents = [*incidents, *(item for item in correlated if item["correlation_id"] not in existing_ids)] + evidence_by_entity: dict[str, list[dict]] = {} for item in timeline: evidence_by_entity.setdefault(str(item.get("entity_id") or "unknown"), []).append(item) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index bd50a99..1a5f5b5 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -2,7 +2,7 @@ import pytest -from main import _risk, _source, build_overview +from main import _correlated_incidents, _risk, _source, build_overview def test_source_normalizes_agents(): @@ -20,6 +20,18 @@ def test_risk_is_transparent_and_bounded(): assert fleet == components[0]["risk"] +def test_incident_requires_explicit_cross_agent_correlation(): + argus = {"id": "a", "source": "argus", "severity": "critical", "timestamp": "2026-07-18T00:00:00Z", "correlation_id": "case-1", "summary": "detected", "provenance": "observed"} + phoenix = {"id": "p", "source": "phoenix", "severity": "high", "timestamp": "2026-07-18T00:00:05Z", "correlation_id": "case-1", "summary": "recovered", "outcome": "verified", "provenance": "live_chaos"} + standalone = {"id": "solo", "source": "phoenix", "severity": "high", "timestamp": "2026-07-18T00:00:06Z", "correlation_id": "case-2"} + incidents = _correlated_incidents([standalone, phoenix, argus]) + assert len(incidents) == 1 + assert incidents[0]["correlation_id"] == "case-1" + assert incidents[0]["status"] == "resolved" + assert incidents[0]["sources"] == ["argus", "phoenix"] + assert [item["id"] for item in incidents[0]["timeline"]] == ["a", "p"] + + @pytest.mark.asyncio async def test_overview_aggregates_sources(): async def fake_get(path, params=None): diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 39799e4..5927949 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -9,6 +9,7 @@ type Timeline = { id?: string; source: string; severity: string; timestamp: string; entity_id?: string entity_name?: string; type: string; summary: string; replayed: boolean correlation_id?: string; stage?: string; action?: string; outcome?: string + provenance?: string payload?: Record } type Component = { @@ -21,14 +22,19 @@ type SourceHealth = { connected: boolean; findings: number; latest_at?: string; live: number; replayed: number critical: number; high: number } +type Incident = { + incident_id: string; correlation_id?: string; title?: string; status?: string; severity?: string + started_at?: string; updated_at?: string; sources?: string[]; evidence_count?: number + provenance?: string[]; timeline?: Timeline[] +} type Overview = { generated_at: string; status: string; degraded_sources?: string[]; fleet_risk: number risk_level: string; counts: Record; sources: Record namespaces?: Record; components: Component[]; timeline: Timeline[] - topology: { nodes: any[]; edges: any[] }; trust: any[]; incidents?: any[] + topology: { nodes: any[]; edges: any[] }; trust: any[]; incidents?: Incident[] } type MetricKind = 'signals' | 'urgent' | 'affected' | 'namespaces' | 'incidents' -type Detail = { kind: 'signal'; signal: Timeline } | { kind: 'component'; component: Component } | { kind: 'metric'; metric: MetricKind } | { kind: 'source'; source: 'argus' | 'phoenix' } +type Detail = { kind: 'signal'; signal: Timeline } | { kind: 'component'; component: Component } | { kind: 'metric'; metric: MetricKind } | { kind: 'source'; source: 'argus' | 'phoenix' } | { kind: 'incident'; incident: Incident } const API = '/api' const ARGUS_URL = (import.meta as any).env.VITE_ARGUS_URL as string | undefined @@ -179,7 +185,7 @@ function TrustLadder({ records }: { records: any[] }) { } -function MetricDetail({ metric, data, onSignal, onComponent }: { metric: MetricKind; data: Overview | null; onSignal: (signal: Timeline) => void; onComponent: (component: Component) => void }) { +function MetricDetail({ metric, data, onSignal, onComponent, onIncident }: { metric: MetricKind; data: Overview | null; onSignal: (signal: Timeline) => void; onComponent: (component: Component) => void; onIncident: (incident: Incident) => void }) { const definitions: Record = { signals: { title: 'Live signals', description: 'Every finding in the current Sentinel Operations Graph evidence window, separated into observed and replayed records.' }, urgent: { title: 'Urgent evidence', description: 'Critical and high-severity findings that should be reviewed before lower-priority telemetry.' }, @@ -191,17 +197,21 @@ function MetricDetail({ metric, data, onSignal, onComponent }: { metric: MetricK if (metric === 'signals' || metric === 'urgent') { const rows = metric === 'urgent' ? (data?.timeline || []).filter(item => ['critical', 'high'].includes(item.severity)) : data?.timeline || []; return <>

{definition.title}

{definition.description}

{rows.length} total{rows.filter(item => !item.replayed).length} observed{rows.filter(item => item.replayed).length} replayed
{rows.slice(0, 20).map((item, index) => )}{!rows.length && }
} if (metric === 'affected') { const rows = (data?.components || []).filter(item => item.finding_count > 0); return <>

{definition.title}

{definition.description}

{rows.map(item => )}
} if (metric === 'namespaces') { const rows = Object.entries(data?.namespaces || {}).sort((a, b) => b[1] - a[1]); return <>

{definition.title}

{definition.description}

{rows.map(([name, count]) => { const affected = (data?.components || []).filter(item => item.namespace === name && item.finding_count > 0).length; return
{name}{affected} affected resources
{count} entities
})}
} - return <>

{definition.title}

{definition.description}

{(data?.incidents || []).map((incident, index) =>
{incident.title || incident.incident_id || 'Correlated incident'}{incident.status || 'open'}
)}{!(data?.incidents || []).length && }
+ return <>

{definition.title}

{definition.description}

{(data?.incidents || []).map((incident, index) => )}{!(data?.incidents || []).length && }
} function DetailDrawer({ detail, data, onClose, onDetail }: { detail: Detail; data: Overview | null; onClose: () => void; onDetail: (detail: Detail) => void }) { useEffect(() => { const escape = (event: KeyboardEvent) => event.key === 'Escape' && onClose(); document.addEventListener('keydown', escape); return () => document.removeEventListener('keydown', escape) }, [onClose]) - if (detail.kind === 'metric') return
onDetail({ kind: 'signal', signal })} onComponent={component => onDetail({ kind: 'component', component })} /> + if (detail.kind === 'metric') return
onDetail({ kind: 'signal', signal })} onComponent={component => onDetail({ kind: 'component', component })} onIncident={incident => onDetail({ kind: 'incident', incident })} /> if (detail.kind === 'source') { const records = (data?.timeline || []).filter(item => item.source === detail.source) const consoleUrl = detail.source === 'argus' ? consolePage(ARGUS_URL, '/threats') : PHOENIX_URL return

{records.length} evidence record{records.length === 1 ? '' : 's'} does not necessarily mean {records.length} incident{records.length === 1 ? '' : 's'}. Incidents exist only after the specialist agent correlates or creates a case.

{records.map((record, index) => )}{!records.length && }
{consoleUrl && {detail.source === 'argus' ? 'Open Argus Threat Feed' : 'Open Phoenix Overview'}} } + if (detail.kind === 'incident') { + const incident = detail.incident + return
STATUS{incident.status || 'open'}SEVERITY{incident.severity || 'info'}SOURCES{(incident.sources || []).join(' + ') || 'unknown'}EVIDENCE{incident.evidence_count || incident.timeline?.length || 0}

Evidence-to-recovery timeline

Every step below carries the same explicit correlation ID. Standalone findings are excluded.

{(incident.timeline || []).map((item, index) => )}{!(incident.timeline || []).length && }
+ } const signal = detail.kind === 'signal' ? detail.signal : undefined, component = detail.kind === 'component' ? detail.component : undefined const evidence = component?.evidence || (signal ? [signal] : []) return
diff --git a/dashboard/src/readability.css b/dashboard/src/readability.css index 9fc1b54..d0034c3 100644 --- a/dashboard/src/readability.css +++ b/dashboard/src/readability.css @@ -10,3 +10,5 @@ @media(max-width:520px){.sog-definition{width:100%}.sog-definition>div{grid-template-columns:1fr}.sog-definition small{grid-column:auto}} .sog-source{font-family:inherit;cursor:pointer}.source-truth{display:flex;align-items:flex-start;gap:10px;margin:16px 0;padding:12px;border:1px solid #f2d65c3d;border-radius:9px;background:#f2d65c09}.source-truth svg{width:18px;flex:none;color:#f2d65c}.source-truth p{margin:0;color:#9ba9bb;font-size:12px;line-height:1.55}.source-truth b{color:#e3e9f2}.source-console-link{display:flex;align-items:center;justify-content:center;gap:7px;margin-top:16px;padding:11px;border:1px solid #b68cff55;border-radius:8px;background:#b68cff0c;color:#d0bcff;text-decoration:none;font:700 11px JetBrains Mono}.source-console-link:hover{background:#b68cff18;border-color:#b68cff88}.source-console-link svg{width:14px} + +.incident-facts{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin:15px 0}.incident-facts span{padding:11px;border:1px solid #27364d;border-radius:8px;background:#0c1522}.incident-facts small{display:block;color:#687a92;font:700 9px JetBrains Mono}.incident-facts b{display:block;margin-top:5px;color:#e3e9f2;font:700 12px JetBrains Mono;text-transform:uppercase}.timeline-help{color:#8191a6;font-size:11px;line-height:1.55}.lifecycle{position:relative;margin-top:11px}.lifecycle:before{content:"";position:absolute;left:12px;top:15px;bottom:15px;width:1px;background:#34445e}.lifecycle>button{position:relative;display:grid;width:100%;grid-template-columns:25px 1fr 13px;gap:9px;align-items:center;padding:12px 5px;border:0;border-bottom:1px solid #1e2c42;background:transparent;color:inherit;text-align:left;cursor:pointer}.lifecycle>button:hover{background:#111c2b}.lifecycle>button>i{z-index:1;width:10px;height:10px;justify-self:center;border:2px solid #0b1420;border-radius:50%;box-shadow:0 0 8px currentColor}.lifecycle>button>div{display:flex;min-width:0;flex-direction:column;gap:5px}.lifecycle header{display:flex;align-items:center;gap:9px}.lifecycle header span,.lifecycle header strong,.lifecycle time{font:700 9px JetBrains Mono;text-transform:uppercase}.lifecycle header strong{color:#8999af}.lifecycle time{margin-left:auto;color:#66778f;text-transform:none}.lifecycle>button b{color:#dbe3ef;font-size:12px}.lifecycle>button small{color:#73849a;font:500 10px JetBrains Mono;text-transform:capitalize}.lifecycle>button>svg{width:12px;color:#61738b}@media(max-width:520px){.incident-facts{grid-template-columns:1fr 1fr}} From f7ffeb61464e0bc0a0d9e94c1760c29b95b4e664 Mon Sep 17 00:00:00 2001 From: kaushik-kumaran Date: Sat, 18 Jul 2026 17:38:59 -0500 Subject: [PATCH 2/2] Normalize correlated evidence summaries --- backend/src/main.py | 21 +++++++++++++-------- backend/tests/test_main.py | 7 ++++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index 7c05845..c3325fa 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -51,6 +51,18 @@ def _severity(finding: dict) -> str: return str(finding.get("severity") or finding.get("payload", {}).get("severity") or "info").lower() +def _summary(finding: dict) -> str: + payload = finding.get("payload", {}) + assessment = payload.get("assessment") + if isinstance(assessment, dict): + assessment = assessment.get("assessment") or assessment.get("summary") + value = (assessment or payload.get("causal_chain") or payload.get("outcome") + or payload.get("rule") or payload.get("description") + or payload.get("annotations", {}).get("summary") or payload.get("alertname") + or finding.get("type", "Operational finding")) + return str(value) + + def _provenance(finding: dict) -> str: payload = finding.get("payload", {}) explicit = payload.get("provenance") or payload.get("execution_mode") or payload.get("domain") @@ -178,14 +190,7 @@ async def _build_overview_uncached() -> dict: "stage": row.get("payload", {}).get("stage") or row.get("payload", {}).get("node"), "action": row.get("payload", {}).get("recommended_action") or row.get("payload", {}).get("action_taken"), "outcome": row.get("payload", {}).get("outcome") or row.get("payload", {}).get("verify_result"), - "summary": row.get("payload", {}).get("assessment") - or row.get("payload", {}).get("causal_chain") - or row.get("payload", {}).get("outcome") - or row.get("payload", {}).get("rule") - or row.get("payload", {}).get("description") - or row.get("payload", {}).get("annotations", {}).get("summary") - or row.get("payload", {}).get("alertname") - or row.get("type", "Operational finding"), + "summary": _summary(row), "payload": row.get("payload", {}), "replayed": bool(row.get("replayed")), "provenance": _provenance(row), } for row in findings[:80]] diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 1a5f5b5..c4f36d8 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -2,7 +2,7 @@ import pytest -from main import _correlated_incidents, _risk, _source, build_overview +from main import _correlated_incidents, _risk, _source, _summary, build_overview def test_source_normalizes_agents(): @@ -10,6 +10,11 @@ def test_source_normalizes_agents(): assert _source({"source": "phoenix"}) == "phoenix" +def test_summary_normalizes_structured_argus_assessment(): + finding = {"payload": {"assessment": {"assessment": "critical shell evidence", "confidence": 0.97}}} + assert _summary(finding) == "critical shell evidence" + + def test_risk_is_transparent_and_bounded(): nodes = [{"entity_id": "pod/prod/api", "name": "api", "security_posture": "high-risk", "fragility_score": .5}] findings = [{"entity_id": "pod/prod/api", "severity": "critical"}]