diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 10c018eb..052752b7 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, FileText, Mail, AtSign } from '../icons'; +import { Activity, AlertTriangle, ArrowLeft, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, FileText, AtSign } from '../icons'; import api from '../utils/api'; -import MailDrawer from './MailDrawer'; import SessionDrawer from './SessionDrawer'; import EmptyState from './EmptyState/EmptyState'; import TTPsObservedSection from './TTPsObservedSection'; @@ -13,6 +12,7 @@ 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 { MailLogPanel } from './AttackerDetail/sections/MailLogPanel'; import { Tag, Section } from './AttackerDetail/ui'; import type { AttackerBehavior, @@ -1283,10 +1283,9 @@ const AttackerDetail: React.FC = () => { }); // Drawer selection (ephemeral UI; data feeds come from the hook). - // Drawer selection (mail/session). The artifact drawer state moved - // into ArtifactsPanel; mail and session follow in the next commits. + // Drawer selection (session). Artifact + mail drawer state are + // owned by their respective sections. const [session, setSession] = useState<{ decky: string; sid: string; fields: Record } | null>(null); - const [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); @@ -1552,98 +1551,12 @@ const AttackerDetail: React.FC = () => { )} - {/* Stored Mail (admin only — bodies are attacker-controlled) */} -
STORED MAIL ({mail.length})} + toggle('mail')} - > - {mailForbidden ? ( - - ) : mail.length > 0 ? ( -
- - - - - - - - - - - - - - {mail.map((row) => { - let fields: Record = {}; - try { fields = JSON.parse(row.fields || '{}'); } catch {} - const storedAs = fields.stored_as ? String(fields.stored_as) : null; - return ( - - - - - - - - - - ); - })} - -
TIMESTAMPDECKYSUBJECTFROMDATE (attacker)SIZE
- {new Date(row.timestamp).toLocaleString()} - {row.decky} - {fields.subject || '—'} - - {fields.from_hdr || fields.from_addr || fields.mail_from || '—'} - - {fields.date_hdr || '—'} - - {fields.size ? `${fields.size} B` : '—'} - - {storedAs && ( - - )} -
-
- ) : ( - - )} -
- - {mailItem && ( - setMailItem(null)} - /> - )} + /> {/* Recorded PTY Sessions (SSH / Telnet) */}
({ + default: ({ storedAs, onClose }: { storedAs: string; onClose: () => void }) => ( +
+ drawer for {storedAs} + +
+ ), +})); + +const fields = (extra: Record = {}) => + JSON.stringify({ + stored_as: '/var/mail/attacker.eml', + subject: 'Hello victim', + from_hdr: 'attacker@example.invalid', + date_hdr: 'Sat, 09 May 2026 11:00:00 +0000', + size: 2048, + ...extra, + }); + +const row = (overrides: Partial = {}): MailLog => ({ + id: 1, + timestamp: '2026-05-09T11:00:00Z', + decky: 'decoy-01', + service: 'smtp', + fields: fields(), + ...overrides, +}); + +describe('MailLogPanel', () => { + it('renders rows with subject + from header parsed from SD fields', () => { + render( + {}} + />, + ); + expect(screen.getByText('Hello victim')).toBeInTheDocument(); + expect(screen.getByText('attacker@example.invalid')).toBeInTheDocument(); + }); + + it('renders the admin-required empty state when mailForbidden is true', () => { + render( + {}} />, + ); + expect(screen.getByText('ADMIN ROLE REQUIRED')).toBeInTheDocument(); + }); + + it('renders the no-mail empty state when not forbidden and list is empty', () => { + render( + {}} />, + ); + expect(screen.getByText('NO MAIL STORED')).toBeInTheDocument(); + }); + + it('falls back through from_hdr -> from_addr -> mail_from', () => { + render( + {}} + />, + ); + expect(screen.getByText('envelope@from.invalid')).toBeInTheDocument(); + }); + + it('opens and closes the MailDrawer on row OPEN button', async () => { + const user = userEvent.setup(); + render( + {}} + />, + ); + expect(screen.queryByTestId('mail-drawer')).not.toBeInTheDocument(); + await user.click(screen.getByText(/^OPEN$/)); + expect(screen.getByTestId('mail-drawer')).toBeInTheDocument(); + await user.click(screen.getByText('close')); + expect(screen.queryByTestId('mail-drawer')).not.toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/AttackerDetail/sections/MailLogPanel.tsx b/decnet_web/src/components/AttackerDetail/sections/MailLogPanel.tsx new file mode 100644 index 00000000..a38168b5 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/sections/MailLogPanel.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { Mail } from '../../../icons'; +import EmptyState from '../../EmptyState/EmptyState'; +import MailDrawer from '../../MailDrawer'; +import { Section } from '../ui'; +import type { MailLog } from '../types'; + +interface Props { + mail: MailLog[]; + mailForbidden: boolean; + open: boolean; + onToggle: () => void; +} + +interface DrawerSelection { + decky: string; + storedAs: string; + fields: Record; +} + +/** STORED MAIL collapsible — admin-gated (the bodies are + * attacker-controlled and never shown to lower roles). When the + * data hook reports `mailForbidden` from a 403 response the section + * renders an explicit "ADMIN ROLE REQUIRED" empty state instead of + * the generic "no rows" copy. */ +export const MailLogPanel: React.FC = ({ + mail, + mailForbidden, + open, + onToggle, +}) => { + const [selected, setSelected] = useState(null); + + return ( + <> +
STORED MAIL ({mail.length})} + open={open} + onToggle={onToggle} + > + {mailForbidden ? ( + + ) : mail.length > 0 ? ( +
+ + + + + + + + + + + + + + {mail.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; + return ( + + + + + + + + + + ); + })} + +
TIMESTAMPDECKYSUBJECTFROMDATE (attacker)SIZE
+ {new Date(row.timestamp).toLocaleString()} + {row.decky} + {(fields.subject as string | undefined) || '—'} + + {(fields.from_hdr as string | undefined) || + (fields.from_addr as string | undefined) || + (fields.mail_from as string | undefined) || + '—'} + + {(fields.date_hdr as string | undefined) || '—'} + + {fields.size ? `${fields.size} B` : '—'} + + {storedAs && ( + + )} +
+
+ ) : ( + + )} +
+ + {selected && ( + setSelected(null)} + /> + )} + + ); +};