diff --git a/CHANGELOG.md b/CHANGELOG.md index 86622ae..0f9dece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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---YYYYMMDD."`. +- 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-.` 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 `` 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 1–5: 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 ``. 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 `
`). A `sim.commands = ""` 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` → `
` substitution — mirrors the `_render_engagement_html` PDF defense. The `
` 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) diff --git a/README.md b/README.md index 27af797..c715f7d 100644 --- a/README.md +++ b/README.md @@ -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 5 — Simulation 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 6 — Engagement 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) ``` --- diff --git a/SPEC.md b/SPEC.md index 73073a0..20244cd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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//export?format=md|csv|pdf`. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : `engagement---YYYYMMDD.`. + +**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. diff --git a/backend/app/api/engagements.py b/backend/app/api/engagements.py index 2e57ce8..ee10972 100644 --- a/backend/app/api/engagements.py +++ b/backend/app/api/engagements.py @@ -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("//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}"'}, + ) diff --git a/backend/app/services/export.py b/backend/app/services/export.py new file mode 100644 index 0000000..0b51e57 --- /dev/null +++ b/backend/app/services/export.py @@ -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
(our own safe markup, inserted after escape). + s = _html_escape(value or "") + s = s.replace("|", "\\|") + s = s.replace("\n", "
") + 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("") + parts.append(f"") + parts.append(f"

{h(engagement.name)}

") + parts.append("
") + if engagement.description: + parts.append(f"

{h(engagement.description)}

") + parts.append(f"

Status: {h(engagement.status.value)}

") + 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"

Dates: {h(sd)} → {h(ed)}

") + parts.append(f"

Created by: {h(_creator(engagement))}

") + ca = engagement.created_at.isoformat() if engagement.created_at else "N/A" + parts.append(f"

Created at: {h(ca)}

") + parts.append("
") + + if simulations: + parts.append("

Simulations

") + thead = "
" + "".join(f"" for col in _HTML_HEADERS) + "" + parts.append(f"
{h(col)}
{thead}") + for sim in simulations: + execution_html = h(_format_execution_text(sim)).replace("\n", "
") + 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 = "" + "".join(f"" for c in cells) + "" + parts.append(row) + parts.append("
{c}
") + + parts.append("") + 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() diff --git a/backend/requirements.txt b/backend/requirements.txt index e00316d..878005e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_export_engagement.py b/backend/tests/test_export_engagement.py new file mode 100644 index 0000000..0850d42 --- /dev/null +++ b/backend/tests/test_export_engagement.py @@ -0,0 +1,270 @@ +"""Endpoint tests for GET /api/engagements//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") diff --git a/backend/tests/test_export_render.py b/backend/tests/test_export_render.py new file mode 100644 index 0000000..2bf2474 --- /dev/null +++ b/backend/tests/test_export_render.py @@ -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 "
" 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="", + commands='', + ) + result = render_engagement_markdown(eng, [sim]) + assert "