From 813e69ee01cadec8af9279a25caf20144fceb724 Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 10 Jun 2026 19:07:35 +0200 Subject: [PATCH 01/16] docs(spec): add C2 integration section (sprint 8 commit #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the SPEC section for the Mythic C2 integration layer. Covers RBAC (RT-only, SOC=403), per-engagement Fernet-encrypted config, c2_config + c2_task data model with ON DELETE CASCADE, full endpoint list, output mapping rules (append-only, idempotent), 2500 ms polling and the fake/real adapter selection via MIMIC_C2_ADAPTER. Also patch tasks/todo.md: fix pytest baseline (256 from main, not 253), make cascade-delete explicit, pin the MythicMeta/Mythic_Scripting source version and document defensive base64 handling. Closes spec-reviewer WARN-1 (SPEC ↔ plan parity), WARN-2 (cascade), INFO-1 (pinned source), INFO-3 (baseline). --- SPEC.md | 66 +++++++++++++++- tasks/todo.md | 204 ++++++++++++++++++-------------------------------- 2 files changed, 137 insertions(+), 133 deletions(-) diff --git a/SPEC.md b/SPEC.md index 20244cd..7331b08 100644 --- a/SPEC.md +++ b/SPEC.md @@ -59,8 +59,70 @@ CSV : exactement 1 ligne d'en-tête + 1 ligne par simulation. Markdown : en-têt Prévoir un module d'authentification : dans un premier temps local à la bdd. 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//c2-config` retourne `has_token: bool` uniquement. Mise à jour via `PUT` ; suppression via `DELETE`. La suppression d'un engagement supprime en cascade sa `c2_config`. + +**Sélection d'adapter** via l'env var `MIMIC_C2_ADAPTER` : +- `mythic` (défaut) : adapter Mythic réel (GraphQL via Hasura). +- `fake` : adapter en mémoire déterministe utilisé pour la validation Playwright et le dev local sans instance Mythic. + +**Modèle de données — additions** : + +`c2_config` (1 ligne par engagement au max) : +| Colonne | Type | Notes | +|---|---|---| +| `id` | int PK | | +| `engagement_id` | int FK `engagements.id` ON DELETE CASCADE, **UNIQUE** | | +| `url` | text | endpoint Mythic, ex. `https://lab.internal:7443` | +| `api_token_encrypted` | text | Fernet ciphertext, jamais en clair | +| `verify_tls` | bool, défaut `true` | `false` autorisé pour labs auto-signés | +| `created_at`, `updated_at` | datetime | | + +`c2_task` (lien simulation ↔ tâche Mythic) : +| Colonne | Type | Notes | +|---|---|---| +| `id` | int PK | | +| `simulation_id` | int FK `simulations.id` ON DELETE CASCADE | | +| `mythic_task_display_id` | int | identifiant côté Mythic | +| `callback_display_id` | int | callback Mythic sur lequel la tâche tourne | +| `command` | text | commande envoyée | +| `params` | text nullable | paramètres associés | +| `status` | text | statut brut Mythic (`submitted`, `completed`, `error`, …) | +| `completed` | bool | `true` quand la tâche est terminée | +| `output` | text nullable | sortie décodée (base64 → utf-8 ; binaire → préfixe `` + 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//c2-config` — `{has_token, url, verify_tls}` (jamais le token en clair). +- `PUT /api/engagements//c2-config` — `{url, api_token?, verify_tls}`. +- `DELETE /api/engagements//c2-config`. +- `POST /api/engagements//c2-config/test` — test de connectivité via l'adapter, renvoie `{ok, error?}`. +- `GET /api/engagements//c2/callbacks` — callbacks actifs de l'instance Mythic configurée. +- `POST /api/simulations//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//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//c2/callbacks//history?page=` — historique paginé des tâches d'un callback, pour l'import. +- `POST /api/simulations//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$ \n\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//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... diff --git a/tasks/todo.md b/tasks/todo.md index a926489..9c9d4e4 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,153 +1,95 @@ -# Sprint 7 — Design Refresh: Terminal-SOC Aesthetic +# Sprint 8 — C2 Layer: Mythic Integration -> Branch : `sprint/7-design` · Worktree : `.claude/worktrees/sprint-7-design` · Base : `main` @ `e27babe` +> Branch : `sprint/8-c2` · Worktree : `.claude/worktrees/sprint-8-c2` · Base : `main` @ `6ca614a` -## §0 — Binding decisions (locked with user, 2026-06-09) +SPEC.md phase 2: "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 simulations au travers du C2." -1. **Visual direction**: Bloomberg / Terminal-SOC — dense, brutalist, semantic colors strong, no ornament. -2. **Border-radius**: **0 everywhere** except status pills (`rounded-pill`) and avatars (round). All buttons, cards, modals, inputs, dropdowns, tables, tags → angular. -3. **Palette**: KEEP current (`#024ad8` primary blue, `slab #111827`, `canvas/paper/cloud/fog/ink` light+dark vars). ADD semantic tokens `success-green` + `warn-amber` (confirmed scope add — needed for SOC-grade status legibility on dashboards and badges). -4. **Scope**: Refonte globale en 1 sprint (all 8 pages + 17 components + tokens + DESIGN.md). -5. **Monospace**: data-only — JetBrains Mono for IDs, dates ISO, commands, execution output, MITRE techniques, metrics. Inter stays for body/labels/headers. -6. **Mono font**: JetBrains Mono, bundled locally via `@fontsource-variable/jetbrains-mono` (consistent with existing Inter bundle). -7. **Modes**: KEEP light + dark both. Toggle stays. -8. **Animations**: **Brutalist — zero transition**. Remove all `transition-*` utilities, focus rings sharp, hover instantaneous. -9. **Display scale reduction**: locked. `display-xxl 72→40`, `display-xl 56→32`, `display-lg 44→28`, `display-md 32→24`, `display-sm 24→20`, `display-xs 20→16`. Headers stay modest in terminal aesthetic — no editorial flourish at hero scale. +## §0 — Binding decisions (locked with user, 2026-06-10) -## §1 — Pre-work checks (team-lead, before dispatch) +1. **C2 target**: Mythic 3.x, behind a thin `C2Adapter` interface (keeps the door open for a custom C2 later). +2. **Scope**: FULL bidirectional — execute + near-real-time tracking + history import. User explicitly overrode the "one-shot first" recommendation; overrun risk accepted (see R1). +3. **C2 config**: per-engagement (URL + API token). Token is write-only at the API level — never returned in clear. +4. **UI anchor**: execution lives in the simulation screen (Red Team card). +5. **Realtime mechanism**: short polling. Frontend: TanStack Query `refetchInterval` 2 500 ms while any attached task is incomplete. Backend: poll-on-read — refreshes non-completed tasks from Mythic when the task list is read. No scheduler, no new infra. +6. **Secret storage**: API token Fernet-encrypted in SQLite. Key from env var `MIMIC_ENCRYPTION_KEY` (mandatory to enable C2 features, never hardcoded — OPSEC rule). +7. **History import**: BOTH — auto-attach of tasks launched from Mimic AND manual browse/select of the callback's task history. +8. **Validation**: fully mocked — no dev Mythic instance available. pytest uses a mocked adapter; e2e uses a built-in `FakeAdapter` selected via `MIMIC_C2_ADAPTER=fake`. +9. **RBAC**: C2 is a RedTeam resource — admin + redteam full access, SOC gets 403 on every C2 endpoint (same precedent as Templates and Export). +10. **Workflow tie-in**: launching a C2 execution auto-transitions the simulation `pending → in_progress` (same rule as a manual RT edit). +11. **Output mapping**: when a task completes (or is imported), its output is APPENDED to `execution_result` prefixed by a `$ ` header line; `executed_at` is set from the first task's timestamp if empty; the command is appended to `commands` if not already present. -- [ ] Confirm `tasks/lessons.md` has nothing contradicting this brief -- [ ] Verify uncommitted `.claude/agents/frontend-builder.md` patch (Skill mandatory) is restored in worktree — sprint hygiene -- [ ] Send plan to **spec-reviewer** for 2-pass approval (vs SPEC.md, vs §0 binding decisions). MUST be APPROVED before any code touches `frontend/`. -- [ ] After approval: dispatch frontend-builder with this todo as brief. +## §1 — Mythic adapter contract (pinned from MythicMeta/Mythic_Scripting client source) -## §2 — Sprint hygiene (commit #1) +- Transport: POST `https://:7443/graphql`, header `apitoken: ` (Hasura behind nginx). +- Issue task: mutation `createTask(callback_id: , command, params, tasking_location: "command_line")`. +- List callbacks: query on `callback` table — `id, display_id, active, host, user, domain, last_checkin`. +- Task status: query on `task` table — `display_id, status, completed` (client lib uses a subscription; we poll the query instead, per decision 5). +- Task output: query on `response` table ordered by id — `response_text` is **base64-encoded**, decode + concatenate. +- History: query `task` filtered by `callback_display_id`, paginated, with command + status + timestamps. -- [ ] `chore(agents): frontend-builder must invoke Skill frontend-design before UI work` — lands BEFORE design work so the agent itself triggers the Skill on first call this sprint. +`C2Adapter` interface (`backend/app/services/c2/adapter.py`): +- `test_connection() -> C2Health` +- `list_callbacks() -> list[C2Callback]` +- `create_task(callback_display_id, command, params) -> int` (task display_id) +- `get_task(task_display_id) -> C2TaskStatus` +- `get_task_output(task_display_id) -> str` (decoded) +- `list_callback_tasks(callback_display_id, page, page_size) -> C2TaskPage` -## §3 — Foundation: DESIGN.md + tokens (commits #2–#4) +Implementations: `MythicAdapter` (requests, `verify_tls` flag from config — lab instances run self-signed), `FakeAdapter` (deterministic in-memory data, selected by `MIMIC_C2_ADAPTER=fake`, also powers e2e). -### §3.1 DESIGN.md rewrite (commit #2) +## §2 — Backend (backend-builder) — milestones M1→M4 -- [ ] Replace current HP-catalog doc (346 lines, off-brand) with terminal-SOC spec covering: - - **Overview**: brutalist BAS Purple Team console, angular surfaces, semantic color signals, data-monospace hybrid - - **Colors**: keep all existing tokens with **role redefinition** for terminal-SOC context. Primary = neutral action. Bloom-deep/coral = destructive/alert. ADD `success` (green) + `warn` (amber) — locked §0 D3 — with light + dark variants and WCAG AA contrast on slab and canvas surfaces - - **Typography**: Inter (body/headers/labels) + JetBrains Mono (data). Concrete tier table with size/weight/line-height - - **Layout**: tighter spacing (replace `section 80px` → `section 48px`; halve card padding on dense surfaces) - - **Shapes**: ALL radii = 0 except `rounded-pill` reserved for status badges and avatars - - **Components**: re-document `btn-*`, `text-input`, `card-*`, `badge-*`, `nav-*`, `modal-*` with brutalist specs (no shadow, hairline borders, zero transition) - - **Do's/Don'ts**: zero rounded on conteneurs; zero transitions; semantic colors only on status surfaces; mono ONLY for data, never headers - - **Iteration guide** -- [ ] Doc lives in English (in-repo). +**Data model (1 Alembic migration):** +- `c2_config`: id, engagement_id FK **unique** (`ON DELETE CASCADE`, same precedent as `simulations.engagement_id`), url, api_token_encrypted, verify_tls bool (default true), created_at, updated_at. +- `c2_task`: id, simulation_id FK (`ON DELETE CASCADE`), mythic_task_display_id int, callback_display_id int, command text, params text nullable, status text, completed bool, output text nullable, source enum(`mimic`,`import`), created_at, completed_at nullable. -### §3.2 Tailwind token refresh (commit #3) +**Endpoints (all admin+redteam; SOC → 403):** +- `GET/PUT/DELETE /api/engagements//c2-config` — GET returns `has_token: bool`, never the token. +- `POST /api/engagements//c2-config/test` — connectivity check via adapter. +- `GET /api/engagements//c2/callbacks` — active callbacks. +- `POST /api/simulations//c2/execute` `{callback_display_id, commands: [str]}` — one Mythic task per command, rows in `c2_task` (source=mimic), auto-transition pending→in_progress. +- `GET /api/simulations//c2/tasks` — poll-on-read: refresh incomplete tasks from Mythic; on completion fetch output and apply decision 11 mapping (idempotent, applied once). +- `GET /api/engagements//c2/callbacks//history?page=` — paginated callback history for import. +- `POST /api/simulations//c2/import` `{task_display_ids: [int]}` — import selected tasks (source=import) + decision 11 mapping. -- [ ] `frontend/tailwind.config.ts`: - - `borderRadius`: keep `none: 0`, keep `pill: 9999px`. Drop or stop using `xs/sm/md/lg/xl` for surfaces — keep only if a badge variant needs `2px` - - ADD `fontFamily.mono`: `['JetBrains Mono Variable', 'JetBrains Mono', 'ui-monospace', 'monospace']` - - ADD semantic colors (locked §0): `success: { DEFAULT, soft }` (green) + `warn: { DEFAULT, soft }` (amber). Pull dark-mode variants from CSS vars too. Suggested anchors — `success #16a34a` (dark `#22c55e`), `warn #d97706` (dark `#f59e0b`); design-reviewer audits WCAG AA at both modes. - - Reduce `display-*` scale (locked §0): `display-xxl 72px → 40px`, `display-xl 56→32`, `display-lg 44→28`, `display-md 32→24`, `display-sm 24→20`, `display-xs 20→16` — terminal headers are modest - - Drop `tracking[0.7px]` and uppercase from `button-md` (still ALLCAPS via class but no letter-spacing) - - Drop shadow tokens or keep but ensure no component class applies them -- [ ] `frontend/src/styles/index.css`: - - Drop `font-size: 16.5px` root bump (back to `16px` standard) - - Set body `line-height: 1.4`, tighten headings to 1.1 - - Rewrite `.btn-primary/ink/outline/outline-ink`: `rounded-none`, NO `transition-colors`, keep `uppercase`, drop `tracking-[0.7px]`, keep `h-11` (touch target) - - Rewrite `.text-input`: `rounded-none`, focus border-primary sharp (no halo), no transition - - Rewrite `.card-product` and any `.card-*`: `rounded-none`, no shadow, 1px hairline border for separation - - Rewrite `.badge-pill-*`: keep `rounded-pill` ONLY here (status badges); strip uppercase if applied - - Rewrite `.modal-backdrop`: same dark backdrop, no rounded for the modal frame itself - - ADD `.mono` utility or rely on Tailwind's `font-mono` (preferred) for data cells +**Milestones:** M1 crypto service (Fernet) + migration + config CRUD + test endpoint → M2 callbacks + execute → M3 poll-on-read status/output + mapping → M4 history + import. +**Tests:** pytest with mocked adapter (~35-45 new), zero live HTTP. Crypto round-trip, RBAC 403 matrix, mapping idempotence, migration up/down. -### §3.3 JetBrains Mono bundle (commit #4) +## §3 — Frontend (frontend-builder) -- [ ] `cd frontend && npm i @fontsource-variable/jetbrains-mono` -- [ ] `frontend/src/styles/fonts.css`: add `@import '@fontsource-variable/jetbrains-mono'` -- [ ] No CDN. Confirms via `npm ls @fontsource-variable/jetbrains-mono`. +- **Engagement detail/form**: "C2 configuration" card — URL, token (password input, write-only placeholder when `has_token`), verify-TLS checkbox, [Test connection] with result feedback. Admin+redteam only. +- **SimulationFormPage RT card**: [Execute via C2] button (hidden when no config; disabled when done) → modal: callback picker table (display_id, host, user, active, last_checkin — font-mono data cells) → commands textarea prefilled from `rt.commands` → Launch. +- **C2 tasks panel** (under RT card): table of attached tasks — command (mono), status badge (semantic tokens), completed_at (mono). `refetchInterval` 2 500 ms while any incomplete; stops when all done. +- **Import history modal**: callback picker → paginated task list with checkboxes → [Import selected]. +- Terminal-SOC compliance: rounded-none, zero transitions, hairline borders, mono for data only. Status colors via `success`/`warn` semantic tokens. +- Vitest ~15-20 new tests. `data-testid` on every new interactive surface for e2e. -## §4 — Component sweep (commit #5) +## §4 — Docs & hygiene -Rule: `rounded-*` → `rounded-none` unless explicitly an avatar or status pill; remove `transition-*`; data text → `font-mono`. +- **SPEC.md C2 section = FIRST commit** of the sprint (sprint 5/6 lesson: never close a sprint with SPEC uncommitted). +- README: `MIMIC_ENCRYPTION_KEY`, `MIMIC_C2_ADAPTER` env vars + docker compose example. +- CHANGELOG sprint 8 entry at close. -- [ ] `Layout.tsx`: nav-bar/utility-strip already angular — confirm. Remove `transition-colors` on theme button and hover-underline transitions. Mono font for any data label exposed (e.g. user.role pill). -- [ ] `StatusBadge.tsx`: KEEP rounded → switch to `rounded-pill` (it's a status pill, locked exception). Audit semantic mapping (planned/active/closed → semantic tokens once added). -- [ ] `SimulationStatusBadge.tsx`: same — `rounded-pill`, semantic colors aligned with new tokens. -- [ ] `FormField.tsx`: angular inputs (already via `.text-input` recipe — confirm). -- [ ] `EmptyState.tsx`: angular wrapper. No rounded illustration container. -- [ ] `ErrorState.tsx`: angular. Bloom-deep border-left if signalling. -- [ ] `LoadingState.tsx`: drop any rounded spinner background. Spinner shape ok. -- [ ] `ConfirmDialog.tsx`: angular modal. Buttons via new `.btn-*` recipes. -- [ ] `Toast.tsx`: angular. Semantic color border-left strip. -- [ ] `ExportEngagementButton.tsx` (sprint 6): angular dropdown menu. Audit `rounded-*` in the menu/item classes. -- [ ] `MitreMatrixModal.tsx`: angular modal. Cells already grid — confirm no rounded. -- [ ] `MitreTechniquePicker.tsx`: angular dropdown. -- [ ] `MitreTechniquesField.tsx`: angular chips. -- [ ] `MitreTechniqueTag.tsx`: angular tag (NOT pill — terminal tag, not a status). Decide once and apply consistently across MITRE surfaces. -- [ ] `TemplatePickerModal.tsx`: angular modal. -- [ ] `SimulationList.tsx`: angular table. Data cells (commands, executed_at, MITRE techniques) → `font-mono`. -- [ ] `ProtectedRoute.tsx`: no visual surface, skip. +## §5 — Pipeline & sequencing -## §5 — Page sweep (commit #6) +1. spec-reviewer pre-pass on this plan (before any code). +2. backend-builder M1+M2 → API contract frozen → frontend-builder starts (parallel with backend M3+M4). +3. design-reviewer on new UI surfaces (screenshots come from the Playwright run — e2e bootstraps its own users, which sidesteps the sprint-7 credentials wall). +4. **code-reviewer must be respawned** (killed 2026-06-10 after idle-loop malfunction) before the review phase. +5. test-verifier: new C2 Playwright specs (fake adapter) + **full 223-spec re-run** — also clears the sprint 7 e2e debt (suite never re-ran after the design refresh). -For each page: header/body/footer review, replace rounded card containers with angular hairline-bordered containers, ensure data cells use mono. +## §6 — Risks -- [ ] `LoginPage.tsx`: angular form card. Drop ornament. -- [ ] `EngagementsListPage.tsx`: angular table container (currently `.card-product` with rounded-xl). Data cells (dates) → mono. -- [ ] `EngagementDetailPage.tsx`: angular header section. Engagement metadata (start/end dates, IDs, created_at) in mono. Simulations table covered via SimulationList. -- [ ] `EngagementFormPage.tsx`: angular form. Date inputs ok. -- [ ] `SimulationFormPage.tsx`: angular form. Commands textarea → mono. -- [ ] `TemplatesListPage.tsx`: angular list. -- [ ] `TemplateFormPage.tsx`: angular form. Commands field → mono. -- [ ] `UsersAdminPage.tsx`: angular table. Username column → mono (it's an ID). +- **R1 — scope**: full bidirectional in one sprint. Mitigation: M1→M4 are ordered; M4 (history import) is the cut line if we overrun. User accepted explicitly. +- **R2 — schema fidelity**: no live Mythic to validate against; adapter pinned to the official `MythicMeta/Mythic_Scripting` client source on **master @ 2026-06-10** (verified via raw.githubusercontent.com). First real connection may need a small patch. README records the exact source URL alongside the "may need a patch" note. Adapter must defensively handle `response_text` base64 decode failures (binary output): on `binascii.Error` keep raw bytes hex-encoded with a prefix ` ` so `execution_result` never silently corrupts. +- **R3 — secret at rest**: Fernet + env key; key rotation out of scope (documented limitation). +- **R4 — polling load**: poll-on-read touches only incomplete tasks of the open simulation — bounded. +- **R5 — e2e drift**: sprint 7 redesign never re-validated by Playwright; the full re-run in §5.5 surfaces any breakage — budget fix time. -## §6 — Test refresh (commit #7) +## §7 — Definition of Done -- [ ] `cd frontend && npm run test -- --run` — identify failing assertions on class names (`rounded-xl`, `card-product`, etc.). Update tests to use semantic queries (role, name, data-testid) where possible; if test asserts on visual class, update assertion to the new class. -- [ ] No new vitest tests added (visual sprint, behavior unchanged). -- [ ] Playwright e2e: should be `data-testid`-driven — run full suite to confirm no regression. If breakage, fix the testid not the test logic. - -## §7 — Reviews - -- [ ] **spec-reviewer** (pre-dispatch, §1): plan validated vs SPEC.md and §0 binding decisions -- [ ] **frontend-builder** (§2-§6): implements, runs typecheck/lint/test, delivers screenshots for design-reviewer (every page + key states, light+dark) -- [ ] **design-reviewer** (post-frontend): reviews screenshots + diff vs new DESIGN.md. Brutalist consistency, mono-discipline (only data), zero-rounded discipline. -- [ ] **code-reviewer** (post-design): reviews frontend diff for duplication, lost reuse, dead code. -- [ ] **test-verifier**: skipped this sprint (no new US, no behavior change). -- [ ] **backend-builder**: idle, no work this sprint. - -## §8 — Git workflow - -- [ ] Branch: `sprint/7-design` (already created from origin/main) -- [ ] Commits: conventional, one per logical group (§2 to §7) -- [ ] PR via `make open-pr` (Gitea pattern, per memory) -- [ ] PR body in `tasks/pr-body-sprint-7.md` -- [ ] CHANGELOG.md sprint 7 section - -## §9 — Risks & mitigations - -- **R1 — Tests break en masse**: many vitest specs may assert on class strings (e.g., `rounded-xl` on cards). Mitigation: update assertions to semantic queries; budget half a phase to test repair. -- **R2 — Dark mode contrast lost**: angular + new semantic colors may break WCAG AA contrast on dark slab. Mitigation: design-reviewer audits both modes; adjust the dark variant hex to meet WCAG AA. Rollback the success/warn family only if no accessible green/amber is achievable on the dark slab. -- **R3 — Mono overflow**: JetBrains Mono is wider than Inter at same px. Cell widths in tables may overflow. Mitigation: keep `table-layout: fixed` and `word-break: break-word` (pattern reused from PDF export CSS sprint 6). -- **R4 — DESIGN.md rewrite churn**: replacing 346 lines is a big diff. Mitigation: rewrite atomically in commit #2, keep token names consistent so downstream commits don't drift. -- **R5 — User taste mismatch**: "Bloomberg/SOC" may not match user's mental image. Mitigation: design-reviewer screenshots → user check-in BEFORE merge. - -## §10 — Definition of Done - -- [ ] All §0 decisions reflected in DESIGN.md + tokens + components + pages -- [ ] `npm run typecheck` clean -- [ ] `npm run lint` clean -- [ ] `npm run test -- --run` all green -- [ ] Backend untouched — `git diff origin/main -- backend/` empty -- [ ] Playwright e2e green (223 baseline preserved) -- [ ] Screenshots delivered (light + dark) for every page + key states -- [ ] DESIGN.md rewritten, no HP/Forma/wordmark/chevron references -- [ ] CHANGELOG.md sprint 7 section -- [ ] PR opened on Gitea -- [ ] User merges PR → sprint closed → team idle ready for sprint 8 - -## §11 — Lessons being applied from prior sprints - -- **SPEC/DESIGN commit-first**: DESIGN.md rewrite is commit #2 (after sprint hygiene). No design churn mid-sprint. -- **spec-reviewer 2-pass**: APPROVED before dispatch, not after. -- **Team idle policy**: 6 agents already mounted, no shutdown until PR merged. -- **frontend-builder MUST invoke `Skill frontend-design`** before UI work (the patch commits as #1, takes effect immediately for the same sprint). +- pytest green (256 baseline on main + new), vitest green (139 baseline + new), Playwright full suite green (223 baseline + new C2 specs). +- spec-reviewer (plan) + design-reviewer (UI) + code-reviewer (diff) all APPROVED. +- SPEC.md, README.md, CHANGELOG.md updated. No secret, key or IP hardcoded anywhere. +- PR #11 opened via `make open-pr`. -- 2.49.1 From 9a9c98beabc1234ccf564c8a64e74ac99c8bd88d Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 10 Jun 2026 19:20:52 +0200 Subject: [PATCH 02/16] feat(backend): c2 crypto + config CRUD + adapter scaffolding (sprint 8 M1) - Add Fernet crypto service (MIMIC_ENCRYPTION_KEY env, C2Disabled on absent key) - Add Alembic migration 0006: c2_config + c2_task tables with cascade FKs - Add C2Config and C2Task SQLAlchemy models - Add C2Adapter ABC with dataclasses (C2Health, C2Callback, C2TaskStatus, C2TaskPage) - Add FakeAdapter (deterministic in-memory, MIMIC_C2_ADAPTER=fake) - Add MythicAdapter scaffold: test_connection() live, M2+ raise NotImplementedError - Add decode_response_text() helper for base64/binary Mythic responses - Add GET/PUT/DELETE/POST-test /api/engagements//c2-config endpoints - RBAC: admin+redteam OK, SOC 403; 503 guard when encryption key absent - Token never returned in API responses; stored Fernet-encrypted only - 42 new tests (300 total, 258 baseline preserved green) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/__init__.py | 3 +- backend/app/api/__init__.py | 3 +- backend/app/api/c2.py | 156 ++++++++ backend/app/models/__init__.py | 5 + backend/app/models/c2_config.py | 34 ++ backend/app/models/c2_task.py | 47 +++ backend/app/services/c2/__init__.py | 20 ++ backend/app/services/c2/adapter.py | 97 +++++ backend/app/services/c2/factory.py | 19 + backend/app/services/c2/fake.py | 91 +++++ backend/app/services/c2/mythic.py | 79 +++++ backend/app/services/crypto.py | 40 +++ backend/migrations/versions/0006_c2_layer.py | 67 ++++ backend/requirements.txt | 3 + backend/tests/test_c2_adapter_fake.py | 30 ++ backend/tests/test_c2_config.py | 352 +++++++++++++++++++ backend/tests/test_crypto.py | 52 +++ backend/tests/test_migration_0006_c2.py | 204 +++++++++++ 18 files changed, 1300 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/c2.py create mode 100644 backend/app/models/c2_config.py create mode 100644 backend/app/models/c2_task.py create mode 100644 backend/app/services/c2/__init__.py create mode 100644 backend/app/services/c2/adapter.py create mode 100644 backend/app/services/c2/factory.py create mode 100644 backend/app/services/c2/fake.py create mode 100644 backend/app/services/c2/mythic.py create mode 100644 backend/app/services/crypto.py create mode 100644 backend/migrations/versions/0006_c2_layer.py create mode 100644 backend/tests/test_c2_adapter_fake.py create mode 100644 backend/tests/test_c2_config.py create mode 100644 backend/tests/test_crypto.py create mode 100644 backend/tests/test_migration_0006_c2.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 350a0e2..e2f2746 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -6,7 +6,7 @@ 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, 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 +38,7 @@ 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) from backend.app.services import mitre as mitre_svc mitre_svc.load_bundle() diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 780821a..083e147 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,8 +1,9 @@ """API blueprints.""" from backend.app.api.auth import auth_bp +from backend.app.api.c2 import 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", "users_bp", "engagements_bp", "simulations_bp", "templates_bp"] diff --git a/backend/app/api/c2.py b/backend/app/api/c2.py new file mode 100644 index 0000000..d4a2774 --- /dev/null +++ b/backend/app/api/c2.py @@ -0,0 +1,156 @@ +"""C2 config endpoints for engagements. + +All four 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. +""" +from __future__ import annotations + +from datetime import UTC, datetime + +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.services.c2.factory import get_adapter +from backend.app.services.crypto import C2Disabled, decrypt, encrypt + +c2_bp = Blueprint("c2", __name__, url_prefix="/api/engagements") + +_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("//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("//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 + + verify_tls = data.get("verify_tls", True) + if not isinstance(verify_tls, bool): + return jsonify({"error": "verify_tls must be a boolean"}), 400 + + cfg: C2Config | None = engagement.c2_config + + if cfg is None: + # New row — api_token is required on creation. + raw_token = data.get("api_token") or "" + if not raw_token: + return jsonify({"error": "api_token is required when creating a config"}), 400 + encrypted = encrypt(raw_token) + cfg = C2Config( + engagement_id=eid, + url=url, + api_token_encrypted=encrypted, + verify_tls=verify_tls, + ) + db.session.add(cfg) + else: + # Update — omitting api_token keeps the existing ciphertext. + cfg.url = url + cfg.verify_tls = verify_tls + cfg.updated_at = datetime.now(UTC) + raw_token = data.get("api_token") or "" + if raw_token: + cfg.api_token_encrypted = encrypt(raw_token) + + db.session.commit() + return jsonify({ + "has_token": True, + "url": cfg.url, + "verify_tls": cfg.verify_tls, + }), 200 + + +@c2_bp.delete("//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("//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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e432347..693b091 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/c2_config.py b/backend/app/models/c2_config.py new file mode 100644 index 0000000..4015a13 --- /dev/null +++ b/backend/app/models/c2_config.py @@ -0,0 +1,34 @@ +"""C2Config model — per-engagement Mythic connection settings.""" +from __future__ import annotations + +from datetime import UTC, datetime + +from backend.app.extensions import db + + +class C2Config(db.Model): # type: ignore[name-defined] + __tablename__ = "c2_config" + + id = db.Column(db.Integer, primary_key=True) + engagement_id = db.Column( + db.Integer, + db.ForeignKey("engagements.id", ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, + ) + url = db.Column(db.Text, nullable=False) + api_token_encrypted = db.Column(db.Text, nullable=False) + verify_tls = db.Column(db.Boolean, nullable=False, default=True) + created_at = db.Column( + db.DateTime, nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at = db.Column(db.DateTime, nullable=True) + + engagement = db.relationship( + "Engagement", + backref=db.backref("c2_config", uselist=False, cascade="all, delete-orphan"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/c2_task.py b/backend/app/models/c2_task.py new file mode 100644 index 0000000..d87de92 --- /dev/null +++ b/backend/app/models/c2_task.py @@ -0,0 +1,47 @@ +"""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) + + simulation = db.relationship( + "Simulation", + backref=db.backref("c2_tasks", cascade="all, delete-orphan", lazy="dynamic"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/services/c2/__init__.py b/backend/app/services/c2/__init__.py new file mode 100644 index 0000000..63c943a --- /dev/null +++ b/backend/app/services/c2/__init__.py @@ -0,0 +1,20 @@ +"""C2 adapter package. Import the factory from here.""" +from backend.app.services.c2.adapter import ( + C2Adapter, + C2Callback, + C2Health, + C2TaskPage, + C2TaskStatus, + decode_response_text, +) +from backend.app.services.c2.factory import get_adapter + +__all__ = [ + "C2Adapter", + "C2Callback", + "C2Health", + "C2TaskPage", + "C2TaskStatus", + "decode_response_text", + "get_adapter", +] diff --git a/backend/app/services/c2/adapter.py b/backend/app/services/c2/adapter.py new file mode 100644 index 0000000..3a576aa --- /dev/null +++ b/backend/app/services/c2/adapter.py @@ -0,0 +1,97 @@ +"""Abstract C2 adapter interface and shared dataclasses.""" +from __future__ import annotations + +import base64 +import binascii +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@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 + + +@dataclass +class C2TaskPage: + items: list[dict] # raw task dicts from Mythic + 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 " " + hex string + so execution_result never silently corrupts. + """ + try: + return base64.b64decode(raw).decode("utf-8") + except binascii.Error: + return " " + raw.encode().hex() + except UnicodeDecodeError: + raw_bytes = base64.b64decode(raw) + return " " + 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.""" + ... diff --git a/backend/app/services/c2/factory.py b/backend/app/services/c2/factory.py new file mode 100644 index 0000000..0c46370 --- /dev/null +++ b/backend/app/services/c2/factory.py @@ -0,0 +1,19 @@ +"""Factory that resolves the C2Adapter implementation from MIMIC_C2_ADAPTER env.""" +from __future__ import annotations + +import os + +from backend.app.services.c2.adapter import C2Adapter + + +def get_adapter(url: str, api_token: str, verify_tls: bool = True) -> C2Adapter: + """Return the correct C2Adapter based on MIMIC_C2_ADAPTER (default: mythic).""" + adapter_name = os.environ.get("MIMIC_C2_ADAPTER", "mythic").lower() + + if adapter_name == "fake": + from backend.app.services.c2.fake import FakeAdapter + return FakeAdapter() + + # Default: real Mythic adapter + from backend.app.services.c2.mythic import MythicAdapter + return MythicAdapter(url=url, api_token=api_token, verify_tls=verify_tls) diff --git a/backend/app/services/c2/fake.py b/backend/app/services/c2/fake.py new file mode 100644 index 0000000..08da43d --- /dev/null +++ b/backend/app/services/c2/fake.py @@ -0,0 +1,91 @@ +"""Deterministic in-memory C2 adapter — used when MIMIC_C2_ADAPTER=fake. + +Intended for integration tests and local development without a live Mythic instance. +""" +from __future__ import annotations + +from backend.app.services.c2.adapter import ( + C2Adapter, + C2Callback, + C2Health, + C2TaskPage, + C2TaskStatus, +) + +_FAKE_CALLBACKS = [ + C2Callback( + display_id=1, + active=True, + host="WORKSTATION-01", + user="jdoe", + domain="LAB", + last_checkin="2026-06-10T00:00:00Z", + ), +] + +_FAKE_TASKS: dict[int, dict] = {} +_next_task_id = 100 + + +class FakeAdapter(C2Adapter): + """In-memory adapter with deterministic behaviour.""" + + 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: + global _next_task_id + tid = _next_task_id + _next_task_id += 1 + _FAKE_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: + task = _FAKE_TASKS.get(task_display_id) + if task is None: + return C2TaskStatus(display_id=task_display_id, status="unknown", completed=False) + return C2TaskStatus( + display_id=task_display_id, + status=task["status"], + completed=task["completed"], + ) + + def get_task_output(self, task_display_id: int) -> str: + task = _FAKE_TASKS.get(task_display_id) + if task is None: + return "" + return task.get("output") or "" + + def list_callback_tasks( + self, + callback_display_id: int, + page: int = 1, + page_size: int = 25, + ) -> C2TaskPage: + items = [ + t for t in _FAKE_TASKS.values() + if t["callback_display_id"] == callback_display_id + ] + start = (page - 1) * page_size + return C2TaskPage( + items=items[start : start + page_size], + total=len(items), + page=page, + page_size=page_size, + ) diff --git a/backend/app/services/c2/mythic.py b/backend/app/services/c2/mythic.py new file mode 100644 index 0000000..3c47a83 --- /dev/null +++ b/backend/app/services/c2/mythic.py @@ -0,0 +1,79 @@ +# Contract pinned from MythicMeta/Mythic_Scripting master @ 2026-06-10 (raw.githubusercontent.com/MythicMeta/Mythic_Scripting/master/mythic/mythic.py) +"""Mythic 3.x C2 adapter. + +M1 implements test_connection() only. +All other methods raise NotImplementedError("M2") — they land in milestone M2/M3. + +Transport: POST https://:7443/graphql +Header: apitoken: +Backend: Hasura-proxied Postgres behind nginx. +""" +from __future__ import annotations + +import requests + +from backend.app.services.c2.adapter import ( + C2Adapter, + C2Callback, + C2Health, + C2TaskPage, + C2TaskStatus, +) + +_HEALTH_QUERY = '{ __typename }' + + +class MythicAdapter(C2Adapter): + """Real Mythic 3.x adapter using GraphQL over HTTP.""" + + def __init__(self, url: str, api_token: str, verify_tls: bool = True) -> None: + self._url = url.rstrip("/") + "/graphql" + self._token = api_token + self._verify = verify_tls + + def _headers(self) -> dict[str, str]: + return { + "Content-Type": "application/json", + "apitoken": self._token, + } + + def 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, + ) + 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]: + raise NotImplementedError("M2") + + def create_task( + self, + callback_display_id: int, + command: str, + params: str | None = None, + ) -> int: + raise NotImplementedError("M2") + + def get_task(self, task_display_id: int) -> C2TaskStatus: + raise NotImplementedError("M2") + + def get_task_output(self, task_display_id: int) -> str: + raise NotImplementedError("M3") + + def list_callback_tasks( + self, + callback_display_id: int, + page: int = 1, + page_size: int = 25, + ) -> C2TaskPage: + raise NotImplementedError("M4") diff --git a/backend/app/services/crypto.py b/backend/app/services/crypto.py new file mode 100644 index 0000000..4c13854 --- /dev/null +++ b/backend/app/services/crypto.py @@ -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"] diff --git a/backend/migrations/versions/0006_c2_layer.py b/backend/migrations/versions/0006_c2_layer.py new file mode 100644 index 0000000..619a394 --- /dev/null +++ b/backend/migrations/versions/0006_c2_layer.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 878005e..9f06e02 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,9 @@ 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 diff --git a/backend/tests/test_c2_adapter_fake.py b/backend/tests/test_c2_adapter_fake.py new file mode 100644 index 0000000..ca464cf --- /dev/null +++ b/backend/tests/test_c2_adapter_fake.py @@ -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") diff --git a/backend/tests/test_c2_config.py b/backend/tests/test_c2_config.py new file mode 100644 index 0000000..f5f76ca --- /dev/null +++ b/backend/tests/test_c2_config.py @@ -0,0 +1,352 @@ +"""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_creates_config(client: FlaskClient, admin_token: str) -> None: + eng = _make_engagement(client, admin_token) + resp = _put_config(client, admin_token, eng["id"]) + assert resp.status_code == 200 + body = resp.get_json() + assert body["has_token"] is True + assert body["url"] == "https://c2.internal:7443" + assert body["verify_tls"] is True + + +def test_put_create_requires_api_token(client: FlaskClient, admin_token: str) -> None: + eng = _make_engagement(client, admin_token) + resp = _put_config(client, admin_token, eng["id"], api_token=None) + assert resp.status_code == 400 + + +def test_put_create_requires_url(client: FlaskClient, admin_token: str) -> None: + eng = _make_engagement(client, admin_token) + resp = client.put( + f"/api/engagements/{eng['id']}/c2-config", + headers=_h(admin_token), + json={"api_token": "tok", "verify_tls": True}, + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# PUT — update, omitting api_token preserves old ciphertext +# --------------------------------------------------------------------------- + + +def test_put_update_omits_token_keeps_old( + app: Flask, client: FlaskClient, admin_token: str +) -> None: + eng = _make_engagement(client, admin_token) + _put_config(client, admin_token, eng["id"], api_token="original-token") + + # Read ciphertext from DB before update. + with app.app_context(): + cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first() + assert cfg is not None + old_cipher = cfg.api_token_encrypted + + # Update URL, omit api_token. + resp = _put_config( + client, admin_token, eng["id"], + url="https://new.internal:7443", api_token=None, + ) + assert resp.status_code == 200 + + with app.app_context(): + cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first() + assert cfg is not None + assert cfg.api_token_encrypted == old_cipher + assert cfg.url == "https://new.internal:7443" + + +def test_put_update_with_token_replaces_ciphertext( + app: Flask, client: FlaskClient, admin_token: str +) -> None: + eng = _make_engagement(client, admin_token) + _put_config(client, admin_token, eng["id"], api_token="original-token") + + with app.app_context(): + cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first() + assert cfg is not None + old_cipher = cfg.api_token_encrypted + + _put_config(client, admin_token, eng["id"], api_token="new-token") + + with app.app_context(): + cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first() + assert cfg is not None + assert cfg.api_token_encrypted != old_cipher + + +# --------------------------------------------------------------------------- +# GET — 200, has_token=True, never cleartext +# --------------------------------------------------------------------------- + + +def test_get_config_returns_has_token_not_cleartext( + client: FlaskClient, admin_token: str +) -> None: + eng = _make_engagement(client, admin_token) + _put_config(client, admin_token, eng["id"], api_token="s3cr3t") + + resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)) + assert resp.status_code == 200 + body = resp.get_json() + assert body["has_token"] is True + assert "api_token" not in body + assert "api_token_encrypted" not in body + assert "s3cr3t" not in str(body) + + +def test_get_config_verify_tls_default_true( + client: FlaskClient, admin_token: str +) -> None: + eng = _make_engagement(client, admin_token) + _put_config(client, admin_token, eng["id"]) + resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)) + assert resp.get_json()["verify_tls"] is True + + +# --------------------------------------------------------------------------- +# DELETE — 204 +# --------------------------------------------------------------------------- + + +def test_delete_config_204(client: FlaskClient, admin_token: str) -> None: + eng = _make_engagement(client, admin_token) + _put_config(client, admin_token, eng["id"]) + + resp = client.delete( + f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token) + ) + assert resp.status_code == 204 + + # Subsequent GET returns 404. + resp2 = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)) + assert resp2.status_code == 404 + + +def test_delete_config_not_found(client: FlaskClient, admin_token: str) -> None: + eng = _make_engagement(client, admin_token) + resp = client.delete( + f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token) + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# CASCADE — delete engagement removes config +# --------------------------------------------------------------------------- + + +def test_cascade_delete_engagement_removes_config( + app: Flask, client: FlaskClient, admin_token: str +) -> None: + eng = _make_engagement(client, admin_token) + _put_config(client, admin_token, eng["id"]) + + with app.app_context(): + assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 1 + + client.delete(f"/api/engagements/{eng['id']}", headers=_h(admin_token)) + + with app.app_context(): + assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 0 + + +# --------------------------------------------------------------------------- +# RBAC matrix +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("method,path_suffix", [ + ("GET", "/c2-config"), + ("PUT", "/c2-config"), + ("DELETE", "/c2-config"), + ("POST", "/c2-config/test"), +]) +def test_soc_gets_403( + client: FlaskClient, admin_token: str, soc_token: str, + method: str, path_suffix: str, +) -> None: + eng = _make_engagement(client, admin_token) + url = f"/api/engagements/{eng['id']}{path_suffix}" + resp = getattr(client, method.lower())(url, headers=_h(soc_token), json={}) + assert resp.status_code == 403 + + +@pytest.mark.parametrize("method,path_suffix", [ + ("GET", "/c2-config"), + ("DELETE", "/c2-config"), + ("POST", "/c2-config/test"), +]) +def test_redteam_gets_allowed( + client: FlaskClient, admin_token: str, redteam_token: str, + method: str, path_suffix: str, +) -> None: + eng = _make_engagement(client, admin_token) + # Ensure config exists for GET/DELETE/test. + _put_config(client, admin_token, eng["id"]) + url = f"/api/engagements/{eng['id']}{path_suffix}" + resp = getattr(client, method.lower())(url, headers=_h(redteam_token), json={}) + # Not 403 and not 401. + assert resp.status_code not in (401, 403) + + +def test_redteam_can_put_config( + client: FlaskClient, admin_token: str, redteam_token: str, +) -> None: + eng = _make_engagement(client, admin_token) + resp = _put_config(client, redteam_token, eng["id"]) + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# 503 guard when MIMIC_ENCRYPTION_KEY is unset +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("method,path_suffix", [ + ("GET", "/c2-config"), + ("PUT", "/c2-config"), + ("DELETE", "/c2-config"), + ("POST", "/c2-config/test"), +]) +def test_503_when_key_unset( + monkeypatch, + client: FlaskClient, + admin_token: str, + method: str, + path_suffix: str, +) -> None: + monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False) + eng = _make_engagement(client, admin_token) + url = f"/api/engagements/{eng['id']}{path_suffix}" + resp = getattr(client, method.lower())(url, headers=_h(admin_token), json={ + "url": "https://c2", "api_token": "tok", "verify_tls": True, + }) + assert resp.status_code == 503 + assert "MIMIC_ENCRYPTION_KEY" in resp.get_json().get("error", "") + + +# --------------------------------------------------------------------------- +# POST /test — connectivity check via fake adapter +# --------------------------------------------------------------------------- + + +def test_post_test_returns_ok_true(client: FlaskClient, admin_token: str) -> None: + eng = _make_engagement(client, admin_token) + _put_config(client, admin_token, eng["id"]) + + resp = client.post( + f"/api/engagements/{eng['id']}/c2-config/test", + headers=_h(admin_token), + json={}, + ) + assert resp.status_code == 200 + body = resp.get_json() + assert body["ok"] is True + assert body["error"] is None + + +def test_post_test_no_config_returns_404(client: FlaskClient, admin_token: str) -> None: + eng = _make_engagement(client, admin_token) + resp = client.post( + f"/api/engagements/{eng['id']}/c2-config/test", + headers=_h(admin_token), + json={}, + ) + assert resp.status_code == 404 diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py new file mode 100644 index 0000000..71da8d3 --- /dev/null +++ b/backend/tests/test_crypto.py @@ -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") diff --git a/backend/tests/test_migration_0006_c2.py b/backend/tests/test_migration_0006_c2.py new file mode 100644 index 0000000..e06277e --- /dev/null +++ b/backend/tests/test_migration_0006_c2.py @@ -0,0 +1,204 @@ +"""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) + # Patch op module for the migration + import alembic.op as alembic_op + original_proxy = alembic_op._proxy # type: ignore[attr-defined] + alembic_op._proxy = ops # type: ignore[attr-defined] + try: + migration_mod.upgrade() + finally: + alembic_op._proxy = original_proxy # type: ignore[attr-defined] + + +def _run_downgrade(engine, migration_mod): + with engine.begin() as conn: + ctx = MigrationContext.configure(conn) + ops = Operations(ctx) + import alembic.op as alembic_op + original_proxy = alembic_op._proxy # type: ignore[attr-defined] + alembic_op._proxy = ops # type: ignore[attr-defined] + try: + migration_mod.downgrade() + finally: + alembic_op._proxy = original_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() -- 2.49.1 From 53755a31d622ed4ba3cbdc951a012291201cc10b Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 10 Jun 2026 19:34:18 +0200 Subject: [PATCH 03/16] feat(backend): c2 callbacks + execute endpoints (sprint 8 M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add C2Error exception to adapter ABC - Add promote_to_in_progress() helper to simulation_workflow (pending→in_progress) - Flesh out MythicAdapter: list_callbacks() (GraphQL query) + create_task() (mutation) - Expand FakeAdapter to 3 deterministic callbacks; switch task store to per-instance - Add GET /api/engagements//c2/callbacks — lists active callbacks via adapter - Add POST /api/simulations//c2/execute — issues tasks, stores C2Task rows, auto-transitions pending→in_progress, blocks on done (409) - Both endpoints: SOC=403, 503 no-key, 502 adapter error, sanitized error messages - Add requests-mock==1.12.1 to requirements.txt - 42 new tests (342 total, 300 M1 baseline preserved green) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/__init__.py | 11 +- backend/app/api/__init__.py | 12 +- backend/app/api/c2.py | 147 ++++++++- backend/app/services/c2/__init__.py | 2 + backend/app/services/c2/adapter.py | 4 + backend/app/services/c2/fake.py | 43 ++- backend/app/services/c2/mythic.py | 93 +++++- backend/app/services/simulation_workflow.py | 13 + backend/requirements.txt | 1 + backend/tests/test_c2_adapter_fake_m2.py | 62 ++++ backend/tests/test_c2_adapter_mythic.py | 137 +++++++++ backend/tests/test_c2_callbacks.py | 142 +++++++++ backend/tests/test_c2_config.py | 15 + backend/tests/test_c2_execute.py | 324 ++++++++++++++++++++ 14 files changed, 983 insertions(+), 23 deletions(-) create mode 100644 backend/tests/test_c2_adapter_fake_m2.py create mode 100644 backend/tests/test_c2_adapter_mythic.py create mode 100644 backend/tests/test_c2_callbacks.py create mode 100644 backend/tests/test_c2_execute.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index e2f2746..3333543 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -6,7 +6,15 @@ from pathlib import Path from flask import Flask, jsonify, send_from_directory -from backend.app.api import auth_bp, c2_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 @@ -39,6 +47,7 @@ def create_app(config_object: object | None = None) -> Flask: 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() diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 083e147..7faef15 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,9 +1,17 @@ """API blueprints.""" from backend.app.api.auth import auth_bp -from backend.app.api.c2 import c2_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", "c2_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", +] diff --git a/backend/app/api/c2.py b/backend/app/api/c2.py index d4a2774..15c5060 100644 --- a/backend/app/api/c2.py +++ b/backend/app/api/c2.py @@ -1,13 +1,15 @@ -"""C2 config endpoints for engagements. +"""C2 endpoints — config CRUD and execution. -All four endpoints: +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 @@ -15,10 +17,15 @@ 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.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"} @@ -70,6 +77,11 @@ def upsert_c2_config(eid: int): url = (data.get("url") or "").strip() if not url: return jsonify({"error": "url is required"}), 400 + parsed = urlparse(url) + if parsed.scheme != "https": + return jsonify({"error": "url must use https"}), 400 + if not parsed.hostname: + return jsonify({"error": "url must contain a hostname"}), 400 verify_tls = data.get("verify_tls", True) if not isinstance(verify_tls, bool): @@ -154,3 +166,134 @@ def test_c2_config(eid: int): ) 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("//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("//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 diff --git a/backend/app/services/c2/__init__.py b/backend/app/services/c2/__init__.py index 63c943a..768b149 100644 --- a/backend/app/services/c2/__init__.py +++ b/backend/app/services/c2/__init__.py @@ -2,6 +2,7 @@ from backend.app.services.c2.adapter import ( C2Adapter, C2Callback, + C2Error, C2Health, C2TaskPage, C2TaskStatus, @@ -12,6 +13,7 @@ from backend.app.services.c2.factory import get_adapter __all__ = [ "C2Adapter", "C2Callback", + "C2Error", "C2Health", "C2TaskPage", "C2TaskStatus", diff --git a/backend/app/services/c2/adapter.py b/backend/app/services/c2/adapter.py index 3a576aa..5ee5460 100644 --- a/backend/app/services/c2/adapter.py +++ b/backend/app/services/c2/adapter.py @@ -7,6 +7,10 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +class C2Error(Exception): + """Raised by adapters when the C2 returns an application-level error.""" + + @dataclass class C2Health: ok: bool diff --git a/backend/app/services/c2/fake.py b/backend/app/services/c2/fake.py index 08da43d..1da4a71 100644 --- a/backend/app/services/c2/fake.py +++ b/backend/app/services/c2/fake.py @@ -1,6 +1,7 @@ """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 @@ -12,6 +13,7 @@ from backend.app.services.c2.adapter import ( C2TaskStatus, ) +# Three fixed callbacks the test suite can pin against. _FAKE_CALLBACKS = [ C2Callback( display_id=1, @@ -21,14 +23,34 @@ _FAKE_CALLBACKS = [ 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", + ), ] -_FAKE_TASKS: dict[int, dict] = {} -_next_task_id = 100 - class FakeAdapter(C2Adapter): - """In-memory adapter with deterministic behaviour.""" + """In-memory adapter with deterministic behaviour. + + Each instance starts with an empty task store and display_ids from 1000. + """ + + def __init__(self) -> None: + self._tasks: dict[int, dict] = {} + self._next_task_id = 1000 def test_connection(self) -> C2Health: return C2Health(ok=True) @@ -42,10 +64,9 @@ class FakeAdapter(C2Adapter): command: str, params: str | None = None, ) -> int: - global _next_task_id - tid = _next_task_id - _next_task_id += 1 - _FAKE_TASKS[tid] = { + tid = self._next_task_id + self._next_task_id += 1 + self._tasks[tid] = { "display_id": tid, "callback_display_id": callback_display_id, "command": command, @@ -57,7 +78,7 @@ class FakeAdapter(C2Adapter): return tid def get_task(self, task_display_id: int) -> C2TaskStatus: - task = _FAKE_TASKS.get(task_display_id) + task = self._tasks.get(task_display_id) if task is None: return C2TaskStatus(display_id=task_display_id, status="unknown", completed=False) return C2TaskStatus( @@ -67,7 +88,7 @@ class FakeAdapter(C2Adapter): ) def get_task_output(self, task_display_id: int) -> str: - task = _FAKE_TASKS.get(task_display_id) + task = self._tasks.get(task_display_id) if task is None: return "" return task.get("output") or "" @@ -79,7 +100,7 @@ class FakeAdapter(C2Adapter): page_size: int = 25, ) -> C2TaskPage: items = [ - t for t in _FAKE_TASKS.values() + t for t in self._tasks.values() if t["callback_display_id"] == callback_display_id ] start = (page - 1) * page_size diff --git a/backend/app/services/c2/mythic.py b/backend/app/services/c2/mythic.py index 3c47a83..eb62c96 100644 --- a/backend/app/services/c2/mythic.py +++ b/backend/app/services/c2/mythic.py @@ -1,12 +1,14 @@ # Contract pinned from MythicMeta/Mythic_Scripting master @ 2026-06-10 (raw.githubusercontent.com/MythicMeta/Mythic_Scripting/master/mythic/mythic.py) """Mythic 3.x C2 adapter. -M1 implements test_connection() only. -All other methods raise NotImplementedError("M2") — they land in milestone M2/M3. - Transport: POST https://:7443/graphql Header: apitoken: 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 @@ -15,12 +17,42 @@ import requests from backend.app.services.c2.adapter import ( C2Adapter, C2Callback, + C2Error, C2Health, C2TaskPage, C2TaskStatus, ) -_HEALTH_QUERY = '{ __typename }' +_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 + } +} +""" class MythicAdapter(C2Adapter): @@ -37,6 +69,18 @@ class MythicAdapter(C2Adapter): "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: @@ -46,6 +90,7 @@ class MythicAdapter(C2Adapter): headers=self._headers(), verify=self._verify, timeout=10, + allow_redirects=False, ) if resp.status_code == 200: return C2Health(ok=True) @@ -54,7 +99,24 @@ class MythicAdapter(C2Adapter): return C2Health(ok=False, error=str(exc)) def list_callbacks(self) -> list[C2Callback]: - raise NotImplementedError("M2") + """Return active callbacks from Mythic (filtered server-side: active=true).""" + try: + data = self._post({"query": _CALLBACKS_QUERY}) + except requests.RequestException as exc: + raise C2Error(str(exc)) 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, @@ -62,10 +124,27 @@ class MythicAdapter(C2Adapter): command: str, params: str | None = None, ) -> int: - raise NotImplementedError("M2") + """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(str(exc)) 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: - raise NotImplementedError("M2") + raise NotImplementedError("M3") def get_task_output(self, task_display_id: int) -> str: raise NotImplementedError("M3") diff --git a/backend/app/services/simulation_workflow.py b/backend/app/services/simulation_workflow.py index cb0b1c5..daad7fe 100644 --- a/backend/app/services/simulation_workflow.py +++ b/backend/app/services/simulation_workflow.py @@ -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: diff --git a/backend/requirements.txt b/backend/requirements.txt index 9f06e02..f22875d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,4 @@ pytest==8.3.3 ruff==0.6.9 mypy==1.11.2 types-requests==2.32.0.20240914 +requests-mock==1.12.1 diff --git a/backend/tests/test_c2_adapter_fake_m2.py b/backend/tests/test_c2_adapter_fake_m2.py new file mode 100644 index 0000000..3da42a4 --- /dev/null +++ b/backend/tests/test_c2_adapter_fake_m2.py @@ -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 diff --git a/backend/tests/test_c2_adapter_mythic.py b/backend/tests/test_c2_adapter_mythic.py new file mode 100644 index 0000000..3deb698 --- /dev/null +++ b/backend/tests/test_c2_adapter_mythic.py @@ -0,0 +1,137 @@ +"""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 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 diff --git a/backend/tests/test_c2_callbacks.py b/backend/tests/test_c2_callbacks.py new file mode 100644 index 0000000..5f35b54 --- /dev/null +++ b/backend/tests/test_c2_callbacks.py @@ -0,0 +1,142 @@ +"""Tests for GET /api/engagements//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", "") diff --git a/backend/tests/test_c2_config.py b/backend/tests/test_c2_config.py index f5f76ca..fd2c617 100644 --- a/backend/tests/test_c2_config.py +++ b/backend/tests/test_c2_config.py @@ -95,6 +95,21 @@ def test_get_config_engagement_not_found(client: FlaskClient, admin_token: str) # --------------------------------------------------------------------------- +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"]) diff --git a/backend/tests/test_c2_execute.py b/backend/tests/test_c2_execute.py new file mode 100644 index 0000000..8b632cc --- /dev/null +++ b/backend/tests/test_c2_execute.py @@ -0,0 +1,324 @@ +"""Tests for POST /api/simulations//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", "") -- 2.49.1 From 5ff6ae89403275bdd684eae7d762b5bb5bf5e85f Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 10 Jun 2026 19:50:11 +0200 Subject: [PATCH 04/16] feat(frontend): c2 config card + execute modal (sprint 8 phase 1) - frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config, deleteC2Config, testC2Config, listCallbacks, executeC2) following the frozen M1+M2 backend contracts - frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult, C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse - frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config, useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2 - frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config card (url + write-only token + verify-tls + save/delete/test-connection), 503 disabled state, ConfirmDialog on delete - frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table (mono data cells), commands textarea pre-filled from rt.commands, Launch disabled until row selected + non-empty commands - frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit mode only, admin+redteam only (canEditEngagements gate) - frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT card, visible only when !isDone && canEditRT && hasC2Config; opens modal - Tests: 33 new tests across api/c2, components/C2ConfigCard, components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage (172 total, 139 baseline + 33 new, all passing) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/c2.ts | 60 +++++ frontend/src/api/types.ts | 49 ++++ frontend/src/components/C2ConfigCard.tsx | 240 ++++++++++++++++++ frontend/src/components/ExecuteViaC2Modal.tsx | 186 ++++++++++++++ frontend/src/hooks/useC2.ts | 81 ++++++ frontend/src/pages/EngagementFormPage.tsx | 7 + frontend/src/pages/SimulationFormPage.tsx | 33 ++- frontend/tests/EngagementFormPage.test.tsx | 108 ++++++++ frontend/tests/SimulationFormPage.test.tsx | 59 +++++ frontend/tests/api/c2.test.ts | 136 ++++++++++ .../tests/components/C2ConfigCard.test.tsx | 135 ++++++++++ .../components/ExecuteViaC2Modal.test.tsx | 160 ++++++++++++ 12 files changed, 1253 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/c2.ts create mode 100644 frontend/src/components/C2ConfigCard.tsx create mode 100644 frontend/src/components/ExecuteViaC2Modal.tsx create mode 100644 frontend/src/hooks/useC2.ts create mode 100644 frontend/tests/EngagementFormPage.test.tsx create mode 100644 frontend/tests/api/c2.test.ts create mode 100644 frontend/tests/components/C2ConfigCard.test.tsx create mode 100644 frontend/tests/components/ExecuteViaC2Modal.test.tsx diff --git a/frontend/src/api/c2.ts b/frontend/src/api/c2.ts new file mode 100644 index 0000000..d4fe677 --- /dev/null +++ b/frontend/src/api/c2.ts @@ -0,0 +1,60 @@ +import { apiClient } from './client'; +import type { + C2Config, + C2ConfigInput, + C2TestResult, + C2CallbacksResponse, + C2ExecuteInput, + C2ExecuteResponse, +} from './types'; + +export async function getC2Config(engagementId: number): Promise { + try { + const { data } = await apiClient.get(`/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 { + const { data } = await apiClient.put( + `/engagements/${engagementId}/c2-config`, + input, + ); + return data; +} + +export async function deleteC2Config(engagementId: number): Promise { + await apiClient.delete(`/engagements/${engagementId}/c2-config`); +} + +export async function testC2Config(engagementId: number): Promise { + const { data } = await apiClient.post( + `/engagements/${engagementId}/c2-config/test`, + ); + return data; +} + +export async function listCallbacks(engagementId: number): Promise { + const { data } = await apiClient.get( + `/engagements/${engagementId}/c2/callbacks`, + ); + return data; +} + +export async function executeC2( + simulationId: number, + input: C2ExecuteInput, +): Promise { + const { data } = await apiClient.post( + `/simulations/${simulationId}/c2/execute`, + input, + ); + return data; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3c977eb..b51f45b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -154,3 +154,52 @@ 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[]; +} + +export interface C2Task { + 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: C2Task[]; +} diff --git a/frontend/src/components/C2ConfigCard.tsx b/frontend/src/components/C2ConfigCard.tsx new file mode 100644 index 0000000..1363310 --- /dev/null +++ b/frontend/src/components/C2ConfigCard.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { extractApiError } from '@/api/client'; +import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2'; +import { ConfirmDialog } from './ConfirmDialog'; +import { FormField, TextInput } from './FormField'; +import { useToast } from '@/hooks/useToast'; + +interface C2ConfigCardProps { + engagementId: number; +} + +export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { + const { push } = useToast(); + + const configQuery = useC2Config(engagementId); + const updateMutation = useUpdateC2Config(engagementId); + const deleteMutation = useDeleteC2Config(engagementId); + const testMutation = useTestC2Config(engagementId); + + const config = configQuery.data; + const is503 = configQuery.error + ? (configQuery.error as { response?: { status?: number } })?.response?.status === 503 + : false; + + const [url, setUrl] = useState(''); + const [token, setToken] = useState(''); + const [verifyTls, setVerifyTls] = useState(true); + const [replaceToken, setReplaceToken] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + // Sync URL and verifyTls from loaded config (but not token — write-only at API level) + useEffect(() => { + if (config) { + setUrl(config.url); + setVerifyTls(config.verify_tls); + } + }, [config]); + + const disabled = is503 || configQuery.isLoading; + + const onSave = async (e: FormEvent) => { + e.preventDefault(); + if (is503) return; + setTestResult(null); + + const input: { url: string; verify_tls: boolean; api_token?: string } = { + url: url.trim(), + verify_tls: verifyTls, + }; + // Send token only if: no existing config, OR user explicitly chose to replace + if (!config?.has_token || replaceToken) { + if (token.trim()) input.api_token = token.trim(); + } + + try { + await updateMutation.mutateAsync(input); + push('C2 configuration saved', 'success'); + setToken(''); + setReplaceToken(false); + } catch (err) { + push(extractApiError(err, 'Could not save C2 configuration'), 'error'); + } + }; + + const onDelete = async () => { + setShowDeleteConfirm(false); + setTestResult(null); + try { + await deleteMutation.mutateAsync(); + push('C2 configuration removed', 'success'); + setUrl(''); + setToken(''); + setVerifyTls(true); + setReplaceToken(false); + } catch (err) { + push(extractApiError(err, 'Could not remove C2 configuration'), 'error'); + } + }; + + const onTest = async () => { + setTestResult(null); + try { + const result = await testMutation.mutateAsync(); + setTestResult({ + ok: result.ok, + message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'), + }); + } catch (err) { + setTestResult({ ok: false, message: extractApiError(err, 'Test failed') }); + } + }; + + const submitting = updateMutation.isPending || deleteMutation.isPending; + + return ( +
+

C2 configuration

+ + {is503 && ( +
+ C2 features are disabled (server has no encryption key configured). +
+ )} + + {configQuery.isLoading ? ( +

Loading…

+ ) : ( +
+ + setUrl(e.target.value)} + disabled={disabled} + /> + + + + {config?.has_token && !replaceToken ? ( +
+ + +
+ ) : ( + setToken(e.target.value)} + disabled={disabled} + /> + )} +
+ +
+ setVerifyTls(e.target.checked)} + disabled={disabled} + className="h-4 w-4 accent-primary" + /> + +
+ +
+ + + + + {testResult !== null && ( + + {testResult.message} + + )} + + {config?.has_token && ( + + )} +
+
+ )} + + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/ExecuteViaC2Modal.tsx b/frontend/src/components/ExecuteViaC2Modal.tsx new file mode 100644 index 0000000..2d621a0 --- /dev/null +++ b/frontend/src/components/ExecuteViaC2Modal.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { extractApiError } from '@/api/client'; +import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2'; +import type { C2Callback } from '@/api/types'; +import { useToast } from '@/hooks/useToast'; + +interface ExecuteViaC2ModalProps { + simulationId: number; + engagementId: number; + initialCommands: string; + onClose: () => void; +} + +function formatCheckin(ts: string): string { + // Show ISO timestamp as-is — it's a data cell (font-mono) + return ts; +} + +export function ExecuteViaC2Modal({ + simulationId, + engagementId, + initialCommands, + onClose, +}: ExecuteViaC2ModalProps): JSX.Element { + const { push } = useToast(); + + const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); + const executeMutation = useExecuteC2(simulationId, engagementId); + + const [selectedId, setSelectedId] = useState(null); + const [commands, setCommands] = useState(initialCommands); + const [submitError, setSubmitError] = useState(null); + + const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? []; + + const commandLines = commands + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + + const canLaunch = selectedId !== null && commandLines.length > 0; + + const onLaunch = async () => { + if (!canLaunch) return; + setSubmitError(null); + try { + const result = await executeMutation.mutateAsync({ + callback_display_id: selectedId, + commands: commandLines, + }); + push(`${result.tasks.length} task(s) submitted`, 'success'); + onClose(); + } catch (err) { + setSubmitError(extractApiError(err, 'Could not execute via C2')); + } + }; + + return ( +
+