diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 2dd56433..964e9a67 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -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 }) => ( -
-
-
- {open ? : } -

{title}

-
- {right &&
e.stopPropagation()}>{right}
} -
- {open && children} -
-); - -// ─── Leaked-IPs row (truncated view + rotation-detection badge) ──────────── - -const ROTATION_THRESHOLD = 20; -const INLINE_LIMIT = 1; - -interface LeakedIPsRowProps { - leaks: NonNullable; - total: number; -} - -const LeakedIPsRow: React.FC = ({ 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 ( -
- - LEAKED IPs:{' '} - - {rotationDetected && ( - - - ROTATION · {total} - - - )} - {visible.map((ip, i, arr) => ( - - {ip} - {i < arr.length - 1 ? ', ' : ''} - - ))} - {!expanded && hiddenInList > 0 && ( - <> - {' '} - - - )} - {remainingBeyondSample > 0 && ( - - (+{remainingBeyondSample} beyond sample) - - )} - {expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && ( - <> - {' '} - - - )} -
- ); -}; - - // ─── 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" */} - {/* Timestamps */} -
toggle('timeline')}> -
-
- FIRST SEEN: - {new Date(attacker.first_seen).toLocaleString()} -
-
- LAST SEEN: - {new Date(attacker.last_seen).toLocaleString()} -
-
- UPDATED: - {new Date(attacker.updated_at).toLocaleString()} -
-
- ORIGIN: - {attacker.country_code ? ( - - {attacker.country_code} - {attacker.country_source && ( - - ({attacker.country_source}) - - )} - - ) : ( - unknown - )} -
-
- AS: - {attacker.asn != null ? ( - - AS{attacker.asn} - {attacker.as_name && ( - - {attacker.as_name} - - )} - {attacker.asn_source && ( - - ({attacker.asn_source}) - - )} - - ) : ( - unknown - )} -
-
- REVERSE DNS: - {attacker.ptr_record ? ( - - {attacker.ptr_record} - - ) : ( - - )} -
- {attacker.ip_leaks && attacker.ip_leaks.length > 0 && ( - - )} -
-
+ toggle('timeline')} + /> {/* Services */}
toggle('services')}> diff --git a/decnet_web/src/components/AttackerDetail/sections/TimelineSection.test.tsx b/decnet_web/src/components/AttackerDetail/sections/TimelineSection.test.tsx new file mode 100644 index 00000000..edc9fb59 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/TimelineSection.test.tsx @@ -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( + {}} + />, + ); + 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( + {}} + />, + ); + expect(screen.getAllByText('unknown')).toHaveLength(2); + }); + + it('renders the ROTATION badge when ip_leaks total >= 20', () => { + render( + {}} + />, + ); + expect(screen.getByText(/ROTATION · 42/)).toBeInTheDocument(); + }); + + it('does NOT render leaked-IPs row when ip_leaks is empty', () => { + render( + {}} + />, + ); + expect(screen.queryByText(/LEAKED IPs/)).not.toBeInTheDocument(); + }); + + it('hides body content when open=false', () => { + render( + {}} + />, + ); + 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( + , + ); + await user.click(screen.getByText('TIMELINE')); + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/decnet_web/src/components/AttackerDetail/sections/TimelineSection.tsx b/decnet_web/src/components/AttackerDetail/sections/TimelineSection.tsx new file mode 100644 index 00000000..f7517c53 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/TimelineSection.tsx @@ -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; + 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 = ({ 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 ( +
+ + LEAKED IPs:{' '} + + {rotationDetected && ( + + + ROTATION · {total} + + + )} + {visible.map((ip, i, arr) => ( + + {ip} + {i < arr.length - 1 ? ', ' : ''} + + ))} + {!expanded && hiddenInList > 0 && ( + <> + {' '} + + + )} + {remainingBeyondSample > 0 && ( + + (+{remainingBeyondSample} beyond sample) + + )} + {expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && ( + <> + {' '} + + + )} +
+ ); +}; + +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 = ({ attacker, open, onToggle }) => ( +
+
+
+ FIRST SEEN: + {new Date(attacker.first_seen).toLocaleString()} +
+
+ LAST SEEN: + {new Date(attacker.last_seen).toLocaleString()} +
+
+ UPDATED: + {new Date(attacker.updated_at).toLocaleString()} +
+
+ ORIGIN: + {attacker.country_code ? ( + + {attacker.country_code} + {attacker.country_source && ( + + ({attacker.country_source}) + + )} + + ) : ( + unknown + )} +
+
+ AS: + {attacker.asn != null ? ( + + AS{attacker.asn} + {attacker.as_name && ( + + {attacker.as_name} + + )} + {attacker.asn_source && ( + + ({attacker.asn_source}) + + )} + + ) : ( + unknown + )} +
+
+ REVERSE DNS: + {attacker.ptr_record ? ( + + {attacker.ptr_record} + + ) : ( + + )} +
+ {attacker.ip_leaks && attacker.ip_leaks.length > 0 && ( + + )} +
+
+); diff --git a/decnet_web/src/components/AttackerDetail/ui.tsx b/decnet_web/src/components/AttackerDetail/ui.tsx index f6eb8eaf..20658445 100644 --- a/decnet_web/src/components/AttackerDetail/ui.tsx +++ b/decnet_web/src/components/AttackerDetail/ui.tsx @@ -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} ); + +/** 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 }) => ( +
+
+
+ {open ? : } +

{title}

+
+ {right &&
e.stopPropagation()}>{right}
} +
+ {open && children} +
+);