Skip to content

Commit 90d64b8

Browse files
authored
Merge pull request #54 from CodeBuildder/agent/correlated-incident-lifecycle
Build Week: Promote correlated incident lifecycle to main
2 parents 43bbef0 + f7ffeb6 commit 90d64b8

4 files changed

Lines changed: 92 additions & 14 deletions

File tree

‎backend/src/main.py‎

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,57 @@ def _severity(finding: dict) -> str:
5151
return str(finding.get("severity") or finding.get("payload", {}).get("severity") or "info").lower()
5252

5353

54+
def _summary(finding: dict) -> str:
55+
payload = finding.get("payload", {})
56+
assessment = payload.get("assessment")
57+
if isinstance(assessment, dict):
58+
assessment = assessment.get("assessment") or assessment.get("summary")
59+
value = (assessment or payload.get("causal_chain") or payload.get("outcome")
60+
or payload.get("rule") or payload.get("description")
61+
or payload.get("annotations", {}).get("summary") or payload.get("alertname")
62+
or finding.get("type", "Operational finding"))
63+
return str(value)
64+
65+
66+
def _provenance(finding: dict) -> str:
67+
payload = finding.get("payload", {})
68+
explicit = payload.get("provenance") or payload.get("execution_mode") or payload.get("domain")
69+
if explicit in {"chaos_mesh", "live_chaos"}:
70+
return "live_chaos"
71+
if explicit in {"simulator", "synthetic"}:
72+
return "simulator"
73+
return "replayed" if finding.get("replayed") else "observed"
74+
75+
76+
def _correlated_incidents(timeline: list[dict]) -> list[dict]:
77+
"""Build cases only from an explicit ID shared by both specialist agents."""
78+
groups: dict[str, list[dict]] = {}
79+
for item in timeline:
80+
correlation_id = str(item.get("correlation_id") or "").strip()
81+
if correlation_id:
82+
groups.setdefault(correlation_id, []).append(item)
83+
84+
severity_rank = {"critical": 5, "high": 4, "medium": 3, "med": 3, "low": 2, "info": 1}
85+
incidents = []
86+
for correlation_id, evidence in groups.items():
87+
sources = {_source(item) for item in evidence}
88+
if not {"argus", "phoenix"}.issubset(sources):
89+
continue
90+
ordered = sorted(evidence, key=lambda item: str(item.get("timestamp") or ""))
91+
severity = max((_severity(item) for item in evidence), key=lambda value: severity_rank.get(value, 0))
92+
terminal = next((item for item in reversed(ordered) if item.get("outcome")), None)
93+
entity = next((item.get("entity_name") for item in ordered if item.get("entity_name")), None)
94+
incidents.append({
95+
"incident_id": f"corr:{correlation_id}", "correlation_id": correlation_id,
96+
"title": f"Argus → Phoenix lifecycle{f' for {entity}' if entity else ''}",
97+
"status": "resolved" if terminal else "open", "severity": severity,
98+
"started_at": ordered[0].get("timestamp"), "updated_at": ordered[-1].get("timestamp"),
99+
"sources": sorted(sources), "evidence_count": len(ordered), "timeline": ordered,
100+
"provenance": sorted({str(item.get("provenance") or "observed") for item in ordered}),
101+
})
102+
return sorted(incidents, key=lambda item: str(item.get("updated_at") or ""), reverse=True)
103+
104+
54105
async def _findings_for_entities(nodes: list[dict]) -> list[dict]:
55106
# SOG currently exposes findings per entity. Keep concurrency bounded so a
56107
# large topology does not overwhelm Redis, while avoiding a judge-facing
@@ -139,17 +190,15 @@ async def _build_overview_uncached() -> dict:
139190
"stage": row.get("payload", {}).get("stage") or row.get("payload", {}).get("node"),
140191
"action": row.get("payload", {}).get("recommended_action") or row.get("payload", {}).get("action_taken"),
141192
"outcome": row.get("payload", {}).get("outcome") or row.get("payload", {}).get("verify_result"),
142-
"summary": row.get("payload", {}).get("assessment")
143-
or row.get("payload", {}).get("causal_chain")
144-
or row.get("payload", {}).get("outcome")
145-
or row.get("payload", {}).get("rule")
146-
or row.get("payload", {}).get("description")
147-
or row.get("payload", {}).get("annotations", {}).get("summary")
148-
or row.get("payload", {}).get("alertname")
149-
or row.get("type", "Operational finding"),
193+
"summary": _summary(row),
150194
"payload": row.get("payload", {}), "replayed": bool(row.get("replayed")),
195+
"provenance": _provenance(row),
151196
} for row in findings[:80]]
152197

198+
correlated = _correlated_incidents(timeline)
199+
existing_ids = {str(item.get("correlation_id") or item.get("incident_id") or "") for item in incidents}
200+
incidents = [*incidents, *(item for item in correlated if item["correlation_id"] not in existing_ids)]
201+
153202
evidence_by_entity: dict[str, list[dict]] = {}
154203
for item in timeline:
155204
evidence_by_entity.setdefault(str(item.get("entity_id") or "unknown"), []).append(item)

‎backend/tests/test_main.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22

33
import pytest
44

5-
from main import _risk, _source, build_overview
5+
from main import _correlated_incidents, _risk, _source, _summary, build_overview
66

77

88
def test_source_normalizes_agents():
99
assert _source({"source": "argus-agent"}) == "argus"
1010
assert _source({"source": "phoenix"}) == "phoenix"
1111

1212

13+
def test_summary_normalizes_structured_argus_assessment():
14+
finding = {"payload": {"assessment": {"assessment": "critical shell evidence", "confidence": 0.97}}}
15+
assert _summary(finding) == "critical shell evidence"
16+
17+
1318
def test_risk_is_transparent_and_bounded():
1419
nodes = [{"entity_id": "pod/prod/api", "name": "api", "security_posture": "high-risk", "fragility_score": .5}]
1520
findings = [{"entity_id": "pod/prod/api", "severity": "critical"}]
@@ -20,6 +25,18 @@ def test_risk_is_transparent_and_bounded():
2025
assert fleet == components[0]["risk"]
2126

2227

28+
def test_incident_requires_explicit_cross_agent_correlation():
29+
argus = {"id": "a", "source": "argus", "severity": "critical", "timestamp": "2026-07-18T00:00:00Z", "correlation_id": "case-1", "summary": "detected", "provenance": "observed"}
30+
phoenix = {"id": "p", "source": "phoenix", "severity": "high", "timestamp": "2026-07-18T00:00:05Z", "correlation_id": "case-1", "summary": "recovered", "outcome": "verified", "provenance": "live_chaos"}
31+
standalone = {"id": "solo", "source": "phoenix", "severity": "high", "timestamp": "2026-07-18T00:00:06Z", "correlation_id": "case-2"}
32+
incidents = _correlated_incidents([standalone, phoenix, argus])
33+
assert len(incidents) == 1
34+
assert incidents[0]["correlation_id"] == "case-1"
35+
assert incidents[0]["status"] == "resolved"
36+
assert incidents[0]["sources"] == ["argus", "phoenix"]
37+
assert [item["id"] for item in incidents[0]["timeline"]] == ["a", "p"]
38+
39+
2340
@pytest.mark.asyncio
2441
async def test_overview_aggregates_sources():
2542
async def fake_get(path, params=None):

‎dashboard/src/App.tsx‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type Timeline = {
99
id?: string; source: string; severity: string; timestamp: string; entity_id?: string
1010
entity_name?: string; type: string; summary: string; replayed: boolean
1111
correlation_id?: string; stage?: string; action?: string; outcome?: string
12+
provenance?: string
1213
payload?: Record<string, unknown>
1314
}
1415
type Component = {
@@ -21,14 +22,19 @@ type SourceHealth = {
2122
connected: boolean; findings: number; latest_at?: string; live: number; replayed: number
2223
critical: number; high: number
2324
}
25+
type Incident = {
26+
incident_id: string; correlation_id?: string; title?: string; status?: string; severity?: string
27+
started_at?: string; updated_at?: string; sources?: string[]; evidence_count?: number
28+
provenance?: string[]; timeline?: Timeline[]
29+
}
2430
type Overview = {
2531
generated_at: string; status: string; degraded_sources?: string[]; fleet_risk: number
2632
risk_level: string; counts: Record<string, number>; sources: Record<string, SourceHealth>
2733
namespaces?: Record<string, number>; components: Component[]; timeline: Timeline[]
28-
topology: { nodes: any[]; edges: any[] }; trust: any[]; incidents?: any[]
34+
topology: { nodes: any[]; edges: any[] }; trust: any[]; incidents?: Incident[]
2935
}
3036
type MetricKind = 'signals' | 'urgent' | 'affected' | 'namespaces' | 'incidents'
31-
type Detail = { kind: 'signal'; signal: Timeline } | { kind: 'component'; component: Component } | { kind: 'metric'; metric: MetricKind } | { kind: 'source'; source: 'argus' | 'phoenix' }
37+
type Detail = { kind: 'signal'; signal: Timeline } | { kind: 'component'; component: Component } | { kind: 'metric'; metric: MetricKind } | { kind: 'source'; source: 'argus' | 'phoenix' } | { kind: 'incident'; incident: Incident }
3238

3339
const API = '/api'
3440
const ARGUS_URL = (import.meta as any).env.VITE_ARGUS_URL as string | undefined
@@ -179,7 +185,7 @@ function TrustLadder({ records }: { records: any[] }) {
179185
</div>
180186
}
181187

182-
function MetricDetail({ metric, data, onSignal, onComponent }: { metric: MetricKind; data: Overview | null; onSignal: (signal: Timeline) => void; onComponent: (component: Component) => void }) {
188+
function MetricDetail({ metric, data, onSignal, onComponent, onIncident }: { metric: MetricKind; data: Overview | null; onSignal: (signal: Timeline) => void; onComponent: (component: Component) => void; onIncident: (incident: Incident) => void }) {
183189
const definitions: Record<MetricKind, { title: string; description: string }> = {
184190
signals: { title: 'Live signals', description: 'Every finding in the current Sentinel Operations Graph evidence window, separated into observed and replayed records.' },
185191
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
191197
if (metric === 'signals' || metric === 'urgent') { const rows = metric === 'urgent' ? (data?.timeline || []).filter(item => ['critical', 'high'].includes(item.severity)) : data?.timeline || []; return <><div className="metric-intro"><h2>{definition.title}</h2><p>{definition.description}</p><div><span><b>{rows.length}</b> total</span><span><b>{rows.filter(item => !item.replayed).length}</b> observed</span><span><b>{rows.filter(item => item.replayed).length}</b> replayed</span></div></div><div className="metric-list">{rows.slice(0, 20).map((item, index) => <button key={item.id || index} onClick={() => onSignal(item)}><i style={{ background: color(item.severity) }} /><div><b>{item.entity_name || label(item.type)}</b><small>{item.source} · {item.summary}</small></div><strong style={{ color: color(item.severity) }}>{item.severity}</strong><time>{age(item.timestamp)}</time><ChevronRight /></button>)}{!rows.length && <Empty text={`No ${metric === 'urgent' ? 'urgent ' : ''}signals are present.`} />}</div></> }
192198
if (metric === 'affected') { const rows = (data?.components || []).filter(item => item.finding_count > 0); return <><div className="metric-intro"><h2>{definition.title}</h2><p>{definition.description}</p></div><div className="metric-list">{rows.map(item => <button key={item.entity_id} onClick={() => onComponent(item)}><i style={{ background: color(item.risk >= 50 ? 'high' : 'medium') }} /><div><b>{item.name}</b><small>{item.namespace} · {item.finding_count} signals · risk {item.risk}</small></div><ChevronRight /></button>)}</div></> }
193199
if (metric === 'namespaces') { const rows = Object.entries(data?.namespaces || {}).sort((a, b) => b[1] - a[1]); return <><div className="metric-intro"><h2>{definition.title}</h2><p>{definition.description}</p></div><div className="namespace-detail">{rows.map(([name, count]) => { const affected = (data?.components || []).filter(item => item.namespace === name && item.finding_count > 0).length; return <div key={name}><Layers3 /><div><b>{name}</b><small>{affected} affected resources</small></div><strong>{count} entities</strong></div> })}</div></> }
194-
return <><div className="metric-intro"><h2>{definition.title}</h2><p>{definition.description}</p></div><div className="metric-list">{(data?.incidents || []).map((incident, index) => <div className="incident-row" key={incident.incident_id || index}><GitBranch /><div><b>{incident.title || incident.incident_id || 'Correlated incident'}</b><small>{incident.status || 'open'}</small></div></div>)}{!(data?.incidents || []).length && <Empty text="No correlated incidents are open. Individual findings remain visible in Live Signals." />}</div></>
200+
return <><div className="metric-intro"><h2>{definition.title}</h2><p>{definition.description}</p></div><div className="metric-list">{(data?.incidents || []).map((incident, index) => <button className="incident-row" key={incident.incident_id || index} onClick={() => onIncident(incident)}><GitBranch /><div><b>{incident.title || incident.incident_id || 'Correlated incident'}</b><small>{incident.status || 'open'} · {incident.evidence_count || incident.timeline?.length || 0} lifecycle records</small></div><strong style={{ color: color(incident.severity || 'info') }}>{incident.severity || 'info'}</strong><ChevronRight /></button>)}{!(data?.incidents || []).length && <Empty text="No correlated incidents exist. Individual findings remain visible in Live Signals." />}</div></>
195201
}
196202

197203
function DetailDrawer({ detail, data, onClose, onDetail }: { detail: Detail; data: Overview | null; onClose: () => void; onDetail: (detail: Detail) => void }) {
198204
useEffect(() => { const escape = (event: KeyboardEvent) => event.key === 'Escape' && onClose(); document.addEventListener('keydown', escape); return () => document.removeEventListener('keydown', escape) }, [onClose])
199-
if (detail.kind === 'metric') return <div className="drawer-wrap" role="dialog" aria-modal="true"><button className="drawer-backdrop" onClick={onClose} aria-label="Close details" /><aside className="drawer metric-drawer"><div className="drawer-head"><div><span>METRIC EXPLORER</span><p>Every number opens into the records behind it.</p></div><button onClick={onClose}><X /></button></div><MetricDetail metric={detail.metric} data={data} onSignal={signal => onDetail({ kind: 'signal', signal })} onComponent={component => onDetail({ kind: 'component', component })} /></aside></div>
205+
if (detail.kind === 'metric') return <div className="drawer-wrap" role="dialog" aria-modal="true"><button className="drawer-backdrop" onClick={onClose} aria-label="Close details" /><aside className="drawer metric-drawer"><div className="drawer-head"><div><span>METRIC EXPLORER</span><p>Every number opens into the records behind it.</p></div><button onClick={onClose}><X /></button></div><MetricDetail metric={detail.metric} data={data} onSignal={signal => onDetail({ kind: 'signal', signal })} onComponent={component => onDetail({ kind: 'component', component })} onIncident={incident => onDetail({ kind: 'incident', incident })} /></aside></div>
200206
if (detail.kind === 'source') {
201207
const records = (data?.timeline || []).filter(item => item.source === detail.source)
202208
const consoleUrl = detail.source === 'argus' ? consolePage(ARGUS_URL, '/threats') : PHOENIX_URL
203209
return <div className="drawer-wrap" role="dialog" aria-modal="true"><button className="drawer-backdrop" onClick={onClose} aria-label="Close details" /><aside className="drawer source-drawer"><div className="drawer-head"><div><span>SOG SOURCE EVIDENCE</span><h2>{detail.source === 'argus' ? 'Argus security evidence' : 'Phoenix resilience evidence'}</h2><p>{records.length} records currently stored in the Sentinel Operations Graph</p></div><button onClick={onClose}><X /></button></div><div className="source-truth"><AlertTriangle /><p><b>{records.length} evidence record{records.length === 1 ? '' : 's'} does not necessarily mean {records.length} incident{records.length === 1 ? '' : 's'}.</b> Incidents exist only after the specialist agent correlates or creates a case.</p></div><div className="metric-list">{records.map((record, index) => <button key={record.id || index} onClick={() => onDetail({ kind: 'signal', signal: record })}><i style={{ background: color(record.severity) }} /><div><b>{record.entity_name || label(record.type)}</b><small>{record.summary}</small></div><strong style={{ color: color(record.severity) }}>{record.severity}</strong><time>{age(record.timestamp)}</time><ChevronRight /></button>)}{!records.length && <Empty text={`No ${detail.source} evidence is currently stored in SOG.`} />}</div>{consoleUrl && <a className="source-console-link" href={consoleUrl}>{detail.source === 'argus' ? 'Open Argus Threat Feed' : 'Open Phoenix Overview'}<ArrowRight /></a>}</aside></div>
204210
}
211+
if (detail.kind === 'incident') {
212+
const incident = detail.incident
213+
return <div className="drawer-wrap" role="dialog" aria-modal="true"><button className="drawer-backdrop" onClick={onClose} aria-label="Close details" /><aside className="drawer incident-drawer"><div className="drawer-head"><div><span>CORRELATED INCIDENT</span><h2>{incident.title || incident.incident_id}</h2><p>{incident.correlation_id ? `Correlation ID · ${incident.correlation_id}` : incident.incident_id}</p></div><button onClick={onClose}><X /></button></div><div className="incident-facts"><span><small>STATUS</small><b>{incident.status || 'open'}</b></span><span><small>SEVERITY</small><b style={{ color: color(incident.severity || 'info') }}>{incident.severity || 'info'}</b></span><span><small>SOURCES</small><b>{(incident.sources || []).join(' + ') || 'unknown'}</b></span><span><small>EVIDENCE</small><b>{incident.evidence_count || incident.timeline?.length || 0}</b></span></div><section><h3><GitBranch /> Evidence-to-recovery timeline</h3><p className="timeline-help">Every step below carries the same explicit correlation ID. Standalone findings are excluded.</p><div className="lifecycle">{(incident.timeline || []).map((item, index) => <button key={item.id || index} onClick={() => onDetail({ kind: 'signal', signal: item })}><i style={{ background: color(item.source) }} /><div><header><span style={{ color: color(item.source) }}>{item.source}</span><strong>{item.stage ? label(item.stage) : label(item.type)}</strong><time>{age(item.timestamp)}</time></header><b>{item.summary}</b><small>{item.provenance || (item.replayed ? 'replayed' : 'observed')}{item.action ? ` · action ${label(item.action)}` : ''}{item.outcome ? ` · outcome ${label(item.outcome)}` : ''}</small></div><ChevronRight /></button>)}{!(incident.timeline || []).length && <Empty text="This SOG incident has no embedded lifecycle records." />}</div></section></aside></div>
214+
}
205215
const signal = detail.kind === 'signal' ? detail.signal : undefined, component = detail.kind === 'component' ? detail.component : undefined
206216
const evidence = component?.evidence || (signal ? [signal] : [])
207217
return <div className="drawer-wrap" role="dialog" aria-modal="true"><button className="drawer-backdrop" onClick={onClose} aria-label="Close details" /><aside className="drawer"><div className="drawer-head"><div><span>{detail.kind === 'signal' ? 'OPERATIONAL EVIDENCE' : 'RESOURCE INTELLIGENCE'}</span><h2>{signal?.entity_name || component?.name || 'Unmapped resource'}</h2><p>{signal?.entity_id || component?.entity_id}</p></div><button onClick={onClose}><X /></button></div>

0 commit comments

Comments
 (0)