diff --git a/CHANGELOG.md b/CHANGELOG.md index 86622ae..94d342c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ 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+. + +--- + +## [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/tasks/lessons.md b/tasks/lessons.md index 518c4bf..b83d6bf 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -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-.` 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_(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)