diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 24baca38..17af6c2a 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -21,6 +21,7 @@ import { BehaviorHeadline, BeaconBlock, DetectedToolsBlock, PhaseSequenceBlock, TcpStackBlock, TimingStatsBlock, BehaviouralPrimitivesPanel, } from './AttackerDetail/behaviour'; +import { IntelPanel } from './AttackerDetail/IntelPanel'; import type { BehaviouralObservation, AttributionPrimitiveState, @@ -35,235 +36,6 @@ export type { BehaviouralObservation, AttributionPrimitiveState }; -// ─── Threat-Intel Panel ───────────────────────────────────────────────────── - -// Mirrors decnet/web/db/models/attacker_intel.py — server returns the row -// fields plus null gaps where a provider hasn't answered yet. We treat -// every column as optional on the wire. -type IntelRow = { - attacker_uuid: string; - attacker_ip: string; - schema_version?: number; - aggregate_verdict?: 'malicious' | 'suspicious' | 'benign' | 'unknown' | null; - greynoise_classification?: string | null; - greynoise_raw?: any; - greynoise_queried_at?: string | null; - abuseipdb_score?: number | null; - abuseipdb_raw?: any; - abuseipdb_queried_at?: string | null; - feodo_listed?: boolean | null; - feodo_raw?: any; - feodo_queried_at?: string | null; - threatfox_listed?: boolean | null; - threatfox_raw?: any; - threatfox_queried_at?: string | null; - cached_at?: string | null; - expires_at?: string | null; -}; - -const VERDICT_TONE: Record = { - malicious: { color: 'var(--alert)', label: 'MALICIOUS' }, - suspicious: { color: 'var(--warn)', label: 'SUSPICIOUS' }, - benign: { color: 'var(--ok)', label: 'BENIGN' }, - unknown: { color: 'var(--fg-4)', label: 'NO SIGNAL' }, -}; - -const fmtTs = (iso?: string | null): string => { - if (!iso) return '—'; - try { - return new Date(iso).toLocaleString(); - } catch { - return iso; - } -}; - -const ProviderRow: React.FC<{ - name: string; - queriedAt?: string | null; - detail: React.ReactNode; -}> = ({ name, queriedAt, detail }) => ( -
-
{name}
-
{detail}
-
- {queriedAt ? fmtTs(queriedAt) : 'pending'} -
-
-); - -const IntelPanel: React.FC<{ uuid: string }> = ({ uuid }) => { - const [intel, setIntel] = useState(null); - const [state, setState] = useState<'loading' | 'absent' | 'ok' | 'error'>('loading'); - - useEffect(() => { - let cancelled = false; - const load = async () => { - setState('loading'); - try { - const res = await api.get(`/attackers/${encodeURIComponent(uuid)}/intel`); - if (!cancelled) { - setIntel(res.data); - setState('ok'); - } - } catch (err: any) { - if (cancelled) return; - if (err?.response?.status === 404) { - setIntel(null); - setState('absent'); - } else { - setState('error'); - } - } - }; - load(); - return () => { cancelled = true; }; - }, [uuid]); - - if (state === 'loading') { - return ( -
- QUERYING INTEL CACHE... -
- ); - } - - if (state === 'error') { - return ( -
- FAILED TO LOAD INTEL -
- ); - } - - if (state === 'absent' || !intel) { - return ( -
- NO INTEL CACHED YET — `decnet enrich` will populate within {' '} - ~1 poll cycle of next observation. -
- ); - } - - const tone = VERDICT_TONE[intel.aggregate_verdict || 'unknown']; - - return ( -
-
- - - {tone.label} - - - aggregate verdict - -
- cached {fmtTs(intel.cached_at)} - expires {fmtTs(intel.expires_at)} -
-
- - - classification: - {intel.greynoise_classification} - - - ) : ( - no answer - ) - } - /> - - - abuse confidence:{' '} - = 75 ? VERDICT_TONE.malicious.color - : intel.abuseipdb_score >= 25 ? VERDICT_TONE.suspicious.color - : VERDICT_TONE.benign.color, - fontWeight: 600, - }}> - {intel.abuseipdb_score}/100 - - - ) : ( - no answer - ) - } - /> - - - known C2 - {intel.feodo_raw?.malware && ( - - ({intel.feodo_raw.malware}) - - )} - - ) : intel.feodo_listed === false ? ( - not on C2 blocklist - ) : ( - no answer - ) - } - /> - - - IOC match - {Array.isArray(intel.threatfox_raw) && intel.threatfox_raw[0]?.malware && ( - - ({intel.threatfox_raw[0].malware}) - - )} - - ) : intel.threatfox_listed === false ? ( - no IOC match - ) : ( - no answer - ) - } - /> -
- ); -}; - - // ─── Main component ───────────────────────────────────────────────────────── const AttackerDetail: React.FC = () => { diff --git a/decnet_web/src/components/AttackerDetail/IntelPanel/IntelPanel.test.tsx b/decnet_web/src/components/AttackerDetail/IntelPanel/IntelPanel.test.tsx new file mode 100644 index 00000000..cba58e59 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/IntelPanel/IntelPanel.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse, server, apiUrl } from '../../../test/server'; +import { IntelPanel } from './IntelPanel'; +import type { IntelRow } from './types'; + +const intel = (over: Partial = {}): IntelRow => ({ + attacker_uuid: 'a-1', + attacker_ip: '1.2.3.4', + aggregate_verdict: 'malicious', + cached_at: '2026-05-01T00:00:00Z', + expires_at: '2026-05-02T00:00:00Z', + greynoise_classification: 'malicious', + greynoise_queried_at: '2026-05-01T00:00:00Z', + abuseipdb_score: 90, + abuseipdb_queried_at: '2026-05-01T00:00:00Z', + feodo_listed: true, + feodo_raw: { malware: 'Emotet' }, + feodo_queried_at: '2026-05-01T00:00:00Z', + threatfox_listed: false, + threatfox_queried_at: '2026-05-01T00:00:00Z', + ...over, +}); + +describe('IntelPanel', () => { + it('renders the aggregate verdict and per-provider rows on success', async () => { + server.use( + http.get(apiUrl('/attackers/a-1/intel'), () => HttpResponse.json(intel())), + ); + render(); + await waitFor(() => expect(screen.getByText('MALICIOUS')).toBeInTheDocument()); + expect(screen.getByText('GREYNOISE')).toBeInTheDocument(); + expect(screen.getByText('ABUSEIPDB')).toBeInTheDocument(); + expect(screen.getByText('FEODO TRACKER')).toBeInTheDocument(); + expect(screen.getByText('THREATFOX')).toBeInTheDocument(); + expect(screen.getByText('90/100')).toBeInTheDocument(); + expect(screen.getByText(/known C2/)).toBeInTheDocument(); + expect(screen.getByText(/Emotet/)).toBeInTheDocument(); + }); + + it('shows the absent placeholder on 404', async () => { + server.use( + http.get(apiUrl('/attackers/a-1/intel'), () => + HttpResponse.json({ detail: 'not cached' }, { status: 404 }), + ), + ); + render(); + await waitFor(() => + expect(screen.getByText(/NO INTEL CACHED YET/)).toBeInTheDocument(), + ); + }); + + it('shows the error placeholder on 500', async () => { + server.use( + http.get(apiUrl('/attackers/a-1/intel'), () => + HttpResponse.json({ detail: 'boom' }, { status: 500 }), + ), + ); + render(); + await waitFor(() => + expect(screen.getByText('FAILED TO LOAD INTEL')).toBeInTheDocument(), + ); + }); +}); diff --git a/decnet_web/src/components/AttackerDetail/IntelPanel/IntelPanel.tsx b/decnet_web/src/components/AttackerDetail/IntelPanel/IntelPanel.tsx new file mode 100644 index 00000000..6b552b5c --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/IntelPanel/IntelPanel.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useState } from 'react'; +import { AlertTriangle, Eye, Shield } from '../../../icons'; +import api from '../../../utils/api'; +import { fmtTs, VERDICT_TONE } from './helpers'; +import type { IntelRow } from './types'; + +interface ProviderRowProps { + name: string; + queriedAt?: string | null; + detail: React.ReactNode; +} + +const ProviderRow: React.FC = ({ name, queriedAt, detail }) => ( +
+
{name}
+
{detail}
+
+ {queriedAt ? fmtTs(queriedAt) : 'pending'} +
+
+); + +export const IntelPanel: React.FC<{ uuid: string }> = ({ uuid }) => { + const [intel, setIntel] = useState(null); + const [state, setState] = useState<'loading' | 'absent' | 'ok' | 'error'>('loading'); + + useEffect(() => { + let cancelled = false; + const load = async () => { + setState('loading'); + try { + const res = await api.get(`/attackers/${encodeURIComponent(uuid)}/intel`); + if (!cancelled) { + setIntel(res.data); + setState('ok'); + } + } catch (err: unknown) { + if (cancelled) return; + const status = (err as { response?: { status?: number } })?.response?.status; + if (status === 404) { + setIntel(null); + setState('absent'); + } else { + setState('error'); + } + } + }; + load(); + return () => { cancelled = true; }; + }, [uuid]); + + if (state === 'loading') { + return ( +
+ QUERYING INTEL CACHE... +
+ ); + } + + if (state === 'error') { + return ( +
+ FAILED TO LOAD INTEL +
+ ); + } + + if (state === 'absent' || !intel) { + return ( +
+ NO INTEL CACHED YET — `decnet enrich` will populate within {' '} + ~1 poll cycle of next observation. +
+ ); + } + + const tone = VERDICT_TONE[intel.aggregate_verdict || 'unknown']; + + return ( +
+
+ + + {tone.label} + + + aggregate verdict + +
+ cached {fmtTs(intel.cached_at)} + expires {fmtTs(intel.expires_at)} +
+
+ + + classification: + {intel.greynoise_classification} + + + ) : ( + no answer + ) + } + /> + + + abuse confidence:{' '} + = 75 ? VERDICT_TONE.malicious.color + : intel.abuseipdb_score >= 25 ? VERDICT_TONE.suspicious.color + : VERDICT_TONE.benign.color, + fontWeight: 600, + }}> + {intel.abuseipdb_score}/100 + + + ) : ( + no answer + ) + } + /> + + + known C2 + {intel.feodo_raw?.malware && ( + + ({intel.feodo_raw.malware}) + + )} + + ) : intel.feodo_listed === false ? ( + not on C2 blocklist + ) : ( + no answer + ) + } + /> + + + IOC match + {Array.isArray(intel.threatfox_raw) && intel.threatfox_raw[0]?.malware && ( + + ({intel.threatfox_raw[0].malware}) + + )} + + ) : intel.threatfox_listed === false ? ( + no IOC match + ) : ( + no answer + ) + } + /> +
+ ); +}; diff --git a/decnet_web/src/components/AttackerDetail/IntelPanel/helpers.ts b/decnet_web/src/components/AttackerDetail/IntelPanel/helpers.ts new file mode 100644 index 00000000..5801ec2b --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/IntelPanel/helpers.ts @@ -0,0 +1,15 @@ +export const VERDICT_TONE: Record = { + malicious: { color: 'var(--alert)', label: 'MALICIOUS' }, + suspicious: { color: 'var(--warn)', label: 'SUSPICIOUS' }, + benign: { color: 'var(--ok)', label: 'BENIGN' }, + unknown: { color: 'var(--fg-4)', label: 'NO SIGNAL' }, +}; + +export const fmtTs = (iso?: string | null): string => { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +}; diff --git a/decnet_web/src/components/AttackerDetail/IntelPanel/index.ts b/decnet_web/src/components/AttackerDetail/IntelPanel/index.ts new file mode 100644 index 00000000..27aa2958 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/IntelPanel/index.ts @@ -0,0 +1,3 @@ +export { IntelPanel } from './IntelPanel'; +export { VERDICT_TONE, fmtTs } from './helpers'; +export type { IntelRow } from './types'; diff --git a/decnet_web/src/components/AttackerDetail/IntelPanel/types.ts b/decnet_web/src/components/AttackerDetail/IntelPanel/types.ts new file mode 100644 index 00000000..f00a1ef1 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/IntelPanel/types.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// Mirrors decnet/web/db/models/attacker_intel.py — server returns the row +// fields plus null gaps where a provider hasn't answered yet. We treat +// every column as optional on the wire. +export interface IntelRow { + attacker_uuid: string; + attacker_ip: string; + schema_version?: number; + aggregate_verdict?: 'malicious' | 'suspicious' | 'benign' | 'unknown' | null; + greynoise_classification?: string | null; + greynoise_raw?: any; + greynoise_queried_at?: string | null; + abuseipdb_score?: number | null; + abuseipdb_raw?: any; + abuseipdb_queried_at?: string | null; + feodo_listed?: boolean | null; + feodo_raw?: any; + feodo_queried_at?: string | null; + threatfox_listed?: boolean | null; + threatfox_raw?: any; + threatfox_queried_at?: string | null; + cached_at?: string | null; + expires_at?: string | null; +}