Compare commits
86 Commits
678ee8fbfb
...
sprint/13-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
578a7c3d1a | ||
|
|
0378792dc4 | ||
|
|
9b6b682acc | ||
|
|
2ff097b648 | ||
|
|
ac0bc8d1b2 | ||
|
|
6648d1cc2e | ||
|
|
2931e4aaf9 | ||
|
|
ad0a3f5cac | ||
|
|
bdda02b07a | ||
|
|
284494cee8 | ||
|
|
5b93f880a3 | ||
|
|
ea870af324 | ||
|
|
fe597e9be3 | ||
|
|
3723bd009b | ||
|
|
51fb2e4f95 | ||
|
|
3cdfba636e | ||
|
|
10d4293f44 | ||
|
|
2c7fcec7cd | ||
|
|
11ce3cfb86 | ||
|
|
790ced4204 | ||
|
|
1324a0c8ce | ||
|
|
5d34a5e171 | ||
|
|
1819089925 | ||
|
|
9a8c5f52ab | ||
|
|
59eaa342a9 | ||
|
|
004d075cad | ||
|
|
bca39dcca7 | ||
|
|
89fb38b107 | ||
| 65857134ef | |||
|
|
208c6e6428 | ||
|
|
91533c214c | ||
|
|
0fab40b486 | ||
|
|
ee5fda6059 | ||
|
|
774ae05748 | ||
|
|
c31f985e7a | ||
| 24d4a3b146 | |||
|
|
8b5b5d94d8 | ||
|
|
76bcb04c8f | ||
|
|
88b97cef2e | ||
|
|
e4b1d6cb57 | ||
|
|
a9fe2fc528 | ||
|
|
38e282a126 | ||
|
|
7d3d39639e | ||
|
|
184a2a16c9 | ||
|
|
7ff153905b | ||
|
|
8f23f59601 | ||
|
|
b83316f715 | ||
|
|
873e52a2a1 | ||
|
|
5ff6ae8940 | ||
|
|
53755a31d6 | ||
|
|
9a9c98beab | ||
|
|
813e69ee01 | ||
| 6ca614a3f3 | |||
|
|
1997a8c621 | ||
|
|
573281f454 | ||
|
|
6995c4c860 | ||
|
|
7ea2fe490c | ||
|
|
0e69eb901c | ||
|
|
5cc830554c | ||
|
|
ec7800ae38 | ||
|
|
c791e50108 | ||
|
|
64da94f10d | ||
|
|
6c05cc2e11 | ||
|
|
5627d7dcfa | ||
|
|
c85ece46b9 | ||
| e27babed5b | |||
|
|
e41679b331 | ||
|
|
2d1c113f0c | ||
|
|
3a9d9d3203 | ||
|
|
4d9447082f | ||
|
|
aeb4bdb025 | ||
|
|
7335b9f2c6 | ||
|
|
fdab324217 | ||
|
|
e4a672c443 | ||
|
|
b572a67066 | ||
|
|
3725d4415e | ||
|
|
123d9812bc | ||
|
|
57dbd14347 | ||
|
|
25877c4092 | ||
|
|
100441bdeb | ||
|
|
5471c8fd89 | ||
|
|
f1a7965ab9 | ||
|
|
87e4409530 | ||
|
|
cf006a2ba8 | ||
|
|
01434c04a7 | ||
|
|
7aaa5ccc6d |
@@ -11,10 +11,22 @@ You are the **Frontend Builder** for the Mimic project (BAS WebUI based on MITRE
|
||||
|
||||
Read these files first, in order:
|
||||
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.
|
||||
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
|
||||
|
||||
- React components under `frontend/src/components/`
|
||||
|
||||
136
CHANGELOG.md
136
CHANGELOG.md
@@ -6,6 +6,142 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed — Sprint 13 (SimulationFormPage 3-tab layout)
|
||||
|
||||
**Frontend only** (253 vitest passing)
|
||||
|
||||
- `frontend/src/components/Tabs.tsx` — Added `disabled?: boolean` per-item: `aria-disabled`, `aria-controls`, native `disabled`, `.tab-underline-disabled` CSS, arrow-key skip logic.
|
||||
- `frontend/src/styles/index.css` — New `.tab-underline-disabled` recipe (`text-graphite opacity-50 cursor-not-allowed`).
|
||||
- `frontend/src/hooks/useHashTab.ts` — Added optional `validIds?: string[]` for hash fallback to defaultId when hash not in valid set.
|
||||
- `frontend/src/pages/SimulationFormPage.tsx` — Full 3-tab refactor: Red Team / SOC / Tâche C2. RT fields in RT tab, SOC fields in SOC tab, `C2TasksPanel` in C2 tab. SOC tab disabled when `status ∉ {review_required, done}`. C2 tab disabled in create mode. Contextual sticky bar hides entirely on C2 tab. SOC-blocked `AlertBanner` removed; tab disabled state replaces it. `safeTab` guards against stale hash from previous navigation.
|
||||
- `frontend/src/i18n/fr.json` — Added `simulation.form.tab.{redTeam,soc,c2}` keys.
|
||||
- `DESIGN.md` — Documented disabled tab variant and `aria-controls` contract.
|
||||
- `frontend/tests/SimulationFormPage.test.tsx` — Rewritten for tab-based layout: SOC-blocked-banner tests removed; tab disabled-state tests, SOC-only field tests, C2 tab panel test, count pill test added.
|
||||
- `frontend/tests/components/Tabs.test.tsx` — Added 5 disabled-state tests.
|
||||
|
||||
### Changed — Sprint 12 (EngagementDetailPage 2-tab merge + full FR i18n)
|
||||
|
||||
**Frontend only** (245 vitest passing — baseline 233 + 12 new i18n smoke tests)
|
||||
|
||||
- `frontend/src/i18n/fr.json` — NEW. ~160-key nested translation file (French). Covers: common, nav, auth, engagement, simulation, template, user.admin, c2, mitre, state, toast, status.
|
||||
- `frontend/src/i18n/index.ts` — NEW. i18next init (lng: fr, no fallback to en, escapeValue: false).
|
||||
- `frontend/src/i18n/status.ts` — NEW. `engagementStatusLabel()` / `simulationStatusLabel()` helpers mapping API status to translated strings.
|
||||
- `frontend/src/lib/format.ts` — NEW. `formatDate()` / `formatDateTime()` using `fr-FR` locale.
|
||||
- `frontend/src/main.tsx` — Added `import './i18n'` bootstrap.
|
||||
- `frontend/vitest.setup.ts` — Added `import './src/i18n'` so all tests use real French translations.
|
||||
- `frontend/src/pages/EngagementDetailPage.tsx` — Merged 3-tab layout → 2 tabs (Description + Simulations); full i18n pass.
|
||||
- `frontend/src/pages/EngagementsListPage.tsx` — Full i18n pass; local `formatDate` removed, `@/lib/format` used.
|
||||
- `frontend/src/pages/EngagementFormPage.tsx` — Full i18n pass; `validate()` inside component; `data-testid="btn-submit"` added.
|
||||
- `frontend/src/pages/SimulationFormPage.tsx` — Full i18n pass; `data-testid` on 4 action buttons.
|
||||
- `frontend/src/pages/TemplatesListPage.tsx` — Full i18n pass.
|
||||
- `frontend/src/pages/TemplateFormPage.tsx` — Full i18n pass.
|
||||
- `frontend/src/pages/UsersAdminPage.tsx` — Full i18n pass; `data-testid="new-role-select"` added.
|
||||
- `frontend/src/components/Layout.tsx` — Nav labels translated.
|
||||
- `frontend/src/components/LoginPage.tsx` — Form labels and error message translated.
|
||||
- `frontend/src/components/ProtectedRoute.tsx` — Forbidden/loading messages translated.
|
||||
- `frontend/src/components/SimulationList.tsx` — Full i18n pass.
|
||||
- `frontend/src/components/TemplatePickerModal.tsx` — Full i18n pass.
|
||||
- `frontend/src/components/MitreTechniqueTag.tsx` — `aria-label` translated (Retirer TX…).
|
||||
- `frontend/src/components/MitreTechniquesField.tsx` — Full i18n pass.
|
||||
- `frontend/src/components/MitreMatrixModal.tsx` — Full i18n pass; plural apply button.
|
||||
- `frontend/src/components/MitreTechniquePicker.tsx` — Placeholder and messages translated.
|
||||
- `frontend/src/components/C2ConfigCard.tsx` — Full i18n pass.
|
||||
- `frontend/src/components/C2TasksPanel.tsx` — Full i18n pass.
|
||||
- `frontend/src/components/ExecuteViaC2Modal.tsx` — Full i18n pass.
|
||||
- `frontend/src/components/ImportC2HistoryModal.tsx` — Full i18n pass.
|
||||
- `frontend/src/components/C2CallbackPicker.tsx` — Full i18n pass.
|
||||
- `frontend/src/components/Toast.tsx` — Dismiss aria-label translated.
|
||||
- `frontend/src/components/ErrorState.tsx` — Title default and Retry button translated.
|
||||
- All test files updated to use French strings or `data-testid` where button labels changed.
|
||||
|
||||
**No backend changes. No DB schema change. No migration.**
|
||||
|
||||
### Added — Sprint 11 (Spectrum UX port: 4 primitives + compact density global)
|
||||
|
||||
**Frontend only** (233 vitest passing — baseline 212 + 21 new tests across 4 new specs)
|
||||
|
||||
- `frontend/src/hooks/useHashTab.ts` — NEW. Pure-TS hook that reads `window.location.hash`, falls back to a default tab id, and listens to `hashchange` (cleanup on unmount). No style dependency.
|
||||
- `frontend/src/components/Tabs.tsx` — NEW. `<Tabs items activeId onChange>` component. Underline variant: `tab-underline` / `tab-underline-active` CSS recipes. Count pill: `tab-count-pill` / `tab-count-pill-active` (`rounded-pill` exception per DESIGN.md). `role="tablist"` + `aria-selected` on tabs. Zero transitions.
|
||||
- `frontend/src/components/AlertBanner.tsx` — NEW. `<AlertBanner variant="error|warn|success|info" title? children>`. Border-l-4 semantic strip, `bg-paper`, Lucide icon at `size={16}`. ARIA: `role="alert"` for error/warn, `role="status"` for success/info. No shadow, no radius, no transition.
|
||||
- `frontend/src/components/BackLink.tsx` — NEW. `<BackLink to children>` renders `ArrowLeft` (Lucide, 14px) + text with `caption-md text-graphite hover:text-primary`. Replaces 3 hand-rolled back-link instances.
|
||||
- `frontend/src/styles/index.css` — Added recipes: `.tab-underline`, `.tab-underline-active`, `.tab-count-pill`, `.tab-count-pill-active`, `.alert-error`, `.alert-warn`, `.alert-success`, `.alert-info`, `.table-compact` (32px row density, global).
|
||||
- `frontend/src/pages/EngagementDetailPage.tsx` — Refactored to 3-tab layout (Schedule / Description / Simulations) wired via `useHashTab('schedule')`. Count pill on Simulations tab. BackLink replaces hand-rolled link.
|
||||
- `frontend/src/pages/SimulationFormPage.tsx` — Done banner and SOC-blocked banner migrated to `<AlertBanner>`. Both back-link instances replaced with `<BackLink>`.
|
||||
- `frontend/src/pages/TemplateFormPage.tsx` — Back-link replaced with `<BackLink>`.
|
||||
- `frontend/src/pages/EngagementsListPage.tsx` — Table marked `table-compact`, inline `px-xl py-md` padding removed from th/td (recipe handles it).
|
||||
- `frontend/src/pages/TemplatesListPage.tsx` — Same compact table treatment.
|
||||
- `frontend/src/pages/UsersAdminPage.tsx` — Same compact table treatment.
|
||||
- `frontend/src/components/SimulationList.tsx` — Same compact table treatment.
|
||||
- `DESIGN.md` — Added `### Navigation › Sub-page tabs` subsection; updated `### Data Tables` row min-height 44px → 32px with WCAG SC 2.5.5 rationale; added `### Inline Banners` subsection under `### Toast Notifications`.
|
||||
|
||||
**Tests added** (21 new assertions across 4 new spec files):
|
||||
- `tests/hooks/useHashTab.test.tsx` — 4 tests (default, hash read on mount, navigate(), empty-hash fallback)
|
||||
- `tests/components/Tabs.test.tsx` — 7 tests (render, aria-selected, active class, count pill, count-pill-active, onChange, brutalism)
|
||||
- `tests/components/AlertBanner.test.tsx` — 6 tests (4 variants × role + class, title prop, brutalism)
|
||||
- `tests/components/BackLink.test.tsx` — 4 tests (render, href, brutalism, svg icon)
|
||||
|
||||
**No backend changes.** No DB schema change. No migration.
|
||||
|
||||
### 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)
|
||||
|
||||
**Backend** (253 pytest passing — 226 sprint-1-to-4 + 28 sprint 5 + 5 sprint 5 post-code-review + 23 sprint 6 + 1 CSV-injection defense-in-depth test)
|
||||
- `backend/app/services/export.py` (new, 302 lines) — 3 pure render functions (`render_engagement_markdown`, `render_engagement_csv`, `render_engagement_pdf`) + filename slugifier (`_export_filename`) + HTML helper for the PDF pipeline + CSV formula-injection defense helper (`_csv_safe`).
|
||||
- New endpoint `GET /api/engagements/<int:eid>/export?format=md|csv|pdf` extended on the existing `engagements_bp`. Decorator `@role_required("admin", "redteam")` (SOC → 403). 400 on missing/unknown format, 404 on unknown engagement. Returns the rendered file body with `Content-Type` matching the format and `Content-Disposition: attachment; filename="engagement-<id>-<slug>-YYYYMMDD.<ext>"`.
|
||||
- Filename slugifier uses `unicodedata.normalize('NFKD', ...).encode('ascii', 'ignore')` to strip accents (`Opération` → `operation`) and falls back to `"unnamed"` when the slug is empty after stripping.
|
||||
- Markdown rendering uses fenced code blocks with `~~~bash` (tildes, not backticks) so backticks in commands don't break the fence. SOC fields are always rendered, even when blank (consistency for handoff). `_creator()` helper renders the username string only (not the `{id, username}` dict).
|
||||
- CSV rendering uses stdlib `csv.writer` (handles multiline / quotes / commas natively). `_csv_safe()` prefixes a single apostrophe to any string starting with `=`, `+`, `-`, `@`, `\t`, or `\r` — defuses Excel / LibreOffice / Google Sheets formula injection on the SOC analyst's machine when they open the exported CSV. Applied to all user-controlled string fields; ISO dates and the enum status value are exempted.
|
||||
- PDF rendering via **WeasyPrint** (Python HTML→PDF). The PDF is generated from the same engagement DATA as the Markdown (not from the Markdown string) via `_render_engagement_html()` and `weasyprint.HTML(string=html).write_pdf()`. CSS inline (≤ 30 lines). All user-controlled fields HTML-escaped via stdlib `html.escape()`.
|
||||
- `docker/Dockerfile` python stage now installs minimal WeasyPrint deps: `libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info`. `libgdk-pixbuf-2.0-0` deliberately excluded (text-only PDF).
|
||||
- `weasyprint>=60.0` added to `backend/requirements.txt`.
|
||||
- No DB schema change. No migration.
|
||||
|
||||
**Frontend** (136 vitest passing — 121 sprint-1-to-5 + 12 sprint 6 + 3 sprint 6 coverage-gap fix)
|
||||
- `frontend/src/components/ExportEngagementButton.tsx` (new) — split-button dropdown `[Export ▼]` with `Download` + `ChevronDown` lucide icons. **Both halves open the dropdown** (no default left-click action — different semantic from sprint 5's `NewSimulationDropdown` where left navigates blank), because there is no obvious default format among MD/CSV/PDF. Loading state per-item, toast on error. Click-outside + Escape close (reuses the `useEffect` + `pointerdown` + `keydown` pattern from `NewSimulationDropdown`). `data-testid="export-dropdown"` for e2e selection. Visual: shares `btn-outline` class with the neighbour `Edit` button.
|
||||
- `frontend/src/api/exports.ts` (new) — `downloadEngagementExport(engagementId, format)` with `responseType: 'blob'`. Reads `Content-Disposition: attachment; filename="..."`, falls back to `engagement-<id>.<ext>` when the header is absent or malformed. Throws an `Error` on non-2xx (caller catches and toasts). Helper `parseContentDispositionFilename()`.
|
||||
- `frontend/src/pages/EngagementDetailPage.tsx` (edited) — integrates `<ExportEngagementButton engagementId={engagement.id} />` in the header next to the `Edit` CTA. Gated by `canEditEngagements` from `useAuth` (admin + redteam).
|
||||
- New test file `frontend/tests/exports.test.ts` covers the API client directly via `axios-mock-adapter` (the component test file mocks `downloadEngagementExport` entirely, so the fallback logic inside `exports.ts` wasn't reachable from there — new file lets the real function run for 3 dedicated tests).
|
||||
|
||||
**Acceptance tests** (Playwright, **223 passed** — baseline sprint 5 = 201, +22 sprint 6)
|
||||
- 3 new spec files (one per US): `us29-export-formats.spec.ts` (8 tests), `us30-export-rbac.spec.ts` (3 tests), `us31-export-robustness.spec.ts` (5 tests).
|
||||
- No regression on sprints 1–5: full pre-sprint-6 suite still green.
|
||||
|
||||
**Security**
|
||||
- CSV formula injection (MEDIUM) flagged by `security-guidance@claude-code-plugins` automated review during the sprint, fixed mid-sprint (commit `57dbd14`). 3 dedicated unit tests cover the apostrophe-prefix on `=`, `@` triggers and the no-op on safe strings.
|
||||
- Defense-in-depth: a property test (`test_export_filename_never_contains_quote_or_crlf`) asserts the slugifier output never contains `"`, `\r`, or `\n` — guards against Content-Disposition header injection if someone later weakens the slug regex.
|
||||
|
||||
### Changed
|
||||
- 2026-06-07 — SPEC.md § Export d'engagement added (between § Templates de simulations and § Stacks techniques). Committed as the **first** sprint commit (`7aaa5cc`), applying the fix-candidate from sprint 5's recurrent "SPEC.md uncommitted at sprint close" lesson. Four-sprint recurrence finally broken.
|
||||
- 2026-06-08 — Team `mimic` (persistent `.claude/teams/mimic/config.json`) instantiated with the full 7-agent project roster (backend-builder, frontend-builder, spec-reviewer, code-reviewer, design-reviewer, test-verifier, devil-advocate). Agents are spawned with an idle prompt at sprint start and woken via SendMessage per phase — flip vs the old "spawn-with-task-only" policy that hit the "team roster is flat" gotcha when respawning. Persistent across sprints from sprint 7+.
|
||||
- 2026-06-08 (post-review, pre-merge) — **Export schema switched to a fixed 7-column handoff layout** uniform across MD/CSV/PDF. Columns (FR headers): `Scénario`, `Test`, `Source de log`, `Commentaires SOC`, `Exécution` (multi-line concat without labels — `executed_at` → `commands` → `execution_result`), `Logs remontés au SIEM`, `Cyber incident`. Removed from the export (intentional, handoff-focused): simulation status, MITRE techniques/tactics, prerequisites, id, created_at, updated_at. Markdown switched from narrative-per-simulation to a GFM table. PDF switched from sectioned HTML to a single `<table>`. SPEC `fdab324`, backend refactor `7335b9f`, e2e adaptation `aeb4bdb`. Final counters: backend 257 pytest, frontend 136 vitest, e2e 223 Playwright.
|
||||
- 2026-06-08 (post-refactor, pre-merge) — **Two MEDIUM security regressions fixed** in the 7-column refactor (`3a9d9d3`), flagged by `security-guidance@claude-code-plugins`:
|
||||
1. **CSV formula injection inside the multi-line `Exécution` cell**: `_csv_safe` only checks `cell[0]`. With `executed_at` non-null, the cell starts with a safe date digit, but inner lines (commands, execution_result) starting with `=`/`+`/`-`/`@` evaded defense. Fix: `_format_execution_csv()` applies `_csv_safe` per user-controlled component BEFORE the multi-line concat. Outer `_csv_safe` on the assembled cell retained as belt-and-braces.
|
||||
2. **Stored XSS in Markdown table cells**: the new GFM table allows inline HTML (we use it for `<br/>`). A `sim.commands = "<script>alert(1)</script>"` would be rendered raw by MD viewers that interpret inline HTML (Notion, Obsidian, GitHub preview). Fix: `_cell()` now calls `html.escape()` on each value BEFORE the pipe-escape and `\n` → `<br/>` substitution — mirrors the `_render_engagement_html` PDF defense. The `<br/>` we insert ourselves stays unescaped (it's not user-controlled). 2 dedicated regression tests added.
|
||||
- 2026-06-09 (post-merge-review) — PDF export: A4 landscape orientation (user feedback post-merge-review). `@page { size: A4 landscape; }` added to `_CSS`; `font-size` reduced to 11px and `table-layout: fixed; word-break: break-word` added to prevent 7-column overflow on narrower portrait layout.
|
||||
|
||||
---
|
||||
|
||||
## [Sprint 5] — Simulation templates + instantiation + nav + dropdown (merged 2026-05-28)
|
||||
|
||||
### Added — Sprint 5 (Simulation templates)
|
||||
|
||||
**Backend** (226 pytest passing — 193 sprint-1-to-4 + 28 sprint 5 + 5 post-code-review)
|
||||
|
||||
464
DESIGN.md
464
DESIGN.md
@@ -1,345 +1,293 @@
|
||||
## 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 8–16px 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:**
|
||||
- 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
|
||||
- HP Electric Blue (`{colors.primary}`) is the lone CTA fill and link color; it appears at most twice per viewport
|
||||
- Bespoke Forma DJR Micro across every surface — display, body, button, caption — at weights 400 / 500 / 600 / 700
|
||||
- Cards round at `{rounded.xl}` (16px) for product/pricing tiles; buttons sit at `{rounded.md}` (4px) with capitalize labels
|
||||
- Geometric blue chevrons (`{colors.primary}` rectangles cut at 45°) frame hero photography and reinforce the wordmark
|
||||
- Dark-navy slabs (`{colors.ink}`) close every page rhythm — testimonial bands, "how can we help?" prelude, and the footer
|
||||
- Section rhythm: utility-strip → top nav → white body → cloud-band → ink slab → cloud-band → ink footer
|
||||
- Angular surfaces everywhere (`border-radius: 0`) — exception: status pills (`rounded-pill`) and avatars
|
||||
- Zero transitions / zero animations — state changes are immediate
|
||||
- Semantic color signals: primary blue = action, success green = confirmed, warn amber = pending/caution, bloom-deep = destructive/error
|
||||
- Monospace data discipline: `font-mono` ONLY for IDs, dates, codes, output, metrics — never for headers, labels, prose
|
||||
- Dense spacing: section gap 48px (not 80px), card padding 12–16px on dense surfaces
|
||||
- Light + dark modes both supported; dark mode is the primary operational mode for SOC analysts
|
||||
|
||||
---
|
||||
|
||||
## Colors
|
||||
|
||||
> **No Interaction sub-section.** Hover colors are silently filtered. Allowed sub-sections: Brand & Accent, Surface, Text, Semantic.
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **Deep Navy** (`{colors.primary-deep}` — `#0e3191`): pressed state for the primary CTA and the visited-link color.
|
||||
- **Soft Blue** (`{colors.primary-soft}` — `#c9e0fc`): pale-blue surface used inside customer-story cards and selection chips.
|
||||
- **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`): CTA on dark slab surfaces where `#024ad8` muddles.
|
||||
- **Deep Navy** (`{colors.primary-deep}` — `#0e3191`): pressed state for primary CTA.
|
||||
- **Soft Blue** (`{colors.primary-soft}` — `#c9e0fc`): selection highlight, chip background on light surfaces.
|
||||
|
||||
### Surface
|
||||
- **Canvas** (`{colors.canvas}` — `#ffffff`): the universal page background. White, full opacity.
|
||||
- **Paper** (`{colors.paper}` — `#ffffff`): card surfaces — same white as canvas, with hairline borders or shadows providing the lift.
|
||||
- **Cloud** (`{colors.cloud}` — `#f7f7f7`): the lightest gray section band, used for alternating-row backgrounds and product-feature card groups.
|
||||
- **Fog** (`{colors.fog}` — `#e8e8e8`): a slightly darker gray surface band, used for FAQ outer panels and the "Trending laptops" header strip.
|
||||
- **Steel** (`{colors.steel}` — `#c2c2c2`): hairline border used on outlined elements with stronger emphasis (focus states, active filter).
|
||||
- **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.
|
||||
- **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.
|
||||
- **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` light / `#1f2937` dark): card and panel surfaces.
|
||||
- **Cloud** (`{colors.cloud}` — `#f7f7f7` light / `#1f2937` dark): alternating section band, table row zebra.
|
||||
- **Fog** (`{colors.fog}` — `#e8e8e8` light / `#374151` dark): secondary section band, input background on dense panels.
|
||||
- **Steel** (`{colors.steel}` — `#c2c2c2` light / `#4b5563` dark): hairline borders, disabled states.
|
||||
- **Hairline** (`{colors.hairline}`): 1px divider between cells, panels, table rows.
|
||||
- **Slab** (`{colors.slab}` — `#111827`): fixed-dark surface — utility strip, footer, dark bands. Does NOT invert in dark mode.
|
||||
|
||||
### Text
|
||||
- **Ink** (`{colors.ink}` — `#1a1a1a`): the universal text color on white surfaces — headlines, body, button labels, navigation.
|
||||
- **Ink Deep** (`{colors.ink-deep}` — `#000000`): pure black used for the wordmark and 1px hairline strokes around badge outlines.
|
||||
- **Ink Soft** (`{colors.ink-soft}` — `#292929`): an alternate near-black used inside dark-navy slabs as a subtle textural shift.
|
||||
- **On Ink** (`{colors.on-ink}` — `#ffffff`): pure white used for headline and body text on every dark-navy slab.
|
||||
- **Charcoal** (`{colors.charcoal}` — `#3d3d3d`): muted body color on white surfaces — secondary descriptions, fine-print disclaimers.
|
||||
- **Graphite** (`{colors.graphite}` — `#636363`): smaller-print color, used for legal lines and timestamp metadata.
|
||||
- **Ink** (`{colors.ink}` — `#1a1a1a` light / `#f9fafb` dark): universal body text color.
|
||||
- **Ink Deep** (`{colors.ink-deep}` — `#000000` light / `#ffffff` dark): maximum contrast for headings.
|
||||
- **Ink Soft** (`{colors.ink-soft}` — `#292929` light / `#e5e7eb` dark): muted body, secondary labels.
|
||||
- **On Ink** (`{colors.ink-on}` — `#ffffff` light / `#111827` dark): text on slab surfaces.
|
||||
- **Charcoal** (`{colors.charcoal}` — `#3d3d3d` light / `#d1d5db` dark): secondary descriptions, captions.
|
||||
- **Graphite** (`{colors.graphite}` — `#636363` light / `#9ca3af` dark): timestamps, metadata, footnotes.
|
||||
|
||||
### Semantic
|
||||
- **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.
|
||||
- **Storm Deep** (`{colors.storm-deep}` — `#356373`): used as a neutral status accent (e.g., printer-plan tier "Versatile" tier color).
|
||||
### Semantic (Status Signals)
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
### 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.
|
||||
|
||||
The 16/14/12-px caption tier carries the catalog metadata — model numbers, spec rows, fine print — at weight 400 with a 1.4–1.5 line-height. Button labels lift to weight 600/700 with positive 0.5–1.1px letter-spacing and uppercase transform — the only place the system tracks letters.
|
||||
Both fonts are bundled locally via `@fontsource-variable/inter` and `@fontsource-variable/jetbrains-mono`. Zero CDN loading at runtime.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||
|---|---|---|---|---|---|
|
||||
| `{typography.display-xxl}` | 72px | 500 | 1.0 | 0 | Hero headline (homepage, laptop hub) |
|
||||
| `{typography.display-xl}` | 56px | 500 | 1.0 | 0 | Section headlines on landing pages |
|
||||
| `{typography.display-lg}` | 44px | 500 | 1.0 | 0 | Sub-section headlines on shop pages |
|
||||
| `{typography.display-md}` | 32px | 500 | 1.0 | 0 | Promo strip headlines, FAQ section headers |
|
||||
| `{typography.display-sm}` | 24px | 500 | 1.17 | 0 | Card titles, pricing-tier names |
|
||||
| `{typography.display-xs}` | 20px | 500 | 1.0 | 0 | Inline list headers, accordion labels |
|
||||
| `{typography.body-lg}` | 18px | 400 | 1.33 | 0 | Lead paragraphs |
|
||||
| `{typography.body-md}` | 16px | 400 | 1.38 | 0 | Default body |
|
||||
| `{typography.body-emphasis}` | 16px | 500 | 1.38 | 0 | Bolded run-in copy |
|
||||
| `{typography.caption-md}` | 14px | 400 | 1.5 | 0 | Specs, metadata, captions |
|
||||
| `{typography.caption-bold}` | 14px | 700 | 1.3 | 0 | Sale tags, in-card highlights |
|
||||
| `{typography.caption-sm}` | 12px | 400 | 1.33 | 0 | Footnotes, legal lines |
|
||||
| `{typography.link-md}` | 16px | 500 | 1.38 | 0 | Inline link emphasis |
|
||||
| `{typography.button-md}` | 14px | 600 | 1.4 | 0.7px | Primary/secondary button labels (uppercase) |
|
||||
| `{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 |
|
||||
| Token | Size | Weight | Line Height | Use |
|
||||
|---|---|---|---|---|
|
||||
| `{typography.display-xxl}` | 40px | 500 | 1.1 | Page-level hero (rare — dashboard title) |
|
||||
| `{typography.display-xl}` | 32px | 500 | 1.1 | Section title (engagement name, modal header) |
|
||||
| `{typography.display-lg}` | 28px | 500 | 1.1 | Sub-section header, panel title |
|
||||
| `{typography.display-md}` | 24px | 500 | 1.1 | Card title, table header group |
|
||||
| `{typography.display-sm}` | 20px | 500 | 1.1 | Item title, form section header |
|
||||
| `{typography.display-xs}` | 16px | 600 | 1.1 | Compact section header, sidebar label |
|
||||
| `{typography.body-lg}` | 18px | 400 | 1.4 | Lead paragraph |
|
||||
| `{typography.body-md}` | 16px | 400 | 1.4 | Default body, form labels |
|
||||
| `{typography.body-emphasis}` | 16px | 500 | 1.4 | Bolded inline copy, table column headers |
|
||||
| `{typography.caption-md}` | 14px | 400 | 1.5 | Secondary metadata, captions, table cells (non-data) |
|
||||
| `{typography.caption-bold}` | 14px | 700 | 1.3 | Status labels, tag text |
|
||||
| `{typography.caption-sm}` | 12px | 400 | 1.33 | Footnotes, legal fine-print |
|
||||
| `{typography.button-md}` | 14px | 600 | 1.4 | Button labels (uppercase) |
|
||||
| `{typography.button-sm}` | 12.6px | 700 | 1.0 | Compact button in tight cells |
|
||||
|
||||
### 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
|
||||
|
||||
### 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.
|
||||
- **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
|
||||
- **Section padding**: `{spacing.section}` (80px) vertical between major bands on desktop; collapses to ~48px 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.
|
||||
- **Gutter**: `{spacing.xl}` (24px) between grid columns at desktop; `{spacing.md}` (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.
|
||||
- **Base unit**: 8px. Half-step 4px.
|
||||
- **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**: 48px vertical between major bands (desktop); ~24px on mobile.
|
||||
- **Card padding**: 16px on dense panels; 24px on standard cards.
|
||||
- **Gutter**: 24px between grid columns on desktop; 16px on tablet/mobile.
|
||||
|
||||
### Grid & Container
|
||||
|
||||
- **Desktop max-width**: 1366px content container with full-bleed-on-canvas section backgrounds.
|
||||
- **Hero**: a single full-width photo card (homepage and laptop-hub hero) with the headline overlay positioned upper-left or upper-right.
|
||||
- **Product family grid**: 4 columns at >1200px, 3 at 1024–1199px, 2 at 768–1023px, 1 below 768px.
|
||||
- **Pricing tiers**: 4 columns at >1024px, 2x2 grid at 768–1023px, single-column accordion below 768px.
|
||||
- **Footer**: 5-column link grid at >1024px, collapsing to 2-column then accordion on mobile.
|
||||
- **Max-width**: 1366px content container, full-bleed on canvas.
|
||||
- **List pages**: full-width table with 1px hairline borders, no card wrap.
|
||||
- **Detail pages**: 2-column split (60/40) on desktop, stacked on mobile.
|
||||
- **Form pages**: single-column centered at 640px max-width.
|
||||
|
||||
### 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 11–12px 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
|
||||
|
||||
### Border Radius Scale
|
||||
### Border Radius
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `{rounded.none}` | 0px | Hero chevron decorations, full-bleed photo heroes, marquee strips |
|
||||
| `{rounded.xs}` | 2px | Secondary chip backgrounds, sale-tag pills |
|
||||
| `{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 |
|
||||
| `{rounded.none}` | 0px | **Default for everything**: buttons, cards, modals, inputs, dropdowns, panels, tags, tables |
|
||||
| `{rounded.pill}` | 9999px | **Status pills only** (`StatusBadge`, `SimulationStatusBadge`) and circular avatars |
|
||||
|
||||
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
|
||||
|
||||
> **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
|
||||
|
||||
**`button-primary`** — the lone HP 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}`
|
||||
- Pressed state `button-primary-pressed` — background `{colors.primary-deep}`, same text
|
||||
- Disabled state `button-primary-disabled` — background `{colors.steel}`, white text
|
||||
- Used for: "Buy now", "Shop now", "Get a printer", primary form submit
|
||||
**`.btn-primary`** — Electric Blue CTA
|
||||
- Background `{colors.primary}`, text white, uppercase, 14px/600, `h-11`, padding `12px 24px`, `rounded-none`
|
||||
- Hover: background `{colors.primary-deep}` — no transition
|
||||
- Disabled: background `{colors.steel}`, cursor not-allowed
|
||||
|
||||
**`button-ink`** — black filled CTA
|
||||
- Background `{colors.ink}`, text `{colors.on-primary}`, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`, type `{typography.button-md}`
|
||||
- Used for: "Buy now" on dark photo overlays, secondary primary actions where the blue would clash with imagery
|
||||
**`.btn-ink`** — Fixed-dark filled CTA
|
||||
- Background `{colors.slab}`, text `{colors.slab-text}`, uppercase, same metrics as btn-primary
|
||||
- Used on dark band surfaces where blue would clash
|
||||
|
||||
**`button-outline`** — blue-text outlined CTA
|
||||
- Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`
|
||||
- Used for: "Compare", "Customize", "Learn more" — secondary actions on white surfaces
|
||||
**`.btn-outline`** — Outlined blue CTA
|
||||
- Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, `rounded-none`
|
||||
- Hover: background `{colors.primary-soft}`
|
||||
|
||||
**`button-outline-ink`** — black-text outlined CTA
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`
|
||||
- Used for: "View" buttons inside product family card grids — neutral against the blue primary
|
||||
**`.btn-outline-ink`** — Outlined neutral CTA
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, `rounded-none`
|
||||
- Hover: background `{colors.cloud}`
|
||||
|
||||
**`button-text-link`** — inline blue link with underline
|
||||
- Background `{colors.canvas}`, text `{colors.primary}`, type `{typography.link-md}`, padding `{spacing.xxs} 0`
|
||||
- Used for: "See details", "Read more" inside cards and disclaimer rows
|
||||
|
||||
### 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
|
||||
**`.btn-text-link`** — Inline text link
|
||||
- Text `{colors.primary}`, no background, no border
|
||||
- Hover: underline, no color change
|
||||
|
||||
### Inputs & Forms
|
||||
|
||||
**`text-input`** + **`text-input-focused`**
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 44px
|
||||
- 1px `{colors.steel}` border in default; gains 1px `{colors.ink}` border on focus (no halo)
|
||||
**`.text-input`** — Standard text field
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.steel}` border, `rounded-none`, `h-11`
|
||||
- Focus: border becomes 1px `{colors.primary}`, no halo, no transition
|
||||
|
||||
**`text-input-search`** — pill search in the top nav
|
||||
- Background `{colors.canvas}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 40px, 1px `{colors.steel}` border, magnifying-glass icon at right
|
||||
**`textarea.text-input`** — Multiline variant
|
||||
- Same as text-input, `min-h-[88px]`, height auto
|
||||
|
||||
**`badge-pill-ink`** — filled tag pill
|
||||
- Background `{colors.ink}`, text `{colors.on-primary}`, rounded `{rounded.lg}`, padding 6px 12px, type `{typography.body-md}`
|
||||
- Used inline next to product titles to mark "New" or featured indicators
|
||||
**`select.text-input`** — Dropdown select
|
||||
- Same surface as text-input, caret via OS or custom SVG
|
||||
|
||||
**`badge-pill-outline`** — outlined tag pill
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, rounded `{rounded.lg}`, padding 6px 12px
|
||||
### Cards & Panels
|
||||
|
||||
**`badge-sale-coral`** — the sale price-stamp
|
||||
- Background `{colors.bloom-coral}`, text `{colors.on-primary}`, rounded `{rounded.sm}`, padding `{spacing.xxs} {spacing.xs}`, type `{typography.caption-bold}`
|
||||
- Used for: "Save $200", "25% off" overlay tags on hero promo cards
|
||||
**`.card-product`** — Standard content card
|
||||
- Background `{colors.paper}`, 1px `{colors.hairline}` border, `rounded-none`, padding `{spacing.md}` (16px)
|
||||
- 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
|
||||
|
||||
**`utility-strip`** — the top-of-page utility bar
|
||||
- Background `{colors.ink}`, text `{colors.on-primary}`, height 36px, padding 0 {spacing.xl}, type `{typography.caption-md}`
|
||||
- Holds: country/locale picker, "For Business / For Home" toggle, "Sign in" link, cart link
|
||||
**`.utility-strip`** — Top utility bar
|
||||
- Background `{colors.slab}`, text `{colors.slab-text}`, height 36px, `font-mono` for user role/username
|
||||
|
||||
**`nav-bar-top`** — desktop top nav (sits below utility strip)
|
||||
- Background `{colors.canvas}`, height 64px, padding 0 32px
|
||||
- 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
|
||||
- 1px `{colors.hairline}` bottom border separates nav from page
|
||||
**`.nav-bar-top`** — Main navigation
|
||||
- Background `{colors.slab}` (fixed dark — does not invert), height 56px
|
||||
- Active link: 2px `{colors.primary}` bottom border
|
||||
|
||||
**`nav-link`**
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, padding `{spacing.xs} {spacing.md}`
|
||||
- Active page draws a 2px `{colors.primary}` underline below the text baseline
|
||||
**`.nav-link`** — Navigation anchor
|
||||
- Text `{colors.slab-text}`, caption-md, `rounded-none`
|
||||
- Active state: 2px primary bottom border
|
||||
|
||||
**Top Nav (Mobile)**
|
||||
- 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
|
||||
#### Sub-page tabs
|
||||
|
||||
**`category-tab`** + **`category-tab-active`** — the pill sub-nav
|
||||
- Default: background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-emphasis}`, rounded `{rounded.pill}`, padding `{spacing.xs} {spacing.lg}`
|
||||
- Active: background `{colors.ink}`, text `{colors.on-primary}`, same rounding
|
||||
- Used on the laptop-shop page for "All / Trending / On Sale" filtering, and on the homepage "How can we help?" closing band
|
||||
Used inside pages that need content partitioning without a URL change (e.g. EngagementDetailPage: Schedule / Description / Simulations).
|
||||
|
||||
### Signature Components
|
||||
- **Container**: `flex items-end border-b border-hairline gap-xs` — sits on the `{colors.hairline}` bottom line.
|
||||
- **`.tab-underline`**: `text-graphite caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs`. Hover is **instant** — no `transition-*`.
|
||||
- **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text.
|
||||
- **Count pill** (optional, e.g. simulation count): `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception.
|
||||
- **Active count pill**: `.tab-count-pill-active` — `bg-primary-soft text-primary`.
|
||||
- **Disabled variant**: `.tab-underline-disabled` — `text-steel cursor-not-allowed`. Uses the same `text-steel` token as `btn-outline:disabled` for system-wide consistency. Applied when `disabled?: boolean` is set on a `TabItem`. Overrides `.tab-underline` hover. ARIA: `aria-disabled="true"` + native `disabled` attribute. Arrow-key navigation skips disabled items.
|
||||
- ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` + `aria-controls="tabpanel-{id}"` on each button.
|
||||
|
||||
**`chevron-decoration`** — the geometric blue slash motif
|
||||
- Background `{colors.primary}`, rounded `{rounded.none}`, no shadow
|
||||
- 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
|
||||
### Data Tables
|
||||
|
||||
**`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
|
||||
- `table-layout: fixed`, `word-break: break-word`
|
||||
- Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border
|
||||
- Data cells: **32px min-height** (compact density, all surfaces). Class: `.table-compact`.
|
||||
- **WCAG SC 2.5.5 tradeoff**: the recommended 44×44px touch target is intentionally not met on data rows. Mimic is a BAS operator console used at desktop on mouse/keyboard — density and scanability take priority over touch target size. Action buttons within rows retain icon ≥ 24px with adequate padding to preserve a usable hit area.
|
||||
- ID / date / technique columns: `font-mono`
|
||||
- Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide
|
||||
|
||||
**`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
|
||||
### Toast Notifications
|
||||
|
||||
**`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}`
|
||||
- Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary)
|
||||
- Background `{colors.paper}`, 1px `{colors.hairline}` border
|
||||
|
||||
#### Inline Banners
|
||||
|
||||
For persistent in-page status messages (not ephemeral toasts). Use `<AlertBanner variant="...">` component.
|
||||
|
||||
Four variants — all share: `bg-paper border border-hairline border-l-4 rounded-none px-md py-sm flex items-start gap-sm`.
|
||||
|
||||
| Variant | Left strip color | ARIA role | Lucide icon |
|
||||
|---------|-----------------|-----------|-------------|
|
||||
| `error` | `border-l-bloom-deep` | `role="alert"` | `AlertCircle` |
|
||||
| `warn` | `border-l-warn` | `role="alert"` | `AlertTriangle` |
|
||||
| `success` | `border-l-success` | `role="status"` | `CheckCircle` |
|
||||
| `info` | `border-l-primary` | `role="status"` | `Info` |
|
||||
|
||||
- Icon: Lucide at `size={16}`, `aria-hidden`, `mt-[2px] shrink-0` for baseline alignment.
|
||||
- No shadow, no rounded corners, no transition.
|
||||
- Optional `title` prop renders as `font-medium text-ink` above the message body.
|
||||
|
||||
---
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Reserve `{colors.primary}` for the primary CTA, link color, and `chevron-decoration` motif — at most twice per viewport
|
||||
- Set every headline in Forma DJR Micro at weight 500 with line-height 1.0 — resist the urge to bump weight at hero scale
|
||||
- Use `{rounded.xl}` (16px) for cards and photo frames; `{rounded.md}` (4px) for buttons and inputs — keep the two-tier split sharp
|
||||
- Pair white `{colors.canvas}` body bands with `{colors.cloud}` (`#f7f7f7`) alternating bands; let the gray do the breathing
|
||||
- Close every page rhythm with a dark-navy `{colors.ink}` slab — the "How can we help?" prelude + footer
|
||||
- Set button labels in uppercase with `{typography.button-md}` (0.7px tracking) — the only place the system tracks letters
|
||||
- Use Soft Lift shadow exclusively for product cards and pricing tiers — leave section bands flat
|
||||
- Frame product photography inside `{rounded.xl}` containers; never use full-bleed circular masks
|
||||
- `rounded-none` on every container, button, input, modal, dropdown, panel, tag
|
||||
- `font-mono` on IDs, ISO dates, MITRE codes, commands, output, metrics
|
||||
- Semantic color for status: success green, warn amber, bloom-deep for errors
|
||||
- 1px hairline borders for panel separation — never shadow
|
||||
- Instant hover (no `transition-*`)
|
||||
- Sharp focus ring: `outline: 2px solid {colors.primary}; outline-offset: 0`
|
||||
- Keep Inter for all headers, labels, prose, navigation, button text
|
||||
|
||||
### Don't
|
||||
- Don't introduce secondary saturated colors outside `{colors.primary}` family + the `bloom-coral` sale-tag and `storm` printer-plan accents
|
||||
- Don't apply heavy material shadows — depth is via color contrast (cloud vs. white) and Soft Lift only
|
||||
- Don't round buttons above `{rounded.md}` (4px); a soft 8px+ button reads as a different brand
|
||||
- Don't run Forma DJR Micro below 12px — small caption at 11px is the floor
|
||||
- Don't use the chevron decoration as inline noise; it is a hero-only architectural element tied to the wordmark
|
||||
- Don't drop ink text opacity to create hierarchy — switch surface or shift to `{colors.charcoal}` / `{colors.graphite}` instead
|
||||
- Don't replace the HP wordmark with a generic sans lockup; the wordmark is a custom mark with its own ratio
|
||||
- Don't round containers — not even `2px`. If it's not a status pill or avatar, `rounded-none`
|
||||
- Don't use `font-mono` for headers, labels, prose, or button text
|
||||
- Don't add `transition-*` on any interactive element
|
||||
- Don't use shadows — hairline borders only
|
||||
- Don't use `{colors.primary}` as a background for sections
|
||||
- Don't drop opacity on ink text — use `{colors.charcoal}` or `{colors.graphite}` for hierarchy
|
||||
- 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 | 480–767px | Same column count; hero scales to ~44px; pricing tiers stack vertically |
|
||||
| Tablet | 768–1023px | 2-column product grid; pricing 2x2; nav still full text labels |
|
||||
| Desktop | 1024–1279px | 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
|
||||
|
||||
1. Focus on ONE component at a time; resist refactoring an entire section 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
|
||||
3. Run `npx @google/design.md lint DESIGN.md` after edits — `broken-ref`, `contrast-ratio`, and `orphaned-tokens` warnings flag issues automatically
|
||||
4. Add new variants as separate component entries (`-pressed`, `-disabled`, `-focused`); never bury state inside prose
|
||||
5. Default body to `{typography.body-md}`; reach for `{typography.body-emphasis}` for run-in bolds; keep display sizes for true heading roles
|
||||
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
|
||||
1. One component at a time — no section refactors in one pass
|
||||
2. Reference tokens by name (`{colors.primary}`, `font-mono`, `rounded-none`) — not hex/px in prose
|
||||
3. When adding a new data field: ask "is this a datum (ID, date, code, metric)?" → `font-mono`. If no → Inter
|
||||
4. New status signals: map to existing semantic tokens (success/warn/bloom-deep/primary). No ad-hoc colors
|
||||
5. For new badge-like elements: pill ONLY if it's a status indicator with semantic color. Otherwise angular tag
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
|
||||
|
||||
> Status: **Sprint 5 — Simulation templates**. Admin/redteam can now create reusable simulation templates (name + description + commands + prerequisites + MITRE techniques + tactics) and instantiate them inside an engagement in one click. Template and instance are fully decoupled — editing one never affects the other. SOC has no access to templates.
|
||||
> Status: **Sprint 6 — Engagement export**. Admin/redteam can now export an engagement to Markdown, CSV, or PDF in one click from `EngagementDetailPage`. The export contains the engagement header and all simulations with both Red Team and SOC fields — closing the "replace the shared Excel" loop. CSV cells are defused against spreadsheet formula injection. SOC has no access to the export.
|
||||
|
||||
---
|
||||
|
||||
@@ -139,9 +139,9 @@ npm run dev # http://localhost:5173 with /api proxied to :5000
|
||||
Tests:
|
||||
|
||||
```bash
|
||||
cd backend && pytest -q # 226 tests
|
||||
cd frontend && npm run test -- --run # 121 tests
|
||||
cd e2e && npx playwright test # 201 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
|
||||
cd backend && pytest -q # 253 tests
|
||||
cd frontend && npm run test -- --run # 136 tests
|
||||
cd e2e && npx playwright test # 223 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
83
SPEC.md
83
SPEC.md
@@ -39,11 +39,90 @@ L'instanciation d'un template dans un engagement crée une **nouvelle simulation
|
||||
|
||||
**RBAC templates = ressource Red Team uniquement** : admin et redteam les gèrent (CRUD). SOC n'a aucun accès (pas de nav link, tous endpoints templates retournent 403). Les nouveaux noms de templates sont uniques pour la clarté UX du dropdown d'instanciation.
|
||||
|
||||
## Export d'engagement
|
||||
Un engagement peut être exporté à tout moment dans 3 formats au choix : **Markdown** (handoff narratif), **CSV** (machine-readable, intégration tableurs), **PDF** (livrable client). Endpoint unique : `GET /api/engagements/<id>/export?format=md|csv|pdf`. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : `engagement-<id>-<slug>-YYYYMMDD.<ext>`.
|
||||
|
||||
**Schéma fixe à 7 colonnes** (en-têtes français) pour tous les formats — une ligne par simulation :
|
||||
|
||||
| # | Colonne | Source |
|
||||
|---|---|---|
|
||||
| 1 | Scénario | `simulation.name` |
|
||||
| 2 | Test | `simulation.description` |
|
||||
| 3 | Source de log | `simulation.log_source` |
|
||||
| 4 | Commentaires SOC | `simulation.soc_comment` |
|
||||
| 5 | Exécution | concat multi-ligne sans labels, ordre fixe : `executed_at` → `commands` → `execution_result` |
|
||||
| 6 | Logs remontés au SIEM | `simulation.logs` |
|
||||
| 7 | Cyber incident | `simulation.incident_number` |
|
||||
|
||||
CSV : exactement 1 ligne d'en-tête + 1 ligne par simulation. Markdown : en-tête engagement (name, dates, status, created_by) puis une table de 7 colonnes. PDF : même structure que le Markdown rendue via HTML→PDF (WeasyPrint). Le statut de la simulation, les techniques/tactiques MITRE, les prerequisites et les métadonnées (id, created_at) ne sont PAS exportés — l'export est un handoff focalisé RT↔SOC, pas un dump complet.
|
||||
|
||||
Prévoir un module d'authentification : dans un premier temps local à la bdd.
|
||||
|
||||
Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.
|
||||
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 `false` | Désactivé par défaut — Mimic cible des labs Mythic avec certs auto-signés. Cocher pour forcer la vérification TLS en prod. |
|
||||
| `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
|
||||
* **FrontEnd** : WebUI
|
||||
- Stacks standard : ReactJS, Vite, TailWind etc...
|
||||
|
||||
@@ -6,7 +6,15 @@ from pathlib import Path
|
||||
|
||||
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.config import Config, TestConfig
|
||||
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(simulations_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
|
||||
mitre_svc.load_bundle()
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
"""API blueprints."""
|
||||
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.simulations import simulations_bp
|
||||
from backend.app.api.templates import templates_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
517
backend/app/api/c2.py
Normal 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", False)
|
||||
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
|
||||
@@ -3,12 +3,19 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from flask import Blueprint, Response, g, jsonify, request
|
||||
|
||||
from backend.app.auth import login_required, role_required
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import Engagement, EngagementStatus
|
||||
from backend.app.models.simulation import Simulation
|
||||
from backend.app.serializers import serialize_engagement
|
||||
from backend.app.services.export import (
|
||||
_export_filename,
|
||||
render_engagement_csv,
|
||||
render_engagement_markdown,
|
||||
render_engagement_pdf,
|
||||
)
|
||||
|
||||
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
|
||||
|
||||
@@ -156,3 +163,48 @@ def delete_engagement(engagement_id: int):
|
||||
db.session.delete(engagement)
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
@engagements_bp.get("/<int:eid>/export")
|
||||
@role_required("admin", "redteam")
|
||||
def export_engagement(eid: int):
|
||||
engagement = db.session.get(Engagement, eid)
|
||||
if engagement is None:
|
||||
return jsonify({"error": "Engagement not found"}), 404
|
||||
|
||||
fmt = request.args.get("format", "").strip().lower()
|
||||
if fmt not in ("md", "csv", "pdf"):
|
||||
return jsonify({"error": "format must be one of: md, csv, pdf"}), 400
|
||||
|
||||
simulations = (
|
||||
Simulation.query.filter_by(engagement_id=eid)
|
||||
.order_by(Simulation.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if fmt == "md":
|
||||
body = render_engagement_markdown(engagement, simulations)
|
||||
filename = _export_filename(engagement, "md")
|
||||
return Response(
|
||||
body,
|
||||
mimetype="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
if fmt == "csv":
|
||||
body = render_engagement_csv(engagement, simulations)
|
||||
filename = _export_filename(engagement, "csv")
|
||||
return Response(
|
||||
body,
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
# pdf
|
||||
body_bytes = render_engagement_pdf(engagement, simulations)
|
||||
filename = _export_filename(engagement, "pdf")
|
||||
return Response(
|
||||
body_bytes,
|
||||
mimetype="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""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.simulation import Simulation, SimulationStatus
|
||||
from backend.app.models.simulation_template import SimulationTemplate
|
||||
@@ -12,4 +14,7 @@ __all__ = [
|
||||
"Simulation",
|
||||
"SimulationStatus",
|
||||
"SimulationTemplate",
|
||||
"C2Config",
|
||||
"C2Task",
|
||||
"C2TaskSource",
|
||||
]
|
||||
|
||||
34
backend/app/models/c2_config.py
Normal file
34
backend/app/models/c2_config.py
Normal 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=False)
|
||||
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}>"
|
||||
48
backend/app/models/c2_task.py
Normal file
48
backend/app/models/c2_task.py
Normal 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}>"
|
||||
22
backend/app/services/c2/__init__.py
Normal file
22
backend/app/services/c2/__init__.py
Normal 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",
|
||||
]
|
||||
117
backend/app/services/c2/adapter.py
Normal file
117
backend/app/services/c2/adapter.py
Normal 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."""
|
||||
...
|
||||
19
backend/app/services/c2/factory.py
Normal file
19
backend/app/services/c2/factory.py
Normal 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 = False) -> 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)
|
||||
176
backend/app/services/c2/fake.py
Normal file
176
backend/app/services/c2/fake.py
Normal 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,
|
||||
)
|
||||
53
backend/app/services/c2/mapping.py
Normal file
53
backend/app/services/c2/mapping.py
Normal 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
|
||||
300
backend/app/services/c2/mythic.py
Normal file
300
backend/app/services/c2/mythic.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# Contract pinned from MythicMeta/Mythic_Scripting master @ 2026-06-10 (raw.githubusercontent.com/MythicMeta/Mythic_Scripting/master/mythic/mythic.py)
|
||||
# Sprint 10 correction: /graphql/ MUST have trailing slash (matches mythic_utilities.get_http_transport).
|
||||
"""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
|
||||
import urllib3
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
|
||||
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 = False) -> None:
|
||||
self._url = url.rstrip("/") + "/graphql/"
|
||||
self._token = api_token
|
||||
self._verify = verify_tls
|
||||
if not verify_tls:
|
||||
# Lab/red-team Mythic instances commonly use self-signed certs.
|
||||
# Suppress urllib3 warnings once per process when verification is off.
|
||||
urllib3.disable_warnings(InsecureRequestWarning)
|
||||
|
||||
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)
|
||||
40
backend/app/services/crypto.py
Normal file
40
backend/app/services/crypto.py
Normal 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"]
|
||||
277
backend/app/services/export.py
Normal file
277
backend/app/services/export.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Engagement export renderers — Markdown, CSV, PDF."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date
|
||||
from html import escape as _html_escape
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.models.engagement import Engagement
|
||||
from backend.app.models.simulation import Simulation
|
||||
|
||||
|
||||
def _export_filename(engagement: Engagement, ext: str) -> str:
|
||||
name = engagement.name or ""
|
||||
normalized = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")[:60] or "unnamed"
|
||||
today = date.today().strftime("%Y%m%d")
|
||||
return f"engagement-{engagement.id}-{slug}-{today}.{ext}"
|
||||
|
||||
|
||||
def _creator(obj: object) -> str:
|
||||
"""Return username string from an ORM object with a created_by relationship."""
|
||||
cb = getattr(obj, "created_by", None)
|
||||
if cb is None:
|
||||
return ""
|
||||
return getattr(cb, "username", "") or ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV formula-injection defense (defined early — used by _format_execution_csv)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
|
||||
# reaches the formula parser in some sheet versions.
|
||||
_CSV_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
|
||||
|
||||
|
||||
def _csv_safe(value: object) -> object:
|
||||
"""Defuse spreadsheet formula injection by prefixing user-controlled cells.
|
||||
|
||||
Excel / LibreOffice / Google Sheets interpret cells starting with =, +, -, @,
|
||||
\\t or \\r as formulas. Since this CSV is the engagement handoff to SOC and is
|
||||
explicitly opened in a spreadsheet app, an authenticated red-team user could
|
||||
craft a simulation field that executes on the SOC analyst's machine. Prefixing
|
||||
with a single apostrophe forces the spreadsheet to treat the cell as text.
|
||||
"""
|
||||
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution cell helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_execution_text(sim: Simulation) -> str:
|
||||
"""Canonical 3-part execution concat for Markdown and PDF (no CSV sanitization)."""
|
||||
parts = [
|
||||
sim.executed_at.isoformat() if sim.executed_at else "",
|
||||
sim.commands or "",
|
||||
sim.execution_result or "",
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _format_execution_csv(sim: Simulation) -> str:
|
||||
"""Execution concat for CSV: each user-controlled component is formula-defused
|
||||
before joining so that inner lines starting with =, +, -, @ are safe."""
|
||||
parts = [
|
||||
sim.executed_at.isoformat() if sim.executed_at else "",
|
||||
str(_csv_safe(sim.commands or "")),
|
||||
str(_csv_safe(sim.execution_result or "")),
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MD_HEADERS = [
|
||||
"Scénario",
|
||||
"Test",
|
||||
"Source de log",
|
||||
"Commentaires SOC",
|
||||
"Exécution",
|
||||
"Logs remontés au SIEM",
|
||||
"Cyber incident",
|
||||
]
|
||||
|
||||
|
||||
def render_engagement_markdown(
|
||||
engagement: Engagement, simulations: list[Simulation]
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append(f"# {engagement.name}")
|
||||
lines.append("")
|
||||
if engagement.description:
|
||||
lines.append(engagement.description)
|
||||
lines.append("")
|
||||
lines.append(f"**Status**: {engagement.status.value}")
|
||||
lines.append(
|
||||
f"**Start date**: {engagement.start_date.isoformat() if engagement.start_date else 'N/A'}"
|
||||
)
|
||||
lines.append(
|
||||
f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}"
|
||||
)
|
||||
lines.append(f"**Created by**: {_creator(engagement)}")
|
||||
lines.append(
|
||||
f"**Created at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if not simulations:
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## Simulations")
|
||||
lines.append("")
|
||||
|
||||
header_row = "| " + " | ".join(_MD_HEADERS) + " |"
|
||||
separator = "| " + " | ".join("---" for _ in _MD_HEADERS) + " |"
|
||||
lines.append(header_row)
|
||||
lines.append(separator)
|
||||
|
||||
for sim in simulations:
|
||||
def _cell(value: str | None) -> str:
|
||||
# Escape HTML (including quotes) first to prevent stored XSS in MD renderers
|
||||
# that interpret inline HTML, then escape pipe (GFM table syntax),
|
||||
# then fold newlines to <br/> (our own safe markup, inserted after escape).
|
||||
s = _html_escape(value or "")
|
||||
s = s.replace("|", "\\|")
|
||||
s = s.replace("\n", "<br/>")
|
||||
return s
|
||||
|
||||
execution = _format_execution_text(sim)
|
||||
row = "| " + " | ".join([
|
||||
_cell(sim.name),
|
||||
_cell(sim.description),
|
||||
_cell(sim.log_source),
|
||||
_cell(sim.soc_comment),
|
||||
_cell(execution),
|
||||
_cell(sim.logs),
|
||||
_cell(sim.incident_number),
|
||||
]) + " |"
|
||||
lines.append(row)
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CSV_HEADERS = [
|
||||
"Scénario",
|
||||
"Test",
|
||||
"Source de log",
|
||||
"Commentaires SOC",
|
||||
"Exécution",
|
||||
"Logs remontés au SIEM",
|
||||
"Cyber incident",
|
||||
]
|
||||
|
||||
|
||||
def render_engagement_csv(
|
||||
_engagement: Engagement, simulations: list[Simulation]
|
||||
) -> str:
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(_CSV_HEADERS)
|
||||
|
||||
for sim in simulations:
|
||||
execution = _format_execution_csv(sim)
|
||||
writer.writerow([
|
||||
_csv_safe(sim.name or ""),
|
||||
_csv_safe(sim.description or ""),
|
||||
_csv_safe(sim.log_source or ""),
|
||||
_csv_safe(sim.soc_comment or ""),
|
||||
_csv_safe(execution), # belt-and-braces: outer check covers empty executed_at case
|
||||
_csv_safe(sim.logs or ""),
|
||||
_csv_safe(sim.incident_number or ""),
|
||||
])
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML (internal, used by PDF renderer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CSS = """
|
||||
@page { size: A4 landscape; margin: 20mm; }
|
||||
body { font-family: sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
|
||||
h1 { font-size: 20px; border-bottom: 2px solid #333; padding-bottom: 6px; }
|
||||
h2 { font-size: 15px; margin-top: 32px; color: #333; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; table-layout: fixed; }
|
||||
th, td { border: 1px solid #ccc; padding: 3px 6px; text-align: left; vertical-align: top; white-space: pre-wrap; word-break: break-word; }
|
||||
th { background: #e0e0e0; }
|
||||
.meta { color: #555; margin-bottom: 16px; }
|
||||
"""
|
||||
|
||||
_HTML_HEADERS = [
|
||||
"Scénario",
|
||||
"Test",
|
||||
"Source de log",
|
||||
"Commentaires SOC",
|
||||
"Exécution",
|
||||
"Logs remontés au SIEM",
|
||||
"Cyber incident",
|
||||
]
|
||||
|
||||
|
||||
def _render_engagement_html(
|
||||
engagement: Engagement, simulations: list[Simulation]
|
||||
) -> str:
|
||||
h = _html_escape
|
||||
parts: list[str] = []
|
||||
|
||||
parts.append("<!DOCTYPE html><html><head><meta charset='utf-8'>")
|
||||
parts.append(f"<style>{_CSS}</style></head><body>")
|
||||
parts.append(f"<h1>{h(engagement.name)}</h1>")
|
||||
parts.append("<div class='meta'>")
|
||||
if engagement.description:
|
||||
parts.append(f"<p>{h(engagement.description)}</p>")
|
||||
parts.append(f"<p><strong>Status:</strong> {h(engagement.status.value)}</p>")
|
||||
sd = engagement.start_date.isoformat() if engagement.start_date else "N/A"
|
||||
ed = engagement.end_date.isoformat() if engagement.end_date else "N/A"
|
||||
parts.append(f"<p><strong>Dates:</strong> {h(sd)} → {h(ed)}</p>")
|
||||
parts.append(f"<p><strong>Created by:</strong> {h(_creator(engagement))}</p>")
|
||||
ca = engagement.created_at.isoformat() if engagement.created_at else "N/A"
|
||||
parts.append(f"<p><strong>Created at:</strong> {h(ca)}</p>")
|
||||
parts.append("</div>")
|
||||
|
||||
if simulations:
|
||||
parts.append("<h2>Simulations</h2>")
|
||||
thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in _HTML_HEADERS) + "</tr></thead>"
|
||||
parts.append(f"<table>{thead}<tbody>")
|
||||
for sim in simulations:
|
||||
execution_html = h(_format_execution_text(sim)).replace("\n", "<br/>")
|
||||
cells = [
|
||||
h(sim.name or ""),
|
||||
h(sim.description or ""),
|
||||
h(sim.log_source or ""),
|
||||
h(sim.soc_comment or ""),
|
||||
execution_html,
|
||||
h(sim.logs or ""),
|
||||
h(sim.incident_number or ""),
|
||||
]
|
||||
row = "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
|
||||
parts.append(row)
|
||||
parts.append("</tbody></table>")
|
||||
|
||||
parts.append("</body></html>")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_engagement_pdf(
|
||||
engagement: Engagement, simulations: list[Simulation]
|
||||
) -> bytes:
|
||||
from weasyprint import HTML
|
||||
|
||||
html = _render_engagement_html(engagement, simulations)
|
||||
return HTML(string=html).write_pdf()
|
||||
@@ -98,6 +98,19 @@ def _maybe_activate_engagement(simulation: Simulation) -> None:
|
||||
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(
|
||||
simulation: Simulation, payload: dict[str, Any], user: User
|
||||
) -> tuple[Any, int] | None:
|
||||
|
||||
67
backend/migrations/versions/0006_c2_layer.py
Normal file
67
backend/migrations/versions/0006_c2_layer.py
Normal 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)
|
||||
30
backend/migrations/versions/0007_c2_task_mapping_applied.py
Normal file
30
backend/migrations/versions/0007_c2_task_mapping_applied.py
Normal 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")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""flip c2_config.verify_tls server_default to false
|
||||
|
||||
Revision ID: 0008_c2_verify_tls_default_false
|
||||
Revises: 0007
|
||||
Create Date: 2026-06-21 00:00:00.000000
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0008_c2_verify_tls_default_false"
|
||||
down_revision = "0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("c2_config") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"verify_tls",
|
||||
existing_type=sa.Boolean(),
|
||||
existing_nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("c2_config") as batch_op:
|
||||
batch_op.alter_column(
|
||||
"verify_tls",
|
||||
existing_type=sa.Boolean(),
|
||||
existing_nullable=False,
|
||||
server_default=sa.true(),
|
||||
)
|
||||
@@ -3,6 +3,11 @@ Flask-SQLAlchemy==3.1.1
|
||||
Flask-Migrate==4.0.7
|
||||
PyJWT==2.9.0
|
||||
argon2-cffi==23.1.0
|
||||
weasyprint>=60.0
|
||||
cryptography==44.0.0
|
||||
requests==2.32.3
|
||||
pytest==8.3.3
|
||||
ruff==0.6.9
|
||||
mypy==1.11.2
|
||||
types-requests==2.32.0.20240914
|
||||
requests-mock==1.12.1
|
||||
|
||||
30
backend/tests/test_c2_adapter_fake.py
Normal file
30
backend/tests/test_c2_adapter_fake.py
Normal 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")
|
||||
62
backend/tests/test_c2_adapter_fake_m2.py
Normal file
62
backend/tests/test_c2_adapter_fake_m2.py
Normal 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
|
||||
110
backend/tests/test_c2_adapter_fake_m3.py
Normal file
110
backend/tests/test_c2_adapter_fake_m3.py
Normal 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)
|
||||
75
backend/tests/test_c2_adapter_fake_m4.py
Normal file
75
backend/tests/test_c2_adapter_fake_m4.py
Normal 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 == []
|
||||
161
backend/tests/test_c2_adapter_mythic.py
Normal file
161
backend/tests/test_c2_adapter_mythic.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""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
|
||||
|
||||
|
||||
class TestMythicAdapterUrlConstruction:
|
||||
def test_mythic_adapter_url_has_trailing_slash(self):
|
||||
a1 = MythicAdapter(url="https://x:7443", api_token="t")
|
||||
assert a1._url == "https://x:7443/graphql/"
|
||||
a2 = MythicAdapter(url="https://x:7443/", api_token="t")
|
||||
assert a2._url == "https://x:7443/graphql/"
|
||||
a3 = MythicAdapter(url="https://x:7443///", api_token="t")
|
||||
assert a3._url == "https://x:7443/graphql/"
|
||||
188
backend/tests/test_c2_adapter_mythic_m3.py
Normal file
188
backend/tests/test_c2_adapter_mythic_m3.py
Normal 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
|
||||
167
backend/tests/test_c2_adapter_mythic_m4.py
Normal file
167
backend/tests/test_c2_adapter_mythic_m4.py
Normal 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
|
||||
142
backend/tests/test_c2_callbacks.py
Normal file
142
backend/tests/test_c2_callbacks.py
Normal 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", "")
|
||||
367
backend/tests/test_c2_config.py
Normal file
367
backend/tests/test_c2_config.py
Normal 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_false(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
_put_config(client, admin_token, eng["id"], verify_tls=False)
|
||||
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
|
||||
assert resp.get_json()["verify_tls"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
324
backend/tests/test_c2_execute.py
Normal file
324
backend/tests/test_c2_execute.py
Normal 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", "")
|
||||
215
backend/tests/test_c2_history.py
Normal file
215
backend/tests/test_c2_history.py
Normal 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", "")
|
||||
437
backend/tests/test_c2_import.py
Normal file
437
backend/tests/test_c2_import.py
Normal 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", "")
|
||||
208
backend/tests/test_c2_mapping.py
Normal file
208
backend/tests/test_c2_mapping.py
Normal 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
|
||||
375
backend/tests/test_c2_tasks_list.py
Normal file
375
backend/tests/test_c2_tasks_list.py
Normal 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"
|
||||
52
backend/tests/test_crypto.py
Normal file
52
backend/tests/test_crypto.py
Normal 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")
|
||||
270
backend/tests/test_export_engagement.py
Normal file
270
backend/tests/test_export_engagement.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Endpoint tests for GET /api/engagements/<eid>/export."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import Engagement, EngagementStatus, User
|
||||
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||
from backend.app.services.export import _export_filename
|
||||
from backend.tests.conftest import auth_headers as _h
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_engagement(client: FlaskClient, token: str, name: str = "Op Alpha") -> dict:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(token),
|
||||
json={"name": name, "start_date": "2026-06-01"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def _make_sim(client: FlaskClient, token: str, eid: int, name: str = "Sim One") -> dict:
|
||||
resp = client.post(
|
||||
f"/api/engagements/{eid}/simulations",
|
||||
headers=_h(token),
|
||||
json={"name": name},
|
||||
)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def _export(client: FlaskClient, token: str, eid: int, fmt: str):
|
||||
return client.get(
|
||||
f"/api/engagements/{eid}/export?format={fmt}",
|
||||
headers=_h(token),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RBAC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
_make_sim(client, admin_token, eng["id"], "Lateral Movement")
|
||||
_make_sim(client, admin_token, eng["id"], "Persistence Check")
|
||||
|
||||
resp = _export(client, admin_token, eng["id"], "md")
|
||||
assert resp.status_code == 200
|
||||
assert "text/markdown" in resp.content_type
|
||||
body = resp.data.decode()
|
||||
assert "Op Alpha" in body
|
||||
# Both simulation names appear as cells in the 7-column table
|
||||
assert "Lateral Movement" in body
|
||||
assert "Persistence Check" in body
|
||||
# Table uses French column headers
|
||||
assert "Scénario" in body
|
||||
|
||||
|
||||
def test_export_markdown_redteam_ok(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
resp = _export(client, redteam_token, eng["id"], "md")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_export_markdown_soc_403(
|
||||
client: FlaskClient, soc_token: str, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
resp = _export(client, soc_token, eng["id"], "md")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_export_unauthenticated_401(client: FlaskClient, admin_token: str) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
resp = client.get(f"/api/engagements/{eng['id']}/export?format=md")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_export_csv_returns_csv_with_one_row_per_simulation(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
_make_sim(client, admin_token, eng["id"], "S1")
|
||||
_make_sim(client, admin_token, eng["id"], "S2")
|
||||
|
||||
resp = _export(client, admin_token, eng["id"], "csv")
|
||||
assert resp.status_code == 200
|
||||
assert "text/csv" in resp.content_type
|
||||
|
||||
import csv as csv_mod
|
||||
import io
|
||||
|
||||
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
|
||||
# 1 header + 2 simulations
|
||||
assert len(rows) == 3
|
||||
|
||||
|
||||
def test_export_csv_columns_match_contract(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
resp = _export(client, admin_token, eng["id"], "csv")
|
||||
assert resp.status_code == 200
|
||||
|
||||
import csv as csv_mod
|
||||
import io
|
||||
|
||||
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
|
||||
expected_headers = [
|
||||
"Scénario",
|
||||
"Test",
|
||||
"Source de log",
|
||||
"Commentaires SOC",
|
||||
"Exécution",
|
||||
"Logs remontés au SIEM",
|
||||
"Cyber incident",
|
||||
]
|
||||
assert rows[0] == expected_headers
|
||||
|
||||
|
||||
def test_export_csv_escapes_special_characters(
|
||||
client: FlaskClient, admin_token: str, app
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
|
||||
with app.app_context():
|
||||
admin = User.query.filter_by(username="admin1").first()
|
||||
sim = Simulation(
|
||||
engagement_id=eng["id"],
|
||||
name='Sim "quoted"',
|
||||
commands='cmd1, cmd2\nnewline "here"',
|
||||
status=SimulationStatus.PENDING,
|
||||
created_by_id=admin.id,
|
||||
)
|
||||
db.session.add(sim)
|
||||
db.session.commit()
|
||||
|
||||
resp = _export(client, admin_token, eng["id"], "csv")
|
||||
assert resp.status_code == 200
|
||||
body = resp.data.decode()
|
||||
# csv.writer must have quoted the fields — no raw unquoted double-quotes breaking rows
|
||||
import csv as csv_mod
|
||||
import io
|
||||
|
||||
rows = list(csv_mod.reader(io.StringIO(body)))
|
||||
assert len(rows) == 2 # header + 1 sim
|
||||
name_col = rows[1][0] # col 0 = Scénario
|
||||
assert "quoted" in name_col
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_export_pdf_returns_pdf_magic_bytes_and_non_empty(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
_make_sim(client, admin_token, eng["id"], "S1")
|
||||
|
||||
resp = _export(client, admin_token, eng["id"], "pdf")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content_type == "application/pdf"
|
||||
assert resp.data[:4] == b"%PDF"
|
||||
assert len(resp.data) > 1024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 400 / 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_export_unknown_format_400(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
resp = client.get(
|
||||
f"/api/engagements/{eng['id']}/export?format=xml",
|
||||
headers=_h(admin_token),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "format must be one of" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_export_missing_format_400(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
resp = client.get(
|
||||
f"/api/engagements/{eng['id']}/export",
|
||||
headers=_h(admin_token),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "format must be one of" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_export_unknown_engagement_404(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
resp = client.get(
|
||||
"/api/engagements/99999/export?format=md",
|
||||
headers=_h(admin_token),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_export_engagement_with_zero_simulations_renders_header_only(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token, "Empty Engagement")
|
||||
|
||||
resp_md = _export(client, admin_token, eng["id"], "md")
|
||||
assert resp_md.status_code == 200
|
||||
assert "Empty Engagement" in resp_md.data.decode()
|
||||
|
||||
resp_csv = _export(client, admin_token, eng["id"], "csv")
|
||||
assert resp_csv.status_code == 200
|
||||
import csv as csv_mod
|
||||
import io
|
||||
|
||||
rows = list(csv_mod.reader(io.StringIO(resp_csv.data.decode())))
|
||||
assert len(rows) == 1 # header only
|
||||
|
||||
resp_pdf = _export(client, admin_token, eng["id"], "pdf")
|
||||
assert resp_pdf.status_code == 200
|
||||
assert resp_pdf.data[:4] == b"%PDF"
|
||||
|
||||
|
||||
def test_export_filename_slugifies_name_and_carries_date(app, admin_user: User) -> None:
|
||||
with app.app_context():
|
||||
eng = Engagement(
|
||||
name="Opération Spéciale!",
|
||||
start_date=date(2026, 6, 1),
|
||||
status=EngagementStatus.PLANNED,
|
||||
created_by_id=admin_user.id,
|
||||
)
|
||||
db.session.add(eng)
|
||||
db.session.commit()
|
||||
|
||||
fname = _export_filename(eng, "md")
|
||||
from datetime import date as _date
|
||||
|
||||
today = _date.today().strftime("%Y%m%d")
|
||||
assert fname.startswith(f"engagement-{eng.id}-")
|
||||
assert "operation-speciale" in fname
|
||||
assert fname.endswith(f"-{today}.md")
|
||||
317
backend/tests/test_export_render.py
Normal file
317
backend/tests/test_export_render.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Unit tests for render functions in backend.app.services.export."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv as _csv
|
||||
import io as _io
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from backend.app.services.export import (
|
||||
render_engagement_csv,
|
||||
render_engagement_markdown,
|
||||
render_engagement_pdf,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures / factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_engagement(**kw) -> Any:
|
||||
from datetime import date
|
||||
|
||||
defaults: dict[str, Any] = {
|
||||
"id": 1,
|
||||
"name": "Test Engagement",
|
||||
"description": "A purple team exercise",
|
||||
"start_date": date(2026, 6, 1),
|
||||
"end_date": date(2026, 6, 30),
|
||||
"status": SimpleNamespace(value="active"),
|
||||
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||
"created_by": SimpleNamespace(username="alice"),
|
||||
}
|
||||
defaults.update(kw)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
|
||||
defaults: dict[str, Any] = {
|
||||
"id": sid,
|
||||
"name": name,
|
||||
"status": SimpleNamespace(value="pending"),
|
||||
"description": "Execute a script",
|
||||
"commands": "whoami",
|
||||
"executed_at": None,
|
||||
"execution_result": None,
|
||||
"log_source": None,
|
||||
"logs": None,
|
||||
"soc_comment": None,
|
||||
"incident_number": None,
|
||||
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||
"updated_at": None,
|
||||
"created_by": SimpleNamespace(username="bob"),
|
||||
}
|
||||
defaults.update(kw)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FR_HEADERS = [
|
||||
"Scénario",
|
||||
"Test",
|
||||
"Source de log",
|
||||
"Commentaires SOC",
|
||||
"Exécution",
|
||||
"Logs remontés au SIEM",
|
||||
"Cyber incident",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_engagement_markdown_includes_header_fields(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
result = render_engagement_markdown(eng, [])
|
||||
assert "Test Engagement" in result
|
||||
assert "2026-06-01" in result
|
||||
assert "2026-06-30" in result
|
||||
assert "active" in result
|
||||
assert "alice" in result
|
||||
|
||||
|
||||
def test_render_engagement_markdown_has_seven_column_table_headers(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim()
|
||||
result = render_engagement_markdown(eng, [sim])
|
||||
for header in _FR_HEADERS:
|
||||
assert header in result, f"Expected French header '{header}' in markdown table"
|
||||
|
||||
|
||||
def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sims = [_make_sim(1, "First Sim"), _make_sim(2, "Second Sim")]
|
||||
result = render_engagement_markdown(eng, sims)
|
||||
first_pos = result.index("First Sim")
|
||||
second_pos = result.index("Second Sim")
|
||||
assert first_pos < second_pos
|
||||
|
||||
|
||||
def test_render_engagement_markdown_no_simulations_has_no_table(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
result = render_engagement_markdown(eng, [])
|
||||
assert "Scénario" not in result
|
||||
assert "## Simulations" not in result
|
||||
|
||||
|
||||
def test_render_engagement_markdown_execution_cell_uses_br_separator(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim(
|
||||
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||
commands="whoami",
|
||||
execution_result="admin@host",
|
||||
)
|
||||
result = render_engagement_markdown(eng, [sim])
|
||||
assert "<br/>" in result
|
||||
assert "whoami" in result
|
||||
assert "admin@host" in result
|
||||
|
||||
|
||||
def test_render_engagement_markdown_pipe_in_cell_is_escaped(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim(name="Name | with pipe")
|
||||
result = render_engagement_markdown(eng, [sim])
|
||||
assert "Name \\| with pipe" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_csv(csv_text: str) -> list[list[str]]:
|
||||
return list(_csv.reader(_io.StringIO(csv_text)))
|
||||
|
||||
|
||||
def test_render_engagement_csv_has_header_row(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
result = render_engagement_csv(eng, [])
|
||||
rows = _parse_csv(result)
|
||||
assert rows[0] == _FR_HEADERS
|
||||
|
||||
|
||||
def test_render_engagement_csv_has_one_row_per_simulation(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sims = [_make_sim(1, "S1"), _make_sim(2, "S2")]
|
||||
result = render_engagement_csv(eng, sims)
|
||||
rows = _parse_csv(result)
|
||||
assert len(rows) == 3 # header + 2 sims
|
||||
|
||||
|
||||
def test_render_engagement_csv_columns_are_seven(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim()
|
||||
result = render_engagement_csv(eng, [sim])
|
||||
rows = _parse_csv(result)
|
||||
assert len(rows[0]) == 7
|
||||
assert len(rows[1]) == 7
|
||||
|
||||
|
||||
def test_render_engagement_csv_execution_column_contains_commands(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim(
|
||||
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||
commands="net user /domain",
|
||||
execution_result="success",
|
||||
)
|
||||
result = render_engagement_csv(eng, [sim])
|
||||
rows = _parse_csv(result)
|
||||
exec_cell = rows[1][4] # col index 4 = Exécution
|
||||
assert "2026-06-01" in exec_cell
|
||||
assert "net user /domain" in exec_cell
|
||||
assert "success" in exec_cell
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV formula injection defense
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_csv_data_row(csv_text: str, row_index: int = 1) -> list[str]:
|
||||
return _parse_csv(csv_text)[row_index]
|
||||
|
||||
|
||||
def test_render_engagement_csv_escapes_formula_injection_in_scenario(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim(name="=cmd|'/c calc'!A1")
|
||||
result = render_engagement_csv(eng, [sim])
|
||||
cells = _parse_csv_data_row(result)
|
||||
# col 0 = Scénario
|
||||
assert cells[0] == "'=cmd|'/c calc'!A1"
|
||||
|
||||
|
||||
def test_render_engagement_csv_escapes_formula_injection_in_execution(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
# executed_at=None so concat is "\ncommand\n" — leading \n is not a trigger.
|
||||
# Use a formula-triggering execution_result to test the final concat.
|
||||
sim = _make_sim(execution_result="=HYPERLINK(\"http://evil\")")
|
||||
result = render_engagement_csv(eng, [sim])
|
||||
cells = _parse_csv_data_row(result)
|
||||
# col 4 = Exécution; concat starts with "\n" (empty executed_at) so not triggered
|
||||
# but the execution_result value is embedded — verify it's present
|
||||
assert "HYPERLINK" in cells[4]
|
||||
|
||||
|
||||
def test_render_engagement_csv_defuses_formula_in_inner_execution_lines(app) -> None:
|
||||
"""When executed_at is set, the cell starts with a safe date, but commands
|
||||
line may inject formulas. Each user-controlled component must be defused."""
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim(
|
||||
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||
commands="=cmd|'/c calc'!A1",
|
||||
execution_result="@SUM(1)",
|
||||
)
|
||||
result = render_engagement_csv(eng, [sim])
|
||||
cells = list(_csv.reader(_io.StringIO(result)))[1]
|
||||
execution_cell = cells[4] # Exécution column
|
||||
assert "'=cmd|'/c calc'!A1" in execution_cell
|
||||
assert "'@SUM(1)" in execution_cell
|
||||
|
||||
|
||||
def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim(name="Mimikatz LSASS Dump", commands="whoami /all")
|
||||
result = render_engagement_csv(eng, [sim])
|
||||
cells = _parse_csv_data_row(result)
|
||||
assert cells[0] == "Mimikatz LSASS Dump"
|
||||
assert "whoami /all" in cells[4]
|
||||
|
||||
|
||||
def test_render_engagement_markdown_escapes_html_in_table_cells(app) -> None:
|
||||
"""User content in table cells must be HTML-escaped to prevent stored XSS
|
||||
when the .md is opened in a renderer that interprets inline HTML."""
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim(
|
||||
name="<script>alert(1)</script>",
|
||||
commands='<img src=x onerror="alert(1)">',
|
||||
)
|
||||
result = render_engagement_markdown(eng, [sim])
|
||||
assert "<script>" not in result
|
||||
assert 'onerror="alert' not in result
|
||||
assert "<script>" in result
|
||||
assert "<img" in result
|
||||
# double-quotes in attribute values are also escaped
|
||||
assert """ in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_engagement_pdf_starts_with_pdf_magic(app) -> None:
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim()
|
||||
result = render_engagement_pdf(eng, [sim])
|
||||
assert isinstance(result, bytes)
|
||||
assert result[:4] == b"%PDF"
|
||||
|
||||
|
||||
def test_render_engagement_pdf_contains_simulation_table(app) -> None:
|
||||
from backend.app.services.export import _render_engagement_html
|
||||
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
sim = _make_sim()
|
||||
html = _render_engagement_html(eng, [sim])
|
||||
assert "<table>" in html
|
||||
for header in _FR_HEADERS:
|
||||
assert header in html, f"Expected French header '{header}' in HTML"
|
||||
|
||||
|
||||
def test_render_engagement_html_has_landscape_page_rule(app) -> None:
|
||||
from backend.app.services.export import _render_engagement_html
|
||||
|
||||
with app.app_context():
|
||||
eng = _make_engagement()
|
||||
html = _render_engagement_html(eng, [])
|
||||
assert "landscape" in html, "HTML must include A4 landscape @page rule for PDF output"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defense-in-depth: filename header injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_export_filename_never_contains_quote_or_crlf() -> None:
|
||||
"""Defense-in-depth: even with malicious engagement names, the filename
|
||||
used in Content-Disposition must never contain header-injection chars."""
|
||||
from backend.app.services.export import _export_filename
|
||||
|
||||
evil = SimpleNamespace(id=42, name='evil"name\r\nX-Injected: yes')
|
||||
fname = _export_filename(evil, "md")
|
||||
assert '"' not in fname
|
||||
assert '\r' not in fname
|
||||
assert '\n' not in fname
|
||||
199
backend/tests/test_migration_0006_c2.py
Normal file
199
backend/tests/test_migration_0006_c2.py
Normal 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()
|
||||
124
backend/tests/test_migration_0007_c2.py
Normal file
124
backend/tests/test_migration_0007_c2.py
Normal 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
|
||||
@@ -9,6 +9,14 @@ RUN npm run build
|
||||
# Stage 2: python runtime
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libcairo2 \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
libfontconfig1 \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY backend/requirements.txt ./backend/
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
COPY backend/ ./backend/
|
||||
|
||||
334
e2e/tests/us29-export-formats.spec.ts
Normal file
334
e2e/tests/us29-export-formats.spec.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* US-29 — Admin/redteam exports an engagement in Markdown, CSV, PDF.
|
||||
*
|
||||
* Strategy: seed one engagement with 2 simulations via the API, then drive the
|
||||
* ExportEngagementButton dropdown in Chromium. Downloads are captured via
|
||||
* page.waitForEvent('download') and read back with fs.readFile.
|
||||
*
|
||||
* AC covered: 29.1 — 29.6
|
||||
*/
|
||||
import * as fs from 'fs/promises';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteEngagement,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
makeClient,
|
||||
type Engagement,
|
||||
} from '../fixtures/api';
|
||||
|
||||
const CSV_HEADER_COLS = [
|
||||
'Scénario',
|
||||
'Test',
|
||||
'Source de log',
|
||||
'Commentaires SOC',
|
||||
'Exécution',
|
||||
'Logs remontés au SIEM',
|
||||
'Cyber incident',
|
||||
];
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const ADMIN_USER = 'us29-admin';
|
||||
const REDTEAM_USER = 'us29-redteam';
|
||||
const PASS = 'us29-pass-strong!';
|
||||
|
||||
interface Simulation {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-4180 row counter.
|
||||
* Walks char-by-char tracking quoting so that newlines inside quoted cells
|
||||
* don't count as row breaks. Returns the total row count (including header)
|
||||
* plus helper strings for assertions.
|
||||
*/
|
||||
function countCsvRows(csv: string): {
|
||||
count: number;
|
||||
headerLine: string;
|
||||
dataText: string;
|
||||
} {
|
||||
let inQuote = false;
|
||||
let rowCount = 0;
|
||||
let lineStart = 0;
|
||||
let headerLine = '';
|
||||
|
||||
for (let i = 0; i < csv.length; i++) {
|
||||
const ch = csv[i];
|
||||
if (ch === '"') {
|
||||
if (inQuote && csv[i + 1] === '"') {
|
||||
i++; // escaped double-quote inside quoted cell
|
||||
} else {
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
} else if ((ch === '\n' || ch === '\r') && !inQuote) {
|
||||
if (ch === '\r' && csv[i + 1] === '\n') i++;
|
||||
const line = csv.slice(lineStart, i).trim();
|
||||
if (line.length > 0) {
|
||||
if (rowCount === 0) headerLine = line;
|
||||
rowCount++;
|
||||
}
|
||||
lineStart = i + 1;
|
||||
}
|
||||
}
|
||||
// trailing row without final newline
|
||||
const tail = csv.slice(lineStart).trim();
|
||||
if (tail.length > 0) {
|
||||
if (rowCount === 0) headerLine = tail;
|
||||
rowCount++;
|
||||
}
|
||||
|
||||
const dataText = rowCount > 1 ? csv.slice(headerLine.length + 1) : '';
|
||||
return { count: rowCount, headerLine, dataText };
|
||||
}
|
||||
|
||||
async function createSimulation(
|
||||
token: string,
|
||||
engagementId: number,
|
||||
name: string,
|
||||
): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) {
|
||||
throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
}
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
test.describe('US-29 — Export formats (admin + redteam)', () => {
|
||||
let adminTok: string;
|
||||
let redteamTok: string;
|
||||
let engagement: Engagement;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(ADMIN_USER, PASS, 'admin');
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
adminTok = (await login(ADMIN_USER, PASS)).token;
|
||||
redteamTok = (await login(REDTEAM_USER, PASS)).token;
|
||||
|
||||
engagement = await createEngagement(adminTok, {
|
||||
name: 'US29 Export Engagement',
|
||||
description: 'Export test engagement',
|
||||
start_date: '2026-01-15',
|
||||
status: 'active',
|
||||
});
|
||||
await createSimulation(adminTok, engagement.id, 'US29 Sim Alpha');
|
||||
await createSimulation(adminTok, engagement.id, 'US29 Sim Beta');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
await deleteEngagement(adminTok, engagement.id);
|
||||
const rootTok = await adminToken();
|
||||
for (const u of [ADMIN_USER, REDTEAM_USER]) await deleteUserByUsername(rootTok, u);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
// AC-29.1 — Export dropdown opens
|
||||
test('AC-29.1 — admin: Export dropdown opens with 3 format items', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, adminTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
|
||||
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click the Export button to open dropdown
|
||||
await dropdownWrapper.locator('button').first().click();
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: /markdown/i }).or(
|
||||
page.locator('[role="menuitem"]').filter({ hasText: /markdown/i })
|
||||
).first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await expect(page.getByText(/csv/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/pdf/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
// AC-29.2 — Markdown download
|
||||
test('AC-29.2 — admin: Markdown download contains engagement name and simulation names', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, adminTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
|
||||
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
|
||||
await dropdownWrapper.locator('button').first().click();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
|
||||
]);
|
||||
|
||||
const filePath = await download.path();
|
||||
expect(filePath).toBeTruthy();
|
||||
|
||||
const content = await fs.readFile(filePath!, 'utf-8');
|
||||
|
||||
// Must contain engagement name and start date in the header section
|
||||
expect(content).toContain('US29 Export Engagement');
|
||||
expect(content).toContain('2026-01-15');
|
||||
// Must use the 7-column GFM table layout
|
||||
expect(content).toContain('| Scénario |');
|
||||
// Simulation names appear in the Scénario column
|
||||
expect(content).toContain('US29 Sim Alpha');
|
||||
expect(content).toContain('US29 Sim Beta');
|
||||
|
||||
// Suggested filename from Content-Disposition must end in .md
|
||||
const suggestedName = download.suggestedFilename();
|
||||
expect(suggestedName).toMatch(/\.md$/);
|
||||
});
|
||||
|
||||
// AC-29.3 — CSV download: N+1 rows (1 header + N simulations)
|
||||
test('AC-29.3 — admin: CSV download has N+1 rows (header + 2 sim rows)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, adminTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
|
||||
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
|
||||
await dropdownWrapper.locator('button').first().click();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(),
|
||||
]);
|
||||
|
||||
const filePath = await download.path();
|
||||
expect(filePath).toBeTruthy();
|
||||
|
||||
const raw = await fs.readFile(filePath!, 'utf-8');
|
||||
|
||||
// Count RFC-4180 rows: walk char-by-char, track quoting state so that
|
||||
// newlines embedded inside quoted cells don't count as row breaks.
|
||||
const rows = countCsvRows(raw);
|
||||
// 1 header + 2 simulation rows
|
||||
expect(rows.count).toBe(3);
|
||||
|
||||
// Header must be exactly the 7 FR columns
|
||||
const headerCells = rows.headerLine.split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
|
||||
expect(headerCells).toEqual(CSV_HEADER_COLS);
|
||||
|
||||
// Scénario column (index 0) contains simulation names
|
||||
expect(rows.dataText).toContain('US29 Sim Alpha');
|
||||
expect(rows.dataText).toContain('US29 Sim Beta');
|
||||
|
||||
const suggestedName = download.suggestedFilename();
|
||||
expect(suggestedName).toMatch(/\.csv$/);
|
||||
});
|
||||
|
||||
// AC-29.4 — PDF download
|
||||
test('AC-29.4 — admin: PDF download has %PDF magic bytes and size > 1 KB', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, adminTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
|
||||
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
|
||||
await dropdownWrapper.locator('button').first().click();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(),
|
||||
]);
|
||||
|
||||
const filePath = await download.path();
|
||||
expect(filePath).toBeTruthy();
|
||||
|
||||
const buf = await fs.readFile(filePath!);
|
||||
// Magic bytes: %PDF
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
// Size > 1 KB
|
||||
expect(buf.byteLength).toBeGreaterThan(1024);
|
||||
|
||||
const suggestedName = download.suggestedFilename();
|
||||
expect(suggestedName).toMatch(/\.pdf$/);
|
||||
});
|
||||
|
||||
// AC-29.5 — Redteam: all 3 formats work
|
||||
test('AC-29.5 — redteam: Markdown download works', async ({ page, context }) => {
|
||||
await seedTokenInStorage(context, redteamTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
|
||||
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
|
||||
await dropdownWrapper.locator('button').first().click();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
|
||||
]);
|
||||
const filePath = await download.path();
|
||||
const content = await fs.readFile(filePath!, 'utf-8');
|
||||
expect(content).toContain('US29 Export Engagement');
|
||||
});
|
||||
|
||||
test('AC-29.5 — redteam: CSV download works', async ({ page, context }) => {
|
||||
await seedTokenInStorage(context, redteamTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
|
||||
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
|
||||
await dropdownWrapper.locator('button').first().click();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(),
|
||||
]);
|
||||
const filePath = await download.path();
|
||||
const raw = await fs.readFile(filePath!, 'utf-8');
|
||||
const rows = countCsvRows(raw);
|
||||
expect(rows.count).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('AC-29.5 — redteam: PDF download works', async ({ page, context }) => {
|
||||
await seedTokenInStorage(context, redteamTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
|
||||
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
|
||||
await dropdownWrapper.locator('button').first().click();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(),
|
||||
]);
|
||||
const filePath = await download.path();
|
||||
const buf = await fs.readFile(filePath!);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
// AC-29.6 — Filename convention: engagement-<id>-<slug>-YYYYMMDD.{ext}
|
||||
test('AC-29.6 — filename matches engagement-<id>-<slug>-YYYYMMDD pattern', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, adminTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
|
||||
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
|
||||
await dropdownWrapper.locator('button').first().click();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
|
||||
]);
|
||||
|
||||
const suggestedName = download.suggestedFilename();
|
||||
// Pattern: engagement-<id>-<slug>-YYYYMMDD.md
|
||||
const filenamePattern = new RegExp(
|
||||
`^engagement-${engagement.id}-[a-z0-9-]+-\\d{8}\\.md$`,
|
||||
);
|
||||
expect(suggestedName).toMatch(filenamePattern);
|
||||
});
|
||||
});
|
||||
90
e2e/tests/us30-export-rbac.spec.ts
Normal file
90
e2e/tests/us30-export-rbac.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* US-30 — SOC role has zero access to the export feature.
|
||||
*
|
||||
* AC-30.1: SOC login → Export button absent from DOM (not just hidden).
|
||||
* AC-30.2: Direct API call with SOC Bearer → 403.
|
||||
* AC-30.3: Direct API call without token → 401.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteEngagement,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
type Engagement,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const SOC_USER = 'us30-soc';
|
||||
const ADMIN_USER = 'us30-admin';
|
||||
const PASS = 'us30-pass-strong!';
|
||||
|
||||
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||
|
||||
test.describe('US-30 — SOC zero access to export', () => {
|
||||
let socTok: string;
|
||||
let adminTok: string;
|
||||
let engagement: Engagement;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(SOC_USER, PASS, 'soc');
|
||||
await ensureUser(ADMIN_USER, PASS, 'admin');
|
||||
socTok = (await login(SOC_USER, PASS)).token;
|
||||
adminTok = (await login(ADMIN_USER, PASS)).token;
|
||||
|
||||
engagement = await createEngagement(adminTok, {
|
||||
name: 'US30 RBAC Engagement',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
await deleteEngagement(adminTok, engagement.id);
|
||||
const rootTok = await adminToken();
|
||||
for (const u of [SOC_USER, ADMIN_USER]) await deleteUserByUsername(rootTok, u);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
// AC-30.1 — SOC: Export button absent from DOM
|
||||
test('AC-30.1 — SOC login: Export dropdown is NOT attached to the DOM', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, socTok);
|
||||
await page.goto(`/engagements/${engagement.id}`);
|
||||
|
||||
// Wait for the page to fully load (engagement header should be visible)
|
||||
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The export dropdown wrapper must not be in the DOM at all
|
||||
await expect(
|
||||
page.locator('[data-testid="export-dropdown"]'),
|
||||
).not.toBeAttached();
|
||||
});
|
||||
|
||||
// AC-30.2 — SOC Bearer token → 403
|
||||
test('AC-30.2 — SOC Bearer: GET /api/engagements/<id>/export?format=md → 403', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${socTok}` },
|
||||
},
|
||||
);
|
||||
expect(response.status()).toBe(403);
|
||||
});
|
||||
|
||||
// AC-30.3 — No token → 401
|
||||
test('AC-30.3 — No token: GET /api/engagements/<id>/export?format=md → 401', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
|
||||
);
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
188
e2e/tests/us31-export-robustness.spec.ts
Normal file
188
e2e/tests/us31-export-robustness.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* US-31 — Export robustness: format validation and edge cases.
|
||||
*
|
||||
* All 4 ACs use direct API calls (no UI needed) for speed and determinism.
|
||||
*
|
||||
* AC-31.1: missing ?format → 400 friendly message
|
||||
* AC-31.2: ?format=xml → 400 friendly message
|
||||
* AC-31.3: engagement 99999 → 404
|
||||
* AC-31.4: engagement with 0 simulations → export OK (CSV = 1 header row only)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteEngagement,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
} from '../fixtures/api';
|
||||
|
||||
const CSV_HEADER_COLS = [
|
||||
'Scénario',
|
||||
'Test',
|
||||
'Source de log',
|
||||
'Commentaires SOC',
|
||||
'Exécution',
|
||||
'Logs remontés au SIEM',
|
||||
'Cyber incident',
|
||||
];
|
||||
|
||||
const ADMIN_USER = 'us31-admin';
|
||||
const PASS = 'us31-pass-strong!';
|
||||
|
||||
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||
|
||||
/**
|
||||
* RFC-4180-aware row counter — same logic as us29 helper.
|
||||
* Embedded newlines inside quoted cells are not counted as row breaks.
|
||||
*/
|
||||
function countCsvRows(csv: string): number {
|
||||
let inQuote = false;
|
||||
let rowCount = 0;
|
||||
let lineStart = 0;
|
||||
|
||||
for (let i = 0; i < csv.length; i++) {
|
||||
const ch = csv[i];
|
||||
if (ch === '"') {
|
||||
if (inQuote && csv[i + 1] === '"') {
|
||||
i++;
|
||||
} else {
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
} else if ((ch === '\n' || ch === '\r') && !inQuote) {
|
||||
if (ch === '\r' && csv[i + 1] === '\n') i++;
|
||||
const line = csv.slice(lineStart, i).trim();
|
||||
if (line.length > 0) rowCount++;
|
||||
lineStart = i + 1;
|
||||
}
|
||||
}
|
||||
const tail = csv.slice(lineStart).trim();
|
||||
if (tail.length > 0) rowCount++;
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
test.describe('US-31 — Export robustness', () => {
|
||||
let adminTok: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(ADMIN_USER, PASS, 'admin');
|
||||
adminTok = (await login(ADMIN_USER, PASS)).token;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const rootTok = await adminToken();
|
||||
await deleteUserByUsername(rootTok, ADMIN_USER);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
// AC-31.1 — missing format → 400
|
||||
test('AC-31.1 — GET /export without format → 400 with friendly error', async ({
|
||||
request,
|
||||
}) => {
|
||||
// Need a valid engagement id — use admin to create one transiently
|
||||
const engagement = await createEngagement(adminTok, {
|
||||
name: 'US31 missing format eng',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/engagements/${engagement.id}/export`,
|
||||
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('error');
|
||||
expect(body.error).toMatch(/format/i);
|
||||
} finally {
|
||||
await deleteEngagement(adminTok, engagement.id);
|
||||
}
|
||||
});
|
||||
|
||||
// AC-31.2 — unknown format → 400
|
||||
test('AC-31.2 — GET /export?format=xml → 400 with friendly error', async ({
|
||||
request,
|
||||
}) => {
|
||||
const engagement = await createEngagement(adminTok, {
|
||||
name: 'US31 bad format eng',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/engagements/${engagement.id}/export?format=xml`,
|
||||
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('error');
|
||||
expect(body.error).toMatch(/format/i);
|
||||
} finally {
|
||||
await deleteEngagement(adminTok, engagement.id);
|
||||
}
|
||||
});
|
||||
|
||||
// AC-31.3 — unknown engagement → 404
|
||||
test('AC-31.3 — GET /engagements/99999/export?format=md → 404', async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/engagements/99999/export?format=md`,
|
||||
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||
);
|
||||
expect(response.status()).toBe(404);
|
||||
});
|
||||
|
||||
// AC-31.4 — engagement with 0 simulations: export OK
|
||||
test('AC-31.4 — engagement with 0 simulations: Markdown export OK (header only)', async ({
|
||||
request,
|
||||
}) => {
|
||||
const engagement = await createEngagement(adminTok, {
|
||||
name: 'US31 empty engagement',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
|
||||
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const text = await response.text();
|
||||
// Engagement header section present
|
||||
expect(text).toContain('US31 empty engagement');
|
||||
// With 0 simulations the GFM table is absent (no rows to render)
|
||||
expect(text).not.toContain('| Scénario |');
|
||||
} finally {
|
||||
await deleteEngagement(adminTok, engagement.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-31.4 — engagement with 0 simulations: CSV export has only 1 header row', async ({
|
||||
request,
|
||||
}) => {
|
||||
const engagement = await createEngagement(adminTok, {
|
||||
name: 'US31 empty CSV engagement',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request.get(
|
||||
`${BASE_URL}/api/engagements/${engagement.id}/export?format=csv`,
|
||||
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
const text = await response.text();
|
||||
// Count rows via RFC-4180-aware counter (handles embedded newlines in quoted cells)
|
||||
const rowCount = countCsvRows(text);
|
||||
expect(rowCount).toBe(1);
|
||||
// The single row is the header with exactly the 7 FR columns
|
||||
const headerCells = text.trim().split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
|
||||
expect(headerCells).toEqual(CSV_HEADER_COLS);
|
||||
} finally {
|
||||
await deleteEngagement(adminTok, engagement.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
97
frontend/package-lock.json
generated
97
frontend/package-lock.json
generated
@@ -8,11 +8,14 @@
|
||||
"name": "mimic-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^26.3.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -339,7 +342,6 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1013,6 +1015,15 @@
|
||||
"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": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -4222,6 +4233,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -4259,6 +4279,34 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -5926,6 +5974,33 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -7046,7 +7121,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -7123,6 +7198,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -7279,6 +7363,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"i18next": "^26.3.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
94
frontend/src/api/c2.ts
Normal file
94
frontend/src/api/c2.ts
Normal 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;
|
||||
}
|
||||
56
frontend/src/api/exports.ts
Normal file
56
frontend/src/api/exports.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import axios from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export type ExportFormat = 'md' | 'csv' | 'pdf';
|
||||
|
||||
export function parseContentDispositionFilename(header: string | undefined): string | null {
|
||||
if (!header) return null;
|
||||
const match = header.match(/filename="([^"]+)"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function parseBlobError(err: unknown): Promise<string> {
|
||||
if (axios.isAxiosError(err) && err.response?.data instanceof Blob) {
|
||||
try {
|
||||
const text = await (err.response.data as Blob).text();
|
||||
const parsed = JSON.parse(text) as { error?: string };
|
||||
if (parsed.error) return parsed.error;
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return 'Export failed';
|
||||
}
|
||||
|
||||
export async function downloadEngagementExport(
|
||||
engagementId: number,
|
||||
format: ExportFormat,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.get(`/engagements/${engagementId}/export`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
let filename = parseContentDispositionFilename(
|
||||
response.headers['content-disposition'] as string | undefined,
|
||||
);
|
||||
if (!filename) {
|
||||
const ext = format === 'md' ? 'md' : format === 'csv' ? 'csv' : 'pdf';
|
||||
filename = `engagement-${engagementId}.${ext}`;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(response.data as Blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
const message = await parseBlobError(err);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -154,3 +154,101 @@ export interface SimulationPatchInput {
|
||||
soc_comment?: 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;
|
||||
}
|
||||
|
||||
30
frontend/src/components/AlertBanner.tsx
Normal file
30
frontend/src/components/AlertBanner.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Info } from 'lucide-react';
|
||||
|
||||
type AlertVariant = 'error' | 'warn' | 'success' | 'info';
|
||||
|
||||
interface AlertBannerProps {
|
||||
variant: AlertVariant;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const CONFIG: Record<AlertVariant, { cls: string; Icon: typeof AlertCircle; role: string }> = {
|
||||
error: { cls: 'alert-error', Icon: AlertCircle, role: 'alert' },
|
||||
warn: { cls: 'alert-warn', Icon: AlertTriangle, role: 'alert' },
|
||||
success: { cls: 'alert-success', Icon: CheckCircle, role: 'status' },
|
||||
info: { cls: 'alert-info', Icon: Info, role: 'status' },
|
||||
};
|
||||
|
||||
export function AlertBanner({ variant, title, children }: AlertBannerProps): JSX.Element {
|
||||
const { cls, Icon, role } = CONFIG[variant];
|
||||
return (
|
||||
<div role={role} className={cls}>
|
||||
<Icon size={16} aria-hidden className="mt-[2px] shrink-0" />
|
||||
<div className="flex flex-col gap-xxs text-[14px]">
|
||||
{title ? <span className="font-medium text-ink">{title}</span> : null}
|
||||
<span className="text-charcoal">{children}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/BackLink.tsx
Normal file
17
frontend/src/components/BackLink.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface BackLinkProps {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function BackLink({ to, children }: BackLinkProps): JSX.Element {
|
||||
return (
|
||||
<Link to={to} className="inline-flex items-center gap-xxs caption-md text-graphite hover:text-primary">
|
||||
<ArrowLeft size={14} aria-hidden />
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/C2CallbackPicker.tsx
Normal file
98
frontend/src/components/C2CallbackPicker.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-[14px] text-graphite">{t('state.loading')}</p>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-[14px] text-bloom-deep">
|
||||
{extractApiError(error, t('state.error.desc'))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (callbacks.length === 0) {
|
||||
return <p className="text-[14px] text-graphite">{t('c2.modal.picker.empty')}</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">{t('c2.modal.picker.col.displayId')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.active')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.host')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.user')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.domain')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.lastCheckin')}</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 ? t('c2.modal.picker.status.active') : t('c2.modal.picker.status.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>
|
||||
);
|
||||
}
|
||||
247
frontend/src/components/C2ConfigCard.tsx
Normal file
247
frontend/src/components/C2ConfigCard.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { t } = useTranslation();
|
||||
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(false);
|
||||
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(t('c2.config.toast.saved'), 'success');
|
||||
setToken('');
|
||||
setReplaceToken(false);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('c2.config.error.save')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
setTestResult(null);
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
push(t('c2.config.toast.deleted'), 'success');
|
||||
setUrl('');
|
||||
setToken('');
|
||||
setVerifyTls(false);
|
||||
setReplaceToken(false);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, t('c2.config.error.delete')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onTest = async () => {
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await testMutation.mutateAsync();
|
||||
setTestResult({
|
||||
ok: result.ok,
|
||||
message: result.ok ? t('c2.config.connected') : (result.error ?? t('c2.config.connectionFailed')),
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: extractApiError(err, t('c2.config.connectionFailed')) });
|
||||
}
|
||||
};
|
||||
|
||||
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">{t('c2.config.title')}</h2>
|
||||
|
||||
{is503 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
|
||||
>
|
||||
{t('c2.config.disabled')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<p className="text-[14px] text-graphite">{t('c2.config.loading')}</p>
|
||||
) : (
|
||||
<form onSubmit={onSave} noValidate className="flex flex-col gap-md">
|
||||
<FormField
|
||||
label={t('c2.config.field.url')}
|
||||
htmlFor="c2-url"
|
||||
hint={t('c2.config.field.urlHint')}
|
||||
>
|
||||
<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={t('c2.config.field.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}
|
||||
>
|
||||
{t('c2.config.btn.replaceToken')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<TextInput
|
||||
id="c2-token"
|
||||
data-testid="c2-token-input"
|
||||
type="password"
|
||||
name="api_token"
|
||||
placeholder={config?.has_token ? t('c2.config.field.tokenHint') : t('c2.config.field.token')}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col gap-xs">
|
||||
<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">
|
||||
{t('c2.config.field.verifyTls')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[12px] text-graphite">
|
||||
{t('c2.config.field.verifyTlsHint')}
|
||||
</p>
|
||||
</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 ? t('c2.config.btn.saving') : t('c2.config.btn.save')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-test-btn"
|
||||
className="btn-outline"
|
||||
onClick={onTest}
|
||||
disabled={disabled || testMutation.isPending || !config}
|
||||
>
|
||||
{testMutation.isPending ? t('c2.config.btn.testing') : t('c2.config.btn.test')}
|
||||
</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}
|
||||
>
|
||||
{t('c2.config.btn.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title={t('c2.config.deleteConfirm.title')}
|
||||
description={t('c2.config.deleteConfirm.desc')}
|
||||
confirmLabel={t('c2.config.deleteConfirm.confirm')}
|
||||
cancelLabel={t('c2.config.deleteConfirm.cancel')}
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/C2TaskStatusBadge.tsx
Normal file
27
frontend/src/components/C2TaskStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/C2TasksPanel.tsx
Normal file
137
frontend/src/components/C2TasksPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useC2Tasks } from '@/hooks/useC2';
|
||||
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
|
||||
|
||||
interface C2TasksPanelProps {
|
||||
simulationId: number;
|
||||
}
|
||||
|
||||
export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
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">{t('c2.tasks.title')}</h3>
|
||||
{isRefreshing && (
|
||||
<span
|
||||
data-testid="c2-task-refresh-indicator"
|
||||
className="text-[12px] text-graphite"
|
||||
>
|
||||
{t('c2.tasks.refreshing')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="border border-hairline rounded-none px-md py-md">
|
||||
<p className="text-[14px] text-graphite">
|
||||
{t('c2.tasks.empty')}
|
||||
</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={t('c2.tasks.col.expand')} />
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.task')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.command')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.source')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.status')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.completedAt')}</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>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function ConfirmDialog({
|
||||
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="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">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function EmptyState({ title, description, action }: EmptyStateProps): JSX
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
{description ? <p className="text-[16px] text-charcoal">{description}</p> : null}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ErrorStateProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorState({ title = 'Something went wrong', message, onRetry }: ErrorStateProps): JSX.Element {
|
||||
export function ErrorState({ title, message, onRetry }: ErrorStateProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('state.error.title');
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
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">{resolvedTitle}</h2>
|
||||
<p className="text-[16px] text-charcoal">{message}</p>
|
||||
{onRetry ? (
|
||||
<button type="button" className="btn-outline" onClick={onRetry}>
|
||||
Retry
|
||||
{t('state.retry')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
131
frontend/src/components/ExecuteViaC2Modal.tsx
Normal file
131
frontend/src/components/ExecuteViaC2Modal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { t } = useTranslation();
|
||||
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(t('c2.modal.execute.toast.launched', { count: result.tasks.length }), 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, t('c2.modal.execute.error.launch')));
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
{t('c2.modal.execute.title')}
|
||||
</h2>
|
||||
|
||||
{/* Callback picker */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">{t('c2.modal.execute.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">
|
||||
{t('simulation.field.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={t('simulation.field.commands')}
|
||||
/>
|
||||
<span className="text-[12px] text-graphite">
|
||||
{t('c2.modal.execute.commandCount', { count: commandLines.length })}
|
||||
</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 ? t('c2.modal.execute.btn.launching') : t('c2.modal.execute.btn.launch')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={onClose}
|
||||
disabled={executeMutation.isPending}
|
||||
>
|
||||
{t('c2.modal.execute.btn.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
frontend/src/components/ExportEngagementButton.tsx
Normal file
104
frontend/src/components/ExportEngagementButton.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, Download } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface ExportEngagementButtonProps {
|
||||
engagementId: number;
|
||||
}
|
||||
|
||||
const FORMATS: { label: string; value: ExportFormat }[] = [
|
||||
{ label: 'Markdown', value: 'md' },
|
||||
{ label: 'CSV', value: 'csv' },
|
||||
{ label: 'PDF', value: 'pdf' },
|
||||
];
|
||||
|
||||
export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { push } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState<ExportFormat | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleDownload = async (format: ExportFormat) => {
|
||||
setLoading(format);
|
||||
try {
|
||||
await downloadEngagementExport(engagementId, format);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
push(err instanceof Error ? err.message : 'Export failed', 'error');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref} data-testid="export-dropdown">
|
||||
<div className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline border-r-0"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
data-testid="export-btn"
|
||||
>
|
||||
<Download size={14} aria-hidden /> {t('export.btn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('export.aria')}
|
||||
aria-expanded={open}
|
||||
className="btn-outline px-sm"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
data-testid="export-dropdown-toggle"
|
||||
>
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-xxs bg-paper border border-hairline rounded-none z-20 min-w-[160px]"
|
||||
role="menu"
|
||||
>
|
||||
{FORMATS.map(({ label, value }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={loading !== null}
|
||||
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog flex items-center gap-sm disabled:opacity-50"
|
||||
onClick={() => handleDownload(value)}
|
||||
data-testid={`export-format-${value}`}
|
||||
>
|
||||
{loading === value ? (
|
||||
<span className="font-mono text-[11px] animate-pulse" aria-hidden>
|
||||
{t('export.btnLoading')}
|
||||
</span>
|
||||
) : null}
|
||||
{loading !== value ? label : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
frontend/src/components/ImportC2HistoryModal.tsx
Normal file
254
frontend/src/components/ImportC2HistoryModal.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { t } = useTranslation();
|
||||
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
|
||||
? t('c2.modal.import.toast.partial', { imported: result.imported, skipped: result.skipped })
|
||||
: t('c2.modal.import.toast.imported', { count: result.imported });
|
||||
push(msg, 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, t('c2.modal.import.error.import')));
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
{t('c2.modal.import.title')}
|
||||
</h2>
|
||||
|
||||
{/* Step 1: callback picker */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">{t('c2.modal.picker.title')}</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">
|
||||
{t('c2.tasks.title')}{' '}
|
||||
{total > 0 && (
|
||||
<span className="text-graphite font-normal">({total} {t('c2.modal.import.total').toLowerCase()})</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{historyQuery.isLoading && (
|
||||
<p className="text-[14px] text-graphite">{t('state.loading')}</p>
|
||||
)}
|
||||
|
||||
{historyQuery.isError && (
|
||||
<p className="text-[14px] text-bloom-deep">
|
||||
{extractApiError(historyQuery.error, t('c2.modal.import.error.import'))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
|
||||
<p className="text-[14px] text-graphite">{t('c2.modal.import.empty')}</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={t('common.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">{t('c2.tasks.col.command')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.status')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.completed')}</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.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 ? t('common.yes') : t('common.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}
|
||||
>
|
||||
{t('c2.modal.import.prev')}
|
||||
</button>
|
||||
<span>
|
||||
{t('c2.modal.import.page')} {page} {t('c2.modal.import.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}
|
||||
>
|
||||
{t('c2.modal.import.next')}
|
||||
</button>
|
||||
{checkedIds.size > 0 && (
|
||||
<span className="ml-auto text-ink">
|
||||
{checkedIds.size} {t('c2.modal.import.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 ? t('c2.modal.import.btn.importing') : t('c2.modal.import.btn.import')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={onClose}
|
||||
disabled={importMutation.isPending}
|
||||
>
|
||||
{t('c2.modal.import.btn.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
@@ -20,6 +21,7 @@ export function Layout(): JSX.Element {
|
||||
const { user, isAdmin, isRedteam, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { theme, cycleTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
@@ -34,15 +36,15 @@ export function Layout(): JSX.Element {
|
||||
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
|
||||
{user ? (
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-[14px]">{user.username}</span>
|
||||
<span className="text-[14px] font-mono">{user.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleTheme}
|
||||
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"
|
||||
aria-label={t('nav.themeAria', { label: themeLabel(theme) })}
|
||||
className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text"
|
||||
>
|
||||
<ThemeIcon theme={theme} />
|
||||
<span className="uppercase tracking-[0.5px]">{themeLabel(theme)}</span>
|
||||
@@ -52,19 +54,19 @@ export function Layout(): JSX.Element {
|
||||
onClick={handleLogout}
|
||||
className="text-[14px] underline-offset-2 hover:underline"
|
||||
>
|
||||
Sign out
|
||||
{t('nav.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* nav-bar-top — paper gives dark-mode lift vs canvas body */}
|
||||
<header className="bg-paper border-b border-hairline">
|
||||
{/* nav-bar-top — fixed dark slab, never inverts (same visual family as utility-strip + footer) */}
|
||||
<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">
|
||||
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
|
||||
<Link to="/engagements" className="flex items-center gap-sm" aria-label={t('nav.brandHome')}>
|
||||
<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>
|
||||
|
||||
<nav className="flex items-center gap-md">
|
||||
@@ -72,22 +74,26 @@ export function Layout(): JSX.Element {
|
||||
to="/engagements"
|
||||
className={({ isActive }) =>
|
||||
`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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Engagements
|
||||
{t('nav.engagements')}
|
||||
</NavLink>
|
||||
{isAdmin || isRedteam ? (
|
||||
<NavLink
|
||||
to="/admin/templates"
|
||||
className={({ isActive }) =>
|
||||
`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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Templates
|
||||
{t('nav.templates')}
|
||||
</NavLink>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
@@ -95,11 +101,13 @@ export function Layout(): JSX.Element {
|
||||
to="/admin/users"
|
||||
className={({ isActive }) =>
|
||||
`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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Users
|
||||
{t('nav.users')}
|
||||
</NavLink>
|
||||
) : null}
|
||||
</nav>
|
||||
@@ -116,7 +124,7 @@ export function Layout(): JSX.Element {
|
||||
{/* footer — fixed dark slab, never inverts */}
|
||||
<footer className="bg-slab text-slab-text">
|
||||
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-slab-muted">
|
||||
Mimic — Internal Purple Team tooling. Authorized engagements only.
|
||||
{t('nav.footer')}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,9 @@ export function LoadingState({ label = 'Loading…' }: { label?: string }): JSX.
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { extractApiError } from '@/api/client';
|
||||
@@ -41,6 +42,7 @@ export function MitreMatrixModal({
|
||||
onApply,
|
||||
onCancel,
|
||||
}: MitreMatrixModalProps): JSX.Element | null {
|
||||
const { t } = useTranslation();
|
||||
const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen);
|
||||
|
||||
const [selectedTechMap, setSelectedTechMap] = useState<Map<string, { id: string; name: string }>>(
|
||||
@@ -170,31 +172,31 @@ export function MitreMatrixModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-xl py-md border-b border-hairline flex-shrink-0">
|
||||
<h2 id="matrix-modal-title" className="text-[18px] font-medium text-ink">
|
||||
MITRE ATT&CK Matrix
|
||||
{t('mitre.matrix.title')}
|
||||
</h2>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Filter techniques…"
|
||||
placeholder={t('mitre.matrix.filter')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="text-input w-56 h-9 text-[14px]"
|
||||
aria-label="Filter techniques"
|
||||
aria-label={t('mitre.matrix.filter')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body — overflow-y-auto, NO overflow-x */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-md py-md">
|
||||
{isLoading && <LoadingState label="Loading MITRE matrix…" />}
|
||||
{isLoading && <LoadingState label={t('mitre.matrix.loading')} />}
|
||||
{isError && (
|
||||
<ErrorState message={extractApiError(error, 'Could not load MITRE matrix')} />
|
||||
<ErrorState message={extractApiError(error, t('mitre.matrix.error'))} />
|
||||
)}
|
||||
{!isLoading && !isError && matrix && (
|
||||
<div
|
||||
@@ -230,7 +232,7 @@ export function MitreMatrixModal({
|
||||
type="button"
|
||||
onClick={() => toggleTactic(tactic.tactic_id)}
|
||||
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
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-cloud hover:bg-fog'
|
||||
@@ -251,7 +253,7 @@ export function MitreMatrixModal({
|
||||
</button>
|
||||
|
||||
{/* 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) => {
|
||||
const isSelected = selectedTechMap.has(tech.id);
|
||||
const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id);
|
||||
@@ -276,7 +278,7 @@ export function MitreMatrixModal({
|
||||
{hasSubtechniques ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`}
|
||||
aria-label={isExpanded ? t('common.collapse', { id: tech.id }) : t('common.expand', { id: tech.id })}
|
||||
onClick={() => toggleExpand(tech.id)}
|
||||
className={`mr-[2px] flex-shrink-0 text-[9px] w-3 leading-none mt-[1px] ${
|
||||
isSelected ? 'text-white' : 'text-graphite'
|
||||
@@ -296,7 +298,7 @@ export function MitreMatrixModal({
|
||||
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'}`}>
|
||||
{tech.name}
|
||||
</span>
|
||||
@@ -318,7 +320,7 @@ export function MitreMatrixModal({
|
||||
: '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>
|
||||
</button>
|
||||
);
|
||||
@@ -327,7 +329,7 @@ export function MitreMatrixModal({
|
||||
);
|
||||
})}
|
||||
{visibleTechniques.length === 0 && searchLower && (
|
||||
<div className="px-xs py-xxs text-[10px] text-graphite italic">No match</div>
|
||||
<div className="px-xs py-xxs text-[10px] text-graphite italic">{t('mitre.matrix.noMatch')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +342,7 @@ export function MitreMatrixModal({
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-md px-xl py-md border-t border-hairline flex-shrink-0">
|
||||
<button type="button" className="btn-outline-ink" onClick={onCancel}>
|
||||
Cancel
|
||||
{t('mitre.matrix.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -349,8 +351,8 @@ export function MitreMatrixModal({
|
||||
disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)}
|
||||
>
|
||||
{totalSelected === 0
|
||||
? 'Clear all'
|
||||
: `Apply ${totalSelected} item${totalSelected !== 1 ? 's' : ''}`}
|
||||
? t('mitre.matrix.clearAll')
|
||||
: t(totalSelected === 1 ? 'mitre.matrix.applyItem_one' : 'mitre.matrix.applyItem_other', { count: totalSelected })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
import { useMitreSearch } from '@/hooks/useMitre';
|
||||
@@ -14,6 +15,7 @@ export function MitreTechniquePicker({
|
||||
onSelect,
|
||||
disabled = false,
|
||||
}: MitreTechniquePickerProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -95,31 +97,31 @@ export function MitreTechniquePicker({
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined}
|
||||
aria-label="Search MITRE technique"
|
||||
aria-label={t('mitre.field.search')}
|
||||
className="text-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder="Search by ID or name (e.g. T1059)"
|
||||
placeholder={t('mitre.field.search')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{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 && (
|
||||
<div className="px-md py-sm text-[14px] text-graphite">Searching…</div>
|
||||
<div className="px-md py-sm text-[14px] text-graphite">{t('state.loading')}</div>
|
||||
)}
|
||||
|
||||
{isError && !isFetching && (
|
||||
<div className="px-md py-sm text-[14px] text-bloom-deep" role="alert">
|
||||
{extractApiError(error, 'MITRE search unavailable')}
|
||||
{extractApiError(error, t('mitre.matrix.error'))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
|
||||
<div className="px-md py-sm text-[14px] text-graphite">No results</div>
|
||||
<div className="px-md py-sm text-[14px] text-graphite">{t('state.empty.default')}</div>
|
||||
)}
|
||||
|
||||
{!isFetching && items.length > 0 && (
|
||||
@@ -144,10 +146,10 @@ export function MitreTechniquePicker({
|
||||
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>
|
||||
{item.tactics.length > 0 && (
|
||||
<span className="text-graphite"> ({item.tactics[0]})</span>
|
||||
<span className="font-mono text-graphite"> ({item.tactics[0]})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
|
||||
interface TechniqueTagProps {
|
||||
@@ -12,23 +13,24 @@ interface TacticTagProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Technique chip — soft blue, id only, name in title
|
||||
// Technique tag — angular, soft blue, monospace ID
|
||||
export function MitreTechniqueTag({
|
||||
technique,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
}: TechniqueTagProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
data-testid="mitre-technique-tag"
|
||||
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}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${technique.id}`}
|
||||
aria-label={`${t('mitre.tag.remove')} ${technique.id}`}
|
||||
onClick={onRemove}
|
||||
className="text-primary-deep opacity-60 hover:opacity-100 leading-none"
|
||||
>
|
||||
@@ -39,23 +41,24 @@ export function MitreTechniqueTag({
|
||||
);
|
||||
}
|
||||
|
||||
// Tactic chip — primary blue filled, id only, name in title
|
||||
// Tactic tag — angular, primary blue filled, monospace ID
|
||||
export function MitreTacticTag({
|
||||
tactic,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
}: TacticTagProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
data-testid="mitre-tactic-tag"
|
||||
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}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${tactic.id}`}
|
||||
aria-label={`${t('mitre.tag.remove')} ${tactic.id}`}
|
||||
onClick={onRemove}
|
||||
className="text-white opacity-60 hover:opacity-100 leading-none"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid2x2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
import { useUpdateSimulation } from '@/hooks/useSimulations';
|
||||
@@ -24,6 +25,7 @@ export function MitreTechniquesField({
|
||||
engagementId,
|
||||
disabled = false,
|
||||
}: MitreTechniquesFieldProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [showMatrix, setShowMatrix] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
@@ -33,34 +35,34 @@ export function MitreTechniquesField({
|
||||
const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
technique_ids: techniques.map((t) => t.id),
|
||||
tactic_ids: nextTactics.map((t) => t.id),
|
||||
technique_ids: techniques.map((tech) => tech.id),
|
||||
tactic_ids: nextTactics.map((tac) => tac.id),
|
||||
});
|
||||
push('Techniques updated', 'success');
|
||||
push(t('mitre.field.savedToast'), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not update techniques'), 'error');
|
||||
push(extractApiError(err, t('mitre.field.errorToast')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTechnique = (id: string) => {
|
||||
void save(value.filter((t) => t.id !== id), tactics);
|
||||
void save(value.filter((tech) => tech.id !== id), tactics);
|
||||
};
|
||||
|
||||
const handleRemoveTactic = (id: string) => {
|
||||
void save(value, tactics.filter((t) => t.id !== id));
|
||||
void save(value, tactics.filter((tac) => tac.id !== id));
|
||||
};
|
||||
|
||||
const handleSelect = (technique: MitreTechnique) => {
|
||||
if (value.some((t) => t.id === technique.id)) return;
|
||||
if (value.some((tech) => tech.id === technique.id)) return;
|
||||
void save([...value, technique], tactics);
|
||||
setShowPicker(false);
|
||||
};
|
||||
|
||||
const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => {
|
||||
setShowMatrix(false);
|
||||
const merged = techniques.map((s) => {
|
||||
const existing = value.find((v) => v.id === s.id);
|
||||
return existing ?? s;
|
||||
const merged = techniques.map((sel) => {
|
||||
const existing = value.find((v) => v.id === sel.id);
|
||||
return existing ?? sel;
|
||||
});
|
||||
void save(merged, newTactics);
|
||||
};
|
||||
@@ -72,22 +74,22 @@ export function MitreTechniquesField({
|
||||
<div className="flex flex-col gap-sm">
|
||||
{/* Chips area */}
|
||||
{isEmpty ? (
|
||||
<p className="text-[13px] text-graphite">No techniques selected</p>
|
||||
<p className="text-[13px] text-graphite">{t('mitre.field.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
|
||||
{tactics.map((t) => (
|
||||
{tactics.map((tac) => (
|
||||
<MitreTacticTag
|
||||
key={t.id}
|
||||
tactic={t}
|
||||
onRemove={() => handleRemoveTactic(t.id)}
|
||||
key={tac.id}
|
||||
tactic={tac}
|
||||
onRemove={() => handleRemoveTactic(tac.id)}
|
||||
disabled={disabled || isPending}
|
||||
/>
|
||||
))}
|
||||
{value.map((t) => (
|
||||
{value.map((tech) => (
|
||||
<MitreTechniqueTag
|
||||
key={t.id}
|
||||
technique={t}
|
||||
onRemove={() => handleRemoveTechnique(t.id)}
|
||||
key={tech.id}
|
||||
technique={tech}
|
||||
onRemove={() => handleRemoveTechnique(tech.id)}
|
||||
disabled={disabled || isPending}
|
||||
/>
|
||||
))}
|
||||
@@ -107,20 +109,20 @@ export function MitreTechniquesField({
|
||||
onClick={() => setShowPicker(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Search technique (e.g. T1059)…
|
||||
{t('mitre.field.search')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open MITRE matrix"
|
||||
aria-label={t('mitre.field.openMatrix')}
|
||||
onClick={() => { setShowPicker(false); setShowMatrix(true); }}
|
||||
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} />
|
||||
</button>
|
||||
{isPending && <span className="text-[12px] text-graphite">Saving…</span>}
|
||||
{isPending && <span className="text-[12px] text-graphite">{t('common.saving')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { Role } from '@/api/types';
|
||||
@@ -23,6 +24,7 @@ export function ProtectedRoute({
|
||||
}: ProtectedRouteProps): JSX.Element {
|
||||
const { user, status } = useAuth();
|
||||
const { push } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const roleDenied = Boolean(
|
||||
@@ -31,12 +33,12 @@ export function ProtectedRoute({
|
||||
|
||||
useEffect(() => {
|
||||
if (roleDenied) {
|
||||
push('Accès refusé', 'error');
|
||||
push(t('auth.forbidden'), 'error');
|
||||
}
|
||||
}, [roleDenied, push]);
|
||||
}, [roleDenied, push, t]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return <LoadingState label="Loading session…" />;
|
||||
return <LoadingState label={t('auth.loadingSession')} />;
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated' || !user) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { EmptyState } from './EmptyState';
|
||||
@@ -16,12 +18,8 @@ interface SimulationListProps {
|
||||
engagementId: number;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return value.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { push } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -61,10 +59,10 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
try {
|
||||
const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id });
|
||||
setShowPicker(false);
|
||||
push(`Created "${sim.name}" from template`, 'success');
|
||||
push(t('simulation.form.toast.created'), 'success');
|
||||
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not create simulation from template'), 'error');
|
||||
push(extractApiError(err, t('simulation.form.error.create')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,17 +71,17 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
<div className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary rounded-r-none border-r border-primary-deep"
|
||||
className="btn-primary border-r border-primary-deep"
|
||||
onClick={handleBlank}
|
||||
data-testid="new-simulation-btn"
|
||||
>
|
||||
<Plus size={14} aria-hidden /> New
|
||||
<Plus size={14} aria-hidden /> {t('simulation.list.new')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More options"
|
||||
aria-label={t('simulation.list.rowActions')}
|
||||
aria-expanded={open}
|
||||
className="btn-primary rounded-l-none px-sm"
|
||||
className="btn-primary px-sm"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
data-testid="new-simulation-dropdown-toggle"
|
||||
>
|
||||
@@ -93,7 +91,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-md shadow-floating dark:shadow-floating-dark z-20 min-w-[180px]"
|
||||
className="absolute right-0 top-full mt-xxs bg-paper border border-hairline rounded-none z-20 min-w-[180px]"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
@@ -133,16 +131,17 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
|
||||
}
|
||||
|
||||
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
|
||||
const { canEditEngagements } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isLoading) return <LoadingState label="Loading simulations…" />;
|
||||
if (isLoading) return <LoadingState label={t('common.loading')} />;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(error, 'Could not load simulations')}
|
||||
message={extractApiError(error, t('simulation.list.error'))}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -151,8 +150,8 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No simulations yet"
|
||||
description="Create the first simulation to start tracking red team tests."
|
||||
title={t('simulation.list.empty.title')}
|
||||
description={t('simulation.list.empty.desc')}
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<NewSimulationDropdown engagementId={engagementId} />
|
||||
@@ -165,32 +164,32 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
return (
|
||||
<div className="flex flex-col gap-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[24px] font-medium text-ink">Simulations</h2>
|
||||
<h2 className="text-[24px] font-medium text-ink">{t('engagement.detail.tabs.simulations')}</h2>
|
||||
{canEditEngagements ? (
|
||||
<NewSimulationDropdown engagementId={engagementId} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Name</th>
|
||||
<th className="px-xl py-md">MITRE</th>
|
||||
<th className="px-xl py-md">Status</th>
|
||||
<th className="px-xl py-md">Executed at</th>
|
||||
<tr>
|
||||
<th>{t('simulation.list.col.name')}</th>
|
||||
<th>{t('simulation.list.col.mitre')}</th>
|
||||
<th>{t('simulation.list.col.status')}</th>
|
||||
<th>{t('simulation.list.col.executedAt')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((sim) => (
|
||||
<tr
|
||||
key={sim.id}
|
||||
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
|
||||
className="hover:bg-cloud cursor-pointer"
|
||||
onClick={() =>
|
||||
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`)
|
||||
}
|
||||
>
|
||||
<td className="px-xl py-md">
|
||||
<td>
|
||||
<Link
|
||||
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
|
||||
className="text-ink font-medium hover:underline"
|
||||
@@ -199,22 +198,22 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
{sim.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||
<td className="text-charcoal font-mono">
|
||||
{(() => {
|
||||
const items = [
|
||||
...(sim.tactics ?? []).map((t) => t.id),
|
||||
...sim.techniques.map((t) => t.id),
|
||||
...(sim.tactics ?? []).map((tactic) => tactic.id),
|
||||
...sim.techniques.map((tech) => tech.id),
|
||||
];
|
||||
if (items.length === 0) return '—';
|
||||
if (items.length === 1) return items[0];
|
||||
return `${items[0]} +${items.length - 1}`;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<td>
|
||||
<SimulationStatusBadge status={sim.status} />
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||
{formatDate(sim.executed_at)}
|
||||
<td className="text-charcoal font-mono">
|
||||
{sim.executed_at ? formatDateTime(sim.executed_at) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SimulationStatus } from '@/api/types';
|
||||
|
||||
const LABELS: Record<SimulationStatus, string> = {
|
||||
pending: 'Pending',
|
||||
in_progress: 'In progress',
|
||||
review_required: 'Review required',
|
||||
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> = {
|
||||
pending: 'bg-fog text-charcoal border border-hairline',
|
||||
pending: 'bg-cloud text-graphite border border-hairline',
|
||||
in_progress: 'bg-primary-soft text-primary-deep',
|
||||
review_required: 'bg-bloom-coral text-white',
|
||||
done: 'bg-storm-deep text-white',
|
||||
review_required: 'bg-warn-soft text-warn',
|
||||
done: 'bg-success-soft text-success',
|
||||
};
|
||||
|
||||
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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-status={status}
|
||||
>
|
||||
{LABELS[status]}
|
||||
{t(`status.simulation.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { EngagementStatus } from '@/api/types';
|
||||
|
||||
const LABELS: Record<EngagementStatus, string> = {
|
||||
planned: 'Planned',
|
||||
active: 'Active',
|
||||
closed: 'Closed',
|
||||
};
|
||||
|
||||
const STYLES: Record<EngagementStatus, string> = {
|
||||
// Outlined ink for planned (neutral), filled primary for active (engagement live),
|
||||
// outlined steel for closed (muted). Stays within DESIGN.md palette.
|
||||
planned: 'bg-canvas text-ink border border-ink',
|
||||
active: 'bg-primary text-white',
|
||||
planned: 'bg-warn-soft text-warn border border-warn',
|
||||
active: 'bg-primary-soft text-primary-deep',
|
||||
closed: 'bg-cloud text-graphite border border-hairline',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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-status={status}
|
||||
>
|
||||
{LABELS[status]}
|
||||
{t(`status.engagement.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
72
frontend/src/components/Tabs.tsx
Normal file
72
frontend/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
interface TabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
items: TabItem[];
|
||||
activeId: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export function Tabs({ items, activeId, onChange }: TabsProps): JSX.Element {
|
||||
function nextEnabled(from: number, direction: 1 | -1): string {
|
||||
const len = items.length;
|
||||
let i = (from + direction + len) % len;
|
||||
while (i !== from) {
|
||||
if (!items[i].disabled) return items[i].id;
|
||||
i = (i + direction + len) % len;
|
||||
}
|
||||
return items[from].id;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
onChange(nextEnabled(index, 1));
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
onChange(nextEnabled(index, -1));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className="flex items-end border-b border-hairline gap-xs"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const isActive = item.id === activeId;
|
||||
const isDisabled = item.disabled ?? false;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`tab-${item.id}`}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tabpanel-${item.id}`}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
onClick={() => { if (!isDisabled) onChange(item.id); }}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
className={`tab-underline${isActive ? ' tab-underline-active' : ''}${isDisabled ? ' tab-underline-disabled' : ''} pb-xs`}
|
||||
>
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
<span
|
||||
className={`ml-xxs tab-count-pill${isActive ? ' tab-count-pill-active' : ''}`}
|
||||
>
|
||||
{item.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
import { useTemplates } from '@/hooks/useTemplates';
|
||||
@@ -23,6 +24,7 @@ export function TemplatePickerModal({
|
||||
onSelectTemplate,
|
||||
isPending = false,
|
||||
}: TemplatePickerModalProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useTemplates();
|
||||
|
||||
return (
|
||||
@@ -34,35 +36,34 @@ export function TemplatePickerModal({
|
||||
>
|
||||
<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">
|
||||
<h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink">
|
||||
From template…
|
||||
{t('template.picker.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
aria-label={t('common.close')}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 -mx-xl px-xl">
|
||||
{isLoading ? <LoadingState label="Loading templates…" /> : null}
|
||||
{isLoading ? <LoadingState label={t('template.picker.loading')} /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(error, 'Could not load templates')}
|
||||
message={extractApiError(error, t('template.form.errorLoad'))}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No templates available"
|
||||
description="Create one from the Templates page."
|
||||
title={t('template.picker.empty')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -70,22 +71,22 @@ export function TemplatePickerModal({
|
||||
<table className="w-full text-left" data-testid="template-picker-table">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-md py-sm">Name</th>
|
||||
<th className="px-md py-sm">MITRE</th>
|
||||
<th className="px-md py-sm">Created by</th>
|
||||
<th className="px-md py-sm">{t('template.list.col.name')}</th>
|
||||
<th className="px-md py-sm">{t('simulation.list.col.mitre')}</th>
|
||||
<th className="px-md py-sm">{t('template.list.col.createdBy')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((t) => (
|
||||
{data.map((template) => (
|
||||
<tr
|
||||
key={t.id}
|
||||
key={template.id}
|
||||
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
|
||||
onClick={() => !isPending && onSelectTemplate(t)}
|
||||
data-testid={`template-row-${t.id}`}
|
||||
onClick={() => !isPending && onSelectTemplate(template)}
|
||||
data-testid={`template-row-${template.id}`}
|
||||
>
|
||||
<td className="px-md py-sm text-ink font-medium">{t.name}</td>
|
||||
<td className="px-md py-sm text-charcoal text-[14px]">{mitreCount(t)}</td>
|
||||
<td className="px-md py-sm text-charcoal text-[14px]">{t.created_by.username}</td>
|
||||
<td className="px-md py-sm text-ink font-medium">{template.name}</td>
|
||||
<td className="px-md py-sm text-charcoal text-[14px]">{mitreCount(template)}</td>
|
||||
<td className="px-md py-sm text-charcoal text-[14px]">{template.created_by.username}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -95,7 +96,7 @@ export function TemplatePickerModal({
|
||||
|
||||
<div className="border-t border-hairline pt-sm">
|
||||
<button type="button" className="btn-outline-ink" onClick={onClose}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 {
|
||||
const { t } = useTranslation();
|
||||
const { toasts, dismiss } = useToast();
|
||||
return (
|
||||
<div
|
||||
@@ -12,29 +10,28 @@ export function ToastViewport(): JSX.Element {
|
||||
aria-atomic="true"
|
||||
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
|
||||
>
|
||||
{toasts.map((t) => {
|
||||
const isError = t.kind === 'error';
|
||||
const isSuccess = t.kind === 'success';
|
||||
// Fixed colors: toasts don't theme (error=dark slab, success=primary blue)
|
||||
{toasts.map((toast) => {
|
||||
const isError = toast.kind === 'error';
|
||||
const isSuccess = toast.kind === 'success';
|
||||
const surface = isError
|
||||
? 'bg-slab text-slab-text'
|
||||
? 'bg-paper text-ink border border-hairline border-l-4 border-l-bloom-deep'
|
||||
: isSuccess
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-canvas text-ink border border-hairline';
|
||||
? 'bg-paper text-ink border border-hairline border-l-4 border-l-success'
|
||||
: 'bg-paper text-ink border border-hairline border-l-4 border-l-primary';
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
key={toast.id}
|
||||
role="status"
|
||||
data-testid="toast"
|
||||
data-kind={t.kind}
|
||||
className={`pointer-events-auto rounded-xl px-md py-sm shadow-soft-lift text-[14px] leading-[1.4] ${surface}`}
|
||||
data-kind={toast.kind}
|
||||
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">
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="Dismiss notification"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label={t('toast.dismiss')}
|
||||
className="text-current opacity-70 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
|
||||
152
frontend/src/hooks/useC2.ts
Normal file
152
frontend/src/hooks/useC2.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
28
frontend/src/hooks/useHashTab.ts
Normal file
28
frontend/src/hooks/useHashTab.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
function readHash(defaultId: string, validIds?: string[]): string {
|
||||
const hash = window.location.hash.slice(1); // strip leading '#'
|
||||
if (!hash) return defaultId;
|
||||
if (validIds && !validIds.includes(hash)) return defaultId;
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function useHashTab(defaultId: string, validIds?: string[]): [string, (id: string) => void] {
|
||||
const [activeId, setActiveId] = useState<string>(() => readHash(defaultId, validIds));
|
||||
|
||||
useEffect(() => {
|
||||
function onHashChange() {
|
||||
setActiveId(readHash(defaultId, validIds));
|
||||
}
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, [defaultId, validIds]);
|
||||
|
||||
const navigate = useCallback((id: string) => {
|
||||
// replaceState: no history entry (Back button unaffected), no anchor-jump scroll
|
||||
history.replaceState(null, '', '#' + id);
|
||||
setActiveId(id);
|
||||
}, []);
|
||||
|
||||
return [activeId, navigate];
|
||||
}
|
||||
525
frontend/src/i18n/fr.json
Normal file
525
frontend/src/i18n/fr.json
Normal file
@@ -0,0 +1,525 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"new": "Nouveau",
|
||||
"loading": "Chargement…",
|
||||
"retry": "Réessayer",
|
||||
"back": "Retour",
|
||||
"dismiss": "Fermer",
|
||||
"view": "Voir",
|
||||
"create": "Créer",
|
||||
"creating": "Création…",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"expand": "Déplier {{id}}",
|
||||
"collapse": "Replier {{id}}",
|
||||
"select": "Sélectionner"
|
||||
},
|
||||
"nav": {
|
||||
"engagements": "Engagements",
|
||||
"templates": "Templates",
|
||||
"users": "Utilisateurs",
|
||||
"signOut": "Se déconnecter",
|
||||
"brand": "Mimic",
|
||||
"brandHome": "Accueil Mimic",
|
||||
"themeAria": "Thème : {{label}} — cliquer pour changer",
|
||||
"footer": "Mimic — Outillage interne Purple Team. Engagements autorisés uniquement."
|
||||
},
|
||||
"export": {
|
||||
"btn": "Exporter",
|
||||
"btnLoading": "Export en cours…",
|
||||
"aria": "Options d'export"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Mimic",
|
||||
"subtitle": "Connectez-vous pour accéder à vos engagements.",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"signIn": "Se connecter",
|
||||
"signingIn": "Connexion…",
|
||||
"invalid": "Identifiants invalides"
|
||||
},
|
||||
"forbidden": "Accès refusé",
|
||||
"loadingSession": "Chargement de la session…"
|
||||
},
|
||||
"engagement": {
|
||||
"list": {
|
||||
"title": "Engagements",
|
||||
"subtitle": "Missions red team et leur statut.",
|
||||
"new": "Nouveau",
|
||||
"empty": {
|
||||
"title": "Aucun engagement",
|
||||
"desc": "Créez votre premier engagement pour commencer à suivre les missions red team."
|
||||
},
|
||||
"col": {
|
||||
"name": "Nom",
|
||||
"status": "Statut",
|
||||
"start": "Début",
|
||||
"end": "Fin",
|
||||
"createdBy": "Créé par",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"view": "Voir",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"deleteConfirm": "Supprimer l'engagement « {{name}} » ? Cette action est irréversible.",
|
||||
"toast": {
|
||||
"deleted": "Engagement supprimé"
|
||||
},
|
||||
"error": {
|
||||
"load": "Impossible de charger les engagements",
|
||||
"delete": "Impossible de supprimer l'engagement"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
"description": "Description",
|
||||
"simulations": "Simulations"
|
||||
},
|
||||
"schedule": {
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin",
|
||||
"status": "Statut",
|
||||
"createdAt": "Créé le"
|
||||
},
|
||||
"edit": "Modifier",
|
||||
"createdBy": "Créé par",
|
||||
"noDescription": "Aucune description fournie.",
|
||||
"loading": "Chargement de l'engagement…",
|
||||
"errorLoad": "Impossible de charger l'engagement",
|
||||
"notFound": "Engagement introuvable",
|
||||
"backTo": "Retour aux engagements"
|
||||
},
|
||||
"form": {
|
||||
"title": {
|
||||
"new": "Nouvel engagement",
|
||||
"edit": "Modifier l'engagement"
|
||||
},
|
||||
"subtitle": {
|
||||
"new": "Définir le périmètre et la planification.",
|
||||
"edit": "Mettre à jour les détails."
|
||||
},
|
||||
"field": {
|
||||
"name": "Nom",
|
||||
"description": "Description",
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin",
|
||||
"endDateHint": "Optionnel",
|
||||
"status": "Statut"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire",
|
||||
"startDateRequired": "La date de début est obligatoire",
|
||||
"endDateAfterStart": "La date de fin doit être égale ou postérieure à la date de début"
|
||||
},
|
||||
"btn": {
|
||||
"create": "Créer",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Engagement créé",
|
||||
"updated": "Engagement mis à jour"
|
||||
},
|
||||
"error": {
|
||||
"save": "Impossible d'enregistrer l'engagement",
|
||||
"load": "Impossible de charger l'engagement"
|
||||
},
|
||||
"loading": "Chargement…",
|
||||
"c2Config": "Configuration C2"
|
||||
}
|
||||
},
|
||||
"simulation": {
|
||||
"form": {
|
||||
"title": {
|
||||
"new": "Nouvelle simulation"
|
||||
},
|
||||
"field": {
|
||||
"name": "Nom",
|
||||
"mitre": "Techniques et tactiques MITRE",
|
||||
"description": "Description",
|
||||
"commands": "Commandes",
|
||||
"commandsHint": "Une commande par ligne",
|
||||
"prerequisites": "Prérequis",
|
||||
"executedAt": "Exécuté le",
|
||||
"executionResult": "Résultat d'exécution",
|
||||
"logSource": "Source de log",
|
||||
"logs": "Logs",
|
||||
"socComment": "Commentaire SOC",
|
||||
"incidentNumber": "Numéro d'incident"
|
||||
},
|
||||
"btn": {
|
||||
"create": "Créer la simulation",
|
||||
"creating": "Création…",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"saveSoc": "Enregistrer SOC",
|
||||
"markReview": "Marquer pour révision",
|
||||
"close": "Clore",
|
||||
"reopen": "Rouvrir",
|
||||
"delete": "Supprimer",
|
||||
"executeC2": "Exécuter via C2",
|
||||
"importC2History": "Importer l'historique C2",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer la simulation",
|
||||
"desc": "Cette action est permanente. La simulation sera définitivement supprimée.",
|
||||
"confirm": "Supprimer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"banner": {
|
||||
"done": "Cette simulation est terminée et en lecture seule. Utilisez Rouvrir pour effectuer des modifications.",
|
||||
"socNotReady": "La simulation n'est pas encore prête pour la révision — l'équipe red team doit la marquer comme « Révision requise » avant que vous puissiez remplir la section SOC."
|
||||
},
|
||||
"header": {
|
||||
"redTeam": "Red Team",
|
||||
"soc": "SOC"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Simulation créée",
|
||||
"updated": "Simulation mise à jour",
|
||||
"deleted": "Simulation supprimée",
|
||||
"markedReview": "Simulation marquée pour révision",
|
||||
"closed": "Simulation close",
|
||||
"reopened": "Simulation rouverte",
|
||||
"socUpdated": "Rapport SOC mis à jour"
|
||||
},
|
||||
"error": {
|
||||
"create": "Impossible de créer la simulation",
|
||||
"update": "Impossible de mettre à jour la simulation",
|
||||
"delete": "Impossible de supprimer la simulation",
|
||||
"soc": "Impossible de mettre à jour les champs SOC",
|
||||
"transition": "Transition échouée",
|
||||
"load": "Impossible de charger la simulation"
|
||||
},
|
||||
"loading": "Chargement de la simulation…",
|
||||
"tab": {
|
||||
"redTeam": "Red Team",
|
||||
"soc": "SOC",
|
||||
"c2": "Tâche C2"
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"col": {
|
||||
"name": "Nom",
|
||||
"mitre": "MITRE",
|
||||
"status": "Statut",
|
||||
"executedAt": "Exécuté le"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucune simulation",
|
||||
"desc": "Créez votre première simulation pour cet engagement."
|
||||
},
|
||||
"new": "Nouvelle simulation",
|
||||
"error": "Impossible de charger les simulations",
|
||||
"rowActions": "Plus d'options"
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"list": {
|
||||
"title": "Templates",
|
||||
"subtitle": "Modèles de simulation réutilisables.",
|
||||
"new": "Nouveau",
|
||||
"empty": {
|
||||
"title": "Aucun template",
|
||||
"desc": "Créez votre premier template de simulation."
|
||||
},
|
||||
"col": {
|
||||
"name": "Nom",
|
||||
"mitre": "MITRE",
|
||||
"createdBy": "Créé par",
|
||||
"updated": "Modifié"
|
||||
},
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"form": {
|
||||
"title": {
|
||||
"new": "Nouveau template",
|
||||
"edit": "Modifier le template"
|
||||
},
|
||||
"field": {
|
||||
"name": "Nom",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Description du template…",
|
||||
"commands": "Commandes",
|
||||
"commandsHint": "Une commande par ligne",
|
||||
"commandsPlaceholder": "whoami\nnet user",
|
||||
"prerequisites": "Prérequis",
|
||||
"prerequisitesPlaceholder": "Prérequis…",
|
||||
"mitre": "Techniques MITRE",
|
||||
"mitreSearch": "Rechercher une technique…",
|
||||
"mitreOpenMatrix": "Ouvrir la matrice",
|
||||
"mitreEmpty": "Aucune technique associée."
|
||||
},
|
||||
"btn": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"delete": "Supprimer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Template créé",
|
||||
"saved": "Template enregistré",
|
||||
"deleted": "Template supprimé"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer le template",
|
||||
"desc": "Cette action est permanente.",
|
||||
"confirm": "Supprimer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"loading": "Chargement du template…",
|
||||
"errorLoad": "Impossible de charger le template",
|
||||
"errorSave": "Impossible d'enregistrer le template",
|
||||
"errorDelete": "Impossible de supprimer le template"
|
||||
},
|
||||
"picker": {
|
||||
"title": "Choisir un template",
|
||||
"empty": "Aucun template disponible.",
|
||||
"loading": "Chargement…"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"admin": {
|
||||
"title": "Utilisateurs",
|
||||
"subtitle": "Gestion des comptes.",
|
||||
"new": "Nouveau",
|
||||
"col": {
|
||||
"username": "Nom d'utilisateur",
|
||||
"role": "Rôle",
|
||||
"createdAt": "Créé le",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"role": {
|
||||
"admin": "Admin",
|
||||
"redteam": "Red Team",
|
||||
"soc": "SOC"
|
||||
},
|
||||
"field": {
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe"
|
||||
},
|
||||
"btn": {
|
||||
"create": "Créer",
|
||||
"creating": "Création…",
|
||||
"resetPassword": "Réinitialiser le mot de passe",
|
||||
"setPassword": "Définir le mot de passe",
|
||||
"delete": "Supprimer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"you": "(vous)",
|
||||
"toast": {
|
||||
"created": "Utilisateur créé",
|
||||
"deleted": "Utilisateur supprimé",
|
||||
"passwordReset": "Mot de passe réinitialisé pour {{username}}",
|
||||
"roleUpdated": "Rôle mis à jour pour {{username}}"
|
||||
},
|
||||
"error": {
|
||||
"create": "Impossible de créer l'utilisateur",
|
||||
"delete": "Impossible de supprimer l'utilisateur",
|
||||
"passwordReset": "Impossible de réinitialiser le mot de passe",
|
||||
"roleUpdate": "Impossible de modifier le rôle",
|
||||
"selfDelete": "Vous ne pouvez pas supprimer votre propre compte",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères"
|
||||
},
|
||||
"deleteConfirm": "Supprimer l'utilisateur « {{username}} » ?",
|
||||
"newPasswordFor": "Nouveau mot de passe pour {{username}}",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"passwordHint": "≥ 8 caractères",
|
||||
"createSection": "Créer un compte",
|
||||
"allSection": "Tous les comptes",
|
||||
"createSubtitle": "Les administrateurs peuvent créer des comptes Red Team ou SOC.",
|
||||
"resetPassword": "Réinitialiser le mot de passe",
|
||||
"empty": {
|
||||
"title": "Aucun utilisateur",
|
||||
"desc": "Créez le premier compte via le formulaire ci-dessus."
|
||||
}
|
||||
}
|
||||
},
|
||||
"c2": {
|
||||
"config": {
|
||||
"title": "Configuration C2",
|
||||
"disabled": "Les fonctionnalités C2 sont désactivées (aucune clé de chiffrement configurée sur le serveur).",
|
||||
"field": {
|
||||
"url": "URL",
|
||||
"urlHint": "HTTPS requis (ex. https://mythic.lab:7443)",
|
||||
"token": "Token API",
|
||||
"tokenHint": "Token API",
|
||||
"verifyTls": "Vérifier le certificat TLS",
|
||||
"verifyTlsHint": "Décochez uniquement pour un Mythic de lab avec des certificats auto-signés. Désactiver la vérification expose le token API aux attaques MITM."
|
||||
},
|
||||
"btn": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"test": "Tester la connexion",
|
||||
"testing": "Test…",
|
||||
"delete": "Supprimer la configuration",
|
||||
"replaceToken": "Remplacer le token"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "Configuration C2 enregistrée",
|
||||
"deleted": "Configuration C2 supprimée"
|
||||
},
|
||||
"error": {
|
||||
"save": "Impossible d'enregistrer la configuration C2",
|
||||
"delete": "Impossible de supprimer la configuration C2"
|
||||
},
|
||||
"connected": "Connecté",
|
||||
"connectionFailed": "Connexion échouée",
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer la configuration C2",
|
||||
"desc": "La configuration C2 de cet engagement sera supprimée. Le token API sera définitivement effacé.",
|
||||
"confirm": "Supprimer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"loading": "Chargement…"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tâches C2",
|
||||
"empty": "Aucune tâche C2.",
|
||||
"col": {
|
||||
"task": "Tâche",
|
||||
"command": "Commande",
|
||||
"source": "Source",
|
||||
"status": "Statut",
|
||||
"completedAt": "Terminé le",
|
||||
"expand": "Déplier"
|
||||
},
|
||||
"refreshing": "Actualisation…"
|
||||
},
|
||||
"modal": {
|
||||
"execute": {
|
||||
"title": "Exécuter via C2",
|
||||
"callback": "Agent cible",
|
||||
"btn": {
|
||||
"launch": "Lancer",
|
||||
"launching": "Lancement…",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"commandCount_one": "{{count}} commande",
|
||||
"commandCount_other": "{{count}} commandes",
|
||||
"validation": {
|
||||
"callbackRequired": "Sélectionnez un agent",
|
||||
"commandsRequired": "Aucune commande à exécuter"
|
||||
},
|
||||
"toast": {
|
||||
"launched": "{{count}} tâche(s) C2 lancée(s)"
|
||||
},
|
||||
"error": {
|
||||
"launch": "Impossible de lancer la tâche C2"
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer l'historique C2",
|
||||
"total": "Total",
|
||||
"page": "Page",
|
||||
"of": "sur",
|
||||
"prev": "Précédent",
|
||||
"next": "Suivant",
|
||||
"btn": {
|
||||
"import": "Importer",
|
||||
"importing": "Importation…",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"selected": "sélectionné(s)",
|
||||
"empty": "Aucun historique C2 disponible.",
|
||||
"col": {
|
||||
"timestamp": "Horodatage",
|
||||
"completed": "Terminé"
|
||||
},
|
||||
"toast": {
|
||||
"imported": "{{count}} tâche(s) importée(s)",
|
||||
"partial": "{{imported}} importée(s), {{skipped}} déjà attachée(s)"
|
||||
},
|
||||
"error": {
|
||||
"import": "Impossible d'importer l'historique C2"
|
||||
}
|
||||
},
|
||||
"picker": {
|
||||
"title": "Sélectionner un agent",
|
||||
"empty": "Aucun agent disponible.",
|
||||
"hostnameColon": "Hôte :",
|
||||
"lastCheckinColon": "Dernière connexion :",
|
||||
"col": {
|
||||
"displayId": "ID",
|
||||
"active": "Actif",
|
||||
"host": "Hôte",
|
||||
"user": "Utilisateur",
|
||||
"domain": "Domaine",
|
||||
"lastCheckin": "Dernière connexion"
|
||||
},
|
||||
"status": {
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mitre": {
|
||||
"matrix": {
|
||||
"title": "Matrice MITRE ATT&CK",
|
||||
"filter": "Filtrer…",
|
||||
"loading": "Chargement de la matrice…",
|
||||
"error": "Impossible de charger la matrice MITRE",
|
||||
"applyItem_one": "Appliquer ({{count}} élément)",
|
||||
"applyItem_other": "Appliquer ({{count}} éléments)",
|
||||
"clearAll": "Tout effacer",
|
||||
"close": "Fermer",
|
||||
"retry": "Réessayer",
|
||||
"noMatch": "Aucun résultat"
|
||||
},
|
||||
"field": {
|
||||
"empty": "Aucune technique associée.",
|
||||
"search": "Rechercher une technique…",
|
||||
"openMatrix": "Ouvrir la matrice",
|
||||
"savedToast": "Technique ajoutée",
|
||||
"errorToast": "Impossible d'ajouter la technique"
|
||||
},
|
||||
"tag": {
|
||||
"remove": "Retirer"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"loading": "Chargement…",
|
||||
"error": {
|
||||
"title": "Erreur",
|
||||
"desc": "Une erreur est survenue."
|
||||
},
|
||||
"empty": {
|
||||
"default": "Aucun résultat."
|
||||
},
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"toast": {
|
||||
"dismiss": "Fermer"
|
||||
},
|
||||
"status": {
|
||||
"engagement": {
|
||||
"planned": "Planifié",
|
||||
"active": "Actif",
|
||||
"closed": "Clôturé"
|
||||
},
|
||||
"simulation": {
|
||||
"pending": "En attente",
|
||||
"in_progress": "En cours",
|
||||
"review_required": "Révision requise",
|
||||
"done": "Terminé"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
frontend/src/i18n/index.ts
Normal file
13
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import fr from './fr.json';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: { fr: { translation: fr } },
|
||||
lng: 'fr',
|
||||
fallbackLng: 'fr',
|
||||
interpolation: { escapeValue: false },
|
||||
returnEmptyString: false,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
8
frontend/src/i18n/status.ts
Normal file
8
frontend/src/i18n/status.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import i18n from './index';
|
||||
import type { EngagementStatus, SimulationStatus } from '@/api/types';
|
||||
|
||||
export const engagementStatusLabel = (s: EngagementStatus): string =>
|
||||
i18n.t(`status.engagement.${s}`);
|
||||
|
||||
export const simulationStatusLabel = (s: SimulationStatus): string =>
|
||||
i18n.t(`status.simulation.${s}`);
|
||||
7
frontend/src/lib/format.ts
Normal file
7
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const formatDate = (iso: string | null | undefined): string => {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('fr-FR');
|
||||
};
|
||||
|
||||
export const formatDateTime = (iso: string): string =>
|
||||
new Date(iso).toLocaleString('fr-FR');
|
||||
@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { ToastProvider } from './hooks/useToast';
|
||||
import './i18n';
|
||||
import './styles/index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -1,80 +1,109 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagement } from '@/hooks/useEngagements';
|
||||
import { useHashTab } from '@/hooks/useHashTab';
|
||||
import { useEngagementSimulations } from '@/hooks/useSimulations';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
import { SimulationList } from '@/components/SimulationList';
|
||||
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import { Tabs } from '@/components/Tabs';
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
type TabId = 'description' | 'simulations';
|
||||
|
||||
export function EngagementDetailPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const { canEditEngagements } = useAuth();
|
||||
|
||||
const detail = useEngagement(numericId);
|
||||
const simsQuery = useEngagementSimulations(numericId);
|
||||
|
||||
if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
const [activeTabRaw, setActiveTab] = useHashTab('description');
|
||||
const activeTab = activeTabRaw as TabId;
|
||||
|
||||
if (detail.isLoading) return <LoadingState label={t('engagement.detail.loading')} />;
|
||||
if (detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
message={extractApiError(detail.error, t('engagement.detail.errorLoad'))}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!detail.data) return <ErrorState message="Engagement not found" />;
|
||||
if (!detail.data) return <ErrorState message={t('engagement.detail.notFound')} />;
|
||||
|
||||
const eng = detail.data;
|
||||
const simCount = simsQuery.data?.length;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'description', label: t('engagement.detail.tabs.description') },
|
||||
{ id: 'simulations', label: t('engagement.detail.tabs.simulations'), count: simCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<Link to="/engagements" className="btn-text-link text-[14px]">
|
||||
← Back to engagements
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none">{eng.name}</h1>
|
||||
<BackLink to="/engagements">{t('engagement.detail.backTo')}</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none">{eng.name}</h1>
|
||||
<div className="flex items-center gap-md">
|
||||
<StatusBadge status={eng.status} />
|
||||
<span className="text-[14px] text-graphite">
|
||||
Created by <span className="text-ink">{eng.created_by.username}</span>
|
||||
{t('engagement.detail.createdBy')}{' '}
|
||||
<span className="text-ink">{eng.created_by.username}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
|
||||
Edit
|
||||
</Link>
|
||||
<ExportEngagementButton engagementId={eng.id} />
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<div className="card-product">
|
||||
<h2 className="text-[20px] font-medium mb-md">Schedule</h2>
|
||||
<dl className="grid grid-cols-2 gap-md text-[14px]">
|
||||
<dt className="text-graphite">Start date</dt>
|
||||
<dd className="text-ink">{eng.start_date}</dd>
|
||||
<dt className="text-graphite">End date</dt>
|
||||
<dd className="text-ink">{eng.end_date ?? '—'}</dd>
|
||||
<dt className="text-graphite">Status</dt>
|
||||
<dd className="text-ink capitalize">{eng.status}</dd>
|
||||
<dt className="text-graphite">Created at</dt>
|
||||
<dd className="text-ink">{eng.created_at}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<Tabs items={tabs} activeId={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<div className="card-product">
|
||||
<h2 className="text-[20px] font-medium mb-md">Description</h2>
|
||||
<p className="text-[16px] text-charcoal whitespace-pre-line">
|
||||
{eng.description?.trim() ? eng.description : 'No description provided.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`tabpanel-${activeTab}`}
|
||||
aria-labelledby={`tab-${activeTab}`}
|
||||
>
|
||||
{activeTab === 'description' && (
|
||||
<div className="card-product flex flex-col gap-md">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<dl className="grid grid-cols-2 gap-x-lg gap-y-xxs text-caption-md">
|
||||
<dt className="text-graphite">{t('engagement.detail.schedule.startDate')}</dt>
|
||||
<dd className="font-mono">{formatDate(eng.start_date)}</dd>
|
||||
<dt className="text-graphite">{t('engagement.detail.schedule.endDate')}</dt>
|
||||
<dd className="font-mono">{eng.end_date ? formatDate(eng.end_date) : '—'}</dd>
|
||||
<dt className="text-graphite">{t('engagement.detail.schedule.status')}</dt>
|
||||
<dd><StatusBadge status={eng.status} /></dd>
|
||||
<dt className="text-graphite">{t('engagement.detail.schedule.createdAt')}</dt>
|
||||
<dd className="font-mono">{formatDate(eng.created_at)}</dd>
|
||||
</dl>
|
||||
{canEditEngagements ? (
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline shrink-0">
|
||||
{t('engagement.detail.edit')}
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
<hr className="border-hairline" />
|
||||
<h2 className="text-display-sm">{t('engagement.detail.tabs.description')}</h2>
|
||||
<p className="text-[16px] text-charcoal whitespace-pre-line">
|
||||
{eng.description?.trim() ? eng.description : t('engagement.detail.noDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SimulationList engagementId={eng.id} />
|
||||
</section>
|
||||
{activeTab === 'simulations' && (
|
||||
<SimulationList engagementId={eng.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { EngagementInput, EngagementStatus } from '@/api/types';
|
||||
import {
|
||||
@@ -7,16 +8,13 @@ import {
|
||||
useEngagement,
|
||||
usePatchEngagement,
|
||||
} from '@/hooks/useEngagements';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
|
||||
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||
{ value: 'planned', label: 'Planned' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
];
|
||||
import { C2ConfigCard } from '@/components/C2ConfigCard';
|
||||
import { engagementStatusLabel } from '@/i18n/status';
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
@@ -34,22 +32,14 @@ const EMPTY: FormState = {
|
||||
status: 'planned',
|
||||
};
|
||||
|
||||
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
|
||||
const errors: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!state.name.trim()) errors.name = 'Name is required';
|
||||
if (!state.start_date) errors.start_date = 'Start date is required';
|
||||
if (state.end_date && state.start_date && state.end_date < state.start_date) {
|
||||
errors.end_date = 'End date must be on or after start date';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function EngagementFormPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const editing = Boolean(id);
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const navigate = useNavigate();
|
||||
const { push } = useToast();
|
||||
const { canEditEngagements } = useAuth();
|
||||
|
||||
const detail = useEngagement(editing ? numericId : undefined);
|
||||
const createMutation = useCreateEngagement();
|
||||
@@ -59,6 +49,22 @@ export function EngagementFormPage(): JSX.Element {
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||
{ value: 'planned', label: engagementStatusLabel('planned') },
|
||||
{ value: 'active', label: engagementStatusLabel('active') },
|
||||
{ value: 'closed', label: engagementStatusLabel('closed') },
|
||||
];
|
||||
|
||||
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
|
||||
const errs: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!state.name.trim()) errs.name = t('engagement.form.validation.nameRequired');
|
||||
if (!state.start_date) errs.start_date = t('engagement.form.validation.startDateRequired');
|
||||
if (state.end_date && state.start_date && state.end_date < state.start_date) {
|
||||
errs.end_date = t('engagement.form.validation.endDateAfterStart');
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
// Hydrate edit form when data arrives.
|
||||
useEffect(() => {
|
||||
if (editing && detail.data) {
|
||||
@@ -72,11 +78,11 @@ export function EngagementFormPage(): JSX.Element {
|
||||
}
|
||||
}, [editing, detail.data]);
|
||||
|
||||
if (editing && detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (editing && detail.isLoading) return <LoadingState label={t('engagement.form.loading')} />;
|
||||
if (editing && detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
message={extractApiError(detail.error, t('engagement.form.error.load'))}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -106,114 +112,128 @@ export function EngagementFormPage(): JSX.Element {
|
||||
try {
|
||||
if (editing && numericId) {
|
||||
await patchMutation.mutateAsync(payload);
|
||||
push('Engagement updated', 'success');
|
||||
push(t('engagement.form.toast.updated'), 'success');
|
||||
navigate(`/engagements/${numericId}`);
|
||||
} else {
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
push('Engagement created', 'success');
|
||||
push(t('engagement.form.toast.created'), 'success');
|
||||
navigate(`/engagements/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not save engagement'));
|
||||
setSubmitError(extractApiError(err, t('engagement.form.error.save')));
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = createMutation.isPending || patchMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl max-w-2xl">
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header>
|
||||
<h1 className="text-[44px] font-medium leading-none">
|
||||
{editing ? 'Edit engagement' : 'New engagement'}
|
||||
<h1 className="text-[32px] font-medium leading-none">
|
||||
{editing ? t('engagement.form.title.edit') : t('engagement.form.title.new')}
|
||||
</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
{editing
|
||||
? 'Update the engagement metadata.'
|
||||
: 'Create a new red team mission to host simulations.'}
|
||||
{editing ? t('engagement.form.subtitle.edit') : t('engagement.form.subtitle.new')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
|
||||
<TextInput
|
||||
id="eng-name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</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}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
editing && canEditEngagements
|
||||
? 'grid grid-cols-1 lg:grid-cols-2 gap-xl items-start'
|
||||
: 'max-w-2xl'
|
||||
}
|
||||
>
|
||||
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label={t('engagement.form.field.name')} htmlFor="eng-name" required error={errors.name}>
|
||||
<TextInput
|
||||
id="eng-start"
|
||||
type="date"
|
||||
name="start_date"
|
||||
value={form.start_date}
|
||||
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
|
||||
id="eng-name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="End date"
|
||||
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 label={t('engagement.form.field.description')} htmlFor="eng-description">
|
||||
<TextArea
|
||||
id="eng-description"
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Status" htmlFor="eng-status" required>
|
||||
<Select
|
||||
id="eng-status"
|
||||
name="status"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<FormField
|
||||
label={t('engagement.form.field.startDate')}
|
||||
htmlFor="eng-start"
|
||||
required
|
||||
error={errors.start_date}
|
||||
>
|
||||
<TextInput
|
||||
id="eng-start"
|
||||
type="date"
|
||||
name="start_date"
|
||||
value={form.start_date}
|
||||
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
<FormField
|
||||
label={t('engagement.form.field.endDate')}
|
||||
htmlFor="eng-end"
|
||||
hint={t('engagement.form.field.endDateHint')}
|
||||
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>
|
||||
) : 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>
|
||||
<FormField label={t('engagement.form.field.status')} htmlFor="eng-status" required>
|
||||
<Select
|
||||
id="eng-status"
|
||||
name="status"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{submitError ? (
|
||||
<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} data-testid="btn-submit">
|
||||
{submitting
|
||||
? t('engagement.form.btn.saving')
|
||||
: editing
|
||||
? t('engagement.form.btn.save')
|
||||
: t('engagement.form.btn.create')}
|
||||
</button>
|
||||
<Link
|
||||
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
|
||||
className="btn-outline-ink"
|
||||
>
|
||||
{t('engagement.form.btn.cancel')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{editing && numericId && canEditEngagements && (
|
||||
<C2ConfigCard engagementId={numericId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Engagement } from '@/api/types';
|
||||
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
|
||||
@@ -9,25 +10,22 @@ import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return value;
|
||||
}
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
export function EngagementsListPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementsList();
|
||||
const { canEditEngagements } = useAuth();
|
||||
const { push } = useToast();
|
||||
const deleteMutation = useDeleteEngagement();
|
||||
|
||||
const onDelete = async (eng: Engagement) => {
|
||||
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
|
||||
if (!window.confirm(t('engagement.list.deleteConfirm', { name: eng.name }))) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(eng.id);
|
||||
push('Engagement deleted', 'success');
|
||||
push(t('engagement.list.toast.deleted'), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not delete engagement'), 'error');
|
||||
push(extractApiError(err, t('engagement.list.error.delete')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,32 +33,32 @@ export function EngagementsListPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-end justify-between gap-md">
|
||||
<div>
|
||||
<h1 className="text-[44px] font-medium leading-none">Engagements</h1>
|
||||
<h1 className="text-[32px] font-medium leading-none">{t('engagement.list.title')}</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
Red team missions and their lifecycle status.
|
||||
{t('engagement.list.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> New
|
||||
<Plus size={14} aria-hidden /> {t('engagement.list.new')}
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{isLoading ? <LoadingState label="Loading engagements…" /> : null}
|
||||
{isLoading ? <LoadingState label={t('common.loading')} /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState message={extractApiError(error, 'Could not load engagements')} onRetry={() => refetch()} />
|
||||
<ErrorState message={extractApiError(error, t('engagement.list.error.load'))} onRetry={() => refetch()} />
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No engagements yet"
|
||||
description="Create your first engagement to start tracking red team missions."
|
||||
title={t('engagement.list.empty.title')}
|
||||
description={t('engagement.list.empty.desc')}
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> New
|
||||
<Plus size={14} aria-hidden /> {t('engagement.list.new')}
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
@@ -69,40 +67,40 @@ export function EngagementsListPage(): JSX.Element {
|
||||
|
||||
{!isLoading && !isError && data && data.length > 0 ? (
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Name</th>
|
||||
<th className="px-xl py-md">Status</th>
|
||||
<th className="px-xl py-md">Start</th>
|
||||
<th className="px-xl py-md">End</th>
|
||||
<th className="px-xl py-md">Created by</th>
|
||||
<th className="px-xl py-md text-right">Actions</th>
|
||||
<tr>
|
||||
<th>{t('engagement.list.col.name')}</th>
|
||||
<th>{t('engagement.list.col.status')}</th>
|
||||
<th>{t('engagement.list.col.start')}</th>
|
||||
<th>{t('engagement.list.col.end')}</th>
|
||||
<th>{t('engagement.list.col.createdBy')}</th>
|
||||
<th className="text-right">{t('engagement.list.col.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((eng) => (
|
||||
<tr key={eng.id} className="border-b border-hairline last:border-0">
|
||||
<td className="px-xl py-md">
|
||||
<tr key={eng.id}>
|
||||
<td>
|
||||
<Link to={`/engagements/${eng.id}`} className="text-ink font-medium hover:underline">
|
||||
{eng.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<td>
|
||||
<StatusBadge status={eng.status} />
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal">{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">{eng.created_by.username}</td>
|
||||
<td className="px-xl py-md text-right">
|
||||
<td className="text-charcoal font-mono">{formatDate(eng.start_date)}</td>
|
||||
<td className="text-charcoal font-mono">{formatDate(eng.end_date)}</td>
|
||||
<td className="text-charcoal">{eng.created_by.username}</td>
|
||||
<td className="text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<Link to={`/engagements/${eng.id}`} className="btn-text-link">
|
||||
View
|
||||
{t('engagement.list.view')}
|
||||
</Link>
|
||||
{canEditEngagements ? (
|
||||
<>
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-text-link">
|
||||
Edit
|
||||
{t('engagement.list.edit')}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
@@ -110,7 +108,7 @@ export function EngagementsListPage(): JSX.Element {
|
||||
onClick={() => onDelete(eng)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
{t('engagement.list.delete')}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { FormField, TextInput } from '@/components/FormField';
|
||||
@@ -14,6 +15,7 @@ export function LoginPage(): JSX.Element {
|
||||
const location = useLocation();
|
||||
const fromPath = (location.state as LocationState | null)?.from;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -34,7 +36,7 @@ export function LoginPage(): JSX.Element {
|
||||
await login(username, password);
|
||||
navigate(fromPath ?? '/engagements', { replace: true });
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, 'Invalid credentials'));
|
||||
setError(extractApiError(err, t('auth.login.invalid')));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -46,12 +48,12 @@ export function LoginPage(): JSX.Element {
|
||||
{/* Chevron echo of the brand mark */}
|
||||
<div className="flex items-center gap-sm">
|
||||
<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>
|
||||
<p className="text-[16px] text-charcoal">Sign in to access your engagements.</p>
|
||||
<p className="text-[16px] text-charcoal">{t('auth.login.subtitle')}</p>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-md">
|
||||
<FormField label="Username" htmlFor="login-username" required>
|
||||
<FormField label={t('auth.login.username')} htmlFor="login-username" required>
|
||||
<TextInput
|
||||
id="login-username"
|
||||
name="username"
|
||||
@@ -62,7 +64,7 @@ export function LoginPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password" required>
|
||||
<FormField label={t('auth.login.password')} htmlFor="login-password" required>
|
||||
<TextInput
|
||||
id="login-password"
|
||||
type="password"
|
||||
@@ -81,7 +83,7 @@ export function LoginPage(): JSX.Element {
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
{submitting ? t('auth.login.signingIn') : t('auth.login.signIn')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Save, RotateCcw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationPatchInput } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useHashTab } from '@/hooks/useHashTab';
|
||||
import {
|
||||
useCreateSimulation,
|
||||
useDeleteSimulation,
|
||||
@@ -12,12 +14,21 @@ import {
|
||||
useTransitionSimulation,
|
||||
useUpdateSimulation,
|
||||
} from '@/hooks/useSimulations';
|
||||
import { useC2Config, useC2Tasks } from '@/hooks/useC2';
|
||||
import { FormField, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
||||
import { ExecuteViaC2Modal } from '@/components/ExecuteViaC2Modal';
|
||||
import { ImportC2HistoryModal } from '@/components/ImportC2HistoryModal';
|
||||
import { C2TasksPanel } from '@/components/C2TasksPanel';
|
||||
import { AlertBanner } from '@/components/AlertBanner';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import { Tabs } from '@/components/Tabs';
|
||||
|
||||
type TabId = 'red-team' | 'soc' | 'c2';
|
||||
|
||||
interface RedteamFormState {
|
||||
name: string;
|
||||
@@ -52,6 +63,7 @@ const EMPTY_SOC: SocFormState = {
|
||||
};
|
||||
|
||||
export function SimulationFormPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { eid, sid } = useParams<{ eid: string; sid: string }>();
|
||||
const engagementId = eid ? Number(eid) : undefined;
|
||||
const simulationId = sid ? Number(sid) : undefined;
|
||||
@@ -61,6 +73,18 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const { push } = useToast();
|
||||
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 c2TaskCount = c2TasksQuery.data?.tasks?.length ?? 0;
|
||||
|
||||
const detail = useSimulation(isNew ? undefined : simulationId);
|
||||
const createMutation = useCreateSimulation(engagementId ?? 0);
|
||||
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
|
||||
@@ -72,6 +96,28 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showC2Modal, setShowC2Modal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
// All hooks must be called unconditionally before any early returns.
|
||||
const [activeTab, setActiveTab] = useHashTab('red-team');
|
||||
|
||||
// Compute disabled flags early using detail.data (may be undefined during load — that's fine).
|
||||
const loadedStatus = detail.data?.status;
|
||||
const earlySOCDisabled = isNew || (loadedStatus !== 'review_required' && loadedStatus !== 'done');
|
||||
const earlyC2Disabled = isNew || isSoc;
|
||||
const earlyHash = activeTab;
|
||||
const earlySafeTab =
|
||||
(earlyHash === 'soc' && earlySOCDisabled) || (earlyHash === 'c2' && earlyC2Disabled)
|
||||
? 'red-team'
|
||||
: earlyHash;
|
||||
|
||||
// Sync window.location.hash when the active hash points to a disabled tab
|
||||
useEffect(() => {
|
||||
if (earlySafeTab !== activeTab) {
|
||||
setActiveTab(earlySafeTab);
|
||||
}
|
||||
}, [earlySafeTab, activeTab, setActiveTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && detail.data) {
|
||||
@@ -93,11 +139,11 @@ export function SimulationFormPage(): JSX.Element {
|
||||
}
|
||||
}, [isNew, detail.data]);
|
||||
|
||||
if (!isNew && detail.isLoading) return <LoadingState label="Loading simulation…" />;
|
||||
if (!isNew && detail.isLoading) return <LoadingState label={t('simulation.form.loading')} />;
|
||||
if (!isNew && detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load simulation')}
|
||||
message={extractApiError(detail.error, t('simulation.form.error.load'))}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -106,13 +152,8 @@ export function SimulationFormPage(): JSX.Element {
|
||||
const simulation = detail.data;
|
||||
const status = simulation?.status;
|
||||
|
||||
// US-18: Done = fully read-only, Reopen only
|
||||
const isDone = status === 'done';
|
||||
|
||||
const canEditRT = isAdmin || isRedteam;
|
||||
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
|
||||
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
|
||||
|
||||
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
|
||||
const rtDisabled = !canEditRT || isDone;
|
||||
const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
|
||||
@@ -123,17 +164,22 @@ export function SimulationFormPage(): JSX.Element {
|
||||
!isDone && (canEditEngagements || isSoc) && status === 'review_required';
|
||||
const showReopen = isDone && (isAdmin || isRedteam || isSoc);
|
||||
|
||||
// Post-load versions (same logic, now with confirmed status)
|
||||
const socTabDisabled = earlySOCDisabled;
|
||||
const c2TabDisabled = earlyC2Disabled;
|
||||
const safeTab = earlySafeTab;
|
||||
|
||||
const onSubmitNew = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setNameError(null);
|
||||
setSubmitError(null);
|
||||
if (!rt.name.trim()) { setNameError('Name is required'); return; }
|
||||
if (!rt.name.trim()) { setNameError(t('simulation.form.validation.nameRequired')); return; }
|
||||
try {
|
||||
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
|
||||
push('Simulation created', 'success');
|
||||
push(t('simulation.form.toast.created'), 'success');
|
||||
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not create simulation'));
|
||||
setSubmitError(extractApiError(err, t('simulation.form.error.create')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,7 +187,7 @@ export function SimulationFormPage(): JSX.Element {
|
||||
e.preventDefault();
|
||||
setNameError(null);
|
||||
setSubmitError(null);
|
||||
if (!rt.name.trim()) { setNameError('Name is required'); return; }
|
||||
if (!rt.name.trim()) { setNameError(t('simulation.form.validation.nameRequired')); return; }
|
||||
const patch: SimulationPatchInput = {
|
||||
name: rt.name.trim(),
|
||||
description: rt.description.trim() || null,
|
||||
@@ -152,9 +198,9 @@ export function SimulationFormPage(): JSX.Element {
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync(patch);
|
||||
push('Simulation updated', 'success');
|
||||
push(t('simulation.form.toast.updated'), 'success');
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not update simulation'));
|
||||
setSubmitError(extractApiError(err, t('simulation.form.error.update')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -169,36 +215,36 @@ export function SimulationFormPage(): JSX.Element {
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync(patch);
|
||||
push('SOC report updated', 'success');
|
||||
push(t('simulation.form.toast.socUpdated'), 'success');
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
|
||||
setSubmitError(extractApiError(err, t('simulation.form.error.soc')));
|
||||
}
|
||||
};
|
||||
|
||||
const onMarkReview = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('review_required');
|
||||
push('Simulation marked for review', 'success');
|
||||
push(t('simulation.form.toast.markedReview'), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Transition failed'), 'error');
|
||||
push(extractApiError(err, t('simulation.form.error.transition')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('done');
|
||||
push('Simulation closed', 'success');
|
||||
push(t('simulation.form.toast.closed'), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Transition failed'), 'error');
|
||||
push(extractApiError(err, t('simulation.form.error.transition')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onReopen = async () => {
|
||||
try {
|
||||
await transitionMutation.mutateAsync('review_required');
|
||||
push('Simulation reopened', 'success');
|
||||
push(t('simulation.form.toast.reopened'), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Transition failed'), 'error');
|
||||
push(extractApiError(err, t('simulation.form.error.transition')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -206,27 +252,45 @@ export function SimulationFormPage(): JSX.Element {
|
||||
setShowDeleteConfirm(false);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(simulationId as number);
|
||||
push('Simulation deleted', 'success');
|
||||
push(t('simulation.form.toast.deleted'), 'success');
|
||||
navigate(`/engagements/${engagementId}`);
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not delete simulation'), 'error');
|
||||
push(extractApiError(err, t('simulation.form.error.delete')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const submitting =
|
||||
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
const tabItems = [
|
||||
{ id: 'red-team' as TabId, label: t('simulation.form.tab.redTeam') },
|
||||
{
|
||||
id: 'soc' as TabId,
|
||||
label: t('simulation.form.tab.soc'),
|
||||
disabled: socTabDisabled,
|
||||
},
|
||||
{
|
||||
id: 'c2' as TabId,
|
||||
label: t('simulation.form.tab.c2'),
|
||||
count: !isNew && c2TaskCount > 0 ? c2TaskCount : undefined,
|
||||
disabled: c2TabDisabled,
|
||||
},
|
||||
];
|
||||
|
||||
// New simulation form
|
||||
if (isNew) {
|
||||
const submitting = createMutation.isPending;
|
||||
const creating = createMutation.isPending;
|
||||
return (
|
||||
<div className="flex flex-col gap-xl max-w-2xl">
|
||||
<header>
|
||||
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
|
||||
← Back to engagement
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none mt-sm">New simulation</h1>
|
||||
<BackLink to={`/engagements/${engagementId}`}>{t('engagement.detail.backTo')}</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none mt-sm">{t('simulation.form.title.new')}</h1>
|
||||
</header>
|
||||
|
||||
<Tabs items={tabItems} activeId="red-team" onChange={() => {}} />
|
||||
|
||||
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
|
||||
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
|
||||
<TextInput
|
||||
id="sim-name"
|
||||
name="name"
|
||||
@@ -236,40 +300,47 @@ export function SimulationFormPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
|
||||
<MitreTechniquesField
|
||||
value={[]}
|
||||
tactics={[]}
|
||||
simulationId={0}
|
||||
engagementId={engagementId ?? 0}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError ? (
|
||||
<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 ? 'Creating…' : 'Create simulation'}
|
||||
</button>
|
||||
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||
<button type="button" className="btn-primary" disabled={creating} data-testid="create-sim-btn" onClick={onSubmitNew}>
|
||||
{creating ? t('simulation.form.btn.creating') : t('simulation.form.btn.create')}
|
||||
</button>
|
||||
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
|
||||
{t('simulation.form.btn.cancel')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const submitting =
|
||||
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
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">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
|
||||
← Back to engagement
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none">{rt.name || simulation?.name}</h1>
|
||||
<BackLink to={`/engagements/${engagementId}`}>{t('engagement.detail.backTo')}</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none">{rt.name || simulation?.name}</h1>
|
||||
{status ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<SimulationStatusBadge status={status} />
|
||||
{simulation?.created_by && (
|
||||
<span className="text-[14px] text-graphite">
|
||||
Created by <span className="text-ink">{simulation.created_by.username}</span>
|
||||
{t('engagement.detail.createdBy')}{' '}
|
||||
<span className="text-ink">{simulation.created_by.username}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -277,238 +348,286 @@ export function SimulationFormPage(): JSX.Element {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Done banner */}
|
||||
{/* Done banner — above tabs, global status info */}
|
||||
{isDone && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-xl 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.
|
||||
</div>
|
||||
<AlertBanner variant="success">
|
||||
{t('simulation.form.banner.done')}
|
||||
</AlertBanner>
|
||||
)}
|
||||
|
||||
{/* SOC banner */}
|
||||
{socBlocked && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="soc-blocked-banner"
|
||||
className="rounded-xl 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 "Review required" before you can fill in the SOC section.
|
||||
</div>
|
||||
)}
|
||||
<Tabs items={tabItems} activeId={safeTab} onChange={setActiveTab} />
|
||||
|
||||
{/* Red Team card */}
|
||||
<form
|
||||
id="rt-form"
|
||||
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
className="card-product flex flex-col gap-md"
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`tabpanel-${safeTab}`}
|
||||
aria-labelledby={`tab-${safeTab}`}
|
||||
>
|
||||
<h2 className="text-[20px] font-medium text-ink">Red Team</h2>
|
||||
{/* ── Red Team tab ──────────────────────────────────────── */}
|
||||
{safeTab === 'red-team' && (
|
||||
<form
|
||||
id="rt-form"
|
||||
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<FormField label={t('simulation.form.field.name')} htmlFor="sim-name" required error={nameError}>
|
||||
<TextInput
|
||||
id="sim-name"
|
||||
name="name"
|
||||
value={rt.name}
|
||||
onChange={(e) => setRt({ ...rt, name: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
|
||||
<TextInput
|
||||
id="sim-name"
|
||||
name="name"
|
||||
value={rt.name}
|
||||
onChange={(e) => setRt({ ...rt, name: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">{t('simulation.form.field.mitre')}</span>
|
||||
<MitreTechniquesField
|
||||
value={simulation?.techniques ?? []}
|
||||
tactics={simulation?.tactics ?? []}
|
||||
simulationId={simulationId as number}
|
||||
engagementId={engagementId as number}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques & Tactics</span>
|
||||
<MitreTechniquesField
|
||||
value={simulation?.techniques ?? []}
|
||||
tactics={simulation?.tactics ?? []}
|
||||
simulationId={simulationId as number}
|
||||
engagementId={engagementId as number}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</div>
|
||||
<FormField label={t('simulation.form.field.description')} htmlFor="sim-description">
|
||||
<TextArea
|
||||
id="sim-description"
|
||||
name="description"
|
||||
value={rt.description}
|
||||
onChange={(e) => setRt({ ...rt, description: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="sim-description">
|
||||
<TextArea
|
||||
id="sim-description"
|
||||
name="description"
|
||||
value={rt.description}
|
||||
onChange={(e) => setRt({ ...rt, description: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('simulation.form.field.commands')} htmlFor="sim-commands" hint={t('simulation.form.field.commandsHint')}>
|
||||
<TextArea
|
||||
id="sim-commands"
|
||||
name="commands"
|
||||
value={rt.commands}
|
||||
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
className="min-h-[160px] font-mono text-[14px]"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Commands" htmlFor="sim-commands" hint="One command per line">
|
||||
<TextArea
|
||||
id="sim-commands"
|
||||
name="commands"
|
||||
value={rt.commands}
|
||||
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
className="min-h-[160px] font-mono text-[14px]"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('simulation.form.field.prerequisites')} htmlFor="sim-prerequisites">
|
||||
<TextArea
|
||||
id="sim-prerequisites"
|
||||
name="prerequisites"
|
||||
value={rt.prerequisites}
|
||||
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Prerequisites" htmlFor="sim-prerequisites">
|
||||
<TextArea
|
||||
id="sim-prerequisites"
|
||||
name="prerequisites"
|
||||
value={rt.prerequisites}
|
||||
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('simulation.form.field.executedAt')} htmlFor="sim-executed-at">
|
||||
<TextInput
|
||||
id="sim-executed-at"
|
||||
type="datetime-local"
|
||||
name="executed_at"
|
||||
value={rt.executed_at}
|
||||
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Executed at" htmlFor="sim-executed-at">
|
||||
<TextInput
|
||||
id="sim-executed-at"
|
||||
type="datetime-local"
|
||||
name="executed_at"
|
||||
value={rt.executed_at}
|
||||
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('simulation.form.field.executionResult')} htmlFor="sim-exec-result">
|
||||
<TextArea
|
||||
id="sim-exec-result"
|
||||
name="execution_result"
|
||||
value={rt.execution_result}
|
||||
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
rows={5}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Execution result" htmlFor="sim-exec-result">
|
||||
<TextArea
|
||||
id="sim-exec-result"
|
||||
name="execution_result"
|
||||
value={rt.execution_result}
|
||||
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
|
||||
disabled={rtDisabled}
|
||||
rows={5}
|
||||
/>
|
||||
</FormField>
|
||||
</form>
|
||||
{!isDone && canEditRT && hasC2Config && (
|
||||
<div className="pt-xs flex items-center gap-md flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-execute-btn"
|
||||
className="btn-outline"
|
||||
onClick={() => setShowC2Modal(true)}
|
||||
>
|
||||
{t('simulation.form.btn.executeC2')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-import-trigger-btn"
|
||||
className="btn-outline"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
>
|
||||
{t('simulation.form.btn.importC2History')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* SOC card */}
|
||||
<form
|
||||
id="soc-form"
|
||||
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<h2 className="text-[20px] font-medium text-ink">SOC</h2>
|
||||
{/* ── SOC tab ───────────────────────────────────────────── */}
|
||||
{safeTab === 'soc' && (
|
||||
<form
|
||||
id="soc-form"
|
||||
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
|
||||
noValidate
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<FormField label={t('simulation.form.field.logSource')} htmlFor="sim-log-source">
|
||||
<TextInput
|
||||
id="sim-log-source"
|
||||
name="log_source"
|
||||
value={soc.log_source}
|
||||
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Log source" htmlFor="sim-log-source">
|
||||
<TextInput
|
||||
id="sim-log-source"
|
||||
name="log_source"
|
||||
value={soc.log_source}
|
||||
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('simulation.form.field.logs')} htmlFor="sim-logs">
|
||||
<TextArea
|
||||
id="sim-logs"
|
||||
name="logs"
|
||||
value={soc.logs}
|
||||
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Logs" htmlFor="sim-logs">
|
||||
<TextArea
|
||||
id="sim-logs"
|
||||
name="logs"
|
||||
value={soc.logs}
|
||||
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
|
||||
disabled={socDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('simulation.form.field.socComment')} 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="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={t('simulation.form.field.incidentNumber')} 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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* ── C2 tab ────────────────────────────────────────────── */}
|
||||
{safeTab === 'c2' && simulationId && (
|
||||
<C2TasksPanel simulationId={simulationId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
|
||||
) : null}
|
||||
|
||||
{/* Unified sticky action bar */}
|
||||
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||
{/* Done state: Reopen only */}
|
||||
{showReopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onReopen}
|
||||
disabled={transitionMutation.isPending}
|
||||
data-testid="reopen-btn"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden />
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
{/* Contextual sticky bar — not rendered at all for C2 tab */}
|
||||
{safeTab !== 'c2' && (
|
||||
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
|
||||
{safeTab === 'red-team' && (
|
||||
<>
|
||||
{!isDone && canEditRT && (
|
||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.save')}
|
||||
</button>
|
||||
)}
|
||||
{showMarkReview && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onMarkReview}
|
||||
disabled={transitionMutation.isPending}
|
||||
data-testid="mark-review-btn"
|
||||
>
|
||||
{t('simulation.form.btn.markReview')}
|
||||
</button>
|
||||
)}
|
||||
{!isDone && canEditEngagements && simulationId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep ml-auto"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={submitting}
|
||||
data-testid="delete-btn"
|
||||
>
|
||||
{t('simulation.form.btn.delete')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Normal state buttons */}
|
||||
{!isDone && canEditRT && (
|
||||
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{!isDone && canSaveSoc && (
|
||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
|
||||
</button>
|
||||
)}
|
||||
{showMarkReview && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onMarkReview}
|
||||
disabled={transitionMutation.isPending}
|
||||
>
|
||||
Mark for review
|
||||
</button>
|
||||
)}
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onClose}
|
||||
disabled={transitionMutation.isPending}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
{!isDone && canEditEngagements && simulationId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep ml-auto"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{safeTab === 'soc' && (
|
||||
<>
|
||||
{!isDone && canSaveSoc && (
|
||||
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
|
||||
<Save size={14} aria-hidden />
|
||||
{updateMutation.isPending ? t('simulation.form.btn.saving') : t('simulation.form.btn.saveSoc')}
|
||||
</button>
|
||||
)}
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onClose}
|
||||
disabled={transitionMutation.isPending}
|
||||
data-testid="close-btn"
|
||||
>
|
||||
{t('simulation.form.btn.close')}
|
||||
</button>
|
||||
)}
|
||||
{showReopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={onReopen}
|
||||
disabled={transitionMutation.isPending}
|
||||
data-testid="reopen-btn"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden />
|
||||
{t('simulation.form.btn.reopen')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
title="Delete simulation"
|
||||
description="This action is permanent. The simulation will be deleted forever."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
title={t('simulation.form.deleteConfirm.title')}
|
||||
description={t('simulation.form.deleteConfirm.desc')}
|
||||
confirmLabel={t('simulation.form.deleteConfirm.confirm')}
|
||||
cancelLabel={t('simulation.form.deleteConfirm.cancel')}
|
||||
destructive
|
||||
onConfirm={onDelete}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Save, Grid2x2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
@@ -9,6 +10,7 @@ import { FormField, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag';
|
||||
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
|
||||
import { MitreMatrixModal } from '@/components/MitreMatrixModal';
|
||||
@@ -24,6 +26,7 @@ interface FormState {
|
||||
const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' };
|
||||
|
||||
export function TemplateFormPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const templateId = id ? Number(id) : undefined;
|
||||
const isNew = !templateId;
|
||||
@@ -46,15 +49,15 @@ export function TemplateFormPage(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (existing.data) {
|
||||
const t = existing.data;
|
||||
const tpl = existing.data;
|
||||
setForm({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
commands: t.commands ?? '',
|
||||
prerequisites: t.prerequisites ?? '',
|
||||
name: tpl.name,
|
||||
description: tpl.description ?? '',
|
||||
commands: tpl.commands ?? '',
|
||||
prerequisites: tpl.prerequisites ?? '',
|
||||
});
|
||||
setTechniques(t.techniques);
|
||||
setTactics(t.tactics);
|
||||
setTechniques(tpl.techniques);
|
||||
setTactics(tpl.tactics);
|
||||
}
|
||||
}, [existing.data]);
|
||||
|
||||
@@ -64,7 +67,7 @@ export function TemplateFormPage(): JSX.Element {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (!form.name.trim()) {
|
||||
setFormError('Name is required');
|
||||
setFormError(t('template.form.validation.nameRequired'));
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
@@ -72,20 +75,20 @@ export function TemplateFormPage(): JSX.Element {
|
||||
description: form.description.trim() || null,
|
||||
commands: form.commands.trim() || null,
|
||||
prerequisites: form.prerequisites.trim() || null,
|
||||
technique_ids: techniques.map((t) => t.id),
|
||||
tactic_ids: tactics.map((t) => t.id),
|
||||
technique_ids: techniques.map((tech) => tech.id),
|
||||
tactic_ids: tactics.map((tac) => tac.id),
|
||||
};
|
||||
try {
|
||||
if (isNew) {
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
push('Template created', 'success');
|
||||
push(t('template.form.toast.created'), 'success');
|
||||
navigate(`/admin/templates/${created.id}/edit`, { replace: true });
|
||||
} else {
|
||||
await updateMutation.mutateAsync(payload);
|
||||
push('Template saved', 'success');
|
||||
push(t('template.form.toast.saved'), 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(extractApiError(err, 'Could not save template'));
|
||||
setFormError(extractApiError(err, t('template.form.errorSave')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -93,10 +96,10 @@ export function TemplateFormPage(): JSX.Element {
|
||||
if (!templateId) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(templateId);
|
||||
push('Template deleted', 'success');
|
||||
push(t('template.form.toast.deleted'), 'success');
|
||||
navigate('/admin/templates', { replace: true });
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not delete template'), 'error');
|
||||
push(extractApiError(err, t('template.form.errorDelete')), 'error');
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
@@ -108,16 +111,16 @@ export function TemplateFormPage(): JSX.Element {
|
||||
};
|
||||
|
||||
const handlePickerSelect = (technique: MitreTechnique) => {
|
||||
if (techniques.some((t) => t.id === technique.id)) return;
|
||||
if (techniques.some((tech) => tech.id === technique.id)) return;
|
||||
setTechniques((prev) => [...prev, technique]);
|
||||
setShowPicker(false);
|
||||
};
|
||||
|
||||
if (!isNew && existing.isLoading) return <LoadingState label="Loading template…" />;
|
||||
if (!isNew && existing.isLoading) return <LoadingState label={t('template.form.loading')} />;
|
||||
if (!isNew && existing.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(existing.error, 'Could not load template')}
|
||||
message={extractApiError(existing.error, t('template.form.errorLoad'))}
|
||||
onRetry={() => existing.refetch()}
|
||||
/>
|
||||
);
|
||||
@@ -127,11 +130,9 @@ export function TemplateFormPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<Link to="/admin/templates" className="btn-text-link text-[14px]">
|
||||
← Back to templates
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none">
|
||||
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}
|
||||
<BackLink to="/admin/templates">{t('engagement.detail.backTo')}</BackLink>
|
||||
<h1 className="text-[32px] font-medium leading-none">
|
||||
{isNew ? t('template.form.title.new') : (existing.data?.name ?? t('template.form.title.edit'))}
|
||||
</h1>
|
||||
</div>
|
||||
{!isNew ? (
|
||||
@@ -141,13 +142,13 @@ export function TemplateFormPage(): JSX.Element {
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
{t('template.form.btn.delete')}
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} className="card-product flex flex-col gap-lg max-w-2xl">
|
||||
<FormField label="Name" htmlFor="tpl-name" required error={formError}>
|
||||
<FormField label={t('template.form.field.name')} htmlFor="tpl-name" required error={formError}>
|
||||
<TextInput
|
||||
id="tpl-name"
|
||||
value={form.name}
|
||||
@@ -156,55 +157,56 @@ export function TemplateFormPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="tpl-desc">
|
||||
<FormField label={t('template.form.field.description')} htmlFor="tpl-desc">
|
||||
<TextArea
|
||||
id="tpl-desc"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
disabled={isPending}
|
||||
placeholder="What does this simulation cover?"
|
||||
placeholder={t('template.form.field.descriptionPlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Commands" htmlFor="tpl-commands" hint="One command per line">
|
||||
<FormField label={t('template.form.field.commands')} htmlFor="tpl-commands" hint={t('template.form.field.commandsHint')}>
|
||||
<TextArea
|
||||
id="tpl-commands"
|
||||
value={form.commands}
|
||||
onChange={(e) => setForm({ ...form, commands: e.target.value })}
|
||||
disabled={isPending}
|
||||
placeholder="e.g. mimikatz.exe sekurlsa::logonpasswords"
|
||||
placeholder={t('template.form.field.commandsPlaceholder')}
|
||||
className="font-mono text-[14px]"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Prerequisites" htmlFor="tpl-prereqs">
|
||||
<FormField label={t('template.form.field.prerequisites')} htmlFor="tpl-prereqs">
|
||||
<TextArea
|
||||
id="tpl-prereqs"
|
||||
value={form.prerequisites}
|
||||
onChange={(e) => setForm({ ...form, prerequisites: e.target.value })}
|
||||
disabled={isPending}
|
||||
placeholder="e.g. Local admin access required"
|
||||
placeholder={t('template.form.field.prerequisitesPlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col gap-sm">
|
||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques & Tactics</span>
|
||||
<span className="text-[14px] font-medium text-ink">{t('template.form.field.mitre')}</span>
|
||||
|
||||
{techniques.length === 0 && tactics.length === 0 ? (
|
||||
<p className="text-[13px] text-graphite">No techniques selected</p>
|
||||
<p className="text-[13px] text-graphite">{t('template.form.field.mitreEmpty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
|
||||
{tactics.map((t) => (
|
||||
{tactics.map((tac) => (
|
||||
<MitreTacticTag
|
||||
key={t.id}
|
||||
tactic={t}
|
||||
onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== t.id))}
|
||||
key={tac.id}
|
||||
tactic={tac}
|
||||
onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== tac.id))}
|
||||
/>
|
||||
))}
|
||||
{techniques.map((t) => (
|
||||
{techniques.map((tech) => (
|
||||
<MitreTechniqueTag
|
||||
key={t.id}
|
||||
technique={t}
|
||||
onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== t.id))}
|
||||
key={tech.id}
|
||||
technique={tech}
|
||||
onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== tech.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -220,15 +222,15 @@ export function TemplateFormPage(): JSX.Element {
|
||||
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
|
||||
onClick={() => setShowPicker(true)}
|
||||
>
|
||||
Search technique (e.g. T1059)…
|
||||
{t('template.form.field.mitreSearch')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open MITRE matrix"
|
||||
aria-label={t('template.form.field.mitreOpenMatrix')}
|
||||
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} />
|
||||
</button>
|
||||
@@ -237,10 +239,10 @@ export function TemplateFormPage(): JSX.Element {
|
||||
|
||||
<div className="flex items-center gap-md pt-xs border-t border-hairline">
|
||||
<button type="submit" className="btn-primary" disabled={isPending}>
|
||||
<Save size={14} aria-hidden /> {isPending ? 'Saving…' : 'Save'}
|
||||
<Save size={14} aria-hidden /> {isPending ? t('template.form.btn.saving') : t('template.form.btn.save')}
|
||||
</button>
|
||||
<Link to="/admin/templates" className="btn-outline-ink">
|
||||
Cancel
|
||||
{t('template.form.btn.cancel')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
@@ -255,9 +257,10 @@ export function TemplateFormPage(): JSX.Element {
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
<ConfirmDialog
|
||||
title="Delete template"
|
||||
description={`Delete "${existing.data?.name ?? 'this template'}"? This cannot be undone. Simulations already created from it are unaffected.`}
|
||||
confirmLabel="Delete"
|
||||
title={t('template.form.deleteConfirm.title')}
|
||||
description={t('template.form.deleteConfirm.desc')}
|
||||
confirmLabel={t('template.form.deleteConfirm.confirm')}
|
||||
cancelLabel={t('template.form.deleteConfirm.cancel')}
|
||||
onConfirm={onDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
destructive
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { SimulationTemplate } from '@/api/types';
|
||||
import { useDeleteTemplate, useTemplates } from '@/hooks/useTemplates';
|
||||
@@ -7,28 +8,25 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
function mitreCount(t: SimulationTemplate): number {
|
||||
return t.techniques.length + t.tactics.length;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
export function TemplatesListPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, error, refetch } = useTemplates();
|
||||
const deleteMutation = useDeleteTemplate();
|
||||
const { push } = useToast();
|
||||
|
||||
const onDelete = async (t: SimulationTemplate) => {
|
||||
if (!window.confirm(`Delete template "${t.name}"? This cannot be undone.`)) return;
|
||||
const onDelete = async (template: SimulationTemplate) => {
|
||||
if (!window.confirm(`${t('template.list.title')} « ${template.name} » ?`)) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(t.id);
|
||||
push('Template deleted', 'success');
|
||||
await deleteMutation.mutateAsync(template.id);
|
||||
push(t('template.form.toast.deleted'), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not delete template'), 'error');
|
||||
push(extractApiError(err, t('template.form.errorDelete')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,32 +34,32 @@ export function TemplatesListPage(): JSX.Element {
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-end justify-between gap-md">
|
||||
<div>
|
||||
<h1 className="text-[44px] font-medium leading-none">Templates</h1>
|
||||
<h1 className="text-[32px] font-medium leading-none">{t('template.list.title')}</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
Reusable simulation blueprints for red team operations.
|
||||
{t('template.list.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/admin/templates/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> New
|
||||
<Plus size={14} aria-hidden /> {t('template.list.new')}
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{isLoading ? <LoadingState label="Loading templates…" /> : null}
|
||||
{isLoading ? <LoadingState label={t('common.loading')} /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(error, 'Could not load templates')}
|
||||
message={extractApiError(error, t('template.form.errorLoad'))}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No templates yet"
|
||||
description="Create your first template to speed up simulation setup."
|
||||
title={t('template.list.empty.title')}
|
||||
description={t('template.list.empty.desc')}
|
||||
action={
|
||||
<Link to="/admin/templates/new" className="btn-primary">
|
||||
<Plus size={14} aria-hidden /> New template
|
||||
<Plus size={14} aria-hidden /> {t('template.list.new')}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
@@ -69,44 +67,44 @@ export function TemplatesListPage(): JSX.Element {
|
||||
|
||||
{!isLoading && !isError && data && data.length > 0 ? (
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Name</th>
|
||||
<th className="px-xl py-md">MITRE</th>
|
||||
<th className="px-xl py-md">Created by</th>
|
||||
<th className="px-xl py-md">Updated</th>
|
||||
<th className="px-xl py-md text-right">Actions</th>
|
||||
<tr>
|
||||
<th>{t('template.list.col.name')}</th>
|
||||
<th>{t('template.list.col.mitre')}</th>
|
||||
<th>{t('template.list.col.createdBy')}</th>
|
||||
<th>{t('template.list.col.updated')}</th>
|
||||
<th className="text-right">{t('engagement.list.col.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((t) => (
|
||||
<tr key={t.id} className="border-b border-hairline last:border-0">
|
||||
<td className="px-xl py-md">
|
||||
{data.map((template) => (
|
||||
<tr key={template.id}>
|
||||
<td>
|
||||
<Link
|
||||
to={`/admin/templates/${t.id}/edit`}
|
||||
to={`/admin/templates/${template.id}/edit`}
|
||||
className="text-ink font-medium hover:underline"
|
||||
>
|
||||
{t.name}
|
||||
{template.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||
{mitreCount(t) === 0 ? '—' : mitreCount(t)}
|
||||
<td className="text-charcoal">
|
||||
{mitreCount(template) === 0 ? '—' : mitreCount(template)}
|
||||
</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-right">
|
||||
<td className="text-charcoal">{template.created_by.username}</td>
|
||||
<td className="text-charcoal font-mono">{formatDate(template.updated_at)}</td>
|
||||
<td className="text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link">
|
||||
Edit
|
||||
<Link to={`/admin/templates/${template.id}/edit`} className="btn-text-link">
|
||||
{t('template.list.edit')}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep"
|
||||
onClick={() => onDelete(t)}
|
||||
onClick={() => onDelete(template)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
{t('template.list.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Fragment, useState, type FormEvent } from 'react';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Role, User } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
@@ -14,12 +16,6 @@ import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
|
||||
const ROLE_OPTIONS: { value: Role; label: string }[] = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'redteam', label: 'Red Team' },
|
||||
{ value: 'soc', label: 'SOC' },
|
||||
];
|
||||
|
||||
interface CreateFormState {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -29,6 +25,7 @@ interface CreateFormState {
|
||||
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
|
||||
|
||||
export function UsersAdminPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const { user: currentUser } = useAuth();
|
||||
const { push } = useToast();
|
||||
const list = useUsersList();
|
||||
@@ -36,6 +33,12 @@ export function UsersAdminPage(): JSX.Element {
|
||||
const patchMutation = usePatchUser();
|
||||
const deleteMutation = useDeleteUser();
|
||||
|
||||
const ROLE_OPTIONS: { value: Role; label: string }[] = [
|
||||
{ value: 'admin', label: t('user.admin.role.admin') },
|
||||
{ value: 'redteam', label: t('user.admin.role.redteam') },
|
||||
{ value: 'soc', label: t('user.admin.role.soc') },
|
||||
];
|
||||
|
||||
const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
@@ -47,15 +50,15 @@ export function UsersAdminPage(): JSX.Element {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
if (createForm.password.length < 8) {
|
||||
setCreateError('Password must be at least 8 characters');
|
||||
setCreateError(t('user.admin.error.passwordMinLength'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(createForm);
|
||||
setCreateForm(EMPTY_CREATE);
|
||||
push('User created', 'success');
|
||||
push(t('user.admin.toast.created'), 'success');
|
||||
} catch (err) {
|
||||
setCreateError(extractApiError(err, 'Could not create user'));
|
||||
setCreateError(extractApiError(err, t('user.admin.error.create')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,53 +66,53 @@ export function UsersAdminPage(): JSX.Element {
|
||||
if (u.role === role) return;
|
||||
try {
|
||||
await patchMutation.mutateAsync({ id: u.id, input: { role } });
|
||||
push(`Role updated for ${u.username}`, 'success');
|
||||
push(t('user.admin.toast.roleUpdated', { username: u.username }), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not update role'), 'error');
|
||||
push(extractApiError(err, t('user.admin.error.roleUpdate')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onResetPassword = async (u: User, e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (resetPassword.length < 8) {
|
||||
push('Password must be at least 8 characters', 'error');
|
||||
push(t('user.admin.error.passwordMinLength'), 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
|
||||
push(`Password reset for ${u.username}`, 'success');
|
||||
push(t('user.admin.toast.passwordReset', { username: u.username }), 'success');
|
||||
setResetOpen(null);
|
||||
setResetPassword('');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not reset password'), 'error');
|
||||
push(extractApiError(err, t('user.admin.error.passwordReset')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (u: User) => {
|
||||
if (currentUser?.id === u.id) {
|
||||
push('You cannot delete your own account', 'error');
|
||||
push(t('user.admin.error.selfDelete'), 'error');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete user "${u.username}"?`)) return;
|
||||
if (!window.confirm(t('user.admin.deleteConfirm', { username: u.username }))) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(u.id);
|
||||
push('User deleted', 'success');
|
||||
push(t('user.admin.toast.deleted'), 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not delete user'), 'error');
|
||||
push(extractApiError(err, t('user.admin.error.delete')), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header>
|
||||
<h1 className="text-[44px] font-medium leading-none">User accounts</h1>
|
||||
<h1 className="text-[32px] font-medium leading-none">{t('user.admin.title')}</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
Manage local accounts. Admins can create new red team or SOC analysts.
|
||||
{t('user.admin.createSubtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="card-product flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||
<h2 className="text-[20px] font-medium">{t('user.admin.createSection')}</h2>
|
||||
{/*
|
||||
Option A structural fix (AC-17.3): labels / inputs / hints in 3 explicit grid rows
|
||||
so the browser can never misalign them by collapsing different-height cells.
|
||||
@@ -121,13 +124,13 @@ export function UsersAdminPage(): JSX.Element {
|
||||
>
|
||||
{/* Row 1 — labels */}
|
||||
<label htmlFor="new-username" className="text-[14px] font-medium text-ink">
|
||||
Username <span className="text-bloom-deep">*</span>
|
||||
{t('user.admin.field.username')} <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<label htmlFor="new-password" className="text-[14px] font-medium text-ink">
|
||||
Password <span className="text-bloom-deep">*</span>
|
||||
{t('user.admin.field.password')} <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<label htmlFor="new-role" className="text-[14px] font-medium text-ink">
|
||||
Role <span className="text-bloom-deep">*</span>
|
||||
{t('user.admin.col.role')} <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<div aria-hidden="true" />
|
||||
|
||||
@@ -148,17 +151,18 @@ export function UsersAdminPage(): JSX.Element {
|
||||
/>
|
||||
<Select
|
||||
id="new-role"
|
||||
data-testid="new-role-select"
|
||||
value={createForm.role}
|
||||
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||
{createMutation.isPending ? t('common.creating') : t('user.admin.btn.create')}
|
||||
</button>
|
||||
|
||||
{/* Row 3 — hints */}
|
||||
<div aria-hidden="true" />
|
||||
<span className="text-[12px] text-graphite">≥ 8 characters</span>
|
||||
<span className="text-[12px] text-graphite">{t('user.admin.passwordHint')}</span>
|
||||
<div aria-hidden="true" />
|
||||
<div aria-hidden="true" />
|
||||
</form>
|
||||
@@ -170,31 +174,31 @@ export function UsersAdminPage(): JSX.Element {
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">All accounts</h2>
|
||||
<h2 className="text-[20px] font-medium">{t('user.admin.allSection')}</h2>
|
||||
|
||||
{list.isLoading ? <LoadingState label="Loading users…" /> : null}
|
||||
{list.isLoading ? <LoadingState label={t('state.loading')} /> : null}
|
||||
{list.isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(list.error, 'Could not load users')}
|
||||
message={extractApiError(list.error, t('user.admin.error.create'))}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No users yet"
|
||||
description="Create the first account using the form above."
|
||||
title={t('user.admin.empty.title')}
|
||||
description={t('user.admin.empty.desc')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!list.isLoading && !list.isError && list.data && list.data.length > 0 ? (
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<table className="table-compact w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Username</th>
|
||||
<th className="px-xl py-md">Role</th>
|
||||
<th className="px-xl py-md">Created</th>
|
||||
<th className="px-xl py-md text-right">Actions</th>
|
||||
<tr>
|
||||
<th>{t('user.admin.col.username')}</th>
|
||||
<th>{t('user.admin.col.role')}</th>
|
||||
<th>{t('user.admin.col.createdAt')}</th>
|
||||
<th className="text-right">{t('user.admin.col.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -204,26 +208,26 @@ export function UsersAdminPage(): JSX.Element {
|
||||
// Fragment must carry the key — `<>` cannot, which broke
|
||||
// per-row reconciliation (reset-password state leaked across rows).
|
||||
<Fragment key={u.id}>
|
||||
<tr className="border-b border-hairline last:border-0">
|
||||
<td className="px-xl py-md font-medium text-ink">
|
||||
{u.username}
|
||||
<tr>
|
||||
<td className="text-ink">
|
||||
<span className="font-mono font-medium">{u.username}</span>
|
||||
{isSelf ? (
|
||||
<span className="ml-sm text-[12px] text-graphite uppercase tracking-[0.5px]">
|
||||
(you)
|
||||
<span className="ml-sm font-sans text-[12px] text-graphite">
|
||||
{t('user.admin.you')}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<td>
|
||||
<Select
|
||||
value={u.role}
|
||||
onChange={(e) => onRoleChange(u, e.target.value as Role)}
|
||||
options={ROLE_OPTIONS}
|
||||
aria-label={`Change role for ${u.username}`}
|
||||
aria-label={`${t('user.admin.col.role')} ${u.username}`}
|
||||
disabled={patchMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal">{u.created_at}</td>
|
||||
<td className="px-xl py-md text-right">
|
||||
<td className="text-charcoal font-mono">{formatDate(u.created_at)}</td>
|
||||
<td className="text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<button
|
||||
type="button"
|
||||
@@ -233,7 +237,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
setResetPassword('');
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
{t('user.admin.resetPassword')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -241,22 +245,23 @@ export function UsersAdminPage(): JSX.Element {
|
||||
disabled={isSelf || deleteMutation.isPending}
|
||||
onClick={() => onDelete(u)}
|
||||
>
|
||||
Delete
|
||||
{t('user.admin.btn.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{resetOpen === u.id ? (
|
||||
<tr className="border-b border-hairline last:border-0 bg-cloud">
|
||||
{/* Aerated row — inline form needs room; compact-density tradeoff intentional */}
|
||||
<td colSpan={4} className="px-xl py-md">
|
||||
<form
|
||||
onSubmit={(e) => onResetPassword(u, e)}
|
||||
className="flex items-end gap-md"
|
||||
>
|
||||
<FormField
|
||||
label={`New password for ${u.username}`}
|
||||
label={t('user.admin.newPasswordFor', { username: u.username })}
|
||||
htmlFor={`reset-${u.id}`}
|
||||
hint="≥ 8 characters"
|
||||
hint={t('user.admin.passwordHint')}
|
||||
>
|
||||
<TextInput
|
||||
id={`reset-${u.id}`}
|
||||
@@ -268,7 +273,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
/>
|
||||
</FormField>
|
||||
<button type="submit" className="btn-primary">
|
||||
Save password
|
||||
{t('user.admin.btn.setPassword')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -278,7 +283,7 @@ export function UsersAdminPage(): JSX.Element {
|
||||
setResetPassword('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('user.admin.btn.cancel')}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/*
|
||||
* 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.
|
||||
* Forma DJR Micro substitute per DESIGN.md §Note on Font Substitutes.
|
||||
*/
|
||||
@import '@fontsource-variable/inter/index.css';
|
||||
@import '@fontsource-variable/jetbrains-mono/index.css';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@layer base {
|
||||
/* Light mode — default */
|
||||
:root {
|
||||
--color-canvas: #ffffff;
|
||||
--color-canvas: #f3f5f8;
|
||||
--color-paper: #ffffff;
|
||||
--color-cloud: #f7f7f7;
|
||||
--color-fog: #e8e8e8;
|
||||
@@ -19,9 +19,11 @@
|
||||
--color-ink-on: #ffffff;
|
||||
--color-charcoal: #3d3d3d;
|
||||
--color-graphite: #636363;
|
||||
|
||||
/* DESIGN.md: body line-height 1.4 when substituting Inter */
|
||||
font-size: 16.5px;
|
||||
/* Semantic status tokens — WCAG AA on canvas and slab surfaces */
|
||||
--color-success: #16a34a;
|
||||
--color-success-soft: #dcfce7;
|
||||
--color-warn: #d97706;
|
||||
--color-warn-soft: #fef3c7;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
@@ -38,6 +40,11 @@
|
||||
--color-ink-on: #111827;
|
||||
--color-charcoal: #d1d5db;
|
||||
--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,
|
||||
@@ -48,6 +55,8 @@
|
||||
|
||||
body {
|
||||
@apply bg-canvas text-ink font-sans antialiased;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
}
|
||||
|
||||
@@ -57,19 +66,20 @@
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1;
|
||||
line-height: 1.1;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
* DESIGN.md component recipes.
|
||||
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px).
|
||||
* Terminal-SOC brutalist component recipes.
|
||||
* border-radius: 0 everywhere except .badge-pill-* (status pills only).
|
||||
* No transition-* on any interactive surface.
|
||||
*/
|
||||
|
||||
.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 {
|
||||
@apply bg-primary-deep;
|
||||
@@ -78,19 +88,19 @@
|
||||
@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 {
|
||||
@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 {
|
||||
@apply bg-paper;
|
||||
@apply opacity-80;
|
||||
}
|
||||
.btn-ink:disabled {
|
||||
@apply bg-steel cursor-not-allowed;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@apply bg-primary-soft;
|
||||
@@ -100,37 +110,89 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@apply bg-cloud;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@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 {
|
||||
@apply bg-canvas rounded-xl p-xl shadow-soft-lift;
|
||||
}
|
||||
.dark .card-product {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.32);
|
||||
@apply bg-paper border border-hairline rounded-none p-md;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Status pill badges — ONLY surfaces with rounded-pill */
|
||||
.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 {
|
||||
@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];
|
||||
}
|
||||
|
||||
/* ─── Sub-page tabs (L1) ─────────────────────────────────────────────── */
|
||||
.tab-underline {
|
||||
@apply text-graphite text-caption-bold cursor-pointer border-b-2 border-transparent hover:text-ink px-xs;
|
||||
}
|
||||
.tab-underline-active {
|
||||
@apply text-primary border-primary;
|
||||
}
|
||||
/* Count pill — rounded-pill is the allowed exception per DESIGN.md */
|
||||
.tab-count-pill {
|
||||
@apply inline-flex items-center rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono;
|
||||
}
|
||||
.tab-count-pill-active {
|
||||
@apply bg-primary-soft text-primary;
|
||||
}
|
||||
/* Disabled variant — consistent with btn-outline:disabled; overrides hover from .tab-underline */
|
||||
.tab-underline-disabled {
|
||||
@apply text-steel cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Inline alert banners (L2) ─────────────────────────────────────── */
|
||||
.alert-error {
|
||||
@apply bg-paper border border-hairline border-l-4 border-l-bloom-deep rounded-none px-md py-sm flex items-start gap-sm;
|
||||
}
|
||||
.alert-warn {
|
||||
@apply bg-paper border border-hairline border-l-4 border-l-warn rounded-none px-md py-sm flex items-start gap-sm;
|
||||
}
|
||||
.alert-success {
|
||||
@apply bg-paper border border-hairline border-l-4 border-l-success rounded-none px-md py-sm flex items-start gap-sm;
|
||||
}
|
||||
.alert-info {
|
||||
@apply bg-paper border border-hairline border-l-4 border-l-primary rounded-none px-md py-sm flex items-start gap-sm;
|
||||
}
|
||||
|
||||
/* ─── Compact table density (L4) — 32px row height, global ──────────── */
|
||||
.table-compact thead tr {
|
||||
@apply text-[12px] uppercase tracking-[0.5px] text-graphite;
|
||||
}
|
||||
.table-compact th {
|
||||
@apply px-md py-xxs;
|
||||
}
|
||||
.table-compact td {
|
||||
@apply px-md py-xxs text-caption-md;
|
||||
height: 32px;
|
||||
}
|
||||
.table-compact tbody tr {
|
||||
@apply border-b border-hairline last:border-0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
/**
|
||||
* Tokens mirror DESIGN.md.
|
||||
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter).
|
||||
* Tokens mirror DESIGN.md (terminal-SOC aesthetic, sprint 7).
|
||||
* Inter Variable: body/headers/labels. JetBrains Mono Variable: data cells only.
|
||||
* Dark mode: class-based, toggled by adding 'dark' to <html>.
|
||||
*/
|
||||
const config: Config = {
|
||||
@@ -11,7 +11,7 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Brand & Accent — primary stays fixed (HP Electric Blue never inverts)
|
||||
// Brand & Accent — primary stays fixed (never inverts)
|
||||
primary: {
|
||||
DEFAULT: '#024ad8',
|
||||
bright: '#296ef9',
|
||||
@@ -34,10 +34,19 @@ const config: Config = {
|
||||
},
|
||||
charcoal: 'var(--color-charcoal)',
|
||||
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-text': '#f9fafb',
|
||||
'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)
|
||||
bloom: {
|
||||
coral: '#ff5050',
|
||||
@@ -53,26 +62,28 @@ const config: Config = {
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Inter Variable"', 'Inter', 'Arial', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono Variable"', '"JetBrains Mono"', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
// DESIGN.md typography scale
|
||||
'display-xxl': ['72px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-xl': ['56px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-lg': ['44px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-md': ['32px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-sm': ['24px', { lineHeight: '1.17', fontWeight: '500' }],
|
||||
'display-xs': ['20px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'body-lg': ['18px', { lineHeight: '1.33', fontWeight: '400' }],
|
||||
'body-md': ['16px', { lineHeight: '1.38', fontWeight: '400' }],
|
||||
'body-emphasis': ['16px', { lineHeight: '1.38', fontWeight: '500' }],
|
||||
// Terminal-SOC display scale — reduced per §0 D9
|
||||
'display-xxl': ['40px', { lineHeight: '1.1', fontWeight: '500' }],
|
||||
'display-xl': ['32px', { lineHeight: '1.1', fontWeight: '500' }],
|
||||
'display-lg': ['28px', { lineHeight: '1.1', fontWeight: '500' }],
|
||||
'display-md': ['24px', { lineHeight: '1.1', fontWeight: '500' }],
|
||||
'display-sm': ['20px', { lineHeight: '1.1', fontWeight: '500' }],
|
||||
'display-xs': ['16px', { lineHeight: '1.1', fontWeight: '600' }],
|
||||
'body-lg': ['18px', { lineHeight: '1.4', fontWeight: '400' }],
|
||||
'body-md': ['16px', { lineHeight: '1.4', fontWeight: '400' }],
|
||||
'body-emphasis': ['16px', { lineHeight: '1.4', fontWeight: '500' }],
|
||||
'caption-md': ['14px', { lineHeight: '1.5', fontWeight: '400' }],
|
||||
'caption-bold': ['14px', { lineHeight: '1.3', fontWeight: '700' }],
|
||||
'caption-sm': ['12px', { lineHeight: '1.33', fontWeight: '400' }],
|
||||
'link-md': ['16px', { lineHeight: '1.38', fontWeight: '500' }],
|
||||
'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600', letterSpacing: '0.7px' }],
|
||||
'link-md': ['16px', { lineHeight: '1.4', fontWeight: '500' }],
|
||||
'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600' }],
|
||||
'button-sm': ['12.6px', { lineHeight: '1.0', fontWeight: '700' }],
|
||||
},
|
||||
spacing: {
|
||||
// DESIGN.md spacing tokens (named, complement Tailwind defaults)
|
||||
// Named tokens complement Tailwind defaults
|
||||
xxs: '4px',
|
||||
xs: '8px',
|
||||
sm: '12px',
|
||||
@@ -80,24 +91,13 @@ const config: Config = {
|
||||
lg: '20px',
|
||||
xl: '24px',
|
||||
xxl: '32px',
|
||||
section: '80px',
|
||||
section: '48px',
|
||||
},
|
||||
borderRadius: {
|
||||
// DESIGN.md radius tokens
|
||||
// Brutalist: 0 everywhere except status pills and avatars
|
||||
none: '0px',
|
||||
xs: '2px',
|
||||
sm: '3px',
|
||||
md: '4px',
|
||||
lg: '8px',
|
||||
xl: '16px',
|
||||
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: {
|
||||
page: '1366px',
|
||||
},
|
||||
|
||||
94
frontend/tests/EngagementDetailPage.test.tsx
Normal file
94
frontend/tests/EngagementDetailPage.test.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { Engagement } from '@/api/types';
|
||||
|
||||
vi.mock('@/api/exports', () => ({
|
||||
downloadEngagementExport: vi.fn(),
|
||||
}));
|
||||
|
||||
const ENGAGEMENT: Engagement = {
|
||||
id: 1,
|
||||
name: 'Test Engagement',
|
||||
description: 'A test engagement',
|
||||
start_date: '2026-06-01',
|
||||
end_date: null,
|
||||
status: 'active',
|
||||
created_at: '2026-06-01T08:00:00',
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
};
|
||||
|
||||
type MockRole = 'admin' | 'redteam' | 'soc';
|
||||
let mockRole: MockRole = 'admin';
|
||||
|
||||
function DetailPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/engagements/:id" element={<EngagementDetailPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: mockRole, created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: mockRole === 'admin',
|
||||
isRedteam: mockRole === 'redteam',
|
||||
isSoc: mockRole === 'soc',
|
||||
canEditEngagements: mockRole === 'admin' || mockRole === 'redteam',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('EngagementDetailPage — RBAC for Export button', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/engagements/1').reply(200, ENGAGEMENT);
|
||||
mock.onGet('/engagements/1/simulations').reply(200, []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('admin sees Export button', async () => {
|
||||
mockRole = 'admin';
|
||||
renderWithProviders(<DetailPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/1'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('export-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redteam sees Export button', async () => {
|
||||
mockRole = 'redteam';
|
||||
renderWithProviders(<DetailPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/1'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('export-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('soc does NOT see Export button', async () => {
|
||||
mockRole = 'soc';
|
||||
renderWithProviders(<DetailPage />, {
|
||||
routerProps: { initialEntries: ['/engagements/1'] },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Engagement')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('export-dropdown')).toBeNull();
|
||||
});
|
||||
});
|
||||
108
frontend/tests/EngagementFormPage.test.tsx
Normal file
108
frontend/tests/EngagementFormPage.test.tsx
Normal 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.getByTestId('btn-submit')).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.getByTestId('btn-submit')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('c2-config-card')).toBeNull();
|
||||
});
|
||||
});
|
||||
133
frontend/tests/ExportEngagementButton.test.tsx
Normal file
133
frontend/tests/ExportEngagementButton.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ExportEngagementButton } from '@/components/ExportEngagementButton';
|
||||
import { ToastViewport } from '@/components/Toast';
|
||||
import { renderWithProviders } from './utils';
|
||||
|
||||
function ExportButtonWithToast({ engagementId }: { engagementId: number }) {
|
||||
return (
|
||||
<>
|
||||
<ExportEngagementButton engagementId={engagementId} />
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock('@/api/exports', () => ({
|
||||
downloadEngagementExport: vi.fn(),
|
||||
}));
|
||||
|
||||
import { downloadEngagementExport } from '@/api/exports';
|
||||
const mockDownload = downloadEngagementExport as ReturnType<typeof vi.fn>;
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: true,
|
||||
isRedteam: false,
|
||||
isSoc: false,
|
||||
canEditEngagements: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ExportEngagementButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders Export button with chevron', () => {
|
||||
renderWithProviders(<ExportEngagementButton engagementId={1} />);
|
||||
expect(screen.getByTestId('export-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('export-dropdown-toggle')).toBeInTheDocument();
|
||||
expect(screen.getByText('Exporter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking primary opens dropdown with three formats', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ExportEngagementButton engagementId={1} />);
|
||||
await user.click(screen.getByTestId('export-btn'));
|
||||
expect(screen.getByText('Markdown')).toBeInTheDocument();
|
||||
expect(screen.getByText('CSV')).toBeInTheDocument();
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking outside closes dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ExportEngagementButton engagementId={1} />);
|
||||
await user.click(screen.getByTestId('export-btn'));
|
||||
expect(screen.getByText('Markdown')).toBeInTheDocument();
|
||||
await user.click(document.body);
|
||||
expect(screen.queryByText('Markdown')).toBeNull();
|
||||
});
|
||||
|
||||
it('Escape closes dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ExportEngagementButton engagementId={1} />);
|
||||
await user.click(screen.getByTestId('export-dropdown-toggle'));
|
||||
expect(screen.getByText('Markdown')).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByText('Markdown')).toBeNull();
|
||||
});
|
||||
|
||||
it('clicking Markdown triggers download with format=md', async () => {
|
||||
mockDownload.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ExportEngagementButton engagementId={42} />);
|
||||
await user.click(screen.getByTestId('export-btn'));
|
||||
await user.click(screen.getByTestId('export-format-md'));
|
||||
expect(mockDownload).toHaveBeenCalledWith(42, 'md');
|
||||
});
|
||||
|
||||
it('clicking CSV triggers download with format=csv', async () => {
|
||||
mockDownload.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ExportEngagementButton engagementId={42} />);
|
||||
await user.click(screen.getByTestId('export-btn'));
|
||||
await user.click(screen.getByTestId('export-format-csv'));
|
||||
expect(mockDownload).toHaveBeenCalledWith(42, 'csv');
|
||||
});
|
||||
|
||||
it('clicking PDF triggers download with format=pdf', async () => {
|
||||
mockDownload.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ExportEngagementButton engagementId={42} />);
|
||||
await user.click(screen.getByTestId('export-btn'));
|
||||
await user.click(screen.getByTestId('export-format-pdf'));
|
||||
expect(mockDownload).toHaveBeenCalledWith(42, 'pdf');
|
||||
});
|
||||
|
||||
it('loading state disables items during in-flight', async () => {
|
||||
let resolve!: () => void;
|
||||
mockDownload.mockReturnValue(new Promise<void>((r) => { resolve = r; }));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ExportEngagementButton engagementId={1} />);
|
||||
await user.click(screen.getByTestId('export-btn'));
|
||||
await user.click(screen.getByTestId('export-format-md'));
|
||||
// Items should be disabled while in-flight
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('export-format-csv')).toBeDisabled();
|
||||
expect(screen.getByTestId('export-format-pdf')).toBeDisabled();
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
it('error response shows toast', async () => {
|
||||
mockDownload.mockRejectedValue(new Error('Export failed: 403 Forbidden'));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ExportButtonWithToast engagementId={1} />);
|
||||
await user.click(screen.getByTestId('export-btn'));
|
||||
await user.click(screen.getByTestId('export-format-md'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toast')).toHaveTextContent('Export failed: 403 Forbidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,7 @@ describe('MitreMatrixModal', () => {
|
||||
);
|
||||
await user.click(t1078Btn!);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Apply/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Appliquer/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -148,7 +148,7 @@ describe('MitreMatrixModal', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Fermer/i }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
@@ -202,7 +202,7 @@ describe('MitreMatrixModal', () => {
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Filter techniques/i);
|
||||
const searchInput = screen.getByPlaceholderText(/Filtrer/i);
|
||||
await user.type(searchInput, 'T1059');
|
||||
|
||||
expect(screen.queryByText('T1078')).toBeNull();
|
||||
@@ -224,7 +224,7 @@ describe('MitreMatrixModal', () => {
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
expect(screen.queryByText(/Default Accounts/)).toBeNull();
|
||||
|
||||
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i });
|
||||
const expandBtn = screen.getByRole('button', { name: /Déplier T1078/i });
|
||||
await user.click(expandBtn);
|
||||
|
||||
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
|
||||
@@ -242,7 +242,7 @@ describe('MitreMatrixModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply 1 item/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Appliquer \(1 élément\)/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,11 +259,11 @@ describe('MitreMatrixModal', () => {
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
||||
const applyBtn = screen.getByRole('button', { name: /Tout effacer/i });
|
||||
expect(applyBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Apply button shows "Clear all" and is enabled when initial selection is deselected', async () => {
|
||||
it('Apply button shows "Tout effacer" and is enabled when initial selection is deselected', async () => {
|
||||
const onApply = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('MitreMatrixModal', () => {
|
||||
);
|
||||
await user.click(t1078Btn!);
|
||||
|
||||
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
||||
const applyBtn = screen.getByRole('button', { name: /Tout effacer/i });
|
||||
expect(applyBtn).not.toBeDisabled();
|
||||
await user.click(applyBtn);
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user