refactor(decnet_web/AttackerDetail): extract MailLogPanel section
Lift STORED MAIL into its own section and pull the mail drawer selection state along with it. Section signals admin-gating through the section's own props (mailForbidden), since the data hook already converts a 403 into that boolean. - New AttackerDetail/sections/MailLogPanel.tsx - MailLogPanel.test.tsx covers row rendering, mailForbidden empty state, no-mail empty state, from_hdr/from_addr/mail_from fallback, and drawer open/close. MailDrawer vi.mock'd same as ArtifactDrawer. - AttackerDetail.tsx loses the mail JSX block, mailItem state, and now-unused Mail/MailDrawer imports.
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 api from '../utils/api';
|
||||||
import MailDrawer from './MailDrawer';
|
|
||||||
import SessionDrawer from './SessionDrawer';
|
import SessionDrawer from './SessionDrawer';
|
||||||
import EmptyState from './EmptyState/EmptyState';
|
import EmptyState from './EmptyState/EmptyState';
|
||||||
import TTPsObservedSection from './TTPsObservedSection';
|
import TTPsObservedSection from './TTPsObservedSection';
|
||||||
@@ -13,6 +12,7 @@ import { TimelineSection } from './AttackerDetail/sections/TimelineSection';
|
|||||||
import { ServicesTargeted } from './AttackerDetail/sections/ServicesTargeted';
|
import { ServicesTargeted } from './AttackerDetail/sections/ServicesTargeted';
|
||||||
import { CommandsViewer } from './AttackerDetail/sections/CommandsViewer';
|
import { CommandsViewer } from './AttackerDetail/sections/CommandsViewer';
|
||||||
import { ArtifactsPanel } from './AttackerDetail/sections/ArtifactsPanel';
|
import { ArtifactsPanel } from './AttackerDetail/sections/ArtifactsPanel';
|
||||||
|
import { MailLogPanel } from './AttackerDetail/sections/MailLogPanel';
|
||||||
import { Tag, Section } from './AttackerDetail/ui';
|
import { Tag, Section } from './AttackerDetail/ui';
|
||||||
import type {
|
import type {
|
||||||
AttackerBehavior,
|
AttackerBehavior,
|
||||||
@@ -1283,10 +1283,9 @@ const AttackerDetail: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Drawer selection (ephemeral UI; data feeds come from the hook).
|
// Drawer selection (ephemeral UI; data feeds come from the hook).
|
||||||
// Drawer selection (mail/session). The artifact drawer state moved
|
// Drawer selection (session). Artifact + mail drawer state are
|
||||||
// into ArtifactsPanel; mail and session follow in the next commits.
|
// owned by their respective sections.
|
||||||
const [session, setSession] = useState<{ decky: string; sid: string; fields: Record<string, any> } | null>(null);
|
const [session, setSession] = useState<{ decky: string; sid: string; fields: Record<string, any> } | null>(null);
|
||||||
const [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
|
|
||||||
|
|
||||||
const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
|
||||||
@@ -1552,98 +1551,12 @@ const AttackerDetail: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Stored Mail (admin only — bodies are attacker-controlled) */}
|
<MailLogPanel
|
||||||
<Section
|
mail={mail}
|
||||||
title={<>STORED MAIL ({mail.length})</>}
|
mailForbidden={mailForbidden}
|
||||||
open={openSections.mail}
|
open={openSections.mail}
|
||||||
onToggle={() => toggle('mail')}
|
onToggle={() => toggle('mail')}
|
||||||
>
|
/>
|
||||||
{mailForbidden ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Mail}
|
|
||||||
title="ADMIN ROLE REQUIRED"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
) : mail.length > 0 ? (
|
|
||||||
<div className="logs-table-container">
|
|
||||||
<table className="logs-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>TIMESTAMP</th>
|
|
||||||
<th>DECKY</th>
|
|
||||||
<th>SUBJECT</th>
|
|
||||||
<th>FROM</th>
|
|
||||||
<th>DATE (attacker)</th>
|
|
||||||
<th>SIZE</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{mail.map((row) => {
|
|
||||||
let fields: Record<string, any> = {};
|
|
||||||
try { fields = JSON.parse(row.fields || '{}'); } catch {}
|
|
||||||
const storedAs = fields.stored_as ? String(fields.stored_as) : null;
|
|
||||||
return (
|
|
||||||
<tr key={row.id}>
|
|
||||||
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
|
|
||||||
{new Date(row.timestamp).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="violet-accent">{row.decky}</td>
|
|
||||||
<td className="matrix-text" style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
|
||||||
{fields.subject || '—'}
|
|
||||||
</td>
|
|
||||||
<td className="matrix-text" style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
|
||||||
{fields.from_hdr || fields.from_addr || fields.mail_from || '—'}
|
|
||||||
</td>
|
|
||||||
<td className="matrix-text" style={{ fontFamily: 'monospace', whiteSpace: 'nowrap', fontSize: '0.75rem' }}>
|
|
||||||
{fields.date_hdr || '—'}
|
|
||||||
</td>
|
|
||||||
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>
|
|
||||||
{fields.size ? `${fields.size} B` : '—'}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{storedAs && (
|
|
||||||
<button
|
|
||||||
onClick={() => setMailItem({ decky: row.decky, storedAs, fields })}
|
|
||||||
title="Inspect stored message"
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: '6px',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
backgroundColor: 'var(--warn-tint-10)',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
border: '1px solid var(--warn)',
|
|
||||||
color: 'var(--warn)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Mail size={11} /> OPEN
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={Mail}
|
|
||||||
title="NO MAIL STORED"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{mailItem && (
|
|
||||||
<MailDrawer
|
|
||||||
decky={mailItem.decky}
|
|
||||||
storedAs={mailItem.storedAs}
|
|
||||||
fields={mailItem.fields}
|
|
||||||
onClose={() => setMailItem(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recorded PTY Sessions (SSH / Telnet) */}
|
{/* Recorded PTY Sessions (SSH / Telnet) */}
|
||||||
<Section
|
<Section
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MailLogPanel } from './MailLogPanel';
|
||||||
|
import type { MailLog } from '../types';
|
||||||
|
|
||||||
|
vi.mock('../../MailDrawer', () => ({
|
||||||
|
default: ({ storedAs, onClose }: { storedAs: string; onClose: () => void }) => (
|
||||||
|
<div data-testid="mail-drawer">
|
||||||
|
drawer for {storedAs}
|
||||||
|
<button onClick={onClose}>close</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fields = (extra: Record<string, unknown> = {}) =>
|
||||||
|
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> = {}): 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(
|
||||||
|
<MailLogPanel
|
||||||
|
mail={[row()]}
|
||||||
|
mailForbidden={false}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<MailLogPanel mail={[]} mailForbidden={true} open={true} onToggle={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('ADMIN ROLE REQUIRED')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the no-mail empty state when not forbidden and list is empty', () => {
|
||||||
|
render(
|
||||||
|
<MailLogPanel mail={[]} mailForbidden={false} open={true} onToggle={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('NO MAIL STORED')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back through from_hdr -> from_addr -> mail_from', () => {
|
||||||
|
render(
|
||||||
|
<MailLogPanel
|
||||||
|
mail={[
|
||||||
|
row({
|
||||||
|
fields: JSON.stringify({
|
||||||
|
stored_as: '/var/mail/x.eml',
|
||||||
|
mail_from: 'envelope@from.invalid',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
mailForbidden={false}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('envelope@from.invalid')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens and closes the MailDrawer on row OPEN button', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MailLogPanel
|
||||||
|
mail={[row()]}
|
||||||
|
mailForbidden={false}
|
||||||
|
open={true}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<Props> = ({
|
||||||
|
mail,
|
||||||
|
mailForbidden,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
const [selected, setSelected] = useState<DrawerSelection | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section
|
||||||
|
title={<>STORED MAIL ({mail.length})</>}
|
||||||
|
open={open}
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
{mailForbidden ? (
|
||||||
|
<EmptyState icon={Mail} title="ADMIN ROLE REQUIRED" size="compact" />
|
||||||
|
) : mail.length > 0 ? (
|
||||||
|
<div className="logs-table-container">
|
||||||
|
<table className="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>TIMESTAMP</th>
|
||||||
|
<th>DECKY</th>
|
||||||
|
<th>SUBJECT</th>
|
||||||
|
<th>FROM</th>
|
||||||
|
<th>DATE (attacker)</th>
|
||||||
|
<th>SIZE</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mail.map((row) => {
|
||||||
|
let fields: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
fields = JSON.parse(row.fields || '{}');
|
||||||
|
} catch {
|
||||||
|
// malformed SD params — preview unavailable
|
||||||
|
}
|
||||||
|
const storedAs = fields.stored_as ? String(fields.stored_as) : null;
|
||||||
|
return (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
|
{new Date(row.timestamp).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="violet-accent">{row.decky}</td>
|
||||||
|
<td
|
||||||
|
className="matrix-text"
|
||||||
|
style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{(fields.subject as string | undefined) || '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="matrix-text"
|
||||||
|
style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{(fields.from_hdr as string | undefined) ||
|
||||||
|
(fields.from_addr as string | undefined) ||
|
||||||
|
(fields.mail_from as string | undefined) ||
|
||||||
|
'—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="matrix-text"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(fields.date_hdr as string | undefined) || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>
|
||||||
|
{fields.size ? `${fields.size} B` : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{storedAs && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected({ decky: row.decky, storedAs, fields })}
|
||||||
|
title="Inspect stored message"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
backgroundColor: 'var(--warn-tint-10)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid var(--warn)',
|
||||||
|
color: 'var(--warn)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Mail size={11} /> OPEN
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState icon={Mail} title="NO MAIL STORED" size="compact" />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<MailDrawer
|
||||||
|
decky={selected.decky}
|
||||||
|
storedAs={selected.storedAs}
|
||||||
|
fields={selected.fields}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user