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:
@@ -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">
|
||||
|
||||
147
decnet_web/src/components/AttackerDetail/types.ts
Normal file
147
decnet_web/src/components/AttackerDetail/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
312
decnet_web/src/components/AttackerDetail/useAttackerDetail.ts
Normal file
312
decnet_web/src/components/AttackerDetail/useAttackerDetail.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user