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})</>}
|
||||
|
||||
@@ -409,6 +409,31 @@ Shared prep landed in commit 1: `_sync_ntlmssp_sources()` in `decnet/engine/depl
|
||||
- Full `TS_INFO_PACKET` (basic-RDP plaintext password) — see scope-down note in commit 2. Re-open as a follow-up DEBT if attacker telemetry actually shows traffic on `PROTOCOL_RDP` without NLA.
|
||||
- Pubkey / Kerberos auth paths — out of scope; mirrors DEBT-038's deferral on the SSH side.
|
||||
|
||||
### DEBT-041 — Intel API + UI keyed by attacker.ip, not attacker.uuid
|
||||
**Files:** `decnet/web/router/attackers/api_get_attacker_intel.py`, `decnet/web/db/sqlmodel_repo.py:upsert_attacker_intel`, `decnet/web/db/models/attacker_intel.py`, `decnet_web/src/components/AttackerDetail.tsx` (`<IntelPanel ip={attacker.ip} />`).
|
||||
|
||||
The threat-intel enrichment surface (DEBT-N/A: `feat(intel)` series) keys every public surface — `GET /api/v1/attackers/{ip}/intel`, the row's `attacker_ip` UNIQUE, and the React `<IntelPanel ip=...>` — on the attacker's IP rather than the canonical `attacker.uuid` we use for every other attacker-detail route. The decision was deliberate in v1: the enricher is woken by `attacker.observed` / `attacker.scored` events whose payload is naturally IP-keyed, the row models a *one-row-per-IP* TTL cache, and standing up a parallel UUID lookup endpoint would have added a join hop with no consumer.
|
||||
|
||||
**Why this is debt, not just a design choice:**
|
||||
1. **NAT / shared-egress collisions.** Two distinct attacker UUIDs that share a source IP (corporate NAT, mobile carrier CGNAT, open VPN exit) collapse to one intel row. Verdicts are technically "about the IP" so this is correct semantically, but the AttackerDetail surface implies *this attacker's intel*, which is misleading when an actor swap goes unnoticed. A UUID-keyed view would let the UI show "this row is shared with N other attacker profiles" honestly.
|
||||
2. **API consistency.** Every other route under `/api/v1/attackers/` is keyed by UUID (`/{uuid}/commands`, `/{uuid}/artifacts`, `/{uuid}/transcripts`, etc.). The IP-keyed `/{ip}/intel` is an outlier that contract-test scaffolding (Schemathesis path-param fuzzing) and OpenAPI-driven SDKs will trip over.
|
||||
3. **Federation-shape mismatch.** DEVELOPMENT_V2's federation work expects gossip-able fingerprints attached to *identity vectors* (session profiles, simhash), not IP-keyed rows. When the federation layer lands and starts asking "what intel exists for this attacker?", the answer is currently a join through the IP — fine, but the abstraction leaks.
|
||||
4. **AttackerDetail.tsx coupling.** `<IntelPanel ip={attacker.ip} />` requires the parent fetch (UUID-keyed) to land before the panel can fire its own request. Two sequential fetches where one would suffice if the panel were UUID-keyed and either (a) the row carried `attacker_uuid` as a queryable index or (b) the endpoint accepted a UUID and performed the IP join server-side.
|
||||
|
||||
**Migration sketch (post-v1):**
|
||||
1. Add `GET /api/v1/attackers/{uuid}/intel` — server-side resolves `uuid → ip`, then `ip → AttackerIntel` row. Keep the IP-keyed route as a deprecated alias for two release cycles.
|
||||
2. Frontend switches `<IntelPanel uuid={...} />` and the parallel-fetches via `Promise.all` with the existing `useEffect`s.
|
||||
3. Decide whether the `attacker_intel` table grows a real foreign key on `attacker_uuid` (with the NAT-collision implications above made explicit in the model docstring) OR whether the row stays IP-keyed and the endpoint just performs the join — the latter is cheaper, the former gives stronger guarantees if/when we want to delete intel rows on attacker purge.
|
||||
|
||||
**Acceptance:**
|
||||
- `/api/v1/attackers/{uuid}/intel` returns the intel row for the attacker's *current* IP, with a clear contract on what happens when an attacker has rotated IPs (see follow-up open question).
|
||||
- The IP-keyed route returns `Deprecation:` header and is removed in v1.2 or v2.0 once external integrations migrate.
|
||||
- AttackerDetail.tsx stops passing `attacker.ip` into `<IntelPanel/>`.
|
||||
|
||||
**Open question:** for an attacker UUID whose row currently carries IP `A` but who first appeared from IP `B`, what should `/attackers/{uuid}/intel` return? Most-recent-IP (current behavior implicit through `attacker.ip`) is the v1 answer; "all intel rows ever associated with this attacker" might surface IP rotation more clearly in a v2 surface. Decide before the migration ships, document either way.
|
||||
|
||||
**Status:** Open. No operational impact today (single-IP attackers are the dominant case), but worth closing before the federation layer lands so the wire-format and API both speak in identity terms, not IP terms.
|
||||
|
||||
### DEBT-032 — Prober can't detect fingerprint rotation without mutation
|
||||
**Files:** `decnet/prober/worker.py` (~lines 235, 286, 334, 392), `decnet/web/db/models.py` (new `decky_service_fingerprints` table).
|
||||
|
||||
@@ -491,6 +516,7 @@ The prober already computes JARM (`worker.py:286`), HASSH (`worker.py:334`), and
|
||||
| DEBT-038 | 🟡 Medium | Honeypot / SSH cred capture | open (document-only) |
|
||||
| ~~DEBT-039~~ | ✅ | Honeypot / Cred emitters | resolved |
|
||||
| ~~DEBT-040~~ | ✅ | Honeypot / RDP+SMB cred framers | resolved |
|
||||
| DEBT-041 | 🟡 Medium | API / UI / Threat-intel keying | open |
|
||||
|
||||
**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests), DEBT-032 (fingerprint rotation detection), DEBT-033 (transcript shard rotation), DEBT-035 (artifacts uid/gid alignment), DEBT-036 (session-profile ingester), DEBT-037 (webhook delivery hardening), DEBT-038 (SSH PAM cred-capture limitations — document-only).
|
||||
**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests), DEBT-032 (fingerprint rotation detection), DEBT-033 (transcript shard rotation), DEBT-035 (artifacts uid/gid alignment), DEBT-036 (session-profile ingester), DEBT-037 (webhook delivery hardening), DEBT-038 (SSH PAM cred-capture limitations — document-only), DEBT-041 (intel API/UI keyed by IP, not UUID).
|
||||
**Estimated remaining effort:** ~21 hours. DEBT-030 Phase B (optimistic staged-buffer editor) is a follow-up, not debt.
|
||||
|
||||
Reference in New Issue
Block a user