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,
|
||||
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<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 ─────────────────────────────────────────────────────────
|
||||
|
||||
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