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:
@@ -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})</>}
|
||||
|
||||
Reference in New Issue
Block a user