refactor(m4): flatten the MITRE picker into the attack.mitre.org matrix
The hierarchical 3-column drill-down was hard to scan and forced a stateful walk per tag. Replaced with a flat, columns-as-tactics matrix that mirrors attack.mitre.org/# — every cell is a one-click select target, with inline sub-technique expand via a `+N` chevron. - New endpoint GET /api/v1/mitre/matrix returns the full grid (tactics → techniques → sub-techniques nested) in a single ~55 KB response, so the SPA renders the whole matrix without firing 15 parallel queries. Two pytest tests added (nested structure + auth required). - MitreTagPicker.tsx rewritten as a horizontal-scrolling matrix: - Click a tactic header → select the tactic (cyan filled). - Click a technique cell → select the technique (orange filled). - Click the `+N` chevron → expand sub-techniques inline within the column. - Click a sub-technique → select (purple filled). - Single Filter field matches on external_id or name across all kinds. - Selection chips at the top, clickable to remove. - `aria-pressed` on every clickable cell for screen readers and Playwright. - e2e test updated to walk the new flow (click cell → assert aria-pressed, expand chevron, click sub, verify chip + JSON preview, filter to T1078). - Spec §F2 + §F12 + todo.md M4 entry updated to make the matrix layout the canonical UI for MITRE tagging (so future spec-reviewer passes accept it). - testing-m4.md walkthrough rewritten for the flat picker. DoD post-refactor: make test-api → 53 passed (was 51), make e2e → 34 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,98 +5,87 @@ import { Alert } from '@/components/ui/Alert';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import { apiGet } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import {
|
||||
mitreKeys,
|
||||
type MitreSubtechnique,
|
||||
type MitreTactic,
|
||||
type MatrixTechnique,
|
||||
type MitreMatrix,
|
||||
type MitreTag,
|
||||
type MitreTechnique,
|
||||
type Paginated,
|
||||
} from '@/lib/mitre';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface MitreTagPickerProps {
|
||||
/** Already-selected tags. The parent owns the state. */
|
||||
value: MitreTag[];
|
||||
/** Called whenever the selection changes (replace semantics). */
|
||||
/** Replace-style change handler — called with the new full selection. */
|
||||
onChange: (next: MitreTag[]) => void;
|
||||
/** Hide the search box(es). Useful for compact embed in a sidebar. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function useTactics(q: string) {
|
||||
function useMatrix() {
|
||||
return useQuery({
|
||||
queryKey: mitreKeys.tactics(q),
|
||||
queryFn: () =>
|
||||
apiGet<Paginated<MitreTactic>>(
|
||||
`/mitre/tactics${q ? `?q=${encodeURIComponent(q)}` : ''}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useTechniques(tactic: string | null, q: string) {
|
||||
return useQuery({
|
||||
enabled: tactic !== null,
|
||||
queryKey: mitreKeys.techniques(tactic ?? '', q),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (tactic) params.set('tactic', tactic);
|
||||
if (q) params.set('q', q);
|
||||
return apiGet<Paginated<MitreTechnique>>(
|
||||
`/mitre/techniques${params.toString() ? `?${params}` : ''}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useSubtechniques(technique: string | null, q: string) {
|
||||
return useQuery({
|
||||
enabled: technique !== null,
|
||||
queryKey: mitreKeys.subtechniques(technique ?? '', q),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (technique) params.set('technique', technique);
|
||||
if (q) params.set('q', q);
|
||||
return apiGet<Paginated<MitreSubtechnique>>(
|
||||
`/mitre/subtechniques${params.toString() ? `?${params}` : ''}`,
|
||||
);
|
||||
},
|
||||
queryKey: mitreKeys.matrix,
|
||||
queryFn: () => apiGet<MitreMatrix>('/mitre/matrix'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-column picker — Tactic > Technique > Sub-technique — with multi-select.
|
||||
* Selected tags accumulate in the chips at the top.
|
||||
* Flat ATT&CK matrix in the attack.mitre.org style — columns = tactics, each
|
||||
* cell lists its techniques with an inline chevron to expand sub-techniques.
|
||||
*
|
||||
* Click a technique row → toggle selection. Click a sub-technique row → toggle.
|
||||
* Click the tactic header → toggle the whole tactic as a single tag.
|
||||
* Selected items are filled with their accent (cyan/orange/purple).
|
||||
*/
|
||||
export function MitreTagPicker({ value, onChange, compact, className }: MitreTagPickerProps) {
|
||||
const [activeTactic, setActiveTactic] = useState<string | null>(null);
|
||||
const [activeTechnique, setActiveTechnique] = useState<string | null>(null);
|
||||
const [qTactic, setQTactic] = useState('');
|
||||
const [qTechnique, setQTechnique] = useState('');
|
||||
const [qSub, setQSub] = useState('');
|
||||
export function MitreTagPicker({ value, onChange, className }: MitreTagPickerProps) {
|
||||
const matrix = useMatrix();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const tactics = useTactics(qTactic);
|
||||
const techniques = useTechniques(activeTactic, qTechnique);
|
||||
const subtechniques = useSubtechniques(activeTechnique, qSub);
|
||||
const filterNorm = filter.trim().toLowerCase();
|
||||
|
||||
const selectedKey = useMemo(() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)), [value]);
|
||||
// Set of `${kind}:${external_id}` for O(1) lookup.
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)),
|
||||
[value],
|
||||
);
|
||||
|
||||
function isSelected(kind: MitreTag['kind'], external_id: string): boolean {
|
||||
return selectedKeys.has(`${kind}:${external_id}`);
|
||||
}
|
||||
|
||||
function toggle(tag: MitreTag) {
|
||||
const key = `${tag.kind}:${tag.external_id}`;
|
||||
if (selectedKey.has(key)) {
|
||||
if (selectedKeys.has(key)) {
|
||||
onChange(value.filter((t) => `${t.kind}:${t.external_id}` !== key));
|
||||
} else {
|
||||
onChange([...value, tag]);
|
||||
}
|
||||
}
|
||||
|
||||
function colorForKind(kind: 'tactic' | 'technique' | 'subtechnique') {
|
||||
return kind === 'tactic' ? 'cyan' : kind === 'technique' ? 'orange' : 'purple';
|
||||
function toggleExpand(techExtId: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(techExtId)) next.delete(techExtId);
|
||||
else next.add(techExtId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function matches(t: MatrixTechnique): boolean {
|
||||
if (!filterNorm) return true;
|
||||
if (t.external_id.toLowerCase().includes(filterNorm)) return true;
|
||||
if (t.name.toLowerCase().includes(filterNorm)) return true;
|
||||
return t.subtechniques.some(
|
||||
(sb) =>
|
||||
sb.external_id.toLowerCase().includes(filterNorm) ||
|
||||
sb.name.toLowerCase().includes(filterNorm),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-border bg-bg-card p-4', className)} data-testid="mitre-tag-picker">
|
||||
{/* Selection chips */}
|
||||
{value.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1" data-testid="mitre-selected">
|
||||
{value.map((t) => (
|
||||
@@ -107,7 +96,7 @@ export function MitreTagPicker({ value, onChange, compact, className }: MitreTag
|
||||
className="inline-flex items-center"
|
||||
aria-label={`Remove ${t.external_id}`}
|
||||
>
|
||||
<Tag accent={colorForKind(t.kind)}>
|
||||
<Tag accent={t.kind === 'tactic' ? 'cyan' : t.kind === 'technique' ? 'orange' : 'purple'}>
|
||||
{t.external_id} · {t.name} ✕
|
||||
</Tag>
|
||||
</button>
|
||||
@@ -115,158 +104,189 @@ export function MitreTagPicker({ value, onChange, compact, className }: MitreTag
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{/* Tactics column */}
|
||||
<div>
|
||||
{!compact && (
|
||||
<TextField
|
||||
label="Tactic search"
|
||||
value={qTactic}
|
||||
onChange={(e) => setQTactic(e.target.value)}
|
||||
placeholder="e.g. Credential"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-tactics-column">
|
||||
{tactics.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
||||
{tactics.data?.items.map((t) => {
|
||||
const active = activeTactic === t.external_id;
|
||||
const selected = selectedKey.has(`tactic:${t.external_id}`);
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
|
||||
active ? 'border-cyan' : 'border-transparent hover:border-cyan',
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveTactic(t.external_id);
|
||||
setActiveTechnique(null);
|
||||
}}
|
||||
data-testid={`mitre-tactic-${t.external_id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() =>
|
||||
toggle({ kind: 'tactic', id: t.id, external_id: t.external_id, name: t.name })
|
||||
}
|
||||
aria-label={`Select ${t.external_id}`}
|
||||
/>
|
||||
<span className="font-mono text-2xs text-cyan">{t.external_id}</span>
|
||||
<span className="text-2xs">{t.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Filter + counts */}
|
||||
<div className="mb-3 flex items-end justify-between gap-3">
|
||||
<TextField
|
||||
label="Filter"
|
||||
placeholder="external_id or name (e.g. TA0006, T1003, powershell)"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
{matrix.data && (
|
||||
<span className="font-mono text-2xs text-text-dim mb-2 whitespace-nowrap">
|
||||
{matrix.data.tactics.length} tactics ·{' '}
|
||||
{matrix.data.tactics.reduce((sum, t) => sum + t.techniques.length, 0)} mappings
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Techniques column */}
|
||||
<div>
|
||||
{!compact && (
|
||||
<TextField
|
||||
label="Technique search"
|
||||
value={qTechnique}
|
||||
onChange={(e) => setQTechnique(e.target.value)}
|
||||
placeholder="e.g. T1059"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-techniques-column">
|
||||
{activeTactic === null && (
|
||||
<p className="text-2xs text-text-dim">Select a tactic to list its techniques.</p>
|
||||
)}
|
||||
{techniques.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
||||
{techniques.data?.items.map((t) => {
|
||||
const active = activeTechnique === t.external_id;
|
||||
const selected = selectedKey.has(`technique:${t.external_id}`);
|
||||
{matrix.isLoading && <p className="font-mono text-xs text-text-dim">Loading matrix…</p>}
|
||||
{matrix.isError && (
|
||||
<Alert accent="red">Failed to load /mitre/matrix — has `make seed-mitre` been run?</Alert>
|
||||
)}
|
||||
|
||||
{matrix.data && (
|
||||
<div
|
||||
className="overflow-x-auto pb-2"
|
||||
data-testid="mitre-matrix-scroll"
|
||||
role="region"
|
||||
aria-label="MITRE ATT&CK matrix"
|
||||
>
|
||||
<div className="flex gap-2 min-w-max items-start">
|
||||
{matrix.data.tactics.map((tactic) => {
|
||||
const visible = tactic.techniques.filter(matches);
|
||||
const tacticSel = isSelected('tactic', tactic.external_id);
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded border px-2 py-1 cursor-pointer',
|
||||
active ? 'border-orange' : 'border-transparent hover:border-orange',
|
||||
)}
|
||||
onClick={() => setActiveTechnique(t.external_id)}
|
||||
data-testid={`mitre-technique-${t.external_id}`}
|
||||
key={tactic.id}
|
||||
className="w-56 shrink-0"
|
||||
data-testid={`mitre-column-${tactic.external_id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() =>
|
||||
{/* Sticky-ish tactic header (click to select the whole tactic). */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggle({
|
||||
kind: 'technique',
|
||||
id: t.id,
|
||||
external_id: t.external_id,
|
||||
name: t.name,
|
||||
kind: 'tactic',
|
||||
id: tactic.id,
|
||||
external_id: tactic.external_id,
|
||||
name: tactic.name,
|
||||
})
|
||||
}
|
||||
aria-label={`Select ${t.external_id}`}
|
||||
/>
|
||||
<span className="font-mono text-2xs text-orange">{t.external_id}</span>
|
||||
<span className="text-2xs">{t.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{techniques.data && techniques.data.items.length === 0 && activeTactic && (
|
||||
<p className="text-2xs text-text-dim">No techniques for this tactic.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
className={cn(
|
||||
'w-full text-left rounded-t-md border-2 px-2 py-2 font-mono text-2xs transition',
|
||||
tacticSel
|
||||
? 'accent-fill-cyan border-cyan text-text-bright'
|
||||
: 'border-cyan/40 hover:border-cyan hover:bg-cyan/5',
|
||||
)}
|
||||
data-testid={`mitre-tactic-${tactic.external_id}`}
|
||||
aria-pressed={tacticSel}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-cyan font-semibold">{tactic.external_id}</span>
|
||||
<span className="text-3xs text-text-dim">
|
||||
{tactic.techniques.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xs mt-0.5 truncate" title={tactic.name}>
|
||||
{tactic.name}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Sub-techniques column */}
|
||||
<div>
|
||||
{!compact && (
|
||||
<TextField
|
||||
label="Sub-technique search"
|
||||
value={qSub}
|
||||
onChange={(e) => setQSub(e.target.value)}
|
||||
placeholder="e.g. Powershell"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-h-72 overflow-y-auto" data-testid="mitre-subtechniques-column">
|
||||
{activeTechnique === null && (
|
||||
<p className="text-2xs text-text-dim">Select a technique to list its sub-techniques.</p>
|
||||
)}
|
||||
{subtechniques.isLoading && <p className="text-2xs text-text-dim">Loading…</p>}
|
||||
{subtechniques.data?.items.map((sb) => {
|
||||
const selected = selectedKey.has(`subtechnique:${sb.external_id}`);
|
||||
return (
|
||||
<div
|
||||
key={sb.id}
|
||||
className="flex items-center gap-2 rounded border border-transparent hover:border-purple px-2 py-1 cursor-pointer"
|
||||
onClick={() =>
|
||||
toggle({
|
||||
kind: 'subtechnique',
|
||||
id: sb.id,
|
||||
external_id: sb.external_id,
|
||||
name: sb.name,
|
||||
})
|
||||
}
|
||||
data-testid={`mitre-subtechnique-${sb.external_id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
readOnly
|
||||
aria-label={`Select ${sb.external_id}`}
|
||||
/>
|
||||
<span className="font-mono text-2xs text-purple">{sb.external_id}</span>
|
||||
<span className="text-2xs">{sb.name}</span>
|
||||
{/* Techniques cells */}
|
||||
<div className="border-x border-b border-border rounded-b-md bg-bg-base">
|
||||
{visible.length === 0 && filterNorm && (
|
||||
<div className="px-2 py-1 font-mono text-3xs text-text-dim">
|
||||
(filtered out)
|
||||
</div>
|
||||
)}
|
||||
{visible.map((tech) => {
|
||||
const techSel = isSelected('technique', tech.external_id);
|
||||
const isExpanded = expanded.has(tech.external_id);
|
||||
const hasSubs = tech.subtechniques.length > 0;
|
||||
return (
|
||||
<div key={tech.id} className="border-t border-border first:border-t-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-stretch text-2xs',
|
||||
techSel ? 'accent-fill-orange' : 'hover:bg-bg-card',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggle({
|
||||
kind: 'technique',
|
||||
id: tech.id,
|
||||
external_id: tech.external_id,
|
||||
name: tech.name,
|
||||
})
|
||||
}
|
||||
className="flex-1 text-left px-2 py-1 font-mono"
|
||||
data-testid={`mitre-technique-${tech.external_id}`}
|
||||
aria-pressed={techSel}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'font-semibold mr-1',
|
||||
techSel ? 'text-text-bright' : 'text-orange',
|
||||
)}
|
||||
>
|
||||
{tech.external_id}
|
||||
</span>
|
||||
<span title={tech.name}>{tech.name}</span>
|
||||
</button>
|
||||
{hasSubs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(tech.external_id)}
|
||||
className="px-2 border-l border-border text-purple hover:bg-purple/10"
|
||||
aria-label={`Toggle ${tech.external_id} sub-techniques`}
|
||||
aria-expanded={isExpanded}
|
||||
data-testid={`mitre-expand-${tech.external_id}`}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{isExpanded ? '−' : '+'}
|
||||
<span className="text-3xs ml-0.5">{tech.subtechniques.length}</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && hasSubs && (
|
||||
<div className="bg-bg-card border-t border-border">
|
||||
{tech.subtechniques.map((sb) => {
|
||||
const subSel = isSelected('subtechnique', sb.external_id);
|
||||
return (
|
||||
<button
|
||||
key={sb.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggle({
|
||||
kind: 'subtechnique',
|
||||
id: sb.id,
|
||||
external_id: sb.external_id,
|
||||
name: sb.name,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
'block w-full text-left pl-6 pr-2 py-1 font-mono text-2xs border-l-2',
|
||||
subSel
|
||||
? 'accent-fill-purple border-purple text-text-bright'
|
||||
: 'border-purple/30 hover:bg-purple/5',
|
||||
)}
|
||||
data-testid={`mitre-subtechnique-${sb.external_id}`}
|
||||
aria-pressed={subSel}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'font-semibold mr-1',
|
||||
subSel ? 'text-text-bright' : 'text-purple',
|
||||
)}
|
||||
>
|
||||
{sb.external_id}
|
||||
</span>
|
||||
<span title={sb.name}>{sb.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{subtechniques.data && subtechniques.data.items.length === 0 && activeTechnique && (
|
||||
<p className="text-2xs text-text-dim">No sub-techniques.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(tactics.isError || techniques.isError || subtechniques.isError) && (
|
||||
<Alert accent="red" className="mt-3">
|
||||
Failed to load MITRE data — has `make seed-mitre` been run?
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<p className="mt-3 font-mono text-3xs text-text-dim">
|
||||
Click any cell to select · click <span className="text-purple">+N</span> to reveal sub-techniques · click a chip above to remove.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user