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
65 changes: 57 additions & 8 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,57 @@ 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")
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
Expand Down Expand Up @@ -139,17 +190,15 @@ 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]]

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)
Expand Down
19 changes: 18 additions & 1 deletion backend/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@

import pytest

from main import _risk, _source, build_overview
from main import _correlated_incidents, _risk, _source, _summary, build_overview


def test_source_normalizes_agents():
assert _source({"source": "argus-agent"}) == "argus"
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"}]
Expand All @@ -20,6 +25,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):
Expand Down
20 changes: 15 additions & 5 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
}
type Component = {
Expand All @@ -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<string, number>; sources: Record<string, SourceHealth>
namespaces?: Record<string, number>; 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
Expand Down Expand Up @@ -179,7 +185,7 @@ function TrustLadder({ records }: { records: any[] }) {
</div>
}

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<MetricKind, { title: string; description: string }> = {
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.' },
Expand All @@ -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 <><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></> }
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></> }
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></> }
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></>
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></>
}

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 <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>
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>
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 <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>
}
if (detail.kind === 'incident') {
const incident = detail.incident
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>
}
const signal = detail.kind === 'signal' ? detail.signal : undefined, component = detail.kind === 'component' ? detail.component : undefined
const evidence = component?.evidence || (signal ? [signal] : [])
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>
Expand Down
Loading