The +New test modal capped at max-w-2xl rendered the 15-column MITRE matrix in a 672px frame with no height cap, so the matrix spilled to the right of the dialog, the form bottom dropped below the viewport, and neither scroll direction worked — buttons were unreachable. - Modal: add a `size` prop (default 2xl, back-compat) with a `7xl` preset. Cap height at calc(100vh-2rem), make the header sticky, and wrap children in a min-w-0 flex-1 overflow-y-auto body so tall content scrolls inside. - MitreTagPicker: move overflow-x-auto from the grid itself to a dedicated scroller wrapper, and add `min-w-0` so the constraint propagates from the modal body. The grid's 1680px intrinsic min-width previously prevented the parent's overflow-x-auto from kicking in. - AdminTestsPage: switch the form layout from `grid gap-3` to `flex flex-col gap-3 min-w-0` and set the modal size to 7xl. The CSS Grid form was propagating min-width: auto to all its items, which let the picker drag the body past the modal width. - AdminScenariosPage: bump the modal to size 3xl for breathing room around the catalogue picker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
|
|
import { MarkdownField } from '@/components/MarkdownField';
|
|
import { MitreTagPicker } from '@/components/MitreTagPicker';
|
|
import { Alert } from '@/components/ui/Alert';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Modal } from '@/components/ui/Modal';
|
|
import { SectionHeader } from '@/components/ui/SectionHeader';
|
|
import { Tag } from '@/components/ui/Tag';
|
|
import { TextField } from '@/components/ui/TextField';
|
|
import {
|
|
ApiError,
|
|
apiDelete,
|
|
apiGet,
|
|
apiPost,
|
|
apiPut,
|
|
} from '@/lib/api';
|
|
import { type MitreTag, type MitreTagKind } from '@/lib/mitre';
|
|
import {
|
|
buildTestQueryString,
|
|
templateKeys,
|
|
type CreateTestTemplatePayload,
|
|
type OpsecLevel,
|
|
type TestTemplate,
|
|
type TestTemplateFilters,
|
|
type TestTemplateListResponse,
|
|
} from '@/lib/templates';
|
|
|
|
const OPSEC_LEVELS: OpsecLevel[] = ['low', 'medium', 'high'];
|
|
|
|
interface FormState {
|
|
name: string;
|
|
description: string;
|
|
objective: string;
|
|
procedure_md: string;
|
|
prerequisites_md: string;
|
|
expected_result_red_md: string;
|
|
expected_detection_blue_md: string;
|
|
opsec_level: OpsecLevel;
|
|
tags: string;
|
|
expected_iocs: string;
|
|
mitre_tags: MitreTag[];
|
|
}
|
|
|
|
function blankForm(): FormState {
|
|
return {
|
|
name: '',
|
|
description: '',
|
|
objective: '',
|
|
procedure_md: '',
|
|
prerequisites_md: '',
|
|
expected_result_red_md: '',
|
|
expected_detection_blue_md: '',
|
|
opsec_level: 'medium',
|
|
tags: '',
|
|
expected_iocs: '',
|
|
mitre_tags: [],
|
|
};
|
|
}
|
|
|
|
function toForm(t: TestTemplate): FormState {
|
|
return {
|
|
name: t.name,
|
|
description: t.description ?? '',
|
|
objective: t.objective ?? '',
|
|
procedure_md: t.procedure_md ?? '',
|
|
prerequisites_md: t.prerequisites_md ?? '',
|
|
expected_result_red_md: t.expected_result_red_md ?? '',
|
|
expected_detection_blue_md: t.expected_detection_blue_md ?? '',
|
|
opsec_level: t.opsec_level,
|
|
tags: t.tags.join(', '),
|
|
expected_iocs: t.expected_iocs.join(', '),
|
|
mitre_tags: t.mitre_tags.map((tag) => ({
|
|
kind: tag.kind as MitreTagKind,
|
|
id: tag.external_id,
|
|
external_id: tag.external_id,
|
|
name: tag.name,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function csvToList(s: string): string[] {
|
|
return s.split(',').map((x) => x.trim()).filter(Boolean);
|
|
}
|
|
|
|
function toPayload(form: FormState): CreateTestTemplatePayload {
|
|
return {
|
|
name: form.name.trim(),
|
|
description: form.description || null,
|
|
objective: form.objective || null,
|
|
procedure_md: form.procedure_md || null,
|
|
prerequisites_md: form.prerequisites_md || null,
|
|
expected_result_red_md: form.expected_result_red_md || null,
|
|
expected_detection_blue_md: form.expected_detection_blue_md || null,
|
|
opsec_level: form.opsec_level,
|
|
tags: csvToList(form.tags),
|
|
expected_iocs: csvToList(form.expected_iocs),
|
|
mitre_tags: form.mitre_tags.map((t) => ({ kind: t.kind, external_id: t.external_id })),
|
|
};
|
|
}
|
|
|
|
function useTestTemplates(filters: TestTemplateFilters) {
|
|
return useQuery({
|
|
queryKey: templateKeys.tests(filters),
|
|
queryFn: () =>
|
|
apiGet<TestTemplateListResponse>(`/test-templates${buildTestQueryString(filters)}`),
|
|
});
|
|
}
|
|
|
|
export function AdminTestsPage() {
|
|
const qc = useQueryClient();
|
|
const [filters, setFilters] = useState<TestTemplateFilters>({});
|
|
const [editing, setEditing] = useState<TestTemplate | null>(null);
|
|
const [creating, setCreating] = useState(false);
|
|
const [form, setForm] = useState<FormState>(blankForm());
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const tests = useTestTemplates(filters);
|
|
|
|
const create = useMutation({
|
|
mutationFn: (payload: CreateTestTemplatePayload) =>
|
|
apiPost<TestTemplate>('/test-templates', payload),
|
|
onSuccess: async () => {
|
|
setCreating(false);
|
|
setForm(blankForm());
|
|
setError(null);
|
|
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
|
|
},
|
|
onError: (e) => setError(humanError(e)),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ id, payload }: { id: string; payload: CreateTestTemplatePayload }) =>
|
|
apiPut<TestTemplate>(`/test-templates/${id}`, payload),
|
|
onSuccess: async () => {
|
|
setEditing(null);
|
|
setError(null);
|
|
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
|
|
},
|
|
onError: (e) => setError(humanError(e)),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/test-templates/${id}`),
|
|
onSuccess: async () => {
|
|
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
|
|
},
|
|
});
|
|
|
|
function openCreate() {
|
|
setForm(blankForm());
|
|
setError(null);
|
|
setCreating(true);
|
|
}
|
|
|
|
function openEdit(t: TestTemplate) {
|
|
setForm(toForm(t));
|
|
setError(null);
|
|
setEditing(t);
|
|
}
|
|
|
|
function submit() {
|
|
const payload = toPayload(form);
|
|
if (!payload.name) {
|
|
setError('Name is required.');
|
|
return;
|
|
}
|
|
if (editing) update.mutate({ id: editing.id, payload });
|
|
else create.mutate(payload);
|
|
}
|
|
|
|
const isModalOpen = creating || editing !== null;
|
|
|
|
return (
|
|
<>
|
|
<SectionHeader
|
|
prefix="Admin"
|
|
highlight="Tests"
|
|
accent="orange"
|
|
description="Reusable test units. Each test belongs to a scenario at instantiation time, but the catalogue lives independently."
|
|
/>
|
|
|
|
<div className="mb-6 flex flex-wrap items-end gap-3" data-testid="tests-filters">
|
|
<TextField
|
|
label="Search"
|
|
placeholder="name or description"
|
|
value={filters.q ?? ''}
|
|
onChange={(e) => setFilters((f) => ({ ...f, q: e.target.value || undefined }))}
|
|
data-testid="filter-q"
|
|
/>
|
|
<TextField
|
|
label="Tactic external_id"
|
|
placeholder="TA0006"
|
|
value={filters.tactic ?? ''}
|
|
onChange={(e) => setFilters((f) => ({ ...f, tactic: e.target.value || undefined }))}
|
|
data-testid="filter-tactic"
|
|
/>
|
|
<label className="block">
|
|
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
|
|
OPSEC
|
|
</span>
|
|
<select
|
|
value={filters.opsec ?? ''}
|
|
onChange={(e) =>
|
|
setFilters((f) => ({ ...f, opsec: (e.target.value as OpsecLevel | '') || undefined }))
|
|
}
|
|
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
|
|
data-testid="filter-opsec"
|
|
>
|
|
<option value="">— all —</option>
|
|
{OPSEC_LEVELS.map((lv) => (
|
|
<option key={lv} value={lv}>{lv}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<TextField
|
|
label="Free tag"
|
|
placeholder="phish"
|
|
value={filters.tag ?? ''}
|
|
onChange={(e) => setFilters((f) => ({ ...f, tag: e.target.value || undefined }))}
|
|
data-testid="filter-tag"
|
|
/>
|
|
<Button accent="orange" onClick={openCreate} data-testid="create-test" className="ml-auto">
|
|
+ New test
|
|
</Button>
|
|
</div>
|
|
|
|
{tests.isError && <Alert accent="red">Failed to load tests.</Alert>}
|
|
|
|
<div className="grid gap-3" data-testid="tests-list">
|
|
{tests.isLoading && <p className="font-mono text-xs text-text-dim">Loading…</p>}
|
|
{tests.data?.items.map((t) => (
|
|
<Card
|
|
key={t.id}
|
|
accent="orange"
|
|
title={t.name}
|
|
sub={t.description ?? '—'}
|
|
data-testid={`test-row-${t.id}`}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Tag accent={t.opsec_level === 'high' ? 'red' : t.opsec_level === 'low' ? 'green' : 'yellow'}>
|
|
opsec: {t.opsec_level}
|
|
</Tag>
|
|
{t.mitre_tags.map((tag) => (
|
|
<Tag
|
|
key={`${tag.kind}:${tag.external_id}`}
|
|
accent={tag.kind === 'tactic' ? 'cyan' : tag.kind === 'technique' ? 'orange' : 'purple'}
|
|
>
|
|
{tag.external_id}
|
|
</Tag>
|
|
))}
|
|
{t.tags.map((tg) => (
|
|
<Tag key={tg} accent="cyan">#{tg}</Tag>
|
|
))}
|
|
<div className="ml-auto flex gap-2">
|
|
<Button accent="orange" onClick={() => openEdit(t)} data-testid={`edit-test-${t.id}`}>
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
accent="rose"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
if (window.confirm(`Soft-delete "${t.name}"?`)) remove.mutate(t.id);
|
|
}}
|
|
data-testid={`delete-test-${t.id}`}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
{tests.data && tests.data.items.length === 0 && !tests.isLoading && (
|
|
<p className="font-mono text-2xs text-text-dim">No tests match the current filters.</p>
|
|
)}
|
|
</div>
|
|
|
|
<Modal
|
|
open={isModalOpen}
|
|
title={editing ? `Test · ${editing.name}` : 'New test template'}
|
|
accent="orange"
|
|
size="7xl"
|
|
onClose={() => {
|
|
setCreating(false);
|
|
setEditing(null);
|
|
}}
|
|
testid="test-template-modal"
|
|
>
|
|
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
|
|
<div className="flex flex-col gap-3 min-w-0">
|
|
<TextField
|
|
label="Name"
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
data-testid="form-name"
|
|
/>
|
|
<TextField
|
|
label="Description"
|
|
value={form.description}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
data-testid="form-description"
|
|
/>
|
|
<TextField
|
|
label="Objective (1-liner)"
|
|
value={form.objective}
|
|
onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))}
|
|
/>
|
|
<MarkdownField
|
|
label="Procedure"
|
|
value={form.procedure_md}
|
|
onChange={(v) => setForm((f) => ({ ...f, procedure_md: v }))}
|
|
data-testid="form-procedure"
|
|
hint="Step-by-step playbook. Markdown supported."
|
|
/>
|
|
<MarkdownField
|
|
label="Prerequisites"
|
|
value={form.prerequisites_md}
|
|
onChange={(v) => setForm((f) => ({ ...f, prerequisites_md: v }))}
|
|
/>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<MarkdownField
|
|
label="Red — expected result"
|
|
value={form.expected_result_red_md}
|
|
onChange={(v) => setForm((f) => ({ ...f, expected_result_red_md: v }))}
|
|
rows={4}
|
|
/>
|
|
<MarkdownField
|
|
label="Blue — expected detection"
|
|
value={form.expected_detection_blue_md}
|
|
onChange={(v) => setForm((f) => ({ ...f, expected_detection_blue_md: v }))}
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
<label className="block">
|
|
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
|
|
OPSEC level
|
|
</span>
|
|
<select
|
|
value={form.opsec_level}
|
|
onChange={(e) => setForm((f) => ({ ...f, opsec_level: e.target.value as OpsecLevel }))}
|
|
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
|
|
data-testid="form-opsec"
|
|
>
|
|
{OPSEC_LEVELS.map((lv) => (
|
|
<option key={lv} value={lv}>{lv}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<TextField
|
|
label="Free tags (comma-separated)"
|
|
value={form.tags}
|
|
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
|
|
data-testid="form-tags"
|
|
hint="e.g. phish, persistence, quick-win"
|
|
/>
|
|
<TextField
|
|
label="Expected IOCs (comma-separated)"
|
|
value={form.expected_iocs}
|
|
onChange={(e) => setForm((f) => ({ ...f, expected_iocs: e.target.value }))}
|
|
hint="Indicators the blue team should look for"
|
|
/>
|
|
<div>
|
|
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-1">
|
|
MITRE ATT&CK tags
|
|
</p>
|
|
<MitreTagPicker
|
|
value={form.mitre_tags}
|
|
onChange={(next) => setForm((f) => ({ ...f, mitre_tags: next }))}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setCreating(false);
|
|
setEditing(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
accent="orange"
|
|
onClick={submit}
|
|
disabled={create.isPending || update.isPending}
|
|
data-testid="form-submit"
|
|
>
|
|
{editing ? 'Save' : 'Create'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function humanError(e: unknown): string {
|
|
if (e instanceof ApiError) {
|
|
const p = e.payload as { error?: string; message?: string } | null;
|
|
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
|
|
}
|
|
return e instanceof Error ? e.message : 'Unexpected error';
|
|
}
|