feat(m7): per-test execution — red/blue zones, evidence pipeline, activity poll
DoD M7 (spec §F5 + §F6 + §F8 + tasks/todo.md M7) covered end-to-end:
Backend
- New migration `91a4e7c6d2f3` adds `mission_tests.last_actor_id` (FK users
ON DELETE SET NULL) and `ix_mission_tests_updated_at` for the polling query.
- `detection_levels`: 4 default rows seeded at boot, `GET /detection-levels`
read-only (CRUD lands in M8).
- `mission_tests` service + `missions` API extension:
- `GET /missions/{id}/tests/{test_id}` — full detail incl. evidence list
- `PUT /missions/{id}/tests/{test_id}` — patch red/blue fields with per-field
perm classification (`mission.write_red_fields` vs `mission.write_blue_fields`)
- `POST /missions/{id}/tests/{test_id}/transition` — pending↔skipped/blocked
and pending→executed→reviewed_by_blue (+ undo paths), side-aware perm gate
that fires *before* idempotency, `executed_at` auto-stamped on the way in
- `GET /missions/{id}/activity?since=<ISO>` — drives the 15 s polling badge
- `evidence` service + top-level `/evidence/<id>` API:
- Streaming upload, SHA256 chunk-by-chunk, 25 MB cap, ext+MIME whitelist
- Content-addressed storage at ${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>
- Atomic `os.replace`, hex-validated SHA path component, root-dir guard
- Membership-aware (404 on miss/forbidden, no existence leak)
- `/diag/reset` now wipes ${EVIDENCE_DIR}/* in test mode (symlink-safe) and
re-seeds detection levels as a safety net.
Frontend
- `lib/missions.ts` — M7 types + queryKey factory + state-machine matrix.
- `pages/MissionTestPage.tsx` — two-zone layout: red border (command, output,
comment, mark-executed + override toggle) and cyan border (detection-level
select, comment, drag-and-drop evidence dropzone). Last-touched badge polls
/activity every 15 s, gated on document.visibilityState. Per-field disable
based on the user's red/blue perms (server stays the arbiter).
- `pages/MissionDetailPage.tsx` — test rows link to the new per-test page.
- `App.tsx` — registers /missions/:id/tests/:testId behind RequireAuth.
- `HomePage.tsx` — hero + roadmap card bumped to M7; next is M8.
Tests
- `backend/tests/test_mission_tests.py` — 27 pytest tests (red/blue field
gating, state-machine matrix incl. idempotent-side enforcement, executed_at
override, 24/26 MB upload + SHA256, MIME/ext whitelist, soft-delete hide,
activity polling with URL-encoded `since`, membership 404 vs admin bypass,
cross-mission evidence access).
- `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 404 message). afterAll restores stable admin and
re-syncs MITRE.
Docs
- CHANGELOG.md: M7 section + post-M7 review-pass subsection.
- README.md: status, feature blurb, roadmap, testing-m7 link.
- tasks/testing-m7.md: manual + automated procedure with transition matrix
and perm-gating table.
- tasks/lessons.md: M7 retrospectives (LogRecord `created` trap, URL-encoded
query timestamps, perm-before-flush, atomic move, polling visibility gate).
Test count: 133 pytest / 49 Playwright, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -4,6 +4,40 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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)
|
### 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`:
|
- **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.
|
- **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.
|
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
|
## 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`.
|
- **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`.
|
- **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.
|
- **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.
|
- **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
@@ -95,7 +96,7 @@ See `.env.example`. The most important ones:
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- **Manual + automated checklist for the current milestone**: see [`tasks/testing-m<N>.md`](tasks/testing-m6.md) (current: `testing-m6.md`).
|
- **Manual + automated checklist for the current milestone**: see [`tasks/testing-m<N>.md`](tasks/testing-m7.md) (current: `testing-m7.md`).
|
||||||
- **Backend unit tests**: `make test-api`
|
- **Backend unit tests**: `make test-api`
|
||||||
- **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`.
|
- **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`.
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ The hooks run `ruff` + `ruff-format` on the backend and `eslint` / `tsc --noEmit
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
See `tasks/todo.md`. Current milestone: **M6 — Missions & snapshot** (done). Next: M7 (red/blue execution on a mission test).
|
See `tasks/todo.md`. Current milestone: **M7 — Red & blue execution on a mission test** (done). Next: M8 (custom detection-level CRUD).
|
||||||
|
|
||||||
## License
|
## 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")
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, abort, jsonify
|
from flask import Blueprint, abort, jsonify
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -16,6 +18,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.install_token import regenerate_install_token
|
from app.core.install_token import regenerate_install_token
|
||||||
from app.db.session import get_engine
|
from app.db.session import get_engine
|
||||||
|
from app.services.detection_levels import seed_detection_levels
|
||||||
|
|
||||||
bp = Blueprint("diag", __name__, url_prefix="/diag")
|
bp = Blueprint("diag", __name__, url_prefix="/diag")
|
||||||
log = logging.getLogger("metamorph.diag")
|
log = logging.getLogger("metamorph.diag")
|
||||||
@@ -108,10 +111,39 @@ def reset_test_state():
|
|||||||
"mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE"
|
"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:
|
except SQLAlchemyError as e:
|
||||||
log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
|
log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
|
||||||
return jsonify({"reset": False, "error": "database_error"}), 500
|
return jsonify({"reset": False, "error": "database_error"}), 500
|
||||||
|
|
||||||
|
# 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()
|
token = regenerate_install_token()
|
||||||
|
|
||||||
# Clear the in-memory rate-limit counters so the e2e suite that follows can
|
# 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})
|
||||||
@@ -9,19 +9,25 @@ Status transitions are routed through a single POST endpoint that accepts a
|
|||||||
target status. We accept either `mission.update` or `mission.archive` at the
|
target status. We accept either `mission.update` or `mission.archive` at the
|
||||||
gate — archiving requires the dedicated perm if the target is `archived`, and
|
gate — archiving requires the dedicated perm if the target is `archived`, and
|
||||||
the service enforces the lifecycle graph (`_VALID_TRANSITIONS`).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date, datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, abort, g, jsonify, request
|
from flask import Blueprint, abort, g, jsonify, request
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
from app.core.auth_decorators import AuthenticatedUser, require_auth, require_perm
|
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
|
from app.services import missions as svc
|
||||||
|
|
||||||
bp = Blueprint("missions", __name__, url_prefix="/missions")
|
bp = Blueprint("missions", __name__, url_prefix="/missions")
|
||||||
@@ -496,3 +502,331 @@ def soft_delete_mission(mission_id: str):
|
|||||||
return jsonify({"error": "not_found"}), 404
|
return jsonify({"error": "not_found"}), 404
|
||||||
log.info("metamorph.mission.soft_deleted", extra={"mission_id": str(mid)})
|
log.info("metamorph.mission.soft_deleted", extra={"mission_id": str(mid)})
|
||||||
return jsonify({"ok": True})
|
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
|
||||||
|
executed_at: datetime | None = None
|
||||||
|
executed_at_overridden: bool | None = None
|
||||||
|
|
||||||
|
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,
|
||||||
|
"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()}), 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",
|
||||||
|
):
|
||||||
|
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()}), 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(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
|||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
from app.api.auth import bp as auth_bp
|
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.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.groups import bp as groups_bp
|
||||||
from app.api.health import bp as health_bp
|
from app.api.health import bp as health_bp
|
||||||
from app.api.invitations import bp as invitations_bp
|
from app.api.invitations import bp as invitations_bp
|
||||||
@@ -30,3 +32,5 @@ bp.register_blueprint(mitre_bp)
|
|||||||
bp.register_blueprint(test_templates_bp)
|
bp.register_blueprint(test_templates_bp)
|
||||||
bp.register_blueprint(scenario_templates_bp)
|
bp.register_blueprint(scenario_templates_bp)
|
||||||
bp.register_blueprint(missions_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.logging import configure_logging
|
||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.services.bootstrap import ensure_system_groups
|
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
|
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:
|
try:
|
||||||
ensure_system_groups()
|
ensure_system_groups()
|
||||||
seed_permissions_and_bindings()
|
seed_permissions_and_bindings()
|
||||||
|
seed_detection_levels()
|
||||||
token = ensure_install_token()
|
token = ensure_install_token()
|
||||||
if token is not None:
|
if token is not None:
|
||||||
log_install_token_banner(token)
|
log_install_token_banner(token)
|
||||||
|
|||||||
@@ -218,6 +218,17 @@ class MissionTest(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
|||||||
nullable=True,
|
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")
|
scenario: Mapped[MissionScenario] = relationship(back_populates="tests")
|
||||||
mitre_tags: Mapped[list["MissionTestMitreTag"]] = relationship(
|
mitre_tags: Mapped[list["MissionTestMitreTag"]] = relationship(
|
||||||
back_populates="mission_test",
|
back_populates="mission_test",
|
||||||
@@ -236,6 +247,7 @@ class MissionTest(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
|||||||
),
|
),
|
||||||
UniqueConstraint("scenario_id", "position", name="uq_mission_tests_position"),
|
UniqueConstraint("scenario_id", "position", name="uq_mission_tests_position"),
|
||||||
Index("ix_mission_tests_state", "state"),
|
Index("ix_mission_tests_state", "state"),
|
||||||
|
Index("ix_mission_tests_updated_at", "updated_at"),
|
||||||
Index(
|
Index(
|
||||||
"ix_mission_tests_active",
|
"ix_mission_tests_active",
|
||||||
"deleted_at",
|
"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",
|
||||||
|
]
|
||||||
668
backend/app/services/mission_tests.py
Normal file
668
backend/app/services/mission_tests.py
Normal file
@@ -0,0 +1,668 @@
|
|||||||
|
"""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
|
||||||
|
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,
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> 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")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if "executed_at_overridden" in touched or "executed_at" in touched:
|
||||||
|
# Editing executed_at is a red-only privilege and only valid when
|
||||||
|
# the test is past the `executed` milestone. Spec M7: override is
|
||||||
|
# behind a deliberate toggle so the auto-stamp default is sticky.
|
||||||
|
if test.state not in {"executed", "reviewed_by_blue"}:
|
||||||
|
raise InvalidTestPayload(
|
||||||
|
"executed_at can only be set when state is executed/reviewed_by_blue"
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
_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"]
|
||||||
884
backend/tests/test_mission_tests.py
Normal file
884
backend/tests/test_mission_tests.py
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
"""M7 — per-test execution, evidence upload, activity polling.
|
||||||
|
|
||||||
|
Fixture stack mirrors `test_missions.py` so we can reuse the test_template/
|
||||||
|
scenario_template catalogue and the red/blue/reader user invitations. M7
|
||||||
|
adds the assumption that detection_levels are seeded (boot does this for
|
||||||
|
the live API; we re-seed inside the module fixture to cover the truncated
|
||||||
|
state).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.install_token import regenerate_install_token
|
||||||
|
from app.main import create_app
|
||||||
|
from app.services import detection_levels as detection_svc
|
||||||
|
from app.services import mitre_seed as mitre_svc
|
||||||
|
|
||||||
|
|
||||||
|
_MINIMAL_BUNDLE = {
|
||||||
|
"type": "bundle",
|
||||||
|
"id": "bundle--00000000-0000-0000-0000-000000000007",
|
||||||
|
"spec_version": "2.1",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"type": "x-mitre-tactic",
|
||||||
|
"id": "x-mitre-tactic--ta0002",
|
||||||
|
"name": "Execution",
|
||||||
|
"x_mitre_shortname": "execution",
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "TA0002"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"id": "attack-pattern--t1059",
|
||||||
|
"name": "Command and Scripting Interpreter",
|
||||||
|
"kill_chain_phases": [
|
||||||
|
{"kill_chain_name": "mitre-attack", "phase_name": "execution"}
|
||||||
|
],
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "T1059"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_all(engine):
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"TRUNCATE mission_test_mitre_tags, mission_tests, "
|
||||||
|
"mission_scenarios, mission_categories, mission_members, "
|
||||||
|
"missions RESTART IDENTITY CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"TRUNCATE scenario_template_tests, scenario_templates, "
|
||||||
|
"test_template_mitre_tags, test_templates "
|
||||||
|
"RESTART IDENTITY CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
|
||||||
|
"user_groups, group_permissions, permissions, settings, groups "
|
||||||
|
"RESTART IDENTITY CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"TRUNCATE mitre_technique_tactics, mitre_subtechniques, "
|
||||||
|
"mitre_techniques, mitre_tactics RESTART IDENTITY CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app(db_engine_or_skip, tmp_path_factory, monkeypatch_module):
|
||||||
|
_truncate_all(db_engine_or_skip)
|
||||||
|
# Re-seed catalogues that boot/seed handles in production but `_truncate_all`
|
||||||
|
# has just wiped.
|
||||||
|
bundle_path = tmp_path_factory.mktemp("m7") / "stix.json"
|
||||||
|
bundle_path.write_text(json.dumps(_MINIMAL_BUNDLE))
|
||||||
|
mitre_svc.seed_mitre(source=bundle_path, expected_sha256=None)
|
||||||
|
detection_svc.seed_detection_levels()
|
||||||
|
# Point the evidence dir at a tmp location so test uploads don't pollute /data.
|
||||||
|
evidence_root = tmp_path_factory.mktemp("evidence")
|
||||||
|
monkeypatch_module.setattr(
|
||||||
|
"app.core.config.settings.EVIDENCE_DIR", str(evidence_root)
|
||||||
|
)
|
||||||
|
flask_app = create_app()
|
||||||
|
flask_app.config.update(TESTING=True)
|
||||||
|
flask_app.config["EVIDENCE_ROOT"] = str(evidence_root)
|
||||||
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def monkeypatch_module():
|
||||||
|
"""Module-scoped monkeypatch — pytest's built-in is function-scoped only."""
|
||||||
|
from _pytest.monkeypatch import MonkeyPatch # noqa: PLC0415
|
||||||
|
|
||||||
|
mp = MonkeyPatch()
|
||||||
|
yield mp
|
||||||
|
mp.undo()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_email(prefix: str) -> str:
|
||||||
|
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer(token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, email: str, password: str) -> str:
|
||||||
|
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
||||||
|
assert r.status_code == 200, r.get_data(as_text=True)
|
||||||
|
return r.get_json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def admin(app):
|
||||||
|
token = regenerate_install_token()
|
||||||
|
email = _unique_email("admin")
|
||||||
|
password = "AdminPass1234!"
|
||||||
|
with app.test_client() as c:
|
||||||
|
r = c.post(
|
||||||
|
"/api/v1/setup",
|
||||||
|
json={"install_token": token, "email": email, "password": password},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.get_data(as_text=True)
|
||||||
|
return {"email": email, "password": password}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin_token(client, admin) -> str:
|
||||||
|
return _login(client, admin["email"], admin["password"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- catalogue --
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test_template(client, admin_token: str, name: str):
|
||||||
|
body = {
|
||||||
|
"name": name,
|
||||||
|
"description": "auto",
|
||||||
|
"objective": "do thing",
|
||||||
|
"procedure_md": f"# {name}",
|
||||||
|
"expected_result_red_md": "red expectation",
|
||||||
|
"expected_detection_blue_md": "blue expectation",
|
||||||
|
"opsec_level": "medium",
|
||||||
|
"tags": [],
|
||||||
|
"expected_iocs": [],
|
||||||
|
"mitre_tags": [{"kind": "technique", "external_id": "T1059"}],
|
||||||
|
}
|
||||||
|
r = client.post("/api/v1/test-templates", headers=_bearer(admin_token), json=body)
|
||||||
|
assert r.status_code == 201, r.get_data(as_text=True)
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_scenario(client, admin_token: str, name: str, test_ids: list[str]):
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/scenario-templates",
|
||||||
|
headers=_bearer(admin_token),
|
||||||
|
json={"name": name, "description": None, "test_template_ids": test_ids},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.get_data(as_text=True)
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def catalogue(app, admin):
|
||||||
|
with app.test_client() as c:
|
||||||
|
tok = _login(c, admin["email"], admin["password"])
|
||||||
|
t1 = _make_test_template(c, tok, "exec-test")
|
||||||
|
sc = _make_scenario(c, tok, "exec-scenario", [t1["id"]])
|
||||||
|
return {"test": t1, "scenario": sc}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- users --
|
||||||
|
|
||||||
|
|
||||||
|
def _invite_user(client, admin_token: str, prefix: str, group_codes: list[str]) -> dict:
|
||||||
|
grp = client.post(
|
||||||
|
"/api/v1/groups",
|
||||||
|
headers=_bearer(admin_token),
|
||||||
|
json={"name": f"{prefix}-grp-{secrets.token_hex(2)}"},
|
||||||
|
).get_json()
|
||||||
|
r_set = client.put(
|
||||||
|
f"/api/v1/groups/{grp['id']}/permissions",
|
||||||
|
headers=_bearer(admin_token),
|
||||||
|
json={"codes": group_codes},
|
||||||
|
)
|
||||||
|
assert r_set.status_code == 200, r_set.get_data(as_text=True)
|
||||||
|
|
||||||
|
email = _unique_email(prefix)
|
||||||
|
password = "Pass1234!"
|
||||||
|
inv = client.post(
|
||||||
|
"/api/v1/invitations",
|
||||||
|
headers=_bearer(admin_token),
|
||||||
|
json={"email_hint": email, "group_ids": [grp["id"]]},
|
||||||
|
)
|
||||||
|
assert inv.status_code == 201, inv.get_data(as_text=True)
|
||||||
|
accept_token = inv.get_json()["token"]
|
||||||
|
r = client.post(
|
||||||
|
f"/api/v1/invitations/accept/{accept_token}",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.get_data(as_text=True)
|
||||||
|
tok = _login(client, email, password)
|
||||||
|
me = client.get("/api/v1/auth/me", headers=_bearer(tok)).get_json()
|
||||||
|
return {"email": email, "password": password, "token": tok, "id": me["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def red_user(client, admin_token):
|
||||||
|
return _invite_user(
|
||||||
|
client,
|
||||||
|
admin_token,
|
||||||
|
"red",
|
||||||
|
[
|
||||||
|
"mission.read",
|
||||||
|
"mission.create",
|
||||||
|
"mission.update",
|
||||||
|
"mission.write_red_fields",
|
||||||
|
"detection_level.read",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def blue_user(client, admin_token):
|
||||||
|
return _invite_user(
|
||||||
|
client,
|
||||||
|
admin_token,
|
||||||
|
"blue",
|
||||||
|
["mission.read", "mission.write_blue_fields", "detection_level.read"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def reader_user(client, admin_token):
|
||||||
|
return _invite_user(client, admin_token, "reader", ["mission.read"])
|
||||||
|
|
||||||
|
|
||||||
|
# Helper: bootstrap a mission with red+blue assigned and snapshot the catalogue.
|
||||||
|
def _make_mission(client, admin_token: str, *, name: str, scenario_id: str,
|
||||||
|
red_id: str | None = None, blue_id: str | None = None) -> dict:
|
||||||
|
members = []
|
||||||
|
if red_id:
|
||||||
|
members.append({"user_id": red_id, "role_hint": "red"})
|
||||||
|
if blue_id:
|
||||||
|
members.append({"user_id": blue_id, "role_hint": "blue"})
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/missions",
|
||||||
|
headers=_bearer(admin_token),
|
||||||
|
json={
|
||||||
|
"name": name,
|
||||||
|
"client_target": "Acme",
|
||||||
|
"scenario_template_ids": [scenario_id],
|
||||||
|
"members": members,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.get_data(as_text=True)
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _first_test_id(mission: dict) -> str:
|
||||||
|
return mission["scenarios"][0]["tests"][0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ detection ==
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_levels_seeded_and_listed(client, admin_token):
|
||||||
|
r = client.get("/api/v1/detection-levels", headers=_bearer(admin_token))
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.get_json()
|
||||||
|
keys = [it["key"] for it in body["items"]]
|
||||||
|
# All four defaults must be present, in position order.
|
||||||
|
assert keys == ["detected_blocked", "detected_alert", "logged_only", "not_detected"]
|
||||||
|
# The default flag is on `not_detected` per the seed.
|
||||||
|
defaults = [it for it in body["items"] if it["is_default"]]
|
||||||
|
assert [d["key"] for d in defaults] == ["not_detected"]
|
||||||
|
# All are flagged system so M8 CRUD can distinguish operator-added levels.
|
||||||
|
assert all(it["is_system"] for it in body["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_levels_requires_perm(client, admin_token, reader_user):
|
||||||
|
# The reader_user fixture has mission.read only — no detection_level.read.
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/detection-levels", headers=_bearer(reader_user["token"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== test ==
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_mission_test_returns_snapshot_state(
|
||||||
|
client, admin_token, catalogue, red_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-get",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
test_id = _first_test_id(mission)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{test_id}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.get_data(as_text=True)
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["state"] == "pending"
|
||||||
|
assert body["red_command"] is None
|
||||||
|
assert body["blue_comment_md"] is None
|
||||||
|
assert body["evidence"] == []
|
||||||
|
assert body["mission_id"] == mission["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_user_writes_red_fields(client, admin_token, catalogue, red_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-red-write",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={
|
||||||
|
"red_command": "powershell -enc ZAB1AG0AeQA=",
|
||||||
|
"red_output": "{stdout}",
|
||||||
|
"red_comment_md": "executed via SYSTEM",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.get_data(as_text=True)
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["red_command"] == "powershell -enc ZAB1AG0AeQA="
|
||||||
|
assert body["red_comment_md"] == "executed via SYSTEM"
|
||||||
|
assert body["last_actor_email"] == red_user["email"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_user_cannot_write_blue_fields(client, admin_token, catalogue, red_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-red-blocked-blue",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"blue_comment_md": "should be blocked"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_user_cannot_write_red_fields(client, admin_token, catalogue, blue_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-blue-blocked-red",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
json={"red_command": "echo nope"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_user_writes_blue_fields_and_picks_detection_level(
|
||||||
|
client, admin_token, catalogue, blue_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-blue-write",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
# First fetch the detection levels.
|
||||||
|
levels = client.get(
|
||||||
|
"/api/v1/detection-levels", headers=_bearer(blue_user["token"])
|
||||||
|
).get_json()["items"]
|
||||||
|
not_detected = next(l for l in levels if l["key"] == "not_detected")
|
||||||
|
r = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
json={
|
||||||
|
"blue_comment_md": "no detection on SOC",
|
||||||
|
"detection_level_id": not_detected["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.get_data(as_text=True)
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["blue_comment_md"] == "no detection on SOC"
|
||||||
|
assert body["detection_level_id"] == not_detected["id"]
|
||||||
|
assert body["detection_level_key"] == "not_detected"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_executed_stamps_executed_at(
|
||||||
|
client, admin_token, catalogue, red_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-exec",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = client.post(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"target_state": "executed"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.get_data(as_text=True)
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["state"] == "executed"
|
||||||
|
assert body["executed_at"] is not None
|
||||||
|
assert body["executed_at_overridden"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_executed_at_override_requires_red_perm_and_state(
|
||||||
|
client, admin_token, catalogue, red_user, blue_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-override",
|
||||||
|
scenario_id=catalogue["scenario"]["id"],
|
||||||
|
red_id=red_user["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
|
||||||
|
# Override while still pending → invalid_request (no executed milestone yet).
|
||||||
|
bad = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={
|
||||||
|
"executed_at": "2026-05-14T10:00:00+00:00",
|
||||||
|
"executed_at_overridden": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert bad.status_code == 400, bad.get_data(as_text=True)
|
||||||
|
|
||||||
|
# Mark executed first so we're allowed to override.
|
||||||
|
client.post(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"target_state": "executed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Blue cannot override (executed_at is a red field).
|
||||||
|
forbidden = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
json={
|
||||||
|
"executed_at": "2026-05-14T10:00:00+00:00",
|
||||||
|
"executed_at_overridden": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
|
||||||
|
# Red successfully overrides.
|
||||||
|
ok = client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={
|
||||||
|
"executed_at": "2026-05-14T10:00:00+00:00",
|
||||||
|
"executed_at_overridden": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200, ok.get_data(as_text=True)
|
||||||
|
body = ok.get_json()
|
||||||
|
assert body["executed_at_overridden"] is True
|
||||||
|
assert body["executed_at"].startswith("2026-05-14T10:00:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_machine_rejects_invalid_transitions(
|
||||||
|
client, admin_token, catalogue, red_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-state",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
# pending → reviewed_by_blue is not allowed (must go through executed first).
|
||||||
|
r = client.post(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"target_state": "reviewed_by_blue"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_by_blue_requires_blue_perm(
|
||||||
|
client, admin_token, catalogue, red_user, blue_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-review",
|
||||||
|
scenario_id=catalogue["scenario"]["id"],
|
||||||
|
red_id=red_user["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
# red marks executed
|
||||||
|
r1 = client.post(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"target_state": "executed"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
# red tries to mark reviewed_by_blue — denied (blue side)
|
||||||
|
r2 = client.post(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"target_state": "reviewed_by_blue"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 403
|
||||||
|
# blue does it — OK
|
||||||
|
r3 = client.post(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
json={"target_state": "reviewed_by_blue"},
|
||||||
|
)
|
||||||
|
assert r3.status_code == 200
|
||||||
|
assert r3.get_json()["state"] == "reviewed_by_blue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_visibility_returns_404_for_outsiders(
|
||||||
|
client, admin_token, catalogue, red_user, reader_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-secret",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(reader_user["token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_bypasses_membership(client, admin_token, catalogue, red_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-admin-sees-all",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
# Admin is not a member; sees the test anyway.
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(admin_token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ evidence ==
|
||||||
|
|
||||||
|
|
||||||
|
def _png_bytes(n: int) -> bytes:
|
||||||
|
"""Return n bytes prefixed with a valid PNG magic so MIME sniffers cooperate."""
|
||||||
|
return b"\x89PNG\r\n\x1a\n" + b"A" * max(0, n - 8)
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(client, mission_id: str, test_id: str, token: str, *,
|
||||||
|
filename: str, content: bytes, mime: str = "image/png"):
|
||||||
|
return client.post(
|
||||||
|
f"/api/v1/missions/{mission_id}/tests/{test_id}/evidence",
|
||||||
|
headers=_bearer(token),
|
||||||
|
data={"file": (io.BytesIO(content), filename, mime)},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_upload_small_succeeds_and_records_sha256(
|
||||||
|
client, admin_token, catalogue, blue_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-ev-small",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
content = _png_bytes(1024)
|
||||||
|
expected = hashlib.sha256(content).hexdigest()
|
||||||
|
r = _upload(client, mission["id"], tid, blue_user["token"],
|
||||||
|
filename="screenshot.png", content=content, mime="image/png")
|
||||||
|
assert r.status_code == 201, r.get_data(as_text=True)
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["sha256"] == expected
|
||||||
|
assert body["size_bytes"] == len(content)
|
||||||
|
assert body["original_filename"] == "screenshot.png"
|
||||||
|
assert body["mime"] == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_upload_24mb_succeeds_26mb_rejected(
|
||||||
|
client, admin_token, catalogue, blue_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-ev-boundaries",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
twenty_four = _png_bytes(24 * 1024 * 1024)
|
||||||
|
ok = _upload(
|
||||||
|
client, mission["id"], tid, blue_user["token"],
|
||||||
|
filename="lab.evtx", content=twenty_four, mime="application/octet-stream",
|
||||||
|
)
|
||||||
|
assert ok.status_code == 201, ok.get_data(as_text=True)[:200]
|
||||||
|
|
||||||
|
twenty_six = _png_bytes(26 * 1024 * 1024)
|
||||||
|
too_big = _upload(
|
||||||
|
client, mission["id"], tid, blue_user["token"],
|
||||||
|
filename="huge.evtx", content=twenty_six, mime="application/octet-stream",
|
||||||
|
)
|
||||||
|
assert too_big.status_code == 400
|
||||||
|
assert too_big.get_json()["error"] == "too_large"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_upload_rejects_unsupported_extension(
|
||||||
|
client, admin_token, catalogue, blue_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-ev-ext",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = _upload(
|
||||||
|
client, mission["id"], tid, blue_user["token"],
|
||||||
|
filename="evil.exe", content=b"\x4d\x5a", mime="application/octet-stream",
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.get_json()["error"] == "unsupported_extension"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_upload_requires_blue_perm(
|
||||||
|
client, admin_token, catalogue, red_user, blue_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-ev-perm",
|
||||||
|
scenario_id=catalogue["scenario"]["id"],
|
||||||
|
red_id=red_user["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
r = _upload(
|
||||||
|
client, mission["id"], tid, red_user["token"],
|
||||||
|
filename="note.txt", content=b"hi", mime="text/plain",
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_download_returns_bytes(client, admin_token, catalogue, blue_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-ev-dl",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
content = b"hello evidence\n"
|
||||||
|
upl = _upload(
|
||||||
|
client, mission["id"], tid, blue_user["token"],
|
||||||
|
filename="note.txt", content=content, mime="text/plain",
|
||||||
|
).get_json()
|
||||||
|
eid = upl["id"]
|
||||||
|
|
||||||
|
meta = client.get(
|
||||||
|
f"/api/v1/evidence/{eid}", headers=_bearer(blue_user["token"])
|
||||||
|
)
|
||||||
|
assert meta.status_code == 200
|
||||||
|
assert meta.get_json()["sha256"] == hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
dl = client.get(
|
||||||
|
f"/api/v1/evidence/{eid}?download=true",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
)
|
||||||
|
assert dl.status_code == 200
|
||||||
|
assert dl.data == content
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_soft_delete_hides_it_from_test_detail(
|
||||||
|
client, admin_token, catalogue, blue_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-ev-del",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
upl = _upload(
|
||||||
|
client, mission["id"], tid, blue_user["token"],
|
||||||
|
filename="evidence.json", content=b'{"ok":true}',
|
||||||
|
mime="application/json",
|
||||||
|
).get_json()
|
||||||
|
eid = upl["id"]
|
||||||
|
|
||||||
|
detail_before = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
).get_json()
|
||||||
|
assert len(detail_before["evidence"]) == 1
|
||||||
|
|
||||||
|
r = client.delete(
|
||||||
|
f"/api/v1/evidence/{eid}", headers=_bearer(blue_user["token"])
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
detail_after = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
).get_json()
|
||||||
|
assert detail_after["evidence"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent_transition_still_checks_side_perm(
|
||||||
|
client, admin_token, catalogue, red_user, blue_user
|
||||||
|
):
|
||||||
|
"""A blue-only user re-POSTing target_state=executed on an already-executed
|
||||||
|
test must NOT receive 200 — even though no write happens, returning success
|
||||||
|
falsely implies they hold the red-side perm. See post-review fix C1."""
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-idemp-side",
|
||||||
|
scenario_id=catalogue["scenario"]["id"],
|
||||||
|
red_id=red_user["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
# Red marks executed.
|
||||||
|
r1 = client.post(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"target_state": "executed"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
# Blue replays the same transition — must be 403, not 200.
|
||||||
|
r2 = client.post(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}/transition",
|
||||||
|
headers=_bearer(blue_user["token"]),
|
||||||
|
json={"target_state": "executed"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_member_of_other_mission_gets_404(
|
||||||
|
client, admin_token, catalogue, blue_user
|
||||||
|
):
|
||||||
|
"""A user who is a blue member of mission B must NOT be able to read an
|
||||||
|
evidence row belonging to mission A — the chain walk must collapse to 404."""
|
||||||
|
mission_a = _make_mission(
|
||||||
|
client, admin_token, name="m7-ev-cross-a",
|
||||||
|
scenario_id=catalogue["scenario"]["id"],
|
||||||
|
# blue_user is NOT a member of A
|
||||||
|
)
|
||||||
|
tid_a = _first_test_id(mission_a)
|
||||||
|
# Admin uploads on mission A.
|
||||||
|
upl = _upload(
|
||||||
|
client, mission_a["id"], tid_a, admin_token,
|
||||||
|
filename="a.txt", content=b"secret", mime="text/plain",
|
||||||
|
).get_json()
|
||||||
|
eid = upl["id"]
|
||||||
|
|
||||||
|
# blue_user joins mission B but tries to read mission A's evidence.
|
||||||
|
_make_mission(
|
||||||
|
client, admin_token, name="m7-ev-cross-b",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
r = client.get(f"/api/v1/evidence/{eid}", headers=_bearer(blue_user["token"]))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_non_member_gets_404(client, admin_token, catalogue, blue_user,
|
||||||
|
reader_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-ev-leak",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], blue_id=blue_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
upl = _upload(
|
||||||
|
client, mission["id"], tid, blue_user["token"],
|
||||||
|
filename="a.txt", content=b"x", mime="text/plain",
|
||||||
|
).get_json()
|
||||||
|
eid = upl["id"]
|
||||||
|
r = client.get(f"/api/v1/evidence/{eid}", headers=_bearer(reader_user["token"]))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ activity ==
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_polling_returns_recent_changes(
|
||||||
|
client, admin_token, catalogue, red_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-activity",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
tid = _first_test_id(mission)
|
||||||
|
|
||||||
|
# Baseline timestamp from server, then a write 'after' it should appear.
|
||||||
|
now = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/activity",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
)
|
||||||
|
assert now.status_code == 200
|
||||||
|
server_t = now.get_json()["server_time"]
|
||||||
|
|
||||||
|
# Mutate via PUT to bump updated_at.
|
||||||
|
client.put(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{tid}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
json={"red_comment_md": "kicked off"},
|
||||||
|
)
|
||||||
|
# `since` must be URL-encoded — its `+` and `:` would otherwise be mangled.
|
||||||
|
since_q = urllib.parse.quote(server_t)
|
||||||
|
fresh = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/activity?since={since_q}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
)
|
||||||
|
assert fresh.status_code == 200
|
||||||
|
items = fresh.get_json()["items"]
|
||||||
|
assert len(items) >= 1
|
||||||
|
assert items[0]["test_id"] == tid
|
||||||
|
assert items[0]["last_actor_email"] == red_user["email"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_invalid_since_returns_400(client, admin_token, catalogue, red_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-activity-bad",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/activity?since=not-a-date",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_404_for_non_member(client, admin_token, catalogue, red_user,
|
||||||
|
reader_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-activity-leak",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/activity",
|
||||||
|
headers=_bearer(reader_user["token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_since_in_future_returns_empty(
|
||||||
|
client, admin_token, catalogue, red_user
|
||||||
|
):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-activity-future",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
future = (datetime.now(tz=timezone.utc) + timedelta(hours=1)).isoformat()
|
||||||
|
since_q = urllib.parse.quote(future)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/activity?since={since_q}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_json()["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_test_id_returns_404(client, admin_token, catalogue, red_user):
|
||||||
|
mission = _make_mission(
|
||||||
|
client, admin_token, name="m7-unknown-test",
|
||||||
|
scenario_id=catalogue["scenario"]["id"], red_id=red_user["id"],
|
||||||
|
)
|
||||||
|
fake = str(uuid.uuid4())
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/missions/{mission['id']}/tests/{fake}",
|
||||||
|
headers=_bearer(red_user["token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
439
e2e/tests/m7-execution.spec.ts
Normal file
439
e2e/tests/m7-execution.spec.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
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(/Pending/);
|
||||||
|
|
||||||
|
// Fill the red command + comment, then save.
|
||||||
|
await page.getByTestId('red-command').fill('whoami /priv');
|
||||||
|
await page.getByTestId('red-comment').fill('Verified locally');
|
||||||
|
await page.getByTestId('red-save').click();
|
||||||
|
|
||||||
|
// After save the state-pill stays Pending (only transitions change it).
|
||||||
|
await expect(page.getByTestId('state-pill')).toContainText(/Pending/);
|
||||||
|
|
||||||
|
// Now transition to executed via the header button.
|
||||||
|
await page.getByTestId('transition-executed').click();
|
||||||
|
await expect(page.getByTestId('state-pill')).toContainText(/Executed/);
|
||||||
|
|
||||||
|
// 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 { MitrePage } from '@/pages/MitrePage';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
import { MissionDetailPage } from '@/pages/MissionDetailPage';
|
import { MissionDetailPage } from '@/pages/MissionDetailPage';
|
||||||
|
import { MissionTestPage } from '@/pages/MissionTestPage';
|
||||||
import { MissionsCreatePage } from '@/pages/MissionsCreatePage';
|
import { MissionsCreatePage } from '@/pages/MissionsCreatePage';
|
||||||
import { MissionsListPage } from '@/pages/MissionsListPage';
|
import { MissionsListPage } from '@/pages/MissionsListPage';
|
||||||
import { ProfilePage } from '@/pages/ProfilePage';
|
import { ProfilePage } from '@/pages/ProfilePage';
|
||||||
@@ -87,6 +88,14 @@ function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/missions/:id/tests/:testId"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<MissionTestPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -165,3 +165,134 @@ export const MISSION_STATUS_LABEL: Record<MissionStatus, string> = {
|
|||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
archived: 'Archived',
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
executed: 'Executed',
|
||||||
|
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>
|
<span className="text-purple">Purple Team Platform</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-mono text-sm font-light text-text-dim mt-2">
|
<p className="font-mono text-sm font-light text-text-dim mt-2">
|
||||||
Collaborative red & blue test orchestration — M6 milestone (Missions & snapshot)
|
Collaborative red & blue test orchestration — M7 milestone (Red & blue execution)
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -141,9 +141,9 @@ export function HomePage() {
|
|||||||
|
|
||||||
<Card accent="purple" title="Roadmap" sub="14 milestones">
|
<Card accent="purple" title="Roadmap" sub="14 milestones">
|
||||||
<p>
|
<p>
|
||||||
M0 + M1 + M2 + M3 + M4 + M5 + M6 done. Next:{' '}
|
M0 + M1 + M2 + M3 + M4 + M5 + M6 + M7 done. Next:{' '}
|
||||||
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
|
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
|
||||||
M7 — Red & blue execution on a mission test
|
M8 — Custom detection-level taxonomy
|
||||||
</code>
|
</code>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { MarkdownField } from '@/components/MarkdownField';
|
import { MarkdownField } from '@/components/MarkdownField';
|
||||||
import { Alert } from '@/components/ui/Alert';
|
import { Alert } from '@/components/ui/Alert';
|
||||||
@@ -679,11 +679,19 @@ export function MissionDetailPage() {
|
|||||||
{sc.tests.map((t) => (
|
{sc.tests.map((t) => (
|
||||||
<tr
|
<tr
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className="border-t border-border/40"
|
className="border-t border-border/40 hover:bg-bg-base/60"
|
||||||
data-testid={`mission-test-${t.id}`}
|
data-testid={`mission-test-${t.id}`}
|
||||||
>
|
>
|
||||||
<td className="py-1 text-text-dim">{t.position + 1}</td>
|
<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 text-text-bright">
|
||||||
|
<Link
|
||||||
|
to={`/missions/${m.id}/tests/${t.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
data-testid={`mission-test-link-${t.id}`}
|
||||||
|
>
|
||||||
|
{t.snapshot_name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
<td className="py-1">
|
<td className="py-1">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{t.mitre_tags.map((tag) => (
|
{t.mitre_tags.map((tag) => (
|
||||||
|
|||||||
750
frontend/src/pages/MissionTestPage.tsx
Normal file
750
frontend/src/pages/MissionTestPage.tsx
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
apiPost,
|
||||||
|
apiPut,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
import {
|
||||||
|
MISSION_TEST_STATE_ACCENT,
|
||||||
|
MISSION_TEST_STATE_LABEL,
|
||||||
|
VALID_TEST_TRANSITIONS,
|
||||||
|
missionKeys,
|
||||||
|
missionTestKeys,
|
||||||
|
type ActivityResponse,
|
||||||
|
type DetectionLevel,
|
||||||
|
type DetectionLevelList,
|
||||||
|
type MissionTestDetail,
|
||||||
|
type MissionTestEvidence,
|
||||||
|
type TestTransitionPayload,
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ?? '');
|
||||||
|
const [override, setOverride] = useState(test.executed_at_overridden);
|
||||||
|
const [executedAt, setExecutedAt] = useState(test.executed_at ?? '');
|
||||||
|
const dirty =
|
||||||
|
command !== (test.red_command ?? '') ||
|
||||||
|
output !== (test.red_output ?? '') ||
|
||||||
|
comment !== (test.red_comment_md ?? '') ||
|
||||||
|
override !== test.executed_at_overridden ||
|
||||||
|
(override && executedAt !== (test.executed_at ?? ''));
|
||||||
|
|
||||||
|
// Sync local state when a refetch lands a newer version of the test
|
||||||
|
// (concurrent collaboration: blue's edit shouldn't blow away our local
|
||||||
|
// unsaved changes, but a fresh load should).
|
||||||
|
useEffect(() => {
|
||||||
|
setCommand(test.red_command ?? '');
|
||||||
|
setOutput(test.red_output ?? '');
|
||||||
|
setComment(test.red_comment_md ?? '');
|
||||||
|
setOverride(test.executed_at_overridden);
|
||||||
|
setExecutedAt(test.executed_at ?? '');
|
||||||
|
// We deliberately reset on every test reload — see comment above.
|
||||||
|
}, [test]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
if (override !== test.executed_at_overridden) {
|
||||||
|
body.executed_at_overridden = override;
|
||||||
|
}
|
||||||
|
if (override) {
|
||||||
|
const iso = executedAt
|
||||||
|
? new Date(executedAt).toISOString()
|
||||||
|
: null;
|
||||||
|
body.executed_at = iso;
|
||||||
|
}
|
||||||
|
save.mutate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiErr = save.error instanceof ApiError ? save.error : null;
|
||||||
|
const canOverride =
|
||||||
|
canWriteRed && (test.state === 'executed' || test.state === 'reviewed_by_blue');
|
||||||
|
|
||||||
|
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>}
|
||||||
|
<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 flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 font-mono text-2xs text-text-dim">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={override}
|
||||||
|
onChange={(e) => setOverride(e.target.checked)}
|
||||||
|
disabled={!canOverride}
|
||||||
|
data-testid="red-executed-override"
|
||||||
|
/>
|
||||||
|
Override executed-at timestamp
|
||||||
|
</label>
|
||||||
|
{override && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={
|
||||||
|
executedAt ? new Date(executedAt).toISOString().slice(0, 16) : ''
|
||||||
|
}
|
||||||
|
onChange={(e) => setExecutedAt(e.target.value)}
|
||||||
|
disabled={!canOverride}
|
||||||
|
className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-2xs text-text"
|
||||||
|
data-testid="red-executed-at"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!override && test.executed_at && (
|
||||||
|
<span className="font-mono text-2xs text-text-dim">
|
||||||
|
auto-stamped at{' '}
|
||||||
|
<code className="text-text">{test.executed_at}</code>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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 ?? '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setComment(test.blue_comment_md ?? '');
|
||||||
|
setLevelId(test.detection_level_id ?? '');
|
||||||
|
}, [test]);
|
||||||
|
|
||||||
|
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 per file is server-enforced; this is just a
|
||||||
|
// friendly UX guardrail so the user does not waste a roundtrip.
|
||||||
|
for (const f of Array.from(files)) {
|
||||||
|
if (f.size > 25 * 1024 * 1024) {
|
||||||
|
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 (≤ 25 MB each)'
|
||||||
|
: 'Read-only — no upload permission'}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
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 transition = useMutation({
|
||||||
|
mutationFn: (body: TestTransitionPayload) =>
|
||||||
|
apiPost<MissionTestDetail>(
|
||||||
|
`/missions/${missionId}/tests/${testId}/transition`,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
onSuccess: (next) => {
|
||||||
|
qc.setQueryData(missionTestKeys.detail(missionId, testId), next);
|
||||||
|
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTransitions = VALID_TEST_TRANSITIONS[test.state] ?? [];
|
||||||
|
const transitionErr =
|
||||||
|
transition.error instanceof ApiError ? transition.error : null;
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
{allowedTransitions.map((target) => (
|
||||||
|
<Button
|
||||||
|
key={target}
|
||||||
|
accent={MISSION_TEST_STATE_ACCENT[target]}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => transition.mutate({ target_state: target })}
|
||||||
|
disabled={transition.isPending}
|
||||||
|
data-testid={`transition-${target}`}
|
||||||
|
>
|
||||||
|
→ {MISSION_TEST_STATE_LABEL[target]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{transitionErr && (
|
||||||
|
<Alert accent="red">
|
||||||
|
{transitionErr.payload &&
|
||||||
|
typeof transitionErr.payload === 'object' &&
|
||||||
|
'message' in (transitionErr.payload as Record<string, unknown>)
|
||||||
|
? `${transitionErr.message} — ${(transitionErr.payload as { message?: string }).message}`
|
||||||
|
: transitionErr.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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,19 @@ project: Metamorph
|
|||||||
- **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`.
|
- **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`.
|
||||||
- **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
|
- **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
|
||||||
|
|
||||||
|
## 2026-05-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
|
## 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.
|
- **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.
|
||||||
|
|||||||
189
tasks/testing-m7.md
Normal file
189
tasks/testing-m7.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
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.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).
|
||||||
|
- Limite côté client à 25 MB/file (garde-fou UX), refus serveur stricte
|
||||||
|
à 25 MB.
|
||||||
|
- Table récap : nom · taille · uploader · `sha256[:12]…` · link download +
|
||||||
|
bouton soft-delete.
|
||||||
|
|
||||||
|
### 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,18 +149,18 @@ spec: tasks/spec.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M7 — Saisie red & blue sur un test ☐
|
## M7 — Saisie red & blue sur un test ☑
|
||||||
|
|
||||||
**But** : exécution de la mission, le cœur du produit.
|
**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).
|
- ☑ 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é 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).
|
- ☑ 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}`).
|
- ☑ 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).
|
- ☑ `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).
|
- ☑ 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).
|
- ☑ 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.
|
- ☑ 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user