Compare commits
12 Commits
main
...
feature/m7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d52e3b50 | ||
|
|
28b8855e88 | ||
|
|
40114d041b | ||
|
|
9fc78e0832 | ||
|
|
447f15213a | ||
|
|
d679ff34d8 | ||
|
|
a26034e1ca | ||
|
|
db9313a1e1 | ||
|
|
5030f4bd83 | ||
|
|
cfcc06cf14 | ||
|
|
5974a181fd | ||
|
|
ed70458d8f |
141
CHANGELOG.md
141
CHANGELOG.md
@@ -4,6 +4,147 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed (amendement 2026-05-15 bis) — explicit test workflow removed, lifecycle now driven by writes
|
||||
|
||||
User feedback: «Enlève également le workflow d'un test, quand on saisit
|
||||
des informations côtés redteam cela signifie qu'il a été exécuté et donc
|
||||
en attente d'une review blueteam.»
|
||||
|
||||
- **Backend `update_mission_test_fields`**: at the end of every PUT, the
|
||||
service inspects the touched field set. Any red-side write on a non-
|
||||
executed test (`pending` / `skipped` / `blocked`) promotes the state to
|
||||
`executed`; if no `executed_at` was supplied, it auto-stamps `now()`.
|
||||
Any blue-side write on an `executed` test promotes to `reviewed_by_blue`.
|
||||
The `/transition` endpoint stays operational for back-fill / admin use
|
||||
but is no longer the primary path.
|
||||
- **`MISSION_TEST_STATE_LABEL`** rephrased to describe the implicit
|
||||
lifecycle instead of the workflow: `Pending → Not started`, `Executed
|
||||
→ Awaiting review`, `Reviewed_by_blue → Reviewed`, `Skipped`, `Blocked`.
|
||||
- **`MissionTestPage.tsx`**: transition buttons in the header are gone.
|
||||
The state pill remains as a passive indicator. The `transition`
|
||||
mutation and its imports are dropped; `useMutation` is still used for
|
||||
the red / blue field saves.
|
||||
- **E2E**: the SPA test that previously clicked `transition-executed`
|
||||
now exercises the implicit promotion — it just types in the red fields
|
||||
and asserts that the state-pill flips from `Not started` to `Awaiting
|
||||
review` on save.
|
||||
- **Tests**: 3 new pytest cases — `test_red_writing_any_red_field_implicitly_executes_and_stamps`
|
||||
(red_command alone bumps state + auto-stamps executed_at),
|
||||
`test_blue_writing_any_blue_field_promotes_executed_to_reviewed`,
|
||||
`test_blue_write_on_pending_does_not_auto_execute` (blue-on-pending is
|
||||
a no-op — only red drives execution per the user's mental model).
|
||||
- Total: **142 pytest** + 49 Playwright green.
|
||||
|
||||
### Fixed (post-amendement 2026-05-15) — stamping executed_at no longer needs a prior state transition
|
||||
|
||||
User feedback: when a red user typed `executed_at` inline on a pending test
|
||||
in the new scenario table, the backend rejected with `HTTP 400 — executed_at
|
||||
can only be set when state is executed/reviewed_by_blue`. The state-gate
|
||||
was a holdover from the original "Mark executed button + override toggle"
|
||||
workflow; it made no sense once the UX let the operator type the time
|
||||
directly.
|
||||
|
||||
- `update_mission_test_fields` (`backend/app/services/mission_tests.py`) no
|
||||
longer rejects writes based on the source state. Stamping a non-null
|
||||
`executed_at` while state ∈ {`pending`, `skipped`, `blocked`} now
|
||||
auto-promotes the state to `executed` in the same write. The promotion
|
||||
rides on the same `mission.write_red_fields` perm that the executed_at
|
||||
field already required — no privilege escalation.
|
||||
- `MissionTestPage.tsx` drops the state-based gate on `canEditExecutedAt`:
|
||||
the field is editable any time the viewer holds `mission.write_red_fields`.
|
||||
- Tests: `test_executed_at_override_requires_red_perm_and_state` was the
|
||||
old guard; it's split into two new cases — `test_red_setting_executed_at_on_pending_auto_transitions_to_executed`
|
||||
(pending → executed via inline stamp, blue still 403'd) and
|
||||
`test_red_setting_executed_at_from_skipped_state_auto_transitions`
|
||||
(skipped → executed via the same path).
|
||||
- Total: **139 pytest** green.
|
||||
|
||||
### Added — M7 amendment (2026-05-15) — blue review fields + full-width scenario table
|
||||
|
||||
User feedback after the M7 ship: the blue team used to maintain 5 extra
|
||||
fields in Excel that we didn't capture, and the per-test page didn't fit
|
||||
their workflow — they wanted a tabular view (one table per scenario, one
|
||||
row per test) with double-click inline edit.
|
||||
|
||||
#### Reviewer follow-ups (applied)
|
||||
- **`blue_incident_at` rejects naïve datetimes** (`backend/app/api/missions.py:_ensure_aware_datetime`): a request with `"2026-05-15T11:00:00"` (no offset) now returns 400 instead of silently letting Postgres interpret it in the session timezone — same rule applied to `executed_at` for consistency. Clients must send `Z` or an explicit `+HH:MM`.
|
||||
- **`blue_incident_recipient_email` is shape-validated** (`backend/app/api/missions.py:_validate_email_shape`): permissive RFC regex (`/^[^@\s]+@[^@\s]+\.[^@\s]+$/`) that allows `.local` / `.corp` / `.test` internal domains. We deliberately don't use Pydantic `EmailStr` — `email-validator`'s `globally_deliverable=True` rejects those (lessons.md M2 captured the same trap for the user signup).
|
||||
- **`MissionTestView` payload expansion documented** as a deliberate F6 enabler — surfacing every annotation in the nested GET means the scenario table renders in a single round trip. Without this, the table would have to call `GET /missions/{id}/tests/{test_id}` once per row.
|
||||
|
||||
#### Backend (shipped)
|
||||
- **Migration `c2a8f4b1d6e9`** adds five nullable columns to `mission_tests`:
|
||||
- `blue_log_source` (varchar 120) — short text like `Firewall`, `NDR`, `Proxy`, `AV`, `EDR`.
|
||||
- `blue_siem_logs` (text) — long-form SIEM excerpt (raw log lines).
|
||||
- `blue_incident_at` (timestamptz) — cyber-incident notification timestamp.
|
||||
- `blue_incident_number` (varchar 120) — incident reference (`INC-2026-1234`).
|
||||
- `blue_incident_recipient_email` (varchar 255) — SOC recipient of the alert.
|
||||
- **All five fields are blue-side** — added to `_BLUE_FIELDS` in `app/services/mission_tests.py` so the existing per-field perm classifier rejects red-only writers with 403, no field-by-field special case.
|
||||
- **`update_mission_test_fields`** accepts each new field via the same `_UNSET` sentinel pattern; `blue_siem_logs` uses the command-style normaliser (`_opt_cmd`) to preserve leading whitespace in log table excerpts; the other text fields use `_opt_md`.
|
||||
- **`MissionTestView`** (the nested view returned by `GET /missions/{id}`) now exposes every annotation field plus `last_actor_*` + `updated_at` + `detection_level_key`. The two FK lookups (detection-level keys, last-actor user labels) are batch-loaded once per request so the call stays O(1) regardless of how many tests the mission contains. Lets the front-end scenario table render in a single GET — no per-row round-trip.
|
||||
- **API**: `UpdateMissionTestPayload` and `_serialize_test` / `_serialize_test_detail` updated. Length caps per spec (120 / 200_000 / 120 / 255).
|
||||
- **Tests**: 3 new pytest cases — `test_blue_user_writes_new_blue_review_fields`, `test_red_user_cannot_write_new_blue_review_fields` (loops each of the 5 fields), `test_blue_review_fields_survive_round_trip_via_get`. Total: **136 pytest** green.
|
||||
|
||||
#### Spec & docs
|
||||
- **`tasks/spec.md` amended** — §4 in-scope bullet on blue saisie now lists the 5 fields, §F6 describes the tabular UX (full-bleed, one table per scenario, double-click inline edit), §8 model bullet enumerates the new columns. Header carries a `revised: 2026-05-15` note pointing readers at the amendment.
|
||||
- **`tasks/todo.md` M7 section** carries a dedicated "Amendement 2026-05-15" sub-block tracking the backend (☑) and frontend (☐) items.
|
||||
|
||||
#### Frontend (shipped)
|
||||
- **`MissionScenarioTable` component** (`frontend/src/pages/MissionScenarioTable.tsx`): per-scenario `<table>` with 7 columns (`Test | Procédure | Exécution | Source de log | Commentaires | Logs SIEM | Cyber Incident`) plus an `Actions` cell that links to the full per-test page. Read mode shows truncated values; double-click toggles a row into edit mode where each cell becomes the right input (text, textarea, datetime-local, select). The `detection_level` lives **inside the Commentaires cell** as a pill + select — no 8th column.
|
||||
- **Single-row-edit invariant**: `editingTestId` state lives in `MissionDetailPage`'s tests tab so only one row across the whole mission is editable at a time. Double-clicking another row while dirty surfaces a `Discard unsaved changes?` prompt; Esc reverts; Save commits the diff.
|
||||
- **Diff-only PUT**: `draftDiff(test, draft)` walks every field and only includes the ones that changed; submitting an unchanged form is a no-op `onEditRequest(null)`. Keeps the per-field perm gate on the server cleanly applicable.
|
||||
- **Full-bleed layout**: the tests tab escapes the layout's `max-w-page` via the canonical `calc(50% - 50vw)` recipe (same as the M4 MITRE picker) so the 7-column table breathes on wide screens without horizontal scroll.
|
||||
- **Per-test page kept** at `/missions/<id>/tests/<test_id>` for evidence upload and the full procedure view — every row's "open ↗" link routes there.
|
||||
- **Datetime semantics consistent**: the table's two datetime-local inputs (executed_at + blue_incident_at) reuse the M7 verbatim recipe (`iso.slice(0, 16)` + `${local}:00Z`), no TZ shift on read or write.
|
||||
|
||||
#### Tests
|
||||
- E2E: existing m6 + m7 specs unaffected (all 49 still green). The new table reuses the `mission-add-scenarios` testid for the modal trigger so the wizard test still works. The old `mission-test-${id}` rows are gone but were never wired into any e2e selector.
|
||||
|
||||
### Fixed (post-M7 UX feedback — evidence whitelist visibility)
|
||||
- **Evidence dropzone didn't tell the operator which extensions are accepted, and the OS file picker showed "All files"** (`frontend/src/pages/MissionTestPage.tsx`): an operator could spend the time picking a `.exe` only to receive a 400 back. Surfaced the whitelist in the UI:
|
||||
- Dropzone now prints `Accepted: .png · .jpg · .jpeg · .pdf · .txt · .log · .json · .csv · .evtx · .zip · max 25 MB / file` (testid `evidence-allowed-formats`).
|
||||
- `<input type="file" accept=".png,.jpg,…">` pre-filters the OS picker to those extensions.
|
||||
- `handleFiles` rejects drag-and-drops of unsupported extensions client-side (still re-checked server-side — defence in depth, not a security boundary).
|
||||
- Constants `EVIDENCE_ALLOWED_EXTENSIONS` + `EVIDENCE_MAX_BYTES` in `frontend/src/lib/missions.ts` keep a single source of truth client-side. Manual mirror of `app/services/evidence.py:ALLOWED_EXTS` + `MAX_BYTES`; cross-referenced via comments so the next bump touches both files.
|
||||
|
||||
### Fixed (post-M7 UX feedback — executed_at override editable in any timezone)
|
||||
- **Time portion of the `executed_at` override was un-editable in non-UTC timezones** (`frontend/src/pages/MissionTestPage.tsx:RedZone`): the naive `new Date(executedAt).toISOString().slice(0, 16)` round-trip on every keystroke silently shifted the hour by the local TZ offset, snapping the time field back to UTC each render. The date could be changed (offset shifts both source and target by the same amount), but the hour couldn't stick.
|
||||
- Fix: keep the local state in `YYYY-MM-DDTHH:MM` form (`executedAtLocal`) and only convert to/from UTC ISO at the boundaries — initial sync from server (`isoToLocalInputValue`) and submit (`localInputValueToIso`).
|
||||
- Also tightened the `useEffect` reset on both Red and Blue zones to depend on `test.id` instead of the whole `test` object so a polling refetch (every 15 s) no longer wipes an in-progress edit. The 15 s activity poll returns a fresh object reference even when the row's content is unchanged.
|
||||
|
||||
### Fixed (post-M7 review pass — spec-reviewer + code-reviewer)
|
||||
- **Idempotent transition leaked false success to a wrong-side user** (`backend/app/services/mission_tests.py:570`): a blue-only viewer POSTing `target_state="executed"` while the test was already executed got a 200 idempotent response, falsely advertising that they held `mission.write_red_fields`. Reordered the gate so the side-perm check runs *before* the idempotency short-circuit, with a new `_IDEMPOTENT_SIDE` table that asks "which side originally produced this state?" — re-asserting that perm even on no-op replays. Test `test_idempotent_transition_still_checks_side_perm`.
|
||||
- **Cross-mission evidence access not pinned by a test** (`backend/tests/test_mission_tests.py:test_evidence_member_of_other_mission_gets_404`): added explicit coverage that a user who is a blue member of mission B sees 404 on an evidence row attached to mission A. The chain walk in `_resolve_evidence_chain` already enforced this, but the regression test was missing.
|
||||
- **`shutil.move` swapped for `os.replace`** (`backend/app/services/evidence.py:240`): `os.replace` is the documented atomic-rename primitive on POSIX and Windows when src/dst share a volume — and our tmpfile is always staged inside the destination directory, so the guarantee holds. Removes the implicit copy+remove fallback from `shutil.move` that would silently break atomicity on a cross-fs `EVIDENCE_DIR`.
|
||||
- **SHA256 path component now hex-validated** (`backend/app/services/evidence.py:227`): the hash always comes from hashlib so it's already hex, but if a future caller ever passes pre-computed bytes we want to fail loudly rather than write to a path like `..something.evtx`. Cheap `re.fullmatch(r"[0-9a-f]{64}", sha256)` guard.
|
||||
- **`EVIDENCE_DIR` filesystem-root guard** (`backend/app/services/evidence.py:_test_dir`): refuse to create per-mission directories when `EVIDENCE_DIR` resolves to `/` (or the equivalent on Windows). Stops a mis-configured operator from laying down content-addressed evidence files at the filesystem root.
|
||||
- **`/diag/reset` evidence cleanup now skips symlinks** (`backend/app/api/diag.py:127`): switched from `is_dir()` to `is_symlink() or not is_dir()` so a hostile or accidental symlink inside `EVIDENCE_DIR` is unlinked rather than `rmtree`'d through.
|
||||
- **N+1 in `_to_detail_view`** (`backend/app/services/mission_tests.py:_to_detail_view`): the last-actor and detection-level lookups each issued their own `s.get()`. Replaced with `select(columns)` queries that return just the needed scalar fields — same SQL count but fewer ORM round-trips, and every PUT/transition exercises this path so it adds up.
|
||||
- **Mission detail row `onClick` removed in favour of the wrapped `Link`** (`frontend/src/pages/MissionDetailPage.tsx:684`): the `tr onClick` + nested `Link` with `stopPropagation` worked but was fragile to accessibility tooling. The link on the test name + the explicit hover class is enough.
|
||||
|
||||
### Added — M7 (Red & blue execution on a mission test)
|
||||
- **Per-mission-test write API** (`app/api/missions.py` + `app/services/mission_tests.py`):
|
||||
- `GET /missions/{id}/tests/{test_id}` — full detail view with snapshot, state, red/blue fields, MITRE tags, evidence list, last-actor metadata.
|
||||
- `PUT /missions/{id}/tests/{test_id}` — patch any subset of `red_command` / `red_output` / `red_comment_md` / `blue_comment_md` / `detection_level_id` / `executed_at` / `executed_at_overridden`. The service classifies each touched field as red-side or blue-side and rejects with 403 if the caller lacks the matching perm. `executed_at*` only writable when the test sits in `executed` or `reviewed_by_blue`.
|
||||
- `POST /missions/{id}/tests/{test_id}/transition` — drives the state machine `pending↔skipped/blocked` + `pending→executed→reviewed_by_blue` (allows undo back to `pending`). Side-aware perm gating: `pending→executed` and `executed→pending` require `write_red_fields`; `executed↔reviewed_by_blue` requires `write_blue_fields`; `pending↔skipped/blocked` accepts either side. Transitioning into `executed` stamps `executed_at=now()` and clears the override; transitioning out (to `pending`) wipes the timestamp.
|
||||
- `GET /missions/{id}/activity?since=<ISO>` — returns mission_tests whose `updated_at > since`, freshest first. Drives the SPA's 15-second polling badge. Response includes `server_time` so the client can chain calls without clock drift.
|
||||
- **Evidence storage pipeline** (`app/services/evidence.py` + `app/api/evidence.py`):
|
||||
- `POST /missions/{id}/tests/{test_id}/evidence` (multipart, gated on `mission.write_blue_fields`): streams the upload into a tmpfile next to the final location, hashing chunk-by-chunk and aborting at the 25 MB cap. Validates extension (whitelist: png/jpg/jpeg/pdf/txt/log/json/csv/evtx/zip) and MIME (permissive allowlist + `application/octet-stream` fallback for `.evtx`). Content-addressed storage: `${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>` — re-uploading byte-identical content reuses the file on disk and inserts a fresh row.
|
||||
- `GET /evidence/{id}` — JSON metadata view; `?download=true` switches to `send_file` with the original filename in `Content-Disposition` and the SHA256 as the ETag.
|
||||
- `DELETE /evidence/{id}` — soft delete (only flips `deleted_at`; physical purge lands in M12).
|
||||
- All three routes are membership-aware via the same chain walk (`evidence → test → scenario → mission`), collapsing "not found" / "not visible" into 404 to prevent existence leaks.
|
||||
- **Activity tracking column** (`backend/alembic/versions/20260514_1000_91a4e7c6d2f3_m7_mission_test_last_actor.py`): added `mission_tests.last_actor_id` (FK `users.id` `ON DELETE SET NULL`) + `ix_mission_tests_updated_at` to support the polling endpoint. Every red/blue write or transition stamps the actor so the "modified by X Ns ago" indicator can resolve a human label.
|
||||
- **Detection-level seed + read** (`app/services/detection_levels.py` + `app/api/detection_levels.py`):
|
||||
- 4 default rows seeded at boot — `detected_blocked` / `detected_alert` / `logged_only` / `not_detected` — colored on the design-system accent palette. The seed is idempotent and never mutates existing rows; new keys added to `DEFAULT_LEVELS` in future releases surface on next boot.
|
||||
- `GET /detection-levels` (gated on `detection_level.read`) returns the catalogue ordered by position. CRUD is M8's territory.
|
||||
- **Per-test page** (`frontend/src/pages/MissionTestPage.tsx`): two-zone layout with the red border on the red half (command, output, markdown comment, mark-executed button, override toggle) and the cyan border on the blue half (detection-level select, comment, drag-and-drop evidence dropzone). Per-field disable based on `mission.write_red_fields` / `mission.write_blue_fields`; server is the ultimate arbiter so the UI is purely advisory. The "Last touched Xs ago by Y" badge polls `/activity` every 15 s while the document is visible.
|
||||
- **Mission detail page wires through to the per-test page** (`frontend/src/pages/MissionDetailPage.tsx`): every row in the Tests tab is now clickable (cursor + hover state) and links to `/missions/<id>/tests/<test_id>`. The route is registered in `App.tsx` behind `RequireAuth`.
|
||||
- **TanStack query keys** (`frontend/src/lib/missions.ts`): added `missionTestKeys.detail()` / `.activity()` / `.detectionLevels()` so the per-test page invalidations stay surgical (don't blow away the whole missions list).
|
||||
- **`/diag/reset` extended** (`app/api/diag.py`): test mode now wipes `${EVIDENCE_DIR}/*` so e2e uploads don't accumulate across runs. Detection levels are preserved (reference data, not catalogue) and the seed is re-run as a safety net.
|
||||
- **Tests**:
|
||||
- **`backend/tests/test_mission_tests.py`** — 25 pytest tests covering: detection-level seed + perm gating; red/blue field-level perms (red user blocked on blue fields and vice-versa); mark-executed stamps `executed_at`; override gating (forbidden while pending, blue-side blocked); state-machine matrix + side perm refinement; membership 404 vs admin bypass; evidence 24 MB ok / 26 MB rejected; SHA256 verification; MIME/extension whitelist; soft-delete hides bytes from detail view; activity polling with `since=` URL-encoded; future `since` returns empty.
|
||||
- **`e2e/tests/m7-execution.spec.ts`** — 5 Playwright tests against the live stack: red-only/blue-only API gating, mark-executed + reviewed_by_blue side enforcement, 24 MB/26 MB upload + SHA256 round-trip, SPA per-test page save + transition, non-member sees the 404 alert instead of mission content. `afterAll` restores the stable admin and re-syncs MITRE.
|
||||
- **HomePage**: hero + roadmap card bumped to `M7 — Red & blue execution on a mission test (done). Next: M8`.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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–M7 delivered (bootstrap → DB schema → auth → RBAC → MITRE ATT&CK reference → test & scenario templates → missions snapshot → red/blue execution on a mission test). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -13,6 +13,7 @@ Collaborative purple-team platform. Red team logs the tests they execute (proced
|
||||
- **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.
|
||||
- **Execution (M7+)**: per-test page `/missions/<id>/tests/<test_id>` with two zones — red (command/output/comment + mark-executed with override) and blue (detection-level select / comment / drag-and-drop evidence upload). Field-level perm gating: `mission.write_red_fields` / `mission.write_blue_fields` are server-enforced *per field*. State machine `pending↔skipped/blocked` + `pending→executed→reviewed_by_blue` with side-aware perms. Evidence pipeline: streaming upload to `${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>`, SHA256 + MIME + extension + 25 MB cap. 15 s activity polling via `/missions/<id>/activity?since=…` drives the "modified by X" badge. 4 default `detection_levels` seeded at boot.
|
||||
- **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production.
|
||||
|
||||
## Quickstart
|
||||
@@ -95,7 +96,7 @@ See `.env.example`. The most important ones:
|
||||
|
||||
## 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-m7.md) (current: `testing-m7.md`).
|
||||
- **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`.
|
||||
|
||||
@@ -137,7 +138,7 @@ The hooks run `ruff` + `ruff-format` on the backend and `eslint` / `tsc --noEmit
|
||||
|
||||
## 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: **M7 — Red & blue execution on a mission test** (done). Next: M8 (custom detection-level CRUD).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""m7 mission test last actor tracking
|
||||
|
||||
Revision ID: 91a4e7c6d2f3
|
||||
Revises: 24765a5014b6
|
||||
Create Date: 2026-05-14 10:00:00.000000
|
||||
|
||||
Adds the `last_actor_id` column to `mission_tests` so the activity polling
|
||||
endpoint can answer "modified by X" without joining the audit log (M14 owns
|
||||
the full audit story). FK to `users.id` with `ON DELETE SET NULL` so deleting
|
||||
a user does not wipe the history of their writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "91a4e7c6d2f3"
|
||||
down_revision: str | None = "24765a5014b6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"mission_tests",
|
||||
sa.Column("last_actor_id", sa.Uuid(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
op.f("fk_mission_tests_last_actor_id_users"),
|
||||
"mission_tests",
|
||||
"users",
|
||||
["last_actor_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_mission_tests_updated_at",
|
||||
"mission_tests",
|
||||
["updated_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_mission_tests_updated_at", table_name="mission_tests")
|
||||
op.drop_constraint(
|
||||
op.f("fk_mission_tests_last_actor_id_users"),
|
||||
"mission_tests",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("mission_tests", "last_actor_id")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""m7 blue review fields on mission_tests
|
||||
|
||||
Revision ID: c2a8f4b1d6e9
|
||||
Revises: 91a4e7c6d2f3
|
||||
Create Date: 2026-05-15 09:00:00.000000
|
||||
|
||||
User feedback after the M7 ship: the blue team's review workflow needs five
|
||||
more fields they used to maintain in Excel — log source, raw SIEM excerpt,
|
||||
plus a small cyber-incident sub-record (timestamp, number, recipient email).
|
||||
All five are blue-side and gated by `mission.write_blue_fields`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c2a8f4b1d6e9"
|
||||
down_revision: str | None = "91a4e7c6d2f3"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"mission_tests",
|
||||
sa.Column("blue_log_source", sa.String(length=120), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"mission_tests",
|
||||
sa.Column("blue_siem_logs", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"mission_tests",
|
||||
sa.Column(
|
||||
"blue_incident_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"mission_tests",
|
||||
sa.Column("blue_incident_number", sa.String(length=120), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"mission_tests",
|
||||
sa.Column(
|
||||
"blue_incident_recipient_email", sa.String(length=255), nullable=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("mission_tests", "blue_incident_recipient_email")
|
||||
op.drop_column("mission_tests", "blue_incident_number")
|
||||
op.drop_column("mission_tests", "blue_incident_at")
|
||||
op.drop_column("mission_tests", "blue_siem_logs")
|
||||
op.drop_column("mission_tests", "blue_log_source")
|
||||
@@ -77,7 +77,7 @@ def login():
|
||||
try:
|
||||
payload = LoginPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
pair = auth_svc.login(payload.email, payload.password)
|
||||
except auth_svc.InvalidCredentials:
|
||||
@@ -144,7 +144,7 @@ def change_password():
|
||||
try:
|
||||
payload = ChangePasswordPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
auth_svc.change_password(g.current_user.id, payload.current_password, payload.new_password)
|
||||
except auth_svc.InvalidCredentials:
|
||||
|
||||
37
backend/app/api/detection_levels.py
Normal file
37
backend/app/api/detection_levels.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Detection-level taxonomy API.
|
||||
|
||||
Read-only in M7 — M8 will add CRUD. The four defaults are seeded at boot
|
||||
via `app.services.detection_levels.seed_detection_levels()`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from app.core.auth_decorators import require_auth, require_perm
|
||||
from app.services import detection_levels as svc
|
||||
|
||||
bp = Blueprint("detection_levels", __name__, url_prefix="/detection-levels")
|
||||
|
||||
|
||||
def _serialize(view: svc.DetectionLevelView) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(view.id),
|
||||
"key": view.key,
|
||||
"label_fr": view.label_fr,
|
||||
"label_en": view.label_en,
|
||||
"color_token": view.color_token,
|
||||
"position": view.position,
|
||||
"is_default": view.is_default,
|
||||
"is_system": view.is_system,
|
||||
}
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
@require_perm("detection_level.read")
|
||||
def list_detection_levels():
|
||||
items = svc.list_detection_levels()
|
||||
return jsonify({"items": [_serialize(it) for it in items]})
|
||||
@@ -8,6 +8,8 @@ is the bedrock of the e2e suite (clean DB + freshly minted install token).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, abort, jsonify
|
||||
from sqlalchemy import text
|
||||
@@ -16,6 +18,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from app.core.config import settings
|
||||
from app.core.install_token import regenerate_install_token
|
||||
from app.db.session import get_engine
|
||||
from app.services.detection_levels import seed_detection_levels
|
||||
|
||||
bp = Blueprint("diag", __name__, url_prefix="/diag")
|
||||
log = logging.getLogger("metamorph.diag")
|
||||
@@ -108,10 +111,39 @@ def reset_test_state():
|
||||
"mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
# Detection levels (M7) are reference data seeded at boot — they
|
||||
# are explicitly preserved here, but the seed is re-run below to
|
||||
# cover the edge case where an operator hand-tweaked the rows
|
||||
# before invoking the reset. The seed is idempotent.
|
||||
except SQLAlchemyError as e:
|
||||
log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
|
||||
return jsonify({"reset": False, "error": "database_error"}), 500
|
||||
|
||||
# M7: wipe the evidence directory so an e2e suite that uploads bytes does
|
||||
# not accumulate files across runs. Only in `test`; in `dev` we keep the
|
||||
# files (operator likely wants to inspect what they uploaded by hand).
|
||||
if settings.APP_ENV == "test":
|
||||
evidence_root = Path(settings.EVIDENCE_DIR)
|
||||
if evidence_root.exists():
|
||||
for child in evidence_root.iterdir():
|
||||
# Symlinks are unlinked, never followed — a hostile or
|
||||
# accidental symlink inside the evidence dir must NOT cause
|
||||
# rmtree to recurse into an unrelated tree.
|
||||
try:
|
||||
if child.is_symlink() or not child.is_dir():
|
||||
child.unlink(missing_ok=True)
|
||||
else:
|
||||
shutil.rmtree(child)
|
||||
except OSError as e:
|
||||
log.warning(
|
||||
"metamorph.diag.evidence_cleanup_failed",
|
||||
extra={"path": str(child), "error": str(e)},
|
||||
)
|
||||
|
||||
# Detection levels were preserved during the wipe; re-run the seed to
|
||||
# cover the off-chance an operator has deleted some rows manually.
|
||||
seed_detection_levels()
|
||||
|
||||
token = regenerate_install_token()
|
||||
|
||||
# Clear the in-memory rate-limit counters so the e2e suite that follows can
|
||||
|
||||
123
backend/app/api/evidence.py
Normal file
123
backend/app/api/evidence.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Top-level evidence routes (download + soft-delete by id).
|
||||
|
||||
Upload is collocated under `/missions/{id}/tests/{test_id}/evidence` because
|
||||
that path encodes the parent context. Once an evidence row exists, callers
|
||||
can address it by id directly — these routes own that side.
|
||||
|
||||
Membership/visibility is enforced through the service (`EvidenceNotFound` is
|
||||
returned for both "missing" and "not visible" outcomes — no existence leak).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, g, jsonify, request, send_file
|
||||
|
||||
from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm
|
||||
from app.services import evidence as svc
|
||||
|
||||
bp = Blueprint("evidence", __name__, url_prefix="/evidence")
|
||||
log = logging.getLogger("metamorph.api.evidence")
|
||||
|
||||
|
||||
def _serialize(ev: svc.EvidenceView) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(ev.id),
|
||||
"mission_test_id": str(ev.mission_test_id),
|
||||
"sha256": ev.sha256,
|
||||
"mime": ev.mime,
|
||||
"size_bytes": ev.size_bytes,
|
||||
"original_filename": ev.original_filename,
|
||||
"uploaded_by_user_id": (
|
||||
str(ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None
|
||||
),
|
||||
"uploaded_by_email": ev.uploaded_by_email,
|
||||
"uploaded_by_display_name": ev.uploaded_by_display_name,
|
||||
"uploaded_at": ev.uploaded_at.isoformat(),
|
||||
"created_at": ev.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
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 _parse_uuid_or_400(raw: str) -> uuid.UUID | None:
|
||||
try:
|
||||
return uuid.UUID(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@bp.get("/<evidence_id>")
|
||||
@require_auth
|
||||
@require_perm("mission.read")
|
||||
def get_evidence(evidence_id: str):
|
||||
"""Metadata read. Use `?download=true` to receive the bytes inline.
|
||||
|
||||
The download mode streams the on-disk file via `send_file` with the
|
||||
original filename in `Content-Disposition`. Browsers handle the
|
||||
Content-Type guess from the stored mime.
|
||||
"""
|
||||
eid = _parse_uuid_or_400(evidence_id)
|
||||
if eid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
user = _current_user()
|
||||
want_download = request.args.get("download", "false").lower() == "true"
|
||||
|
||||
if want_download:
|
||||
try:
|
||||
view, path = svc.get_evidence_for_download(
|
||||
eid, viewer_id=user.id, viewer_is_admin=user.is_admin
|
||||
)
|
||||
except svc.EvidenceNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
log.info(
|
||||
"metamorph.evidence.download",
|
||||
extra={
|
||||
"evidence_id": str(eid),
|
||||
"user_id": str(user.id),
|
||||
"size_bytes": view.size_bytes,
|
||||
},
|
||||
)
|
||||
return send_file(
|
||||
str(path),
|
||||
mimetype=view.mime,
|
||||
as_attachment=True,
|
||||
download_name=view.original_filename,
|
||||
etag=view.sha256,
|
||||
conditional=True,
|
||||
max_age=0,
|
||||
)
|
||||
|
||||
try:
|
||||
view = svc.get_evidence(
|
||||
eid, viewer_id=user.id, viewer_is_admin=user.is_admin
|
||||
)
|
||||
except svc.EvidenceNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(_serialize(view))
|
||||
|
||||
|
||||
@bp.delete("/<evidence_id>")
|
||||
@require_auth
|
||||
@require_perm("mission.write_blue_fields")
|
||||
def soft_delete_evidence(evidence_id: str):
|
||||
eid = _parse_uuid_or_400(evidence_id)
|
||||
if eid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
svc.soft_delete_evidence(
|
||||
eid, viewer_id=user.id, viewer_is_admin=user.is_admin
|
||||
)
|
||||
except svc.EvidenceNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify({"ok": True})
|
||||
@@ -84,7 +84,7 @@ def create_group():
|
||||
try:
|
||||
payload = CreateGroupPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
g = groups_svc.create_group(name=payload.name, description=payload.description)
|
||||
except groups_svc.GroupNameConflict as e:
|
||||
@@ -106,7 +106,7 @@ def update_group(group_id: str):
|
||||
try:
|
||||
payload = UpdateGroupPayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
description_unset = "description" not in raw
|
||||
try:
|
||||
g = groups_svc.update_group(
|
||||
@@ -153,7 +153,7 @@ def set_permissions(group_id: str):
|
||||
try:
|
||||
payload = SetPermissionsPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
g = groups_svc.set_group_permissions(gid, payload.codes)
|
||||
except groups_svc.GroupNotFound:
|
||||
|
||||
@@ -36,7 +36,7 @@ def create():
|
||||
try:
|
||||
payload = CreateInvitationPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -124,7 +124,7 @@ def accept(token: str):
|
||||
try:
|
||||
payload = AcceptInvitationPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
user_id = inv_svc.accept(
|
||||
token,
|
||||
|
||||
@@ -9,25 +9,65 @@ 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`).
|
||||
|
||||
M7 extends this blueprint with per-test routes under `/missions/<id>/tests/...`
|
||||
plus an activity polling endpoint. The split is purely organisational — the
|
||||
membership and visibility rules stay identical to M6.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from flask import Blueprint, abort, g, jsonify, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from pydantic import AfterValidator, BaseModel, Field, ValidationError
|
||||
|
||||
from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm
|
||||
from app.services import evidence as evidence_svc
|
||||
from app.services import mission_tests as test_svc
|
||||
from app.services import missions as svc
|
||||
|
||||
bp = Blueprint("missions", __name__, url_prefix="/missions")
|
||||
log = logging.getLogger("metamorph.api.missions")
|
||||
|
||||
|
||||
# RFC-shaped email regex — permissive on TLDs so internal/lab domains
|
||||
# (`.local`, `.corp`, `.test`) pass. We deliberately don't use Pydantic
|
||||
# `EmailStr`: `email-validator` runs `globally_deliverable=True` by
|
||||
# default, which rejects everything that's not on the public TLD list
|
||||
# (lessons.md M2 + the same trap the recipient-email field would hit).
|
||||
_EMAIL_SHAPE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def _validate_email_shape(value: str | None) -> str | None:
|
||||
if value is None or value == "":
|
||||
return value
|
||||
v = value.strip()
|
||||
if not _EMAIL_SHAPE.match(v):
|
||||
raise ValueError("invalid email shape")
|
||||
return v
|
||||
|
||||
|
||||
def _ensure_aware_datetime(value: datetime | None) -> datetime | None:
|
||||
"""Reject naïve datetimes — the column is `timestamptz` and Postgres
|
||||
would interpret a naïve value in the session timezone, which the M7
|
||||
fixes deliberately moved away from. Clients must send an explicit
|
||||
offset (or `Z` suffix). The same rule applies to `executed_at`."""
|
||||
if value is None:
|
||||
return value
|
||||
if value.tzinfo is None:
|
||||
raise ValueError("datetime must include a timezone offset (e.g. trailing Z)")
|
||||
return value
|
||||
|
||||
|
||||
_AwareDatetime = Annotated[datetime, AfterValidator(_ensure_aware_datetime)]
|
||||
_EmailShape = Annotated[str, AfterValidator(_validate_email_shape)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Payloads
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -124,6 +164,28 @@ def _serialize_test(t: svc.MissionTestView) -> dict[str, Any]:
|
||||
"source_test_template_id": (
|
||||
str(t.source_test_template_id) if t.source_test_template_id else None
|
||||
),
|
||||
# Annotation fields (post-M7 feedback): included in the nested mission
|
||||
# detail so the front-end scenario table renders without a per-test
|
||||
# round trip.
|
||||
"red_command": t.red_command,
|
||||
"red_output": t.red_output,
|
||||
"red_comment_md": t.red_comment_md,
|
||||
"blue_comment_md": t.blue_comment_md,
|
||||
"detection_level_id": (
|
||||
str(t.detection_level_id) if t.detection_level_id else None
|
||||
),
|
||||
"detection_level_key": t.detection_level_key,
|
||||
"blue_log_source": t.blue_log_source,
|
||||
"blue_siem_logs": t.blue_siem_logs,
|
||||
"blue_incident_at": (
|
||||
t.blue_incident_at.isoformat() if t.blue_incident_at else None
|
||||
),
|
||||
"blue_incident_number": t.blue_incident_number,
|
||||
"blue_incident_recipient_email": t.blue_incident_recipient_email,
|
||||
"last_actor_id": str(t.last_actor_id) if t.last_actor_id else None,
|
||||
"last_actor_email": t.last_actor_email,
|
||||
"last_actor_display_name": t.last_actor_display_name,
|
||||
"updated_at": t.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -294,7 +356,7 @@ 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
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
view = svc.create_mission(
|
||||
@@ -339,7 +401,7 @@ def update_mission(mission_id: str):
|
||||
try:
|
||||
payload = UpdateMissionPayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 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:
|
||||
@@ -377,7 +439,7 @@ def add_scenarios(mission_id: str):
|
||||
try:
|
||||
payload = AddScenariosPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
view = svc.add_scenarios_to_mission(
|
||||
@@ -410,7 +472,7 @@ def set_members(mission_id: str):
|
||||
try:
|
||||
payload = SetMembersPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
view = svc.set_mission_members(
|
||||
@@ -445,7 +507,7 @@ def transition(mission_id: str):
|
||||
try:
|
||||
payload = TransitionPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 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:
|
||||
@@ -496,3 +558,355 @@ def soft_delete_mission(mission_id: str):
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
log.info("metamorph.mission.soft_deleted", extra={"mission_id": str(mid)})
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# M7 — per-test routes
|
||||
# =========================================================================== #
|
||||
|
||||
|
||||
class UpdateMissionTestPayload(BaseModel):
|
||||
red_command: str | None = Field(default=None, max_length=20_000)
|
||||
red_output: str | None = Field(default=None, max_length=200_000)
|
||||
red_comment_md: str | None = Field(default=None, max_length=20_000)
|
||||
blue_comment_md: str | None = Field(default=None, max_length=20_000)
|
||||
detection_level_id: uuid.UUID | None = None
|
||||
# Both timestamps must be aware (Z or explicit offset). See
|
||||
# `_ensure_aware_datetime` for why we reject naïve.
|
||||
executed_at: _AwareDatetime | None = None
|
||||
executed_at_overridden: bool | None = None
|
||||
# Post-M7 blue review fields (cf. user feedback 2026-05-15). Free-form
|
||||
# short text for log_source / incident_number; long text for siem_logs;
|
||||
# email goes through `_validate_email_shape` (permissive RFC regex —
|
||||
# we serve internal/lab domains so strict TLD lists are too tight,
|
||||
# cf. tasks/lessons.md M2).
|
||||
blue_log_source: str | None = Field(default=None, max_length=120)
|
||||
blue_siem_logs: str | None = Field(default=None, max_length=200_000)
|
||||
blue_incident_at: _AwareDatetime | None = None
|
||||
blue_incident_number: str | None = Field(default=None, max_length=120)
|
||||
blue_incident_recipient_email: _EmailShape | None = Field(default=None, max_length=255)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class TestTransitionPayload(BaseModel):
|
||||
target_state: str = Field(min_length=1, max_length=24)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
def _serialize_evidence(ev: test_svc.EvidenceView) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(ev.id),
|
||||
"mission_test_id": str(ev.mission_test_id),
|
||||
"sha256": ev.sha256,
|
||||
"mime": ev.mime,
|
||||
"size_bytes": ev.size_bytes,
|
||||
"original_filename": ev.original_filename,
|
||||
"uploaded_by_user_id": (
|
||||
str(ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None
|
||||
),
|
||||
"uploaded_by_email": ev.uploaded_by_email,
|
||||
"uploaded_by_display_name": ev.uploaded_by_display_name,
|
||||
"uploaded_at": ev.uploaded_at.isoformat(),
|
||||
"created_at": ev.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_test_detail(t: test_svc.MissionTestDetailView) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(t.id),
|
||||
"mission_id": str(t.mission_id),
|
||||
"scenario_id": str(t.scenario_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,
|
||||
"red_command": t.red_command,
|
||||
"red_output": t.red_output,
|
||||
"red_comment_md": t.red_comment_md,
|
||||
"blue_comment_md": t.blue_comment_md,
|
||||
"detection_level_id": (
|
||||
str(t.detection_level_id) if t.detection_level_id else None
|
||||
),
|
||||
"detection_level_key": t.detection_level_key,
|
||||
"blue_log_source": t.blue_log_source,
|
||||
"blue_siem_logs": t.blue_siem_logs,
|
||||
"blue_incident_at": (
|
||||
t.blue_incident_at.isoformat() if t.blue_incident_at else None
|
||||
),
|
||||
"blue_incident_number": t.blue_incident_number,
|
||||
"blue_incident_recipient_email": t.blue_incident_recipient_email,
|
||||
"last_actor_id": str(t.last_actor_id) if t.last_actor_id else None,
|
||||
"last_actor_email": t.last_actor_email,
|
||||
"last_actor_display_name": t.last_actor_display_name,
|
||||
"updated_at": t.updated_at.isoformat(),
|
||||
"mitre_tags": [
|
||||
{
|
||||
"kind": tag.kind,
|
||||
"external_id": tag.external_id,
|
||||
"name": tag.name,
|
||||
"url": tag.url,
|
||||
}
|
||||
for tag in t.mitre_tags
|
||||
],
|
||||
"evidence": [_serialize_evidence(e) for e in t.evidence],
|
||||
}
|
||||
|
||||
|
||||
def _serialize_activity(a: test_svc.ActivityEntryView) -> dict[str, Any]:
|
||||
return {
|
||||
"test_id": str(a.test_id),
|
||||
"scenario_id": str(a.scenario_id),
|
||||
"state": a.state,
|
||||
"updated_at": a.updated_at.isoformat(),
|
||||
"last_actor_id": str(a.last_actor_id) if a.last_actor_id else None,
|
||||
"last_actor_email": a.last_actor_email,
|
||||
"last_actor_display_name": a.last_actor_display_name,
|
||||
}
|
||||
|
||||
|
||||
def _has_perm(user: AuthenticatedUser, code: str) -> bool:
|
||||
return user.is_admin or code in user.permissions
|
||||
|
||||
|
||||
@bp.get("/<mission_id>/tests/<test_id>")
|
||||
@require_auth
|
||||
@require_perm("mission.read")
|
||||
def get_mission_test(mission_id: str, test_id: str):
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
tid = _parse_uuid_or_400(test_id)
|
||||
if mid is None or tid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
user = _current_user()
|
||||
try:
|
||||
view = test_svc.get_mission_test(
|
||||
mid, tid, viewer_id=user.id, viewer_is_admin=user.is_admin
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except test_svc.MissionTestNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(_serialize_test_detail(view))
|
||||
|
||||
|
||||
@bp.put("/<mission_id>/tests/<test_id>")
|
||||
@require_auth
|
||||
@require_perm("mission.write_red_fields", "mission.write_blue_fields")
|
||||
def update_mission_test(mission_id: str, test_id: str):
|
||||
"""Patch any subset of red/blue fields on a test.
|
||||
|
||||
The outer decorator gates on *either* side perm so a user with only
|
||||
`write_blue_fields` reaches the handler — but the service then refuses
|
||||
individual fields they cannot write (red fields → 403). The membership
|
||||
filter remains row-level inside the service.
|
||||
"""
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
tid = _parse_uuid_or_400(test_id)
|
||||
if mid is None or tid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
|
||||
raw = request.get_json(silent=True) or {}
|
||||
try:
|
||||
payload = UpdateMissionTestPayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
for field in (
|
||||
"red_command",
|
||||
"red_output",
|
||||
"red_comment_md",
|
||||
"blue_comment_md",
|
||||
"detection_level_id",
|
||||
"executed_at",
|
||||
"executed_at_overridden",
|
||||
"blue_log_source",
|
||||
"blue_siem_logs",
|
||||
"blue_incident_at",
|
||||
"blue_incident_number",
|
||||
"blue_incident_recipient_email",
|
||||
):
|
||||
if field in raw:
|
||||
kwargs[field] = getattr(payload, field)
|
||||
|
||||
user = _current_user()
|
||||
try:
|
||||
view = test_svc.update_mission_test_fields(
|
||||
mid,
|
||||
tid,
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
has_red_perm=_has_perm(user, "mission.write_red_fields"),
|
||||
has_blue_perm=_has_perm(user, "mission.write_blue_fields"),
|
||||
**kwargs,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except test_svc.MissionTestNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except test_svc.MissingFieldPermission as e:
|
||||
log.info(
|
||||
"metamorph.mission_test.field_perm_denied",
|
||||
extra={
|
||||
"mission_id": str(mid),
|
||||
"test_id": str(tid),
|
||||
"user_id": str(user.id),
|
||||
"reason": str(e),
|
||||
},
|
||||
)
|
||||
return jsonify({"error": "forbidden", "message": str(e)}), 403
|
||||
except test_svc.InvalidTestPayload as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
log.info(
|
||||
"metamorph.mission_test.updated",
|
||||
extra={
|
||||
"mission_id": str(mid),
|
||||
"test_id": str(tid),
|
||||
"fields": sorted(kwargs.keys()),
|
||||
},
|
||||
)
|
||||
return jsonify(_serialize_test_detail(view))
|
||||
|
||||
|
||||
@bp.post("/<mission_id>/tests/<test_id>/transition")
|
||||
@require_auth
|
||||
@require_perm("mission.write_red_fields", "mission.write_blue_fields")
|
||||
def transition_mission_test(mission_id: str, test_id: str):
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
tid = _parse_uuid_or_400(test_id)
|
||||
if mid is None or tid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
payload = TestTransitionPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
|
||||
user = _current_user()
|
||||
try:
|
||||
view = test_svc.transition_mission_test(
|
||||
mid,
|
||||
tid,
|
||||
payload.target_state,
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
has_red_perm=_has_perm(user, "mission.write_red_fields"),
|
||||
has_blue_perm=_has_perm(user, "mission.write_blue_fields"),
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except test_svc.MissionTestNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except test_svc.MissingFieldPermission as e:
|
||||
return jsonify({"error": "forbidden", "message": str(e)}), 403
|
||||
except test_svc.InvalidTestTransition as e:
|
||||
return jsonify({"error": "invalid_transition", "message": str(e)}), 409
|
||||
except test_svc.InvalidTestPayload as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
log.info(
|
||||
"metamorph.mission_test.transitioned",
|
||||
extra={
|
||||
"mission_id": str(mid),
|
||||
"test_id": str(tid),
|
||||
"state": view.state,
|
||||
},
|
||||
)
|
||||
return jsonify(_serialize_test_detail(view))
|
||||
|
||||
|
||||
@bp.post("/<mission_id>/tests/<test_id>/evidence")
|
||||
@require_auth
|
||||
@require_perm("mission.write_blue_fields")
|
||||
def upload_evidence(mission_id: str, test_id: str):
|
||||
"""Multipart upload — single `file` part. Returns the new evidence row.
|
||||
|
||||
Streaming + size cap + SHA256 calc happen in the service; we just sniff
|
||||
the request and surface the right error codes.
|
||||
"""
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
tid = _parse_uuid_or_400(test_id)
|
||||
if mid is None or tid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
|
||||
upload = request.files.get("file")
|
||||
if upload is None or not upload.filename:
|
||||
return jsonify({"error": "missing_file"}), 400
|
||||
|
||||
user = _current_user()
|
||||
try:
|
||||
view = evidence_svc.add_evidence(
|
||||
mid,
|
||||
tid,
|
||||
file_stream=upload.stream,
|
||||
original_filename=upload.filename,
|
||||
mime=upload.mimetype or "application/octet-stream",
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except test_svc.MissionTestNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except evidence_svc.EvidenceValidationError as e:
|
||||
return jsonify({"error": e.code, "message": str(e)}), 400
|
||||
except evidence_svc.EvidenceStorageError as e:
|
||||
return jsonify({"error": "storage_failed", "message": str(e)}), 500
|
||||
log.info(
|
||||
"metamorph.api.evidence.uploaded",
|
||||
extra={
|
||||
"mission_id": str(mid),
|
||||
"test_id": str(tid),
|
||||
"evidence_id": str(view.id),
|
||||
"size_bytes": view.size_bytes,
|
||||
},
|
||||
)
|
||||
return jsonify(_serialize_evidence(view)), 201
|
||||
|
||||
|
||||
@bp.get("/<mission_id>/activity")
|
||||
@require_auth
|
||||
@require_perm("mission.read")
|
||||
def mission_activity(mission_id: str):
|
||||
"""Polled by the per-test page to drive the "modified by X" badge.
|
||||
|
||||
Accepts an optional `since=<ISO datetime>` filter. Returns only mission
|
||||
tests, not auth/templates — those are out of scope for this indicator.
|
||||
"""
|
||||
mid = _parse_uuid_or_400(mission_id)
|
||||
if mid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
|
||||
since_raw = request.args.get("since")
|
||||
since: datetime | None = None
|
||||
if since_raw:
|
||||
try:
|
||||
since = datetime.fromisoformat(since_raw)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid_since"}), 400
|
||||
|
||||
user = _current_user()
|
||||
try:
|
||||
entries = test_svc.list_activity_since(
|
||||
mid,
|
||||
viewer_id=user.id,
|
||||
viewer_is_admin=user.is_admin,
|
||||
since=since,
|
||||
)
|
||||
except svc.MissionNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"items": [_serialize_activity(e) for e in entries],
|
||||
"server_time": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -125,7 +125,7 @@ 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
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
view = svc.create_scenario_template(
|
||||
name=payload.name,
|
||||
@@ -154,7 +154,7 @@ def update_scenario_template(scenario_id: str):
|
||||
try:
|
||||
payload = UpdateScenarioPayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "name" in raw:
|
||||
kwargs["name"] = payload.name
|
||||
@@ -179,7 +179,7 @@ def set_scenario_tests(scenario_id: str):
|
||||
try:
|
||||
payload = SetTestsPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
view = svc.set_scenario_tests(sid, payload.test_template_ids)
|
||||
except svc.ScenarioTemplateNotFound:
|
||||
|
||||
@@ -47,7 +47,7 @@ def setup():
|
||||
try:
|
||||
payload = SetupPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
|
||||
try:
|
||||
result = bootstrap_admin(
|
||||
|
||||
@@ -176,7 +176,7 @@ 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
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
view = svc.create_test_template(
|
||||
name=payload.name,
|
||||
@@ -213,7 +213,7 @@ def update_test_template(template_id: str):
|
||||
try:
|
||||
payload = UpdateTestTemplatePayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
|
||||
# Only forward keys actually present in the body — model_validate leaves
|
||||
# missing fields as None and we can't distinguish "explicitly null" from
|
||||
|
||||
@@ -147,7 +147,7 @@ def update_user(user_id: str):
|
||||
try:
|
||||
payload = UpdateUserPayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
|
||||
# Distinguish "key absent" (no change) from "key=null" (clear) for display_name.
|
||||
display_name_unset = "display_name" not in raw
|
||||
@@ -200,7 +200,7 @@ def set_groups(user_id: str):
|
||||
try:
|
||||
payload = SetGroupsPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
return jsonify({"error": "invalid_request", "details": e.errors(include_context=False, include_url=False)}), 400
|
||||
try:
|
||||
u = users_svc.set_user_groups(uid, payload.group_ids)
|
||||
except users_svc.UserNotFound:
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
from flask import Blueprint
|
||||
|
||||
from app.api.auth import bp as auth_bp
|
||||
from app.api.detection_levels import bp as detection_levels_bp
|
||||
from app.api.diag import bp as diag_bp
|
||||
from app.api.evidence import bp as evidence_bp
|
||||
from app.api.groups import bp as groups_bp
|
||||
from app.api.health import bp as health_bp
|
||||
from app.api.invitations import bp as invitations_bp
|
||||
@@ -30,3 +32,5 @@ bp.register_blueprint(mitre_bp)
|
||||
bp.register_blueprint(test_templates_bp)
|
||||
bp.register_blueprint(scenario_templates_bp)
|
||||
bp.register_blueprint(missions_bp)
|
||||
bp.register_blueprint(detection_levels_bp)
|
||||
bp.register_blueprint(evidence_bp)
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.core.install_token import (
|
||||
from app.core.logging import configure_logging
|
||||
from app.core.rate_limit import limiter
|
||||
from app.services.bootstrap import ensure_system_groups
|
||||
from app.services.detection_levels import seed_detection_levels
|
||||
from app.services.permissions_seed import seed_all as seed_permissions_and_bindings
|
||||
|
||||
|
||||
@@ -29,6 +30,7 @@ def _try_bootstrap_at_boot(log: logging.Logger) -> None:
|
||||
try:
|
||||
ensure_system_groups()
|
||||
seed_permissions_and_bindings()
|
||||
seed_detection_levels()
|
||||
token = ensure_install_token()
|
||||
if token is not None:
|
||||
log_install_token_banner(token)
|
||||
|
||||
@@ -212,12 +212,38 @@ class MissionTest(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
ForeignKey("detection_levels.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
# Post-M7 blue-side review fields (cf. user feedback 2026-05-15). The
|
||||
# blue team historically maintained these in an Excel sheet — log source,
|
||||
# raw SIEM excerpt, plus a small cyber-incident sub-record. All five are
|
||||
# gated by `mission.write_blue_fields` at the service layer.
|
||||
blue_log_source: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
blue_siem_logs: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
blue_incident_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
blue_incident_number: Mapped[str | None] = mapped_column(
|
||||
String(120), nullable=True
|
||||
)
|
||||
blue_incident_recipient_email: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("mission_categories.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# --- Activity tracking (M7) ---
|
||||
# Last user who wrote any red/blue field, flipped state, or uploaded
|
||||
# evidence on this test. Used by the polling activity endpoint to drive
|
||||
# the "modified by X Ns ago" badge. FK ON DELETE SET NULL so removing a
|
||||
# user retains the history (the badge falls back to "<deleted>").
|
||||
last_actor_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
scenario: Mapped[MissionScenario] = relationship(back_populates="tests")
|
||||
mitre_tags: Mapped[list["MissionTestMitreTag"]] = relationship(
|
||||
back_populates="mission_test",
|
||||
@@ -236,6 +262,7 @@ class MissionTest(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
),
|
||||
UniqueConstraint("scenario_id", "position", name="uq_mission_tests_position"),
|
||||
Index("ix_mission_tests_state", "state"),
|
||||
Index("ix_mission_tests_updated_at", "updated_at"),
|
||||
Index(
|
||||
"ix_mission_tests_active",
|
||||
"deleted_at",
|
||||
|
||||
140
backend/app/services/detection_levels.py
Normal file
140
backend/app/services/detection_levels.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Detection-level taxonomy.
|
||||
|
||||
The 4 default levels are seeded at boot. M7 exposes read-only access so the
|
||||
blue side of a mission test can pick a level; M8 will add CRUD.
|
||||
|
||||
The seed is idempotent and additive: rows whose `key` already exists are left
|
||||
alone (operators may have renamed labels). Only missing keys are inserted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import session_scope
|
||||
from app.models.setting import DetectionLevel
|
||||
|
||||
log = logging.getLogger("metamorph.detection_levels")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DetectionLevelView:
|
||||
id: uuid.UUID
|
||||
key: str
|
||||
label_fr: str
|
||||
label_en: str
|
||||
color_token: str
|
||||
position: int
|
||||
is_default: bool
|
||||
is_system: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DefaultLevel:
|
||||
key: str
|
||||
label_fr: str
|
||||
label_en: str
|
||||
color_token: str
|
||||
position: int
|
||||
is_default: bool
|
||||
|
||||
|
||||
# Seed catalogue. Colors map onto the design-system accents (cf. tasks/design.md).
|
||||
DEFAULT_LEVELS: tuple[_DefaultLevel, ...] = (
|
||||
_DefaultLevel(
|
||||
key="detected_blocked",
|
||||
label_fr="Bloqué",
|
||||
label_en="Blocked",
|
||||
color_token="red",
|
||||
position=0,
|
||||
is_default=False,
|
||||
),
|
||||
_DefaultLevel(
|
||||
key="detected_alert",
|
||||
label_fr="Alerte détectée",
|
||||
label_en="Alert detected",
|
||||
color_token="orange",
|
||||
position=1,
|
||||
is_default=False,
|
||||
),
|
||||
_DefaultLevel(
|
||||
key="logged_only",
|
||||
label_fr="Loggé uniquement",
|
||||
label_en="Logged only",
|
||||
color_token="yellow",
|
||||
position=2,
|
||||
is_default=False,
|
||||
),
|
||||
_DefaultLevel(
|
||||
key="not_detected",
|
||||
label_fr="Non détecté",
|
||||
label_en="Not detected",
|
||||
color_token="rose",
|
||||
position=3,
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _to_view(r: DetectionLevel) -> DetectionLevelView:
|
||||
return DetectionLevelView(
|
||||
id=r.id,
|
||||
key=r.key,
|
||||
label_fr=r.label_fr,
|
||||
label_en=r.label_en,
|
||||
color_token=r.color_token,
|
||||
position=r.position,
|
||||
is_default=r.is_default,
|
||||
is_system=r.is_system,
|
||||
)
|
||||
|
||||
|
||||
def seed_detection_levels() -> dict[str, int]:
|
||||
"""Insert any default level whose `key` is missing. Idempotent.
|
||||
|
||||
We never mutate existing rows here — operators are free to rename labels
|
||||
or change the default flag. Adding a new entry to `DEFAULT_LEVELS` in a
|
||||
future release will surface it on the next boot.
|
||||
"""
|
||||
created = 0
|
||||
with session_scope() as s:
|
||||
existing_keys = set(s.scalars(select(DetectionLevel.key)).all())
|
||||
for lvl in DEFAULT_LEVELS:
|
||||
if lvl.key in existing_keys:
|
||||
continue
|
||||
s.add(
|
||||
DetectionLevel(
|
||||
key=lvl.key,
|
||||
label_fr=lvl.label_fr,
|
||||
label_en=lvl.label_en,
|
||||
color_token=lvl.color_token,
|
||||
position=lvl.position,
|
||||
is_default=lvl.is_default,
|
||||
is_system=True,
|
||||
)
|
||||
)
|
||||
created += 1
|
||||
# `created` is a reserved LogRecord attribute (timestamp) — use a prefixed key.
|
||||
log.info(
|
||||
"metamorph.detection_levels.seeded",
|
||||
extra={"rows_created": created, "total": len(DEFAULT_LEVELS)},
|
||||
)
|
||||
return {"created": created, "total": len(DEFAULT_LEVELS)}
|
||||
|
||||
|
||||
def list_detection_levels() -> list[DetectionLevelView]:
|
||||
with session_scope() as s:
|
||||
rows = s.scalars(
|
||||
select(DetectionLevel).order_by(DetectionLevel.position, DetectionLevel.key)
|
||||
).all()
|
||||
return [_to_view(r) for r in rows]
|
||||
|
||||
|
||||
def get_detection_level(level_id: uuid.UUID) -> DetectionLevelView | None:
|
||||
with session_scope() as s:
|
||||
r = s.get(DetectionLevel, level_id)
|
||||
return _to_view(r) if r is not None else None
|
||||
391
backend/app/services/evidence.py
Normal file
391
backend/app/services/evidence.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""Blue-side evidence storage service (M7).
|
||||
|
||||
Files live under `${EVIDENCE_DIR}/<mission_id>/<test_id>/<sha256><ext>`.
|
||||
The path is content-addressed: re-uploading byte-identical content into the
|
||||
same test reuses the existing file on disk and inserts a fresh row (so we
|
||||
keep history of who uploaded what without duplicating bytes).
|
||||
|
||||
The upload pipeline streams to a tmpfile inside the same per-test directory
|
||||
(`atomic move` semantics on POSIX), computing the SHA256 chunk-by-chunk and
|
||||
aborting when the byte count crosses `MAX_BYTES`. We refuse files whose
|
||||
extension is not in the whitelist; MIME is also validated but with a more
|
||||
permissive fallback (browsers and `file(1)` disagree on `.evtx`).
|
||||
|
||||
Soft delete only flips `deleted_at`. The bytes are kept on disk so a future
|
||||
admin `/admin/purge` (M12) can remove them physically. Until then, the path
|
||||
is still queryable but the API hides it from non-admins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import User
|
||||
from app.models.evidence import EvidenceFile
|
||||
from app.models.mission import MissionScenario, MissionTest
|
||||
from app.services.mission_tests import (
|
||||
EvidenceView,
|
||||
_ensure_mission_visible,
|
||||
_load_test,
|
||||
_to_evidence_view,
|
||||
_touch,
|
||||
)
|
||||
|
||||
log = logging.getLogger("metamorph.evidence")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation rules
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
MAX_BYTES: int = 25 * 1024 * 1024 # 25 MB per spec §M7
|
||||
|
||||
# Filename extensions accepted at the upload boundary. Lowercased; the upload
|
||||
# handler downcases the original filename's tail before comparing.
|
||||
ALLOWED_EXTS: frozenset[str] = frozenset(
|
||||
{
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".pdf",
|
||||
".txt",
|
||||
".log",
|
||||
".json",
|
||||
".csv",
|
||||
".evtx",
|
||||
".zip",
|
||||
}
|
||||
)
|
||||
|
||||
# Accept a permissive MIME set so common browser/OS combos clear validation.
|
||||
# `.evtx` is canonically `application/octet-stream`; some Windows clients send
|
||||
# `application/x-msexcel` for csv; etc. We trust the extension first and use
|
||||
# the MIME as a secondary signal.
|
||||
ALLOWED_MIMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"application/csv",
|
||||
"application/json",
|
||||
"application/octet-stream",
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Exceptions
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class EvidenceNotFound(Exception):
|
||||
"""Evidence row missing, soft-deleted, or not visible to the viewer."""
|
||||
|
||||
|
||||
class EvidenceValidationError(Exception):
|
||||
"""Extension/MIME/size invalid at the upload boundary."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class EvidenceStorageError(Exception):
|
||||
"""Disk I/O failure during upload — bytes left on disk are best-effort cleaned."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _evidence_dir() -> Path:
|
||||
return Path(settings.EVIDENCE_DIR).resolve()
|
||||
|
||||
|
||||
def _test_dir(mission_id: uuid.UUID, test_id: uuid.UUID) -> Path:
|
||||
root = _evidence_dir()
|
||||
# Refuse to lay down per-mission directories at filesystem roots — an
|
||||
# operator who set EVIDENCE_DIR=/ would otherwise write into / itself.
|
||||
if root in (Path("/"), Path(root.anchor)):
|
||||
raise EvidenceStorageError("EVIDENCE_DIR cannot be a filesystem root")
|
||||
return root / str(mission_id) / str(test_id)
|
||||
|
||||
|
||||
def _sniff_ext(filename: str) -> str:
|
||||
"""Lowercased extension including the leading dot, or '' if none."""
|
||||
name = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
if "." not in name:
|
||||
return ""
|
||||
return "." + name.rsplit(".", 1)[-1].lower()
|
||||
|
||||
|
||||
def _validate_meta(filename: str, mime: str) -> str:
|
||||
ext = _sniff_ext(filename)
|
||||
if not ext:
|
||||
raise EvidenceValidationError(
|
||||
"missing_extension", "filename must have an extension"
|
||||
)
|
||||
if ext not in ALLOWED_EXTS:
|
||||
raise EvidenceValidationError(
|
||||
"unsupported_extension", f"extension {ext!r} is not allowed"
|
||||
)
|
||||
normalised_mime = (mime or "application/octet-stream").lower().split(";", 1)[0].strip()
|
||||
if normalised_mime not in ALLOWED_MIMES:
|
||||
raise EvidenceValidationError(
|
||||
"unsupported_mime", f"mime {normalised_mime!r} is not allowed"
|
||||
)
|
||||
return ext
|
||||
|
||||
|
||||
def _stream_to_tmpfile(
|
||||
src: BinaryIO, target_dir: Path
|
||||
) -> tuple[Path, str, int]:
|
||||
"""Stream the upload into a tmpfile under `target_dir`, capping size.
|
||||
|
||||
Returns (tmp_path, sha256_hex, total_bytes). Raises
|
||||
`EvidenceValidationError("too_large", …)` once the cumulative count goes
|
||||
above `MAX_BYTES`. The tmpfile is *always* removed on error.
|
||||
"""
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=".upload-", dir=str(target_dir))
|
||||
tmp_path = Path(tmp_name)
|
||||
hasher = hashlib.sha256()
|
||||
total = 0
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
while True:
|
||||
chunk = src.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_BYTES:
|
||||
raise EvidenceValidationError(
|
||||
"too_large",
|
||||
f"file exceeds the {MAX_BYTES} byte limit",
|
||||
)
|
||||
hasher.update(chunk)
|
||||
fh.write(chunk)
|
||||
return tmp_path, hasher.hexdigest(), total
|
||||
except Exception:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Public API
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def add_evidence(
|
||||
mission_id: uuid.UUID,
|
||||
test_id: uuid.UUID,
|
||||
*,
|
||||
file_stream: BinaryIO,
|
||||
original_filename: str,
|
||||
mime: str,
|
||||
viewer_id: uuid.UUID,
|
||||
viewer_is_admin: bool,
|
||||
) -> EvidenceView:
|
||||
"""Persist the upload and return a view of the new evidence row.
|
||||
|
||||
Pre-conditions:
|
||||
- The caller already verified that the viewer holds `mission.write_blue_fields`.
|
||||
- Mission + test visibility is enforced here (404, not 403).
|
||||
|
||||
Disk layout:
|
||||
${EVIDENCE_DIR}/<mission_id>/<test_id>/<sha256><ext>
|
||||
"""
|
||||
ext = _validate_meta(original_filename, mime)
|
||||
target_dir = _test_dir(mission_id, test_id)
|
||||
|
||||
# Visibility/existence check BEFORE we touch disk.
|
||||
with session_scope() as s:
|
||||
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
|
||||
_load_test(s, mission_id, test_id) # raises MissionTestNotFound on miss
|
||||
|
||||
tmp_path, sha256, size_bytes = _stream_to_tmpfile(file_stream, target_dir)
|
||||
|
||||
# Defence in depth — the hash comes from hashlib but if any caller ever
|
||||
# passes pre-computed bytes we want to fail loudly rather than write to a
|
||||
# path like `..something.evtx`.
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", sha256):
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise EvidenceStorageError("computed sha256 is malformed")
|
||||
|
||||
final_path = target_dir / f"{sha256}{ext}"
|
||||
try:
|
||||
if final_path.exists():
|
||||
# Same bytes already on disk — drop the tmp and reuse the canonical path.
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
else:
|
||||
# `os.replace` is the atomic rename primitive on POSIX (and the
|
||||
# documented atomic rename on Windows when src/dst live on the
|
||||
# same volume). We stage the tmpfile in `target_dir` so it
|
||||
# always shares a filesystem with the destination.
|
||||
os.replace(str(tmp_path), str(final_path))
|
||||
except OSError as e:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
log.warning(
|
||||
"metamorph.evidence.storage_failed",
|
||||
extra={"mission_id": str(mission_id), "test_id": str(test_id), "error": str(e)},
|
||||
)
|
||||
raise EvidenceStorageError(str(e)) from e
|
||||
|
||||
with session_scope() as s:
|
||||
# Re-load + double-check visibility (defence in depth: the membership
|
||||
# set could have changed between the pre-check and now).
|
||||
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
|
||||
test = _load_test(s, mission_id, test_id)
|
||||
ev = EvidenceFile(
|
||||
mission_test_id=test.id,
|
||||
sha256=sha256,
|
||||
mime=(mime or "application/octet-stream").lower().split(";", 1)[0].strip(),
|
||||
size_bytes=size_bytes,
|
||||
storage_path=str(final_path),
|
||||
original_filename=original_filename[:255],
|
||||
uploaded_by_user_id=viewer_id,
|
||||
uploaded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
s.add(ev)
|
||||
_touch(test, viewer_id)
|
||||
s.flush()
|
||||
s.refresh(ev)
|
||||
uploader = s.get(User, viewer_id)
|
||||
log.info(
|
||||
"metamorph.evidence.added",
|
||||
extra={
|
||||
"evidence_id": str(ev.id),
|
||||
"mission_id": str(mission_id),
|
||||
"test_id": str(test_id),
|
||||
"sha256": sha256,
|
||||
"size_bytes": size_bytes,
|
||||
"mime": ev.mime,
|
||||
},
|
||||
)
|
||||
return _to_evidence_view(ev, uploader)
|
||||
|
||||
|
||||
def _resolve_evidence_chain(
|
||||
s, evidence_id: uuid.UUID
|
||||
) -> tuple[EvidenceFile, MissionTest, MissionScenario] | None:
|
||||
"""Walk evidence → test → scenario, returning None if any link is missing or deleted."""
|
||||
ev = s.get(EvidenceFile, evidence_id)
|
||||
if ev is None or ev.deleted_at is not None:
|
||||
return None
|
||||
test = s.get(MissionTest, ev.mission_test_id)
|
||||
if test is None or test.deleted_at is not None:
|
||||
return None
|
||||
scenario = s.get(MissionScenario, test.scenario_id)
|
||||
if scenario is None or scenario.deleted_at is not None:
|
||||
return None
|
||||
return ev, test, scenario
|
||||
|
||||
|
||||
def get_evidence(
|
||||
evidence_id: uuid.UUID,
|
||||
*,
|
||||
viewer_id: uuid.UUID,
|
||||
viewer_is_admin: bool,
|
||||
) -> EvidenceView:
|
||||
"""Read a single evidence record. Membership-aware (404 on miss/forbidden)."""
|
||||
with session_scope() as s:
|
||||
chain = _resolve_evidence_chain(s, evidence_id)
|
||||
if chain is None:
|
||||
raise EvidenceNotFound()
|
||||
ev, _, scenario = chain
|
||||
try:
|
||||
_ensure_mission_visible(s, scenario.mission_id, viewer_id, viewer_is_admin)
|
||||
except Exception as e:
|
||||
raise EvidenceNotFound() from e
|
||||
uploader = s.get(User, ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None
|
||||
return _to_evidence_view(ev, uploader)
|
||||
|
||||
|
||||
def get_evidence_for_download(
|
||||
evidence_id: uuid.UUID,
|
||||
*,
|
||||
viewer_id: uuid.UUID,
|
||||
viewer_is_admin: bool,
|
||||
) -> tuple[EvidenceView, Path]:
|
||||
"""Return view + on-disk path. Raises EvidenceNotFound if the bytes are gone."""
|
||||
with session_scope() as s:
|
||||
chain = _resolve_evidence_chain(s, evidence_id)
|
||||
if chain is None:
|
||||
raise EvidenceNotFound()
|
||||
ev, _, scenario = chain
|
||||
try:
|
||||
_ensure_mission_visible(s, scenario.mission_id, viewer_id, viewer_is_admin)
|
||||
except Exception as e:
|
||||
raise EvidenceNotFound() from e
|
||||
uploader = s.get(User, ev.uploaded_by_user_id) if ev.uploaded_by_user_id else None
|
||||
view = _to_evidence_view(ev, uploader)
|
||||
path = Path(ev.storage_path)
|
||||
if not path.exists():
|
||||
log.warning(
|
||||
"metamorph.evidence.bytes_missing",
|
||||
extra={"evidence_id": str(evidence_id), "path": str(path)},
|
||||
)
|
||||
raise EvidenceNotFound()
|
||||
return view, path
|
||||
|
||||
|
||||
def soft_delete_evidence(
|
||||
evidence_id: uuid.UUID,
|
||||
*,
|
||||
viewer_id: uuid.UUID,
|
||||
viewer_is_admin: bool,
|
||||
) -> None:
|
||||
"""Mark an evidence row deleted. Disk bytes are kept until admin purge (M12)."""
|
||||
with session_scope() as s:
|
||||
chain = _resolve_evidence_chain(s, evidence_id)
|
||||
if chain is None:
|
||||
raise EvidenceNotFound()
|
||||
ev, test, scenario = chain
|
||||
try:
|
||||
_ensure_mission_visible(s, scenario.mission_id, viewer_id, viewer_is_admin)
|
||||
except Exception as e:
|
||||
raise EvidenceNotFound() from e
|
||||
ev.deleted_at = datetime.now(tz=timezone.utc)
|
||||
_touch(test, viewer_id)
|
||||
s.flush()
|
||||
log.info(
|
||||
"metamorph.evidence.soft_deleted",
|
||||
extra={"evidence_id": str(evidence_id), "mission_id": str(scenario.mission_id)},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_BYTES",
|
||||
"ALLOWED_EXTS",
|
||||
"ALLOWED_MIMES",
|
||||
"EvidenceNotFound",
|
||||
"EvidenceValidationError",
|
||||
"EvidenceStorageError",
|
||||
"add_evidence",
|
||||
"get_evidence",
|
||||
"get_evidence_for_download",
|
||||
"soft_delete_evidence",
|
||||
]
|
||||
740
backend/app/services/mission_tests.py
Normal file
740
backend/app/services/mission_tests.py
Normal file
@@ -0,0 +1,740 @@
|
||||
"""Per-mission-test execution service (M7).
|
||||
|
||||
Where M6 builds the snapshot, M7 brings the test to life:
|
||||
|
||||
- Red side: command, output, comment, mark-executed (auto + override).
|
||||
- Blue side: detection level, comment, evidence (delegated to `evidence.py`).
|
||||
- State machine: pending↔skipped/blocked, executed→reviewed_by_blue.
|
||||
|
||||
The caller is responsible for telling us which side it has perms for via
|
||||
`has_red_perm` / `has_blue_perm`. The service refuses field/state writes that
|
||||
require a side the caller does not hold, raising `MissingFieldPermission`.
|
||||
|
||||
Mission membership is enforced here (404 not 403) consistent with M6 to
|
||||
prevent existence leaks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.db.session import session_scope
|
||||
from app.db.types import MISSION_TEST_STATES
|
||||
from app.models.auth import User
|
||||
from app.models.evidence import EvidenceFile
|
||||
from app.models.mission import (
|
||||
Mission,
|
||||
MissionScenario,
|
||||
MissionTest,
|
||||
)
|
||||
from app.models.setting import DetectionLevel
|
||||
from app.services.missions import (
|
||||
MissionNotFound,
|
||||
_is_member,
|
||||
)
|
||||
|
||||
log = logging.getLogger("metamorph.mission_tests")
|
||||
|
||||
_UNSET: Any = object()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# State machine
|
||||
# --------------------------------------------------------------------------- #
|
||||
#
|
||||
# Per spec §M7: pending↔skipped/blocked, executed→reviewed_by_blue.
|
||||
# We also allow `executed → pending` and `reviewed_by_blue → executed` so a
|
||||
# red/blue user can revert a misclick without admin intervention. Soft-delete
|
||||
# is the only forward-only sink (handled outside this service).
|
||||
#
|
||||
|
||||
_VALID_TRANSITIONS: dict[str, frozenset[str]] = {
|
||||
"pending": frozenset({"executed", "skipped", "blocked"}),
|
||||
"executed": frozenset({"reviewed_by_blue", "pending"}),
|
||||
"reviewed_by_blue": frozenset({"executed"}),
|
||||
"skipped": frozenset({"pending"}),
|
||||
"blocked": frozenset({"pending"}),
|
||||
}
|
||||
|
||||
# Which side "owns" each transition for permission purposes:
|
||||
# "red" → requires mission.write_red_fields
|
||||
# "blue" → requires mission.write_blue_fields
|
||||
# "any" → either side suffices
|
||||
_TRANSITION_SIDE: dict[tuple[str, str], str] = {
|
||||
("pending", "executed"): "red",
|
||||
("pending", "skipped"): "any",
|
||||
("pending", "blocked"): "any",
|
||||
("executed", "reviewed_by_blue"): "blue",
|
||||
("executed", "pending"): "red",
|
||||
("reviewed_by_blue", "executed"): "blue",
|
||||
("skipped", "pending"): "any",
|
||||
("blocked", "pending"): "any",
|
||||
}
|
||||
|
||||
# Same-state idempotent POSTs are still gated: a user replaying a "mark
|
||||
# executed" must still hold red perms even if the row is already executed.
|
||||
# This map answers "if you wanted to BE in state X, which side originally
|
||||
# brought you here?" — and therefore what perm a no-op repeat should require.
|
||||
_IDEMPOTENT_SIDE: dict[str, str] = {
|
||||
"executed": "red",
|
||||
"reviewed_by_blue": "blue",
|
||||
"pending": "any",
|
||||
"skipped": "any",
|
||||
"blocked": "any",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Exceptions
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class MissionTestNotFound(Exception):
|
||||
"""Test missing, soft-deleted, or not under the given mission/viewer."""
|
||||
|
||||
|
||||
class InvalidTestTransition(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingFieldPermission(Exception):
|
||||
"""Caller tried to write a field requiring a side perm they do not hold."""
|
||||
|
||||
|
||||
class InvalidTestPayload(Exception):
|
||||
"""Generic validation error (bad dates, unknown detection level, ...)."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Views
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceView:
|
||||
id: uuid.UUID
|
||||
mission_test_id: uuid.UUID
|
||||
sha256: str
|
||||
mime: str
|
||||
size_bytes: int
|
||||
original_filename: str
|
||||
uploaded_by_user_id: uuid.UUID | None
|
||||
uploaded_by_email: str | None
|
||||
uploaded_by_display_name: str | None
|
||||
uploaded_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionTestMitreTagView:
|
||||
kind: str
|
||||
external_id: str
|
||||
name: str
|
||||
url: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionTestDetailView:
|
||||
id: uuid.UUID
|
||||
mission_id: uuid.UUID
|
||||
scenario_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
|
||||
red_command: str | None
|
||||
red_output: str | None
|
||||
red_comment_md: str | None
|
||||
blue_comment_md: str | None
|
||||
detection_level_id: uuid.UUID | None
|
||||
detection_level_key: str | None
|
||||
# Post-M7 blue review fields (cf. user feedback 2026-05-15).
|
||||
blue_log_source: str | None
|
||||
blue_siem_logs: str | None
|
||||
blue_incident_at: datetime | None
|
||||
blue_incident_number: str | None
|
||||
blue_incident_recipient_email: str | None
|
||||
last_actor_id: uuid.UUID | None
|
||||
last_actor_email: str | None
|
||||
last_actor_display_name: str | None
|
||||
updated_at: datetime
|
||||
mitre_tags: list[MissionTestMitreTagView]
|
||||
evidence: list[EvidenceView]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActivityEntryView:
|
||||
test_id: uuid.UUID
|
||||
scenario_id: uuid.UUID
|
||||
state: str
|
||||
updated_at: datetime
|
||||
last_actor_id: uuid.UUID | None
|
||||
last_actor_email: str | None
|
||||
last_actor_display_name: str | None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _opt_md(value: Any) -> str | None:
|
||||
"""Normalise a markdown/text input: strip-then-collapse-to-None on empty."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise InvalidTestPayload("text field must be a string")
|
||||
v = value.strip()
|
||||
return v or None
|
||||
|
||||
|
||||
def _opt_cmd(value: Any) -> str | None:
|
||||
"""Same as `_opt_md` but preserves trailing/leading whitespace inside the body."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise InvalidTestPayload("text field must be a string")
|
||||
return value if value != "" else None
|
||||
|
||||
|
||||
def _ensure_state(value: str) -> str:
|
||||
if value not in MISSION_TEST_STATES:
|
||||
raise InvalidTestPayload(f"state must be one of {MISSION_TEST_STATES}")
|
||||
return value
|
||||
|
||||
|
||||
def _load_test(
|
||||
s: Session, mission_id: uuid.UUID, test_id: uuid.UUID
|
||||
) -> MissionTest:
|
||||
"""Fetch a live mission_test guarded by mission id, raising on misses."""
|
||||
stmt = (
|
||||
select(MissionTest)
|
||||
.join(MissionScenario, MissionTest.scenario_id == MissionScenario.id)
|
||||
.options(selectinload(MissionTest.mitre_tags))
|
||||
.where(
|
||||
MissionTest.id == test_id,
|
||||
MissionScenario.mission_id == mission_id,
|
||||
MissionTest.deleted_at.is_(None),
|
||||
MissionScenario.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
row = s.scalars(stmt).one_or_none()
|
||||
if row is None:
|
||||
raise MissionTestNotFound()
|
||||
return row
|
||||
|
||||
|
||||
def _ensure_mission_visible(
|
||||
s: Session, mission_id: uuid.UUID, viewer_id: uuid.UUID, viewer_is_admin: bool
|
||||
) -> Mission:
|
||||
"""Confirm the mission exists, is live, and is visible to the viewer.
|
||||
|
||||
Returns the Mission row for reuse (e.g. to log the parent name in audit
|
||||
extras). Raises `MissionNotFound` on any miss — we mirror M6's membership
|
||||
visibility contract: leaking existence via 403 is forbidden.
|
||||
"""
|
||||
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()
|
||||
return m
|
||||
|
||||
|
||||
def _to_evidence_view(ev: EvidenceFile, uploader: User | None) -> EvidenceView:
|
||||
return EvidenceView(
|
||||
id=ev.id,
|
||||
mission_test_id=ev.mission_test_id,
|
||||
sha256=ev.sha256,
|
||||
mime=ev.mime,
|
||||
size_bytes=ev.size_bytes,
|
||||
original_filename=ev.original_filename,
|
||||
uploaded_by_user_id=ev.uploaded_by_user_id,
|
||||
uploaded_by_email=uploader.email if uploader is not None else None,
|
||||
uploaded_by_display_name=uploader.display_name if uploader is not None else None,
|
||||
uploaded_at=ev.uploaded_at,
|
||||
created_at=ev.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _load_evidence_for_test(s: Session, test_id: uuid.UUID) -> list[EvidenceView]:
|
||||
rows = s.scalars(
|
||||
select(EvidenceFile)
|
||||
.where(
|
||||
EvidenceFile.mission_test_id == test_id,
|
||||
EvidenceFile.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(EvidenceFile.uploaded_at.asc(), EvidenceFile.id.asc())
|
||||
).all()
|
||||
if not rows:
|
||||
return []
|
||||
uploader_ids = {r.uploaded_by_user_id for r in rows if r.uploaded_by_user_id}
|
||||
uploaders: dict[uuid.UUID, User] = {}
|
||||
if uploader_ids:
|
||||
uploaders = {
|
||||
u.id: u
|
||||
for u in s.scalars(
|
||||
select(User).where(User.id.in_(uploader_ids))
|
||||
).all()
|
||||
}
|
||||
return [
|
||||
_to_evidence_view(r, uploaders.get(r.uploaded_by_user_id) if r.uploaded_by_user_id else None)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _to_detail_view(
|
||||
s: Session, mission_id: uuid.UUID, test: MissionTest
|
||||
) -> MissionTestDetailView:
|
||||
# Batch the two FK lookups (last actor + detection level) into a single
|
||||
# round trip instead of two `s.get` calls — every PUT/transition returns
|
||||
# the detail view, so this matters.
|
||||
last_actor_email: str | None = None
|
||||
last_actor_display_name: str | None = None
|
||||
level_key: str | None = None
|
||||
if test.last_actor_id is not None:
|
||||
actor = s.execute(
|
||||
select(User.email, User.display_name).where(User.id == test.last_actor_id)
|
||||
).first()
|
||||
if actor is not None:
|
||||
last_actor_email, last_actor_display_name = actor.email, actor.display_name
|
||||
if test.detection_level_id is not None:
|
||||
level_key = s.scalar(
|
||||
select(DetectionLevel.key).where(DetectionLevel.id == test.detection_level_id)
|
||||
)
|
||||
tag_views = [
|
||||
MissionTestMitreTagView(
|
||||
kind=tag.mitre_kind,
|
||||
external_id=tag.mitre_external_id,
|
||||
name=tag.mitre_name,
|
||||
url=tag.mitre_url,
|
||||
)
|
||||
for tag in sorted(
|
||||
test.mitre_tags, key=lambda t: (t.mitre_kind, t.mitre_external_id)
|
||||
)
|
||||
]
|
||||
return MissionTestDetailView(
|
||||
id=test.id,
|
||||
mission_id=mission_id,
|
||||
scenario_id=test.scenario_id,
|
||||
position=test.position,
|
||||
snapshot_name=test.snapshot_name,
|
||||
snapshot_description=test.snapshot_description,
|
||||
snapshot_objective=test.snapshot_objective,
|
||||
snapshot_procedure_md=test.snapshot_procedure_md,
|
||||
snapshot_prerequisites_md=test.snapshot_prerequisites_md,
|
||||
snapshot_expected_red_md=test.snapshot_expected_red_md,
|
||||
snapshot_expected_blue_md=test.snapshot_expected_blue_md,
|
||||
snapshot_opsec_level=test.snapshot_opsec_level,
|
||||
snapshot_tags=list(test.snapshot_tags or []),
|
||||
snapshot_expected_iocs=list(test.snapshot_expected_iocs or []),
|
||||
state=test.state,
|
||||
executed_at=test.executed_at,
|
||||
executed_at_overridden=test.executed_at_overridden,
|
||||
red_command=test.red_command,
|
||||
red_output=test.red_output,
|
||||
red_comment_md=test.red_comment_md,
|
||||
blue_comment_md=test.blue_comment_md,
|
||||
detection_level_id=test.detection_level_id,
|
||||
detection_level_key=level_key,
|
||||
blue_log_source=test.blue_log_source,
|
||||
blue_siem_logs=test.blue_siem_logs,
|
||||
blue_incident_at=test.blue_incident_at,
|
||||
blue_incident_number=test.blue_incident_number,
|
||||
blue_incident_recipient_email=test.blue_incident_recipient_email,
|
||||
last_actor_id=test.last_actor_id,
|
||||
last_actor_email=last_actor_email,
|
||||
last_actor_display_name=last_actor_display_name,
|
||||
updated_at=test.updated_at,
|
||||
mitre_tags=tag_views,
|
||||
evidence=_load_evidence_for_test(s, test.id),
|
||||
)
|
||||
|
||||
|
||||
def _touch(test: MissionTest, actor_id: uuid.UUID) -> None:
|
||||
"""Stamp the actor + bump the activity clock.
|
||||
|
||||
`updated_at` is auto-managed by SQLAlchemy's `onupdate=func.now()` mixin
|
||||
only when at least one mapped attribute changes. Assigning `last_actor_id`
|
||||
triggers that, even when the actor is the same as the previous one
|
||||
(Pydantic-clean payloads still flush the assignment).
|
||||
"""
|
||||
test.last_actor_id = actor_id
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Public API — read
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def get_mission_test(
|
||||
mission_id: uuid.UUID,
|
||||
test_id: uuid.UUID,
|
||||
*,
|
||||
viewer_id: uuid.UUID,
|
||||
viewer_is_admin: bool,
|
||||
) -> MissionTestDetailView:
|
||||
with session_scope() as s:
|
||||
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
|
||||
test = _load_test(s, mission_id, test_id)
|
||||
return _to_detail_view(s, mission_id, test)
|
||||
|
||||
|
||||
def list_activity_since(
|
||||
mission_id: uuid.UUID,
|
||||
*,
|
||||
viewer_id: uuid.UUID,
|
||||
viewer_is_admin: bool,
|
||||
since: datetime | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[ActivityEntryView]:
|
||||
"""List mission_tests whose `updated_at > since`, freshest first.
|
||||
|
||||
Drives the "modified by X Ns ago" badge on the per-test page. Soft-deleted
|
||||
tests/scenarios are excluded so a deletion does not appear as activity.
|
||||
"""
|
||||
with session_scope() as s:
|
||||
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
|
||||
stmt = (
|
||||
select(MissionTest, MissionScenario)
|
||||
.join(MissionScenario, MissionTest.scenario_id == MissionScenario.id)
|
||||
.where(
|
||||
MissionScenario.mission_id == mission_id,
|
||||
MissionTest.deleted_at.is_(None),
|
||||
MissionScenario.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(MissionTest.updated_at.desc(), MissionTest.id.asc())
|
||||
.limit(max(1, min(limit, 500)))
|
||||
)
|
||||
if since is not None:
|
||||
stmt = stmt.where(MissionTest.updated_at > since)
|
||||
rows = s.execute(stmt).all()
|
||||
actor_ids = {r.MissionTest.last_actor_id for r in rows if r.MissionTest.last_actor_id}
|
||||
actors: dict[uuid.UUID, User] = {}
|
||||
if actor_ids:
|
||||
actors = {
|
||||
u.id: u
|
||||
for u in s.scalars(select(User).where(User.id.in_(actor_ids))).all()
|
||||
}
|
||||
out: list[ActivityEntryView] = []
|
||||
for row in rows:
|
||||
t = row.MissionTest
|
||||
actor = actors.get(t.last_actor_id) if t.last_actor_id else None
|
||||
out.append(
|
||||
ActivityEntryView(
|
||||
test_id=t.id,
|
||||
scenario_id=t.scenario_id,
|
||||
state=t.state,
|
||||
updated_at=t.updated_at,
|
||||
last_actor_id=t.last_actor_id,
|
||||
last_actor_email=actor.email if actor else None,
|
||||
last_actor_display_name=actor.display_name if actor else None,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Public API — write
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# Side membership for each writable field (mirror of the spec's red/blue split).
|
||||
_RED_FIELDS = {"red_command", "red_output", "red_comment_md",
|
||||
"executed_at", "executed_at_overridden"}
|
||||
_BLUE_FIELDS = {
|
||||
"blue_comment_md",
|
||||
"detection_level_id",
|
||||
"blue_log_source",
|
||||
"blue_siem_logs",
|
||||
"blue_incident_at",
|
||||
"blue_incident_number",
|
||||
"blue_incident_recipient_email",
|
||||
}
|
||||
|
||||
|
||||
def _classify_fields(touched: set[str]) -> tuple[bool, bool]:
|
||||
"""Return (needs_red, needs_blue) for the set of field names being written."""
|
||||
return (
|
||||
bool(touched & _RED_FIELDS),
|
||||
bool(touched & _BLUE_FIELDS),
|
||||
)
|
||||
|
||||
|
||||
def update_mission_test_fields(
|
||||
mission_id: uuid.UUID,
|
||||
test_id: uuid.UUID,
|
||||
*,
|
||||
viewer_id: uuid.UUID,
|
||||
viewer_is_admin: bool,
|
||||
has_red_perm: bool,
|
||||
has_blue_perm: bool,
|
||||
red_command: Any = _UNSET,
|
||||
red_output: Any = _UNSET,
|
||||
red_comment_md: Any = _UNSET,
|
||||
blue_comment_md: Any = _UNSET,
|
||||
detection_level_id: Any = _UNSET,
|
||||
executed_at: Any = _UNSET,
|
||||
executed_at_overridden: Any = _UNSET,
|
||||
blue_log_source: Any = _UNSET,
|
||||
blue_siem_logs: Any = _UNSET,
|
||||
blue_incident_at: Any = _UNSET,
|
||||
blue_incident_number: Any = _UNSET,
|
||||
blue_incident_recipient_email: Any = _UNSET,
|
||||
) -> MissionTestDetailView:
|
||||
"""Patch any subset of the red/blue annotation fields.
|
||||
|
||||
Field-level perm enforcement happens *before* any write so a forbidden
|
||||
field never even lands in the SQL transaction (cleaner audit logs).
|
||||
"""
|
||||
touched: set[str] = set()
|
||||
if red_command is not _UNSET:
|
||||
touched.add("red_command")
|
||||
if red_output is not _UNSET:
|
||||
touched.add("red_output")
|
||||
if red_comment_md is not _UNSET:
|
||||
touched.add("red_comment_md")
|
||||
if blue_comment_md is not _UNSET:
|
||||
touched.add("blue_comment_md")
|
||||
if detection_level_id is not _UNSET:
|
||||
touched.add("detection_level_id")
|
||||
if executed_at is not _UNSET:
|
||||
touched.add("executed_at")
|
||||
if executed_at_overridden is not _UNSET:
|
||||
touched.add("executed_at_overridden")
|
||||
if blue_log_source is not _UNSET:
|
||||
touched.add("blue_log_source")
|
||||
if blue_siem_logs is not _UNSET:
|
||||
touched.add("blue_siem_logs")
|
||||
if blue_incident_at is not _UNSET:
|
||||
touched.add("blue_incident_at")
|
||||
if blue_incident_number is not _UNSET:
|
||||
touched.add("blue_incident_number")
|
||||
if blue_incident_recipient_email is not _UNSET:
|
||||
touched.add("blue_incident_recipient_email")
|
||||
|
||||
needs_red, needs_blue = _classify_fields(touched)
|
||||
if not viewer_is_admin:
|
||||
if needs_red and not has_red_perm:
|
||||
raise MissingFieldPermission(
|
||||
"mission.write_red_fields required for red-side fields"
|
||||
)
|
||||
if needs_blue and not has_blue_perm:
|
||||
raise MissingFieldPermission(
|
||||
"mission.write_blue_fields required for blue-side fields"
|
||||
)
|
||||
|
||||
with session_scope() as s:
|
||||
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
|
||||
test = _load_test(s, mission_id, test_id)
|
||||
|
||||
if not touched:
|
||||
return _to_detail_view(s, mission_id, test)
|
||||
|
||||
if "red_command" in touched:
|
||||
test.red_command = _opt_cmd(red_command)
|
||||
if "red_output" in touched:
|
||||
test.red_output = _opt_cmd(red_output)
|
||||
if "red_comment_md" in touched:
|
||||
test.red_comment_md = _opt_md(red_comment_md)
|
||||
if "blue_comment_md" in touched:
|
||||
test.blue_comment_md = _opt_md(blue_comment_md)
|
||||
|
||||
if "detection_level_id" in touched:
|
||||
if detection_level_id is None:
|
||||
test.detection_level_id = None
|
||||
else:
|
||||
if not isinstance(detection_level_id, uuid.UUID):
|
||||
raise InvalidTestPayload("detection_level_id must be a UUID")
|
||||
lvl = s.get(DetectionLevel, detection_level_id)
|
||||
if lvl is None:
|
||||
raise InvalidTestPayload("unknown detection_level_id")
|
||||
test.detection_level_id = detection_level_id
|
||||
|
||||
# Post-M7 blue-side review fields — short text + long text + a small
|
||||
# cyber-incident sub-record. Email is sanity-checked at the API layer
|
||||
# via Pydantic; the service just normalises empty strings to NULL.
|
||||
if "blue_log_source" in touched:
|
||||
test.blue_log_source = _opt_md(blue_log_source)
|
||||
if "blue_siem_logs" in touched:
|
||||
# SIEM excerpts can legitimately have leading whitespace inside
|
||||
# the body (table-like log lines), so use the command-style
|
||||
# normaliser that only collapses purely-empty strings to NULL.
|
||||
test.blue_siem_logs = _opt_cmd(blue_siem_logs)
|
||||
if "blue_incident_at" in touched:
|
||||
if blue_incident_at is not None and not isinstance(blue_incident_at, datetime):
|
||||
raise InvalidTestPayload("blue_incident_at must be an ISO datetime")
|
||||
test.blue_incident_at = blue_incident_at
|
||||
if "blue_incident_number" in touched:
|
||||
test.blue_incident_number = _opt_md(blue_incident_number)
|
||||
if "blue_incident_recipient_email" in touched:
|
||||
test.blue_incident_recipient_email = _opt_md(
|
||||
blue_incident_recipient_email
|
||||
)
|
||||
|
||||
if "executed_at_overridden" in touched or "executed_at" in touched:
|
||||
# Editing executed_at is a red-only privilege (gated above via
|
||||
# _RED_FIELDS). State auto-promotion is handled by the general
|
||||
# block below; this branch just validates and applies the
|
||||
# timestamp + override flag.
|
||||
new_overridden = (
|
||||
bool(executed_at_overridden)
|
||||
if "executed_at_overridden" in touched
|
||||
else test.executed_at_overridden
|
||||
)
|
||||
new_at = test.executed_at if "executed_at" not in touched else executed_at
|
||||
if new_overridden and new_at is None:
|
||||
raise InvalidTestPayload(
|
||||
"executed_at_overridden=true requires a non-null executed_at"
|
||||
)
|
||||
if "executed_at" in touched and new_at is not None and not isinstance(new_at, datetime):
|
||||
raise InvalidTestPayload("executed_at must be an ISO datetime")
|
||||
test.executed_at = new_at
|
||||
test.executed_at_overridden = new_overridden
|
||||
|
||||
# Implicit lifecycle (post-amendement 2026-05-15 bis): the explicit
|
||||
# workflow is gone from the UI. A write to ANY red field implies the
|
||||
# test was executed AND the review (if any) is potentially stale —
|
||||
# so the state always lands on `executed`, even from `reviewed_by_blue`
|
||||
# (red just changed something, blue needs to re-review). A write to
|
||||
# ANY blue field on an executed test implies the blue team reviewed
|
||||
# it. Note that a same-PUT red+blue (e.g. an admin filling both
|
||||
# sides) flows `* → executed → reviewed_by_blue` and lands on
|
||||
# `reviewed_by_blue`. The /transition endpoint stays for back-fill
|
||||
# but is no longer the primary path.
|
||||
red_touched = bool(touched & _RED_FIELDS)
|
||||
blue_touched = bool(touched & _BLUE_FIELDS)
|
||||
if red_touched:
|
||||
if test.executed_at is None:
|
||||
test.executed_at = datetime.now(tz=timezone.utc)
|
||||
test.executed_at_overridden = False
|
||||
test.state = "executed"
|
||||
if blue_touched and test.state == "executed":
|
||||
test.state = "reviewed_by_blue"
|
||||
|
||||
_touch(test, viewer_id)
|
||||
s.flush()
|
||||
s.refresh(test)
|
||||
return _to_detail_view(s, mission_id, test)
|
||||
|
||||
|
||||
def transition_mission_test(
|
||||
mission_id: uuid.UUID,
|
||||
test_id: uuid.UUID,
|
||||
target_state: str,
|
||||
*,
|
||||
viewer_id: uuid.UUID,
|
||||
viewer_is_admin: bool,
|
||||
has_red_perm: bool,
|
||||
has_blue_perm: bool,
|
||||
) -> MissionTestDetailView:
|
||||
"""Drive the test through its lifecycle and side-effect `executed_at`.
|
||||
|
||||
Transitioning *into* `executed` stamps `executed_at = now()` and clears
|
||||
the override flag — the deliberate red-side action commits the timeline.
|
||||
Transitioning *out of* `executed` (to `pending`) clears the timestamp so
|
||||
a re-execution starts from a clean slate.
|
||||
"""
|
||||
_ensure_state(target_state)
|
||||
|
||||
with session_scope() as s:
|
||||
_ensure_mission_visible(s, mission_id, viewer_id, viewer_is_admin)
|
||||
test = _load_test(s, mission_id, test_id)
|
||||
|
||||
# Perm gate runs BEFORE the idempotency short-circuit. A blue-only
|
||||
# user POSTing target_state="executed" while the test is already
|
||||
# executed must NOT get a 200 — it would falsely advertise that they
|
||||
# hold the red-side perm. We resolve the would-be transition's side
|
||||
# (or, on a no-op, fall back to the source side which originally
|
||||
# produced the state) and enforce it before any response shape.
|
||||
allowed = _VALID_TRANSITIONS.get(test.state, frozenset())
|
||||
if test.state != target_state and target_state not in allowed:
|
||||
raise InvalidTestTransition(
|
||||
f"cannot transition test from {test.state!r} to {target_state!r}"
|
||||
)
|
||||
|
||||
side: str | None
|
||||
if test.state == target_state:
|
||||
# Idempotent path: require the perm the *forward* transition
|
||||
# would have needed. For terminal-states (already executed →
|
||||
# executed), this is the side that *brought* the test here.
|
||||
side = _IDEMPOTENT_SIDE.get(target_state)
|
||||
else:
|
||||
side = _TRANSITION_SIDE.get((test.state, target_state))
|
||||
|
||||
if not viewer_is_admin and side is not None:
|
||||
if side == "red" and not has_red_perm:
|
||||
raise MissingFieldPermission(
|
||||
"mission.write_red_fields required for this transition"
|
||||
)
|
||||
if side == "blue" and not has_blue_perm:
|
||||
raise MissingFieldPermission(
|
||||
"mission.write_blue_fields required for this transition"
|
||||
)
|
||||
if side == "any" and not (has_red_perm or has_blue_perm):
|
||||
raise MissingFieldPermission(
|
||||
"either mission.write_red_fields or mission.write_blue_fields "
|
||||
"is required"
|
||||
)
|
||||
|
||||
if test.state == target_state:
|
||||
# Genuine no-op: idempotent 200 with the current snapshot.
|
||||
return _to_detail_view(s, mission_id, test)
|
||||
|
||||
if target_state == "executed":
|
||||
test.executed_at = datetime.now(tz=timezone.utc)
|
||||
test.executed_at_overridden = False
|
||||
elif target_state == "pending":
|
||||
# Returning to pending wipes the execution timestamp so a re-run
|
||||
# starts clean. Notes/comments are preserved (history value).
|
||||
test.executed_at = None
|
||||
test.executed_at_overridden = False
|
||||
|
||||
test.state = target_state
|
||||
_touch(test, viewer_id)
|
||||
s.flush()
|
||||
s.refresh(test)
|
||||
return _to_detail_view(s, mission_id, test)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EvidenceView",
|
||||
"MissionTestDetailView",
|
||||
"MissionTestMitreTagView",
|
||||
"ActivityEntryView",
|
||||
"MissionTestNotFound",
|
||||
"InvalidTestTransition",
|
||||
"MissingFieldPermission",
|
||||
"InvalidTestPayload",
|
||||
"get_mission_test",
|
||||
"list_activity_since",
|
||||
"update_mission_test_fields",
|
||||
"transition_mission_test",
|
||||
"_touch",
|
||||
"_load_test",
|
||||
"_ensure_mission_visible",
|
||||
"_to_detail_view",
|
||||
"_to_evidence_view",
|
||||
]
|
||||
|
||||
|
||||
# Re-export — used by `app/api/missions.py` to wire the
|
||||
# 404 handling without importing the originals from M6 in two places.
|
||||
__all__ += ["MissionNotFound"]
|
||||
@@ -129,6 +129,24 @@ class MissionTestView:
|
||||
executed_at_overridden: bool
|
||||
mitre_tags: list[MissionMitreTagView]
|
||||
source_test_template_id: uuid.UUID | None
|
||||
# Annotation fields are surfaced here so the mission detail page can
|
||||
# render the full scenario table without a per-test round trip (the
|
||||
# batch lookups for detection_level_key + last_actor below stay O(1)).
|
||||
red_command: str | None
|
||||
red_output: str | None
|
||||
red_comment_md: str | None
|
||||
blue_comment_md: str | None
|
||||
detection_level_id: uuid.UUID | None
|
||||
detection_level_key: str | None
|
||||
blue_log_source: str | None
|
||||
blue_siem_logs: str | None
|
||||
blue_incident_at: datetime | None
|
||||
blue_incident_number: str | None
|
||||
blue_incident_recipient_email: str | None
|
||||
last_actor_id: uuid.UUID | None
|
||||
last_actor_email: str | None
|
||||
last_actor_display_name: str | None
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -530,14 +548,60 @@ def _member_views(s: Session, mission: Mission) -> list[MissionMemberView]:
|
||||
return out
|
||||
|
||||
|
||||
def _scenario_views(scenarios: list[MissionScenario]) -> list[MissionScenarioView]:
|
||||
def _scenario_views(
|
||||
s: Session, 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."""
|
||||
don't drift the rendered list silently.
|
||||
|
||||
The annotation fields (red/blue review state) are surfaced too so the
|
||||
front-end scenario table renders in a single GET. To keep the call O(1)
|
||||
in the number of tests, the detection-level keys and last-actor labels
|
||||
are batch-loaded.
|
||||
"""
|
||||
from app.models.auth import User as _User # noqa: PLC0415 — local import to avoid a cycle
|
||||
from app.models.setting import DetectionLevel as _DetectionLevel # noqa: PLC0415
|
||||
|
||||
live_scenarios = [sc for sc in scenarios if sc.deleted_at is None]
|
||||
if not live_scenarios:
|
||||
return []
|
||||
|
||||
# Collect every (test) annotation FK upfront so we can batch the two
|
||||
# extra queries (detection levels + last-actor users) instead of doing
|
||||
# one s.get() per row.
|
||||
level_ids: set[uuid.UUID] = set()
|
||||
actor_ids: set[uuid.UUID] = set()
|
||||
for sc in live_scenarios:
|
||||
for t in sc.tests:
|
||||
if t.deleted_at is not None:
|
||||
continue
|
||||
if t.detection_level_id is not None:
|
||||
level_ids.add(t.detection_level_id)
|
||||
if t.last_actor_id is not None:
|
||||
actor_ids.add(t.last_actor_id)
|
||||
|
||||
level_keys: dict[uuid.UUID, str] = {}
|
||||
if level_ids:
|
||||
for row in s.execute(
|
||||
select(_DetectionLevel.id, _DetectionLevel.key).where(
|
||||
_DetectionLevel.id.in_(level_ids)
|
||||
)
|
||||
).all():
|
||||
level_keys[row.id] = row.key
|
||||
|
||||
actors: dict[uuid.UUID, tuple[str, str | None]] = {}
|
||||
if actor_ids:
|
||||
for row in s.execute(
|
||||
select(_User.id, _User.email, _User.display_name).where(
|
||||
_User.id.in_(actor_ids)
|
||||
)
|
||||
).all():
|
||||
actors[row.id] = (row.email, row.display_name)
|
||||
|
||||
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):
|
||||
for sc in sorted(live_scenarios, 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):
|
||||
@@ -552,6 +616,10 @@ def _scenario_views(scenarios: list[MissionScenario]) -> list[MissionScenarioVie
|
||||
t.mitre_tags, key=lambda tg: (tg.mitre_kind, tg.mitre_external_id)
|
||||
)
|
||||
]
|
||||
actor_email: str | None = None
|
||||
actor_display: str | None = None
|
||||
if t.last_actor_id is not None and t.last_actor_id in actors:
|
||||
actor_email, actor_display = actors[t.last_actor_id]
|
||||
test_views.append(
|
||||
MissionTestView(
|
||||
id=t.id,
|
||||
@@ -571,6 +639,25 @@ def _scenario_views(scenarios: list[MissionScenario]) -> list[MissionScenarioVie
|
||||
executed_at_overridden=t.executed_at_overridden,
|
||||
mitre_tags=tag_views,
|
||||
source_test_template_id=t.source_test_template_id,
|
||||
red_command=t.red_command,
|
||||
red_output=t.red_output,
|
||||
red_comment_md=t.red_comment_md,
|
||||
blue_comment_md=t.blue_comment_md,
|
||||
detection_level_id=t.detection_level_id,
|
||||
detection_level_key=(
|
||||
level_keys.get(t.detection_level_id)
|
||||
if t.detection_level_id
|
||||
else None
|
||||
),
|
||||
blue_log_source=t.blue_log_source,
|
||||
blue_siem_logs=t.blue_siem_logs,
|
||||
blue_incident_at=t.blue_incident_at,
|
||||
blue_incident_number=t.blue_incident_number,
|
||||
blue_incident_recipient_email=t.blue_incident_recipient_email,
|
||||
last_actor_id=t.last_actor_id,
|
||||
last_actor_email=actor_email,
|
||||
last_actor_display_name=actor_display,
|
||||
updated_at=t.updated_at,
|
||||
)
|
||||
)
|
||||
views.append(
|
||||
@@ -589,7 +676,7 @@ def _scenario_views(scenarios: list[MissionScenario]) -> list[MissionScenarioVie
|
||||
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)
|
||||
scenario_views = _scenario_views(s, scenarios)
|
||||
tests_count = sum(len(sc.tests) for sc in scenario_views)
|
||||
return MissionView(
|
||||
id=m.id,
|
||||
|
||||
1156
backend/tests/test_mission_tests.py
Normal file
1156
backend/tests/test_mission_tests.py
Normal file
File diff suppressed because it is too large
Load Diff
436
e2e/tests/m7-execution.spec.ts
Normal file
436
e2e/tests/m7-execution.spec.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M7 — Red/blue execution on a mission test.
|
||||
*
|
||||
* Scope (cf. tasks/spec.md §M7):
|
||||
* - Field-level perm gating (write_red_fields vs write_blue_fields).
|
||||
* - State machine transitions and side-effect on `executed_at`.
|
||||
* - Evidence upload: 24 MB ok, 26 MB rejected, SHA256 verified.
|
||||
* - Activity polling endpoint surfaces the last actor.
|
||||
* - SPA: the per-test page exposes both zones, accepts a small file via the
|
||||
* dropzone, and shows the "modified by X" badge after a write.
|
||||
*
|
||||
* `afterAll` restores `admin@metamorph.local` / `AdminPass1234!` and re-syncs
|
||||
* MITRE so subsequent manual sessions are not staring at an empty stack.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = `m7-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 login(
|
||||
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 inviteUser(
|
||||
request: APIRequestContext,
|
||||
adminAuth: Record<string, string>,
|
||||
prefix: string,
|
||||
groupCodes: string[],
|
||||
): Promise<{ email: string; password: string; token: string; id: string }> {
|
||||
const grp = await request.post('/api/v1/groups', {
|
||||
headers: adminAuth,
|
||||
data: { name: `${prefix}-${crypto.randomUUID().slice(0, 4)}` },
|
||||
});
|
||||
expect(grp.status()).toBe(201);
|
||||
const grpId = (await grp.json()).id as string;
|
||||
const setPerms = await request.put(`/api/v1/groups/${grpId}/permissions`, {
|
||||
headers: adminAuth,
|
||||
data: { codes: groupCodes },
|
||||
});
|
||||
expect(setPerms.status()).toBe(200);
|
||||
const email = `${prefix}-${crypto.randomUUID().slice(0, 6)}@metamorph.local`;
|
||||
const inv = await request.post('/api/v1/invitations', {
|
||||
headers: adminAuth,
|
||||
data: { email_hint: email, group_ids: [grpId] },
|
||||
});
|
||||
expect(inv.status()).toBe(201);
|
||||
const inviteToken = (await inv.json()).token as string;
|
||||
const password = 'Pass1234!';
|
||||
const accept = await request.post(
|
||||
`/api/v1/invitations/accept/${inviteToken}`,
|
||||
{ data: { email, password } },
|
||||
);
|
||||
expect(accept.status()).toBe(201);
|
||||
const token = await login(request, email, password);
|
||||
const me = await request.get('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(me.status()).toBe(200);
|
||||
return { email, password, token, id: (await me.json()).id as string };
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('M7 — Test execution', () => {
|
||||
let templateId = '';
|
||||
let scenarioId = '';
|
||||
|
||||
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 login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const sync = await request.post('/api/v1/mitre/sync', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(sync.status()).toBe(200);
|
||||
const auth = { Authorization: `Bearer ${access}` };
|
||||
|
||||
const t = await request.post('/api/v1/test-templates', {
|
||||
headers: auth,
|
||||
data: {
|
||||
name: 'm7-test',
|
||||
description: 'auto',
|
||||
objective: 'do thing',
|
||||
procedure_md: '# steps',
|
||||
expected_result_red_md: 'red expects',
|
||||
expected_detection_blue_md: 'blue expects',
|
||||
opsec_level: 'medium',
|
||||
tags: [],
|
||||
expected_iocs: [],
|
||||
mitre_tags: [{ kind: 'technique', external_id: 'T1059' }],
|
||||
},
|
||||
});
|
||||
expect(t.status()).toBe(201);
|
||||
templateId = (await t.json()).id as string;
|
||||
|
||||
const sc = await request.post('/api/v1/scenario-templates', {
|
||||
headers: auth,
|
||||
data: {
|
||||
name: 'm7-scenario',
|
||||
description: 'auto',
|
||||
test_template_ids: [templateId],
|
||||
},
|
||||
});
|
||||
expect(sc.status()).toBe(201);
|
||||
scenarioId = (await sc.json()).id as string;
|
||||
});
|
||||
|
||||
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 login(
|
||||
request,
|
||||
'admin@metamorph.local',
|
||||
'AdminPass1234!',
|
||||
);
|
||||
await request.post('/api/v1/mitre/sync', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// API — field-level perm gating + state machine
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('red-only user cannot write blue fields; blue-only user cannot write red', async ({
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
|
||||
const red = await inviteUser(request, adminAuth, 'red', [
|
||||
'mission.read',
|
||||
'mission.create',
|
||||
'mission.write_red_fields',
|
||||
'detection_level.read',
|
||||
]);
|
||||
const blue = await inviteUser(request, adminAuth, 'blue', [
|
||||
'mission.read',
|
||||
'mission.write_blue_fields',
|
||||
'detection_level.read',
|
||||
]);
|
||||
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: {
|
||||
name: 'm7-fields',
|
||||
scenario_template_ids: [scenarioId],
|
||||
members: [
|
||||
{ user_id: red.id, role_hint: 'red' },
|
||||
{ user_id: blue.id, role_hint: 'blue' },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(mission.status()).toBe(201);
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
const redCannotBlue = await request.put(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: { blue_comment_md: 'should be blocked' },
|
||||
},
|
||||
);
|
||||
expect(redCannotBlue.status()).toBe(403);
|
||||
|
||||
const blueCannotRed = await request.put(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${blue.token}` },
|
||||
data: { red_command: 'should be blocked' },
|
||||
},
|
||||
);
|
||||
expect(blueCannotRed.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('mark-executed stamps executed_at and gates reviewed_by_blue to blue side', async ({
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
const red = await inviteUser(request, adminAuth, 'red', [
|
||||
'mission.read',
|
||||
'mission.create',
|
||||
'mission.write_red_fields',
|
||||
]);
|
||||
const blue = await inviteUser(request, adminAuth, 'blue', [
|
||||
'mission.read',
|
||||
'mission.write_blue_fields',
|
||||
]);
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: {
|
||||
name: 'm7-state',
|
||||
scenario_template_ids: [scenarioId],
|
||||
members: [
|
||||
{ user_id: red.id, role_hint: 'red' },
|
||||
{ user_id: blue.id, role_hint: 'blue' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
const execute = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: { target_state: 'executed' },
|
||||
},
|
||||
);
|
||||
expect(execute.status()).toBe(200);
|
||||
const executedBody = await execute.json();
|
||||
expect(executedBody.state).toBe('executed');
|
||||
expect(executedBody.executed_at).not.toBeNull();
|
||||
|
||||
// Red cannot review_by_blue.
|
||||
const redReview = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: { target_state: 'reviewed_by_blue' },
|
||||
},
|
||||
);
|
||||
expect(redReview.status()).toBe(403);
|
||||
|
||||
const blueReview = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/transition`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${blue.token}` },
|
||||
data: { target_state: 'reviewed_by_blue' },
|
||||
},
|
||||
);
|
||||
expect(blueReview.status()).toBe(200);
|
||||
expect((await blueReview.json()).state).toBe('reviewed_by_blue');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// API — evidence upload
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('evidence upload — 24 MB accepted, 26 MB rejected, SHA256 verified', async ({
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
const blue = await inviteUser(request, adminAuth, 'blue', [
|
||||
'mission.read',
|
||||
'mission.write_blue_fields',
|
||||
]);
|
||||
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: adminAuth,
|
||||
data: {
|
||||
name: 'm7-evidence',
|
||||
scenario_template_ids: [scenarioId],
|
||||
members: [{ user_id: blue.id, role_hint: 'blue' }],
|
||||
},
|
||||
});
|
||||
expect(mission.status()).toBe(201);
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
const headerOk = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const tail24 = Buffer.alloc(24 * 1024 * 1024 - headerOk.length, 0x41);
|
||||
const file24 = Buffer.concat([headerOk, tail24]);
|
||||
const expected = await crypto.subtle.digest('SHA-256', file24);
|
||||
const expectedHex = Array.from(new Uint8Array(expected))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
const ok = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/evidence`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${blue.token}` },
|
||||
multipart: {
|
||||
file: {
|
||||
name: 'lab.evtx',
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: file24,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(ok.status()).toBe(201);
|
||||
const body = await ok.json();
|
||||
expect(body.size_bytes).toBe(file24.length);
|
||||
expect(body.sha256).toBe(expectedHex);
|
||||
|
||||
const file26 = Buffer.concat([
|
||||
headerOk,
|
||||
Buffer.alloc(26 * 1024 * 1024 - headerOk.length, 0x41),
|
||||
]);
|
||||
const tooBig = await request.post(
|
||||
`/api/v1/missions/${m.id}/tests/${testId}/evidence`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${blue.token}` },
|
||||
multipart: {
|
||||
file: {
|
||||
name: 'huge.evtx',
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: file26,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(tooBig.status()).toBe(400);
|
||||
expect((await tooBig.json()).error).toBe('too_large');
|
||||
|
||||
const evictGet = await request.get(
|
||||
`/api/v1/evidence/${body.id}?download=true`,
|
||||
{ headers: { Authorization: `Bearer ${blue.token}` } },
|
||||
);
|
||||
expect(evictGet.status()).toBe(200);
|
||||
const dlBytes = await evictGet.body();
|
||||
expect(dlBytes.length).toBe(file24.length);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SPA — per-test page edits & uploads
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('SPA — per-test page: red comment save bumps activity badge', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
const red = await inviteUser(request, adminAuth, 'red', [
|
||||
'mission.read',
|
||||
'mission.create',
|
||||
'mission.update',
|
||||
'mission.write_red_fields',
|
||||
'detection_level.read',
|
||||
]);
|
||||
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: { Authorization: `Bearer ${red.token}` },
|
||||
data: {
|
||||
name: 'm7-spa',
|
||||
scenario_template_ids: [scenarioId],
|
||||
members: [{ user_id: red.id, role_hint: 'red' }],
|
||||
},
|
||||
});
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
// Log the SPA in as the red user.
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(red.email);
|
||||
await page.getByLabel(/password/i).fill(red.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(red.email);
|
||||
|
||||
await page.goto(`/missions/${m.id}/tests/${testId}`);
|
||||
await expect(page.getByTestId('mission-test-page')).toBeVisible();
|
||||
await expect(page.getByTestId('state-pill')).toContainText(/Not started/);
|
||||
|
||||
// Fill the red command + comment, then save. Post-amendement 2026-05-15
|
||||
// bis: writing red fields implicitly promotes the state — no transition
|
||||
// button click required.
|
||||
await page.getByTestId('red-command').fill('whoami /priv');
|
||||
await page.getByTestId('red-comment').fill('Verified locally');
|
||||
await page.getByTestId('red-save').click();
|
||||
|
||||
await expect(page.getByTestId('state-pill')).toContainText(/Awaiting review/);
|
||||
|
||||
// The "last touched" line should now mention the red user.
|
||||
await expect(page.locator('text=/Last touched/')).toBeVisible();
|
||||
});
|
||||
|
||||
test('SPA — non-member sees 404 message instead of mission content', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const adminAccess = await login(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const adminAuth = { Authorization: `Bearer ${adminAccess}` };
|
||||
const owner = await inviteUser(request, adminAuth, 'own', [
|
||||
'mission.read',
|
||||
'mission.create',
|
||||
'mission.write_red_fields',
|
||||
]);
|
||||
const outsider = await inviteUser(request, adminAuth, 'out', [
|
||||
'mission.read',
|
||||
]);
|
||||
|
||||
const mission = await request.post('/api/v1/missions', {
|
||||
headers: { Authorization: `Bearer ${owner.token}` },
|
||||
data: {
|
||||
name: 'm7-private',
|
||||
scenario_template_ids: [scenarioId],
|
||||
},
|
||||
});
|
||||
const m = await mission.json();
|
||||
const testId = m.scenarios[0].tests[0].id as string;
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(outsider.email);
|
||||
await page.getByLabel(/password/i).fill(outsider.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(outsider.email);
|
||||
|
||||
await page.goto(`/missions/${m.id}/tests/${testId}`);
|
||||
await expect(
|
||||
page.locator('text=/Mission test not found/'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { HomePage } from '@/pages/HomePage';
|
||||
import { MitrePage } from '@/pages/MitrePage';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { MissionDetailPage } from '@/pages/MissionDetailPage';
|
||||
import { MissionTestPage } from '@/pages/MissionTestPage';
|
||||
import { MissionsCreatePage } from '@/pages/MissionsCreatePage';
|
||||
import { MissionsListPage } from '@/pages/MissionsListPage';
|
||||
import { ProfilePage } from '@/pages/ProfilePage';
|
||||
@@ -87,6 +88,14 @@ function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/missions/:id/tests/:testId"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<MissionTestPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
|
||||
@@ -51,6 +51,23 @@ export interface MissionTest {
|
||||
executed_at_overridden: boolean;
|
||||
mitre_tags: MissionMitreTag[];
|
||||
source_test_template_id: string | null;
|
||||
// Annotation fields surfaced server-side post-M7 so the scenario table
|
||||
// renders in a single GET (cf. CHANGELOG 2026-05-15 amendment).
|
||||
red_command: string | null;
|
||||
red_output: string | null;
|
||||
red_comment_md: string | null;
|
||||
blue_comment_md: string | null;
|
||||
detection_level_id: string | null;
|
||||
detection_level_key: string | null;
|
||||
blue_log_source: string | null;
|
||||
blue_siem_logs: string | null;
|
||||
blue_incident_at: string | null;
|
||||
blue_incident_number: string | null;
|
||||
blue_incident_recipient_email: string | null;
|
||||
last_actor_id: string | null;
|
||||
last_actor_email: string | null;
|
||||
last_actor_display_name: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MissionScenario {
|
||||
@@ -165,3 +182,168 @@ export const MISSION_STATUS_LABEL: Record<MissionStatus, string> = {
|
||||
completed: 'Completed',
|
||||
archived: 'Archived',
|
||||
};
|
||||
|
||||
// =========================================================================== //
|
||||
// M7 — per-test execution
|
||||
// =========================================================================== //
|
||||
|
||||
export interface DetectionLevel {
|
||||
id: string;
|
||||
key: string;
|
||||
label_fr: string;
|
||||
label_en: string;
|
||||
color_token: string;
|
||||
position: number;
|
||||
is_default: boolean;
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface DetectionLevelList {
|
||||
items: DetectionLevel[];
|
||||
}
|
||||
|
||||
export interface MissionTestEvidence {
|
||||
id: string;
|
||||
mission_test_id: string;
|
||||
sha256: string;
|
||||
mime: string;
|
||||
size_bytes: number;
|
||||
original_filename: string;
|
||||
uploaded_by_user_id: string | null;
|
||||
uploaded_by_email: string | null;
|
||||
uploaded_by_display_name: string | null;
|
||||
uploaded_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MissionTestDetail {
|
||||
id: string;
|
||||
mission_id: string;
|
||||
scenario_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;
|
||||
red_command: string | null;
|
||||
red_output: string | null;
|
||||
red_comment_md: string | null;
|
||||
blue_comment_md: string | null;
|
||||
detection_level_id: string | null;
|
||||
detection_level_key: string | null;
|
||||
blue_log_source: string | null;
|
||||
blue_siem_logs: string | null;
|
||||
blue_incident_at: string | null;
|
||||
blue_incident_number: string | null;
|
||||
blue_incident_recipient_email: string | null;
|
||||
last_actor_id: string | null;
|
||||
last_actor_email: string | null;
|
||||
last_actor_display_name: string | null;
|
||||
updated_at: string;
|
||||
mitre_tags: MissionMitreTag[];
|
||||
evidence: MissionTestEvidence[];
|
||||
}
|
||||
|
||||
export interface UpdateMissionTestPayload {
|
||||
red_command?: string | null;
|
||||
red_output?: string | null;
|
||||
red_comment_md?: string | null;
|
||||
blue_comment_md?: string | null;
|
||||
detection_level_id?: string | null;
|
||||
executed_at?: string | null;
|
||||
executed_at_overridden?: boolean;
|
||||
blue_log_source?: string | null;
|
||||
blue_siem_logs?: string | null;
|
||||
blue_incident_at?: string | null;
|
||||
blue_incident_number?: string | null;
|
||||
blue_incident_recipient_email?: string | null;
|
||||
}
|
||||
|
||||
export interface TestTransitionPayload {
|
||||
target_state: MissionTestState;
|
||||
}
|
||||
|
||||
export interface ActivityEntry {
|
||||
test_id: string;
|
||||
scenario_id: string;
|
||||
state: MissionTestState;
|
||||
updated_at: string;
|
||||
last_actor_id: string | null;
|
||||
last_actor_email: string | null;
|
||||
last_actor_display_name: string | null;
|
||||
}
|
||||
|
||||
export interface ActivityResponse {
|
||||
items: ActivityEntry[];
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
export const missionTestKeys = {
|
||||
detail: (missionId: string, testId: string) =>
|
||||
['missions', 'detail', missionId, 'tests', testId] as const,
|
||||
activity: (missionId: string) => ['missions', missionId, 'activity'] as const,
|
||||
detectionLevels: () => ['detection-levels'] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Evidence upload constraints. Mirror of the backend whitelist in
|
||||
* `app/services/evidence.py` — kept in sync by hand because exposing the
|
||||
* full whitelist via an endpoint would be a one-trip-too-many on every
|
||||
* page mount. If the backend list changes, update this array too.
|
||||
*/
|
||||
export const EVIDENCE_ALLOWED_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.log',
|
||||
'.json',
|
||||
'.csv',
|
||||
'.evtx',
|
||||
'.zip',
|
||||
] as const;
|
||||
|
||||
export const EVIDENCE_MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
// Post-amendement 2026-05-15 bis: the explicit workflow is gone from the
|
||||
// UI — the state column survives in the DB but the labels describe the
|
||||
// implicit lifecycle (driven by which side has written data).
|
||||
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
|
||||
pending: 'Not started',
|
||||
executed: 'Awaiting review',
|
||||
reviewed_by_blue: 'Reviewed',
|
||||
skipped: 'Skipped',
|
||||
blocked: 'Blocked',
|
||||
};
|
||||
|
||||
export const MISSION_TEST_STATE_ACCENT: Record<
|
||||
MissionTestState,
|
||||
'teal' | 'orange' | 'green' | 'rose' | 'red'
|
||||
> = {
|
||||
pending: 'teal',
|
||||
executed: 'orange',
|
||||
reviewed_by_blue: 'green',
|
||||
skipped: 'rose',
|
||||
blocked: 'red',
|
||||
};
|
||||
|
||||
// Front-end mirror of the backend state-machine matrix so the UI only renders
|
||||
// the transitions the server will accept. Keep this in sync with
|
||||
// `app/services/mission_tests.py::_VALID_TRANSITIONS`.
|
||||
export const VALID_TEST_TRANSITIONS: Record<MissionTestState, MissionTestState[]> = {
|
||||
pending: ['executed', 'skipped', 'blocked'],
|
||||
executed: ['reviewed_by_blue', 'pending'],
|
||||
reviewed_by_blue: ['executed'],
|
||||
skipped: ['pending'],
|
||||
blocked: ['pending'],
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ export function HomePage() {
|
||||
<span className="text-purple">Purple Team Platform</span>
|
||||
</h1>
|
||||
<p className="font-mono text-sm font-light text-text-dim mt-2">
|
||||
Collaborative red & blue test orchestration — M6 milestone (Missions & snapshot)
|
||||
Collaborative red & blue test orchestration — M7 milestone (Red & blue execution)
|
||||
</p>
|
||||
</header>
|
||||
<SectionHeader
|
||||
@@ -141,9 +141,9 @@ export function HomePage() {
|
||||
|
||||
<Card accent="purple" title="Roadmap" sub="14 milestones">
|
||||
<p>
|
||||
M0 + M1 + M2 + M3 + M4 + M5 + M6 done. Next:{' '}
|
||||
M0 + M1 + M2 + M3 + M4 + M5 + M6 + M7 done. Next:{' '}
|
||||
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
|
||||
M7 — Red & blue execution on a mission test
|
||||
M8 — Custom detection-level taxonomy
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
MISSION_STATUS_ACCENT,
|
||||
MISSION_STATUS_LABEL,
|
||||
missionKeys,
|
||||
missionTestKeys,
|
||||
type AddScenariosPayload,
|
||||
type DetectionLevelList,
|
||||
type MemberPayload,
|
||||
type Mission,
|
||||
type MissionRoleHint,
|
||||
@@ -25,6 +27,90 @@ import {
|
||||
type TransitionPayload,
|
||||
type UpdateMissionPayload,
|
||||
} from '@/lib/missions';
|
||||
import { MissionScenarioTable } from '@/pages/MissionScenarioTable';
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Tests tab — full-bleed scenario tables //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
interface FullBleedTestsProps {
|
||||
mission: Mission;
|
||||
canEdit: boolean;
|
||||
canWriteRed: boolean;
|
||||
canWriteBlue: boolean;
|
||||
onAddScenarios: () => void;
|
||||
}
|
||||
|
||||
function FullBleedTests({
|
||||
mission,
|
||||
canEdit,
|
||||
canWriteRed,
|
||||
canWriteBlue,
|
||||
onAddScenarios,
|
||||
}: FullBleedTestsProps) {
|
||||
// A single row across the whole mission is in edit mode at a time so the
|
||||
// user never juggles two unsaved drafts (spec §F6 amendement 2026-05-15).
|
||||
const [editingTestId, setEditingTestId] = useState<string | null>(null);
|
||||
const detectionLevels = useQuery({
|
||||
queryKey: missionTestKeys.detectionLevels(),
|
||||
queryFn: () => apiGet<DetectionLevelList>('/detection-levels'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
Double-click a row to edit · server is the perm arbiter ·
|
||||
snapshots stay frozen at append time.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Button
|
||||
accent="cyan"
|
||||
onClick={onAddScenarios}
|
||||
data-testid="mission-add-scenarios"
|
||||
>
|
||||
+ Add scenarios
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{mission.scenarios.length === 0 ? (
|
||||
<p className="font-mono text-xs text-text-dim">
|
||||
No scenarios snapshotted yet.
|
||||
{canEdit && ' Click "Add scenarios" to append one.'}
|
||||
</p>
|
||||
) : (
|
||||
// Full-bleed escape from the layout's max-w-page (same recipe as
|
||||
// the MITRE picker). Lets the 7-column table breathe on wide
|
||||
// screens without forcing a horizontal scroll.
|
||||
<div
|
||||
className="px-[60px]"
|
||||
style={{
|
||||
marginLeft: 'calc(50% - 50vw)',
|
||||
marginRight: 'calc(50% - 50vw)',
|
||||
width: '100vw',
|
||||
}}
|
||||
data-testid="mission-scenarios"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{mission.scenarios.map((sc) => (
|
||||
<MissionScenarioTable
|
||||
key={sc.id}
|
||||
missionId={mission.id}
|
||||
scenario={sc}
|
||||
detectionLevels={detectionLevels.data?.items ?? []}
|
||||
canWriteRed={canWriteRed}
|
||||
canWriteBlue={canWriteBlue}
|
||||
editingTestId={editingTestId}
|
||||
onEditRequest={setEditingTestId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import type {
|
||||
ScenarioTemplate,
|
||||
ScenarioTemplateListResponse,
|
||||
@@ -625,104 +711,19 @@ export function MissionDetailPage() {
|
||||
</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>
|
||||
<FullBleedTests
|
||||
mission={m}
|
||||
canEdit={canEdit}
|
||||
canWriteRed={
|
||||
state.user?.is_admin === true ||
|
||||
state.user?.permissions.includes('mission.write_red_fields') === true
|
||||
}
|
||||
canWriteBlue={
|
||||
state.user?.is_admin === true ||
|
||||
state.user?.permissions.includes('mission.write_blue_fields') === true
|
||||
}
|
||||
onAddScenarios={() => setAddScenarios(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'members' && (
|
||||
|
||||
597
frontend/src/pages/MissionScenarioTable.tsx
Normal file
597
frontend/src/pages/MissionScenarioTable.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* Full-bleed scenario table with inline edit (spec §F6, amendement 2026-05-15).
|
||||
*
|
||||
* One table per scenario. Double-click a row to enter edit mode — a *single*
|
||||
* row in edit mode at a time, Esc cancels, "Save" commits, double-clicking
|
||||
* another row while dirty prompts to save first.
|
||||
*
|
||||
* Cells in edit mode are gated by the viewer's red/blue perms:
|
||||
* - `Exécution` (executed_at + red_command) → mission.write_red_fields
|
||||
* - everything else → mission.write_blue_fields
|
||||
*
|
||||
* `detection_level` lives inside the `Commentaires` cell as a select +
|
||||
* pill, not as its own column (spec §F6).
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState, type KeyboardEvent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { ApiError, apiPut } from '@/lib/api';
|
||||
import {
|
||||
MISSION_TEST_STATE_ACCENT,
|
||||
MISSION_TEST_STATE_LABEL,
|
||||
missionKeys,
|
||||
missionTestKeys,
|
||||
type DetectionLevel,
|
||||
type MissionScenario,
|
||||
type MissionTest,
|
||||
type MissionTestDetail,
|
||||
type UpdateMissionTestPayload,
|
||||
} from '@/lib/missions';
|
||||
|
||||
interface MissionScenarioTableProps {
|
||||
missionId: string;
|
||||
scenario: MissionScenario;
|
||||
detectionLevels: DetectionLevel[];
|
||||
canWriteRed: boolean;
|
||||
canWriteBlue: boolean;
|
||||
/** Currently-editing test id across the whole mission — managed by the
|
||||
* parent so opening edit on a row in scenario B closes any active edit
|
||||
* in scenario A. */
|
||||
editingTestId: string | null;
|
||||
onEditRequest: (testId: string | null) => void;
|
||||
}
|
||||
|
||||
// ----- helpers -------------------------------------------------------------
|
||||
|
||||
function isoToInput(iso: string | null): string {
|
||||
// Strip the time portion verbatim. No TZ shift (cf. M7 lessons).
|
||||
if (!iso) return '';
|
||||
return iso.slice(0, 16);
|
||||
}
|
||||
|
||||
function inputToIso(local: string): string | null {
|
||||
if (!local) return null;
|
||||
return `${local}:00Z`;
|
||||
}
|
||||
|
||||
function emptyToNull(s: string): string | null {
|
||||
const t = s.trim();
|
||||
return t === '' ? null : s;
|
||||
}
|
||||
|
||||
// ----- editable row state --------------------------------------------------
|
||||
|
||||
interface RowDraft {
|
||||
// Red fields
|
||||
executed_at: string; // local YYYY-MM-DDTHH:MM
|
||||
red_command: string;
|
||||
// Blue fields
|
||||
blue_log_source: string;
|
||||
blue_comment_md: string;
|
||||
detection_level_id: string;
|
||||
blue_siem_logs: string;
|
||||
blue_incident_at: string;
|
||||
blue_incident_number: string;
|
||||
blue_incident_recipient_email: string;
|
||||
}
|
||||
|
||||
function makeDraft(t: MissionTest): RowDraft {
|
||||
return {
|
||||
executed_at: isoToInput(t.executed_at),
|
||||
red_command: t.red_command ?? '',
|
||||
blue_log_source: t.blue_log_source ?? '',
|
||||
blue_comment_md: t.blue_comment_md ?? '',
|
||||
detection_level_id: t.detection_level_id ?? '',
|
||||
blue_siem_logs: t.blue_siem_logs ?? '',
|
||||
blue_incident_at: isoToInput(t.blue_incident_at),
|
||||
blue_incident_number: t.blue_incident_number ?? '',
|
||||
blue_incident_recipient_email: t.blue_incident_recipient_email ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function draftDiff(t: MissionTest, d: RowDraft): UpdateMissionTestPayload | null {
|
||||
const body: UpdateMissionTestPayload = {};
|
||||
const before = makeDraft(t);
|
||||
let dirty = false;
|
||||
if (d.executed_at !== before.executed_at) {
|
||||
body.executed_at = inputToIso(d.executed_at);
|
||||
body.executed_at_overridden = d.executed_at !== '';
|
||||
dirty = true;
|
||||
}
|
||||
if (d.red_command !== before.red_command) {
|
||||
body.red_command = emptyToNull(d.red_command);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_log_source !== before.blue_log_source) {
|
||||
body.blue_log_source = emptyToNull(d.blue_log_source);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_comment_md !== before.blue_comment_md) {
|
||||
body.blue_comment_md = emptyToNull(d.blue_comment_md);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.detection_level_id !== before.detection_level_id) {
|
||||
body.detection_level_id = d.detection_level_id || null;
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_siem_logs !== before.blue_siem_logs) {
|
||||
body.blue_siem_logs = d.blue_siem_logs === '' ? null : d.blue_siem_logs;
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_incident_at !== before.blue_incident_at) {
|
||||
body.blue_incident_at = inputToIso(d.blue_incident_at);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_incident_number !== before.blue_incident_number) {
|
||||
body.blue_incident_number = emptyToNull(d.blue_incident_number);
|
||||
dirty = true;
|
||||
}
|
||||
if (d.blue_incident_recipient_email !== before.blue_incident_recipient_email) {
|
||||
body.blue_incident_recipient_email = emptyToNull(
|
||||
d.blue_incident_recipient_email,
|
||||
);
|
||||
dirty = true;
|
||||
}
|
||||
return dirty ? body : null;
|
||||
}
|
||||
|
||||
// ----- read-mode cell helpers ---------------------------------------------
|
||||
|
||||
function truncate(s: string | null, n: number): string {
|
||||
if (!s) return '—';
|
||||
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
||||
}
|
||||
|
||||
function ExecutionCell({ test }: { test: MissionTest }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<code className="font-mono text-3xs text-text-bright">
|
||||
{test.executed_at ?? '—'}
|
||||
</code>
|
||||
{test.red_command && (
|
||||
<code className="font-mono text-3xs text-text-dim whitespace-pre-wrap break-all">
|
||||
{truncate(test.red_command, 140)}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentairesCell({
|
||||
test,
|
||||
detectionLevels,
|
||||
}: {
|
||||
test: MissionTest;
|
||||
detectionLevels: DetectionLevel[];
|
||||
}) {
|
||||
// Commentaires is blue-team content only — the workflow state lives in
|
||||
// the Test column (cf. user feedback 2026-05-15 ter).
|
||||
const lvl = detectionLevels.find((l) => l.id === test.detection_level_id);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{lvl && (
|
||||
<div>
|
||||
<Tag accent={(lvl.color_token as 'red') ?? 'cyan'}>{lvl.label_en}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{test.blue_comment_md && (
|
||||
<p className="font-mono text-3xs text-text whitespace-pre-wrap">
|
||||
{truncate(test.blue_comment_md, 220)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CyberIncidentCell({ test }: { test: MissionTest }) {
|
||||
if (
|
||||
!test.blue_incident_at &&
|
||||
!test.blue_incident_number &&
|
||||
!test.blue_incident_recipient_email
|
||||
) {
|
||||
return <span className="font-mono text-3xs text-text-dim">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 font-mono text-3xs">
|
||||
{test.blue_incident_at && (
|
||||
<code className="text-text-bright">{test.blue_incident_at}</code>
|
||||
)}
|
||||
{test.blue_incident_number && (
|
||||
<span className="text-text">{test.blue_incident_number}</span>
|
||||
)}
|
||||
{test.blue_incident_recipient_email && (
|
||||
<span className="text-text-dim">{test.blue_incident_recipient_email}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- table component -----------------------------------------------------
|
||||
|
||||
export function MissionScenarioTable({
|
||||
missionId,
|
||||
scenario,
|
||||
detectionLevels,
|
||||
canWriteRed,
|
||||
canWriteBlue,
|
||||
editingTestId,
|
||||
onEditRequest,
|
||||
}: MissionScenarioTableProps) {
|
||||
const qc = useQueryClient();
|
||||
const editingTest = scenario.tests.find((t) => t.id === editingTestId);
|
||||
const [draft, setDraft] = useState<RowDraft | null>(
|
||||
editingTest ? makeDraft(editingTest) : null,
|
||||
);
|
||||
const [apiErr, setApiErr] = useState<string | null>(null);
|
||||
|
||||
// Sync the draft whenever the parent flips us into edit mode for a row
|
||||
// (or out of it). We never re-derive from `scenario.tests` mid-edit so a
|
||||
// polling refetch doesn't blow the user's typing.
|
||||
useEffect(() => {
|
||||
if (editingTest) {
|
||||
setDraft(makeDraft(editingTest));
|
||||
} else {
|
||||
setDraft(null);
|
||||
}
|
||||
setApiErr(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editingTestId]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async ({
|
||||
testId,
|
||||
body,
|
||||
}: {
|
||||
testId: string;
|
||||
body: UpdateMissionTestPayload;
|
||||
}) =>
|
||||
apiPut<MissionTestDetail>(
|
||||
`/missions/${missionId}/tests/${testId}`,
|
||||
body,
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||||
if (editingTestId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: missionTestKeys.detail(missionId, editingTestId),
|
||||
});
|
||||
}
|
||||
onEditRequest(null);
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
if (e instanceof ApiError) {
|
||||
const payload = e.payload as { message?: string } | undefined;
|
||||
setApiErr(
|
||||
payload?.message
|
||||
? `${e.message} — ${payload.message}`
|
||||
: e.message,
|
||||
);
|
||||
} else {
|
||||
setApiErr(String(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function tryEnterEdit(testId: string) {
|
||||
if (!canWriteRed && !canWriteBlue) return;
|
||||
if (editingTestId && editingTestId !== testId && draft && editingTest) {
|
||||
const diff = draftDiff(editingTest, draft);
|
||||
if (diff) {
|
||||
const ok = window.confirm(
|
||||
'You have unsaved changes on another row. Discard them?',
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
onEditRequest(testId);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (draft && editingTest) {
|
||||
const diff = draftDiff(editingTest, draft);
|
||||
if (diff) {
|
||||
const ok = window.confirm('Discard unsaved changes?');
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
onEditRequest(null);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
if (!editingTest || !draft) return;
|
||||
const diff = draftDiff(editingTest, draft);
|
||||
if (!diff) {
|
||||
onEditRequest(null);
|
||||
return;
|
||||
}
|
||||
save.mutate({ testId: editingTest.id, body: diff });
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers to set a single draft field without rewriting the whole object.
|
||||
function set<K extends keyof RowDraft>(key: K, value: RowDraft[K]) {
|
||||
setDraft((d) => (d ? { ...d, [key]: value } : d));
|
||||
}
|
||||
|
||||
const colHeaderClass =
|
||||
'text-left py-2 px-2 font-mono text-3xs uppercase tracking-wider2 text-text-dim border-b border-border bg-bg-card';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-border bg-bg-card"
|
||||
data-testid={`scenario-table-${scenario.id}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 px-3 py-2 border-b border-border">
|
||||
<Tag accent="cyan">#{scenario.position + 1}</Tag>
|
||||
<h3 className="font-mono text-sm text-text-bright">
|
||||
{scenario.snapshot_name}
|
||||
</h3>
|
||||
{scenario.snapshot_description && (
|
||||
<span className="font-mono text-2xs text-text-dim">
|
||||
· {scenario.snapshot_description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse font-mono text-2xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${colHeaderClass} w-[7rem]`}>Test</th>
|
||||
<th className={`${colHeaderClass} w-[14rem]`}>Procédure</th>
|
||||
<th className={`${colHeaderClass} w-[12rem]`}>Exécution</th>
|
||||
<th className={`${colHeaderClass} w-[10rem]`}>Source de log</th>
|
||||
<th className={`${colHeaderClass} w-[16rem]`}>Commentaires</th>
|
||||
<th className={`${colHeaderClass} w-[20rem]`}>Logs SIEM</th>
|
||||
<th className={`${colHeaderClass} w-[12rem]`}>Cyber Incident</th>
|
||||
<th className={`${colHeaderClass} w-[8rem]`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scenario.tests.map((t) => {
|
||||
const isEditing = editingTestId === t.id;
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<tr
|
||||
key={t.id}
|
||||
className="border-t border-border/40 align-top hover:bg-bg-base/40 cursor-pointer"
|
||||
onDoubleClick={() => tryEnterEdit(t.id)}
|
||||
data-testid={`row-${t.id}`}
|
||||
>
|
||||
<td className="py-2 px-2 text-text-bright">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>
|
||||
<Tag accent={MISSION_TEST_STATE_ACCENT[t.state]}>
|
||||
{MISSION_TEST_STATE_LABEL[t.state]}
|
||||
</Tag>
|
||||
</div>
|
||||
<span>{t.snapshot_name}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.mitre_tags.slice(0, 3).map((tag) => (
|
||||
<Tag
|
||||
accent="cyan"
|
||||
key={`${tag.kind}-${tag.external_id}`}
|
||||
>
|
||||
{tag.external_id}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text whitespace-pre-wrap">
|
||||
{truncate(
|
||||
t.snapshot_objective ?? t.snapshot_description ?? '',
|
||||
180,
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-2 align-top">
|
||||
<ExecutionCell test={t} />
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text">
|
||||
{t.blue_log_source ?? '—'}
|
||||
</td>
|
||||
<td className="py-2 px-2 align-top">
|
||||
<CommentairesCell
|
||||
test={t}
|
||||
detectionLevels={detectionLevels}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text whitespace-pre-wrap break-words">
|
||||
{truncate(t.blue_siem_logs, 240)}
|
||||
</td>
|
||||
<td className="py-2 px-2 align-top">
|
||||
<CyberIncidentCell test={t} />
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text-dim">
|
||||
<Link
|
||||
to={`/missions/${missionId}/tests/${t.id}`}
|
||||
className="text-cyan hover:underline"
|
||||
data-testid={`open-test-${t.id}`}
|
||||
>
|
||||
open ↗
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (!draft) return null;
|
||||
return (
|
||||
<tr
|
||||
key={t.id}
|
||||
className="border-t border-border/40 align-top bg-bg-base/30"
|
||||
data-testid={`row-edit-${t.id}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
<td className="py-2 px-2 text-text-bright">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{t.snapshot_name}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.mitre_tags.slice(0, 3).map((tag) => (
|
||||
<Tag
|
||||
accent="cyan"
|
||||
key={`${tag.kind}-${tag.external_id}`}
|
||||
>
|
||||
{tag.external_id}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-text whitespace-pre-wrap">
|
||||
{t.snapshot_objective ?? t.snapshot_description ?? ''}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.executed_at}
|
||||
onChange={(e) => set('executed_at', e.target.value)}
|
||||
disabled={!canWriteRed}
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text-bright"
|
||||
data-testid={`cell-executed-at-${t.id}`}
|
||||
/>
|
||||
<textarea
|
||||
value={draft.red_command}
|
||||
onChange={(e) => set('red_command', e.target.value)}
|
||||
disabled={!canWriteRed}
|
||||
rows={3}
|
||||
placeholder="command"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-red-command-${t.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="text"
|
||||
value={draft.blue_log_source}
|
||||
onChange={(e) => set('blue_log_source', e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
placeholder="EDR / Firewall / NDR …"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-log-source-${t.id}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<select
|
||||
value={draft.detection_level_id}
|
||||
onChange={(e) =>
|
||||
set('detection_level_id', e.target.value)
|
||||
}
|
||||
disabled={!canWriteBlue}
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-detection-level-${t.id}`}
|
||||
>
|
||||
<option value="">— detection —</option>
|
||||
{detectionLevels.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.label_en} ({l.key})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<textarea
|
||||
value={draft.blue_comment_md}
|
||||
onChange={(e) => set('blue_comment_md', e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
rows={3}
|
||||
placeholder="blueteam comment (markdown)"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-blue-comment-${t.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<textarea
|
||||
value={draft.blue_siem_logs}
|
||||
onChange={(e) => set('blue_siem_logs', e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
rows={6}
|
||||
placeholder="raw SIEM log lines"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-siem-logs-${t.id}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draft.blue_incident_at}
|
||||
onChange={(e) => set('blue_incident_at', e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-incident-at-${t.id}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.blue_incident_number}
|
||||
onChange={(e) =>
|
||||
set('blue_incident_number', e.target.value)
|
||||
}
|
||||
disabled={!canWriteBlue}
|
||||
placeholder="INC-2026-…"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-incident-number-${t.id}`}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={draft.blue_incident_recipient_email}
|
||||
onChange={(e) =>
|
||||
set('blue_incident_recipient_email', e.target.value)
|
||||
}
|
||||
disabled={!canWriteBlue}
|
||||
placeholder="soc@…"
|
||||
className="w-full rounded border border-border bg-bg-card px-1 py-1 font-mono text-3xs text-text"
|
||||
data-testid={`cell-incident-email-${t.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2 align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
accent="green"
|
||||
onClick={commit}
|
||||
disabled={save.isPending}
|
||||
data-testid={`cell-save-${t.id}`}
|
||||
>
|
||||
{save.isPending ? '…' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
accent="teal"
|
||||
variant="ghost"
|
||||
onClick={cancel}
|
||||
data-testid={`cell-cancel-${t.id}`}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{apiErr && (
|
||||
<p className="font-mono text-3xs text-red whitespace-pre-wrap">
|
||||
{apiErr}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="px-3 py-1 font-mono text-3xs text-text-dim border-t border-border">
|
||||
Double-click a row to edit · Esc to cancel · "open ↗" for the full
|
||||
per-test page (evidence upload, full procedure)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
745
frontend/src/pages/MissionTestPage.tsx
Normal file
745
frontend/src/pages/MissionTestPage.tsx
Normal file
@@ -0,0 +1,745 @@
|
||||
/**
|
||||
* Per-test execution page (M7).
|
||||
*
|
||||
* Two zones, mirror of the spec §M7:
|
||||
* - Red zone (red border) — command, output, markdown comment, mark-executed.
|
||||
* - Blue zone (cyan border) — detection level, markdown comment, evidence dropzone.
|
||||
*
|
||||
* State transitions are driven from a small button row in the header. The
|
||||
* "modified by X Ns ago" indicator polls `/missions/{id}/activity?since=…`
|
||||
* every 15s while the page is mounted (and the document is visible).
|
||||
*
|
||||
* Field-level permissions:
|
||||
* - Admins always see and write everything.
|
||||
* - `mission.write_red_fields` enables the red-side form.
|
||||
* - `mission.write_blue_fields` enables the blue-side form + uploads.
|
||||
* The server is the ultimate arbiter (PUT/POST will 403 if a side is forbidden);
|
||||
* the UI just disables inputs the user cannot write to reduce confusion.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, 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 { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { TextField } from '@/components/ui/TextField';
|
||||
import { ApiError, apiDelete, apiFetch, apiGet, apiPut } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import {
|
||||
EVIDENCE_ALLOWED_EXTENSIONS,
|
||||
EVIDENCE_MAX_BYTES,
|
||||
MISSION_TEST_STATE_ACCENT,
|
||||
MISSION_TEST_STATE_LABEL,
|
||||
missionKeys,
|
||||
missionTestKeys,
|
||||
type ActivityResponse,
|
||||
type DetectionLevel,
|
||||
type DetectionLevelList,
|
||||
type MissionTestDetail,
|
||||
type MissionTestEvidence,
|
||||
type UpdateMissionTestPayload,
|
||||
} from '@/lib/missions';
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string | null | undefined): string {
|
||||
if (!iso) return 'never';
|
||||
const then = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
const seconds = Math.max(0, Math.floor((now - then) / 1000));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datetime helpers — zero timezone interpretation. The user types a
|
||||
* wall-clock value, the server stores it verbatim, and we display it back
|
||||
* unchanged. We never go through `new Date(...).toISOString()` because that
|
||||
* would shift the value by the browser's local offset on every render.
|
||||
*/
|
||||
function isoToInputValue(iso: string | null | undefined): string {
|
||||
// `2026-05-15T10:30:00+00:00` → `2026-05-15T10:30`.
|
||||
if (!iso) return '';
|
||||
return iso.slice(0, 16);
|
||||
}
|
||||
|
||||
function inputValueToIso(local: string): string | null {
|
||||
// `2026-05-15T10:30` → `2026-05-15T10:30:00Z`. The `Z` makes the ISO
|
||||
// unambiguous for the backend without applying any local-time shift.
|
||||
if (!local) return null;
|
||||
return `${local}:00Z`;
|
||||
}
|
||||
|
||||
function useMissionTest(missionId: string, testId: string) {
|
||||
return useQuery({
|
||||
queryKey: missionTestKeys.detail(missionId, testId),
|
||||
queryFn: () =>
|
||||
apiGet<MissionTestDetail>(`/missions/${missionId}/tests/${testId}`),
|
||||
enabled: !!missionId && !!testId,
|
||||
});
|
||||
}
|
||||
|
||||
function useDetectionLevels(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: missionTestKeys.detectionLevels(),
|
||||
queryFn: () => apiGet<DetectionLevelList>('/detection-levels'),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Activity indicator //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
function useActivityWatcher(
|
||||
missionId: string,
|
||||
testId: string,
|
||||
onTouched: () => void,
|
||||
) {
|
||||
const lastServerTimeRef = useRef<string | null>(null);
|
||||
const onTouchedRef = useRef(onTouched);
|
||||
onTouchedRef.current = onTouched;
|
||||
|
||||
useEffect(() => {
|
||||
if (!missionId) return;
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const since = lastServerTimeRef.current;
|
||||
const url =
|
||||
`/missions/${missionId}/activity` +
|
||||
(since ? `?since=${encodeURIComponent(since)}` : '');
|
||||
const res = await apiGet<ActivityResponse>(url);
|
||||
if (cancelled) return;
|
||||
lastServerTimeRef.current = res.server_time;
|
||||
// We only care about activity on the same test (the badge is local).
|
||||
if (res.items.some((it) => it.test_id === testId)) {
|
||||
onTouchedRef.current();
|
||||
}
|
||||
} catch {
|
||||
// Network blips are non-fatal — the badge just doesn't refresh.
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the timestamp without firing onTouched.
|
||||
void poll();
|
||||
const handle = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void poll();
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(handle);
|
||||
};
|
||||
}, [missionId, testId]);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Red zone //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
interface RedZoneProps {
|
||||
test: MissionTestDetail;
|
||||
missionId: string;
|
||||
canWriteRed: boolean;
|
||||
}
|
||||
|
||||
function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
|
||||
const qc = useQueryClient();
|
||||
const [command, setCommand] = useState(test.red_command ?? '');
|
||||
const [output, setOutput] = useState(test.red_output ?? '');
|
||||
const [comment, setComment] = useState(test.red_comment_md ?? '');
|
||||
// `executedAtInput` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local
|
||||
// input speaks — stored verbatim, displayed verbatim, no TZ shift on either
|
||||
// side. Conversion only happens at submit (append `:00Z`).
|
||||
const [executedAtInput, setExecutedAtInput] = useState(
|
||||
isoToInputValue(test.executed_at),
|
||||
);
|
||||
const executedAtServer = isoToInputValue(test.executed_at);
|
||||
const dirty =
|
||||
command !== (test.red_command ?? '') ||
|
||||
output !== (test.red_output ?? '') ||
|
||||
comment !== (test.red_comment_md ?? '') ||
|
||||
executedAtInput !== executedAtServer;
|
||||
|
||||
// Sync local state only when the *identity* of the test changes (route
|
||||
// change). Polling refetches return a new object reference for the same
|
||||
// test_id; resetting on those would wipe whatever the user is mid-typing.
|
||||
useEffect(() => {
|
||||
setCommand(test.red_command ?? '');
|
||||
setOutput(test.red_output ?? '');
|
||||
setComment(test.red_comment_md ?? '');
|
||||
setExecutedAtInput(isoToInputValue(test.executed_at));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [test.id]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: UpdateMissionTestPayload) =>
|
||||
apiPut<MissionTestDetail>(
|
||||
`/missions/${missionId}/tests/${test.id}`,
|
||||
body,
|
||||
),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(missionTestKeys.detail(missionId, test.id), next);
|
||||
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||||
},
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const body: UpdateMissionTestPayload = {
|
||||
red_command: command.trim() || null,
|
||||
red_output: output.length === 0 ? null : output,
|
||||
red_comment_md: comment.trim() || null,
|
||||
};
|
||||
// Any change to the executed_at field is implicitly a manual override —
|
||||
// the operator wouldn't have touched it otherwise. The override flag is
|
||||
// a backend-side bookkeeping detail and never surfaced in the UI.
|
||||
if (executedAtInput !== executedAtServer) {
|
||||
body.executed_at = inputValueToIso(executedAtInput);
|
||||
body.executed_at_overridden = executedAtInput !== '';
|
||||
}
|
||||
save.mutate(body);
|
||||
}
|
||||
|
||||
const apiErr = save.error instanceof ApiError ? save.error : null;
|
||||
// Post-amendement 2026-05-15: the backend auto-promotes the state from
|
||||
// pending/skipped/blocked when a non-null executed_at is stamped, so the
|
||||
// UI no longer needs to disable the input on those states. Red perm is
|
||||
// still required.
|
||||
const canEditExecutedAt = canWriteRed;
|
||||
|
||||
return (
|
||||
<Card accent="red" className="flex flex-col gap-3" data-testid="red-zone">
|
||||
<SectionHeader prefix="Red" highlight="Execution" accent="red" />
|
||||
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
|
||||
|
||||
{/* The execution timestamp anchors the blue team's log correlation,
|
||||
so it leads the form (cf. user feedback 2026-05-15). What the user
|
||||
types is what gets stored — no timezone interpretation. */}
|
||||
<div data-testid="red-executed-block">
|
||||
<label className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
|
||||
Executed at
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={executedAtInput}
|
||||
onChange={(e) => setExecutedAtInput(e.target.value)}
|
||||
disabled={!canEditExecutedAt}
|
||||
className="mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
|
||||
data-testid="red-executed-at"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
disabled={!canWriteRed}
|
||||
className="font-mono"
|
||||
data-testid="red-command"
|
||||
placeholder="powershell -enc ..."
|
||||
/>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
|
||||
Output
|
||||
</span>
|
||||
<textarea
|
||||
value={output}
|
||||
onChange={(e) => setOutput(e.target.value)}
|
||||
disabled={!canWriteRed}
|
||||
rows={8}
|
||||
className="w-full rounded-md border border-border bg-bg-card p-3 font-mono text-xs text-text"
|
||||
data-testid="red-output"
|
||||
placeholder="stdout / stderr capture"
|
||||
/>
|
||||
</label>
|
||||
<MarkdownField
|
||||
label="Comment"
|
||||
value={comment}
|
||||
onChange={setComment}
|
||||
disabled={!canWriteRed}
|
||||
data-testid="red-comment"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
accent="red"
|
||||
onClick={submit}
|
||||
disabled={!canWriteRed || !dirty || save.isPending}
|
||||
data-testid="red-save"
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Save red fields'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Blue zone //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
interface BlueZoneProps {
|
||||
test: MissionTestDetail;
|
||||
missionId: string;
|
||||
canWriteBlue: boolean;
|
||||
detectionLevels: DetectionLevel[];
|
||||
}
|
||||
|
||||
function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZoneProps) {
|
||||
const qc = useQueryClient();
|
||||
const [comment, setComment] = useState(test.blue_comment_md ?? '');
|
||||
const [levelId, setLevelId] = useState(test.detection_level_id ?? '');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dropError, setDropError] = useState<string | null>(null);
|
||||
|
||||
const dirty =
|
||||
comment !== (test.blue_comment_md ?? '') ||
|
||||
levelId !== (test.detection_level_id ?? '');
|
||||
|
||||
// Sync from server only on test-identity change (route nav), not on every
|
||||
// polling refetch — see RedZone for the rationale.
|
||||
useEffect(() => {
|
||||
setComment(test.blue_comment_md ?? '');
|
||||
setLevelId(test.detection_level_id ?? '');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [test.id]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: UpdateMissionTestPayload) =>
|
||||
apiPut<MissionTestDetail>(
|
||||
`/missions/${missionId}/tests/${test.id}`,
|
||||
body,
|
||||
),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(missionTestKeys.detail(missionId, test.id), next);
|
||||
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||||
},
|
||||
});
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await apiFetch(
|
||||
`/missions/${missionId}/tests/${test.id}/evidence`,
|
||||
{ method: 'POST', body: fd },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res
|
||||
.json()
|
||||
.catch(() => ({ error: 'upload_failed' as const }));
|
||||
throw new ApiError(res.status, body);
|
||||
}
|
||||
return (await res.json()) as MissionTestEvidence;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: missionTestKeys.detail(missionId, test.id),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (evidenceId: string) =>
|
||||
apiDelete<{ ok: boolean }>(`/evidence/${evidenceId}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: missionTestKeys.detail(missionId, test.id),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||||
},
|
||||
});
|
||||
|
||||
function submit() {
|
||||
save.mutate({
|
||||
blue_comment_md: comment.trim() || null,
|
||||
detection_level_id: levelId || null,
|
||||
});
|
||||
}
|
||||
|
||||
function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setDropError(null);
|
||||
// Per spec §M7, max 25 MB + ext whitelist are server-enforced; the client
|
||||
// checks too so a drag-and-drop of a `.exe` doesn't waste a roundtrip
|
||||
// and the operator gets immediate feedback.
|
||||
for (const f of Array.from(files)) {
|
||||
const dotIdx = f.name.lastIndexOf('.');
|
||||
const ext = dotIdx >= 0 ? f.name.slice(dotIdx).toLowerCase() : '';
|
||||
if (!ext || !(EVIDENCE_ALLOWED_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
setDropError(
|
||||
`"${f.name}" has an unsupported extension. Accepted: ${EVIDENCE_ALLOWED_EXTENSIONS.join(', ')}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (f.size > EVIDENCE_MAX_BYTES) {
|
||||
setDropError(`"${f.name}" exceeds the 25 MB limit.`);
|
||||
continue;
|
||||
}
|
||||
upload.mutate(f);
|
||||
}
|
||||
}
|
||||
|
||||
const apiErr =
|
||||
save.error instanceof ApiError
|
||||
? save.error
|
||||
: upload.error instanceof ApiError
|
||||
? upload.error
|
||||
: remove.error instanceof ApiError
|
||||
? remove.error
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card accent="cyan" className="flex flex-col gap-3" data-testid="blue-zone">
|
||||
<SectionHeader prefix="Blue" highlight="Detection" accent="cyan" />
|
||||
{apiErr && (
|
||||
<Alert accent="red">
|
||||
{apiErr.message}
|
||||
{typeof apiErr.payload === 'object' &&
|
||||
apiErr.payload &&
|
||||
'message' in (apiErr.payload as Record<string, unknown>)
|
||||
? ` — ${(apiErr.payload as { message?: string }).message}`
|
||||
: ''}
|
||||
</Alert>
|
||||
)}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
|
||||
Detection level
|
||||
</span>
|
||||
<select
|
||||
value={levelId}
|
||||
onChange={(e) => setLevelId(e.target.value)}
|
||||
disabled={!canWriteBlue}
|
||||
className="rounded-md border border-border bg-bg-card px-2 py-2 font-mono text-xs text-text"
|
||||
data-testid="blue-detection-level"
|
||||
>
|
||||
<option value="">— none —</option>
|
||||
{detectionLevels.map((lvl) => (
|
||||
<option key={lvl.id} value={lvl.id}>
|
||||
{lvl.label_en} ({lvl.key})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<MarkdownField
|
||||
label="Comment"
|
||||
value={comment}
|
||||
onChange={setComment}
|
||||
disabled={!canWriteBlue}
|
||||
data-testid="blue-comment"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
accent="cyan"
|
||||
onClick={submit}
|
||||
disabled={!canWriteBlue || !dirty || save.isPending}
|
||||
data-testid="blue-save"
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Save blue fields'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
|
||||
Evidence
|
||||
</p>
|
||||
<div
|
||||
className={`rounded-md border-2 border-dashed p-4 text-center ${
|
||||
canWriteBlue ? 'border-cyan/60' : 'border-border'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
if (!canWriteBlue) return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!canWriteBlue) return;
|
||||
e.preventDefault();
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}}
|
||||
data-testid="evidence-dropzone"
|
||||
>
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
{canWriteBlue
|
||||
? 'Drag files here or click to pick'
|
||||
: 'Read-only — no upload permission'}
|
||||
</p>
|
||||
{canWriteBlue && (
|
||||
<p
|
||||
className="mt-1 font-mono text-3xs uppercase tracking-wider2 text-text-dim"
|
||||
data-testid="evidence-allowed-formats"
|
||||
>
|
||||
Accepted: {EVIDENCE_ALLOWED_EXTENSIONS.join(' · ')} ·{' '}
|
||||
max 25 MB / file
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
// `accept` is the native filter the OS file-picker honours. It
|
||||
// accepts a comma-separated list of extensions or MIME types.
|
||||
// Servers re-check both, so this is a UX nicety, not a security
|
||||
// boundary.
|
||||
accept={EVIDENCE_ALLOWED_EXTENSIONS.join(',')}
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
data-testid="evidence-file-input"
|
||||
/>
|
||||
{canWriteBlue && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
accent="cyan"
|
||||
className="mt-2"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={upload.isPending}
|
||||
data-testid="evidence-pick"
|
||||
>
|
||||
{upload.isPending ? 'Uploading…' : 'Pick files'}
|
||||
</Button>
|
||||
)}
|
||||
{dropError && (
|
||||
<p className="mt-2 font-mono text-2xs text-red">{dropError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{test.evidence.length === 0 ? (
|
||||
<p className="font-mono text-2xs text-text-dim">No evidence yet.</p>
|
||||
) : (
|
||||
<table className="w-full font-mono text-2xs">
|
||||
<thead>
|
||||
<tr className="text-text-dim uppercase tracking-wider2">
|
||||
<th className="text-left py-1">File</th>
|
||||
<th className="text-left py-1">Size</th>
|
||||
<th className="text-left py-1">By</th>
|
||||
<th className="text-left py-1">SHA256</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-testid="evidence-list">
|
||||
{test.evidence.map((ev) => (
|
||||
<tr
|
||||
key={ev.id}
|
||||
className="border-t border-border/40"
|
||||
data-testid={`evidence-row-${ev.id}`}
|
||||
>
|
||||
<td className="py-1 text-text-bright">{ev.original_filename}</td>
|
||||
<td className="py-1">{formatBytes(ev.size_bytes)}</td>
|
||||
<td className="py-1 text-text">
|
||||
{ev.uploaded_by_email ?? '<deleted>'}
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<code className="text-text-dim">{ev.sha256.slice(0, 12)}…</code>
|
||||
</td>
|
||||
<td className="py-1 text-right">
|
||||
<a
|
||||
href={`/api/v1/evidence/${ev.id}?download=true`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan font-mono text-2xs"
|
||||
data-testid={`evidence-download-${ev.id}`}
|
||||
>
|
||||
download
|
||||
</a>
|
||||
{canWriteBlue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Soft-delete evidence "${ev.original_filename}"?`,
|
||||
)
|
||||
) {
|
||||
remove.mutate(ev.id);
|
||||
}
|
||||
}}
|
||||
className="ml-2 text-rose font-mono text-2xs"
|
||||
data-testid={`evidence-delete-${ev.id}`}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Top-level page //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export function MissionTestPage() {
|
||||
const params = useParams<{ id: string; testId: string }>();
|
||||
const missionId = params.id ?? '';
|
||||
const testId = params.testId ?? '';
|
||||
const navigate = useNavigate();
|
||||
const { state } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const detail = useMissionTest(missionId, testId);
|
||||
const levels = useDetectionLevels(
|
||||
!!state.user &&
|
||||
(state.user.is_admin ||
|
||||
state.user.permissions.includes('detection_level.read')),
|
||||
);
|
||||
|
||||
const onActivityTouched = useCallback(() => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: missionTestKeys.detail(missionId, testId),
|
||||
});
|
||||
}, [qc, missionId, testId]);
|
||||
|
||||
useActivityWatcher(missionId, testId, onActivityTouched);
|
||||
|
||||
const test = detail.data;
|
||||
|
||||
const perms = useMemo(() => {
|
||||
const isAdmin = !!state.user?.is_admin;
|
||||
const codes = state.user?.permissions ?? [];
|
||||
return {
|
||||
isAdmin,
|
||||
canWriteRed: isAdmin || codes.includes('mission.write_red_fields'),
|
||||
canWriteBlue: isAdmin || codes.includes('mission.write_blue_fields'),
|
||||
};
|
||||
}, [state.user]);
|
||||
|
||||
if (!missionId || !testId) {
|
||||
return (
|
||||
<Alert accent="red">Missing mission or test id in URL.</Alert>
|
||||
);
|
||||
}
|
||||
if (detail.isLoading) {
|
||||
return <p className="font-mono text-xs text-text-dim">Loading…</p>;
|
||||
}
|
||||
if (detail.error instanceof ApiError && detail.error.status === 404) {
|
||||
return (
|
||||
<Alert accent="rose">
|
||||
Mission test not found, or you are not a member of this mission.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (!test) {
|
||||
return <Alert accent="red">Failed to load mission test.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4" data-testid="mission-test-page">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<Link
|
||||
to={`/missions/${missionId}`}
|
||||
className="font-mono text-2xs uppercase tracking-wider2 text-text-dim hover:text-text-bright"
|
||||
data-testid="back-to-mission"
|
||||
>
|
||||
← Back to mission
|
||||
</Link>
|
||||
<h1 className="mt-1 font-mono text-xl font-bold text-text-bright">
|
||||
{test.snapshot_name}
|
||||
</h1>
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
Last touched{' '}
|
||||
<span data-testid="last-actor-rel">{formatRelative(test.updated_at)}</span>
|
||||
{test.last_actor_email
|
||||
? ` by ${test.last_actor_display_name ?? test.last_actor_email}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
{/* Post-amendement 2026-05-15 bis: no transition buttons. The pill
|
||||
is a passive indicator of where the implicit lifecycle stands
|
||||
(driven by who has written data). */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span data-testid="state-pill">
|
||||
<Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
|
||||
{MISSION_TEST_STATE_LABEL[test.state]}
|
||||
</Tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{test.mitre_tags.map((tag) => (
|
||||
<Tag accent="cyan" key={`${tag.kind}-${tag.external_id}`}>
|
||||
{tag.external_id} — {tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
<Tag accent="teal">OPSEC: {test.snapshot_opsec_level}</Tag>
|
||||
</div>
|
||||
{test.snapshot_objective && (
|
||||
<p className="font-mono text-xs text-text">
|
||||
<span className="text-text-dim">Objective: </span>
|
||||
{test.snapshot_objective}
|
||||
</p>
|
||||
)}
|
||||
{test.snapshot_procedure_md && (
|
||||
<details className="font-mono text-2xs text-text-dim" data-testid="procedure">
|
||||
<summary className="cursor-pointer text-text">Procedure</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-text">
|
||||
{test.snapshot_procedure_md}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{test.snapshot_expected_red_md && (
|
||||
<details className="font-mono text-2xs text-text-dim">
|
||||
<summary className="cursor-pointer text-text">Expected (red)</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-text">
|
||||
{test.snapshot_expected_red_md}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{test.snapshot_expected_blue_md && (
|
||||
<details className="font-mono text-2xs text-text-dim">
|
||||
<summary className="cursor-pointer text-text">Expected (blue)</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-text">
|
||||
{test.snapshot_expected_blue_md}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<RedZone test={test} missionId={missionId} canWriteRed={perms.canWriteRed} />
|
||||
<BlueZone
|
||||
test={test}
|
||||
missionId={missionId}
|
||||
canWriteBlue={perms.canWriteBlue}
|
||||
detectionLevels={levels.data?.items ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation hint when state moves to archived parent — out of M7 scope, but
|
||||
we still let the page render. */}
|
||||
{detail.error instanceof ApiError &&
|
||||
detail.error.status >= 400 &&
|
||||
detail.error.status < 500 && (
|
||||
<Button accent="cyan" onClick={() => navigate(`/missions/${missionId}`)}>
|
||||
Back to mission
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,34 @@ 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`.
|
||||
- **`/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-15 — M7 amendement : 5 champs blue + vue tabulaire
|
||||
|
||||
- **`e.errors()` de Pydantic v2 embarque l'exception originale dans `ctx`** quand un `AfterValidator` lève. `jsonify(e.errors())` crash avec `TypeError: Object of type ValueError is not JSON serializable`. Fix project-wide : `e.errors(include_context=False, include_url=False)` — strippe le ctx et l'URL doc, garde le reste qui est déjà JSON-safe. Sed global sur `backend/app/api/*.py`. Mémo : si la stack ajoute un nouveau handler `except ValidationError as e:`, prendre le même pattern.
|
||||
- **Pydantic `EmailStr` reste trop strict pour le projet** (lessons M2 captured this). Pour le destinataire d'alerte, j'ai utilisé `Annotated[str, AfterValidator(_validate_email_shape)]` avec une regex `^[^@\s]+@[^@\s]+\.[^@\s]+$`. Pas de validation TLD. Si un futur champ "production" exige de la rigueur, on aura besoin de deux types : `_InternalEmail` (permissive) et `_PublicEmail` (strict). Pas le cas aujourd'hui — outil interne.
|
||||
- **Naïve datetime + `timestamptz` = piège silencieux**. Postgres interprète la datetime naïve dans la session TZ ; sur l'API la majorité des clients enverra `2026-05-15T11:00:00` sans `Z`. Réponse : `Annotated[datetime, AfterValidator(_ensure_aware_datetime)]` qui rejette `tzinfo is None` avec 400. Le front respecte déjà la convention (append `:00Z` au datetime-local). À reproduire sur tout nouveau champ `timestamptz` accepté en write.
|
||||
- **Élargir `MissionTestView` (la vue nested dans `GET /missions/{id}`) est OK** tant qu'on garde la requête en O(1) — j'ai ajouté ~15 fields mais batch-load les détections + last-actor users en 2 queries totales, peu importe le nombre de tests. Sans le batch, c'était un classique N+1.
|
||||
- **Pattern édition inline en table** :
|
||||
- State `editingTestId` *au-dessus* du tableau (un seul row en édition à la fois sur toute la mission).
|
||||
- `draft` localement à `MissionScenarioTable`, copié depuis le test à l'entrée d'édition.
|
||||
- `draftDiff(test, draft)` retourne `null` si rien n'a changé (évite un `PUT` vide).
|
||||
- `useEffect([editingTestId])` re-derive le draft seulement quand l'identité change (pas sur polling refetch — leçon M7 déjà capturée).
|
||||
- `window.confirm` sur Esc-with-dirty et sur double-click d'une autre ligne avec dirty draft.
|
||||
- **Full-bleed escape `max-w-page`** : `marginLeft: 'calc(50% - 50vw)'` + `marginRight: 'calc(50% - 50vw)'` + `width: '100vw'`. Pattern déjà inventé pour le picker MITRE en M4 ; testé OK pour 7 colonnes denses. À factoriser dans un composant `<FullBleed>` si on en a un 3ᵉ usage.
|
||||
- **`detection_level` rendu en pill dans la cellule Commentaires** plutôt que comme 8ᵉ colonne : la spec listait 7 colonnes héritées d'Excel ; ajouter une 8ᵉ aurait cassé le mental model du user. Pill au-dessus du commentaire est plus naturel + économise l'espace horizontal.
|
||||
|
||||
## 2026-05-14 — M7 execution + evidence + activity
|
||||
|
||||
- **`logging.LogRecord` reserves `created`** — same trap as `name` (M3 lessons): `extra={"created": n}` raises `KeyError: "Attempt to overwrite 'created' in LogRecord"`. Pattern: prefix with the entity (`rows_created`). The `created` is the LogRecord timestamp, hence the conflict. Reserved-key cheatsheet (kept growing): `name, msg, args, levelname, levelno, pathname, filename, module, funcName, created, msecs, lineno, thread, threadName, process`.
|
||||
- **Query string `+` is `%20` once `request.args` decodes it.** A naked ISO datetime in `?since=2026-05-14T07:55:16+00:00` arrives as `2026-05-14T07:55:16 00:00`, which `datetime.fromisoformat` rejects with `ValueError`. The fix is on the *client* (URL-encode) — not on the server (a tolerant "space → +" reparse would conflate real-spaces with un-encoded plusses). Now codified in `testing-m7.md` §7 + every test that hits `/activity?since=` calls `urllib.parse.quote`.
|
||||
- **Field-level perm enforcement must happen *before* the SQL transaction.** First M7 draft did `_load_test(...)` then `if not allowed: raise`. Two issues: (a) extra DB hit on a refused request, (b) audit log conflated "row exists" with "perm denied". Refactor: classify the touched fields → check perms → only then enter `session_scope`. Cleaner audit log and one fewer round-trip on the 403 path.
|
||||
- **Streamed upload + atomic move is the canonical pattern for content-addressed evidence.** Writing chunks to a tmpfile *inside* the final per-test dir lets `shutil.move` reduce to a POSIX `rename(2)` (atomic). If the SHA256 already exists on disk (re-upload of the same bytes), we drop the tmp and reuse — a fresh DB row records *who* uploaded it, even though no new bytes hit the disk. Saves storage AND preserves provenance.
|
||||
- **Pyright's "underscore prefix unused" rule does not silence destructured tuple slots.** `ev, _test, scenario = chain` still triggers `"_test is not accessed"`. Workaround: use a single underscore (`_`) or index the tuple. Single underscore is conventional in Python for "I'm intentionally ignoring this".
|
||||
- **TanStack v5 `useQueryClient.setQueryData(detailKey, next)`** is the right idiom after a mutation that returns the freshly-saved row — avoids a refetch, and the polling query still invalidates correctly on activity events. Pattern: `onSuccess: (next) => { qc.setQueryData(detailKey, next); qc.invalidateQueries({ queryKey: parentKey }); }`.
|
||||
- **Activity polling must gate on `document.visibilityState === 'visible'`** or every backgrounded tab hits the API every 15 s, multiplying for free across a team's tab graveyard. Single-line check; massive impact.
|
||||
- **PUT vs transition split kept the model coherent.** Tempting to fold "mark executed" into PUT `{state:'executed'}` but it conflates two concerns: state lifecycle vs field write. Keeping the transition POST separate makes the side-effect (`executed_at = now()`) easy to reason about and the perm gate per-target trivial.
|
||||
- **`/diag/reset` must clean the evidence dir in test mode** otherwise the e2e suite accumulates 24 MB blobs across runs. Gated on `APP_ENV == "test"` so `dev` keeps the operator's manual uploads.
|
||||
- **The `last_actor_id` migration adds an index on `updated_at`** — without it, the activity poll's `WHERE updated_at > since ORDER BY updated_at DESC` was sequential-scanning. With the index, the plan switches to an index range scan even on the empty case (which is the most common one when nothing has changed).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
---
|
||||
type: spec
|
||||
date: "2026-05-08"
|
||||
revised: "2026-05-15"
|
||||
tags: [spec, ready]
|
||||
status: ready
|
||||
project: Metamorph
|
||||
---
|
||||
|
||||
> **2026-05-15 amendment** — added 5 blue-side review fields to the M7
|
||||
> scope (cf. §4 in-scope bullet on blue saisie, §F6, §8 model) and
|
||||
> reworked the per-test UX to a full-bleed tabular view with inline edit.
|
||||
> All other commitments stand. Detailed delta at the bottom of §4.
|
||||
|
||||
# Metamorph — Spec
|
||||
|
||||
> Spec finalisée après tour de questions du 2026-05-08. §12 et §13 vides : prête pour l'exécution. Le tracking quotidien bascule sur `Templates/Project.md`.
|
||||
@@ -46,7 +52,8 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
|
||||
- Snapshot des templates au moment de l'instanciation dans une mission (modifier un template ne touche pas les missions existantes).
|
||||
- Saisie des résultats red (texte uniquement : commande, output, commentaires) avec horodatage auto au clic « Marquer exécuté » + override manuel.
|
||||
- Saisie des preuves blue : multi-fichiers (PNG/JPG/PDF/TXT/LOG/JSON/CSV/EVTX/ZIP, max 25 Mo/fichier, SHA256 stocké) + commentaires markdown + niveau de détection (taxonomie custom paramétrable par admin, seed par défaut : `detected_blocked / detected_alert / logged_only / not_detected`).
|
||||
- Workflow par test instance : `pending → executed → reviewed_by_blue` + voies `skipped / blocked`.
|
||||
- **Saisie côté blue — fiche de review étendue (amendement 2026-05-15)** : en plus du commentaire et du niveau de détection, la fiche de review d'un test capture les 5 champs additionnels que la blue maintenait en Excel — **`log_source`** (texte court : Firewall / NDR / Proxy / AV / EDR / …), **`siem_logs`** (texte long, extrait brut de logs collectés au SIEM), **sous-record cyber-incident** `(incident_at: timestamptz, incident_number: texte court, incident_recipient_email: email)` qui matérialise l'alerte envoyée à l'équipe SOC. Tous ces champs sont blue-side et gated par `mission.write_blue_fields`.
|
||||
- **Cycle de vie d'un test instance — implicite, piloté par les écritures (amendement 2026-05-15 bis)** : aucun bouton de transition exposé en UI. La colonne `state` du DB reste (`pending / executed / reviewed_by_blue / skipped / blocked`) mais elle est **auto-promue** par le service à chaque PUT : (1) toute écriture côté red sur un test `pending|skipped|blocked` fait passer à `executed` et auto-stamp `executed_at = now()` si absent ; (2) toute écriture côté blue sur un test `executed` fait passer à `reviewed_by_blue`. Une écriture blue sur un test `pending` n'auto-promote pas (seule la red implique l'exécution). L'endpoint `/transition` est conservé pour back-fill admin mais n'est plus appelé par l'UI.
|
||||
- Visibilité mission : whitebox totale pour la blue team dès la création (pas de masquage des procédures).
|
||||
- Édition concurrente : last-write-wins + indicateur « modifié par X il y a Ns » via polling léger. Conflits red/blue impossibles par construction (champs disjoints).
|
||||
- Notifications in-app uniquement (badge + liste), pas de SMTP.
|
||||
@@ -90,7 +97,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
|
||||
- **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.
|
||||
- **F5** — Saisie côté red : commande lancée, output texte, commentaires markdown, statut, timestamp auto+override.
|
||||
- **F6** — Saisie côté blue : niveau de détection (enum custom plateforme), commentaires markdown, multi-fichiers (whitelist).
|
||||
- **F6** — Saisie côté blue : niveau de détection (enum custom plateforme), commentaires markdown, multi-fichiers (whitelist), **source de log** (texte court, max 120 caractères, free-form en v1 — promu en taxonomie M8+), **extrait SIEM** (texte long, max 200_000 caractères côté API), **sous-record cyber-incident** dont les 3 champs sont **indépendants et tous optionnels** : `incident_at` (timestamptz, exige un offset explicite — `Z` ou `+HH:MM` ; les naïves sont rejetées en 400), `incident_number` (texte court, max 120), `incident_recipient_email` (texte avec validation RFC-shape permissive — autorise `.local` / `.corp` / `.test` pour les domaines internes). UI: **vue tabulaire pleine largeur d'écran** (échappe le `max-w-page` du layout) à raison d'**un tableau par scénario, une ligne par test**. **Colonnes** : `Test | Procédure | Exécution | Source de log | Commentaires | Logs SIEM | Cyber Incident`. Le `detection_level` est rendu **dans la cellule Commentaires** sous forme de pill colorée au-dessus du commentaire (pas de 8ᵉ colonne). **Édition inline** : double-clic sur une ligne → un *seul* row passe en édition à la fois (les autres restent en lecture, le double-clic d'une autre ligne propose de save/discard la précédente) ; les cellules deviennent des inputs gated par les perms red/blue de l'utilisateur ; **Esc** annule (revert vers le snapshot serveur), **Save** commit, **clic en dehors** prompt si dirty. La page détail d'un test (`/missions/{id}/tests/{test_id}`) reste accessible pour l'upload de preuves (dropzone + table).
|
||||
- **F7** — Génération slide reveal.js standalone + export PDF client, groupé par MITRE Tactic (custom optionnel).
|
||||
- **F8** — Notifications in-app (badge + flux) à chaque transition de statut d'un test concernant l'utilisateur.
|
||||
- **F9** — Export mission : JSON complet (API + UI), CSV agrégé.
|
||||
@@ -129,7 +136,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô
|
||||
- `users`, `groups`, `permissions`, `user_groups`, `group_permissions`, `invitations`
|
||||
- `mitre_tactics`, `mitre_techniques`, `mitre_subtechniques`
|
||||
- `test_templates`, `scenario_templates`, `scenario_template_tests` (jointure ordonnée)
|
||||
- `missions`, `mission_members`, `mission_scenarios` (snapshot), `mission_tests` (snapshot + state d'exécution), `mission_categories` (custom)
|
||||
- `missions`, `mission_members`, `mission_scenarios` (snapshot), `mission_tests` (snapshot + state d'exécution + annotations red `red_command/red_output/red_comment_md` + annotations blue `blue_comment_md/detection_level_id/blue_log_source/blue_siem_logs/blue_incident_at/blue_incident_number/blue_incident_recipient_email`), `mission_categories` (custom)
|
||||
- `evidence_files` (FK `mission_test_id`, sha256, mime, size, path)
|
||||
- `notifications` (in-app)
|
||||
- `detection_levels` (taxonomie custom, seedée avec 4 niveaux par défaut)
|
||||
|
||||
237
tasks/testing-m7.md
Normal file
237
tasks/testing-m7.md
Normal file
@@ -0,0 +1,237 @@
|
||||
---
|
||||
type: testing
|
||||
milestone: M7
|
||||
date: "2026-05-14"
|
||||
project: Metamorph
|
||||
---
|
||||
|
||||
# Testing M7 — Red & blue execution on a mission test
|
||||
|
||||
## 1. Lancement de la stack
|
||||
|
||||
```bash
|
||||
make up
|
||||
make migrate # applies the M7 last_actor_id migration (91a4e7c6d2f3)
|
||||
```
|
||||
|
||||
Le boot seede automatiquement les 4 detection_levels par défaut
|
||||
(`detected_blocked` / `detected_alert` / `logged_only` / `not_detected`) via
|
||||
`seed_detection_levels()`. Si tu pars d'un stack pré-existant, un `make
|
||||
restart` (down+up) suffit — le seed est idempotent.
|
||||
|
||||
> L'admin stable `admin@metamorph.local / AdminPass1234!` est restauré par
|
||||
> le hook `afterAll` du spec e2e M7. La 1ʳᵉ fois, bootstrappe-le via `/setup`.
|
||||
|
||||
## 2. Tests automatisés
|
||||
|
||||
```bash
|
||||
make test-api # 131 tests pytest, dont 25 M7 (perm gating, state machine, evidence, activity)
|
||||
make e2e # 48 tests Playwright, dont 5 M7 (red/blue gating, 24/26 MB, SHA256, SPA)
|
||||
```
|
||||
|
||||
Rapport HTML : `e2e/playwright-report/`.
|
||||
|
||||
> **Reminder** : `make test-api` et `make e2e` partagent le Postgres dev.
|
||||
> Lancer en milieu de session **wipe** les données — l'`afterAll` re-bootstrap
|
||||
> l'admin stable, mais les missions/tests/uploads sur le disque créés à la
|
||||
> main sont perdus.
|
||||
|
||||
## 3. Smoke navigateur
|
||||
|
||||
### Pré-requis
|
||||
- Stack `make up` + admin loggé.
|
||||
- Une mission existante avec au moins **1 scenario** snapshotté contenant
|
||||
**≥ 1 test** (voir `testing-m6.md` pour le chemin de création).
|
||||
|
||||
### 3.0 Vue tabulaire (`/missions/<id>` — onglet tests, amendement 2026-05-15)
|
||||
|
||||
L'onglet **tests** rend désormais un tableau plein écran **par scénario**
|
||||
(un row par test). Largeur pleine viewport (`max-w-page` échappé via la
|
||||
recette `calc(50% - 50vw)` — même mécanisme que le picker MITRE).
|
||||
|
||||
**Colonnes** :
|
||||
| Colonne | Read mode | Edit mode | Perm requise |
|
||||
|---------|-----------|-----------|--------------|
|
||||
| **Test** | nom + chips MITRE | (read-only) | — |
|
||||
| **Procédure** | snapshot_objective / description tronqués 180 chars | (read-only) | — |
|
||||
| **Exécution** | `executed_at` + `red_command` tronqué | datetime-local + textarea command | `mission.write_red_fields` |
|
||||
| **Source de log** | `blue_log_source` | input texte (placeholder `EDR / Firewall / NDR …`) | `mission.write_blue_fields` |
|
||||
| **Commentaires** | pill state + pill detection_level + commentaire | select detection_level + textarea commentaire | `mission.write_blue_fields` |
|
||||
| **Logs SIEM** | `blue_siem_logs` tronqué 240 chars | textarea 6 lignes | `mission.write_blue_fields` |
|
||||
| **Cyber Incident** | `incident_at` / `incident_number` / `incident_recipient_email` empilés | 3 inputs (datetime-local / texte / email) | `mission.write_blue_fields` |
|
||||
| **Actions** | lien `open ↗` vers `/missions/<id>/tests/<test_id>` (full detail + evidence) | boutons `Save` / `Cancel` | — |
|
||||
|
||||
**Workflow d'édition** :
|
||||
1. Double-clic sur une ligne → la ligne entre en mode édition (les cellules
|
||||
deviennent des inputs). Une seule ligne en édition à la fois — un double-clic
|
||||
sur une autre ligne propose `Discard unsaved changes?` si la précédente
|
||||
est dirty.
|
||||
2. **Esc** = cancel (prompt si dirty).
|
||||
3. **Save** = `PUT /missions/{id}/tests/{test_id}` avec **uniquement les champs
|
||||
modifiés**. Les cellules qu'un user ne peut pas écrire restent disabled ;
|
||||
le serveur revalide quoi qu'il arrive (defense in depth).
|
||||
4. Pour l'upload de preuves : cliquer `open ↗` qui ouvre la page détail
|
||||
(zone Red / zone Blue + dropzone evidence).
|
||||
|
||||
### 3.1 Page de test (`/missions/<id>/tests/<test_id>`)
|
||||
|
||||
1. Depuis `/missions/<id>`, onglet **tests**, cliquer une ligne (ou le nom du
|
||||
test). Redirection vers la page dédiée.
|
||||
2. **En-tête** :
|
||||
- `← Back to mission` (link `data-testid="back-to-mission"`).
|
||||
- Nom du test (snapshot).
|
||||
- Ligne *"Last touched Xs ago by Y"* — vide à la création, remplie dès qu'un
|
||||
champ est sauvé.
|
||||
- Status pill (`Pending` / `Executed` / `Reviewed` / `Skipped` / `Blocked`).
|
||||
- Boutons de transitions autorisés depuis l'état courant (voir matrice en
|
||||
§6).
|
||||
3. **Card metadata** : MITRE chips, OPSEC tag, et 4 `<details>` pliés
|
||||
(Objective / Procedure / Expected red / Expected blue).
|
||||
|
||||
### 3.2 Zone Red (bordure rouge)
|
||||
- `Command` (mono, `data-testid="red-command"`).
|
||||
- `Output` (textarea mono multilign, `data-testid="red-output"`).
|
||||
- `Comment` (markdown, `data-testid="red-comment"`).
|
||||
- Toggle **Override executed-at** + input datetime-local — disabled tant que
|
||||
le test n'est pas `executed` / `reviewed_by_blue`.
|
||||
- Bouton **Save red fields** :
|
||||
- disabled si rien n'a changé ou si l'utilisateur n'a pas
|
||||
`mission.write_red_fields`.
|
||||
- Sur succès, le bandeau "Last touched" se met à jour (cache invalidé).
|
||||
|
||||
### 3.3 Zone Blue (bordure cyan)
|
||||
- Select `Detection level` (sourcé de `/detection-levels`).
|
||||
- `Comment` (markdown, `data-testid="blue-comment"`).
|
||||
- Bouton **Save blue fields** (analogue à la zone red).
|
||||
- **Evidence dropzone** :
|
||||
- Drag & drop ou bouton **Pick files** (multi-fichiers).
|
||||
- Sous la dropzone, la liste des extensions acceptées est affichée :
|
||||
`Accepted: .png · .jpg · .jpeg · .pdf · .txt · .log · .json · .csv · .evtx · .zip · max 25 MB / file`
|
||||
(testid `evidence-allowed-formats`).
|
||||
- Le sélecteur OS est pré-filtré via l'attribut `accept` — pas de "All files".
|
||||
- Un drag&drop d'une extension hors-liste est rejeté côté client avec
|
||||
le message d'erreur en rouge ; le serveur re-vérifie quoi qu'il
|
||||
arrive (`unsupported_extension` / `too_large`).
|
||||
- Limite : **25 MB / fichier** (côté client = garde-fou UX, côté serveur =
|
||||
stricte avec stream cap chunk-par-chunk).
|
||||
- Table récap : nom · taille · uploader · `sha256[:12]…` · link download +
|
||||
bouton soft-delete.
|
||||
|
||||
### 3.5 Override executed-at — pièges timezone
|
||||
- Le toggle **Override executed-at timestamp** affiche un input
|
||||
`datetime-local` qui parle en **heure locale du navigateur** (ex.
|
||||
Europe/Paris UTC+2). L'état local du composant garde la valeur sous
|
||||
forme `YYYY-MM-DDTHH:MM` ; la conversion en UTC ISO n'a lieu qu'au
|
||||
submit. Donc si tu tapes `10:30` à Paris, le serveur reçoit
|
||||
`08:30:00+00:00` — c'est attendu.
|
||||
- Le polling activity (15 s) ne re-sync l'état local **que** sur changement
|
||||
d'identité du test (`useEffect([test.id])`) — une frappe en cours n'est
|
||||
jamais écrasée par un refetch.
|
||||
|
||||
### 3.4 Indicateur d'activité
|
||||
|
||||
- À l'arrivée sur la page, le polling `GET /missions/<id>/activity` démarre
|
||||
(toutes les 15 s, gated sur `document.visibilityState === 'visible'`).
|
||||
- Si un autre user édite le test, la query est invalidée → la page reload
|
||||
les champs (TanStack cache replaced).
|
||||
- Le `server_time` est passé en `?since=` à l'appel suivant pour ne recevoir
|
||||
que ce qui a bougé depuis.
|
||||
|
||||
## 4. Vérifications fonctionnelles (DoD)
|
||||
|
||||
### 4.1 Red écrit en parallèle de Blue, sans conflit
|
||||
|
||||
1. Sur un test `pending`, login en red dans 1 onglet, en blue dans un autre.
|
||||
2. Red : remplit `red_command` + sauve.
|
||||
3. Blue : sélectionne `detected_alert` + commentaire + sauve.
|
||||
4. Les 2 saves passent en 200, aucun conflit.
|
||||
5. Rafraîchir l'onglet red → les champs blue apparaissent (et réciproquement).
|
||||
|
||||
### 4.2 Perm gating field-level
|
||||
|
||||
| User | red_command | red_comment | blue_comment | detection_level | upload |
|
||||
|--------------|------------:|------------:|-------------:|----------------:|-------:|
|
||||
| red | ✓ | ✓ | **403** | **403** | **403** |
|
||||
| blue | **403** | **403** | ✓ | ✓ | ✓ |
|
||||
| red + blue | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| admin | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
### 4.3 Evidence upload — limites
|
||||
|
||||
1. Upload un fichier `.evtx` de **24 MB** → 201, body inclut `sha256`,
|
||||
`size_bytes=25165824`, `mime=application/octet-stream`.
|
||||
2. Vérif `sha256` côté client : `sha256sum file24.evtx` == `body.sha256`.
|
||||
3. Upload un fichier `.evtx` de **26 MB** → 400 `{error:"too_large"}`.
|
||||
4. Upload un fichier `.exe` (1 octet) → 400 `{error:"unsupported_extension"}`.
|
||||
5. Download via le lien `download` → bytes byte-for-byte identiques.
|
||||
|
||||
### 4.4 Soft delete d'evidence
|
||||
1. Upload un PDF, vérif qu'il apparaît dans la table.
|
||||
2. Cliquer **delete** → confirmation → row disparaît.
|
||||
3. `GET /evidence/<id>` → 404 (le row reste en DB avec `deleted_at` set,
|
||||
mais le service l'occulte).
|
||||
4. Sur disque, `/data/evidence/<mission_id>/<test_id>/<sha256>.pdf` est
|
||||
**conservé** (purge physique = M12).
|
||||
|
||||
## 5. Vérification du state machine
|
||||
|
||||
| from | to | result | side requis |
|
||||
|--------------------|-------------------|--------|-------------|
|
||||
| pending | executed | 200 | red |
|
||||
| pending | skipped | 200 | any |
|
||||
| pending | blocked | 200 | any |
|
||||
| pending | reviewed_by_blue | **409** | — |
|
||||
| executed | reviewed_by_blue | 200 | blue |
|
||||
| executed | pending | 200 | red (reset) |
|
||||
| reviewed_by_blue | executed | 200 | blue |
|
||||
| reviewed_by_blue | pending | **409** | — |
|
||||
| skipped | pending | 200 | any |
|
||||
| blocked | pending | 200 | any |
|
||||
| any | (same state) | 200 | — (no-op) |
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
|
||||
-d '{"target_state":"executed"}' \
|
||||
http://localhost:8080/api/v1/missions/<mid>/tests/<tid>/transition
|
||||
```
|
||||
|
||||
Side-effect attendu : `target_state="executed"` stamp `executed_at=now()` et
|
||||
remet `executed_at_overridden=false`. Le retour à `pending` efface
|
||||
`executed_at`.
|
||||
|
||||
## 6. Vérification override executed_at
|
||||
|
||||
1. État `pending` → PUT `{"executed_at": "...", "executed_at_overridden": true}`
|
||||
→ **400** (refusé tant que le test n'a pas été marqué executed).
|
||||
2. Transition `pending → executed` → `executed_at` auto-stamp.
|
||||
3. PUT `{"executed_at":"2026-05-14T10:00:00+00:00","executed_at_overridden":true}`
|
||||
→ 200, body reflète la nouvelle date + override=true.
|
||||
4. Blue user tente le même PUT → **403** (executed_at est red-side).
|
||||
|
||||
## 7. Vérification activity polling
|
||||
|
||||
```bash
|
||||
# Snapshot t0
|
||||
curl -H "Authorization: Bearer $T" \
|
||||
http://localhost:8080/api/v1/missions/<mid>/activity \
|
||||
| jq .server_time
|
||||
# Mutate
|
||||
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
|
||||
-d '{"red_comment_md":"poke"}' \
|
||||
http://localhost:8080/api/v1/missions/<mid>/tests/<tid>
|
||||
# Poll t1 (URL-encode the timestamp's `+`)
|
||||
SINCE=$(python -c "import urllib.parse;print(urllib.parse.quote('${T0}'))")
|
||||
curl -H "Authorization: Bearer $T" \
|
||||
"http://localhost:8080/api/v1/missions/<mid>/activity?since=${SINCE}"
|
||||
```
|
||||
|
||||
Réponse attendue : 1 entrée pour le test mis à jour, avec `last_actor_email`
|
||||
peuplé.
|
||||
|
||||
## 8. Quick teardown
|
||||
|
||||
```bash
|
||||
make down
|
||||
# ou reset complet (test-only) :
|
||||
curl -X POST http://localhost:8080/api/v1/diag/reset
|
||||
```
|
||||
@@ -149,21 +149,33 @@ spec: tasks/spec.md
|
||||
|
||||
---
|
||||
|
||||
## M7 — Saisie red & blue sur un test ☐
|
||||
## M7 — Saisie red & blue sur un test ☑ (+ amendement 2026-05-15 ↻)
|
||||
|
||||
**But** : exécution de la mission, le cœur du produit.
|
||||
|
||||
- ☐ Modale ou page dédiée `Mission > Test #N` avec deux zones distinctes (red / blue), bordures accentuées par couleur (rouge / cyan).
|
||||
- ☐ Côté red : champ commande (mono), output (mono multiline), commentaire markdown, bouton « Marquer exécuté » qui set `state=executed` + `executed_at=now()` ; édition de `executed_at` derrière un toggle « override ».
|
||||
- ☐ Côté blue : sélecteur `detection_level`, commentaire markdown, zone d'upload multi-fichiers (drag-and-drop).
|
||||
- ☐ Upload preuves : `POST /missions/{id}/tests/{test_id}/evidence` (multipart, validation extension+MIME+taille≤25Mo, calcul SHA256, stockage `/data/evidence/<mission_id>/<test_id>/<sha256>{ext}`).
|
||||
- ☐ `GET /evidence/{id}` (download, vérif perm) ; `DELETE /evidence/{id}` (soft).
|
||||
- ☐ Permissions : tout endpoint d'écriture vérifie `mission.write_red_fields` ou `mission.write_blue_fields` selon le champ touché ; les deux peuvent coexister sur un même groupe (pas exclusifs en code).
|
||||
- ☐ Bouton « Statut » avec choix `executed`, `reviewed_by_blue`, `skipped`, `blocked` (transitions contrôlées : pending↔skipped/blocked, executed→reviewed_by_blue).
|
||||
- ☐ Indicateur « modifié par X il y a Ns » : polling `GET /missions/{id}/activity?since=…` toutes les 15 s tant que la page est active.
|
||||
- ☑ Modale ou page dédiée `Mission > Test #N` avec deux zones distinctes (red / blue), bordures accentuées par couleur (rouge / cyan).
|
||||
- ☑ Côté red : champ commande (mono), output (mono multiline), commentaire markdown, bouton « Marquer exécuté » qui set `state=executed` + `executed_at=now()` ; édition de `executed_at` derrière un toggle « override ».
|
||||
- ☑ Côté blue : sélecteur `detection_level`, commentaire markdown, zone d'upload multi-fichiers (drag-and-drop).
|
||||
- ☑ Upload preuves : `POST /missions/{id}/tests/{test_id}/evidence` (multipart, validation extension+MIME+taille≤25Mo, calcul SHA256, stockage `/data/evidence/<mission_id>/<test_id>/<sha256>{ext}`).
|
||||
- ☑ `GET /evidence/{id}` (download, vérif perm) ; `DELETE /evidence/{id}` (soft).
|
||||
- ☑ Permissions : tout endpoint d'écriture vérifie `mission.write_red_fields` ou `mission.write_blue_fields` selon le champ touché ; les deux peuvent coexister sur un même groupe (pas exclusifs en code).
|
||||
- ☑ Bouton « Statut » avec choix `executed`, `reviewed_by_blue`, `skipped`, `blocked` (transitions contrôlées : pending↔skipped/blocked, executed→reviewed_by_blue).
|
||||
- ☑ Indicateur « modifié par X il y a Ns » : polling `GET /missions/{id}/activity?since=…` toutes les 15 s tant que la page est active.
|
||||
|
||||
**DoD** : red et blue saisissent en parallèle sans conflit ; un user sans `write_blue_fields` reçoit 403 sur les champs blue ; un fichier .evtx de 24 Mo est uploadé, un de 26 Mo est rejeté ; le hash SHA256 est correct.
|
||||
|
||||
### Amendement 2026-05-15 — fiche de review blue étendue + vue tabulaire
|
||||
|
||||
- ☑ Migration `c2a8f4b1d6e9` : 5 nouvelles colonnes sur `mission_tests` — `blue_log_source` (varchar 120), `blue_siem_logs` (text), `blue_incident_at` (timestamptz), `blue_incident_number` (varchar 120), `blue_incident_recipient_email` (varchar 255).
|
||||
- ☑ Service `mission_tests` : `_BLUE_FIELDS` étendu aux 5 nouveaux champs, `update_mission_test_fields` accepte chaque kwarg, perm gating identique (red user → 403 sur chaque champ).
|
||||
- ☑ Service `missions` : `MissionTestView` (vue nested dans `GET /missions/{id}`) inclut désormais toutes les annotations red/blue + `last_actor_*` + `updated_at` + `detection_level_key`, avec batch-lookup détection-level/users pour rester O(1) en nombre de tests.
|
||||
- ☑ API : `UpdateMissionTestPayload` + serializer `_serialize_test` / `_serialize_test_detail` mis à jour, validation length-cap par champ.
|
||||
- ☑ Tests pytest : 3 nouveaux (`test_blue_user_writes_new_blue_review_fields`, `test_red_user_cannot_write_new_blue_review_fields`, `test_blue_review_fields_survive_round_trip_via_get`) — 136 verts.
|
||||
- ☐ Frontend : vue tabulaire pleine largeur dans l'onglet **tests** du detail page, un tableau par scénario, colonnes `Test | Procédure | Exécution | Source de log | Commentaires | Logs SIEM | Cyber Incident`, double-clic = mode édition inline gated par perms. La page `/missions/<id>/tests/<test_id>` reste pour l'upload de preuves.
|
||||
- ☐ Docs : CHANGELOG section dédiée, testing-m7.md mise à jour pour la matrice de colonnes + le workflow d'édition inline.
|
||||
|
||||
**DoD amendement** : blue user double-clique sur une ligne, saisit `log_source` + `siem_logs` + le sous-record incident, sauve ; rafraîchir la mission → tout est persisté ; red user double-clique sur la même ligne → ne peut éditer que `Exécution` (`executed_at`, `red_command`).
|
||||
|
||||
---
|
||||
|
||||
## M8 — Niveaux de détection custom ☐
|
||||
|
||||
Reference in New Issue
Block a user