feature/m4-mitre #1
@@ -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,22 +127,25 @@ 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"
|
||||
className="bg-bg-base flex flex-col min-w-0"
|
||||
data-testid={`mitre-column-${tactic.external_id}`}
|
||||
>
|
||||
{/* Sticky-ish tactic header (click to select the whole tactic). */}
|
||||
{/* Tactic header — name only (attack.mitre.org style) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -155,29 +157,27 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
'w-full text-left rounded-t-md border-2 px-2 py-2 font-mono text-2xs transition',
|
||||
'w-full text-left px-2 py-1.5 font-sans border-b transition',
|
||||
tacticSel
|
||||
? 'accent-fill-cyan border-cyan text-text-bright'
|
||||
: 'border-cyan/40 hover:border-cyan hover:bg-cyan/5',
|
||||
: '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}
|
||||
>
|
||||
<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}>
|
||||
<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">
|
||||
<div className="flex flex-col">
|
||||
{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)
|
||||
</div>
|
||||
)}
|
||||
@@ -186,11 +186,13 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
|
||||
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 key={tech.id} className="border-b border-border last:border-b-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-stretch text-2xs',
|
||||
techSel ? 'accent-fill-orange' : 'hover:bg-bg-card',
|
||||
'flex items-stretch text-xs',
|
||||
techSel
|
||||
? 'accent-fill-orange text-text-bright'
|
||||
: 'hover:bg-orange/10',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -203,39 +205,36 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
|
||||
name: tech.name,
|
||||
})
|
||||
}
|
||||
className="flex-1 text-left px-2 py-1 font-mono"
|
||||
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={cn(
|
||||
'font-semibold mr-1',
|
||||
techSel ? 'text-text-bright' : 'text-orange',
|
||||
)}
|
||||
>
|
||||
{tech.external_id}
|
||||
</span>
|
||||
<span title={tech.name}>{tech.name}</span>
|
||||
<span className="leading-tight">{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`}
|
||||
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="font-mono">
|
||||
{isExpanded ? '−' : '+'}
|
||||
<span className="text-3xs ml-0.5">{tech.subtechniques.length}</span>
|
||||
</span>
|
||||
<span className="text-purple">{isExpanded ? '▾' : '▸'}</span>
|
||||
<span className="ml-0.5 align-middle">{tech.subtechniques.length}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && hasSubs && (
|
||||
<div className="bg-bg-card border-t border-border">
|
||||
<div className="bg-bg-card">
|
||||
{tech.subtechniques.map((sb) => {
|
||||
const subSel = isSelected('subtechnique', sb.external_id);
|
||||
return (
|
||||
@@ -251,23 +250,16 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
'block w-full text-left pl-6 pr-2 py-1 font-mono text-2xs border-l-2',
|
||||
'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/5',
|
||||
: '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={cn(
|
||||
'font-semibold mr-1',
|
||||
subSel ? 'text-text-bright' : 'text-purple',
|
||||
)}
|
||||
>
|
||||
{sb.external_id}
|
||||
</span>
|
||||
<span title={sb.name}>{sb.name}</span>
|
||||
<span className="leading-tight">{sb.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -281,11 +273,10 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user