diff --git a/frontend/src/api/mitre.ts b/frontend/src/api/mitre.ts index 4750d62..aadd042 100644 --- a/frontend/src/api/mitre.ts +++ b/frontend/src/api/mitre.ts @@ -1,5 +1,5 @@ import { apiClient } from './client'; -import type { MitreTechnique } from './types'; +import type { MitreTactic, MitreTechnique } from './types'; export async function searchMitreTechniques(query: string): Promise { const { data } = await apiClient.get('/mitre/techniques', { @@ -7,3 +7,8 @@ export async function searchMitreTechniques(query: string): Promise { + const { data } = await apiClient.get('/mitre/matrix'); + return data; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d640768..6c73e3e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -61,12 +61,28 @@ export interface MitreTechnique { tactics: string[]; } +export interface MitreMatrixSubtechnique { + id: string; + name: string; +} + +export interface MitreMatrixTechnique { + id: string; + name: string; + subtechniques: MitreMatrixSubtechnique[]; +} + +export interface MitreTactic { + tactic_id: string; + tactic_name: string; + techniques: MitreMatrixTechnique[]; +} + export interface Simulation { id: number; engagement_id: number; name: string; - mitre_technique_id: string | null; - mitre_technique_name: string | null; + techniques: MitreTechnique[]; description: string | null; commands: string | null; prerequisites: string | null; @@ -88,8 +104,7 @@ export interface SimulationCreateInput { export interface SimulationPatchInput { name?: string; - mitre_technique_id?: string | null; - mitre_technique_name?: string | null; + technique_ids?: string[]; description?: string | null; commands?: string | null; prerequisites?: string | null; diff --git a/frontend/src/components/MitreMatrixModal.tsx b/frontend/src/components/MitreMatrixModal.tsx new file mode 100644 index 0000000..6f170f7 --- /dev/null +++ b/frontend/src/components/MitreMatrixModal.tsx @@ -0,0 +1,344 @@ +import { useEffect, useRef, useState } from 'react'; +import { LoadingState } from './LoadingState'; +import { ErrorState } from './ErrorState'; +import { extractApiError } from '@/api/client'; +import { useMitreMatrix } from '@/hooks/useMitre'; +import type { MitreTechnique } from '@/api/types'; + +interface MitreMatrixModalProps { + isOpen: boolean; + initialSelection: MitreTechnique[]; + onApply: (selection: MitreTechnique[]) => void; + onCancel: () => void; +} + +function techniqueInTactic( + tacticTechniques: { id: string; subtechniques: { id: string }[] }[], + selection: Set, +): number { + let count = 0; + for (const t of tacticTechniques) { + if (selection.has(t.id)) count++; + for (const s of t.subtechniques) { + if (selection.has(s.id)) count++; + } + } + return count; +} + +export function MitreMatrixModal({ + isOpen, + initialSelection, + onApply, + onCancel, +}: MitreMatrixModalProps): JSX.Element | null { + const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen); + + // Selected IDs → Map id → {id, name} for reconstruct + const [selectedMap, setSelectedMap] = useState>( + () => new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])), + ); + const [expandedTechniques, setExpandedTechniques] = useState>(new Set()); + const [search, setSearch] = useState(''); + + const containerRef = useRef(null); + const searchInputRef = useRef(null); + + // Reset local state when modal opens with new initialSelection + useEffect(() => { + if (isOpen) { + setSelectedMap(new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }]))); + setExpandedTechniques(new Set()); + setSearch(''); + } + }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + // Focus search input on open + useEffect(() => { + if (isOpen) { + // Small delay lets the DOM render before focus + setTimeout(() => searchInputRef.current?.focus(), 0); + } + }, [isOpen]); + + // Escape closes modal + useEffect(() => { + if (!isOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isOpen, onCancel]); + + const getFocusableElements = () => { + if (!containerRef.current) return []; + return Array.from( + containerRef.current.querySelectorAll( + 'a, button, input, [tabindex]:not([tabindex="-1"])', + ), + ).filter((el) => !(el as HTMLButtonElement | HTMLInputElement).disabled && !el.hidden && el.tabIndex !== -1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Tab') return; + const focusables = getFocusableElements(); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + if (!isOpen) return null; + + const toggleTechnique = (id: string, name: string) => { + setSelectedMap((prev) => { + const next = new Map(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.set(id, { id, name }); + } + return next; + }); + }; + + const toggleExpand = (id: string) => { + setExpandedTechniques((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const searchLower = search.toLowerCase().trim(); + + // Figure out which technique IDs should be auto-expanded due to a sub-technique match + const autoExpanded = new Set(); + if (searchLower && matrix) { + for (const tactic of matrix) { + for (const tech of tactic.techniques) { + const subMatch = tech.subtechniques.some( + (s) => s.id.toLowerCase().includes(searchLower) || s.name.toLowerCase().includes(searchLower), + ); + if (subMatch) autoExpanded.add(tech.id); + } + } + } + + const handleApply = () => { + // Reconstruct MitreTechnique[] from selected IDs. + // tactics are not available here; parent will use what it has or send [] + const selection: MitreTechnique[] = Array.from(selectedMap.values()).map((t) => ({ + id: t.id, + name: t.name, + tactics: [], + })); + onApply(selection); + }; + + const totalSelected = selectedMap.size; + + return ( +
+ {/* Backdrop */} + + ); +} diff --git a/frontend/src/components/MitreTechniquePicker.tsx b/frontend/src/components/MitreTechniquePicker.tsx index a86ccbc..5d0106b 100644 --- a/frontend/src/components/MitreTechniquePicker.tsx +++ b/frontend/src/components/MitreTechniquePicker.tsx @@ -1,55 +1,26 @@ -import { - useEffect, - useRef, - useState, - type KeyboardEvent, -} from 'react'; +import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; import { extractApiError } from '@/api/client'; import type { MitreTechnique } from '@/api/types'; import { useMitreSearch } from '@/hooks/useMitre'; interface MitreTechniquePickerProps { - techniqueId: string | null; - techniqueName: string | null; - onChange: (id: string | null, name: string | null) => void; + onSelect: (technique: MitreTechnique) => void; disabled?: boolean; } -function formatOption(t: MitreTechnique): string { - const tacticList = t.tactics.length > 0 ? ` (${t.tactics[0]})` : ''; - return `${t.id} — ${t.name}${tacticList}`; -} - const DEBOUNCE_MS = 200; export function MitreTechniquePicker({ - techniqueId, - techniqueName, - onChange, + onSelect, disabled = false, }: MitreTechniquePickerProps): JSX.Element { - const [inputValue, setInputValue] = useState( - techniqueId && techniqueName ? `${techniqueId} — ${techniqueName}` : '', - ); + const [inputValue, setInputValue] = useState(''); const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const debounceRef = useRef | null>(null); const containerRef = useRef(null); const listRef = useRef(null); - // True once we've synced the first real techniqueId from props (parent/API load). - // After that we stop reacting to null, so keystrokes that emit onChange(null,null) - // don't propagate back and wipe the input mid-stroke. - const hasHydratedFromProps = useRef(false); - - useEffect(() => { - if (techniqueId && techniqueName) { - setInputValue(`${techniqueId} — ${techniqueName}`); - hasHydratedFromProps.current = true; - } else if (!techniqueId && !hasHydratedFromProps.current) { - setInputValue(''); - } - }, [techniqueId, techniqueName]); const { data: results, isFetching, isError, error } = useMitreSearch(query, open); @@ -57,8 +28,6 @@ export function MitreTechniquePicker({ const handleInputChange = (value: string) => { setInputValue(value); - // Clear the selection when user starts typing - onChange(null, null); setOpen(true); setActiveIndex(-1); @@ -69,11 +38,12 @@ export function MitreTechniquePicker({ }; const selectItem = (item: MitreTechnique) => { - setInputValue(formatOption(item)); - onChange(item.id, item.name); + onSelect(item); + // Reset to empty after selection — parent handles append + dedup + setInputValue(''); + setQuery(''); setOpen(false); setActiveIndex(-1); - setQuery(''); }; const handleKeyDown = (e: KeyboardEvent) => { @@ -98,7 +68,6 @@ export function MitreTechniquePicker({ } }; - // Scroll active item into view useEffect(() => { if (activeIndex >= 0 && listRef.current) { const el = listRef.current.children[activeIndex] as HTMLElement | undefined; @@ -106,7 +75,6 @@ export function MitreTechniquePicker({ } }, [activeIndex]); - // Close dropdown on click outside useEffect(() => { const onPointerDown = (e: PointerEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { @@ -127,13 +95,11 @@ export function MitreTechniquePicker({ aria-expanded={open} aria-controls={listboxId} aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined} - aria-label="MITRE technique" + aria-label="Search MITRE technique" className="text-input" value={inputValue} onChange={(e) => handleInputChange(e.target.value)} - onFocus={() => { - if (!techniqueId) setOpen(true); - }} + onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} disabled={disabled} placeholder="Search by ID or name (e.g. T1059)" @@ -174,7 +140,6 @@ export function MitreTechniquePicker({ i === activeIndex ? 'bg-primary-soft text-ink' : 'text-ink hover:bg-cloud' }`} onPointerDown={(e) => { - // Prevent input blur before we handle the click e.preventDefault(); selectItem(item); }} diff --git a/frontend/src/components/MitreTechniqueTag.tsx b/frontend/src/components/MitreTechniqueTag.tsx new file mode 100644 index 0000000..a4d15be --- /dev/null +++ b/frontend/src/components/MitreTechniqueTag.tsx @@ -0,0 +1,33 @@ +import type { MitreTechnique } from '@/api/types'; + +interface MitreTechniqueTagProps { + technique: MitreTechnique; + onRemove: () => void; + disabled?: boolean; +} + +export function MitreTechniqueTag({ + technique, + onRemove, + disabled = false, +}: MitreTechniqueTagProps): JSX.Element { + return ( + + {technique.id} + — {technique.name} + {!disabled && ( + + )} + + ); +} diff --git a/frontend/src/components/MitreTechniquesField.tsx b/frontend/src/components/MitreTechniquesField.tsx new file mode 100644 index 0000000..b454fee --- /dev/null +++ b/frontend/src/components/MitreTechniquesField.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import { extractApiError } from '@/api/client'; +import type { MitreTechnique } from '@/api/types'; +import { useUpdateSimulation } from '@/hooks/useSimulations'; +import { useToast } from '@/hooks/useToast'; +import { MitreTechniqueTag } from './MitreTechniqueTag'; +import { MitreTechniquePicker } from './MitreTechniquePicker'; +import { MitreMatrixModal } from './MitreMatrixModal'; + +interface MitreTechniquesFieldProps { + value: MitreTechnique[]; + simulationId: number; + engagementId: number; + disabled?: boolean; +} + +export function MitreTechniquesField({ + value, + simulationId, + engagementId, + disabled = false, +}: MitreTechniquesFieldProps): JSX.Element { + const [showMatrix, setShowMatrix] = useState(false); + const [showPicker, setShowPicker] = useState(false); + + const { push } = useToast(); + const updateMutation = useUpdateSimulation(simulationId, engagementId); + + const save = async (techniques: MitreTechnique[]) => { + try { + await updateMutation.mutateAsync({ + technique_ids: techniques.map((t) => t.id), + }); + push('Techniques updated', 'success'); + } catch (err) { + push(extractApiError(err, 'Could not update techniques'), 'error'); + } + }; + + const handleRemove = (id: string) => { + const next = value.filter((t) => t.id !== id); + void save(next); + }; + + const handleSelect = (technique: MitreTechnique) => { + // Dedup: no-op if already present + if (value.some((t) => t.id === technique.id)) return; + const next = [...value, technique]; + void save(next); + }; + + const handleMatrixApply = (selection: MitreTechnique[]) => { + setShowMatrix(false); + // Merge: preserve existing tactics on items already in value, fill from selection otherwise. + // The backend re-enriches tactics at serialize time, so the exact tactics here don't matter. + const merged = selection.map((s) => { + const existing = value.find((v) => v.id === s.id); + return existing ?? s; + }); + void save(merged); + }; + + const isPending = updateMutation.isPending; + + return ( +
+ {/* Tag list */} + {value.length === 0 ? ( +

+ No techniques selected — use the matrix or the quick search to add. +

+ ) : ( +
+ {value.map((t) => ( + handleRemove(t.id)} + disabled={disabled || isPending} + /> + ))} +
+ )} + + {/* Action buttons — hidden in read-only mode */} + {!disabled && ( +
+ + + {isPending && ( + Saving… + )} +
+ )} + + {/* Inline Quick Search picker */} + {showPicker && !disabled && ( +
+ { + handleSelect(technique); + setShowPicker(false); + }} + disabled={isPending} + /> +
+ )} + + {/* Matrix modal */} + setShowMatrix(false)} + /> +
+ ); +} diff --git a/frontend/src/components/SimulationList.tsx b/frontend/src/components/SimulationList.tsx index 210a1f6..ee8618c 100644 --- a/frontend/src/components/SimulationList.tsx +++ b/frontend/src/components/SimulationList.tsx @@ -95,7 +95,11 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme - {sim.mitre_technique_id ?? '—'} + {sim.techniques.length === 0 + ? '—' + : sim.techniques.length === 1 + ? sim.techniques[0].id + : `${sim.techniques[0].id} +${sim.techniques.length - 1}`} diff --git a/frontend/src/hooks/useMitre.ts b/frontend/src/hooks/useMitre.ts index 18d736f..f1d8985 100644 --- a/frontend/src/hooks/useMitre.ts +++ b/frontend/src/hooks/useMitre.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { searchMitreTechniques } from '@/api/mitre'; +import { getMitreMatrix, searchMitreTechniques } from '@/api/mitre'; export function useMitreSearch(query: string, enabled: boolean) { return useQuery({ @@ -9,3 +9,12 @@ export function useMitreSearch(query: string, enabled: boolean) { staleTime: 5 * 60 * 1000, }); } + +export function useMitreMatrix(enabled: boolean) { + return useQuery({ + queryKey: ['mitre', 'matrix'], + queryFn: getMitreMatrix, + enabled, + staleTime: Infinity, + }); +} diff --git a/frontend/src/pages/SimulationFormPage.tsx b/frontend/src/pages/SimulationFormPage.tsx index 2e274d9..c852226 100644 --- a/frontend/src/pages/SimulationFormPage.tsx +++ b/frontend/src/pages/SimulationFormPage.tsx @@ -16,12 +16,10 @@ import { LoadingState } from '@/components/LoadingState'; import { ErrorState } from '@/components/ErrorState'; import { SimulationStatusBadge } from '@/components/SimulationStatusBadge'; import { ConfirmDialog } from '@/components/ConfirmDialog'; -import { MitreTechniquePicker } from '@/components/MitreTechniquePicker'; +import { MitreTechniquesField } from '@/components/MitreTechniquesField'; interface RedteamFormState { name: string; - mitre_technique_id: string | null; - mitre_technique_name: string | null; description: string; commands: string; prerequisites: string; @@ -38,8 +36,6 @@ interface SocFormState { const EMPTY_RT: RedteamFormState = { name: '', - mitre_technique_id: null, - mitre_technique_name: null, description: '', commands: '', prerequisites: '', @@ -81,8 +77,6 @@ export function SimulationFormPage(): JSX.Element { const s = detail.data; setRt({ name: s.name, - mitre_technique_id: s.mitre_technique_id, - mitre_technique_name: s.mitre_technique_name, description: s.description ?? '', commands: s.commands ?? '', prerequisites: s.prerequisites ?? '', @@ -154,8 +148,6 @@ export function SimulationFormPage(): JSX.Element { } const patch: SimulationPatchInput = { name: rt.name.trim(), - mitre_technique_id: rt.mitre_technique_id ?? null, - mitre_technique_name: rt.mitre_technique_name ?? null, description: rt.description.trim() || null, commands: rt.commands.trim() || null, prerequisites: rt.prerequisites.trim() || null, @@ -314,16 +306,15 @@ export function SimulationFormPage(): JSX.Element { /> - - - setRt({ ...rt, mitre_technique_id: id, mitre_technique_name: name }) - } +
+ MITRE Techniques + - +