diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 328dabc9..10c018eb 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Activity, AlertTriangle, ArrowLeft, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Package, FileText, Mail, AtSign } from '../icons'; +import { Activity, AlertTriangle, ArrowLeft, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, FileText, Mail, AtSign } from '../icons'; import api from '../utils/api'; -import ArtifactDrawer from './ArtifactDrawer'; import MailDrawer from './MailDrawer'; import SessionDrawer from './SessionDrawer'; import EmptyState from './EmptyState/EmptyState'; @@ -13,6 +12,7 @@ import { AttackerStats } from './AttackerDetail/sections/AttackerStats'; import { TimelineSection } from './AttackerDetail/sections/TimelineSection'; import { ServicesTargeted } from './AttackerDetail/sections/ServicesTargeted'; import { CommandsViewer } from './AttackerDetail/sections/CommandsViewer'; +import { ArtifactsPanel } from './AttackerDetail/sections/ArtifactsPanel'; import { Tag, Section } from './AttackerDetail/ui'; import type { AttackerBehavior, @@ -1283,7 +1283,8 @@ const AttackerDetail: React.FC = () => { }); // Drawer selection (ephemeral UI; data feeds come from the hook). - const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); + // Drawer selection (mail/session). The artifact drawer state moved + // into ArtifactsPanel; mail and session follow in the next commits. const [session, setSession] = useState<{ decky: string; sid: string; fields: Record } | null>(null); const [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); @@ -1499,89 +1500,11 @@ const AttackerDetail: React.FC = () => { - {/* Captured Artifacts */} -
CAPTURED ARTIFACTS ({artifacts.length})} + toggle('artifacts')} - > - {artifacts.length > 0 ? ( -
- - - - - - - - - - - - - {artifacts.map((row) => { - let fields: Record = {}; - try { fields = JSON.parse(row.fields || '{}'); } catch {} - const storedAs = fields.stored_as ? String(fields.stored_as) : null; - const sha = fields.sha256 ? String(fields.sha256) : ''; - return ( - - - - - - - - - ); - })} - -
TIMESTAMPDECKYFILENAMESIZESHA-256
- {new Date(row.timestamp).toLocaleString()} - {row.decky} - {fields.orig_path ?? storedAs ?? '—'} - - {fields.size ? `${fields.size} B` : '—'} - - {sha ? `${sha.slice(0, 12)}…` : '—'} - - {storedAs && ( - - )} -
-
- ) : ( - - )} -
- - {artifact && ( - setArtifact(null)} - /> - )} + /> {/* SMTP Victim Domains (viewer-safe rollup) */}
({ + default: ({ storedAs, onClose }: { storedAs: string; onClose: () => void }) => ( +
+ drawer for {storedAs} + +
+ ), +})); + +const fields = (extra: Record = {}) => + JSON.stringify({ + stored_as: '/var/captures/abc.bin', + sha256: 'a'.repeat(64), + size: 1024, + orig_path: '/etc/passwd', + ...extra, + }); + +const row = (overrides: Partial = {}): ArtifactLog => ({ + id: 1, + timestamp: '2026-05-09T11:00:00Z', + decky: 'decoy-01', + service: 'ssh', + fields: fields(), + ...overrides, +}); + +describe('ArtifactsPanel', () => { + it('renders one row per artifact with parsed filename + truncated sha', () => { + render( + {}} + />, + ); + expect(screen.getByText('decoy-01')).toBeInTheDocument(); + expect(screen.getByText('/etc/passwd')).toBeInTheDocument(); + expect(screen.getByText(/aaaaaaaaaaaa…/)).toBeInTheDocument(); + expect(screen.getByText(/^OPEN$/)).toBeInTheDocument(); + }); + + it('shows the empty-state when artifacts is []', () => { + render( + {}} />, + ); + expect(screen.getByText('NO ARTIFACTS CAPTURED')).toBeInTheDocument(); + }); + + it('omits the OPEN button when stored_as is absent', () => { + render( + {}} + />, + ); + expect(screen.queryByText(/^OPEN$/)).not.toBeInTheDocument(); + }); + + it('opens the drawer on OPEN click and closes on the drawer close', async () => { + const user = userEvent.setup(); + render( + {}} + />, + ); + expect(screen.queryByTestId('artifact-drawer')).not.toBeInTheDocument(); + + await user.click(screen.getByText(/^OPEN$/)); + expect(screen.getByTestId('artifact-drawer')).toBeInTheDocument(); + + await user.click(screen.getByText('close')); + expect(screen.queryByTestId('artifact-drawer')).not.toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/AttackerDetail/sections/ArtifactsPanel.tsx b/decnet_web/src/components/AttackerDetail/sections/ArtifactsPanel.tsx new file mode 100644 index 00000000..58ffbee2 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/ArtifactsPanel.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react'; +import { Package, Paperclip } from '../../../icons'; +import EmptyState from '../../EmptyState/EmptyState'; +import ArtifactDrawer from '../../ArtifactDrawer'; +import { Section } from '../ui'; +import type { ArtifactLog } from '../types'; + +interface Props { + artifacts: ArtifactLog[]; + open: boolean; + onToggle: () => void; +} + +interface DrawerSelection { + decky: string; + storedAs: string; + fields: Record; +} + +/** CAPTURED ARTIFACTS collapsible — file-drop log with inline + * preview button per row. The drawer's open/close state lives + * here; the artifact list itself is read from the data hook. */ +export const ArtifactsPanel: React.FC = ({ artifacts, open, onToggle }) => { + const [selected, setSelected] = useState(null); + + return ( + <> +
CAPTURED ARTIFACTS ({artifacts.length})} + open={open} + onToggle={onToggle} + > + {artifacts.length > 0 ? ( +
+ + + + + + + + + + + + + {artifacts.map((row) => { + let fields: Record = {}; + try { + fields = JSON.parse(row.fields || '{}'); + } catch { + // malformed SD params — preview unavailable + } + const storedAs = fields.stored_as ? String(fields.stored_as) : null; + const sha = fields.sha256 ? String(fields.sha256) : ''; + return ( + + + + + + + + + ); + })} + +
TIMESTAMPDECKYFILENAMESIZESHA-256
+ {new Date(row.timestamp).toLocaleString()} + {row.decky} + {(fields.orig_path as string | undefined) ?? storedAs ?? '—'} + + {fields.size ? `${fields.size} B` : '—'} + + {sha ? `${sha.slice(0, 12)}…` : '—'} + + {storedAs && ( + + )} +
+
+ ) : ( + + )} +
+ + {selected && ( + setSelected(null)} + /> + )} + + ); +};