refactor(decnet_web/AttackerDetail): extract TimelineSection

Lift the TIMELINE collapsible (timestamps, ASN, reverse DNS,
leaked-IPs row with rotation detection) into its own section.
LeakedIPsRow + the rotation/inline-limit constants come along
since they were only ever used here.

Also moves the shared `Section` collapsible primitive into
AttackerDetail/ui.tsx so the remaining sections can adopt the
template without re-importing through the parent module.

- New AttackerDetail/sections/TimelineSection.tsx (LeakedIPsRow
  inline as a private helper)
- AttackerDetail/ui.tsx now exports both Tag and Section
- AttackerDetail.tsx loses LeakedIPsRow, the Section helper, the
  Timeline JSX block, and now-unused imports (ChevronUp, ChevronDown,
  AttackerData)
- TimelineSection.test.tsx covers timestamps, unknown-origin path,
  rotation badge, empty leaks, collapse, and toggle callback
This commit is contained in:
2026-05-09 04:43:13 -04:00
parent f524d283b7
commit 95e1a4ab7a
4 changed files with 333 additions and 213 deletions

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Activity, AlertTriangle, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from '../icons';
import { Activity, AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, 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';
@@ -10,9 +10,9 @@ import TTPsObservedSection from './TTPsObservedSection';
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
import { Tag } from './AttackerDetail/ui';
import { TimelineSection } from './AttackerDetail/sections/TimelineSection';
import { Tag, Section } from './AttackerDetail/ui';
import type {
AttackerData,
AttackerBehavior,
BehaviouralObservation,
AttributionPrimitiveState,
@@ -1008,144 +1008,6 @@ export const BehaviouralPrimitivesPanel: React.FC<{
);
};
// ─── Collapsible section ────────────────────────────────────────────────────
const Section: React.FC<{
title: React.ReactNode;
right?: React.ReactNode;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}> = ({ title, right, open, onToggle, children }) => (
<div className="logs-section">
<div
className="section-header"
style={{ justifyContent: 'space-between', cursor: 'pointer', userSelect: 'none' }}
onClick={onToggle}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{open ? <ChevronUp size={16} className="dim" /> : <ChevronDown size={16} className="dim" />}
<h2>{title}</h2>
</div>
{right && <div onClick={(e) => e.stopPropagation()}>{right}</div>}
</div>
{open && children}
</div>
);
// ─── Leaked-IPs row (truncated view + rotation-detection badge) ────────────
const ROTATION_THRESHOLD = 20;
const INLINE_LIMIT = 1;
interface LeakedIPsRowProps {
leaks: NonNullable<AttackerData['ip_leaks']>;
total: number;
}
const LeakedIPsRow: React.FC<LeakedIPsRowProps> = ({ leaks, total }) => {
const [expanded, setExpanded] = useState(false);
const distinctIPs = Array.from(
new Set(
leaks
.map((l) => l.payload?.real_ip_claim)
.filter((v): v is string => !!v),
),
);
const rotationDetected = total >= ROTATION_THRESHOLD;
const visible = expanded ? distinctIPs : distinctIPs.slice(0, INLINE_LIMIT);
const hiddenInList = distinctIPs.length - visible.length;
// Backend caps server-side leaks at 10 rows; "total" is the unbounded
// count — may exceed what we actually have IP values for.
const remainingBeyondSample = total - distinctIPs.length;
const ipTooltip = (ip: string): string => {
const latest = leaks.find((l) => l.payload?.real_ip_claim === ip);
return latest
? `Leaked via ${latest.payload.source_header ?? '?'}; source ${latest.payload.source_ip ?? '?'}`
: '';
};
return (
<div>
<span className="dim" style={{ color: 'var(--warn, #e0a040)' }}>
LEAKED IPs:{' '}
</span>
{rotationDetected && (
<span
style={{ marginRight: 8, display: 'inline-block' }}
title={`${total} distinct claimed IPs — almost certainly XFF-rotation / WAF-bypass probing, not a real attribution leak.`}
>
<Tag color="var(--alert, #ff4d4d)">
ROTATION · {total}
</Tag>
</span>
)}
{visible.map((ip, i, arr) => (
<span
key={ip}
style={{
color: 'var(--warn, #e0a040)',
fontFamily: 'monospace',
}}
title={ipTooltip(ip)}
>
{ip}
{i < arr.length - 1 ? ', ' : ''}
</span>
))}
{!expanded && hiddenInList > 0 && (
<>
{' '}
<button
onClick={() => setExpanded(true)}
style={{
background: 'transparent',
border: 'none',
color: 'var(--warn, #e0a040)',
cursor: 'pointer',
padding: 0,
fontFamily: 'inherit',
textDecoration: 'underline',
}}
>
+ {hiddenInList} more
</button>
</>
)}
{remainingBeyondSample > 0 && (
<span
className="dim"
style={{ marginLeft: 6, fontSize: '0.75rem' }}
title="Only the 10 most-recent claimed IPs are fetched; the total count is the full DB tally."
>
(+{remainingBeyondSample} beyond sample)
</span>
)}
{expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && (
<>
{' '}
<button
onClick={() => setExpanded(false)}
style={{
background: 'transparent',
border: 'none',
color: 'var(--accent-color)',
cursor: 'pointer',
padding: 0,
fontFamily: 'inherit',
textDecoration: 'underline',
}}
>
collapse
</button>
</>
)}
</div>
);
};
// ─── Threat-Intel Panel ─────────────────────────────────────────────────────
// Mirrors decnet/web/db/models/attacker_intel.py — server returns the row
@@ -1464,78 +1326,11 @@ const AttackerDetail: React.FC = () => {
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
<TTPsObservedSection scope="attacker" uuid={attacker.uuid} />
{/* Timestamps */}
<Section title="TIMELINE" open={openSections.timeline} onToggle={() => toggle('timeline')}>
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '32px', fontSize: '0.85rem' }}>
<div>
<span className="dim">FIRST SEEN: </span>
<span className="matrix-text">{new Date(attacker.first_seen).toLocaleString()}</span>
</div>
<div>
<span className="dim">LAST SEEN: </span>
<span className="matrix-text">{new Date(attacker.last_seen).toLocaleString()}</span>
</div>
<div>
<span className="dim">UPDATED: </span>
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
</div>
<div>
<span className="dim">ORIGIN: </span>
{attacker.country_code ? (
<span className="matrix-text">
{attacker.country_code}
{attacker.country_source && (
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
({attacker.country_source})
</span>
)}
</span>
) : (
<span className="dim">unknown</span>
)}
</div>
<div>
<span className="dim">AS: </span>
{attacker.asn != null ? (
<span className="matrix-text">
AS{attacker.asn}
{attacker.as_name && (
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
{attacker.as_name}
</span>
)}
{attacker.asn_source && (
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
({attacker.asn_source})
</span>
)}
</span>
) : (
<span className="dim">unknown</span>
)}
</div>
<div>
<span className="dim">REVERSE DNS: </span>
{attacker.ptr_record ? (
<span
className="matrix-text"
style={{ fontFamily: 'monospace' }}
title="One-shot PTR record resolved at first sighting."
>
{attacker.ptr_record}
</span>
) : (
<span className="dim"></span>
)}
</div>
{attacker.ip_leaks && attacker.ip_leaks.length > 0 && (
<LeakedIPsRow
leaks={attacker.ip_leaks}
total={attacker.ip_leaks_total ?? attacker.ip_leaks.length}
/>
)}
</div>
</Section>
<TimelineSection
attacker={attacker}
open={openSections.timeline}
onToggle={() => toggle('timeline')}
/>
{/* Services */}
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { makeAttacker } from '../../../test/fixtures';
import { TimelineSection } from './TimelineSection';
describe('TimelineSection', () => {
it('renders the labelled timestamp + ASN + reverse-DNS rows', () => {
render(
<TimelineSection
attacker={makeAttacker({
asn: 12345,
as_name: 'EXAMPLE-AS',
asn_source: 'ipinfo',
ptr_record: 'host.example.com',
})}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText('FIRST SEEN:')).toBeInTheDocument();
expect(screen.getByText('LAST SEEN:')).toBeInTheDocument();
expect(screen.getByText(/AS12345/)).toBeInTheDocument();
expect(screen.getByText('host.example.com')).toBeInTheDocument();
});
it('renders "unknown" when origin and ASN are absent', () => {
render(
<TimelineSection
attacker={makeAttacker({ country_code: null, asn: null })}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getAllByText('unknown')).toHaveLength(2);
});
it('renders the ROTATION badge when ip_leaks total >= 20', () => {
render(
<TimelineSection
attacker={makeAttacker({
ip_leaks: [
{
timestamp: '2026-05-01T10:00:00Z',
bounty_type: 'xff_leak',
payload: { real_ip_claim: '1.1.1.1', source_header: 'X-Forwarded-For' },
},
],
ip_leaks_total: 42,
})}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText(/ROTATION · 42/)).toBeInTheDocument();
});
it('does NOT render leaked-IPs row when ip_leaks is empty', () => {
render(
<TimelineSection
attacker={makeAttacker()}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.queryByText(/LEAKED IPs/)).not.toBeInTheDocument();
});
it('hides body content when open=false', () => {
render(
<TimelineSection
attacker={makeAttacker()}
open={false}
onToggle={() => {}}
/>,
);
expect(screen.queryByText('FIRST SEEN:')).not.toBeInTheDocument();
});
it('fires onToggle when the section header is clicked', async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
render(
<TimelineSection
attacker={makeAttacker()}
open={true}
onToggle={onToggle}
/>,
);
await user.click(screen.getByText('TIMELINE'));
expect(onToggle).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,204 @@
import React, { useState } from 'react';
import { Section, Tag } from '../ui';
import type { AttackerData } from '../types';
const ROTATION_THRESHOLD = 20;
const INLINE_LIMIT = 1;
interface LeakedIPsRowProps {
leaks: NonNullable<AttackerData['ip_leaks']>;
total: number;
}
/** "LEAKED IPs" inline list with rotation-detection badge. The
* backend caps the server-side sample at 10 rows; `total` is the
* unbounded count, which may exceed what we have actual IP values
* for. Past ROTATION_THRESHOLD distinct claims, badge in red — that
* pattern is XFF-rotation / WAF-bypass probing, not real
* attribution leakage. */
const LeakedIPsRow: React.FC<LeakedIPsRowProps> = ({ leaks, total }) => {
const [expanded, setExpanded] = useState(false);
const distinctIPs = Array.from(
new Set(
leaks
.map((l) => l.payload?.real_ip_claim)
.filter((v): v is string => !!v),
),
);
const rotationDetected = total >= ROTATION_THRESHOLD;
const visible = expanded ? distinctIPs : distinctIPs.slice(0, INLINE_LIMIT);
const hiddenInList = distinctIPs.length - visible.length;
const remainingBeyondSample = total - distinctIPs.length;
const ipTooltip = (ip: string): string => {
const latest = leaks.find((l) => l.payload?.real_ip_claim === ip);
return latest
? `Leaked via ${latest.payload.source_header ?? '?'}; source ${latest.payload.source_ip ?? '?'}`
: '';
};
return (
<div>
<span className="dim" style={{ color: 'var(--warn, #e0a040)' }}>
LEAKED IPs:{' '}
</span>
{rotationDetected && (
<span
style={{ marginRight: 8, display: 'inline-block' }}
title={`${total} distinct claimed IPs — almost certainly XFF-rotation / WAF-bypass probing, not a real attribution leak.`}
>
<Tag color="var(--alert, #ff4d4d)">
ROTATION · {total}
</Tag>
</span>
)}
{visible.map((ip, i, arr) => (
<span
key={ip}
style={{ color: 'var(--warn, #e0a040)', fontFamily: 'monospace' }}
title={ipTooltip(ip)}
>
{ip}
{i < arr.length - 1 ? ', ' : ''}
</span>
))}
{!expanded && hiddenInList > 0 && (
<>
{' '}
<button
onClick={() => setExpanded(true)}
style={{
background: 'transparent',
border: 'none',
color: 'var(--warn, #e0a040)',
cursor: 'pointer',
padding: 0,
fontFamily: 'inherit',
textDecoration: 'underline',
}}
>
+ {hiddenInList} more
</button>
</>
)}
{remainingBeyondSample > 0 && (
<span
className="dim"
style={{ marginLeft: 6, fontSize: '0.75rem' }}
title="Only the 10 most-recent claimed IPs are fetched; the total count is the full DB tally."
>
(+{remainingBeyondSample} beyond sample)
</span>
)}
{expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && (
<>
{' '}
<button
onClick={() => setExpanded(false)}
style={{
background: 'transparent',
border: 'none',
color: 'var(--accent-color)',
cursor: 'pointer',
padding: 0,
fontFamily: 'inherit',
textDecoration: 'underline',
}}
>
collapse
</button>
</>
)}
</div>
);
};
interface Props {
attacker: AttackerData;
open: boolean;
onToggle: () => void;
}
/** TIMELINE collapsible — first/last seen, ASN/origin, reverse DNS,
* and leaked-IP row when the attacker has any ip_leaks. */
export const TimelineSection: React.FC<Props> = ({ attacker, open, onToggle }) => (
<Section title="TIMELINE" open={open} onToggle={onToggle}>
<div
style={{
padding: '16px',
display: 'flex',
flexWrap: 'wrap',
gap: '32px',
fontSize: '0.85rem',
}}
>
<div>
<span className="dim">FIRST SEEN: </span>
<span className="matrix-text">{new Date(attacker.first_seen).toLocaleString()}</span>
</div>
<div>
<span className="dim">LAST SEEN: </span>
<span className="matrix-text">{new Date(attacker.last_seen).toLocaleString()}</span>
</div>
<div>
<span className="dim">UPDATED: </span>
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
</div>
<div>
<span className="dim">ORIGIN: </span>
{attacker.country_code ? (
<span className="matrix-text">
{attacker.country_code}
{attacker.country_source && (
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
({attacker.country_source})
</span>
)}
</span>
) : (
<span className="dim">unknown</span>
)}
</div>
<div>
<span className="dim">AS: </span>
{attacker.asn != null ? (
<span className="matrix-text">
AS{attacker.asn}
{attacker.as_name && (
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
{attacker.as_name}
</span>
)}
{attacker.asn_source && (
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
({attacker.asn_source})
</span>
)}
</span>
) : (
<span className="dim">unknown</span>
)}
</div>
<div>
<span className="dim">REVERSE DNS: </span>
{attacker.ptr_record ? (
<span
className="matrix-text"
style={{ fontFamily: 'monospace' }}
title="One-shot PTR record resolved at first sighting."
>
{attacker.ptr_record}
</span>
) : (
<span className="dim"></span>
)}
</div>
{attacker.ip_leaks && attacker.ip_leaks.length > 0 && (
<LeakedIPsRow
leaks={attacker.ip_leaks}
total={attacker.ip_leaks_total ?? attacker.ip_leaks.length}
/>
)}
</div>
</Section>
);

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { ChevronDown, ChevronUp } from '../../icons';
/** Pill-style tag chip used throughout the AttackerDetail surface
* for badges, filters, and category labels. Color drives both the
@@ -20,3 +21,30 @@ export const Tag: React.FC<{ children: React.ReactNode; color?: string }> = ({
{children}
</span>
);
/** Collapsible panel used by every section on the AttackerDetail page.
* The header is the toggle; an optional `right` slot hosts controls
* (filters, action buttons) whose clicks are stopped from bubbling
* to the toggle handler. */
export const Section: React.FC<{
title: React.ReactNode;
right?: React.ReactNode;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}> = ({ title, right, open, onToggle, children }) => (
<div className="logs-section">
<div
className="section-header"
style={{ justifyContent: 'space-between', cursor: 'pointer', userSelect: 'none' }}
onClick={onToggle}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{open ? <ChevronUp size={16} className="dim" /> : <ChevronDown size={16} className="dim" />}
<h2>{title}</h2>
</div>
{right && <div onClick={(e) => e.stopPropagation()}>{right}</div>}
</div>
{open && children}
</div>
);