diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index c8f0757e..a56ad61e 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -19,6 +19,7 @@ const LiveLogs = lazy(() => import('./components/LiveLogs')); const Webhooks = lazy(() => import('./components/Webhooks')); const Attackers = lazy(() => import('./components/Attackers')); const AttackerDetail = lazy(() => import('./components/AttackerDetail')); +const IdentityDetail = lazy(() => import('./components/IdentityDetail')); const Config = lazy(() => import('./components/Config')); const Bounty = lazy(() => import('./components/Bounty')); const Credentials = lazy(() => import('./components/Credentials')); @@ -113,6 +114,7 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 6a426bc5..ef972fc8 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -46,6 +46,11 @@ interface AttackerBehavior { 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; @@ -1405,6 +1410,24 @@ const AttackerDetail: React.FC = () => { {attacker.is_traversal && ( TRAVERSAL )} + {/* Conditional Identity badge — surfaces only when the clusterer + has linked this observation to a resolved actor identity. + Zero behavior change when identity_id is null (which is + uniformly true until the clusterer ships). */} + {attacker.identity_id && ( + navigate(`/identities/${attacker.identity_id}`)} + > + IDENTITY · {attacker.identity_id.slice(0, 8)} + + )} {/* Stats Row */} diff --git a/decnet_web/src/components/IdentityDetail.tsx b/decnet_web/src/components/IdentityDetail.tsx new file mode 100644 index 00000000..cb05170a --- /dev/null +++ b/decnet_web/src/components/IdentityDetail.tsx @@ -0,0 +1,307 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ArrowLeft, Crosshair, Fingerprint, Globe, Radio } from '../icons'; +import api from '../utils/api'; +import './Dashboard.css'; + +/* + * IdentityDetail — read-only view of a resolved attacker identity. + * + * The clusterer worker that populates these rows is a separate + * downstream effort; until it ships, /identities/* responses are + * empty and this page renders the not-found state. See + * development/IDENTITY_RESOLUTION.md. + * + * The page is intentionally narrow at v1: header (uuid + campaign + * link if assigned), aggregated stats (observation count, fingerprint + * counts), and a list of linked observations that link back to + * AttackerDetail. Bigger surfaces (intel summary, kd_digraph_simhash + * neighbors, federation gossip status) ship after the clusterer + * lands and there's data to render. + */ + +interface IdentityData { + uuid: string; + schema_version: number; + campaign_id: string | null; + first_seen_at: string | null; + last_seen_at: string | null; + created_at: string; + updated_at: string; + confidence: number | null; + observation_count: number; + observation_count_live: number; + ja3_hashes: string | null; + hassh_hashes: string | null; + payload_simhashes: string | null; + c2_endpoints: string | null; + kd_digraph_simhash: string | null; + merged_into_uuid: string | null; + notes: string | null; +} + +interface ObservationRow { + uuid: string; + ip: string; + first_seen: string; + last_seen: string; + event_count: number; + asn?: number | null; + country_code?: string | null; +} + +const safeParseJsonList = (raw: string | null): string[] => { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +const IdentityDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [identity, setIdentity] = useState(null); + const [observations, setObservations] = useState([]); + const [observationTotal, setObservationTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + const fetchIdentity = async () => { + setLoading(true); + try { + const res = await api.get(`/identities/${id}`); + setIdentity(res.data); + setError(null); + } catch (err: any) { + if (err.response?.status === 404) { + setError('IDENTITY NOT FOUND'); + } else { + setError('FAILED TO LOAD IDENTITY'); + } + } finally { + setLoading(false); + } + }; + fetchIdentity(); + }, [id]); + + useEffect(() => { + if (!id) return; + const fetchObservations = async () => { + try { + const res = await api.get(`/identities/${id}/observations?limit=50&offset=0`); + setObservations(res.data.data ?? []); + setObservationTotal(res.data.total ?? 0); + } catch { + setObservations([]); + setObservationTotal(0); + } + }; + fetchObservations(); + }, [id]); + + if (loading) { + return ( +
+
+ LOADING IDENTITY… +
+
+ ); + } + + if (error || !identity) { + return ( +
+ +
+ {error || 'IDENTITY NOT FOUND'} +
+
+ ); + } + + const ja3List = safeParseJsonList(identity.ja3_hashes); + const hasshList = safeParseJsonList(identity.hassh_hashes); + const payloadList = safeParseJsonList(identity.payload_simhashes); + const c2List = safeParseJsonList(identity.c2_endpoints); + + return ( +
+ + + {/* Header */} +
+ +

+ IDENTITY · {identity.uuid} +

+ {identity.campaign_id && ( + + CAMPAIGN · {identity.campaign_id.slice(0, 8)} + + )} + {identity.merged_into_uuid && ( + navigate(`/identities/${identity.merged_into_uuid}`)} + > + MERGED INTO {identity.merged_into_uuid.slice(0, 8)} + + )} +
+ + {/* Stats row */} +
+
+
{identity.observation_count_live}
+
OBSERVATIONS
+
+
+
{ja3List.length}
+
JA3
+
+
+
{hasshList.length}
+
HASSH
+
+
+
{payloadList.length}
+
PAYLOADS
+
+
+
{c2List.length}
+
C2 ENDPOINTS
+
+
+ + {/* Confidence + schema version, only show if populated */} + {(identity.confidence !== null || identity.schema_version > 1) && ( +
+ {identity.confidence !== null && ( + + CONFIDENCE · {identity.confidence.toFixed(3)} + + )} + + SCHEMA · v{identity.schema_version} + +
+ )} + + {/* Fingerprint detail rows */} + {ja3List.length > 0 && ( + } label="JA3" items={ja3List} /> + )} + {hasshList.length > 0 && ( + } label="HASSH" items={hasshList} /> + )} + {c2List.length > 0 && ( + } label="C2 ENDPOINTS" items={c2List} /> + )} + + {/* Observations table */} +
+
+ +

+ OBSERVATIONS · {observationTotal} +

+
+ {observations.length === 0 ? ( +
+ No observations linked yet. The clusterer assigns observations + asynchronously; they should appear shortly after the next + clusterer pass. +
+ ) : ( + + + + + + + + + + + {observations.map((obs) => ( + navigate(`/attackers/${obs.uuid}`)} + > + + + + + + ))} + +
IPFIRST SEENLAST SEENEVENTS
{obs.ip}{obs.first_seen}{obs.last_seen}{obs.event_count}
+ )} +
+ + {identity.notes && ( +
+
+ ANALYST NOTES +
+
+ {identity.notes} +
+
+ )} +
+ ); +}; + +const FingerprintList: React.FC<{ + icon: React.ReactNode; + label: string; + items: string[]; +}> = ({ icon, label, items }) => ( +
+
+ {icon} + + {label} + +
+
+ {items.map((v) => ( + + {v} + + ))} +
+
+); + +export default IdentityDetail;