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 SessionDrawer from './SessionDrawer';
|
||||||
import EmptyState from './EmptyState/EmptyState';
|
import EmptyState from './EmptyState/EmptyState';
|
||||||
import TTPsObservedSection from './TTPsObservedSection';
|
import TTPsObservedSection from './TTPsObservedSection';
|
||||||
import { useIdentityStream } from './useIdentityStream';
|
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
|
||||||
import {
|
import type {
|
||||||
useAttackerStream,
|
AttackerData,
|
||||||
type ObservationFrame,
|
AttackerBehavior,
|
||||||
type AttributionStateChangedFrame,
|
BehaviouralObservation,
|
||||||
type AttributionMultiActorFrame,
|
AttributionPrimitiveState,
|
||||||
} from './useAttackerStream';
|
} from './AttackerDetail/types';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
|
|
||||||
interface AttackerBehavior {
|
// Re-export the types historically exposed from this module so external
|
||||||
os_guess: string | null;
|
// importers (tests, future siblings) keep their import paths stable
|
||||||
hop_distance: number | null;
|
// while the canonical definitions live in ./AttackerDetail/types.
|
||||||
tcp_fingerprint: {
|
export type { BehaviouralObservation, AttributionPrimitiveState };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ───────────────────────────────────────────────────
|
// ─── Fingerprint rendering ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1499,21 +1388,27 @@ const IntelPanel: React.FC<{ uuid: string }> = ({ uuid }) => {
|
|||||||
const AttackerDetail: React.FC = () => {
|
const AttackerDetail: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [attacker, setAttacker] = useState<AttackerData | null>(null);
|
// Data layer is owned by the hook: REST fetches, attribution table,
|
||||||
// Live behavioural-primitive state. Seeded from
|
// and per-attacker / per-identity SSE streams all live there.
|
||||||
// attacker.observations on first fetch; mutated in place by the
|
const {
|
||||||
// useAttackerStream hook below (latest-wins per primitive).
|
attacker,
|
||||||
const [observations, setObservations] = useState<BehaviouralObservation[]>([]);
|
observations,
|
||||||
// Attribution-engine state per primitive. Seeded from
|
attribution,
|
||||||
// GET /attackers/{id}/attribution on mount; live-updated via
|
loading,
|
||||||
// attribution.state_changed SSE frames. Map keyed on primitive
|
error,
|
||||||
// for O(1) badge lookup in the panel.
|
commands,
|
||||||
const [attribution, setAttribution] = useState<Map<string, AttributionPrimitiveState>>(
|
cmdTotal,
|
||||||
() => new Map(),
|
cmdPage,
|
||||||
);
|
setCmdPage,
|
||||||
const [loading, setLoading] = useState(true);
|
serviceFilter,
|
||||||
const [error, setError] = useState<string | null>(null);
|
setServiceFilter,
|
||||||
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
cmdLimit,
|
||||||
|
artifacts,
|
||||||
|
smtpTargets,
|
||||||
|
mail,
|
||||||
|
mailForbidden,
|
||||||
|
sessions,
|
||||||
|
} = useAttackerDetail(id);
|
||||||
|
|
||||||
// Section collapse state
|
// Section collapse state
|
||||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||||
@@ -1531,271 +1426,13 @@ const AttackerDetail: React.FC = () => {
|
|||||||
mail: true,
|
mail: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Captured file-drop artifacts (ssh inotify farm) for this attacker.
|
// Drawer selection (ephemeral UI; data feeds come from the hook).
|
||||||
type ArtifactLog = {
|
|
||||||
id: number;
|
|
||||||
timestamp: string;
|
|
||||||
decky: string;
|
|
||||||
service: string;
|
|
||||||
fields: string; // JSON-encoded SD params (parsed lazily below)
|
|
||||||
};
|
|
||||||
const [artifacts, setArtifacts] = useState<ArtifactLog[]>([]);
|
|
||||||
const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
|
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);
|
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 [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
|
||||||
|
|
||||||
const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<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