Compare commits

..

21 Commits

Author SHA1 Message Date
e27babed5b Merge pull request 'feat: sprint 6 — engagement export (md/csv/pdf)' (#9) from sprint/6-export into main
Reviewed-on: #9
2026-06-09 16:19:02 +00:00
Knacky
e41679b331 fix(export): render PDF in A4 landscape for 7-column readability
Add @page { size: A4 landscape } to _CSS, reduce font-size to 11px,
and set table-layout: fixed + word-break: break-word so 7 columns
fit without overflow. Unit test asserts the landscape rule is present
in the rendered HTML.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:13:46 +02:00
Knacky
2d1c113f0c docs: log 2 MEDIUM security fixes in CHANGELOG (post-refactor)
CSV multiline injection + Markdown stored-XSS regressions caught by
security-guidance@claude-code-plugins on the 7-column refactor.
Backend fix in 3a9d9d3 (257 pytest, ruff/mypy clean). PR #9 body
counter bumped 255 → 257.
2026-06-08 19:29:59 +02:00
Knacky
3a9d9d3203 fix(security): defuse CSV formula injection in multiline exécution cell + HTML-escape Markdown table cells
Finding 1 — CSV multiline formula injection:
- Split _format_execution into _format_execution_text (MD/PDF, no sanitization) and
  _format_execution_csv (CSV, applies _csv_safe to each user-controlled component before join)
- Moved _CSV_FORMULA_TRIGGERS + _csv_safe above the format helpers (required by _format_execution_csv)
- Outer _csv_safe on the Exécution cell retained as belt-and-braces for the empty-date case
- New test: test_render_engagement_csv_defuses_formula_in_inner_execution_lines

Finding 2 — Stored XSS in Markdown table:
- _cell() in render_engagement_markdown now calls _html_escape() (quote=True, default)
  before pipe-escaping and \n→<br/> substitution — correct order preserved
- New test: test_render_engagement_markdown_escapes_html_in_table_cells

255 → 257 passed, ruff clean, mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:29:10 +02:00
Knacky
4d9447082f docs: sprint 6 amendment — 7-column schema in CHANGELOG + PR body
Post-review user decision (2026-06-08) switched the export payload to a
fixed 7-column FR handoff schema (Scénario / Test / Source de log /
Commentaires SOC / Exécution / Logs remontés au SIEM / Cyber incident).

Logged in CHANGELOG [Unreleased] Changed section with commit refs
(SPEC fdab324, backend 7335b9f, e2e aeb4bdb) and updated PR #9 body
counters: 255 pytest (was 253), 136 vitest unchanged, 223 e2e
unchanged.
2026-06-08 19:23:02 +02:00
Knacky
aeb4bdb025 test(e2e): adapt export specs to 7-column schema (Scénario/Test/...)
Update AC-29.2 (Markdown) to assert | Scénario | GFM table header.
Update AC-29.3 (CSV) to assert exact 7 FR column names instead of 'name'.
Update AC-31.4 (empty engagement) MD to assert table absent, CSV header
to assert exact 7 FR columns.
Drop unused sim1/sim2 vars and makeClient import (NIT cleanup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:21:51 +02:00
Knacky
7335b9f2c6 refactor(export): switch render output to fixed 7-column schema (Scénario, Test, ...)
All three renderers (MD, CSV, PDF) now emit a uniform 7-column table with
French headers matching the RT↔SOC handoff contract locked in SPEC.md fdab324.

Helpers added:
- _format_execution(sim): canonical 3-part concat (executed_at / commands / execution_result)
- _MD_HEADERS / _HTML_HEADERS / _CSV_HEADERS unified to the same 7 FR strings

Helpers removed (no longer called):
- _tactic_names() — MITRE tactics dropped from export
- _enrich_sim_techniques() — MITRE techniques dropped from export

Fields dropped from export: status, techniques, tactic_ids, prerequisites, id,
created_at, updated_at (intentional — focused RT↔SOC handoff, see SPEC §Export).

_csv_safe() still applied to all 7 user-controlled cells including Exécution concat.

Tests updated: 255 passed, ruff clean, mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:49 +02:00
Knacky
fdab324217 docs(spec): export — switch to fixed 7-column handoff schema
User decision 2026-06-08 (post-PR-9, pre-merge): the export schema is
now a fixed 7-column layout focused on the RT↔SOC handoff, applied
uniformly across Markdown / CSV / PDF.

Columns (French headers): Scénario, Test, Source de log,
Commentaires SOC, Exécution (multiline concat of executed_at +
commands + execution_result, no labels), Logs remontés au SIEM,
Cyber incident.

Removed from the export (intentional): simulation status, MITRE
techniques and tactics, prerequisites, id, created_at, updated_at.
The export is a handoff product, not a full data dump.

This is the spec change that drives the upcoming render refactor
in services/export.py. SPEC committed first per the sprint-6
positional fix (FIRST commit, not at sprint close).
2026-06-08 19:10:42 +02:00
Knacky
e4a672c443 docs: sprint 6 wrap-up — README + CHANGELOG + 6 lessons
- README "Status" bumped to sprint 6 + test counts (253 backend, 136
  frontend, 223 e2e).
- CHANGELOG [Unreleased] section for sprint 6: backend, frontend, e2e,
  security, and changed-section notes (SPEC commit-first + mimic team).
- 6 sprint-6 lessons in tasks/lessons.md:
  1. SPEC.md commit-first tamed the 4-sprint recurrence
  2. Persistent team mimic + idle members > "never idle"
  3. Security plugin caught CSV formula injection mid-sprint
  4. Stdlib first before custom helpers
  5. Tests that mock at module level can't exercise the target's branches
  6. _engagement param for signature symmetry across render trio

This is the team-lead wrap-up commit. PR body in tasks/pr-body-sprint-6.md
will be ingested by make open-pr.
2026-06-08 18:35:21 +02:00
Knacky
b572a67066 test(e2e): sprint 6 acceptance — US-29 / US-30 / US-31
Adds 3 Playwright spec files covering all 13 ACs for the engagement
export feature:
- us29-export-formats.spec.ts (8 tests): dropdown, md/csv/pdf downloads,
  admin + redteam, filename convention
- us30-export-rbac.spec.ts (3 tests): SOC button absent, SOC 403, no-token 401
- us31-export-robustness.spec.ts (4 tests): missing format 400, bad format 400,
  unknown engagement 404, zero-sim export OK

Total: 201 → 223 Playwright tests. No regressions on sprints 1–5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:31:32 +02:00
Knacky
3725d4415e chore: code-review cleanups (NITs + filename defense-in-depth test)
- NIT-1: remove dead _technique_names() and _technique_ids() helpers (no callers)
- NIT-2: rename engagement → _engagement in render_engagement_csv signature
- NIT-4: remove duplicate inline User import in test_export_csv_escapes_special_characters
- NIT-5: add comment on _CSV_FORMULA_TRIGGERS explaining \t and \r inclusion
- REUSE: replace custom _html_escape with stdlib html.escape (quote=True default)
- Remove now-unnecessary type: ignore comments on weasyprint (stubs resolve cleanly)
- Add test_export_filename_never_contains_quote_or_crlf defense-in-depth test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:23:39 +02:00
Knacky
123d9812bc test: cover Content-Disposition fallback in ExportEngagementButton 2026-06-08 18:20:29 +02:00
Knacky
57dbd14347 fix(security): defuse CSV formula injection in engagement export (MEDIUM)
Authenticated red-team users could craft any user-controlled string field
(name, description, commands, prerequisites, execution_result, log_source,
logs, soc_comment, incident_number, MITRE technique IDs) starting with =,
+, -, @, \t or \r. When the SOC analyst opens the exported CSV in Excel /
LibreOffice / Google Sheets — explicitly the consumption flow this sprint
optimizes for — the spreadsheet executes the field as a formula on the
SOC's machine.

Fix: new helper _csv_safe() prefixes a single apostrophe to any string
starting with a formula-trigger character, forcing the spreadsheet to
render the cell as text. Applied to every user-controlled field in
render_engagement_csv. Numeric and ISO-date fields are not wrapped.

Tests:
- test_render_engagement_csv_escapes_formula_injection_in_name
- test_render_engagement_csv_escapes_formula_injection_in_commands
- test_render_engagement_csv_does_not_alter_safe_strings

Result: 249 → 252 passing (the 1 remaining failure is pre-existing
test_index_without_built_frontend_returns_json, unrelated to this fix).

Flagged by security-guidance@claude-code-plugins automated review.
2026-06-08 18:13:16 +02:00
Knacky
25877c4092 test: ExportEngagementButton + EngagementDetailPage RBAC tests
9 tests for ExportEngagementButton (render, open, close-outside,
Escape, per-format trigger, loading state, error toast).
3 RBAC tests for EngagementDetailPage (admin/redteam see Export,
soc does not). Total: 121 → 133 vitest passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:04:56 +02:00
Knacky
100441bdeb feat: ExportEngagementButton + exports API client
Add split-button dropdown [Export ▼] on EngagementDetailPage that
downloads engagement as Markdown, CSV, or PDF via
GET /api/engagements/<id>/export?format=md|csv|pdf.

Both halves open the dropdown (no default left-click action).
RBAC-gated with canEditEngagements (admin + redteam only).
Loading state per item, toast on error, click-outside + Escape close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:04:49 +02:00
Knacky
5471c8fd89 test: add export endpoint + render unit tests (226 → 249 passing)
- test_export_engagement.py: 13 endpoint tests — RBAC (admin/redteam ok, SOC 403,
  401 unauthenticated), CSV column contract, CSV special char escaping, PDF magic bytes,
  400 on missing/unknown format, 404 on missing engagement, zero-simulations edge case,
  filename slugification.
- test_export_render.py: 10 unit tests on pure render functions — header fields,
  simulation order, techniques/tactics enrichment, SOC fields always rendered,
  backtick safety in commands, CSV header row, multi-technique pipe join, PDF magic
  bytes, MITRE bundle not loaded does not crash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:40 +02:00
Knacky
f1a7965ab9 chore: add WeasyPrint system deps to Dockerfile python stage
apt-get install libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b
libfontconfig1 shared-mime-info — minimal set for text-only PDF rendering.
libgdk-pixbuf-2.0-0 excluded (no images in PDF, verified via weasyprint --info).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:31 +02:00
Knacky
87e4409530 feat: add engagement export service and endpoint (md/csv/pdf)
- New module backend/app/services/export.py with render_engagement_markdown,
  render_engagement_csv, render_engagement_pdf, _render_engagement_html helper,
  and _export_filename slugifier (NFKD + fallback "unnamed").
- Extend engagements_bp with GET /api/engagements/<int:eid>/export?format=md|csv|pdf,
  gated @role_required("admin","redteam"). Returns 400 on missing/unknown format,
  404 on unknown engagement, correct Content-Type + Content-Disposition headers.
- Reuses _enrich_techniques and _enrich_tactics from serializers.py; resilient
  to MITRE bundle not loaded (returns empty tactics, no crash).
- Adds weasyprint>=60.0 to backend/requirements.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:22 +02:00
Knacky
cf006a2ba8 docs(plan): sprint 6 — apply spec-reviewer Pass 1 fixes (1 BLOCKER + 6 WARN)
Fixes applied:
- BLOCKER §2 : EngagementDetailPage.test.tsx → "nouveau" (n'existe pas
  encore), pas "existant — adapter".
- WARN §1 : "Première ligne du summary" obligatoire pour backend-builder
  avec le path final EXACT (anti-URL-drift, lesson sprint 5).
- WARN §0/§1 : slug avec NFKD-strip pour accents + fallback "unnamed"
  pour edge case nom 100% non-alphanum.
- WARN §2 : ExportEngagementButton les DEUX moitiés ouvrent le dropdown
  (pas d'action par défaut — différence vs NewSimulationDropdown).
- WARN §2 : exports.ts throw Error sur non-2xx pour pipeline toast.
- WARN §1 : created_by rendu username-only en MD/CSV (pas la dict).
- WARN §1 : PDF généré depuis les DONNÉES (pas depuis le string Markdown).

NITs incorporés :
- gdk-pixbuf-2.0-0 retiré du set minimal (text-only PDF), avec note
  pour confirmer via weasyprint --info.
- data-testid="export-dropdown" sur le wrapper pour AC-30.1.
- AC-29.3 : compter rows via csv.reader, pas file.split.
- §0 point 14 : style explicite btn-outline (cohérence header).
- Test MITRE-bundle-not-loaded ajouté à test_export_render.py.

Plan prêt pour spec-reviewer Pass 2.
2026-06-07 18:38:41 +02:00
Knacky
01434c04a7 docs(plan): sprint 6 — engagement export (md/csv/pdf) plan
3 user stories scoped (US-29 export formats, US-30 SOC zero access,
US-31 format/engagement robustness). Backend extends engagements_bp
with GET /api/engagements/<id>/export?format=md|csv|pdf returning the
rendered file, no DB schema change. Frontend adds an
ExportEngagementButton split-button dropdown on EngagementDetailPage,
gated to admin+redteam.

Binding decisions locked with the user: 3 formats Markdown/CSV/PDF,
RBAC admin+redteam, engagement + all simulations RT+SOC, single
endpoint with format query param. WeasyPrint chosen for PDF (Python
HTML→PDF, ~50MB cairo/pango deps to add to Dockerfile, accepted).

Plan ready for spec-reviewer Pass 1.
2026-06-07 18:29:59 +02:00
Knacky
7aaa5ccc6d docs(spec): add § Export d'engagement section (sprint 6)
Specifies the new export feature contract:
- 3 formats : Markdown, CSV, PDF
- Engagement header + all simulations RT + SOC
- Endpoint unique GET /api/engagements/<id>/export?format=md|csv|pdf
- RBAC admin + redteam (SOC zero access, cohérent avec Templates)
- Filename normalisé engagement-<id>-<slug>-YYYYMMDD.<ext>

Committed as commit #1 of sprint 6 — applies lesson learned in sprints 3/4/5
where the SPEC section sat as uncommitted M SPEC.md until sprint-close
discovery. Per lessons.md §sprint-5 fix candidate "Stage SPEC.md as part
of the FIRST sprint commit, not as a separate later commit."
2026-06-07 18:29:49 +02:00
21 changed files with 2361 additions and 268 deletions

View File

@@ -6,6 +6,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]
### Added — Sprint 6 (Engagement export)
**Backend** (253 pytest passing — 226 sprint-1-to-4 + 28 sprint 5 + 5 sprint 5 post-code-review + 23 sprint 6 + 1 CSV-injection defense-in-depth test)
- `backend/app/services/export.py` (new, 302 lines) — 3 pure render functions (`render_engagement_markdown`, `render_engagement_csv`, `render_engagement_pdf`) + filename slugifier (`_export_filename`) + HTML helper for the PDF pipeline + CSV formula-injection defense helper (`_csv_safe`).
- New endpoint `GET /api/engagements/<int:eid>/export?format=md|csv|pdf` extended on the existing `engagements_bp`. Decorator `@role_required("admin", "redteam")` (SOC → 403). 400 on missing/unknown format, 404 on unknown engagement. Returns the rendered file body with `Content-Type` matching the format and `Content-Disposition: attachment; filename="engagement-<id>-<slug>-YYYYMMDD.<ext>"`.
- Filename slugifier uses `unicodedata.normalize('NFKD', ...).encode('ascii', 'ignore')` to strip accents (`Opération``operation`) and falls back to `"unnamed"` when the slug is empty after stripping.
- Markdown rendering uses fenced code blocks with `~~~bash` (tildes, not backticks) so backticks in commands don't break the fence. SOC fields are always rendered, even when blank (consistency for handoff). `_creator()` helper renders the username string only (not the `{id, username}` dict).
- CSV rendering uses stdlib `csv.writer` (handles multiline / quotes / commas natively). `_csv_safe()` prefixes a single apostrophe to any string starting with `=`, `+`, `-`, `@`, `\t`, or `\r` — defuses Excel / LibreOffice / Google Sheets formula injection on the SOC analyst's machine when they open the exported CSV. Applied to all user-controlled string fields; ISO dates and the enum status value are exempted.
- PDF rendering via **WeasyPrint** (Python HTML→PDF). The PDF is generated from the same engagement DATA as the Markdown (not from the Markdown string) via `_render_engagement_html()` and `weasyprint.HTML(string=html).write_pdf()`. CSS inline (≤ 30 lines). All user-controlled fields HTML-escaped via stdlib `html.escape()`.
- `docker/Dockerfile` python stage now installs minimal WeasyPrint deps: `libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info`. `libgdk-pixbuf-2.0-0` deliberately excluded (text-only PDF).
- `weasyprint>=60.0` added to `backend/requirements.txt`.
- No DB schema change. No migration.
**Frontend** (136 vitest passing — 121 sprint-1-to-5 + 12 sprint 6 + 3 sprint 6 coverage-gap fix)
- `frontend/src/components/ExportEngagementButton.tsx` (new) — split-button dropdown `[Export ▼]` with `Download` + `ChevronDown` lucide icons. **Both halves open the dropdown** (no default left-click action — different semantic from sprint 5's `NewSimulationDropdown` where left navigates blank), because there is no obvious default format among MD/CSV/PDF. Loading state per-item, toast on error. Click-outside + Escape close (reuses the `useEffect` + `pointerdown` + `keydown` pattern from `NewSimulationDropdown`). `data-testid="export-dropdown"` for e2e selection. Visual: shares `btn-outline` class with the neighbour `Edit` button.
- `frontend/src/api/exports.ts` (new) — `downloadEngagementExport(engagementId, format)` with `responseType: 'blob'`. Reads `Content-Disposition: attachment; filename="..."`, falls back to `engagement-<id>.<ext>` when the header is absent or malformed. Throws an `Error` on non-2xx (caller catches and toasts). Helper `parseContentDispositionFilename()`.
- `frontend/src/pages/EngagementDetailPage.tsx` (edited) — integrates `<ExportEngagementButton engagementId={engagement.id} />` in the header next to the `Edit` CTA. Gated by `canEditEngagements` from `useAuth` (admin + redteam).
- New test file `frontend/tests/exports.test.ts` covers the API client directly via `axios-mock-adapter` (the component test file mocks `downloadEngagementExport` entirely, so the fallback logic inside `exports.ts` wasn't reachable from there — new file lets the real function run for 3 dedicated tests).
**Acceptance tests** (Playwright, **223 passed** — baseline sprint 5 = 201, +22 sprint 6)
- 3 new spec files (one per US): `us29-export-formats.spec.ts` (8 tests), `us30-export-rbac.spec.ts` (3 tests), `us31-export-robustness.spec.ts` (5 tests).
- No regression on sprints 15: full pre-sprint-6 suite still green.
**Security**
- CSV formula injection (MEDIUM) flagged by `security-guidance@claude-code-plugins` automated review during the sprint, fixed mid-sprint (commit `57dbd14`). 3 dedicated unit tests cover the apostrophe-prefix on `=`, `@` triggers and the no-op on safe strings.
- Defense-in-depth: a property test (`test_export_filename_never_contains_quote_or_crlf`) asserts the slugifier output never contains `"`, `\r`, or `\n` — guards against Content-Disposition header injection if someone later weakens the slug regex.
### Changed
- 2026-06-07 — SPEC.md § Export d'engagement added (between § Templates de simulations and § Stacks techniques). Committed as the **first** sprint commit (`7aaa5cc`), applying the fix-candidate from sprint 5's recurrent "SPEC.md uncommitted at sprint close" lesson. Four-sprint recurrence finally broken.
- 2026-06-08 — Team `mimic` (persistent `.claude/teams/mimic/config.json`) instantiated with the full 7-agent project roster (backend-builder, frontend-builder, spec-reviewer, code-reviewer, design-reviewer, test-verifier, devil-advocate). Agents are spawned with an idle prompt at sprint start and woken via SendMessage per phase — flip vs the old "spawn-with-task-only" policy that hit the "team roster is flat" gotcha when respawning. Persistent across sprints from sprint 7+.
- 2026-06-08 (post-review, pre-merge) — **Export schema switched to a fixed 7-column handoff layout** uniform across MD/CSV/PDF. Columns (FR headers): `Scénario`, `Test`, `Source de log`, `Commentaires SOC`, `Exécution` (multi-line concat without labels — `executed_at``commands``execution_result`), `Logs remontés au SIEM`, `Cyber incident`. Removed from the export (intentional, handoff-focused): simulation status, MITRE techniques/tactics, prerequisites, id, created_at, updated_at. Markdown switched from narrative-per-simulation to a GFM table. PDF switched from sectioned HTML to a single `<table>`. SPEC `fdab324`, backend refactor `7335b9f`, e2e adaptation `aeb4bdb`. Final counters: backend 257 pytest, frontend 136 vitest, e2e 223 Playwright.
- 2026-06-08 (post-refactor, pre-merge) — **Two MEDIUM security regressions fixed** in the 7-column refactor (`3a9d9d3`), flagged by `security-guidance@claude-code-plugins`:
1. **CSV formula injection inside the multi-line `Exécution` cell**: `_csv_safe` only checks `cell[0]`. With `executed_at` non-null, the cell starts with a safe date digit, but inner lines (commands, execution_result) starting with `=`/`+`/`-`/`@` evaded defense. Fix: `_format_execution_csv()` applies `_csv_safe` per user-controlled component BEFORE the multi-line concat. Outer `_csv_safe` on the assembled cell retained as belt-and-braces.
2. **Stored XSS in Markdown table cells**: the new GFM table allows inline HTML (we use it for `<br/>`). A `sim.commands = "<script>alert(1)</script>"` would be rendered raw by MD viewers that interpret inline HTML (Notion, Obsidian, GitHub preview). Fix: `_cell()` now calls `html.escape()` on each value BEFORE the pipe-escape and `\n``<br/>` substitution — mirrors the `_render_engagement_html` PDF defense. The `<br/>` we insert ourselves stays unescaped (it's not user-controlled). 2 dedicated regression tests added.
- 2026-06-09 (post-merge-review) — PDF export: A4 landscape orientation (user feedback post-merge-review). `@page { size: A4 landscape; }` added to `_CSS`; `font-size` reduced to 11px and `table-layout: fixed; word-break: break-word` added to prevent 7-column overflow on narrower portrait layout.
---
## [Sprint 5] — Simulation templates + instantiation + nav + dropdown (merged 2026-05-28)
### Added — Sprint 5 (Simulation templates)
**Backend** (226 pytest passing — 193 sprint-1-to-4 + 28 sprint 5 + 5 post-code-review)

View File

@@ -2,7 +2,7 @@
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
> Status: **Sprint 5Simulation templates**. Admin/redteam can now create reusable simulation templates (name + description + commands + prerequisites + MITRE techniques + tactics) and instantiate them inside an engagement in one click. Template and instance are fully decoupled — editing one never affects the other. SOC has no access to templates.
> Status: **Sprint 6Engagement export**. Admin/redteam can now export an engagement to Markdown, CSV, or PDF in one click from `EngagementDetailPage`. The export contains the engagement header and all simulations with both Red Team and SOC fields — closing the "replace the shared Excel" loop. CSV cells are defused against spreadsheet formula injection. SOC has no access to the export.
---
@@ -139,9 +139,9 @@ npm run dev # http://localhost:5173 with /api proxied to :5000
Tests:
```bash
cd backend && pytest -q # 226 tests
cd frontend && npm run test -- --run # 121 tests
cd e2e && npx playwright test # 201 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
cd backend && pytest -q # 253 tests
cd frontend && npm run test -- --run # 136 tests
cd e2e && npx playwright test # 223 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
```
---

17
SPEC.md
View File

@@ -39,6 +39,23 @@ L'instanciation d'un template dans un engagement crée une **nouvelle simulation
**RBAC templates = ressource Red Team uniquement** : admin et redteam les gèrent (CRUD). SOC n'a aucun accès (pas de nav link, tous endpoints templates retournent 403). Les nouveaux noms de templates sont uniques pour la clarté UX du dropdown d'instanciation.
## Export d'engagement
Un engagement peut être exporté à tout moment dans 3 formats au choix : **Markdown** (handoff narratif), **CSV** (machine-readable, intégration tableurs), **PDF** (livrable client). Endpoint unique : `GET /api/engagements/<id>/export?format=md|csv|pdf`. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : `engagement-<id>-<slug>-YYYYMMDD.<ext>`.
**Schéma fixe à 7 colonnes** (en-têtes français) pour tous les formats — une ligne par simulation :
| # | Colonne | Source |
|---|---|---|
| 1 | Scénario | `simulation.name` |
| 2 | Test | `simulation.description` |
| 3 | Source de log | `simulation.log_source` |
| 4 | Commentaires SOC | `simulation.soc_comment` |
| 5 | Exécution | concat multi-ligne sans labels, ordre fixe : `executed_at``commands``execution_result` |
| 6 | Logs remontés au SIEM | `simulation.logs` |
| 7 | Cyber incident | `simulation.incident_number` |
CSV : exactement 1 ligne d'en-tête + 1 ligne par simulation. Markdown : en-tête engagement (name, dates, status, created_by) puis une table de 7 colonnes. PDF : même structure que le Markdown rendue via HTML→PDF (WeasyPrint). Le statut de la simulation, les techniques/tactiques MITRE, les prerequisites et les métadonnées (id, created_at) ne sont PAS exportés — l'export est un handoff focalisé RT↔SOC, pas un dump complet.
Prévoir un module d'authentification : dans un premier temps local à la bdd.
Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.

View File

@@ -3,12 +3,19 @@ from __future__ import annotations
from datetime import date
from flask import Blueprint, g, jsonify, request
from flask import Blueprint, Response, g, jsonify, request
from backend.app.auth import login_required, role_required
from backend.app.extensions import db
from backend.app.models import Engagement, EngagementStatus
from backend.app.models.simulation import Simulation
from backend.app.serializers import serialize_engagement
from backend.app.services.export import (
_export_filename,
render_engagement_csv,
render_engagement_markdown,
render_engagement_pdf,
)
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
@@ -156,3 +163,48 @@ def delete_engagement(engagement_id: int):
db.session.delete(engagement)
db.session.commit()
return "", 204
@engagements_bp.get("/<int:eid>/export")
@role_required("admin", "redteam")
def export_engagement(eid: int):
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
fmt = request.args.get("format", "").strip().lower()
if fmt not in ("md", "csv", "pdf"):
return jsonify({"error": "format must be one of: md, csv, pdf"}), 400
simulations = (
Simulation.query.filter_by(engagement_id=eid)
.order_by(Simulation.id.asc())
.all()
)
if fmt == "md":
body = render_engagement_markdown(engagement, simulations)
filename = _export_filename(engagement, "md")
return Response(
body,
mimetype="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
if fmt == "csv":
body = render_engagement_csv(engagement, simulations)
filename = _export_filename(engagement, "csv")
return Response(
body,
mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# pdf
body_bytes = render_engagement_pdf(engagement, simulations)
filename = _export_filename(engagement, "pdf")
return Response(
body_bytes,
mimetype="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View File

@@ -0,0 +1,277 @@
"""Engagement export renderers — Markdown, CSV, PDF."""
from __future__ import annotations
import csv
import io
import re
import unicodedata
from datetime import date
from html import escape as _html_escape
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.app.models.engagement import Engagement
from backend.app.models.simulation import Simulation
def _export_filename(engagement: Engagement, ext: str) -> str:
name = engagement.name or ""
normalized = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")[:60] or "unnamed"
today = date.today().strftime("%Y%m%d")
return f"engagement-{engagement.id}-{slug}-{today}.{ext}"
def _creator(obj: object) -> str:
"""Return username string from an ORM object with a created_by relationship."""
cb = getattr(obj, "created_by", None)
if cb is None:
return ""
return getattr(cb, "username", "") or ""
# ---------------------------------------------------------------------------
# CSV formula-injection defense (defined early — used by _format_execution_csv)
# ---------------------------------------------------------------------------
# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
# reaches the formula parser in some sheet versions.
_CSV_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
def _csv_safe(value: object) -> object:
"""Defuse spreadsheet formula injection by prefixing user-controlled cells.
Excel / LibreOffice / Google Sheets interpret cells starting with =, +, -, @,
\\t or \\r as formulas. Since this CSV is the engagement handoff to SOC and is
explicitly opened in a spreadsheet app, an authenticated red-team user could
craft a simulation field that executes on the SOC analyst's machine. Prefixing
with a single apostrophe forces the spreadsheet to treat the cell as text.
"""
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
return "'" + value
return value
# ---------------------------------------------------------------------------
# Execution cell helpers
# ---------------------------------------------------------------------------
def _format_execution_text(sim: Simulation) -> str:
"""Canonical 3-part execution concat for Markdown and PDF (no CSV sanitization)."""
parts = [
sim.executed_at.isoformat() if sim.executed_at else "",
sim.commands or "",
sim.execution_result or "",
]
return "\n".join(parts)
def _format_execution_csv(sim: Simulation) -> str:
"""Execution concat for CSV: each user-controlled component is formula-defused
before joining so that inner lines starting with =, +, -, @ are safe."""
parts = [
sim.executed_at.isoformat() if sim.executed_at else "",
str(_csv_safe(sim.commands or "")),
str(_csv_safe(sim.execution_result or "")),
]
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Markdown
# ---------------------------------------------------------------------------
_MD_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
def render_engagement_markdown(
engagement: Engagement, simulations: list[Simulation]
) -> str:
lines: list[str] = []
lines.append(f"# {engagement.name}")
lines.append("")
if engagement.description:
lines.append(engagement.description)
lines.append("")
lines.append(f"**Status**: {engagement.status.value}")
lines.append(
f"**Start date**: {engagement.start_date.isoformat() if engagement.start_date else 'N/A'}"
)
lines.append(
f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}"
)
lines.append(f"**Created by**: {_creator(engagement)}")
lines.append(
f"**Created at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}"
)
lines.append("")
if not simulations:
return "\n".join(lines)
lines.append("---")
lines.append("")
lines.append("## Simulations")
lines.append("")
header_row = "| " + " | ".join(_MD_HEADERS) + " |"
separator = "| " + " | ".join("---" for _ in _MD_HEADERS) + " |"
lines.append(header_row)
lines.append(separator)
for sim in simulations:
def _cell(value: str | None) -> str:
# Escape HTML (including quotes) first to prevent stored XSS in MD renderers
# that interpret inline HTML, then escape pipe (GFM table syntax),
# then fold newlines to <br/> (our own safe markup, inserted after escape).
s = _html_escape(value or "")
s = s.replace("|", "\\|")
s = s.replace("\n", "<br/>")
return s
execution = _format_execution_text(sim)
row = "| " + " | ".join([
_cell(sim.name),
_cell(sim.description),
_cell(sim.log_source),
_cell(sim.soc_comment),
_cell(execution),
_cell(sim.logs),
_cell(sim.incident_number),
]) + " |"
lines.append(row)
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CSV
# ---------------------------------------------------------------------------
_CSV_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
def render_engagement_csv(
_engagement: Engagement, simulations: list[Simulation]
) -> str:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_HEADERS)
for sim in simulations:
execution = _format_execution_csv(sim)
writer.writerow([
_csv_safe(sim.name or ""),
_csv_safe(sim.description or ""),
_csv_safe(sim.log_source or ""),
_csv_safe(sim.soc_comment or ""),
_csv_safe(execution), # belt-and-braces: outer check covers empty executed_at case
_csv_safe(sim.logs or ""),
_csv_safe(sim.incident_number or ""),
])
return buf.getvalue()
# ---------------------------------------------------------------------------
# HTML (internal, used by PDF renderer)
# ---------------------------------------------------------------------------
_CSS = """
@page { size: A4 landscape; margin: 20mm; }
body { font-family: sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
h1 { font-size: 20px; border-bottom: 2px solid #333; padding-bottom: 6px; }
h2 { font-size: 15px; margin-top: 32px; color: #333; }
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; table-layout: fixed; }
th, td { border: 1px solid #ccc; padding: 3px 6px; text-align: left; vertical-align: top; white-space: pre-wrap; word-break: break-word; }
th { background: #e0e0e0; }
.meta { color: #555; margin-bottom: 16px; }
"""
_HTML_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
def _render_engagement_html(
engagement: Engagement, simulations: list[Simulation]
) -> str:
h = _html_escape
parts: list[str] = []
parts.append("<!DOCTYPE html><html><head><meta charset='utf-8'>")
parts.append(f"<style>{_CSS}</style></head><body>")
parts.append(f"<h1>{h(engagement.name)}</h1>")
parts.append("<div class='meta'>")
if engagement.description:
parts.append(f"<p>{h(engagement.description)}</p>")
parts.append(f"<p><strong>Status:</strong> {h(engagement.status.value)}</p>")
sd = engagement.start_date.isoformat() if engagement.start_date else "N/A"
ed = engagement.end_date.isoformat() if engagement.end_date else "N/A"
parts.append(f"<p><strong>Dates:</strong> {h(sd)}{h(ed)}</p>")
parts.append(f"<p><strong>Created by:</strong> {h(_creator(engagement))}</p>")
ca = engagement.created_at.isoformat() if engagement.created_at else "N/A"
parts.append(f"<p><strong>Created at:</strong> {h(ca)}</p>")
parts.append("</div>")
if simulations:
parts.append("<h2>Simulations</h2>")
thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in _HTML_HEADERS) + "</tr></thead>"
parts.append(f"<table>{thead}<tbody>")
for sim in simulations:
execution_html = h(_format_execution_text(sim)).replace("\n", "<br/>")
cells = [
h(sim.name or ""),
h(sim.description or ""),
h(sim.log_source or ""),
h(sim.soc_comment or ""),
execution_html,
h(sim.logs or ""),
h(sim.incident_number or ""),
]
row = "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
parts.append(row)
parts.append("</tbody></table>")
parts.append("</body></html>")
return "".join(parts)
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
def render_engagement_pdf(
engagement: Engagement, simulations: list[Simulation]
) -> bytes:
from weasyprint import HTML
html = _render_engagement_html(engagement, simulations)
return HTML(string=html).write_pdf()

View File

@@ -3,6 +3,7 @@ Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
PyJWT==2.9.0
argon2-cffi==23.1.0
weasyprint>=60.0
pytest==8.3.3
ruff==0.6.9
mypy==1.11.2

View File

@@ -0,0 +1,270 @@
"""Endpoint tests for GET /api/engagements/<eid>/export."""
from __future__ import annotations
from datetime import date
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models import Engagement, EngagementStatus, User
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.export import _export_filename
from backend.tests.conftest import auth_headers as _h
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str, name: str = "Op Alpha") -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": name, "start_date": "2026-06-01"},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _make_sim(client: FlaskClient, token: str, eid: int, name: str = "Sim One") -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": name},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _export(client: FlaskClient, token: str, eid: int, fmt: str):
return client.get(
f"/api/engagements/{eid}/export?format={fmt}",
headers=_h(token),
)
# ---------------------------------------------------------------------------
# RBAC
# ---------------------------------------------------------------------------
def test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "Lateral Movement")
_make_sim(client, admin_token, eng["id"], "Persistence Check")
resp = _export(client, admin_token, eng["id"], "md")
assert resp.status_code == 200
assert "text/markdown" in resp.content_type
body = resp.data.decode()
assert "Op Alpha" in body
# Both simulation names appear as cells in the 7-column table
assert "Lateral Movement" in body
assert "Persistence Check" in body
# Table uses French column headers
assert "Scénario" in body
def test_export_markdown_redteam_ok(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
resp = _export(client, redteam_token, eng["id"], "md")
assert resp.status_code == 200
def test_export_markdown_soc_403(
client: FlaskClient, soc_token: str, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _export(client, soc_token, eng["id"], "md")
assert resp.status_code == 403
def test_export_unauthenticated_401(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(f"/api/engagements/{eng['id']}/export?format=md")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# CSV
# ---------------------------------------------------------------------------
def test_export_csv_returns_csv_with_one_row_per_simulation(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "S1")
_make_sim(client, admin_token, eng["id"], "S2")
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
assert "text/csv" in resp.content_type
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
# 1 header + 2 simulations
assert len(rows) == 3
def test_export_csv_columns_match_contract(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
expected_headers = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
assert rows[0] == expected_headers
def test_export_csv_escapes_special_characters(
client: FlaskClient, admin_token: str, app
) -> None:
eng = _make_engagement(client, admin_token)
with app.app_context():
admin = User.query.filter_by(username="admin1").first()
sim = Simulation(
engagement_id=eng["id"],
name='Sim "quoted"',
commands='cmd1, cmd2\nnewline "here"',
status=SimulationStatus.PENDING,
created_by_id=admin.id,
)
db.session.add(sim)
db.session.commit()
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
body = resp.data.decode()
# csv.writer must have quoted the fields — no raw unquoted double-quotes breaking rows
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(body)))
assert len(rows) == 2 # header + 1 sim
name_col = rows[1][0] # col 0 = Scénario
assert "quoted" in name_col
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
def test_export_pdf_returns_pdf_magic_bytes_and_non_empty(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "S1")
resp = _export(client, admin_token, eng["id"], "pdf")
assert resp.status_code == 200
assert resp.content_type == "application/pdf"
assert resp.data[:4] == b"%PDF"
assert len(resp.data) > 1024
# ---------------------------------------------------------------------------
# 400 / 404
# ---------------------------------------------------------------------------
def test_export_unknown_format_400(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/export?format=xml",
headers=_h(admin_token),
)
assert resp.status_code == 400
assert "format must be one of" in resp.get_json()["error"]
def test_export_missing_format_400(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/export",
headers=_h(admin_token),
)
assert resp.status_code == 400
assert "format must be one of" in resp.get_json()["error"]
def test_export_unknown_engagement_404(
client: FlaskClient, admin_token: str
) -> None:
resp = client.get(
"/api/engagements/99999/export?format=md",
headers=_h(admin_token),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_export_engagement_with_zero_simulations_renders_header_only(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token, "Empty Engagement")
resp_md = _export(client, admin_token, eng["id"], "md")
assert resp_md.status_code == 200
assert "Empty Engagement" in resp_md.data.decode()
resp_csv = _export(client, admin_token, eng["id"], "csv")
assert resp_csv.status_code == 200
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp_csv.data.decode())))
assert len(rows) == 1 # header only
resp_pdf = _export(client, admin_token, eng["id"], "pdf")
assert resp_pdf.status_code == 200
assert resp_pdf.data[:4] == b"%PDF"
def test_export_filename_slugifies_name_and_carries_date(app, admin_user: User) -> None:
with app.app_context():
eng = Engagement(
name="Opération Spéciale!",
start_date=date(2026, 6, 1),
status=EngagementStatus.PLANNED,
created_by_id=admin_user.id,
)
db.session.add(eng)
db.session.commit()
fname = _export_filename(eng, "md")
from datetime import date as _date
today = _date.today().strftime("%Y%m%d")
assert fname.startswith(f"engagement-{eng.id}-")
assert "operation-speciale" in fname
assert fname.endswith(f"-{today}.md")

View File

@@ -0,0 +1,317 @@
"""Unit tests for render functions in backend.app.services.export."""
from __future__ import annotations
import csv as _csv
import io as _io
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from backend.app.services.export import (
render_engagement_csv,
render_engagement_markdown,
render_engagement_pdf,
)
# ---------------------------------------------------------------------------
# Fixtures / factories
# ---------------------------------------------------------------------------
def _make_engagement(**kw) -> Any:
from datetime import date
defaults: dict[str, Any] = {
"id": 1,
"name": "Test Engagement",
"description": "A purple team exercise",
"start_date": date(2026, 6, 1),
"end_date": date(2026, 6, 30),
"status": SimpleNamespace(value="active"),
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
"created_by": SimpleNamespace(username="alice"),
}
defaults.update(kw)
return SimpleNamespace(**defaults)
def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
defaults: dict[str, Any] = {
"id": sid,
"name": name,
"status": SimpleNamespace(value="pending"),
"description": "Execute a script",
"commands": "whoami",
"executed_at": None,
"execution_result": None,
"log_source": None,
"logs": None,
"soc_comment": None,
"incident_number": None,
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
"updated_at": None,
"created_by": SimpleNamespace(username="bob"),
}
defaults.update(kw)
return SimpleNamespace(**defaults)
# ---------------------------------------------------------------------------
# Shared constants
# ---------------------------------------------------------------------------
_FR_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
# ---------------------------------------------------------------------------
# Markdown tests
# ---------------------------------------------------------------------------
def test_render_engagement_markdown_includes_header_fields(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_markdown(eng, [])
assert "Test Engagement" in result
assert "2026-06-01" in result
assert "2026-06-30" in result
assert "active" in result
assert "alice" in result
def test_render_engagement_markdown_has_seven_column_table_headers(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_markdown(eng, [sim])
for header in _FR_HEADERS:
assert header in result, f"Expected French header '{header}' in markdown table"
def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
with app.app_context():
eng = _make_engagement()
sims = [_make_sim(1, "First Sim"), _make_sim(2, "Second Sim")]
result = render_engagement_markdown(eng, sims)
first_pos = result.index("First Sim")
second_pos = result.index("Second Sim")
assert first_pos < second_pos
def test_render_engagement_markdown_no_simulations_has_no_table(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_markdown(eng, [])
assert "Scénario" not in result
assert "## Simulations" not in result
def test_render_engagement_markdown_execution_cell_uses_br_separator(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="whoami",
execution_result="admin@host",
)
result = render_engagement_markdown(eng, [sim])
assert "<br/>" in result
assert "whoami" in result
assert "admin@host" in result
def test_render_engagement_markdown_pipe_in_cell_is_escaped(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="Name | with pipe")
result = render_engagement_markdown(eng, [sim])
assert "Name \\| with pipe" in result
# ---------------------------------------------------------------------------
# CSV tests
# ---------------------------------------------------------------------------
def _parse_csv(csv_text: str) -> list[list[str]]:
return list(_csv.reader(_io.StringIO(csv_text)))
def test_render_engagement_csv_has_header_row(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_csv(eng, [])
rows = _parse_csv(result)
assert rows[0] == _FR_HEADERS
def test_render_engagement_csv_has_one_row_per_simulation(app) -> None:
with app.app_context():
eng = _make_engagement()
sims = [_make_sim(1, "S1"), _make_sim(2, "S2")]
result = render_engagement_csv(eng, sims)
rows = _parse_csv(result)
assert len(rows) == 3 # header + 2 sims
def test_render_engagement_csv_columns_are_seven(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_csv(eng, [sim])
rows = _parse_csv(result)
assert len(rows[0]) == 7
assert len(rows[1]) == 7
def test_render_engagement_csv_execution_column_contains_commands(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="net user /domain",
execution_result="success",
)
result = render_engagement_csv(eng, [sim])
rows = _parse_csv(result)
exec_cell = rows[1][4] # col index 4 = Exécution
assert "2026-06-01" in exec_cell
assert "net user /domain" in exec_cell
assert "success" in exec_cell
# ---------------------------------------------------------------------------
# CSV formula injection defense
# ---------------------------------------------------------------------------
def _parse_csv_data_row(csv_text: str, row_index: int = 1) -> list[str]:
return _parse_csv(csv_text)[row_index]
def test_render_engagement_csv_escapes_formula_injection_in_scenario(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="=cmd|'/c calc'!A1")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# col 0 = Scénario
assert cells[0] == "'=cmd|'/c calc'!A1"
def test_render_engagement_csv_escapes_formula_injection_in_execution(app) -> None:
with app.app_context():
eng = _make_engagement()
# executed_at=None so concat is "\ncommand\n" — leading \n is not a trigger.
# Use a formula-triggering execution_result to test the final concat.
sim = _make_sim(execution_result="=HYPERLINK(\"http://evil\")")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# col 4 = Exécution; concat starts with "\n" (empty executed_at) so not triggered
# but the execution_result value is embedded — verify it's present
assert "HYPERLINK" in cells[4]
def test_render_engagement_csv_defuses_formula_in_inner_execution_lines(app) -> None:
"""When executed_at is set, the cell starts with a safe date, but commands
line may inject formulas. Each user-controlled component must be defused."""
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="=cmd|'/c calc'!A1",
execution_result="@SUM(1)",
)
result = render_engagement_csv(eng, [sim])
cells = list(_csv.reader(_io.StringIO(result)))[1]
execution_cell = cells[4] # Exécution column
assert "'=cmd|'/c calc'!A1" in execution_cell
assert "'@SUM(1)" in execution_cell
def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="Mimikatz LSASS Dump", commands="whoami /all")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
assert cells[0] == "Mimikatz LSASS Dump"
assert "whoami /all" in cells[4]
def test_render_engagement_markdown_escapes_html_in_table_cells(app) -> None:
"""User content in table cells must be HTML-escaped to prevent stored XSS
when the .md is opened in a renderer that interprets inline HTML."""
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
name="<script>alert(1)</script>",
commands='<img src=x onerror="alert(1)">',
)
result = render_engagement_markdown(eng, [sim])
assert "<script>" not in result
assert 'onerror="alert' not in result
assert "&lt;script&gt;" in result
assert "&lt;img" in result
# double-quotes in attribute values are also escaped
assert "&quot;" in result
# ---------------------------------------------------------------------------
# PDF tests
# ---------------------------------------------------------------------------
def test_render_engagement_pdf_starts_with_pdf_magic(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_pdf(eng, [sim])
assert isinstance(result, bytes)
assert result[:4] == b"%PDF"
def test_render_engagement_pdf_contains_simulation_table(app) -> None:
from backend.app.services.export import _render_engagement_html
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
html = _render_engagement_html(eng, [sim])
assert "<table>" in html
for header in _FR_HEADERS:
assert header in html, f"Expected French header '{header}' in HTML"
def test_render_engagement_html_has_landscape_page_rule(app) -> None:
from backend.app.services.export import _render_engagement_html
with app.app_context():
eng = _make_engagement()
html = _render_engagement_html(eng, [])
assert "landscape" in html, "HTML must include A4 landscape @page rule for PDF output"
# ---------------------------------------------------------------------------
# Defense-in-depth: filename header injection
# ---------------------------------------------------------------------------
def test_export_filename_never_contains_quote_or_crlf() -> None:
"""Defense-in-depth: even with malicious engagement names, the filename
used in Content-Disposition must never contain header-injection chars."""
from backend.app.services.export import _export_filename
evil = SimpleNamespace(id=42, name='evil"name\r\nX-Injected: yes')
fname = _export_filename(evil, "md")
assert '"' not in fname
assert '\r' not in fname
assert '\n' not in fname

View File

@@ -9,6 +9,14 @@ RUN npm run build
# Stage 2: python runtime
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2 \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libfontconfig1 \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./backend/
RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend/ ./backend/

View File

@@ -0,0 +1,334 @@
/**
* US-29 — Admin/redteam exports an engagement in Markdown, CSV, PDF.
*
* Strategy: seed one engagement with 2 simulations via the API, then drive the
* ExportEngagementButton dropdown in Chromium. Downloads are captured via
* page.waitForEvent('download') and read back with fs.readFile.
*
* AC covered: 29.1 — 29.6
*/
import * as fs from 'fs/promises';
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
makeClient,
type Engagement,
} from '../fixtures/api';
const CSV_HEADER_COLS = [
'Scénario',
'Test',
'Source de log',
'Commentaires SOC',
'Exécution',
'Logs remontés au SIEM',
'Cyber incident',
];
import { seedTokenInStorage } from '../fixtures/auth';
const ADMIN_USER = 'us29-admin';
const REDTEAM_USER = 'us29-redteam';
const PASS = 'us29-pass-strong!';
interface Simulation {
id: number;
name: string;
}
/**
* RFC-4180 row counter.
* Walks char-by-char tracking quoting so that newlines inside quoted cells
* don't count as row breaks. Returns the total row count (including header)
* plus helper strings for assertions.
*/
function countCsvRows(csv: string): {
count: number;
headerLine: string;
dataText: string;
} {
let inQuote = false;
let rowCount = 0;
let lineStart = 0;
let headerLine = '';
for (let i = 0; i < csv.length; i++) {
const ch = csv[i];
if (ch === '"') {
if (inQuote && csv[i + 1] === '"') {
i++; // escaped double-quote inside quoted cell
} else {
inQuote = !inQuote;
}
} else if ((ch === '\n' || ch === '\r') && !inQuote) {
if (ch === '\r' && csv[i + 1] === '\n') i++;
const line = csv.slice(lineStart, i).trim();
if (line.length > 0) {
if (rowCount === 0) headerLine = line;
rowCount++;
}
lineStart = i + 1;
}
}
// trailing row without final newline
const tail = csv.slice(lineStart).trim();
if (tail.length > 0) {
if (rowCount === 0) headerLine = tail;
rowCount++;
}
const dataText = rowCount > 1 ? csv.slice(headerLine.length + 1) : '';
return { count: rowCount, headerLine, dataText };
}
async function createSimulation(
token: string,
engagementId: number,
name: string,
): Promise<Simulation> {
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
if (r.status !== 201) {
throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`);
}
return r.data as Simulation;
}
test.describe('US-29 — Export formats (admin + redteam)', () => {
let adminTok: string;
let redteamTok: string;
let engagement: Engagement;
test.beforeAll(async () => {
await ensureUser(ADMIN_USER, PASS, 'admin');
await ensureUser(REDTEAM_USER, PASS, 'redteam');
adminTok = (await login(ADMIN_USER, PASS)).token;
redteamTok = (await login(REDTEAM_USER, PASS)).token;
engagement = await createEngagement(adminTok, {
name: 'US29 Export Engagement',
description: 'Export test engagement',
start_date: '2026-01-15',
status: 'active',
});
await createSimulation(adminTok, engagement.id, 'US29 Sim Alpha');
await createSimulation(adminTok, engagement.id, 'US29 Sim Beta');
});
test.afterAll(async () => {
try {
await deleteEngagement(adminTok, engagement.id);
const rootTok = await adminToken();
for (const u of [ADMIN_USER, REDTEAM_USER]) await deleteUserByUsername(rootTok, u);
} catch { /* noop */ }
});
// AC-29.1 — Export dropdown opens
test('AC-29.1 — admin: Export dropdown opens with 3 format items', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
// Click the Export button to open dropdown
await dropdownWrapper.locator('button').first().click();
await expect(page.getByRole('menuitem', { name: /markdown/i }).or(
page.locator('[role="menuitem"]').filter({ hasText: /markdown/i })
).first()).toBeVisible({ timeout: 5_000 });
await expect(page.getByText(/csv/i).first()).toBeVisible();
await expect(page.getByText(/pdf/i).first()).toBeVisible();
});
// AC-29.2 — Markdown download
test('AC-29.2 — admin: Markdown download contains engagement name and simulation names', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const content = await fs.readFile(filePath!, 'utf-8');
// Must contain engagement name and start date in the header section
expect(content).toContain('US29 Export Engagement');
expect(content).toContain('2026-01-15');
// Must use the 7-column GFM table layout
expect(content).toContain('| Scénario |');
// Simulation names appear in the Scénario column
expect(content).toContain('US29 Sim Alpha');
expect(content).toContain('US29 Sim Beta');
// Suggested filename from Content-Disposition must end in .md
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.md$/);
});
// AC-29.3 — CSV download: N+1 rows (1 header + N simulations)
test('AC-29.3 — admin: CSV download has N+1 rows (header + 2 sim rows)', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const raw = await fs.readFile(filePath!, 'utf-8');
// Count RFC-4180 rows: walk char-by-char, track quoting state so that
// newlines embedded inside quoted cells don't count as row breaks.
const rows = countCsvRows(raw);
// 1 header + 2 simulation rows
expect(rows.count).toBe(3);
// Header must be exactly the 7 FR columns
const headerCells = rows.headerLine.split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
expect(headerCells).toEqual(CSV_HEADER_COLS);
// Scénario column (index 0) contains simulation names
expect(rows.dataText).toContain('US29 Sim Alpha');
expect(rows.dataText).toContain('US29 Sim Beta');
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.csv$/);
});
// AC-29.4 — PDF download
test('AC-29.4 — admin: PDF download has %PDF magic bytes and size > 1 KB', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const buf = await fs.readFile(filePath!);
// Magic bytes: %PDF
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
// Size > 1 KB
expect(buf.byteLength).toBeGreaterThan(1024);
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.pdf$/);
});
// AC-29.5 — Redteam: all 3 formats work
test('AC-29.5 — redteam: Markdown download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const filePath = await download.path();
const content = await fs.readFile(filePath!, 'utf-8');
expect(content).toContain('US29 Export Engagement');
});
test('AC-29.5 — redteam: CSV download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(),
]);
const filePath = await download.path();
const raw = await fs.readFile(filePath!, 'utf-8');
const rows = countCsvRows(raw);
expect(rows.count).toBeGreaterThanOrEqual(3);
});
test('AC-29.5 — redteam: PDF download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(),
]);
const filePath = await download.path();
const buf = await fs.readFile(filePath!);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
// AC-29.6 — Filename convention: engagement-<id>-<slug>-YYYYMMDD.{ext}
test('AC-29.6 — filename matches engagement-<id>-<slug>-YYYYMMDD pattern', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const suggestedName = download.suggestedFilename();
// Pattern: engagement-<id>-<slug>-YYYYMMDD.md
const filenamePattern = new RegExp(
`^engagement-${engagement.id}-[a-z0-9-]+-\\d{8}\\.md$`,
);
expect(suggestedName).toMatch(filenamePattern);
});
});

View File

@@ -0,0 +1,90 @@
/**
* US-30 — SOC role has zero access to the export feature.
*
* AC-30.1: SOC login → Export button absent from DOM (not just hidden).
* AC-30.2: Direct API call with SOC Bearer → 403.
* AC-30.3: Direct API call without token → 401.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
type Engagement,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const SOC_USER = 'us30-soc';
const ADMIN_USER = 'us30-admin';
const PASS = 'us30-pass-strong!';
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
test.describe('US-30 — SOC zero access to export', () => {
let socTok: string;
let adminTok: string;
let engagement: Engagement;
test.beforeAll(async () => {
await ensureUser(SOC_USER, PASS, 'soc');
await ensureUser(ADMIN_USER, PASS, 'admin');
socTok = (await login(SOC_USER, PASS)).token;
adminTok = (await login(ADMIN_USER, PASS)).token;
engagement = await createEngagement(adminTok, {
name: 'US30 RBAC Engagement',
start_date: '2026-01-01',
});
});
test.afterAll(async () => {
try {
await deleteEngagement(adminTok, engagement.id);
const rootTok = await adminToken();
for (const u of [SOC_USER, ADMIN_USER]) await deleteUserByUsername(rootTok, u);
} catch { /* noop */ }
});
// AC-30.1 — SOC: Export button absent from DOM
test('AC-30.1 — SOC login: Export dropdown is NOT attached to the DOM', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socTok);
await page.goto(`/engagements/${engagement.id}`);
// Wait for the page to fully load (engagement header should be visible)
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 10_000 });
// The export dropdown wrapper must not be in the DOM at all
await expect(
page.locator('[data-testid="export-dropdown"]'),
).not.toBeAttached();
});
// AC-30.2 — SOC Bearer token → 403
test('AC-30.2 — SOC Bearer: GET /api/engagements/<id>/export?format=md → 403', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
{
headers: { Authorization: `Bearer ${socTok}` },
},
);
expect(response.status()).toBe(403);
});
// AC-30.3 — No token → 401
test('AC-30.3 — No token: GET /api/engagements/<id>/export?format=md → 401', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
);
expect(response.status()).toBe(401);
});
});

View File

@@ -0,0 +1,188 @@
/**
* US-31 — Export robustness: format validation and edge cases.
*
* All 4 ACs use direct API calls (no UI needed) for speed and determinism.
*
* AC-31.1: missing ?format → 400 friendly message
* AC-31.2: ?format=xml → 400 friendly message
* AC-31.3: engagement 99999 → 404
* AC-31.4: engagement with 0 simulations → export OK (CSV = 1 header row only)
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
} from '../fixtures/api';
const CSV_HEADER_COLS = [
'Scénario',
'Test',
'Source de log',
'Commentaires SOC',
'Exécution',
'Logs remontés au SIEM',
'Cyber incident',
];
const ADMIN_USER = 'us31-admin';
const PASS = 'us31-pass-strong!';
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
/**
* RFC-4180-aware row counter — same logic as us29 helper.
* Embedded newlines inside quoted cells are not counted as row breaks.
*/
function countCsvRows(csv: string): number {
let inQuote = false;
let rowCount = 0;
let lineStart = 0;
for (let i = 0; i < csv.length; i++) {
const ch = csv[i];
if (ch === '"') {
if (inQuote && csv[i + 1] === '"') {
i++;
} else {
inQuote = !inQuote;
}
} else if ((ch === '\n' || ch === '\r') && !inQuote) {
if (ch === '\r' && csv[i + 1] === '\n') i++;
const line = csv.slice(lineStart, i).trim();
if (line.length > 0) rowCount++;
lineStart = i + 1;
}
}
const tail = csv.slice(lineStart).trim();
if (tail.length > 0) rowCount++;
return rowCount;
}
test.describe('US-31 — Export robustness', () => {
let adminTok: string;
test.beforeAll(async () => {
await ensureUser(ADMIN_USER, PASS, 'admin');
adminTok = (await login(ADMIN_USER, PASS)).token;
});
test.afterAll(async () => {
try {
const rootTok = await adminToken();
await deleteUserByUsername(rootTok, ADMIN_USER);
} catch { /* noop */ }
});
// AC-31.1 — missing format → 400
test('AC-31.1 — GET /export without format → 400 with friendly error', async ({
request,
}) => {
// Need a valid engagement id — use admin to create one transiently
const engagement = await createEngagement(adminTok, {
name: 'US31 missing format eng',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(400);
const body = await response.json();
expect(body).toHaveProperty('error');
expect(body.error).toMatch(/format/i);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
// AC-31.2 — unknown format → 400
test('AC-31.2 — GET /export?format=xml → 400 with friendly error', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 bad format eng',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=xml`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(400);
const body = await response.json();
expect(body).toHaveProperty('error');
expect(body.error).toMatch(/format/i);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
// AC-31.3 — unknown engagement → 404
test('AC-31.3 — GET /engagements/99999/export?format=md → 404', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/99999/export?format=md`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(404);
});
// AC-31.4 — engagement with 0 simulations: export OK
test('AC-31.4 — engagement with 0 simulations: Markdown export OK (header only)', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 empty engagement',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(200);
const text = await response.text();
// Engagement header section present
expect(text).toContain('US31 empty engagement');
// With 0 simulations the GFM table is absent (no rows to render)
expect(text).not.toContain('| Scénario |');
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
test('AC-31.4 — engagement with 0 simulations: CSV export has only 1 header row', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 empty CSV engagement',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=csv`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(200);
const text = await response.text();
// Count rows via RFC-4180-aware counter (handles embedded newlines in quoted cells)
const rowCount = countCsvRows(text);
expect(rowCount).toBe(1);
// The single row is the header with exactly the 7 FR columns
const headerCells = text.trim().split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
expect(headerCells).toEqual(CSV_HEADER_COLS);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
});

View File

@@ -0,0 +1,56 @@
import axios from 'axios';
import { apiClient } from './client';
export type ExportFormat = 'md' | 'csv' | 'pdf';
export function parseContentDispositionFilename(header: string | undefined): string | null {
if (!header) return null;
const match = header.match(/filename="([^"]+)"/);
return match ? match[1] : null;
}
async function parseBlobError(err: unknown): Promise<string> {
if (axios.isAxiosError(err) && err.response?.data instanceof Blob) {
try {
const text = await (err.response.data as Blob).text();
const parsed = JSON.parse(text) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
// fall through to default
}
}
if (err instanceof Error) return err.message;
return 'Export failed';
}
export async function downloadEngagementExport(
engagementId: number,
format: ExportFormat,
): Promise<void> {
try {
const response = await apiClient.get(`/engagements/${engagementId}/export`, {
params: { format },
responseType: 'blob',
});
let filename = parseContentDispositionFilename(
response.headers['content-disposition'] as string | undefined,
);
if (!filename) {
const ext = format === 'md' ? 'md' : format === 'csv' ? 'csv' : 'pdf';
filename = `engagement-${engagementId}.${ext}`;
}
const url = URL.createObjectURL(response.data as Blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
} catch (err) {
const message = await parseBlobError(err);
throw new Error(message);
}
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, Download, Loader2 } from 'lucide-react';
import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
import { useToast } from '@/hooks/useToast';
interface ExportEngagementButtonProps {
engagementId: number;
}
const FORMATS: { label: string; value: ExportFormat }[] = [
{ label: 'Markdown', value: 'md' },
{ label: 'CSV', value: 'csv' },
{ label: 'PDF', value: 'pdf' },
];
export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element {
const { push } = useToast();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState<ExportFormat | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
const handleDownload = async (format: ExportFormat) => {
setLoading(format);
try {
await downloadEngagementExport(engagementId, format);
setOpen(false);
} catch (err) {
push(err instanceof Error ? err.message : 'Export failed', 'error');
} finally {
setLoading(null);
}
};
return (
<div className="relative" ref={ref} data-testid="export-dropdown">
<div className="inline-flex">
<button
type="button"
className="btn-outline rounded-r-none border-r-0"
onClick={() => setOpen((v) => !v)}
data-testid="export-btn"
>
<Download size={14} aria-hidden /> Export
</button>
<button
type="button"
aria-label="Export options"
aria-expanded={open}
className="btn-outline rounded-l-none px-sm"
onClick={() => setOpen((v) => !v)}
data-testid="export-dropdown-toggle"
>
<ChevronDown size={14} aria-hidden />
</button>
</div>
{open ? (
<div
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-md shadow-floating dark:shadow-floating-dark z-20 min-w-[160px]"
role="menu"
>
{FORMATS.map(({ label, value }) => (
<button
key={value}
type="button"
role="menuitem"
disabled={loading !== null}
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog flex items-center gap-sm disabled:opacity-50"
onClick={() => handleDownload(value)}
data-testid={`export-format-${value}`}
>
{loading === value ? (
<Loader2 size={12} className="animate-spin" aria-hidden />
) : null}
{label}
</button>
))}
</div>
) : null}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { StatusBadge } from '@/components/StatusBadge';
import { SimulationList } from '@/components/SimulationList';
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
export function EngagementDetailPage(): JSX.Element {
const { id } = useParams<{ id: string }>();
@@ -43,9 +44,12 @@ export function EngagementDetailPage(): JSX.Element {
</div>
</div>
{canEditEngagements ? (
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
Edit
</Link>
<div className="flex items-center gap-sm">
<ExportEngagementButton engagementId={eng.id} />
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
Edit
</Link>
</div>
) : null}
</header>

View File

@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
import { renderWithProviders } from './utils';
import type { Engagement } from '@/api/types';
vi.mock('@/api/exports', () => ({
downloadEngagementExport: vi.fn(),
}));
const ENGAGEMENT: Engagement = {
id: 1,
name: 'Test Engagement',
description: 'A test engagement',
start_date: '2026-06-01',
end_date: null,
status: 'active',
created_at: '2026-06-01T08:00:00',
created_by: { id: 1, username: 'alice' },
};
type MockRole = 'admin' | 'redteam' | 'soc';
let mockRole: MockRole = 'admin';
function DetailPage() {
return (
<Routes>
<Route path="/engagements/:id" element={<EngagementDetailPage />} />
</Routes>
);
}
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: mockRole, created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: mockRole === 'admin',
isRedteam: mockRole === 'redteam',
isSoc: mockRole === 'soc',
canEditEngagements: mockRole === 'admin' || mockRole === 'redteam',
}),
}));
describe('EngagementDetailPage — RBAC for Export button', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/1').reply(200, ENGAGEMENT);
mock.onGet('/engagements/1/simulations').reply(200, []);
});
afterEach(() => {
mock.restore();
});
it('admin sees Export button', async () => {
mockRole = 'admin';
renderWithProviders(<DetailPage />, {
routerProps: { initialEntries: ['/engagements/1'] },
});
await waitFor(() => {
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
});
expect(screen.getByTestId('export-dropdown')).toBeInTheDocument();
});
it('redteam sees Export button', async () => {
mockRole = 'redteam';
renderWithProviders(<DetailPage />, {
routerProps: { initialEntries: ['/engagements/1'] },
});
await waitFor(() => {
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
});
expect(screen.getByTestId('export-dropdown')).toBeInTheDocument();
});
it('soc does NOT see Export button', async () => {
mockRole = 'soc';
renderWithProviders(<DetailPage />, {
routerProps: { initialEntries: ['/engagements/1'] },
});
await waitFor(() => {
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
});
expect(screen.queryByTestId('export-dropdown')).toBeNull();
});
});

View File

@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
import { ToastViewport } from '@/components/Toast';
import { renderWithProviders } from './utils';
function ExportButtonWithToast({ engagementId }: { engagementId: number }) {
return (
<>
<ExportEngagementButton engagementId={engagementId} />
<ToastViewport />
</>
);
}
vi.mock('@/api/exports', () => ({
downloadEngagementExport: vi.fn(),
}));
import { downloadEngagementExport } from '@/api/exports';
const mockDownload = downloadEngagementExport as ReturnType<typeof vi.fn>;
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: true,
isRedteam: false,
isSoc: false,
canEditEngagements: true,
}),
}));
describe('ExportEngagementButton', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders Export button with chevron', () => {
renderWithProviders(<ExportEngagementButton engagementId={1} />);
expect(screen.getByTestId('export-btn')).toBeInTheDocument();
expect(screen.getByTestId('export-dropdown-toggle')).toBeInTheDocument();
expect(screen.getByText('Export')).toBeInTheDocument();
});
it('clicking primary opens dropdown with three formats', async () => {
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={1} />);
await user.click(screen.getByTestId('export-btn'));
expect(screen.getByText('Markdown')).toBeInTheDocument();
expect(screen.getByText('CSV')).toBeInTheDocument();
expect(screen.getByText('PDF')).toBeInTheDocument();
});
it('clicking outside closes dropdown', async () => {
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={1} />);
await user.click(screen.getByTestId('export-btn'));
expect(screen.getByText('Markdown')).toBeInTheDocument();
await user.click(document.body);
expect(screen.queryByText('Markdown')).toBeNull();
});
it('Escape closes dropdown', async () => {
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={1} />);
await user.click(screen.getByTestId('export-dropdown-toggle'));
expect(screen.getByText('Markdown')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByText('Markdown')).toBeNull();
});
it('clicking Markdown triggers download with format=md', async () => {
mockDownload.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={42} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-md'));
expect(mockDownload).toHaveBeenCalledWith(42, 'md');
});
it('clicking CSV triggers download with format=csv', async () => {
mockDownload.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={42} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-csv'));
expect(mockDownload).toHaveBeenCalledWith(42, 'csv');
});
it('clicking PDF triggers download with format=pdf', async () => {
mockDownload.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={42} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-pdf'));
expect(mockDownload).toHaveBeenCalledWith(42, 'pdf');
});
it('loading state disables items during in-flight', async () => {
let resolve!: () => void;
mockDownload.mockReturnValue(new Promise<void>((r) => { resolve = r; }));
const user = userEvent.setup();
renderWithProviders(<ExportEngagementButton engagementId={1} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-md'));
// Items should be disabled while in-flight
await waitFor(() => {
expect(screen.getByTestId('export-format-csv')).toBeDisabled();
expect(screen.getByTestId('export-format-pdf')).toBeDisabled();
});
resolve();
});
it('error response shows toast', async () => {
mockDownload.mockRejectedValue(new Error('Export failed: 403 Forbidden'));
const user = userEvent.setup();
renderWithProviders(<ExportButtonWithToast engagementId={1} />);
await user.click(screen.getByTestId('export-btn'));
await user.click(screen.getByTestId('export-format-md'));
await waitFor(() => {
expect(screen.getByTestId('toast')).toBeInTheDocument();
expect(screen.getByTestId('toast')).toHaveTextContent('Export failed: 403 Forbidden');
});
});
});

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { downloadEngagementExport } from '@/api/exports';
describe('downloadEngagementExport — Content-Disposition fallback', () => {
let mock: MockAdapter;
let capturedAnchor: HTMLAnchorElement | null = null;
beforeEach(() => {
mock = new MockAdapter(apiClient);
capturedAnchor = null;
globalThis.URL.createObjectURL = vi.fn().mockReturnValue('blob:fake-url');
globalThis.URL.revokeObjectURL = vi.fn();
const origCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = origCreateElement(tag);
if (tag === 'a') {
capturedAnchor = el as HTMLAnchorElement;
vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {});
}
return el;
});
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
});
afterEach(() => {
mock.restore();
vi.restoreAllMocks();
});
it('uses fallback filename engagement-{id}.md when Content-Disposition is absent', async () => {
mock.onGet('/engagements/42/export').reply(200, new Blob(['# test']), {
'content-type': 'text/markdown',
});
await downloadEngagementExport(42, 'md');
expect(capturedAnchor?.download).toBe('engagement-42.md');
});
it('uses fallback filename engagement-{id}.csv when Content-Disposition is malformed (no filename=)', async () => {
mock.onGet('/engagements/7/export').reply(200, new Blob(['col1,col2']), {
'content-type': 'text/csv',
'content-disposition': 'attachment',
});
await downloadEngagementExport(7, 'csv');
expect(capturedAnchor?.download).toBe('engagement-7.csv');
});
it('uses filename from Content-Disposition when header is well-formed', async () => {
mock.onGet('/engagements/5/export').reply(200, new Blob(['data']), {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="engagement-5-slug-20260101.pdf"',
});
await downloadEngagementExport(5, 'pdf');
expect(capturedAnchor?.download).toBe('engagement-5-slug-20260101.pdf');
});
});

View File

@@ -4,6 +4,34 @@ Recurring mistakes and the rule we adopted so the same issue doesn't bite twice.
---
## Sprint 6 (closed 2026-06-08)
### Process — SPEC.md commit-first finally tamed the 4-sprint recurrence (team-lead)
**Context** : Sprints 3, 4, AND 5 had each shipped initial PRs with `M SPEC.md` uncommitted. Sprint 5 lessons.md proposed concrete fix candidates ; sprint 6 adopted candidate #1 (stage SPEC.md as part of the FIRST sprint commit, before any code). Result: commit `7aaa5cc` was the SPEC update, all subsequent commits were code/tests/docs, and `git status` at sprint close was 100% clean — no orphan SPEC change to carry over.
**Lesson** : process recurrences die when the fix is positional ("FIRST commit") rather than a checklist item at sprint close. The discipline is structural now: as soon as the team-lead writes the SPEC section in §0 of the plan, the matching SPEC.md edit commit follows immediately, before the plan commit itself. Memory updated. Keep doing this.
### Engineering — Persistent team `mimic` + idle members is worth the token cost (team-lead + user)
**Context** : Sprint 5 left `~/.claude/teams/mimic/config.json` as an empty husk because past sprints had called `TeamDelete` at wrap-up. Sprint 6 attempted to spawn agents one-by-one with their phase tasks; hit "team roster is flat — Teammates cannot spawn other teammates" when respawning backend-builder for a security fix mid-sprint. User flipped policy: spawn ALL 7 project-defined agents (`.claude/agents/*.md`) into team `mimic` at sprint start with idle prompts, wake them via `SendMessage` per phase, no shutdown until sprint close.
**Lesson** : the "never idle" rule from earlier memory was a token-economy heuristic that became a coordination problem. The team-roster gotcha (couldn't re-add a name after termination, couldn't add a fresh role after the first two spawns settled) confirms: pre-spawn the full roster idle, address by name throughout, accept the idle token cost. `feedback-team-spawn` memory now reflects this. DO NOT call TeamDelete unless we explicitly want to nuke the team for sprint 7+ work. The team persists, the members are re-spawned each sprint into the same team.
### Engineering — Security plugin caught CSV formula injection mid-sprint (team-lead + backend-builder)
**Context** : `security-guidance@claude-code-plugins` automated review flagged the CSV writer in `render_engagement_csv()` as vulnerable to spreadsheet formula injection (MEDIUM). The team-lead's first instinct was "this is internal, RBAC limits the attacker to authenticated red-team users — likely not exploitable" ; on second reading, recognized that the **explicit consumption path** for the CSV is the SOC analyst opening it in Excel/LibreOffice, AND that the red-team and SOC are different users on different machines (handoff is the whole point of the sprint). Fix applied (`_csv_safe()` helper, apostrophe prefix on formula-trigger chars), 3 tests added (`57dbd14`).
**Lesson** : when a security finding cites "internal service, may not be exploitable", ask one more layer of questions about the consumption path. If the cell content ever reaches a different user's spreadsheet, defuse it. Cost: 10-line helper + 3 tests, 60 seconds of work. Don't dismiss SEC findings on RBAC alone.
### Engineering — Stdlib first before custom helpers (code-reviewer + backend-builder)
**Context** : The export.py service shipped with a local `_html_escape` helper that reimplemented `html.escape(text, quote=True)` from the stdlib. Code-reviewer's NIT flagged it. Replaced by `from html import escape as _html_escape` (-7 lines, identical behavior).
**Lesson** : before writing an escape/format/parse helper, grep for an stdlib equivalent. The Python stdlib has `html`, `csv`, `unicodedata`, `re`, `email.utils`, `urllib.parse` — most "escape this safely" needs are already solved. Custom helpers age into security maintenance debt; stdlib doesn't.
### Engineering — Tests that mock the API client at module level can't exercise its fallback paths (frontend-builder + code-reviewer)
**Context** : `ExportEngagementButton.test.tsx` uses `vi.mock('@/api/exports', ...)` at module level — replaces the whole `downloadEngagementExport` function with a stub. Code-reviewer flagged that the Content-Disposition fallback inside `exports.ts` (`engagement-<id>.<ext>` when the header is malformed) was uncovered. Fix: new dedicated test file `exports.test.ts` that mocks the underlying `apiClient` (axios) instead, so the real `downloadEngagementExport` runs (`123d981`).
**Lesson** : a test file that mocks its target at module level can validate the CALLERS but not the TARGET'S internal logic. To cover a function's internal branches (fallbacks, error parsing, header parsing), you need a separate test file that mocks one layer DEEPER (axios, fetch, transport) and lets the function under test execute. Pattern : one test file per layer.
### Engineering — `engagement` param as `_engagement` for signature symmetry (backend-builder + code-reviewer)
**Context** : `render_engagement_csv()` takes `engagement` but never reads it — by design, since the CSV is machine-readable strict (no engagement header inside the file, only in the filename). Pyright flagged "not accessed". Two options: rename to `_engagement` (intentional unused marker) or drop the param. We renamed — keeps the trio of render functions (`md`/`csv`/`pdf`) callable with the same arguments from the endpoint switch.
**Lesson** : when one function in a sibling-trio doesn't need a parameter that the others use, keep the signature symmetric and prefix the unused param with `_`. The endpoint stays callable in a uniform way (`render_engagement_<fmt>(engagement, simulations)`), and the underscore signals "intentional" to mypy/pyright/ruff. Don't sacrifice symmetry for purity.
---
## Sprint 5 (closed 2026-05-28)
### Process — The "git status pre-sprint-close" discipline is still broken, 3 sprints in a row (team-lead)

36
tasks/pr-body-sprint-6.md Normal file
View File

@@ -0,0 +1,36 @@
## Summary
- **Engagement export** : `GET /api/engagements/<id>/export?format=md|csv|pdf` — clôt la boucle « remplace l'Excel partagé RT ↔ SOC » du SPEC.
- **3 formats livrés** : Markdown (table GFM 7 colonnes), CSV (7 colonnes machine-readable, défense formula-injection), PDF (table HTML→PDF via WeasyPrint).
- **Schéma fixe 7 colonnes FR** uniforme MD/CSV/PDF (décision post-review, pre-merge) : `Scénario`, `Test`, `Source de log`, `Commentaires SOC`, `Exécution` (multi-ligne sans labels — `executed_at``commands``execution_result`), `Logs remontés au SIEM`, `Cyber incident`. Champs retirés intentionnellement : status, MITRE techniques/tactics, prerequisites, id, created_at, updated_at.
- **UI** : split-button dropdown `[Export ▼]` sur `EngagementDetailPage`, 3 items. Les **deux moitiés ouvrent le menu** (différence sémantique vs sprint 5 où la gauche naviguait blank — il n'y a pas de format "défaut" évident).
- **RBAC SOC zero access** : admin + redteam exportent ; SOC ne voit pas le bouton (DOM-absent) et tous endpoints `/api/engagements/<id>/export*` → 403.
- **Security MEDIUM fix mid-sprint** : CSV formula injection défusée par `_csv_safe()` (apostrophe-prefix sur `=`/`+`/`-`/`@`/`\t`/`\r`). Le red team aurait pu injecter une formule qui s'exécute chez le SOC à l'ouverture de l'Excel.
## Test plan
- **Backend** : **257/257** pytest (`ruff` + `mypy` clean).
- **Frontend** : **136/136** vitest (`typecheck` + `lint` clean).
- **E2e Playwright** : **223/223** verts — baseline sprint 5 = 201, +22 sprint 6.
## Comment tester en local
```bash
make build && make start # auto-podman, +50 MB d'image (deps WeasyPrint)
make create-admin USER=alice PASS=changeme8 # si premier setup
# Ouvrir http://127.0.0.1:5000 (IPv4 explicite si IPv6 par défaut)
```
Scénarios :
1. **Export Markdown** — login admin → engagement avec ≥ 2 simulations → header → `[Export ▼]` → Markdown. Le `.md` téléchargé contient le nom de l'engagement, ses dates, et le détail de chaque simulation RT + SOC.
2. **Export CSV** — même flow → CSV. Ouvre dans LibreOffice : 1 ligne header + N lignes simulations, commands multilines correctement échappés, colonnes RT et SOC visibles.
3. **Export PDF** — même flow → PDF. Le fichier doit s'ouvrir dans un viewer PDF avec un rendu propre (titres, sections, tables).
4. **CSV formula injection (sécurité)** — crée une simulation avec `name = "=cmd|'/c calc'!A1"`, exporte le CSV, ouvre dans Excel/LibreOffice. La cellule doit afficher le texte littéral `=cmd|'/c calc'!A1` (apostrophe forcé), pas exécuter la formule.
5. **SOC zero access** — login en SOC → engagement → bouton `Export` ABSENT du header. Test API direct : `curl -H "Authorization: Bearer <SOC_TOKEN>" http://127.0.0.1:5000/api/engagements/1/export?format=md``403`.
6. **Engagement vide** — engagement avec 0 simulations → export OK (header seul ; CSV = 1 ligne header).
7. **Filename normalisé** — engagement nommé `"Opération Spéciale"` → filename Content-Disposition = `engagement-<id>-operation-speciale-YYYYMMDD.<ext>` (NFKD strip des accents).
## Notes
- **Endpoint unique** avec query param `format`, pas 3 routes séparées — 1 RBAC à protéger, 1 test d'intégration RBAC.
- **PDF pipeline** : WeasyPrint (Python HTML→PDF). Le PDF est généré depuis les MÊMES DONNÉES que le Markdown (pas depuis le string Markdown) via `_render_engagement_html()`. CSS inline ≤ 30 lignes.
- **Dockerfile** : +6 libs minimales pour WeasyPrint (`libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info`). `libgdk-pixbuf-2.0-0` exclu (text-only PDF, vérifié `weasyprint --info`).
- **Process wins sprint 6** : SPEC.md committed en commit #1 du sprint (recurrence 4 sprints enfin tuée) ; spec-reviewer 2-pass APPROVED avant dispatch backend (0 addendum mid-implementation, comme sprint 5) ; team `mimic` persistante avec les 7 agents idle (cohérence cross-sprint à partir du sprint 7+).
🤖 Generated with [Claude Code](https://claude.com/claude-code)

View File

@@ -1,300 +1,281 @@
# Sprint 5Simulation templates
# Sprint 6Engagement export (Markdown + CSV + PDF)
**Branche** : `sprint/5-templates`
**Statut** : 🟢 SPRINT COMPLET — backend 226/226 + frontend 121/121 + e2e 201/201, PR prête
**Base** : `main` @ `9873c53` (PR #7 sprint 4 mergé)
**Objectif** : permettre à un admin/redteam de créer des **templates de simulations** pré-remplies (RT-side : name, description, commands, prerequisites, techniques, tactics). Instancier un template dans un engagement crée une nouvelle simulation décorrélée (copie indépendante — éditer l'instance ne touche pas le template et vice-versa). User QA item 8 sprint 3.
> Branch : `sprint/6-export` · Worktree : `.claude/worktrees/sprint-6-export` · Base : `main` @ `678ee8f`
## §0 — Binding decisions (locked with the user 2026-06-07)
1. **Scope du sprint** : export d'un engagement (header + toutes ses simulations RT + SOC) vers Markdown, CSV et PDF — clôt la boucle « remplace l'utilisation d'un fichier excel plat partagé entre la redteam et les analystes SOC en fin de mission ».
2. **Formats livrés** : Markdown, CSV, PDF (3 formats). JSON exclu (redondant avec l'API existante).
3. **RBAC** : `admin` + `redteam` peuvent exporter. **SOC = pas d'accès** (pas de bouton dans l'UI, endpoint `/api/engagements/<eid>/export` → 403). Cohérent avec le pattern templates sprint 5 (livrable RedTeam).
4. **Contenu de l'export** : Engagement header (name, description, dates, status, created_by, created_at) + **toutes** les simulations de l'engagement, avec leurs champs RT (name, techniques, tactics, description, commands, prerequisites, executed_at, execution_result, status) ET SOC (log_source, logs, soc_comment, incident_number). Ordre des simulations : `id ASC` (ordre de création).
5. **Déclenchement UI** : un bouton **split-button dropdown** sur `EngagementDetailPage` libellé `[Export ▼]`, qui ouvre un menu `Markdown / CSV / PDF`. Click → download direct (Blob + `URL.createObjectURL`). Pas de modal de configuration. Pattern réutilisé du dropdown sprint 5 (`SimulationList`).
### Décisions techniques arrêtées par le team-lead (à challenger par spec-reviewer)
6. **Endpoint backend** : **un seul** endpoint `GET /api/engagements/<eid>/export?format=md|csv|pdf` plutôt que 3 endpoints distincts. Une seule route à protéger (RBAC), un seul test d'intégration RBAC, switch sur `format` en interne. Format inconnu → **400** `{error: "format must be one of: md, csv, pdf"}`. Format manquant → **400** (pas de défaut implicite — évite l'ambiguïté).
7. **Markdown** : généré via string templating Python (pas de lib externe). Simple, déterministe, testable par assertion de sous-chaînes.
8. **CSV** : généré via `csv.writer` (stdlib). Une ligne d'en-tête + N lignes simulations. Colonnes : `id, name, status, techniques (joined "|"), tactics (joined "|"), description, commands, prerequisites, executed_at, execution_result, log_source, logs, soc_comment, incident_number, created_at, updated_at`. **Pas de header engagement dans le CSV** (format machine-readable strict) ; l'engagement context sort dans le filename.
9. **PDF** : généré via **WeasyPrint** (Python HTML→PDF, lib mature, qualité de rendu pro, dépendances système cairo/pango/gdk-pixbuf à ajouter au `python:3.12-slim` du Dockerfile). Pipeline : on génère **le même HTML** que pour le Markdown (mais wrappé en `<html>...<style>...</html>`), puis WeasyPrint le rend en PDF. Le styling CSS est inline (≤ 30 lignes : hierarchy h1/h2/h3, code-block monospace, alternance fond pour les simulations). Pas de logo / page de garde — keep it simple.
10. **Filename convention** : `engagement-<id>-<slugified-name>-YYYYMMDD.{ext}`. Slugification :
```python
import unicodedata, re
normalized = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode()
slug = re.sub(r'[^a-z0-9]+', '-', normalized.lower()).strip('-')[:60] or "unnamed"
```
Le NFKD-strip enlève les accents proprement (`Opération` → `Operation`), le fallback `"unnamed"` couvre le edge case d'un nom 100 % non-alphanum (`"---!!!"` → `""` → `"unnamed"`). `YYYYMMDD` = `date.today().strftime('%Y%m%d')` côté serveur. Le frontend lit `Content-Disposition` pour le nom du fichier.
11. **Content-Type** : `text/markdown; charset=utf-8`, `text/csv; charset=utf-8`, `application/pdf`.
12. **Génération synchrone** : Flask renvoie le fichier dans la même requête (un engagement = quelques dizaines de simulations max, génération < 500 ms même PDF). Pas de job async.
13. **Pas de cache** : chaque export régénère depuis la DB (état toujours frais).
14. **Frontend client** : pour télécharger, on utilise un fetch avec `responseType: 'blob'`, on lit `Content-Disposition` pour le filename, puis `URL.createObjectURL` + `<a>` invisible + `click()`. Pas de navigation. Le bouton `[Export ▼]` utilise la classe **`btn-outline`** (la même que le bouton `Edit` du header existant — cohérence visuelle directe). Le dropdown wrapper réutilise le même token set que le sprint 5 `NewSimulationDropdown` (`shadow-floating` + `dark:shadow-floating-dark`, `bg-canvas` + `dark:bg-fog`).
### Points OUVERTS pour le spec-reviewer (à valider Pass 1)
- **WeasyPrint vs alternatives** : retenu pour rendu pro + pipeline HTML mutualisable. Alternatives écartées : `reportlab` (layout programmatique = beaucoup plus de code), `xhtml2pdf` (rendu inférieur), `pdfkit + wkhtmltopdf` (binaire externe en archive partielle). Le coût Dockerfile (≈ 50 MB de libs cairo/pango) est accepté.
- **CSV sans header engagement** : choix de pureté tabulaire (Excel-friendly direct). Le team-lead a tranché. Spec-reviewer doit confirmer ou proposer la variante "1 ligne commentaire `# Engagement: <name>`".
- **Pas de JSON export** : redondant avec l'API. À confirmer.
- **Statut `done` inclus comme tous les autres** : pas de filtre par défaut. L'utilisateur exporte toujours TOUT.
---
## 0. SPEC.md à enrichir en début de sprint
## §1 — Backend (Sonnet · backend-builder)
Ajouter une section `## Templates de simulations` (entre § Fonctionnement et § Authentification & rôles) :
### Modèle de données
**Aucun changement** de modèle. Pas de migration. L'export est en lecture seule sur les modèles existants `Engagement` + `Simulation`.
> Un **template de simulation** est une simulation pré-remplie côté redteam (name + description + commandes + pré-requis + techniques MITRE + tactiques MITRE) qui sert de point de départ pour instancier rapidement des simulations dans un engagement. Le template ne contient PAS de partie SOC, ni de date d'exécution, ni de résultat d'exécution — ces champs restent par-instance. L'instanciation d'un template dans un engagement crée une **nouvelle simulation indépendante** : le template et l'instance sont décorrélés, l'édition de l'un n'affecte pas l'autre. **Templates = ressource red team** : admin et redteam les gèrent (CRUD). SOC n'y a aucun accès (ni lecture, ni écriture, pas de nav link).
### Services / serializers
- Nouveau module **`backend/app/services/export.py`** avec 3 fonctions pures testables unitairement :
- `render_engagement_markdown(engagement: Engagement, simulations: list[Simulation]) -> str`
- `render_engagement_csv(engagement: Engagement, simulations: list[Simulation]) -> str`
- `render_engagement_pdf(engagement: Engagement, simulations: list[Simulation]) -> bytes`
- Le rendu Markdown réutilise `_enrich_techniques` + `_enrich_tactics` de `serializers.py` pour avoir `[{id, name}]` au lieu de juste les IDs.
- Le rendu PDF construit l'HTML à partir d'un template Python `_render_engagement_html(engagement, simulations) -> str` (string templating, pas Jinja — KISS) **et le passe à `weasyprint.HTML(string=html).write_pdf()`. Important : le PDF est généré à partir des MÊMES DONNÉES (engagement + simulations) que le Markdown, PAS à partir du string Markdown — `_render_engagement_html` est un rendu distinct.**
- **Rendu de `created_by`** : pour Markdown et CSV, on rend la `username` seule (`engagement.created_by.username`), pas la dict `{id, username}`. Pour la cohérence du livrable handoff. Idem pour `simulation.created_by`.
- **MITRE non chargé** : si le bundle n'est pas chargé, `_enrich_techniques` retourne `tactics: []` silencieusement (cohérent avec `serialize_simulation` existant — pas de 503 dans l'export). Le render doit continuer sans crash. Test dédié exigé (cf. § Tests).
L'évolution est tracée dans CHANGELOG.md § Changed sprint 5.
### Endpoint
- Extension du blueprint `engagements_bp` existant. Path : `GET /api/engagements/<int:eid>/export?format=md|csv|pdf`.
- Décorateur : `@role_required("admin", "redteam")`.
- Logique :
1. Charger l'engagement (404 si absent).
2. Parse `format` query param. Format manquant ou inconnu → 400 `{error: "format must be one of: md, csv, pdf"}`.
3. Charger les simulations triées par `id ASC`.
4. Appeler la fonction `render_engagement_<fmt>(engagement, simulations)`.
5. Construire la `Response` avec `Content-Type`, `Content-Disposition: attachment; filename="<slug>.<ext>"`, et le body.
- Filename helper : `_export_filename(engagement, ext) -> str` (slugifier + date).
---
### Tests
**Cible : 226 → 245+ pytest passing.**
## 1. User stories
Fichiers nouveaux :
- `backend/tests/test_export_engagement.py` — couvre l'endpoint + RBAC + format inconnu.
- `test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations`
- `test_export_markdown_redteam_ok`
- `test_export_markdown_soc_403`
- `test_export_csv_returns_csv_with_one_row_per_simulation`
- `test_export_csv_columns_match_contract` (assert exact header row)
- `test_export_csv_escapes_special_characters` (commands avec virgule, guillemet, newline)
- `test_export_pdf_returns_pdf_magic_bytes_and_non_empty`
- `test_export_unknown_format_400`
- `test_export_missing_format_400`
- `test_export_unknown_engagement_404`
- `test_export_engagement_with_zero_simulations_renders_header_only`
- `test_export_unauthenticated_401`
- `test_export_filename_slugifies_name_and_carries_date`
- `backend/tests/test_export_render.py` — tests unitaires sur les 3 fonctions pures.
- `test_render_engagement_markdown_includes_header_fields`
- `test_render_engagement_markdown_lists_all_simulations_in_order`
- `test_render_engagement_markdown_includes_techniques_with_id_and_name`
- `test_render_engagement_markdown_includes_tactics`
- `test_render_engagement_markdown_includes_soc_fields_even_when_blank` (cohérence handoff)
- `test_render_engagement_markdown_escapes_backticks_in_commands` (fenced code block safety)
- `test_render_engagement_csv_has_header_row`
- `test_render_engagement_csv_joins_multi_techniques_with_pipe`
- `test_render_engagement_pdf_starts_with_pdf_magic` (assert `output[:4] == b'%PDF'`)
- `test_render_engagement_markdown_with_mitre_bundle_not_loaded_does_not_crash` (assert render OK et contient les technique IDs même quand le bundle MITRE est absent — sécurise les Docker cold-starts)
### US-26 — En tant qu'admin/redteam, je crée et gère des templates de simulations
**Pourquoi** : standardiser des simulations récurrentes (ex: "Mimikatz LSASS dump", "PowerShell empire stager") et éviter de retaper les mêmes commandes/MITRE à chaque engagement.
**Critères d'acceptation**
- [ ] AC-26.1 : modèle `SimulationTemplate` (table `simulation_templates`) :
- `id` int PK
- `name` str NOT NULL UNIQUE (limite UX : un template unique par nom pour éviter les doublons dans le dropdown d'instanciation)
- `description` text nullable
- `commands` text nullable (chaîne multiligne, une commande par ligne, pattern sprint 2)
- `prerequisites` text nullable
- `techniques` JSON NOT NULL default `[]` (liste `[{id, name}]`, snapshot des techniques MITRE)
- `tactic_ids` JSON NOT NULL default `[]` (liste de TA-id strings)
- `created_at` datetime NOT NULL
- `updated_at` datetime nullable
- `created_by_id` int FK User
- [ ] AC-26.2 : migration Alembic `0005_simulation_templates.py` — CREATE TABLE simulation_templates + index sur `name`. Downgrade : DROP TABLE.
- [ ] AC-26.3 : `GET /api/templates` (admin|redteam) → liste `[{id, name, description, commands, prerequisites, techniques: [{id, name, tactics}], tactics: [{id, name}], created_at, created_by}]`, ordre `name ASC`. SOC → 403.
- [ ] AC-26.4 : `POST /api/templates` (admin|redteam) → 201 + template créé. Body : `{name, description?, commands?, prerequisites?, technique_ids?: [...], tactic_ids?: [...]}`. Valide : `name` non vide, name unique (409 si doublon), technique_ids / tactic_ids validés contre bundle MITRE / `_TACTIC_IDS` (réutilise les helpers `_resolve_technique_ids` / `_resolve_tactic_ids` sprint 3/4). SOC → 403.
- [ ] AC-26.5 : `GET /api/templates/<tid>` (admin|redteam) → 200 ou 404. SOC → 403.
- [ ] AC-26.6 : `PATCH /api/templates/<tid>` (admin|redteam) → 200, accepte les mêmes champs que POST en partial. Si `name` est modifié et entre en conflit avec un autre template → 409. SOC → 403.
- [ ] AC-26.7 : `DELETE /api/templates/<tid>` (admin|redteam) → 204. **Pas de cascade vers les simulations déjà instanciées** — celles-ci sont décorrélées et survivent. SOC → 403.
- [ ] AC-26.8 : page `/admin/templates` (admin|redteam uniquement) liste les templates en table (Name, MITRE count, Created by, Updated at, Actions). Boutons "New template" + "Edit" + "Delete". Tous les endpoints templates sont gated `@role_required("admin", "redteam")` côté backend, et ProtectedRoute frontend impose le même filtre.
### US-27 — En tant que redteam, j'instancie un template dans un engagement
**Pourquoi** : c'est le use-case principal des templates.
**Critères d'acceptation**
- [ ] AC-27.1 : `POST /api/engagements/<eid>/simulations` (admin|redteam) accepte un nouveau paramètre optionnel `template_id`. Si présent, le serveur valide que le template existe (404 sinon), puis crée une nouvelle simulation en copiant :
- `name` ← template.name (peut être override par `name` du body si fourni)
- `description` ← template.description
- `commands` ← template.commands
- `prerequisites` ← template.prerequisites
- `techniques` ← template.techniques (deep copy)
- `tactic_ids` ← template.tactic_ids (deep copy)
- Autres champs : status=pending, executed_at=null, execution_result=null, SOC fields=null
- [ ] AC-27.2 : `POST` sans `template_id` garde le comportement sprint 2 (création vierge avec juste `name`).
- [ ] AC-27.3 : la simulation créée depuis un template est **complètement décorrélée** : modifier l'instance ne touche pas le template, modifier le template ne touche pas les instances existantes. Pas de FK `template_id` stockée sur la simulation (clean decoupling).
- [ ] AC-27.4 : auto-transition pending → in_progress NE se déclenche PAS lors de la création depuis un template (même si le template a un name + description + techniques non vides). La création reste status=pending — la transition se fera au prochain PATCH explicite de la redteam. Cohérent avec règle sprint 2 "trigger sur PATCH" pas "trigger sur création".
- [ ] AC-27.5 : engagement auto-status n'est PAS déclenché par l'instanciation (status reste planned). Coherent avec AC-27.4.
- [ ] AC-27.6 : sur `EngagementDetailPage` (sprint 2/3/4), le bouton "+ New" (ou équivalent UI) ouvre désormais un menu / dropdown / modal avec 2 options : "Blank" et "From template…". L'option "From template…" affiche la liste des templates disponibles avec leur nom + un aperçu (count techniques/tactics). Click sur un template → POST avec `template_id` + redirection sur la simu créée. **Si `useTemplates()` retourne une liste vide → la modale affiche un `<EmptyState title="No templates available" description="Create one from the Templates page" />`. NE PAS désactiver l'option "From template…" dans le dropdown** (l'utilisateur doit pouvoir l'ouvrir pour comprendre qu'il n'y a rien — un disabled item silencieux serait confus).
- [ ] AC-27.7 : SOC n'a PAS accès au bouton d'instanciation (cohérent avec RBAC simulation creation sprint 2).
### US-28 — En tant qu'admin/redteam, j'accède aux templates depuis la nav
**Critères d'acceptation**
- [ ] AC-28.1 : `Layout.tsx` topbar nav contient un nouveau lien "Templates" (visible **uniquement à admin + redteam**). Pour SOC : le lien n'apparaît pas (cohérent avec "Users" qui est admin-only et masqué côté SOC).
- [ ] AC-28.2 : `ProtectedRoute` pour `/admin/templates` impose `roles=["admin", "redteam"]`. SOC qui tente d'y accéder en tapant l'URL → redirigé vers `/engagements` + toast "Accès refusé" (pattern existant ProtectedRoute sprint 1).
- [ ] AC-28.3 : la page `/admin/templates` n'inclut PAS de mode "read-only SOC" — elle est strictement admin+redteam. Les composants peuvent assumer `canEditTemplates = isAdmin || isRedteam = true` (toujours vrai à ce niveau).
---
## 2. Brief technique — Backend Builder
**Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs, agents, scripts.
### Livrables
**Modèle `SimulationTemplate`** (`backend/app/models/simulation_template.py` — nouveau fichier)
```python
from datetime import UTC, datetime
from sqlalchemy.orm import Mapped, mapped_column
class SimulationTemplate(db.Model):
__tablename__ = "simulation_templates"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
description = db.Column(db.Text, nullable=True)
commands = db.Column(db.Text, nullable=True)
prerequisites = db.Column(db.Text, nullable=True)
techniques = db.Column(db.JSON, nullable=False, default=list)
tactic_ids = db.Column(db.JSON, nullable=False, default=list)
created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(UTC))
updated_at = db.Column(db.DateTime, nullable=True)
created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
created_by = db.relationship("User")
```
Ajouter à `backend/app/models/__init__.py`.
**Migration Alembic `0005_simulation_templates.py`**
- Upgrade : `op.create_table("simulation_templates", ...)` avec tous les champs et la contrainte UNIQUE sur `name`. Pas besoin de batch (CREATE TABLE est natif SQLite).
- Downgrade : `op.drop_table("simulation_templates")`. SQLite OK natif.
- Pas de backfill (table vide à la création).
**Serializer** (`backend/app/serializers.py`)
- Nouvelle fonction `serialize_template(t)` :
```python
return {
"id": t.id,
"name": t.name,
"description": t.description,
"commands": t.commands,
"prerequisites": t.prerequisites,
"techniques": _enrich_techniques(t.techniques), # réutilise sprint 3
"tactics": _enrich_tactics(t.tactic_ids), # réutilise sprint 4
"created_at": t.created_at.isoformat() if t.created_at else None,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
"created_by": serialize_user_brief(t.created_by) if t.created_by else None,
}
### Dépendances
- `weasyprint>=60.0` ajouté à `backend/requirements.txt`.
- `docker/Dockerfile` stage Python : ajouter les libs minimales WeasyPrint pour Debian slim. **Set minimal pour text-only PDF** :
```
- Pattern parallèle à `serialize_simulation` mais SANS les champs SOC / status / executed_at.
apt-get install -y libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info
```
**Note** : `libgdk-pixbuf-2.0-0` n'est requis QUE si on intègre des images dans le PDF. Notre rendu est text-only → on peut s'en passer. Le builder confirme via `weasyprint --info` dans le container après build. Documenter dans le PR.
**API** (`backend/app/api/templates.py` — nouveau blueprint)
**Tous les endpoints templates sont gated `@role_required("admin", "redteam")` — SOC reçoit 403 partout.**
- `GET /api/templates` (admin|redteam) → liste serializée, tri `name ASC`.
- `POST /api/templates` (admin|redteam) → création. Validation : `name` non vide (400), `technique_ids` / `tactic_ids` valides (réutilise `_resolve_technique_ids` / `_resolve_tactic_ids` de `simulation_workflow.py` — import direct, KISS). Pour le `name` UNIQUE conflict : **catch `sqlalchemy.exc.IntegrityError` sur INSERT → 409 `{"error": "template name already exists"}`**. Pas de pre-check SELECT (race condition + code mort, la contrainte UNIQUE en DB est l'autorité).
- `GET /api/templates/<tid>` (admin|redteam) → 200 ou 404.
- `PATCH /api/templates/<tid>` (admin|redteam) → update partial. Pour le `name` conflict : même pattern, **catch IntegrityError sur UPDATE → 409 `{"error": "template name already exists"}`**. Cas edge : PATCH avec `name == current_name` (no-op rename) → 200 (l'UPDATE sur sa propre row ne viole pas UNIQUE). Mettre à jour `updated_at`.
- `DELETE /api/templates/<tid>` (admin|redteam) → 204. Pas de cascade FK simulations (les simulations n'ont pas de `template_id` FK).
Enregistrer le blueprint dans `backend/app/__init__.py`.
**Modification `POST /api/engagements/<eid>/simulations`** (`backend/app/api/simulations.py`)
- Le payload accepte maintenant un `template_id` optionnel.
- Si présent :
- Charger le template (404 si non trouvé).
- Créer la simulation en **setant DIRECTEMENT les champs RT** sur l'objet ORM Simulation à partir des champs du template (`sim.techniques = template.techniques`, `sim.tactic_ids = template.tactic_ids`, `sim.description = template.description`, `sim.commands = template.commands`, `sim.prerequisites = template.prerequisites`).
- `name` du body override si fourni, sinon `template.name`.
- **NE PAS appeler `apply_patch()`, `_resolve_technique_ids()`, ni `_resolve_tactic_ids()`** — les données viennent du template déjà persisté+validé, re-résoudre frapperait inutilement le bundle MITRE ET déclencherait l'auto-transition pending→in_progress via la logique auto-trigger de `apply_patch`, ce qui violerait AC-27.4. Le set direct ORM est intentionnellement court-circuité.
- Si absent : comportement actuel inchangé (création vierge avec `name`).
- Auto-transition NE PAS déclencher (status reste pending — règle sprint 2 "trigger sur PATCH", la création ne compte pas). Le fait de ne pas appeler `apply_patch` est ce qui garantit ça structurellement.
- Engagement auto-status NE PAS déclencher (status engagement reste planned). Idem — `_maybe_activate_engagement` n'est appelé que depuis `apply_patch`.
**Tests pytest**
- `test_simulation_templates_crud.py` (nouveau) :
- GET liste vide, GET liste après création, GET liste tri name ASC.
- POST valide → 201, fields persisted.
- POST name vide → 400.
- POST name dupliqué → 409.
- POST technique_id inconnu → 400.
- POST tactic_id inconnu → 400.
- POST par SOC → 403.
- GET inexistant → 404.
- PATCH valide → 200, updated_at set.
- PATCH name → conflit → 409.
- PATCH par SOC → 403.
- DELETE valide → 204, GET ensuite → 404.
- DELETE par SOC → 403.
- `test_simulations_from_template.py` (nouveau) :
- POST simulation avec template_id valide → copie tous les RT fields, status=pending, executed_at=null, SOC fields=null.
- POST avec template_id valide + name override → name override gagne.
- POST avec template_id inexistant → 404.
- POST avec template_id par SOC → 403 (cohérent avec création).
- Vérifier décorrélation : créer template → instancier → modifier l'instance → assert template inchangé. Symétrique : modifier le template → instance inchangée.
- Auto-transition NE PAS déclenchée (sim reste pending même si template avait des techniques).
- Engagement reste planned (auto-status NOT triggered).
- Migration test : `0005` create/drop round-trip propre (réutilise pattern Alembic round-trip sprint 3/4 avec `Path(__file__)`).
**Quality bar** : ruff + mypy clean, tous tests existants + nouveaux verts.
### Règles
- Pas de touche au frontend, e2e, agents, scripts, Makefile.
- Renvoyer le summary attendu (cf. `.claude/agents/backend-builder.md`).
### Livrable backend-builder (summary attendu)
- **PREMIÈRE LIGNE OBLIGATOIRE** du summary (lesson sprint 5 — URL drift silencieuse interdite) :
```
endpoint final = GET /api/engagements/<int:eid>/export?format=md|csv|pdf
```
Texte EXACT, pas paraphrasé. Si le builder a choisi un autre path, il le déclare ici en deviation.
- Tous les fichiers créés/modifiés
- Contrat API précis (statuts, query params, headers de réponse) en table
- Liste des helpers réutilisés (`_enrich_techniques`, `_enrich_tactics`, `serialize_user_brief`)
- **Section "Déviations vs plan"** explicite (cf. lesson sprint 5)
- Résultats pytest + ruff + mypy
---
## 3. Brief technique — Frontend Builder
## §2 — Frontend (Sonnet · frontend-builder)
**Scope strict** : `frontend/` uniquement.
### Composants
- **`ExportEngagementButton.tsx`** (nouveau) : split-button dropdown style sprint 5.
- Bouton principal `Export` (icône `Download` lucide-react) + chevron à droite (icône `ChevronDown`).
- **IMPORTANT — différence sémantique vs `NewSimulationDropdown` sprint 5** : les DEUX moitiés (label `Export` + chevron) ouvrent le dropdown. Il n'y a PAS d'action par défaut sur le click gauche (parce qu'il n'y a pas de format "défaut" évident parmi Markdown/CSV/PDF). Ce n'est PAS le même pattern que `[+ New]` (où la gauche navigue vers `/.../new` blank).
- Dropdown : 3 items "Markdown" / "CSV" / "PDF". Click → mutation download.
- Fermeture : click outside + Escape (réutiliser le hook/effet du dropdown sprint 5 dans `SimulationList`).
- Loading state : pendant la mutation, le composant affiche un spinner inline sur l'item cliqué, le dropdown reste ouvert. Désactive les 3 items pendant l'in-flight.
- Toast erreur sur 4xx/5xx.
- **`data-testid="export-dropdown"`** sur le wrapper du composant pour permettre au test-verifier d'asserter la présence/absence DOM (AC-30.1).
- Style : utiliser la classe utilitaire **`btn-outline`** (la même que le bouton `Edit` du header existant) — cohérence visuelle directe avec le header.
- **`EngagementDetailPage.tsx`** : intégrer `<ExportEngagementButton engagementId={engagement.id} />` dans le header de la page, à côté du bouton `Edit` existant. **Visible uniquement si `currentUser.role in ['admin', 'redteam']`** (gate côté UI + RBAC backend de toute façon en force) — réutiliser le helper `canEditEngagements` de `useAuth` (le même rôle set).
**Screenshots MANDATORY (lesson sprint 4)** : à la fin de ton travail, dev server + auth flow (page.goto('/login') + fill creds + submit + wait) pour fournir MIN 6 screenshots :
1. `/admin/templates` liste (admin OU redteam vue, ≥ 2 templates) — light + dark
2. Template create/edit form (mode edit avec techniques + tactic chips) — light + dark
3. EngagementDetail avec dropdown "Blank | From template…" ouvert — light
4. TemplatePickerModal ouverte (au moins 2 templates listés) — light
5. TemplatePickerModal ouverte avec aucun template — empty state visible — light
6. Simulation créée depuis un template (champs pré-remplis avec le nom, MITRE chips) — light
### API client
- **`frontend/src/api/exports.ts`** (nouveau) :
- `downloadEngagementExport(engagementId: number, format: 'md' | 'csv' | 'pdf'): Promise<void>` — fait un GET `/api/engagements/<id>/export?format=<fmt>` avec `responseType: 'blob'`, lit `Content-Disposition` pour le filename, crée un `Blob` + `URL.createObjectURL` + `<a>.click()`, puis `URL.revokeObjectURL`. **Contrat d'erreur** : sur réponse non-2xx, parse le JSON `{error: "..."}` du body (ou défaut "Export failed") et **throw un `Error`** avec le message — laisse le caller catcher pour le toast.
- Helper `parseContentDispositionFilename(header: string | undefined): string | null` (regex `filename="..."`, fallback null).
Paths absolus dans le summary. Si auth flow ne marche pas → escalade.
### Types
- Aucun nouveau type API (l'export retourne un Blob).
### Livrables
### Tests
**Cible : 121 → 130+ vitest passing.**
**Types** (`frontend/src/api/types.ts`)
- `SimulationTemplate`: `{id, name, description, commands, prerequisites, techniques: MitreTechnique[], tactics: MitreTactic[], created_at, updated_at, created_by}`.
- `SimulationTemplateCreateInput`: payload POST.
- `SimulationTemplatePatchInput`: payload PATCH.
- Étendre `SimulationCreateInput` avec `template_id?: number`.
Fichiers nouveaux :
- `frontend/tests/ExportEngagementButton.test.tsx`
- `renders Export button with chevron`
- `clicking primary opens dropdown with three formats`
- `clicking outside closes dropdown`
- `Escape closes dropdown`
- `clicking Markdown triggers download with format=md`
- `clicking CSV triggers download with format=csv`
- `clicking PDF triggers download with format=pdf`
- `loading state disables items during in-flight`
- `error response shows toast`
- `frontend/tests/EngagementDetailPage.test.tsx` (**nouveau** — il n'existe pas encore, le builder le crée from scratch) :
- `admin sees Export button`
- `redteam sees Export button`
- `soc does NOT see Export button`
**API client** (`frontend/src/api/templates.ts` — nouveau)
- `listTemplates()`, `getTemplate(id)`, `createTemplate(input)`, `updateTemplate(id, patch)`, `deleteTemplate(id)`.
### Screenshots OBLIGATOIRES (lesson sprint 4)
- `EngagementDetailPage` light + dark, dropdown fermé.
- `EngagementDetailPage` light + dark, dropdown ouvert (3 items visibles).
- `EngagementDetailPage` SOC view — bouton Export ABSENT.
- Le builder doit fournir un script Playwright authenti (réutiliser le pattern sprint 5 — `page.goto('/login') → fill → wait nav`).
**Hooks TanStack Query** (`frontend/src/hooks/useTemplates.ts` — nouveau)
- `useTemplates()`, `useTemplate(id)`, mutations `useCreateTemplate`, `useUpdateTemplate`, `useDeleteTemplate`.
- Invalidation : create/update/delete invalide `["templates"]` et `["templates", id]`.
**Pages**
- **`TemplatesListPage.tsx`** (nouveau, `/admin/templates`) — admin+redteam only :
- Table : Name, MITRE count (techniques + tactics), Created by, Updated at, Actions.
- Bouton "+ New template" en header.
- Actions par ligne : "Edit" + "Delete".
- Click sur une ligne → `/admin/templates/:id/edit`.
- States : loading / error / empty.
- **`TemplateFormPage.tsx`** (nouveau, `/admin/templates/new` et `/admin/templates/:id/edit`) — admin+redteam only :
- Form pour name + description + commands (textarea) + prerequisites + MitreTechniquesField.
- Mode `new` : seul `name` requis ; après création, redirige sur `/admin/templates/:id/edit`.
- Mode `edit` : load existing template, allow update.
- Bouton Delete (confirmation modal).
- Pas de mode read-only (SOC n'a pas accès aux routes).
- **`EngagementDetailPage.tsx`** (modification) :
- Remplacer le bouton simple "+ New simulation" par un dropdown OU un menu :
- "Blank" (action default)
- "From template…" → ouvre une modale avec la liste des templates.
- Modale "From template…" : `useTemplates()`, table simple Name + MITRE count, click sur un template → `useCreateSimulation` avec `template_id: t.id` → redirect sur la simu créée.
**Composants** (`frontend/src/components/`)
- **`TemplatePickerModal.tsx`** (nouveau) : modale qui liste les templates, permet de cliquer pour instancier. Props : `engagementId`, `onClose`, `onInstantiated(simId)`.
**Routing** (`App.tsx`) — toutes routes templates gated `roles=["admin", "redteam"]` :
- `/admin/templates` (admin|redteam)
- `/admin/templates/new` (admin|redteam)
- `/admin/templates/:id/edit` (admin|redteam)
**Layout** (`Layout.tsx`)
- Nouveau lien "Templates" dans la topbar, à droite de "Users" — **visible UNIQUEMENT à admin + redteam** (masqué pour SOC, pattern identique à "Users" qui est admin-only).
**Tests Vitest**
- `TemplatesListPage.test.tsx` — loading/error/empty + boutons New/Edit/Delete présents (admin|redteam — pas de variante soc puisque route inaccessible).
- `TemplateFormPage.test.tsx` — mode new + mode edit (pas de mode read-only).
- `TemplatePickerModal.test.tsx` — liste templates, click instantiate, gestion erreur, close.
- `EngagementDetailPage.test.tsx` — adapter pour le nouveau dropdown "+ New simulation".
### Règles
- Lit le summary backend EN PREMIER.
- Pas d'invention d'endpoints.
- Réutilise `LoadingState`, `ErrorState`, `EmptyState`, `Toast`, `ConfirmDialog`, `MitreTechniquesField`, `StatusBadge` etc.
- Respect DESIGN.md tokens. Dark mode déjà en place — applique les patterns sprint 4 (`bg-canvas dark:bg-canvas`, etc.).
- Pas de dépendance npm sans escalade.
### Livrable frontend-builder (summary attendu)
- Tous les fichiers créés/modifiés
- API contracts consommés exactement comme livrés par backend (cf. lesson sprint 5 — path drift à éviter, grep `Content-Disposition` dans la PR)
- Helpers réutilisés (`useToast`, etc.)
- Résultats vitest + typecheck + lint
- Liste des écrans capturés (light + dark, role-by-role)
---
## 4. Brief — Test verifier
## §3 — Acceptance tests (Sonnet · test-verifier)
E2e Playwright. Un fichier par US :
- `us26-templates-crud.spec.ts` — AC-26.1 → AC-26.8 (focus API + UI gérance templates)
- `us27-instantiate-from-template.spec.ts` — AC-27.1 → AC-27.7 (création simu depuis template, décorrélation)
- `us28-templates-nav.spec.ts` — AC-28.1 → AC-28.3 (nav link, accès SOC read-only)
**Cible : 201 → 215+ Playwright passing.**
Adapter les sprint 2/3/4 e2e si l'ajout du dropdown "+ New simulation" casse des sélecteurs (les tests sprint 2/3 cliquent directement sur "+ New" — désormais ça ouvre un menu avant d'aller au form blanc).
3 user stories à couvrir :
### US-29 — Admin/redteam exporte l'engagement en Markdown/CSV/PDF
- **AC-29.1** : login admin → engagement avec ≥ 2 simulations → click "Export" → dropdown s'ouvre.
- **AC-29.2** : click "Markdown" → download d'un `.md` avec `Content-Type: text/markdown`. Le fichier contient le nom de l'engagement, la date de début, et le nom de chaque simulation.
- **AC-29.3** : click "CSV" → download d'un `.csv` avec exactement N+1 **rows CSV** (1 header + N simulations). La colonne `name` contient les noms des simulations. **Note implémentation test** : compter les rows via `csv.reader` (ou équivalent JS), PAS via `file.split('\n')` — les commands multilines produisent des cells avec newlines embedded entre quotes, le line-count du fichier > row-count CSV.
- **AC-29.4** : click "PDF" → download avec `Content-Type: application/pdf`, taille > 1 KB, magic bytes `%PDF`.
- **AC-29.5** : login redteam → mêmes 3 formats fonctionnent.
- **AC-29.6** : filename respecte `engagement-<id>-<slug>-YYYYMMDD.{ext}` (assert via Content-Disposition).
### US-30 — SOC pas d'accès à l'export
- **AC-30.1** : login SOC → engagement page → bouton "Export" **ABSENT** du DOM (pas seulement `display: none`). Assert via `expect(page.locator('[data-testid="export-dropdown"]')).not.toBeAttached()`.
- **AC-30.2** : appel direct API `GET /api/engagements/<id>/export?format=md` (Bearer SOC) → 403.
- **AC-30.3** : (sanity) appel API sans token → 401.
### US-31 — Robustesse format / engagement
- **AC-31.1** : `GET /api/engagements/<id>/export` sans `format` → 400 message friendly.
- **AC-31.2** : `GET /api/engagements/<id>/export?format=xml` → 400 friendly.
- **AC-31.3** : `GET /api/engagements/99999/export?format=md` → 404.
- **AC-31.4** : engagement avec 0 simulations → export OK (header seul, le CSV n'a qu'une ligne d'en-tête, le MD n'a pas de section simulation).
### Bouncing
- Si un AC échoue → bounce au builder responsable (backend ou frontend), pas de patch test-side.
---
## 5. Definition of Done — Sprint 5
## §4 — Reviews
- [ ] Tous les AC US-26 → US-28 passent.
- [ ] Backend pytest verts (~193 existants + ~25 nouveaux). Ruff + mypy clean.
- [ ] Frontend vitest verts (92 existants + nouveaux). Typecheck + lint clean.
- [ ] E2e Playwright suite verte (sprint 1-4 + sprint 5).
- [ ] Migration 0005 testée via Alembic round-trip.
- [ ] SPEC.md § Templates de simulations ajoutée.
- [ ] README.md mis à jour si nouveau bullet "Templates" pertinent.
- [ ] CHANGELOG.md sprint 5 entry sous [Unreleased].
- [ ] **Design-reviewer pass** sur les nouveaux écrans (lesson sprint 4 — design-reviewer = part of workflow depuis sprint 4).
- [ ] Code-reviewer sans BLOCKER.
- [ ] PR via `make open-pr` (sprint 4 dogfood validé).
### Spec-reviewer Pass 1 (avant dispatch)
- Lit ce `tasks/todo.md` § 0 + § 1 + § 2 + § 3.
- Verdict attendu : APPROVED / NEEDS-CHANGES par section.
- Points particuliers à challenger : WeasyPrint vs reportlab, CSV sans header engagement, URL drift (un seul endpoint avec query param vs 3 endpoints distincts).
### Spec-reviewer Pass 2 (après mes éventuels édits du plan)
- Re-validation des changements apportés.
- **TEAM-LEAD : ne PAS dispatcher backend tant que Pass 2 n'a pas répondu APPROVED.** Lesson sprint 5 — la patience sur le 2-pass a éliminé les addenda mid-implementation.
### Code-reviewer (après backend + frontend)
- LSP first (`goToDefinition`, `findReferences`).
- Focalise sur : pureté des render functions (testables), gestion des deps WeasyPrint dans Dockerfile, échappement CSV, filename slug, dropdown close-on-outside réutilisation.
### Design-reviewer (après screenshots frontend)
- Light + dark cohérence du dropdown Export.
- Vérifie que le bouton respecte la convention "icône + label court ≤ 8 chars" (`Export`).
- Audit alignement vs le header existant de la page.
### Test-verifier (après code-reviewer APPROVED)
- Écrit 1 spec file par US (`us29-export-formats`, `us30-export-rbac`, `us31-export-robustness`).
- Rapport pass/fail par AC.
---
## 6. Décisions arrêtées (utilisateur 2026-05-28)
## §5 — SPEC.md update (au tout début du sprint — lesson sprint 3/4/5)
1. **Table** : `simulation_templates` séparée (clean schema, pas de colonnes nullable confuses).
2. **Instantiation API** : extension de `POST /api/engagements/<eid>/simulations` avec `template_id` optionnel.
3. **Name uniqueness** : UNIQUE (1 template par nom, UX dropdown clean).
4. **Template RBAC** : admin + redteam writable. **SOC pas d'accès du tout** — pas de nav link, pas de page, tous endpoints templates → 403. Templates sont une ressource Red Team uniquement.
5. **UI instanciation** : dropdown sur le bouton "+ New simulation" (Blank | From template…).
Ajouter une section **§ Export d'engagement** entre § Templates de simulations et § Authentification & rôles :
> ## Export d'engagement
> Un engagement peut être exporté à tout moment dans 3 formats au choix : **Markdown** (handoff narratif), **CSV** (machine-readable, intégration tableurs), **PDF** (livrable client). L'export contient l'en-tête de l'engagement et toutes ses simulations avec les champs Red Team **et** SOC. Endpoint unique : `GET /api/engagements/<id>/export?format=md|csv|pdf`. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : `engagement-<id>-<slug>-YYYYMMDD.<ext>`.
**Le commit qui crée cette section doit être le PREMIER commit du sprint** (pas le dernier — sinon on rate le bug récurrent identifié dans les lessons). Le commit suivant peut être le plan lui-même (`tasks/todo.md` + `tasks/lessons.md`).
---
## 7. Plan d'exécution
## §6 — Workflow git du sprint
1. ✅ User a validé les 5 décisions §6 (2026-05-28). SOC zero access acté.
2. 🟡 Team-lead met à jour SPEC.md (§0).
3. 🟡 Spec-reviewer valide le plan (2-pass — lesson sprint 3/4 — RBAC SOC blocked, name unique conflict 409 handling, template_id passing through auto-transition, design-reviewer scope new pages).
4. 🔵 Backend-builder : modèle + migration 0005 + endpoints + tests.
5. 🔵 Frontend-builder : pages Templates list/form + TemplatePickerModal + nav link + dropdown engagement + tests Vitest. Screenshots mandatory.
6. 🔵 Design-reviewer : revoit diff frontend + screenshots.
7. 🔵 Code-reviewer : LSP-first review du diff complet.
8. 🔵 Test-verifier : e2e US-26 → US-28.
9. 🟢 Team-lead : `make open-pr` + récap.
- Branch : `sprint/6-export` (créée @ `678ee8f`).
- Commit séquence :
1. `docs(spec): add § Export d'engagement section` (le `M SPEC.md` ne doit JAMAIS rester unstaged)
2. `docs(plan): sprint 6 plan + sprint-5 lessons folded` (tasks/)
3. Commits backend (un ou deux, signés par backend-builder)
4. Commits frontend (un ou deux, signés par frontend-builder)
5. Commit post-code-review fixes (si nécessaire)
6. Commit screenshots design + e2e tests
7. Wrap-up commit team-lead : CHANGELOG + README + lessons.md sprint-6 + plan final
- PR via `make open-pr SPRINT=6 TITLE="feat: sprint 6 — engagement export (md/csv/pdf)" BODY=tasks/pr-body-sprint-6.md` (3e dogfood du wrapper sprint 4).
Branche : `sprint/5-templates`.
---
## §7 — Risk / hazard list
| # | Risk | Mitigation |
|---|---|---|
| 1 | WeasyPrint deps gonflent l'image Docker | Liste minimale documentée + WeasyPrint déjà packagé sur Debian slim ; mesurer Δ MB image build après vs avant |
| 2 | CSV mal-échappé avec commands multilines / quotes | Utiliser `csv.writer` stdlib (handles tout automatiquement), pas de string concat manuel |
| 3 | Markdown casse sur backticks dans commands | Fenced code blocks `~~~bash` (tildes au lieu de backticks pour les blocks contenant des backticks), OU escape via `markdown.escape` |
| 4 | Test PDF fragile sur le contenu | Asserter UNIQUEMENT : Content-Type, magic bytes `%PDF`, taille > 1 KB. Pas de regex sur le texte rendu (binary). |
| 5 | URL drift backend (`/export` vs `/engagements/<id>/export`) | Lesson sprint 5 — la 1re ligne du backend summary doit confirmer le path exact |
| 6 | Frontend oublie `URL.revokeObjectURL` → fuite mémoire | Test unitaire explicite : assert `revokeObjectURL` appelé après le click téléchargement |
| 7 | SPEC.md uncommitted à la fin du sprint (3 sprints en série !) | Commit SPEC.md en commit #1 du sprint, pas en wrap-up. Étape « cendrillon » du plan ci-dessus. |
---
## §8 — Definition of Done (sprint-level)
- [ ] §5 SPEC.md committed AS THE FIRST COMMIT of the sprint.
- [ ] Backend : 245+ pytest, ruff clean, mypy clean.
- [ ] Frontend : 130+ vitest, typecheck clean, lint clean.
- [ ] E2e : 215+ Playwright, 0 régression vs main.
- [ ] Screenshots fournies : EngagementDetailPage light + dark, dropdown fermé + ouvert, vue SOC sans bouton.
- [ ] Dockerfile mis à jour avec deps WeasyPrint + `make build` réussit.
- [ ] CHANGELOG.md `[Unreleased] → Sprint 6` rédigée.
- [ ] README.md « Status » bumped + section dans le tableau des features si pertinent.
- [ ] PR ouverte via `make open-pr` (pas via UI manuelle).
- [ ] `git status` au sprint-close affiche **uniquement** des fichiers ignorés (lesson récurrente).