feat: sprint 6 — engagement export (md/csv/pdf) #9

Merged
knacky merged 20 commits from sprint/6-export into main 2026-06-09 16:19:02 +00:00

20 Commits

Author SHA1 Message Date
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