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, }; }