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.
This commit is contained in:
2026-05-09 04:36:35 -04:00
parent 07a7d4918c
commit 22cfb10617
4 changed files with 647 additions and 396 deletions

View File

@@ -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<string, string>;
};
}>;
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<AttackerData | null>(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<BehaviouralObservation[]>([]);
// 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<Map<string, AttributionPrimitiveState>>(
() => new Map(),
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [serviceFilter, setServiceFilter] = useState<string | null>(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<Record<string, boolean>>({
@@ -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<ArtifactLog[]>([]);
// Drawer selection (ephemeral UI; data feeds come from the hook).
const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | 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<SessionLog[]>([]);
const [session, setSession] = useState<{ decky: string; sid: string; fields: Record<string, any> } | 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<SmtpTargetRow[]>([]);
// 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<MailLog[]>([]);
const [mailForbidden, setMailForbidden] = useState(false);
const [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
// Commands pagination state
const [commands, setCommands] = useState<AttackerData['commands']>([]);
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<string, AttributionPrimitiveState>();
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<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);
// 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 (
<div className="dashboard">

View File

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

View File

@@ -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([]);
});
});

View File

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