diff --git a/decnet_web/src/components/AttackerDetail.behaviour_panel.test.tsx b/decnet_web/src/components/AttackerDetail.behaviour_panel.test.tsx index 9d94ec6a..05ba5a6b 100644 --- a/decnet_web/src/components/AttackerDetail.behaviour_panel.test.tsx +++ b/decnet_web/src/components/AttackerDetail.behaviour_panel.test.tsx @@ -4,8 +4,24 @@ import { render, screen } from '@testing-library/react'; import { BehaviouralPrimitivesPanel, type BehaviouralObservation, + type AttributionPrimitiveState, } from './AttackerDetail'; +const _attr = ( + primitive: string, + state: AttributionPrimitiveState['state'], + confidence = 0.85, + observation_count = 6, +): AttributionPrimitiveState => ({ + primitive, + current_value: 'x', + state, + confidence, + observation_count, + last_change_ts: 1714000000, + last_observation_ts: 1714000300, +}); + const _obs = ( primitive: string, value: unknown, @@ -66,6 +82,75 @@ describe('BehaviouralPrimitivesPanel', () => { expect(row.textContent).toContain('91%'); }); + it('renders attribution badges only for primitives in the map', () => { + const observations: BehaviouralObservation[] = [ + _obs('motor.input_modality', 'pasted', 0.91), + _obs('cognitive.feedback_loop_engagement', 'closed_loop', 0.88), + ]; + const attribution = new Map([ + ['motor.input_modality', _attr('motor.input_modality', 'stable', 0.95)], + // cognitive.feedback_loop_engagement intentionally absent — + // the panel must not render a badge for it. + ]); + render( + , + ); + const badge = screen.getByTestId('attribution-badge-motor.input_modality'); + expect(badge.textContent).toBe('STABLE'); + expect(badge.getAttribute('data-state')).toBe('stable'); + expect( + screen.queryByTestId( + 'attribution-badge-cognitive.feedback_loop_engagement', + ), + ).toBeNull(); + }); + + it('renders each of the five frozen states with a distinct label', () => { + const cases: [AttributionPrimitiveState['state'], string][] = [ + ['stable', 'STABLE'], + ['drifting', 'DRIFTING'], + ['conflicted', 'CONFLICTED'], + ['multi_actor', 'MULTI-ACTOR'], + ['unknown', 'UNKNOWN'], + ]; + const observations: BehaviouralObservation[] = cases.map((_pair, i) => + _obs(`motor.synthetic_${i}`, 'x'), + ); + const attribution = new Map( + cases.map(([state], i) => [ + `motor.synthetic_${i}`, + _attr(`motor.synthetic_${i}`, state), + ]), + ); + render( + , + ); + for (const [state, label] of cases) { + const idx = cases.findIndex(([s]) => s === state); + const badge = screen.getByTestId( + `attribution-badge-motor.synthetic_${idx}`, + ); + expect(badge.textContent).toBe(label); + expect(badge.getAttribute('data-state')).toBe(state); + } + }); + + it('does not render badges when no attribution prop is provided', () => { + const observations: BehaviouralObservation[] = [ + _obs('motor.input_modality', 'pasted'), + ]; + render(); + expect( + screen.queryByTestId('attribution-badge-motor.input_modality'), + ).toBeNull(); + }); + it('groups by top-level domain in the canonical order', () => { const observations: BehaviouralObservation[] = [ _obs('emotional_valence.arousal', 'medium_engaged'), diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 3d35a665..209b7aef 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -8,7 +8,12 @@ import SessionDrawer from './SessionDrawer'; import EmptyState from './EmptyState/EmptyState'; import TTPsObservedSection from './TTPsObservedSection'; import { useIdentityStream } from './useIdentityStream'; -import { useAttackerStream, type ObservationFrame } from './useAttackerStream'; +import { + useAttackerStream, + type ObservationFrame, + type AttributionStateChangedFrame, + type AttributionMultiActorFrame, +} from './useAttackerStream'; import './Dashboard.css'; interface AttackerBehavior { @@ -113,6 +118,21 @@ export interface BehaviouralObservation { source?: string; } +// Per-(identity, primitive) attribution state — derived by the +// attribution engine. Keyed by primitive when consumed by +// BehaviouralPrimitivesPanel; the API also returns identity_uuid + +// timestamps which the panel doesn't render but the live SSE handler +// uses to merge updates. +export interface AttributionPrimitiveState { + primitive: string; + current_value: unknown; + state: 'unknown' | 'stable' | 'drifting' | 'conflicted' | 'multi_actor'; + confidence: number; + observation_count: number; + last_change_ts: number; + last_observation_ts: number; +} + // ─── Fingerprint rendering ─────────────────────────────────────────────────── const fpTypeLabel: Record = { @@ -954,9 +974,53 @@ function _renderValue(value: unknown): string { return JSON.stringify(value); } +// Per-state badge styling. Five states, frozen vocabulary — +// matches decnet/correlation/attribution/aggregate.py. multi_actor is +// the loudest because the cross-primitive correlator (Phase 5) only +// fires multi_actor_suspected when >= 2 primitives flag it. +const ATTRIBUTION_STATE_STYLE: Record< + AttributionPrimitiveState['state'], + { label: string; bg: string; fg: string; border: string } +> = { + stable: { label: 'STABLE', bg: 'rgba(64,224,128,0.12)', fg: '#7fe9a4', border: '#3a8c5a' }, + drifting: { label: 'DRIFTING', bg: 'rgba(240,196,64,0.12)', fg: '#f0c440', border: '#a08020' }, + conflicted: { label: 'CONFLICTED', bg: 'rgba(240,96,96,0.12)', fg: '#f06060', border: '#a04040' }, + multi_actor: { label: 'MULTI-ACTOR', bg: 'rgba(180,96,240,0.16)', fg: '#c896f6', border: '#7a4fb0' }, + unknown: { label: 'UNKNOWN', bg: 'transparent', fg: 'var(--text-dim,#888)', border: 'var(--border-color)' }, +}; + +const AttributionBadge: React.FC<{ state: AttributionPrimitiveState }> = ({ state }) => { + const style = ATTRIBUTION_STATE_STYLE[state.state] ?? ATTRIBUTION_STATE_STYLE.unknown; + return ( + + {style.label} + + ); +}; + export const BehaviouralPrimitivesPanel: React.FC<{ observations: ReadonlyArray; -}> = ({ observations }) => { + attribution?: ReadonlyMap; +}> = ({ observations, attribution }) => { if (!observations.length) { return (
@@ -1036,6 +1100,9 @@ export const BehaviouralPrimitivesPanel: React.FC<{ > {_renderValue(obs.value)} + {attribution?.get(obs.primitive) ? ( + + ) : null} { // attacker.observations on first fetch; mutated in place by the // useAttackerStream hook below (latest-wins per primitive). const [observations, setObservations] = useState([]); + // Attribution-engine state per primitive. Seeded from + // GET /attackers/{id}/attribution on mount; live-updated via + // attribution.state_changed SSE frames. Map keyed on primitive + // for O(1) badge lookup in the panel. + const [attribution, setAttribution] = useState>( + () => new Map(), + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [serviceFilter, setServiceFilter] = useState(null); @@ -1528,6 +1602,28 @@ const AttackerDetail: React.FC = () => { fetchAttacker(); }, [id]); + // Fetch attribution state on mount + whenever the attacker uuid + // changes. Quietly tolerates 404s (the attribution worker may be + // off in a dev decky). + useEffect(() => { + if (!id) return; + let cancelled = false; + (async () => { + try { + const res = await api.get(`/attackers/${id}/attribution`); + if (cancelled) return; + const next = new Map(); + const primitives = (res.data?.primitives ?? []) as AttributionPrimitiveState[]; + for (const row of primitives) next.set(row.primitive, row); + setAttribution(next); + } catch { + // Endpoint optional in dev; the panel will simply not render + // badges. Don't surface the error to the user. + } + })(); + return () => { cancelled = true; }; + }, [id]); + // Re-fetch this attacker row whenever an identity event references // its uuid. The IDENTITY badge appears once the clusterer binds the // row, and follows through merges / unmerges live. @@ -1584,6 +1680,40 @@ const AttackerDetail: React.FC = () => { ]; }); }, + // Live attribution-state badge updates. Backend filters on + // identity_uuid so we only see frames for this attacker's + // identity; merge by primitive. + onAttributionStateChanged: (frame: AttributionStateChangedFrame) => { + setAttribution((prev) => { + const next = new Map(prev); + const prior = next.get(frame.primitive); + next.set(frame.primitive, { + primitive: frame.primitive, + current_value: frame.current_value, + state: frame.new_state, + confidence: frame.confidence, + observation_count: frame.observation_count, + // last_change_ts is derived: this frame IS a transition, so + // it locks here. last_observation_ts comes from the frame. + last_change_ts: frame.ts, + last_observation_ts: frame.ts, + // Carry forward the prior change ts only when state didn't + // actually flip (defensive — backend gates these on + // transition, but a future relaxation shouldn't lie about + // "stable since X"). + ...(prior && prior.state === frame.new_state + ? { last_change_ts: prior.last_change_ts } + : {}), + }); + return next; + }); + }, + onMultiActorSuspected: (_frame: AttributionMultiActorFrame) => { + // The per-primitive badges already reflect multi_actor on each + // contributing primitive; the cross-primitive escalation is a + // SIEM-channel signal, not a UI-only badge. Listener wired so + // a future "two operators detected" banner has a live source. + }, }); useEffect(() => { @@ -1972,7 +2102,7 @@ const AttackerDetail: React.FC = () => { open={openSections.behavioural} onToggle={() => toggle('behavioural')} > - + {/* Commands */} diff --git a/decnet_web/src/components/useAttackerStream.ts b/decnet_web/src/components/useAttackerStream.ts index bbce601e..bdea6346 100644 --- a/decnet_web/src/components/useAttackerStream.ts +++ b/decnet_web/src/components/useAttackerStream.ts @@ -19,6 +19,15 @@ * primitive rides in `payload.primitive`. * * `fingerprint.rotated` — `attacker.fingerprint_rotated`. * * `attacker.scored` — score-threshold crossings. + * * `attribution.state_changed` — per-(identity, primitive) + * state-machine transition (Phase 4 of the + * attribution engine: stable / drifting / + * conflicted / multi_actor / unknown). + * Backend filters on `payload.identity_uuid` + * matching the attacker's resolved identity. + * * `attribution.multi_actor_suspected` — cross-primitive correlator + * output (Phase 5): >= 2 primitives flagged + * multi_actor on the same identity. */ import { useEffect, useRef } from 'react'; @@ -36,11 +45,39 @@ export interface SnapshotFrame { observations: ObservationFrame[]; } +export type AttributionState = + | 'unknown' + | 'stable' + | 'drifting' + | 'conflicted' + | 'multi_actor'; + +export interface AttributionStateChangedFrame { + identity_uuid: string; + primitive: string; + old_state: AttributionState | null; + new_state: AttributionState; + current_value: unknown; + confidence: number; + observation_count: number; + ts: number; +} + +export interface AttributionMultiActorFrame { + identity_uuid: string; + primitives: string[]; + evidence_summary: string; + confidence: number; + ts: number; +} + export type AttackerStreamEventName = | 'snapshot' | 'observation' | 'fingerprint.rotated' - | 'attacker.scored'; + | 'attacker.scored' + | 'attribution.state_changed' + | 'attribution.multi_actor_suspected'; export interface AttackerStreamEvent { name: AttackerStreamEventName | string; @@ -57,6 +94,8 @@ export interface UseAttackerStreamOptions { onObservation?: (data: ObservationFrame) => void; onFingerprintRotated?: (data: Record) => void; onScored?: (data: Record) => void; + onAttributionStateChanged?: (data: AttributionStateChangedFrame) => void; + onMultiActorSuspected?: (data: AttributionMultiActorFrame) => void; onError?: () => void; } @@ -65,6 +104,8 @@ const NAMED_EVENTS: AttackerStreamEventName[] = [ 'observation', 'fingerprint.rotated', 'attacker.scored', + 'attribution.state_changed', + 'attribution.multi_actor_suspected', ]; const RECONNECT_MS = 3000; @@ -76,6 +117,8 @@ export function useAttackerStream({ onObservation, onFingerprintRotated, onScored, + onAttributionStateChanged, + onMultiActorSuspected, onError, }: UseAttackerStreamOptions): void { const esRef = useRef(null); @@ -84,11 +127,15 @@ export function useAttackerStream({ const onObservationRef = useRef(onObservation); const onFingerprintRotatedRef = useRef(onFingerprintRotated); const onScoredRef = useRef(onScored); + const onAttributionStateChangedRef = useRef(onAttributionStateChanged); + const onMultiActorSuspectedRef = useRef(onMultiActorSuspected); const onErrorRef = useRef(onError); useEffect(() => { onSnapshotRef.current = onSnapshot; }, [onSnapshot]); useEffect(() => { onObservationRef.current = onObservation; }, [onObservation]); useEffect(() => { onFingerprintRotatedRef.current = onFingerprintRotated; }, [onFingerprintRotated]); useEffect(() => { onScoredRef.current = onScored; }, [onScored]); + useEffect(() => { onAttributionStateChangedRef.current = onAttributionStateChanged; }, [onAttributionStateChanged]); + useEffect(() => { onMultiActorSuspectedRef.current = onMultiActorSuspected; }, [onMultiActorSuspected]); useEffect(() => { onErrorRef.current = onError; }, [onError]); useEffect(() => { @@ -125,6 +172,16 @@ export function useAttackerStream({ case 'attacker.scored': onScoredRef.current?.(payload); break; + case 'attribution.state_changed': + onAttributionStateChangedRef.current?.( + payload as unknown as AttributionStateChangedFrame, + ); + break; + case 'attribution.multi_actor_suspected': + onMultiActorSuspectedRef.current?.( + payload as unknown as AttributionMultiActorFrame, + ); + break; } };