Files
DECNET/decnet_web/src/components/AttackerDetail/useAttackerDetail.ts
anti 22cfb10617 refactor(decnet_web/AttackerDetail): extract data layer into useAttackerDetail
The AttackerDetail page body owned all 7 REST fetches plus 2 SSE
streams inline as 200+ lines of useEffect plumbing. Lift them into
a single hook so section components extracted in follow-up commits
consume typed values, not setState pairs.

- New ./AttackerDetail/types.ts holds the canonical AttackerData,
  BehaviouralObservation, AttributionPrimitiveState plus newly-named
  ArtifactLog / SessionLog / SmtpTargetRow / MailLog / CommandRow
  (previously inline anonymous types).
- New ./AttackerDetail/useAttackerDetail.ts owns:
  * GET /attackers/:id (404 -> ATTACKER NOT FOUND)
  * GET /attackers/:id/attribution (silent-tolerant)
  * GET /attackers/:id/commands paged with 422 alert preserved
  * GET /attackers/:id/{artifacts,smtp-targets,mail,transcripts}
    (mail surfaces a 403 boolean for the admin-gated viewer)
  * useAttackerStream + useIdentityStream subscriptions, including
    the live attribution-state-changed merge.
- AttackerDetail.tsx re-exports BehaviouralObservation /
  AttributionPrimitiveState so AttackerDetail.behaviour_panel.test
  and any future external importer keeps working unchanged.
- New useAttackerDetail.test.ts covers loading -> success, 404,
  paged commands offset, serviceFilter resets cmdPage, and mail 403
  via MSW handlers (the SSE hooks are vi.mock'd; jsdom can't host
  EventSource).

No behavior change for the rendered page; all 37 tests green.
2026-05-09 04:36:35 -04:00

313 lines
9.2 KiB
TypeScript

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<string, AttributionPrimitiveState>;
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<AttackerData | null>(null);
const [observations, setObservations] = useState<BehaviouralObservation[]>([]);
const [attribution, setAttribution] = useState<Map<string, AttributionPrimitiveState>>(
() => new Map(),
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
const [commands, setCommands] = useState<CommandRow[]>([]);
const [cmdTotal, setCmdTotal] = useState(0);
const [cmdPage, setCmdPage] = useState(1);
const cmdLimit = COMMAND_PAGE_SIZE;
const [artifacts, setArtifacts] = useState<ArtifactLog[]>([]);
const [smtpTargets, setSmtpTargets] = useState<SmtpTargetRow[]>([]);
const [mail, setMail] = useState<MailLog[]>([]);
const [mailForbidden, setMailForbidden] = useState(false);
const [sessions, setSessions] = useState<SessionLog[]>([]);
// 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<string, AttributionPrimitiveState>();
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<string>();
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,
};
}