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.
313 lines
9.2 KiB
TypeScript
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,
|
|
};
|
|
}
|