docs: sprint 3 wrap-up — README + CHANGELOG + lessons + plan final

- README: status bump to sprint 3, test counts refreshed (164/86/105), IPv6 note for the e2e runner
- CHANGELOG: sprint 3 entry under [Unreleased] (multi-tech model + matrix endpoint + auto-save UI); sprint 2 moved to its own [Sprint 2] section (merged 2026-05-27)
- tasks/lessons.md: 6 lessons captured (2-pass spec-review, inline summary scoping, "test in brief means test in commit" discipline, SQLite batch_alter_table, real migration round-trip, modal Apply 0 disambiguation)
- tasks/todo.md: status flipped to 🟢 SPRINT COMPLET, execution sequence ticks updated with commit hashes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-27 04:55:12 +02:00
parent df8a6b605b
commit b001f57774
4 changed files with 290 additions and 193 deletions

View File

@@ -1,252 +1,290 @@
# Sprint 2Simulations + MITRE ATT&CK
# Sprint 3MITRE matrix modal + multi-technique simulations
**Branche** : `sprint/2-simulations`
**Statut** : 🟢 SPRINT COMPLET — 32 acceptance tests sprint 2 verts, code-review traité (2 MAJOR + 2 MINOR + 2 NITs fixés), PR prête
**Base** : `main` (sprint 1 mergé en `7fc79cc`)
**Objectif** : livrer les simulations (CRUD + workflow Pending→In progress→Review required→Done) à l'intérieur d'un engagement, avec autocomplete MITRE ATT&CK alimenté par un bundle STIX local. C'est le cœur métier — l'app remplace enfin le fichier Excel partagé redteam/SOC.
**Branche** : `sprint/3-mitre-matrix`
**Statut** : 🟢 SPRINT COMPLET — 105/105 sprint 3 e2e verts, code-review traité, PR prête
**Base** : `main` @ `e1d9738`
**Objectif** : remplacer la sélection MITRE mono-technique de sprint 2 par une sélection multi-techniques avec deux modes complémentaires : autocomplete (rapide) et matrice cliquable (exploration). Les techniques choisies s'affichent comme tags sur la simulation.
---
## 0. Évolution SPEC.md à acter en début de sprint
SPEC.md § Simulation dit aujourd'hui "Type d'attaque MITRE correspondant (peut être une liste de référence)" au singulier. Le team-lead met à jour cette ligne en début de sprint (pas de PR séparée) pour refléter le scope multi-techniques. Texte cible :
> Types d'attaque MITRE correspondants (multi-techniques) — sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale.
L'évolution est tracée dans CHANGELOG.md § Changed du sprint 3.
---
## 1. User stories
### US-7 — En tant que redteam, je crée une simulation dans un engagement
**Pourquoi** : c'est la feature centrale du sprint 2.
### US-13 — En tant que redteam, je sélectionne plusieurs techniques MITRE par simulation
**Pourquoi** : un test couvre souvent plusieurs TTPs (ex : Initial Access → Discovery → Execution). Mono-technique limite la description réelle d'un test.
**Critères d'acceptation**
- [ ] AC-7.1 : `POST /api/engagements/<eid>/simulations {name}` (admin|redteam) → 201 + simulation `{id, engagement_id, name, status: "pending", ...}`. `name` requis, non vide.
- [ ] AC-7.2 : autres rôles (soc) → 403.
- [ ] AC-7.3 : engagement inexistant → 404. Engagement existant mais aucune simulation → liste vide.
- [ ] AC-7.4 : `GET /api/engagements/<eid>/simulations` (auth) → liste des simulations de l'engagement, ordonnée `created_at desc`.
- [ ] AC-7.5 : page `/engagements/:eid` (EngagementDetailPage) remplace le placeholder Sprint 2 par une section "Simulations" : liste (colonnes: name, MITRE id, status badge, executed_at) + bouton "Nouvelle simulation" pour admin/redteam.
- [ ] AC-7.6 : depuis cette liste, click sur une ligne → ouvre `/engagements/:eid/simulations/:sid/edit` (page d'édition role-aware, unique URL pour view+edit).
- [ ] AC-13.1 : modèle `Simulation` n'a plus `mitre_technique_id` ni `mitre_technique_name` (scalaires). Remplacés par `techniques` (colonne JSON, liste d'objets `{id: str, name: str}`, défaut `[]`).
- [ ] AC-13.2 : migration Alembic `0003_simulation_techniques_array.py` :
- ajoute la colonne `techniques` (JSON)
- backfill les simulations existantes : si `mitre_technique_id` non null → `techniques = [{id, name}]`, sinon `techniques = []`
- drop les deux anciennes colonnes
- migration réversible (downgrade : prendre le premier élément, ré-injecter dans les scalaires, drop `techniques`)
- [ ] AC-13.3 : sérialisation simulation expose `techniques: [{id, name, tactics: [...]}]` — le backend enrichit chaque entrée avec ses `tactics` depuis le service MITRE au moment du serialize (snapshot d'`id`+`name` en DB, tactics dérivées au runtime depuis le bundle).
- [ ] AC-13.4 : `PATCH /api/simulations/<sid>` accepte `{technique_ids: ["T1059", "T1078"]}` (liste d'IDs string). Backend valide chaque ID contre le bundle MITRE, résout `name`, écrit `[{id, name}]` en DB. ID inconnu → 400 `{error: "unknown technique id: T9999"}`.
- [ ] AC-13.5 : la règle d'auto-transition `pending → in_progress` s'applique aussi à `technique_ids` quand la liste reçue est non vide.
### US-8 — En tant que redteam, je renseigne les détails techniques d'une simulation
**Pourquoi** : c'est la trace de ce que la redteam a exécuté.
### US-14 — En tant que redteam, je vois et retire les techniques d'une simulation sous forme de tags
**Pourquoi** : visualiser rapidement la couverture TTP d'un test.
**Critères d'acceptation**
- [ ] AC-8.1 : `PATCH /api/simulations/<sid>` (admin|redteam) accepte les champs redteam : `name`, `mitre_technique_id`, `mitre_technique_name`, `description`, `commands` (texte multiligne, une commande par ligne), `prerequisites`, `executed_at` (ISO datetime), `execution_result`. Champs partiels OK.
- [ ] AC-8.2 : règle d'auto-transition pending → in_progress. Trigger PRÉCIS : `PATCH /api/simulations/<sid>` par admin|redteam où **le payload JSON contient au moins une clé parmi les champs redteam** (`name`, `mitre_technique_id`, `mitre_technique_name`, `description`, `commands`, `prerequisites`, `executed_at`, `execution_result`) **dont la valeur n'est ni `null` ni une string vide ni une liste vide**, ET status courant == `pending`. La comparaison se fait sur le payload entrant — pas sur l'état final de la simulation. Un PATCH qui ne ré-envoie qu'un champ inchangé (ex: même `name`) déclenche quand même la transition, car c'est une action explicite "la redteam saisit". L'auto-transition ne se déclenche jamais sur un PATCH `soc`.
- [ ] AC-8.3 : `commands` est stocké en colonne `text` (chaîne multiligne, une commande par ligne). Sérialisation API = texte brut tel que stocké. Le frontend affiche dans un `<textarea>`.
- [ ] AC-8.4 : `executed_at` valide ISO 8601 ou null. Si invalide → 400 `{error: "invalid executed_at"}`.
- [ ] AC-8.5 : page `/engagements/:eid/simulations/:sid` affiche un formulaire avec deux sections visibles ("Red Team" et "SOC"). Pour admin/redteam, les deux sections sont éditables. Validation client : `name` non vide.
- [ ] AC-8.6 : autocomplete MITRE dans le champ "Technique" — voir US-10.
- [ ] AC-14.1 : sur `SimulationFormPage`, à la place du seul `MitreTechniquePicker` du sprint 2, un composant `MitreTechniquesField` affiche :
- Liste des techniques sélectionnées sous forme de chips/tags (id + name, ex : `T1059 — Command and Scripting Interpreter`), avec un `×` cliquable pour retirer chaque technique.
- Bouton "Add technique" qui ouvre la modale matrice (US-15).
- Bouton "Quick search" qui ouvre l'autocomplete existant (réutilisation du `MitreTechniquePicker`) en mode "ajoute à la liste" (sélection = append, pas replace).
- État vide : message "No techniques selected — use the matrix or the quick search to add."
- [ ] AC-14.2 : retirer un tag (× sur le chip) déclenche un PATCH immédiat (auto-save) avec la liste mise à jour. La modale matrice (US-15) auto-save aussi via "Apply". Le picker Quick Search auto-save chaque sélection. Toast `'Techniques updated'` sur succès, toast erreur sinon. Pas de bouton Save manuel pour les techniques.
- [ ] AC-14.3 : sur `SimulationList` (table dans EngagementDetailPage), la colonne "MITRE" affiche un compteur + premier tag (ex : `T1059 +2` si 3 techniques sélectionnées). Si la liste est vide, afficher `—`.
- [ ] AC-14.4 : ordre des tags dans la simulation préservé entre lecture et écriture (pas de tri imposé côté serveur).
- [ ] AC-14.5 : tags affichés avec les couleurs/spacing DESIGN.md (`bg-primary-soft`, `text-primary-deep`, `rounded-full`, `px-md py-xxs`).
### US-9 — En tant qu'analyste SOC, je remplis ma partie de la simulation
**Pourquoi** : le SOC documente la détection sans toucher au scope redteam.
### US-15 — En tant que redteam, j'ouvre la matrice MITRE ATT&CK pour explorer et sélectionner des techniques
**Pourquoi** : l'autocomplete est efficace si on sait ce qu'on cherche ; la matrice est nécessaire pour "voir ce qui existe" et combiner par tactique.
**Critères d'acceptation**
- [ ] AC-9.1 : `PATCH /api/simulations/<sid>` envoyé par un user `soc` n'accepte QUE les champs SOC : `log_source`, `logs`, `soc_comment`, `incident_number`. Si la requête contient un champ redteam → 403 `{error: "soc cannot edit redteam fields"}`.
- [ ] AC-9.2 : un user `soc` ne peut PATCH une simulation que si son status est `review_required` ou `done`. Avant ça → 403 `{error: "simulation not ready for SOC review"}`.
- [ ] AC-9.3 : page `/engagements/:eid/simulations/:sid` pour un user `soc` : la section "Red Team" est rendue en read-only (champs grisés) ; la section "SOC" est éditable.
- [ ] AC-9.4 : si la simulation est en `pending` ou `in_progress` et qu'un soc visite la page, un bandeau "Simulation pas encore en revue — la redteam doit la marquer comme 'Review required' avant que vous puissiez intervenir" s'affiche, les champs SOC sont désactivés.
- [ ] AC-15.1 : nouvel endpoint `GET /api/mitre/matrix` (auth, tous rôles) → tree `[{tactic_id, tactic_name, techniques: [{id, name, subtechniques: [{id, name}]}]}]`. Chaque technique top-level embarque ses sub-techniques (`T1059``[T1059.001, T1059.002, ...]`). Ordre des tactiques = ordre canonique MITRE Enterprise (Initial Access → Execution → Persistence → ... → Exfiltration → Impact). 503 si bundle non chargé.
- [ ] AC-15.2 : composant `MitreMatrixModal` :
- Modal large (≥ 1100px), scroll vertical interne.
- Layout horizontal en colonnes : 1 colonne par tactique. Header de colonne = nom de la tactique + compteur de techniques sélectionnées dans cette tactique (sub-techniques incluses).
- Chaque technique top-level = bouton/cellule cliquable. État sélectionné visible (`bg-primary` + texte blanc).
- Si la technique a des sub-techniques (`subtechniques.length > 0`), un chevron (▸/▾) précède le nom. Click sur le chevron = expand/collapse (n'affecte PAS la sélection). Click sur le label = toggle sélection de la technique top-level.
- Sub-techniques affichées en cascade indentée sous leur parent quand expand. Cliquables individuellement (toggle de la sub). État visuel distinct : `bg-primary-soft` quand sélectionnée, indent `pl-md`, font-size légèrement plus petit.
- Sélectionner une sub-technique ne sélectionne PAS le parent (les deux sont indépendants côté data). Mais le compteur de tactique somme parent + subs sélectionnées.
- Champ de recherche en haut du modal qui filtre les techniques affichées (case-insensitive sur id ET name). Quand le filtre matche une sub-technique, son parent est automatiquement expand pour la rendre visible.
- Boutons en footer : "Cancel" (ferme sans appliquer), "Apply N techniques" (compteur = total parents + subs sélectionnés).
- [ ] AC-15.3 : la modale est ouverte depuis le bouton "Add technique" de US-14. Elle reçoit en input la liste actuelle de techniques sélectionnées et travaille sur une copie locale ; "Apply" déclenche directement le PATCH (auto-save, cf AC-14.2) et ferme la modale ; "Cancel" jette le diff local.
- [ ] AC-15.4 : Escape ferme la modale (= Cancel). Click sur le backdrop = Cancel.
- [ ] AC-15.5 : a11y V1 — **scope minimal explicite** : (1) focus initial sur le champ recherche à l'ouverture, (2) Tab cycle entre les éléments focusables de la modale (wrap : dernier élément → premier), (3) Escape ferme = onCancel, (4) ARIA `role="dialog"` + `aria-labelledby` sur le titre. Full WAI-ARIA dialog conformance (live regions, focus restoration au close, screen reader announcements détaillés) **out of scope V1** — c'est une dette assumée à reprendre dans un sprint a11y dédié.
### US-10 — En tant que redteam, j'autocomplète une technique MITRE ATT&CK
**Pourquoi** : éviter de taper l'id à la main, garantir la cohérence.
**Critères d'acceptation**
- [ ] AC-10.1 : `make update-mitre` télécharge le bundle STIX 2.1 Enterprise depuis `https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json` et l'écrit dans `backend/data/mitre/enterprise-attack.json`. Le bundle est COMMITTÉ dans le repo (`make build` reste autosuffisant). `make update-mitre` reste l'unique méthode de rafraîchissement et le diff résultant est committé manuellement.
- [ ] AC-10.2 : `GET /api/mitre/techniques?q=<query>` (auth, tous rôles) → liste max 20 résultats `[{id, name, tactics: ["initial-access", ...]}]`. Recherche full-text sur `id` (ex: "T1059") OU `name` (ex: "Command and Scripting Interpreter"), case-insensitive, ordonnée : match exact id > match préfixe id > match nom.
- [ ] AC-10.3 : si le bundle local est absent → endpoint répond 503 `{error: "mitre bundle not loaded"}`. Le team-lead documente `make update-mitre` dans le README.
- [ ] AC-10.4 : sous-techniques (id format `T1059.001`) incluses dans l'index.
- [ ] AC-10.5 : composant frontend `MitreTechniquePicker` : input + dropdown des matches (debounce 200ms, navigation clavier ↑↓ + Enter, Escape ferme le dropdown), affichage `T1059 — Command and Scripting Interpreter (initial-access)`. La sélection d'une suggestion remplit `mitre_technique_id` ET `mitre_technique_name` du form. Pas de fallback free-text : si l'utilisateur tape sans sélectionner, le champ technique reste vide en sortie de form (id et name `null`).
### US-11 — En tant qu'utilisateur, je transitionne le workflow d'une simulation
**Pourquoi** : la coordination redteam ↔ soc passe par le statut.
**Critères d'acceptation**
- [ ] AC-11.1 : `POST /api/simulations/<sid>/transition {to: "review_required"}` → 200, requiert status courant ∈ {`pending`, `in_progress`} et role ∈ {admin, redteam}. Refuse les autres transitions → 409 `{error: "invalid transition"}`.
- [ ] AC-11.2 : `POST /api/simulations/<sid>/transition {to: "done"}` → 200, requiert status courant == `review_required` et role ∈ {admin, redteam, soc}. Autres transitions → 409.
- [ ] AC-11.3 : aucune transition arrière (ex: done → pending) n'est permise. Pas de transition `→ pending` ni `→ in_progress` via cet endpoint (le passage à `in_progress` est strictement automatique cf AC-8.2).
- [ ] AC-11.4 : sur la page d'édition simulation, deux boutons contextuels :
- Pour admin/redteam, status ∈ {pending, in_progress} : bouton "Marquer en revue".
- Pour admin/redteam/soc, status == review_required : bouton "Clôturer".
- Sinon : boutons cachés.
- [ ] AC-11.5 : après transition réussie, la query simulation et la liste sont invalidées (TanStack Query), le badge se met à jour.
### US-12 — En tant qu'admin ou redteam, je supprime une simulation
**Critères d'acceptation**
- [ ] AC-12.1 : `DELETE /api/simulations/<sid>` (admin|redteam) → 204.
- [ ] AC-12.2 : `soc` → 403.
- [ ] AC-12.3 : suppression d'engagement (cascade) supprime toutes ses simulations.
- [ ] AC-12.4 : bouton "Supprimer" sur la page d'édition (admin/redteam uniquement), avec confirmation modal.
### US-16 — En tant que user (tous rôles), j'utilise les autres fonctionnalités sans régression
**Critères d'acceptation** (régression)
- [ ] AC-16.1 : workflow sprint 2 (auto-transition, transitions manuelles, RBAC SOC) inchangé — tous les ACs sprint 2 (US-7 → US-12) continuent de passer.
- [ ] AC-16.2 : l'ancien `MitreTechniquePicker` est conservé dans la base de code MAIS sa signature passe en clean rewrite (`onSelect({id, name})` au lieu de `onChange(id, name)`), wrappé par `MitreTechniquesField` en mode append.
- [ ] AC-16.3 : aucune e2e sprint 1/sprint 2 ne casse. Quelques assertions sprint 2 (US-8 et US-10) qui validaient le mono-technique sont mises à jour pour refléter la liste.
---
## 2. Brief technique — Backend Builder
**Scope strict** : `backend/`, `docker/`, `Makefile` (target `update-mitre`).
**Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs (team-lead).
### Livrables
**Modèle `Simulation`** (`backend/app/models/simulation.py`)
| Champ | Type | Notes |
|---|---|---|
| id | int PK | |
| engagement_id | int FK Engagement, CASCADE | requis |
| name | str, NOT NULL | redteam-side |
| mitre_technique_id | str, nullable | ex "T1059" / "T1059.001" |
| mitre_technique_name | str, nullable | snapshot pour résilience aux maj MITRE |
| description | text, nullable | redteam-side |
| commands | text, nullable | chaîne multiligne, une commande par ligne — pas de JSON |
| prerequisites | text, nullable | redteam-side |
| executed_at | datetime, nullable | redteam-side |
| execution_result | text, nullable | redteam-side |
| log_source | text, nullable | soc-side |
| logs | text, nullable | soc-side |
| soc_comment | text, nullable | soc-side |
| incident_number | str, nullable | soc-side |
| status | enum(pending/in_progress/review_required/done), défaut `pending` | |
| created_at | datetime | |
| updated_at | datetime, nullable | mis à jour à chaque PATCH |
| created_by_id | int FK User | |
- Remplacer `mitre_technique_id`, `mitre_technique_name` (str nullable) par :
```python
techniques: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
```
- Stockage : `[{"id": "T1059", "name": "Command and Scripting Interpreter"}, ...]`. Pas de `tactics` en DB (dérivé au serialize).
**Migration Alembic** `0002_add_simulations.py` — table `simulations` + FK indexes (`engagement_id`, `created_by_id`).
**Service workflow** (`backend/app/services/simulation_workflow.py`) — **mise à jour RBAC field-level OBLIGATOIRE**
- Dans le `REDTEAM_FIELDS` frozenset existant : **retirer** `"mitre_technique_id"` et `"mitre_technique_name"`, **ajouter** `"technique_ids"`.
- Sans ce changement : un user soc qui PATCH avec `{technique_ids: [...]}` reçoit un silent no-op (champ ignoré) au lieu du 403 attendu. La gate field-level RBAC pour `technique_ids` repose intégralement sur ce frozenset.
- Le `SOC_FIELDS` frozenset reste inchangé.
- Tester explicitement : `test_simulations_techniques.py` doit inclure "SOC PATCH technique_ids → 403" (cf. liste de tests plus bas).
**Endpoints** (nouveau blueprint `backend/app/api/simulations.py`)
- `GET /api/engagements/<eid>/simulations` — list, auth, all roles
- `POST /api/engagements/<eid>/simulations` — create, admin|redteam
- `GET /api/simulations/<sid>` — get, auth
- `PATCH /api/simulations/<sid>` — update avec RBAC field-level (voir AC-8/9)
- `DELETE /api/simulations/<sid>` — admin|redteam
- `POST /api/simulations/<sid>/transition` — state machine
- `GET /api/mitre/techniques?q=` — autocomplete (200 OK + array, 503 si bundle absent)
**Migration Alembic `0003_simulation_techniques_array.py`**
- Upgrade :
1. Ajouter colonne `techniques` (JSON, nullable=True temporaire, default `'[]'`) — `op.add_column` direct OK.
2. Data migration : pour chaque ligne, si `mitre_technique_id` IS NOT NULL → set `techniques = '[{"id":"<id>","name":"<name>"}]'`, sinon `'[]'`.
3. ALTER column `techniques` → nullable=False — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** car SQLite ne supporte pas ALTER COLUMN nativement.
4. Drop columns `mitre_technique_id`, `mitre_technique_name` — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** (même raison : SQLite ne supporte pas DROP COLUMN hors batch mode).
- Downgrade : symétrique avec les MÊMES guards batch_alter_table pour les étapes ALTER/DROP. Recrée les 2 colonnes, prend le premier élément de `techniques` si non vide, drop `techniques`.
- Pattern à suivre : la migration `0002_add_simulations.py` (sprint 2) — vérifier le style batch_alter_table déjà en place.
**Serializer** : retourne `created_by={id, username}` (pattern existant). `commands` → string brut (tel que stocké en DB, peut être `null` ou chaîne multiligne).
**Service workflow** (`backend/app/services/simulation_workflow.py`)
- `apply_patch(simulation, payload, user)` :
- sépare champs redteam vs soc
- vérifie RBAC field-level
- détecte auto-transition pending → in_progress (AC-8.2)
- applique le patch + commit
- `transition(simulation, to_status, user)` :
- vérifie state machine (transitions autorisées)
- vérifie RBAC role
- met à jour status + updated_at
**Serializer** (`backend/app/serializers.py`)
- `serialize_simulation(sim)` :
- Avant retour, enrichir chaque tag avec `tactics` depuis `mitre_svc.get_tactics(id)`. Si la technique a été retirée du bundle MITRE entre-temps, `tactics = []` (gracieux).
- `commands` reste tel quel (text brut, inchangé sprint 2).
**Service MITRE** (`backend/app/services/mitre.py`)
- Au boot de l'app : tente de charger `backend/data/mitre/enterprise-attack.json` en mémoire ; si absent ou parse error → flag `mitre_loaded = False` (logue warning, app démarre quand même).
- Indexe les objets STIX `type == "attack-pattern"` : extract `external_id` (T-id), `name`, `kill_chain_phases[].phase_name`.
- Fonction `search(query, limit=20)` : ranking par exact-id > prefix-id > substring-name.
- Étendre l'index avec un dict `tactics_by_technique: dict[str, list[str]]` pour lookup O(1) au serialize.
- Nouvelle fonction `get_tactics(technique_id: str) -> list[str]`.
- Nouvelle fonction `lookup_name(technique_id: str) -> str | None` — utilisée par l'endpoint PATCH pour résoudre le name côté serveur (le client n'envoie que les IDs).
- Nouvelle fonction `get_matrix() -> list[dict]` :
```json
[
{"tactic_id": "TA0001", "tactic_name": "Initial Access",
"techniques": [
{"id": "T1078", "name": "Valid Accounts",
"subtechniques": [{"id": "T1078.001", "name": "Default Accounts"}, ...]},
...
]},
...
]
```
Sub-techniques embarquées sous chaque parent (relation STIX `subtechnique-of` dans le bundle). Si la technique n'en a pas, `subtechniques: []`.
Ordre des tactiques : canonical MITRE Enterprise order (12 tactics). Lecture depuis les objets STIX `x-mitre-tactic` ordonnés par `x_mitre_shortname` natif OU constante module-level hardcodée si plus simple.
Ordre des techniques au sein d'une tactique : alphabétique par `name` (déterministe, lisible).
**`Makefile`** : remplacer le no-op de `update-mitre` par :
```makefile
MITRE_URL ?= https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json
update-mitre:
@mkdir -p backend/data/mitre
@curl -fsSL "$(MITRE_URL)" -o backend/data/mitre/enterprise-attack.json
@echo "MITRE bundle updated"
@if docker ps --format '{{.Names}}' | grep -q "^$(CONTAINER)$$"; then \
echo "Restarting $(CONTAINER) to reload MITRE bundle..."; \
docker restart $(CONTAINER); \
fi
```
**API** (`backend/app/api/simulations.py`)
- `GET /api/mitre/matrix` — nouvel endpoint, 200 + tree, 503 si bundle absent.
- `PATCH /api/simulations/<sid>` : le payload accepte maintenant `technique_ids: list[str]` à la place de `mitre_technique_id` + `mitre_technique_name`. Validation : tous les IDs doivent exister dans le bundle (400 sinon), `name` snapshot servi par `lookup_name`. Pas de rétrocompat avec les anciens champs scalaires (clean break — pas d'utilisateur externe).
- **Dedup serveur** : avant écriture en DB, dédupliquer la liste `technique_ids` en préservant l'ordre (`list(dict.fromkeys(technique_ids))`). Le client peut envoyer accidentellement des doublons (race UI ou bug), le serveur ne doit jamais persister deux fois la même technique.
- Auto-transition (AC-13.5) : un `technique_ids` non vide (≥1 élément) compte comme redteam-side filled, déclenche `pending → in_progress`. Liste vide = pas de trigger.
**Dockerfile** : copier `backend/data/mitre/` dans l'image (présent dans le repo, donc fonctionne au premier build).
**Tests pytest**
- `test_simulations_techniques.py` (nouveau) :
- Création + PATCH `technique_ids` → simulation a la bonne liste, sérialisation expose `techniques` avec `tactics`.
- PATCH avec ID inconnu → 400.
- Auto-transition sur `technique_ids` non vide.
- Retirer toutes les techniques (`technique_ids: []`) → pas de trigger d'auto-transition (cohérent avec règle "valeur vide").
- **Dedup** : PATCH avec `technique_ids: ["T1059", "T1078", "T1059"]` → DB ne stocke que 2 entrées, ordre préservé (`T1059` en premier).
- `test_mitre.py` (existant) — ajouter :
- `get_matrix()` renvoie les bonnes tactiques dans le bon ordre.
- `lookup_name(unknown)` → None.
- `get_tactics(known)` → liste correcte (≥1 tactique).
- `test_simulations_crud.py` + `test_simulations_patch.py` + `test_simulations_workflow.py` (existants) — adapter toute assertion qui touchait `mitre_technique_id` / `mitre_technique_name`.
- Migration : test que les anciennes simulations en `pending` avec un id mono-tech sont upgradées en `techniques: [{id, name}]` (fixture inline ou test direct sur Alembic).
**Bundle MITRE** : committé dans le repo à `backend/data/mitre/enterprise-attack.json`. Le backend-builder l'inclut dans son premier commit via `make update-mitre`.
**Tests pytest** (`backend/tests/`)
- `test_simulations_crud.py` : create + list + get + delete + cascade, RBAC create/delete.
- `test_simulations_patch.py` : auto-transition pending→in_progress, RBAC field-level soc, blocage soc avant review_required (AC-9.2).
- `test_simulations_workflow.py` : transitions valides/invalides, RBAC par transition.
- `test_mitre.py` : load bundle (fixture mini), search ranking, endpoint 503 si pas chargé, sous-techniques incluses.
Tous les tests existants doivent rester verts. Lint ruff + mypy clean.
### Règles
- Pas de touche au frontend.
- Pas d'invention de dépendances (pas besoin d'en ajouter).
- Renvoyer le summary attendu (cf. `.claude/agents/backend-builder.md`).
**Quality bar** : ruff + mypy clean, tous les tests existants + nouveaux verts.
---
## 3. Brief technique — Frontend Builder
**Scope strict** : `frontend/` UNIQUEMENT. Interdiction de toucher `e2e/`.
**Scope strict** : `frontend/` uniquement.
**Note process (lesson learned sprint 2)** : avant de marquer la tâche terminée, lance le dev server et screenshot (a) la matrice modale ouverte avec ≥3 techniques sélectionnées et (b) la simulation form avec ≥2 tags affichés. Joins-les à ton summary final.
### Livrables
**Types** (`frontend/src/api/types.ts`) : ajouter `Simulation`, `SimulationStatus`, `MitreTechnique`, et les payloads PATCH/POST.
**Types** (`frontend/src/api/types.ts`)
- `MitreTechnique`: `{id: string, name: string, tactics: string[]}` (déjà existant pour le picker — réutiliser, ajouter `tactics` si manquant).
- Ajouter `MitreTactic`: `{tactic_id: string, tactic_name: string, techniques: MitreMatrixTechnique[]}` avec `MitreMatrixTechnique = {id: string, name: string, subtechniques: {id: string, name: string}[]}`.
- `Simulation.techniques: MitreTechnique[]` à la place de `mitre_technique_id` + `mitre_technique_name`. PATCH payload : `{technique_ids: string[]}`.
**Client API** (`frontend/src/api/simulations.ts`, `frontend/src/api/mitre.ts`)
- `listSimulations(engagementId)`, `createSimulation(engagementId, {name})`, `getSimulation(id)`, `updateSimulation(id, patch)`, `deleteSimulation(id)`, `transitionSimulation(id, to)`.
- `searchMitreTechniques(query)`.
**API client** (`frontend/src/api/mitre.ts`)
- `searchMitreTechniques(q)` — existant, garder.
- `getMitreMatrix()` — nouveau, GET `/api/mitre/matrix`.
**Hooks TanStack Query** (`frontend/src/hooks/useSimulations.ts`)
- `useEngagementSimulations(engagementId)`, `useSimulation(id)`, mutations `useCreateSimulation`, `useUpdateSimulation`, `useDeleteSimulation`, `useTransitionSimulation`.
- Invalidation : transition + update + delete invalident `["simulations", id]` et `["engagements", eid, "simulations"]`.
**Hooks** (`frontend/src/hooks/useMitre.ts`)
- `useMitreSearch(q, enabled)` — existant, garder.
- `useMitreMatrix(enabled)` — nouveau hook TanStack Query, `staleTime: Infinity` (la matrice ne change qu'avec `make update-mitre` + redémarrage).
**Hook `useMitre`** : `useMitreSearch(query, enabled)` (debounce géré côté composant, hook sans staleTime court — cache 5min).
**Composants**
- **`MitreTechniqueTag.tsx`** (nouveau) : chip affichant `{id} — {name}` avec un bouton `×`. Props : `technique: MitreTechnique`, `onRemove: () => void`, `disabled?: boolean`.
- **`MitreTechniquesField.tsx`** (nouveau, dans `frontend/src/components/`) : conteneur qui orchestre la sélection multi-tech avec **auto-save** (PATCH déclenché par chaque add/remove/Apply).
- Props : `value: MitreTechnique[]`, `simulationId: number`, `disabled?: boolean`. (Pas de `onChange` du parent — le composant fait son propre PATCH via `useUpdateSimulation`.)
- UI : liste de `<MitreTechniqueTag>` + 2 boutons "Add technique" (ouvre matrix) et "Quick search" (ouvre/toggle picker autocomplete inline).
- Dédup : si l'utilisateur essaye d'ajouter une technique déjà présente, no-op silencieux (pas de PATCH non plus).
- Auto-save : chaque mutation (× sur tag, Apply matrice, sélection Quick Search) déclenche `useUpdateSimulation` avec `{technique_ids: [...]}`. Toast succès `'Techniques updated'`, toast erreur sinon. Pendant le PATCH : disable l'interaction (les × deviennent grisés, les boutons disabled).
- **`MitreMatrixModal.tsx`** (nouveau) : modale matrice avec sub-techniques expand/collapse.
- Props : `isOpen: boolean`, `initialSelection: MitreTechnique[]`, `onApply: (selection: MitreTechnique[]) => void`, `onCancel: () => void`.
- État local : (a) copie de `initialSelection` mutée par les toggles, (b) `expandedTechniques: Set<string>` pour les IDs parents dépliés.
- Layout : flex horizontal scrollable, 1 colonne par tactique. Largeur fixe 220px par colonne pour cohérence visuelle.
- Chevron `▸/▾` à gauche du nom des techniques qui ont des sub-techniques (`subtechniques.length > 0`). Click chevron = toggle expand (mute le set `expandedTechniques`), ne modifie PAS la sélection.
- Click sur le label d'une technique top-level = toggle sa sélection (le chevron ne se déclenche pas dans ce cas — séparer les zones cliquables).
- Sub-techniques rendues en cascade indentée sous leur parent quand expand : `pl-md text-[12px] bg-cloud rounded` (vs parent `text-[14px]`). Cliquables individuellement, sélection indépendante du parent.
- Compteur header de tactique = nombre de techniques **parents + subs** sélectionnées dans cette tactique.
- Champ recherche en haut : filtre case-insensitive sur id ET name. Une sub-technique matchée force l'expand de son parent (modifie automatiquement `expandedTechniques`).
- Modale : `position: fixed`, backdrop `bg-ink/60`, container `bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden`.
- Footer : "Cancel" (jette les changements locaux + ferme), "Apply N techniques" (compteur total ; click → onApply renvoie la liste complète, parent fait le PATCH auto-save US-14.2).
- Focus trap (scope minimal V1, cf AC-15.5) :
- `useEffect` au mount → `searchInputRef.current?.focus()`.
- `onKeyDown` au niveau du container modale :
- `Tab` sans shift sur le dernier élément focusable → `preventDefault()` + focus le premier.
- `Shift+Tab` sur le premier → `preventDefault()` + focus le dernier.
- Récupérer la liste des focusables via `container.querySelectorAll('a, button, input, [tabindex]:not([tabindex="-1"])')`, ignorer ceux `disabled` ou `hidden`.
- Pas de focus restoration ni de live region — out of scope V1.
- Pas de dépendance npm.
- Escape → onCancel. Click backdrop → onCancel.
- **`MitreTechniquePicker.tsx`** (existant, sprint 2) : clean rewrite de la signature.
- Avant : `onChange(id: string | null, name: string | null)` qui remplaçait la valeur.
- Après : `onSelect({id, name})` — un seul match sélectionné, le parent (MitreTechniquesField) gère l'append + le dédup.
- Plus de prop `techniqueId`/`techniqueName` en entrée (le picker est désormais un sélecteur "one-shot" qui se réinitialise après chaque sélection).
**Pages**
- `EngagementDetailPage.tsx` : remplacer le placeholder (lignes 74-81) par `<SimulationList engagementId={eng.id} />`. Conserver le reste.
- `SimulationFormPage.tsx` (`/engagements/:eid/simulations/new` et `/engagements/:eid/simulations/:sid/edit`) :
- Layout en deux cards : "Red Team" et "SOC".
- Champs redteam : name, MitreTechniquePicker, description, commands (textarea, une commande par ligne, envoyé tel quel — pas de split), prerequisites, executed_at (datetime-local input), execution_result.
- Champs SOC : log_source, logs, soc_comment, incident_number.
- Boutons en footer : "Save", "Marquer en revue" (si AC-11.4), "Clôturer" (si AC-11.4), "Supprimer" (modal de confirmation, admin/redteam).
- Mode création (`new`) : seul `name` requis ; après création, redirige sur `/engagements/:eid/simulations/:sid/edit`.
**Composants** (`frontend/src/components/`)
- `SimulationList.tsx` : table tri par created_at desc, colonnes (Name, MITRE, Status badge, Executed at), bouton "Nouvelle" si admin/redteam, ligne cliquable → navigate edit page.
- `SimulationStatusBadge.tsx` : variant du StatusBadge existant si possible (factoriser), 4 couleurs (pending=fog, in_progress=primary-soft, review_required=bloom-coral, done=storm-deep). Si le StatusBadge existant n'est pas factorisable proprement, créer un nouveau composant — pas d'over-engineering.
- `MitreTechniquePicker.tsx` : input + dropdown, debounce 200ms (`useDebouncedValue` ou util inline), navigation clavier (↑/↓/Enter/Escape), affichage `T1059 — Command and Scripting Interpreter (initial-access)`. Loading state inline.
- `ConfirmDialog.tsx` : modal générique de confirmation (utilisée pour delete).
- **`SimulationFormPage.tsx`** : remplacer le `<MitreTechniquePicker>` standalone par un `<MitreTechniquesField simulationId={sim.id}>`. Le state `rt.techniques` disparait du form (les techniques ont leur propre cycle de save via le champ lui-même — auto-save). Le bouton "Save Red Team" continue de batcher tous les AUTRES champs (name, description, commands, etc.) mais ne touche pas aux techniques. Affichage read-only (rôle SOC) : afficher les tags sans `×`, boutons Add/Quick Search masqués (`disabled` prop).
**Routing** (`App.tsx`)
- Ajouter `/engagements/:eid/simulations/new` (auth, admin|redteam)
- Ajouter `/engagements/:eid/simulations/:sid/edit` (auth, all roles, RBAC champs interne)
- **`SimulationList.tsx`** : colonne MITRE — afficher `techniques[0]?.id + (techniques.length > 1 ? ` +${techniques.length - 1}` : '')`. Si `techniques` est vide, afficher ``.
**Tests Vitest** (`frontend/tests/`)
- `SimulationList.test.tsx` : loading/error/empty + bouton "Nouvelle" gated par role.
- `MitreTechniquePicker.test.tsx` : autocomplete debounce, sélection met à jour, navigation clavier.
- `SimulationFormPage.test.tsx` : rôle redteam → tous champs éditables ; rôle soc → champs RT disabled, soc-side enabled si status review_required, bandeau si pending.
- `SimulationStatusBadge.test.tsx` : 4 variants.
**Tests Vitest**
- `MitreTechniqueTag.test.tsx` — render id+name, click × appelle onRemove.
- `MitreTechniquesField.test.tsx` — affiche tags, "Add technique" ouvre le modal matrix, "Quick search" ouvre le picker, dédup silencieuse, remove via × appelle onChange avec liste mise à jour.
- `MitreMatrixModal.test.tsx` — render colonnes par tactique, click toggle sélection, Apply renvoie liste, Cancel jette, Escape ferme, search filtre.
- Adapter `MitreTechniquePicker.test.tsx` (sprint 2) à la nouvelle signature `onSelect`.
- Adapter `SimulationFormPage.test.tsx` (sprint 2) — assertions sur `techniques` array au lieu de scalaire.
**Quality bar** : typecheck + lint + vitest clean.
### Règles
- Lit le summary du backend EN PREMIER (contrat API).
- Pas d'invention d'endpoints. Mismatch → escalade au team-lead.
- Réutiliser `LoadingState`, `ErrorState`, `EmptyState`, `Toast`, `FormField`, `StatusBadge` existants. NE PAS dupliquer.
- Respect DESIGN.md (utiliser tokens Tailwind existants — pas de couleurs hardcodées).
- Pas de CDN remote.
- Lit le summary backend EN PREMIER.
- Pas d'invention d'endpoints — `GET /api/mitre/matrix` est le seul nouveau, déjà spec'd.
- Réutiliser `LoadingState`, `ErrorState`, `ConfirmDialog`, `useToast`, action bar pattern (sprint 2) existants.
- Respect DESIGN.md tokens (palette + spacing). Tags = `bg-primary-soft text-primary-deep rounded-full px-md py-xxs gap-xxs text-[14px]`.
- Pas de nouvelle dépendance npm sans escalade au team-lead.
---
## 4. Definition of Done — Sprint 2
## 4. Brief — Test verifier
- [ ] Tous les critères AC-7 → AC-12 passent.
- [ ] `pytest` (existing 63 + nouveaux ~25) tous verts. `ruff`, `mypy` clean.
- [ ] `npm run typecheck`, `lint`, `test` clean frontend.
- [ ] Playwright suite (existing 36 + nouveaux ~15) verte.
- [ ] `make build` + `make start` + `make update-mitre` + workflow simulation complet manuel OK.
- [ ] Code-reviewer (Opus) sans BLOCKER ouvert.
- [ ] `SPEC.md` (section Simulation enrichie si besoin), `README.md` (mention `make update-mitre` + workflow), `CHANGELOG.md` à jour.
- [ ] PR ouverte sur `sprint/2-simulations`, récap synthétique team-lead, validation utilisateur.
E2e Playwright. Un fichier par US :
- `us13-multi-techniques.spec.ts` — AC-13.1 → AC-13.5 (focus API + données)
- `us14-techniques-tags.spec.ts` — AC-14.1 → AC-14.5 (UI tags + remove)
- `us15-mitre-matrix-modal.spec.ts` — AC-15.1 → AC-15.5 (modal interaction + a11y)
- `us16-regression-sprint2.spec.ts` — re-exécuter les ACs critiques sprint 2 (auto-transition US-8, workflow US-11, SOC restrictions US-9) avec le nouveau modèle.
Mettre à jour les e2e sprint 2 qui assertaient `mitre_technique_id` / `mitre_technique_name` scalaires (US-8, US-10 selon le grep).
---
## 5. Décisions arrêtées (utilisateur 2026-05-26)
## 5. Definition of Done — Sprint 3
1. **Source MITRE** : `https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json` (default team-lead).
2. **MITRE bundle dans le repo** : COMMITTÉ (`backend/data/mitre/enterprise-attack.json` versionné, `make build` autosuffisant).
3. **Commands storage** : colonne `text` multiligne, une commande par ligne, transport tel quel.
4. **Workflow auto-transition** pending→in_progress : déclenchée par toute PATCH admin/redteam touchant ≥1 champ redteam à valeur non vide (default team-lead).
5. **Page simulation** : UNE page d'édition role-aware (`/engagements/:eid/simulations/:sid/edit`), pas de page détail séparée.
6. **Suppression cascade** : delete engagement → delete simulations (default team-lead).
7. **SOC restriction status** : soc ne peut PATCH que si status ∈ {review_required, done}.
8. **Sous-techniques MITRE** : incluses dans l'autocomplete (T1059.001 visible) (default team-lead).
- [ ] Tous les AC US-13 → US-16 passent.
- [ ] Backend tests verts (`pytest -q`). Ruff + mypy clean.
- [ ] Frontend tests verts (`npm run test -- --run`). Typecheck + lint clean.
- [ ] E2e Playwright suite verte (sprint 1 + 2 + 3).
- [ ] Migration Alembic testée upgrade + downgrade.
- [ ] SPEC.md mis à jour (multi-techniques acté).
- [ ] README.md mis à jour (mention matrice + multi-tech dans la description workflow).
- [ ] CHANGELOG.md sprint 3 entry sous [Unreleased].
- [ ] Code-reviewer sans BLOCKER.
- [ ] **Frontend-builder a screenshot la matrice modale + la simulation form avec tags AVANT de marquer la tâche terminée (lesson learned sprint 2).**
- [ ] PR ouverte + récap synthétique team-lead.
---
## 6. Plan d'exécution (séquence)
## 6. Décisions arrêtées (utilisateur 2026-05-27)
1. ✅ User a validé les 8 décisions §5 (2026-05-26).
2.**Spec-reviewer** : APPROVED WITH NOTES (4 items mineurs corrigés avant dispatch).
3.**Backend-builder** : commit `006c4c2` (67 nouveaux tests, 130 passing).
4.**Frontend-builder** : commit `765bb5a` (41 nouveaux tests, 61 passing).
5.**Code-reviewer** : 2 MAJOR + 4 MINOR + 3 NITs → 2 commits de fix (`83bf60f` backend, `c9032a9`+`cf0e8a8` frontend).
6.**Test-verifier** : 32/32 sprint 2 verts, commits `da905cc` + `54e90f7` (AC-4.9 refresh).
7. 🟡 **Team-lead** : récap + PR en cours.
1. **Storage multi-tech** : colonne JSON `[{id, name}]` (KISS, pattern `commands` sprint 2).
2. **Sub-techniques dans la matrice** : OUI, affichées avec expand/collapse par technique parent. Sub-techniques sont aussi accessibles via Quick Search en plus.
3. **API shape** : `PATCH` reçoit `{technique_ids: ["T1059", "T1059.001", ...]}` — IDs uniquement (parents et subs au même niveau dans la liste). Backend résout names depuis le bundle.
4. **Rétrocompat** : migration backfill `[{id, name}]` depuis les scalaires. Pas de rétrocompat API.
5. **MitreTechniquePicker** : clean rewrite de la signature (`onSelect({id, name})`).
6. **Matrix layout** : colonnes par tactique, 220px fixe, scroll horizontal global.
7. **Apply de la modale matrice** : auto-save immédiat (PATCH déclenché par `MitreTechniquesField` quand le modal renvoie sa liste via `onApply`). Add/remove via tag × ou Quick Search aussi auto-save.
8. **Sprint 4 framing** (anticipation, NE PAS implémenter dans sprint 3) : Dark mode (toggle + tokens dark + persistence) + Hygiène process UI (`design-reviewer` agent + screenshot mandatory dans brief frontend-builder). Connecteur C2 reporté au-delà. Les builders sprint 3 N'ajoutent PAS de tokens dark, N'invoquent PAS le design-reviewer (qui n'existe pas encore). Seule la lesson `screenshots mandatory` est déjà appliquée en sprint 3 dans le brief frontend (§3).
Branche unique : `sprint/2-simulations`.
---
## 7. Plan d'exécution
1. ✅ User a validé les 8 décisions §6 (2026-05-27).
2. ✅ Team-lead a mis à jour SPEC.md (§0).
3. ✅ Spec-reviewer : APPROVED WITH NOTES après 2 passes (5 items au total, tous traités).
4. ✅ Backend-builder : commits `b5ea292` + `673b25e` (model + migration + matrix endpoint + 503 unloaded, 162 passing).
5. ✅ Frontend-builder : commit `771483f` (MitreTechniquesField + MitreMatrixModal + tags + auto-save + screenshots, 84 passing).
6. ✅ Code-reviewer : APPROVED WITH NITS (2 MINORs + 4 NITs).
7. ✅ Post-review fixes : `4596f09` + `393b6ed` backend (164 passing) + `39f4076` frontend (86 passing).
8. ✅ Test-verifier : commit `df8a6b6` (105/106 sprint 3 e2e verts, 1 pré-existant sprint 1 — DB pollution, non-régression).
9. 🟡 Team-lead : récap + PR en cours.
4. 🔵 Backend-builder : modèle + migration + endpoints + tests.
5. 🔵 Frontend-builder : composants + page update + tests Vitest. Screenshots obligatoires avant "done".
6. 🔵 Code-reviewer : LSP-first.
7. 🔵 Test-verifier : e2e US-13 → US-16 + adaptation sprint 2.
8. 🟢 Team-lead : PR + récap.