feat(web): IntelPanel on AttackerDetail + DEBT-041 entry

Read-only IP-keyed intel surface on the attacker detail page. Renders
the aggregate verdict (color-coded MALICIOUS/SUSPICIOUS/BENIGN/NO SIGNAL)
plus a per-provider row with verdict, queried-at timestamp, and
provider-specific detail (GreyNoise classification, AbuseIPDB
0-100 score, Feodo C2 listing + malware family, ThreatFox IOC match
+ malware family). 404 from the API renders as 'NO INTEL CACHED YET'
with a hint that decnet enrich will populate it on the next pass —
TTL drives the refresh, no manual button.

DEBT-041 documents the API/UI IP-keying as a v1 expedient that will
need a UUID-keyed sibling endpoint before federation lands. NAT
collisions, attacker.uuid consistency across attacker routes, and the
sequential-fetch UX are all callouts on that ticket; the migration
sketch is laid out so the v1.x followup is unambiguous.

Frontend build: clean (55.58 kB AttackerDetail bundle, +~5kB for the
panel). Note: not browser-tested in this session — recommend a manual
smoke against a deployed master before tagging.
This commit is contained in:
2026-04-26 05:25:25 -04:00
parent 8a6d632ab0
commit a009549326
2 changed files with 266 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from '../icons';
import { Activity, AlertTriangle, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Eye, Fingerprint, Globe, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from '../icons';
import api from '../utils/api';
import ArtifactDrawer from './ArtifactDrawer';
import MailDrawer from './MailDrawer';
@@ -950,6 +950,234 @@ const LeakedIPsRow: React.FC<LeakedIPsRowProps> = ({ leaks, total }) => {
};
// ─── 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_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<string, { color: string; label: string }> = {
malicious: { color: '#ff4d4d', label: 'MALICIOUS' },
suspicious: { color: '#ffae42', label: 'SUSPICIOUS' },
benign: { color: '#5fd07a', label: 'BENIGN' },
unknown: { color: 'rgba(255,255,255,0.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 }) => (
<div style={{
display: 'grid',
gridTemplateColumns: '160px 1fr auto',
gap: '12px',
padding: '10px 16px',
borderTop: '1px solid rgba(255,255,255,0.05)',
alignItems: 'center',
fontSize: '0.85rem',
}}>
<div style={{ letterSpacing: '1px', opacity: 0.7 }}>{name}</div>
<div>{detail}</div>
<div style={{ opacity: 0.4, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{queriedAt ? fmtTs(queriedAt) : 'pending'}
</div>
</div>
);
const IntelPanel: React.FC<{ ip: string }> = ({ ip }) => {
const [intel, setIntel] = useState<IntelRow | null>(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(ip)}/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; };
}, [ip]);
if (state === 'loading') {
return (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
QUERYING INTEL CACHE...
</div>
);
}
if (state === 'error') {
return (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.6, color: '#ff8080' }}>
FAILED TO LOAD INTEL
</div>
);
}
if (state === 'absent' || !intel) {
return (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
NO INTEL CACHED YET `decnet enrich` will populate within {' '}
<span style={{ opacity: 0.7 }}>~1 poll cycle</span> of next observation.
</div>
);
}
const tone = VERDICT_TONE[intel.aggregate_verdict || 'unknown'];
return (
<div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '14px 16px',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}>
<Shield size={16} style={{ color: tone.color }} />
<span style={{
letterSpacing: '2px',
fontWeight: 600,
color: tone.color,
}}>
{tone.label}
</span>
<span style={{ opacity: 0.4, fontSize: '0.7rem' }}>
aggregate verdict
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '16px', fontSize: '0.7rem', opacity: 0.5 }}>
<span>cached {fmtTs(intel.cached_at)}</span>
<span>expires {fmtTs(intel.expires_at)}</span>
</div>
</div>
<ProviderRow
name="GREYNOISE"
queriedAt={intel.greynoise_queried_at}
detail={
intel.greynoise_classification ? (
<span>
classification: <span style={{ color: VERDICT_TONE[intel.greynoise_classification]?.color || 'inherit' }}>
{intel.greynoise_classification}
</span>
</span>
) : (
<span style={{ opacity: 0.4 }}>no answer</span>
)
}
/>
<ProviderRow
name="ABUSEIPDB"
queriedAt={intel.abuseipdb_queried_at}
detail={
intel.abuseipdb_score !== null && intel.abuseipdb_score !== undefined ? (
<span>
abuse confidence:{' '}
<span style={{
color: intel.abuseipdb_score >= 75 ? VERDICT_TONE.malicious.color
: intel.abuseipdb_score >= 25 ? VERDICT_TONE.suspicious.color
: VERDICT_TONE.benign.color,
fontWeight: 600,
}}>
{intel.abuseipdb_score}/100
</span>
</span>
) : (
<span style={{ opacity: 0.4 }}>no answer</span>
)
}
/>
<ProviderRow
name="FEODO TRACKER"
queriedAt={intel.feodo_queried_at}
detail={
intel.feodo_listed === true ? (
<span style={{ color: VERDICT_TONE.malicious.color, fontWeight: 600 }}>
<AlertTriangle size={12} style={{ verticalAlign: 'middle' }} /> known C2
{intel.feodo_raw?.malware && (
<span style={{ opacity: 0.7, marginLeft: '8px', fontWeight: 400 }}>
({intel.feodo_raw.malware})
</span>
)}
</span>
) : intel.feodo_listed === false ? (
<span style={{ opacity: 0.5 }}>not on C2 blocklist</span>
) : (
<span style={{ opacity: 0.4 }}>no answer</span>
)
}
/>
<ProviderRow
name="THREATFOX"
queriedAt={intel.threatfox_queried_at}
detail={
intel.threatfox_listed === true ? (
<span style={{ color: VERDICT_TONE.malicious.color, fontWeight: 600 }}>
<Eye size={12} style={{ verticalAlign: 'middle' }} /> IOC match
{Array.isArray(intel.threatfox_raw) && intel.threatfox_raw[0]?.malware && (
<span style={{ opacity: 0.7, marginLeft: '8px', fontWeight: 400 }}>
({intel.threatfox_raw[0].malware})
</span>
)}
</span>
) : intel.threatfox_listed === false ? (
<span style={{ opacity: 0.5 }}>no IOC match</span>
) : (
<span style={{ opacity: 0.4 }}>no answer</span>
)
}
/>
</div>
);
};
// ─── Main component ─────────────────────────────────────────────────────────
const AttackerDetail: React.FC = () => {
@@ -968,6 +1196,7 @@ const AttackerDetail: React.FC = () => {
behavior: true,
commands: true,
fingerprints: true,
intel: true,
artifacts: true,
sessions: true,
smtpTargets: true,
@@ -1527,6 +1756,15 @@ const AttackerDetail: React.FC = () => {
);
})()}
{/* Threat-Intel Enrichment — keyed by attacker.ip (see DEBT-041) */}
<Section
title={<><Globe size={14} style={{ verticalAlign: 'middle', marginRight: '6px' }} />THREAT INTEL</>}
open={openSections.intel}
onToggle={() => toggle('intel')}
>
<IntelPanel ip={attacker.ip} />
</Section>
{/* Captured Artifacts */}
<Section
title={<>CAPTURED ARTIFACTS ({artifacts.length})</>}