refactor(m4): match attack.mitre.org sizing — equal-width cols, name-only cells

Visual parity pass against attack.mitre.org/# per user feedback ("trop dense,
illisible, je veux la même représentation"):

- Layout switched from flex+fixed-width 224px columns to a CSS grid of
  `repeat(N, minmax(0, 1fr))` so the 15 tactic columns share the container
  width equally. No more horizontal scroll on a standard desktop.
- Cells now show NAME ONLY (matches mitre.org). The external_id (TA00xx /
  T1xxx / T1xxx.xxx) is preserved in the chip selection bar at the top and
  in the `title` hover tooltip on every cell — surfaces on demand, doesn't
  consume cell real estate.
- Font: switched to `font-sans` (IBM Plex Sans) at `text-xs` (12px) across
  cells, matching the mitre.org typography. Headers use the same family at
  the same size with a 10px sub-line for the technique count.
- Chevron icons: ▸ (collapsed) / ▾ (expanded) — small, sub-technique count
  rendered inline beside the chevron.
- Helper line below the matrix tells the user where the IDs went.

Spec §F2 + testing-m4.md walkthrough rewritten to lock the new sizing rules
in (font-xs, no external_id in cells, hover/chip for the ID, no horizontal
scroll). spec-reviewer will see the matching contract.

DoD: make e2e → 34 passed. Selectors (data-testid + aria-pressed) unchanged
so the existing M4 e2e test still walks the new layout end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-12 18:41:11 +02:00
parent 7dbe2dbc28
commit 8742fb2b6e
3 changed files with 150 additions and 157 deletions

View File

@@ -30,12 +30,11 @@ function useMatrix() {
} }
/** /**
* Flat ATT&CK matrix in the attack.mitre.org style — columns = tactics, each * Flat ATT&CK matrix in the attack.mitre.org/# style — 15 columns share the
* cell lists its techniques with an inline chevron to expand sub-techniques. * available width (no horizontal scroll on standard desktop). Cells show the
* * technique NAME only; the external_id surfaces in the chips at the top and in
* Click a technique row → toggle selection. Click a sub-technique row → toggle. * the hover tooltip. A `▸/▾` chevron beside a technique expands its
* Click the tactic header → toggle the whole tactic as a single tag. * sub-techniques inline within the column.
* Selected items are filled with their accent (cyan/orange/purple).
*/ */
export function MitreTagPicker({ value, onChange, className }: MitreTagPickerProps) { export function MitreTagPicker({ value, onChange, className }: MitreTagPickerProps) {
const matrix = useMatrix(); const matrix = useMatrix();
@@ -44,7 +43,7 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
const filterNorm = filter.trim().toLowerCase(); const filterNorm = filter.trim().toLowerCase();
// Set of `${kind}:${external_id}` for O(1) lookup. // O(1) selection lookup.
const selectedKeys = useMemo( const selectedKeys = useMemo(
() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)), () => new Set(value.map((t) => `${t.kind}:${t.external_id}`)),
[value], [value],
@@ -114,7 +113,7 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
className="max-w-md" className="max-w-md"
/> />
{matrix.data && ( {matrix.data && (
<span className="font-mono text-2xs text-text-dim mb-2 whitespace-nowrap"> <span className="font-sans text-xs text-text-dim mb-2 whitespace-nowrap">
{matrix.data.tactics.length} tactics ·{' '} {matrix.data.tactics.length} tactics ·{' '}
{matrix.data.tactics.reduce((sum, t) => sum + t.techniques.length, 0)} mappings {matrix.data.tactics.reduce((sum, t) => sum + t.techniques.length, 0)} mappings
</span> </span>
@@ -128,164 +127,156 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
{matrix.data && ( {matrix.data && (
<div <div
className="overflow-x-auto pb-2"
data-testid="mitre-matrix-scroll" data-testid="mitre-matrix-scroll"
role="region" role="region"
aria-label="MITRE ATT&CK matrix" aria-label="MITRE ATT&CK matrix"
/* Equal-width columns share the container; no horizontal scroll. */
className="grid gap-px bg-border rounded"
style={{
gridTemplateColumns: `repeat(${matrix.data.tactics.length}, minmax(0, 1fr))`,
}}
> >
<div className="flex gap-2 min-w-max items-start"> {matrix.data.tactics.map((tactic) => {
{matrix.data.tactics.map((tactic) => { const visible = tactic.techniques.filter(matches);
const visible = tactic.techniques.filter(matches); const tacticSel = isSelected('tactic', tactic.external_id);
const tacticSel = isSelected('tactic', tactic.external_id); return (
return ( <div
<div key={tactic.id}
key={tactic.id} className="bg-bg-base flex flex-col min-w-0"
className="w-56 shrink-0" data-testid={`mitre-column-${tactic.external_id}`}
data-testid={`mitre-column-${tactic.external_id}`} >
{/* Tactic header — name only (attack.mitre.org style) */}
<button
type="button"
onClick={() =>
toggle({
kind: 'tactic',
id: tactic.id,
external_id: tactic.external_id,
name: tactic.name,
})
}
className={cn(
'w-full text-left px-2 py-1.5 font-sans border-b transition',
tacticSel
? 'accent-fill-cyan border-cyan text-text-bright'
: 'bg-bg-card border-border hover:bg-cyan/10 text-text-bright',
)}
title={`${tactic.external_id}${tactic.name}`}
data-testid={`mitre-tactic-${tactic.external_id}`}
aria-pressed={tacticSel}
> >
{/* Sticky-ish tactic header (click to select the whole tactic). */} <div className="text-xs font-semibold leading-tight break-words">
<button {tactic.name}
type="button" </div>
onClick={() => <div className="text-[10px] text-text-dim mt-0.5">
toggle({ {tactic.techniques.length} technique{tactic.techniques.length === 1 ? '' : 's'}
kind: 'tactic', </div>
id: tactic.id, </button>
external_id: tactic.external_id,
name: tactic.name,
})
}
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>
{/* Techniques cells */} {/* Techniques cells */}
<div className="border-x border-b border-border rounded-b-md bg-bg-base"> <div className="flex flex-col">
{visible.length === 0 && filterNorm && ( {visible.length === 0 && filterNorm && (
<div className="px-2 py-1 font-mono text-3xs text-text-dim"> <div className="px-2 py-1 font-sans text-[10px] text-text-dim italic">
(filtered out) (filtered out)
</div> </div>
)} )}
{visible.map((tech) => { {visible.map((tech) => {
const techSel = isSelected('technique', tech.external_id); const techSel = isSelected('technique', tech.external_id);
const isExpanded = expanded.has(tech.external_id); const isExpanded = expanded.has(tech.external_id);
const hasSubs = tech.subtechniques.length > 0; const hasSubs = tech.subtechniques.length > 0;
return ( return (
<div key={tech.id} className="border-t border-border first:border-t-0"> <div key={tech.id} className="border-b border-border last:border-b-0">
<div <div
className={cn( className={cn(
'flex items-stretch text-2xs', 'flex items-stretch text-xs',
techSel ? 'accent-fill-orange' : 'hover:bg-bg-card', techSel
)} ? 'accent-fill-orange text-text-bright'
: 'hover:bg-orange/10',
)}
>
<button
type="button"
onClick={() =>
toggle({
kind: 'technique',
id: tech.id,
external_id: tech.external_id,
name: tech.name,
})
}
className="flex-1 min-w-0 text-left px-2 py-1.5 font-sans break-words"
title={`${tech.external_id}${tech.name}`}
data-testid={`mitre-technique-${tech.external_id}`}
aria-pressed={techSel}
> >
<span className="leading-tight">{tech.name}</span>
</button>
{hasSubs && (
<button <button
type="button" type="button"
onClick={() => onClick={() => toggleExpand(tech.external_id)}
toggle({ className={cn(
kind: 'technique', 'shrink-0 px-1 border-l text-[10px] font-mono leading-none',
id: tech.id, techSel
external_id: tech.external_id, ? 'border-text-bright/30 text-text-bright hover:bg-text-bright/10'
name: tech.name, : 'border-border text-purple hover:bg-purple/10',
}) )}
} aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${tech.external_id} sub-techniques`}
className="flex-1 text-left px-2 py-1 font-mono" aria-expanded={isExpanded}
data-testid={`mitre-technique-${tech.external_id}`} data-testid={`mitre-expand-${tech.external_id}`}
aria-pressed={techSel} title={`${tech.subtechniques.length} sub-technique${tech.subtechniques.length === 1 ? '' : 's'}`}
> >
<span <span className="text-purple">{isExpanded ? '▾' : '▸'}</span>
className={cn( <span className="ml-0.5 align-middle">{tech.subtechniques.length}</span>
'font-semibold mr-1',
techSel ? 'text-text-bright' : 'text-orange',
)}
>
{tech.external_id}
</span>
<span title={tech.name}>{tech.name}</span>
</button> </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>
);
})} {isExpanded && hasSubs && (
</div> <div className="bg-bg-card">
{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-5 pr-2 py-1 font-sans text-xs border-l-2 break-words',
subSel
? 'accent-fill-purple border-purple text-text-bright'
: 'border-purple/30 hover:bg-purple/10',
)}
title={`${sb.external_id}${sb.name}`}
data-testid={`mitre-subtechnique-${sb.external_id}`}
aria-pressed={subSel}
>
<span className="leading-tight">{sb.name}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div> </div>
); </div>
})} );
</div> })}
</div> </div>
)} )}
<p className="mt-3 font-mono text-3xs text-text-dim"> <p className="mt-3 font-sans text-[11px] 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. Hover a cell for its <code className="font-mono text-purple">external_id</code>. Click a cell to toggle selection. Use <span className="text-purple font-mono"></span> to reveal sub-techniques inline. Click a chip above to remove.
</p> </p>
</div> </div>
); );

View File

@@ -79,7 +79,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
## 5. Exigences fonctionnelles ## 5. Exigences fonctionnelles
- **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4). - **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. **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. - **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 fidèle à `attack.mitre.org/#` 15 colonnes equal-width partageant la largeur disponible (pas de scroll horizontal sur desktop standard), headers = nom de la tactic seul (l'`external_id` `TA00xx` n'apparaît qu'au hover et dans les chips de sélection), cellules = nom de la technique seul (même règle pour `T1xxx`), chevron `▸ N / ▾ N` qui déplie inline les sub-techniques dans la colonne. Police sans-serif uniforme, taille `text-xs` (12px). Click sur une cellule = (dé)sélection. Selection multi-niveaux (tactic / technique / sub-technique) cumulative, chips de sélection en haut avec `external_id · name`.
- **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires. - **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. - **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. - **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override.

View File

@@ -39,14 +39,16 @@ Le rapport HTML est dans `e2e/playwright-report/`, le JUnit dans `e2e/playwright
3. Carte **Source** : vérifier `version 19.0` + URL pinnée + `Last sync` non vide. 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). 4. Carte **Sync** (admin uniquement) : cliquer **Trigger MITRE sync** → bannière verte avec counts (15 tactics / 222 techniques / 475 subtechniques).
5. **Picker — matrice flat type attack.mitre.org** : 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. - La matrice tient sur la largeur de la page sans scroll horizontal (15 colonnes de largeur égale, partagent l'espace dispo).
- Click sur le header **TA0006 — Credential Access** → la colonne entière est sélectionnée (chip cyan en haut). - Chaque header de colonne montre **seulement le nom de la tactic** (ex. `Credential Access`) + `17 techniques` en petit dessous. L'`external_id` (TA0006) apparaît au hover (title).
- Click sur le header **Credential Access** → toute la colonne est sélectionnée (chip cyan en haut, header en cyan filled).
- Re-click pour désélectionner. - Re-click pour désélectionner.
- Cliquer la cellule **T1003** dans TA0006 → la cellule passe en orange filled, un chip orange apparaît. - Les cellules affichent **uniquement le nom de la technique** (ex. `OS Credential Dumping`). L'`external_id` (T1003) apparaît au hover (title) et dans le chip de sélection.
- Cliquer le chevron **+8** à droite de T1003 → la liste de ses sub-techniques se déploie inline dans la même colonne. - Cliquer la cellule **OS Credential Dumping** → cellule en orange filled, chip `T1003 · OS Credential Dumping` en haut.
- Cliquer **T1003.001 LSASS Memory** → cell purple filled, chip purple ajouté en haut. - Cliquer le chevron `▸ 8` à droite de la cellule → la liste des sub-techniques se déploie inline dans la même colonne, chevron passe à `▾ 8`.
- Cliquer **LSASS Memory** (sub-technique) → cell purple filled, chip `T1003.001 · LSASS Memory`.
- Click le chip pour le retirer. - Click le chip pour le retirer.
- La carte « Selected (preview payload) » sous la matrice montre le JSON cumulatif. - La carte « Selected (preview payload) » sous la matrice montre le JSON cumulatif avec les `external_id`.
### 3.2 Filtre ### 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). 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).
@@ -109,6 +111,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] `/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] Sha256 mismatch sur la pinned URL → 502 `checksum_mismatch`, DB intacte.
- [x] Bundle local (`--source <path>`) bypasse la vérif checksum. - [x] Bundle local (`--source <path>`) bypasse la vérif checksum.
- [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] Picker SPA : matrice flat attack.mitre.org-style 15 colonnes equal-width sans scroll horizontal, cellules avec **name only** (external_id au hover + dans chips), chevron `▸ N / ▾ 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] `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. - [x] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403.