feature/m4-mitre #1

Merged
knacky merged 13 commits from feature/m4-mitre into main 2026-05-12 17:24:14 +00:00
3 changed files with 150 additions and 157 deletions
Showing only changes of commit 8742fb2b6e - Show all commits

View File

@@ -30,12 +30,11 @@ function useMatrix() {
}
/**
* 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).
* Flat ATT&CK matrix in the attack.mitre.org/# style — 15 columns share the
* 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
* the hover tooltip. A `▸/▾` chevron beside a technique expands its
* sub-techniques inline within the column.
*/
export function MitreTagPicker({ value, onChange, className }: MitreTagPickerProps) {
const matrix = useMatrix();
@@ -44,7 +43,7 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
const filterNorm = filter.trim().toLowerCase();
// Set of `${kind}:${external_id}` for O(1) lookup.
// O(1) selection lookup.
const selectedKeys = useMemo(
() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)),
[value],
@@ -114,7 +113,7 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
className="max-w-md"
/>
{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.reduce((sum, t) => sum + t.techniques.length, 0)} mappings
</span>
@@ -128,164 +127,156 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
{matrix.data && (
<div
className="overflow-x-auto pb-2"
data-testid="mitre-matrix-scroll"
role="region"
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) => {
const visible = tactic.techniques.filter(matches);
const tacticSel = isSelected('tactic', tactic.external_id);
return (
<div
key={tactic.id}
className="w-56 shrink-0"
data-testid={`mitre-column-${tactic.external_id}`}
{matrix.data.tactics.map((tactic) => {
const visible = tactic.techniques.filter(matches);
const tacticSel = isSelected('tactic', tactic.external_id);
return (
<div
key={tactic.id}
className="bg-bg-base flex flex-col min-w-0"
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). */}
<button
type="button"
onClick={() =>
toggle({
kind: 'tactic',
id: tactic.id,
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>
<div className="text-xs font-semibold leading-tight break-words">
{tactic.name}
</div>
<div className="text-[10px] text-text-dim mt-0.5">
{tactic.techniques.length} technique{tactic.techniques.length === 1 ? '' : 's'}
</div>
</button>
{/* 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',
)}
{/* Techniques cells */}
<div className="flex flex-col">
{visible.length === 0 && filterNorm && (
<div className="px-2 py-1 font-sans text-[10px] text-text-dim italic">
(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-b border-border last:border-b-0">
<div
className={cn(
'flex items-stretch text-xs',
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
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}
onClick={() => toggleExpand(tech.external_id)}
className={cn(
'shrink-0 px-1 border-l text-[10px] font-mono leading-none',
techSel
? 'border-text-bright/30 text-text-bright hover:bg-text-bright/10'
: 'border-border text-purple hover:bg-purple/10',
)}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${tech.external_id} sub-techniques`}
aria-expanded={isExpanded}
data-testid={`mitre-expand-${tech.external_id}`}
title={`${tech.subtechniques.length} sub-technique${tech.subtechniques.length === 1 ? '' : 's'}`}
>
<span
className={cn(
'font-semibold mr-1',
techSel ? 'text-text-bright' : 'text-orange',
)}
>
{tech.external_id}
</span>
<span title={tech.name}>{tech.name}</span>
<span className="text-purple">{isExpanded ? '▾' : '▸'}</span>
<span className="ml-0.5 align-middle">{tech.subtechniques.length}</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>
{isExpanded && hasSubs && (
<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>
)}
<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 className="mt-3 font-sans text-[11px] text-text-dim">
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>
</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
- **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.
- **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.

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.
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** :
- 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).
- La matrice tient sur la largeur de la page sans scroll horizontal (15 colonnes de largeur égale, partagent l'espace dispo).
- 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.
- 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.
- 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 la cellule **OS Credential Dumping** → cellule en orange filled, chip `T1003 · OS Credential Dumping` 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.
- 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
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] Sha256 mismatch sur la pinned URL → 502 `checksum_mismatch`, DB intacte.
- [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] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403.