Compare commits

..

33 Commits

Author SHA1 Message Date
Knacky
8b5b5d94d8 fix(frontend): floating dropdowns and matrix modal frame use bg-paper
MitreTechniquePicker dropdown, SimulationList overflow menu,
ExportEngagementButton format menu, and MitreMatrixModal dialog frame
all used bg-canvas as their surface color. With the tinted canvas
(#f3f5f8), these floating surfaces appeared slightly grey instead of
clean white. Switched to bg-paper (#ffffff light / #1f2937 dark).
MitreMatrixModal cell hover (bg-canvas) intentionally preserved —
matrix cells sit on canvas, not on paper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:12:07 +02:00
Knacky
76bcb04c8f style(frontend): inputs + outline buttons inside cards use bg-paper
.text-input, .btn-outline, .btn-outline-ink were using bg-canvas which
now resolves to the tinted #f3f5f8 instead of white, making inputs and
Cancel/outlined buttons visually gritty on white paper cards. Switching
all three to bg-paper restores white surfaces inside cards in light mode.
Dark mode unaffected (canvas/paper both resolve correctly there).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:11:59 +02:00
Knacky
88b97cef2e feat(frontend): 2-column layout for EngagementFormPage in edit mode
In edit mode with canEditEngagements, wraps [form | C2ConfigCard] in a
lg:grid-cols-2 responsive grid with items-start alignment. Stacks to
single column on screens narrower than lg. In create mode, retains the
existing max-w-2xl single-column layout. No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:01:09 +02:00
Knacky
e4b1d6cb57 style(frontend): tint canvas light to #f3f5f8 for cards-on-canvas contrast
Canvas and paper were both #ffffff in light mode — cards only separated
by a 1px hairline, causing eye fatigue. Tints the canvas token to a
very pale cool neutral (#f3f5f8) so paper cards lift naturally without
shadow or radius, preserving brutalism. Dark mode tokens unchanged.
Updates DESIGN.md Surface section with rationale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:01:00 +02:00
Knacky
a9fe2fc528 docs(sprint-9): plan UI 2-col engagement + global contrast pass 2026-06-11 10:53:16 +02:00
Knacky
38e282a126 fix(backend): complete c2 task→simulation mapping per spec + sanitize adapter errors (sprint 8 code-review)
mapping.py — full §0.11 contract:
1. execution_result: append '$ <command>\n<output>\n' block (previously
   wrote raw output without command header, making multi-task blobs
   unreadable in exports)
2. executed_at: set from task.completed_at when currently null (was
   completely missing — simulation.executed_at stayed null forever)
3. commands: append task.command deduplicated line-by-line (was
   completely missing — simulation.commands stayed empty)

mythic.py — sanitize transport errors:
Replace 'raise C2Error(str(exc))' (which leaks the Mythic URL via
requests exception repr) with 'raise C2Error(f"C2 transport error:
{type(exc).__name__}")'. Original exc stays chained for backend logs.

api/c2.py — remove redundant 'task.mapping_applied = True' in import
endpoint (apply_task_to_simulation() already sets it).

test_c2_mapping.py — full rewrite: 19 tests covering command blocks,
executed_at set/preserve, commands dedup, idempotency.

test_c2_adapter_mythic.py — add URL-leak sanitization assertion.

468 passed; ruff + mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:28:49 +02:00
Knacky
7d3d39639e fix(backend): expose c2_task.source in GET /c2/tasks response
Serialize source as t.source.value (string) in list_simulation_tasks.
Updated test_c2_tasks_list shape assertion to include 'source' and
assert value is 'mimic' for execute-created tasks. Added test in
test_c2_import to assert source='import' in GET /c2/tasks after import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:24:22 +02:00
Knacky
184a2a16c9 fix(frontend): a11y on clickable rows + correct c2 source field + pill metric alignment (sprint 8 design-review)
F1: add tabIndex/role/onKeyDown/aria-expanded to C2TasksPanel expander rows and
    C2CallbackPicker callback rows; focus-visible ring via Tailwind utilities
F2: add source:'mimic'|'import' to C2TaskListItem; C2TasksPanel reads task.source
    instead of mapping_applied for the Source badge label
F3: align C2TaskStatusBadge and C2CallbackPicker Active/Inactive pill metrics to
    py-[6px] text-[14px] font-medium (matches SimulationStatusBadge / StatusBadge)
F4: replace hand-rolled Source pill class string with badge-pill-outline recipe
Tests: 212/212 passing (+3 new: Enter/Space key on expander, Enter key on callback row)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:22:45 +02:00
Knacky
7ff153905b feat(frontend): c2 tasks panel + history import (sprint 8 phase 2)
- Add getC2Tasks / listCallbackHistory / importC2 API functions + types
- useC2Tasks with 2500ms polling (stops when all tasks completed)
- useC2CallbackHistory, useImportC2 hooks
- C2TaskStatusBadge, C2TasksPanel (expandable output rows, polling indicator)
- C2CallbackPicker extracted as shared component (reused in both modals)
- ImportC2HistoryModal: 2-step callback picker → paginated history table
- SimulationFormPage: RT card + tasks panel share left grid column; Import C2 history button
- 37 new tests (api/c2, C2TasksPanel, ImportC2HistoryModal, SimulationFormPage panel visibility)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:11:12 +02:00
Knacky
8f23f59601 feat(backend): c2 callback history + task import (sprint 8 M4)
Command source decision: extended C2TaskStatus with command: str | None
(default None). Added command_name to _GET_TASK_QUERY so get_task() returns
command in a single round-trip — no separate history fetch needed on import.
4-line change, zero cascading test impact.

adapter.py:
- C2TaskStatus: add command: str | None = None field
- C2HistoricalTask: new dataclass (display_id, command, params, status,
  completed, timestamp) for history rows
- C2TaskPage.items: typed as list[C2HistoricalTask] (was list[dict])

mythic.py:
- _GET_TASK_QUERY: add command_name field
- _LIST_CALLBACK_TASKS_QUERY: new query (order_by id desc, limit/offset)
- _COUNT_CALLBACK_TASKS_QUERY: new aggregate query for total
- get_task(): surfaces command_name as status.command
- list_callback_tasks(): two _post() calls (tasks + count), allow_redirects=False

fake.py:
- _FAKE_HISTORY: frozen deterministic history (cb1=12, cb2=0, cb3=5 tasks)
- list_callback_tasks(): serves from _FAKE_HISTORY, pagination applied
- get_task(): returns command from _tasks dict

api/c2.py:
- GET /api/engagements/<eid>/c2/callbacks/<cid>/history: page+page_size
  defaults 1/25, cap 100, reject <1, 502 on adapter error
- POST /api/simulations/<sid>/c2/import: idempotent per (sim,mythic_id) pair,
  source=import, completed tasks get output+mapping_applied, incomplete tasks
  stored for poll-on-read pickup, auto-transition pending→in_progress

60 new tests (456 total); pytest/ruff/mypy all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:09:29 +02:00
Knacky
b83316f715 fix(backend): repair 0006 migration tests for alembic 1.18.x proxy api
Replace deprecated alembic.op._proxy assignment with
ops._install_proxy() / ops._remove_proxy() pattern required
by Alembic >= 1.13. Consistent with test_migration_0007_c2.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:02:46 +02:00
Knacky
873e52a2a1 feat(backend): c2 poll-on-read + output mapping (sprint 8 M3)
- adapter.py: add completed_at field to C2TaskStatus dataclass
- mythic.py: implement get_task() (GraphQL task query) and
  get_task_output() (response query + decode_response_text concat)
- fake.py: deterministic state progression via per-instance call counter;
  get_task_output raises C2Error until completed
- mapping.py: apply_task_to_simulation() idempotent output mapper
  (mapping_applied anchor prevents double-writes)
- migration 0007: add mapping_applied BOOLEAN NOT NULL DEFAULT false to c2_task
- c2_task model: mapping_applied column added
- api/c2.py: GET /api/simulations/<id>/c2/tasks poll-on-read endpoint;
  refreshes incomplete tasks from C2, fetches output on completion,
  applies mapping, skips re-polling for completed tasks; best-effort
  (C2Error on individual task skipped, returns 200 with stale status)
- 51 new tests (396 total); pytest/ruff/mypy all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:56:06 +02:00
Knacky
5ff6ae8940 feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config,
  deleteC2Config, testC2Config, listCallbacks, executeC2) following the
  frozen M1+M2 backend contracts
- frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult,
  C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse
- frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config,
  useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2
- frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config
  card (url + write-only token + verify-tls + save/delete/test-connection),
  503 disabled state, ConfirmDialog on delete
- frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table
  (mono data cells), commands textarea pre-filled from rt.commands,
  Launch disabled until row selected + non-empty commands
- frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit
  mode only, admin+redteam only (canEditEngagements gate)
- frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT
  card, visible only when !isDone && canEditRT && hasC2Config; opens modal
- Tests: 33 new tests across api/c2, components/C2ConfigCard,
  components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage
  (172 total, 139 baseline + 33 new, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:50:11 +02:00
Knacky
53755a31d6 feat(backend): c2 callbacks + execute endpoints (sprint 8 M2)
- Add C2Error exception to adapter ABC
- Add promote_to_in_progress() helper to simulation_workflow (pending→in_progress)
- Flesh out MythicAdapter: list_callbacks() (GraphQL query) + create_task() (mutation)
- Expand FakeAdapter to 3 deterministic callbacks; switch task store to per-instance
- Add GET /api/engagements/<id>/c2/callbacks — lists active callbacks via adapter
- Add POST /api/simulations/<id>/c2/execute — issues tasks, stores C2Task rows,
  auto-transitions pending→in_progress, blocks on done (409)
- Both endpoints: SOC=403, 503 no-key, 502 adapter error, sanitized error messages
- Add requests-mock==1.12.1 to requirements.txt
- 42 new tests (342 total, 300 M1 baseline preserved green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:38:07 +02:00
Knacky
9a9c98beab feat(backend): c2 crypto + config CRUD + adapter scaffolding (sprint 8 M1)
- Add Fernet crypto service (MIMIC_ENCRYPTION_KEY env, C2Disabled on absent key)
- Add Alembic migration 0006: c2_config + c2_task tables with cascade FKs
- Add C2Config and C2Task SQLAlchemy models
- Add C2Adapter ABC with dataclasses (C2Health, C2Callback, C2TaskStatus, C2TaskPage)
- Add FakeAdapter (deterministic in-memory, MIMIC_C2_ADAPTER=fake)
- Add MythicAdapter scaffold: test_connection() live, M2+ raise NotImplementedError
- Add decode_response_text() helper for base64/binary Mythic responses
- Add GET/PUT/DELETE/POST-test /api/engagements/<id>/c2-config endpoints
- RBAC: admin+redteam OK, SOC 403; 503 guard when encryption key absent
- Token never returned in API responses; stored Fernet-encrypted only
- 42 new tests (300 total, 258 baseline preserved green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:20:52 +02:00
Knacky
813e69ee01 docs(spec): add C2 integration section (sprint 8 commit #1)
Introduce the SPEC section for the Mythic C2 integration layer.
Covers RBAC (RT-only, SOC=403), per-engagement Fernet-encrypted config,
c2_config + c2_task data model with ON DELETE CASCADE, full endpoint
list, output mapping rules (append-only, idempotent), 2500 ms polling
and the fake/real adapter selection via MIMIC_C2_ADAPTER.

Also patch tasks/todo.md: fix pytest baseline (256 from main, not 253),
make cascade-delete explicit, pin the MythicMeta/Mythic_Scripting source
version and document defensive base64 handling.

Closes spec-reviewer WARN-1 (SPEC ↔ plan parity), WARN-2 (cascade),
INFO-1 (pinned source), INFO-3 (baseline).
2026-06-10 19:07:35 +02:00
6ca614a3f3 Merge pull request 'feat(design): terminal-SOC aesthetic refresh (sprint 7)' (#10) from sprint/7-design into main
Reviewed-on: #10
2026-06-10 16:40:20 +00:00
Knacky
1997a8c621 feat(frontend): side-by-side red-team/SOC columns on simulation screen
Wrap RT and SOC form cards in a responsive 2-column grid
(grid gap-xl lg:grid-cols-2 items-start) on the simulation edit view.
Drop the max-w-3xl constraint from the outer container so the grid
can use full page width (matching EngagementDetailPage). Header,
banners, submitError, and sticky action bar remain full-width above
the grid. The isNew create form keeps its current narrow layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 18:17:54 +02:00
Knacky
573281f454 fix(design): code-reviewer polish — dedupe tag-mitre recipe, drop dead boxShadow tokens, cover StatusBadge classes
F1: MitreTechniqueTag inline classes replaced with className="tag-mitre gap-xxs"
    (index.css .tag-mitre is now the single source of truth for technique tags).
F2: boxShadow block removed from tailwind.config.ts — no component referenced
    shadow-soft-lift / shadow-floating / *-dark; DESIGN.md §Don'ts prohibits shadows.
F4: StatusBadge.test.tsx gains 3 class assertions (bg-warn-soft / bg-primary-soft / bg-cloud)
    mirroring SimulationStatusBadge.test.tsx pattern. Test count: 136 → 139.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 05:53:53 +02:00
Knacky
6995c4c860 fix(design): address design-reviewer findings F1-F6 — nav slab, spinner, badge coverage, mono discipline
F3: nav-bar-top bg-paper → bg-slab text-slab-text (3-band slab anchoring restored).
    NavLinks: text-slab-muted default, text-slab-text + border-primary active.
    Logo span: text-ink → text-slab-text.
F2: (you) label extracted from font-mono td into adjacent font-sans span.
F1: Loader2 circular spinner → EXPORTING… text with animate-pulse (terminal-SOC compatible).

Screenshots regenerated:
- All 8 pages light+dark (01→10)
- 05-simulation-form-edit light+dark (F6)
- 11-mitre-matrix-modal light+dark (F6)
- 12-toast-success light+dark (F6)
- 13-confirm-dialog light+dark (F6)
- admin-light/dark-open/closed regenerated from HEAD (F4)

F4: StatusBadge.tsx confirmed single code path — planned → bg-warn-soft (no divergence in code).
    Divergence in prior captures was stale cache; regenerated admin-* confirm consistency.
F5: Simulations seeded (pending/in_progress/review_required/done) via API;
    10-sim-list-badges shows all 4 semantic badge colors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:43:08 +02:00
Knacky
7ea2fe490c docs: add sprint 7 terminal-SOC design refresh to CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:01:29 +02:00
Knacky
0e69eb901c test(frontend): update vitest assertions for new design tokens 2026-06-09 18:46:03 +02:00
Knacky
5cc830554c refactor(pages): apply terminal-SOC aesthetic to all 8 pages 2026-06-09 18:44:47 +02:00
Knacky
ec7800ae38 refactor(components): squared shapes + mono data cells across all components 2026-06-09 18:42:26 +02:00
Knacky
c791e50108 feat(design): bundle JetBrains Mono for data cells 2026-06-09 18:39:13 +02:00
Knacky
64da94f10d feat(design): refresh tailwind tokens for brutalist terminal aesthetic 2026-06-09 18:38:43 +02:00
Knacky
6c05cc2e11 docs(design): rewrite DESIGN.md for terminal-SOC aesthetic 2026-06-09 18:37:44 +02:00
Knacky
5627d7dcfa docs(sprint 7): plan — terminal-SOC design refresh
9 binding decisions locked with user 2026-06-09 (4-question + 4-question
+ 3-question rounds). Visual direction Bloomberg / terminal SOC. Border
radius 0 except status pills and avatars. Palette kept (primary blue +
slab + canvas/paper/cloud/fog/ink), ADD success/warn semantic tokens.
Scope: 8 pages + 17 components + tokens + DESIGN.md rewrite, all in one
sprint. JetBrains Mono for data only (Inter stays for body/headers).
Light + dark both kept. Zero transitions (brutalist).

Plan validated by spec-reviewer pre-pass: APPROVED with 3 findings
addressed inline (D9 added, R2 reworded, semantic tokens promoted from
optional to locked).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:34:45 +02:00
Knacky
c85ece46b9 chore(agents): frontend-builder must invoke Skill frontend-design before UI work
Adds a mandatory pre-step to the frontend-builder agent prompt: invoke the
frontend-design skill at sprint start before creating or modifying any
visible UI component. DESIGN.md rules project-specific tokens; the skill
covers universal principles (typographic hierarchy, alignment, contrast,
focus, density, motion). Skip allowed only for pure logic/data-layer work
with no visual change.

Authored locally during sprint 6 (uncommitted in worktree), bundled into
sprint 7 hygiene as the first commit so it takes effect immediately for
the design refresh work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:34:36 +02:00
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
88 changed files with 8176 additions and 892 deletions

View File

@@ -11,10 +11,22 @@ You are the **Frontend Builder** for the Mimic project (BAS WebUI based on MITRE
Read these files first, in order: Read these files first, in order:
1. `SPEC.md` — global spec and technical decisions. 1. `SPEC.md` — global spec and technical decisions.
2. `DESIGN.md` — UI design system. **Mandatory** — every component you build must follow it. 2. `DESIGN.md` — UI design system. **Mandatory** — every component you build must follow it (tokens, slab, btn-outline, etc.).
3. The **backend-builder's summary** for the current sprint (in `tasks/todo.md` or the latest team-lead dispatch). This is your API contract. 3. The **backend-builder's summary** for the current sprint (in `tasks/todo.md` or the latest team-lead dispatch). This is your API contract.
4. `tasks/lessons.md` — past mistakes to avoid. 4. `tasks/lessons.md` — past mistakes to avoid.
## Mandatory skill — `frontend-design`
Before creating or modifying **any visible UI component** (new page, new component, layout change, state additions like loading/error/empty), you MUST invoke the `frontend-design` skill once at the start of the sprint via:
```
Skill({ skill: "frontend-design" })
```
`DESIGN.md` rules the **project-specific** tokens and motifs (slab, btn-outline, palette, BAS layout patterns). `frontend-design` adds the **universal** principles `DESIGN.md` doesn't restate: typographic hierarchy, alignment grid, contrast ratios, focus states, density rhythm, motion restraint. The two are complementary — `DESIGN.md` wins on tokens/component shape, `frontend-design` wins on visual craft.
Exception: pure logic/data-layer work with no visible UI change (hook refactor, query key rename, internal type tightening) — skip the skill, note it in your summary.
## What you build ## What you build
- React components under `frontend/src/components/` - React components under `frontend/src/components/`

View File

@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
### Changed — Sprint 7 (Terminal-SOC design refresh)
**Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names)
- `DESIGN.md` — complete rewrite from HP-catalog (346 lines) to terminal-SOC brutalist spec. Covers palette (success/warn tokens added), typography (Inter + JetBrains Mono), layout, shapes (border-radius 0 rule), component patterns, and Do/Don't list.
- `frontend/tailwind.config.ts` — added `success` / `warn` semantic color tokens (WCAG AA in both light+dark); added `fontFamily.mono` (JetBrains Mono Variable); reduced all `display-*` scale (xxl 72→40, xl 56→32, lg 44→28, md 32→24, sm 24→20, xs 20→16); `borderRadius` reduced to `none: '0px'` + `pill: '9999px'` only; `section` spacing 40px→48px.
- `frontend/src/styles/index.css` — CSS vars for `--color-success[-soft]` / `--color-warn[-soft]` (light + dark variants); all `.btn-*` classes: `rounded-none`, no `transition-colors`; `.text-input`: `rounded-none`, no transition; `.card-product`: `rounded-none`, `border border-hairline`, no shadow; `.badge-pill-*` kept `rounded-pill`; added `.tag-mitre` (angular MITRE tags, `font-mono`).
- `frontend/src/styles/fonts.css` — added `@import '@fontsource-variable/jetbrains-mono/index.css'` (local, no CDN).
- `frontend/src/components/StatusBadge.tsx``rounded-lg``rounded-pill`; new semantic colors: `planned → warn-soft`, `active → primary-soft`, `closed → cloud/graphite`.
- `frontend/src/components/SimulationStatusBadge.tsx``rounded-lg``rounded-pill`; semantic mapping: `pending → cloud`, `in_progress → primary-soft`, `review_required → warn-soft`, `done → success-soft`.
- `frontend/src/components/Toast.tsx` — removed `rounded-xl` and shadow; left border strips: `error → border-l-bloom-deep`, `success → border-l-success`, default → `border-l-primary`.
- `frontend/src/components/MitreTechniqueTag.tsx``rounded-full``rounded-none` on both technique and tactic tags; added `font-mono` (MITRE IDs are data).
- `frontend/src/components/ExportEngagementButton.tsx` — removed `rounded-r-none` / `rounded-l-none` from split-button; dropdown: `rounded-md shadow-floating``rounded-none`.
- `frontend/src/components/SimulationList.tsx` — dropdown: `rounded-md shadow-floating``rounded-none`; MITRE column + `executed_at`: added `font-mono`; split-button: removed radius classes.
- `frontend/src/pages/SimulationFormPage.tsx` — h1: `text-[44px]``text-[32px]`; Done/SOC banners: `rounded-xl``rounded-none`.
- `frontend/src/pages/UsersAdminPage.tsx` — h1: `text-[44px]``text-[32px]`; username column + created_at column: added `font-mono`.
- `frontend/tests/SimulationStatusBadge.test.tsx` — updated 3 assertions for renamed semantic tokens (`bg-fog``bg-cloud`, `bg-bloom-coral``bg-warn-soft`, `bg-storm-deep``bg-success-soft`).
**No backend changes.** No DB schema change. No migration.
### Added — Sprint 6 (Engagement export) ### 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** (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)
@@ -36,7 +56,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
### Changed ### 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-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 — 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 255 pytest, frontend 136 vitest, e2e 223 Playwright. - 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.
--- ---

438
DESIGN.md
View File

@@ -1,345 +1,263 @@
## Overview ## Overview
HP reads like a long-running consumer-electronics catalog crossed with an enterprise-software product page. The whole system sits on **pure white** (`{colors.canvas}``#ffffff`) with thin gray panels (`{colors.cloud}` / `{colors.fog}`) for alternating section bands. There is one chromatic action color — **HP Electric Blue** (`{colors.primary}``#024ad8`) — and one ink color (`{colors.ink}``#1a1a1a`); together they do ninety percent of the work. Type is a single family across every surface: **Forma DJR Micro**, HP's bespoke geometric grotesque, set at weight 500 for headlines and 400 for body — clean, neutral, slightly mechanical. Mimic is a **BAS (Breach and Attack Simulation) purple-team console** — not a product catalog, not a marketing page. The aesthetic is **Bloomberg Terminal / SOC dashboard**: dense, angular, semantic-color-driven, zero ornament. Every surface decision reinforces operational trust: data is primary, chrome is invisible.
The signature gesture is **angular blue chevrons** — sharp 0-radius slashes derived from the HP wordmark's pair of parallel slashes — that anchor the homepage hero, the laptop-page hero, and the printer pricing page. They appear on the left and right edges of the primary banner card, layered behind product photography. Outside those decorative slashes, every other surface is rectilinear with **soft 816px corners** on cards and a 4px corner on buttons. The system sits on a **pale-tinted canvas** (light: `#f3f5f8`) / **dark slab** (dark) with one chromatic action color — **Electric Blue** (`{colors.primary}``#024ad8`) — and semantic status signals (`success`, `warn`, `bloom-deep`). Inter handles body/headers/labels. JetBrains Mono carries data: IDs, ISO dates, commands, execution output, MITRE technique codes, metrics — anything that must be read at a glance without typographic distraction.
The system breaks into three voice modes: a **white commercial body** for product browsing (cards, category icons, pricing tiers); a **dark navy slab** (`{colors.ink}` near-black) for testimonial bands, the closing "How can we help?" footer-prelude, and the page footer; and a **light fog band** (`{colors.cloud}` / `{colors.fog}`) for utility sections like comparison strips and FAQ accordions. The blue accent appears only on filled CTAs, link text, the chevron decorations, and the active price-stamp on a featured tier — never as a section background. **No rounded corners on containers.** No shadows on interactive surfaces. No transitions. Hover is instantaneous. Focus rings are sharp. This is a tool, not a storefront.
**Key Characteristics:** **Key Characteristics:**
- Pure white canvas (`{colors.canvas}`) with deep ink (`{colors.ink}`) running every body surface; light fog bands (`{colors.cloud}`, `{colors.fog}`) alternate for section rhythm - Angular surfaces everywhere (`border-radius: 0`) — exception: status pills (`rounded-pill`) and avatars
- HP Electric Blue (`{colors.primary}`) is the lone CTA fill and link color; it appears at most twice per viewport - Zero transitions / zero animations — state changes are immediate
- Bespoke Forma DJR Micro across every surface — display, body, button, caption — at weights 400 / 500 / 600 / 700 - Semantic color signals: primary blue = action, success green = confirmed, warn amber = pending/caution, bloom-deep = destructive/error
- Cards round at `{rounded.xl}` (16px) for product/pricing tiles; buttons sit at `{rounded.md}` (4px) with capitalize labels - Monospace data discipline: `font-mono` ONLY for IDs, dates, codes, output, metrics — never for headers, labels, prose
- Geometric blue chevrons (`{colors.primary}` rectangles cut at 45°) frame hero photography and reinforce the wordmark - Dense spacing: section gap 48px (not 80px), card padding 1216px on dense surfaces
- Dark-navy slabs (`{colors.ink}`) close every page rhythm — testimonial bands, "how can we help?" prelude, and the footer - Light + dark modes both supported; dark mode is the primary operational mode for SOC analysts
- Section rhythm: utility-strip → top nav → white body → cloud-band → ink slab → cloud-band → ink footer
---
## Colors ## Colors
> **No Interaction sub-section.** Hover colors are silently filtered. Allowed sub-sections: Brand & Accent, Surface, Text, Semantic.
### Brand & Accent ### Brand & Accent
- **HP Electric Blue** (`{colors.primary}``#024ad8`): the system's lone signal — primary CTA fill, link color, chevron-decoration fill, active sub-nav indicator. Reserved. - **Electric Blue** (`{colors.primary}``#024ad8`): primary CTA fill, active nav indicator, focus ring. Never used as a section background.
- **Bright Blue** (`{colors.primary-bright}``#296ef9`): a slightly lighter variant used inside dark slabs (testimonial-card buttons, dark-band CTA links) where the deeper blue would muddy. - **Bright Blue** (`{colors.primary-bright}``#296ef9`): CTA on dark slab surfaces where `#024ad8` muddles.
- **Deep Navy** (`{colors.primary-deep}``#0e3191`): pressed state for the primary CTA and the visited-link color. - **Deep Navy** (`{colors.primary-deep}``#0e3191`): pressed state for primary CTA.
- **Soft Blue** (`{colors.primary-soft}``#c9e0fc`): pale-blue surface used inside customer-story cards and selection chips. - **Soft Blue** (`{colors.primary-soft}``#c9e0fc`): selection highlight, chip background on light surfaces.
### Surface ### Surface
- **Canvas** (`{colors.canvas}``#ffffff`): the universal page background. White, full opacity. - **Canvas** (`{colors.canvas}``#f3f5f8` light / `#111827` dark): universal page background. In light mode, canvas is tinted while paper stays pure white so cards lift without shadow or radius, preserving brutalism.
- **Paper** (`{colors.paper}``#ffffff`): card surfaces — same white as canvas, with hairline borders or shadows providing the lift. - **Paper** (`{colors.paper}``#ffffff` light / `#1f2937` dark): card and panel surfaces.
- **Cloud** (`{colors.cloud}``#f7f7f7`): the lightest gray section band, used for alternating-row backgrounds and product-feature card groups. - **Cloud** (`{colors.cloud}``#f7f7f7` light / `#1f2937` dark): alternating section band, table row zebra.
- **Fog** (`{colors.fog}``#e8e8e8`): a slightly darker gray surface band, used for FAQ outer panels and the "Trending laptops" header strip. - **Fog** (`{colors.fog}``#e8e8e8` light / `#374151` dark): secondary section band, input background on dense panels.
- **Steel** (`{colors.steel}``#c2c2c2`): hairline border used on outlined elements with stronger emphasis (focus states, active filter). - **Steel** (`{colors.steel}``#c2c2c2` light / `#4b5563` dark): hairline borders, disabled states.
- **Bloom Coral / Bloom Rose** (`{colors.bloom-coral}` / `{colors.bloom-rose}``#ff5050`, `#f9d4d2`): the "Get 25% off" sale-tag chip + soft pink lifestyle accent on the sale hero. - **Hairline** (`{colors.hairline}`): 1px divider between cells, panels, table rows.
- **Storm Mist / Sea / Deep** (`{colors.storm-mist}`, `{colors.storm-sea}`, `{colors.storm-deep}``#8ebdce`, `#7fadbe`, `#356373`): the teal-storm tones reserved for the printer-plan illustration backdrop and supporting infographic accents. - **Slab** (`{colors.slab}``#111827`): fixed-dark surface — utility strip, footer, dark bands. Does NOT invert in dark mode.
### Text ### Text
- **Ink** (`{colors.ink}``#1a1a1a`): the universal text color on white surfaces — headlines, body, button labels, navigation. - **Ink** (`{colors.ink}``#1a1a1a` light / `#f9fafb` dark): universal body text color.
- **Ink Deep** (`{colors.ink-deep}``#000000`): pure black used for the wordmark and 1px hairline strokes around badge outlines. - **Ink Deep** (`{colors.ink-deep}``#000000` light / `#ffffff` dark): maximum contrast for headings.
- **Ink Soft** (`{colors.ink-soft}``#292929`): an alternate near-black used inside dark-navy slabs as a subtle textural shift. - **Ink Soft** (`{colors.ink-soft}``#292929` light / `#e5e7eb` dark): muted body, secondary labels.
- **On Ink** (`{colors.on-ink}``#ffffff`): pure white used for headline and body text on every dark-navy slab. - **On Ink** (`{colors.ink-on}``#ffffff` light / `#111827` dark): text on slab surfaces.
- **Charcoal** (`{colors.charcoal}``#3d3d3d`): muted body color on white surfaces — secondary descriptions, fine-print disclaimers. - **Charcoal** (`{colors.charcoal}``#3d3d3d` light / `#d1d5db` dark): secondary descriptions, captions.
- **Graphite** (`{colors.graphite}``#636363`): smaller-print color, used for legal lines and timestamp metadata. - **Graphite** (`{colors.graphite}``#636363` light / `#9ca3af` dark): timestamps, metadata, footnotes.
### Semantic ### Semantic (Status Signals)
- **Bloom Deep** (`{colors.bloom-deep}``#b3262b`) + **Bloom Wine** (`{colors.bloom-wine}``#5a1313`): error and discount-emphasis colors. The deep brick reads as "sale" or "destructive" depending on placement. - **Success** (`{colors.success}``#16a34a` light / `#22c55e` dark): confirmed detections, done status, positive metrics. Background softened to `{colors.success-soft}` (`#dcfce7` light / `#14532d` dark) for badge fills.
- **Storm Deep** (`{colors.storm-deep}``#356373`): used as a neutral status accent (e.g., printer-plan tier "Versatile" tier color). - **Warn** (`{colors.warn}``#d97706` light / `#f59e0b` dark): pending review, caution states, partial detection. Background softened to `{colors.warn-soft}` (`#fef3c7` light / `#78350f` dark) for badge fills.
- **Bloom Deep** (`{colors.bloom-deep}``#b3262b`): error, destructive action, failed detection. Bloom family covers red-spectrum signals.
- **Bloom Wine** (`{colors.bloom-wine}``#5a1313`): darkened destructive emphasis.
- **Bloom Coral** (`{colors.bloom-coral}``#ff5050`): alert highlight on dark surfaces.
---
## Typography ## Typography
### Font Family ### Font Families
- **Inter Variable** (`{fontFamily.sans}`): body text, labels, headers, navigation, form fields. The universal surface font.
- **JetBrains Mono Variable** (`{fontFamily.mono}`): data cells ONLY — engagement IDs, simulation IDs, ISO 8601 dates, execution commands, terminal output, MITRE technique codes (T1059.001), numeric metrics, usernames-as-identifiers. Never used for prose, headings, or labels.
The voice is **single-family**: Forma DJR Micro (HP's bespoke geometric grotesque, fallback Arial) across every surface — display, body, button, caption. Forma DJR Micro is a wide, slightly rounded grotesque designed at small optical sizes to stay legible at UI-chrome scale. HP runs it at weight 400 for body, 500 for display headlines, 600/700 for emphasis and button labels. Both fonts are bundled locally via `@fontsource-variable/inter` and `@fontsource-variable/jetbrains-mono`. Zero CDN loading at runtime.
The 16/14/12-px caption tier carries the catalog metadata — model numbers, spec rows, fine print — at weight 400 with a 1.41.5 line-height. Button labels lift to weight 600/700 with positive 0.51.1px letter-spacing and uppercase transform — the only place the system tracks letters.
### Hierarchy ### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use | | Token | Size | Weight | Line Height | Use |
|---|---|---|---|---|---| |---|---|---|---|---|
| `{typography.display-xxl}` | 72px | 500 | 1.0 | 0 | Hero headline (homepage, laptop hub) | | `{typography.display-xxl}` | 40px | 500 | 1.1 | Page-level hero (rare — dashboard title) |
| `{typography.display-xl}` | 56px | 500 | 1.0 | 0 | Section headlines on landing pages | | `{typography.display-xl}` | 32px | 500 | 1.1 | Section title (engagement name, modal header) |
| `{typography.display-lg}` | 44px | 500 | 1.0 | 0 | Sub-section headlines on shop pages | | `{typography.display-lg}` | 28px | 500 | 1.1 | Sub-section header, panel title |
| `{typography.display-md}` | 32px | 500 | 1.0 | 0 | Promo strip headlines, FAQ section headers | | `{typography.display-md}` | 24px | 500 | 1.1 | Card title, table header group |
| `{typography.display-sm}` | 24px | 500 | 1.17 | 0 | Card titles, pricing-tier names | | `{typography.display-sm}` | 20px | 500 | 1.1 | Item title, form section header |
| `{typography.display-xs}` | 20px | 500 | 1.0 | 0 | Inline list headers, accordion labels | | `{typography.display-xs}` | 16px | 600 | 1.1 | Compact section header, sidebar label |
| `{typography.body-lg}` | 18px | 400 | 1.33 | 0 | Lead paragraphs | | `{typography.body-lg}` | 18px | 400 | 1.4 | Lead paragraph |
| `{typography.body-md}` | 16px | 400 | 1.38 | 0 | Default body | | `{typography.body-md}` | 16px | 400 | 1.4 | Default body, form labels |
| `{typography.body-emphasis}` | 16px | 500 | 1.38 | 0 | Bolded run-in copy | | `{typography.body-emphasis}` | 16px | 500 | 1.4 | Bolded inline copy, table column headers |
| `{typography.caption-md}` | 14px | 400 | 1.5 | 0 | Specs, metadata, captions | | `{typography.caption-md}` | 14px | 400 | 1.5 | Secondary metadata, captions, table cells (non-data) |
| `{typography.caption-bold}` | 14px | 700 | 1.3 | 0 | Sale tags, in-card highlights | | `{typography.caption-bold}` | 14px | 700 | 1.3 | Status labels, tag text |
| `{typography.caption-sm}` | 12px | 400 | 1.33 | 0 | Footnotes, legal lines | | `{typography.caption-sm}` | 12px | 400 | 1.33 | Footnotes, legal fine-print |
| `{typography.link-md}` | 16px | 500 | 1.38 | 0 | Inline link emphasis | | `{typography.button-md}` | 14px | 600 | 1.4 | Button labels (uppercase) |
| `{typography.button-md}` | 14px | 600 | 1.4 | 0.7px | Primary/secondary button labels (uppercase) | | `{typography.button-sm}` | 12.6px | 700 | 1.0 | Compact button in tight cells |
| `{typography.button-sm}` | 12.6px | 700 | 1.0 | 0.126px | Compact button labels in tight cells |
| `{typography.price-md}` | 24px | 500 | 1.17 | 0 | Tier and product price stamps |
### Principles ### Monospace Discipline
The typographic decision worth flagging: HP runs **weight 500 for every display size**, including the largest 72px hero headline. Most editorial systems jump to 600/700 at hero scale; HP doesn't. The result feels open and approachable rather than commanding — appropriate for a brand that sells across consumer, SMB, and enterprise audiences in the same catalog. JetBrains Mono carries data that must be scanned without typographic noise. Apply `font-mono` (Tailwind) or `font-family: var(--font-mono)` to:
Forma DJR Micro's rounded-grotesque shapes do most of the warmth. There's no italic in the system except inside legal disclaimers; emphasis is carried by weight (500 → body-emphasis, 700 → caption-bold) instead. - Engagement IDs, simulation IDs (any UUID or integer identifier)
- ISO 8601 dates and timestamps (`2024-06-09T14:32:00`)
- MITRE technique codes (`T1059.001`, `TA0002`)
- Execution commands and command fields
- Terminal / execution output
- Numeric metrics (counts, durations)
- Usernames displayed as record identifiers (not greeting text)
### Note on Font Substitutes Never apply `font-mono` to: page titles, section headers, body prose, navigation, form labels, button text.
Forma DJR Micro is proprietary (Commercial Type / Mark Caneso). Closest open-source substitutes: ---
- **Inter** at weights 400 / 500 / 600 / 700 — slightly narrower than Forma DJR Micro; bump font-size by ~3% to compensate
- **Manrope** at weights 400 / 500 / 600 / 700 — closer in proportion, gentler curves; use directly with no metric adjustment
- **Roboto** at weights 400 / 500 / 700 — flatter character; use as last-resort fallback
When swapping, set body line-height to 1.4 and display line-height to 1.0 explicitly — the Forma DJR Micro line-height numbers are tight, and most substitutes default looser.
## Layout ## Layout
### Spacing System ### Spacing System
- **Base unit**: 8px. Smaller half-step at 4px. The scale is gentle — most card padding lands at 16px or 24px; section gap at 80px. - **Base unit**: 8px. Half-step 4px.
- **Tokens (front matter)**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 80px - **Tokens**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 48px
- **Section padding**: `{spacing.section}` (80px) vertical between major bands on desktop; collapses to ~48px on mobile. - **Section padding**: 48px vertical between major bands (desktop); ~24px on mobile.
- **Card internal padding**: `{spacing.xl}` (24px) for product cards; `{spacing.xxl}` (32px) for promo strips and feature cards; `{spacing.md}` (16px) for compact article tiles. - **Card padding**: 16px on dense panels; 24px on standard cards.
- **Gutter**: `{spacing.xl}` (24px) between grid columns at desktop; `{spacing.md}` (16px) on tablet/mobile. - **Gutter**: 24px between grid columns on desktop; 16px on tablet/mobile.
The 80px section gap is the universal rhythm constant — it appears between every major homepage band, between the hero and the comparison table on the printer-plan page, and between feature rows on the laptop-shop page.
### Grid & Container ### Grid & Container
- **Desktop max-width**: 1366px content container with full-bleed-on-canvas section backgrounds. - **Max-width**: 1366px content container, full-bleed on canvas.
- **Hero**: a single full-width photo card (homepage and laptop-hub hero) with the headline overlay positioned upper-left or upper-right. - **List pages**: full-width table with 1px hairline borders, no card wrap.
- **Product family grid**: 4 columns at >1200px, 3 at 10241199px, 2 at 7681023px, 1 below 768px. - **Detail pages**: 2-column split (60/40) on desktop, stacked on mobile.
- **Pricing tiers**: 4 columns at >1024px, 2x2 grid at 7681023px, single-column accordion below 768px. - **Form pages**: single-column centered at 640px max-width.
- **Footer**: 5-column link grid at >1024px, collapsing to 2-column then accordion on mobile.
### Whitespace Philosophy ### Whitespace Philosophy
Whitespace is **commercial-clean** — generous around hero photography, tight around catalog spec rows. Product cards leave breathing room above and below the photo (≥32px) so the laptop or printer reads as a hero shot rather than a thumbnail. The fine-print disclaimer regions (legal, footnote rows) tighten line-height to 1.3 and shrink type to 1112px so the bulk of fine print stays compact. Dense but not cramped. Table rows at 44px height (WCAG touch target). Card padding minimum 12px. No whitespace used as decoration — every gap serves alignment or grouping. Editorial breathing room (80px sections, hero-scale photography) does not apply here.
## Elevation & Depth ---
| Level | Treatment | Use |
|---|---|---|
| 0 — Flat | No border, no shadow. | Section bands (white, cloud, fog), full-bleed photo heroes |
| 1 — Hairline | 1px solid `{colors.hairline}` (`#e8e8e8`) border, no shadow. | Outlined buttons, comparison-table cells, FAQ accordion outers |
| 2 — Soft Lift | `0 2px 8px rgba(26, 26, 26, 0.08)`. | Product cards, pricing-tier columns, customer-story tiles |
| 3 — Floating Modal | `0 8px 24px rgba(26, 26, 26, 0.12)`. | Add-to-cart drawer, mobile-nav sheet, image zoom modal |
The system is mostly flat — depth is communicated by **color contrast** (cloud-band vs. white card on the same band) rather than shadow elevation. The Soft Lift level is the workhorse for the catalog — every product tile and pricing column gets it; nothing else does. Modal-floating is rare and reserved for transient overlays.
### Decorative Depth
The system's most distinctive depth gesture is the **HP blue chevron pair** — two angular `{colors.primary}` slashes (no radius, no shadow) that sit on the left and right of the homepage hero card and the laptop-shop hero. They're not decorative noise; they're a literal echo of the HP wordmark's two parallel slashes, scaled up to architectural size. Treat them as a brand artifact, not a generic geometric flourish.
Photography on the homepage and laptop-shop pages frames product imagery inside `{rounded.xl}` (16px) containers with a soft 1px hairline. Lifestyle photography (testimonials, "How HP works for X") sits full-bleed inside dark-navy slabs without rounding.
## Shapes ## Shapes
### Border Radius Scale ### Border Radius
| Token | Value | Use | | Token | Value | Use |
|---|---|---| |---|---|---|
| `{rounded.none}` | 0px | Hero chevron decorations, full-bleed photo heroes, marquee strips | | `{rounded.none}` | 0px | **Default for everything**: buttons, cards, modals, inputs, dropdowns, panels, tags, tables |
| `{rounded.xs}` | 2px | Secondary chip backgrounds, sale-tag pills | | `{rounded.pill}` | 9999px | **Status pills only** (`StatusBadge`, `SimulationStatusBadge`) and circular avatars |
| `{rounded.sm}` | 3px | Default secondary CTA radius (small touch zones) |
| `{rounded.md}` | 4px | Primary buttons, secondary buttons, text inputs |
| `{rounded.lg}` | 8px | Badge pills, category-icon cards, FAQ row containers |
| `{rounded.xl}` | 16px | Product cards, pricing tiers, customer-story tiles, photo frames |
| `{rounded.pill}` | 9999px | Category sub-nav tabs, search-pill input, filter chips |
The system maintains a clear two-tier philosophy: **buttons stay sharp** (4px, almost rectilinear) while **cards and photo frames stay soft** (16px). This split is the visual signature — sharp interactive elements against softer container surfaces. No intermediate radius values (`xs`, `sm`, `md`, `lg`, `xl`) are used on visible surfaces. The brutalist rule: if it's not a status pill or avatar, `border-radius: 0`.
### Photography Geometry ---
Hero photography sits in `{rounded.xl}` (16px) frames with no border. Product family thumbnails inside the laptop-grid are 1:1 (square) on a `{colors.canvas}` background, padded so the laptop is shown at ~70% of the frame. Customer-story photography uses 16:9 inside the same `{rounded.xl}` frame. There are no full-bleed circular avatars; testimonial avatars are 4px-rounded squares. ## Elevation
No shadows on interactive surfaces. Elevation is communicated by **border contrast** (1px hairline on paper over canvas) not box-shadow.
| Level | Treatment | Use |
|---|---|---|
| 0 — Flat | No border, no shadow | Page background, full-bleed bands |
| 1 — Hairline | `1px solid {colors.hairline}` | Cards, panels, table cells, input borders |
| 2 — Modal overlay | `rgba(0,0,0,0.6)` backdrop | Modal dialogs — backdrop only, frame stays hairline |
Shadows (`box-shadow`) are not used anywhere.
---
## Components ## Components
> **No hover states documented.** Every component spec below documents only Default and Active/Pressed states. Variants live as separate front-matter entries. > Every component spec below: border-radius **0** unless noted. No `transition-*` on any interactive surface. Hover is instantaneous.
### Buttons ### Buttons
**`button-primary`** — the lone HP Electric Blue CTA **`.btn-primary`** — Electric Blue CTA
- Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.button-md}` (uppercase, 0.7px tracking), padding `{spacing.sm} {spacing.xl}` (12 × 24), height 44px, rounded `{rounded.md}` - Background `{colors.primary}`, text white, uppercase, 14px/600, `h-11`, padding `12px 24px`, `rounded-none`
- Pressed state `button-primary-pressed` background `{colors.primary-deep}`, same text - Hover: background `{colors.primary-deep}` — no transition
- Disabled state `button-primary-disabled` background `{colors.steel}`, white text - Disabled: background `{colors.steel}`, cursor not-allowed
- Used for: "Buy now", "Shop now", "Get a printer", primary form submit
**`button-ink`** — black filled CTA **`.btn-ink`** — Fixed-dark filled CTA
- Background `{colors.ink}`, text `{colors.on-primary}`, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`, type `{typography.button-md}` - Background `{colors.slab}`, text `{colors.slab-text}`, uppercase, same metrics as btn-primary
- Used for: "Buy now" on dark photo overlays, secondary primary actions where the blue would clash with imagery - Used on dark band surfaces where blue would clash
**`button-outline`** — blue-text outlined CTA **`.btn-outline`** — Outlined blue CTA
- Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}` - Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, `rounded-none`
- Used for: "Compare", "Customize", "Learn more" — secondary actions on white surfaces - Hover: background `{colors.primary-soft}`
**`button-outline-ink`** — black-text outlined CTA **`.btn-outline-ink`** — Outlined neutral CTA
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}` - Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, `rounded-none`
- Used for: "View" buttons inside product family card grids — neutral against the blue primary - Hover: background `{colors.cloud}`
**`button-text-link`** — inline blue link with underline **`.btn-text-link`** — Inline text link
- Background `{colors.canvas}`, text `{colors.primary}`, type `{typography.link-md}`, padding `{spacing.xxs} 0` - Text `{colors.primary}`, no background, no border
- Used for: "See details", "Read more" inside cards and disclaimer rows - Hover: underline, no color change
### Cards & Containers
**`card-product`** — the workhorse product tile
- Background `{colors.canvas}`, rounded `{rounded.xl}` (16px), padding `{spacing.xl}` (24px), Soft Lift shadow
- Layout: hero photo (1:1 ratio) on top, title in `{typography.display-xs}`, spec rows in `{typography.caption-md}`, price in `{typography.price-md}`, CTA pinned to bottom
- Used for: laptop catalog cards, desktop catalog cards
**`card-product-feature`** — full-row feature card with photo + copy
- Background `{colors.cloud}`, rounded `{rounded.xl}`, padding `{spacing.xxl}` (32px)
- Layout: photo on the left (50% width), copy on the right with section eyebrow + title + body + CTA pair
- Used for: "Trending laptops" feature rows, "Shop these must haves"
**`card-pricing-tier`** + **`card-pricing-tier-featured`**
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xl}`, Soft Lift shadow
- Tier name in `{typography.display-sm}`, monthly price in `{typography.display-md}` with `{typography.caption-md}` cadence, page count caption, full feature list, primary CTA
- Featured tier carries `{colors.primary}` text accent on the price-stamp + a `{colors.primary}` thin top border instead of a colored card background — never inverted to dark
**`card-customer-story`** — the three-up testimonial tile
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}` (16px), Soft Lift shadow
- 16:9 photo at top in `{rounded.xl}` frame, quote excerpt in `{typography.body-md}`, attribution row at the bottom
- Used in the "See what our customers say" homepage section
**`card-article-tile`** — the four-up "Latest from HP" tile
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}`, Soft Lift shadow
- 16:9 thumbnail at top, date eyebrow in `{typography.caption-sm}`, title in `{typography.body-emphasis}`, "Read more" link
**`card-category-icon`** — the small icon-and-label card in the homepage "Our Products" row
- Background `{colors.canvas}`, rounded `{rounded.lg}` (8px), padding `{spacing.md}`
- 48px icon at top, label in `{typography.body-emphasis}` below
- Used for: Laptops, Desktops, Printers, Computer Tools, Accessories, Enterprise Solutions
**`hero-promo-card`** — the homepage hero card with chevron decorations
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xxl}` (32px)
- Photography occupies left half; copy block (eyebrow + headline + price stamp + CTA pair) occupies right half
- Flanked by `chevron-decoration` blue slashes outside the card's bounding box on left and right edges
**`promo-strip-dark`** — the inline dark navy promo block
- Background `{colors.ink}`, text `{colors.on-ink}`, rounded `{rounded.xl}`, padding `{spacing.xxl} 48px`
- Used for: "When did work start getting in the way of work?" mid-page promo, the SMB testimonial slab
### Inputs & Forms ### Inputs & Forms
**`text-input`** + **`text-input-focused`** **`.text-input`** — Standard text field
- Background `{colors.canvas}`, text `{colors.ink}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 44px - Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.steel}` border, `rounded-none`, `h-11`
- 1px `{colors.steel}` border in default; gains 1px `{colors.ink}` border on focus (no halo) - Focus: border becomes 1px `{colors.primary}`, no halo, no transition
**`text-input-search`** — pill search in the top nav **`textarea.text-input`** — Multiline variant
- Background `{colors.canvas}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 40px, 1px `{colors.steel}` border, magnifying-glass icon at right - Same as text-input, `min-h-[88px]`, height auto
**`badge-pill-ink`** — filled tag pill **`select.text-input`** — Dropdown select
- Background `{colors.ink}`, text `{colors.on-primary}`, rounded `{rounded.lg}`, padding 6px 12px, type `{typography.body-md}` - Same surface as text-input, caret via OS or custom SVG
- Used inline next to product titles to mark "New" or featured indicators
**`badge-pill-outline`** — outlined tag pill ### Cards & Panels
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, rounded `{rounded.lg}`, padding 6px 12px
**`badge-sale-coral`** — the sale price-stamp **`.card-product`** — Standard content card
- Background `{colors.bloom-coral}`, text `{colors.on-primary}`, rounded `{rounded.sm}`, padding `{spacing.xxs} {spacing.xs}`, type `{typography.caption-bold}` - Background `{colors.paper}`, 1px `{colors.hairline}` border, `rounded-none`, padding `{spacing.md}` (16px)
- Used for: "Save $200", "25% off" overlay tags on hero promo cards - No shadow. Dense surfaces use padding `{spacing.sm}` (12px).
**`.modal-backdrop`** — Modal overlay backdrop
- `background-color: rgba(0,0,0,0.6)` — fixed, never themed
- Modal frame: `{colors.paper}` background, 1px `{colors.hairline}` border, `rounded-none`
### Badges & Tags
**`.badge-pill-*`** — Status pills (StatusBadge, SimulationStatusBadge)
- `rounded-pill` (9999px) — THE ONLY rounded surfaces on status badges
- Semantic fill: planned→warn-soft/warn, active→primary-soft/primary, closed→cloud/graphite
- done→success-soft/success, review→warn-soft/warn, pending→cloud/graphite, in-progress→primary-soft/primary
**MITRE technique tags** — NOT pills
- Angular (`rounded-none`), 1px `{colors.hairline}` border, `{colors.cloud}` background, caption-md size
- They are labels, not status signals — no pill shape
### Navigation ### Navigation
**`utility-strip`** — the top-of-page utility bar **`.utility-strip`** — Top utility bar
- Background `{colors.ink}`, text `{colors.on-primary}`, height 36px, padding 0 {spacing.xl}, type `{typography.caption-md}` - Background `{colors.slab}`, text `{colors.slab-text}`, height 36px, `font-mono` for user role/username
- Holds: country/locale picker, "For Business / For Home" toggle, "Sign in" link, cart link
**`nav-bar-top`** — desktop top nav (sits below utility strip) **`.nav-bar-top`** — Main navigation
- Background `{colors.canvas}`, height 64px, padding 0 32px - Background `{colors.slab}` (fixed dark — does not invert), height 56px
- Layout: HP wordmark logo flush left → middle category list (Laptops / Desktops / Printers / Accessories / Solutions / Support) → right slot with Search field, Sign-in link, Cart icon - Active link: 2px `{colors.primary}` bottom border
- 1px `{colors.hairline}` bottom border separates nav from page
**`nav-link`** **`.nav-link`** — Navigation anchor
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, padding `{spacing.xs} {spacing.md}` - Text `{colors.slab-text}`, caption-md, `rounded-none`
- Active page draws a 2px `{colors.primary}` underline below the text baseline - Active state: 2px primary bottom border
**Top Nav (Mobile)** ### Data Tables
- Same height, hamburger icon replaces the middle category list, Search and Cart stay visible
- Drawer expands as a full-canvas sheet with `{typography.body-lg}` link list and a sticky Sign-in CTA at bottom
**`category-tab`** + **`category-tab-active`** — the pill sub-nav - `table-layout: fixed`, `word-break: break-word`
- Default: background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-emphasis}`, rounded `{rounded.pill}`, padding `{spacing.xs} {spacing.lg}` - Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border
- Active: background `{colors.ink}`, text `{colors.on-primary}`, same rounding - Data cells: 44px min-height, 1px `{colors.hairline}` bottom border
- Used on the laptop-shop page for "All / Trending / On Sale" filtering, and on the homepage "How can we help?" closing band - ID / date / technique columns: `font-mono`
- Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide
### Signature Components ### Toast Notifications
**`chevron-decoration`** — the geometric blue slash motif - Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary)
- Background `{colors.primary}`, rounded `{rounded.none}`, no shadow - Background `{colors.paper}`, 1px `{colors.hairline}` border
- Renders as a sharp parallelogram cut at ~60° angle, sized to the height of the hero card it flanks
- Reserved for hero bands and full-page banners — never decorative noise inside cards
**`faq-row`** — the accordion row on the printer-plan FAQ ---
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.lg} {spacing.xl}`, type `{typography.body-emphasis}`
- 1px `{colors.hairline}` divider between rows; chevron-down icon on the right collapsed, chevron-up when expanded
- Body answer renders inside the same row container in `{typography.body-md}` after expansion
**`help-band-dark`** — the closing "How can we help?" prelude band
- Background `{colors.ink}`, text `{colors.on-primary}`, padding 64px {spacing.xl}
- Layout: large lifestyle photograph as the band background (low-opacity) with chip-style category tabs centered: Browse Topics / Live Chat / Contact / Diagnose / Order Status
**`footer-dark`**
- Background `{colors.ink}`, text `{colors.on-primary}`, type `{typography.body-md}`, padding 64px {spacing.xl}
- 5-column link grid (Company / Shop / Support / Resources / Connect) with `{typography.body-emphasis}` headers and `{typography.caption-md}` link rows
- Bottom strip carries social icons, language picker, and legal lines in `{typography.caption-sm}` muted to `{colors.steel}`
## Do's and Don'ts ## Do's and Don'ts
### Do ### Do
- Reserve `{colors.primary}` for the primary CTA, link color, and `chevron-decoration` motif — at most twice per viewport - `rounded-none` on every container, button, input, modal, dropdown, panel, tag
- Set every headline in Forma DJR Micro at weight 500 with line-height 1.0 — resist the urge to bump weight at hero scale - `font-mono` on IDs, ISO dates, MITRE codes, commands, output, metrics
- Use `{rounded.xl}` (16px) for cards and photo frames; `{rounded.md}` (4px) for buttons and inputs — keep the two-tier split sharp - Semantic color for status: success green, warn amber, bloom-deep for errors
- Pair white `{colors.canvas}` body bands with `{colors.cloud}` (`#f7f7f7`) alternating bands; let the gray do the breathing - 1px hairline borders for panel separation — never shadow
- Close every page rhythm with a dark-navy `{colors.ink}` slab — the "How can we help?" prelude + footer - Instant hover (no `transition-*`)
- Set button labels in uppercase with `{typography.button-md}` (0.7px tracking) — the only place the system tracks letters - Sharp focus ring: `outline: 2px solid {colors.primary}; outline-offset: 0`
- Use Soft Lift shadow exclusively for product cards and pricing tiers — leave section bands flat - Keep Inter for all headers, labels, prose, navigation, button text
- Frame product photography inside `{rounded.xl}` containers; never use full-bleed circular masks
### Don't ### Don't
- Don't introduce secondary saturated colors outside `{colors.primary}` family + the `bloom-coral` sale-tag and `storm` printer-plan accents - Don't round containers — not even `2px`. If it's not a status pill or avatar, `rounded-none`
- Don't apply heavy material shadows — depth is via color contrast (cloud vs. white) and Soft Lift only - Don't use `font-mono` for headers, labels, prose, or button text
- Don't round buttons above `{rounded.md}` (4px); a soft 8px+ button reads as a different brand - Don't add `transition-*` on any interactive element
- Don't run Forma DJR Micro below 12px — small caption at 11px is the floor - Don't use shadows — hairline borders only
- Don't use the chevron decoration as inline noise; it is a hero-only architectural element tied to the wordmark - Don't use `{colors.primary}` as a background for sections
- Don't drop ink text opacity to create hierarchy — switch surface or shift to `{colors.charcoal}` / `{colors.graphite}` instead - Don't drop opacity on ink text — use `{colors.charcoal}` or `{colors.graphite}` for hierarchy
- Don't replace the HP wordmark with a generic sans lockup; the wordmark is a custom mark with its own ratio - Don't use animated spinners with rounded tracks — a simple spinning line or text indicator fits the terminal aesthetic better
- Don't apply `rounded-pill` to anything that is not a STATUS BADGE or AVATAR
## Responsive Behavior ---
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Mobile | < 480px | Single-column stack; hamburger nav; section padding drops to ~48px; hero serif scales to ~36px |
| Mobile-Large | 480767px | Same column count; hero scales to ~44px; pricing tiers stack vertically |
| Tablet | 7681023px | 2-column product grid; pricing 2x2; nav still full text labels |
| Desktop | 10241279px | 3-column product grid; 4-column pricing; full nav |
| Desktop-Large | ≥ 1280px | 4-column product grid; 1366px content max-width with full-bleed bands |
### Touch Targets
Every interactive element clears 44×44px on mobile. `button-primary` at 44px height + 24px horizontal padding meets WCAG-AAA touch target. `category-tab` at 8px 20px padding bumps to 12px 24px on touch screens. Nav-link tap areas extend invisibly beyond the text run to the full 44px row height. Sticky cart/sign-in icons in the top nav use 44×44 invisible hit boxes around their visible 24×24 glyph.
### Collapsing Strategy
- **Utility strip**: stays visible on every breakpoint; dropdowns collapse into a single "Account" icon below 768px
- **Top nav**: middle category list collapses into a hamburger drawer below 1024px; the right-side Search + Sign-in + Cart stay visible
- **Hero**: stays single-column at every breakpoint; chevron decorations shrink to ~60% size on tablet and disappear entirely on mobile
- **Product family grid**: 4 → 3 → 2 → 1 column as breakpoints shrink; cards keep `{rounded.xl}` corners at every size
- **Pricing comparison table**: 4-column grid on desktop collapses to 2x2 on tablet, then stacks into individual accordion-style cards on mobile
- **Footer**: 5-column link grid → 2-column tablet → single-column accordion on mobile; HP wordmark stays flush left
### Image Behavior
Hero photography uses `{rounded.xl}` containers at every breakpoint. The chevron decorations vanish on mobile; the underlying photo card centers in the viewport. Lifestyle photography in the testimonial and "how-can-we-help" bands maintains 16:9 ratio with horizontal cropping rather than letterboxing on mobile. There are no art-direction crop swaps between desktop and mobile — the same image is used at every size.
## Iteration Guide ## Iteration Guide
1. Focus on ONE component at a time; resist refactoring an entire section in one pass 1. One component at a time — no section refactors in one pass
2. Reference component names and tokens directly (`{colors.primary}`, `{typography.display-xxl}`, `{rounded.xl}`, `card-product`) — do not paraphrase to hex/px in prose 2. Reference tokens by name (`{colors.primary}`, `font-mono`, `rounded-none`) — not hex/px in prose
3. Run `npx @google/design.md lint DESIGN.md` after edits — `broken-ref`, `contrast-ratio`, and `orphaned-tokens` warnings flag issues automatically 3. When adding a new data field: ask "is this a datum (ID, date, code, metric)?" → `font-mono`. If no → Inter
4. Add new variants as separate component entries (`-pressed`, `-disabled`, `-focused`); never bury state inside prose 4. New status signals: map to existing semantic tokens (success/warn/bloom-deep/primary). No ad-hoc colors
5. Default body to `{typography.body-md}`; reach for `{typography.body-emphasis}` for run-in bolds; keep display sizes for true heading roles 5. For new badge-like elements: pill ONLY if it's a status indicator with semantic color. Otherwise angular tag
6. Keep `{colors.primary}` scarce — at most two flame elements per viewport (one CTA + one chevron decoration). Three flame items in one viewport is over-saturation
7. When introducing a new section band, choose from `{colors.canvas}` / `{colors.cloud}` / `{colors.fog}` / `{colors.ink}` — six pre-defined surface modes is the entire surface vocabulary

66
SPEC.md
View File

@@ -59,8 +59,70 @@ CSV : exactement 1 ligne d'en-tête + 1 ligne par simulation. Markdown : en-têt
Prévoir un module d'authentification : dans un premier temps local à la bdd. 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. Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.
Dans un second temps, après que la V1 soit terminée, nous ajouterons une couche permettant de se connecter à un C2 (mythic ou maison) afin d'exécuter des simulation au travers du C2. Dans un second temps, après que la V1 soit terminée, nous ajouterons une couche permettant de se connecter à un C2 (mythic ou maison) afin d'exécuter des simulation au travers du C2.
## Intégration C2 (Sprint 8+)
Couche d'intégration C2 permettant d'exécuter les commandes d'une simulation à travers un Command & Control distant, suivre l'avancement des tâches en quasi-temps réel, et importer l'historique d'exécutions existant. **Implémentation de référence : Mythic 3.x**, derrière une interface `C2Adapter` mince qui ne ferme pas la porte à un C2 maison ultérieur.
**RBAC C2 = ressource Red Team uniquement** (précédent Templates + Export) : admin et redteam ont accès complet (config + exécution + import). SOC retourne 403 sur tous les endpoints C2 (pas de nav link, pas d'affichage du panneau C2).
**Configuration par engagement** : chaque engagement possède au plus une `c2_config` (URL Mythic + API token + flag `verify_tls`). Le token est **chiffré au repos** via `cryptography.Fernet` ; la clé est dérivée de l'env var `MIMIC_ENCRYPTION_KEY` (variable obligatoire pour activer la fonctionnalité C2 — jamais hardcodée, conforme à la règle OPSEC zero-secret-in-code). Le token n'est jamais renvoyé en clair par l'API — `GET /api/engagements/<id>/c2-config` retourne `has_token: bool` uniquement. Mise à jour via `PUT` ; suppression via `DELETE`. La suppression d'un engagement supprime en cascade sa `c2_config`.
**Sélection d'adapter** via l'env var `MIMIC_C2_ADAPTER` :
- `mythic` (défaut) : adapter Mythic réel (GraphQL via Hasura).
- `fake` : adapter en mémoire déterministe utilisé pour la validation Playwright et le dev local sans instance Mythic.
**Modèle de données — additions** :
`c2_config` (1 ligne par engagement au max) :
| Colonne | Type | Notes |
|---|---|---|
| `id` | int PK | |
| `engagement_id` | int FK `engagements.id` ON DELETE CASCADE, **UNIQUE** | |
| `url` | text | endpoint Mythic, ex. `https://lab.internal:7443` |
| `api_token_encrypted` | text | Fernet ciphertext, jamais en clair |
| `verify_tls` | bool, défaut `true` | `false` autorisé pour labs auto-signés |
| `created_at`, `updated_at` | datetime | |
`c2_task` (lien simulation ↔ tâche Mythic) :
| Colonne | Type | Notes |
|---|---|---|
| `id` | int PK | |
| `simulation_id` | int FK `simulations.id` ON DELETE CASCADE | |
| `mythic_task_display_id` | int | identifiant côté Mythic |
| `callback_display_id` | int | callback Mythic sur lequel la tâche tourne |
| `command` | text | commande envoyée |
| `params` | text nullable | paramètres associés |
| `status` | text | statut brut Mythic (`submitted`, `completed`, `error`, …) |
| `completed` | bool | `true` quand la tâche est terminée |
| `output` | text nullable | sortie décodée (base64 → utf-8 ; binaire → préfixe `<binary>` + hex) |
| `source` | enum `mimic` \| `import` | tâche lancée depuis Mimic ou importée a posteriori |
| `created_at` | datetime | |
| `completed_at` | datetime nullable | timestamp de complétion |
**Endpoints C2** (tous admin+redteam ; SOC = 403) :
- `GET /api/engagements/<id>/c2-config``{has_token, url, verify_tls}` (jamais le token en clair).
- `PUT /api/engagements/<id>/c2-config``{url, api_token?, verify_tls}`.
- `DELETE /api/engagements/<id>/c2-config`.
- `POST /api/engagements/<id>/c2-config/test` — test de connectivité via l'adapter, renvoie `{ok, error?}`.
- `GET /api/engagements/<id>/c2/callbacks` — callbacks actifs de l'instance Mythic configurée.
- `POST /api/simulations/<id>/c2/execute` `{callback_display_id, commands: [str]}` — une tâche Mythic par commande, stockées dans `c2_task` (source=`mimic`). **Auto-transition** : si la simulation est `pending`, elle passe à `in_progress` (même règle que l'édition manuelle RT — cf. § Fonctionnement).
- `GET /api/simulations/<id>/c2/tasks` — poll-on-read : à la lecture, rafraîchit le statut et l'output des `c2_task` non terminées depuis Mythic, applique le mapping de sortie (voir ci-dessous) à la simulation pour chaque tâche qui vient de se terminer (idempotent — appliqué une seule fois par tâche).
- `GET /api/engagements/<id>/c2/callbacks/<cid>/history?page=` — historique paginé des tâches d'un callback, pour l'import.
- `POST /api/simulations/<id>/c2/import` `{task_display_ids: [int]}` — import sélectif de tâches (source=`import`) + mapping de sortie.
**Mapping de sortie vers la simulation** (appliqué une fois par tâche, lors de la complétion ou de l'import) :
- `simulation.execution_result` reçoit en append le bloc `\n$ <command>\n<output>\n` (préserve l'existant, jamais d'écrasement).
- `simulation.executed_at` est renseigné depuis le timestamp de la première tâche complétée si le champ est vide ; sinon non modifié.
- `simulation.commands` reçoit en append la commande si elle n'y figure pas déjà (déduplication ligne par ligne).
**Suivi temps réel** : polling court — le frontend re-fetch `GET /api/simulations/<id>/c2/tasks` toutes les **2 500 ms** via TanStack Query `refetchInterval` tant qu'une tâche attachée n'est pas terminée ; le polling s'arrête automatiquement quand toutes les tâches sont `completed`. Pas d'infrastructure ajoutée côté serveur (pas de WebSocket, pas de scheduler).
**UI** : les contrôles C2 vivent dans la carte Red Team de l'écran simulation — bouton `[Execute via C2]` ouvrant une modale (picker de callback + textarea de commandes pré-remplie depuis `commands`), panneau des tâches attachées sous la carte, et modale d'import historique. Configuration C2 visible/éditable depuis l'écran de détail/édition d'engagement.
**Validation** : MVP entièrement mocké — pytest utilise un adapter mocké (zéro HTTP live), Playwright utilise l'adapter `fake` (déterministe). Le branchement contre une instance Mythic réelle est repoussé au premier usage opérationnel et peut nécessiter un patch mineur du contrat GraphQL.
## Stacks techniques ## Stacks techniques
* **FrontEnd** : WebUI * **FrontEnd** : WebUI
- Stacks standard : ReactJS, Vite, TailWind etc... - Stacks standard : ReactJS, Vite, TailWind etc...

View File

@@ -6,7 +6,15 @@ from pathlib import Path
from flask import Flask, jsonify, send_from_directory from flask import Flask, jsonify, send_from_directory
from backend.app.api import auth_bp, engagements_bp, simulations_bp, templates_bp, users_bp from backend.app.api import (
auth_bp,
c2_bp,
engagements_bp,
sims_c2_bp,
simulations_bp,
templates_bp,
users_bp,
)
from backend.app.cli import register_cli from backend.app.cli import register_cli
from backend.app.config import Config, TestConfig from backend.app.config import Config, TestConfig
from backend.app.errors import register_error_handlers from backend.app.errors import register_error_handlers
@@ -38,6 +46,8 @@ def create_app(config_object: object | None = None) -> Flask:
app.register_blueprint(engagements_bp) app.register_blueprint(engagements_bp)
app.register_blueprint(simulations_bp) app.register_blueprint(simulations_bp)
app.register_blueprint(templates_bp) app.register_blueprint(templates_bp)
app.register_blueprint(c2_bp)
app.register_blueprint(sims_c2_bp)
from backend.app.services import mitre as mitre_svc from backend.app.services import mitre as mitre_svc
mitre_svc.load_bundle() mitre_svc.load_bundle()

View File

@@ -1,8 +1,17 @@
"""API blueprints.""" """API blueprints."""
from backend.app.api.auth import auth_bp from backend.app.api.auth import auth_bp
from backend.app.api.c2 import c2_bp, sims_c2_bp
from backend.app.api.engagements import engagements_bp from backend.app.api.engagements import engagements_bp
from backend.app.api.simulations import simulations_bp from backend.app.api.simulations import simulations_bp
from backend.app.api.templates import templates_bp from backend.app.api.templates import templates_bp
from backend.app.api.users import users_bp from backend.app.api.users import users_bp
__all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp", "templates_bp"] __all__ = [
"auth_bp",
"c2_bp",
"sims_c2_bp",
"users_bp",
"engagements_bp",
"simulations_bp",
"templates_bp",
]

517
backend/app/api/c2.py Normal file
View File

@@ -0,0 +1,517 @@
"""C2 endpoints — config CRUD and execution.
All endpoints:
- Require admin or redteam role (SOC → 403).
- Return 503 when MIMIC_ENCRYPTION_KEY is not set.
- Never include the cleartext API token in any response.
- Adapter errors → 502 with sanitized message (no URL or token in body).
"""
from __future__ import annotations
from datetime import UTC, datetime
from urllib.parse import urlparse
from flask import Blueprint, jsonify, request
from backend.app.auth import role_required
from backend.app.extensions import db
from backend.app.models import Engagement
from backend.app.models.c2_config import C2Config
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.factory import get_adapter
from backend.app.services.c2.mapping import apply_task_to_simulation
from backend.app.services.crypto import C2Disabled, decrypt, encrypt
from backend.app.services.simulation_workflow import promote_to_in_progress
c2_bp = Blueprint("c2", __name__, url_prefix="/api/engagements")
sims_c2_bp = Blueprint("sims_c2", __name__, url_prefix="/api/simulations")
_503_BODY = {"error": "C2 disabled: MIMIC_ENCRYPTION_KEY not set"}
def _crypto_guard():
"""Return a 503 Response when crypto key is absent, else None."""
try:
# Attempt a dummy operation to test key availability.
encrypt("probe")
return None
except C2Disabled:
return jsonify(_503_BODY), 503
@c2_bp.get("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def get_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
return jsonify({
"has_token": bool(cfg.api_token_encrypted),
"url": cfg.url,
"verify_tls": cfg.verify_tls,
}), 200
@c2_bp.put("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def upsert_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
data = request.get_json(silent=True) or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url is required"}), 400
parsed = urlparse(url)
if parsed.scheme != "https":
return jsonify({"error": "url must use https"}), 400
if not parsed.hostname:
return jsonify({"error": "url must contain a hostname"}), 400
verify_tls = data.get("verify_tls", True)
if not isinstance(verify_tls, bool):
return jsonify({"error": "verify_tls must be a boolean"}), 400
cfg: C2Config | None = engagement.c2_config
if cfg is None:
# New row — api_token is required on creation.
raw_token = data.get("api_token") or ""
if not raw_token:
return jsonify({"error": "api_token is required when creating a config"}), 400
encrypted = encrypt(raw_token)
cfg = C2Config(
engagement_id=eid,
url=url,
api_token_encrypted=encrypted,
verify_tls=verify_tls,
)
db.session.add(cfg)
else:
# Update — omitting api_token keeps the existing ciphertext.
cfg.url = url
cfg.verify_tls = verify_tls
cfg.updated_at = datetime.now(UTC)
raw_token = data.get("api_token") or ""
if raw_token:
cfg.api_token_encrypted = encrypt(raw_token)
db.session.commit()
return jsonify({
"has_token": True,
"url": cfg.url,
"verify_tls": cfg.verify_tls,
}), 200
@c2_bp.delete("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def delete_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
db.session.delete(cfg)
db.session.commit()
return "", 204
@c2_bp.post("/<int:eid>/c2-config/test")
@role_required("admin", "redteam")
def test_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
try:
api_token = decrypt(cfg.api_token_encrypted)
except ValueError:
return jsonify({"ok": False, "error": "Stored token is corrupt"}), 200
adapter = get_adapter(
url=cfg.url,
api_token=api_token,
verify_tls=cfg.verify_tls,
)
health = adapter.test_connection()
return jsonify({"ok": health.ok, "error": health.error}), 200
# ---------------------------------------------------------------------------
# M2 — callbacks listing + execute
# ---------------------------------------------------------------------------
def _load_adapter_for_engagement(engagement: Engagement):
"""Decrypt token and return adapter, or return a (response, status) error tuple."""
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return None, (jsonify({"error": "C2 config not found"}), 404)
try:
api_token = decrypt(cfg.api_token_encrypted)
except ValueError:
return None, (jsonify({"error": "Stored token is corrupt"}), 500)
adapter = get_adapter(url=cfg.url, api_token=api_token, verify_tls=cfg.verify_tls)
return adapter, None
@c2_bp.get("/<int:eid>/c2/callbacks")
@role_required("admin", "redteam")
def list_callbacks(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
try:
callbacks = adapter.list_callbacks()
except C2Error as exc:
return jsonify({"error": str(exc)}), 502
return jsonify({
"callbacks": [
{
"display_id": cb.display_id,
"active": cb.active,
"host": cb.host,
"user": cb.user,
"domain": cb.domain,
"last_checkin": cb.last_checkin,
}
for cb in callbacks
]
}), 200
@sims_c2_bp.post("/<int:sid>/c2/execute")
@role_required("admin", "redteam")
def execute_simulation(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
# Done is terminal — block execution.
if sim.status == SimulationStatus.DONE:
return jsonify({"error": "simulation is done — reopen first"}), 409
data = request.get_json(silent=True) or {}
callback_display_id = data.get("callback_display_id")
commands = data.get("commands")
if not isinstance(callback_display_id, int):
return jsonify({"error": "callback_display_id must be an integer"}), 400
if not isinstance(commands, list) or len(commands) == 0:
return jsonify({"error": "commands must be a non-empty list"}), 400
for cmd in commands:
if not isinstance(cmd, str):
return jsonify({"error": "each command must be a string"}), 400
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
created_tasks = []
try:
for command in commands:
mythic_id = adapter.create_task(
callback_display_id=callback_display_id,
command=command,
)
task = C2Task(
simulation_id=sid,
mythic_task_display_id=mythic_id,
callback_display_id=callback_display_id,
command=command,
params=None,
status="submitted",
completed=False,
source=C2TaskSource.MIMIC,
created_at=datetime.now(UTC),
)
db.session.add(task)
created_tasks.append(task)
except C2Error as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 502
# Auto-transition pending → in_progress (no-op for other statuses).
promote_to_in_progress(sim)
db.session.commit()
return jsonify({
"tasks": [
{
"id": t.id,
"mythic_task_display_id": t.mythic_task_display_id,
"command": t.command,
"status": t.status,
"completed": t.completed,
}
for t in created_tasks
]
}), 200
# ---------------------------------------------------------------------------
# M3 — poll-on-read task listing
# ---------------------------------------------------------------------------
@sims_c2_bp.get("/<int:sid>/c2/tasks")
@role_required("admin", "redteam")
def list_simulation_tasks(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
tasks: list[C2Task] = C2Task.query.filter_by(simulation_id=sid).all()
for task in tasks:
if task.completed:
continue
try:
status = adapter.get_task(task.mythic_task_display_id)
except C2Error:
# Best-effort refresh — skip this task if the adapter fails.
continue
task.status = status.status
task.completed = status.completed
if status.completed:
task.completed_at = status.completed_at or datetime.now(UTC)
try:
task.output = adapter.get_task_output(task.mythic_task_display_id)
except C2Error:
task.output = ""
apply_task_to_simulation(task, sim)
db.session.commit()
return jsonify({
"tasks": [
{
"id": t.id,
"mythic_task_display_id": t.mythic_task_display_id,
"callback_display_id": t.callback_display_id,
"command": t.command,
"params": t.params,
"status": t.status,
"completed": t.completed,
"output": t.output,
"source": t.source.value,
"mapping_applied": t.mapping_applied,
"created_at": t.created_at.isoformat() if t.created_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
}
for t in tasks
]
}), 200
# ---------------------------------------------------------------------------
# M4 — callback history + task import
# ---------------------------------------------------------------------------
@c2_bp.get("/<int:eid>/c2/callbacks/<int:cid>/history")
@role_required("admin", "redteam")
def list_callback_history(eid: int, cid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
# Validate pagination params.
try:
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 25))
except (ValueError, TypeError):
return jsonify({"error": "page and page_size must be integers"}), 400
if page < 1 or page_size < 1:
return jsonify({"error": "page and page_size must be >= 1"}), 400
if page_size > 100:
return jsonify({"error": "page_size must be <= 100"}), 400
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
try:
page_result = adapter.list_callback_tasks(
callback_display_id=cid,
page=page,
page_size=page_size,
)
except C2Error as exc:
return jsonify({"error": str(exc)}), 502
return jsonify({
"tasks": [
{
"display_id": t.display_id,
"command": t.command,
"params": t.params,
"status": t.status,
"completed": t.completed,
"timestamp": t.timestamp,
}
for t in page_result.items
],
"total": page_result.total,
"page": page_result.page,
"page_size": page_result.page_size,
}), 200
@sims_c2_bp.post("/<int:sid>/c2/import")
@role_required("admin", "redteam")
def import_tasks(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
if sim.status == SimulationStatus.DONE:
return jsonify({"error": "simulation is done — reopen first"}), 409
data = request.get_json(silent=True) or {}
callback_display_id = data.get("callback_display_id")
task_display_ids = data.get("task_display_ids")
if not isinstance(callback_display_id, int):
return jsonify({"error": "callback_display_id must be an integer"}), 400
if not isinstance(task_display_ids, list) or len(task_display_ids) == 0:
return jsonify({"error": "task_display_ids must be a non-empty list"}), 400
for tid in task_display_ids:
if not isinstance(tid, int):
return jsonify({"error": "each task_display_id must be an integer"}), 400
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
imported_count = 0
skipped_count = 0
try:
for task_display_id in task_display_ids:
# Idempotency: skip if already imported for this simulation.
existing = C2Task.query.filter_by(
simulation_id=sid,
mythic_task_display_id=task_display_id,
).first()
if existing is not None:
skipped_count += 1
continue
status = adapter.get_task(task_display_id)
task = C2Task(
simulation_id=sid,
mythic_task_display_id=task_display_id,
callback_display_id=callback_display_id,
command=status.command or "",
params=None,
status=status.status,
completed=status.completed,
source=C2TaskSource.IMPORT,
created_at=datetime.now(UTC),
mapping_applied=False,
)
if status.completed:
task.completed_at = status.completed_at or datetime.now(UTC)
try:
task.output = adapter.get_task_output(task_display_id)
except C2Error:
task.output = ""
db.session.add(task)
db.session.flush()
apply_task_to_simulation(task, sim)
else:
db.session.add(task)
imported_count += 1
except C2Error as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 502
# Auto-transition pending → in_progress when at least one task was imported.
if imported_count > 0:
promote_to_in_progress(sim)
db.session.commit()
return jsonify({"imported": imported_count, "skipped": skipped_count}), 200

View File

@@ -1,4 +1,6 @@
"""SQLAlchemy models.""" """SQLAlchemy models."""
from backend.app.models.c2_config import C2Config
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.engagement import Engagement, EngagementStatus from backend.app.models.engagement import Engagement, EngagementStatus
from backend.app.models.simulation import Simulation, SimulationStatus from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.models.simulation_template import SimulationTemplate from backend.app.models.simulation_template import SimulationTemplate
@@ -12,4 +14,7 @@ __all__ = [
"Simulation", "Simulation",
"SimulationStatus", "SimulationStatus",
"SimulationTemplate", "SimulationTemplate",
"C2Config",
"C2Task",
"C2TaskSource",
] ]

View File

@@ -0,0 +1,34 @@
"""C2Config model — per-engagement Mythic connection settings."""
from __future__ import annotations
from datetime import UTC, datetime
from backend.app.extensions import db
class C2Config(db.Model): # type: ignore[name-defined]
__tablename__ = "c2_config"
id = db.Column(db.Integer, primary_key=True)
engagement_id = db.Column(
db.Integer,
db.ForeignKey("engagements.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)
url = db.Column(db.Text, nullable=False)
api_token_encrypted = db.Column(db.Text, nullable=False)
verify_tls = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
updated_at = db.Column(db.DateTime, nullable=True)
engagement = db.relationship(
"Engagement",
backref=db.backref("c2_config", uselist=False, cascade="all, delete-orphan"),
)
def __repr__(self) -> str:
return f"<C2Config engagement_id={self.engagement_id}>"

View File

@@ -0,0 +1,48 @@
"""C2Task model — link between a Mimic simulation and a Mythic task."""
from __future__ import annotations
import enum
from datetime import UTC, datetime
from backend.app.extensions import db
class C2TaskSource(str, enum.Enum):
MIMIC = "mimic"
IMPORT = "import"
class C2Task(db.Model): # type: ignore[name-defined]
__tablename__ = "c2_task"
id = db.Column(db.Integer, primary_key=True)
simulation_id = db.Column(
db.Integer,
db.ForeignKey("simulations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
mythic_task_display_id = db.Column(db.Integer, nullable=False)
callback_display_id = db.Column(db.Integer, nullable=False)
command = db.Column(db.Text, nullable=False)
params = db.Column(db.Text, nullable=True)
status = db.Column(db.Text, nullable=False)
completed = db.Column(db.Boolean, nullable=False, default=False)
output = db.Column(db.Text, nullable=True)
source = db.Column(
db.Enum(C2TaskSource, name="c2task_source"),
nullable=False,
)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
completed_at = db.Column(db.DateTime, nullable=True)
mapping_applied = db.Column(db.Boolean, nullable=False, default=False)
simulation = db.relationship(
"Simulation",
backref=db.backref("c2_tasks", cascade="all, delete-orphan", lazy="dynamic"),
)
def __repr__(self) -> str:
return f"<C2Task simulation_id={self.simulation_id} mythic_id={self.mythic_task_display_id}>"

View File

@@ -0,0 +1,22 @@
"""C2 adapter package. Import the factory from here."""
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2TaskPage,
C2TaskStatus,
decode_response_text,
)
from backend.app.services.c2.factory import get_adapter
__all__ = [
"C2Adapter",
"C2Callback",
"C2Error",
"C2Health",
"C2TaskPage",
"C2TaskStatus",
"decode_response_text",
"get_adapter",
]

View File

@@ -0,0 +1,117 @@
"""Abstract C2 adapter interface and shared dataclasses."""
from __future__ import annotations
import base64
import binascii
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
class C2Error(Exception):
"""Raised by adapters when the C2 returns an application-level error."""
@dataclass
class C2Health:
ok: bool
error: str | None = None
@dataclass
class C2Callback:
display_id: int
active: bool
host: str
user: str
domain: str
last_checkin: str # ISO-8601 string
@dataclass
class C2TaskStatus:
display_id: int
status: str
completed: bool
completed_at: datetime | None = field(default=None)
# command_name is populated by get_task() so import doesn't need a second round-trip.
command: str | None = field(default=None)
@dataclass
class C2HistoricalTask:
"""A task entry from callback history (carries command + params, unlike C2TaskStatus)."""
display_id: int
command: str
params: str | None
status: str
completed: bool
timestamp: str | None # ISO-8601 or None
@dataclass
class C2TaskPage:
items: list[C2HistoricalTask]
total: int
page: int
page_size: int
def decode_response_text(raw: str) -> str:
"""Decode a base64-encoded Mythic response_text field.
On binascii.Error (binary payload) returns "<binary> " + hex string
so execution_result never silently corrupts.
"""
try:
return base64.b64decode(raw).decode("utf-8")
except binascii.Error:
return "<binary> " + raw.encode().hex()
except UnicodeDecodeError:
raw_bytes = base64.b64decode(raw)
return "<binary> " + raw_bytes.hex()
class C2Adapter(ABC):
"""Thin interface over a C2 backend (Mythic or custom)."""
@abstractmethod
def test_connection(self) -> C2Health:
"""Verify that the C2 is reachable and the token is valid."""
...
@abstractmethod
def list_callbacks(self) -> list[C2Callback]:
"""Return active callbacks visible to this API token."""
...
@abstractmethod
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
"""Issue a task and return its Mythic display_id."""
...
@abstractmethod
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Return current status of a task."""
...
@abstractmethod
def get_task_output(self, task_display_id: int) -> str:
"""Return decoded, concatenated output for a completed task."""
...
@abstractmethod
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
"""Return a paginated history of tasks for a callback."""
...

View File

@@ -0,0 +1,19 @@
"""Factory that resolves the C2Adapter implementation from MIMIC_C2_ADAPTER env."""
from __future__ import annotations
import os
from backend.app.services.c2.adapter import C2Adapter
def get_adapter(url: str, api_token: str, verify_tls: bool = True) -> C2Adapter:
"""Return the correct C2Adapter based on MIMIC_C2_ADAPTER (default: mythic)."""
adapter_name = os.environ.get("MIMIC_C2_ADAPTER", "mythic").lower()
if adapter_name == "fake":
from backend.app.services.c2.fake import FakeAdapter
return FakeAdapter()
# Default: real Mythic adapter
from backend.app.services.c2.mythic import MythicAdapter
return MythicAdapter(url=url, api_token=api_token, verify_tls=verify_tls)

View File

@@ -0,0 +1,176 @@
"""Deterministic in-memory C2 adapter — used when MIMIC_C2_ADAPTER=fake.
Intended for integration tests and local development without a live Mythic instance.
Task state is per-instance so parallel tests don't interfere with each other.
"""
from __future__ import annotations
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2HistoricalTask,
C2TaskPage,
C2TaskStatus,
)
# Frozen base timestamp — all fake history tasks share this prefix for determinism.
_BASE_TS = "2026-06-10T00:00:00Z"
# Deterministic history for list_callback_tasks:
# callback 1 → 12 tasks, callback 2 → 0 tasks, callback 3 → 5 tasks.
# Commands cycle through a fixed set; even-indexed tasks are completed.
_HISTORY_COMMANDS = ["whoami", "hostname", "id", "ipconfig", "net user", "pwd"]
_FAKE_HISTORY: dict[int, list[C2HistoricalTask]] = {
1: [
C2HistoricalTask(
display_id=100 + i,
command=_HISTORY_COMMANDS[i % len(_HISTORY_COMMANDS)],
params=None,
status="completed" if i % 2 == 0 else "submitted",
completed=i % 2 == 0,
timestamp=_BASE_TS if i % 2 == 0 else None,
)
for i in range(12)
],
2: [],
3: [
C2HistoricalTask(
display_id=200 + i,
command=_HISTORY_COMMANDS[i % len(_HISTORY_COMMANDS)],
params=None,
status="completed" if i % 2 == 0 else "submitted",
completed=i % 2 == 0,
timestamp=_BASE_TS if i % 2 == 0 else None,
)
for i in range(5)
],
}
# Three fixed callbacks the test suite can pin against.
_FAKE_CALLBACKS = [
C2Callback(
display_id=1,
active=True,
host="WORKSTATION-01",
user="jdoe",
domain="LAB",
last_checkin="2026-06-10T00:00:00Z",
),
C2Callback(
display_id=2,
active=True,
host="SERVER-DC01",
user="svc_backup",
domain="LAB",
last_checkin="2026-06-10T00:01:00Z",
),
C2Callback(
display_id=3,
active=True,
host="LAPTOP-RT",
user="admin",
domain="LAB",
last_checkin="2026-06-10T00:02:00Z",
),
]
class FakeAdapter(C2Adapter):
"""In-memory adapter with deterministic behaviour.
Each instance starts with an empty task store and display_ids from 1000.
get_task() state progression per task (keyed by display_id):
- First call after create_task → submitted, completed=False
- Second and subsequent calls → completed=True, status="completed"
"""
def __init__(self) -> None:
self._tasks: dict[int, dict] = {}
self._next_task_id = 1000
# Tracks how many times get_task has been called per display_id.
self._get_task_calls: dict[int, int] = {}
def test_connection(self) -> C2Health:
return C2Health(ok=True)
def list_callbacks(self) -> list[C2Callback]:
return list(_FAKE_CALLBACKS)
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
tid = self._next_task_id
self._next_task_id += 1
self._tasks[tid] = {
"display_id": tid,
"callback_display_id": callback_display_id,
"command": command,
"params": params,
"status": "submitted",
"completed": False,
"output": None,
}
return tid
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Deterministic state progression: first call → submitted, second+ → completed.
Tracks call count regardless of whether the task was created by this instance,
so the endpoint poll-on-read flow works across separate adapter instantiations.
"""
call_count = self._get_task_calls.get(task_display_id, 0) + 1
self._get_task_calls[task_display_id] = call_count
task = self._tasks.get(task_display_id)
if call_count >= 2:
completed = True
status = "completed"
if task is not None:
task["status"] = "completed"
task["completed"] = True
else:
completed = False
status = task["status"] if task is not None else "submitted"
return C2TaskStatus(
display_id=task_display_id,
status=status,
completed=completed,
command=task["command"] if task is not None else None,
)
def get_task_output(self, task_display_id: int) -> str:
"""Returns deterministic output once task is completed; raises C2Error before that."""
# Check call count — completed if get_task was called at least twice.
if self._get_task_calls.get(task_display_id, 0) < 2:
# Also allow tasks in _tasks that were explicitly set to completed.
task = self._tasks.get(task_display_id)
if task is None or not task.get("completed", False):
raise C2Error("task not completed")
task = self._tasks.get(task_display_id)
command = task["command"] if task is not None else "unknown"
return f"output for task {task_display_id}: {command}\n"
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
all_items = _FAKE_HISTORY.get(callback_display_id, [])
start = (page - 1) * page_size
return C2TaskPage(
items=all_items[start : start + page_size],
total=len(all_items),
page=page,
page_size=page_size,
)

View File

@@ -0,0 +1,53 @@
"""C2 task → Simulation output mapping.
apply_task_to_simulation() implements the full §0.11 contract:
1. execution_result — append "$ <command>\n<output>\n" block.
2. executed_at — set from task.completed_at when currently null.
3. commands — append task.command deduplicated line-by-line.
Caller is responsible for committing the session.
"""
from __future__ import annotations
from datetime import UTC, datetime
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation
def apply_task_to_simulation(task: C2Task, simulation: Simulation) -> None:
"""Apply completed task data to simulation fields per §0.11.
Idempotent: no-op when task.mapping_applied is already True.
Always sets mapping_applied = True on exit so the task is never re-processed.
"""
if task.mapping_applied:
return
output = (task.output or "").strip()
# 1) execution_result — "$ <command>\n<output>\n" block, only when output is non-empty.
if output:
block = f"$ {task.command}\n{output}\n"
existing = simulation.execution_result or ""
if existing:
sep = "" if existing.endswith("\n") else "\n"
simulation.execution_result = existing + sep + block
else:
simulation.execution_result = block
# 2) executed_at — set once from the first completed task's timestamp.
if simulation.executed_at is None and task.completed_at is not None:
simulation.executed_at = task.completed_at
# 3) commands — append deduplicated line.
if task.command:
existing_cmds = (simulation.commands or "").splitlines()
if task.command.strip() not in (line.strip() for line in existing_cmds):
if simulation.commands:
simulation.commands = simulation.commands + "\n" + task.command
else:
simulation.commands = task.command
simulation.updated_at = datetime.now(UTC)
task.mapping_applied = True

View File

@@ -0,0 +1,293 @@
# Contract pinned from MythicMeta/Mythic_Scripting master @ 2026-06-10 (raw.githubusercontent.com/MythicMeta/Mythic_Scripting/master/mythic/mythic.py)
"""Mythic 3.x C2 adapter.
Transport: POST https://<host>:7443/graphql
Header: apitoken: <token>
Backend: Hasura-proxied Postgres behind nginx.
M1: test_connection()
M2: list_callbacks(), create_task()
M3: get_task(), get_task_output()
M4: list_callback_tasks()
"""
from __future__ import annotations
from datetime import datetime
import requests
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2HistoricalTask,
C2TaskPage,
C2TaskStatus,
decode_response_text,
)
_HEALTH_QUERY = "{ __typename }"
_CALLBACKS_QUERY = """
query {
callback(order_by: {id: asc}, where: {active: {_eq: true}}) {
id
display_id
active
host
user
domain
last_checkin
}
}
"""
_CREATE_TASK_MUTATION = """
mutation CreateTask($callback_id: Int!, $command: String!, $params: String!) {
createTask(
callback_id: $callback_id,
command: $command,
params: $params,
tasking_location: "command_line"
) {
id
display_id
error
}
}
"""
_GET_TASK_QUERY = """
query GetTask($display_id: Int!) {
task(where: {display_id: {_eq: $display_id}}) {
display_id
command_name
status
completed
timestamp
}
}
"""
_LIST_CALLBACK_TASKS_QUERY = """
query ListCallbackTasks($callback_display_id: Int!, $limit: Int!, $offset: Int!) {
task(
where: {callback: {display_id: {_eq: $callback_display_id}}}
order_by: {id: desc}
limit: $limit
offset: $offset
) {
display_id
command_name
params
status
completed
timestamp
}
}
"""
_COUNT_CALLBACK_TASKS_QUERY = """
query CountCallbackTasks($callback_display_id: Int!) {
task_aggregate(where: {callback: {display_id: {_eq: $callback_display_id}}}) {
aggregate {
count
}
}
}
"""
_GET_TASK_OUTPUT_QUERY = """
query GetTaskOutput($display_id: Int!) {
response(
where: {task: {display_id: {_eq: $display_id}}}
order_by: {id: asc}
) {
response_text
}
}
"""
class MythicAdapter(C2Adapter):
"""Real Mythic 3.x adapter using GraphQL over HTTP."""
def __init__(self, url: str, api_token: str, verify_tls: bool = True) -> None:
self._url = url.rstrip("/") + "/graphql"
self._token = api_token
self._verify = verify_tls
def _headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"apitoken": self._token,
}
def _post(self, body: dict) -> dict:
resp = requests.post(
self._url,
json=body,
headers=self._headers(),
verify=self._verify,
timeout=10,
allow_redirects=False,
)
resp.raise_for_status()
return resp.json()
def test_connection(self) -> C2Health:
"""POST a trivial introspection query to verify reachability and token validity."""
try:
resp = requests.post(
self._url,
json={"query": _HEALTH_QUERY},
headers=self._headers(),
verify=self._verify,
timeout=10,
allow_redirects=False,
)
if resp.status_code == 200:
return C2Health(ok=True)
return C2Health(ok=False, error=f"HTTP {resp.status_code}")
except requests.RequestException as exc:
return C2Health(ok=False, error=str(exc))
def list_callbacks(self) -> list[C2Callback]:
"""Return active callbacks from Mythic (filtered server-side: active=true)."""
try:
data = self._post({"query": _CALLBACKS_QUERY})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
callbacks_raw = data.get("data", {}).get("callback", [])
return [
C2Callback(
display_id=cb["display_id"],
active=cb["active"],
host=cb.get("host") or "",
user=cb.get("user") or "",
domain=cb.get("domain") or "",
last_checkin=cb.get("last_checkin") or "",
)
for cb in callbacks_raw
]
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
"""Issue a task on a callback; return Mythic task display_id."""
try:
data = self._post({
"query": _CREATE_TASK_MUTATION,
"variables": {
"callback_id": callback_display_id,
"command": command,
"params": params or "",
},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
task_data = data.get("data", {}).get("createTask", {})
error_msg = task_data.get("error")
if error_msg:
raise C2Error(error_msg)
return int(task_data["display_id"])
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Return current task status from Mythic."""
try:
data = self._post({
"query": _GET_TASK_QUERY,
"variables": {"display_id": task_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("task", [])
if not rows:
raise C2Error(f"task {task_display_id} not found in Mythic")
row = rows[0]
completed_at: datetime | None = None
if row.get("completed") and row.get("timestamp"):
try:
completed_at = datetime.fromisoformat(
row["timestamp"].replace("Z", "+00:00")
)
except ValueError:
completed_at = None
return C2TaskStatus(
display_id=row["display_id"],
status=row["status"],
completed=bool(row.get("completed", False)),
completed_at=completed_at,
command=row.get("command_name") or None,
)
def get_task_output(self, task_display_id: int) -> str:
"""Return decoded, concatenated output for a task."""
try:
data = self._post({
"query": _GET_TASK_OUTPUT_QUERY,
"variables": {"display_id": task_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("response", [])
return "".join(
decode_response_text(r["response_text"])
for r in rows
if r.get("response_text")
)
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
"""Return a paginated, most-recent-first history of tasks for a callback."""
offset = (page - 1) * page_size
try:
data = self._post({
"query": _LIST_CALLBACK_TASKS_QUERY,
"variables": {
"callback_display_id": callback_display_id,
"limit": page_size,
"offset": offset,
},
})
count_data = self._post({
"query": _COUNT_CALLBACK_TASKS_QUERY,
"variables": {"callback_display_id": callback_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("task", [])
total: int = (
count_data.get("data", {})
.get("task_aggregate", {})
.get("aggregate", {})
.get("count", 0)
)
items = [
C2HistoricalTask(
display_id=r["display_id"],
command=r.get("command_name") or "",
params=r.get("params") or None,
status=r.get("status") or "",
completed=bool(r.get("completed", False)),
timestamp=r.get("timestamp") or None,
)
for r in rows
]
return C2TaskPage(items=items, total=total, page=page, page_size=page_size)

View File

@@ -0,0 +1,40 @@
"""Fernet-based encryption service for sensitive fields.
Key is read from the MIMIC_ENCRYPTION_KEY env var (Fernet base64-urlsafe 32-byte key).
When the key is absent the service raises C2Disabled so callers can return 503.
The key is never logged or returned in any response.
"""
from __future__ import annotations
import os
from cryptography.fernet import Fernet, InvalidToken
class C2Disabled(Exception):
"""Raised when MIMIC_ENCRYPTION_KEY is not set."""
def _get_fernet() -> Fernet:
key = os.environ.get("MIMIC_ENCRYPTION_KEY")
if not key:
raise C2Disabled("C2 disabled: MIMIC_ENCRYPTION_KEY not set")
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt(plaintext: str) -> str:
"""Encrypt *plaintext* and return a Fernet token (str)."""
f = _get_fernet()
return f.encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
"""Decrypt a Fernet token and return the plaintext string."""
f = _get_fernet()
try:
return f.decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise ValueError("Invalid ciphertext") from exc
__all__ = ["C2Disabled", "encrypt", "decrypt"]

View File

@@ -30,7 +30,36 @@ def _creator(obj: object) -> str:
return getattr(cb, "username", "") or "" return getattr(cb, "username", "") or ""
def _format_execution(sim: Simulation) -> str: # ---------------------------------------------------------------------------
# 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 = [ parts = [
sim.executed_at.isoformat() if sim.executed_at else "", sim.executed_at.isoformat() if sim.executed_at else "",
sim.commands or "", sim.commands or "",
@@ -39,6 +68,17 @@ def _format_execution(sim: Simulation) -> str:
return "\n".join(parts) 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 # Markdown
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -91,11 +131,16 @@ def render_engagement_markdown(
lines.append(separator) lines.append(separator)
for sim in simulations: for sim in simulations:
execution = _format_execution(sim).replace("\n", "<br/>")
def _cell(value: str | None) -> str: def _cell(value: str | None) -> str:
return (value or "").replace("|", "\\|").replace("\n", "<br/>") # 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([ row = "| " + " | ".join([
_cell(sim.name), _cell(sim.name),
_cell(sim.description), _cell(sim.description),
@@ -125,24 +170,6 @@ _CSV_HEADERS = [
"Cyber incident", "Cyber incident",
] ]
# \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
def render_engagement_csv( def render_engagement_csv(
_engagement: Engagement, simulations: list[Simulation] _engagement: Engagement, simulations: list[Simulation]
@@ -152,13 +179,13 @@ def render_engagement_csv(
writer.writerow(_CSV_HEADERS) writer.writerow(_CSV_HEADERS)
for sim in simulations: for sim in simulations:
execution = _format_execution(sim) execution = _format_execution_csv(sim)
writer.writerow([ writer.writerow([
_csv_safe(sim.name or ""), _csv_safe(sim.name or ""),
_csv_safe(sim.description or ""), _csv_safe(sim.description or ""),
_csv_safe(sim.log_source or ""), _csv_safe(sim.log_source or ""),
_csv_safe(sim.soc_comment or ""), _csv_safe(sim.soc_comment or ""),
_csv_safe(execution), _csv_safe(execution), # belt-and-braces: outer check covers empty executed_at case
_csv_safe(sim.logs or ""), _csv_safe(sim.logs or ""),
_csv_safe(sim.incident_number or ""), _csv_safe(sim.incident_number or ""),
]) ])
@@ -171,11 +198,12 @@ def render_engagement_csv(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_CSS = """ _CSS = """
body { font-family: sans-serif; font-size: 13px; color: #1a1a1a; margin: 40px; } @page { size: A4 landscape; margin: 20mm; }
h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 6px; } body { font-family: sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
h2 { font-size: 17px; margin-top: 32px; color: #333; } h1 { font-size: 20px; border-bottom: 2px solid #333; padding-bottom: 6px; }
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; } h2 { font-size: 15px; margin-top: 32px; color: #333; }
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; vertical-align: top; white-space: pre-wrap; } 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; } th { background: #e0e0e0; }
.meta { color: #555; margin-bottom: 16px; } .meta { color: #555; margin-bottom: 16px; }
""" """
@@ -217,7 +245,7 @@ def _render_engagement_html(
thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in _HTML_HEADERS) + "</tr></thead>" thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in _HTML_HEADERS) + "</tr></thead>"
parts.append(f"<table>{thead}<tbody>") parts.append(f"<table>{thead}<tbody>")
for sim in simulations: for sim in simulations:
execution_html = h(_format_execution(sim)).replace("\n", "<br/>") execution_html = h(_format_execution_text(sim)).replace("\n", "<br/>")
cells = [ cells = [
h(sim.name or ""), h(sim.name or ""),
h(sim.description or ""), h(sim.description or ""),

View File

@@ -98,6 +98,19 @@ def _maybe_activate_engagement(simulation: Simulation) -> None:
db.session.add(engagement) db.session.add(engagement)
def promote_to_in_progress(simulation: Simulation) -> None:
"""Transition simulation pending → in_progress if it is currently pending.
Also advances the engagement planned → active via _maybe_activate_engagement.
No-op when the simulation is already in any other status.
Caller must commit.
"""
if simulation.status == SimulationStatus.PENDING:
simulation.status = SimulationStatus.IN_PROGRESS
simulation.updated_at = datetime.now(UTC)
_maybe_activate_engagement(simulation)
def apply_patch( def apply_patch(
simulation: Simulation, payload: dict[str, Any], user: User simulation: Simulation, payload: dict[str, Any], user: User
) -> tuple[Any, int] | None: ) -> tuple[Any, int] | None:

View File

@@ -0,0 +1,67 @@
"""create c2_config and c2_task tables
Revision ID: 0006
Revises: 0005
Create Date: 2026-06-10 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"c2_config",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"engagement_id",
sa.Integer(),
sa.ForeignKey("engagements.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("api_token_encrypted", sa.Text(), nullable=False),
sa.Column("verify_tls", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_c2_config_engagement_id", "c2_config", ["engagement_id"])
op.create_table(
"c2_task",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"simulation_id",
sa.Integer(),
sa.ForeignKey("simulations.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("mythic_task_display_id", sa.Integer(), nullable=False),
sa.Column("callback_display_id", sa.Integer(), nullable=False),
sa.Column("command", sa.Text(), nullable=False),
sa.Column("params", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("completed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("output", sa.Text(), nullable=True),
sa.Column(
"source",
sa.Enum("mimic", "import", name="c2task_source"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
)
def downgrade() -> None:
op.drop_table("c2_task")
op.drop_index("ix_c2_config_engagement_id", "c2_config")
op.drop_table("c2_config")
# Remove the enum type (no-op on SQLite, required on Postgres)
sa.Enum(name="c2task_source").drop(op.get_bind(), checkfirst=True)

View File

@@ -0,0 +1,30 @@
"""add mapping_applied column to c2_task
Revision ID: 0007
Revises: 0006
Create Date: 2026-06-10 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("c2_task") as batch_op:
batch_op.add_column(
sa.Column(
"mapping_applied",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table("c2_task") as batch_op:
batch_op.drop_column("mapping_applied")

View File

@@ -4,6 +4,10 @@ Flask-Migrate==4.0.7
PyJWT==2.9.0 PyJWT==2.9.0
argon2-cffi==23.1.0 argon2-cffi==23.1.0
weasyprint>=60.0 weasyprint>=60.0
cryptography==44.0.0
requests==2.32.3
pytest==8.3.3 pytest==8.3.3
ruff==0.6.9 ruff==0.6.9
mypy==1.11.2 mypy==1.11.2
types-requests==2.32.0.20240914
requests-mock==1.12.1

View File

@@ -0,0 +1,30 @@
"""Tests for the FakeAdapter deterministic in-memory implementation."""
from __future__ import annotations
from backend.app.services.c2.adapter import C2Health
from backend.app.services.c2.fake import FakeAdapter
class TestFakeAdapterTestConnection:
def test_returns_ok_true(self):
adapter = FakeAdapter()
health = adapter.test_connection()
assert isinstance(health, C2Health)
assert health.ok is True
assert health.error is None
def test_list_callbacks_returns_list(self):
adapter = FakeAdapter()
callbacks = adapter.list_callbacks()
assert isinstance(callbacks, list)
assert len(callbacks) >= 1
def test_list_callbacks_fields(self):
adapter = FakeAdapter()
cb = adapter.list_callbacks()[0]
assert hasattr(cb, "display_id")
assert hasattr(cb, "active")
assert hasattr(cb, "host")
assert hasattr(cb, "user")
assert hasattr(cb, "domain")
assert hasattr(cb, "last_checkin")

View File

@@ -0,0 +1,62 @@
"""FakeAdapter M2 tests — list_callbacks shape, create_task monotonicity."""
from __future__ import annotations
from backend.app.services.c2.fake import FakeAdapter
class TestFakeAdapterListCallbacks:
def test_returns_three_callbacks(self):
adapter = FakeAdapter()
callbacks = adapter.list_callbacks()
assert len(callbacks) == 3
def test_all_active(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.active is True
def test_display_ids_are_1_2_3(self):
adapter = FakeAdapter()
ids = [cb.display_id for cb in adapter.list_callbacks()]
assert ids == [1, 2, 3]
def test_pinned_last_checkin_format(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.last_checkin.startswith("2026-06-10")
def test_callbacks_have_host_user_domain(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.host
assert cb.user
assert cb.domain
class TestFakeAdapterCreateTask:
def test_returns_monotonic_ids_from_1000(self):
adapter = FakeAdapter()
id1 = adapter.create_task(1, "whoami")
id2 = adapter.create_task(1, "ipconfig")
assert id1 == 1000
assert id2 == 1001
def test_separate_instances_start_at_1000_independently(self):
a1 = FakeAdapter()
a2 = FakeAdapter()
assert a1.create_task(1, "cmd") == 1000
assert a2.create_task(1, "cmd") == 1000
def test_stores_command_and_callback(self):
adapter = FakeAdapter()
tid = adapter.create_task(callback_display_id=2, command="ls", params="-la")
task = adapter._tasks[tid]
assert task["command"] == "ls"
assert task["params"] == "-la"
assert task["callback_display_id"] == 2
def test_initial_status_submitted(self):
adapter = FakeAdapter()
tid = adapter.create_task(1, "hostname")
assert adapter._tasks[tid]["status"] == "submitted"
assert adapter._tasks[tid]["completed"] is False

View File

@@ -0,0 +1,110 @@
"""FakeAdapter M3 state-progression tests — get_task and get_task_output."""
from __future__ import annotations
import pytest
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.fake import FakeAdapter
@pytest.fixture()
def adapter() -> FakeAdapter:
return FakeAdapter()
@pytest.fixture()
def adapter_with_task(adapter: FakeAdapter) -> tuple[FakeAdapter, int]:
tid = adapter.create_task(callback_display_id=1, command="whoami")
return adapter, tid
class TestFakeAdapterGetTaskProgression:
def test_first_call_returns_submitted(self, adapter_with_task):
a, tid = adapter_with_task
status = a.get_task(tid)
assert status.status == "submitted"
assert status.completed is False
def test_second_call_returns_completed(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid) # first call
status = a.get_task(tid) # second call
assert status.status == "completed"
assert status.completed is True
def test_subsequent_calls_stay_completed(self, adapter_with_task):
a, tid = adapter_with_task
for _ in range(5):
a.get_task(tid)
status = a.get_task(tid)
assert status.completed is True
def test_unknown_task_id_returns_submitted_on_first_call(self, adapter):
"""A task ID not created by this instance still goes through submitted→completed."""
status = adapter.get_task(9999)
assert status.display_id == 9999
assert status.status == "submitted"
assert status.completed is False
def test_call_counters_are_per_task(self, adapter):
"""Two tasks have independent state — completing one does not affect the other."""
t1 = adapter.create_task(callback_display_id=1, command="whoami")
t2 = adapter.create_task(callback_display_id=1, command="ipconfig")
# Advance t1 to completed via two calls.
adapter.get_task(t1)
adapter.get_task(t1)
# t2 first call should still be submitted.
s2 = adapter.get_task(t2)
assert s2.status == "submitted"
assert s2.completed is False
def test_instances_are_isolated(self):
"""Per-instance counters — different FakeAdapter instances don't share state."""
a1 = FakeAdapter()
a2 = FakeAdapter()
t1 = a1.create_task(1, "cmd")
t2 = a2.create_task(1, "cmd")
a1.get_task(t1)
a1.get_task(t1) # a1's task is now completed
# a2's task with same display_id (both start at 1000) should be independent.
assert t1 == t2 == 1000
s2 = a2.get_task(t2)
assert s2.status == "submitted"
class TestFakeAdapterGetTaskOutput:
def test_raises_before_completed(self, adapter_with_task):
a, tid = adapter_with_task
with pytest.raises(C2Error, match="task not completed"):
a.get_task_output(tid)
def test_raises_after_first_get_task_call_only(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid) # first call — still submitted
with pytest.raises(C2Error, match="task not completed"):
a.get_task_output(tid)
def test_returns_output_after_completed(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid)
a.get_task(tid) # now completed
output = a.get_task_output(tid)
assert "whoami" in output
assert str(tid) in output
def test_output_format(self, adapter):
tid = adapter.create_task(callback_display_id=2, command="ipconfig /all")
adapter.get_task(tid)
adapter.get_task(tid)
output = adapter.get_task_output(tid)
assert output == f"output for task {tid}: ipconfig /all\n"
def test_unknown_task_raises_c2error(self, adapter):
"""Task ID never created and never polled — not completed → C2Error."""
with pytest.raises(C2Error, match="task not completed"):
adapter.get_task_output(9999)

View File

@@ -0,0 +1,75 @@
"""FakeAdapter M4 tests — list_callback_tasks pagination."""
from __future__ import annotations
import pytest
from backend.app.services.c2.adapter import C2HistoricalTask
from backend.app.services.c2.fake import FakeAdapter
@pytest.fixture()
def adapter() -> FakeAdapter:
return FakeAdapter()
class TestFakeAdapterListCallbackTasks:
def test_callback_1_returns_12_total(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert page.total == 12
def test_callback_2_returns_0_tasks(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=2, page=1, page_size=25)
assert page.total == 0
assert page.items == []
def test_callback_3_returns_5_tasks(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=3, page=1, page_size=25)
assert page.total == 5
assert len(page.items) == 5
def test_items_are_c2_historical_task_instances(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
for item in page.items:
assert isinstance(item, C2HistoricalTask)
def test_pagination_page1(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
assert len(page.items) == 5
assert page.page == 1
assert page.page_size == 5
def test_pagination_page2(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=5)
assert len(page.items) == 5
assert page.page == 2
def test_pagination_last_page_partial(self, adapter):
# 12 tasks, page_size=5 → page 3 has 2 items.
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=5)
assert len(page.items) == 2
assert page.total == 12
def test_pagination_beyond_range_returns_empty(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=99, page_size=25)
assert len(page.items) == 0
assert page.total == 12
def test_history_is_deterministic_across_instances(self):
a1 = FakeAdapter()
a2 = FakeAdapter()
p1 = a1.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
p2 = a2.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert [t.display_id for t in p1.items] == [t.display_id for t in p2.items]
def test_completed_and_submitted_mix(self, adapter):
"""Callback 1 has alternating completed/submitted tasks (even=completed)."""
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=12)
completed = [t for t in page.items if t.completed]
submitted = [t for t in page.items if not t.completed]
assert len(completed) == 6
assert len(submitted) == 6
def test_unknown_callback_returns_empty(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=999, page=1, page_size=25)
assert page.total == 0
assert page.items == []

View File

@@ -0,0 +1,151 @@
"""MythicAdapter unit tests — mocked HTTP with requests-mock."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
class TestMythicAdapterListCallbacks:
def test_returns_callbacks_from_graphql(self, adapter):
payload = {
"data": {
"callback": [
{
"id": 1,
"display_id": 1,
"active": True,
"host": "HOST-01",
"user": "jdoe",
"domain": "LAB",
"last_checkin": "2026-06-10T00:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
callbacks = adapter.list_callbacks()
assert len(callbacks) == 1
assert callbacks[0].display_id == 1
assert callbacks[0].host == "HOST-01"
assert callbacks[0].user == "jdoe"
def test_sends_apitoken_header(self, adapter):
payload = {"data": {"callback": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.list_callbacks()
sent_headers = m.last_request.headers
assert sent_headers.get("apitoken") == _TOKEN
def test_verify_tls_flag_passed(self):
"""Adapter with verify_tls=True should pass verify=True to requests."""
adapter_tls = MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=True)
payload = {"data": {"callback": []}}
# requests-mock intercepts before TLS — just confirm no error path triggered.
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
callbacks = adapter_tls.list_callbacks()
assert isinstance(callbacks, list)
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("connection refused"))
with pytest.raises(C2Error):
adapter.list_callbacks()
def test_http_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=500, text="Internal Server Error")
with pytest.raises(C2Error):
adapter.list_callbacks()
class TestMythicAdapterCreateTask:
def test_returns_display_id_on_success(self, adapter):
payload = {
"data": {
"createTask": {
"id": 42,
"display_id": 7,
"error": None,
}
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
tid = adapter.create_task(callback_display_id=1, command="whoami")
assert tid == 7
def test_sends_apitoken_header(self, adapter):
payload = {"data": {"createTask": {"id": 1, "display_id": 1, "error": None}}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.create_task(1, "cmd")
sent_headers = m.last_request.headers
assert sent_headers.get("apitoken") == _TOKEN
def test_error_field_raises_c2error(self, adapter):
payload = {
"data": {
"createTask": {
"id": None,
"display_id": None,
"error": "callback not found",
}
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
with pytest.raises(C2Error, match="callback not found"):
adapter.create_task(1, "whoami")
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
with pytest.raises(C2Error):
adapter.create_task(1, "whoami")
class TestMythicAdapterErrorSanitization:
def test_connection_error_message_does_not_contain_url(self, adapter):
"""C2Error message must not expose the configured Mythic URL."""
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError(
f"HTTPSConnectionPool(host='{_BASE_URL}', port=7443): Max retries exceeded"
))
with pytest.raises(C2Error) as exc_info:
adapter.list_callbacks()
assert _BASE_URL not in str(exc_info.value)
assert "ConnectionError" in str(exc_info.value)
class TestMythicAdapterNoRedirects:
def test_does_not_follow_redirect(self, adapter):
"""Adapter must not follow HTTP redirects (allow_redirects=False)."""
with rm_module.Mocker() as m:
# Simulate a redirect response; requests-mock won't auto-follow it.
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/graphql"})
# With allow_redirects=False the 301 is treated as a non-2xx → raise_for_status raises.
with pytest.raises(C2Error):
adapter.list_callbacks()
# Exactly one request was made — no follow-up to Location.
assert len(m.request_history) == 1

View File

@@ -0,0 +1,188 @@
"""MythicAdapter M3 tests — get_task and get_task_output, mocked HTTP."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
class TestMythicAdapterGetTask:
def test_returns_status_for_incomplete_task(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"status": "processing",
"completed": False,
"timestamp": None,
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.display_id == 7
assert status.status == "processing"
assert status.completed is False
assert status.completed_at is None
def test_returns_completed_at_for_completed_task(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.completed is True
assert status.completed_at is not None
assert status.completed_at.year == 2026
def test_raises_when_task_not_found(self, adapter):
payload = {"data": {"task": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
with pytest.raises(C2Error, match="not found"):
adapter.get_task(999)
def test_sends_apitoken_header(self, adapter):
payload = {
"data": {
"task": [
{"display_id": 1, "status": "submitted", "completed": False, "timestamp": None}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.get_task(1)
assert m.last_request.headers.get("apitoken") == _TOKEN
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
with pytest.raises(C2Error):
adapter.get_task(1)
def test_no_redirect_followed(self, adapter):
"""get_task must not follow HTTP redirects."""
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.get_task(1)
assert len(m.request_history) == 1
def test_invalid_timestamp_does_not_crash(self, adapter):
"""A malformed timestamp field falls back to completed_at=None without raising."""
payload = {
"data": {
"task": [
{
"display_id": 5,
"status": "completed",
"completed": True,
"timestamp": "not-a-date",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(5)
assert status.completed is True
assert status.completed_at is None
class TestMythicAdapterGetTaskOutput:
def test_returns_decoded_output(self, adapter):
import base64
encoded = base64.b64encode(b"Administrator\r\n").decode()
payload = {
"data": {
"response": [{"response_text": encoded}]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert "Administrator" in output
def test_concatenates_multiple_responses(self, adapter):
import base64
r1 = base64.b64encode(b"line one\n").decode()
r2 = base64.b64encode(b"line two\n").decode()
payload = {
"data": {
"response": [{"response_text": r1}, {"response_text": r2}]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert "line one" in output
assert "line two" in output
def test_returns_empty_string_when_no_responses(self, adapter):
payload = {"data": {"response": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert output == ""
def test_skips_empty_response_text(self, adapter):
import base64
encoded = base64.b64encode(b"real output").decode()
payload = {
"data": {
"response": [
{"response_text": ""},
{"response_text": encoded},
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert output == "real output"
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
with pytest.raises(C2Error):
adapter.get_task_output(7)
def test_no_redirect_followed(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=302, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.get_task_output(1)
assert len(m.request_history) == 1

View File

@@ -0,0 +1,167 @@
"""MythicAdapter M4 tests — list_callback_tasks, mocked HTTP."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error, C2HistoricalTask
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
def _task_list_payload(tasks: list[dict]) -> dict:
return {"data": {"task": tasks}}
def _count_payload(count: int) -> dict:
return {"data": {"task_aggregate": {"aggregate": {"count": count}}}}
class TestMythicAdapterListCallbackTasks:
def test_returns_tasks_from_graphql(self, adapter):
tasks_payload = _task_list_payload([
{
"display_id": 7,
"command_name": "whoami",
"params": "",
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
])
count_payload = _count_payload(1)
with rm_module.Mocker() as m:
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert page.total == 1
assert len(page.items) == 1
item = page.items[0]
assert isinstance(item, C2HistoricalTask)
assert item.display_id == 7
assert item.command == "whoami"
assert item.completed is True
def test_pagination_offset_calculation(self, adapter):
"""page=2, page_size=10 → offset=10 must be sent to Mythic."""
tasks_payload = _task_list_payload([])
count_payload = _count_payload(0)
with rm_module.Mocker() as m:
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=10)
# First request is the task list; check variables.
first_body = m.request_history[0].json()
variables = first_body.get("variables", {})
assert variables.get("offset") == 10
assert variables.get("limit") == 10
def test_sends_apitoken_header(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(0)},
])
adapter.list_callback_tasks(callback_display_id=1)
for req in m.request_history:
assert req.headers.get("apitoken") == _TOKEN
def test_empty_task_list(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(0)},
])
page = adapter.list_callback_tasks(callback_display_id=1)
assert page.total == 0
assert page.items == []
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
def test_http_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=500, text="error")
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
def test_no_redirect_followed(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
# Both requests (tasks + count) should each only make one attempt.
for req in m.request_history:
assert req.method == "POST"
def test_page_and_page_size_in_response(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(50)},
])
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=10)
assert page.page == 3
assert page.page_size == 10
assert page.total == 50
class TestMythicAdapterGetTaskCommandField:
"""Ensure command_name is surfaced via get_task() C2TaskStatus.command."""
def test_get_task_returns_command(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"command_name": "shell",
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.command == "shell"
def test_get_task_command_none_when_missing(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"command_name": None,
"status": "submitted",
"completed": False,
"timestamp": None,
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.command is None

View File

@@ -0,0 +1,142 @@
"""Tests for GET /api/engagements/<id>/c2/callbacks."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask.testing import FlaskClient
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
class TestGetCallbacksHappyPath:
def test_returns_3_callbacks_with_fake_adapter(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 200
body = resp.get_json()
assert "callbacks" in body
assert len(body["callbacks"]) == 3
def test_callback_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
cb = resp.get_json()["callbacks"][0]
assert "display_id" in cb
assert "active" in cb
assert "host" in cb
assert "user" in cb
assert "domain" in cb
assert "last_checkin" in cb
def test_redteam_allowed(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(redteam_token),
)
assert resp.status_code == 200
class TestGetCallbacksErrorCases:
def test_404_when_no_config(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 404
def test_404_engagement_not_found(self, client: FlaskClient, admin_token: str) -> None:
resp = client.get(
"/api/engagements/9999/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 404
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(soc_token),
)
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 503
def test_502_when_adapter_raises(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self):
raise C2Error("mythic unreachable")
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callbacks", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 502
assert "mythic unreachable" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,367 @@
"""Tests for C2 config CRUD endpoints.
Covers:
- GET 404 when no config exists
- PUT create (api_token required)
- PUT update with omitted token keeps old ciphertext
- GET 200 returns has_token=True, never cleartext
- DELETE 204
- Cascade delete when engagement is deleted
- RBAC: admin OK / redteam OK / SOC 403 on all 4 endpoints
- 503 guard when MIMIC_ENCRYPTION_KEY is unset
- POST /test with fake adapter
"""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.models.c2_config import C2Config
from backend.tests.conftest import auth_headers as _h
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
"""Default: key is present. Individual tests can override."""
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _put_config(
client: FlaskClient,
token: str,
eid: int,
*,
url: str = "https://c2.internal:7443",
api_token: str | None = "s3cr3t",
verify_tls: bool = True,
) -> dict:
payload: dict = {"url": url, "verify_tls": verify_tls}
if api_token is not None:
payload["api_token"] = api_token
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json=payload,
)
return resp
# ---------------------------------------------------------------------------
# GET — 404 when no config
# ---------------------------------------------------------------------------
def test_get_config_not_found(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.status_code == 404
def test_get_config_engagement_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.get("/api/engagements/9999/c2-config", headers=_h(admin_token))
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PUT — create
# ---------------------------------------------------------------------------
def test_put_rejects_http_scheme(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"], url="http://c2.internal:7443")
assert resp.status_code == 400
assert "https" in resp.get_json().get("error", "").lower()
def test_put_rejects_missing_hostname(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
# urlparse("https://:7443") produces an empty hostname
resp = _put_config(client, admin_token, eng["id"], url="https://:7443")
assert resp.status_code == 400
assert "hostname" in resp.get_json().get("error", "").lower()
def test_put_creates_config(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"])
assert resp.status_code == 200
body = resp.get_json()
assert body["has_token"] is True
assert body["url"] == "https://c2.internal:7443"
assert body["verify_tls"] is True
def test_put_create_requires_api_token(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"], api_token=None)
assert resp.status_code == 400
def test_put_create_requires_url(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.put(
f"/api/engagements/{eng['id']}/c2-config",
headers=_h(admin_token),
json={"api_token": "tok", "verify_tls": True},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# PUT — update, omitting api_token preserves old ciphertext
# ---------------------------------------------------------------------------
def test_put_update_omits_token_keeps_old(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="original-token")
# Read ciphertext from DB before update.
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
old_cipher = cfg.api_token_encrypted
# Update URL, omit api_token.
resp = _put_config(
client, admin_token, eng["id"],
url="https://new.internal:7443", api_token=None,
)
assert resp.status_code == 200
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
assert cfg.api_token_encrypted == old_cipher
assert cfg.url == "https://new.internal:7443"
def test_put_update_with_token_replaces_ciphertext(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="original-token")
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
old_cipher = cfg.api_token_encrypted
_put_config(client, admin_token, eng["id"], api_token="new-token")
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
assert cfg.api_token_encrypted != old_cipher
# ---------------------------------------------------------------------------
# GET — 200, has_token=True, never cleartext
# ---------------------------------------------------------------------------
def test_get_config_returns_has_token_not_cleartext(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="s3cr3t")
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.status_code == 200
body = resp.get_json()
assert body["has_token"] is True
assert "api_token" not in body
assert "api_token_encrypted" not in body
assert "s3cr3t" not in str(body)
def test_get_config_verify_tls_default_true(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.get_json()["verify_tls"] is True
# ---------------------------------------------------------------------------
# DELETE — 204
# ---------------------------------------------------------------------------
def test_delete_config_204(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.delete(
f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)
)
assert resp.status_code == 204
# Subsequent GET returns 404.
resp2 = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp2.status_code == 404
def test_delete_config_not_found(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.delete(
f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# CASCADE — delete engagement removes config
# ---------------------------------------------------------------------------
def test_cascade_delete_engagement_removes_config(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
with app.app_context():
assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 1
client.delete(f"/api/engagements/{eng['id']}", headers=_h(admin_token))
with app.app_context():
assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 0
# ---------------------------------------------------------------------------
# RBAC matrix
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("PUT", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_soc_gets_403(
client: FlaskClient, admin_token: str, soc_token: str,
method: str, path_suffix: str,
) -> None:
eng = _make_engagement(client, admin_token)
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(soc_token), json={})
assert resp.status_code == 403
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_redteam_gets_allowed(
client: FlaskClient, admin_token: str, redteam_token: str,
method: str, path_suffix: str,
) -> None:
eng = _make_engagement(client, admin_token)
# Ensure config exists for GET/DELETE/test.
_put_config(client, admin_token, eng["id"])
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(redteam_token), json={})
# Not 403 and not 401.
assert resp.status_code not in (401, 403)
def test_redteam_can_put_config(
client: FlaskClient, admin_token: str, redteam_token: str,
) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, redteam_token, eng["id"])
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# 503 guard when MIMIC_ENCRYPTION_KEY is unset
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("PUT", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_503_when_key_unset(
monkeypatch,
client: FlaskClient,
admin_token: str,
method: str,
path_suffix: str,
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(admin_token), json={
"url": "https://c2", "api_token": "tok", "verify_tls": True,
})
assert resp.status_code == 503
assert "MIMIC_ENCRYPTION_KEY" in resp.get_json().get("error", "")
# ---------------------------------------------------------------------------
# POST /test — connectivity check via fake adapter
# ---------------------------------------------------------------------------
def test_post_test_returns_ok_true(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.post(
f"/api/engagements/{eng['id']}/c2-config/test",
headers=_h(admin_token),
json={},
)
assert resp.status_code == 200
body = resp.get_json()
assert body["ok"] is True
assert body["error"] is None
def test_post_test_no_config_returns_404(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.post(
f"/api/engagements/{eng['id']}/c2-config/test",
headers=_h(admin_token),
json={},
)
assert resp.status_code == 404

View File

@@ -0,0 +1,324 @@
"""Tests for POST /api/simulations/<id>/c2/execute."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _execute(
client: FlaskClient,
token: str,
sid: int,
commands: list,
callback_display_id: int = 1,
):
return client.post(
f"/api/simulations/{sid}/c2/execute",
headers=_h(token),
json={"callback_display_id": callback_display_id, "commands": commands},
)
def _advance_to_in_progress(client: FlaskClient, token: str, sid: int) -> None:
client.patch(
f"/api/simulations/{sid}",
headers=_h(token),
json={"name": "Sim Alpha"},
)
def _advance_to_review_required(client: FlaskClient, token: str, sid: int) -> None:
_advance_to_in_progress(client, token, sid)
client.post(
f"/api/simulations/{sid}/transition",
headers=_h(token),
json={"to": "review_required"},
)
def _advance_to_done(client: FlaskClient, redteam_token: str, soc_token: str, sid: int) -> None:
_advance_to_review_required(client, redteam_token, sid)
client.post(
f"/api/simulations/{sid}/transition",
headers=_h(soc_token),
json={"to": "done"},
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestExecuteHappyPath:
def test_two_commands_create_two_tasks(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami", "ipconfig"])
assert resp.status_code == 200
body = resp.get_json()
assert len(body["tasks"]) == 2
assert body["tasks"][0]["command"] == "whoami"
assert body["tasks"][1]["command"] == "ipconfig"
with app.app_context():
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
assert len(rows) == 2
def test_task_response_shape(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["hostname"])
task = resp.get_json()["tasks"][0]
assert "id" in task
assert "mythic_task_display_id" in task
assert "command" in task
assert "status" in task
assert "completed" in task
def test_pending_sim_transitions_to_in_progress(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
assert sim["status"] == "pending"
_execute(client, admin_token, sim["id"], ["whoami"])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_already_in_progress_stays_in_progress(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_in_progress(client, admin_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 200
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_review_required_sim_still_allowed(
self, app: Flask, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_review_required(client, admin_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["net use"])
assert resp.status_code == 200
# Status stays review_required — no regression to in_progress.
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.REVIEW_REQUIRED
def test_redteam_can_execute(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, redteam_token, sim["id"], ["whoami"])
assert resp.status_code == 200
def test_mythic_task_display_id_stored(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mythic_task_display_id == 1000 # FakeAdapter starts at 1000
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
class TestExecuteValidation:
def test_400_empty_commands(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], [])
assert resp.status_code == 400
def test_400_non_string_command(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/execute",
headers=_h(admin_token),
json={"callback_display_id": 1, "commands": [42]},
)
assert resp.status_code == 400
def test_400_missing_callback_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/execute",
headers=_h(admin_token),
json={"commands": ["whoami"]},
)
assert resp.status_code == 400
def test_409_done_sim(
self,
client: FlaskClient,
admin_token: str,
soc_token: str,
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_done(client, admin_token, soc_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 409
assert "done" in resp.get_json().get("error", "").lower()
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _execute(client, admin_token, 9999, ["whoami"])
assert resp.status_code == 404
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 404
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, soc_token, sim["id"], ["whoami"])
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 503
def test_502_adapter_error(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, callback_display_id, command, params=None):
raise C2Error("task queue full")
monkeypatch.setattr(fake_mod.FakeAdapter, "create_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 502
assert "task queue full" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,215 @@
"""Tests for GET /api/engagements/<id>/c2/callbacks/<cid>/history."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask.testing import FlaskClient
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _history(client: FlaskClient, token: str, eid: int, cid: int, **params):
return client.get(
f"/api/engagements/{eid}/c2/callbacks/{cid}/history",
headers=_h(token),
query_string=params,
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestHistoryHappyPath:
def test_returns_200(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 200
def test_response_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
body = resp.get_json()
assert "tasks" in body
assert "total" in body
assert "page" in body
assert "page_size" in body
def test_task_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
task = resp.get_json()["tasks"][0]
for field in ("display_id", "command", "params", "status", "completed", "timestamp"):
assert field in task, f"missing field: {field}"
def test_default_page_is_1(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["page"] == 1
def test_default_page_size_is_25(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["page_size"] == 25
def test_callback_1_has_12_total(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["total"] == 12
def test_callback_2_has_0_tasks(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 2)
body = resp.get_json()
assert body["total"] == 0
assert body["tasks"] == []
def test_pagination_page_size_applied(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=1, page_size=5)
body = resp.get_json()
assert len(body["tasks"]) == 5
assert body["page_size"] == 5
def test_redteam_can_view_history(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, redteam_token, eng["id"], 1)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestHistoryValidation:
def test_400_page_size_too_large(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=101)
assert resp.status_code == 400
def test_400_page_zero(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=0)
assert resp.status_code == 400
def test_400_page_size_zero(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=0)
assert resp.status_code == 400
def test_400_page_negative(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=-1)
assert resp.status_code == 400
def test_400_page_size_100_is_ok(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=100)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Authorization / error cases
# ---------------------------------------------------------------------------
class TestHistoryErrors:
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, soc_token, eng["id"], 1)
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 503
def test_404_engagement_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _history(client, admin_token, 9999, 1)
assert resp.status_code == 404
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 404
def test_502_adapter_error(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, callback_display_id, page=1, page_size=25):
raise C2Error("upstream error")
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callback_tasks", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 502
assert "upstream error" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,437 @@
"""Tests for POST /api/simulations/<id>/c2/import."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _import(client: FlaskClient, token: str, sid: int, task_display_ids: list, callback_display_id: int = 1):
return client.post(
f"/api/simulations/{sid}/c2/import",
headers=_h(token),
json={"callback_display_id": callback_display_id, "task_display_ids": task_display_ids},
)
def _make_completed_get_task(monkeypatch, command: str = "whoami"):
"""Patch FakeAdapter.get_task to return completed=True with a command."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
command=command,
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
def _output(self, task_display_id: int) -> str:
return f"output for {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
def _advance_to_review_required(client, token, sid):
client.patch(f"/api/simulations/{sid}", headers=_h(token), json={"name": "Sim Alpha"})
client.post(f"/api/simulations/{sid}/transition", headers=_h(token), json={"to": "review_required"})
def _advance_to_done(client, admin_token, soc_token, sid):
_advance_to_review_required(client, admin_token, sid)
client.post(f"/api/simulations/{sid}/transition", headers=_h(soc_token), json={"to": "done"})
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestImportHappyPath:
def test_imports_two_completed_tasks(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch, command="whoami")
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100, 101])
assert resp.status_code == 200
body = resp.get_json()
assert body["imported"] == 2
assert body["skipped"] == 0
with app.app_context():
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
assert len(rows) == 2
def test_imported_tasks_have_source_import(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.source == C2TaskSource.IMPORT
def test_completed_tasks_get_mapping_applied(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mapping_applied is True
def test_idempotent_import_counts_skipped(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
# First import.
_import(client, admin_token, sim["id"], [100, 101])
# Second import with one overlap.
resp = _import(client, admin_token, sim["id"], [100, 102])
body = resp.get_json()
assert body["imported"] == 1
assert body["skipped"] == 1
def test_auto_transition_pending_to_in_progress(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
assert sim["status"] == "pending"
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_no_transition_when_already_in_progress(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
# Advance to in_progress manually.
client.patch(
f"/api/simulations/{sim['id']}",
headers=_h(admin_token),
json={"name": "Sim Alpha"},
)
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_no_transition_when_review_required(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_review_required(client, admin_token, sim["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.REVIEW_REQUIRED
def test_incomplete_task_stored_without_mapping(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""An incomplete task is stored as-is; mapping_applied stays False."""
from backend.app.services.c2 import fake as fake_mod
def _submitted(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="submitted",
completed=False,
command="shell",
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _submitted)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [200])
assert resp.status_code == 200
assert resp.get_json()["imported"] == 1
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.completed is False
assert task.mapping_applied is False
assert task.output is None
def test_command_stored_from_get_task(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Command field on the stored row comes from adapter.get_task().command."""
_make_completed_get_task(monkeypatch, command="net user /domain")
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.command == "net user /domain"
def test_redteam_can_import(
self, monkeypatch, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, redteam_token, sim["id"], [100])
assert resp.status_code == 200
def test_source_field_is_import_in_tasks_listing(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Imported tasks appear with source='import' in GET /c2/tasks response."""
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
resp = client.get(
f"/api/simulations/{sim['id']}/c2/tasks",
headers=_h(admin_token),
)
assert resp.status_code == 200
task = resp.get_json()["tasks"][0]
assert task["source"] == "import"
def test_no_transition_when_all_skipped(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""If imported=0 (all skipped), do not transition pending→in_progress."""
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100]) # first import
_import(client, admin_token, sim["id"], []) # empty — should 400 before this matters
# Reset to pending state via a fresh sim (can't undo, just verify the 0-skipped case).
# We test: importing same task again = skipped=1, imported=0 → no double-transition.
resp = _import(client, admin_token, sim["id"], [100])
body = resp.get_json()
assert body["imported"] == 0
assert body["skipped"] == 1
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestImportValidation:
def test_400_empty_task_display_ids(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [])
assert resp.status_code == 400
def test_400_non_int_task_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/import",
headers=_h(admin_token),
json={"callback_display_id": 1, "task_display_ids": ["not-an-int"]},
)
assert resp.status_code == 400
def test_400_missing_callback_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/import",
headers=_h(admin_token),
json={"task_display_ids": [100]},
)
assert resp.status_code == 400
def test_409_done_simulation(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_done(client, admin_token, soc_token, sim["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 409
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _import(client, admin_token, 9999, [100])
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Authorization / error cases
# ---------------------------------------------------------------------------
class TestImportErrors:
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, soc_token, sim["id"], [100])
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 503
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 404
def test_502_adapter_error_on_get_task(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, task_display_id: int) -> C2TaskStatus:
raise C2Error("Mythic unreachable")
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 502
assert "Mythic unreachable" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,208 @@
"""Unit tests for apply_task_to_simulation() mapping helper — §0.11 contract."""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock
from backend.app.services.c2.mapping import apply_task_to_simulation
def _make_task(
command: str = "whoami",
output: str | None = "root",
mapping_applied: bool = False,
completed_at: datetime | None = None,
) -> MagicMock:
task = MagicMock()
task.command = command
task.output = output
task.mapping_applied = mapping_applied
task.completed_at = completed_at
return task
def _make_sim(
execution_result: str | None = None,
executed_at: datetime | None = None,
commands: str | None = None,
) -> MagicMock:
sim = MagicMock()
sim.execution_result = execution_result
sim.executed_at = executed_at
sim.commands = commands
sim.updated_at = None
return sim
class TestExecutionResult:
def test_first_task_produces_command_block(self):
task = _make_task(command="whoami", output="root")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ whoami\nroot\n"
def test_second_task_appended_with_block_separator(self):
"""Two tasks → two '$ command\noutput\n' blocks separated by a single newline."""
sim = _make_sim()
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.execution_result == "$ whoami\nroot\n$ hostname\nlab-1\n"
def test_no_double_blank_line_when_existing_ends_with_newline(self):
"""If existing result already ends with \n, no extra blank line is inserted."""
sim = _make_sim(execution_result="$ id\nuid=0\n")
task = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ id\nuid=0\n$ hostname\nlab-1\n"
def test_empty_output_skips_block_but_marks_applied(self):
task = _make_task(output="")
sim = _make_sim(execution_result="$ id\nuid=0\n")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ id\nuid=0\n"
assert task.mapping_applied is True
def test_none_output_skips_block_but_marks_applied(self):
task = _make_task(output=None)
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result is None
assert task.mapping_applied is True
def test_command_with_empty_string_produces_dollar_header(self):
"""Empty command → block header is '$ \n<output>\n' (consistent, not suppressed)."""
task = _make_task(command="", output="some output")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ \nsome output\n" or sim.execution_result == "$ \nsome output\n"
class TestExecutedAt:
def test_sets_executed_at_from_task_when_null(self):
ts = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC)
task = _make_task(completed_at=ts)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(task, sim)
assert sim.executed_at == ts
def test_does_not_overwrite_existing_executed_at(self):
original_ts = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC)
later_ts = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC)
task = _make_task(completed_at=later_ts)
sim = _make_sim(executed_at=original_ts)
apply_task_to_simulation(task, sim)
assert sim.executed_at == original_ts
def test_executed_at_stays_null_when_task_completed_at_is_none(self):
task = _make_task(completed_at=None)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(task, sim)
assert sim.executed_at is None
def test_first_task_sets_executed_at_second_does_not_overwrite(self):
ts1 = datetime(2026, 6, 10, 10, 0, 0, tzinfo=UTC)
ts2 = datetime(2026, 6, 10, 11, 0, 0, tzinfo=UTC)
t1 = _make_task(command="whoami", output="root", completed_at=ts1)
t2 = _make_task(command="hostname", output="lab-1", completed_at=ts2)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.executed_at == ts1
class TestCommandsDedup:
def test_appends_command_to_empty_commands(self):
task = _make_task(command="whoami", output="root")
sim = _make_sim(commands=None)
apply_task_to_simulation(task, sim)
assert sim.commands == "whoami"
def test_appends_second_distinct_command(self):
sim = _make_sim(commands=None)
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.commands == "whoami\nhostname"
def test_deduplicates_repeated_command(self):
sim = _make_sim(commands=None)
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="whoami", output="root2")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.commands == "whoami"
def test_dedup_is_case_and_whitespace_stripped(self):
sim = _make_sim(commands="whoami")
task = _make_task(command=" whoami ", output="root")
apply_task_to_simulation(task, sim)
# " whoami ".strip() == "whoami" which is already present → no append.
assert sim.commands == "whoami"
def test_empty_command_not_appended(self):
task = _make_task(command="", output="output")
sim = _make_sim(commands=None)
apply_task_to_simulation(task, sim)
# task.command is falsy → commands block skipped.
assert sim.commands is None
class TestIdempotency:
def test_no_op_when_mapping_already_applied(self):
task = _make_task(output="root", mapping_applied=True)
sim = _make_sim(execution_result="existing")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "existing"
def test_always_marks_mapping_applied(self):
task = _make_task(output="root")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert task.mapping_applied is True
def test_updated_at_is_set(self):
task = _make_task(output="root")
sim = _make_sim()
before = datetime.now(UTC)
apply_task_to_simulation(task, sim)
assert sim.updated_at is not None
assert sim.updated_at >= before

View File

@@ -0,0 +1,375 @@
"""Tests for GET /api/simulations/<id>/c2/tasks — poll-on-read endpoint."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _execute(client: FlaskClient, token: str, sid: int, commands: list, callback_display_id: int = 1):
return client.post(
f"/api/simulations/{sid}/c2/execute",
headers=_h(token),
json={"callback_display_id": callback_display_id, "commands": commands},
)
def _list_tasks(client: FlaskClient, token: str, sid: int):
return client.get(
f"/api/simulations/{sid}/c2/tasks",
headers=_h(token),
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestListTasksHappyPath:
def test_returns_empty_list_when_no_tasks(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
assert resp.get_json()["tasks"] == []
def test_returns_task_after_execute(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
tasks = resp.get_json()["tasks"]
assert len(tasks) == 1
assert tasks[0]["command"] == "whoami"
def test_task_shape(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["hostname"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
for field in ("id", "mythic_task_display_id", "callback_display_id",
"command", "params", "status", "completed", "output",
"source", "mapping_applied", "created_at", "completed_at"):
assert field in task, f"missing field: {field}"
assert task["source"] == "mimic"
def test_first_poll_returns_submitted(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
# First GET — FakeAdapter.get_task() first call → submitted.
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["status"] == "submitted"
assert task["completed"] is False
def test_poll_marks_completed_when_adapter_returns_completed(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""When adapter.get_task returns completed=True the task is updated in DB."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["completed"] is True
assert task["status"] == "completed"
def test_output_populated_after_completion(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Output is fetched and stored when task transitions to completed."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
def _output(self, task_display_id: int) -> str:
return f"whoami result for task {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["output"] is not None
assert "whoami" in task["output"]
def test_mapping_applied_set_after_completion(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mapping_applied is True
def test_execution_result_updated_on_simulation(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
def _output(self, task_display_id: int) -> str:
return f"WORKSTATION-01\\whoami output {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"])
with app.app_context():
updated_sim = db.session.get(Simulation, sim["id"])
assert updated_sim is not None
assert updated_sim.execution_result is not None
assert "whoami" in updated_sim.execution_result
def test_completed_task_not_re_polled(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Once task.completed=True in DB, subsequent GETs skip polling (no re-poll)."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
call_count = {"n": 0}
def _completed(self, task_display_id: int) -> C2TaskStatus:
call_count["n"] += 1
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"]) # 1st GET — marks task completed (1 call)
first_count = call_count["n"]
_list_tasks(client, admin_token, sim["id"]) # 2nd GET — task already completed, skip poll
# get_task should NOT have been called again on the 2nd GET.
assert call_count["n"] == first_count, "completed task should not be re-polled"
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
task = resp.get_json()["tasks"][0]
assert task["completed"] is True
def test_redteam_can_list_tasks(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, redteam_token, sim["id"])
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
class TestListTasksErrors:
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _list_tasks(client, admin_token, 9999)
assert resp.status_code == 404
def test_403_soc_forbidden(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, soc_token, sim["id"])
assert resp.status_code == 403
def test_503_no_encryption_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 503
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 404
def test_adapter_error_during_poll_is_tolerated(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""If get_task raises C2Error during poll, the task is skipped (best-effort)."""
from backend.app.services.c2 import fake as fake_mod
def _boom(self, task_display_id: int):
raise C2Error("upstream unavailable")
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
# Should still return 200 with the task (un-refreshed status).
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
tasks = resp.get_json()["tasks"]
assert len(tasks) == 1
# Status is stale (not updated due to error) — still "submitted".
assert tasks[0]["status"] == "submitted"

View File

@@ -0,0 +1,52 @@
"""Tests for the Fernet crypto service."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from backend.app.services.crypto import C2Disabled, decrypt, encrypt
@pytest.fixture()
def fernet_key(monkeypatch) -> str:
key = Fernet.generate_key().decode()
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", key)
return key
@pytest.fixture()
def no_key(monkeypatch):
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
class TestEncryptDecrypt:
def test_round_trip(self, fernet_key):
plaintext = "s3cr3t-api-token"
ciphertext = encrypt(plaintext)
assert ciphertext != plaintext
assert decrypt(ciphertext) == plaintext
def test_different_tokens_for_same_input(self, fernet_key):
# Fernet tokens are non-deterministic (random IV).
t1 = encrypt("same")
t2 = encrypt("same")
assert t1 != t2
assert decrypt(t1) == decrypt(t2) == "same"
def test_decrypt_invalid_ciphertext(self, fernet_key):
with pytest.raises(ValueError):
decrypt("not-valid-fernet-token")
class TestKeyAbsent:
def test_encrypt_raises_c2disabled(self, no_key):
with pytest.raises(C2Disabled):
encrypt("anything")
def test_decrypt_raises_c2disabled(self, no_key):
with pytest.raises(C2Disabled):
decrypt("anything")
def test_c2disabled_message(self, no_key):
with pytest.raises(C2Disabled, match="MIMIC_ENCRYPTION_KEY"):
encrypt("x")

View File

@@ -220,6 +220,23 @@ def test_render_engagement_csv_escapes_formula_injection_in_execution(app) -> No
assert "HYPERLINK" in cells[4] 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: def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
with app.app_context(): with app.app_context():
eng = _make_engagement() eng = _make_engagement()
@@ -230,6 +247,24 @@ def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
assert "whoami /all" in cells[4] 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 # PDF tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -256,6 +291,15 @@ def test_render_engagement_pdf_contains_simulation_table(app) -> None:
assert header in html, f"Expected French header '{header}' in HTML" 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 # Defense-in-depth: filename header injection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -0,0 +1,199 @@
"""Migration round-trip test for 0006_c2_layer.
Verifies that upgrade() creates c2_config and c2_task with the expected schema,
and that downgrade() removes both tables cleanly.
Uses the resolved-path pattern (derives path from __file__) to avoid the
hardcoded-path regression documented in lessons.md Sprint 4.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
def _load_migration():
versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
path = versions_dir / "0006_c2_layer.py"
spec = importlib.util.spec_from_file_location("migration_0006", path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
def _fresh_engine():
"""In-memory SQLite with the tables that 0006 depends on already present."""
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at DATETIME NOT NULL
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE engagements (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
start_date DATE NOT NULL,
end_date DATE,
status TEXT NOT NULL DEFAULT 'planned',
created_at DATETIME NOT NULL,
created_by_id INTEGER NOT NULL REFERENCES users(id)
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE simulations (
id INTEGER PRIMARY KEY,
engagement_id INTEGER NOT NULL REFERENCES engagements(id),
name TEXT NOT NULL,
techniques JSON NOT NULL DEFAULT '[]',
tactic_ids JSON NOT NULL DEFAULT '[]',
description TEXT,
commands TEXT,
prerequisites TEXT,
executed_at DATETIME,
execution_result TEXT,
log_source TEXT,
logs TEXT,
soc_comment TEXT,
incident_number TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
updated_at DATETIME,
created_by_id INTEGER NOT NULL REFERENCES users(id)
)
"""
)
)
return engine
def _run_upgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.upgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
def _run_downgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.downgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
class TestMigration0006Upgrade:
def test_c2_config_table_created(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
assert "c2_config" in insp.get_table_names()
def test_c2_task_table_created(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
assert "c2_task" in insp.get_table_names()
def test_c2_config_columns(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_config")}
assert {"id", "engagement_id", "url", "api_token_encrypted",
"verify_tls", "created_at", "updated_at"} <= cols
def test_c2_config_unique_constraint_on_engagement_id(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
# Insert a user and engagement first.
with engine.begin() as conn:
conn.execute(text(
"INSERT INTO users (id, username, password_hash, role, created_at) "
"VALUES (1, 'u', 'h', 'admin', '2026-01-01')"
))
conn.execute(text(
"INSERT INTO engagements (id, name, start_date, status, created_at, created_by_id) "
"VALUES (1, 'Op', '2026-01-01', 'planned', '2026-01-01', 1)"
))
conn.execute(text(
"INSERT INTO c2_config (engagement_id, url, api_token_encrypted, verify_tls, created_at) "
"VALUES (1, 'https://c2', 'tok', 1, '2026-01-01')"
))
# Second insert on same engagement_id must fail.
try:
conn.execute(text(
"INSERT INTO c2_config (engagement_id, url, api_token_encrypted, verify_tls, created_at) "
"VALUES (1, 'https://c2b', 'tok2', 1, '2026-01-01')"
))
raised = False
except Exception:
raised = True
assert raised, "UNIQUE constraint on c2_config.engagement_id must be enforced"
def test_c2_task_columns(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert {"id", "simulation_id", "mythic_task_display_id", "callback_display_id",
"command", "params", "status", "completed", "output", "source",
"created_at", "completed_at"} <= cols
class TestMigration0006Downgrade:
def test_downgrade_removes_c2_config(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
assert "c2_config" not in insp.get_table_names()
def test_downgrade_removes_c2_task(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
assert "c2_task" not in insp.get_table_names()

View File

@@ -0,0 +1,124 @@
"""Migration round-trip test for 0007_c2_task_mapping_applied.
Verifies that upgrade() adds the mapping_applied column and downgrade() removes it.
Uses the resolved-path pattern per lessons.md Sprint 4.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
def _load_migration(name: str):
versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
path = versions_dir / name
spec = importlib.util.spec_from_file_location(name.removesuffix(".py"), path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
def _fresh_engine_with_c2_task():
"""In-memory SQLite with c2_task already created (as left by 0006 upgrade)."""
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE c2_task (
id INTEGER PRIMARY KEY,
simulation_id INTEGER NOT NULL,
mythic_task_display_id INTEGER NOT NULL,
callback_display_id INTEGER NOT NULL,
command TEXT NOT NULL,
params TEXT,
status TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0,
output TEXT,
source TEXT NOT NULL,
created_at DATETIME NOT NULL,
completed_at DATETIME
)
"""))
return engine
def _run_upgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.upgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
def _run_downgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.downgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
class TestMigration0007Upgrade:
def test_mapping_applied_column_added(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert "mapping_applied" in cols
def test_mapping_applied_defaults_to_false(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
# Insert a row before upgrading (no mapping_applied column yet).
with engine.begin() as conn:
conn.execute(text(
"INSERT INTO c2_task "
"(simulation_id, mythic_task_display_id, callback_display_id, "
"command, status, completed, source, created_at) "
"VALUES (1, 1000, 1, 'whoami', 'submitted', 0, 'mimic', '2026-01-01')"
))
_run_upgrade(engine, mod)
with engine.begin() as conn:
row = conn.execute(
text("SELECT mapping_applied FROM c2_task WHERE id = 1")
).fetchone()
assert row is not None
# SQLite stores booleans as 0/1.
assert row[0] == 0 or row[0] is False
class TestMigration0007Downgrade:
def test_downgrade_removes_mapping_applied(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert "mapping_applied" not in cols
def test_downgrade_does_not_drop_other_columns(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert {"id", "simulation_id", "command", "status", "completed"} <= cols

View File

@@ -8,6 +8,7 @@
"name": "mimic-frontend", "name": "mimic-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
@@ -1013,6 +1014,15 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",

View File

@@ -12,6 +12,7 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",

94
frontend/src/api/c2.ts Normal file
View File

@@ -0,0 +1,94 @@
import { apiClient } from './client';
import type {
C2CallbackHistoryResponse,
C2Config,
C2ConfigInput,
C2TestResult,
C2CallbacksResponse,
C2ExecuteInput,
C2ExecuteResponse,
C2ImportInput,
C2ImportResponse,
C2TasksResponse,
} from './types';
export async function getC2Config(engagementId: number): Promise<C2Config | null> {
try {
const { data } = await apiClient.get<C2Config>(`/engagements/${engagementId}/c2-config`);
return data;
} catch (err: unknown) {
const e = err as { response?: { status?: number } };
if (e?.response?.status === 404) return null;
throw err;
}
}
export async function putC2Config(
engagementId: number,
input: C2ConfigInput,
): Promise<C2Config> {
const { data } = await apiClient.put<C2Config>(
`/engagements/${engagementId}/c2-config`,
input,
);
return data;
}
export async function deleteC2Config(engagementId: number): Promise<void> {
await apiClient.delete(`/engagements/${engagementId}/c2-config`);
}
export async function testC2Config(engagementId: number): Promise<C2TestResult> {
const { data } = await apiClient.post<C2TestResult>(
`/engagements/${engagementId}/c2-config/test`,
);
return data;
}
export async function listCallbacks(engagementId: number): Promise<C2CallbacksResponse> {
const { data } = await apiClient.get<C2CallbacksResponse>(
`/engagements/${engagementId}/c2/callbacks`,
);
return data;
}
export async function executeC2(
simulationId: number,
input: C2ExecuteInput,
): Promise<C2ExecuteResponse> {
const { data } = await apiClient.post<C2ExecuteResponse>(
`/simulations/${simulationId}/c2/execute`,
input,
);
return data;
}
export async function getC2Tasks(simulationId: number): Promise<C2TasksResponse> {
const { data } = await apiClient.get<C2TasksResponse>(
`/simulations/${simulationId}/c2/tasks`,
);
return data;
}
export async function listCallbackHistory(
engagementId: number,
callbackDisplayId: number,
params: { page: number; pageSize: number },
): Promise<C2CallbackHistoryResponse> {
const { data } = await apiClient.get<C2CallbackHistoryResponse>(
`/engagements/${engagementId}/c2/callbacks/${callbackDisplayId}/history`,
{ params: { page: params.page, page_size: params.pageSize } },
);
return data;
}
export async function importC2(
simulationId: number,
input: C2ImportInput,
): Promise<C2ImportResponse> {
const { data } = await apiClient.post<C2ImportResponse>(
`/simulations/${simulationId}/c2/import`,
input,
);
return data;
}

View File

@@ -154,3 +154,101 @@ export interface SimulationPatchInput {
soc_comment?: string | null; soc_comment?: string | null;
incident_number?: string | null; incident_number?: string | null;
} }
// C2 types
export interface C2Config {
has_token: boolean;
url: string;
verify_tls: boolean;
}
export interface C2ConfigInput {
url: string;
api_token?: string;
verify_tls: boolean;
}
export interface C2TestResult {
ok: boolean;
error: string | null;
}
export interface C2Callback {
display_id: number;
active: boolean;
host: string;
user: string;
domain: string;
last_checkin: string;
}
export interface C2CallbacksResponse {
callbacks: C2Callback[];
}
// Thin shape returned by the execute endpoint
export interface C2ExecuteTask {
id: number;
mythic_task_display_id: number;
command: string;
status: string;
completed: boolean;
}
export interface C2ExecuteInput {
callback_display_id: number;
commands: string[];
}
export interface C2ExecuteResponse {
tasks: C2ExecuteTask[];
}
// Full shape returned by the tasks list endpoint (M3)
export interface C2TaskListItem {
id: number;
mythic_task_display_id: number;
callback_display_id: number;
command: string;
params: string | null;
status: string;
completed: boolean;
output: string | null;
mapping_applied: boolean;
source: 'mimic' | 'import';
created_at: string;
completed_at: string | null;
}
export interface C2TasksResponse {
tasks: C2TaskListItem[];
}
// Callback history (M4)
export interface C2HistoryTask {
display_id: number;
command: string;
status: string;
completed: boolean;
completed_at: string | null;
created_at: string;
}
export interface C2CallbackHistoryResponse {
tasks: C2HistoryTask[];
total: number;
page: number;
page_size: number;
}
// Import (M4)
export interface C2ImportInput {
callback_display_id: number;
task_display_ids: number[];
}
export interface C2ImportResponse {
imported: number;
skipped: number;
}

View File

@@ -0,0 +1,95 @@
import { extractApiError } from '@/api/client';
import type { C2Callback } from '@/api/types';
interface C2CallbackPickerProps {
callbacks: C2Callback[];
isLoading: boolean;
isError: boolean;
error: unknown;
selectedId: number | null;
onSelect: (id: number) => void;
rowTestId?: string;
}
export function C2CallbackPicker({
callbacks,
isLoading,
isError,
error,
selectedId,
onSelect,
rowTestId = 'c2-callback-row',
}: C2CallbackPickerProps): JSX.Element {
if (isLoading) {
return <p className="text-[14px] text-graphite">Loading callbacks</p>;
}
if (isError) {
return (
<p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(error, 'Unknown error')}
</p>
);
}
if (callbacks.length === 0) {
return <p className="text-[14px] text-graphite">No callbacks available.</p>;
}
return (
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
</tr>
</thead>
<tbody>
{callbacks.map((cb) => {
const isSelected = selectedId === cb.display_id;
return (
<tr
key={cb.display_id}
data-testid={rowTestId}
onClick={() => onSelect(cb.display_id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(cb.display_id);
}
}}
tabIndex={0}
role="button"
className={`cursor-pointer border-b border-hairline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
isSelected ? 'bg-primary-soft' : 'hover:bg-cloud'
}`}
>
<td className="px-md py-sm font-mono">{cb.display_id}</td>
<td className="px-md py-sm">
<span
className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${
cb.active
? 'bg-primary-soft text-primary-deep'
: 'bg-cloud text-graphite border border-hairline'
}`}
>
{cb.active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-md py-sm font-mono">{cb.host}</td>
<td className="px-md py-sm font-mono">{cb.user}</td>
<td className="px-md py-sm font-mono">{cb.domain}</td>
<td className="px-md py-sm font-mono">{cb.last_checkin}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,240 @@
import { useEffect, useState, type FormEvent } from 'react';
import { extractApiError } from '@/api/client';
import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2';
import { ConfirmDialog } from './ConfirmDialog';
import { FormField, TextInput } from './FormField';
import { useToast } from '@/hooks/useToast';
interface C2ConfigCardProps {
engagementId: number;
}
export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
const { push } = useToast();
const configQuery = useC2Config(engagementId);
const updateMutation = useUpdateC2Config(engagementId);
const deleteMutation = useDeleteC2Config(engagementId);
const testMutation = useTestC2Config(engagementId);
const config = configQuery.data;
const is503 = configQuery.error
? (configQuery.error as { response?: { status?: number } })?.response?.status === 503
: false;
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [verifyTls, setVerifyTls] = useState(true);
const [replaceToken, setReplaceToken] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
// Sync URL and verifyTls from loaded config (but not token — write-only at API level)
useEffect(() => {
if (config) {
setUrl(config.url);
setVerifyTls(config.verify_tls);
}
}, [config]);
const disabled = is503 || configQuery.isLoading;
const onSave = async (e: FormEvent) => {
e.preventDefault();
if (is503) return;
setTestResult(null);
const input: { url: string; verify_tls: boolean; api_token?: string } = {
url: url.trim(),
verify_tls: verifyTls,
};
// Send token only if: no existing config, OR user explicitly chose to replace
if (!config?.has_token || replaceToken) {
if (token.trim()) input.api_token = token.trim();
}
try {
await updateMutation.mutateAsync(input);
push('C2 configuration saved', 'success');
setToken('');
setReplaceToken(false);
} catch (err) {
push(extractApiError(err, 'Could not save C2 configuration'), 'error');
}
};
const onDelete = async () => {
setShowDeleteConfirm(false);
setTestResult(null);
try {
await deleteMutation.mutateAsync();
push('C2 configuration removed', 'success');
setUrl('');
setToken('');
setVerifyTls(true);
setReplaceToken(false);
} catch (err) {
push(extractApiError(err, 'Could not remove C2 configuration'), 'error');
}
};
const onTest = async () => {
setTestResult(null);
try {
const result = await testMutation.mutateAsync();
setTestResult({
ok: result.ok,
message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'),
});
} catch (err) {
setTestResult({ ok: false, message: extractApiError(err, 'Test failed') });
}
};
const submitting = updateMutation.isPending || deleteMutation.isPending;
return (
<div
data-testid="c2-config-card"
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">C2 configuration</h2>
{is503 && (
<div
role="alert"
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
>
C2 features are disabled (server has no encryption key configured).
</div>
)}
{configQuery.isLoading ? (
<p className="text-[14px] text-graphite">Loading</p>
) : (
<form onSubmit={onSave} noValidate className="flex flex-col gap-md">
<FormField
label="URL"
htmlFor="c2-url"
hint="HTTPS required (e.g. https://mythic.lab:7443)"
>
<TextInput
id="c2-url"
data-testid="c2-url-input"
type="url"
name="url"
placeholder="https://mythic.lab:7443"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={disabled}
/>
</FormField>
<FormField label="API token" htmlFor="c2-token">
{config?.has_token && !replaceToken ? (
<div className="flex items-center gap-md">
<TextInput
id="c2-token"
data-testid="c2-token-input"
type="password"
name="api_token"
placeholder="••••••••"
value=""
readOnly
disabled={disabled}
/>
<button
type="button"
className="btn-text-link text-[14px] whitespace-nowrap"
onClick={() => setReplaceToken(true)}
disabled={disabled}
>
Replace token
</button>
</div>
) : (
<TextInput
id="c2-token"
data-testid="c2-token-input"
type="password"
name="api_token"
placeholder={config?.has_token ? 'Enter new token' : 'API token'}
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={disabled}
/>
)}
</FormField>
<div className="flex items-center gap-sm">
<input
id="c2-verify-tls"
data-testid="c2-verify-tls"
type="checkbox"
checked={verifyTls}
onChange={(e) => setVerifyTls(e.target.checked)}
disabled={disabled}
className="h-4 w-4 accent-primary"
/>
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
Verify TLS certificate
</label>
</div>
<div className="flex items-center gap-md flex-wrap">
<button
type="submit"
data-testid="c2-save-btn"
className="btn-primary"
disabled={disabled || submitting}
>
{updateMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
data-testid="c2-test-btn"
className="btn-outline"
onClick={onTest}
disabled={disabled || testMutation.isPending || !config}
>
{testMutation.isPending ? 'Testing…' : 'Test connection'}
</button>
{testResult !== null && (
<span
className={`text-[14px] ${testResult.ok ? 'text-success' : 'text-bloom-deep'}`}
>
{testResult.message}
</span>
)}
{config?.has_token && (
<button
type="button"
data-testid="c2-delete-btn"
className="btn-text-link text-bloom-deep ml-auto"
onClick={() => setShowDeleteConfirm(true)}
disabled={disabled || submitting}
>
Delete configuration
</button>
)}
</div>
</form>
)}
{showDeleteConfirm && (
<ConfirmDialog
title="Delete C2 configuration"
description="This will remove the C2 configuration for this engagement. The API token will be permanently deleted."
confirmLabel="Delete"
cancelLabel="Cancel"
destructive
onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
// Dedicated badge for Mythic task statuses — separate from simulation status badges.
// submitted / processed → primary-soft (in-flight)
// completed → success-soft
// error* / fail* → warn-soft (task-level issue, not system error)
// anything else → cloud / graphite (unknown/neutral)
interface C2TaskStatusBadgeProps {
status: string;
}
function badgeClass(status: string): string {
const s = status.toLowerCase();
if (s === 'completed') return 'bg-success-soft text-success';
if (s.startsWith('error') || s.startsWith('fail')) return 'bg-warn-soft text-warn';
if (s === 'submitted' || s === 'processed') return 'bg-primary-soft text-primary-deep';
return 'bg-cloud text-graphite border border-hairline';
}
export function C2TaskStatusBadge({ status }: C2TaskStatusBadgeProps): JSX.Element {
return (
<span
className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${badgeClass(status)}`}
>
{status}
</span>
);
}

View File

@@ -0,0 +1,135 @@
import { Fragment, useState } from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { useC2Tasks } from '@/hooks/useC2';
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
interface C2TasksPanelProps {
simulationId: number;
}
export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
const query = useC2Tasks(simulationId, { enabled: true });
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const tasks = query.data?.tasks ?? [];
const isRefreshing = query.isFetching && !query.isLoading;
function toggleExpand(id: number, completed: boolean) {
if (!completed) return;
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
return (
<div
data-testid="c2-tasks-panel"
className="card-product flex flex-col gap-md"
>
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-medium text-ink">C2 Tasks</h3>
{isRefreshing && (
<span
data-testid="c2-task-refresh-indicator"
className="text-[12px] text-graphite"
>
Refreshing
</span>
)}
</div>
{tasks.length === 0 ? (
<div className="border border-hairline rounded-none px-md py-md">
<p className="text-[14px] text-graphite">
No C2 tasks yet. Use Execute via C2 to launch commands.
</p>
</div>
) : (
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink w-8" aria-label="Expand" />
<th className="px-md py-sm text-left font-medium text-ink">Task</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
<th className="px-md py-sm text-left font-medium text-ink">Source</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed at</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => {
const isExpanded = expandedIds.has(task.id);
const canExpand = task.completed && Boolean(task.output);
return (
<Fragment key={task.id}>
<tr
data-testid="c2-task-row"
onClick={() => toggleExpand(task.id, task.completed)}
onKeyDown={(e) => {
if (canExpand && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
toggleExpand(task.id, task.completed);
}
}}
tabIndex={canExpand ? 0 : undefined}
role={canExpand ? 'button' : undefined}
aria-expanded={canExpand ? isExpanded : undefined}
className={`border-b border-hairline ${canExpand ? 'cursor-pointer hover:bg-cloud focus:outline-none focus-visible:ring-2 focus-visible:ring-primary' : ''}`}
>
<td className="px-md py-sm text-graphite">
{canExpand ? (
isExpanded ? (
<ChevronDown size={14} aria-hidden />
) : (
<ChevronRight size={14} aria-hidden />
)
) : null}
</td>
<td className="px-md py-sm font-mono">#{task.mythic_task_display_id}</td>
<td
className="px-md py-sm font-mono max-w-[200px] truncate"
title={task.command}
>
{task.command}
</td>
<td className="px-md py-sm">
<span className="badge-pill-outline">
{task.source === 'mimic' ? 'MIMIC' : 'IMPORT'}
</span>
</td>
<td className="px-md py-sm">
<C2TaskStatusBadge status={task.status} />
</td>
<td className="px-md py-sm font-mono text-graphite">
{task.completed_at ?? '—'}
</td>
</tr>
{isExpanded && task.output && (
<tr className="border-b border-hairline bg-cloud">
<td colSpan={6} className="px-md py-sm">
<pre
data-testid="c2-task-output"
className="font-mono text-[12px] whitespace-pre-wrap text-ink"
>
{task.output}
</pre>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -25,7 +25,7 @@ export function ConfirmDialog({
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center"
> >
<div className="modal-backdrop absolute inset-0" onClick={onCancel} aria-hidden="true" /> <div className="modal-backdrop absolute inset-0" onClick={onCancel} aria-hidden="true" />
<div className="relative card-product shadow-floating max-w-sm w-full mx-md flex flex-col gap-md"> <div className="relative card-product max-w-sm w-full mx-md flex flex-col gap-md">
<h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink"> <h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink">
{title} {title}
</h2> </h2>

View File

@@ -10,7 +10,7 @@ export function EmptyState({ title, description, action }: EmptyStateProps): JSX
return ( return (
<div <div
data-testid="empty-state" data-testid="empty-state"
className="card-product flex flex-col items-start gap-md border border-hairline" className="card-product flex flex-col items-start gap-md"
> >
<h2 className="text-[24px] font-medium text-ink">{title}</h2> <h2 className="text-[24px] font-medium text-ink">{title}</h2>
{description ? <p className="text-[16px] text-charcoal">{description}</p> : null} {description ? <p className="text-[16px] text-charcoal">{description}</p> : null}

View File

@@ -9,7 +9,7 @@ export function ErrorState({ title = 'Something went wrong', message, onRetry }:
<div <div
role="alert" role="alert"
data-testid="error-state" data-testid="error-state"
className="card-product border border-bloom-deep/20 flex flex-col items-start gap-md" className="card-product border-l-4 border-l-bloom-deep flex flex-col items-start gap-md"
> >
<h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2> <h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2>
<p className="text-[16px] text-charcoal">{message}</p> <p className="text-[16px] text-charcoal">{message}</p>

View File

@@ -0,0 +1,129 @@
import { useState } from 'react';
import { extractApiError } from '@/api/client';
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker';
import { useToast } from '@/hooks/useToast';
interface ExecuteViaC2ModalProps {
simulationId: number;
engagementId: number;
initialCommands: string;
onClose: () => void;
}
export function ExecuteViaC2Modal({
simulationId,
engagementId,
initialCommands,
onClose,
}: ExecuteViaC2ModalProps): JSX.Element {
const { push } = useToast();
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
const executeMutation = useExecuteC2(simulationId, engagementId);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [commands, setCommands] = useState(initialCommands);
const [submitError, setSubmitError] = useState<string | null>(null);
const callbacks = callbacksQuery.data?.callbacks ?? [];
const commandLines = commands
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
const canLaunch = selectedId !== null && commandLines.length > 0;
const onLaunch = async () => {
if (!canLaunch) return;
setSubmitError(null);
try {
const result = await executeMutation.mutateAsync({
callback_display_id: selectedId,
commands: commandLines,
});
push(`${result.tasks.length} task(s) submitted`, 'success');
onClose();
} catch (err) {
setSubmitError(extractApiError(err, 'Could not execute via C2'));
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="c2-modal-title"
data-testid="c2-modal"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
<div className="relative card-product w-full max-w-3xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-modal-title" className="text-[20px] font-medium text-ink">
Execute via C2
</h2>
{/* Callback picker */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
<C2CallbackPicker
callbacks={callbacks}
isLoading={callbacksQuery.isLoading}
isError={callbacksQuery.isError}
error={callbacksQuery.error}
selectedId={selectedId}
onSelect={setSelectedId}
rowTestId="c2-callback-row"
/>
</div>
{/* Commands */}
<div className="flex flex-col gap-xs">
<label htmlFor="c2-commands" className="text-[14px] font-medium text-ink">
Commands
</label>
<textarea
id="c2-commands"
data-testid="c2-commands-textarea"
value={commands}
onChange={(e) => setCommands(e.target.value)}
className="text-input min-h-[112px] py-sm font-mono text-[14px]"
placeholder="One command per line"
/>
<span className="text-[12px] text-graphite">
{commandLines.length} command{commandLines.length !== 1 ? 's' : ''} one task per line
</span>
</div>
{submitError && (
<p role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</p>
)}
{/* Footer */}
<div className="flex items-center gap-md pt-xs">
<button
type="button"
data-testid="c2-launch-btn"
className="btn-primary"
onClick={onLaunch}
disabled={!canLaunch || executeMutation.isPending}
>
{executeMutation.isPending ? 'Launching…' : 'Launch'}
</button>
<button
type="button"
className="btn-outline-ink"
onClick={onClose}
disabled={executeMutation.isPending}
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ChevronDown, Download, Loader2 } from 'lucide-react'; import { ChevronDown, Download } from 'lucide-react';
import { downloadEngagementExport, type ExportFormat } from '@/api/exports'; import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@@ -54,7 +54,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
<div className="inline-flex"> <div className="inline-flex">
<button <button
type="button" type="button"
className="btn-outline rounded-r-none border-r-0" className="btn-outline border-r-0"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
data-testid="export-btn" data-testid="export-btn"
> >
@@ -64,7 +64,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
type="button" type="button"
aria-label="Export options" aria-label="Export options"
aria-expanded={open} aria-expanded={open}
className="btn-outline rounded-l-none px-sm" className="btn-outline px-sm"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
data-testid="export-dropdown-toggle" data-testid="export-dropdown-toggle"
> >
@@ -74,7 +74,7 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
{open ? ( {open ? (
<div <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]" className="absolute right-0 top-full mt-xxs bg-paper border border-hairline rounded-none z-20 min-w-[160px]"
role="menu" role="menu"
> >
{FORMATS.map(({ label, value }) => ( {FORMATS.map(({ label, value }) => (
@@ -88,9 +88,11 @@ export function ExportEngagementButton({ engagementId }: ExportEngagementButtonP
data-testid={`export-format-${value}`} data-testid={`export-format-${value}`}
> >
{loading === value ? ( {loading === value ? (
<Loader2 size={12} className="animate-spin" aria-hidden /> <span className="font-mono text-[11px] animate-pulse" aria-hidden>
EXPORTING
</span>
) : null} ) : null}
{label} {loading !== value ? label : null}
</button> </button>
))} ))}
</div> </div>

View File

@@ -0,0 +1,252 @@
import { useState } from 'react';
import { extractApiError } from '@/api/client';
import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker';
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
import { useToast } from '@/hooks/useToast';
const PAGE_SIZE = 25;
interface ImportC2HistoryModalProps {
simulationId: number;
engagementId: number;
onClose: () => void;
}
export function ImportC2HistoryModal({
simulationId,
engagementId,
onClose,
}: ImportC2HistoryModalProps): JSX.Element {
const { push } = useToast();
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
const importMutation = useImportC2(simulationId);
const [selectedCallbackId, setSelectedCallbackId] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
const [submitError, setSubmitError] = useState<string | null>(null);
const historyQuery = useC2CallbackHistory(engagementId, selectedCallbackId, {
page,
pageSize: PAGE_SIZE,
enabled: selectedCallbackId !== null,
});
const historyTasks = historyQuery.data?.tasks ?? [];
const total = historyQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const callbacks = callbacksQuery.data?.callbacks ?? [];
function handleCallbackSelect(id: number) {
setSelectedCallbackId(id);
setPage(1);
setCheckedIds(new Set());
}
function toggleCheck(displayId: number) {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(displayId)) {
next.delete(displayId);
} else {
next.add(displayId);
}
return next;
});
}
const canImport = checkedIds.size > 0 && selectedCallbackId !== null;
const onImport = async () => {
if (!canImport) return;
setSubmitError(null);
try {
const result = await importMutation.mutateAsync({
callback_display_id: selectedCallbackId,
task_display_ids: Array.from(checkedIds),
});
const msg =
result.skipped > 0
? `Imported ${result.imported} task(s), ${result.skipped} already attached`
: `Imported ${result.imported} task(s)`;
push(msg, 'success');
onClose();
} catch (err) {
setSubmitError(extractApiError(err, 'Could not import tasks'));
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="c2-import-modal-title"
data-testid="c2-import-modal"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
<div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
Import C2 history
</h2>
{/* Step 1: callback picker */}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span>
<C2CallbackPicker
callbacks={callbacks}
isLoading={callbacksQuery.isLoading}
isError={callbacksQuery.isError}
error={callbacksQuery.error}
selectedId={selectedCallbackId}
onSelect={handleCallbackSelect}
rowTestId="c2-import-callback-row"
/>
</div>
{/* Step 2: history table (shown once a callback is selected) */}
{selectedCallbackId !== null && (
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">
Task history{' '}
{total > 0 && (
<span className="text-graphite font-normal">({total} total)</span>
)}
</span>
{historyQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading history</p>
)}
{historyQuery.isError && (
<p className="text-[14px] text-bloom-deep">
{extractApiError(historyQuery.error, 'Could not load history')}
</p>
)}
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
<p className="text-[14px] text-graphite">No task history for this callback.</p>
)}
{historyTasks.length > 0 && (
<>
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm w-8" aria-label="Select" />
<th className="px-md py-sm text-left font-medium text-ink">#</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed</th>
<th className="px-md py-sm text-left font-medium text-ink">Timestamp</th>
</tr>
</thead>
<tbody>
{historyTasks.map((task) => (
<tr
key={task.display_id}
data-testid="c2-history-row"
onClick={() => toggleCheck(task.display_id)}
className="cursor-pointer border-b border-hairline hover:bg-cloud"
>
<td className="px-md py-sm">
<input
type="checkbox"
data-testid="c2-history-row-checkbox"
checked={checkedIds.has(task.display_id)}
onChange={() => toggleCheck(task.display_id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 accent-primary"
/>
</td>
<td className="px-md py-sm font-mono">{task.display_id}</td>
<td
className="px-md py-sm font-mono max-w-[200px] truncate"
title={task.command}
>
{task.command}
</td>
<td className="px-md py-sm">
<C2TaskStatusBadge status={task.status} />
</td>
<td className="px-md py-sm text-[14px]">
{task.completed ? 'Yes' : 'No'}
</td>
<td className="px-md py-sm font-mono text-graphite">
{task.created_at}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center gap-md text-[14px] text-graphite">
<button
type="button"
data-testid="c2-history-prev"
className="btn-outline-ink"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button
type="button"
data-testid="c2-history-next"
className="btn-outline-ink"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</button>
{checkedIds.size > 0 && (
<span className="ml-auto text-ink">
{checkedIds.size} selected
</span>
)}
</div>
</>
)}
</div>
)}
{submitError && (
<p role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</p>
)}
{/* Footer */}
<div className="flex items-center gap-md pt-xs">
<button
type="button"
data-testid="c2-import-submit-btn"
className="btn-primary"
onClick={onImport}
disabled={!canImport || importMutation.isPending}
>
{importMutation.isPending ? 'Importing…' : 'Import selected'}
</button>
<button
type="button"
className="btn-outline-ink"
onClick={onClose}
disabled={importMutation.isPending}
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -34,15 +34,15 @@ export function Layout(): JSX.Element {
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span> <span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
{user ? ( {user ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<span className="text-[12px] uppercase tracking-[0.5px] text-slab-muted"> <span className="text-[12px] uppercase tracking-[0.5px] text-slab-muted font-mono">
{user.role} {user.role}
</span> </span>
<span className="text-[14px]">{user.username}</span> <span className="text-[14px] font-mono">{user.username}</span>
<button <button
type="button" type="button"
onClick={cycleTheme} onClick={cycleTheme}
aria-label={`Theme: ${themeLabel(theme)} — click to cycle`} aria-label={`Theme: ${themeLabel(theme)} — click to cycle`}
className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text transition-colors" className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text"
> >
<ThemeIcon theme={theme} /> <ThemeIcon theme={theme} />
<span className="uppercase tracking-[0.5px]">{themeLabel(theme)}</span> <span className="uppercase tracking-[0.5px]">{themeLabel(theme)}</span>
@@ -59,12 +59,12 @@ export function Layout(): JSX.Element {
</div> </div>
</div> </div>
{/* nav-bar-top — paper gives dark-mode lift vs canvas body */} {/* nav-bar-top — fixed dark slab, never inverts (same visual family as utility-strip + footer) */}
<header className="bg-paper border-b border-hairline"> <header className="bg-slab text-slab-text">
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between"> <div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home"> <Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden /> <span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
<span className="text-[20px] font-medium tracking-tight text-ink">Mimic</span> <span className="text-[20px] font-medium tracking-tight text-slab-text">Mimic</span>
</Link> </Link>
<nav className="flex items-center gap-md"> <nav className="flex items-center gap-md">
@@ -72,7 +72,9 @@ export function Layout(): JSX.Element {
to="/engagements" to="/engagements"
className={({ isActive }) => className={({ isActive }) =>
`text-[16px] py-2 px-md ${ `text-[16px] py-2 px-md ${
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal' isActive
? 'text-slab-text border-b-2 border-primary'
: 'text-slab-muted hover:text-slab-text'
}` }`
} }
> >
@@ -83,7 +85,9 @@ export function Layout(): JSX.Element {
to="/admin/templates" to="/admin/templates"
className={({ isActive }) => className={({ isActive }) =>
`text-[16px] py-2 px-md ${ `text-[16px] py-2 px-md ${
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal' isActive
? 'text-slab-text border-b-2 border-primary'
: 'text-slab-muted hover:text-slab-text'
}` }`
} }
> >
@@ -95,7 +99,9 @@ export function Layout(): JSX.Element {
to="/admin/users" to="/admin/users"
className={({ isActive }) => className={({ isActive }) =>
`text-[16px] py-2 px-md ${ `text-[16px] py-2 px-md ${
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal' isActive
? 'text-slab-text border-b-2 border-primary'
: 'text-slab-muted hover:text-slab-text'
}` }`
} }
> >

View File

@@ -4,9 +4,9 @@ export function LoadingState({ label = 'Loading…' }: { label?: string }): JSX.
role="status" role="status"
aria-live="polite" aria-live="polite"
data-testid="loading-state" data-testid="loading-state"
className="flex items-center justify-center py-section text-graphite text-[16px]" className="flex items-center justify-center py-section text-graphite text-[16px] font-mono"
> >
<span className="inline-block h-2 w-2 rounded-pill bg-primary animate-pulse mr-sm" /> <span className="inline-block h-2 w-2 bg-primary animate-pulse mr-sm" />
{label} {label}
</div> </div>
); );

View File

@@ -170,7 +170,7 @@ export function MitreMatrixModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="matrix-modal-title" aria-labelledby="matrix-modal-title"
className="relative bg-canvas rounded-xl shadow-floating dark:shadow-floating-dark max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col" className="relative bg-paper rounded-none border border-hairline max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
style={{ width: '1400px' }} style={{ width: '1400px' }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
@@ -230,7 +230,7 @@ export function MitreMatrixModal({
type="button" type="button"
onClick={() => toggleTactic(tactic.tactic_id)} onClick={() => toggleTactic(tactic.tactic_id)}
title={`${tactic.tactic_name} (${tactic.tactic_id}) — click to tag this tactic`} title={`${tactic.tactic_name} (${tactic.tactic_id}) — click to tag this tactic`}
className={`w-full text-left px-xs py-xxs rounded-t-sm border border-b-0 border-hairline transition-colors ${ className={`w-full text-left px-xs py-xxs rounded-none border border-b-0 border-hairline ${
tacticSelected tacticSelected
? 'bg-primary border-primary' ? 'bg-primary border-primary'
: 'bg-cloud hover:bg-fog' : 'bg-cloud hover:bg-fog'
@@ -251,7 +251,7 @@ export function MitreMatrixModal({
</button> </button>
{/* Techniques */} {/* Techniques */}
<div className="border border-hairline rounded-b-sm overflow-hidden flex flex-col"> <div className="border border-hairline rounded-none overflow-hidden flex flex-col">
{visibleTechniques.map((tech, techIdx) => { {visibleTechniques.map((tech, techIdx) => {
const isSelected = selectedTechMap.has(tech.id); const isSelected = selectedTechMap.has(tech.id);
const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id); const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id);
@@ -296,7 +296,7 @@ export function MitreMatrixModal({
isSelected ? 'text-white' : 'text-ink' isSelected ? 'text-white' : 'text-ink'
}`} }`}
> >
<span className="font-semibold block truncate">{tech.id}</span> <span className="font-mono font-semibold block truncate">{tech.id}</span>
<span className={`block truncate text-[10px] ${isSelected ? 'text-white/80' : 'text-charcoal'}`}> <span className={`block truncate text-[10px] ${isSelected ? 'text-white/80' : 'text-charcoal'}`}>
{tech.name} {tech.name}
</span> </span>
@@ -318,7 +318,7 @@ export function MitreMatrixModal({
: 'bg-cloud text-charcoal hover:bg-fog' : 'bg-cloud text-charcoal hover:bg-fog'
}`} }`}
> >
<span className="font-semibold block truncate">{sub.id}</span> <span className="font-mono font-semibold block truncate">{sub.id}</span>
<span className="block truncate">{sub.name}</span> <span className="block truncate">{sub.name}</span>
</button> </button>
); );

View File

@@ -107,7 +107,7 @@ export function MitreTechniquePicker({
/> />
{open && ( {open && (
<div className="absolute z-20 w-full mt-xxs bg-canvas border border-steel rounded-md shadow-floating overflow-hidden"> <div className="absolute z-20 w-full mt-xxs bg-paper border border-steel rounded-none overflow-hidden">
{isFetching && ( {isFetching && (
<div className="px-md py-sm text-[14px] text-graphite">Searching</div> <div className="px-md py-sm text-[14px] text-graphite">Searching</div>
)} )}
@@ -144,10 +144,10 @@ export function MitreTechniquePicker({
selectItem(item); selectItem(item);
}} }}
> >
<span className="font-medium">{item.id}</span> <span className="font-mono font-medium">{item.id}</span>
<span className="text-charcoal"> {item.name}</span> <span className="text-charcoal"> {item.name}</span>
{item.tactics.length > 0 && ( {item.tactics.length > 0 && (
<span className="text-graphite"> ({item.tactics[0]})</span> <span className="font-mono text-graphite"> ({item.tactics[0]})</span>
)} )}
</li> </li>
))} ))}

View File

@@ -12,7 +12,7 @@ interface TacticTagProps {
disabled?: boolean; disabled?: boolean;
} }
// Technique chip — soft blue, id only, name in title // Technique tag — angular, soft blue, monospace ID
export function MitreTechniqueTag({ export function MitreTechniqueTag({
technique, technique,
onRemove, onRemove,
@@ -22,7 +22,7 @@ export function MitreTechniqueTag({
<span <span
data-testid="mitre-technique-tag" data-testid="mitre-technique-tag"
title={`${technique.id}${technique.name}`} title={`${technique.id}${technique.name}`}
className="inline-flex items-center gap-xxs bg-primary-soft text-primary-deep rounded-full px-sm py-xxs text-[13px] font-medium" className="tag-mitre gap-xxs"
> >
{technique.id} {technique.id}
{!disabled && ( {!disabled && (
@@ -39,7 +39,7 @@ export function MitreTechniqueTag({
); );
} }
// Tactic chip — primary blue filled, id only, name in title // Tactic tag — angular, primary blue filled, monospace ID
export function MitreTacticTag({ export function MitreTacticTag({
tactic, tactic,
onRemove, onRemove,
@@ -49,7 +49,7 @@ export function MitreTacticTag({
<span <span
data-testid="mitre-tactic-tag" data-testid="mitre-tactic-tag"
title={`${tactic.id}${tactic.name}`} title={`${tactic.id}${tactic.name}`}
className="inline-flex items-center gap-xxs bg-primary text-white rounded-full px-sm py-xxs text-[13px] font-medium" className="inline-flex items-center gap-xxs bg-primary text-white rounded-none px-sm py-xxs text-[13px] font-mono"
> >
{tactic.id} {tactic.id}
{!disabled && ( {!disabled && (

View File

@@ -116,7 +116,7 @@ export function MitreTechniquesField({
aria-label="Open MITRE matrix" aria-label="Open MITRE matrix"
onClick={() => { setShowPicker(false); setShowMatrix(true); }} onClick={() => { setShowPicker(false); setShowMatrix(true); }}
disabled={isPending} disabled={isPending}
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-md border border-steel text-graphite hover:text-ink hover:border-ink transition-colors" className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink"
> >
<Grid2x2 size={16} /> <Grid2x2 size={16} />
</button> </button>

View File

@@ -73,7 +73,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
<div className="inline-flex"> <div className="inline-flex">
<button <button
type="button" type="button"
className="btn-primary rounded-r-none border-r border-primary-deep" className="btn-primary border-r border-primary-deep"
onClick={handleBlank} onClick={handleBlank}
data-testid="new-simulation-btn" data-testid="new-simulation-btn"
> >
@@ -83,7 +83,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
type="button" type="button"
aria-label="More options" aria-label="More options"
aria-expanded={open} aria-expanded={open}
className="btn-primary rounded-l-none px-sm" className="btn-primary px-sm"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
data-testid="new-simulation-dropdown-toggle" data-testid="new-simulation-dropdown-toggle"
> >
@@ -93,7 +93,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
{open ? ( {open ? (
<div <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-[180px]" className="absolute right-0 top-full mt-xxs bg-paper border border-hairline rounded-none z-20 min-w-[180px]"
role="menu" role="menu"
> >
<button <button
@@ -199,7 +199,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
{sim.name} {sim.name}
</Link> </Link>
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px]"> <td className="px-xl py-md text-charcoal text-[14px] font-mono">
{(() => { {(() => {
const items = [ const items = [
...(sim.tactics ?? []).map((t) => t.id), ...(sim.tactics ?? []).map((t) => t.id),
@@ -213,7 +213,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
<td className="px-xl py-md"> <td className="px-xl py-md">
<SimulationStatusBadge status={sim.status} /> <SimulationStatusBadge status={sim.status} />
</td> </td>
<td className="px-xl py-md text-charcoal text-[14px]"> <td className="px-xl py-md text-charcoal text-[14px] font-mono">
{formatDate(sim.executed_at)} {formatDate(sim.executed_at)}
</td> </td>
</tr> </tr>

View File

@@ -7,19 +7,17 @@ const LABELS: Record<SimulationStatus, string> = {
done: 'Done', done: 'Done',
}; };
// Fixed colors — badge backgrounds are decorative/semantic, not themeable.
// text-white is hardcoded (not text-canvas) so dark mode doesn't invert it to near-black.
const STYLES: Record<SimulationStatus, string> = { const STYLES: Record<SimulationStatus, string> = {
pending: 'bg-fog text-charcoal border border-hairline', pending: 'bg-cloud text-graphite border border-hairline',
in_progress: 'bg-primary-soft text-primary-deep', in_progress: 'bg-primary-soft text-primary-deep',
review_required: 'bg-bloom-coral text-white', review_required: 'bg-warn-soft text-warn',
done: 'bg-storm-deep text-white', done: 'bg-success-soft text-success',
}; };
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element { export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
return ( return (
<span <span
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`} className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
data-testid="simulation-status-badge" data-testid="simulation-status-badge"
data-status={status} data-status={status}
> >

View File

@@ -7,17 +7,15 @@ const LABELS: Record<EngagementStatus, string> = {
}; };
const STYLES: Record<EngagementStatus, string> = { const STYLES: Record<EngagementStatus, string> = {
// Outlined ink for planned (neutral), filled primary for active (engagement live), planned: 'bg-warn-soft text-warn border border-warn',
// outlined steel for closed (muted). Stays within DESIGN.md palette. active: 'bg-primary-soft text-primary-deep',
planned: 'bg-canvas text-ink border border-ink',
active: 'bg-primary text-white',
closed: 'bg-cloud text-graphite border border-hairline', closed: 'bg-cloud text-graphite border border-hairline',
}; };
export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element { export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element {
return ( return (
<span <span
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`} className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
data-testid="status-badge" data-testid="status-badge"
data-status={status} data-status={status}
> >

View File

@@ -34,7 +34,7 @@ export function TemplatePickerModal({
> >
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" /> <div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
<div className="relative card-product shadow-floating dark:shadow-floating-dark max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden"> <div className="relative card-product max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink"> <h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink">
From template From template
@@ -43,7 +43,7 @@ export function TemplatePickerModal({
type="button" type="button"
aria-label="Close" aria-label="Close"
onClick={onClose} onClick={onClose}
className="text-graphite hover:text-ink transition-colors text-[20px] leading-none" className="text-graphite hover:text-ink text-[20px] leading-none"
> >
× ×
</button> </button>

View File

@@ -1,9 +1,5 @@
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
/**
* Stack of toast notifications anchored bottom-right.
* Pure DESIGN.md surfaces: rounded-xl, soft-lift, ink slab for errors.
*/
export function ToastViewport(): JSX.Element { export function ToastViewport(): JSX.Element {
const { toasts, dismiss } = useToast(); const { toasts, dismiss } = useToast();
return ( return (
@@ -15,19 +11,18 @@ export function ToastViewport(): JSX.Element {
{toasts.map((t) => { {toasts.map((t) => {
const isError = t.kind === 'error'; const isError = t.kind === 'error';
const isSuccess = t.kind === 'success'; const isSuccess = t.kind === 'success';
// Fixed colors: toasts don't theme (error=dark slab, success=primary blue)
const surface = isError const surface = isError
? 'bg-slab text-slab-text' ? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep'
: isSuccess : isSuccess
? 'bg-primary text-white' ? 'bg-paper text-ink border border-hairline border-l-4 border-l-success'
: 'bg-canvas text-ink border border-hairline'; : 'bg-paper text-ink border border-hairline border-l-4 border-l-primary';
return ( return (
<div <div
key={t.id} key={t.id}
role="status" role="status"
data-testid="toast" data-testid="toast"
data-kind={t.kind} data-kind={t.kind}
className={`pointer-events-auto rounded-xl px-md py-sm shadow-soft-lift text-[14px] leading-[1.4] ${surface}`} className={`pointer-events-auto rounded-none px-md py-sm text-[14px] leading-[1.4] ${surface}`}
> >
<div className="flex items-start justify-between gap-sm"> <div className="flex items-start justify-between gap-sm">
<span className="flex-1">{t.message}</span> <span className="flex-1">{t.message}</span>

152
frontend/src/hooks/useC2.ts Normal file
View File

@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
deleteC2Config,
executeC2,
getC2Config,
getC2Tasks,
importC2,
listCallbackHistory,
listCallbacks,
putC2Config,
testC2Config,
} from '@/api/c2';
import type {
C2ConfigInput,
C2ExecuteInput,
C2ImportInput,
C2TasksResponse,
} from '@/api/types';
function c2ConfigKey(engagementId: number) {
return ['c2-config', engagementId] as const;
}
function c2CallbacksKey(engagementId: number) {
return ['c2-callbacks', engagementId] as const;
}
function c2TasksKey(simulationId: number) {
return ['c2-tasks', simulationId] as const;
}
function c2HistoryKey(engagementId: number, callbackDisplayId: number, page: number, pageSize: number) {
return ['c2-history', engagementId, callbackDisplayId, page, pageSize] as const;
}
function simulationKey(id: number) {
return ['simulations', id] as const;
}
export function useC2Config(engagementId: number | undefined, options?: { enabled?: boolean }) {
const enabled =
typeof engagementId === 'number' &&
!Number.isNaN(engagementId) &&
(options?.enabled !== false);
return useQuery({
queryKey: engagementId ? c2ConfigKey(engagementId) : ['c2-config', 'none'],
queryFn: () => getC2Config(engagementId as number),
enabled,
});
}
export function useUpdateC2Config(engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: C2ConfigInput) => putC2Config(engagementId, input),
onSuccess: () => qc.invalidateQueries({ queryKey: c2ConfigKey(engagementId) }),
});
}
export function useDeleteC2Config(engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => deleteC2Config(engagementId),
onSuccess: () => qc.invalidateQueries({ queryKey: c2ConfigKey(engagementId) }),
});
}
export function useTestC2Config(engagementId: number) {
return useMutation({
mutationFn: () => testC2Config(engagementId),
});
}
export function useC2Callbacks(engagementId: number | undefined, options?: { enabled?: boolean }) {
const enabled =
typeof engagementId === 'number' &&
!Number.isNaN(engagementId) &&
(options?.enabled !== false);
return useQuery({
queryKey: engagementId ? c2CallbacksKey(engagementId) : ['c2-callbacks', 'none'],
queryFn: () => listCallbacks(engagementId as number),
enabled,
});
}
export function useC2Tasks(simulationId: number | undefined, options?: { enabled?: boolean }) {
const enabled =
typeof simulationId === 'number' &&
!Number.isNaN(simulationId) &&
(options?.enabled !== false);
return useQuery({
queryKey: simulationId ? c2TasksKey(simulationId) : ['c2-tasks', 'none'],
queryFn: () => getC2Tasks(simulationId as number),
enabled,
// Poll every 2500 ms while any task is incomplete; stop when all done.
refetchInterval: (query: { state: { data?: C2TasksResponse } }) =>
query.state.data?.tasks?.some((t) => !t.completed) ? 2500 : false,
});
}
export function useExecuteC2(simulationId: number, engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: C2ExecuteInput) => executeC2(simulationId, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
qc.invalidateQueries({ queryKey: ['engagements', engagementId, 'simulations'] });
qc.invalidateQueries({ queryKey: c2TasksKey(simulationId) });
},
});
}
export function useC2CallbackHistory(
engagementId: number | undefined,
callbackDisplayId: number | null,
options?: { page?: number; pageSize?: number; enabled?: boolean },
) {
const page = options?.page ?? 1;
const pageSize = options?.pageSize ?? 25;
const enabled =
typeof engagementId === 'number' &&
!Number.isNaN(engagementId) &&
callbackDisplayId !== null &&
(options?.enabled !== false);
return useQuery({
queryKey:
engagementId && callbackDisplayId !== null
? c2HistoryKey(engagementId, callbackDisplayId, page, pageSize)
: ['c2-history', 'none'],
queryFn: () =>
listCallbackHistory(engagementId as number, callbackDisplayId as number, {
page,
pageSize,
}),
enabled,
});
}
export function useImportC2(simulationId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: C2ImportInput) => importC2(simulationId, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: c2TasksKey(simulationId) });
qc.invalidateQueries({ queryKey: simulationKey(simulationId) });
},
});
}

View File

@@ -35,7 +35,7 @@ export function EngagementDetailPage(): JSX.Element {
<Link to="/engagements" className="btn-text-link text-[14px]"> <Link to="/engagements" className="btn-text-link text-[14px]">
Back to engagements Back to engagements
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none">{eng.name}</h1> <h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<StatusBadge status={eng.status} /> <StatusBadge status={eng.status} />
<span className="text-[14px] text-graphite"> <span className="text-[14px] text-graphite">
@@ -58,13 +58,13 @@ export function EngagementDetailPage(): JSX.Element {
<h2 className="text-[20px] font-medium mb-md">Schedule</h2> <h2 className="text-[20px] font-medium mb-md">Schedule</h2>
<dl className="grid grid-cols-2 gap-md text-[14px]"> <dl className="grid grid-cols-2 gap-md text-[14px]">
<dt className="text-graphite">Start date</dt> <dt className="text-graphite">Start date</dt>
<dd className="text-ink">{eng.start_date}</dd> <dd className="text-ink font-mono">{eng.start_date}</dd>
<dt className="text-graphite">End date</dt> <dt className="text-graphite">End date</dt>
<dd className="text-ink">{eng.end_date ?? '—'}</dd> <dd className="text-ink font-mono">{eng.end_date ?? '—'}</dd>
<dt className="text-graphite">Status</dt> <dt className="text-graphite">Status</dt>
<dd className="text-ink capitalize">{eng.status}</dd> <dd className="text-ink capitalize">{eng.status}</dd>
<dt className="text-graphite">Created at</dt> <dt className="text-graphite">Created at</dt>
<dd className="text-ink">{eng.created_at}</dd> <dd className="text-ink font-mono">{eng.created_at}</dd>
</dl> </dl>
</div> </div>

View File

@@ -7,10 +7,12 @@ import {
useEngagement, useEngagement,
usePatchEngagement, usePatchEngagement,
} from '@/hooks/useEngagements'; } from '@/hooks/useEngagements';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { FormField, Select, TextArea, TextInput } from '@/components/FormField'; import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { C2ConfigCard } from '@/components/C2ConfigCard';
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [ const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
{ value: 'planned', label: 'Planned' }, { value: 'planned', label: 'Planned' },
@@ -50,6 +52,7 @@ export function EngagementFormPage(): JSX.Element {
const numericId = id ? Number(id) : undefined; const numericId = id ? Number(id) : undefined;
const navigate = useNavigate(); const navigate = useNavigate();
const { push } = useToast(); const { push } = useToast();
const { canEditEngagements } = useAuth();
const detail = useEngagement(editing ? numericId : undefined); const detail = useEngagement(editing ? numericId : undefined);
const createMutation = useCreateEngagement(); const createMutation = useCreateEngagement();
@@ -121,9 +124,9 @@ export function EngagementFormPage(): JSX.Element {
const submitting = createMutation.isPending || patchMutation.isPending; const submitting = createMutation.isPending || patchMutation.isPending;
return ( return (
<div className="flex flex-col gap-xl max-w-2xl"> <div className="flex flex-col gap-xl">
<header> <header>
<h1 className="text-[44px] font-medium leading-none"> <h1 className="text-[32px] font-medium leading-none">
{editing ? 'Edit engagement' : 'New engagement'} {editing ? 'Edit engagement' : 'New engagement'}
</h1> </h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
@@ -133,87 +136,99 @@ export function EngagementFormPage(): JSX.Element {
</p> </p>
</header> </header>
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md"> <div
<FormField label="Name" htmlFor="eng-name" required error={errors.name}> className={
<TextInput editing && canEditEngagements
id="eng-name" ? 'grid grid-cols-1 lg:grid-cols-2 gap-xl items-start'
name="name" : 'max-w-2xl'
value={form.name} }
onChange={(e) => setForm({ ...form, name: e.target.value })} >
required <form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
/> <FormField label="Name" htmlFor="eng-name" required error={errors.name}>
</FormField>
<FormField label="Description" htmlFor="eng-description">
<TextArea
id="eng-description"
name="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField
label="Start date"
htmlFor="eng-start"
required
error={errors.start_date}
>
<TextInput <TextInput
id="eng-start" id="eng-name"
type="date" name="name"
name="start_date" value={form.name}
value={form.start_date} onChange={(e) => setForm({ ...form, name: e.target.value })}
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
required required
/> />
</FormField> </FormField>
<FormField <FormField label="Description" htmlFor="eng-description">
label="End date" <TextArea
htmlFor="eng-end" id="eng-description"
hint="Leave empty to clear / leave open-ended" name="description"
error={errors.end_date} value={form.description}
> onChange={(e) => setForm({ ...form, description: e.target.value })}
<TextInput
id="eng-end"
type="date"
name="end_date"
value={form.end_date}
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
/> />
</FormField> </FormField>
</div>
<FormField label="Status" htmlFor="eng-status" required> <div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<Select <FormField
id="eng-status" label="Start date"
name="status" htmlFor="eng-start"
value={form.status} required
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })} error={errors.start_date}
options={STATUS_OPTIONS} >
/> <TextInput
</FormField> id="eng-start"
type="date"
name="start_date"
value={form.start_date}
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
required
/>
</FormField>
{submitError ? ( <FormField
<div role="alert" className="text-[14px] text-bloom-deep"> label="End date"
{submitError} htmlFor="eng-end"
hint="Leave empty to clear / leave open-ended"
error={errors.end_date}
>
<TextInput
id="eng-end"
type="date"
name="end_date"
value={form.end_date}
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
/>
</FormField>
</div> </div>
) : null}
<div className="flex items-center gap-md pt-sm"> <FormField label="Status" htmlFor="eng-status" required>
<button type="submit" className="btn-primary" disabled={submitting}> <Select
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'} id="eng-status"
</button> name="status"
<Link value={form.status}
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'} onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
className="btn-outline-ink" options={STATUS_OPTIONS}
> />
Cancel </FormField>
</Link>
</div> {submitError ? (
</form> <div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
</button>
<Link
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
className="btn-outline-ink"
>
Cancel
</Link>
</div>
</form>
{editing && numericId && canEditEngagements && (
<C2ConfigCard engagementId={numericId} />
)}
</div>
</div> </div>
); );
} }

View File

@@ -35,7 +35,7 @@ export function EngagementsListPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-end justify-between gap-md"> <header className="flex items-end justify-between gap-md">
<div> <div>
<h1 className="text-[44px] font-medium leading-none">Engagements</h1> <h1 className="text-[32px] font-medium leading-none">Engagements</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Red team missions and their lifecycle status. Red team missions and their lifecycle status.
</p> </p>
@@ -91,8 +91,8 @@ export function EngagementsListPage(): JSX.Element {
<td className="px-xl py-md"> <td className="px-xl py-md">
<StatusBadge status={eng.status} /> <StatusBadge status={eng.status} />
</td> </td>
<td className="px-xl py-md text-charcoal">{formatDate(eng.start_date)}</td> <td className="px-xl py-md text-charcoal font-mono">{formatDate(eng.start_date)}</td>
<td className="px-xl py-md text-charcoal">{formatDate(eng.end_date)}</td> <td className="px-xl py-md text-charcoal font-mono">{formatDate(eng.end_date)}</td>
<td className="px-xl py-md text-charcoal">{eng.created_by.username}</td> <td className="px-xl py-md text-charcoal">{eng.created_by.username}</td>
<td className="px-xl py-md text-right"> <td className="px-xl py-md text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">

View File

@@ -46,7 +46,7 @@ export function LoginPage(): JSX.Element {
{/* Chevron echo of the brand mark */} {/* Chevron echo of the brand mark */}
<div className="flex items-center gap-sm"> <div className="flex items-center gap-sm">
<span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden /> <span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden />
<h1 className="text-[32px] font-medium leading-none">Mimic</h1> <h1 className="text-[28px] font-medium leading-none">Mimic</h1>
</div> </div>
<p className="text-[16px] text-charcoal">Sign in to access your engagements.</p> <p className="text-[16px] text-charcoal">Sign in to access your engagements.</p>

View File

@@ -12,12 +12,16 @@ import {
useTransitionSimulation, useTransitionSimulation,
useUpdateSimulation, useUpdateSimulation,
} from '@/hooks/useSimulations'; } from '@/hooks/useSimulations';
import { useC2Config, useC2Tasks } from '@/hooks/useC2';
import { FormField, TextArea, TextInput } from '@/components/FormField'; import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge'; import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { MitreTechniquesField } from '@/components/MitreTechniquesField'; import { MitreTechniquesField } from '@/components/MitreTechniquesField';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
import { C2TasksPanel } from '@/components/C2TasksPanel';
interface RedteamFormState { interface RedteamFormState {
name: string; name: string;
@@ -61,6 +65,20 @@ export function SimulationFormPage(): JSX.Element {
const { push } = useToast(); const { push } = useToast();
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth(); const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
const canEditRT = isAdmin || isRedteam;
const c2ConfigQuery = useC2Config(
!isNew && typeof engagementId === 'number' ? engagementId : undefined,
{ enabled: !isNew && canEditRT },
);
const hasC2Config = c2ConfigQuery.data !== null && c2ConfigQuery.data !== undefined;
const c2TasksQuery = useC2Tasks(!isNew ? simulationId : undefined, {
enabled: !isNew && canEditRT,
});
const hasTasks = (c2TasksQuery.data?.tasks?.length ?? 0) > 0;
// Show panel when: has C2 config (so Execute button is visible) OR already has tasks
const showTasksPanel = !isNew && canEditRT && (hasC2Config || hasTasks);
const detail = useSimulation(isNew ? undefined : simulationId); const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0); const createMutation = useCreateSimulation(engagementId ?? 0);
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0); const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
@@ -72,6 +90,8 @@ export function SimulationFormPage(): JSX.Element {
const [nameError, setNameError] = useState<string | null>(null); const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showC2Modal, setShowC2Modal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
useEffect(() => { useEffect(() => {
if (!isNew && detail.data) { if (!isNew && detail.data) {
@@ -109,7 +129,6 @@ export function SimulationFormPage(): JSX.Element {
// US-18: Done = fully read-only, Reopen only // US-18: Done = fully read-only, Reopen only
const isDone = status === 'done'; const isDone = status === 'done';
const canEditRT = isAdmin || isRedteam;
const socCanEdit = isSoc && (status === 'review_required' || status === 'done'); const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress'); const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
@@ -222,7 +241,7 @@ export function SimulationFormPage(): JSX.Element {
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement Back to engagement
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none mt-sm">New simulation</h1> <h1 className="text-[32px] font-medium leading-none mt-sm">New simulation</h1>
</header> </header>
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md"> <form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
@@ -257,13 +276,13 @@ export function SimulationFormPage(): JSX.Element {
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending; updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
return ( return (
<div className="flex flex-col gap-xl max-w-3xl"> <div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md"> <header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm"> <div className="flex flex-col gap-sm">
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]"> <Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement Back to engagement
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none">{rt.name || simulation?.name}</h1> <h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? ( {status ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<SimulationStatusBadge status={status} /> <SimulationStatusBadge status={status} />
@@ -281,7 +300,7 @@ export function SimulationFormPage(): JSX.Element {
{isDone && ( {isDone && (
<div <div
role="status" role="status"
className="rounded-xl px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal" className="rounded-none px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal"
> >
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes. This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
</div> </div>
@@ -292,146 +311,178 @@ export function SimulationFormPage(): JSX.Element {
<div <div
role="alert" role="alert"
data-testid="soc-blocked-banner" data-testid="soc-blocked-banner"
className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal" className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
> >
Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section. Simulation not yet ready for review the red team must mark it as &quot;Review required&quot; before you can fill in the SOC section.
</div> </div>
)} )}
{/* Red Team card */} {/* 2-column grid: RT+tasks left, SOC right. Stacks vertically below lg. */}
<form <div className="grid gap-xl lg:grid-cols-2 items-start">
id="rt-form" {/* Left column: RT card + C2 tasks panel */}
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()} <div className="flex flex-col gap-xl">
noValidate {/* Red Team card */}
className="card-product flex flex-col gap-md" <form
> id="rt-form"
<h2 className="text-[20px] font-medium text-ink">Red Team</h2> onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">Red Team</h2>
<FormField label="Name" htmlFor="sim-name" required error={nameError}> <FormField label="Name" htmlFor="sim-name" required error={nameError}>
<TextInput <TextInput
id="sim-name" id="sim-name"
name="name" name="name"
value={rt.name} value={rt.name}
onChange={(e) => setRt({ ...rt, name: e.target.value })} onChange={(e) => setRt({ ...rt, name: e.target.value })}
disabled={rtDisabled} disabled={rtDisabled}
required required
/> />
</FormField> </FormField>
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">MITRE Techniques &amp; Tactics</span> <span className="text-[14px] font-medium text-ink">MITRE Techniques &amp; Tactics</span>
<MitreTechniquesField <MitreTechniquesField
value={simulation?.techniques ?? []} value={simulation?.techniques ?? []}
tactics={simulation?.tactics ?? []} tactics={simulation?.tactics ?? []}
simulationId={simulationId as number} simulationId={simulationId as number}
engagementId={engagementId as number} engagementId={engagementId as number}
disabled={rtDisabled} disabled={rtDisabled}
/> />
</div> </div>
<FormField label="Description" htmlFor="sim-description"> <FormField label="Description" htmlFor="sim-description">
<TextArea <TextArea
id="sim-description" id="sim-description"
name="description" name="description"
value={rt.description} value={rt.description}
onChange={(e) => setRt({ ...rt, description: e.target.value })} onChange={(e) => setRt({ ...rt, description: e.target.value })}
disabled={rtDisabled} disabled={rtDisabled}
/> />
</FormField> </FormField>
<FormField label="Commands" htmlFor="sim-commands" hint="One command per line"> <FormField label="Commands" htmlFor="sim-commands" hint="One command per line">
<TextArea <TextArea
id="sim-commands" id="sim-commands"
name="commands" name="commands"
value={rt.commands} value={rt.commands}
onChange={(e) => setRt({ ...rt, commands: e.target.value })} onChange={(e) => setRt({ ...rt, commands: e.target.value })}
disabled={rtDisabled} disabled={rtDisabled}
className="min-h-[160px] font-mono text-[14px]" className="min-h-[160px] font-mono text-[14px]"
/> />
</FormField> </FormField>
<FormField label="Prerequisites" htmlFor="sim-prerequisites"> <FormField label="Prerequisites" htmlFor="sim-prerequisites">
<TextArea <TextArea
id="sim-prerequisites" id="sim-prerequisites"
name="prerequisites" name="prerequisites"
value={rt.prerequisites} value={rt.prerequisites}
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })} onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
disabled={rtDisabled} disabled={rtDisabled}
/> />
</FormField> </FormField>
<FormField label="Executed at" htmlFor="sim-executed-at"> <FormField label="Executed at" htmlFor="sim-executed-at">
<TextInput <TextInput
id="sim-executed-at" id="sim-executed-at"
type="datetime-local" type="datetime-local"
name="executed_at" name="executed_at"
value={rt.executed_at} value={rt.executed_at}
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })} onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
disabled={rtDisabled} disabled={rtDisabled}
/> />
</FormField> </FormField>
<FormField label="Execution result" htmlFor="sim-exec-result"> <FormField label="Execution result" htmlFor="sim-exec-result">
<TextArea <TextArea
id="sim-exec-result" id="sim-exec-result"
name="execution_result" name="execution_result"
value={rt.execution_result} value={rt.execution_result}
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })} onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
disabled={rtDisabled} disabled={rtDisabled}
rows={5} rows={5}
/> />
</FormField> </FormField>
</form>
{/* SOC card */} {!isDone && canEditRT && hasC2Config && (
<form <div className="pt-xs flex items-center gap-md flex-wrap">
id="soc-form" <button
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()} type="button"
noValidate data-testid="c2-execute-btn"
className="card-product flex flex-col gap-md" className="btn-outline"
> onClick={() => setShowC2Modal(true)}
<h2 className="text-[20px] font-medium text-ink">SOC</h2> >
Execute via C2
</button>
<button
type="button"
data-testid="c2-import-trigger-btn"
className="btn-outline"
onClick={() => setShowImportModal(true)}
>
Import C2 history
</button>
</div>
)}
</form>
<FormField label="Log source" htmlFor="sim-log-source"> {/* C2 tasks panel — under RT card, same left column */}
<TextInput {showTasksPanel && simulationId && (
id="sim-log-source" <C2TasksPanel simulationId={simulationId} />
name="log_source" )}
value={soc.log_source} </div>{/* end left column */}
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Logs" htmlFor="sim-logs"> {/* SOC card */}
<TextArea <form
id="sim-logs" id="soc-form"
name="logs" onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
value={soc.logs} noValidate
onChange={(e) => setSoc({ ...soc, logs: e.target.value })} className="card-product flex flex-col gap-md"
disabled={socDisabled} >
/> <h2 className="text-[20px] font-medium text-ink">SOC</h2>
</FormField>
<FormField label="SOC comment" htmlFor="sim-soc-comment"> <FormField label="Log source" htmlFor="sim-log-source">
<TextArea <TextInput
id="sim-soc-comment" id="sim-log-source"
name="soc_comment" name="log_source"
value={soc.soc_comment} value={soc.log_source}
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })} onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
disabled={socDisabled} disabled={socDisabled}
/> />
</FormField> </FormField>
<FormField label="Incident number" htmlFor="sim-incident"> <FormField label="Logs" htmlFor="sim-logs">
<TextInput <TextArea
id="sim-incident" id="sim-logs"
name="incident_number" name="logs"
value={soc.incident_number} value={soc.logs}
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })} onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
disabled={socDisabled} disabled={socDisabled}
/> />
</FormField> </FormField>
</form>
<FormField label="SOC comment" htmlFor="sim-soc-comment">
<TextArea
id="sim-soc-comment"
name="soc_comment"
value={soc.soc_comment}
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Incident number" htmlFor="sim-incident">
<TextInput
id="sim-incident"
name="incident_number"
value={soc.incident_number}
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })}
disabled={socDisabled}
/>
</FormField>
</form>
</div>
{submitError ? ( {submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div> <div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
@@ -509,6 +560,23 @@ export function SimulationFormPage(): JSX.Element {
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}
/> />
)} )}
{showC2Modal && simulationId && typeof engagementId === 'number' && (
<ExecuteViaC2Modal
simulationId={simulationId}
engagementId={engagementId}
initialCommands={rt.commands}
onClose={() => setShowC2Modal(false)}
/>
)}
{showImportModal && simulationId && typeof engagementId === 'number' && (
<ImportC2HistoryModal
simulationId={simulationId}
engagementId={engagementId}
onClose={() => setShowImportModal(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -130,7 +130,7 @@ export function TemplateFormPage(): JSX.Element {
<Link to="/admin/templates" className="btn-text-link text-[14px]"> <Link to="/admin/templates" className="btn-text-link text-[14px]">
Back to templates Back to templates
</Link> </Link>
<h1 className="text-[44px] font-medium leading-none"> <h1 className="text-[32px] font-medium leading-none">
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')} {isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}
</h1> </h1>
</div> </div>
@@ -173,6 +173,7 @@ export function TemplateFormPage(): JSX.Element {
onChange={(e) => setForm({ ...form, commands: e.target.value })} onChange={(e) => setForm({ ...form, commands: e.target.value })}
disabled={isPending} disabled={isPending}
placeholder="e.g. mimikatz.exe&#10;sekurlsa::logonpasswords" placeholder="e.g. mimikatz.exe&#10;sekurlsa::logonpasswords"
className="font-mono text-[14px]"
/> />
</FormField> </FormField>
@@ -228,7 +229,7 @@ export function TemplateFormPage(): JSX.Element {
type="button" type="button"
aria-label="Open MITRE matrix" aria-label="Open MITRE matrix"
onClick={() => { setShowPicker(false); setShowMatrix(true); }} onClick={() => { setShowPicker(false); setShowMatrix(true); }}
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-md border border-steel text-graphite hover:text-ink hover:border-ink transition-colors" className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-none border border-steel text-graphite hover:text-ink hover:border-ink"
> >
<Grid2x2 size={16} /> <Grid2x2 size={16} />
</button> </button>

View File

@@ -36,7 +36,7 @@ export function TemplatesListPage(): JSX.Element {
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header className="flex items-end justify-between gap-md"> <header className="flex items-end justify-between gap-md">
<div> <div>
<h1 className="text-[44px] font-medium leading-none">Templates</h1> <h1 className="text-[32px] font-medium leading-none">Templates</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Reusable simulation blueprints for red team operations. Reusable simulation blueprints for red team operations.
</p> </p>
@@ -94,7 +94,7 @@ export function TemplatesListPage(): JSX.Element {
{mitreCount(t) === 0 ? '—' : mitreCount(t)} {mitreCount(t) === 0 ? '—' : mitreCount(t)}
</td> </td>
<td className="px-xl py-md text-charcoal">{t.created_by.username}</td> <td className="px-xl py-md text-charcoal">{t.created_by.username}</td>
<td className="px-xl py-md text-charcoal">{formatDate(t.updated_at)}</td> <td className="px-xl py-md text-charcoal font-mono">{formatDate(t.updated_at)}</td>
<td className="px-xl py-md text-right"> <td className="px-xl py-md text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link"> <Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link">

View File

@@ -102,7 +102,7 @@ export function UsersAdminPage(): JSX.Element {
return ( return (
<div className="flex flex-col gap-xl"> <div className="flex flex-col gap-xl">
<header> <header>
<h1 className="text-[44px] font-medium leading-none">User accounts</h1> <h1 className="text-[32px] font-medium leading-none">User accounts</h1>
<p className="text-charcoal text-[16px] mt-sm"> <p className="text-charcoal text-[16px] mt-sm">
Manage local accounts. Admins can create new red team or SOC analysts. Manage local accounts. Admins can create new red team or SOC analysts.
</p> </p>
@@ -205,10 +205,10 @@ export function UsersAdminPage(): JSX.Element {
// per-row reconciliation (reset-password state leaked across rows). // per-row reconciliation (reset-password state leaked across rows).
<Fragment key={u.id}> <Fragment key={u.id}>
<tr className="border-b border-hairline last:border-0"> <tr className="border-b border-hairline last:border-0">
<td className="px-xl py-md font-medium text-ink"> <td className="px-xl py-md text-ink">
{u.username} <span className="font-mono font-medium">{u.username}</span>
{isSelf ? ( {isSelf ? (
<span className="ml-sm text-[12px] text-graphite uppercase tracking-[0.5px]"> <span className="ml-sm font-sans text-[12px] text-graphite">
(you) (you)
</span> </span>
) : null} ) : null}
@@ -222,7 +222,7 @@ export function UsersAdminPage(): JSX.Element {
disabled={patchMutation.isPending} disabled={patchMutation.isPending}
/> />
</td> </td>
<td className="px-xl py-md text-charcoal">{u.created_at}</td> <td className="px-xl py-md text-charcoal font-mono">{u.created_at}</td>
<td className="px-xl py-md text-right"> <td className="px-xl py-md text-right">
<div className="inline-flex gap-sm"> <div className="inline-flex gap-sm">
<button <button

View File

@@ -1,6 +1,7 @@
/* /*
* Inter Variable — bundled locally via @fontsource-variable/inter. * Inter Variable — bundled locally via @fontsource-variable/inter.
* JetBrains Mono Variable — bundled locally via @fontsource-variable/jetbrains-mono.
* NO remote CDN / Google Fonts loading at runtime. * NO remote CDN / Google Fonts loading at runtime.
* Forma DJR Micro substitute per DESIGN.md §Note on Font Substitutes.
*/ */
@import '@fontsource-variable/inter/index.css'; @import '@fontsource-variable/inter/index.css';
@import '@fontsource-variable/jetbrains-mono/index.css';

View File

@@ -7,7 +7,7 @@
@layer base { @layer base {
/* Light mode — default */ /* Light mode — default */
:root { :root {
--color-canvas: #ffffff; --color-canvas: #f3f5f8;
--color-paper: #ffffff; --color-paper: #ffffff;
--color-cloud: #f7f7f7; --color-cloud: #f7f7f7;
--color-fog: #e8e8e8; --color-fog: #e8e8e8;
@@ -19,9 +19,11 @@
--color-ink-on: #ffffff; --color-ink-on: #ffffff;
--color-charcoal: #3d3d3d; --color-charcoal: #3d3d3d;
--color-graphite: #636363; --color-graphite: #636363;
/* Semantic status tokens — WCAG AA on canvas and slab surfaces */
/* DESIGN.md: body line-height 1.4 when substituting Inter */ --color-success: #16a34a;
font-size: 16.5px; --color-success-soft: #dcfce7;
--color-warn: #d97706;
--color-warn-soft: #fef3c7;
} }
/* Dark mode overrides */ /* Dark mode overrides */
@@ -38,6 +40,11 @@
--color-ink-on: #111827; --color-ink-on: #111827;
--color-charcoal: #d1d5db; --color-charcoal: #d1d5db;
--color-graphite: #9ca3af; --color-graphite: #9ca3af;
/* Semantic status tokens — dark variants, WCAG AA on #111827 slab */
--color-success: #22c55e;
--color-success-soft: #14532d;
--color-warn: #f59e0b;
--color-warn-soft: #78350f;
} }
html, html,
@@ -48,6 +55,8 @@
body { body {
@apply bg-canvas text-ink font-sans antialiased; @apply bg-canvas text-ink font-sans antialiased;
font-size: 16px;
line-height: 1.4;
font-feature-settings: 'cv11', 'ss01'; font-feature-settings: 'cv11', 'ss01';
} }
@@ -57,19 +66,20 @@
h4, h4,
h5, h5,
h6 { h6 {
line-height: 1; line-height: 1.1;
font-weight: 500; font-weight: 500;
} }
} }
@layer components { @layer components {
/* /*
* DESIGN.md component recipes. * Terminal-SOC brutalist component recipes.
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px). * border-radius: 0 everywhere except .badge-pill-* (status pills only).
* No transition-* on any interactive surface.
*/ */
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center gap-xs bg-primary text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors; @apply inline-flex items-center justify-center gap-xs bg-primary text-white uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
} }
.btn-primary:hover { .btn-primary:hover {
@apply bg-primary-deep; @apply bg-primary-deep;
@@ -78,19 +88,19 @@
@apply bg-steel cursor-not-allowed; @apply bg-steel cursor-not-allowed;
} }
/* btn-ink uses fixed dark slab so it doesn't invert in dark mode */ /* btn-ink: fixed dark slab does not invert in dark mode */
.btn-ink { .btn-ink {
@apply inline-flex items-center justify-center gap-xs bg-slab text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors; @apply inline-flex items-center justify-center gap-xs bg-slab text-slab-text uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
} }
.btn-ink:hover { .btn-ink:hover {
@apply bg-paper; @apply opacity-80;
} }
.btn-ink:disabled { .btn-ink:disabled {
@apply bg-steel cursor-not-allowed; @apply bg-steel cursor-not-allowed;
} }
.btn-outline { .btn-outline {
@apply inline-flex items-center justify-center gap-xs bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors; @apply inline-flex items-center justify-center gap-xs bg-paper text-primary border border-primary uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
} }
.btn-outline:hover { .btn-outline:hover {
@apply bg-primary-soft; @apply bg-primary-soft;
@@ -100,37 +110,41 @@
} }
.btn-outline-ink { .btn-outline-ink {
@apply inline-flex items-center justify-center gap-xs bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors; @apply inline-flex items-center justify-center gap-xs bg-paper text-ink border border-ink uppercase font-semibold text-[14px] leading-[1.4] rounded-none px-xl py-sm h-11;
} }
.btn-outline-ink:hover { .btn-outline-ink:hover {
@apply bg-cloud; @apply bg-cloud;
} }
.btn-text-link { .btn-text-link {
@apply inline-flex items-center gap-xxs text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline; @apply inline-flex items-center gap-xxs text-primary font-medium text-[16px] leading-[1.4] underline-offset-2 hover:underline;
} }
.text-input { .text-input {
@apply block w-full bg-canvas text-ink rounded-md border border-steel px-md py-sm h-11 text-[16px] leading-[1.38] focus:outline-none focus:border-ink; @apply block w-full bg-paper text-ink rounded-none border border-steel px-md py-sm h-11 text-[16px] leading-[1.4] focus:outline-none focus:border-primary;
} }
/* Panel / card — hairline border, no shadow, no radius */
.card-product { .card-product {
@apply bg-canvas rounded-xl p-xl shadow-soft-lift; @apply bg-paper border border-hairline rounded-none p-md;
}
.dark .card-product {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.32);
} }
/* Fixed-color modal backdrop — must not use themed ink (inverts in dark mode) */ /* Fixed modal backdrop — must not use themed ink (inverts in dark mode) */
.modal-backdrop { .modal-backdrop {
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
} }
/* Status pill badges — ONLY surfaces with rounded-pill */
.badge-pill-ink { .badge-pill-ink {
@apply inline-flex items-center bg-ink text-white rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium; @apply inline-flex items-center bg-ink text-white rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
} }
.badge-pill-outline { .badge-pill-outline {
@apply inline-flex items-center bg-canvas text-ink border border-ink rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium; @apply inline-flex items-center bg-canvas text-ink border border-ink rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
}
/* MITRE technique tags — angular, not pills */
.tag-mitre {
@apply inline-flex items-center bg-cloud text-ink border border-hairline rounded-none px-2 py-[2px] font-mono text-[12px] leading-[1.33];
} }
} }

View File

@@ -1,8 +1,8 @@
import type { Config } from 'tailwindcss'; import type { Config } from 'tailwindcss';
/** /**
* Tokens mirror DESIGN.md. * Tokens mirror DESIGN.md (terminal-SOC aesthetic, sprint 7).
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter). * Inter Variable: body/headers/labels. JetBrains Mono Variable: data cells only.
* Dark mode: class-based, toggled by adding 'dark' to <html>. * Dark mode: class-based, toggled by adding 'dark' to <html>.
*/ */
const config: Config = { const config: Config = {
@@ -11,7 +11,7 @@ const config: Config = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Brand & Accent — primary stays fixed (HP Electric Blue never inverts) // Brand & Accent — primary stays fixed (never inverts)
primary: { primary: {
DEFAULT: '#024ad8', DEFAULT: '#024ad8',
bright: '#296ef9', bright: '#296ef9',
@@ -34,10 +34,19 @@ const config: Config = {
}, },
charcoal: 'var(--color-charcoal)', charcoal: 'var(--color-charcoal)',
graphite: 'var(--color-graphite)', graphite: 'var(--color-graphite)',
// Fixed dark slab — never inverts in dark mode (utility strip, footer, dark bands) // Fixed dark slab — never inverts in dark mode
slab: '#111827', slab: '#111827',
'slab-text': '#f9fafb', 'slab-text': '#f9fafb',
'slab-muted': '#6b7280', 'slab-muted': '#6b7280',
// Semantic status tokens — light+dark variants via CSS vars
success: {
DEFAULT: 'var(--color-success)',
soft: 'var(--color-success-soft)',
},
warn: {
DEFAULT: 'var(--color-warn)',
soft: 'var(--color-warn-soft)',
},
// Semantic / decorative — fixed (not themeable) // Semantic / decorative — fixed (not themeable)
bloom: { bloom: {
coral: '#ff5050', coral: '#ff5050',
@@ -53,26 +62,28 @@ const config: Config = {
}, },
fontFamily: { fontFamily: {
sans: ['"Inter Variable"', 'Inter', 'Arial', 'sans-serif'], sans: ['"Inter Variable"', 'Inter', 'Arial', 'sans-serif'],
mono: ['"JetBrains Mono Variable"', '"JetBrains Mono"', 'ui-monospace', 'monospace'],
}, },
fontSize: { fontSize: {
// DESIGN.md typography scale // Terminal-SOC display scale — reduced per §0 D9
'display-xxl': ['72px', { lineHeight: '1.0', fontWeight: '500' }], 'display-xxl': ['40px', { lineHeight: '1.1', fontWeight: '500' }],
'display-xl': ['56px', { lineHeight: '1.0', fontWeight: '500' }], 'display-xl': ['32px', { lineHeight: '1.1', fontWeight: '500' }],
'display-lg': ['44px', { lineHeight: '1.0', fontWeight: '500' }], 'display-lg': ['28px', { lineHeight: '1.1', fontWeight: '500' }],
'display-md': ['32px', { lineHeight: '1.0', fontWeight: '500' }], 'display-md': ['24px', { lineHeight: '1.1', fontWeight: '500' }],
'display-sm': ['24px', { lineHeight: '1.17', fontWeight: '500' }], 'display-sm': ['20px', { lineHeight: '1.1', fontWeight: '500' }],
'display-xs': ['20px', { lineHeight: '1.0', fontWeight: '500' }], 'display-xs': ['16px', { lineHeight: '1.1', fontWeight: '600' }],
'body-lg': ['18px', { lineHeight: '1.33', fontWeight: '400' }], 'body-lg': ['18px', { lineHeight: '1.4', fontWeight: '400' }],
'body-md': ['16px', { lineHeight: '1.38', fontWeight: '400' }], 'body-md': ['16px', { lineHeight: '1.4', fontWeight: '400' }],
'body-emphasis': ['16px', { lineHeight: '1.38', fontWeight: '500' }], 'body-emphasis': ['16px', { lineHeight: '1.4', fontWeight: '500' }],
'caption-md': ['14px', { lineHeight: '1.5', fontWeight: '400' }], 'caption-md': ['14px', { lineHeight: '1.5', fontWeight: '400' }],
'caption-bold': ['14px', { lineHeight: '1.3', fontWeight: '700' }], 'caption-bold': ['14px', { lineHeight: '1.3', fontWeight: '700' }],
'caption-sm': ['12px', { lineHeight: '1.33', fontWeight: '400' }], 'caption-sm': ['12px', { lineHeight: '1.33', fontWeight: '400' }],
'link-md': ['16px', { lineHeight: '1.38', fontWeight: '500' }], 'link-md': ['16px', { lineHeight: '1.4', fontWeight: '500' }],
'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600', letterSpacing: '0.7px' }], 'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600' }],
'button-sm': ['12.6px', { lineHeight: '1.0', fontWeight: '700' }],
}, },
spacing: { spacing: {
// DESIGN.md spacing tokens (named, complement Tailwind defaults) // Named tokens complement Tailwind defaults
xxs: '4px', xxs: '4px',
xs: '8px', xs: '8px',
sm: '12px', sm: '12px',
@@ -80,24 +91,13 @@ const config: Config = {
lg: '20px', lg: '20px',
xl: '24px', xl: '24px',
xxl: '32px', xxl: '32px',
section: '80px', section: '48px',
}, },
borderRadius: { borderRadius: {
// DESIGN.md radius tokens // Brutalist: 0 everywhere except status pills and avatars
none: '0px', none: '0px',
xs: '2px',
sm: '3px',
md: '4px',
lg: '8px',
xl: '16px',
pill: '9999px', pill: '9999px',
}, },
boxShadow: {
'soft-lift': '0 2px 8px rgba(26, 26, 26, 0.08)',
floating: '0 8px 24px rgba(26, 26, 26, 0.12)',
'soft-lift-dark': '0 2px 8px rgba(0, 0, 0, 0.32)',
'floating-dark': '0 8px 24px rgba(0, 0, 0, 0.48)',
},
maxWidth: { maxWidth: {
page: '1366px', page: '1366px',
}, },

View File

@@ -0,0 +1,108 @@
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 { EngagementFormPage } from '@/pages/EngagementFormPage';
import { renderWithProviders } from './utils';
import type { Engagement } from '@/api/types';
const ENGAGEMENT: Engagement = {
id: 5,
name: 'Test Engagement',
description: null,
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';
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',
}),
}));
function EditPage() {
return (
<Routes>
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
</Routes>
);
}
function NewPage() {
return (
<Routes>
<Route path="/engagements/new" element={<EngagementFormPage />} />
</Routes>
);
}
describe('EngagementFormPage — C2 config card visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/5').reply(200, ENGAGEMENT);
mock.onGet('/engagements/5/c2-config').reply(404);
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
it('shows C2 config card in EDIT mode for admin', async () => {
mockRole = 'admin';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-config-card')).toBeInTheDocument();
});
});
it('shows C2 config card in EDIT mode for redteam', async () => {
mockRole = 'redteam';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-config-card')).toBeInTheDocument();
});
});
it('does NOT show C2 config card in EDIT mode for SOC', async () => {
mockRole = 'soc';
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/5/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-config-card')).toBeNull();
});
it('does NOT show C2 config card on the NEW engagement form', async () => {
mockRole = 'admin';
renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/new'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /create engagement/i })).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-config-card')).toBeNull();
});
});

View File

@@ -67,6 +67,7 @@ describe('SimulationFormPage — redteam mode (edit existing)', () => {
mockRole = 'redteam'; mockRole = 'redteam';
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM); mock.onGet('/simulations/7').reply(200, BASE_SIM);
mock.onGet('/engagements/42/c2-config').reply(404);
}); });
afterEach(() => { afterEach(() => {
@@ -154,6 +155,8 @@ describe('SimulationFormPage — SOC role + pending (blocked)', () => {
mockRole = 'soc'; mockRole = 'soc';
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM); mock.onGet('/simulations/7').reply(200, BASE_SIM);
// SOC role: useC2Config disabled (canEditRT=false), so no request expected — stub anyway
mock.onGet('/engagements/42/c2-config').reply(404);
}); });
afterEach(() => { afterEach(() => {
@@ -202,6 +205,7 @@ describe('SimulationFormPage — SOC role + review_required (can edit SOC fields
mockRole = 'soc'; mockRole = 'soc';
mock = new MockAdapter(apiClient); mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' }); mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
mock.onGet('/engagements/42/c2-config').reply(404);
}); });
afterEach(() => { afterEach(() => {
@@ -273,3 +277,160 @@ describe('SimulationFormPage — new simulation', () => {
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
}); });
}); });
describe('SimulationFormPage — Execute via C2 button visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows Execute via C2 button when c2 config exists', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-execute-btn')).toBeInTheDocument();
});
});
it('hides Execute via C2 button when no c2 config (404)', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
});
it('hides Execute via C2 button when simulation is done', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'done' });
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('reopen-btn')).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-execute-btn')).toBeNull();
});
});
describe('SimulationFormPage — C2 tasks panel visibility', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows C2 tasks panel when c2 config exists (even with no tasks)', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
});
it('hides C2 tasks panel when no c2 config and no tasks', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
// Wait for page data to load then confirm no panel
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
});
it('shows C2 tasks panel when tasks exist even without c2 config', async () => {
mock.onGet('/engagements/42/c2-config').reply(404);
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'SYSTEM',
mapping_applied: false,
source: 'import',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
});
it('SOC role never sees C2 tasks panel', async () => {
mockRole = 'soc';
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
});
expect(screen.queryByTestId('c2-tasks-panel')).toBeNull();
});
it('shows Import C2 history button when c2 config exists', async () => {
mock.onGet('/engagements/42/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('c2-import-trigger-btn')).toBeInTheDocument();
});
});
});

View File

@@ -18,10 +18,10 @@ describe('SimulationStatusBadge', () => {
expect(badge.textContent).toBe(label); expect(badge.textContent).toBe(label);
}); });
it('applies fog surface for pending', () => { it('applies cloud surface for pending (terminal-SOC semantic tokens)', () => {
render(<SimulationStatusBadge status="pending" />); render(<SimulationStatusBadge status="pending" />);
const badge = screen.getByTestId('simulation-status-badge'); const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-fog'); expect(badge.className).toContain('bg-cloud');
}); });
it('applies primary-soft surface for in_progress', () => { it('applies primary-soft surface for in_progress', () => {
@@ -30,15 +30,15 @@ describe('SimulationStatusBadge', () => {
expect(badge.className).toContain('bg-primary-soft'); expect(badge.className).toContain('bg-primary-soft');
}); });
it('applies bloom-coral surface for review_required', () => { it('applies warn-soft surface for review_required (terminal-SOC semantic tokens)', () => {
render(<SimulationStatusBadge status="review_required" />); render(<SimulationStatusBadge status="review_required" />);
const badge = screen.getByTestId('simulation-status-badge'); const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-bloom-coral'); expect(badge.className).toContain('bg-warn-soft');
}); });
it('applies storm-deep surface for done', () => { it('applies success-soft surface for done (terminal-SOC semantic tokens)', () => {
render(<SimulationStatusBadge status="done" />); render(<SimulationStatusBadge status="done" />);
const badge = screen.getByTestId('simulation-status-badge'); const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-storm-deep'); expect(badge.className).toContain('bg-success-soft');
}); });
}); });

View File

@@ -9,4 +9,19 @@ describe('StatusBadge', () => {
expect(badge).toHaveAttribute('data-status', status); expect(badge).toHaveAttribute('data-status', status);
expect(badge.textContent?.toLowerCase()).toBe(status); expect(badge.textContent?.toLowerCase()).toBe(status);
}); });
it('applies warn-soft surface for planned', () => {
render(<StatusBadge status="planned" />);
expect(screen.getByTestId('status-badge').className).toContain('bg-warn-soft');
});
it('applies primary-soft surface for active', () => {
render(<StatusBadge status="active" />);
expect(screen.getByTestId('status-badge').className).toContain('bg-primary-soft');
});
it('applies cloud surface for closed', () => {
render(<StatusBadge status="closed" />);
expect(screen.getByTestId('status-badge').className).toContain('bg-cloud');
});
}); });

View File

@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import {
deleteC2Config,
executeC2,
getC2Config,
getC2Tasks,
importC2,
listCallbackHistory,
listCallbacks,
putC2Config,
testC2Config,
} from '@/api/c2';
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
describe('getC2Config', () => {
it('returns config on 200', async () => {
mock.onGet('/engagements/1/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
const result = await getC2Config(1);
expect(result).toEqual({ has_token: true, url: 'https://mythic.lab:7443', verify_tls: true });
});
it('returns null on 404', async () => {
mock.onGet('/engagements/1/c2-config').reply(404);
const result = await getC2Config(1);
expect(result).toBeNull();
});
it('throws on other errors', async () => {
mock.onGet('/engagements/1/c2-config').reply(503);
await expect(getC2Config(1)).rejects.toThrow();
});
});
describe('putC2Config', () => {
it('sends PUT to correct URL with body', async () => {
mock.onPut('/engagements/2/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: false,
});
const result = await putC2Config(2, {
url: 'https://mythic.lab:7443',
api_token: 'secret',
verify_tls: false,
});
expect(result.has_token).toBe(true);
expect(result.verify_tls).toBe(false);
const req = mock.history['put'][0];
expect(req.url).toBe('/engagements/2/c2-config');
const body = JSON.parse(req.data as string);
expect(body.api_token).toBe('secret');
expect(body.verify_tls).toBe(false);
});
});
describe('deleteC2Config', () => {
it('sends DELETE to correct URL', async () => {
mock.onDelete('/engagements/3/c2-config').reply(204);
await expect(deleteC2Config(3)).resolves.toBeUndefined();
expect(mock.history['delete'][0].url).toBe('/engagements/3/c2-config');
});
});
describe('testC2Config', () => {
it('sends POST and returns test result', async () => {
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
const result = await testC2Config(1);
expect(result.ok).toBe(true);
expect(result.error).toBeNull();
expect(mock.history['post'][0].url).toBe('/engagements/1/c2-config/test');
});
it('returns error message when connection fails', async () => {
mock
.onPost('/engagements/1/c2-config/test')
.reply(200, { ok: false, error: 'Connection refused' });
const result = await testC2Config(1);
expect(result.ok).toBe(false);
expect(result.error).toBe('Connection refused');
});
});
describe('listCallbacks', () => {
it('sends GET and returns callbacks', async () => {
mock.onGet('/engagements/1/c2/callbacks').reply(200, {
callbacks: [
{
display_id: 1,
active: true,
host: 'WIN-TARGET',
user: 'administrator',
domain: 'lab.local',
last_checkin: '2026-06-10T10:00:00',
},
],
});
const result = await listCallbacks(1);
expect(result.callbacks).toHaveLength(1);
expect(result.callbacks[0].display_id).toBe(1);
expect(result.callbacks[0].host).toBe('WIN-TARGET');
expect(mock.history['get'][0].url).toBe('/engagements/1/c2/callbacks');
});
});
describe('executeC2', () => {
it('sends POST with callback_display_id and commands', async () => {
mock.onPost('/simulations/5/c2/execute').reply(200, {
tasks: [
{ id: 1, mythic_task_display_id: 42, command: 'whoami', status: 'submitted', completed: false },
],
});
const result = await executeC2(5, {
callback_display_id: 1,
commands: ['whoami'],
});
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].command).toBe('whoami');
const req = mock.history['post'][0];
expect(req.url).toBe('/simulations/5/c2/execute');
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(1);
expect(body.commands).toEqual(['whoami']);
});
});
describe('getC2Tasks', () => {
it('GET /simulations/:id/c2/tasks returns tasks list', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, {
tasks: [
{
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'NT AUTHORITY\\SYSTEM',
mapping_applied: true,
source: 'mimic',
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
},
],
});
const result = await getC2Tasks(7);
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].status).toBe('completed');
expect(result.tasks[0].output).toBe('NT AUTHORITY\\SYSTEM');
expect(mock.history['get'][0].url).toBe('/simulations/7/c2/tasks');
});
});
describe('listCallbackHistory', () => {
it('GET with page/page_size params', async () => {
mock.onGet('/engagements/1/c2/callbacks/2/history').reply(200, {
tasks: [],
total: 0,
page: 1,
page_size: 25,
});
const result = await listCallbackHistory(1, 2, { page: 1, pageSize: 25 });
expect(result.total).toBe(0);
const req = mock.history['get'][0];
expect(req.url).toBe('/engagements/1/c2/callbacks/2/history');
expect(req.params).toMatchObject({ page: 1, page_size: 25 });
});
});
describe('importC2', () => {
it('POST /simulations/:id/c2/import with task_display_ids', async () => {
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 3, skipped: 1 });
const result = await importC2(7, {
callback_display_id: 2,
task_display_ids: [10, 11, 12, 13],
});
expect(result.imported).toBe(3);
expect(result.skipped).toBe(1);
const req = mock.history['post'][0];
expect(req.url).toBe('/simulations/7/c2/import');
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(2);
expect(body.task_display_ids).toEqual([10, 11, 12, 13]);
});
});

View File

@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { C2ConfigCard } from '@/components/C2ConfigCard';
import { renderWithProviders } from '../utils';
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,
}),
}));
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
describe('C2ConfigCard — no config (404)', () => {
it('renders the card with empty fields when no config exists', async () => {
mock.onGet('/engagements/1/c2-config').reply(404);
renderWithProviders(<C2ConfigCard engagementId={1} />);
// Wait for loading to finish — query resolves to null on 404
await waitFor(() => {
expect(screen.getByTestId('c2-url-input')).toBeInTheDocument();
});
expect(screen.getByTestId('c2-token-input')).toBeInTheDocument();
expect(screen.getByTestId('c2-verify-tls')).toBeInTheDocument();
expect(screen.getByTestId('c2-save-btn')).toBeInTheDocument();
// Delete button only shown when has_token
expect(screen.queryByTestId('c2-delete-btn')).toBeNull();
});
});
describe('C2ConfigCard — with config (has_token=true)', () => {
beforeEach(() => {
mock.onGet('/engagements/1/c2-config').reply(200, {
has_token: true,
url: 'https://mythic.lab:7443',
verify_tls: true,
});
});
it('shows Replace token affordance when has_token=true', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument();
});
// Token input shows placeholder bullets (readOnly)
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
expect(tokenInput.readOnly).toBe(true);
expect(tokenInput.placeholder).toBe('••••••••');
});
it('shows Delete configuration button when has_token=true', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-delete-btn')).toBeInTheDocument();
});
});
it('clicking Replace token makes input editable', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Replace token'));
await waitFor(() => {
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
expect(tokenInput.readOnly).toBeFalsy();
});
});
it('Test connection button is enabled when config exists', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
});
it('shows Connected on successful test', async () => {
mock.onPost('/engagements/1/c2-config/test').reply(200, { ok: true, error: null });
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('c2-test-btn'));
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
});
it('shows error message on failed test', async () => {
mock
.onPost('/engagements/1/c2-config/test')
.reply(200, { ok: false, error: 'Connection refused' });
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(screen.getByTestId('c2-test-btn')).not.toBeDisabled();
});
fireEvent.click(screen.getByTestId('c2-test-btn'));
await waitFor(() => {
expect(screen.getByText('Connection refused')).toBeInTheDocument();
});
});
});
describe('C2ConfigCard — 503 disabled state', () => {
it('shows 503 banner and disables all inputs', async () => {
mock.onGet('/engagements/1/c2-config').reply(503);
renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => {
expect(
screen.getByText(/C2 features are disabled/i),
).toBeInTheDocument();
});
expect(screen.getByTestId('c2-save-btn')).toBeDisabled();
expect(screen.getByTestId('c2-url-input')).toBeDisabled();
expect(screen.getByTestId('c2-token-input')).toBeDisabled();
expect(screen.getByTestId('c2-verify-tls')).toBeDisabled();
});
});

View File

@@ -0,0 +1,220 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { C2TasksPanel } from '@/components/C2TasksPanel';
import { renderWithProviders } from '../utils';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'redteam', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: false,
isRedteam: true,
isSoc: false,
canEditEngagements: true,
}),
}));
const COMPLETED_TASK = {
id: 1,
mythic_task_display_id: 10,
callback_display_id: 1,
command: 'whoami',
params: null,
status: 'completed',
completed: true,
output: 'NT AUTHORITY\\SYSTEM',
mapping_applied: true,
source: 'mimic' as const,
created_at: '2026-06-10T10:00:00',
completed_at: '2026-06-10T10:00:05',
};
const PENDING_TASK = {
id: 2,
mythic_task_display_id: 11,
callback_display_id: 1,
command: 'ipconfig',
params: null,
status: 'submitted',
completed: false,
output: null,
mapping_applied: false,
source: 'import' as const,
created_at: '2026-06-10T10:00:10',
completed_at: null,
};
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
describe('C2TasksPanel — empty state', () => {
it('shows empty state copy when tasks array is empty', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
expect(screen.getByText(/No C2 tasks yet/i)).toBeInTheDocument();
expect(screen.queryByTestId('c2-task-row')).toBeNull();
});
});
describe('C2TasksPanel — populated rows', () => {
beforeEach(() => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [COMPLETED_TASK, PENDING_TASK] });
});
it('renders one row per task', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(2);
});
});
it('displays task command and mythic display id', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('whoami')).toBeInTheDocument();
expect(screen.getByText('ipconfig')).toBeInTheDocument();
expect(screen.getByText('#10')).toBeInTheDocument();
expect(screen.getByText('#11')).toBeInTheDocument();
});
});
it('shows MIMIC source badge for source=mimic', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('MIMIC')).toBeInTheDocument();
});
});
it('shows IMPORT source badge for source=import', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('IMPORT')).toBeInTheDocument();
});
});
it('shows completed_at timestamp for completed task', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('2026-06-10T10:00:05')).toBeInTheDocument();
});
});
it('shows em dash for null completed_at', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument();
});
});
});
describe('C2TasksPanel — expand on click', () => {
beforeEach(() => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [COMPLETED_TASK] });
});
it('output row is hidden before click', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
expect(screen.queryByTestId('c2-task-output')).toBeNull();
});
it('clicking a completed row reveals the output', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
fireEvent.click(screen.getByTestId('c2-task-row'));
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
expect(screen.getByTestId('c2-task-output')).toHaveTextContent('NT AUTHORITY\\SYSTEM');
});
it('clicking the expanded row collapses the output', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
fireEvent.click(screen.getByTestId('c2-task-row'));
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('c2-task-row'));
expect(screen.queryByTestId('c2-task-output')).toBeNull();
});
it('clicking an incomplete task row does not expand', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [PENDING_TASK] });
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
fireEvent.click(screen.getByTestId('c2-task-row'));
expect(screen.queryByTestId('c2-task-output')).toBeNull();
});
it('Enter key on completed row toggles output (a11y)', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
const row = screen.getByTestId('c2-task-row');
fireEvent.keyDown(row, { key: 'Enter' });
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
fireEvent.keyDown(row, { key: 'Enter' });
expect(screen.queryByTestId('c2-task-output')).toBeNull();
});
it('Space key on completed row toggles output (a11y)', async () => {
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
const row = screen.getByTestId('c2-task-row');
fireEvent.keyDown(row, { key: ' ' });
expect(screen.getByTestId('c2-task-output')).toBeInTheDocument();
});
});
describe('C2TasksPanel — refresh indicator', () => {
it('does not show refresh indicator on initial load', async () => {
mock.onGet('/simulations/7/c2/tasks').reply(200, { tasks: [] });
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
});
// During isLoading, isFetching is true but isRefreshing = isFetching && !isLoading = false
expect(screen.queryByTestId('c2-task-refresh-indicator')).toBeNull();
});
});
describe('C2TasksPanel — polling behaviour', () => {
it('does not refetch when all tasks are completed (refetchInterval false)', async () => {
// With all completed tasks, refetchInterval returns false — only one GET call expected
let callCount = 0;
mock.onGet('/simulations/7/c2/tasks').reply(() => {
callCount++;
return [200, { tasks: [COMPLETED_TASK] }];
});
renderWithProviders(<C2TasksPanel simulationId={7} />);
await waitFor(() => {
expect(screen.getAllByTestId('c2-task-row')).toHaveLength(1);
});
// Wait a bit and confirm no extra fetches happened beyond initial
await new Promise((r) => setTimeout(r, 100));
expect(callCount).toBe(1);
});
});

View File

@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
import { renderWithProviders } from '../utils';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'redteam', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: false,
isRedteam: true,
isSoc: false,
canEditEngagements: true,
}),
}));
const CALLBACKS = [
{
display_id: 1,
active: true,
host: 'WIN-TARGET',
user: 'administrator',
domain: 'lab.local',
last_checkin: '2026-06-10T10:00:00',
},
{
display_id: 2,
active: false,
host: 'WIN-DC01',
user: 'SYSTEM',
domain: 'lab.local',
last_checkin: '2026-06-10T09:00:00',
},
];
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/42/c2/callbacks').reply(200, { callbacks: CALLBACKS });
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
function renderModal(initialCommands = 'whoami\nipconfig') {
const onClose = vi.fn();
renderWithProviders(
<ExecuteViaC2Modal
simulationId={7}
engagementId={42}
initialCommands={initialCommands}
onClose={onClose}
/>,
);
return { onClose };
}
describe('ExecuteViaC2Modal', () => {
it('renders modal with title and callback table', async () => {
renderModal();
expect(screen.getByTestId('c2-modal')).toBeInTheDocument();
expect(screen.getByText('Execute via C2')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
});
it('renders callback rows with mono data', async () => {
renderModal();
await waitFor(() => {
expect(screen.getByText('WIN-TARGET')).toBeInTheDocument();
expect(screen.getByText('WIN-DC01')).toBeInTheDocument();
expect(screen.getByText('administrator')).toBeInTheDocument();
});
});
it('Launch button is disabled before selecting a callback', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
expect(screen.getByTestId('c2-launch-btn')).toBeDisabled();
});
it('Launch button is disabled when commands are empty', async () => {
renderModal('');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
expect(screen.getByTestId('c2-launch-btn')).toBeDisabled();
});
it('Launch button enabled after selecting row and having commands', async () => {
renderModal('whoami');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
expect(screen.getByTestId('c2-launch-btn')).not.toBeDisabled();
});
it('calls executeC2 with correct body and closes modal on success', async () => {
mock.onPost('/simulations/7/c2/execute').reply(200, {
tasks: [
{ id: 1, mythic_task_display_id: 10, command: 'whoami', status: 'submitted', completed: false },
{ id: 2, mythic_task_display_id: 11, command: 'ipconfig', status: 'submitted', completed: false },
],
});
const { onClose } = renderModal('whoami\nipconfig');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
fireEvent.click(screen.getByTestId('c2-launch-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
const req = mock.history['post'][0];
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(1);
expect(body.commands).toEqual(['whoami', 'ipconfig']);
});
it('shows inline error and keeps modal open on executeC2 failure', async () => {
mock.onPost('/simulations/7/c2/execute').reply(500, { error: 'Mythic unreachable' });
const { onClose } = renderModal('whoami');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-callback-row')[0]);
fireEvent.click(screen.getByTestId('c2-launch-btn'));
await waitFor(() => {
expect(screen.getByText('Mythic unreachable')).toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
});
it('Cancel button calls onClose', async () => {
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('prefills commands textarea from initialCommands', async () => {
renderModal('net user\nwhoami /all');
const textarea = screen.getByTestId('c2-commands-textarea') as HTMLTextAreaElement;
expect(textarea.value).toBe('net user\nwhoami /all');
});
it('Enter key on callback row selects it (a11y)', async () => {
renderModal('whoami');
await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
});
const firstRow = screen.getAllByTestId('c2-callback-row')[0];
fireEvent.keyDown(firstRow, { key: 'Enter' });
// Row is now selected → Launch button should be enabled
expect(screen.getByTestId('c2-launch-btn')).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,380 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
import { ToastViewport } from '@/components/Toast';
import { renderWithProviders } from '../utils';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'redteam', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: false,
isRedteam: true,
isSoc: false,
canEditEngagements: true,
}),
}));
const CALLBACKS = [
{
display_id: 1,
active: true,
host: 'WIN-TARGET',
user: 'administrator',
domain: 'lab.local',
last_checkin: '2026-06-10T10:00:00',
},
{
display_id: 2,
active: false,
host: 'WIN-DC01',
user: 'SYSTEM',
domain: 'lab.local',
last_checkin: '2026-06-10T09:00:00',
},
];
const HISTORY_TASKS = [
{
display_id: 10,
command: 'whoami',
status: 'completed',
completed: true,
completed_at: '2026-06-10T10:00:05',
created_at: '2026-06-10T10:00:00',
},
{
display_id: 11,
command: 'ipconfig',
status: 'completed',
completed: true,
completed_at: '2026-06-10T10:00:10',
created_at: '2026-06-10T10:00:05',
},
];
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
mock.onGet('/engagements/42/c2/callbacks').reply(200, { callbacks: CALLBACKS });
});
afterEach(() => {
mock.restore();
vi.clearAllMocks();
});
function renderModal() {
const onClose = vi.fn();
renderWithProviders(
<>
<ImportC2HistoryModal
simulationId={7}
engagementId={42}
onClose={onClose}
/>
<ToastViewport />
</>,
);
return { onClose };
}
describe('ImportC2HistoryModal — step 1: callback picker', () => {
it('renders modal with title and callback rows', async () => {
renderModal();
expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument();
expect(screen.getByText('Import C2 history')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
});
it('history table is not shown before selecting a callback', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
expect(screen.queryByTestId('c2-history-row')).toBeNull();
});
it('Import button is disabled with no selection', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
});
});
describe('ImportC2HistoryModal — step 2: history table appears after callback select', () => {
beforeEach(() => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: HISTORY_TASKS,
total: 2,
page: 1,
page_size: 25,
});
});
it('shows history table after selecting a callback', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
});
it('shows history task commands in the table', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getByText('whoami')).toBeInTheDocument();
expect(screen.getByText('ipconfig')).toBeInTheDocument();
});
});
});
describe('ImportC2HistoryModal — multi-checkbox selection', () => {
beforeEach(() => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: HISTORY_TASKS,
total: 2,
page: 1,
page_size: 25,
});
});
it('Import button remains disabled with no tasks checked', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
});
it('Import button becomes enabled after checking a task row', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
expect(screen.getByTestId('c2-import-submit-btn')).not.toBeDisabled();
});
it('checking via checkbox also enables Import', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row-checkbox')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-history-row-checkbox')[1]);
expect(screen.getByTestId('c2-import-submit-btn')).not.toBeDisabled();
});
it('unchecking a row disables Import when it was the only selection', async () => {
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
// Check then uncheck
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
expect(screen.getByTestId('c2-import-submit-btn')).toBeDisabled();
});
});
describe('ImportC2HistoryModal — pagination', () => {
it('shows Prev/Next buttons when tasks exceed page_size', async () => {
// 30 tasks, page_size 25 → 2 pages
const manyTasks = Array.from({ length: 25 }, (_, i) => ({
display_id: i + 1,
command: `cmd${i + 1}`,
status: 'completed',
completed: true,
completed_at: '2026-06-10T10:00:00',
created_at: '2026-06-10T10:00:00',
}));
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: manyTasks,
total: 30,
page: 1,
page_size: 25,
});
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getByTestId('c2-history-prev')).toBeInTheDocument();
expect(screen.getByTestId('c2-history-next')).toBeInTheDocument();
});
});
it('Prev is disabled on page 1', async () => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: HISTORY_TASKS,
total: 50,
page: 1,
page_size: 25,
});
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getByTestId('c2-history-prev')).toBeDisabled();
});
expect(screen.getByTestId('c2-history-next')).not.toBeDisabled();
});
});
describe('ImportC2HistoryModal — submit payload', () => {
beforeEach(() => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: HISTORY_TASKS,
total: 2,
page: 1,
page_size: 25,
});
});
it('sends correct callback_display_id and task_display_ids on import', async () => {
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 2, skipped: 0 });
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(2);
});
// Select both tasks
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getAllByTestId('c2-history-row')[1]);
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
const req = mock.history['post'][0];
expect(req.url).toBe('/simulations/7/c2/import');
const body = JSON.parse(req.data as string);
expect(body.callback_display_id).toBe(1);
expect(body.task_display_ids).toContain(10);
expect(body.task_display_ids).toContain(11);
});
});
describe('ImportC2HistoryModal — toast wording', () => {
beforeEach(() => {
mock.onGet('/engagements/42/c2/callbacks/1/history').reply(200, {
tasks: [HISTORY_TASKS[0]],
total: 1,
page: 1,
page_size: 25,
});
});
it('shows "Imported N task(s)" when skipped is 0', async () => {
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 1, skipped: 0 });
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
});
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => {
expect(screen.getByText('Imported 1 task(s)')).toBeInTheDocument();
});
});
it('shows skipped count when skipped > 0', async () => {
mock.onPost('/simulations/7/c2/import').reply(200, { imported: 0, skipped: 1 });
renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
});
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => {
expect(
screen.getByText('Imported 0 task(s), 1 already attached'),
).toBeInTheDocument();
});
});
it('shows inline error and keeps modal open on import failure', async () => {
mock.onPost('/simulations/7/c2/import').reply(500, { error: 'Import failed' });
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getAllByTestId('c2-import-callback-row')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('c2-history-row')).toHaveLength(1);
});
fireEvent.click(screen.getAllByTestId('c2-history-row')[0]);
fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => {
expect(screen.getByText('Import failed')).toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
});
});
describe('ImportC2HistoryModal — Cancel button', () => {
it('Cancel button calls onClose', async () => {
const { onClose } = renderModal();
await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
});
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -7,7 +7,7 @@
- **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. - **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 ## Test plan
- **Backend** : **255/255** pytest (`ruff` + `mypy` clean). - **Backend** : **257/257** pytest (`ruff` + `mypy` clean).
- **Frontend** : **136/136** vitest (`typecheck` + `lint` clean). - **Frontend** : **136/136** vitest (`typecheck` + `lint` clean).
- **E2e Playwright** : **223/223** verts — baseline sprint 5 = 201, +22 sprint 6. - **E2e Playwright** : **223/223** verts — baseline sprint 5 = 201, +22 sprint 6.

View File

@@ -1,281 +1,73 @@
# Sprint 6Engagement export (Markdown + CSV + PDF) # Sprint 9UI: engagement 2-col + global contrast pass
> Branch : `sprint/6-export` · Worktree : `.claude/worktrees/sprint-6-export` · Base : `main` @ `678ee8f` **Base**: `sprint/8-c2` (sprint 8 not yet merged on origin/main, but its `C2ConfigCard` is the right pane).
**Scope**: frontend-only. No backend, no schema. No new features.
## §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.
--- ---
## §1 — Backend (Sonnet · backend-builder) ## Decisions (locked)
### Modèle de données 1. **Engagement page** : passer en **2 colonnes** sur desktop (`lg:grid-cols-2`), `[engagement form | C2ConfigCard]`. Mobile/tablet : stack vertical (comportement actuel).
**Aucun changement** de modèle. Pas de migration. L'export est en lecture seule sur les modèles existants `Engagement` + `Simulation`. 2. **Contraste global** : le problème est que `canvas` (page bg) et `paper` (card bg) sont **tous deux `#ffffff`** en light mode. Les cartes ne ressortent que par leur hairline 1px → fatigue oculaire confirmée par l'utilisateur.
3. **Fix retenu** : **tinter le canvas light** d'un neutre froid très pâle. `paper` reste blanc pur. Les cartes "lèvent" naturellement sans casser le brutalisme.
### Services / serializers - Proposition : `canvas` light `#f3f5f8` (gris-bleu très pâle, cohérent avec l'electric blue), `paper` light `#ffffff`.
- Nouveau module **`backend/app/services/export.py`** avec 3 fonctions pures testables unitairement : - Dark mode **inchangé** (`canvas #111827` / `paper #1f2937` déjà différenciés).
- `render_engagement_markdown(engagement: Engagement, simulations: list[Simulation]) -> str` 4. **Pas de shadow**, pas de radius. La brutalité reste intacte — seul le contraste de surface change.
- `render_engagement_csv(engagement: Engagement, simulations: list[Simulation]) -> str` 5. **Hairline** : à vérifier sur le nouveau canvas. Si nécessaire, passer `hairline` light de la valeur actuelle à un poil plus sombre pour préserver la lisibilité du bord sur tinted canvas. Mais éviter si la lecture est déjà bonne.
- `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).
### 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.**
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)
### 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** :
```
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.
### 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
--- ---
## §2 — Frontend (Sonnet · frontend-builder) ## Task A — EngagementFormPage 2-col
### Composants **File** : `frontend/src/pages/EngagementFormPage.tsx`
- **`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).
### API client - Remplacer le wrapper `<div className="flex flex-col gap-xl max-w-2xl">` par un container plus large + grid 2-col responsive.
- **`frontend/src/api/exports.ts`** (nouveau) : - Header reste en haut, full width.
- `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. - Body : `grid grid-cols-1 lg:grid-cols-2 gap-xl` avec :
- Helper `parseContentDispositionFilename(header: string | undefined): string | null` (regex `filename="..."`, fallback null). - Col gauche : `<form>` engagement (déjà en `card-product`).
- Col droite : `<C2ConfigCard>` (seulement quand `editing && canEditEngagements`).
- Si pas en edit (création) : col droite vide → garder la grid mais que la col gauche se déploie via `lg:col-span-2` (pour pas avoir un vide à droite). Acceptable alternative : `flex` + `max-w-2xl` quand non-editing.
- Pas de modif sur la logique de form, validation, mutations.
### Types ## Task B — Contrast pass (tokens)
- Aucun nouveau type API (l'export retourne un Blob).
### Tests **Files** :
**Cible : 121 → 130+ vitest passing.** - `DESIGN.md` § Surface : mettre à jour `canvas` light = `#f3f5f8`, conserver `paper` light = `#ffffff`. Documenter dans la même section que "canvas tints lift paper cards in light mode without violating brutalism".
- Token source de vérité (Tailwind config ou CSS vars). Localiser et appliquer la même MAJ. Probablement `frontend/tailwind.config.js` ou un `frontend/src/styles/tokens.css` / `index.css`.
- Vérifier qu'aucun composant ne hardcode `#ffffff` pour la page bg (devrait utiliser `bg-canvas`).
- Tests CSS smoke : `bg-canvas` continue de matcher, dark mode inchangé.
Fichiers nouveaux : ## Task C — Visual regression check
- `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`
### Screenshots OBLIGATOIRES (lesson sprint 4) - `pnpm vitest run` clean.
- `EngagementDetailPage` light + dark, dropdown fermé. - `pnpm tsc --noEmit` clean.
- `EngagementDetailPage` light + dark, dropdown ouvert (3 items visibles). - `pnpm lint` clean.
- `EngagementDetailPage` SOC view — bouton Export ABSENT. - Screenshots avant/après (au moins) :
- Le builder doit fournir un script Playwright authenti (réutiliser le pattern sprint 5 — `page.goto('/login') → fill → wait nav`). - EngagementsListPage (cards-on-canvas)
- EngagementDetailPage
### Livrable frontend-builder (summary attendu) - EngagementFormPage (edit, avec C2ConfigCard à droite)
- Tous les fichiers créés/modifiés - SimulationFormPage (déjà 2-col sprint 7, vérifier que le tinted canvas n'écrase pas)
- API contracts consommés exactement comme livrés par backend (cf. lesson sprint 5 — path drift à éviter, grep `Content-Disposition` dans la PR) - LoginPage
- Helpers réutilisés (`useToast`, etc.) - Dark mode : passe rapide pour confirmer aucune régression.
- Résultats vitest + typecheck + lint
- Liste des écrans capturés (light + dark, role-by-role)
--- ---
## §3 — Acceptance tests (Sonnet · test-verifier) ## Sequencing
**Cible : 201 → 215+ Playwright passing.** 1. **frontend-builder** : Task A + B + C. Une seule passe, commits atomiques.
2. **design-reviewer** : revue visuelle après merge des commits builder. Focus :
3 user stories à couvrir : - Lecture confortable cards-on-tinted-canvas.
- Hairline encore visible.
### US-29 — Admin/redteam exporte l'engagement en Markdown/CSV/PDF - Dark mode inchangé.
- **AC-29.1** : login admin → engagement avec ≥ 2 simulations → click "Export" → dropdown s'ouvre. - Pas de régression sur components qui pourraient ré-utiliser `bg-canvas` pour autre chose (dropdowns, modals).
- **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.
--- ---
## §4 — Reviews ## Definition of Done
### Spec-reviewer Pass 1 (avant dispatch) - EngagementFormPage en édition : 2 colonnes desktop, stack mobile.
- Lit ce `tasks/todo.md` § 0 + § 1 + § 2 + § 3. - Page bg différencié de card bg en light mode (eyes confort).
- Verdict attendu : APPROVED / NEEDS-CHANGES par section. - Vitest + typecheck + lint verts.
- Points particuliers à challenger : WeasyPrint vs reportlab, CSV sans header engagement, URL drift (un seul endpoint avec query param vs 3 endpoints distincts). - Design-reviewer APPROVED.
- Screenshots livrés ou écueil documenté.
### Spec-reviewer Pass 2 (après mes éventuels édits du plan) - Commits conventional, branche `sprint/9-ui-contrast`.
- 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.
---
## §5 — SPEC.md update (au tout début du sprint — lesson sprint 3/4/5)
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`).
---
## §6 — Workflow git du sprint
- 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).
---
## §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).