refactor(decnet_web/AttackerDetail): lift IntelPanel
Move IntelPanel + IntelRow type + ProviderRow + VERDICT_TONE/fmtTs helpers into AttackerDetail/IntelPanel/. AttackerDetail.tsx drops from 680 to 449 LOC. New IntelPanel.test.tsx covers the loading, absent (404), error (500), and ok states with MSW handlers.
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
|||||||
BehaviorHeadline, BeaconBlock, DetectedToolsBlock, PhaseSequenceBlock,
|
BehaviorHeadline, BeaconBlock, DetectedToolsBlock, PhaseSequenceBlock,
|
||||||
TcpStackBlock, TimingStatsBlock, BehaviouralPrimitivesPanel,
|
TcpStackBlock, TimingStatsBlock, BehaviouralPrimitivesPanel,
|
||||||
} from './AttackerDetail/behaviour';
|
} from './AttackerDetail/behaviour';
|
||||||
|
import { IntelPanel } from './AttackerDetail/IntelPanel';
|
||||||
import type {
|
import type {
|
||||||
BehaviouralObservation,
|
BehaviouralObservation,
|
||||||
AttributionPrimitiveState,
|
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<string, { color: string; label: string }> = {
|
|
||||||
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 }) => (
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '160px 1fr auto',
|
|
||||||
gap: '12px',
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderTop: '1px solid var(--matrix-tint-5)',
|
|
||||||
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<{ uuid: string }> = ({ uuid }) => {
|
|
||||||
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(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 (
|
|
||||||
<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 var(--matrix-tint-5)',
|
|
||||||
}}>
|
|
||||||
<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 ─────────────────────────────────────────────────────────
|
// ─── Main component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const AttackerDetail: React.FC = () => {
|
const AttackerDetail: React.FC = () => {
|
||||||
|
|||||||
@@ -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> = {}): 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(<IntelPanel uuid="a-1" />);
|
||||||
|
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(<IntelPanel uuid="a-1" />);
|
||||||
|
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(<IntelPanel uuid="a-1" />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('FAILED TO LOAD INTEL')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ProviderRowProps> = ({ name, queriedAt, detail }) => (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '160px 1fr auto',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderTop: '1px solid var(--matrix-tint-5)',
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IntelPanel: React.FC<{ uuid: string }> = ({ uuid }) => {
|
||||||
|
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(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 (
|
||||||
|
<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 var(--matrix-tint-5)',
|
||||||
|
}}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const VERDICT_TONE: Record<string, { color: string; label: string }> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { IntelPanel } from './IntelPanel';
|
||||||
|
export { VERDICT_TONE, fmtTs } from './helpers';
|
||||||
|
export type { IntelRow } from './types';
|
||||||
25
decnet_web/src/components/AttackerDetail/IntelPanel/types.ts
Normal file
25
decnet_web/src/components/AttackerDetail/IntelPanel/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user