Compare commits

..
1 Commits
Author SHA1 Message Date
Knacky 4c25e198fc Milestone 3 2026-05-11 06:05:27 +02:00
44 changed files with 23 additions and 10023 deletions

No files matched your search

-133
View File
@@ -4,139 +4,6 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
## [Unreleased] ## [Unreleased]
### Fixed (post-M6 SPA — mission detail page was read-only)
- **Mission detail page couldn't edit metadata, append scenarios, or change members** (`frontend/src/pages/MissionDetailPage.tsx`): the M6 SPA shipped the 3-step *creation* wizard but no edit affordance on the detail page — even though the backend already exposed `PUT /missions/{id}`, `POST /missions/{id}/scenarios`, and `PUT /missions/{id}/members`. Added three modals gated by `is_admin || mission.update`:
- **Edit metadata** (header button, opens a 3xl modal): name / client_target / dates / description_md, full inline validation (empty name, inverted dates) mirroring the wizard's step 1.
- **Add scenarios** (in the Tests tab): scenario picker reusing the wizard step-2 visual, calls `POST /missions/{id}/scenarios` which appends snapshots at `current_max_position + 1`. The footer line tells the user how many tests will be appended.
- **Edit members** (in the Members tab): roster + red/blue toggles, calls `PUT /missions/{id}/members` (full-set replace) — same UX as the wizard step 3, pre-populated with the current member set.
- Detail page now imports `useAuth` to compute `canEdit` once and reuses it across all three buttons.
- E2E spec extended: new test `SPA — detail page edits metadata, appends scenarios, edits members` exercises the three modals end-to-end against a pre-seeded mission. Suite is now 44 Playwright tests (6 in M6).
### Fixed (post-M6 review pass — spec-reviewer + code-reviewer)
- **SPA cache invalidation only refreshed the empty-filter list** (`frontend/src/lib/missions.ts:136`): `missionKeys.list()` returns `['missions','list',{}]`. TanStack v5's `invalidateQueries({queryKey})` is prefix-based, but `{}` is treated as an atomic final element — so create / transition / delete called with that key only invalidated the *exact* empty-filter list, leaving any filtered variant stale until manual refetch. Added `missionKeys.listPrefix()` returning `['missions','list']` and switched all three mutation `onSuccess` paths to it.
- **Snapshot lacked the per-scenario advisory lock** (`backend/app/services/missions.py:467`): a concurrent `PUT /scenario-templates/{id}/tests` (M5 reorder, which deletes-then-reinserts join rows) running while `_snapshot_scenarios` walked `sc.tests` could freeze a torn snapshot — `selectinload` re-queries under READ COMMITTED so a partial view was possible. Added `_lock_scenario_ids_for_snapshot` that acquires the same `pg_advisory_xact_lock` key used by `set_scenario_tests` (blake2b digest of the scenario UUID, sorted to avoid deadlocks). Snapshot and reorder now serialise per scenario.
- **Transition endpoint leaked its body shape via 400 before the perm gate** (`backend/app/api/missions.py:441`): a user without `mission.update` or `mission.archive` POSTing `{"status":"x"}` got a Pydantic 400 instead of 403. Added `@require_perm("mission.update", "mission.archive")` so the gate fires before the parse; the inner refinement still enforces the per-target perm. Test `test_transition_perm_gate_runs_before_payload_parse`.
- **LIKE wildcards in user-typed search were honoured as SQL wildcards** (`backend/app/services/missions.py:632,637`): `?q=%` matched every mission. Added `_escape_like` that pre-escapes `%`, `_`, `\` and a matching `escape='\\'` argument on every `.like(...)` call. Test `test_search_treats_wildcards_as_literals`.
- **Counts ignored soft-deleted mission children** (`backend/app/services/missions.py:587,597`): `tests_count` and the detail view summed `len(sc.tests)` without filtering `MissionTest.deleted_at`. Harmless today (M6 doesn't soft-delete mission tests), but would drift silently once M7+ surfaces `state=skipped/blocked`. Added the filter in both `_to_list_item` and `_scenario_views`.
- **`/users/roster` was unordered** (`backend/app/api/users.py:73`): the wizard's member list shuffled rows on every refetch. Sorted by `email` for predictable rendering + stable e2e selectors.
- **Frontend transition button accent collapsed `in_progress` and `completed` into one colour** (`frontend/src/pages/MissionDetailPage.tsx:97`): both rendered cyan, so the status legend in the list didn't match the transition button. Added a `TRANSITION_BUTTON_ACCENT` map mirroring `MISSION_STATUS_ACCENT` (cyan/orange/green/teal).
- **Soft-deleted source scenario was a silent foot-gun**: `_load_scenario_templates_for_snapshot` already rejected it, but no test pinned the behaviour. Added `test_create_mission_rejects_soft_deleted_scenario` so future refactors can't regress to "freeze a tombstoned scenario into a fresh mission".
- **E2E wizard assertion used `getByRole('button', { name: /In Progress/i })`** (`e2e/tests/m6-missions.spec.ts:287`): the accessible name is `→ In Progress` and the arrow Unicode is brittle. Switched to `getByTestId('mission-transition-in_progress')`.
### Added — M6 (Missions & snapshot)
- **CRUD `missions`** (`app/services/missions.py` + `app/api/missions.py`):
- Fields: name, client_target, date_start, date_end, status (`draft/in_progress/completed/archived`), description (markdown), visibility_mode (frozen to `whitebox` in v1).
- On creation/append, the service **snapshots** the selected `scenario_templates` and all their `test_templates` into `mission_scenarios` / `mission_tests` (every template field — including OPSEC level, tags, expected IOCs, MITRE tags). The denormalised `mission_test_mitre_tags` table copies `external_id`, `name`, `url` so a later MITRE re-sync that drops the entry can't alter a mission's tags (spec §11).
- `source_*_template_id` FKs survive template soft-deletes (`ON DELETE SET NULL`); the mission's frozen content is unaffected.
- **Membership visibility**: non-admin viewers see only missions where they are a `mission_members` row. The service maps "not visible" → 404 (no existence leak via 403). Admins bypass via the `admin` group.
- **Status state machine**: `draft → in_progress → completed → archived`; `archived → ∅`. The transition endpoint accepts the target status, validates the move, and rejects invalid jumps with 409. Idempotent (target=current) is a no-op 200.
- Auto-creator-membership: a non-admin caller of `POST /missions` is auto-added as `role_hint='red'` if not already in the `members[]` payload — so they retain visibility on the mission they just created.
- REST: `GET/POST /missions`, `GET/PUT/DELETE /missions/{id}`, `POST /missions/{id}/scenarios` (append snapshots at the end), `PUT /missions/{id}/members` (replace set), `POST /missions/{id}/transition`.
- Filters on list: `q` (LIKE on name/description), `status`, `client` (LIKE on client_target). `include_deleted=true` is admin-only (403 otherwise).
- **`GET /users/roster`** (`app/api/users.py`): a deliberately minimal listing — `id`, `email`, `display_name` of active users only — accessible to any holder of `user.read`, `mission.create`, or `mission.update`. Lets a non-admin red teamer populate the wizard's member picker without exposing the admin-grade `/users` endpoint (which leaks `is_admin`, `is_active`, group memberships).
- **Frontend**:
- `lib/missions.ts` — typed client + queryKey factory + status accent map + filter query-string builder.
- `pages/MissionsListPage.tsx` — list cards (one per mission) with status accent, scenario/test/member counts, date range, plus filters (q, client, status).
- `pages/MissionsCreatePage.tsx` — **3-step wizard**: metadata → scenario picker → member roster (red/blue toggles + auto-include the non-admin creator). Submits via `POST /missions` and redirects to the detail page.
- `pages/MissionDetailPage.tsx` — header with transition buttons (only the legal next states are rendered), soft-delete with confirm prompt, and 4 tabs: **Tests** (table of snapshotted tests with MITRE tags, OPSEC, state), **Members** (role-coloured pills), **Synthesis** (placeholder for M10), **Export** (placeholder for M11).
- Nav adds **Missions** link visible to anyone with `mission.read` or admin.
- **/diag/reset** truncates the mission tables before the template tables — `mission_scenarios.source_scenario_template_id` and `mission_tests.source_test_template_id` are `ON DELETE SET NULL`, so wiping missions first avoids the round-trip through the null-update path.
- **Testing**:
- `backend/tests/test_missions.py` — **22 pytest** covering snapshot fidelity (rename source template after snapshot → mission unchanged), MITRE tag propagation, membership-based 404, perm gating (create vs read vs archive), status transition chain + invalid jumps (409), member set replace + role-hint validation, scenario append at correct position, soft-delete, partial metadata update, inverted-date rejection, admin-only `include_deleted`.
- `e2e/tests/m6-missions.spec.ts` — **5 Playwright** (snapshot freezing, membership visibility for non-admin red, status transition + 409, SPA wizard end-to-end, SPA list + status filter).
- `tasks/testing-m6.md`.
### Added — M5 (Test & scenario templates)
- **CRUD `test_templates`** (`app/services/test_templates.py` + `app/api/test_templates.py`):
- Fields: name, description, objective, procedure (markdown), prerequisites (markdown), expected result red, expected detection blue, OPSEC level (`low/medium/high`), free tags (TEXT[]), expected IOCs (TEXT[]).
- Polymorphic MITRE tag set (`(kind, external_id)` ↔ exactly one of `tactic_id`/`technique_id`/`subtechnique_id`). The wire payload uses ATT&CK external IDs — server resolves to UUIDs.
- Filters: `q` (LIKE on name/description), `tactic`/`technique`/`subtechnique` (joined via subquery on the polymorphic tag table), `opsec`, `tag` (array contains).
- REST: `GET /test-templates`, `GET /test-templates/{id}`, `POST /test-templates`, `PUT /test-templates/{id}` (partial, with explicit `_UNSET` sentinel so omitted fields stay untouched), `DELETE /test-templates/{id}` (soft).
- **CRUD `scenario_templates`** (`app/services/scenario_templates.py` + `app/api/scenario_templates.py`):
- Ordered list of test_templates with `position` (UNIQUE `scenario_template_id, position`).
- Reorder via full replace: `PUT /scenario-templates/{id}/tests` deletes the join rows and re-inserts at positions `0..N-1` — clean atomic op that respects the UNIQUE constraint without a 2-phase position shuffle.
- The same test can appear multiple times (chained operations).
- REST: `GET`/`POST`/`PATCH` (metadata) / `DELETE` (soft) on `/scenario-templates`.
- **Frontend**:
- `lib/templates.ts` — typed client + queryKey factory.
- `pages/AdminTestsPage.tsx` — list + filters (q, tactic, opsec, tag) + modal with full field set + embedded `<MitreTagPicker>` for tags.
- `pages/AdminScenariosPage.tsx` — list + modal with **@dnd-kit/sortable** vertical drag-and-drop on the ordered test list. New deps: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`.
- `components/MarkdownField.tsx` — lean textarea with markdown hint (no heavy editor dep; rendering happens at display time in M7).
- Nav adds **Tests** and **Scenarios** links (admin-gated).
- **/diag/reset** truncates the 4 new tables before the MITRE block — the `scenario_template_tests.test_template_id` FK is `ON DELETE RESTRICT`, so the order matters.
- **Testing**:
- `backend/tests/test_templates.py` — **19 pytest** (create/list/filter by tactic+opsec+tag, MITRE tag resolution + replacement on update, soft-delete, perm gating, scenario create+reorder+delete, soft-deleted test linking semantics).
- `e2e/tests/m5-templates.spec.ts` — **4 Playwright** (API CRUD round-trip, scenario reorder, SPA list + opsec filter, SPA scenario list rendering with ordered tests).
- `tasks/testing-m5.md`.
### Fixed (M5 implementation)
- **`LogRecord` key collision**: `log.info(..., extra={"name": ...})` raises `KeyError("Attempt to overwrite 'name' in LogRecord")` because `name` is reserved by Python's stdlib logging. Renamed to `template_name`.
- **React `currentTarget` null in deferred state updaters**: `onChange={(e) => setX((prev) => ({ ...prev, q: e.currentTarget.value }))}` blanked the page on the first user input because `currentTarget` is cleared after the listener bubble ends, before React invokes the updater. Switched all M5 handlers to `e.target.value`, which persists on the synthetic event.
### Fixed (post-M5 — scenario reorder 500 + cross-worker lock correctness)
- **`PUT /scenario-templates/{id}/tests` returned 500** (`backend/app/services/scenario_templates.py:218`): the two-argument form `pg_advisory_xact_lock(:n, :m)` failed with `function pg_advisory_xact_lock(smallint, bigint) does not exist`. Postgres only provides `(int4, int4)` and `(bigint)` overloads — psycopg promoted `m = hash(uuid) & 0xFFFFFFFF` (up to 2^32-1) to bigint and there's no matching overload. Switched to the single-argument bigint form with `CAST(:key AS bigint)`.
- **Cross-worker lock was a no-op** (same site): Python's built-in `hash()` is randomised per process via `PYTHONHASHSEED`, so each gunicorn worker computed a different key for the same `scenario_id`, and concurrent reorders on different workers acquired independent locks — defeating the serialisation. Replaced with `blake2b(scenario_id.bytes, digest_size=8)` interpreted as a signed int64. Stable, deterministic, fits in `bigint`.
### Fixed (post-M5 UI — modal layout for the test-template editor)
- **Modal box capped its width at `max-w-2xl` and had no vertical scroll** (`frontend/src/components/ui/Modal.tsx`): opening **+ New test** rendered the 15-column MITRE matrix inside a 672 px frame with no height cap, so the matrix spilled to the right and the form bottom dropped below the viewport — buttons unreachable, no scroll. Added a `size` prop (default `2xl` for back-compat), `max-h-[calc(100vh-2rem)]` + `flex flex-col` on the dialog, and an inner `min-w-0 flex-1 overflow-y-auto` body so the header stays pinned while the form scrolls inside the modal.
- **MITRE matrix overflow-x failed to scroll inside the modal body** (`frontend/src/components/MitreTagPicker.tsx`): `overflow-x-auto` sat directly on the grid element, but the grid's intrinsic min-width (`15 × minmax(7rem, …)` = 1680 px) prevented it from shrinking below its content, so the grid spilled outside its parent instead of scrolling. Wrapped the grid in a dedicated `overflow-x-auto rounded min-w-0 w-full` scroller and added `min-w-0` to the picker root so the constraint propagates from the modal body. The grid now scrolls horizontally inside the modal.
- **`grid gap-3` form layout in the test-template modal propagated `min-width: auto`** (`frontend/src/pages/AdminTestsPage.tsx`): each grid item refused to shrink below its widest child, so the picker dragged the form (and the body) past the modal width. Switched the form to `flex flex-col gap-3 min-w-0`, which breaks the propagation while preserving vertical spacing.
- **Test-template modal now uses `size="7xl"`** and the scenario-template modal `size="3xl"` to match their content density.
### Fixed (post-M5 review pass — spec-reviewer + code-reviewer)
- **Filter combinator was OR, not AND** (`backend/app/services/test_templates.py:235`): `?tactic=TA0002&technique=T1059` returned templates matching *either* facet instead of *both*. Pre-fix also pooled all three UUIDs into a shared `IN` list across three columns, theoretically allowing a UUID collision to match across kinds. Refactored to one IN-subquery per facet, ANDed together via repeated `WHERE id IN (...)`.
- **Concurrent reorder race on `set_scenario_tests`** (`backend/app/services/scenario_templates.py:207`): two parallel reorders on the same scenario could deadlock on the `UNIQUE(scenario_id, position)` constraint under READ COMMITTED. Added a per-scenario `pg_advisory_xact_lock(0x5C3, hash(scenario_id))` mirroring the M4 `/mitre/sync` pattern; different scenarios don't contend.
- **N+1 on `_to_view` MITRE resolution** (`backend/app/services/test_templates.py:160`): rendering K templates with ~T tags each fired up to K×T `s.get(...)` calls. Added `_to_views_batch` that pre-builds `{uuid → MitreRow}` maps in 3 queries and feeds them to per-template view assembly; `list_test_templates` now issues 4 queries total regardless of list size.
- **Wire-level item length cap on `tags` / `expected_iocs`** (`backend/app/api/test_templates.py:18-21`): the DB columns are `ARRAY(String(64))` / `ARRAY(String(255))` but the API layer only capped the LIST length, not item strings — long inputs hit the driver with `StringDataRightTruncation`. Added `Annotated[str, StringConstraints(...)]` types so the API returns 400 with a clean validation error.
- **Front-end mutation cache hygiene** (`frontend/src/pages/AdminScenariosPage.tsx:148-156`): `updateMeta` and `setTests` mutations are run sequentially in `submit()`; on partial failure (metadata saved but reorder failed) the cache stayed stale. Both mutations now `onSettled: invalidate` so whatever step landed is reflected without manual refresh.
- **Backend vs front-end consistency on duplicate tests in a scenario** (`frontend/src/pages/AdminScenariosPage.tsx:227-231`): the backend allows the same `test_template` to appear multiple times (chained ops; the UNIQUE constraint is `(scenario_id, position)` not `(scenario_id, test_template_id)`), but the catalogue picker was filtering out already-picked items. Removed the filter — only soft-deleted tests are excluded now.
- **Test coverage closure** (`backend/tests/test_templates.py`): +4 pytest (tactic+technique AND-semantics, `extra="forbid"` rejection, empty `mitre_tags` explicit clear, 65-char tag length cap → 400). Total backend now 23 M5 tests + 39 elsewhere = 81 pass.
### Added — M4 (MITRE ATT&CK Enterprise)
- **STIX 2.1 parser + upsert** (`app/services/mitre_seed.py`): stdlib-only (`urllib.request` + `hashlib`), pinned to Enterprise v19.0 (`enterprise-attack-19.0.json`, sha256 `df520ea0…`). Parses 25k+ STIX objects → 15 tactics, 222 techniques, 475 sub-techniques in ~1.1 s. Skips revoked + deprecated, resolves sub-technique parents via `relationship[subtechnique-of]` with a `T1003.001 → T1003` dotted-id fallback, copies kill-chain phases into the `mitre_technique_tactics` M2M.
- **CLI**: `flask metamorph seed-mitre [--source <path|url>] [--checksum-sha256 <hex>] [--skip-checksum]` (`app/cli.py`). `make seed-mitre` wraps it.
- **REST endpoints** (`app/api/mitre.py`):
- `GET /api/v1/mitre/tactics`, `/mitre/techniques?tactic=…&q=…`, `/mitre/subtechniques?technique=…&q=…` (paginated, search on name/external_id).
- `GET /api/v1/mitre/status` (last_sync, version, source_url, defaults).
- `POST /api/v1/mitre/sync` (perm `mitre.sync`) — re-pull on demand.
- **Persisted metadata** in `settings`: `mitre_last_sync`, `mitre_version`, `mitre_source_url`.
- **Compose volume `metamorph_mitre`** mounted at `/data/mitre/` in the api container — caches the downloaded bundle across restarts. Owned by `metamorph:metamorph`.
- **Frontend**:
- `<MitreTagPicker>` component: flat ATT&CK matrix matching `attack.mitre.org/#` — full-bleed beyond `max-w-page`, 15 equal-width columns via `grid-template-columns: repeat(N, minmax(7rem, 1fr))`, sans-serif 12px, **name-only cells** (external_id surfaces on hover via `title` and in selection chips), `▸/▾` chevron expands sub-techniques inline within the column, multi-select with chip-removal at the top. Returns `MitreTag[]` (`kind`, `id`, `external_id`, `name`), ready for M5 templates.
- `/mitre` showcase page with status card, admin-gated **Trigger sync** button, the picker, and a JSON `<pre>` preview of the current selection.
- Nav adds **MITRE** link for any logged-in user.
- **Testing**:
- `backend/tests/test_mitre.py` — **12 pytest** (parser, idempotence, checksum mismatch, persisted settings, endpoint variants, perm enforcement) using a hand-crafted minimal STIX bundle (no network in tests).
- `e2e/tests/m4-mitre.spec.ts` — **6 Playwright** against the live stack (calls `/mitre/sync` once in `beforeAll`).
- `tasks/testing-m4.md`.
### Fixed (post-M4 spec-review pass)
- **Sync integrity guarantee**: `seed_mitre()` now refuses a custom URL without either `expected_sha256` or an explicit `allow_unverified=true`. Closes a "typo in `mitre_source_url` setting routes the seed to attacker JSON" footgun. CLI surfaces this via `--checksum-sha256` / `--skip-checksum`; API via `{"source", "expected_sha256", "allow_unverified"}` body.
- **`/diag/reset` consistency**: now truncates the `mitre_*` tables alongside `settings` so `GET /mitre/status` and `GET /mitre/tactics` agree after a reset (previously: catalogue rows persisted, but `mitre_last_sync` got wiped → status lied).
- **Spec drift §10 #4**: amended "14 tactics" → "≥ 14 tactics (v19 ships 15)" to reflect MITRE v8+ reality.
### Fixed (post-M4 code-review pass)
- **SSRF allowlist on `/mitre/sync`**: host must be in `MITRE_ALLOWED_HOSTS` (defaults to `raw.githubusercontent.com`, comma-separated env override). Closes the "admin holding `mitre.sync` can pivot the api container at cloud metadata (`169.254.169.254`) or internal mirrors" vector. New `MitreSourceForbidden` exception → 400 with `source_forbidden` error code.
- **Concurrent sync race**: `seed_mitre()` now acquires `pg_advisory_xact_lock(hashtext('mitre.seed'))` at the top of the transaction so two `/mitre/sync` calls serialise cleanly across the `DELETE` + re-`INSERT` of `mitre_technique_tactics`.
- **Typed sync contract end-to-end**: Pydantic `SyncResultOut` on the backend (`app/api/mitre.py`) mirrored by a `MitreSyncResult` TS interface (`frontend/src/lib/mitre.ts`). The MitrePage mutation no longer uses an `as Record<string, unknown>` escape hatch.
- **N+1 in dotted sub-technique fallback**: pre-built `{external_id → id}` dict at function entry; was firing one extra SELECT per orphan (currently 0 with MITRE, but a latent footgun for partial bundles).
- **`SETTING_VERSION` cleared explicitly when source != default**: previously kept the stale pinned version after a custom-URL re-sync; now `_upsert_setting(..., None)` so `/mitre/status` doesn't lie.
- **Internal error scrub on `/mitre/sync`**: 500 responses no longer leak URLError / DB driver text via `str(e)` — stack lands in JSON logs only.
- **E2E pinned to exact MITRE v19 counts** (15/222/475/0 orphans) for parser-regression detection; previously `>=` thresholds could mask "revoked tactics silently included".
- **E2E uses `crypto.randomUUID()`** instead of `Math.random()` for unique test emails.
- **Test coverage for security guards**: `file://` rejection, disallowed HTTPS host, custom-URL-without-sha refusal, dotted-id fallback, version-clearing semantics — 5 new pytest covering paths the spec-review demanded but no test enforced.
### Decisions (intentional)
- **Bundle "embarqué" interpreted as seed-time download + named-volume cache**, not "binary baked into the Docker image". Keeps the image ~150 MB, makes version bumps a constant edit, plays nicely with `make seed-mitre` re-runs. Air-gapped operators copy the file into the volume + pass `--source /data/mitre/<file>`.
- **Read endpoints unauthenticated-perm-wise but auth-required**: MITRE data is public reference material — no `mitre.read` perm. Status endpoint is similarly open (under `@require_auth`) to keep `/mitre/status` simple for the UI badge.
- **No `requests` / `httpx` dep added**: stdlib `urllib.request` is enough and avoids inflating the image.
### Validated end-to-end (M4 DoD)
- `make clean && make up && make migrate && make seed-mitre` → 15 tactics / 222 techniques / 475 sub-techniques / 254 links / 0 orphans / ~1.1 s.
- `make test-api` → **58 pytest pass** (1 health + 8 schema + 15 auth + 15 RBAC + 19 MITRE) in ~5 s.
- `make e2e` → **34 Playwright pass** (8 M0 + 4 M1 + 8 M2 + 8 M3 + 6 M4) in ~18 s.
- Spec-reviewer PASS after fixes applied.
### Added — M3 (RBAC: groups, permissions, users) ### Added — M3 (RBAC: groups, permissions, users)
- **Permission catalogue** (`app/services/permissions_seed.py`): 31 atomic codes across 10 families (`user`, `group`, `invitation`, `test_template`, `scenario_template`, `mission`, `detection_level`, `setting`, `mitre.sync`). Seeded at boot **and** after `/setup` to handle a freshly truncated DB. Idempotent + additive on system groups (never removes a perm). - **Permission catalogue** (`app/services/permissions_seed.py`): 31 atomic codes across 10 families (`user`, `group`, `invitation`, `test_template`, `scenario_template`, `mission`, `detection_level`, `setting`, `mitre.sync`). Seeded at boot **and** after `/setup` to handle a freshly truncated DB. Idempotent + additive on system groups (never removes a perm).
- **Default group bindings**: `admin` = all 31 codes; `redteam` = 8 (catalogue read + mission.{read,create,update,archive,write_red_fields} + detection_level.read); `blueteam` = 5 (catalogue read + mission.{read,write_blue_fields} + detection_level.read). - **Default group bindings**: `admin` = all 31 codes; `redteam` = 8 (catalogue read + mission.{read,create,update,archive,write_red_fields} + detection_level.read); `blueteam` = 5 (catalogue read + mission.{read,write_blue_fields} + detection_level.read).
+3 -18
View File
@@ -2,17 +2,13 @@
Collaborative purple-team platform. Red team logs the tests they execute (procedure, command, timestamp); blue team annotates each test with detection evidence (alerts, logs, files). At the end of an engagement, Metamorph generates a standalone reveal.js slide deck classified by MITRE ATT&CK tactic. Collaborative purple-team platform. Red team logs the tests they execute (procedure, command, timestamp); blue team annotates each test with detection evidence (alerts, logs, files). At the end of an engagement, Metamorph generates a standalone reveal.js slide deck classified by MITRE ATT&CK tactic.
> **Status**: M0–M5 delivered (bootstrap → DB schema → auth → RBAC → MITRE ATT&CK reference → test & scenario templates). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan. > **Status**: M0 (bootstrap). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan.
## Stack ## Stack
- **Backend**: Python 3.12, Flask 3, SQLAlchemy 2 + Alembic (M1+), PostgreSQL 16. - **Backend**: Python 3.12, Flask 3, SQLAlchemy 2 + Alembic (M1+), PostgreSQL 16.
- **Frontend**: React 18 + TypeScript + Vite + TailwindCSS (RTOps design tokens, see `tasks/design.md`). - **Frontend**: React 18 + TypeScript + Vite + TailwindCSS (RTOps design tokens, see `tasks/design.md`).
- **Auth (M2+)**: JWT access (1h) + refresh (30d), Argon2id, invite-link enrollment. - **Auth (M2+)**: JWT access (1h) + refresh (30d), Argon2id, invite-link enrollment.
- **RBAC (M3+)**: atomic permissions (31 codes) bundled into custom groups; 3 system groups seeded (`admin` / `redteam` / `blueteam`).
- **MITRE ATT&CK (M4+)**: Enterprise reference catalogue pinned to v19.0, seedable via `make seed-mitre`.
- **Template catalogue (M5+)**: reusable `test_templates` (markdown procedure, OPSEC level, free tags, expected IOCs, MITRE tags) + ordered `scenario_templates` with drag-and-drop reordering. Admin pages at `/admin/tests` and `/admin/scenarios`.
- **Missions (M6+)**: `missions` snapshot one or more scenario templates at creation time; template edits don't drift live missions (`mission_*` tables freeze every field, including MITRE tags). Non-admin members see only their own missions (membership filter, 404 on existence-leak attempts). Status state machine `draft → in_progress → completed → archived`, archive perm gated separately. SPA: list/filter at `/missions`, 3-step create wizard at `/missions/new`, detail page with Tests / Members / Synthesis / Export tabs.
- **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production. - **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production.
## Quickstart ## Quickstart
@@ -47,17 +43,6 @@ Then:
- Front: <http://localhost:8080> - Front: <http://localhost:8080>
- API health: <http://localhost:8080/api/v1/health> (proxied) or <http://localhost:8000/api/v1/health> - API health: <http://localhost:8080/api/v1/health> (proxied) or <http://localhost:8000/api/v1/health>
### First-time setup
```bash
make migrate # apply DB schema
make print-install-token # prints the bootstrap admin token (logs banner)
# visit http://localhost:8080/setup, paste the token, create the admin account
make seed-mitre # populate the MITRE ATT&CK reference (~50 MB, ~1 s)
```
The MITRE bundle is cached in the named volume `metamorph_mitre` (`/data/mitre/<file>.json` inside the api container). For air-gapped operators, pre-populate the volume with your own STIX 2.1 file and run `podman compose exec api flask --app app.cli metamorph seed-mitre --source /data/mitre/your-file.json --skip-checksum`.
To stop: To stop:
```bash ```bash
@@ -95,7 +80,7 @@ See `.env.example`. The most important ones:
## Testing ## Testing
- **Manual + automated checklist for the current milestone**: see [`tasks/testing-m<N>.md`](tasks/testing-m6.md) (current: `testing-m6.md`). - **Manual + automated checklist for the current milestone**: see [`tasks/testing-m<N>.md`](tasks/testing-m0.md) (currently `testing-m0.md`).
- **Backend unit tests**: `make test-api` - **Backend unit tests**: `make test-api`
- **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`. - **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`.
@@ -137,7 +122,7 @@ The hooks run `ruff` + `ruff-format` on the backend and `eslint` / `tsc --noEmit
## Roadmap ## Roadmap
See `tasks/todo.md`. Current milestone: **M6 — Missions & snapshot** (done). Next: M7 (red/blue execution on a mission test). See `tasks/todo.md`. Current milestone: **M0 — bootstrap**.
## License ## License
+1 -1
View File
@@ -30,7 +30,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
# Non-root user # Non-root user
RUN groupadd --gid 10001 metamorph \ RUN groupadd --gid 10001 metamorph \
&& useradd --uid 10001 --gid metamorph --shell /usr/sbin/nologin --create-home metamorph \ && useradd --uid 10001 --gid metamorph --shell /usr/sbin/nologin --create-home metamorph \
&& mkdir -p /data/evidence /data/mitre \ && mkdir -p /data/evidence \
&& chown -R metamorph:metamorph /data && chown -R metamorph:metamorph /data
COPY --from=deps /opt/venv /opt/venv COPY --from=deps /opt/venv /opt/venv
-36
View File
@@ -66,48 +66,12 @@ def reset_test_state():
try: try:
with get_engine().begin() as conn: with get_engine().begin() as conn:
# Auth + RBAC + settings reset.
conn.execute( conn.execute(
text( text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, " "TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, settings, groups RESTART IDENTITY CASCADE" "user_groups, settings, groups RESTART IDENTITY CASCADE"
) )
) )
# Mission catalogue reset (M6). Truncated before the template tables
# below because `mission_scenarios.source_scenario_template_id` and
# `mission_tests.source_test_template_id` are ON DELETE SET NULL — a
# cascade-truncate of templates would attempt to null those columns
# and stall on the constraint check. Wiping the mission tables first
# avoids that round-trip; cascades from `missions` then take care of
# members, scenarios, tests, mitre_tags, categories.
conn.execute(
text(
"TRUNCATE mission_test_mitre_tags, mission_tests, "
"mission_scenarios, mission_categories, mission_members, "
"missions RESTART IDENTITY CASCADE"
)
)
# Template catalogue reset (M5). The MITRE truncate below cascades to
# the polymorphic tag join, but the template rows themselves must be
# wiped first because `scenario_template_tests.test_template_id` is
# ON DELETE RESTRICT.
conn.execute(
text(
"TRUNCATE scenario_template_tests, scenario_templates, "
"test_template_mitre_tags, test_templates "
"RESTART IDENTITY CASCADE"
)
)
# MITRE reference reset — kept in sync with `settings` so a freshly
# reset stack has `GET /mitre/status` and `GET /mitre/tactics` agree
# ("no data, no last_sync"). The e2e suite re-syncs via /mitre/sync
# when it needs catalogue data.
conn.execute(
text(
"TRUNCATE mitre_technique_tactics, mitre_subtechniques, "
"mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE"
)
)
except SQLAlchemyError as e: except SQLAlchemyError as e:
log.error("metamorph.diag.reset_failed", extra={"error": str(e)}) log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
return jsonify({"reset": False, "error": "database_error"}), 500 return jsonify({"reset": False, "error": "database_error"}), 500
-498
View File
@@ -1,498 +0,0 @@
"""Missions API.
Per spec §4: a non-admin user can only see (or edit) missions they are a
member of. The decorator stack here gates the *action type* by permission
code; the service layer applies the membership filter. Both layers fail
closed.
Status transitions are routed through a single POST endpoint that accepts a
target status. We accept either `mission.update` or `mission.archive` at the
gate — archiving requires the dedicated perm if the target is `archived`, and
the service enforces the lifecycle graph (`_VALID_TRANSITIONS`).
"""
from __future__ import annotations
import logging
import uuid
from datetime import date
from typing import Any
from flask import Blueprint, abort, g, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm
from app.services import missions as svc
bp = Blueprint("missions", __name__, url_prefix="/missions")
log = logging.getLogger("metamorph.api.missions")
# --------------------------------------------------------------------------- #
# Payloads
# --------------------------------------------------------------------------- #
class MemberPayload(BaseModel):
user_id: uuid.UUID
role_hint: str = Field(min_length=1, max_length=8)
model_config = {"extra": "forbid"}
class CreateMissionPayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
client_target: str | None = Field(default=None, max_length=255)
date_start: date | None = None
date_end: date | None = None
description_md: str | None = Field(default=None, max_length=20_000)
scenario_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=64)
members: list[MemberPayload] = Field(default_factory=list, max_length=128)
model_config = {"extra": "forbid"}
class UpdateMissionPayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
client_target: str | None = Field(default=None, max_length=255)
date_start: date | None = None
date_end: date | None = None
description_md: str | None = Field(default=None, max_length=20_000)
model_config = {"extra": "forbid"}
class AddScenariosPayload(BaseModel):
scenario_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=64)
model_config = {"extra": "forbid"}
class SetMembersPayload(BaseModel):
members: list[MemberPayload] = Field(default_factory=list, max_length=128)
model_config = {"extra": "forbid"}
class TransitionPayload(BaseModel):
status: str = Field(min_length=1, max_length=16)
model_config = {"extra": "forbid"}
# --------------------------------------------------------------------------- #
# Serialisers
# --------------------------------------------------------------------------- #
def _serialize_member(m: svc.MissionMemberView) -> dict[str, Any]:
return {
"user_id": str(m.user_id),
"user_email": m.user_email,
"user_display_name": m.user_display_name,
"role_hint": m.role_hint,
}
def _serialize_mitre_tag(tag: svc.MissionMitreTagView) -> dict[str, Any]:
return {
"kind": tag.kind,
"external_id": tag.external_id,
"name": tag.name,
"url": tag.url,
}
def _serialize_test(t: svc.MissionTestView) -> dict[str, Any]:
return {
"id": str(t.id),
"position": t.position,
"snapshot_name": t.snapshot_name,
"snapshot_description": t.snapshot_description,
"snapshot_objective": t.snapshot_objective,
"snapshot_procedure_md": t.snapshot_procedure_md,
"snapshot_prerequisites_md": t.snapshot_prerequisites_md,
"snapshot_expected_red_md": t.snapshot_expected_red_md,
"snapshot_expected_blue_md": t.snapshot_expected_blue_md,
"snapshot_opsec_level": t.snapshot_opsec_level,
"snapshot_tags": t.snapshot_tags,
"snapshot_expected_iocs": t.snapshot_expected_iocs,
"state": t.state,
"executed_at": t.executed_at.isoformat() if t.executed_at else None,
"executed_at_overridden": t.executed_at_overridden,
"mitre_tags": [_serialize_mitre_tag(tag) for tag in t.mitre_tags],
"source_test_template_id": (
str(t.source_test_template_id) if t.source_test_template_id else None
),
}
def _serialize_scenario(sc: svc.MissionScenarioView) -> dict[str, Any]:
return {
"id": str(sc.id),
"position": sc.position,
"snapshot_name": sc.snapshot_name,
"snapshot_description": sc.snapshot_description,
"tests": [_serialize_test(t) for t in sc.tests],
"source_scenario_template_id": (
str(sc.source_scenario_template_id)
if sc.source_scenario_template_id
else None
),
}
def _serialize_list_item(m: svc.MissionListItemView) -> dict[str, Any]:
return {
"id": str(m.id),
"name": m.name,
"client_target": m.client_target,
"date_start": m.date_start.isoformat() if m.date_start else None,
"date_end": m.date_end.isoformat() if m.date_end else None,
"status": m.status,
"description_md": m.description_md,
"visibility_mode": m.visibility_mode,
"scenarios_count": m.scenarios_count,
"tests_count": m.tests_count,
"members_count": m.members_count,
"deleted_at": m.deleted_at.isoformat() if m.deleted_at else None,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
def _serialize_detail(m: svc.MissionView) -> dict[str, Any]:
base = {
"id": str(m.id),
"name": m.name,
"client_target": m.client_target,
"date_start": m.date_start.isoformat() if m.date_start else None,
"date_end": m.date_end.isoformat() if m.date_end else None,
"status": m.status,
"description_md": m.description_md,
"visibility_mode": m.visibility_mode,
"scenarios_count": m.scenarios_count,
"tests_count": m.tests_count,
"members_count": m.members_count,
"deleted_at": m.deleted_at.isoformat() if m.deleted_at else None,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
base["scenarios"] = [_serialize_scenario(sc) for sc in m.scenarios]
base["members"] = [_serialize_member(mb) for mb in m.members]
return base
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _parse_uuid_or_400(raw: str) -> uuid.UUID | None:
try:
return uuid.UUID(raw)
except ValueError:
return None
def _pagination_args() -> tuple[int, int] | tuple[None, tuple[int, str]]:
try:
limit = int(request.args.get("limit", "100"))
offset = int(request.args.get("offset", "0"))
except ValueError:
return None, (400, "invalid_pagination")
return max(1, min(limit, 500)), max(0, offset)
def _current_user() -> AuthenticatedUser:
user: AuthenticatedUser | None = getattr(g, "current_user", None)
if user is None:
abort(401, description="not authenticated")
assert user is not None # for Pyright; abort raises HTTPException
return user
def _to_assignments(payload_members: list[MemberPayload]) -> list[svc.MemberAssignment]:
return [
svc.MemberAssignment(user_id=m.user_id, role_hint=m.role_hint)
for m in payload_members
]
# --------------------------------------------------------------------------- #
# Endpoints
# --------------------------------------------------------------------------- #
@bp.get("")
@require_auth
@require_perm("mission.read")
def list_missions():
paging = _pagination_args()
if paging[0] is None:
return jsonify({"error": paging[1][1]}), paging[1][0]
limit, offset = paging
q = request.args.get("q") or None
status = request.args.get("status") or None
client = request.args.get("client") or None
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
user = _current_user()
if include_deleted and not user.is_admin:
return jsonify({"error": "forbidden"}), 403
try:
items, total = svc.list_missions(
viewer_id=user.id,
viewer_is_admin=user.is_admin,
q=q,
status=status,
client=client,
include_deleted=include_deleted,
limit=limit,
offset=offset,
)
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return jsonify(
{
"items": [_serialize_list_item(it) for it in items],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<mission_id>")
@require_auth
@require_perm("mission.read")
def get_mission(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
user = _current_user()
if include_deleted and not user.is_admin:
return jsonify({"error": "forbidden"}), 403
try:
view = svc.get_mission(
mid,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
include_deleted=include_deleted,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize_detail(view))
@bp.post("")
@require_auth
@require_perm("mission.create")
def create_mission():
try:
payload = CreateMissionPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
user = _current_user()
try:
view = svc.create_mission(
name=payload.name,
creator_id=user.id,
creator_is_admin=user.is_admin,
client_target=payload.client_target,
date_start=payload.date_start,
date_end=payload.date_end,
description_md=payload.description_md,
scenario_template_ids=list(payload.scenario_template_ids),
members=_to_assignments(payload.members),
)
except svc.UnknownScenarioTemplate as e:
return jsonify({"error": "unknown_scenario_template", "message": str(e)}), 400
except svc.UnknownUser as e:
return jsonify({"error": "unknown_user", "message": str(e)}), 400
except svc.InvalidMemberPayload as e:
return jsonify({"error": "invalid_member", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.mission.created",
extra={
"mission_id": str(view.id),
"scenarios": view.scenarios_count,
"tests": view.tests_count,
"members": view.members_count,
},
)
return jsonify(_serialize_detail(view)), 201
@bp.put("/<mission_id>")
@require_auth
@require_perm("mission.update")
def update_mission(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateMissionPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
# Distinguish "not provided" from "explicitly null" by looking at the raw body.
kwargs: dict[str, Any] = {}
if "name" in raw and payload.name is not None:
kwargs["name"] = payload.name
if "client_target" in raw:
kwargs["client_target"] = payload.client_target
if "date_start" in raw:
kwargs["date_start"] = payload.date_start
if "date_end" in raw:
kwargs["date_end"] = payload.date_end
if "description_md" in raw:
kwargs["description_md"] = payload.description_md
user = _current_user()
try:
view = svc.update_mission_metadata(
mid,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
**kwargs,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return jsonify(_serialize_detail(view))
@bp.post("/<mission_id>/scenarios")
@require_auth
@require_perm("mission.update")
def add_scenarios(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = AddScenariosPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
user = _current_user()
try:
view = svc.add_scenarios_to_mission(
mid,
list(payload.scenario_template_ids),
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except svc.UnknownScenarioTemplate as e:
return jsonify({"error": "unknown_scenario_template", "message": str(e)}), 400
log.info(
"metamorph.mission.scenarios_added",
extra={
"mission_id": str(mid),
"added": len(payload.scenario_template_ids),
},
)
return jsonify(_serialize_detail(view))
@bp.put("/<mission_id>/members")
@require_auth
@require_perm("mission.update")
def set_members(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetMembersPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
user = _current_user()
try:
view = svc.set_mission_members(
mid,
_to_assignments(payload.members),
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except svc.UnknownUser as e:
return jsonify({"error": "unknown_user", "message": str(e)}), 400
except svc.InvalidMemberPayload as e:
return jsonify({"error": "invalid_member", "message": str(e)}), 400
return jsonify(_serialize_detail(view))
@bp.post("/<mission_id>/transition")
@require_auth
@require_perm("mission.update", "mission.archive")
def transition(mission_id: str):
"""Status transition. The outer decorator gates the endpoint on holding
EITHER `mission.update` or `mission.archive` — so a request with neither
perm sees 403 before its body is even parsed (no shape leak via 400).
The inner refinement then enforces the per-target rule: `mission.archive`
is required when the target is `archived`; `mission.update` covers the
other transitions. Admins bypass via the decorator's `is_admin` check.
"""
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = TransitionPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
user = _current_user()
required = "mission.archive" if payload.status == "archived" else "mission.update"
if not user.is_admin and required not in user.permissions:
log.info(
"metamorph.auth.permission_denied",
extra={
"user_id": str(user.id),
"required": [required],
"had": sorted(user.permissions),
},
)
return jsonify({"error": "forbidden"}), 403
try:
view = svc.transition_mission_status(
mid,
payload.status,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
except svc.InvalidTransition as e:
return jsonify({"error": "invalid_transition", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.mission.transitioned",
extra={"mission_id": str(mid), "status": view.status},
)
return jsonify(_serialize_detail(view))
@bp.delete("/<mission_id>")
@require_auth
@require_perm("mission.delete")
def soft_delete_mission(mission_id: str):
mid = _parse_uuid_or_400(mission_id)
if mid is None:
return jsonify({"error": "invalid_id"}), 400
user = _current_user()
try:
svc.soft_delete_mission(
mid,
viewer_id=user.id,
viewer_is_admin=user.is_admin,
)
except svc.MissionNotFound:
return jsonify({"error": "not_found"}), 404
log.info("metamorph.mission.soft_deleted", extra={"mission_id": str(mid)})
return jsonify({"ok": True})
-296
View File
@@ -1,296 +0,0 @@
"""MITRE ATT&CK reference endpoints.
Read access is open to any authenticated user (the catalogue is reference
data — not sensitive on its own). Sync is admin-only via `mitre.sync`.
"""
from __future__ import annotations
import logging
from flask import Blueprint, jsonify, request
from pydantic import BaseModel
from sqlalchemy import func, or_, select
from app.core.auth_decorators import require_auth, require_perm
from app.db.session import session_scope
from app.models.mitre import MitreSubtechnique, MitreTactic, MitreTechnique
from app.services import mitre_seed as mitre_seed_svc
class SyncResultOut(BaseModel):
"""Response schema for `POST /mitre/sync`. Mirrors `SeedResult.as_dict()`."""
tactics_upserted: int
techniques_upserted: int
subtechniques_upserted: int
subtechniques_skipped_orphan: int
technique_tactic_links: int
version: str | None
source: str
started_at: str
finished_at: str
duration_ms: int
bp = Blueprint("mitre", __name__, url_prefix="/mitre")
log = logging.getLogger("metamorph.api.mitre")
def _pagination_args() -> tuple[int, int] | tuple[None, tuple[int, str]]:
"""Returns (limit, offset) or (None, (status, error_payload))."""
try:
limit = int(request.args.get("limit", "100"))
offset = int(request.args.get("offset", "0"))
except ValueError:
return None, (400, "invalid_pagination")
limit = max(1, min(limit, 500))
offset = max(0, offset)
return limit, offset
def _search(stmt, model, q: str | None):
if not q:
return stmt
like = f"%{q.lower()}%"
return stmt.where(
or_(
func.lower(model.name).like(like),
func.lower(model.external_id).like(like),
)
)
def _serialize_tactic(t: MitreTactic) -> dict:
return {
"id": str(t.id),
"external_id": t.external_id,
"short_name": t.short_name,
"name": t.name,
"description": t.description,
"url": t.url,
}
def _serialize_technique(t: MitreTechnique, *, include_tactics: bool = True) -> dict:
out = {
"id": str(t.id),
"external_id": t.external_id,
"name": t.name,
"description": t.description,
"url": t.url,
}
if include_tactics:
out["tactics"] = sorted(
({"external_id": tac.external_id, "name": tac.name} for tac in t.tactics),
key=lambda d: d["external_id"],
)
return out
def _serialize_subtechnique(sb: MitreSubtechnique) -> dict:
return {
"id": str(sb.id),
"external_id": sb.external_id,
"name": sb.name,
"description": sb.description,
"url": sb.url,
"technique_id": str(sb.technique_id),
}
@bp.get("/tactics")
@require_auth
def list_tactics():
paging = _pagination_args()
if paging[0] is None:
return jsonify({"error": paging[1][1]}), paging[1][0]
limit, offset = paging
q = request.args.get("q") or None
with session_scope() as s:
stmt = select(MitreTactic).order_by(MitreTactic.external_id.asc())
stmt = _search(stmt, MitreTactic, q)
total = s.scalar(_search(select(func.count()).select_from(MitreTactic), MitreTactic, q)) or 0
rows = s.scalars(stmt.limit(limit).offset(offset)).all()
return jsonify(
{
"items": [_serialize_tactic(t) for t in rows],
"total": int(total),
"limit": limit,
"offset": offset,
}
)
@bp.get("/techniques")
@require_auth
def list_techniques():
paging = _pagination_args()
if paging[0] is None:
return jsonify({"error": paging[1][1]}), paging[1][0]
limit, offset = paging
q = request.args.get("q") or None
tactic = request.args.get("tactic") or None
with session_scope() as s:
stmt = select(MitreTechnique).order_by(MitreTechnique.external_id.asc())
count_stmt = select(func.count()).select_from(MitreTechnique)
if tactic:
tac = s.scalar(select(MitreTactic).where(MitreTactic.external_id == tactic))
if tac is None:
return jsonify({"items": [], "total": 0, "limit": limit, "offset": offset})
stmt = stmt.join(MitreTechnique.tactics).where(MitreTactic.id == tac.id)
count_stmt = (
count_stmt.select_from(MitreTechnique)
.join(MitreTechnique.tactics)
.where(MitreTactic.id == tac.id)
)
stmt = _search(stmt, MitreTechnique, q)
count_stmt = _search(count_stmt, MitreTechnique, q)
total = s.scalar(count_stmt) or 0
rows = s.scalars(stmt.limit(limit).offset(offset)).all()
return jsonify(
{
"items": [_serialize_technique(t) for t in rows],
"total": int(total),
"limit": limit,
"offset": offset,
}
)
@bp.get("/subtechniques")
@require_auth
def list_subtechniques():
paging = _pagination_args()
if paging[0] is None:
return jsonify({"error": paging[1][1]}), paging[1][0]
limit, offset = paging
q = request.args.get("q") or None
technique = request.args.get("technique") or None
with session_scope() as s:
stmt = select(MitreSubtechnique).order_by(MitreSubtechnique.external_id.asc())
count_stmt = select(func.count()).select_from(MitreSubtechnique)
if technique:
tech = s.scalar(
select(MitreTechnique).where(MitreTechnique.external_id == technique)
)
if tech is None:
return jsonify({"items": [], "total": 0, "limit": limit, "offset": offset})
stmt = stmt.where(MitreSubtechnique.technique_id == tech.id)
count_stmt = count_stmt.where(MitreSubtechnique.technique_id == tech.id)
stmt = _search(stmt, MitreSubtechnique, q)
count_stmt = _search(count_stmt, MitreSubtechnique, q)
total = s.scalar(count_stmt) or 0
rows = s.scalars(stmt.limit(limit).offset(offset)).all()
return jsonify(
{
"items": [_serialize_subtechnique(sb) for sb in rows],
"total": int(total),
"limit": limit,
"offset": offset,
}
)
@bp.get("/matrix")
@require_auth
def matrix():
"""Return the full Enterprise matrix: tactics → techniques → sub-techniques.
One-shot endpoint so the SPA can render the flat attack.mitre.org-style
grid without firing 15 parallel queries. The payload is ~55 KB serialised
against MITRE v19 (15 tactics × ~50 techniques × ~3 subs).
"""
with session_scope() as s:
# All techniques + their tactics (selectin-loaded by the relationship).
techniques = s.scalars(
select(MitreTechnique).order_by(MitreTechnique.external_id.asc())
).all()
# Sub-techniques bucketed by parent.
subs_by_parent: dict = {}
for sb in s.scalars(
select(MitreSubtechnique).order_by(MitreSubtechnique.external_id.asc())
).all():
subs_by_parent.setdefault(sb.technique_id, []).append(
{
"id": str(sb.id),
"external_id": sb.external_id,
"name": sb.name,
}
)
# Tactics in canonical kill-chain order (matches attack.mitre.org).
tactics = s.scalars(
select(MitreTactic).order_by(MitreTactic.external_id.asc())
).all()
# Group techniques by tactic short_name.
techs_by_tactic: dict = {}
for t in techniques:
entry = {
"id": str(t.id),
"external_id": t.external_id,
"name": t.name,
"subtechniques": subs_by_parent.get(t.id, []),
}
for tac in t.tactics:
techs_by_tactic.setdefault(tac.short_name, []).append(entry)
return jsonify(
{
"tactics": [
{
"id": str(t.id),
"external_id": t.external_id,
"short_name": t.short_name,
"name": t.name,
"techniques": techs_by_tactic.get(t.short_name, []),
}
for t in tactics
]
}
)
@bp.get("/status")
@require_auth
def status():
return jsonify(mitre_seed_svc.read_status())
@bp.post("/sync")
@require_auth
@require_perm("mitre.sync")
def sync():
"""Re-pull the configured (or default) STIX source and upsert.
Custom `source` URLs MUST be paired with either `expected_sha256` (integrity
guarantee) or `allow_unverified: true` (explicit opt-out) — the seed service
will raise otherwise. The host is allowlisted (defaults to
raw.githubusercontent.com, overridable via the MITRE_ALLOWED_HOSTS env).
"""
payload = request.get_json(silent=True) or {}
source = payload.get("source") # optional URL override
expected_sha256 = payload.get("expected_sha256")
allow_unverified = bool(payload.get("allow_unverified", False))
try:
result = mitre_seed_svc.seed_mitre(
source=source,
expected_sha256=expected_sha256
or (mitre_seed_svc.MITRE_DEFAULT_SHA256 if source is None else None),
allow_unverified=allow_unverified,
)
except mitre_seed_svc.MitreSourceForbidden as e:
return jsonify({"error": "source_forbidden", "message": str(e)}), 400
except mitre_seed_svc.MitreChecksumMismatch as e:
return jsonify({"error": "checksum_mismatch", "message": str(e)}), 502
except mitre_seed_svc.MitreSeedError as e:
return jsonify({"error": "seed_failed", "message": str(e)}), 502
except Exception: # noqa: BLE001
# Do NOT leak the internal error string to the client (URLError stack,
# DB driver text). The stack lands in our JSON logs.
log.exception("metamorph.api.mitre.sync_failed")
return jsonify({"error": "internal_error"}), 500
# Validate via the Pydantic Out model so the response contract is
# explicit (single source of truth shared with the TS interface).
payload_out = SyncResultOut.model_validate(result.as_dict()).model_dump()
log.info("metamorph.api.mitre.sync_done", extra=payload_out)
return jsonify(payload_out)
-208
View File
@@ -1,208 +0,0 @@
"""Scenario-template CRUD + reorder endpoints.
`PUT /<id>/tests` is the reorder/replace endpoint — it takes the full ordered
list and rewrites the join rows. There's no partial mutation API for the test
list: the wire contract is simpler and the client (drag-and-drop) already
holds the full ordering.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import require_auth, require_perm
from app.services import scenario_templates as svc
bp = Blueprint("scenario_templates", __name__, url_prefix="/scenario-templates")
log = logging.getLogger("metamorph.api.scenario_templates")
class CreateScenarioPayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
test_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=512)
model_config = {"extra": "forbid"}
class UpdateScenarioPayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
model_config = {"extra": "forbid"}
class SetTestsPayload(BaseModel):
test_template_ids: list[uuid.UUID] = Field(default_factory=list, max_length=512)
model_config = {"extra": "forbid"}
def _serialize(sc: svc.ScenarioTemplateView) -> dict[str, Any]:
return {
"id": str(sc.id),
"name": sc.name,
"description": sc.description,
"tests": [
{
"position": t.position,
"test_template_id": str(t.test_template_id),
"test_template_name": t.test_template_name,
"test_template_deleted": t.test_template_deleted,
}
for t in sc.tests
],
"tests_count": sc.tests_count,
"deleted_at": sc.deleted_at.isoformat() if sc.deleted_at else None,
"created_at": sc.created_at.isoformat(),
"updated_at": sc.updated_at.isoformat(),
}
def _parse_uuid_or_400(raw: str):
try:
return uuid.UUID(raw)
except ValueError:
return None
def _pagination_args() -> tuple[int, int] | tuple[None, tuple[int, str]]:
try:
limit = int(request.args.get("limit", "100"))
offset = int(request.args.get("offset", "0"))
except ValueError:
return None, (400, "invalid_pagination")
return max(1, min(limit, 500)), max(0, offset)
@bp.get("")
@require_auth
@require_perm("scenario_template.read")
def list_scenario_templates():
paging = _pagination_args()
if paging[0] is None:
return jsonify({"error": paging[1][1]}), paging[1][0]
limit, offset = paging
q = request.args.get("q") or None
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
items, total = svc.list_scenario_templates(
q=q, include_deleted=include_deleted, limit=limit, offset=offset
)
return jsonify(
{
"items": [_serialize(it) for it in items],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<scenario_id>")
@require_auth
@require_perm("scenario_template.read")
def get_scenario_template(scenario_id: str):
sid = _parse_uuid_or_400(scenario_id)
if sid is None:
return jsonify({"error": "invalid_id"}), 400
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
try:
view = svc.get_scenario_template(sid, include_deleted=include_deleted)
except svc.ScenarioTemplateNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(view))
@bp.post("")
@require_auth
@require_perm("scenario_template.create")
def create_scenario_template():
try:
payload = CreateScenarioPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
view = svc.create_scenario_template(
name=payload.name,
description=payload.description,
test_template_ids=payload.test_template_ids,
)
except svc.UnknownTestTemplate as e:
return jsonify({"error": "unknown_test_template", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.scenario_template.created",
extra={"id": str(view.id), "tests": len(view.tests)},
)
return jsonify(_serialize(view)), 201
@bp.patch("/<scenario_id>")
@require_auth
@require_perm("scenario_template.update")
def update_scenario_template(scenario_id: str):
sid = _parse_uuid_or_400(scenario_id)
if sid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateScenarioPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
kwargs: dict[str, Any] = {}
if "name" in raw:
kwargs["name"] = payload.name
if "description" in raw:
kwargs["description"] = payload.description
try:
view = svc.update_scenario_template(sid, **kwargs)
except svc.ScenarioTemplateNotFound:
return jsonify({"error": "not_found"}), 404
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return jsonify(_serialize(view))
@bp.put("/<scenario_id>/tests")
@require_auth
@require_perm("scenario_template.update")
def set_scenario_tests(scenario_id: str):
sid = _parse_uuid_or_400(scenario_id)
if sid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetTestsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
view = svc.set_scenario_tests(sid, payload.test_template_ids)
except svc.ScenarioTemplateNotFound:
return jsonify({"error": "not_found"}), 404
except svc.UnknownTestTemplate as e:
return jsonify({"error": "unknown_test_template", "message": str(e)}), 400
log.info(
"metamorph.scenario_template.tests_set",
extra={"id": str(sid), "tests": len(view.tests)},
)
return jsonify(_serialize(view))
@bp.delete("/<scenario_id>")
@require_auth
@require_perm("scenario_template.delete")
def soft_delete_scenario_template(scenario_id: str):
sid = _parse_uuid_or_400(scenario_id)
if sid is None:
return jsonify({"error": "invalid_id"}), 400
try:
svc.soft_delete_scenario_template(sid)
except svc.ScenarioTemplateNotFound:
return jsonify({"error": "not_found"}), 404
log.info("metamorph.scenario_template.soft_deleted", extra={"id": str(sid)})
return jsonify({"ok": True})
-257
View File
@@ -1,257 +0,0 @@
"""Test-template CRUD endpoints.
Reads gated by `test_template.read`. Writes gated by `test_template.{create,
update,delete}`. Service layer handles all DB work; this module only validates
the wire payload and shapes the JSON response.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, StringConstraints, ValidationError
from typing import Annotated
from app.core.auth_decorators import require_auth, require_perm
from app.services import test_templates as svc
# Tag and IOC entries are stored as PG ARRAY(String(N)). Cap items at the wire
# layer so over-sized inputs return 400 with a useful message rather than the
# bare StringDataRightTruncation from the driver.
TagStr = Annotated[str, StringConstraints(min_length=1, max_length=64)]
IocStr = Annotated[str, StringConstraints(min_length=1, max_length=255)]
bp = Blueprint("test_templates", __name__, url_prefix="/test-templates")
log = logging.getLogger("metamorph.api.test_templates")
# === Payload schemas ==========================================================
class MitreTagIn(BaseModel):
kind: str = Field(min_length=1)
external_id: str = Field(min_length=1, max_length=16)
model_config = {"extra": "forbid"}
class CreateTestTemplatePayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
objective: str | None = Field(default=None, max_length=4000)
procedure_md: str | None = Field(default=None, max_length=32_000)
prerequisites_md: str | None = Field(default=None, max_length=32_000)
expected_result_red_md: str | None = Field(default=None, max_length=32_000)
expected_detection_blue_md: str | None = Field(default=None, max_length=32_000)
opsec_level: str = Field(default="medium")
tags: list[TagStr] = Field(default_factory=list, max_length=64)
expected_iocs: list[IocStr] = Field(default_factory=list, max_length=128)
mitre_tags: list[MitreTagIn] = Field(default_factory=list, max_length=64)
model_config = {"extra": "forbid"}
class UpdateTestTemplatePayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
objective: str | None = Field(default=None, max_length=4000)
procedure_md: str | None = Field(default=None, max_length=32_000)
prerequisites_md: str | None = Field(default=None, max_length=32_000)
expected_result_red_md: str | None = Field(default=None, max_length=32_000)
expected_detection_blue_md: str | None = Field(default=None, max_length=32_000)
opsec_level: str | None = None
tags: list[TagStr] | None = Field(default=None, max_length=64)
expected_iocs: list[IocStr] | None = Field(default=None, max_length=128)
mitre_tags: list[MitreTagIn] | None = Field(default=None, max_length=64)
model_config = {"extra": "forbid"}
# === Serializers ==============================================================
def _serialize(t: svc.TestTemplateView) -> dict[str, Any]:
return {
"id": str(t.id),
"name": t.name,
"description": t.description,
"objective": t.objective,
"procedure_md": t.procedure_md,
"prerequisites_md": t.prerequisites_md,
"expected_result_red_md": t.expected_result_red_md,
"expected_detection_blue_md": t.expected_detection_blue_md,
"opsec_level": t.opsec_level,
"tags": list(t.tags),
"expected_iocs": list(t.expected_iocs),
"mitre_tags": [
{"kind": tag.kind, "external_id": tag.external_id, "name": tag.name, "url": tag.url}
for tag in t.mitre_tags
],
"deleted_at": t.deleted_at.isoformat() if t.deleted_at else None,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
def _parse_uuid_or_400(raw: str):
try:
return uuid.UUID(raw)
except ValueError:
return None
def _pagination_args() -> tuple[int, int] | tuple[None, tuple[int, str]]:
try:
limit = int(request.args.get("limit", "100"))
offset = int(request.args.get("offset", "0"))
except ValueError:
return None, (400, "invalid_pagination")
return max(1, min(limit, 500)), max(0, offset)
# === Endpoints ================================================================
@bp.get("")
@require_auth
@require_perm("test_template.read")
def list_test_templates():
paging = _pagination_args()
if paging[0] is None:
return jsonify({"error": paging[1][1]}), paging[1][0]
limit, offset = paging
q = request.args.get("q") or None
tactic = request.args.get("tactic") or None
technique = request.args.get("technique") or None
subtechnique = request.args.get("subtechnique") or None
opsec_level = request.args.get("opsec") or None
tag = request.args.get("tag") or None
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
try:
items, total = svc.list_test_templates(
q=q,
tactic=tactic,
technique=technique,
subtechnique=subtechnique,
opsec_level=opsec_level,
tag=tag,
include_deleted=include_deleted,
limit=limit,
offset=offset,
)
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return jsonify(
{
"items": [_serialize(it) for it in items],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<template_id>")
@require_auth
@require_perm("test_template.read")
def get_test_template(template_id: str):
tid = _parse_uuid_or_400(template_id)
if tid is None:
return jsonify({"error": "invalid_id"}), 400
include_deleted = request.args.get("include_deleted", "false").lower() == "true"
try:
view = svc.get_test_template(tid, include_deleted=include_deleted)
except svc.TestTemplateNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(view))
@bp.post("")
@require_auth
@require_perm("test_template.create")
def create_test_template():
try:
payload = CreateTestTemplatePayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
view = svc.create_test_template(
name=payload.name,
description=payload.description,
objective=payload.objective,
procedure_md=payload.procedure_md,
prerequisites_md=payload.prerequisites_md,
expected_result_red_md=payload.expected_result_red_md,
expected_detection_blue_md=payload.expected_detection_blue_md,
opsec_level=payload.opsec_level,
tags=payload.tags,
expected_iocs=payload.expected_iocs,
mitre_tags=[svc.MitreTagRef(kind=t.kind, external_id=t.external_id) for t in payload.mitre_tags],
)
except svc.UnknownMitreTag as e:
return jsonify({"error": "unknown_mitre_tag", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.test_template.created",
extra={"id": str(view.id), "template_name": view.name},
)
return jsonify(_serialize(view)), 201
@bp.put("/<template_id>")
@require_auth
@require_perm("test_template.update")
def update_test_template(template_id: str):
tid = _parse_uuid_or_400(template_id)
if tid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateTestTemplatePayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
# Only forward keys actually present in the body — model_validate leaves
# missing fields as None and we can't distinguish "explicitly null" from
# "omitted". The set of keys in `raw` is the wire-level intent.
kwargs: dict[str, Any] = {}
for field_name in (
"name", "description", "objective", "procedure_md", "prerequisites_md",
"expected_result_red_md", "expected_detection_blue_md",
"opsec_level", "tags", "expected_iocs",
):
if field_name in raw:
kwargs[field_name] = getattr(payload, field_name)
if "mitre_tags" in raw:
kwargs["mitre_tags"] = (
[svc.MitreTagRef(kind=t.kind, external_id=t.external_id) for t in (payload.mitre_tags or [])]
)
try:
view = svc.update_test_template(tid, **kwargs)
except svc.TestTemplateNotFound:
return jsonify({"error": "not_found"}), 404
except svc.UnknownMitreTag as e:
return jsonify({"error": "unknown_mitre_tag", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info("metamorph.test_template.updated", extra={"id": str(tid), "fields": sorted(kwargs.keys())})
return jsonify(_serialize(view))
@bp.delete("/<template_id>")
@require_auth
@require_perm("test_template.delete")
def soft_delete_test_template(template_id: str):
tid = _parse_uuid_or_400(template_id)
if tid is None:
return jsonify({"error": "invalid_id"}), 400
try:
svc.soft_delete_test_template(tid)
except svc.TestTemplateNotFound:
return jsonify({"error": "not_found"}), 404
log.info("metamorph.test_template.soft_deleted", extra={"id": str(tid)})
return jsonify({"ok": True})
-31
View File
@@ -56,37 +56,6 @@ def _parse_uuid_or_400(raw: str):
return None return None
@bp.get("/roster")
@require_auth
@require_perm("user.read", "mission.create", "mission.update")
def list_roster():
"""Minimal user list for mission member assignment.
Returns only `id`, `email`, `display_name` of active, non-deleted users.
Accessible to anyone who can create or update a mission — strictly lighter
than `GET /users`, which leaks `is_admin` (via groups), `is_active`, and
group memberships and is therefore reserved to `user.read`.
"""
q = request.args.get("q") or None
rows = users_svc.list_users(q=q, is_active=True, limit=200, offset=0)[0]
# Sort by email for predictable rendering and stable e2e selectors.
return jsonify(
{
"items": [
{
"id": str(u.id),
"email": u.email,
"display_name": u.display_name,
}
for u in sorted(
(u for u in rows if u.deleted_at is None),
key=lambda x: x.email,
)
]
}
)
@bp.get("") @bp.get("")
@require_auth @require_auth
@require_perm("user.read") @require_perm("user.read")
-8
View File
@@ -9,12 +9,8 @@ from app.api.diag import bp as diag_bp
from app.api.groups import bp as groups_bp from app.api.groups import bp as groups_bp
from app.api.health import bp as health_bp from app.api.health import bp as health_bp
from app.api.invitations import bp as invitations_bp from app.api.invitations import bp as invitations_bp
from app.api.missions import bp as missions_bp
from app.api.mitre import bp as mitre_bp
from app.api.permissions import bp as permissions_bp from app.api.permissions import bp as permissions_bp
from app.api.scenario_templates import bp as scenario_templates_bp
from app.api.setup import bp as setup_bp from app.api.setup import bp as setup_bp
from app.api.test_templates import bp as test_templates_bp
from app.api.users import bp as users_bp from app.api.users import bp as users_bp
bp = Blueprint("v1", __name__, url_prefix="/api/v1") bp = Blueprint("v1", __name__, url_prefix="/api/v1")
@@ -26,7 +22,3 @@ bp.register_blueprint(invitations_bp)
bp.register_blueprint(users_bp) bp.register_blueprint(users_bp)
bp.register_blueprint(groups_bp) bp.register_blueprint(groups_bp)
bp.register_blueprint(permissions_bp) bp.register_blueprint(permissions_bp)
bp.register_blueprint(mitre_bp)
bp.register_blueprint(test_templates_bp)
bp.register_blueprint(scenario_templates_bp)
bp.register_blueprint(missions_bp)
+4 -60
View File
@@ -56,66 +56,10 @@ def print_install_token(force: bool):
@metamorph.command("seed-mitre") @metamorph.command("seed-mitre")
@click.option( def seed_mitre():
"--source", """Placeholder for M4 — left so `make seed-mitre` doesn't crash."""
default=None, click.echo("MITRE seeding will land in M4. (no-op for now)", err=True)
help="STIX bundle source: local path or HTTPS URL. Defaults to the pinned MITRE Enterprise release.", sys.exit(0)
)
@click.option(
"--checksum-sha256",
"checksum_sha256",
default=None,
help="Expected sha256 of the bundle (required with a non-default --source URL unless --skip-checksum).",
)
@click.option(
"--skip-checksum",
is_flag=True,
help="Skip sha256 verification entirely (escape hatch for testing).",
)
def seed_mitre(source: str | None, checksum_sha256: str | None, skip_checksum: bool):
"""Seed/refresh the MITRE ATT&CK Enterprise reference tables.
Upserts on `external_id`. Re-running with the same source updates the
name/description/url and re-applies the technique↔tactic mapping.
"""
from app.services.mitre_seed import (
MITRE_DEFAULT_SHA256,
MITRE_DEFAULT_URL,
seed_mitre as seed_mitre_svc,
)
if skip_checksum:
expected_sha = None
elif checksum_sha256:
expected_sha = checksum_sha256
elif source is None or source == MITRE_DEFAULT_URL:
expected_sha = MITRE_DEFAULT_SHA256
else:
expected_sha = None # let seed_mitre_svc decide whether to refuse
click.echo(
f"Seeding from {source or MITRE_DEFAULT_URL} "
f"(sha256 check: {'off' if skip_checksum else expected_sha or 'unverified'}) ...",
err=True,
)
try:
result = seed_mitre_svc(
source=source,
expected_sha256=expected_sha,
allow_unverified=skip_checksum,
)
except Exception as e: # noqa: BLE001
click.echo(f"seed-mitre failed: {e}", err=True)
sys.exit(2)
click.echo(
f" tactics: {result.tactics_upserted}, "
f"techniques: {result.techniques_upserted}, "
f"subtechniques: {result.subtechniques_upserted} "
f"(skipped orphans: {result.subtechniques_skipped_orphan}), "
f"links: {result.technique_tactic_links}, "
f"duration: {(result.finished_at - result.started_at).total_seconds():.1f}s",
err=True,
)
app.cli.add_command(metamorph) app.cli.add_command(metamorph)
-944
View File
@@ -1,944 +0,0 @@
"""Mission CRUD + snapshot service.
A mission is a *materialised* run of one or more scenario templates: when the
mission is created (or scenarios are appended later), the service copies the
template rows into `mission_scenarios` / `mission_tests` / `mission_test_mitre_tags`
verbatim. Editing the source templates afterwards does not touch the mission —
that's the snapshot contract from spec §11.
Visibility rule (spec §4, last bullet): a non-admin user can only see a mission
they are a member of. The decorator layer enforces *which type of action* is
allowed (perm codes); this service enforces *which mission* is visible.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from datetime import date, datetime, timezone
from typing import Any, Iterable
from sqlalchemy import func, or_, select, text
from sqlalchemy.orm import Session, selectinload
from app.db.session import session_scope
from app.db.types import (
MISSION_ROLE_HINTS,
MISSION_STATUSES,
)
from app.models.auth import User
from app.models.mission import (
Mission,
MissionMember,
MissionScenario,
MissionTest,
MissionTestMitreTag,
)
from app.models.mitre import MitreSubtechnique, MitreTactic, MitreTechnique
from app.models.template import (
ScenarioTemplate,
TestTemplate,
TestTemplateMitreTag,
)
_UNSET: Any = object()
# Status transition graph. A target status that's not in the source's set is
# rejected as InvalidTransition. `archived` is a one-way sink (un-archiving
# would require an explicit restore endpoint, out of M6 scope).
_VALID_TRANSITIONS: dict[str, frozenset[str]] = {
"draft": frozenset({"in_progress", "archived"}),
"in_progress": frozenset({"completed", "archived"}),
"completed": frozenset({"archived"}),
"archived": frozenset(),
}
# --------------------------------------------------------------------------- #
# Exceptions
# --------------------------------------------------------------------------- #
class MissionNotFound(Exception):
"""Mission missing, soft-deleted, or not visible to the viewer."""
class UnknownScenarioTemplate(Exception):
pass
class UnknownUser(Exception):
pass
class InvalidTransition(Exception):
pass
class InvalidMemberPayload(Exception):
pass
# --------------------------------------------------------------------------- #
# Views (detached dataclasses — safe to return after session_scope exits)
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class MemberAssignment:
"""Inbound member spec. The service resolves the user and validates the hint."""
user_id: uuid.UUID
role_hint: str
@dataclass(frozen=True)
class MissionMemberView:
user_id: uuid.UUID
user_email: str
user_display_name: str | None
role_hint: str
@dataclass(frozen=True)
class MissionMitreTagView:
kind: str
external_id: str
name: str
url: str | None
@dataclass(frozen=True)
class MissionTestView:
id: uuid.UUID
position: int
snapshot_name: str
snapshot_description: str | None
snapshot_objective: str | None
snapshot_procedure_md: str | None
snapshot_prerequisites_md: str | None
snapshot_expected_red_md: str | None
snapshot_expected_blue_md: str | None
snapshot_opsec_level: str
snapshot_tags: list[str]
snapshot_expected_iocs: list[str]
state: str
executed_at: datetime | None
executed_at_overridden: bool
mitre_tags: list[MissionMitreTagView]
source_test_template_id: uuid.UUID | None
@dataclass(frozen=True)
class MissionScenarioView:
id: uuid.UUID
position: int
snapshot_name: str
snapshot_description: str | None
tests: list[MissionTestView]
source_scenario_template_id: uuid.UUID | None
@dataclass(frozen=True)
class MissionListItemView:
id: uuid.UUID
name: str
client_target: str | None
date_start: date | None
date_end: date | None
status: str
description_md: str | None
visibility_mode: str
scenarios_count: int
tests_count: int
members_count: int
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
@dataclass(frozen=True)
class MissionView:
id: uuid.UUID
name: str
client_target: str | None
date_start: date | None
date_end: date | None
status: str
description_md: str | None
visibility_mode: str
scenarios_count: int
tests_count: int
members_count: int
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
scenarios: list[MissionScenarioView]
members: list[MissionMemberView]
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _opt_str(value: str | None) -> str | None:
if value is None:
return None
v = value.strip()
return v or None
def _normalize_name(value: str) -> str:
name = (value or "").strip()
if not name:
raise ValueError("name is required")
if len(name) > 255:
raise ValueError("name must be ≤ 255 characters")
return name
def _validate_dates(date_start: date | None, date_end: date | None) -> None:
if date_start and date_end and date_end < date_start:
raise ValueError("date_end must be on or after date_start")
def _validate_status(value: str) -> str:
if value not in MISSION_STATUSES:
raise ValueError(f"status must be one of {MISSION_STATUSES}")
return value
def _validate_role_hint(value: str) -> str:
if value not in MISSION_ROLE_HINTS:
raise InvalidMemberPayload(f"role_hint must be one of {MISSION_ROLE_HINTS}")
return value
def _escape_like(raw: str) -> str:
"""Escape LIKE wildcards so user-typed `%` / `_` / `\\` stay literal."""
return raw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _lock_scenario_ids_for_snapshot(s: Session, scenario_ids: list[uuid.UUID]) -> None:
"""Acquire a per-scenario `pg_advisory_xact_lock` for every source scenario
we're about to snapshot.
Why: a concurrent admin invoking `set_scenario_tests(scenario_id)` (M5)
deletes-then-reinserts the `scenario_template_tests` join rows mid-transaction.
Under READ COMMITTED, `_snapshot_scenarios` could observe a partial view
(selectinload re-queries) and freeze a torn snapshot. Sharing the same lock
key as `app.services.scenario_templates.set_scenario_tests` makes the
snapshot wait until the reorder commits (and vice versa).
The lock keys are derived deterministically from the scenario UUIDs via
blake2b (cf. lessons: `hash()` is randomised per-worker). We sort the keys
before acquiring to avoid deadlocks with another snapshotter that holds
them in a different order.
"""
if not scenario_ids:
return
keys: list[int] = []
for sid in scenario_ids:
digest = hashlib.blake2b(sid.bytes, digest_size=8).digest()
keys.append(int.from_bytes(digest, "big", signed=True))
for key in sorted(keys):
s.execute(
text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"),
{"key": key},
)
def _is_member(s: Session, mission_id: uuid.UUID, viewer_id: uuid.UUID) -> bool:
return (
s.scalar(
select(func.count())
.select_from(MissionMember)
.where(
MissionMember.mission_id == mission_id,
MissionMember.user_id == viewer_id,
)
)
or 0
) > 0
def _membership_filter(viewer_id: uuid.UUID):
"""SQL predicate restricting to missions where viewer_id is a member."""
return Mission.id.in_(
select(MissionMember.mission_id).where(MissionMember.user_id == viewer_id)
)
def _load_users_map(s: Session, ids: Iterable[uuid.UUID]) -> dict[uuid.UUID, User]:
ids_list = [i for i in ids]
if not ids_list:
return {}
rows = s.scalars(
select(User).where(User.id.in_(ids_list), User.deleted_at.is_(None))
).all()
return {u.id: u for u in rows}
# --------------------------------------------------------------------------- #
# MITRE denormalisation
# --------------------------------------------------------------------------- #
def _collect_mitre_ids(
tag_rows: Iterable[TestTemplateMitreTag],
) -> tuple[set[uuid.UUID], set[uuid.UUID], set[uuid.UUID]]:
tactic_ids: set[uuid.UUID] = set()
technique_ids: set[uuid.UUID] = set()
sub_ids: set[uuid.UUID] = set()
for tag in tag_rows:
if tag.mitre_kind == "tactic" and tag.tactic_id is not None:
tactic_ids.add(tag.tactic_id)
elif tag.mitre_kind == "technique" and tag.technique_id is not None:
technique_ids.add(tag.technique_id)
elif tag.mitre_kind == "subtechnique" and tag.subtechnique_id is not None:
sub_ids.add(tag.subtechnique_id)
return tactic_ids, technique_ids, sub_ids
def _resolve_mitre_lookup(
s: Session,
tactic_ids: set[uuid.UUID],
technique_ids: set[uuid.UUID],
sub_ids: set[uuid.UUID],
) -> tuple[
dict[uuid.UUID, MitreTactic],
dict[uuid.UUID, MitreTechnique],
dict[uuid.UUID, MitreSubtechnique],
]:
"""Batch-load all MITRE rows referenced by a snapshot in 3 queries."""
tactic_map: dict[uuid.UUID, MitreTactic] = {}
technique_map: dict[uuid.UUID, MitreTechnique] = {}
sub_map: dict[uuid.UUID, MitreSubtechnique] = {}
if tactic_ids:
tactic_map = {
r.id: r
for r in s.scalars(
select(MitreTactic).where(MitreTactic.id.in_(tactic_ids))
).all()
}
if technique_ids:
technique_map = {
r.id: r
for r in s.scalars(
select(MitreTechnique).where(MitreTechnique.id.in_(technique_ids))
).all()
}
if sub_ids:
sub_map = {
r.id: r
for r in s.scalars(
select(MitreSubtechnique).where(MitreSubtechnique.id.in_(sub_ids))
).all()
}
return tactic_map, technique_map, sub_map
def _snapshot_tag(
tag: TestTemplateMitreTag,
tactic_map: dict[uuid.UUID, MitreTactic],
technique_map: dict[uuid.UUID, MitreTechnique],
sub_map: dict[uuid.UUID, MitreSubtechnique],
) -> MissionTestMitreTag | None:
"""Convert a template's polymorphic MITRE tag into a frozen mission tag.
Returns None if the referenced MITRE row vanished between read and snapshot
(paranoid: should not happen inside one tx).
"""
if tag.mitre_kind == "tactic" and tag.tactic_id in tactic_map:
r = tactic_map[tag.tactic_id]
return MissionTestMitreTag(
mitre_kind="tactic",
mitre_external_id=r.external_id,
mitre_name=r.name,
mitre_url=r.url,
)
if tag.mitre_kind == "technique" and tag.technique_id in technique_map:
r = technique_map[tag.technique_id]
return MissionTestMitreTag(
mitre_kind="technique",
mitre_external_id=r.external_id,
mitre_name=r.name,
mitre_url=r.url,
)
if tag.mitre_kind == "subtechnique" and tag.subtechnique_id in sub_map:
r = sub_map[tag.subtechnique_id]
return MissionTestMitreTag(
mitre_kind="subtechnique",
mitre_external_id=r.external_id,
mitre_name=r.name,
mitre_url=r.url,
)
return None
# --------------------------------------------------------------------------- #
# Snapshot
# --------------------------------------------------------------------------- #
def _load_scenario_templates_for_snapshot(
s: Session, scenario_ids: list[uuid.UUID]
) -> dict[uuid.UUID, ScenarioTemplate]:
"""Load scenarios in eager-load mode and reject unknowns/soft-deleted upfront."""
if not scenario_ids:
return {}
rows = s.scalars(
select(ScenarioTemplate)
.options(selectinload(ScenarioTemplate.tests))
.where(ScenarioTemplate.id.in_(scenario_ids))
).all()
by_id = {sc.id: sc for sc in rows}
missing = set(scenario_ids) - by_id.keys()
if missing:
raise UnknownScenarioTemplate(
f"unknown scenario_template ids: {sorted(str(m) for m in missing)}"
)
deleted = [sc.id for sc in rows if sc.deleted_at is not None]
if deleted:
raise UnknownScenarioTemplate(
f"cannot snapshot soft-deleted scenario_template ids: "
f"{sorted(str(d) for d in deleted)}"
)
return by_id
def _snapshot_scenarios(
s: Session,
mission_id: uuid.UUID,
scenario_ids: list[uuid.UUID],
start_position: int,
) -> None:
"""Append `scenario_ids` as new MissionScenario+MissionTest rows under the mission.
Position counter continues from `start_position`. Each scenario_template's
`tests` order is preserved 1:1. MITRE tags on the source templates are
copied as denormalised `MissionTestMitreTag` rows (frozen external_id/name/url).
"""
if not scenario_ids:
return
_lock_scenario_ids_for_snapshot(s, scenario_ids)
sc_by_id = _load_scenario_templates_for_snapshot(s, scenario_ids)
# Collect the underlying test_template ids in stable order.
ordered_test_ids: list[uuid.UUID] = []
for sid in scenario_ids:
sc = sc_by_id[sid]
for link in sc.tests:
ordered_test_ids.append(link.test_template_id)
test_template_map: dict[uuid.UUID, TestTemplate] = {}
if ordered_test_ids:
test_template_rows = s.scalars(
select(TestTemplate)
.options(selectinload(TestTemplate.mitre_tags))
.where(TestTemplate.id.in_(set(ordered_test_ids)))
).all()
test_template_map = {t.id: t for t in test_template_rows}
# A test_template may be soft-deleted between the scenario authoring and
# the mission creation. We do not refuse the snapshot (the user expects
# the scenario's planned tests to appear); we just freeze the last
# known content, which is what a snapshot is for.
missing_t = set(ordered_test_ids) - test_template_map.keys()
if missing_t:
raise UnknownScenarioTemplate(
f"scenario references missing test_template ids: "
f"{sorted(str(m) for m in missing_t)}"
)
# Pre-load all MITRE rows referenced by any tag across all involved templates.
all_tag_rows: list[TestTemplateMitreTag] = []
for t in test_template_map.values():
all_tag_rows.extend(t.mitre_tags)
tactic_map, technique_map, sub_map = _resolve_mitre_lookup(
s, *_collect_mitre_ids(all_tag_rows)
)
pos = start_position
for sid in scenario_ids:
sc = sc_by_id[sid]
ms = MissionScenario(
mission_id=mission_id,
source_scenario_template_id=sc.id,
snapshot_name=sc.name,
snapshot_description=sc.description,
position=pos,
)
s.add(ms)
s.flush() # populate ms.id for the child tests
test_pos = 0
for link in sc.tests:
tt = test_template_map[link.test_template_id]
mt = MissionTest(
scenario_id=ms.id,
source_test_template_id=tt.id,
position=test_pos,
snapshot_name=tt.name,
snapshot_description=tt.description,
snapshot_objective=tt.objective,
snapshot_procedure_md=tt.procedure_md,
snapshot_prerequisites_md=tt.prerequisites_md,
snapshot_expected_red_md=tt.expected_result_red_md,
snapshot_expected_blue_md=tt.expected_detection_blue_md,
snapshot_opsec_level=tt.opsec_level,
snapshot_tags=list(tt.tags or []),
snapshot_expected_iocs=list(tt.expected_iocs or []),
state="pending",
)
s.add(mt)
s.flush()
for src_tag in tt.mitre_tags:
snap = _snapshot_tag(src_tag, tactic_map, technique_map, sub_map)
if snap is not None:
snap.mission_test_id = mt.id
s.add(snap)
test_pos += 1
pos += 1
s.flush()
# --------------------------------------------------------------------------- #
# View assembly
# --------------------------------------------------------------------------- #
def _member_views(s: Session, mission: Mission) -> list[MissionMemberView]:
if not mission.members:
return []
users = _load_users_map(s, [m.user_id for m in mission.members])
out: list[MissionMemberView] = []
for m in mission.members:
u = users.get(m.user_id)
out.append(
MissionMemberView(
user_id=m.user_id,
user_email=u.email if u else "<deleted>",
user_display_name=(u.display_name if u else None),
role_hint=m.role_hint,
)
)
out.sort(key=lambda mv: (mv.role_hint, mv.user_email))
return out
def _scenario_views(scenarios: list[MissionScenario]) -> list[MissionScenarioView]:
"""Assemble scenario views. `mission_scenarios` and `mission_tests` both
carry `SoftDeleteMixin`; M6 doesn't surface soft-deletion of those rows in
any endpoint, but the filter is applied here so future deletions (M7+)
don't drift the rendered list silently."""
views: list[MissionScenarioView] = []
live = [sc for sc in scenarios if sc.deleted_at is None]
for sc in sorted(live, key=lambda s_: s_.position):
test_views: list[MissionTestView] = []
live_tests = [t for t in sc.tests if t.deleted_at is None]
for t in sorted(live_tests, key=lambda t_: t_.position):
tag_views = [
MissionMitreTagView(
kind=tag.mitre_kind,
external_id=tag.mitre_external_id,
name=tag.mitre_name,
url=tag.mitre_url,
)
for tag in sorted(
t.mitre_tags, key=lambda tg: (tg.mitre_kind, tg.mitre_external_id)
)
]
test_views.append(
MissionTestView(
id=t.id,
position=t.position,
snapshot_name=t.snapshot_name,
snapshot_description=t.snapshot_description,
snapshot_objective=t.snapshot_objective,
snapshot_procedure_md=t.snapshot_procedure_md,
snapshot_prerequisites_md=t.snapshot_prerequisites_md,
snapshot_expected_red_md=t.snapshot_expected_red_md,
snapshot_expected_blue_md=t.snapshot_expected_blue_md,
snapshot_opsec_level=t.snapshot_opsec_level,
snapshot_tags=list(t.snapshot_tags or []),
snapshot_expected_iocs=list(t.snapshot_expected_iocs or []),
state=t.state,
executed_at=t.executed_at,
executed_at_overridden=t.executed_at_overridden,
mitre_tags=tag_views,
source_test_template_id=t.source_test_template_id,
)
)
views.append(
MissionScenarioView(
id=sc.id,
position=sc.position,
snapshot_name=sc.snapshot_name,
snapshot_description=sc.snapshot_description,
tests=test_views,
source_scenario_template_id=sc.source_scenario_template_id,
)
)
return views
def _to_detail_view(s: Session, m: Mission) -> MissionView:
scenarios = [sc for sc in m.scenarios if sc.deleted_at is None]
members = _member_views(s, m)
scenario_views = _scenario_views(scenarios)
tests_count = sum(len(sc.tests) for sc in scenario_views)
return MissionView(
id=m.id,
name=m.name,
client_target=m.client_target,
date_start=m.date_start,
date_end=m.date_end,
status=m.status,
description_md=m.description_md,
visibility_mode=m.visibility_mode,
scenarios_count=len(scenario_views),
tests_count=tests_count,
members_count=len(members),
deleted_at=m.deleted_at,
created_at=m.created_at,
updated_at=m.updated_at,
scenarios=scenario_views,
members=members,
)
def _to_list_item(m: Mission) -> MissionListItemView:
# Cheap counts via the loaded relationships (selectinloaded by the caller).
# We filter soft-deleted children consistently with `_scenario_views` so
# the list and the detail page agree.
live_scenarios = [sc for sc in m.scenarios if sc.deleted_at is None]
tests_count = sum(
len([t for t in sc.tests if t.deleted_at is None]) for sc in live_scenarios
)
return MissionListItemView(
id=m.id,
name=m.name,
client_target=m.client_target,
date_start=m.date_start,
date_end=m.date_end,
status=m.status,
description_md=m.description_md,
visibility_mode=m.visibility_mode,
scenarios_count=len(live_scenarios),
tests_count=tests_count,
members_count=len(m.members),
deleted_at=m.deleted_at,
created_at=m.created_at,
updated_at=m.updated_at,
)
# --------------------------------------------------------------------------- #
# Public API — list / get
# --------------------------------------------------------------------------- #
def list_missions(
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
q: str | None = None,
status: str | None = None,
client: str | None = None,
include_deleted: bool = False,
limit: int = 100,
offset: int = 0,
) -> tuple[list[MissionListItemView], int]:
with session_scope() as s:
stmt = (
select(Mission)
.options(
selectinload(Mission.scenarios).selectinload(MissionScenario.tests),
selectinload(Mission.members),
)
.order_by(Mission.created_at.desc(), Mission.id.desc())
)
count_stmt = select(func.count()).select_from(Mission)
if not include_deleted:
stmt = stmt.where(Mission.deleted_at.is_(None))
count_stmt = count_stmt.where(Mission.deleted_at.is_(None))
if not viewer_is_admin:
stmt = stmt.where(_membership_filter(viewer_id))
count_stmt = count_stmt.where(_membership_filter(viewer_id))
if status:
_validate_status(status)
stmt = stmt.where(Mission.status == status)
count_stmt = count_stmt.where(Mission.status == status)
if client:
like = f"%{_escape_like(client.lower())}%"
cond = func.lower(Mission.client_target).like(like, escape="\\")
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
if q:
like = f"%{_escape_like(q.lower())}%"
cond = or_(
func.lower(Mission.name).like(like, escape="\\"),
func.lower(Mission.description_md).like(like, escape="\\"),
)
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
total = s.scalar(count_stmt) or 0
rows = s.scalars(
stmt.limit(max(1, min(limit, 500))).offset(max(0, offset))
).all()
return [_to_list_item(m) for m in rows], int(total)
def get_mission(
mission_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
include_deleted: bool = False,
) -> MissionView:
with session_scope() as s:
m = s.get(Mission, mission_id)
if m is None:
raise MissionNotFound()
if m.deleted_at is not None and not include_deleted:
raise MissionNotFound()
if not viewer_is_admin and not _is_member(s, mission_id, viewer_id):
raise MissionNotFound()
return _to_detail_view(s, m)
# --------------------------------------------------------------------------- #
# Public API — write
# --------------------------------------------------------------------------- #
def _validate_members(s: Session, members: list[MemberAssignment]) -> None:
"""Reject duplicates, bad role hints, unknown/soft-deleted users."""
seen: set[uuid.UUID] = set()
for m in members:
if m.user_id in seen:
raise InvalidMemberPayload(f"duplicate user_id: {m.user_id}")
seen.add(m.user_id)
_validate_role_hint(m.role_hint)
if not members:
return
user_map = _load_users_map(s, seen)
missing = seen - user_map.keys()
if missing:
raise UnknownUser(f"unknown or deleted user_ids: {sorted(str(u) for u in missing)}")
def create_mission(
*,
name: str,
creator_id: uuid.UUID,
creator_is_admin: bool,
client_target: str | None = None,
date_start: date | None = None,
date_end: date | None = None,
description_md: str | None = None,
scenario_template_ids: list[uuid.UUID] | None = None,
members: list[MemberAssignment] | None = None,
) -> MissionView:
"""Create a mission and snapshot the requested scenarios + their tests.
Side effect: if `creator_is_admin` is False and the creator is not in
`members`, they are added with `role_hint='red'`. This prevents the
non-admin creator from immediately losing visibility on the mission they
just created (membership-based visibility, see spec §4).
"""
name_norm = _normalize_name(name)
_validate_dates(date_start, date_end)
scenarios = list(scenario_template_ids or [])
members_list = list(members or [])
with session_scope() as s:
_validate_members(s, members_list)
# Auto-add the non-admin creator as a member so they retain visibility.
if not creator_is_admin and not any(m.user_id == creator_id for m in members_list):
members_list = [
MemberAssignment(user_id=creator_id, role_hint="red"),
*members_list,
]
# Defensive re-validation in case the creator id was bogus.
_validate_members(s, members_list)
mission = Mission(
name=name_norm,
client_target=_opt_str(client_target),
date_start=date_start,
date_end=date_end,
description_md=_opt_str(description_md),
status="draft",
visibility_mode="whitebox",
)
s.add(mission)
s.flush()
for m in members_list:
s.add(
MissionMember(
mission_id=mission.id,
user_id=m.user_id,
role_hint=m.role_hint,
)
)
if scenarios:
_snapshot_scenarios(s, mission.id, scenarios, start_position=0)
s.flush()
s.refresh(mission)
return _to_detail_view(s, mission)
def update_mission_metadata(
mission_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
name: str | None = None,
client_target: Any = _UNSET,
date_start: Any = _UNSET,
date_end: Any = _UNSET,
description_md: Any = _UNSET,
) -> MissionView:
with session_scope() as s:
m = s.get(Mission, mission_id)
if m is None or m.deleted_at is not None:
raise MissionNotFound()
if not viewer_is_admin and not _is_member(s, mission_id, viewer_id):
raise MissionNotFound()
if name is not None:
m.name = _normalize_name(name)
if client_target is not _UNSET:
m.client_target = _opt_str(client_target)
if date_start is not _UNSET:
m.date_start = date_start
if date_end is not _UNSET:
m.date_end = date_end
# Validate the combined date pair regardless of which side was passed.
_validate_dates(m.date_start, m.date_end)
if description_md is not _UNSET:
m.description_md = _opt_str(description_md)
s.flush()
s.refresh(m)
return _to_detail_view(s, m)
def add_scenarios_to_mission(
mission_id: uuid.UUID,
scenario_template_ids: list[uuid.UUID],
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> MissionView:
"""Append more snapshot scenarios to an existing mission.
They land at `current_max_position + 1` and onwards. Empty list is a no-op
and just returns the current view.
"""
with session_scope() as s:
m = s.get(Mission, mission_id)
if m is None or m.deleted_at is not None:
raise MissionNotFound()
if not viewer_is_admin and not _is_member(s, mission_id, viewer_id):
raise MissionNotFound()
if scenario_template_ids:
max_pos = s.scalar(
select(func.coalesce(func.max(MissionScenario.position), -1)).where(
MissionScenario.mission_id == mission_id
)
)
_snapshot_scenarios(
s,
mission_id,
list(scenario_template_ids),
start_position=int(max_pos) + 1,
)
s.flush()
s.refresh(m)
return _to_detail_view(s, m)
def set_mission_members(
mission_id: uuid.UUID,
members: list[MemberAssignment],
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> MissionView:
"""Replace the entire member set. Wipe + insert, like the scenario reorder."""
with session_scope() as s:
m = s.get(Mission, mission_id)
if m is None or m.deleted_at is not None:
raise MissionNotFound()
if not viewer_is_admin and not _is_member(s, mission_id, viewer_id):
raise MissionNotFound()
_validate_members(s, members)
for link in list(m.members):
s.delete(link)
s.flush()
for assignment in members:
s.add(
MissionMember(
mission_id=m.id,
user_id=assignment.user_id,
role_hint=assignment.role_hint,
)
)
s.flush()
s.refresh(m)
return _to_detail_view(s, m)
def transition_mission_status(
mission_id: uuid.UUID,
target_status: str,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> MissionView:
"""Move the mission's status one step along the lifecycle graph."""
_validate_status(target_status)
with session_scope() as s:
m = s.get(Mission, mission_id)
if m is None or m.deleted_at is not None:
raise MissionNotFound()
if not viewer_is_admin and not _is_member(s, mission_id, viewer_id):
raise MissionNotFound()
if target_status == m.status:
# No-op transitions are valid: a client retry should not 409.
s.refresh(m)
return _to_detail_view(s, m)
allowed = _VALID_TRANSITIONS.get(m.status, frozenset())
if target_status not in allowed:
raise InvalidTransition(
f"cannot transition from {m.status!r} to {target_status!r}"
)
m.status = target_status
s.flush()
s.refresh(m)
return _to_detail_view(s, m)
def soft_delete_mission(
mission_id: uuid.UUID,
*,
viewer_id: uuid.UUID,
viewer_is_admin: bool,
) -> None:
with session_scope() as s:
m = s.get(Mission, mission_id)
if m is None or m.deleted_at is not None:
raise MissionNotFound()
if not viewer_is_admin and not _is_member(s, mission_id, viewer_id):
raise MissionNotFound()
m.deleted_at = datetime.now(tz=timezone.utc)
-515
View File
@@ -1,515 +0,0 @@
"""MITRE ATT&CK Enterprise seed + sync.
Parses a STIX 2.1 bundle into the `mitre_*` tables. Idempotent: re-running
upserts on `external_id`, refreshes name/description/url, and re-applies the
technique↔tactic mapping. Sub-techniques whose parent is missing in the
bundle are skipped (with a WARNING log).
Defaults pin a specific Enterprise release (see `MITRE_DEFAULT_*`). The pin
is honored by the CLI (`flask metamorph seed-mitre`) and by the
`POST /mitre/sync` admin endpoint; both accept a `--source` / `source_url`
override for air-gapped operators.
The bundle is downloaded with `urllib.request` (stdlib — no extra dep) and
cached at `MITRE_BUNDLE_CACHE_PATH` (default `/data/mitre/<basename>.json`).
Pass an absolute path as `source` to bypass the network entirely.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
from sqlalchemy import delete, select, text as sql_text
from app.db.session import session_scope
from app.models.mitre import (
MitreSubtechnique,
MitreTactic,
MitreTechnique,
MitreTechniqueTactic,
)
from app.models.setting import Setting
log = logging.getLogger("metamorph.mitre.seed")
# === Default pin =============================================================
#
# MITRE publishes versioned bundles at
# `https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack-<X.Y>.json`.
# Update these three constants in lock-step when bumping the pin. The SHA256
# is verified against the downloaded bytes — a mismatch aborts the seed.
#
MITRE_VERSION = "19.0"
MITRE_DEFAULT_URL = (
"https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/"
"enterprise-attack/enterprise-attack-19.0.json"
)
MITRE_DEFAULT_SHA256 = "df520ea0775a57db7bff760145b02fed89290802913e056b7ed5970b02f3626a"
MITRE_BUNDLE_CACHE_PATH = Path(os.environ.get("MITRE_CACHE_DIR", "/data/mitre"))
MITRE_DOWNLOAD_TIMEOUT_SECONDS = 120
# Hosts authorised as a source for a MITRE sync. An admin holding `mitre.sync`
# could otherwise pivot the in-container HTTP fetch to internal services
# (169.254.169.254, db, internal mirrors). Override via the `MITRE_ALLOWED_HOSTS`
# env (comma-separated) when running against a private mirror.
MITRE_ALLOWED_HOSTS: frozenset[str] = frozenset(
h.strip()
for h in os.environ.get(
"MITRE_ALLOWED_HOSTS", "raw.githubusercontent.com"
).split(",")
if h.strip()
)
# Settings keys used to expose the seed metadata to the operator UI/CLI.
SETTING_LAST_SYNC = "mitre_last_sync"
SETTING_VERSION = "mitre_version"
SETTING_SOURCE_URL = "mitre_source_url"
ATTACK_SOURCE_NAME = "mitre-attack"
KILL_CHAIN_NAME = "mitre-attack"
class MitreSeedError(Exception):
pass
class MitreChecksumMismatch(MitreSeedError):
pass
class MitreSourceForbidden(MitreSeedError):
"""The provided source URL points to a host outside the allowlist."""
@dataclass
class ParsedBundle:
tactics: list[dict] = field(default_factory=list)
techniques: list[dict] = field(default_factory=list) # parent techniques
subtechniques: list[dict] = field(default_factory=list)
# Map: subtechnique attack-pattern STIX id -> parent technique STIX id
subtechnique_parents: dict[str, str] = field(default_factory=dict)
spec_version: str | None = None
@dataclass
class SeedResult:
tactics_upserted: int
techniques_upserted: int
subtechniques_upserted: int
subtechniques_skipped_orphan: int
technique_tactic_links: int
version: str | None
source: str
started_at: datetime
finished_at: datetime
def as_dict(self) -> dict:
return {
"tactics_upserted": self.tactics_upserted,
"techniques_upserted": self.techniques_upserted,
"subtechniques_upserted": self.subtechniques_upserted,
"subtechniques_skipped_orphan": self.subtechniques_skipped_orphan,
"technique_tactic_links": self.technique_tactic_links,
"version": self.version,
"source": self.source,
"started_at": self.started_at.isoformat(),
"finished_at": self.finished_at.isoformat(),
"duration_ms": int(
(self.finished_at - self.started_at).total_seconds() * 1000
),
}
# === I/O =====================================================================
def _is_url(source: str) -> bool:
parsed = urllib.parse.urlparse(source)
return parsed.scheme in ("http", "https")
def _ensure_host_allowed(url: str) -> None:
"""Raise MitreSourceForbidden if the URL targets a non-allowlisted host."""
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise MitreSourceForbidden(f"unsupported URL scheme: {parsed.scheme!r}")
host = (parsed.hostname or "").lower()
if host not in MITRE_ALLOWED_HOSTS:
raise MitreSourceForbidden(
f"host {host!r} not in MITRE_ALLOWED_HOSTS={sorted(MITRE_ALLOWED_HOSTS)}"
)
def _sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""):
h.update(chunk)
return h.hexdigest()
def _download(url: str, dest: Path, *, expected_sha256: str | None = None) -> Path:
_ensure_host_allowed(url)
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
log.info("metamorph.mitre.download.start", extra={"url": url, "dest": str(dest)})
req = urllib.request.Request(url, headers={"User-Agent": "metamorph-mitre-seed/1.0"})
with urllib.request.urlopen(req, timeout=MITRE_DOWNLOAD_TIMEOUT_SECONDS) as resp:
with tmp.open("wb") as f:
for chunk in iter(lambda: resp.read(1 << 16), b""):
f.write(chunk)
if expected_sha256:
actual = _sha256_of(tmp)
if actual != expected_sha256:
tmp.unlink(missing_ok=True)
raise MitreChecksumMismatch(
f"sha256 mismatch for {url}: expected {expected_sha256}, got {actual}"
)
tmp.replace(dest)
log.info(
"metamorph.mitre.download.done",
extra={"url": url, "bytes": dest.stat().st_size},
)
return dest
def resolve_source_to_path(
source: str | Path | None,
*,
cache_dir: Path = MITRE_BUNDLE_CACHE_PATH,
expected_sha256: str | None = MITRE_DEFAULT_SHA256,
) -> tuple[Path, str]:
"""Return (path, source_label). Downloads if `source` is an URL; otherwise
treats it as a local file. `None` → default URL.
`source_label` is what we persist in `settings.mitre_source_url`."""
if source is None:
source = MITRE_DEFAULT_URL
source_str = str(source)
if _is_url(source_str):
basename = source_str.rsplit("/", 1)[-1] or "enterprise-attack.json"
dest = cache_dir / basename
_download(source_str, dest, expected_sha256=expected_sha256)
return dest, source_str
path = Path(source_str)
if not path.exists():
raise MitreSeedError(f"source path does not exist: {path}")
return path, str(path)
# === STIX parsing ============================================================
def _attack_ref(obj: dict) -> dict | None:
for ref in obj.get("external_references") or ():
if ref.get("source_name") == ATTACK_SOURCE_NAME and ref.get("external_id"):
return ref
return None
def parse_bundle(path: Path) -> ParsedBundle:
"""Read the STIX bundle into normalized dicts ready for SQL upserts."""
with path.open("r", encoding="utf-8") as f:
bundle = json.load(f)
objs = bundle.get("objects") or []
parsed = ParsedBundle(spec_version=bundle.get("spec_version"))
parents_by_subtech: dict[str, str] = {}
for o in objs:
if (
o.get("type") == "relationship"
and o.get("relationship_type") == "subtechnique-of"
and not o.get("revoked")
):
parents_by_subtech[o["source_ref"]] = o["target_ref"]
parsed.subtechnique_parents = parents_by_subtech
for o in objs:
if o.get("revoked") or o.get("x_mitre_deprecated"):
continue
kind = o.get("type")
if kind == "x-mitre-tactic":
ref = _attack_ref(o)
if not ref:
continue
parsed.tactics.append(
{
"external_id": ref["external_id"],
"name": o.get("name") or "",
"short_name": o.get("x_mitre_shortname") or "",
"description": o.get("description"),
"url": ref.get("url"),
}
)
elif kind == "attack-pattern":
ref = _attack_ref(o)
if not ref:
continue
common = {
"external_id": ref["external_id"],
"name": o.get("name") or "",
"description": o.get("description"),
"url": ref.get("url"),
}
if o.get("x_mitre_is_subtechnique"):
parent_stix = parents_by_subtech.get(o["id"])
parsed.subtechniques.append(
{**common, "stix_id": o["id"], "parent_stix_id": parent_stix}
)
else:
# Capture kill_chain_phases so we can map to tactics by short_name.
phases = [
p.get("phase_name")
for p in (o.get("kill_chain_phases") or ())
if p.get("kill_chain_name") == KILL_CHAIN_NAME and p.get("phase_name")
]
parsed.techniques.append(
{**common, "stix_id": o["id"], "phase_names": phases}
)
return parsed
# === DB upserts ==============================================================
def _upsert_tactics(s, tactics: Iterable[dict]) -> tuple[dict, int]:
"""Upsert tactics. Returns (short_name → tactic_id, n_upserted)."""
existing = {t.external_id: t for t in s.scalars(select(MitreTactic)).all()}
short_to_id: dict = {}
upserted = 0
for t in tactics:
row = existing.get(t["external_id"])
if row is None:
row = MitreTactic(
external_id=t["external_id"],
short_name=t["short_name"],
name=t["name"],
description=t["description"],
url=t["url"],
)
s.add(row)
s.flush()
upserted += 1
else:
row.short_name = t["short_name"]
row.name = t["name"]
row.description = t["description"]
row.url = t["url"]
upserted += 1
short_to_id[t["short_name"]] = row.id
return short_to_id, upserted
def _upsert_techniques(
s, techniques: Iterable[dict], short_to_tactic_id: dict
) -> tuple[dict, int, int]:
"""Upsert techniques + their tactic links. Returns (stix_id→technique_id, n_upserted, n_links)."""
existing = {t.external_id: t for t in s.scalars(select(MitreTechnique)).all()}
stix_to_id: dict = {}
n_upserted = 0
n_links = 0
# We'll rebuild the technique↔tactic mapping for clarity (drop + add). This
# is O(techniques × tactics) but cheap relative to the parse itself.
s.execute(delete(MitreTechniqueTactic))
for t in techniques:
row = existing.get(t["external_id"])
if row is None:
row = MitreTechnique(
external_id=t["external_id"],
name=t["name"],
description=t["description"],
url=t["url"],
)
s.add(row)
s.flush()
else:
row.name = t["name"]
row.description = t["description"]
row.url = t["url"]
n_upserted += 1
stix_to_id[t["stix_id"]] = row.id
for phase in t.get("phase_names", []):
tac_id = short_to_tactic_id.get(phase)
if tac_id is None:
# Tactic referenced but not in bundle — log + skip.
log.warning(
"metamorph.mitre.unknown_tactic_phase",
extra={"technique": t["external_id"], "phase": phase},
)
continue
s.add(MitreTechniqueTactic(technique_id=row.id, tactic_id=tac_id))
n_links += 1
return stix_to_id, n_upserted, n_links
def _upsert_subtechniques(
s,
subtechniques: Iterable[dict],
stix_to_tech_id: dict,
) -> tuple[int, int]:
"""Returns (n_upserted, n_skipped_orphans).
`n_upserted` is the count of rows whose state was applied (INSERT or
UPDATE) — matches Postgres upsert semantics.
"""
existing = {sb.external_id: sb for sb in s.scalars(select(MitreSubtechnique)).all()}
# Pre-index techniques by external_id so the dotted-id fallback doesn't
# issue N+1 SELECTs (was a latent footgun for partial-bundle re-syncs).
parent_by_external: dict[str, object] = {
t.external_id: t.id
for t in s.scalars(select(MitreTechnique)).all()
}
n_upserted = 0
n_skipped = 0
for sb in subtechniques:
parent_stix = sb.get("parent_stix_id")
parent_id = stix_to_tech_id.get(parent_stix) if parent_stix else None
if parent_id is None:
# Fall back to the dotted external_id convention (T1003.001 → T1003).
m = re.match(r"^(T\d+)\.\d+$", sb["external_id"])
if m:
parent_id = parent_by_external.get(m.group(1))
if parent_id is None:
log.warning(
"metamorph.mitre.orphan_subtechnique",
extra={"subtechnique": sb["external_id"]},
)
n_skipped += 1
continue
row = existing.get(sb["external_id"])
if row is None:
s.add(
MitreSubtechnique(
external_id=sb["external_id"],
name=sb["name"],
description=sb["description"],
url=sb["url"],
technique_id=parent_id,
)
)
else:
row.name = sb["name"]
row.description = sb["description"]
row.url = sb["url"]
row.technique_id = parent_id
n_upserted += 1
return n_upserted, n_skipped
def _upsert_setting(s, key: str, value: object) -> None:
row = s.scalar(select(Setting).where(Setting.key == key))
if row is None:
s.add(Setting(key=key, value=value))
else:
row.value = value
# === Entry point =============================================================
def seed_mitre(
*,
source: str | Path | None = None,
expected_sha256: str | None = MITRE_DEFAULT_SHA256,
cache_dir: Path = MITRE_BUNDLE_CACHE_PATH,
allow_unverified: bool = False,
) -> SeedResult:
"""Top-level seed. URL → download + verify + parse; path → just parse.
Custom URLs (anything other than `MITRE_DEFAULT_URL`) MUST be paired with
an `expected_sha256` for integrity, or with `allow_unverified=True` to opt
out explicitly. This avoids a silent integrity bypass when an operator
points the sync at a typo'd or attacker-controlled mirror.
"""
started_at = datetime.now(tz=timezone.utc)
if source is not None and _is_url(str(source)) and str(source) != MITRE_DEFAULT_URL:
if expected_sha256 is None or expected_sha256 == MITRE_DEFAULT_SHA256:
# The caller passed a non-default URL but didn't override the hash:
# MITRE_DEFAULT_SHA256 would obviously not match → force an explicit
# decision rather than silently bypassing.
if not allow_unverified:
raise MitreSeedError(
"custom URL requires an expected_sha256 (or allow_unverified=True)"
)
expected_sha256 = None
path, source_label = resolve_source_to_path(
source, cache_dir=cache_dir, expected_sha256=expected_sha256
)
parsed = parse_bundle(path)
log.info(
"metamorph.mitre.parsed",
extra={
"tactics": len(parsed.tactics),
"techniques": len(parsed.techniques),
"subtechniques": len(parsed.subtechniques),
"spec_version": parsed.spec_version,
},
)
with session_scope() as s:
# Serialize concurrent /mitre/sync calls. The lock is transaction-scoped
# (released automatically at COMMIT/ROLLBACK), so a second sync arriving
# while the first is mid-DELETE+INSERT of `mitre_technique_tactics`
# blocks until the first commits. Avoids the unique-constraint race the
# code-reviewer flagged. hashtext() is stable across sessions.
s.execute(sql_text("SELECT pg_advisory_xact_lock(hashtext('mitre.seed'))"))
short_to_tactic_id, n_tactics = _upsert_tactics(s, parsed.tactics)
stix_to_tech_id, n_techs, n_links = _upsert_techniques(
s, parsed.techniques, short_to_tactic_id
)
n_subs, n_orphan = _upsert_subtechniques(s, parsed.subtechniques, stix_to_tech_id)
finished_at = datetime.now(tz=timezone.utc)
_upsert_setting(s, SETTING_LAST_SYNC, finished_at.isoformat())
# `version` reflects the known pin only when seeded from MITRE_DEFAULT_URL;
# otherwise we explicitly clear it so /mitre/status doesn't lie about a
# stale version after a custom-URL re-sync.
version = MITRE_VERSION if source_label == MITRE_DEFAULT_URL else None
_upsert_setting(s, SETTING_VERSION, version)
_upsert_setting(s, SETTING_SOURCE_URL, source_label)
result = SeedResult(
tactics_upserted=n_tactics,
techniques_upserted=n_techs,
subtechniques_upserted=n_subs,
subtechniques_skipped_orphan=n_orphan,
technique_tactic_links=n_links,
version=version,
source=source_label,
started_at=started_at,
finished_at=finished_at,
)
log.info("metamorph.mitre.seed_completed", extra=result.as_dict())
return result
def read_status() -> dict:
"""Return the persisted seed metadata for `GET /mitre/status`."""
keys = {SETTING_LAST_SYNC, SETTING_VERSION, SETTING_SOURCE_URL}
out = {k: None for k in keys}
with session_scope() as s:
for row in s.scalars(select(Setting).where(Setting.key.in_(keys))).all():
out[row.key] = row.value
return {
"last_sync": out[SETTING_LAST_SYNC],
"version": out[SETTING_VERSION],
"source_url": out[SETTING_SOURCE_URL],
"default_url": MITRE_DEFAULT_URL,
"default_version": MITRE_VERSION,
}
-259
View File
@@ -1,259 +0,0 @@
"""CRUD service for `scenario_templates` + their ordered test list.
Re-ordering is implemented as **full delete + re-insert** of the
`scenario_template_tests` rows. The UNIQUE (scenario_template_id, position)
constraint makes any naive position-swap fail mid-transaction; wiping the set
then re-inserting at positions 0..N-1 keeps the operation atomic and obvious.
The same test_template may legitimately appear multiple times in a scenario
(chained operations), so we key on `(scenario_id, position)`, not
`(scenario_id, test_template_id)`.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, or_, select, text
from sqlalchemy.orm import Session, selectinload
_UNSET: Any = object()
from app.db.session import session_scope
from app.models.template import (
ScenarioTemplate,
ScenarioTemplateTest,
TestTemplate,
)
class ScenarioTemplateNotFound(Exception):
pass
class UnknownTestTemplate(Exception):
"""Raised when a scenario references a non-existent or soft-deleted test."""
@dataclass(frozen=True)
class ScenarioTestView:
position: int
test_template_id: uuid.UUID
test_template_name: str
test_template_deleted: bool
@dataclass(frozen=True)
class ScenarioTemplateView:
id: uuid.UUID
name: str
description: str | None
tests: list[ScenarioTestView]
tests_count: int
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
def _to_view(s: Session, sc: ScenarioTemplate) -> ScenarioTemplateView:
test_ids = [link.test_template_id for link in sc.tests]
name_by_id: dict[uuid.UUID, tuple[str, bool]] = {}
if test_ids:
rows = s.scalars(select(TestTemplate).where(TestTemplate.id.in_(test_ids))).all()
for row in rows:
name_by_id[row.id] = (row.name, row.deleted_at is not None)
tests = [
ScenarioTestView(
position=link.position,
test_template_id=link.test_template_id,
test_template_name=name_by_id.get(link.test_template_id, ("<missing>", True))[0],
test_template_deleted=name_by_id.get(link.test_template_id, ("<missing>", True))[1],
)
for link in sc.tests
]
return ScenarioTemplateView(
id=sc.id,
name=sc.name,
description=sc.description,
tests=tests,
tests_count=len(tests),
deleted_at=sc.deleted_at,
created_at=sc.created_at,
updated_at=sc.updated_at,
)
def _base_query():
return select(ScenarioTemplate).options(selectinload(ScenarioTemplate.tests))
def list_scenario_templates(
*,
q: str | None = None,
include_deleted: bool = False,
limit: int = 100,
offset: int = 0,
) -> tuple[list[ScenarioTemplateView], int]:
with session_scope() as s:
stmt = _base_query().order_by(ScenarioTemplate.name.asc())
count_stmt = select(func.count()).select_from(ScenarioTemplate)
if not include_deleted:
stmt = stmt.where(ScenarioTemplate.deleted_at.is_(None))
count_stmt = count_stmt.where(ScenarioTemplate.deleted_at.is_(None))
if q:
like = f"%{q.lower()}%"
cond = or_(
func.lower(ScenarioTemplate.name).like(like),
func.lower(ScenarioTemplate.description).like(like),
)
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
total = s.scalar(count_stmt) or 0
rows = s.scalars(stmt.limit(max(1, min(limit, 500))).offset(max(0, offset))).all()
return [_to_view(s, sc) for sc in rows], int(total)
def get_scenario_template(scenario_id: uuid.UUID, *, include_deleted: bool = False) -> ScenarioTemplateView:
with session_scope() as s:
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None:
raise ScenarioTemplateNotFound()
if sc.deleted_at is not None and not include_deleted:
raise ScenarioTemplateNotFound()
return _to_view(s, sc)
def _validate_test_ids(s: Session, ids: list[uuid.UUID]) -> None:
"""Reject unknown or soft-deleted test_template ids before persisting."""
if not ids:
return
found = s.execute(
select(TestTemplate.id, TestTemplate.deleted_at).where(TestTemplate.id.in_(ids))
).all()
known = {row.id for row in found}
deleted = {row.id for row in found if row.deleted_at is not None}
missing = set(ids) - known
if missing:
raise UnknownTestTemplate(f"unknown test_template ids: {sorted(str(m) for m in missing)}")
if deleted:
raise UnknownTestTemplate(
f"cannot reference soft-deleted test_template ids: {sorted(str(d) for d in deleted)}"
)
def _opt_str(value: str | None) -> str | None:
if value is None:
return None
s = value.strip()
return s or None
def create_scenario_template(
*,
name: str,
description: str | None = None,
test_template_ids: list[uuid.UUID] | None = None,
) -> ScenarioTemplateView:
name_norm = (name or "").strip()
if not name_norm:
raise ValueError("name is required")
ids = list(test_template_ids or [])
with session_scope() as s:
_validate_test_ids(s, ids)
sc = ScenarioTemplate(
name=name_norm,
description=_opt_str(description),
)
s.add(sc)
s.flush()
for position, tid in enumerate(ids):
s.add(
ScenarioTemplateTest(
scenario_template_id=sc.id,
test_template_id=tid,
position=position,
)
)
s.flush()
s.refresh(sc)
return _to_view(s, sc)
def update_scenario_template(
scenario_id: uuid.UUID,
*,
name: str | None = None,
description: Any = _UNSET,
) -> ScenarioTemplateView:
with session_scope() as s:
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None or sc.deleted_at is not None:
raise ScenarioTemplateNotFound()
if name is not None:
n = name.strip()
if not n:
raise ValueError("name cannot be empty")
sc.name = n
if description is not _UNSET:
sc.description = _opt_str(description)
s.flush()
s.refresh(sc)
return _to_view(s, sc)
def set_scenario_tests(
scenario_id: uuid.UUID,
test_template_ids: list[uuid.UUID],
) -> ScenarioTemplateView:
"""Replace the entire ordered test list. `position` becomes the index.
Acquires a per-scenario advisory lock to serialise concurrent reorders.
Without it, two parallel `PUT /scenario-templates/{id}/tests` calls would
race on the wipe-then-insert sequence and deadlock on the UNIQUE(position)
constraint under READ COMMITTED. Mirrors the M4 pattern on /mitre/sync.
"""
with session_scope() as s:
# Lock keyed on the scenario UUID — different scenarios don't block
# each other. Single bigint form so we don't have to juggle int32
# signed ranges. blake2b is used instead of Python's built-in hash()
# because the latter is randomised per-process (PYTHONHASHSEED), so
# two gunicorn workers would compute different keys for the same
# scenario and the lock wouldn't serialise across them.
digest = hashlib.blake2b(scenario_id.bytes, digest_size=8).digest()
lock_key = int.from_bytes(digest, "big", signed=True)
s.execute(
text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"),
{"key": lock_key},
)
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None or sc.deleted_at is not None:
raise ScenarioTemplateNotFound()
_validate_test_ids(s, test_template_ids)
# Wipe then re-insert. The UNIQUE(position) constraint forbids a
# naive UPDATE-swap; full-replace keeps the op atomic + readable.
for link in list(sc.tests):
s.delete(link)
s.flush()
for position, tid in enumerate(test_template_ids):
s.add(
ScenarioTemplateTest(
scenario_template_id=sc.id,
test_template_id=tid,
position=position,
)
)
s.flush()
s.refresh(sc)
return _to_view(s, sc)
def soft_delete_scenario_template(scenario_id: uuid.UUID) -> None:
with session_scope() as s:
sc = s.get(ScenarioTemplate, scenario_id)
if sc is None or sc.deleted_at is not None:
raise ScenarioTemplateNotFound()
sc.deleted_at = datetime.now(tz=timezone.utc)
-495
View File
@@ -1,495 +0,0 @@
"""CRUD service for `test_templates` + their MITRE tags.
The MITRE tag set is **fully replaced** on every update — partial mutation of
the join rows would force the API client to track tag UUIDs they never created.
The polymorphic join (one of `tactic_id` / `technique_id` / `subtechnique_id`
populated) is owned here: callers pass `(kind, external_id)` tuples and we
resolve them to the matching MITRE row.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
_UNSET: Any = object()
from app.db.session import session_scope
from app.db.types import MITRE_KINDS, OPSEC_LEVELS
from app.models.mitre import MitreSubtechnique, MitreTactic, MitreTechnique
from app.models.template import TestTemplate, TestTemplateMitreTag
class TestTemplateNotFound(Exception):
pass
class UnknownMitreTag(Exception):
"""Raised when an (kind, external_id) tuple doesn't resolve to a known MITRE row."""
@dataclass(frozen=True)
class MitreTagRef:
"""Inbound MITRE tag reference. `external_id` is the ATT&CK identifier
(TA…/T…/T….…) — we resolve it server-side, the client never sees UUIDs.
"""
kind: str # "tactic" | "technique" | "subtechnique"
external_id: str
@dataclass(frozen=True)
class MitreTagView:
kind: str
external_id: str
name: str
url: str | None
@dataclass(frozen=True)
class TestTemplateView:
id: uuid.UUID
name: str
description: str | None
objective: str | None
procedure_md: str | None
prerequisites_md: str | None
expected_result_red_md: str | None
expected_detection_blue_md: str | None
opsec_level: str
tags: list[str]
expected_iocs: list[str]
mitre_tags: list[MitreTagView]
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
def _validate_opsec(value: str) -> str:
if value not in OPSEC_LEVELS:
raise ValueError(f"opsec_level must be one of {OPSEC_LEVELS}")
return value
def _normalize_string_list(values: Iterable[str] | None) -> list[str]:
if not values:
return []
seen: set[str] = set()
out: list[str] = []
for raw in values:
if not isinstance(raw, str):
raise ValueError("list items must be strings")
v = raw.strip()
if not v or v in seen:
continue
seen.add(v)
out.append(v)
return out
def _resolve_mitre_refs(s: Session, refs: list[MitreTagRef]) -> list[TestTemplateMitreTag]:
"""Translate `(kind, external_id)` pairs into half-populated join rows.
Validates that:
- `kind` is one of the supported values
- each external_id resolves to an existing MITRE row
- the combination is unique inside the payload (de-duped silently — same
tag twice is a no-op, not an error)
"""
if not refs:
return []
# Dedupe input
deduped: dict[tuple[str, str], MitreTagRef] = {}
for ref in refs:
if ref.kind not in MITRE_KINDS:
raise ValueError(f"mitre tag kind must be one of {MITRE_KINDS}")
if not ref.external_id:
raise ValueError("mitre tag external_id is required")
deduped[(ref.kind, ref.external_id)] = ref
tactic_ids = {r.external_id for r in deduped.values() if r.kind == "tactic"}
technique_ids = {r.external_id for r in deduped.values() if r.kind == "technique"}
subtechnique_ids = {r.external_id for r in deduped.values() if r.kind == "subtechnique"}
tactic_map = {
t.external_id: t.id
for t in s.scalars(select(MitreTactic).where(MitreTactic.external_id.in_(tactic_ids))).all()
}
technique_map = {
t.external_id: t.id
for t in s.scalars(select(MitreTechnique).where(MitreTechnique.external_id.in_(technique_ids))).all()
}
subtechnique_map = {
sb.external_id: sb.id
for sb in s.scalars(
select(MitreSubtechnique).where(MitreSubtechnique.external_id.in_(subtechnique_ids))
).all()
}
rows: list[TestTemplateMitreTag] = []
missing: list[tuple[str, str]] = []
for ref in deduped.values():
if ref.kind == "tactic":
mid = tactic_map.get(ref.external_id)
if mid is None:
missing.append((ref.kind, ref.external_id))
continue
rows.append(TestTemplateMitreTag(mitre_kind="tactic", tactic_id=mid))
elif ref.kind == "technique":
mid = technique_map.get(ref.external_id)
if mid is None:
missing.append((ref.kind, ref.external_id))
continue
rows.append(TestTemplateMitreTag(mitre_kind="technique", technique_id=mid))
else:
mid = subtechnique_map.get(ref.external_id)
if mid is None:
missing.append((ref.kind, ref.external_id))
continue
rows.append(TestTemplateMitreTag(mitre_kind="subtechnique", subtechnique_id=mid))
if missing:
raise UnknownMitreTag(f"unknown MITRE tags: {sorted(missing)}")
return rows
def _resolve_mitre_views(s: Session, tags: list[TestTemplateMitreTag]) -> list[MitreTagView]:
"""Batch-resolve polymorphic MITRE FKs into MitreTagViews in 3 queries
total — one per kind — regardless of how many tags or templates the
caller is rendering.
"""
tactic_ids = {t.tactic_id for t in tags if t.mitre_kind == "tactic" and t.tactic_id is not None}
technique_ids = {t.technique_id for t in tags if t.mitre_kind == "technique" and t.technique_id is not None}
sub_ids = {t.subtechnique_id for t in tags if t.mitre_kind == "subtechnique" and t.subtechnique_id is not None}
tactic_map: dict[uuid.UUID, MitreTactic] = {}
technique_map: dict[uuid.UUID, MitreTechnique] = {}
sub_map: dict[uuid.UUID, MitreSubtechnique] = {}
if tactic_ids:
tactic_map = {row.id: row for row in s.scalars(select(MitreTactic).where(MitreTactic.id.in_(tactic_ids))).all()}
if technique_ids:
technique_map = {
row.id: row
for row in s.scalars(select(MitreTechnique).where(MitreTechnique.id.in_(technique_ids))).all()
}
if sub_ids:
sub_map = {
row.id: row
for row in s.scalars(select(MitreSubtechnique).where(MitreSubtechnique.id.in_(sub_ids))).all()
}
views: list[MitreTagView] = []
for tag in tags:
if tag.mitre_kind == "tactic" and tag.tactic_id in tactic_map:
row_t = tactic_map[tag.tactic_id]
views.append(MitreTagView(kind="tactic", external_id=row_t.external_id, name=row_t.name, url=row_t.url))
elif tag.mitre_kind == "technique" and tag.technique_id in technique_map:
row_te = technique_map[tag.technique_id]
views.append(MitreTagView(kind="technique", external_id=row_te.external_id, name=row_te.name, url=row_te.url))
elif tag.mitre_kind == "subtechnique" and tag.subtechnique_id in sub_map:
row_sb = sub_map[tag.subtechnique_id]
views.append(MitreTagView(kind="subtechnique", external_id=row_sb.external_id, name=row_sb.name, url=row_sb.url))
views.sort(key=lambda v: (v.kind, v.external_id))
return views
def _to_views_batch(s: Session, templates: list[TestTemplate]) -> list[TestTemplateView]:
"""List-level batcher: one bulk MITRE resolve for all templates' tags.
For a list of K templates with ~T tags each, this issues 3 queries total
(one per MITRE kind) instead of 3K. We build (kind, uuid) → row maps
once, then assemble each template's view in memory.
"""
tactic_ids: set[uuid.UUID] = set()
technique_ids: set[uuid.UUID] = set()
sub_ids: set[uuid.UUID] = set()
for t in templates:
for tag in t.mitre_tags:
if tag.mitre_kind == "tactic" and tag.tactic_id is not None:
tactic_ids.add(tag.tactic_id)
elif tag.mitre_kind == "technique" and tag.technique_id is not None:
technique_ids.add(tag.technique_id)
elif tag.mitre_kind == "subtechnique" and tag.subtechnique_id is not None:
sub_ids.add(tag.subtechnique_id)
tactic_map: dict[uuid.UUID, MitreTactic] = (
{row.id: row for row in s.scalars(select(MitreTactic).where(MitreTactic.id.in_(tactic_ids))).all()}
if tactic_ids
else {}
)
technique_map: dict[uuid.UUID, MitreTechnique] = (
{row.id: row for row in s.scalars(select(MitreTechnique).where(MitreTechnique.id.in_(technique_ids))).all()}
if technique_ids
else {}
)
sub_map: dict[uuid.UUID, MitreSubtechnique] = (
{row.id: row for row in s.scalars(select(MitreSubtechnique).where(MitreSubtechnique.id.in_(sub_ids))).all()}
if sub_ids
else {}
)
def _views_for(tags: list[TestTemplateMitreTag]) -> list[MitreTagView]:
out: list[MitreTagView] = []
for tag in tags:
if tag.mitre_kind == "tactic" and tag.tactic_id in tactic_map:
row_t = tactic_map[tag.tactic_id]
out.append(MitreTagView(kind="tactic", external_id=row_t.external_id, name=row_t.name, url=row_t.url))
elif tag.mitre_kind == "technique" and tag.technique_id in technique_map:
row_te = technique_map[tag.technique_id]
out.append(MitreTagView(kind="technique", external_id=row_te.external_id, name=row_te.name, url=row_te.url))
elif tag.mitre_kind == "subtechnique" and tag.subtechnique_id in sub_map:
row_sb = sub_map[tag.subtechnique_id]
out.append(MitreTagView(kind="subtechnique", external_id=row_sb.external_id, name=row_sb.name, url=row_sb.url))
out.sort(key=lambda v: (v.kind, v.external_id))
return out
views: list[TestTemplateView] = []
for t in templates:
views.append(
TestTemplateView(
id=t.id,
name=t.name,
description=t.description,
objective=t.objective,
procedure_md=t.procedure_md,
prerequisites_md=t.prerequisites_md,
expected_result_red_md=t.expected_result_red_md,
expected_detection_blue_md=t.expected_detection_blue_md,
opsec_level=t.opsec_level,
tags=list(t.tags or []),
expected_iocs=list(t.expected_iocs or []),
mitre_tags=_views_for(list(t.mitre_tags)),
deleted_at=t.deleted_at,
created_at=t.created_at,
updated_at=t.updated_at,
)
)
return views
def _to_view(s: Session, t: TestTemplate) -> TestTemplateView:
tag_views = _resolve_mitre_views(s, list(t.mitre_tags))
return TestTemplateView(
id=t.id,
name=t.name,
description=t.description,
objective=t.objective,
procedure_md=t.procedure_md,
prerequisites_md=t.prerequisites_md,
expected_result_red_md=t.expected_result_red_md,
expected_detection_blue_md=t.expected_detection_blue_md,
opsec_level=t.opsec_level,
tags=list(t.tags or []),
expected_iocs=list(t.expected_iocs or []),
mitre_tags=tag_views,
deleted_at=t.deleted_at,
created_at=t.created_at,
updated_at=t.updated_at,
)
def _base_query():
return select(TestTemplate).options(selectinload(TestTemplate.mitre_tags))
def list_test_templates(
*,
q: str | None = None,
tactic: str | None = None, # external_id like "TA0006"
technique: str | None = None,
subtechnique: str | None = None,
opsec_level: str | None = None,
tag: str | None = None,
include_deleted: bool = False,
limit: int = 100,
offset: int = 0,
) -> tuple[list[TestTemplateView], int]:
with session_scope() as s:
stmt = _base_query().order_by(TestTemplate.name.asc())
count_stmt = select(func.count()).select_from(TestTemplate)
if not include_deleted:
stmt = stmt.where(TestTemplate.deleted_at.is_(None))
count_stmt = count_stmt.where(TestTemplate.deleted_at.is_(None))
if q:
like = f"%{q.lower()}%"
cond = or_(
func.lower(TestTemplate.name).like(like),
func.lower(TestTemplate.description).like(like),
)
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
if opsec_level:
_validate_opsec(opsec_level)
stmt = stmt.where(TestTemplate.opsec_level == opsec_level)
count_stmt = count_stmt.where(TestTemplate.opsec_level == opsec_level)
if tag:
stmt = stmt.where(TestTemplate.tags.any(tag))
count_stmt = count_stmt.where(TestTemplate.tags.any(tag))
# MITRE facets: each provided facet (tactic, technique, subtechnique) is
# AND-combined — a template tagged BOTH `TA0006` AND `T1003` matches a
# query with `?tactic=TA0006&technique=T1003`, but a template tagged
# only `TA0006` does NOT. Each facet matches strictly its own column
# (no cross-column UUID collision risk).
def _facet_subquery(column, mitre_id: uuid.UUID):
return (
select(TestTemplateMitreTag.test_template_id)
.where(column == mitre_id)
.distinct()
)
if tactic:
tac = s.scalar(select(MitreTactic).where(MitreTactic.external_id == tactic))
if tac is None:
return [], 0
sub_q = _facet_subquery(TestTemplateMitreTag.tactic_id, tac.id)
stmt = stmt.where(TestTemplate.id.in_(sub_q))
count_stmt = count_stmt.where(TestTemplate.id.in_(sub_q))
if technique:
tech = s.scalar(select(MitreTechnique).where(MitreTechnique.external_id == technique))
if tech is None:
return [], 0
sub_q = _facet_subquery(TestTemplateMitreTag.technique_id, tech.id)
stmt = stmt.where(TestTemplate.id.in_(sub_q))
count_stmt = count_stmt.where(TestTemplate.id.in_(sub_q))
if subtechnique:
sub = s.scalar(select(MitreSubtechnique).where(MitreSubtechnique.external_id == subtechnique))
if sub is None:
return [], 0
sub_q = _facet_subquery(TestTemplateMitreTag.subtechnique_id, sub.id)
stmt = stmt.where(TestTemplate.id.in_(sub_q))
count_stmt = count_stmt.where(TestTemplate.id.in_(sub_q))
total = s.scalar(count_stmt) or 0
rows = s.scalars(stmt.limit(max(1, min(limit, 500))).offset(max(0, offset))).all()
return _to_views_batch(s, list(rows)), int(total)
def get_test_template(template_id: uuid.UUID, *, include_deleted: bool = False) -> TestTemplateView:
with session_scope() as s:
t = s.get(TestTemplate, template_id)
if t is None:
raise TestTemplateNotFound()
if t.deleted_at is not None and not include_deleted:
raise TestTemplateNotFound()
return _to_view(s, t)
def create_test_template(
*,
name: str,
description: str | None = None,
objective: str | None = None,
procedure_md: str | None = None,
prerequisites_md: str | None = None,
expected_result_red_md: str | None = None,
expected_detection_blue_md: str | None = None,
opsec_level: str = "medium",
tags: list[str] | None = None,
expected_iocs: list[str] | None = None,
mitre_tags: list[MitreTagRef] | None = None,
) -> TestTemplateView:
name_norm = (name or "").strip()
if not name_norm:
raise ValueError("name is required")
_validate_opsec(opsec_level)
norm_tags = _normalize_string_list(tags)
norm_iocs = _normalize_string_list(expected_iocs)
with session_scope() as s:
t = TestTemplate(
name=name_norm,
description=_opt_str(description),
objective=_opt_str(objective),
procedure_md=procedure_md or None,
prerequisites_md=prerequisites_md or None,
expected_result_red_md=expected_result_red_md or None,
expected_detection_blue_md=expected_detection_blue_md or None,
opsec_level=opsec_level,
tags=norm_tags,
expected_iocs=norm_iocs,
)
s.add(t)
s.flush()
if mitre_tags:
rows = _resolve_mitre_refs(s, mitre_tags)
for row in rows:
row.test_template_id = t.id
s.add(row)
s.flush()
s.refresh(t)
return _to_view(s, t)
def _opt_str(value: str | None) -> str | None:
if value is None:
return None
s = value.strip()
return s or None
def update_test_template(
template_id: uuid.UUID,
*,
name: str | None = None,
description: Any = _UNSET,
objective: Any = _UNSET,
procedure_md: Any = _UNSET,
prerequisites_md: Any = _UNSET,
expected_result_red_md: Any = _UNSET,
expected_detection_blue_md: Any = _UNSET,
opsec_level: str | None = None,
tags: Any = _UNSET,
expected_iocs: Any = _UNSET,
mitre_tags: Any = _UNSET,
) -> TestTemplateView:
with session_scope() as s:
t = s.get(TestTemplate, template_id)
if t is None or t.deleted_at is not None:
raise TestTemplateNotFound()
if name is not None:
n = name.strip()
if not n:
raise ValueError("name cannot be empty")
t.name = n
if description is not _UNSET:
t.description = _opt_str(description)
if objective is not _UNSET:
t.objective = _opt_str(objective)
if procedure_md is not _UNSET:
t.procedure_md = procedure_md or None
if prerequisites_md is not _UNSET:
t.prerequisites_md = prerequisites_md or None
if expected_result_red_md is not _UNSET:
t.expected_result_red_md = expected_result_red_md or None
if expected_detection_blue_md is not _UNSET:
t.expected_detection_blue_md = expected_detection_blue_md or None
if opsec_level is not None:
_validate_opsec(opsec_level)
t.opsec_level = opsec_level
if tags is not _UNSET:
t.tags = _normalize_string_list(tags)
if expected_iocs is not _UNSET:
t.expected_iocs = _normalize_string_list(expected_iocs)
if mitre_tags is not _UNSET:
for row in list(t.mitre_tags):
s.delete(row)
s.flush()
rows = _resolve_mitre_refs(s, list(mitre_tags or []))
for row in rows:
row.test_template_id = t.id
s.add(row)
s.flush()
s.refresh(t)
return _to_view(s, t)
def soft_delete_test_template(template_id: uuid.UUID) -> None:
with session_scope() as s:
t = s.get(TestTemplate, template_id)
if t is None or t.deleted_at is not None:
raise TestTemplateNotFound()
t.deleted_at = datetime.now(tz=timezone.utc)
-781
View File
@@ -1,781 +0,0 @@
"""M6 — Mission CRUD, snapshot fidelity, membership visibility, transitions.
The fixture stack mirrors `test_templates.py`: one shared `app` per module,
fresh truncate at the start, a minimal MITRE bundle seeded for tag resolution,
plus a small catalogue of test_templates and scenario_templates created via
the admin API so the snapshot path is exercised end-to-end (not via raw ORM).
"""
from __future__ import annotations
import json
import secrets
import pytest
from sqlalchemy import text
from app.core.install_token import regenerate_install_token
from app.main import create_app
from app.services import mitre_seed as mitre_svc
def _truncate_all(engine):
with engine.begin() as conn:
# Order matches /diag/reset: missions before templates before MITRE.
conn.execute(
text(
"TRUNCATE mission_test_mitre_tags, mission_tests, "
"mission_scenarios, mission_categories, mission_members, "
"missions RESTART IDENTITY CASCADE"
)
)
conn.execute(
text(
"TRUNCATE scenario_template_tests, scenario_templates, "
"test_template_mitre_tags, test_templates "
"RESTART IDENTITY CASCADE"
)
)
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, group_permissions, permissions, settings, groups "
"RESTART IDENTITY CASCADE"
)
)
conn.execute(
text(
"TRUNCATE mitre_technique_tactics, mitre_subtechniques, "
"mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE"
)
)
_MINIMAL_BUNDLE = {
"type": "bundle",
"id": "bundle--00000000-0000-0000-0000-000000000006",
"spec_version": "2.1",
"objects": [
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0002",
"name": "Execution",
"x_mitre_shortname": "execution",
"external_references": [
{"source_name": "mitre-attack", "external_id": "TA0002"}
],
},
{
"type": "attack-pattern",
"id": "attack-pattern--t1059",
"name": "Command and Scripting Interpreter",
"kill_chain_phases": [
{"kill_chain_name": "mitre-attack", "phase_name": "execution"}
],
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059"}
],
},
{
"type": "attack-pattern",
"id": "attack-pattern--t1059-001",
"name": "PowerShell",
"x_mitre_is_subtechnique": True,
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059.001"}
],
},
{
"type": "relationship",
"id": "relationship--rel1",
"relationship_type": "subtechnique-of",
"source_ref": "attack-pattern--t1059-001",
"target_ref": "attack-pattern--t1059",
},
],
}
@pytest.fixture(scope="module")
def app(db_engine_or_skip, tmp_path_factory):
_truncate_all(db_engine_or_skip)
bundle_path = tmp_path_factory.mktemp("m6") / "stix.json"
bundle_path.write_text(json.dumps(_MINIMAL_BUNDLE))
mitre_svc.seed_mitre(source=bundle_path, expected_sha256=None)
flask_app = create_app()
flask_app.config.update(TESTING=True)
return flask_app
@pytest.fixture()
def client(app):
return app.test_client()
def _unique_email(prefix: str) -> str:
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _login(client, email: str, password: str) -> str:
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.get_data(as_text=True)
return r.get_json()["access_token"]
@pytest.fixture(scope="module")
def admin(app):
token = regenerate_install_token()
email = _unique_email("admin")
password = "AdminPass1234!"
with app.test_client() as c:
r = c.post(
"/api/v1/setup",
json={"install_token": token, "email": email, "password": password},
)
assert r.status_code == 201, r.get_data(as_text=True)
return {"email": email, "password": password}
@pytest.fixture()
def admin_token(client, admin) -> str:
return _login(client, admin["email"], admin["password"])
# ---------------------------------------------------------------- catalogue --
def _mitre_kind(external_id: str) -> str:
if external_id.startswith("TA"):
return "tactic"
if "." in external_id:
return "subtechnique"
return "technique"
def _make_test_template(client, admin_token: str, *, name: str, mitre: str = "T1059"):
body = {
"name": name,
"description": "auto",
"objective": "do thing",
"procedure_md": f"# {name}\n1. run",
"expected_result_red_md": "red expectation",
"expected_detection_blue_md": "blue expectation",
"opsec_level": "medium",
"tags": ["fast"],
"expected_iocs": ["evil.exe"],
"mitre_tags": [{"kind": _mitre_kind(mitre), "external_id": mitre}],
}
r = client.post("/api/v1/test-templates", headers=_bearer(admin_token), json=body)
assert r.status_code == 201, r.get_data(as_text=True)
return r.get_json()
def _make_scenario(client, admin_token: str, *, name: str, test_ids: list[str]):
r = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={
"name": name,
"description": f"auto-{name}",
"test_template_ids": test_ids,
},
)
assert r.status_code == 201, r.get_data(as_text=True)
return r.get_json()
@pytest.fixture(scope="module")
def catalogue(app, admin):
"""Pre-seeded templates + scenarios so tests can reference them by id."""
with app.test_client() as c:
tok = _login(c, admin["email"], admin["password"])
t1 = _make_test_template(c, tok, name="cat-test-1", mitre="T1059")
t2 = _make_test_template(
c, tok, name="cat-test-2", mitre="T1059.001"
)
t3 = _make_test_template(c, tok, name="cat-test-3", mitre="T1059")
sc_one = _make_scenario(
c, tok, name="cat-scenario-A", test_ids=[t1["id"], t2["id"], t3["id"]]
)
sc_solo = _make_scenario(c, tok, name="cat-scenario-B", test_ids=[t1["id"]])
return {
"tests": {"t1": t1, "t2": t2, "t3": t3},
"scenarios": {"a": sc_one, "b": sc_solo},
}
# --------------------------------------------------------------------- users --
def _invite_user(client, admin_token: str, prefix: str, group_codes: list[str]) -> dict:
"""Invite a user pre-bound to a freshly-minted group with the listed perm codes."""
grp = client.post(
"/api/v1/groups",
headers=_bearer(admin_token),
json={"name": f"{prefix}-grp-{secrets.token_hex(2)}"},
).get_json()
r_set = client.put(
f"/api/v1/groups/{grp['id']}/permissions",
headers=_bearer(admin_token),
json={"codes": group_codes},
)
assert r_set.status_code == 200, r_set.get_data(as_text=True)
email = _unique_email(prefix)
password = "Pass1234!"
inv = client.post(
"/api/v1/invitations",
headers=_bearer(admin_token),
json={"email_hint": email, "group_ids": [grp["id"]]},
)
assert inv.status_code == 201, inv.get_data(as_text=True)
accept_token = inv.get_json()["token"]
r = client.post(
f"/api/v1/invitations/accept/{accept_token}",
json={"email": email, "password": password},
)
assert r.status_code == 201, r.get_data(as_text=True)
me_token = _login(client, email, password)
me = client.get("/api/v1/auth/me", headers=_bearer(me_token)).get_json()
return {"email": email, "password": password, "token": me_token, "id": me["id"]}
@pytest.fixture()
def red_user(client, admin_token):
return _invite_user(
client,
admin_token,
"red",
[
"mission.read",
"mission.create",
"mission.update",
"mission.archive",
"mission.write_red_fields",
],
)
@pytest.fixture()
def blue_user(client, admin_token):
return _invite_user(
client,
admin_token,
"blue",
["mission.read", "mission.write_blue_fields"],
)
@pytest.fixture()
def reader_user(client, admin_token):
"""A user with mission.read only — for "non-member can't see" checks."""
return _invite_user(client, admin_token, "reader", ["mission.read"])
@pytest.fixture()
def noperm_user(client, admin_token):
"""A user with no mission perms at all."""
return _invite_user(client, admin_token, "noperm", [])
# ================================================================ snapshot ==
def test_create_mission_snapshots_scenarios_and_tests(client, admin_token, catalogue):
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "snapshot-fidelity",
"client_target": "Acme Corp",
"description_md": "## ROE\n- approved\n",
"scenario_template_ids": [catalogue["scenarios"]["a"]["id"]],
},
)
assert r.status_code == 201, r.get_data(as_text=True)
body = r.get_json()
assert body["status"] == "draft"
assert body["visibility_mode"] == "whitebox"
assert body["scenarios_count"] == 1
assert body["tests_count"] == 3
assert body["members_count"] == 0 # admin creator is not auto-added
sc = body["scenarios"][0]
assert sc["position"] == 0
assert sc["snapshot_name"] == "cat-scenario-A"
names_in_order = [t["snapshot_name"] for t in sc["tests"]]
assert names_in_order == ["cat-test-1", "cat-test-2", "cat-test-3"]
# MITRE denormalised into the snapshot
t1 = next(t for t in sc["tests"] if t["snapshot_name"] == "cat-test-1")
kinds = [(tag["kind"], tag["external_id"]) for tag in t1["mitre_tags"]]
assert kinds == [("technique", "T1059")]
def test_snapshot_is_frozen_after_template_edits(client, admin_token, catalogue):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "frozen-after-edits",
"scenario_template_ids": [catalogue["scenarios"]["b"]["id"]],
},
)
assert create.status_code == 201
mission_id = create.get_json()["id"]
# Mutate the source test_template: rename + change MITRE
edit = client.put(
f"/api/v1/test-templates/{catalogue['tests']['t1']['id']}",
headers=_bearer(admin_token),
json={
"name": "RENAMED-AFTER-SNAPSHOT",
"mitre_tags": [{"kind": "tactic", "external_id": "TA0002"}],
},
)
assert edit.status_code == 200
# Mission still sees the pre-edit snapshot
again = client.get(
f"/api/v1/missions/{mission_id}", headers=_bearer(admin_token)
).get_json()
sc = again["scenarios"][0]
assert sc["tests"][0]["snapshot_name"] == "cat-test-1"
assert [(t["kind"], t["external_id"]) for t in sc["tests"][0]["mitre_tags"]] == [
("technique", "T1059")
]
# Revert the rename so other tests still find the original name
client.put(
f"/api/v1/test-templates/{catalogue['tests']['t1']['id']}",
headers=_bearer(admin_token),
json={
"name": "cat-test-1",
"mitre_tags": [{"kind": "technique", "external_id": "T1059"}],
},
)
def test_create_mission_rejects_unknown_scenario(client, admin_token):
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "bad-ref",
"scenario_template_ids": ["00000000-0000-0000-0000-000000000099"],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_scenario_template"
def test_create_mission_rejects_soft_deleted_scenario(client, admin_token):
# Build a scenario, soft-delete it, then try to snapshot — must 400 with
# `unknown_scenario_template` so we don't silently freeze a tombstoned
# template into a new mission.
t = _make_test_template(client, admin_token, name="sd-rejection-t")
sc = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "sd-rejection-sc", "test_template_ids": [t["id"]]},
).get_json()
del_r = client.delete(
f"/api/v1/scenario-templates/{sc['id']}", headers=_bearer(admin_token)
)
assert del_r.status_code == 200
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "sd-rejection-mission",
"scenario_template_ids": [sc["id"]],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_scenario_template"
def test_create_mission_validates_dates(client, admin_token):
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "date-flip",
"date_start": "2026-06-01",
"date_end": "2026-05-01",
},
)
assert r.status_code == 400
assert "date_end" in r.get_json().get("message", "").lower()
# =================================================== membership visibility ==
def test_non_admin_creator_auto_added(client, red_user, catalogue):
r = client.post(
"/api/v1/missions",
headers=_bearer(red_user["token"]),
json={
"name": "red-self-created",
"scenario_template_ids": [catalogue["scenarios"]["b"]["id"]],
},
)
assert r.status_code == 201, r.get_data(as_text=True)
body = r.get_json()
assert body["members_count"] == 1
assert body["members"][0]["user_id"] == red_user["id"]
assert body["members"][0]["role_hint"] == "red"
# And the red user can see it back via /missions
r2 = client.get("/api/v1/missions", headers=_bearer(red_user["token"]))
ids = [it["id"] for it in r2.get_json()["items"]]
assert body["id"] in ids
def test_non_admin_cannot_see_missions_they_are_not_members_of(
client, admin_token, reader_user, catalogue
):
# Admin creates a mission with NO members
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "hidden-from-reader",
"scenario_template_ids": [catalogue["scenarios"]["b"]["id"]],
},
)
assert create.status_code == 201
mid = create.get_json()["id"]
# Reader has mission.read but is not a member → empty list + 404
r_list = client.get("/api/v1/missions", headers=_bearer(reader_user["token"]))
ids = [it["id"] for it in r_list.get_json()["items"]]
assert mid not in ids
r_get = client.get(
f"/api/v1/missions/{mid}", headers=_bearer(reader_user["token"])
)
assert r_get.status_code == 404
def test_non_member_get_returns_404_not_403(client, admin_token, reader_user):
"""Existence leak guard: non-members should see 404, not 403."""
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "stealth-mission"},
)
mid = create.get_json()["id"]
r = client.get(f"/api/v1/missions/{mid}", headers=_bearer(reader_user["token"]))
assert r.status_code == 404
# ===================================================== perm gating ==========
def test_create_requires_mission_create_perm(client, blue_user):
"""Blue team users (no mission.create) cannot create missions."""
r = client.post(
"/api/v1/missions",
headers=_bearer(blue_user["token"]),
json={"name": "no-perm"},
)
assert r.status_code == 403
def test_list_requires_mission_read_perm(client, noperm_user):
r = client.get("/api/v1/missions", headers=_bearer(noperm_user["token"]))
assert r.status_code == 403
def test_transition_perm_gate_runs_before_payload_parse(client, blue_user):
"""A user without `mission.update` or `mission.archive` should see 403,
not 400, even when posting a malformed body — otherwise the endpoint's
shape leaks via the validation error message."""
# blue_user only has mission.read + mission.write_blue_fields, so neither
# mission.update nor mission.archive is held.
r = client.post(
"/api/v1/missions/00000000-0000-0000-0000-000000000000/transition",
headers=_bearer(blue_user["token"]),
json={"status": "garbage-not-a-valid-shape"},
)
assert r.status_code == 403
def test_search_treats_wildcards_as_literals(client, admin_token):
"""User-typed `%` and `_` must NOT act as SQL LIKE wildcards."""
client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "no-wildcards-here"},
)
# Without escaping, `?q=%` would match every mission. With escaping, it
# only matches names that literally contain `%`.
r = client.get("/api/v1/missions?q=%25", headers=_bearer(admin_token))
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "no-wildcards-here" not in names
def test_archive_requires_mission_archive_not_just_update(client, admin_token):
"""A user with mission.update but no mission.archive cannot archive."""
# blue_user only has mission.read + mission.write_blue_fields — no update either.
# We'll craft a user with update-only here.
update_only = _invite_user(client, admin_token, "u-only", ["mission.read", "mission.update"])
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "to-archive",
"members": [{"user_id": update_only["id"], "role_hint": "red"}],
},
)
assert create.status_code == 201
mid = create.get_json()["id"]
# update-only can transition to in_progress (mission.update is enough)
r1 = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(update_only["token"]),
json={"status": "in_progress"},
)
assert r1.status_code == 200
# … but cannot archive
r2 = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(update_only["token"]),
json={"status": "archived"},
)
assert r2.status_code == 403
# ==================================================== status transitions ==
def test_valid_transition_chain(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "chain"},
)
mid = create.get_json()["id"]
for target in ("in_progress", "completed", "archived"):
r = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(admin_token),
json={"status": target},
)
assert r.status_code == 200, (target, r.get_data(as_text=True))
assert r.get_json()["status"] == target
def test_invalid_transition_409(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "invalid-jump"},
)
mid = create.get_json()["id"]
# draft → completed is not allowed (must pass through in_progress)
r = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(admin_token),
json={"status": "completed"},
)
assert r.status_code == 409
assert r.get_json()["error"] == "invalid_transition"
def test_unknown_target_status_400(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "bad-status"},
)
mid = create.get_json()["id"]
r = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(admin_token),
json={"status": "delivered"},
)
assert r.status_code == 400
def test_idempotent_same_status_transition(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "idempotent"},
)
mid = create.get_json()["id"]
r = client.post(
f"/api/v1/missions/{mid}/transition",
headers=_bearer(admin_token),
json={"status": "draft"},
)
assert r.status_code == 200
assert r.get_json()["status"] == "draft"
# ============================================================ members =====
def test_set_members_replaces_full_set(client, admin_token, red_user, blue_user):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "members-replace",
"members": [{"user_id": red_user["id"], "role_hint": "red"}],
},
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}/members",
headers=_bearer(admin_token),
json={"members": [{"user_id": blue_user["id"], "role_hint": "blue"}]},
)
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
assert body["members_count"] == 1
assert body["members"][0]["user_id"] == blue_user["id"]
# And red can no longer see it
r_red = client.get(
f"/api/v1/missions/{mid}", headers=_bearer(red_user["token"])
)
assert r_red.status_code == 404
def test_set_members_rejects_unknown_user(client, admin_token):
create = client.post(
"/api/v1/missions", headers=_bearer(admin_token), json={"name": "ghost-member"}
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}/members",
headers=_bearer(admin_token),
json={
"members": [
{
"user_id": "00000000-0000-0000-0000-000000000123",
"role_hint": "red",
}
]
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_user"
def test_set_members_rejects_bad_role_hint(client, admin_token, red_user):
create = client.post(
"/api/v1/missions", headers=_bearer(admin_token), json={"name": "bad-hint"}
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}/members",
headers=_bearer(admin_token),
json={"members": [{"user_id": red_user["id"], "role_hint": "yellow"}]},
)
assert r.status_code == 400
# ==================================================== add scenarios ======
def test_add_scenarios_appends_at_end(client, admin_token, catalogue):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "appendable",
"scenario_template_ids": [catalogue["scenarios"]["b"]["id"]],
},
)
mid = create.get_json()["id"]
r = client.post(
f"/api/v1/missions/{mid}/scenarios",
headers=_bearer(admin_token),
json={"scenario_template_ids": [catalogue["scenarios"]["a"]["id"]]},
)
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
assert body["scenarios_count"] == 2
positions = [sc["position"] for sc in body["scenarios"]]
assert positions == [0, 1]
# Second scenario lands at position 1
sc1 = next(sc for sc in body["scenarios"] if sc["position"] == 1)
assert sc1["snapshot_name"] == "cat-scenario-A"
assert len(sc1["tests"]) == 3
# ============================================================= delete =====
def test_soft_delete_hides_from_list(client, admin_token):
create = client.post(
"/api/v1/missions", headers=_bearer(admin_token), json={"name": "to-delete"}
)
mid = create.get_json()["id"]
r_del = client.delete(f"/api/v1/missions/{mid}", headers=_bearer(admin_token))
assert r_del.status_code == 200
r_list = client.get("/api/v1/missions", headers=_bearer(admin_token))
ids = [it["id"] for it in r_list.get_json()["items"]]
assert mid not in ids
# include_deleted=true brings it back (admin only)
r_list2 = client.get(
"/api/v1/missions?include_deleted=true", headers=_bearer(admin_token)
)
ids2 = [it["id"] for it in r_list2.get_json()["items"]]
assert mid in ids2
def test_include_deleted_forbidden_for_non_admin(client, red_user):
r = client.get(
"/api/v1/missions?include_deleted=true", headers=_bearer(red_user["token"])
)
assert r.status_code == 403
# ============================================================ update ======
def test_update_metadata_partial(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "to-rename",
"client_target": "X",
"date_start": "2026-06-01",
"date_end": "2026-06-10",
},
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}",
headers=_bearer(admin_token),
json={"name": "renamed", "client_target": None},
)
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
assert body["name"] == "renamed"
assert body["client_target"] is None
# date fields untouched
assert body["date_start"] == "2026-06-01"
assert body["date_end"] == "2026-06-10"
def test_update_rejects_inverted_dates(client, admin_token):
create = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "invert",
"date_start": "2026-06-01",
"date_end": "2026-06-10",
},
)
mid = create.get_json()["id"]
r = client.put(
f"/api/v1/missions/{mid}",
headers=_bearer(admin_token),
json={"date_end": "2026-05-01"},
)
assert r.status_code == 400
-453
View File
@@ -1,453 +0,0 @@
"""Integration tests for M4: STIX parser + seed + /mitre/* endpoints.
Uses a minimal hand-crafted STIX bundle (no network) so the parser logic and
the upsert semantics can be exercised deterministically.
"""
from __future__ import annotations
import json
import secrets
from pathlib import Path
import pytest
from sqlalchemy import text
from app.core.install_token import regenerate_install_token
from app.main import create_app
from app.services import mitre_seed as mitre_svc
def _truncate_all(engine):
with engine.begin() as conn:
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, group_permissions, permissions, settings, groups, "
"mitre_subtechniques, mitre_technique_tactics, mitre_techniques, "
"mitre_tactics RESTART IDENTITY CASCADE"
)
)
@pytest.fixture(scope="module")
def app(db_engine_or_skip):
_truncate_all(db_engine_or_skip)
flask_app = create_app()
flask_app.config.update(TESTING=True)
return flask_app
@pytest.fixture()
def client(app):
return app.test_client()
def _unique_email(prefix: str) -> str:
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
@pytest.fixture(scope="module")
def admin_credentials(app, db_engine_or_skip):
"""Bootstrap a fresh admin once for the whole module."""
token = regenerate_install_token()
email = _unique_email("admin")
password = "AdminPass1234!"
with app.test_client() as c:
r = c.post(
"/api/v1/setup",
json={"install_token": token, "email": email, "password": password},
)
assert r.status_code == 201, r.get_data(as_text=True)
return {"email": email, "password": password, "user_id": r.get_json()["user_id"]}
def _login(client, email: str, password: str) -> str:
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.get_data(as_text=True)
return r.get_json()["access_token"]
# === Fixture STIX bundle =====================================================
MINIMAL_BUNDLE = {
"type": "bundle",
"id": "bundle--00000000-0000-0000-0000-000000000001",
"spec_version": "2.1",
"objects": [
# Tactic 1 — kept
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0001",
"name": "Initial Access",
"description": "Get a foothold.",
"x_mitre_shortname": "initial-access",
"external_references": [
{
"source_name": "mitre-attack",
"external_id": "TA0001",
"url": "https://attack.mitre.org/tactics/TA0001/",
}
],
},
# Tactic 2 — kept
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0002",
"name": "Execution",
"description": "Run code.",
"x_mitre_shortname": "execution",
"external_references": [
{
"source_name": "mitre-attack",
"external_id": "TA0002",
"url": "https://attack.mitre.org/tactics/TA0002/",
}
],
},
# Revoked tactic — must be skipped
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0099",
"name": "Doomed",
"x_mitre_shortname": "doomed",
"revoked": True,
"external_references": [
{"source_name": "mitre-attack", "external_id": "TA0099"}
],
},
# Technique T1059 covers both tactics
{
"type": "attack-pattern",
"id": "attack-pattern--t1059",
"name": "Command and Scripting Interpreter",
"description": "Use shells.",
"kill_chain_phases": [
{"kill_chain_name": "mitre-attack", "phase_name": "initial-access"},
{"kill_chain_name": "mitre-attack", "phase_name": "execution"},
],
"external_references": [
{
"source_name": "mitre-attack",
"external_id": "T1059",
"url": "https://attack.mitre.org/techniques/T1059/",
}
],
},
# Technique T1078 only initial-access
{
"type": "attack-pattern",
"id": "attack-pattern--t1078",
"name": "Valid Accounts",
"description": "Use legit creds.",
"kill_chain_phases": [
{"kill_chain_name": "mitre-attack", "phase_name": "initial-access"},
],
"external_references": [
{
"source_name": "mitre-attack",
"external_id": "T1078",
"url": "https://attack.mitre.org/techniques/T1078/",
}
],
},
# Deprecated technique — skipped
{
"type": "attack-pattern",
"id": "attack-pattern--t1190",
"name": "Exploit Public-Facing Application",
"x_mitre_deprecated": True,
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1190"}
],
},
# Sub-technique of T1059
{
"type": "attack-pattern",
"id": "attack-pattern--t1059-001",
"name": "PowerShell",
"description": "Windows shell.",
"x_mitre_is_subtechnique": True,
"external_references": [
{
"source_name": "mitre-attack",
"external_id": "T1059.001",
"url": "https://attack.mitre.org/techniques/T1059/001/",
}
],
},
# Relationship attaching the sub to its parent
{
"type": "relationship",
"id": "relationship--rel1",
"relationship_type": "subtechnique-of",
"source_ref": "attack-pattern--t1059-001",
"target_ref": "attack-pattern--t1059",
},
],
}
@pytest.fixture()
def fixture_bundle_path(tmp_path: Path) -> Path:
path = tmp_path / "minimal-stix.json"
path.write_text(json.dumps(MINIMAL_BUNDLE))
return path
# === Parser unit tests =======================================================
def test_parser_extracts_active_objects(fixture_bundle_path):
parsed = mitre_svc.parse_bundle(fixture_bundle_path)
assert len(parsed.tactics) == 2 # TA0001 + TA0002 (TA0099 revoked)
assert {t["external_id"] for t in parsed.tactics} == {"TA0001", "TA0002"}
assert len(parsed.techniques) == 2 # T1059 + T1078 (T1190 deprecated)
assert {t["external_id"] for t in parsed.techniques} == {"T1059", "T1078"}
assert len(parsed.subtechniques) == 1
sb = parsed.subtechniques[0]
assert sb["external_id"] == "T1059.001"
assert sb["parent_stix_id"] == "attack-pattern--t1059"
# === Seed integration tests ==================================================
def test_seed_against_fixture(app, fixture_bundle_path):
result = mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
assert result.tactics_upserted == 2
assert result.techniques_upserted == 2
assert result.subtechniques_upserted == 1
assert result.subtechniques_skipped_orphan == 0
assert result.technique_tactic_links == 3 # T1059→TA0001, T1059→TA0002, T1078→TA0001
def test_seed_is_idempotent(app, fixture_bundle_path):
"""Running twice yields the same row counts and no SQL errors."""
first = mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
second = mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
assert (first.tactics_upserted, first.techniques_upserted, first.subtechniques_upserted) == (
second.tactics_upserted,
second.techniques_upserted,
second.subtechniques_upserted,
)
def test_seed_persists_setting(app, fixture_bundle_path):
"""settings table records the last sync timestamp + source URL."""
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
status = mitre_svc.read_status()
assert status["last_sync"] is not None
# We seeded from a local path so version is None and source_url is the path string.
assert status["source_url"] == str(fixture_bundle_path)
assert status["version"] is None # only set when source == MITRE_DEFAULT_URL
def test_checksum_mismatch_aborts(tmp_path, monkeypatch):
"""A wrong sha256 triggers MitreChecksumMismatch and skips DB writes.
We monkey-patch the allowlist to accept `file://` for the duration of the
test — file:// is rejected in production by `_ensure_host_allowed` (cf.
`test_seed_refuses_file_url`), but we need to drive `_download` past that
gate to exercise the sha256 path.
"""
path = tmp_path / "tiny.json"
path.write_text(json.dumps(MINIMAL_BUNDLE))
monkeypatch.setattr(mitre_svc, "_ensure_host_allowed", lambda _: None)
bogus = "0" * 64
with pytest.raises(mitre_svc.MitreChecksumMismatch):
mitre_svc._download(
f"file://{path}", tmp_path / "out.json", expected_sha256=bogus
)
# === API endpoint tests ======================================================
def test_list_tactics_requires_auth(app, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
r = c.get("/api/v1/mitre/tactics")
assert r.status_code == 401
def test_list_tactics_returns_seeded(app, admin_credentials, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
access = _login(c, admin_credentials["email"], admin_credentials["password"])
r = c.get(
"/api/v1/mitre/tactics", headers={"Authorization": f"Bearer {access}"}
)
assert r.status_code == 200
body = r.get_json()
assert body["total"] == 2
ids = [t["external_id"] for t in body["items"]]
assert "TA0001" in ids and "TA0002" in ids
def test_filter_techniques_by_tactic(app, admin_credentials, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
access = _login(c, admin_credentials["email"], admin_credentials["password"])
r = c.get(
"/api/v1/mitre/techniques?tactic=TA0002",
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 200
body = r.get_json()
# Only T1059 covers TA0002 (execution); T1078 covers initial-access only.
ext_ids = [t["external_id"] for t in body["items"]]
assert ext_ids == ["T1059"]
def test_subtechniques_listed_under_parent(app, admin_credentials, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
access = _login(c, admin_credentials["email"], admin_credentials["password"])
r = c.get(
"/api/v1/mitre/subtechniques?technique=T1059",
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 200
body = r.get_json()
ext_ids = [t["external_id"] for t in body["items"]]
assert ext_ids == ["T1059.001"]
def test_status_endpoint(app, admin_credentials, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
access = _login(c, admin_credentials["email"], admin_credentials["password"])
r = c.get("/api/v1/mitre/status", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
body = r.get_json()
assert body["last_sync"] is not None
assert body["default_url"].startswith("https://")
assert body["default_version"]
def test_sync_endpoint_requires_perm(app, admin_credentials, fixture_bundle_path):
"""A non-admin without mitre.sync gets 403."""
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
# Bootstrap a no-perm user via invitation.
admin_access = _login(c, admin_credentials["email"], admin_credentials["password"])
eve_email = _unique_email("eve")
inv = c.post(
"/api/v1/invitations",
headers={"Authorization": f"Bearer {admin_access}"},
json={"email_hint": eve_email},
)
token = inv.get_json()["token"]
c.post(
f"/api/v1/invitations/accept/{token}",
json={"email": eve_email, "password": "EvePass1234!"},
)
eve_access = _login(c, eve_email, "EvePass1234!")
r = c.post(
"/api/v1/mitre/sync", headers={"Authorization": f"Bearer {eve_access}"}
)
assert r.status_code == 403
def test_search_filter_on_name(app, admin_credentials, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
access = _login(c, admin_credentials["email"], admin_credentials["password"])
r = c.get(
"/api/v1/mitre/techniques?q=valid",
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 200
ext_ids = [t["external_id"] for t in r.get_json()["items"]]
assert ext_ids == ["T1078"]
def test_matrix_endpoint_returns_nested_grid(app, admin_credentials, fixture_bundle_path):
"""GET /mitre/matrix returns the flat tactic→technique→subtechnique grid."""
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
access = _login(c, admin_credentials["email"], admin_credentials["password"])
r = c.get("/api/v1/mitre/matrix", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
body = r.get_json()
tactics = body["tactics"]
assert {t["external_id"] for t in tactics} == {"TA0001", "TA0002"}
# TA0001 has T1059 (multi-tactic) + T1078; T1059 carries its sub.
ta0001 = next(t for t in tactics if t["external_id"] == "TA0001")
techs = {t["external_id"]: t for t in ta0001["techniques"]}
assert set(techs.keys()) == {"T1059", "T1078"}
assert techs["T1059"]["subtechniques"][0]["external_id"] == "T1059.001"
assert techs["T1078"]["subtechniques"] == []
# TA0002 only carries T1059 (no T1078).
ta0002 = next(t for t in tactics if t["external_id"] == "TA0002")
assert [t["external_id"] for t in ta0002["techniques"]] == ["T1059"]
def test_matrix_endpoint_requires_auth(app, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
assert c.get("/api/v1/mitre/matrix").status_code == 401
# === Security guards ==========================================================
def test_seed_refuses_file_url(tmp_path):
"""file:// (or any scheme outside the allowlist) is rejected — protects
against a privileged operator pivoting the in-container fetch to local
filesystem reads via the URL path."""
path = tmp_path / "bundle.json"
path.write_text(json.dumps(MINIMAL_BUNDLE))
with pytest.raises(mitre_svc.MitreSourceForbidden):
mitre_svc._download(f"file://{path}", tmp_path / "out.json")
def test_seed_refuses_disallowed_https_host(tmp_path):
"""An HTTPS URL outside MITRE_ALLOWED_HOSTS is rejected before any I/O.
Closes the SSRF surface (cloud metadata, internal mirrors)."""
with pytest.raises(mitre_svc.MitreSourceForbidden):
mitre_svc._download("https://attacker.example/bundle.json", tmp_path / "out.json")
def test_seed_refuses_custom_url_without_sha(tmp_path):
"""End-to-end refusal: even an allowlisted custom URL needs a sha or an
explicit allow_unverified=True."""
# Use the default URL with a different sha to simulate "custom" semantics
# without actually hitting the network: pass a different MITRE_DEFAULT_URL.
# The cleanest expression is to call seed_mitre with the same URL but no sha
# — but the default URL gets the default sha auto-set; we need to bypass.
with pytest.raises(mitre_svc.MitreSeedError):
mitre_svc.seed_mitre(
source="https://raw.githubusercontent.com/some-other-path/bundle.json",
expected_sha256=None,
allow_unverified=False,
)
def test_dotted_id_fallback_resolves_orphan_subtechnique(app, tmp_path):
"""When the STIX `subtechnique-of` relationship is missing, the parser
must fall back to the dotted convention (T1003.001 → T1003)."""
bundle = json.loads(json.dumps(MINIMAL_BUNDLE)) # deep copy
# Strip the relationship object so the parent_stix_id lookup fails.
bundle["objects"] = [o for o in bundle["objects"] if o.get("type") != "relationship"]
bundle_path = tmp_path / "no-rel.json"
bundle_path.write_text(json.dumps(bundle))
result = mitre_svc.seed_mitre(source=bundle_path, expected_sha256=None)
# The fallback resolves T1059.001 → T1059 via the dotted-id pattern,
# so the subtechnique is still attached (no orphan).
assert result.subtechniques_upserted == 1
assert result.subtechniques_skipped_orphan == 0
def test_seed_clears_version_when_source_is_not_default(app, fixture_bundle_path):
"""A custom source must NULL `mitre_version` so /mitre/status doesn't lie
about a stale upstream pin."""
# First seed from the default URL would set version=19.0; here we seed from
# a local file path, which should write version=None.
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
assert mitre_svc.read_status()["version"] is None
-567
View File
@@ -1,567 +0,0 @@
"""M5 — Template catalogue integration tests.
Covers `test_template` and `scenario_template` CRUD + ordering + perm gating.
Relies on a minimal MITRE seed (T1059 / TA0001 / T1059.001) so the polymorphic
tag join can be exercised end-to-end.
"""
from __future__ import annotations
import json
import secrets
import pytest
from sqlalchemy import text
from app.core.install_token import regenerate_install_token
from app.main import create_app
from app.services import mitre_seed as mitre_svc
def _truncate_all(engine):
with engine.begin() as conn:
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, group_permissions, permissions, settings, groups, "
"scenario_template_tests, scenario_templates, "
"test_template_mitre_tags, test_templates, "
"mitre_subtechniques, mitre_technique_tactics, mitre_techniques, "
"mitre_tactics RESTART IDENTITY CASCADE"
)
)
# Same minimal bundle as in test_mitre.py — keeps tag resolution deterministic
# without re-pulling the full enterprise STIX bundle.
_MINIMAL_BUNDLE = {
"type": "bundle",
"id": "bundle--00000000-0000-0000-0000-000000000002",
"spec_version": "2.1",
"objects": [
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0001",
"name": "Initial Access",
"x_mitre_shortname": "initial-access",
"external_references": [
{"source_name": "mitre-attack", "external_id": "TA0001"}
],
},
{
"type": "x-mitre-tactic",
"id": "x-mitre-tactic--ta0002",
"name": "Execution",
"x_mitre_shortname": "execution",
"external_references": [
{"source_name": "mitre-attack", "external_id": "TA0002"}
],
},
{
"type": "attack-pattern",
"id": "attack-pattern--t1059",
"name": "Command and Scripting Interpreter",
"kill_chain_phases": [
{"kill_chain_name": "mitre-attack", "phase_name": "execution"}
],
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059"}
],
},
{
"type": "attack-pattern",
"id": "attack-pattern--t1059-001",
"name": "PowerShell",
"x_mitre_is_subtechnique": True,
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059.001"}
],
},
{
"type": "relationship",
"id": "relationship--rel1",
"relationship_type": "subtechnique-of",
"source_ref": "attack-pattern--t1059-001",
"target_ref": "attack-pattern--t1059",
},
],
}
@pytest.fixture(scope="module")
def app(db_engine_or_skip, tmp_path_factory):
_truncate_all(db_engine_or_skip)
bundle_path = tmp_path_factory.mktemp("m5") / "stix.json"
bundle_path.write_text(json.dumps(_MINIMAL_BUNDLE))
mitre_svc.seed_mitre(source=bundle_path, expected_sha256=None)
flask_app = create_app()
flask_app.config.update(TESTING=True)
return flask_app
@pytest.fixture()
def client(app):
return app.test_client()
def _unique_email(prefix: str) -> str:
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
@pytest.fixture(scope="module")
def admin(app):
token = regenerate_install_token()
email = _unique_email("admin")
password = "AdminPass1234!"
with app.test_client() as c:
r = c.post(
"/api/v1/setup",
json={"install_token": token, "email": email, "password": password},
)
assert r.status_code == 201, r.get_data(as_text=True)
return {"email": email, "password": password}
def _login(client, email: str, password: str) -> str:
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.get_data(as_text=True)
return r.get_json()["access_token"]
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def admin_token(client, admin) -> str:
return _login(client, admin["email"], admin["password"])
# === Reader fixture: an invited user with only `test_template.read` =========
def _bootstrap_user_without_perms(client, admin_token: str, prefix: str) -> tuple[str, str]:
email = _unique_email(prefix)
inv = client.post(
"/api/v1/invitations",
headers=_bearer(admin_token),
json={"email_hint": email},
)
token = inv.get_json()["token"]
password = "ReaderPass1234!"
client.post(
f"/api/v1/invitations/accept/{token}",
json={"email": email, "password": password},
)
return email, _login(client, email, password)
# === test_template CRUD =====================================================
def _make_test(client, admin_token: str, **overrides):
body = {
"name": overrides.pop("name", f"Test {secrets.token_hex(2)}"),
"description": overrides.pop("description", "auto"),
"objective": "do thing",
"procedure_md": "1. step",
"expected_result_red_md": "red expectation",
"expected_detection_blue_md": "blue expectation",
"opsec_level": overrides.pop("opsec_level", "medium"),
"tags": overrides.pop("tags", ["fast"]),
"expected_iocs": ["evil.exe"],
"mitre_tags": overrides.pop("mitre_tags", [{"kind": "technique", "external_id": "T1059"}]),
**overrides,
}
r = client.post("/api/v1/test-templates", headers=_bearer(admin_token), json=body)
assert r.status_code == 201, r.get_data(as_text=True)
return r.get_json()
def test_create_test_template_with_mitre_tags(client, admin_token):
body = _make_test(
client,
admin_token,
name="PowerShell exec",
mitre_tags=[
{"kind": "tactic", "external_id": "TA0002"},
{"kind": "technique", "external_id": "T1059"},
{"kind": "subtechnique", "external_id": "T1059.001"},
],
)
assert body["opsec_level"] == "medium"
kinds = sorted((t["kind"], t["external_id"]) for t in body["mitre_tags"])
assert kinds == [
("subtechnique", "T1059.001"),
("tactic", "TA0002"),
("technique", "T1059"),
]
def test_create_test_template_rejects_unknown_mitre(client, admin_token):
r = client.post(
"/api/v1/test-templates",
headers=_bearer(admin_token),
json={
"name": "Bad",
"mitre_tags": [{"kind": "technique", "external_id": "T9999"}],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_mitre_tag"
def test_create_test_template_rejects_bad_opsec(client, admin_token):
r = client.post(
"/api/v1/test-templates",
headers=_bearer(admin_token),
json={"name": "Bad", "opsec_level": "burner"},
)
assert r.status_code == 400
def test_list_test_templates_filter_by_tactic(client, admin_token):
_make_test(
client,
admin_token,
name="filterable-1",
mitre_tags=[{"kind": "tactic", "external_id": "TA0002"}],
)
r = client.get(
"/api/v1/test-templates?tactic=TA0002",
headers=_bearer(admin_token),
)
assert r.status_code == 200
body = r.get_json()
names = [it["name"] for it in body["items"]]
assert "filterable-1" in names
def test_list_test_templates_filter_by_opsec(client, admin_token):
_make_test(client, admin_token, name="high-opsec", opsec_level="high")
r = client.get(
"/api/v1/test-templates?opsec=high",
headers=_bearer(admin_token),
)
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "high-opsec" in names
assert all(it["opsec_level"] == "high" for it in r.get_json()["items"])
def test_list_test_templates_filter_by_tag(client, admin_token):
_make_test(client, admin_token, name="tagged-fast", tags=["fast", "phish"])
r = client.get(
"/api/v1/test-templates?tag=phish",
headers=_bearer(admin_token),
)
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "tagged-fast" in names
def test_list_test_templates_search_q(client, admin_token):
_make_test(client, admin_token, name="unique-token-azertyuiop")
r = client.get(
"/api/v1/test-templates?q=AZERTYUIOP", # case-insensitive
headers=_bearer(admin_token),
)
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "unique-token-azertyuiop" in names
def test_update_test_template_replaces_mitre_tags(client, admin_token):
body = _make_test(
client,
admin_token,
name="to-update",
mitre_tags=[{"kind": "tactic", "external_id": "TA0001"}],
)
r = client.put(
f"/api/v1/test-templates/{body['id']}",
headers=_bearer(admin_token),
json={"mitre_tags": [{"kind": "technique", "external_id": "T1059"}]},
)
assert r.status_code == 200, r.get_data(as_text=True)
updated = r.get_json()
kinds = [(t["kind"], t["external_id"]) for t in updated["mitre_tags"]]
assert kinds == [("technique", "T1059")]
def test_update_test_template_partial_keeps_unset_fields(client, admin_token):
body = _make_test(
client,
admin_token,
name="partial-update",
opsec_level="low",
tags=["a", "b"],
)
r = client.put(
f"/api/v1/test-templates/{body['id']}",
headers=_bearer(admin_token),
json={"name": "renamed"},
)
assert r.status_code == 200
updated = r.get_json()
assert updated["name"] == "renamed"
assert updated["opsec_level"] == "low" # untouched
assert set(updated["tags"]) == {"a", "b"} # untouched
def test_soft_delete_then_list_hides_by_default(client, admin_token):
body = _make_test(client, admin_token, name="to-be-deleted")
r = client.delete(
f"/api/v1/test-templates/{body['id']}", headers=_bearer(admin_token)
)
assert r.status_code == 200
r2 = client.get("/api/v1/test-templates", headers=_bearer(admin_token))
names = [it["name"] for it in r2.get_json()["items"]]
assert "to-be-deleted" not in names
# And reappears with include_deleted=true
r3 = client.get(
"/api/v1/test-templates?include_deleted=true",
headers=_bearer(admin_token),
)
names3 = [it["name"] for it in r3.get_json()["items"]]
assert "to-be-deleted" in names3
def test_read_perm_required(client, admin_token):
"""A user without `test_template.read` gets 403."""
_, eve_token = _bootstrap_user_without_perms(client, admin_token, "eve-noperm")
r = client.get("/api/v1/test-templates", headers=_bearer(eve_token))
assert r.status_code == 403
def test_write_perm_required(client, admin_token):
"""A user with only `test_template.read` cannot create.
Bootstrap path: create a dedicated group via the admin API, bind only the
`test_template.read` perm, then invite a user pre-assigned to that group.
"""
# 1. Create the read-only group + bind the single perm.
grp = client.post(
"/api/v1/groups",
headers=_bearer(admin_token),
json={"name": f"tpl-reader-{secrets.token_hex(2)}"},
).get_json()
r_set = client.put(
f"/api/v1/groups/{grp['id']}/permissions",
headers=_bearer(admin_token),
json={"codes": ["test_template.read"]},
)
assert r_set.status_code == 200, r_set.get_data(as_text=True)
# 2. Invite a user already attached to that group.
email = _unique_email("alice-readonly")
password = "ReaderPass1234!"
inv = client.post(
"/api/v1/invitations",
headers=_bearer(admin_token),
json={"email_hint": email, "group_ids": [grp["id"]]},
).get_json()
client.post(
f"/api/v1/invitations/accept/{inv['token']}",
json={"email": email, "password": password},
)
token = _login(client, email, password)
r = client.get("/api/v1/test-templates", headers=_bearer(token))
assert r.status_code == 200, r.get_data(as_text=True)
r2 = client.post(
"/api/v1/test-templates", headers=_bearer(token), json={"name": "X"}
)
assert r2.status_code == 403
# === scenario_template CRUD =================================================
def test_create_scenario_with_ordered_tests(client, admin_token):
a = _make_test(client, admin_token, name="scn-a")
b = _make_test(client, admin_token, name="scn-b")
c = _make_test(client, admin_token, name="scn-c")
r = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={
"name": "phishing-flow",
"description": "click → exec → persist",
"test_template_ids": [a["id"], b["id"], c["id"]],
},
)
assert r.status_code == 201, r.get_data(as_text=True)
body = r.get_json()
assert body["tests_count"] == 3
assert [t["position"] for t in body["tests"]] == [0, 1, 2]
assert [t["test_template_name"] for t in body["tests"]] == ["scn-a", "scn-b", "scn-c"]
def test_reorder_scenario_tests(client, admin_token):
a = _make_test(client, admin_token, name="reord-a")
b = _make_test(client, admin_token, name="reord-b")
c = _make_test(client, admin_token, name="reord-c")
created = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={
"name": "reorder-me",
"test_template_ids": [a["id"], b["id"], c["id"]],
},
).get_json()
# Reverse order.
r = client.put(
f"/api/v1/scenario-templates/{created['id']}/tests",
headers=_bearer(admin_token),
json={"test_template_ids": [c["id"], b["id"], a["id"]]},
)
assert r.status_code == 200
after = r.get_json()
assert [t["test_template_name"] for t in after["tests"]] == ["reord-c", "reord-b", "reord-a"]
# Re-reading via GET yields the same order — confirms persistence.
fresh = client.get(
f"/api/v1/scenario-templates/{created['id']}", headers=_bearer(admin_token)
).get_json()
assert [t["test_template_name"] for t in fresh["tests"]] == ["reord-c", "reord-b", "reord-a"]
def test_scenario_rejects_unknown_test_id(client, admin_token):
r = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={
"name": "bad",
"test_template_ids": ["00000000-0000-0000-0000-000000000000"],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_test_template"
def test_scenario_rejects_soft_deleted_test_on_create(client, admin_token):
a = _make_test(client, admin_token, name="will-be-deleted")
client.delete(f"/api/v1/test-templates/{a['id']}", headers=_bearer(admin_token))
r = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "linked", "test_template_ids": [a["id"]]},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_test_template"
def test_scenario_surfaces_soft_deleted_test_after_link(client, admin_token):
"""Once linked, a test can be soft-deleted without breaking the scenario —
the join row stays and the API flags the test as deleted."""
a = _make_test(client, admin_token, name="linked-then-deleted")
sc = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "survives", "test_template_ids": [a["id"]]},
).get_json()
client.delete(f"/api/v1/test-templates/{a['id']}", headers=_bearer(admin_token))
fresh = client.get(
f"/api/v1/scenario-templates/{sc['id']}", headers=_bearer(admin_token)
).get_json()
assert fresh["tests"][0]["test_template_deleted"] is True
def test_scenario_soft_delete(client, admin_token):
sc = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "doomed-scn"},
).get_json()
r = client.delete(
f"/api/v1/scenario-templates/{sc['id']}", headers=_bearer(admin_token)
)
assert r.status_code == 200
names = [
it["name"]
for it in client.get(
"/api/v1/scenario-templates", headers=_bearer(admin_token)
).get_json()["items"]
]
assert "doomed-scn" not in names
def test_scenario_perm_required(client, admin_token):
_, eve_token = _bootstrap_user_without_perms(client, admin_token, "scn-eve")
r = client.get("/api/v1/scenario-templates", headers=_bearer(eve_token))
assert r.status_code == 403
# === Post-review fixes ======================================================
def test_list_filter_combines_facets_with_and_semantics(client, admin_token):
"""A template tagged only `TA0002` is NOT in `?tactic=TA0002&technique=T1059`.
Pre-fix the OR-combined query would return it. AND-combined semantics
(one IN subquery per facet) restrict the set to templates matching ALL
requested facets.
"""
a = _make_test(
client,
admin_token,
name="and-tactic-only",
mitre_tags=[{"kind": "tactic", "external_id": "TA0002"}],
)
b = _make_test(
client,
admin_token,
name="and-both-tags",
mitre_tags=[
{"kind": "tactic", "external_id": "TA0002"},
{"kind": "technique", "external_id": "T1059"},
],
)
r = client.get(
"/api/v1/test-templates?tactic=TA0002&technique=T1059",
headers=_bearer(admin_token),
)
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "and-both-tags" in names
assert "and-tactic-only" not in names
_ = a, b # silence unused vars from linter
def test_create_test_template_rejects_extra_fields(client, admin_token):
"""`model_config = {"extra": "forbid"}` — unknown fields must 400."""
r = client.post(
"/api/v1/test-templates",
headers=_bearer(admin_token),
json={"name": "extra-test", "rogue_field": "smuggled"},
)
assert r.status_code == 400
def test_update_test_template_explicit_empty_mitre_clears(client, admin_token):
"""`PUT { mitre_tags: [] }` is an explicit clear, not a no-op."""
body = _make_test(
client,
admin_token,
name="clear-tags",
mitre_tags=[{"kind": "technique", "external_id": "T1059"}],
)
assert len(body["mitre_tags"]) == 1
r = client.put(
f"/api/v1/test-templates/{body['id']}",
headers=_bearer(admin_token),
json={"mitre_tags": []},
)
assert r.status_code == 200
assert r.get_json()["mitre_tags"] == []
def test_tag_item_length_capped_at_64(client, admin_token):
"""Individual `tags` items must be ≤ 64 chars at the wire layer."""
long_tag = "x" * 65
r = client.post(
"/api/v1/test-templates",
headers=_bearer(admin_token),
json={"name": "long-tag", "tags": [long_tag]},
)
assert r.status_code == 400
-2
View File
@@ -38,7 +38,6 @@ services:
EVIDENCE_DIR: ${EVIDENCE_DIR} EVIDENCE_DIR: ${EVIDENCE_DIR}
volumes: volumes:
- metamorph_evidence:/data/evidence - metamorph_evidence:/data/evidence
- metamorph_mitre:/data/mitre
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -77,7 +76,6 @@ services:
volumes: volumes:
metamorph_db: metamorph_db:
metamorph_evidence: metamorph_evidence:
metamorph_mitre:
networks: networks:
metamorph: metamorph:
-178
View File
@@ -1,178 +0,0 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
/**
* M4 — MITRE ATT&CK Enterprise reference catalogue + tag picker.
*
* The seed itself (download + parse) is exercised by pytest with a small
* fixture bundle. This spec hits the live stack with the real, pinned bundle
* by calling `POST /mitre/sync` once and then validating the read endpoints
* + the picker UI.
*/
// crypto.randomUUID() guarantees uniqueness across parallel test runs; the
// Math.random() previous pattern could collide one-in-a-million in CI.
const ADMIN_EMAIL = `admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!';
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
const r = await request.post('/api/v1/diag/reset');
expect(r.status()).toBe(200);
return (await r.json()).install_token as string;
}
async function loginAndGetAccess(
request: APIRequestContext,
email: string,
password: string,
): Promise<string> {
const r = await request.post('/api/v1/auth/login', { data: { email, password } });
expect(r.status()).toBe(200);
return (await r.json()).access_token as string;
}
async function loginViaSpa(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(email);
}
test.describe.configure({ mode: 'serial' });
test.describe('M4 — MITRE ATT&CK reference', () => {
test.beforeAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
const setup = await request.post('/api/v1/setup', {
data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(setup.status()).toBe(201);
// Trigger a real sync against the pinned MITRE URL. Idempotent — if the
// mitre_* tables were left populated by a previous run, this is a no-op
// upsert.
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const sync = await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
expect(sync.status(), `mitre sync failed: ${await sync.text()}`).toBe(200);
const result = await sync.json();
// Pinned exactly to MITRE Enterprise v19.0 — bump alongside MITRE_VERSION
// in `app/services/mitre_seed.py` when the pin changes. Exact counts catch
// parser regressions that would silently include revoked/deprecated rows.
expect(result.tactics_upserted).toBe(15);
expect(result.techniques_upserted).toBe(222);
expect(result.subtechniques_upserted).toBe(475);
expect(result.subtechniques_skipped_orphan).toBe(0);
});
test('GET /mitre/tactics returns 14+ Enterprise tactics', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/mitre/tactics', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const body = await r.json();
expect(body.total).toBeGreaterThanOrEqual(14);
const ids = body.items.map((t: { external_id: string }) => t.external_id);
expect(ids).toContain('TA0001'); // Initial Access
expect(ids).toContain('TA0006'); // Credential Access
});
test('GET /mitre/techniques?tactic=TA0006 filters', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/mitre/techniques?tactic=TA0006&limit=200', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const body = await r.json();
expect(body.total).toBeGreaterThan(0);
// OS Credential Dumping is the textbook TA0006 example.
const ids = body.items.map((t: { external_id: string }) => t.external_id);
expect(ids).toContain('T1003');
});
test('GET /mitre/subtechniques?technique=T1003 lists 8 sub-techniques', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/mitre/subtechniques?technique=T1003', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const ids = (await r.json()).items.map((t: { external_id: string }) => t.external_id);
expect(ids).toContain('T1003.001'); // LSASS Memory
expect(ids.length).toBeGreaterThanOrEqual(5);
});
test('GET /mitre/status returns version + last_sync', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const r = await request.get('/api/v1/mitre/status', {
headers: { Authorization: `Bearer ${access}` },
});
expect(r.status()).toBe(200);
const body = await r.json();
expect(body.last_sync).toBeTruthy();
expect(body.default_url).toContain('mitre-attack');
expect(body.default_version).toBeTruthy();
});
test('SPA MITRE matrix renders + click cells to select technique + sub-technique', async ({ page }) => {
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/mitre');
// Status card shows a non-null last_sync.
await expect(page.getByTestId('mitre-last-sync')).not.toHaveText('never');
const picker = page.getByTestId('mitre-tag-picker');
await expect(picker).toBeVisible();
// The matrix has a column per tactic.
await expect(picker.getByTestId('mitre-column-TA0006')).toBeVisible();
// 1. Click the T1003 cell (Credential Dumping under TA0006) → technique selected.
const t1003 = picker.getByTestId('mitre-technique-T1003').first();
await t1003.scrollIntoViewIfNeeded();
await t1003.click();
await expect(page.getByTestId('mitre-selected')).toContainText('T1003');
await expect(t1003).toHaveAttribute('aria-pressed', 'true');
// 2. Expand T1003's sub-techniques inline via the +N chevron.
await picker.getByTestId('mitre-expand-T1003').first().click();
const sub = picker.getByTestId('mitre-subtechnique-T1003.001').first();
await expect(sub).toBeVisible();
// 3. Click the sub-technique → chip + JSON preview both update.
await sub.click();
await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001');
await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"');
// 4. Filter the matrix on "valid" → TA0006/T1003 are hidden but TA0001/T1078 visible.
await picker.getByLabel(/^filter$/i).fill('valid');
await expect(picker.getByTestId('mitre-technique-T1078').first()).toBeVisible();
});
test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => {
// Invite a no-perm user via the admin.
const adminAccess = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const eveEmail = `eve-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const inv = await request.post('/api/v1/invitations', {
headers: { Authorization: `Bearer ${adminAccess}` },
data: { email_hint: eveEmail },
});
const token = (await inv.json()).token;
await request.post(`/api/v1/invitations/accept/${token}`, {
data: { email: eveEmail, password: 'EvePass1234!' },
});
const eveAccess = await loginAndGetAccess(request, eveEmail, 'EvePass1234!');
const r = await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${eveAccess}` },
});
expect(r.status()).toBe(403);
// The MITRE page is reachable in read-only mode for any logged-in user,
// but the Sync card is hidden for non-admins.
await loginViaSpa(page, eveEmail, 'EvePass1234!');
await page.goto('/mitre');
await expect(page.getByTestId('mitre-tag-picker')).toBeVisible();
await expect(page.getByTestId('mitre-sync')).toHaveCount(0);
});
});
-253
View File
@@ -1,253 +0,0 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
/**
* M5 — Test + Scenario template catalogue.
*
* Verifies CRUD on /test-templates and /scenario-templates plus the admin SPA
* pages. We do NOT seed the full MITRE bundle here — M4 already covers that
* suite. This spec only needs ONE technique resolvable from a STIX-like
* shape (we ride on the same `/diag/reset` then re-seed MITRE so tag refs
* resolve).
*/
const ADMIN_EMAIL = `admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!';
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
const r = await request.post('/api/v1/diag/reset');
expect(r.status()).toBe(200);
return (await r.json()).install_token as string;
}
async function loginAndGetAccess(
request: APIRequestContext,
email: string,
password: string,
): Promise<string> {
const r = await request.post('/api/v1/auth/login', { data: { email, password } });
expect(r.status()).toBe(200);
return (await r.json()).access_token as string;
}
async function loginViaSpa(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(email);
}
test.describe.configure({ mode: 'serial' });
test.describe('M5 — Template catalogue', () => {
test.beforeAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
const setup = await request.post('/api/v1/setup', {
data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(setup.status()).toBe(201);
// MITRE re-sync — picker + tag refs rely on the canonical bundle.
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const sync = await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
expect(sync.status()).toBe(200);
});
test.afterAll(async ({ request }) => {
// Restore the stable admin (cf. memory feedback_metamorph_test_admin):
// any wipe should leave admin@metamorph.local / AdminPass1234! usable.
const installToken = await resetAndMintToken(request);
await request.post('/api/v1/setup', {
data: {
install_token: installToken,
email: 'admin@metamorph.local',
password: 'AdminPass1234!',
},
});
// Re-seed MITRE so subsequent manual sessions don't see an empty matrix.
const access = await loginAndGetAccess(request, 'admin@metamorph.local', 'AdminPass1234!');
await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
});
// === API smoke ============================================================
test('CRUD test-templates via API', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const auth = { Authorization: `Bearer ${access}` };
// Create
const r1 = await request.post('/api/v1/test-templates', {
headers: auth,
data: {
name: 'phish-link',
description: 'send a phishing email with tracked link',
objective: 'land a click',
procedure_md: '1. craft mail\n2. send\n3. await click',
opsec_level: 'low',
tags: ['phish', 'initial-access'],
expected_iocs: ['phish@example.com'],
mitre_tags: [
{ kind: 'tactic', external_id: 'TA0001' },
{ kind: 'technique', external_id: 'T1566' },
],
},
});
expect(r1.status(), await r1.text()).toBe(201);
const created = await r1.json();
expect(created.name).toBe('phish-link');
expect(created.mitre_tags.length).toBe(2);
expect(created.tags).toContain('phish');
// Update — partial: change opsec only
const r2 = await request.put(`/api/v1/test-templates/${created.id}`, {
headers: auth,
data: { opsec_level: 'high' },
});
expect(r2.status()).toBe(200);
const updated = await r2.json();
expect(updated.opsec_level).toBe('high');
expect(updated.name).toBe('phish-link'); // untouched
// List + filter by tactic
const r3 = await request.get('/api/v1/test-templates?tactic=TA0001', {
headers: auth,
});
expect(r3.status()).toBe(200);
const list = await r3.json();
expect(list.items.map((it: { name: string }) => it.name)).toContain('phish-link');
// Reject unknown MITRE
const r4 = await request.post('/api/v1/test-templates', {
headers: auth,
data: {
name: 'bad',
mitre_tags: [{ kind: 'technique', external_id: 'T9999' }],
},
});
expect(r4.status()).toBe(400);
expect((await r4.json()).error).toBe('unknown_mitre_tag');
// Soft-delete
const r5 = await request.delete(`/api/v1/test-templates/${created.id}`, {
headers: auth,
});
expect(r5.status()).toBe(200);
const r6 = await request.get('/api/v1/test-templates', { headers: auth });
expect(
(await r6.json()).items.map((it: { name: string }) => it.name),
).not.toContain('phish-link');
});
test('Scenario template: create + reorder + soft-delete', async ({ request }) => {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const auth = { Authorization: `Bearer ${access}` };
async function mkTest(name: string): Promise<string> {
const r = await request.post('/api/v1/test-templates', {
headers: auth,
data: { name },
});
expect(r.status()).toBe(201);
return (await r.json()).id as string;
}
const a = await mkTest('scn-step-a');
const b = await mkTest('scn-step-b');
const c = await mkTest('scn-step-c');
// Create with [a, b, c]
const r1 = await request.post('/api/v1/scenario-templates', {
headers: auth,
data: { name: 'ordered-scenario', test_template_ids: [a, b, c] },
});
expect(r1.status()).toBe(201);
const sc = await r1.json();
expect(sc.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([
'scn-step-a',
'scn-step-b',
'scn-step-c',
]);
// Reorder → [c, a, b]
const r2 = await request.put(`/api/v1/scenario-templates/${sc.id}/tests`, {
headers: auth,
data: { test_template_ids: [c, a, b] },
});
expect(r2.status()).toBe(200);
const after = await r2.json();
expect(after.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([
'scn-step-c',
'scn-step-a',
'scn-step-b',
]);
// Soft-delete the scenario.
const r3 = await request.delete(`/api/v1/scenario-templates/${sc.id}`, { headers: auth });
expect(r3.status()).toBe(200);
const list = await (await request.get('/api/v1/scenario-templates', { headers: auth })).json();
expect(list.items.map((it: { name: string }) => it.name)).not.toContain('ordered-scenario');
});
// === SPA smoke ============================================================
test('SPA — admin sees the test catalogue and can filter', async ({ page, request }) => {
// Seed two tests up front via the API — exercise the SPA list + filter
// pipeline without fighting the heavy create-modal (covered by API tests).
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const auth = { Authorization: `Bearer ${access}` };
await request.post('/api/v1/test-templates', {
headers: auth,
data: { name: 'spa-list-fast', opsec_level: 'low', tags: ['fast'] },
});
await request.post('/api/v1/test-templates', {
headers: auth,
data: { name: 'spa-list-slow', opsec_level: 'high' },
});
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/admin/tests');
await expect(page.getByText('spa-list-fast')).toBeVisible();
await expect(page.getByText('spa-list-slow')).toBeVisible();
await page.getByTestId('filter-opsec').selectOption('high');
await expect(page.getByText('spa-list-slow')).toBeVisible();
await expect(page.getByText('spa-list-fast')).toBeHidden();
});
test('SPA — scenario list shows ordered tests with their position', async ({ page, request }) => {
// Seed a 3-test scenario via the API; the SPA must render the order as
// saved. Pointer-event drag is flaky in CI, and the API-level reorder
// test already covers the persistence pipeline.
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const auth = { Authorization: `Bearer ${access}` };
const ids: string[] = [];
for (const name of ['drag-1', 'drag-2', 'drag-3']) {
const r = await request.post('/api/v1/test-templates', {
headers: auth,
data: { name },
});
ids.push((await r.json()).id);
}
const scResp = await request.post('/api/v1/scenario-templates', {
headers: auth,
data: {
name: 'spa-rendered-scenario',
test_template_ids: [ids[2], ids[0], ids[1]],
},
});
const scId = (await scResp.json()).id;
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/admin/scenarios');
const card = page.locator(`[data-testid="scenario-row-${scId}"]`);
await expect(card).toBeVisible();
await expect(card.getByText('1. drag-3')).toBeVisible();
await expect(card.getByText('2. drag-1')).toBeVisible();
await expect(card.getByText('3. drag-2')).toBeVisible();
});
});
-405
View File
@@ -1,405 +0,0 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
/**
* M6 — Mission CRUD, snapshot fidelity, membership visibility, transitions.
*
* The suite covers:
* - Snapshot independence (mutating a template after mission creation must NOT
* propagate into the mission's snapshot).
* - Membership visibility (non-admin viewers see only their own missions).
* - Status transition state machine (draft → in_progress → completed → archived).
* - SPA: list + 3-step create wizard + detail page tabs.
*
* Template + MITRE seed are pulled in `beforeAll`; the `afterAll` hook restores
* the stable admin (memory rule `feedback-metamorph-test-admin`) and re-seeds
* MITRE so subsequent manual sessions don't see an empty matrix.
*/
const ADMIN_EMAIL = `m6-admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const ADMIN_PASSWORD = 'AdminPass1234!';
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
const r = await request.post('/api/v1/diag/reset');
expect(r.status()).toBe(200);
return (await r.json()).install_token as string;
}
async function loginAndGetAccess(
request: APIRequestContext,
email: string,
password: string,
): Promise<string> {
const r = await request.post('/api/v1/auth/login', { data: { email, password } });
expect(r.status()).toBe(200);
return (await r.json()).access_token as string;
}
async function loginViaSpa(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByTestId('me-email')).toHaveText(email);
}
test.describe.configure({ mode: 'serial' });
test.describe('M6 — Missions', () => {
test.beforeAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
const setup = await request.post('/api/v1/setup', {
data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(setup.status()).toBe(201);
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
const sync = await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
expect(sync.status()).toBe(200);
});
test.afterAll(async ({ request }) => {
const installToken = await resetAndMintToken(request);
await request.post('/api/v1/setup', {
data: {
install_token: installToken,
email: 'admin@metamorph.local',
password: 'AdminPass1234!',
},
});
const access = await loginAndGetAccess(request, 'admin@metamorph.local', 'AdminPass1234!');
await request.post('/api/v1/mitre/sync', {
headers: { Authorization: `Bearer ${access}` },
});
});
// ---------- helpers ----------------------------------------------------
async function adminAuth(request: APIRequestContext): Promise<Record<string, string>> {
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
return { Authorization: `Bearer ${access}` };
}
async function makeTest(
request: APIRequestContext,
auth: Record<string, string>,
name: string,
mitre = 'T1059',
): Promise<string> {
const r = await request.post('/api/v1/test-templates', {
headers: auth,
data: {
name,
mitre_tags: [{ kind: 'technique', external_id: mitre }],
},
});
expect(r.status(), await r.text()).toBe(201);
return (await r.json()).id as string;
}
async function makeScenario(
request: APIRequestContext,
auth: Record<string, string>,
name: string,
testIds: string[],
): Promise<string> {
const r = await request.post('/api/v1/scenario-templates', {
headers: auth,
data: { name, test_template_ids: testIds },
});
expect(r.status(), await r.text()).toBe(201);
return (await r.json()).id as string;
}
// ---------- API: snapshot fidelity ------------------------------------
test('Snapshot freezes scenario + test fields at creation time', async ({ request }) => {
const auth = await adminAuth(request);
const tid = await makeTest(request, auth, 'snap-t1');
const sid = await makeScenario(request, auth, 'snap-scenario', [tid]);
const create = await request.post('/api/v1/missions', {
headers: auth,
data: {
name: 'snap-mission',
client_target: 'Acme',
scenario_template_ids: [sid],
},
});
expect(create.status(), await create.text()).toBe(201);
const mission = await create.json();
expect(mission.scenarios_count).toBe(1);
expect(mission.tests_count).toBe(1);
expect(mission.scenarios[0].tests[0].snapshot_name).toBe('snap-t1');
// Mutate the source template AFTER snapshot
const edit = await request.put(`/api/v1/test-templates/${tid}`, {
headers: auth,
data: {
name: 'RENAMED-LATER',
mitre_tags: [{ kind: 'tactic', external_id: 'TA0002' }],
},
});
expect(edit.status()).toBe(200);
// Mission still sees the pre-edit snapshot
const refetch = await request.get(`/api/v1/missions/${mission.id}`, { headers: auth });
expect(refetch.status()).toBe(200);
const snapshot = await refetch.json();
expect(snapshot.scenarios[0].tests[0].snapshot_name).toBe('snap-t1');
expect(
snapshot.scenarios[0].tests[0].mitre_tags.map(
(t: { external_id: string }) => t.external_id,
),
).toEqual(['T1059']);
});
// ---------- API: membership visibility --------------------------------
test('Non-admin members see only missions they belong to', async ({ request }) => {
const auth = await adminAuth(request);
// Create a group with mission.* perms and invite a "red" user.
const grp = await request
.post('/api/v1/groups', { headers: auth, data: { name: 'm6-red-grp' } })
.then((r) => r.json());
const setPerms = await request.put(`/api/v1/groups/${grp.id}/permissions`, {
headers: auth,
data: {
codes: ['mission.read', 'mission.create', 'mission.update', 'mission.archive'],
},
});
expect(setPerms.status()).toBe(200);
const redEmail = `m6-red-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const redPwd = 'RedPass1234!';
const inv = await request
.post('/api/v1/invitations', {
headers: auth,
data: { email_hint: redEmail, group_ids: [grp.id] },
})
.then((r) => r.json());
const accept = await request.post(`/api/v1/invitations/accept/${inv.token}`, {
data: { email: redEmail, password: redPwd },
});
expect(accept.status()).toBe(201);
const redAccess = await loginAndGetAccess(request, redEmail, redPwd);
const redAuth = { Authorization: `Bearer ${redAccess}` };
// Admin creates a mission with NO members → red should not see it.
const hidden = await request
.post('/api/v1/missions', {
headers: auth,
data: { name: 'm6-admin-hidden' },
})
.then((r) => r.json());
const redList = await request.get('/api/v1/missions', { headers: redAuth });
expect(redList.status()).toBe(200);
const visible = (await redList.json()).items.map((it: { name: string }) => it.name);
expect(visible).not.toContain('m6-admin-hidden');
const redGetHidden = await request.get(`/api/v1/missions/${hidden.id}`, {
headers: redAuth,
});
expect(redGetHidden.status()).toBe(404);
// Red creates their own mission — auto-added as member → visible to them.
const ownResp = await request.post('/api/v1/missions', {
headers: redAuth,
data: { name: 'm6-red-own' },
});
expect(ownResp.status(), await ownResp.text()).toBe(201);
const own = await ownResp.json();
expect(own.members.map((m: { user_id: string }) => m.user_id)).toContain(
own.members[0].user_id,
);
const redListAfter = await request.get('/api/v1/missions', { headers: redAuth });
const namesAfter = (await redListAfter.json()).items.map(
(it: { name: string }) => it.name,
);
expect(namesAfter).toContain('m6-red-own');
});
// ---------- API: transitions ------------------------------------------
test('Status transition chain and rejection of invalid jumps', async ({ request }) => {
const auth = await adminAuth(request);
const m = await request
.post('/api/v1/missions', {
headers: auth,
data: { name: 'm6-status-chain' },
})
.then((r) => r.json());
for (const target of ['in_progress', 'completed', 'archived']) {
const r = await request.post(`/api/v1/missions/${m.id}/transition`, {
headers: auth,
data: { status: target },
});
expect(r.status(), await r.text()).toBe(200);
expect((await r.json()).status).toBe(target);
}
// Re-create + try an invalid jump draft → completed (must be 409)
const m2 = await request
.post('/api/v1/missions', {
headers: auth,
data: { name: 'm6-status-jump' },
})
.then((r) => r.json());
const bad = await request.post(`/api/v1/missions/${m2.id}/transition`, {
headers: auth,
data: { status: 'completed' },
});
expect(bad.status()).toBe(409);
expect((await bad.json()).error).toBe('invalid_transition');
});
// ---------- SPA -------------------------------------------------------
test('SPA — admin creates a mission via the 3-step wizard', async ({ page, request }) => {
const auth = await adminAuth(request);
const ids: string[] = [];
for (const name of ['spa-wizard-t1', 'spa-wizard-t2', 'spa-wizard-t3']) {
ids.push(await makeTest(request, auth, name));
}
const sid = await makeScenario(request, auth, 'spa-wizard-scenario', ids);
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/missions');
await page.getByTestId('missions-new-link').click();
await expect(page).toHaveURL(/\/missions\/new$/);
// Step 1 — Metadata
await page.getByTestId('meta-name').fill('spa-wizard-mission');
await page.getByTestId('meta-client').fill('Acme via SPA');
await page.getByTestId('missions-create-next').click();
// Step 2 — Scenarios
await page.getByTestId(`scenario-toggle-${sid}`).click();
await page.getByTestId('missions-create-next').click();
// Step 3 — Members (admin doesn't need to add themselves; submit straight away)
await page.getByTestId('missions-create-submit').click();
// Should land on the detail page
await expect(page).toHaveURL(/\/missions\/[0-9a-f-]+$/);
await expect(page.getByTestId('mission-transition-in_progress')).toBeVisible();
await expect(page.getByTestId('mission-tab-tests')).toBeVisible();
// Tests tab renders 3 snapshotted tests
await expect(page.getByText('spa-wizard-t1')).toBeVisible();
await expect(page.getByText('spa-wizard-t2')).toBeVisible();
await expect(page.getByText('spa-wizard-t3')).toBeVisible();
});
test('SPA — detail page edits metadata, appends scenarios, edits members', async ({
page,
request,
}) => {
const auth = await adminAuth(request);
// Pre-seed: one mission with one initial scenario; a second scenario to
// append; and a second user we can assign as a member from the SPA.
const initialTestId = await makeTest(request, auth, 'spa-edit-initial-t');
const initialScenarioId = await makeScenario(
request,
auth,
'spa-edit-initial-scenario',
[initialTestId],
);
const extraTestId = await makeTest(request, auth, 'spa-edit-appended-t');
const extraScenarioId = await makeScenario(
request,
auth,
'spa-edit-appended-scenario',
[extraTestId],
);
const mission = await request
.post('/api/v1/missions', {
headers: auth,
data: {
name: 'spa-edit-target',
client_target: 'Initial Co.',
scenario_template_ids: [initialScenarioId],
},
})
.then((r) => r.json());
// A second user the admin can add as a member via the modal.
const teammateEmail = `spa-edit-mate-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
const inv = await request
.post('/api/v1/invitations', {
headers: auth,
data: { email_hint: teammateEmail },
})
.then((r) => r.json());
await request.post(`/api/v1/invitations/accept/${inv.token}`, {
data: { email: teammateEmail, password: 'MatePass1234!' },
});
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`/missions/${mission.id}`);
await expect(page.getByText('Initial Co.')).toBeVisible();
// --- Edit metadata --------------------------------------------------
await page.getByTestId('mission-edit-meta').click();
const metaModal = page.getByTestId('mission-edit-meta-modal');
await expect(metaModal).toBeVisible();
await metaModal.getByTestId('meta-edit-client').fill('Renamed Co.');
await metaModal.getByTestId('meta-edit-save').click();
await expect(metaModal).toBeHidden();
await expect(page.getByText('Renamed Co.')).toBeVisible();
// --- Append a scenario ---------------------------------------------
await page.getByTestId('mission-add-scenarios').click();
const addModal = page.getByTestId('mission-add-scenarios-modal');
await expect(addModal).toBeVisible();
await addModal.getByTestId(`add-scenario-toggle-${extraScenarioId}`).click();
await addModal.getByTestId('add-scenarios-save').click();
await expect(addModal).toBeHidden();
// Both scenarios now visible in the Tests tab
await expect(page.getByText('spa-edit-initial-scenario')).toBeVisible();
await expect(page.getByText('spa-edit-appended-scenario')).toBeVisible();
await expect(page.getByText('spa-edit-appended-t')).toBeVisible();
// --- Edit members ---------------------------------------------------
await page.getByTestId('mission-tab-members').click();
await page.getByTestId('mission-edit-members').click();
const memModal = page.getByTestId('mission-edit-members-modal');
await expect(memModal).toBeVisible();
// The roster row test-ids encode the new user's id; we don't know it here
// but the email is unique, so locate the row by email text and toggle red.
const teammateRow = memModal.getByText(teammateEmail).locator('..').locator('..');
await teammateRow.getByRole('button', { name: /red/i }).click();
await memModal.getByTestId('edit-members-save').click();
await expect(memModal).toBeHidden();
await expect(page.getByText(teammateEmail)).toBeVisible();
});
test('SPA — list page filters by status', async ({ page, request }) => {
const auth = await adminAuth(request);
// Seed two missions with distinct statuses.
const m1 = await request
.post('/api/v1/missions', { headers: auth, data: { name: 'filter-draft' } })
.then((r) => r.json());
const m2 = await request
.post('/api/v1/missions', { headers: auth, data: { name: 'filter-active' } })
.then((r) => r.json());
await request.post(`/api/v1/missions/${m2.id}/transition`, {
headers: auth,
data: { status: 'in_progress' },
});
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto('/missions');
await expect(page.getByText('filter-draft')).toBeVisible();
await expect(page.getByText('filter-active')).toBeVisible();
await page.getByTestId('missions-filter-status').selectOption('in_progress');
await expect(page.getByText('filter-active')).toBeVisible();
await expect(page.getByText('filter-draft')).toBeHidden();
// Sanity: m1 / m2 ids should match what the list-card test-id encodes.
await expect(page.getByTestId(`mission-card-${m2.id}`)).toBeVisible();
await expect(page.getByTestId(`mission-card-${m1.id}`)).toBeHidden();
});
});
-3
View File
@@ -13,9 +13,6 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\""
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/ibm-plex-sans": "^5.0.20", "@fontsource/ibm-plex-sans": "^5.0.20",
"@fontsource/jetbrains-mono": "^5.0.20", "@fontsource/jetbrains-mono": "^5.0.20",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",
-54
View File
@@ -6,15 +6,9 @@ import { RequireAdmin } from '@/components/RequireAdmin';
import { RequireAuth } from '@/components/RequireAuth'; import { RequireAuth } from '@/components/RequireAuth';
import { AdminGroupsPage } from '@/pages/AdminGroupsPage'; import { AdminGroupsPage } from '@/pages/AdminGroupsPage';
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage'; import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
import { AdminScenariosPage } from '@/pages/AdminScenariosPage';
import { AdminTestsPage } from '@/pages/AdminTestsPage';
import { AdminUsersPage } from '@/pages/AdminUsersPage'; import { AdminUsersPage } from '@/pages/AdminUsersPage';
import { HomePage } from '@/pages/HomePage'; import { HomePage } from '@/pages/HomePage';
import { MitrePage } from '@/pages/MitrePage';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { MissionDetailPage } from '@/pages/MissionDetailPage';
import { MissionsCreatePage } from '@/pages/MissionsCreatePage';
import { MissionsListPage } from '@/pages/MissionsListPage';
import { ProfilePage } from '@/pages/ProfilePage'; import { ProfilePage } from '@/pages/ProfilePage';
import { RegisterPage } from '@/pages/RegisterPage'; import { RegisterPage } from '@/pages/RegisterPage';
import { SetupPage } from '@/pages/SetupPage'; import { SetupPage } from '@/pages/SetupPage';
@@ -55,38 +49,6 @@ function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/mitre"
element={
<RequireAuth>
<MitrePage />
</RequireAuth>
}
/>
<Route
path="/missions"
element={
<RequireAuth>
<MissionsListPage />
</RequireAuth>
}
/>
<Route
path="/missions/new"
element={
<RequireAuth>
<MissionsCreatePage />
</RequireAuth>
}
/>
<Route
path="/missions/:id"
element={
<RequireAuth>
<MissionDetailPage />
</RequireAuth>
}
/>
<Route <Route
path="/admin/users" path="/admin/users"
element={ element={
@@ -111,22 +73,6 @@ function App() {
</RequireAdmin> </RequireAdmin>
} }
/> />
<Route
path="/admin/tests"
element={
<RequireAdmin>
<AdminTestsPage />
</RequireAdmin>
}
/>
<Route
path="/admin/scenarios"
element={
<RequireAdmin>
<AdminScenariosPage />
</RequireAdmin>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>
+1 -7
View File
@@ -36,17 +36,11 @@ export function Layout() {
<> <>
{navItem('/', 'Home')} {navItem('/', 'Home')}
{navItem('/profile', 'Profile')} {navItem('/profile', 'Profile')}
{navItem('/mitre', 'MITRE')}
{(state.user.is_admin ||
state.user.permissions.includes('mission.read')) &&
navItem('/missions', 'Missions')}
{state.user.is_admin && ( {state.user.is_admin && (
<> <>
{navItem('/admin/users', 'Users')} {navItem('/admin/users', 'Users')}
{navItem('/admin/groups', 'Groups')} {navItem('/admin/groups', 'Groups')}
{navItem('/admin/invitations', 'Invitations')} {navItem('/admin/invitations', 'Invitations')}
{navItem('/admin/tests', 'Tests')}
{navItem('/admin/scenarios', 'Scenarios')}
</> </>
)} )}
<span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email"> <span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email">
@@ -74,7 +68,7 @@ export function Layout() {
<Outlet /> <Outlet />
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim"> <footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · M4 mitre · M5 templates · M6 missions · design system from tasks/design.md metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · design system from tasks/design.md
</footer> </footer>
</div> </div>
</div> </div>
-45
View File
@@ -1,45 +0,0 @@
import { useId, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
interface MarkdownFieldProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'> {
label: string;
value: string;
onChange: (next: string) => void;
rows?: number;
hint?: string;
}
/**
* Markdown-content textarea. We deliberately keep it textarea-only (no fancy
* WYSIWYG editor) — markdown lives well in plain text and the saved blob is
* rendered to HTML at display time (M6/M7 mission pages). The label exposes
* "markdown" so the user knows the field accepts MD syntax.
*/
export function MarkdownField({ label, value, onChange, rows = 6, hint, id, className, ...rest }: MarkdownFieldProps) {
const fallbackId = useId();
const inputId = id ?? fallbackId;
return (
<div className="block">
<label
htmlFor={inputId}
className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim"
>
{label} <span className="text-text-dim/60">· markdown</span>
</label>
<textarea
id={inputId}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
'mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim',
'focus:border-cyan focus:outline-none',
className,
)}
{...rest}
/>
{hint && <p className="mt-1 font-mono text-2xs text-text-dim">{hint}</p>}
</div>
);
}
-293
View File
@@ -1,293 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { apiGet } from '@/lib/api';
import { cn } from '@/lib/cn';
import {
mitreKeys,
type MatrixTechnique,
type MitreMatrix,
type MitreTag,
} from '@/lib/mitre';
interface MitreTagPickerProps {
/** Already-selected tags. The parent owns the state. */
value: MitreTag[];
/** Replace-style change handler — called with the new full selection. */
onChange: (next: MitreTag[]) => void;
className?: string;
}
function useMatrix() {
return useQuery({
queryKey: mitreKeys.matrix,
queryFn: () => apiGet<MitreMatrix>('/mitre/matrix'),
staleTime: 5 * 60_000,
});
}
/**
* Flat ATT&CK matrix in the attack.mitre.org/# style — 15 columns share the
* available width (no horizontal scroll on standard desktop). Cells show the
* technique NAME only; the external_id surfaces in the chips at the top and in
* the hover tooltip. A `▸/▾` chevron beside a technique expands its
* sub-techniques inline within the column.
*/
export function MitreTagPicker({ value, onChange, className }: MitreTagPickerProps) {
const matrix = useMatrix();
const [filter, setFilter] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const filterNorm = filter.trim().toLowerCase();
// O(1) selection lookup.
const selectedKeys = useMemo(
() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)),
[value],
);
function isSelected(kind: MitreTag['kind'], external_id: string): boolean {
return selectedKeys.has(`${kind}:${external_id}`);
}
function toggle(tag: MitreTag) {
const key = `${tag.kind}:${tag.external_id}`;
if (selectedKeys.has(key)) {
onChange(value.filter((t) => `${t.kind}:${t.external_id}` !== key));
} else {
onChange([...value, tag]);
}
}
function toggleExpand(techExtId: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(techExtId)) next.delete(techExtId);
else next.add(techExtId);
return next;
});
}
function matches(t: MatrixTechnique): boolean {
if (!filterNorm) return true;
if (t.external_id.toLowerCase().includes(filterNorm)) return true;
if (t.name.toLowerCase().includes(filterNorm)) return true;
return t.subtechniques.some(
(sb) =>
sb.external_id.toLowerCase().includes(filterNorm) ||
sb.name.toLowerCase().includes(filterNorm),
);
}
return (
<div className={cn('rounded-lg border border-border bg-bg-card p-4 min-w-0', className)} data-testid="mitre-tag-picker">
{/* Selection chips */}
{value.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1" data-testid="mitre-selected">
{value.map((t) => (
<button
key={`${t.kind}:${t.external_id}`}
type="button"
onClick={() => toggle(t)}
className="inline-flex items-center"
aria-label={`Remove ${t.external_id}`}
>
<Tag accent={t.kind === 'tactic' ? 'cyan' : t.kind === 'technique' ? 'orange' : 'purple'}>
{t.external_id} · {t.name} ✕
</Tag>
</button>
))}
</div>
)}
{/* Filter + counts */}
<div className="mb-3 flex items-end justify-between gap-3">
<TextField
label="Filter"
placeholder="external_id or name (e.g. TA0006, T1003, powershell)"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="max-w-md"
/>
{matrix.data && (
<span className="font-sans text-xs text-text-dim mb-2 whitespace-nowrap">
{matrix.data.tactics.length} tactics ·{' '}
{matrix.data.tactics.reduce((sum, t) => sum + t.techniques.length, 0)} mappings
</span>
)}
</div>
{matrix.isLoading && <p className="font-mono text-xs text-text-dim">Loading matrix…</p>}
{matrix.isError && (
<Alert accent="red">Failed to load /mitre/matrix — has `make seed-mitre` been run?</Alert>
)}
{matrix.data && (
<div
data-testid="mitre-matrix-scroll"
role="region"
aria-label="MITRE ATT&CK matrix"
/* `minmax(7rem, 1fr)` ensures every cell is wide enough for the
* longest single word in MITRE names (no mid-word breaks), and
* stretches to fill the container otherwise. The wrapper scrolls
* horizontally — placing `overflow-x-auto` on the grid itself fails
* because the grid's intrinsic min-width (15 × 7rem) prevents it
* from shrinking below its parent, so the grid spills out instead
* of scrolling. */
className="overflow-x-auto rounded min-w-0 w-full"
>
<div
className="grid gap-px bg-border"
style={{
gridTemplateColumns: `repeat(${matrix.data.tactics.length}, minmax(7rem, 1fr))`,
}}
>
{matrix.data.tactics.map((tactic) => {
const visible = tactic.techniques.filter(matches);
const tacticSel = isSelected('tactic', tactic.external_id);
return (
<div
key={tactic.id}
className="bg-bg-base flex flex-col min-w-0"
data-testid={`mitre-column-${tactic.external_id}`}
>
{/* Tactic header — name only (attack.mitre.org style) */}
<button
type="button"
onClick={() =>
toggle({
kind: 'tactic',
id: tactic.id,
external_id: tactic.external_id,
name: tactic.name,
})
}
className={cn(
'w-full text-left px-2 py-1.5 font-sans border-b transition',
tacticSel
? 'accent-fill-cyan border-cyan text-text-bright'
: 'bg-bg-card border-border hover:bg-cyan/10 text-text-bright',
)}
title={`${tactic.external_id} — ${tactic.name}`}
data-testid={`mitre-tactic-${tactic.external_id}`}
aria-pressed={tacticSel}
>
<div className="text-xs font-semibold leading-tight break-normal hyphens-none">
{tactic.name}
</div>
<div className="text-[10px] text-text-dim mt-0.5">
{tactic.techniques.length} technique{tactic.techniques.length === 1 ? '' : 's'}
</div>
</button>
{/* Techniques cells */}
<div className="flex flex-col">
{visible.length === 0 && filterNorm && (
<div className="px-2 py-1 font-sans text-[10px] text-text-dim italic">
(filtered out)
</div>
)}
{visible.map((tech) => {
const techSel = isSelected('technique', tech.external_id);
const isExpanded = expanded.has(tech.external_id);
const hasSubs = tech.subtechniques.length > 0;
return (
<div key={tech.id} className="border-b border-border last:border-b-0">
<div
className={cn(
'flex items-stretch text-xs',
techSel
? 'accent-fill-orange text-text-bright'
: 'hover:bg-orange/10',
)}
>
<button
type="button"
onClick={() =>
toggle({
kind: 'technique',
id: tech.id,
external_id: tech.external_id,
name: tech.name,
})
}
className="flex-1 min-w-0 text-left px-2 py-1.5 font-sans break-normal hyphens-none"
title={`${tech.external_id} — ${tech.name}`}
data-testid={`mitre-technique-${tech.external_id}`}
aria-pressed={techSel}
>
<span className="leading-tight">{tech.name}</span>
</button>
{hasSubs && (
<button
type="button"
onClick={() => toggleExpand(tech.external_id)}
className={cn(
'shrink-0 px-1 border-l text-[10px] font-mono leading-none',
techSel
? 'border-text-bright/30 text-text-bright hover:bg-text-bright/10'
: 'border-border text-purple hover:bg-purple/10',
)}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${tech.external_id} sub-techniques`}
aria-expanded={isExpanded}
data-testid={`mitre-expand-${tech.external_id}`}
title={`${tech.subtechniques.length} sub-technique${tech.subtechniques.length === 1 ? '' : 's'}`}
>
<span className="text-purple">{isExpanded ? '▾' : '▸'}</span>
<span className="ml-0.5 align-middle">{tech.subtechniques.length}</span>
</button>
)}
</div>
{isExpanded && hasSubs && (
<div className="bg-bg-card">
{tech.subtechniques.map((sb) => {
const subSel = isSelected('subtechnique', sb.external_id);
return (
<button
key={sb.id}
type="button"
onClick={() =>
toggle({
kind: 'subtechnique',
id: sb.id,
external_id: sb.external_id,
name: sb.name,
})
}
className={cn(
'block w-full text-left pl-5 pr-2 py-1 font-sans text-xs border-l-2 break-normal hyphens-none',
subSel
? 'accent-fill-purple border-purple text-text-bright'
: 'border-purple/30 hover:bg-purple/10',
)}
title={`${sb.external_id} — ${sb.name}`}
data-testid={`mitre-subtechnique-${sb.external_id}`}
aria-pressed={subSel}
>
<span className="leading-tight">{sb.name}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
<p className="mt-3 font-sans text-[11px] text-text-dim">
Hover a cell for its <code className="font-mono text-purple">external_id</code>. Click a cell to toggle selection. Use <span className="text-purple font-mono">▸</span> to reveal sub-techniques inline. Click a chip above to remove.
</p>
</div>
);
}
+4 -31
View File
@@ -4,20 +4,6 @@ import { Button } from '@/components/ui/Button';
import { SectionHeader } from '@/components/ui/SectionHeader'; import { SectionHeader } from '@/components/ui/SectionHeader';
import { type Accent } from '@/lib/cn'; import { type Accent } from '@/lib/cn';
type ModalSize = 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl';
const SIZE_CLASS: Record<ModalSize, string> = {
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl',
};
interface ModalProps { interface ModalProps {
open: boolean; open: boolean;
title: string; title: string;
@@ -26,27 +12,14 @@ interface ModalProps {
children: ReactNode; children: ReactNode;
/** Optional name to give the dialog role for screen readers / Playwright. */ /** Optional name to give the dialog role for screen readers / Playwright. */
testid?: string; testid?: string;
/** Max-width preset. Defaults to `2xl` to keep historical behavior. */
size?: ModalSize;
} }
/** /**
* Centered modal with a backdrop. Closes on Escape and on backdrop click. * Centered modal with a backdrop. Closes on Escape and on backdrop click.
* The accessible name comes from the SectionHeader's `highlight`, so the dialog * The accessible name comes from the SectionHeader's `highlight`, so the dialog
* can be located via `getByRole('dialog', { name: ... })`. * can be located via `getByRole('dialog', { name: ... })`.
*
* The dialog caps its height at the viewport and scrolls its body internally,
* so tall content (MITRE matrix, long forms) never escapes the viewport.
*/ */
export function Modal({ export function Modal({ open, title, accent = 'cyan', onClose, children, testid }: ModalProps) {
open,
title,
accent = 'cyan',
onClose,
children,
testid,
size = '2xl',
}: ModalProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -74,15 +47,15 @@ export function Modal({
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
data-testid={testid} data-testid={testid}
className={`flex w-full ${SIZE_CLASS[size]} max-h-[calc(100vh-2rem)] flex-col rounded-lg border border-border bg-bg-base shadow-2xl`} className="w-full max-w-2xl rounded-lg border border-border bg-bg-base p-6 shadow-2xl"
> >
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-6 pt-6 pb-2"> <div className="flex items-start justify-between gap-4">
<SectionHeader prefix="Edit" highlight={title} accent={accent} className="mt-0 mb-4" /> <SectionHeader prefix="Edit" highlight={title} accent={accent} className="mt-0 mb-4" />
<Button variant="ghost" onClick={onClose} aria-label="Close dialog"> <Button variant="ghost" onClick={onClose} aria-label="Close dialog">
✕ ✕
</Button> </Button>
</div> </div>
<div className="min-w-0 flex-1 overflow-y-auto px-6 py-4">{children}</div> {children}
</div> </div>
</div> </div>
); );
-167
View File
@@ -1,167 +0,0 @@
/**
* Mission types + query-key factory.
*
* A mission is a *snapshot* of one or more scenario templates: the backend
* copies template fields into mission_* tables at creation time, and template
* edits after that point do not propagate. Types here mirror the server-side
* dataclasses in `app/services/missions.py`.
*/
export type MissionStatus = 'draft' | 'in_progress' | 'completed' | 'archived';
export type MissionRoleHint = 'red' | 'blue';
export type MissionTestState =
| 'pending'
| 'executed'
| 'reviewed_by_blue'
| 'skipped'
| 'blocked';
export type MissionVisibilityMode = 'whitebox' | 'titles_only' | 'executed_only';
export type MissionMitreKind = 'tactic' | 'technique' | 'subtechnique';
export type MissionOpsecLevel = 'low' | 'medium' | 'high';
export interface MissionMember {
user_id: string;
user_email: string;
user_display_name: string | null;
role_hint: MissionRoleHint;
}
export interface MissionMitreTag {
kind: MissionMitreKind;
external_id: string;
name: string;
url: string | null;
}
export interface MissionTest {
id: string;
position: number;
snapshot_name: string;
snapshot_description: string | null;
snapshot_objective: string | null;
snapshot_procedure_md: string | null;
snapshot_prerequisites_md: string | null;
snapshot_expected_red_md: string | null;
snapshot_expected_blue_md: string | null;
snapshot_opsec_level: MissionOpsecLevel;
snapshot_tags: string[];
snapshot_expected_iocs: string[];
state: MissionTestState;
executed_at: string | null;
executed_at_overridden: boolean;
mitre_tags: MissionMitreTag[];
source_test_template_id: string | null;
}
export interface MissionScenario {
id: string;
position: number;
snapshot_name: string;
snapshot_description: string | null;
tests: MissionTest[];
source_scenario_template_id: string | null;
}
export interface MissionListItem {
id: string;
name: string;
client_target: string | null;
date_start: string | null;
date_end: string | null;
status: MissionStatus;
description_md: string | null;
visibility_mode: MissionVisibilityMode;
scenarios_count: number;
tests_count: number;
members_count: number;
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface Mission extends MissionListItem {
scenarios: MissionScenario[];
members: MissionMember[];
}
export interface MissionListResponse {
items: MissionListItem[];
total: number;
limit: number;
offset: number;
}
export interface MissionFilters {
q?: string;
status?: MissionStatus | '';
client?: string;
}
export interface MemberPayload {
user_id: string;
role_hint: MissionRoleHint;
}
export interface CreateMissionPayload {
name: string;
client_target?: string | null;
date_start?: string | null;
date_end?: string | null;
description_md?: string | null;
scenario_template_ids?: string[];
members?: MemberPayload[];
}
export interface UpdateMissionPayload {
name?: string;
client_target?: string | null;
date_start?: string | null;
date_end?: string | null;
description_md?: string | null;
}
export interface AddScenariosPayload {
scenario_template_ids: string[];
}
export interface SetMembersPayload {
members: MemberPayload[];
}
export interface TransitionPayload {
status: MissionStatus;
}
export const missionKeys = {
/** Prefix-only key — pass this to `invalidateQueries` to refresh every
* filtered variant. Matching is prefix-based: `['missions','list',{q:'x'}]`
* also gets invalidated.
*/
listPrefix: () => ['missions', 'list'] as const,
list: (filters?: MissionFilters) => ['missions', 'list', filters ?? {}] as const,
detail: (id: string) => ['missions', 'detail', id] as const,
};
export function buildMissionQueryString(filters: MissionFilters | undefined): string {
if (!filters) return '';
const params = new URLSearchParams();
if (filters.q) params.set('q', filters.q);
if (filters.status) params.set('status', filters.status);
if (filters.client) params.set('client', filters.client);
const s = params.toString();
return s ? `?${s}` : '';
}
export const MISSION_STATUS_ACCENT: Record<MissionStatus, 'cyan' | 'orange' | 'green' | 'teal'> = {
draft: 'cyan',
in_progress: 'orange',
completed: 'green',
archived: 'teal',
};
export const MISSION_STATUS_LABEL: Record<MissionStatus, string> = {
draft: 'Draft',
in_progress: 'In Progress',
completed: 'Completed',
archived: 'Archived',
};
-105
View File
@@ -1,105 +0,0 @@
/** Shared types + query keys for MITRE ATT&CK browsing. */
export interface MitreTactic {
id: string;
external_id: string;
short_name: string;
name: string;
description: string | null;
url: string | null;
}
export interface MitreTechnique {
id: string;
external_id: string;
name: string;
description: string | null;
url: string | null;
tactics: Array<{ external_id: string; name: string }>;
}
export interface MitreSubtechnique {
id: string;
external_id: string;
name: string;
description: string | null;
url: string | null;
technique_id: string;
}
export interface Paginated<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface MitreStatus {
last_sync: string | null;
version: string | null;
source_url: string | null;
default_url: string;
default_version: string;
}
export type MitreTagKind = 'tactic' | 'technique' | 'subtechnique';
export interface MitreTag {
kind: MitreTagKind;
id: string;
external_id: string;
name: string;
}
// Query keys. `status` + `matrix` drive the M4 picker; the per-list factories
// (`tactics`/`techniques`/`subtechniques`) are unused today but the M5
// template forms will consume them for the standalone REST endpoints when
// users edit a single test's tags inline.
export const mitreKeys = {
status: ['mitre', 'status'] as const,
matrix: ['mitre', 'matrix'] as const,
tactics: (q?: string) => ['mitre', 'tactics', q ?? ''] as const,
techniques: (tactic?: string, q?: string) =>
['mitre', 'techniques', tactic ?? '', q ?? ''] as const,
subtechniques: (technique?: string, q?: string) =>
['mitre', 'subtechniques', technique ?? '', q ?? ''] as const,
};
export interface MatrixSubtechnique {
id: string;
external_id: string;
name: string;
}
export interface MatrixTechnique {
id: string;
external_id: string;
name: string;
subtechniques: MatrixSubtechnique[];
}
export interface MatrixTactic {
id: string;
external_id: string;
short_name: string;
name: string;
techniques: MatrixTechnique[];
}
export interface MitreMatrix {
tactics: MatrixTactic[];
}
/** Mirror of backend `SyncResultOut` (`api/mitre.py`). */
export interface MitreSyncResult {
tactics_upserted: number;
techniques_upserted: number;
subtechniques_upserted: number;
subtechniques_skipped_orphan: number;
technique_tactic_links: number;
version: string | null;
source: string;
started_at: string;
finished_at: string;
duration_ms: number;
}
-136
View File
@@ -1,136 +0,0 @@
/**
* Shared types + query-key factory for the M5 template catalogue.
*
* Two resources: `test_templates` (atomic test units) and `scenario_templates`
* (ordered lists of tests). Both back the admin pages and feed the M6 mission
* wizard.
*/
import type { MitreTagKind } from './mitre';
export type OpsecLevel = 'low' | 'medium' | 'high';
export interface MitreTagOut {
kind: MitreTagKind;
external_id: string;
name: string;
url: string | null;
}
export interface MitreTagInWire {
kind: MitreTagKind;
external_id: string;
}
export interface TestTemplate {
id: string;
name: string;
description: string | null;
objective: string | null;
procedure_md: string | null;
prerequisites_md: string | null;
expected_result_red_md: string | null;
expected_detection_blue_md: string | null;
opsec_level: OpsecLevel;
tags: string[];
expected_iocs: string[];
mitre_tags: MitreTagOut[];
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface TestTemplateListResponse {
items: TestTemplate[];
total: number;
limit: number;
offset: number;
}
export interface CreateTestTemplatePayload {
name: string;
description?: string | null;
objective?: string | null;
procedure_md?: string | null;
prerequisites_md?: string | null;
expected_result_red_md?: string | null;
expected_detection_blue_md?: string | null;
opsec_level?: OpsecLevel;
tags?: string[];
expected_iocs?: string[];
mitre_tags?: MitreTagInWire[];
}
export type UpdateTestTemplatePayload = Partial<CreateTestTemplatePayload>;
export interface ScenarioTest {
position: number;
test_template_id: string;
test_template_name: string;
test_template_deleted: boolean;
}
export interface ScenarioTemplate {
id: string;
name: string;
description: string | null;
tests: ScenarioTest[];
tests_count: number;
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface ScenarioTemplateListResponse {
items: ScenarioTemplate[];
total: number;
limit: number;
offset: number;
}
export interface CreateScenarioPayload {
name: string;
description?: string | null;
test_template_ids?: string[];
}
export interface UpdateScenarioPayload {
name?: string;
description?: string | null;
}
export interface SetScenarioTestsPayload {
test_template_ids: string[];
}
export interface TestTemplateFilters {
q?: string;
tactic?: string;
technique?: string;
subtechnique?: string;
opsec?: OpsecLevel | '';
tag?: string;
}
export const templateKeys = {
// Test templates
tests: (filters?: TestTemplateFilters) =>
['templates', 'tests', filters ?? {}] as const,
test: (id: string) => ['templates', 'tests', id] as const,
// Scenario templates
scenarios: (q?: string) => ['templates', 'scenarios', q ?? ''] as const,
scenario: (id: string) => ['templates', 'scenarios', id] as const,
};
export function buildTestQueryString(filters: TestTemplateFilters | undefined): string {
if (!filters) return '';
const params = new URLSearchParams();
if (filters.q) params.set('q', filters.q);
if (filters.tactic) params.set('tactic', filters.tactic);
if (filters.technique) params.set('technique', filters.technique);
if (filters.subtechnique) params.set('subtechnique', filters.subtechnique);
if (filters.opsec) params.set('opsec', filters.opsec);
if (filters.tag) params.set('tag', filters.tag);
const s = params.toString();
return s ? `?${s}` : '';
}
-443
View File
@@ -1,443 +0,0 @@
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from '@/lib/api';
import {
templateKeys,
type CreateScenarioPayload,
type ScenarioTemplate,
type ScenarioTemplateListResponse,
type TestTemplate,
type TestTemplateListResponse,
} from '@/lib/templates';
interface FormState {
name: string;
description: string;
test_ids: string[];
}
function blankForm(): FormState {
return { name: '', description: '', test_ids: [] };
}
function toForm(sc: ScenarioTemplate): FormState {
return {
name: sc.name,
description: sc.description ?? '',
test_ids: sc.tests.map((t) => t.test_template_id),
};
}
function useScenarios(q: string) {
return useQuery({
queryKey: templateKeys.scenarios(q),
queryFn: () =>
apiGet<ScenarioTemplateListResponse>(
`/scenario-templates${q ? `?q=${encodeURIComponent(q)}` : ''}`,
),
});
}
function useTestCatalogue() {
return useQuery({
queryKey: templateKeys.tests({}),
queryFn: () => apiGet<TestTemplateListResponse>('/test-templates?limit=500'),
});
}
interface SortableTestRowProps {
id: string;
index: number;
name: string;
onRemove: () => void;
}
function SortableTestRow({ id, index, name, onRemove }: SortableTestRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<li
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded-md border border-border bg-bg-card px-3 py-2"
data-testid={`scenario-test-row-${id}`}
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab font-mono text-text-dim hover:text-text-bright active:cursor-grabbing"
aria-label={`Drag ${name}`}
data-testid={`drag-handle-${id}`}
>
☰
</button>
<span className="font-mono text-2xs text-text-dim w-6">{String(index + 1).padStart(2, '0')}</span>
<span className="font-mono text-xs text-text-bright flex-1">{name}</span>
<Button variant="ghost" accent="rose" onClick={onRemove} aria-label={`Remove ${name}`}>
✕
</Button>
</li>
);
}
export function AdminScenariosPage() {
const qc = useQueryClient();
const [q, setQ] = useState('');
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<ScenarioTemplate | null>(null);
const [form, setForm] = useState<FormState>(blankForm());
const [error, setError] = useState<string | null>(null);
const scenarios = useScenarios(q);
const catalogue = useTestCatalogue();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const create = useMutation({
mutationFn: (payload: CreateScenarioPayload) =>
apiPost<ScenarioTemplate>('/scenario-templates', payload),
onSuccess: async () => {
setCreating(false);
setForm(blankForm());
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
},
onError: (e) => setError(humanError(e)),
});
// updateMeta and setTests both invalidate on success so a partial failure
// (metadata saved, reorder rejected) still leaves the cache consistent
// with whichever step landed.
const updateMeta = useMutation({
mutationFn: ({ id, name, description }: { id: string; name: string; description: string | null }) =>
apiPatch<ScenarioTemplate>(`/scenario-templates/${id}`, { name, description }),
onSettled: () => qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] }),
});
const setTests = useMutation({
mutationFn: ({ id, test_template_ids }: { id: string; test_template_ids: string[] }) =>
apiPut<ScenarioTemplate>(`/scenario-templates/${id}/tests`, { test_template_ids }),
onSettled: () => qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] }),
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/scenario-templates/${id}`),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
},
});
function openCreate() {
setForm(blankForm());
setError(null);
setCreating(true);
}
function openEdit(sc: ScenarioTemplate) {
setForm(toForm(sc));
setError(null);
setEditing(sc);
}
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
setForm((f) => {
const from = f.test_ids.indexOf(String(active.id));
const to = f.test_ids.indexOf(String(over.id));
if (from < 0 || to < 0) return f;
return { ...f, test_ids: arrayMove(f.test_ids, from, to) };
});
}
async function submit() {
setError(null);
if (!form.name.trim()) {
setError('Name is required.');
return;
}
try {
if (editing) {
// Two-step: metadata first, then ordered tests.
await updateMeta.mutateAsync({
id: editing.id,
name: form.name.trim(),
description: form.description || null,
});
await setTests.mutateAsync({ id: editing.id, test_template_ids: form.test_ids });
} else {
await create.mutateAsync({
name: form.name.trim(),
description: form.description || null,
test_template_ids: form.test_ids,
});
}
await qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] });
setEditing(null);
setCreating(false);
} catch (e) {
setError(humanError(e));
}
}
const isModalOpen = creating || editing !== null;
const testNameById = useMemo(() => {
const map = new Map<string, string>();
catalogue.data?.items.forEach((t) => map.set(t.id, t.name));
return map;
}, [catalogue.data]);
// Tests offered for inclusion. The same test_template MAY appear multiple
// times in a scenario (chained operations are a real purple-team pattern,
// cf. `scenario_template_tests` UNIQUE on `(scenario_id, position)`, not
// on `test_template_id`). So we do NOT exclude already-picked items —
// only soft-deleted ones, which the backend would reject.
const availableTests = useMemo<TestTemplate[]>(() => {
if (!catalogue.data) return [];
return catalogue.data.items.filter((t) => !t.deleted_at);
}, [catalogue.data]);
return (
<>
<SectionHeader
prefix="Admin"
highlight="Scenarios"
accent="purple"
description="Ordered playbooks composed from the test catalogue. Drag rows to reorder; the order is the execution sequence."
/>
<div className="mb-6 flex flex-wrap items-end gap-3">
<TextField
label="Search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="name or description"
data-testid="scenarios-search"
/>
<Button accent="purple" onClick={openCreate} data-testid="create-scenario" className="ml-auto">
+ New scenario
</Button>
</div>
{scenarios.isError && <Alert accent="red">Failed to load scenarios.</Alert>}
<div className="grid gap-3" data-testid="scenarios-list">
{scenarios.isLoading && <p className="font-mono text-xs text-text-dim">Loading…</p>}
{scenarios.data?.items.map((sc) => (
<Card
key={sc.id}
accent="purple"
title={sc.name}
sub={sc.description ?? '—'}
data-testid={`scenario-row-${sc.id}`}
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent="purple">{sc.tests_count} test{sc.tests_count === 1 ? '' : 's'}</Tag>
{sc.tests.slice(0, 4).map((t) => (
<Tag key={`${sc.id}:${t.position}`} accent={t.test_template_deleted ? 'rose' : 'cyan'}>
{t.position + 1}. {t.test_template_name}
</Tag>
))}
{sc.tests.length > 4 && (
<Tag accent="yellow">+{sc.tests.length - 4} more</Tag>
)}
<div className="ml-auto flex gap-2">
<Button accent="purple" onClick={() => openEdit(sc)} data-testid={`edit-scenario-${sc.id}`}>
Edit
</Button>
<Button
accent="rose"
variant="ghost"
onClick={() => {
if (window.confirm(`Soft-delete "${sc.name}"?`)) remove.mutate(sc.id);
}}
data-testid={`delete-scenario-${sc.id}`}
>
Delete
</Button>
</div>
</div>
</Card>
))}
{scenarios.data && scenarios.data.items.length === 0 && !scenarios.isLoading && (
<p className="font-mono text-2xs text-text-dim">No scenarios yet.</p>
)}
</div>
<Modal
open={isModalOpen}
title={editing ? `Scenario · ${editing.name}` : 'New scenario template'}
accent="purple"
size="3xl"
onClose={() => {
setCreating(false);
setEditing(null);
}}
testid="scenario-template-modal"
>
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
<div className="grid gap-3">
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
data-testid="form-scenario-name"
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
data-testid="form-scenario-description"
/>
<div>
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-2">
Tests in order ({form.test_ids.length})
</p>
{form.test_ids.length === 0 ? (
<p className="font-mono text-2xs text-text-dim mb-2">No test picked yet.</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={form.test_ids} strategy={verticalListSortingStrategy}>
<ol className="grid gap-2 mb-3" data-testid="scenario-tests-ordered">
{form.test_ids.map((id, idx) => (
<SortableTestRow
key={id}
id={id}
index={idx}
name={testNameById.get(id) ?? '<missing>'}
onRemove={() =>
setForm((f) => ({ ...f, test_ids: f.test_ids.filter((t) => t !== id) }))
}
/>
))}
</ol>
</SortableContext>
</DndContext>
)}
<CataloguePicker
tests={availableTests}
onAdd={(id) => setForm((f) => ({ ...f, test_ids: [...f.test_ids, id] }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => {
setCreating(false);
setEditing(null);
}}
>
Cancel
</Button>
<Button
accent="purple"
onClick={submit}
disabled={create.isPending || updateMeta.isPending || setTests.isPending}
data-testid="form-scenario-submit"
>
{editing ? 'Save' : 'Create'}
</Button>
</div>
</div>
</Modal>
</>
);
}
interface CataloguePickerProps {
tests: TestTemplate[];
onAdd: (id: string) => void;
}
function CataloguePicker({ tests, onAdd }: CataloguePickerProps) {
const [q, setQ] = useState('');
const filtered = useMemo(() => {
const norm = q.trim().toLowerCase();
if (!norm) return tests.slice(0, 50);
return tests.filter((t) => t.name.toLowerCase().includes(norm)).slice(0, 50);
}, [tests, q]);
return (
<div className="rounded-md border border-border bg-bg-card p-3">
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-2">
Add a test from the catalogue
</p>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search catalogue…"
className="mb-2 w-full rounded-md border border-border bg-bg-base px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim focus:border-cyan focus:outline-none"
data-testid="scenario-catalogue-search"
/>
<ul className="max-h-48 overflow-auto grid gap-1" data-testid="scenario-catalogue-list">
{filtered.map((t) => (
<li key={t.id} className="flex items-center gap-2">
<button
type="button"
onClick={() => onAdd(t.id)}
className="flex-1 text-left rounded-sm px-2 py-1 font-mono text-xs text-text-bright hover:bg-bg-base"
data-testid={`catalogue-add-${t.id}`}
>
+ {t.name}
</button>
</li>
))}
{filtered.length === 0 && (
<li className="font-mono text-2xs text-text-dim">No matching test in the catalogue.</li>
)}
</ul>
</div>
);
}
function humanError(e: unknown): string {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
}
return e instanceof Error ? e.message : 'Unexpected error';
}
-404
View File
@@ -1,404 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { MarkdownField } from '@/components/MarkdownField';
import { MitreTagPicker } from '@/components/MitreTagPicker';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiGet,
apiPost,
apiPut,
} from '@/lib/api';
import { type MitreTag, type MitreTagKind } from '@/lib/mitre';
import {
buildTestQueryString,
templateKeys,
type CreateTestTemplatePayload,
type OpsecLevel,
type TestTemplate,
type TestTemplateFilters,
type TestTemplateListResponse,
} from '@/lib/templates';
const OPSEC_LEVELS: OpsecLevel[] = ['low', 'medium', 'high'];
interface FormState {
name: string;
description: string;
objective: string;
procedure_md: string;
prerequisites_md: string;
expected_result_red_md: string;
expected_detection_blue_md: string;
opsec_level: OpsecLevel;
tags: string;
expected_iocs: string;
mitre_tags: MitreTag[];
}
function blankForm(): FormState {
return {
name: '',
description: '',
objective: '',
procedure_md: '',
prerequisites_md: '',
expected_result_red_md: '',
expected_detection_blue_md: '',
opsec_level: 'medium',
tags: '',
expected_iocs: '',
mitre_tags: [],
};
}
function toForm(t: TestTemplate): FormState {
return {
name: t.name,
description: t.description ?? '',
objective: t.objective ?? '',
procedure_md: t.procedure_md ?? '',
prerequisites_md: t.prerequisites_md ?? '',
expected_result_red_md: t.expected_result_red_md ?? '',
expected_detection_blue_md: t.expected_detection_blue_md ?? '',
opsec_level: t.opsec_level,
tags: t.tags.join(', '),
expected_iocs: t.expected_iocs.join(', '),
mitre_tags: t.mitre_tags.map((tag) => ({
kind: tag.kind as MitreTagKind,
id: tag.external_id,
external_id: tag.external_id,
name: tag.name,
})),
};
}
function csvToList(s: string): string[] {
return s.split(',').map((x) => x.trim()).filter(Boolean);
}
function toPayload(form: FormState): CreateTestTemplatePayload {
return {
name: form.name.trim(),
description: form.description || null,
objective: form.objective || null,
procedure_md: form.procedure_md || null,
prerequisites_md: form.prerequisites_md || null,
expected_result_red_md: form.expected_result_red_md || null,
expected_detection_blue_md: form.expected_detection_blue_md || null,
opsec_level: form.opsec_level,
tags: csvToList(form.tags),
expected_iocs: csvToList(form.expected_iocs),
mitre_tags: form.mitre_tags.map((t) => ({ kind: t.kind, external_id: t.external_id })),
};
}
function useTestTemplates(filters: TestTemplateFilters) {
return useQuery({
queryKey: templateKeys.tests(filters),
queryFn: () =>
apiGet<TestTemplateListResponse>(`/test-templates${buildTestQueryString(filters)}`),
});
}
export function AdminTestsPage() {
const qc = useQueryClient();
const [filters, setFilters] = useState<TestTemplateFilters>({});
const [editing, setEditing] = useState<TestTemplate | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<FormState>(blankForm());
const [error, setError] = useState<string | null>(null);
const tests = useTestTemplates(filters);
const create = useMutation({
mutationFn: (payload: CreateTestTemplatePayload) =>
apiPost<TestTemplate>('/test-templates', payload),
onSuccess: async () => {
setCreating(false);
setForm(blankForm());
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
onError: (e) => setError(humanError(e)),
});
const update = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: CreateTestTemplatePayload }) =>
apiPut<TestTemplate>(`/test-templates/${id}`, payload),
onSuccess: async () => {
setEditing(null);
setError(null);
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
onError: (e) => setError(humanError(e)),
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete<{ ok: boolean }>(`/test-templates/${id}`),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['templates', 'tests'] });
},
});
function openCreate() {
setForm(blankForm());
setError(null);
setCreating(true);
}
function openEdit(t: TestTemplate) {
setForm(toForm(t));
setError(null);
setEditing(t);
}
function submit() {
const payload = toPayload(form);
if (!payload.name) {
setError('Name is required.');
return;
}
if (editing) update.mutate({ id: editing.id, payload });
else create.mutate(payload);
}
const isModalOpen = creating || editing !== null;
return (
<>
<SectionHeader
prefix="Admin"
highlight="Tests"
accent="orange"
description="Reusable test units. Each test belongs to a scenario at instantiation time, but the catalogue lives independently."
/>
<div className="mb-6 flex flex-wrap items-end gap-3" data-testid="tests-filters">
<TextField
label="Search"
placeholder="name or description"
value={filters.q ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, q: e.target.value || undefined }))}
data-testid="filter-q"
/>
<TextField
label="Tactic external_id"
placeholder="TA0006"
value={filters.tactic ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, tactic: e.target.value || undefined }))}
data-testid="filter-tactic"
/>
<label className="block">
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
OPSEC
</span>
<select
value={filters.opsec ?? ''}
onChange={(e) =>
setFilters((f) => ({ ...f, opsec: (e.target.value as OpsecLevel | '') || undefined }))
}
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
data-testid="filter-opsec"
>
<option value="">— all —</option>
{OPSEC_LEVELS.map((lv) => (
<option key={lv} value={lv}>{lv}</option>
))}
</select>
</label>
<TextField
label="Free tag"
placeholder="phish"
value={filters.tag ?? ''}
onChange={(e) => setFilters((f) => ({ ...f, tag: e.target.value || undefined }))}
data-testid="filter-tag"
/>
<Button accent="orange" onClick={openCreate} data-testid="create-test" className="ml-auto">
+ New test
</Button>
</div>
{tests.isError && <Alert accent="red">Failed to load tests.</Alert>}
<div className="grid gap-3" data-testid="tests-list">
{tests.isLoading && <p className="font-mono text-xs text-text-dim">Loading…</p>}
{tests.data?.items.map((t) => (
<Card
key={t.id}
accent="orange"
title={t.name}
sub={t.description ?? '—'}
data-testid={`test-row-${t.id}`}
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent={t.opsec_level === 'high' ? 'red' : t.opsec_level === 'low' ? 'green' : 'yellow'}>
opsec: {t.opsec_level}
</Tag>
{t.mitre_tags.map((tag) => (
<Tag
key={`${tag.kind}:${tag.external_id}`}
accent={tag.kind === 'tactic' ? 'cyan' : tag.kind === 'technique' ? 'orange' : 'purple'}
>
{tag.external_id}
</Tag>
))}
{t.tags.map((tg) => (
<Tag key={tg} accent="cyan">#{tg}</Tag>
))}
<div className="ml-auto flex gap-2">
<Button accent="orange" onClick={() => openEdit(t)} data-testid={`edit-test-${t.id}`}>
Edit
</Button>
<Button
accent="rose"
variant="ghost"
onClick={() => {
if (window.confirm(`Soft-delete "${t.name}"?`)) remove.mutate(t.id);
}}
data-testid={`delete-test-${t.id}`}
>
Delete
</Button>
</div>
</div>
</Card>
))}
{tests.data && tests.data.items.length === 0 && !tests.isLoading && (
<p className="font-mono text-2xs text-text-dim">No tests match the current filters.</p>
)}
</div>
<Modal
open={isModalOpen}
title={editing ? `Test · ${editing.name}` : 'New test template'}
accent="orange"
size="7xl"
onClose={() => {
setCreating(false);
setEditing(null);
}}
testid="test-template-modal"
>
{error && <Alert accent="red" className="mb-3">{error}</Alert>}
<div className="flex flex-col gap-3 min-w-0">
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
data-testid="form-name"
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
data-testid="form-description"
/>
<TextField
label="Objective (1-liner)"
value={form.objective}
onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))}
/>
<MarkdownField
label="Procedure"
value={form.procedure_md}
onChange={(v) => setForm((f) => ({ ...f, procedure_md: v }))}
data-testid="form-procedure"
hint="Step-by-step playbook. Markdown supported."
/>
<MarkdownField
label="Prerequisites"
value={form.prerequisites_md}
onChange={(v) => setForm((f) => ({ ...f, prerequisites_md: v }))}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<MarkdownField
label="Red — expected result"
value={form.expected_result_red_md}
onChange={(v) => setForm((f) => ({ ...f, expected_result_red_md: v }))}
rows={4}
/>
<MarkdownField
label="Blue — expected detection"
value={form.expected_detection_blue_md}
onChange={(v) => setForm((f) => ({ ...f, expected_detection_blue_md: v }))}
rows={4}
/>
</div>
<label className="block">
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
OPSEC level
</span>
<select
value={form.opsec_level}
onChange={(e) => setForm((f) => ({ ...f, opsec_level: e.target.value as OpsecLevel }))}
className="mt-1 rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
data-testid="form-opsec"
>
{OPSEC_LEVELS.map((lv) => (
<option key={lv} value={lv}>{lv}</option>
))}
</select>
</label>
<TextField
label="Free tags (comma-separated)"
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
data-testid="form-tags"
hint="e.g. phish, persistence, quick-win"
/>
<TextField
label="Expected IOCs (comma-separated)"
value={form.expected_iocs}
onChange={(e) => setForm((f) => ({ ...f, expected_iocs: e.target.value }))}
hint="Indicators the blue team should look for"
/>
<div>
<p className="font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim mb-1">
MITRE ATT&amp;CK tags
</p>
<MitreTagPicker
value={form.mitre_tags}
onChange={(next) => setForm((f) => ({ ...f, mitre_tags: next }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => {
setCreating(false);
setEditing(null);
}}
>
Cancel
</Button>
<Button
accent="orange"
onClick={submit}
disabled={create.isPending || update.isPending}
data-testid="form-submit"
>
{editing ? 'Save' : 'Create'}
</Button>
</div>
</div>
</Modal>
</>
);
}
function humanError(e: unknown): string {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
return p?.message ?? p?.error ?? `HTTP ${e.status}`;
}
return e instanceof Error ? e.message : 'Unexpected error';
}
+3 -3
View File
@@ -61,7 +61,7 @@ export function HomePage() {
<span className="text-purple">Purple Team Platform</span> <span className="text-purple">Purple Team Platform</span>
</h1> </h1>
<p className="font-mono text-sm font-light text-text-dim mt-2"> <p className="font-mono text-sm font-light text-text-dim mt-2">
Collaborative red &amp; blue test orchestration — M6 milestone (Missions &amp; snapshot) Collaborative red &amp; blue test orchestration — M3 milestone (RBAC)
</p> </p>
</header> </header>
<SectionHeader <SectionHeader
@@ -141,9 +141,9 @@ export function HomePage() {
<Card accent="purple" title="Roadmap" sub="14 milestones"> <Card accent="purple" title="Roadmap" sub="14 milestones">
<p> <p>
M0 + M1 + M2 + M3 + M4 + M5 + M6 done. Next:{' '} M0 + M1 + M2 + M3 done. Next:{' '}
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs"> <code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
M7 — Red &amp; blue execution on a mission test M4 — MITRE ATT&amp;CK
</code> </code>
. .
</p> </p>
-807
View File
@@ -1,807 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { MarkdownField } from '@/components/MarkdownField';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
MISSION_STATUS_ACCENT,
MISSION_STATUS_LABEL,
missionKeys,
type AddScenariosPayload,
type MemberPayload,
type Mission,
type MissionRoleHint,
type MissionStatus,
type SetMembersPayload,
type TransitionPayload,
type UpdateMissionPayload,
} from '@/lib/missions';
import type {
ScenarioTemplate,
ScenarioTemplateListResponse,
} from '@/lib/templates';
import { templateKeys } from '@/lib/templates';
const TABS = ['tests', 'members', 'synthesis', 'export'] as const;
type Tab = (typeof TABS)[number];
const ALLOWED_TRANSITIONS: Record<MissionStatus, MissionStatus[]> = {
draft: ['in_progress', 'archived'],
in_progress: ['completed', 'archived'],
completed: ['archived'],
archived: [],
};
const TRANSITION_BUTTON_ACCENT: Record<MissionStatus, 'cyan' | 'orange' | 'green' | 'teal'> = {
draft: 'cyan',
in_progress: 'orange',
completed: 'green',
archived: 'teal',
};
interface RosterUser {
id: string;
email: string;
display_name: string | null;
}
interface RosterResponse {
items: RosterUser[];
}
interface MemberSelection {
user_id: string;
role_hint: MissionRoleHint;
}
function useMission(id: string) {
return useQuery({
queryKey: missionKeys.detail(id),
queryFn: () => apiGet<Mission>(`/missions/${id}`),
enabled: !!id,
});
}
function useScenarioCatalogue(enabled: boolean) {
return useQuery({
queryKey: templateKeys.scenarios(''),
queryFn: () =>
apiGet<ScenarioTemplateListResponse>('/scenario-templates?limit=500'),
enabled,
});
}
function useRoster(enabled: boolean) {
return useQuery({
queryKey: ['users', 'roster'],
queryFn: () => apiGet<RosterResponse>('/users/roster'),
enabled,
});
}
function formatDateRange(start: string | null, end: string | null): string {
if (!start && !end) return 'No dates set';
if (start && end) return `${start} → ${end}`;
return start ?? end ?? '';
}
// --------------------------------------------------------------------------- //
// Metadata edit modal //
// --------------------------------------------------------------------------- //
interface MetaEditModalProps {
mission: Mission;
open: boolean;
onClose: () => void;
}
function MetaEditModal({ mission, open, onClose }: MetaEditModalProps) {
const qc = useQueryClient();
const [name, setName] = useState(mission.name);
const [client, setClient] = useState(mission.client_target ?? '');
const [dateStart, setDateStart] = useState(mission.date_start ?? '');
const [dateEnd, setDateEnd] = useState(mission.date_end ?? '');
const [description, setDescription] = useState(mission.description_md ?? '');
// Reset form whenever the modal opens with a (potentially newer) mission.
useEffect(() => {
if (!open) return;
setName(mission.name);
setClient(mission.client_target ?? '');
setDateStart(mission.date_start ?? '');
setDateEnd(mission.date_end ?? '');
setDescription(mission.description_md ?? '');
}, [open, mission]);
const update = useMutation({
mutationFn: (body: UpdateMissionPayload) =>
apiPut<Mission>(`/missions/${mission.id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
onClose();
},
});
const apiErr = update.error instanceof ApiError ? update.error : null;
const nameInvalid = name.trim().length === 0;
const datesInvalid = dateStart && dateEnd && dateEnd < dateStart;
function submit() {
update.mutate({
name: name.trim(),
client_target: client.trim() || null,
date_start: dateStart || null,
date_end: dateEnd || null,
description_md: description.trim() || null,
});
}
return (
<Modal
open={open}
onClose={onClose}
title={mission.name}
accent="cyan"
size="3xl"
testid="mission-edit-meta-modal"
>
<div className="flex flex-col gap-3 min-w-0">
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<TextField
label="Name"
required
value={name}
onChange={(e) => setName(e.target.value)}
data-testid="meta-edit-name"
/>
<TextField
label="Client / target"
value={client}
onChange={(e) => setClient(e.target.value)}
data-testid="meta-edit-client"
/>
<TextField
label="Start date"
type="date"
value={dateStart}
onChange={(e) => setDateStart(e.target.value)}
data-testid="meta-edit-date-start"
/>
<TextField
label="End date"
type="date"
value={dateEnd}
onChange={(e) => setDateEnd(e.target.value)}
data-testid="meta-edit-date-end"
/>
</div>
<MarkdownField
label="ROE / Description"
value={description}
onChange={setDescription}
data-testid="meta-edit-description"
/>
{datesInvalid && (
<p className="font-mono text-2xs text-red" data-testid="meta-edit-date-error">
End date must be on or after start date.
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button accent="teal" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
accent="green"
onClick={submit}
disabled={nameInvalid || !!datesInvalid || update.isPending}
data-testid="meta-edit-save"
>
{update.isPending ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
</Modal>
);
}
// --------------------------------------------------------------------------- //
// Add-scenarios modal //
// --------------------------------------------------------------------------- //
interface AddScenariosModalProps {
mission: Mission;
open: boolean;
onClose: () => void;
}
function AddScenariosModal({ mission, open, onClose }: AddScenariosModalProps) {
const qc = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const catalogue = useScenarioCatalogue(open);
useEffect(() => {
if (open) setSelected([]);
}, [open]);
const add = useMutation({
mutationFn: (body: AddScenariosPayload) =>
apiPost<Mission>(`/missions/${mission.id}/scenarios`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
onClose();
},
});
const apiErr = add.error instanceof ApiError ? add.error : null;
function toggle(id: string) {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
function submit() {
add.mutate({ scenario_template_ids: selected });
}
const totalTestsToAdd = useMemo(() => {
if (!catalogue.data) return 0;
const by_id = new Map<string, ScenarioTemplate>(
catalogue.data.items.map((sc) => [sc.id, sc] as const),
);
return selected.reduce((acc, id) => acc + (by_id.get(id)?.tests_count ?? 0), 0);
}, [selected, catalogue.data]);
return (
<Modal
open={open}
onClose={onClose}
title={`Add scenarios to ${mission.name}`}
accent="cyan"
size="3xl"
testid="mission-add-scenarios-modal"
>
<div className="flex flex-col gap-3 min-w-0">
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
{catalogue.isError && <Alert accent="red">Failed to load scenarios.</Alert>}
{catalogue.isLoading && (
<p className="font-mono text-xs text-text-dim">Loading…</p>
)}
<p className="font-mono text-2xs text-text-dim">
{selected.length} scenario{selected.length === 1 ? '' : 's'} ·{' '}
{totalTestsToAdd} test{totalTestsToAdd === 1 ? '' : 's'} will be appended
after the current {mission.scenarios_count}.
</p>
<ul
className="grid grid-cols-1 gap-2 md:grid-cols-2"
data-testid="add-scenarios-picker"
>
{catalogue.data?.items.map((sc) => {
const isSelected = selected.includes(sc.id);
return (
<li key={sc.id}>
<button
type="button"
className={`w-full rounded-md border ${
isSelected ? 'border-cyan text-cyan' : 'border-border text-text'
} bg-bg-card p-3 text-left font-mono text-xs hover:border-cyan`}
onClick={() => toggle(sc.id)}
data-testid={`add-scenario-toggle-${sc.id}`}
aria-pressed={isSelected}
>
<div className="flex items-center justify-between">
<span className="text-text-bright">{sc.name}</span>
<Tag accent="purple">{sc.tests_count} tests</Tag>
</div>
{sc.description && (
<p className="mt-1 text-text-dim">{sc.description}</p>
)}
</button>
</li>
);
})}
</ul>
{catalogue.data && catalogue.data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">
No scenarios in the catalogue yet.
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button accent="teal" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
accent="green"
onClick={submit}
disabled={selected.length === 0 || add.isPending}
data-testid="add-scenarios-save"
>
{add.isPending ? 'Adding…' : `Add ${selected.length} scenario${selected.length === 1 ? '' : 's'}`}
</Button>
</div>
</div>
</Modal>
);
}
// --------------------------------------------------------------------------- //
// Edit-members modal //
// --------------------------------------------------------------------------- //
interface EditMembersModalProps {
mission: Mission;
open: boolean;
onClose: () => void;
}
function EditMembersModal({ mission, open, onClose }: EditMembersModalProps) {
const qc = useQueryClient();
const roster = useRoster(open);
const [members, setMembers] = useState<MemberSelection[]>([]);
useEffect(() => {
if (!open) return;
setMembers(
mission.members.map((m) => ({
user_id: m.user_id,
role_hint: m.role_hint,
})),
);
}, [open, mission]);
const save = useMutation({
mutationFn: (body: SetMembersPayload) =>
apiPut<Mission>(`/missions/${mission.id}/members`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
onClose();
},
});
const apiErr = save.error instanceof ApiError ? save.error : null;
function setRole(user_id: string, role_hint: MissionRoleHint) {
setMembers((prev) =>
prev.some((m) => m.user_id === user_id)
? prev.map((m) => (m.user_id === user_id ? { ...m, role_hint } : m))
: [...prev, { user_id, role_hint }],
);
}
function remove(user_id: string) {
setMembers((prev) => prev.filter((m) => m.user_id !== user_id));
}
function submit() {
const payload: SetMembersPayload = {
members: members.map(
(m): MemberPayload => ({ user_id: m.user_id, role_hint: m.role_hint }),
),
};
save.mutate(payload);
}
return (
<Modal
open={open}
onClose={onClose}
title={`Members of ${mission.name}`}
accent="cyan"
size="3xl"
testid="mission-edit-members-modal"
>
<div className="flex flex-col gap-3 min-w-0">
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
{roster.isError && <Alert accent="red">Failed to load roster.</Alert>}
{roster.isLoading && (
<p className="font-mono text-xs text-text-dim">Loading users…</p>
)}
<ul className="flex flex-col gap-2" data-testid="edit-members-picker">
{roster.data?.items.map((u) => {
const selected = members.find((m) => m.user_id === u.id);
return (
<li
key={u.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-bg-card p-3"
data-testid={`edit-member-row-${u.id}`}
>
<div>
<p className="font-mono text-xs text-text-bright">
{u.display_name ?? u.email}
</p>
{u.display_name && (
<p className="font-mono text-2xs text-text-dim">{u.email}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
accent="red"
variant={selected?.role_hint === 'red' ? 'solid' : 'outline'}
onClick={() => setRole(u.id, 'red')}
data-testid={`edit-member-${u.id}-red`}
>
Red
</Button>
<Button
accent="cyan"
variant={selected?.role_hint === 'blue' ? 'solid' : 'outline'}
onClick={() => setRole(u.id, 'blue')}
data-testid={`edit-member-${u.id}-blue`}
>
Blue
</Button>
{selected && (
<Button
accent="rose"
variant="ghost"
onClick={() => remove(u.id)}
data-testid={`edit-member-${u.id}-clear`}
>
✕
</Button>
)}
</div>
</li>
);
})}
</ul>
<div className="flex items-center justify-end gap-2 pt-2">
<Button accent="teal" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
accent="green"
onClick={submit}
disabled={save.isPending}
data-testid="edit-members-save"
>
{save.isPending ? 'Saving…' : 'Save members'}
</Button>
</div>
</div>
</Modal>
);
}
// --------------------------------------------------------------------------- //
// Main page //
// --------------------------------------------------------------------------- //
export function MissionDetailPage() {
const params = useParams();
const missionId = params.id ?? '';
const navigate = useNavigate();
const qc = useQueryClient();
const { state } = useAuth();
const canEdit =
state.user?.is_admin ||
state.user?.permissions.includes('mission.update') ||
false;
const [tab, setTab] = useState<Tab>('tests');
const [editMeta, setEditMeta] = useState(false);
const [addScenarios, setAddScenarios] = useState(false);
const [editMembers, setEditMembers] = useState(false);
const detail = useMission(missionId);
const transition = useMutation({
mutationFn: (body: TransitionPayload) =>
apiPost<Mission>(`/missions/${missionId}/transition`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
},
});
const remove = useMutation({
mutationFn: () => apiDelete<{ ok: true }>(`/missions/${missionId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
navigate('/missions');
},
});
const apiErr = detail.error instanceof ApiError ? detail.error : null;
const m = detail.data;
if (apiErr) {
return (
<section>
<SectionHeader prefix="Mission" highlight="Not found" accent="rose" />
<Alert accent="rose">{apiErr.message}</Alert>
</section>
);
}
if (!m) {
return <p className="font-mono text-xs text-text-dim">Loading mission…</p>;
}
const accent = MISSION_STATUS_ACCENT[m.status];
const allowedNext = ALLOWED_TRANSITIONS[m.status];
return (
<section data-testid={`mission-detail-${m.id}`}>
<div className="flex items-baseline justify-between flex-wrap gap-3">
<SectionHeader prefix="Mission" highlight={m.name} accent={accent} />
<div className="flex items-center gap-2">
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
{canEdit && (
<Button
accent="cyan"
variant="outline"
onClick={() => setEditMeta(true)}
data-testid="mission-edit-meta"
>
Edit
</Button>
)}
{allowedNext.map((target) => (
<Button
key={target}
accent={TRANSITION_BUTTON_ACCENT[target]}
onClick={() => transition.mutate({ status: target })}
data-testid={`mission-transition-${target}`}
disabled={transition.isPending}
>
→ {MISSION_STATUS_LABEL[target]}
</Button>
))}
<Button
accent="rose"
variant="ghost"
onClick={() => {
if (
window.confirm(
`Soft-delete mission "${m.name}"? An admin can restore from the trash.`,
)
) {
remove.mutate();
}
}}
data-testid="mission-delete"
disabled={remove.isPending}
>
Delete
</Button>
</div>
</div>
<Card className="mb-4">
<dl className="grid grid-cols-2 gap-3 md:grid-cols-4 font-mono text-2xs">
<div>
<dt className="text-text-dim uppercase tracking-wider2">Client</dt>
<dd className="text-text-bright">{m.client_target ?? '—'}</dd>
</div>
<div>
<dt className="text-text-dim uppercase tracking-wider2">Dates</dt>
<dd className="text-text-bright">
{formatDateRange(m.date_start, m.date_end)}
</dd>
</div>
<div>
<dt className="text-text-dim uppercase tracking-wider2">Scenarios</dt>
<dd className="text-text-bright">{m.scenarios_count}</dd>
</div>
<div>
<dt className="text-text-dim uppercase tracking-wider2">Tests</dt>
<dd className="text-text-bright">{m.tests_count}</dd>
</div>
</dl>
{m.description_md && (
<pre className="mt-3 whitespace-pre-wrap font-mono text-xs text-text">{m.description_md}</pre>
)}
</Card>
<nav className="flex gap-1 border-b border-border mb-4" aria-label="Mission tabs">
{TABS.map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
data-testid={`mission-tab-${t}`}
className={`px-3 py-2 font-mono text-2xs uppercase tracking-wider2 ${
tab === t
? 'text-cyan border-b-2 border-cyan -mb-px'
: 'text-text-dim hover:text-text-bright'
}`}
>
{t}
</button>
))}
</nav>
{tab === 'tests' && (
<Card>
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
<p className="font-mono text-2xs text-text-dim">
Snapshots are frozen at append time — editing a source template
does not propagate.
</p>
{canEdit && (
<Button
accent="cyan"
onClick={() => setAddScenarios(true)}
data-testid="mission-add-scenarios"
>
+ Add scenarios
</Button>
)}
</div>
{m.scenarios.length === 0 ? (
<p className="font-mono text-xs text-text-dim">
No scenarios snapshotted yet.
{canEdit && ' Click "Add scenarios" to append one.'}
</p>
) : (
<div className="flex flex-col gap-4" data-testid="mission-scenarios">
{m.scenarios.map((sc) => (
<div
key={sc.id}
className="rounded-md border border-border bg-bg-card p-3"
data-testid={`mission-scenario-${sc.id}`}
>
<div className="flex items-center gap-2 mb-2">
<Tag accent="cyan">#{sc.position + 1}</Tag>
<p className="font-mono text-xs text-text-bright">
{sc.snapshot_name}
</p>
</div>
{sc.snapshot_description && (
<p className="mb-2 font-mono text-2xs text-text-dim">
{sc.snapshot_description}
</p>
)}
<table className="w-full font-mono text-2xs">
<thead>
<tr className="text-text-dim uppercase tracking-wider2">
<th className="text-left py-1">#</th>
<th className="text-left py-1">Test</th>
<th className="text-left py-1">MITRE</th>
<th className="text-left py-1">OPSEC</th>
<th className="text-left py-1">State</th>
</tr>
</thead>
<tbody>
{sc.tests.map((t) => (
<tr
key={t.id}
className="border-t border-border/40"
data-testid={`mission-test-${t.id}`}
>
<td className="py-1 text-text-dim">{t.position + 1}</td>
<td className="py-1 text-text-bright">{t.snapshot_name}</td>
<td className="py-1">
<div className="flex flex-wrap gap-1">
{t.mitre_tags.map((tag) => (
<Tag
accent="cyan"
key={`${tag.kind}-${tag.external_id}`}
>
{tag.external_id}
</Tag>
))}
</div>
</td>
<td className="py-1 text-text">
{t.snapshot_opsec_level}
</td>
<td className="py-1">
<Tag
accent={
t.state === 'pending'
? 'teal'
: t.state === 'executed'
? 'orange'
: t.state === 'reviewed_by_blue'
? 'green'
: 'rose'
}
>
{t.state}
</Tag>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
)}
</Card>
)}
{tab === 'members' && (
<Card>
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
<p className="font-mono text-2xs text-text-dim">
Members see this mission and (for reds) can author red-side fields
on its tests in M7+.
</p>
{canEdit && (
<Button
accent="cyan"
onClick={() => setEditMembers(true)}
data-testid="mission-edit-members"
>
Edit members
</Button>
)}
</div>
{m.members.length === 0 ? (
<p className="font-mono text-xs text-text-dim">
No members assigned.
{canEdit && ' Click "Edit members" to add some.'}
</p>
) : (
<ul className="flex flex-col gap-2" data-testid="mission-members">
{m.members.map((mb) => (
<li
key={mb.user_id}
className="flex items-center justify-between rounded-md border border-border bg-bg-card p-3"
data-testid={`mission-member-${mb.user_id}`}
>
<div>
<p className="font-mono text-xs text-text-bright">
{mb.user_display_name ?? mb.user_email}
</p>
{mb.user_display_name && (
<p className="font-mono text-2xs text-text-dim">
{mb.user_email}
</p>
)}
</div>
<Tag accent={mb.role_hint === 'red' ? 'red' : 'cyan'}>
{mb.role_hint}
</Tag>
</li>
))}
</ul>
)}
</Card>
)}
{tab === 'synthesis' && (
<Card>
<p className="font-mono text-xs text-text-dim">
Reveal.js slide synthesis lands in M10.
</p>
</Card>
)}
{tab === 'export' && (
<Card>
<p className="font-mono text-xs text-text-dim">
JSON / CSV exports land in M11.
</p>
</Card>
)}
<MetaEditModal mission={m} open={editMeta} onClose={() => setEditMeta(false)} />
<AddScenariosModal
mission={m}
open={addScenarios}
onClose={() => setAddScenarios(false)}
/>
<EditMembersModal
mission={m}
open={editMembers}
onClose={() => setEditMembers(false)}
/>
</section>
);
}
-404
View File
@@ -1,404 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { MarkdownField } from '@/components/MarkdownField';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiGet, apiPost } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
missionKeys,
type CreateMissionPayload,
type Mission,
type MissionRoleHint,
} from '@/lib/missions';
import {
templateKeys,
type ScenarioTemplate,
type ScenarioTemplateListResponse,
} from '@/lib/templates';
interface RosterUser {
id: string;
email: string;
display_name: string | null;
}
interface RosterResponse {
items: RosterUser[];
}
interface MetaState {
name: string;
client_target: string;
date_start: string;
date_end: string;
description_md: string;
}
interface MemberSelection {
user_id: string;
role_hint: MissionRoleHint;
}
const STEPS: Array<{ key: 'meta' | 'scenarios' | 'members'; label: string }> = [
{ key: 'meta', label: 'Metadata' },
{ key: 'scenarios', label: 'Scenarios' },
{ key: 'members', label: 'Members' },
];
function blankMeta(): MetaState {
return {
name: '',
client_target: '',
date_start: '',
date_end: '',
description_md: '',
};
}
function useScenarioCatalogue() {
return useQuery({
queryKey: templateKeys.scenarios(''),
queryFn: () =>
apiGet<ScenarioTemplateListResponse>('/scenario-templates?limit=500'),
});
}
function useRoster() {
return useQuery({
queryKey: ['users', 'roster'],
queryFn: () => apiGet<RosterResponse>('/users/roster'),
});
}
export function MissionsCreatePage() {
const { state } = useAuth();
const me = state.user;
const navigate = useNavigate();
const qc = useQueryClient();
const [stepIdx, setStepIdx] = useState(0);
const step = STEPS[stepIdx];
const [meta, setMeta] = useState<MetaState>(blankMeta);
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
const [members, setMembers] = useState<MemberSelection[]>(() =>
me && !me.is_admin
? [{ user_id: me.id, role_hint: 'red' }]
: [],
);
const scenarios = useScenarioCatalogue();
const roster = useRoster();
const createMutation = useMutation({
mutationFn: (body: CreateMissionPayload) => apiPost<Mission>('/missions', body),
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
navigate(`/missions/${created.id}`);
},
});
const apiErr =
createMutation.error instanceof ApiError ? createMutation.error : null;
const metaInvalid = meta.name.trim().length === 0;
const datesInvalid =
meta.date_start &&
meta.date_end &&
meta.date_end < meta.date_start;
const scenarioById = useMemo(() => {
const m = new Map<string, ScenarioTemplate>();
for (const sc of scenarios.data?.items ?? []) m.set(sc.id, sc);
return m;
}, [scenarios.data]);
const next = () => setStepIdx((i) => Math.min(i + 1, STEPS.length - 1));
const prev = () => setStepIdx((i) => Math.max(i - 1, 0));
function toggleScenario(id: string) {
setScenarioIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
function setMemberRole(user_id: string, role_hint: MissionRoleHint) {
setMembers((prev) =>
prev.some((m) => m.user_id === user_id)
? prev.map((m) => (m.user_id === user_id ? { ...m, role_hint } : m))
: [...prev, { user_id, role_hint }],
);
}
function removeMember(user_id: string) {
setMembers((prev) => prev.filter((m) => m.user_id !== user_id));
}
function submit() {
const payload: CreateMissionPayload = {
name: meta.name.trim(),
client_target: meta.client_target.trim() || null,
date_start: meta.date_start || null,
date_end: meta.date_end || null,
description_md: meta.description_md.trim() || null,
scenario_template_ids: scenarioIds,
members,
};
createMutation.mutate(payload);
}
const totalSelectedTests = useMemo(
() =>
scenarioIds.reduce(
(acc, id) => acc + (scenarioById.get(id)?.tests_count ?? 0),
0,
),
[scenarioIds, scenarioById],
);
return (
<section data-testid="missions-create">
<SectionHeader prefix="New" highlight="Mission" accent="cyan" />
<Card className="mb-6">
<ol className="flex items-center gap-2" data-testid="missions-create-steps">
{STEPS.map((s, i) => {
const active = i === stepIdx;
const done = i < stepIdx;
const accent: 'cyan' | 'green' | 'teal' = active
? 'cyan'
: done
? 'green'
: 'teal';
return (
<li key={s.key} className="flex items-center gap-2">
<Tag accent={accent}>{i + 1}. {s.label}</Tag>
{i < STEPS.length - 1 && (
<span className="text-text-dim font-mono">→</span>
)}
</li>
);
})}
</ol>
</Card>
{apiErr && (
<div data-testid="missions-create-error" className="mb-4">
<Alert accent="red">{apiErr.message}</Alert>
</div>
)}
{step.key === 'meta' && (
<Card title="Metadata" sub="Identification and scope">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<TextField
label="Name"
required
value={meta.name}
onChange={(e) => setMeta((p) => ({ ...p, name: e.target.value }))}
data-testid="meta-name"
placeholder="purple-q2-2026"
/>
<TextField
label="Client / target"
value={meta.client_target}
onChange={(e) =>
setMeta((p) => ({ ...p, client_target: e.target.value }))
}
data-testid="meta-client"
placeholder="Acme Corp"
/>
<TextField
label="Start date"
type="date"
value={meta.date_start}
onChange={(e) =>
setMeta((p) => ({ ...p, date_start: e.target.value }))
}
data-testid="meta-date-start"
/>
<TextField
label="End date"
type="date"
value={meta.date_end}
onChange={(e) =>
setMeta((p) => ({ ...p, date_end: e.target.value }))
}
data-testid="meta-date-end"
/>
</div>
<div className="mt-4">
<MarkdownField
label="ROE / Description"
value={meta.description_md}
onChange={(v) =>
setMeta((p) => ({ ...p, description_md: v }))
}
data-testid="meta-description"
/>
</div>
{datesInvalid && (
<p className="mt-3 font-mono text-2xs text-red" data-testid="meta-date-error">
End date must be on or after start date.
</p>
)}
</Card>
)}
{step.key === 'scenarios' && (
<Card
title="Scenarios"
sub={`Select reusable scenarios — ${totalSelectedTests} tests will be snapshotted`}
>
{scenarios.isError && (
<Alert accent="red">Failed to load scenarios.</Alert>
)}
{scenarios.isLoading && (
<p className="font-mono text-xs text-text-dim">Loading…</p>
)}
<ul
className="grid grid-cols-1 gap-2 md:grid-cols-2"
data-testid="scenarios-picker"
>
{scenarios.data?.items.map((sc) => {
const selected = scenarioIds.includes(sc.id);
return (
<li key={sc.id}>
<button
type="button"
className={`w-full rounded-md border ${
selected ? 'border-cyan text-cyan' : 'border-border text-text'
} bg-bg-card p-3 text-left font-mono text-xs hover:border-cyan`}
onClick={() => toggleScenario(sc.id)}
data-testid={`scenario-toggle-${sc.id}`}
aria-pressed={selected}
>
<div className="flex items-center justify-between">
<span className="text-text-bright">{sc.name}</span>
<Tag accent="purple">{sc.tests_count} tests</Tag>
</div>
{sc.description && (
<p className="mt-1 text-text-dim">{sc.description}</p>
)}
</button>
</li>
);
})}
</ul>
{scenarios.data && scenarios.data.items.length === 0 && (
<p className="font-mono text-xs text-text-dim">
No scenarios in the catalogue yet — create one in
{' '}<a href="/admin/scenarios" className="text-cyan underline">Admin → Scenarios</a>{' '}
first.
</p>
)}
</Card>
)}
{step.key === 'members' && (
<Card title="Members" sub="Who works on this mission and on which side">
{roster.isError && (
<Alert accent="red">Failed to load roster.</Alert>
)}
{roster.isLoading && (
<p className="font-mono text-xs text-text-dim">Loading users…</p>
)}
<ul
className="flex flex-col gap-2"
data-testid="members-picker"
>
{roster.data?.items.map((u) => {
const selected = members.find((m) => m.user_id === u.id);
return (
<li
key={u.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-bg-card p-3"
data-testid={`member-row-${u.id}`}
>
<div>
<p className="font-mono text-xs text-text-bright">
{u.display_name ?? u.email}
</p>
{u.display_name && (
<p className="font-mono text-2xs text-text-dim">{u.email}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
accent="red"
variant={selected?.role_hint === 'red' ? 'solid' : 'outline'}
onClick={() => setMemberRole(u.id, 'red')}
data-testid={`member-${u.id}-red`}
>
Red
</Button>
<Button
accent="cyan"
variant={selected?.role_hint === 'blue' ? 'solid' : 'outline'}
onClick={() => setMemberRole(u.id, 'blue')}
data-testid={`member-${u.id}-blue`}
>
Blue
</Button>
{selected && (
<Button
accent="rose"
variant="ghost"
onClick={() => removeMember(u.id)}
data-testid={`member-${u.id}-clear`}
>
✕
</Button>
)}
</div>
</li>
);
})}
</ul>
</Card>
)}
<div className="mt-6 flex items-center justify-between">
<Button
accent="teal"
variant="ghost"
onClick={prev}
disabled={stepIdx === 0}
data-testid="missions-create-prev"
>
← Back
</Button>
{stepIdx < STEPS.length - 1 ? (
<Button
accent="cyan"
onClick={next}
disabled={step.key === 'meta' && (metaInvalid || !!datesInvalid)}
data-testid="missions-create-next"
>
Next →
</Button>
) : (
<Button
accent="green"
onClick={submit}
disabled={
createMutation.isPending ||
metaInvalid ||
!!datesInvalid
}
data-testid="missions-create-submit"
>
{createMutation.isPending ? 'Creating…' : 'Create mission'}
</Button>
)}
</div>
</section>
);
}
-167
View File
@@ -1,167 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiGet } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
MISSION_STATUS_ACCENT,
MISSION_STATUS_LABEL,
buildMissionQueryString,
missionKeys,
type MissionFilters,
type MissionListResponse,
type MissionStatus,
} from '@/lib/missions';
const STATUS_OPTIONS: Array<{ value: '' | MissionStatus; label: string }> = [
{ value: '', label: 'All statuses' },
{ value: 'draft', label: MISSION_STATUS_LABEL.draft },
{ value: 'in_progress', label: MISSION_STATUS_LABEL.in_progress },
{ value: 'completed', label: MISSION_STATUS_LABEL.completed },
{ value: 'archived', label: MISSION_STATUS_LABEL.archived },
];
function useMissions(filters: MissionFilters) {
return useQuery({
queryKey: missionKeys.list(filters),
queryFn: () =>
apiGet<MissionListResponse>(`/missions${buildMissionQueryString(filters)}`),
});
}
function formatDateRange(start: string | null, end: string | null): string {
if (!start && !end) return '—';
if (start && end) return `${start} → ${end}`;
return start ?? end ?? '—';
}
export function MissionsListPage() {
const { state } = useAuth();
const canCreate =
state.user?.is_admin || state.user?.permissions.includes('mission.create');
const [q, setQ] = useState('');
const [status, setStatus] = useState<'' | MissionStatus>('');
const [client, setClient] = useState('');
const filters = useMemo<MissionFilters>(
() => ({
q: q.trim() || undefined,
status: status || undefined,
client: client.trim() || undefined,
}),
[q, status, client],
);
const { data, error, isLoading } = useMissions(filters);
const apiErr = error instanceof ApiError ? error : null;
return (
<section data-testid="missions-list">
<div className="flex items-baseline justify-between">
<SectionHeader prefix="Plan" highlight="Missions" accent="cyan" />
{canCreate && (
<Link to="/missions/new" data-testid="missions-new-link">
<Button accent="cyan">+ New mission</Button>
</Link>
)}
</div>
<Card className="mb-6">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<TextField
label="Search"
placeholder="name or description"
value={q}
onChange={(e) => setQ(e.target.value)}
data-testid="missions-filter-q"
/>
<TextField
label="Client"
placeholder="acme corp"
value={client}
onChange={(e) => setClient(e.target.value)}
data-testid="missions-filter-client"
/>
<div className="flex flex-col gap-1">
<label className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as '' | MissionStatus)}
data-testid="missions-filter-status"
className="bg-bg-card border border-border rounded px-3 py-2 font-mono text-xs text-text-bright"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</Card>
{apiErr && (
<div data-testid="missions-error">
<Alert accent="red">{apiErr.message}</Alert>
</div>
)}
{isLoading && (
<p className="font-mono text-xs text-text-dim">Loading missions…</p>
)}
{data && data.items.length === 0 && !isLoading && (
<Card>
<p className="font-mono text-xs text-text-dim" data-testid="missions-empty">
No missions match the filters. {canCreate ? 'Create one to get started.' : ''}
</p>
</Card>
)}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2" data-testid="missions-grid">
{data?.items.map((m) => {
const accent = MISSION_STATUS_ACCENT[m.status];
return (
<Link key={m.id} to={`/missions/${m.id}`} data-testid={`mission-card-${m.id}`}>
<Card
accent={accent}
title={m.name}
sub={m.client_target ?? 'No client'}
className="h-full"
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
<Tag accent="cyan">{m.scenarios_count} scenarios</Tag>
<Tag accent="purple">{m.tests_count} tests</Tag>
<Tag accent="teal">{m.members_count} members</Tag>
</div>
<p className="mt-3 font-mono text-2xs text-text-dim">
{formatDateRange(m.date_start, m.date_end)}
</p>
</Card>
</Link>
);
})}
</div>
{data && (
<p
className="mt-4 font-mono text-2xs text-text-dim"
data-testid="missions-total"
>
{data.total} mission{data.total === 1 ? '' : 's'} total
</p>
)}
</section>
);
}
-137
View File
@@ -1,137 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { MitreTagPicker } from '@/components/MitreTagPicker';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { ApiError, apiGet, apiPost } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
mitreKeys,
type MitreStatus,
type MitreSyncResult,
type MitreTag,
} from '@/lib/mitre';
export function MitrePage() {
const { state } = useAuth();
const qc = useQueryClient();
const [selected, setSelected] = useState<MitreTag[]>([]);
const [syncResult, setSyncResult] = useState<string | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
const status = useQuery({
queryKey: mitreKeys.status,
queryFn: () => apiGet<MitreStatus>('/mitre/status'),
});
const sync = useMutation({
mutationFn: () => apiPost<MitreSyncResult>('/mitre/sync'),
onMutate: () => {
setSyncResult(null);
setSyncError(null);
},
onSuccess: async (res) => {
const counts = `${res.tactics_upserted} tactics, ${res.techniques_upserted} techniques, ${res.subtechniques_upserted} subtechniques`;
setSyncResult(`Sync completed in ${(res.duration_ms / 1000).toFixed(1)}s — ${counts}.`);
await qc.invalidateQueries({ queryKey: ['mitre'] });
},
onError: (e) => {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setSyncError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setSyncError(e instanceof Error ? e.message : 'Sync failed');
}
},
});
return (
<>
<SectionHeader
prefix="MITRE"
highlight="ATT&CK"
accent="cyan"
description="Reference catalogue: tactics, techniques, sub-techniques. Tag picker reusable by tests and missions."
/>
<div className="grid gap-4 mb-6 [grid-template-columns:repeat(auto-fill,minmax(360px,1fr))]">
<Card accent="cyan" title="Source" sub={status.data?.version ? `version ${status.data.version}` : '—'}>
<p>
URL:{' '}
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs break-all">
{status.data?.source_url ?? status.data?.default_url ?? '—'}
</code>
</p>
<p className="mt-2">
Last sync:{' '}
<code
className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs"
data-testid="mitre-last-sync"
>
{status.data?.last_sync ?? 'never'}
</code>
</p>
</Card>
{state.user?.is_admin && (
<Card accent="orange" title="Sync" sub="re-pull the bundle from the configured source">
<p className="text-2xs text-text-dim mb-3">
The seed CLI runs the same logic; trigger from here only if the URL is reachable from the api container.
</p>
<Button
accent="orange"
onClick={() => sync.mutate()}
disabled={sync.isPending}
data-testid="mitre-sync"
>
{sync.isPending ? 'Syncing…' : 'Trigger MITRE sync'}
</Button>
{syncResult && <Alert accent="green" className="mt-3">{syncResult}</Alert>}
{syncError && <Alert accent="red" className="mt-3">{syncError}</Alert>}
</Card>
)}
</div>
<SectionHeader prefix="Tag" highlight="Picker" accent="orange" />
{/* Full-bleed the matrix beyond max-w-page so it uses the full viewport
* width. `calc(50% - 50vw)` is the canonical CSS recipe: the element's
* left edge slides back to viewport x=0 regardless of how big the
* outer max-w-page container is. `px-[60px]` mirrors the page padding
* so cells don't touch the viewport edge. */}
<div
className="px-[60px]"
style={{
marginLeft: 'calc(50% - 50vw)',
marginRight: 'calc(50% - 50vw)',
width: '100vw',
}}
>
<MitreTagPicker value={selected} onChange={setSelected} />
</div>
{selected.length > 0 && (
<Card accent="purple" className="mt-6" title="Selected (preview payload)">
<pre className="text-2xs text-text-bright whitespace-pre-wrap" data-testid="mitre-selected-json">
{JSON.stringify(selected, null, 2)}
</pre>
</Card>
)}
{selected.length === 0 && (
<p className="mt-3 font-mono text-2xs text-text-dim">
Click any cell to toggle. Use <span className="text-purple">▸</span> to reveal sub-techniques inline. Hover a cell for its <code className="text-purple">external_id</code>. Selections accumulate.
</p>
)}
<div className="mt-4">
<Tag accent="cyan">Tactic</Tag>
<Tag accent="orange">Technique</Tag>
<Tag accent="purple">Sub-technique</Tag>
</div>
</>
);
}
-34
View File
@@ -41,17 +41,6 @@ project: Metamorph
- **Le test d'intégration "expected tables/FK/CHECK" est le bon filet de sécurité** pour M1+ : il a immédiatement attrapé les fixes du reviewer (le retrait de `ck_mission_test_mitre_tags_exactly_one_mitre_fk` aurait été un oubli silencieux sinon). - **Le test d'intégration "expected tables/FK/CHECK" est le bon filet de sécurité** pour M1+ : il a immédiatement attrapé les fixes du reviewer (le retrait de `ck_mission_test_mitre_tags_exactly_one_mitre_fk` aurait été un oubli silencieux sinon).
- **Lancer le DoD avant de dire "M1 done"** : règle gravée à M0, respectée ici. `make clean && make up && make migrate && make test-api && make e2e` est la séquence canonique de fin de milestone. - **Lancer le DoD avant de dire "M1 done"** : règle gravée à M0, respectée ici. `make clean && make up && make migrate && make test-api && make e2e` est la séquence canonique de fin de milestone.
## 2026-05-12 — M4 MITRE ATT&CK
- **STIX parsing avec stdlib uniquement** (`urllib.request` + `json` + `hashlib`) suffit pour 50 MB de bundle, ~1.1 s parse end-to-end. Pas besoin de `requests`/`httpx`. Toute future ingestion de gros JSON pinné → stdlib first, ne pas inflater l'image pour un cas d'usage one-shot.
- **Le sous-jacent MITRE évolue** : la spec mentionne "14 tactics" mais la v19 actuelle en ship 15 (Reconnaissance + Resource Development depuis v8). Les assertions de DoD sont à exprimer en `>= X` quand un référentiel externe est en jeu, pas en `== X`. Pattern : décorréler la valeur exacte du contrat (sinon la maintenance casse au prochain bump).
- **Sub-technique parent resolution** : la source authoritative est la `relationship[subtechnique-of]` STIX, pas la convention dotted-id `T1003.001 → T1003`. La regex en fallback ne sert que si la relation manque (jamais le cas avec MITRE officiel, mais utile pour bundles custom).
- **`session_scope()` enveloppe tout le seed dans une seule transaction** → les lecteurs externes ne voient jamais un état intermédiaire pendant le DELETE+INSERT de `mitre_technique_tactics`. Postgres READ COMMITTED isole. Pas besoin d'advisory lock sauf si on s'attend à des syncs concurrents.
- **Checksum bypass silencieux = footgun**. Avant : `source != MITRE_DEFAULT_URL` → `expected_sha256 = None`. Un admin qui type un domaine attaquant dans `mitre_source_url` ingère du JSON arbitraire sans intégrité. Patron correct : `MitreSeedError("custom URL requires an expected_sha256 or allow_unverified=True")`. L'opt-out explicite (`--skip-checksum` côté CLI, `allow_unverified: true` côté API) reste possible mais visible.
- **`/diag/reset` cohérence** : si on TRUNCATE `settings` mais pas les tables de référence MITRE (gardées car coûteuses à re-seeder), `GET /mitre/status` retourne `last_sync: null` alors que `GET /mitre/tactics` retourne 15 lignes. Discrepancy mensongère. Fix : TRUNCATE aussi les `mitre_*` dans `/diag/reset` (test-only endpoint, on accepte la re-sync via `/mitre/sync` en `beforeAll`).
- **Volume permissions et chown au build** : `mkdir -p /data/mitre && chown -R metamorph:metamorph /data` dans le Dockerfile suffit POUR le premier `make up` (podman copie l'ownership de l'image lors de l'init du named volume). Mais si un volume préexiste owned root, le chown ne replay pas. À documenter en pré-requis dans `tasks/testing-m<N>.md`, ou ajouter un entrypoint shim qui valide les perms au boot.
- **Build cache du front silencieux** : `podman build` montre `Using cache aad724...` même quand src/ a changé si le diff entre les arborescences est invisible (mtime). En cas de doute : `podman build --no-cache` une fois pour confirmer que le typecheck passe, puis `make down && make up` pour pousser le bundle. Réflexe à garder en mémoire.
## 2026-05-11 — M3 RBAC, groupes, users, invitations ## 2026-05-11 — M3 RBAC, groupes, users, invitations
- **`logging.LogRecord` réserve `name`** comme attribut interne (en plus de `message`, `levelname`, `pathname`, `filename`, `module`, `funcName`, `lineno`, `asctime`, `process`, `thread`, `args`). Donc `log.info("metamorph.x.created", extra={"name": entity.name})` lève `KeyError: "Attempt to overwrite 'name' in LogRecord"`. Patron : préfixer toute clé risquée par l'entité (`group_name`, `user_name`, `template_name`). À documenter dans le style guide quand on en aura un. - **`logging.LogRecord` réserve `name`** comme attribut interne (en plus de `message`, `levelname`, `pathname`, `filename`, `module`, `funcName`, `lineno`, `asctime`, `process`, `thread`, `args`). Donc `log.info("metamorph.x.created", extra={"name": entity.name})` lève `KeyError: "Attempt to overwrite 'name' in LogRecord"`. Patron : préfixer toute clé risquée par l'entité (`group_name`, `user_name`, `template_name`). À documenter dans le style guide quand on en aura un.
@@ -78,29 +67,6 @@ project: Metamorph
- **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`. - **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`.
- **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var. - **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
## 2026-05-13 — M6 missions + snapshot
- **Snapshot independence requires more than column copies — denormalise the join tables too.** `mission_tests` copies every scalar template field, but if `mission_test_mitre_tags` kept FKs to `mitre_*` rows, a future re-sync that drops a technique would cascade through `ON DELETE CASCADE` and silently mutate frozen missions. The M1 schema already split `mission_test_mitre_tags` with frozen `(mitre_external_id, mitre_name, mitre_url)` columns and no FK — at snapshot time we denormalise via a 3-query batch lookup (`_resolve_mitre_lookup`) and build the rows in-memory. Pattern to reuse for any "frozen reference" relationship in the future.
- **`/diag/reset` truncate order is FK-aware**: `mission_scenarios.source_scenario_template_id` and `mission_tests.source_test_template_id` are `ON DELETE SET NULL`. Truncating template tables first would force PG to NULL those columns one by one. Reverse the order — wipe mission tables (which cascade to members/scenarios/tests/tags/categories from `missions`) BEFORE the templates. Saves a round-trip + keeps the truncate logically aligned with the dependency graph.
- **Membership visibility = 404, not 403.** Returning 403 for "mission exists but you're not a member" leaks the existence of the mission. The service returns 404 in both "doesn't exist" and "not visible to you" cases via the same `MissionNotFound` exception. The decorator stack handles perm-level 403 (you can't even GET /missions); the service handles row-level 404. Pattern: gate "type of action" via decorator perms, gate "which rows" via service-level membership filters that collapse to 404.
- **Auto-add the non-admin creator as a member.** Without this, a redteamer who creates a mission and forgets to add themselves to `members[]` immediately loses visibility (403 on subsequent GETs because they're not a member). Solved at the service layer: `if not creator_is_admin and creator_id not in members: prepend (creator_id, 'red')`. Admin creators don't auto-add because they bypass membership anyway. Documented in the docstring + tested explicitly (`test_non_admin_creator_auto_added`).
- **Minimum-surface roster endpoint pattern**: `/users` returns admin metadata (is_admin via groups, is_active, group memberships). The mission wizard needs a list of assignable users from a non-admin redteamer's perspective — exposing /users to them would leak admin metadata. Added a dedicated `GET /users/roster` returning only `(id, email, display_name)` and gated by **any of** `user.read`, `mission.create`, `mission.update`. Pattern: when a cross-feature needs a smaller slice of an admin endpoint, create a dedicated lightweight endpoint rather than relaxing the admin one.
- **Pyright is not always wrong about "unused" parameters** — the original `_to_list_item(s: Session, m: Mission)` took `s` but never accessed it (the function uses already-`selectinload`ed relationships). Removed the param. Lesson: when adding a `Session` parameter to a view-assembly helper, audit whether the body actually issues queries through it.
- **`flask.abort()` is not typed `NoReturn` in this project's Pyright config** so `def f() -> X: if x is None: abort(...); return x` raises a return-type error. Workaround: add `assert user is not None` after the abort to narrow the type. Cleaner than `cast(...)`. Pattern to reuse anywhere we abort-and-return.
- **Snapshot of multiple scenarios is a 4-query write** regardless of test count: (1) load N scenario_templates with their join rows, (2) load M test_templates by id with mitre_tags, (3) batch-resolve MITRE rows (3 queries for tactic/technique/sub), (4) insert mission_scenarios + mission_tests + mission_test_mitre_tags via the SQLAlchemy unit of work. Avoid the temptation to query inside per-test loops — it explodes to O(scenarios × tests × tag_kinds) easily.
## 2026-05-12 — M5 templates + scenarios
- **`extra={"name": ...}` dans `log.info()` crash silencieusement** — Python's `logging.LogRecord` réserve `name` (le logger name). Coût : 500 sur le POST, message peu parlant (`KeyError: "Attempt to overwrite 'name' in LogRecord"`). Fix : renommer la clé (`template_name`). Liste réservée à éviter : `name`, `msg`, `args`, `levelname`, `levelno`, `pathname`, `filename`, `module`, `funcName`, `created`, `msecs`, `lineno`, `thread`, `threadName`, `process`. Pattern : préfixer les clés extra par l'entité (`template_name`, `group_id`, `user_id` est OK mais `id` aussi est piégeux dans certains setups).
- **React 18 + `setX((prev) => ({...prev, val: e.currentTarget.value }))` → page blanche au 1er input.** `e.currentTarget` est cleared après la fin du bubble, AVANT que l'updater fonctionnel exécute. Le synthetic event survit (pas de pooling depuis React 17), mais `currentTarget` est setté/cleared par le dispatcher. Fix : `e.target.value` (qui persiste sur le synthetic event), ou capturer `const v = e.currentTarget.value;` avant le `setX`. À garder en tête : tout `onChange` qui passe par un updater fonctionnel doit lire `e.target`, pas `e.currentTarget`.
- **Sentinel `Any = object()` plutôt que `... (Ellipsis)`** pour les "field unset" optional en service Python. Pyright voit `... = object()` correctement comme `Any`, alors que `description: str | None | object = ...` rend `description.strip()` invalide. Pattern : `_UNSET: Any = object()` au top du module + `description: Any = _UNSET` dans la signature + `if description is not _UNSET: ...`. Net + typecheck-friendly.
- **Postgres UNIQUE(scenario_id, position) + position-swap = ON CONFLICT pendant l'UPDATE.** Pour réordonner, le pattern naïf (UPDATE position) viole la contrainte sur le 1er swap. Trois options : (a) full delete + re-insert dans la même tx [retenu, atomique + lisible], (b) shift d'offset (UPDATE position = position + 1000 puis renumérotation), (c) deferred constraint. (a) gagne en simplicité — la liste rarement >50 éléments, le coût est négligeable.
- **`@dnd-kit/sortable` requires `useSortable({ id })` IDs to be unique and stable across renders.** Si on utilise un index numérique comme id, drag-and-drop ne réagit pas. Utiliser `test_template_id` (UUID stable) marche directement.
- **Frontend deps ajoutés à `package.json` sans `package-lock.json`** : le Dockerfile fait `npm install --no-audit --no-fund` sur fallback. OK pour M5 (3 deps `@dnd-kit/*`). À l'avenir, freeze un lockfile avant M14 pour build reproductibles.
- **Playwright `getByTestId` est défini par `testIdAttributeName: 'data-testid'`** dans `playwright.config.ts`. Pour qu'un test-id descende sur l'input via TextField, il faut que `...rest` soit spread sur l'input (déjà OK dans `TextField.tsx`). Mais avec un wrapper `<div><label/><input/></div>`, `getByTestId` matche le DIV si le test-id est dessus. Bien le mettre sur l'élément interactif (input/button), pas sur le container.
- **`/diag/reset` truncate order matters** : `scenario_template_tests.test_template_id` est FK `ON DELETE RESTRICT`, donc il faut truncate `scenario_template_tests` AVANT `test_templates`. Hierarchy : `scenario_template_tests → scenario_templates → test_template_mitre_tags → test_templates → mitre_*`. Maintenant inscrite dans `diag.py`.
- **Modal embarquant le `MitreTagPicker` complet (15 cols × 50 techniques)** : le picker se charge via `/mitre/matrix` (~94 KB). Affichage instantané, OK. Pour de futurs modals lourds, considérer le lazy-render derrière un toggle ou tab.
<!-- <!--
Template for future entries: Template for future entries:
+3 -10
View File
@@ -79,14 +79,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
## 5. Exigences fonctionnelles ## 5. Exigences fonctionnelles
- **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4). - **F1** — Gestion users/groupes/invitations par admin avec permissions atomiques (familles listées en §4).
- **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus. **Représentation UI du picker MITRE** : matrice flat fidèle à `attack.mitre.org/#` — - **F2** — CRUD tests unitaires templates avec MITRE ATT&CK (Tactic+Technique+Sub-technique multi), procédure markdown/code, prérequis, résultat attendu red, détection attendue blue, niveau OPSEC (low/med/high), tags libres, IOCs attendus.
- **Full-bleed** : le picker s'étend sur toute la largeur du viewport (s'échappe du `max-w-page` global du layout) pour exposer un maximum de cellules sans scroll.
- **15 colonnes** equal-width via `grid-template-columns: repeat(N, minmax(7rem, 1fr))` ; scroll horizontal seulement en dernier recours sur viewport étroit (<≈1680px).
- **Wrap word-only** : `overflow-wrap: normal` + `hyphens: none` — les noms cassent uniquement sur les espaces, jamais au milieu d'un mot.
- **Headers** = nom de la tactic seul + compteur de techniques en 10px ; l'`external_id` `TA00xx` n'apparaît qu'au hover (title) et dans les chips de sélection.
- **Cellules** = nom de la technique seul (idem pour `T1xxx` au hover) ; chevron `▸ N / ▾ N` qui déplie inline les sub-techniques dans la colonne.
- **Police** sans-serif uniforme `text-xs` (12px) pour cells + headers, `10px` pour les sub-counts.
- **Click** sur une cellule = (dé)sélection ; selection multi-niveaux (tactic / technique / sub-technique) cumulative ; chips de sélection en haut avec `external_id · name` cliquables pour retirer.
- **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires. - **F3** — CRUD scénarios = liste ordonnée (drag-and-drop) de tests unitaires.
- **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation. - **F4** — CRUD missions (métadonnées §4) composées d'un ou plusieurs scénarios, snapshot des templates à l'instanciation.
- **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override. - **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override.
@@ -96,7 +89,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé. - **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
- **F10** — Soft delete + purge admin. - **F10** — Soft delete + purge admin.
- **F11** — Switch i18n FR/EN par utilisateur (préférence persistée). - **F11** — Switch i18n FR/EN par utilisateur (préférence persistée).
- **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti. Le picker (cf. F2) se base sur un endpoint `GET /mitre/matrix` qui retourne la grille complète (tactics → techniques → sub-techniques) en un seul appel. - **F12** — Sync MITRE ATT&CK Enterprise : dataset STIX embarqué (seed) + job admin manuel pour re-puller depuis github.com/mitre/cti.
## 6. Exigences non fonctionnelles ## 6. Exigences non fonctionnelles
- **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves. - **NF-perf** : UI fluide, pagination côté API au-delà de 50 éléments par liste, lazy-loading des fichiers de preuves.
@@ -144,7 +137,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
1. Premier boot : token d'install affiché dans les logs, accès `/setup` permet de créer le 1er admin. 1. Premier boot : token d'install affiché dans les logs, accès `/setup` permet de créer le 1er admin.
2. Admin crée un groupe custom et lui attribue des permissions atomiques (write_red_fields uniquement, par exemple). 2. Admin crée un groupe custom et lui attribue des permissions atomiques (write_red_fields uniquement, par exemple).
3. Admin envoie un lien d'invitation, l'utilisateur s'enregistre avec succès et hérite des bons groupes. 3. Admin envoie un lien d'invitation, l'utilisateur s'enregistre avec succès et hérite des bons groupes.
4. Admin importe la matrice MITRE et crée un test unitaire avec Tactic+Technique+Sub-technique. *(≥ 14 tactics Enterprise — la v19 du pin actuel en ship 15.)* 4. Admin importe la matrice MITRE et crée un test unitaire avec Tactic+Technique+Sub-technique.
5. Admin compose un scénario de 3 tests ordonnés via drag-and-drop. 5. Admin compose un scénario de 3 tests ordonnés via drag-and-drop.
6. Red Teamer crée une mission avec scénario, assigne 1 red + 1 blue. 6. Red Teamer crée une mission avec scénario, assigne 1 red + 1 blue.
7. Red Teamer marque un test « exécuté », saisit commande+output, timestamp auto capturé. 7. Red Teamer marque un test « exécuté », saisit commande+output, timestamp auto capturé.
-116
View File
@@ -1,116 +0,0 @@
---
type: testing
milestone: M4
date: "2026-05-12"
project: Metamorph
---
# Testing M4 — MITRE ATT&CK Enterprise
## 1. Lancement de la stack
```bash
make clean # reset si une stack tournait
make up # build + start db/api/front
make migrate
make seed-mitre # télécharge le bundle pinné v19.0 (~50 MB, ~1 s parse)
```
> **Permissions volume** : `metamorph_mitre` est créé chowné `metamorph:metamorph` par le Dockerfile à la 1ʳᵉ initialisation. Si tu as un volume préexistant (d'une expé antérieure) appartenant à root, le seed échouera avec `PermissionError`. Solution : `podman volume rm metamorph_metamorph_mitre` avant `make up`.
## 2. Tests automatisés
```bash
make test-api # 58 tests pytest dont 19 MITRE (parser, idempotence, security guards, all endpoints, dotted fallback, version clearing)
make e2e # 34 tests Playwright dont 6 M4
```
Le rapport HTML est dans `e2e/playwright-report/`, le JUnit dans `e2e/playwright-report/junit.xml`.
## 3. Procédure manuelle (smoke navigateur)
### Pré-requis
- Stack up, migrations appliquées, `make seed-mitre` exécuté.
- Le bundle est cache dans le volume `metamorph_mitre` (`/data/mitre/enterprise-attack-19.0.json`). Pour ré-utiliser un fichier local : `flask --app app.cli metamorph seed-mitre --source /chemin/vers/enterprise-attack.json`.
### 3.1 Page MITRE (`/mitre`)
1. Se connecter en admin.
2. Cliquer **MITRE** dans la nav → page chargée.
3. Carte **Source** : vérifier `version 19.0` + URL pinnée + `Last sync` non vide.
4. Carte **Sync** (admin uniquement) : cliquer **Trigger MITRE sync** → bannière verte avec counts (15 tactics / 222 techniques / 475 subtechniques).
5. **Picker — matrice flat type attack.mitre.org** :
- La matrice tient sur la largeur de la page sans scroll horizontal (15 colonnes de largeur égale, partagent l'espace dispo).
- Chaque header de colonne montre **seulement le nom de la tactic** (ex. `Credential Access`) + `17 techniques` en petit dessous. L'`external_id` (TA0006) apparaît au hover (title).
- Click sur le header **Credential Access** → toute la colonne est sélectionnée (chip cyan en haut, header en cyan filled).
- Re-click pour désélectionner.
- Les cellules affichent **uniquement le nom de la technique** (ex. `OS Credential Dumping`). L'`external_id` (T1003) apparaît au hover (title) et dans le chip de sélection.
- Cliquer la cellule **OS Credential Dumping** → cellule en orange filled, chip `T1003 · OS Credential Dumping` en haut.
- Cliquer le chevron `▸ 8` à droite de la cellule → la liste des sub-techniques se déploie inline dans la même colonne, chevron passe à `▾ 8`.
- Cliquer **LSASS Memory** (sub-technique) → cell purple filled, chip `T1003.001 · LSASS Memory`.
- Click le chip pour le retirer.
- La carte « Selected (preview payload) » sous la matrice montre le JSON cumulatif avec les `external_id`.
### 3.2 Filtre
1. Taper `dump` dans le champ **Filter** → seules T1003 + sub-techniques restent visibles, les autres techniques sont cachées (mais leurs colonnes restent visibles pour préserver la grille).
2. Taper `TA0006` → idem mais filtre par `external_id`.
3. Vider le filtre → toutes les cellules réapparaissent.
### 3.3 Non-admin
1. Inviter un user sans perms via Admin > Invitations.
2. Se connecter en tant que ce user.
3. Naviguer sur `/mitre` → page accessible, picker fonctionnel (read-only).
4. La carte **Sync** n'apparaît PAS (UI gate `is_admin`).
5. Tenter `POST /api/v1/mitre/sync` via curl avec son token → **403** `insufficient permissions`.
### 3.4 Re-sync admin
```bash
ACCESS=$(curl -sX POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}' | jq -r .access_token)
curl -sX POST http://localhost:8080/api/v1/mitre/sync \
-H "Authorization: Bearer $ACCESS" | jq
```
Sortie attendue :
```json
{
"tactics_upserted": 15,
"techniques_upserted": 222,
"subtechniques_upserted": 475,
"subtechniques_skipped_orphan": 0,
"technique_tactic_links": 254,
"version": "19.0",
"duration_ms": ~1000
}
```
### 3.5 Sync via URL custom
```bash
curl -sX POST http://localhost:8080/api/v1/mitre/sync \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d '{"source":"https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack-18.1.json"}' | jq
```
- Avec une URL ≠ pinned : sha256 désactivé, `version` est `null` (on ne connaît pas la version d'un fichier custom).
### 3.6 Mode air-gap
1. Préparer un STIX 2.1 valide localement : `enterprise-attack-19.0.json`.
2. Le copier dans le volume :
```bash
podman cp enterprise-attack-19.0.json metamorph-api:/data/mitre/
```
3. Lancer le seed pointé sur le path : `podman compose exec api flask --app app.cli metamorph seed-mitre --source /data/mitre/enterprise-attack-19.0.json --skip-checksum`
## 4. Points de contrôle critiques
- [x] `make seed-mitre` initial pinné v19.0 → 15/222/475 sans orphans.
- [x] Re-lancer le seed est idempotent (mêmes counts).
- [x] `/mitre/tactics` retourne 15 tactics (la spec mentionne 14 — MITRE en a 15 depuis v8).
- [x] `/mitre/techniques?tactic=TA0006` retourne ≥ 17 techniques incl. T1003.
- [x] `/mitre/subtechniques?technique=T1003` retourne 8 sub-techniques.
- [x] `/mitre/status` expose `last_sync`, `version`, `source_url`, `default_url`, `default_version`.
- [x] `/mitre/sync` exige la perm `mitre.sync` (admin via bypass `is_admin`).
- [x] Sha256 mismatch sur la pinned URL → 502 `checksum_mismatch`, DB intacte.
- [x] Bundle local (`--source <path>`) bypasse la vérif checksum.
- [x] Picker SPA : matrice flat attack.mitre.org-style — 15 colonnes equal-width sans scroll horizontal, cellules avec **name only** (external_id au hover + dans chips), chevron `▸ N / ▾ N` → sub-techniques inline, chips multi-niveaux en haut.
- [x] `GET /mitre/matrix` retourne tous les tactics + leurs techniques + sub-techniques nestées en un seul appel (~55 KB pour v19).
- [x] Non-admin : voit la page `/mitre` mais pas la carte Sync ; `POST /mitre/sync` → 403.
-131
View File
@@ -1,131 +0,0 @@
---
type: testing
milestone: M5
date: "2026-05-12"
project: Metamorph
---
# Testing M5 — Templates : tests unitaires & scénarios
## 1. Lancement de la stack
```bash
make clean
make up
make migrate
make seed-mitre # tag picker needs the catalogue
```
> L'admin stable `admin@metamorph.local / AdminPass1234!` est restauré
> automatiquement par le hook `afterAll` du spec e2e M5 — mais la 1ʳᵉ fois,
> bootstrappe-le via `/setup` ou laisse les tests faire le travail.
## 2. Tests automatisés
```bash
make test-api # 81 tests pytest dont 23 M5 (CRUD, perm, mitre tags, reorder, AND-semantics, extra="forbid", item caps, empty-clear)
make e2e # 38 tests Playwright dont 4 M5 (API CRUD + scenario reorder + SPA list/filter)
```
Rapport HTML : `e2e/playwright-report/`. JUnit : `e2e/playwright-report/junit.xml`.
## 3. Smoke navigateur
### Pré-requis
- Stack `make up` + admin loggé.
- MITRE seedé (vérifier via `/mitre`).
### 3.1 Catalogue de tests (`/admin/tests`)
1. Cliquer **Tests** dans la nav admin → page chargée.
2. Cliquer **+ New test** → modal s'ouvre avec :
- Champs : Name, Description, Objective, Procedure (markdown), Prerequisites, Red expected, Blue expected, OPSEC, Free tags, Expected IOCs.
- Sous-section **MITRE ATT&CK tags** : matrice complète, mêmes interactions que `/mitre`.
3. Remplir au minimum `Name=phish-link`, OPSEC=`low`, ajouter 2 tags MITRE (ex. `TA0001 + T1566`) → **Create** → carte apparaît dans la liste avec chips OPSEC + MITRE.
4. Cliquer **Edit** sur la carte → modal pré-remplie, modifier OPSEC à `high` → **Save** → la card est repeinte avec l'accent rouge OPSEC.
5. Filtres en haut :
- `Search` (full-text q sur nom/description)
- `Tactic external_id` (ex. `TA0001`)
- `OPSEC` (select : —all— / low / medium / high)
- `Free tag` (mot-clé libre)
6. Cliquer **Delete** sur une carte → confirm popup → la card disparaît (soft-delete : visible via `?include_deleted=true` côté API).
### 3.2 Catalogue de scénarios (`/admin/scenarios`)
1. Cliquer **Scenarios** dans la nav admin.
2. **+ New scenario** → modal.
- Champs Name + Description.
- Catalogue picker en bas : champ de recherche + liste des tests dispos (max 50).
3. Cliquer 3 tests dans le catalogue → ils s'empilent dans la liste ordonnée avec leurs indices `01/02/03`.
4. **Drag-and-drop** : empoigner la poignée `☰` à gauche d'une ligne et glisser vers le haut/bas → la liste se réordonne. La grille met à jour les indices au relâchement.
5. **Save** → carte apparaît avec un Tag « N tests » + l'aperçu des 4 premiers tests dans l'ordre choisi.
6. Re-ouvrir Edit → l'ordre est persisté côté serveur (vérifie le numéro 01, 02, 03 dans la modal).
7. Supprimer un `test_template` dont un scénario dépend (via `/admin/tests`) → la card scénario marque le test en rose dans le résumé (`test_template_deleted: true`).
### 3.3 Permissions
1. Inviter Bob via Admin > Invitations sans groupe → Bob peut se logger mais reçoit `403` sur `/api/v1/test-templates`.
2. Lui attacher un groupe avec seulement `test_template.read` → Bob voit `/admin/tests`... non, l'UI gate sur `is_admin`. La perm seule donne l'accès API ; l'UI ne l'expose pas pour les non-admins (par design M5).
3. Bob tente `POST /api/v1/test-templates` → `403` (manque `test_template.create`).
## 4. Smoke API
### 4.1 Login admin
```bash
ACCESS=$(curl -sX POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}' | jq -r .access_token)
```
### 4.2 Créer un test taggué MITRE
```bash
curl -sX POST http://localhost:8080/api/v1/test-templates \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d '{
"name": "lsass-dump",
"opsec_level": "high",
"tags": ["creds"],
"mitre_tags": [
{"kind":"technique","external_id":"T1003"},
{"kind":"subtechnique","external_id":"T1003.001"}
]
}' | jq
```
### 4.3 Créer un scénario ordonné
```bash
# Suppose 3 ids: $A $B $C
curl -sX POST http://localhost:8080/api/v1/scenario-templates \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"chained\",\"test_template_ids\":[\"$A\",\"$B\",\"$C\"]}" | jq
# Reorder (full replace)
curl -sX PUT http://localhost:8080/api/v1/scenario-templates/<scn_id>/tests \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d "{\"test_template_ids\":[\"$C\",\"$A\",\"$B\"]}" | jq
```
### 4.4 Filtre par tactic
```bash
curl -s "http://localhost:8080/api/v1/test-templates?tactic=TA0006" \
-H "Authorization: Bearer $ACCESS" | jq '.items[].name'
```
## 5. Points de contrôle critiques
- [x] `POST /test-templates` rejette MITRE inconnu avec `400 unknown_mitre_tag`.
- [x] `POST /test-templates` rejette opsec hors `low/medium/high`.
- [x] `PUT /test-templates/{id}` partial keeps unset fields.
- [x] `PUT /test-templates/{id}` avec `mitre_tags` **remplace** la collection (pas d'append).
- [x] `DELETE /test-templates/{id}` soft-delete (visible avec `?include_deleted=true`).
- [x] `POST /scenario-templates` rejette test_template inconnu ou soft-deleted.
- [x] `PUT /scenario-templates/{id}/tests` rewrite atomique (delete + re-insert, contrainte UNIQUE(position) honorée).
- [x] Un test soft-deleted **après** linking reste référencé : `test_template_deleted: true` sur le scénario.
- [x] Filtres list: `q`, `tactic`, `technique`, `subtechnique`, `opsec`, `tag` cumulatifs.
- [x] Perm gating : `test_template.{read,create,update,delete}` + `scenario_template.{read,create,update,delete}`.
- [x] `/diag/reset` truncate les 4 nouvelles tables (`scenario_template_tests`, `scenario_templates`, `test_template_mitre_tags`, `test_templates`) avant les tables MITRE.
- [x] UI : drag-and-drop @dnd-kit/sortable réordonne la liste, save persistant.
-124
View File
@@ -1,124 +0,0 @@
---
type: testing
milestone: M6
date: "2026-05-13"
project: Metamorph
---
# Testing M6 — Missions & snapshot
## 1. Lancement de la stack
```bash
make clean
make up
make migrate
make seed-mitre # MITRE tags are snapshotted onto mission_tests; without them
# the snapshot will simply have an empty mitre_tags array
```
> L'admin stable `admin@metamorph.local / AdminPass1234!` est restauré
> automatiquement par le hook `afterAll` du spec e2e M6, mais la 1ʳᵉ fois,
> bootstrappe-le via `/setup` (ou laisse les tests faire le travail).
## 2. Tests automatisés
```bash
make test-api # 103 tests pytest dont 22 M6 (snapshot, membership, transitions, members CRUD, perm gating)
make e2e # 43 tests Playwright dont 5 M6 (snapshot freezing, non-admin visibility, transitions, wizard, list filter)
```
Rapport HTML : `e2e/playwright-report/`.
## 3. Smoke navigateur
### Pré-requis
- Stack `make up` + admin loggé.
- MITRE seedé (`/mitre` montre 15 tactics).
- Au moins **1 test_template** et **1 scenario_template** dans le catalogue M5
(pour avoir quelque chose à snapshotter).
### 3.1 Liste & création (`/missions`)
1. Cliquer **Missions** dans la nav (visible si tu as la perm `mission.read` ou tu es admin) → la liste s'affiche avec un message vide la 1ʳᵉ fois.
2. Cliquer **+ New mission** → page wizard `/missions/new`.
3. **Étape 1 — Metadata** :
- `Name` (requis) → `purple-2026-Q2`
- `Client / target` → `Acme Corp`
- `Start date` / `End date` → si tu inverses, un message en rouge apparaît et **Next** est désactivé.
- `ROE / Description` (markdown) → optionnel.
4. **Next** → **Étape 2 — Scenarios** :
- Le catalogue M5 s'affiche en grille de boutons. Cliquer un scénario le sélectionne (bordure cyan).
- Le sous-titre du Card affiche le total de tests qui seront snapshotés.
5. **Next** → **Étape 3 — Members** :
- Le roster (issu de `/users/roster`) liste les utilisateurs actifs.
- Pour chaque user, deux boutons **Red** / **Blue** togglent l'inclusion + le rôle. ✕ retire.
- Si tu es un redteamer non-admin, tu es pré-sélectionné en `red` (auto-add côté backend si tu oublies).
6. **Create mission** → redirection vers `/missions/<id>`. La nouvelle mission apparaît en haut de la liste après retour.
### 3.2 Filtres (`/missions`)
- **Search** : full-text sur `name` / `description_md`.
- **Client** : LIKE sur `client_target`.
- **Status** : select draft / in_progress / completed / archived.
- Les filtres sont combinés en AND (ex : `status=in_progress & client=acme`).
### 3.3 Page détail (`/missions/<id>`)
1. En-tête : nom + status pill + boutons de transition.
- **draft** → boutons `→ In Progress` et `→ Archived`.
- **in_progress** → `→ Completed` et `→ Archived`.
- **completed** → `→ Archived` uniquement.
- **archived** → aucun bouton.
2. Cliquer un bouton → status update immédiat (cache invalidé, badge re-rendu).
3. **Delete** (en rose) → confirm prompt → soft-delete → redirige vers `/missions`. Réapparait via `?include_deleted=true` (admin only).
4. **Tabs** :
- **tests** : tableau par scénario avec `# | Test | MITRE | OPSEC | State`. Les MITRE chips affichent l'external_id frozen.
- **members** : pills Red/Blue avec email + display_name.
- **synthesis** : placeholder « lands in M10 ».
- **export** : placeholder « lands in M11 ».
## 4. Vérification du snapshot (DoD)
1. Crée une mission qui référence un scenario_template `sc1` contenant `test_template_t1`.
2. Aller dans `/admin/tests`, éditer `test_template_t1` : changer le nom et les tags MITRE.
3. Retour sur `/missions/<id>` (rafraîchir si la cache TanStack tient encore) → la table montre **toujours** l'ancien nom et l'ancien tag MITRE. Le snapshot est gelé. ✅
## 5. Vérification visibilité par membership
1. Login en admin, créer 2 missions :
- `m-only-admin` sans aucun membre.
- `m-shared` avec Alice (red) en membre.
2. Login en Alice.
3. `/missions` → seule `m-shared` apparaît dans la liste. `GET /api/v1/missions/<m-only-admin>` retourne **404** (pas 403 — pas de fuite d'existence).
4. Alice tente de PUT/transition/delete sur `m-only-admin` → 404 idem.
## 6. Vérification transitions
| from | to | result |
|-------------|---------------|--------|
| draft | in_progress | 200 |
| draft | archived | 200 |
| draft | completed | **409 invalid_transition** |
| in_progress | completed | 200 |
| in_progress | archived | 200 |
| completed | archived | 200 |
| completed | in_progress | **409** |
| archived | (anything) | **409** |
| any | (same status) | 200 (no-op) |
```bash
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"status":"completed"}' \
http://localhost:8080/api/v1/missions/<id>/transition
```
## 7. Quick teardown
```bash
make down
# ou pour un reset complet :
curl -X POST http://localhost:8080/api/v1/diag/reset # test-only, wipes everything
```
> Reminder: `make test-api` and `make e2e` **share the dev DB container** —
> running them mid-session WILL wipe user data. The M6 spec's `afterAll`
> restores the stable admin and re-seeds MITRE, but custom templates / missions
> you've created by hand are lost. Cf. `tasks/lessons.md` (M5 lessons section).
+4 -4
View File
@@ -101,7 +101,7 @@ spec: tasks/spec.md
--- ---
## M4 — MITRE ATT&CK Enterprise ☑ ## M4 — MITRE ATT&CK Enterprise ☐
**But** : le référentiel ATT&CK est interrogeable et tagué sur les tests. **But** : le référentiel ATT&CK est interrogeable et tagué sur les tests.
@@ -111,13 +111,13 @@ spec: tasks/spec.md
- ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`). - ☐ Endpoint `POST /mitre/sync` (perm `mitre.sync`) qui re-pull depuis l'URL configurée (setting `mitre_source_url`).
- ☐ Persister `mitre_last_sync` dans `settings`. - ☐ Persister `mitre_last_sync` dans `settings`.
- ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`). - ☐ Endpoint `GET /mitre/tactics`, `/mitre/techniques?tactic=…`, `/mitre/subtechniques?technique=…` (pagination + recherche full-text simple sur `name`).
- ☐ Front : composant `<MitreTagPicker>` — matrice flat type `attack.mitre.org/#` (colonnes = tactics, cellules = techniques, chevron `+N` qui déplie les sub-techniques inline). Click = (dé)sélection, multi-niveaux cumulatif, chips en haut, recherche par `external_id` ou `name`. Alimenté par `GET /mitre/matrix` (one-shot, ~55 KB). - ☐ Front : composant `<MitreTagPicker>` (autocomplete combiné Tactic > Technique > Sub-technique, multi-select).
**DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant. **DoD** : après `make seed-mitre`, `GET /mitre/tactics` retourne 14 tactics Enterprise ; le picker permet de tagger un test avec « TA0002 / T1059.001 » et l'enregistrement est persistant.
--- ---
## M5 — Templates : tests unitaires & scénarios ☑ ## M5 — Templates : tests unitaires & scénarios ☐
**But** : admin peut bâtir le catalogue réutilisable. **But** : admin peut bâtir le catalogue réutilisable.
@@ -133,7 +133,7 @@ spec: tasks/spec.md
--- ---
## M6 — Missions & snapshot ☑ ## M6 — Missions & snapshot ☐
**But** : transformer les templates en missions vivantes. **But** : transformer les templates en missions vivantes.