diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 18670e0d..d11ca33e 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -7,131 +7,20 @@ import MailDrawer from './MailDrawer'; import SessionDrawer from './SessionDrawer'; import EmptyState from './EmptyState/EmptyState'; import TTPsObservedSection from './TTPsObservedSection'; -import { useIdentityStream } from './useIdentityStream'; -import { - useAttackerStream, - type ObservationFrame, - type AttributionStateChangedFrame, - type AttributionMultiActorFrame, -} from './useAttackerStream'; +import { useAttackerDetail } from './AttackerDetail/useAttackerDetail'; +import type { + AttackerData, + AttackerBehavior, + BehaviouralObservation, + AttributionPrimitiveState, +} from './AttackerDetail/types'; import './Dashboard.css'; -interface AttackerBehavior { - os_guess: string | null; - hop_distance: number | null; - tcp_fingerprint: { - window?: number | null; - wscale?: number | null; - mss?: number | null; - options_sig?: string; - has_sack?: boolean; - has_timestamps?: boolean; - tos?: number | null; - dscp?: number | null; - ecn?: number | null; - ipid_class?: string | null; - isn_class?: string | null; - } | null; - retransmit_count: number; - behavior_class: string | null; - beacon_interval_s: number | null; - beacon_jitter_pct: number | null; - tool_guesses: string[] | null; - timing_stats: { - event_count?: number; - duration_s?: number; - mean_iat_s?: number | null; - median_iat_s?: number | null; - stdev_iat_s?: number | null; - min_iat_s?: number | null; - max_iat_s?: number | null; - cv?: number | null; - } | null; - phase_sequence: { - recon_end_ts?: string | null; - exfil_start_ts?: string | null; - exfil_latency_s?: number | null; - large_payload_count?: number; - } | null; - updated_at?: string; -} +// Re-export the types historically exposed from this module so external +// importers (tests, future siblings) keep their import paths stable +// while the canonical definitions live in ./AttackerDetail/types. +export type { BehaviouralObservation, AttributionPrimitiveState }; -interface AttackerData { - uuid: string; - ip: string; - // Resolved identity FK. NULL while the clusterer hasn't run on this - // observation yet, or hasn't seen enough stable signal (JA3, HASSH, - // payload hash, C2 callback) to claim a same-hands match. See - // development/IDENTITY_RESOLUTION.md. - identity_id?: string | null; - first_seen: string; - last_seen: string; - event_count: number; - service_count: number; - decky_count: number; - services: string[]; - deckies: string[]; - traversal_path: string | null; - is_traversal: boolean; - bounty_count: number; - credential_count: number; - fingerprints: any[]; - commands: { service: string; decky: string; command: string; timestamp: string }[]; - country_code: string | null; - country_source: string | null; - asn: number | null; - as_name: string | null; - asn_source: string | null; - ptr_record: string | null; - updated_at: string; - behavior: AttackerBehavior | null; - service_activity?: { - interacted: string[]; - scanned: string[]; - }; - ip_leaks?: Array<{ - timestamp: string; - decky?: string; - service?: string; - bounty_type: string; - payload: { - source_ip?: string; - real_ip_claim?: string; - source_header?: string; - headers_seen?: Record; - }; - }>; - ip_leaks_total?: number; - // BEHAVE-SHELL behavioural primitives — latest value per primitive - // for this attacker. The REST `/api/v1/attackers/{uuid}` route - // returns this field; the SSE `/events` stream live-updates it via - // useAttackerStream. Empty array until the profiler worker has - // processed at least one session shard for this attacker. - observations?: BehaviouralObservation[]; -} - -export interface BehaviouralObservation { - primitive: string; - value: unknown; - confidence: number; - ts?: number; - 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 ─────────────────────────────────────────────────── @@ -1499,21 +1388,27 @@ const IntelPanel: React.FC<{ uuid: string }> = ({ uuid }) => { const AttackerDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [attacker, setAttacker] = useState(null); - // Live behavioural-primitive state. Seeded from - // 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); + // Data layer is owned by the hook: REST fetches, attribution table, + // and per-attacker / per-identity SSE streams all live there. + const { + attacker, + observations, + attribution, + loading, + error, + commands, + cmdTotal, + cmdPage, + setCmdPage, + serviceFilter, + setServiceFilter, + cmdLimit, + artifacts, + smtpTargets, + mail, + mailForbidden, + sessions, + } = useAttackerDetail(id); // Section collapse state const [openSections, setOpenSections] = useState>({ @@ -1531,271 +1426,13 @@ const AttackerDetail: React.FC = () => { mail: true, }); - // Captured file-drop artifacts (ssh inotify farm) for this attacker. - type ArtifactLog = { - id: number; - timestamp: string; - decky: string; - service: string; - fields: string; // JSON-encoded SD params (parsed lazily below) - }; - const [artifacts, setArtifacts] = useState([]); + // Drawer selection (ephemeral UI; data feeds come from the hook). const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); - - // PTY session transcripts (sessrec) for this attacker. - type SessionLog = { - id: number; - timestamp: string; - decky: string; - service: string; - fields: string; - }; - const [sessions, setSessions] = useState([]); const [session, setSession] = useState<{ decky: string; sid: string; fields: Record } | null>(null); - - // SMTP victim-domain rollup (viewer-safe: domains only, no local parts). - type SmtpTargetRow = { - domain: string; - count: number; - first_seen: string; - last_seen: string; - }; - const [smtpTargets, setSmtpTargets] = useState([]); - - // Stored SMTP messages (admin-gated: full attacker-controlled bodies). - type MailLog = { - id: number; - timestamp: string; - decky: string; - service: string; - fields: string; - }; - const [mail, setMail] = useState([]); - const [mailForbidden, setMailForbidden] = useState(false); const [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); - // Commands pagination state - const [commands, setCommands] = useState([]); - const [cmdTotal, setCmdTotal] = useState(0); - const [cmdPage, setCmdPage] = useState(1); - const cmdLimit = 50; - - useEffect(() => { - const fetchAttacker = async () => { - setLoading(true); - try { - const res = await api.get(`/attackers/${id}`); - setAttacker(res.data); - setObservations(res.data?.observations ?? []); - } catch (err: any) { - if (err.response?.status === 404) { - setError('ATTACKER NOT FOUND'); - } else { - setError('FAILED TO LOAD ATTACKER PROFILE'); - } - } finally { - setLoading(false); - } - }; - 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. - useIdentityStream({ - enabled: !!id, - onEvent: (ev) => { - if (!id) return; - const payload = ev.payload || {}; - const refs = new Set(); - const addUuid = (v: unknown) => { - if (typeof v === 'string') refs.add(v); - }; - addUuid(payload.observation_uuid); - const obsList = payload.observation_uuids; - if (Array.isArray(obsList)) obsList.forEach(addUuid); - // merge / unmerge events carry identity uuids, not observation - // uuids — but if the current attacker's identity_id matches any - // of them, we still want to refresh so the badge link follows. - addUuid(payload.identity_uuid); - addUuid(payload.winner_uuid); - addUuid(payload.loser_uuid); - addUuid(payload.resurrected_uuid); - addUuid(payload.former_winner_uuid); - - const myIdentity = attacker?.identity_id; - if (refs.has(id) || (myIdentity && refs.has(myIdentity))) { - api.get(`/attackers/${id}`) - .then((res) => setAttacker(res.data)) - .catch(() => {}); - } - }, - }); - - // Live behavioural-primitive updates: subscribe to per-attacker - // SSE and replace-by-primitive on every observation event. - useAttackerStream({ - attackerUuid: id ?? '', - enabled: !!id, - onSnapshot: (data) => { - setObservations(data.observations ?? []); - }, - onObservation: (frame: ObservationFrame) => { - setObservations((prev) => { - const filtered = prev.filter((o) => o.primitive !== frame.primitive); - return [ - ...filtered, - { - primitive: frame.primitive, - value: frame.value, - confidence: frame.confidence, - ts: frame.ts, - source: frame.source, - }, - ]; - }); - }, - // 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(() => { - if (!id) return; - const fetchCommands = async () => { - try { - const offset = (cmdPage - 1) * cmdLimit; - let url = `/attackers/${id}/commands?limit=${cmdLimit}&offset=${offset}`; - if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`; - const res = await api.get(url); - setCommands(res.data.data); - setCmdTotal(res.data.total); - } catch (err: any) { - if (err.response?.status === 422) { - alert("Fuck off."); - } - setCommands([]); - setCmdTotal(0); - } - }; - fetchCommands(); - }, [id, cmdPage, serviceFilter]); - - // Reset command page when service filter changes - useEffect(() => { - setCmdPage(1); - }, [serviceFilter]); - - useEffect(() => { - if (!id) return; - const fetchArtifacts = async () => { - try { - const res = await api.get(`/attackers/${id}/artifacts`); - setArtifacts(res.data.data ?? []); - } catch { - setArtifacts([]); - } - }; - fetchArtifacts(); - }, [id]); - - useEffect(() => { - if (!id) return; - const fetchSmtpTargets = async () => { - try { - const res = await api.get(`/attackers/${id}/smtp-targets`); - setSmtpTargets(res.data.data ?? []); - } catch { - setSmtpTargets([]); - } - }; - fetchSmtpTargets(); - }, [id]); - - useEffect(() => { - if (!id) return; - const fetchMail = async () => { - try { - const res = await api.get(`/attackers/${id}/mail`); - setMail(res.data.data ?? []); - setMailForbidden(false); - } catch (err: any) { - setMail([]); - setMailForbidden(err?.response?.status === 403); - } - }; - fetchMail(); - }, [id]); - - useEffect(() => { - if (!id) return; - const fetchSessions = async () => { - try { - const res = await api.get(`/attackers/${id}/transcripts`); - setSessions(res.data.data ?? []); - } catch { - setSessions([]); - } - }; - fetchSessions(); - }, [id]); - if (loading) { return (
diff --git a/decnet_web/src/components/AttackerDetail/types.ts b/decnet_web/src/components/AttackerDetail/types.ts new file mode 100644 index 00000000..b97f7319 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/types.ts @@ -0,0 +1,147 @@ +/** Shared types for the AttackerDetail page surface. The canonical + * definitions live here; AttackerDetail.tsx re-exports the public + * ones (BehaviouralObservation, AttributionPrimitiveState) so + * external importers stay stable through the refactor. */ + +export interface AttackerBehavior { + os_guess: string | null; + hop_distance: number | null; + tcp_fingerprint: { + window?: number | null; + wscale?: number | null; + mss?: number | null; + options_sig?: string; + has_sack?: boolean; + has_timestamps?: boolean; + tos?: number | null; + dscp?: number | null; + ecn?: number | null; + ipid_class?: string | null; + isn_class?: string | null; + } | null; + retransmit_count: number; + behavior_class: string | null; + beacon_interval_s: number | null; + beacon_jitter_pct: number | null; + tool_guesses: string[] | null; + timing_stats: { + event_count?: number; + duration_s?: number; + mean_iat_s?: number | null; + median_iat_s?: number | null; + stdev_iat_s?: number | null; + min_iat_s?: number | null; + max_iat_s?: number | null; + cv?: number | null; + } | null; + phase_sequence: { + recon_end_ts?: string | null; + exfil_start_ts?: string | null; + exfil_latency_s?: number | null; + large_payload_count?: number; + } | null; + updated_at?: string; +} + +export interface CommandRow { + service: string; + decky: string; + command: string; + timestamp: string; +} + +export interface AttackerData { + uuid: string; + ip: string; + identity_id?: string | null; + first_seen: string; + last_seen: string; + event_count: number; + service_count: number; + decky_count: number; + services: string[]; + deckies: string[]; + traversal_path: string | null; + is_traversal: boolean; + bounty_count: number; + credential_count: number; + // Heterogeneous fingerprint blobs — schema varies per fp_type and + // is rendered by the per-type Fp* components in AttackerDetail.tsx. + fingerprints: unknown[]; + commands: CommandRow[]; + country_code: string | null; + country_source: string | null; + asn: number | null; + as_name: string | null; + asn_source: string | null; + ptr_record: string | null; + updated_at: string; + behavior: AttackerBehavior | null; + service_activity?: { + interacted: string[]; + scanned: string[]; + }; + ip_leaks?: Array<{ + timestamp: string; + decky?: string; + service?: string; + bounty_type: string; + payload: { + source_ip?: string; + real_ip_claim?: string; + source_header?: string; + headers_seen?: Record; + }; + }>; + ip_leaks_total?: number; + observations?: BehaviouralObservation[]; +} + +export interface BehaviouralObservation { + primitive: string; + value: unknown; + confidence: number; + ts?: number; + source?: string; +} + +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; +} + +export interface ArtifactLog { + id: number; + timestamp: string; + decky: string; + service: string; + fields: string; +} + +export interface SessionLog { + id: number; + timestamp: string; + decky: string; + service: string; + fields: string; +} + +export interface SmtpTargetRow { + domain: string; + count: number; + first_seen: string; + last_seen: string; +} + +export interface MailLog { + id: number; + timestamp: string; + decky: string; + service: string; + fields: string; +} diff --git a/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts b/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts new file mode 100644 index 00000000..43a710f6 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/useAttackerDetail.test.ts @@ -0,0 +1,155 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse, server, apiUrl } from '../../test/server'; +import { makeAttacker } from '../../test/fixtures'; + +// Stub the SSE hooks — they would otherwise open EventSource connections +// jsdom can't serve. The hook under test only cares that the callbacks +// it passes are wired up, not what they receive in isolation. +vi.mock('../useAttackerStream', () => ({ + useAttackerStream: vi.fn(), +})); +vi.mock('../useIdentityStream', () => ({ + useIdentityStream: vi.fn(), +})); + +// Suppress the alert() the hook fires on a 422 command-filter response. +beforeEach(() => { + vi.stubGlobal('alert', vi.fn()); +}); + +import { useAttackerDetail } from './useAttackerDetail'; + +const ID = '11111111-1111-1111-1111-111111111111'; + +const attackerHandler = (body: unknown, status = 200) => + http.get(apiUrl(`/attackers/${ID}`), () => + HttpResponse.json(body, { status }), + ); + +const stockHandlers = () => [ + attackerHandler(makeAttacker()), + http.get(apiUrl(`/attackers/${ID}/attribution`), () => + HttpResponse.json({ primitives: [] }), + ), + http.get(apiUrl(`/attackers/${ID}/commands`), ({ request }) => { + const url = new URL(request.url); + const offset = Number(url.searchParams.get('offset') ?? 0); + return HttpResponse.json({ + data: [{ + service: 'ssh', + decky: 'decoy-01', + command: `cmd-offset-${offset}`, + timestamp: '2026-05-09T11:00:00Z', + }], + total: 137, + }); + }), + http.get(apiUrl(`/attackers/${ID}/artifacts`), () => + HttpResponse.json({ data: [] }), + ), + http.get(apiUrl(`/attackers/${ID}/smtp-targets`), () => + HttpResponse.json({ data: [] }), + ), + http.get(apiUrl(`/attackers/${ID}/mail`), () => + HttpResponse.json({ data: [] }), + ), + http.get(apiUrl(`/attackers/${ID}/transcripts`), () => + HttpResponse.json({ data: [] }), + ), +]; + +describe('useAttackerDetail', () => { + it('loads attacker data and clears loading on success', async () => { + server.use(...stockHandlers()); + + const { result } = renderHook(() => useAttackerDetail(ID)); + + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBeNull(); + expect(result.current.attacker?.uuid).toBe(ID); + expect(result.current.cmdTotal).toBe(137); + expect(result.current.commands[0]?.command).toBe('cmd-offset-0'); + }); + + it('reports ATTACKER NOT FOUND on 404', async () => { + server.use( + attackerHandler({ detail: 'not found' }, 404), + // remaining handlers degrade gracefully + http.get(apiUrl(`/attackers/${ID}/attribution`), () => + HttpResponse.json({ primitives: [] }), + ), + http.get(apiUrl(`/attackers/${ID}/commands`), () => + HttpResponse.json({ data: [], total: 0 }), + ), + http.get(apiUrl(`/attackers/${ID}/artifacts`), () => + HttpResponse.json({ data: [] }), + ), + http.get(apiUrl(`/attackers/${ID}/smtp-targets`), () => + HttpResponse.json({ data: [] }), + ), + http.get(apiUrl(`/attackers/${ID}/mail`), () => + HttpResponse.json({ data: [] }), + ), + http.get(apiUrl(`/attackers/${ID}/transcripts`), () => + HttpResponse.json({ data: [] }), + ), + ); + + const { result } = renderHook(() => useAttackerDetail(ID)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('ATTACKER NOT FOUND'); + expect(result.current.attacker).toBeNull(); + }); + + it('refetches commands on cmdPage change with paged offset', async () => { + server.use(...stockHandlers()); + + const { result } = renderHook(() => useAttackerDetail(ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.commands[0]?.command).toBe('cmd-offset-0'); + + act(() => result.current.setCmdPage(3)); + + await waitFor(() => + expect(result.current.commands[0]?.command).toBe('cmd-offset-100'), + ); + expect(result.current.cmdPage).toBe(3); + }); + + it('resets cmdPage to 1 when serviceFilter changes', async () => { + server.use(...stockHandlers()); + + const { result } = renderHook(() => useAttackerDetail(ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.setCmdPage(4)); + await waitFor(() => expect(result.current.cmdPage).toBe(4)); + + act(() => result.current.setServiceFilter('ssh')); + await waitFor(() => expect(result.current.cmdPage).toBe(1)); + expect(result.current.serviceFilter).toBe('ssh'); + }); + + it('flags mailForbidden on 403', async () => { + server.use( + ...stockHandlers().filter( + (h) => !h.info.path.endsWith('/mail'), + ), + http.get(apiUrl(`/attackers/${ID}/mail`), () => + HttpResponse.json({ detail: 'forbidden' }, { status: 403 }), + ), + ); + + const { result } = renderHook(() => useAttackerDetail(ID)); + + await waitFor(() => expect(result.current.mailForbidden).toBe(true)); + expect(result.current.mail).toEqual([]); + }); +}); diff --git a/decnet_web/src/components/AttackerDetail/useAttackerDetail.ts b/decnet_web/src/components/AttackerDetail/useAttackerDetail.ts new file mode 100644 index 00000000..c89b4bba --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/useAttackerDetail.ts @@ -0,0 +1,312 @@ +import { useEffect, useState } from 'react'; +import api from '../../utils/api'; +import { useIdentityStream } from '../useIdentityStream'; +import { + useAttackerStream, + type ObservationFrame, + type AttributionStateChangedFrame, + type AttributionMultiActorFrame, +} from '../useAttackerStream'; +import type { + AttackerData, + BehaviouralObservation, + AttributionPrimitiveState, + ArtifactLog, + SessionLog, + SmtpTargetRow, + MailLog, + CommandRow, +} from './types'; + +export const COMMAND_PAGE_SIZE = 50; + +export interface UseAttackerDetailResult { + attacker: AttackerData | null; + observations: BehaviouralObservation[]; + attribution: Map; + loading: boolean; + error: string | null; + + // Commands paging + commands: CommandRow[]; + cmdTotal: number; + cmdPage: number; + setCmdPage: (n: number) => void; + serviceFilter: string | null; + setServiceFilter: (s: string | null) => void; + cmdLimit: number; + + // Auxiliary feeds + artifacts: ArtifactLog[]; + smtpTargets: SmtpTargetRow[]; + mail: MailLog[]; + mailForbidden: boolean; + sessions: SessionLog[]; +} + +interface ApiErrorLike { + response?: { status?: number }; +} + +const isApiError = (e: unknown): e is ApiErrorLike => + typeof e === 'object' && e !== null && 'response' in e; + +/** Owns every read-side data flow for the AttackerDetail page — + * REST fetches plus the per-attacker and per-identity SSE streams. + * Section components consume the returned values; none of them + * open their own connections. */ +export function useAttackerDetail(id: string | undefined): UseAttackerDetailResult { + const [attacker, setAttacker] = useState(null); + const [observations, setObservations] = useState([]); + const [attribution, setAttribution] = useState>( + () => new Map(), + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [serviceFilter, setServiceFilter] = useState(null); + const [commands, setCommands] = useState([]); + const [cmdTotal, setCmdTotal] = useState(0); + const [cmdPage, setCmdPage] = useState(1); + const cmdLimit = COMMAND_PAGE_SIZE; + + const [artifacts, setArtifacts] = useState([]); + const [smtpTargets, setSmtpTargets] = useState([]); + const [mail, setMail] = useState([]); + const [mailForbidden, setMailForbidden] = useState(false); + const [sessions, setSessions] = useState([]); + + // Primary attacker fetch. + useEffect(() => { + if (!id) return; + let cancelled = false; + setLoading(true); + (async () => { + try { + const res = await api.get(`/attackers/${id}`); + if (cancelled) return; + setAttacker(res.data); + setObservations(res.data?.observations ?? []); + setError(null); + } catch (err: unknown) { + if (cancelled) return; + if (isApiError(err) && err.response?.status === 404) { + setError('ATTACKER NOT FOUND'); + } else { + setError('FAILED TO LOAD ATTACKER PROFILE'); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [id]); + + // Attribution table; tolerated 404/network failure (worker may be off). + 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 { + // optional endpoint + } + })(); + return () => { cancelled = true; }; + }, [id]); + + // Identity-event refresh: re-fetch attacker row when an identity + // event references either this attacker uuid or its bound identity. + useIdentityStream({ + enabled: !!id, + onEvent: (ev) => { + if (!id) return; + const payload = ev.payload || {}; + const refs = new Set(); + const addUuid = (v: unknown) => { + if (typeof v === 'string') refs.add(v); + }; + addUuid(payload.observation_uuid); + const obsList = payload.observation_uuids; + if (Array.isArray(obsList)) obsList.forEach(addUuid); + addUuid(payload.identity_uuid); + addUuid(payload.winner_uuid); + addUuid(payload.loser_uuid); + addUuid(payload.resurrected_uuid); + addUuid(payload.former_winner_uuid); + + const myIdentity = attacker?.identity_id; + if (refs.has(id) || (myIdentity && refs.has(myIdentity))) { + api.get(`/attackers/${id}`) + .then((res) => setAttacker(res.data)) + .catch(() => {}); + } + }, + }); + + // Per-attacker live behaviour + attribution updates. + useAttackerStream({ + attackerUuid: id ?? '', + enabled: !!id, + onSnapshot: (data) => { + setObservations(data.observations ?? []); + }, + onObservation: (frame: ObservationFrame) => { + setObservations((prev) => { + const filtered = prev.filter((o) => o.primitive !== frame.primitive); + return [ + ...filtered, + { + primitive: frame.primitive, + value: frame.value, + confidence: frame.confidence, + ts: frame.ts, + source: frame.source, + }, + ]; + }); + }, + 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: frame.ts, + last_observation_ts: frame.ts, + ...(prior && prior.state === frame.new_state + ? { last_change_ts: prior.last_change_ts } + : {}), + }); + return next; + }); + }, + onMultiActorSuspected: (_frame: AttributionMultiActorFrame) => { + // Cross-primitive escalation is a SIEM-channel signal. + // Listener is wired so a future banner has a live source. + }, + }); + + // Paged command list — re-fetches on page or filter change. + useEffect(() => { + if (!id) return; + let cancelled = false; + (async () => { + try { + const offset = (cmdPage - 1) * cmdLimit; + let url = `/attackers/${id}/commands?limit=${cmdLimit}&offset=${offset}`; + if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`; + const res = await api.get(url); + if (cancelled) return; + setCommands(res.data.data); + setCmdTotal(res.data.total); + } catch (err: unknown) { + if (cancelled) return; + if (isApiError(err) && err.response?.status === 422) { + // Backend gate hit a malformed filter; surface loudly so a + // user typo (e.g. unknown service) is visible immediately. + alert('Fuck off.'); + } + setCommands([]); + setCmdTotal(0); + } + })(); + return () => { cancelled = true; }; + }, [id, cmdPage, serviceFilter, cmdLimit]); + + // Reset to page 1 whenever filter flips. + useEffect(() => { + setCmdPage(1); + }, [serviceFilter]); + + // Static auxiliary feeds — single-shot per id. + useEffect(() => { + if (!id) return; + let cancelled = false; + (async () => { + try { + const res = await api.get(`/attackers/${id}/artifacts`); + if (!cancelled) setArtifacts(res.data.data ?? []); + } catch { + if (!cancelled) setArtifacts([]); + } + })(); + return () => { cancelled = true; }; + }, [id]); + + useEffect(() => { + if (!id) return; + let cancelled = false; + (async () => { + try { + const res = await api.get(`/attackers/${id}/smtp-targets`); + if (!cancelled) setSmtpTargets(res.data.data ?? []); + } catch { + if (!cancelled) setSmtpTargets([]); + } + })(); + return () => { cancelled = true; }; + }, [id]); + + useEffect(() => { + if (!id) return; + let cancelled = false; + (async () => { + try { + const res = await api.get(`/attackers/${id}/mail`); + if (cancelled) return; + setMail(res.data.data ?? []); + setMailForbidden(false); + } catch (err: unknown) { + if (cancelled) return; + setMail([]); + setMailForbidden(isApiError(err) && err.response?.status === 403); + } + })(); + return () => { cancelled = true; }; + }, [id]); + + useEffect(() => { + if (!id) return; + let cancelled = false; + (async () => { + try { + const res = await api.get(`/attackers/${id}/transcripts`); + if (!cancelled) setSessions(res.data.data ?? []); + } catch { + if (!cancelled) setSessions([]); + } + })(); + return () => { cancelled = true; }; + }, [id]); + + return { + attacker, + observations, + attribution, + loading, + error, + commands, + cmdTotal, + cmdPage, + setCmdPage, + serviceFilter, + setServiceFilter, + cmdLimit, + artifacts, + smtpTargets, + mail, + mailForbidden, + sessions, + }; +}