refactor(decnet_web/AttackerDetail): extract ArtifactsPanel section
Lift CAPTURED ARTIFACTS into its own section, taking the drawer selection state with it (the parent shell no longer owns artifact-modal state). - New AttackerDetail/sections/ArtifactsPanel.tsx Drawer is rendered as a sibling of the section so its z-index and focus-trap behavior mirror the original. - ArtifactsPanel.test.tsx covers row rendering with parsed SD fields, empty state, missing stored_as (no OPEN button), and the open/close cycle. ArtifactDrawer is vi.mock'd to a stub so we don't need MSW handlers for its content fetch. - AttackerDetail.tsx loses the artifact JSX block, the artifact state, and now-unused Paperclip/Package/ArtifactDrawer imports.
This commit is contained in:
@@ -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<string, any> } | 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<string, any> } | null>(null);
|
||||
const [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
|
||||
|
||||
@@ -1499,89 +1500,11 @@ const AttackerDetail: React.FC = () => {
|
||||
<IntelPanel uuid={id!} />
|
||||
</Section>
|
||||
|
||||
{/* Captured Artifacts */}
|
||||
<Section
|
||||
title={<>CAPTURED ARTIFACTS ({artifacts.length})</>}
|
||||
<ArtifactsPanel
|
||||
artifacts={artifacts}
|
||||
open={openSections.artifacts}
|
||||
onToggle={() => toggle('artifacts')}
|
||||
>
|
||||
{artifacts.length > 0 ? (
|
||||
<div className="logs-table-container">
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TIMESTAMP</th>
|
||||
<th>DECKY</th>
|
||||
<th>FILENAME</th>
|
||||
<th>SIZE</th>
|
||||
<th>SHA-256</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{artifacts.map((row) => {
|
||||
let fields: Record<string, any> = {};
|
||||
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 (
|
||||
<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.orig_path ?? storedAs ?? '—'}
|
||||
</td>
|
||||
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>
|
||||
{fields.size ? `${fields.size} B` : '—'}
|
||||
</td>
|
||||
<td className="dim" style={{ fontFamily: 'monospace', fontSize: '0.7rem' }}>
|
||||
{sha ? `${sha.slice(0, 12)}…` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{storedAs && (
|
||||
<button
|
||||
onClick={() => setArtifact({ decky: row.decky, storedAs, fields })}
|
||||
title="Inspect captured artifact"
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Paperclip size={11} /> OPEN
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="NO ARTIFACTS CAPTURED"
|
||||
size="compact"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{artifact && (
|
||||
<ArtifactDrawer
|
||||
decky={artifact.decky}
|
||||
storedAs={artifact.storedAs}
|
||||
fields={artifact.fields}
|
||||
onClose={() => setArtifact(null)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* SMTP Victim Domains (viewer-safe rollup) */}
|
||||
<Section
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ArtifactsPanel } from './ArtifactsPanel';
|
||||
import type { ArtifactLog } from '../types';
|
||||
|
||||
// ArtifactDrawer fetches over the network; render a simple placeholder
|
||||
// so we can assert "the drawer opened" without standing up MSW handlers.
|
||||
vi.mock('../../ArtifactDrawer', () => ({
|
||||
default: ({ storedAs, onClose }: { storedAs: string; onClose: () => void }) => (
|
||||
<div data-testid="artifact-drawer">
|
||||
drawer for {storedAs}
|
||||
<button onClick={onClose}>close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const fields = (extra: Record<string, unknown> = {}) =>
|
||||
JSON.stringify({
|
||||
stored_as: '/var/captures/abc.bin',
|
||||
sha256: 'a'.repeat(64),
|
||||
size: 1024,
|
||||
orig_path: '/etc/passwd',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const row = (overrides: Partial<ArtifactLog> = {}): 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(
|
||||
<ArtifactsPanel
|
||||
artifacts={[row()]}
|
||||
open={true}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ArtifactsPanel artifacts={[]} open={true} onToggle={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText('NO ARTIFACTS CAPTURED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the OPEN button when stored_as is absent', () => {
|
||||
render(
|
||||
<ArtifactsPanel
|
||||
artifacts={[row({ fields: JSON.stringify({ orig_path: '/x' }) })]}
|
||||
open={true}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ArtifactsPanel
|
||||
artifacts={[row()]}
|
||||
open={true}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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<Props> = ({ artifacts, open, onToggle }) => {
|
||||
const [selected, setSelected] = useState<DrawerSelection | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section
|
||||
title={<>CAPTURED ARTIFACTS ({artifacts.length})</>}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{artifacts.length > 0 ? (
|
||||
<div className="logs-table-container">
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TIMESTAMP</th>
|
||||
<th>DECKY</th>
|
||||
<th>FILENAME</th>
|
||||
<th>SIZE</th>
|
||||
<th>SHA-256</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{artifacts.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;
|
||||
const sha = fields.sha256 ? String(fields.sha256) : '';
|
||||
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.orig_path as string | undefined) ?? storedAs ?? '—'}
|
||||
</td>
|
||||
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>
|
||||
{fields.size ? `${fields.size} B` : '—'}
|
||||
</td>
|
||||
<td
|
||||
className="dim"
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
{sha ? `${sha.slice(0, 12)}…` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{storedAs && (
|
||||
<button
|
||||
onClick={() => setSelected({ decky: row.decky, storedAs, fields })}
|
||||
title="Inspect captured artifact"
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Paperclip size={11} /> OPEN
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon={Package} title="NO ARTIFACTS CAPTURED" size="compact" />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{selected && (
|
||||
<ArtifactDrawer
|
||||
decky={selected.decky}
|
||||
storedAs={selected.storedAs}
|
||||
fields={selected.fields}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user