From 7dbe2dbc2884b033fdb44a5f727f4b655128c0b1 Mon Sep 17 00:00:00 2001 From: Knacky Date: Tue, 12 May 2026 18:32:20 +0200 Subject: [PATCH] refactor(m4): flatten the MITRE picker into the attack.mitre.org matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/mitre.py | 59 +++ backend/tests/test_mitre.py | 29 ++ e2e/tests/m4-mitre.spec.ts | 32 +- frontend/src/components/MitreTagPicker.tsx | 416 +++++++++++---------- frontend/src/lib/mitre.ts | 26 ++ tasks/spec.md | 4 +- tasks/testing-m4.md | 27 +- tasks/todo.md | 2 +- 8 files changed, 371 insertions(+), 224 deletions(-) diff --git a/backend/app/api/mitre.py b/backend/app/api/mitre.py index e0937e9..ee74afa 100644 --- a/backend/app/api/mitre.py +++ b/backend/app/api/mitre.py @@ -175,6 +175,65 @@ def list_subtechniques(): ) +@bp.get("/matrix") +@require_auth +def matrix(): + """Return the full Enterprise matrix: tactics → techniques → sub-techniques. + + One-shot endpoint so the SPA can render the flat attack.mitre.org-style + grid without firing 15 parallel queries. The payload is ~55 KB serialised + against MITRE v19 (15 tactics × ~50 techniques × ~3 subs). + """ + with session_scope() as s: + # All techniques + their tactics (selectin-loaded by the relationship). + techniques = s.scalars( + select(MitreTechnique).order_by(MitreTechnique.external_id.asc()) + ).all() + # Sub-techniques bucketed by parent. + subs_by_parent: dict = {} + for sb in s.scalars( + select(MitreSubtechnique).order_by(MitreSubtechnique.external_id.asc()) + ).all(): + subs_by_parent.setdefault(sb.technique_id, []).append( + { + "id": str(sb.id), + "external_id": sb.external_id, + "name": sb.name, + } + ) + # Tactics in canonical kill-chain order (matches attack.mitre.org). + tactics = s.scalars( + select(MitreTactic).order_by(MitreTactic.external_id.asc()) + ).all() + + # Group techniques by tactic short_name. + techs_by_tactic: dict = {} + for t in techniques: + entry = { + "id": str(t.id), + "external_id": t.external_id, + "name": t.name, + "subtechniques": subs_by_parent.get(t.id, []), + } + for tac in t.tactics: + techs_by_tactic.setdefault(tac.short_name, []).append(entry) + + return jsonify( + { + "tactics": [ + { + "id": str(t.id), + "external_id": t.external_id, + "short_name": t.short_name, + "name": t.name, + "techniques": techs_by_tactic.get(t.short_name, []), + } + for t in tactics + ] + } + ) + + @bp.get("/status") @require_auth def status(): diff --git a/backend/tests/test_mitre.py b/backend/tests/test_mitre.py index cd297cb..a673fea 100644 --- a/backend/tests/test_mitre.py +++ b/backend/tests/test_mitre.py @@ -358,3 +358,32 @@ def test_search_filter_on_name(app, admin_credentials, fixture_bundle_path): assert r.status_code == 200 ext_ids = [t["external_id"] for t in r.get_json()["items"]] assert ext_ids == ["T1078"] + + +def test_matrix_endpoint_returns_nested_grid(app, admin_credentials, fixture_bundle_path): + """GET /mitre/matrix returns the flat tactic→technique→subtechnique grid.""" + mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) + with app.test_client() as c: + access = _login(c, admin_credentials["email"], admin_credentials["password"]) + r = c.get("/api/v1/mitre/matrix", headers={"Authorization": f"Bearer {access}"}) + assert r.status_code == 200 + body = r.get_json() + tactics = body["tactics"] + assert {t["external_id"] for t in tactics} == {"TA0001", "TA0002"} + + # TA0001 has T1059 (multi-tactic) + T1078; T1059 carries its sub. + ta0001 = next(t for t in tactics if t["external_id"] == "TA0001") + techs = {t["external_id"]: t for t in ta0001["techniques"]} + assert set(techs.keys()) == {"T1059", "T1078"} + assert techs["T1059"]["subtechniques"][0]["external_id"] == "T1059.001" + assert techs["T1078"]["subtechniques"] == [] + + # TA0002 only carries T1059 (no T1078). + ta0002 = next(t for t in tactics if t["external_id"] == "TA0002") + assert [t["external_id"] for t in ta0002["techniques"]] == ["T1059"] + + +def test_matrix_endpoint_requires_auth(app, fixture_bundle_path): + mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None) + with app.test_client() as c: + assert c.get("/api/v1/mitre/matrix").status_code == 401 diff --git a/e2e/tests/m4-mitre.spec.ts b/e2e/tests/m4-mitre.spec.ts index a9e4cc4..0c7b865 100644 --- a/e2e/tests/m4-mitre.spec.ts +++ b/e2e/tests/m4-mitre.spec.ts @@ -109,7 +109,7 @@ test.describe('M4 — MITRE ATT&CK reference', () => { expect(body.default_version).toBeTruthy(); }); - test('SPA MITRE page renders + picker walks tactic → technique → subtechnique', async ({ page }) => { + test('SPA MITRE matrix renders + click cells to select technique + sub-technique', async ({ page }) => { await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto('/mitre'); @@ -118,19 +118,29 @@ test.describe('M4 — MITRE ATT&CK reference', () => { const picker = page.getByTestId('mitre-tag-picker'); await expect(picker).toBeVisible(); + // The matrix has a column per tactic. + await expect(picker.getByTestId('mitre-column-TA0006')).toBeVisible(); - // 1. Click on TA0006 (Credential Access) - await picker.getByTestId('mitre-tactic-TA0006').click(); - // 2. Techniques column populates; click T1003 - await expect(picker.getByTestId('mitre-technique-T1003')).toBeVisible(); - await picker.getByTestId('mitre-technique-T1003').click(); - // 3. Sub-techniques column populates with T1003.001 onward - await expect(picker.getByTestId('mitre-subtechnique-T1003.001')).toBeVisible(); - // 4. Select the sub-technique → chip appears in the selection bar - await picker.getByTestId('mitre-subtechnique-T1003.001').click(); + // 1. Click the T1003 cell (Credential Dumping under TA0006) → technique selected. + const t1003 = picker.getByTestId('mitre-technique-T1003').first(); + await t1003.scrollIntoViewIfNeeded(); + await t1003.click(); + await expect(page.getByTestId('mitre-selected')).toContainText('T1003'); + await expect(t1003).toHaveAttribute('aria-pressed', 'true'); + + // 2. Expand T1003's sub-techniques inline via the +N chevron. + await picker.getByTestId('mitre-expand-T1003').first().click(); + const sub = picker.getByTestId('mitre-subtechnique-T1003.001').first(); + await expect(sub).toBeVisible(); + + // 3. Click the sub-technique → chip + JSON preview both update. + await sub.click(); await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001'); - // 5. Preview payload card shows the JSON encoded selection await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"'); + + // 4. Filter the matrix on "valid" → TA0006/T1003 are hidden but TA0001/T1078 visible. + await picker.getByLabel(/^filter$/i).fill('valid'); + await expect(picker.getByTestId('mitre-technique-T1078').first()).toBeVisible(); }); test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => { diff --git a/frontend/src/components/MitreTagPicker.tsx b/frontend/src/components/MitreTagPicker.tsx index 3691723..a4f4afa 100644 --- a/frontend/src/components/MitreTagPicker.tsx +++ b/frontend/src/components/MitreTagPicker.tsx @@ -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>( - `/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>( - `/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>( - `/mitre/subtechniques${params.toString() ? `?${params}` : ''}`, - ); - }, + queryKey: mitreKeys.matrix, + queryFn: () => apiGet('/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(null); - const [activeTechnique, setActiveTechnique] = useState(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>(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 (
+ {/* Selection chips */} {value.length > 0 && (
{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}`} > - + {t.external_id} · {t.name} ✕ @@ -115,158 +104,189 @@ export function MitreTagPicker({ value, onChange, compact, className }: MitreTag
)} -
- {/* Tactics column */} -
- {!compact && ( - setQTactic(e.target.value)} - placeholder="e.g. Credential" - /> - )} -
- {tactics.isLoading &&

Loading…

} - {tactics.data?.items.map((t) => { - const active = activeTactic === t.external_id; - const selected = selectedKey.has(`tactic:${t.external_id}`); - return ( -
{ - setActiveTactic(t.external_id); - setActiveTechnique(null); - }} - data-testid={`mitre-tactic-${t.external_id}`} - > - e.stopPropagation()} - onChange={() => - toggle({ kind: 'tactic', id: t.id, external_id: t.external_id, name: t.name }) - } - aria-label={`Select ${t.external_id}`} - /> - {t.external_id} - {t.name} -
- ); - })} -
-
+ {/* Filter + counts */} +
+ setFilter(e.target.value)} + className="max-w-md" + /> + {matrix.data && ( + + {matrix.data.tactics.length} tactics ·{' '} + {matrix.data.tactics.reduce((sum, t) => sum + t.techniques.length, 0)} mappings + + )} +
- {/* Techniques column */} -
- {!compact && ( - setQTechnique(e.target.value)} - placeholder="e.g. T1059" - /> - )} -
- {activeTactic === null && ( -

Select a tactic to list its techniques.

- )} - {techniques.isLoading &&

Loading…

} - {techniques.data?.items.map((t) => { - const active = activeTechnique === t.external_id; - const selected = selectedKey.has(`technique:${t.external_id}`); + {matrix.isLoading &&

Loading matrix…

} + {matrix.isError && ( + Failed to load /mitre/matrix — has `make seed-mitre` been run? + )} + + {matrix.data && ( +
+
+ {matrix.data.tactics.map((tactic) => { + const visible = tactic.techniques.filter(matches); + const tacticSel = isSelected('tactic', tactic.external_id); return (
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}`} > - e.stopPropagation()} - onChange={() => + {/* Sticky-ish tactic header (click to select the whole tactic). */} +
- ); - })} - {techniques.data && techniques.data.items.length === 0 && activeTactic && ( -

No techniques for this tactic.

- )} -
-
+ 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} + > +
+ {tactic.external_id} + + {tactic.techniques.length} + +
+
+ {tactic.name} +
+ - {/* Sub-techniques column */} -
- {!compact && ( - setQSub(e.target.value)} - placeholder="e.g. Powershell" - /> - )} -
- {activeTechnique === null && ( -

Select a technique to list its sub-techniques.

- )} - {subtechniques.isLoading &&

Loading…

} - {subtechniques.data?.items.map((sb) => { - const selected = selectedKey.has(`subtechnique:${sb.external_id}`); - return ( -
- toggle({ - kind: 'subtechnique', - id: sb.id, - external_id: sb.external_id, - name: sb.name, - }) - } - data-testid={`mitre-subtechnique-${sb.external_id}`} - > - - {sb.external_id} - {sb.name} + {/* Techniques cells */} +
+ {visible.length === 0 && filterNorm && ( +
+ (filtered out) +
+ )} + {visible.map((tech) => { + const techSel = isSelected('technique', tech.external_id); + const isExpanded = expanded.has(tech.external_id); + const hasSubs = tech.subtechniques.length > 0; + return ( +
+
+ + {hasSubs && ( + + )} +
+ + {isExpanded && hasSubs && ( +
+ {tech.subtechniques.map((sb) => { + const subSel = isSelected('subtechnique', sb.external_id); + return ( + + ); + })} +
+ )} +
+ ); + })} +
); })} - {subtechniques.data && subtechniques.data.items.length === 0 && activeTechnique && ( -

No sub-techniques.

- )}
-
- {(tactics.isError || techniques.isError || subtechniques.isError) && ( - - Failed to load MITRE data — has `make seed-mitre` been run? - )} + +

+ Click any cell to select · click +N to reveal sub-techniques · click a chip above to remove. +

); } diff --git a/frontend/src/lib/mitre.ts b/frontend/src/lib/mitre.ts index fc7d42f..67fa10e 100644 --- a/frontend/src/lib/mitre.ts +++ b/frontend/src/lib/mitre.ts @@ -53,9 +53,35 @@ export interface MitreTag { export const mitreKeys = { status: ['mitre', 'status'] as const, + matrix: ['mitre', 'matrix'] as const, tactics: (q?: string) => ['mitre', 'tactics', q ?? ''] as const, techniques: (tactic?: string, q?: string) => ['mitre', 'techniques', tactic ?? '', q ?? ''] as const, subtechniques: (technique?: string, q?: string) => ['mitre', 'subtechniques', technique ?? '', q ?? ''] as const, }; + +export interface MatrixSubtechnique { + id: string; + external_id: string; + name: string; +} + +export interface MatrixTechnique { + id: string; + external_id: string; + name: string; + subtechniques: MatrixSubtechnique[]; +} + +export interface MatrixTactic { + id: string; + external_id: string; + short_name: string; + name: string; + techniques: MatrixTechnique[]; +} + +export interface MitreMatrix { + tactics: MatrixTactic[]; +} diff --git a/tasks/spec.md b/tasks/spec.md index d77d891..7f8b369 100644 --- a/tasks/spec.md +++ b/tasks/spec.md @@ -79,7 +79,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô ## 5. Exigences fonctionnelles - **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4). -- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus. +- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus. **Représentation UI du picker MITRE** : matrice flat type `attack.mitre.org/#` — colonnes = tactics (ordonnées TA00xx), cellules = techniques, chevron `+N` qui déplie inline les sub-techniques. Click sur une cellule = (dé)sélection. Selection multi-niveaux (tactic / technique / sub-technique) cumulative, chips de sélection en haut. - **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires. - **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation. - **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override. @@ -89,7 +89,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô - **F9** — Export mission : JSON complet (API + UI), CSV agrégé. - **F10** — Soft delete + purge admin. - **F11** — Switch i18n FR/EN par utilisateur (préférence persistée). -- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti. +- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti. Le picker (cf. F2) se base sur un endpoint `GET /mitre/matrix` qui retourne la grille complète (tactics → techniques → sub-techniques) en un seul appel. ## 6. Exigences non fonctionnelles - **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves. diff --git a/tasks/testing-m4.md b/tasks/testing-m4.md index b22577d..edc3322 100644 --- a/tasks/testing-m4.md +++ b/tasks/testing-m4.md @@ -38,18 +38,20 @@ Le rapport HTML est dans `e2e/playwright-report/`, le JUnit dans `e2e/playwright 2. Cliquer **MITRE** dans la nav → page chargée. 3. Carte **Source** : vérifier `version 19.0` + URL pinnée + `Last sync` non vide. 4. Carte **Sync** (admin uniquement) : cliquer **Trigger MITRE sync** → bannière verte avec counts (15 tactics / 222 techniques / 475 subtechniques). -5. Picker : - - Cliquer **TA0006 — Credential Access** dans la colonne gauche. - - La colonne du milieu liste **T1003 OS Credential Dumping** et 16 autres techniques. - - Cliquer **T1003** → la colonne droite liste **T1003.001** à **T1003.008**. - - Cocher la case en face de **T1003.001 PowerShell**. - - Un chip cyan/orange/purple apparaît en haut + la carte « Selected (preview payload) » montre le JSON. - - Cliquer le chip → désélection. +5. **Picker — matrice flat type attack.mitre.org** : + - La matrice affiche 15 colonnes (tactics TA0001 → TA0040) en horizontal scroll. Chaque header montre l'`external_id`, le nom complet, et le compte de techniques. + - Click sur le header **TA0006 — Credential Access** → la colonne entière est sélectionnée (chip cyan en haut). + - Re-click pour désélectionner. + - Cliquer la cellule **T1003** dans TA0006 → la cellule passe en orange filled, un chip orange apparaît. + - Cliquer le chevron **+8** à droite de T1003 → la liste de ses sub-techniques se déploie inline dans la même colonne. + - Cliquer **T1003.001 LSASS Memory** → cell purple filled, chip purple ajouté en haut. + - Click le chip pour le retirer. + - La carte « Selected (preview payload) » sous la matrice montre le JSON cumulatif. -### 3.2 Filtres / recherche -1. Dans la colonne **Tactic search**, taper `cred` → seule TA0006 reste. -2. Sélectionner TA0006, dans **Technique search** taper `dump` → T1003 apparaît. -3. Vider les recherches, sélectionner T1059 → vérifier T1059.001 à T1059.012 dans les sub-techniques. +### 3.2 Filtre +1. Taper `dump` dans le champ **Filter** → seules T1003 + sub-techniques restent visibles, les autres techniques sont cachées (mais leurs colonnes restent visibles pour préserver la grille). +2. Taper `TA0006` → idem mais filtre par `external_id`. +3. Vider le filtre → toutes les cellules réapparaissent. ### 3.3 Non-admin 1. Inviter un user sans perms via Admin > Invitations. @@ -107,5 +109,6 @@ curl -sX POST http://localhost:8080/api/v1/mitre/sync \ - [x] `/mitre/sync` exige la perm `mitre.sync` (admin via bypass `is_admin`). - [x] Sha256 mismatch sur la pinned URL → 502 `checksum_mismatch`, DB intacte. - [x] Bundle local (`--source `) bypasse la vérif checksum. -- [x] Picker SPA : tactic → technique → subtechnique, multi-select, déselection via chip cliquable. +- [x] Picker SPA : matrice flat attack.mitre.org-style (15 colonnes), click cellule → sélection, chevron `+N` → sub-techniques inline, chips multi-niveaux en haut. +- [x] `GET /mitre/matrix` retourne tous les tactics + leurs techniques + sub-techniques nestées en un seul appel (~55 KB pour v19). - [x] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403. diff --git a/tasks/todo.md b/tasks/todo.md index 7aba737..f7be60b 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -111,7 +111,7 @@ spec: tasks/spec.md - ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`). - ☐ Persister `mitre_last_sync` dans `settings`. - ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`). -- ☐ Front : composant `` (autocomplete combiné Tactic > Technique > Sub-technique, multi-select). +- ☐ Front : composant `` — matrice flat type `attack.mitre.org/#` (colonnes = tactics, cellules = techniques, chevron `+N` qui déplie les sub-techniques inline). Click = (dé)sélection, multi-niveaux cumulatif, chips en haut, recherche par `external_id` ou `name`. Alimenté par `GET /mitre/matrix` (one-shot, ~55 KB). **DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant.