diff --git a/CHANGELOG.md b/CHANGELOG.md index 1914687..9b49a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to this project will be documented here. Format: [Keep a Cha ## [Unreleased] +### Changed (amendement 2026-05-15 bis) — explicit test workflow removed, lifecycle now driven by writes + +User feedback: «Enlève également le workflow d'un test, quand on saisit +des informations côtés redteam cela signifie qu'il a été exécuté et donc +en attente d'une review blueteam.» + +- **Backend `update_mission_test_fields`**: at the end of every PUT, the + service inspects the touched field set. Any red-side write on a non- + executed test (`pending` / `skipped` / `blocked`) promotes the state to + `executed`; if no `executed_at` was supplied, it auto-stamps `now()`. + Any blue-side write on an `executed` test promotes to `reviewed_by_blue`. + The `/transition` endpoint stays operational for back-fill / admin use + but is no longer the primary path. +- **`MISSION_TEST_STATE_LABEL`** rephrased to describe the implicit + lifecycle instead of the workflow: `Pending → Not started`, `Executed + → Awaiting review`, `Reviewed_by_blue → Reviewed`, `Skipped`, `Blocked`. +- **`MissionTestPage.tsx`**: transition buttons in the header are gone. + The state pill remains as a passive indicator. The `transition` + mutation and its imports are dropped; `useMutation` is still used for + the red / blue field saves. +- **E2E**: the SPA test that previously clicked `transition-executed` + now exercises the implicit promotion — it just types in the red fields + and asserts that the state-pill flips from `Not started` to `Awaiting + review` on save. +- **Tests**: 3 new pytest cases — `test_red_writing_any_red_field_implicitly_executes_and_stamps` + (red_command alone bumps state + auto-stamps executed_at), + `test_blue_writing_any_blue_field_promotes_executed_to_reviewed`, + `test_blue_write_on_pending_does_not_auto_execute` (blue-on-pending is + a no-op — only red drives execution per the user's mental model). +- Total: **142 pytest** + 49 Playwright green. + ### Fixed (post-amendement 2026-05-15) — stamping executed_at no longer needs a prior state transition User feedback: when a red user typed `executed_at` inline on a pending test diff --git a/backend/app/services/mission_tests.py b/backend/app/services/mission_tests.py index 23e1ba6..e285c2d 100644 --- a/backend/app/services/mission_tests.py +++ b/backend/app/services/mission_tests.py @@ -591,15 +591,9 @@ def update_mission_test_fields( if "executed_at_overridden" in touched or "executed_at" in touched: # Editing executed_at is a red-only privilege (gated above via - # _RED_FIELDS). We no longer reject the write based on the - # current state — the spec amendement 2026-05-15 lets the red - # team record an execution timestamp inline, which would be - # circular if they had to transition the state machine first. - # Instead, stamping a non-null timestamp implicitly bumps the - # state forward from any non-executed source so the persisted - # record stays internally consistent. The same `mission. - # write_red_fields` perm covers both moves, so this isn't a - # privilege escalation. + # _RED_FIELDS). State auto-promotion is handled by the general + # block below; this branch just validates and applies the + # timestamp + override flag. new_overridden = ( bool(executed_at_overridden) if "executed_at_overridden" in touched @@ -612,11 +606,25 @@ def update_mission_test_fields( ) 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") - if new_at is not None and test.state in {"pending", "skipped", "blocked"}: - test.state = "executed" test.executed_at = new_at test.executed_at_overridden = new_overridden + # Implicit lifecycle (post-amendement 2026-05-15 bis): the explicit + # workflow is gone from the UI. A write to ANY red field implies the + # test was executed (auto-stamp executed_at if the operator didn't + # supply one); a write to ANY blue field on an executed test implies + # the blue team reviewed it. The /transition endpoint stays for + # back-fill but is no longer the primary path. + red_touched = bool(touched & _RED_FIELDS) + blue_touched = bool(touched & _BLUE_FIELDS) + if red_touched and test.state in {"pending", "skipped", "blocked"}: + if test.executed_at is None: + test.executed_at = datetime.now(tz=timezone.utc) + test.executed_at_overridden = False + test.state = "executed" + if blue_touched and test.state == "executed": + test.state = "reviewed_by_blue" + _touch(test, viewer_id) s.flush() s.refresh(test) diff --git a/backend/tests/test_mission_tests.py b/backend/tests/test_mission_tests.py index dc68462..481336b 100644 --- a/backend/tests/test_mission_tests.py +++ b/backend/tests/test_mission_tests.py @@ -618,6 +618,82 @@ def test_red_setting_executed_at_on_pending_auto_transitions_to_executed( assert forbidden.status_code == 403 +def test_red_writing_any_red_field_implicitly_executes_and_stamps( + client, admin_token, catalogue, red_user +): + """Post-amendement 2026-05-15 bis: the explicit state-machine workflow + is gone. Writing ANY red field on a non-executed test promotes the + state to `executed`; if the operator didn't supply an `executed_at`, + the service auto-stamps `now()` so the persisted row is internally + consistent.""" + mission = _make_mission( + client, admin_token, name="m7-implicit-execute", + 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": "whoami /priv"}, # no executed_at! + ) + assert r.status_code == 200, r.get_data(as_text=True) + body = r.get_json() + assert body["state"] == "executed" + assert body["red_command"] == "whoami /priv" + assert body["executed_at"] is not None # auto-stamped + assert body["executed_at_overridden"] is False + + +def test_blue_writing_any_blue_field_promotes_executed_to_reviewed( + client, admin_token, catalogue, red_user, blue_user +): + """A blue write on an executed test implicitly promotes the state to + `reviewed_by_blue` — no explicit transition required from the UI.""" + mission = _make_mission( + client, admin_token, name="m7-implicit-review", + scenario_id=catalogue["scenario"]["id"], + red_id=red_user["id"], blue_id=blue_user["id"], + ) + tid = _first_test_id(mission) + # Red executes (also implicit). + client.put( + f"/api/v1/missions/{mission['id']}/tests/{tid}", + headers=_bearer(red_user["token"]), + json={"red_command": "powershell -enc Y2FsYw=="}, + ) + # Blue reviews by writing a comment — state should auto-promote. + r = client.put( + f"/api/v1/missions/{mission['id']}/tests/{tid}", + headers=_bearer(blue_user["token"]), + json={"blue_comment_md": "alert raised on EDR"}, + ) + assert r.status_code == 200, r.get_data(as_text=True) + body = r.get_json() + assert body["state"] == "reviewed_by_blue" + + +def test_blue_write_on_pending_does_not_auto_execute( + client, admin_token, catalogue, blue_user +): + """A blue write on a still-pending test does NOT auto-execute — only + red's writes imply execution (per user feedback: 'quand on saisit des + informations côtés redteam cela signifie qu'il a été exécuté').""" + mission = _make_mission( + client, admin_token, name="m7-blue-on-pending", + 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={"blue_comment_md": "pre-emptive note"}, + ) + assert r.status_code == 200 + body = r.get_json() + assert body["state"] == "pending" # unchanged + assert body["executed_at"] is None # not stamped + + def test_red_setting_executed_at_from_skipped_state_auto_transitions( client, admin_token, catalogue, red_user ): diff --git a/e2e/tests/m7-execution.spec.ts b/e2e/tests/m7-execution.spec.ts index a6dd0ae..f3b0d66 100644 --- a/e2e/tests/m7-execution.spec.ts +++ b/e2e/tests/m7-execution.spec.ts @@ -382,19 +382,16 @@ test.describe('M7 — Test execution', () => { await page.goto(`/missions/${m.id}/tests/${testId}`); await expect(page.getByTestId('mission-test-page')).toBeVisible(); - await expect(page.getByTestId('state-pill')).toContainText(/Pending/); + await expect(page.getByTestId('state-pill')).toContainText(/Not started/); - // Fill the red command + comment, then save. + // Fill the red command + comment, then save. Post-amendement 2026-05-15 + // bis: writing red fields implicitly promotes the state — no transition + // button click required. await page.getByTestId('red-command').fill('whoami /priv'); await page.getByTestId('red-comment').fill('Verified locally'); await page.getByTestId('red-save').click(); - // 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/); + await expect(page.getByTestId('state-pill')).toContainText(/Awaiting review/); // The "last touched" line should now mention the red user. await expect(page.locator('text=/Last touched/')).toBeVisible(); diff --git a/frontend/src/lib/missions.ts b/frontend/src/lib/missions.ts index 73f73ad..6e16f88 100644 --- a/frontend/src/lib/missions.ts +++ b/frontend/src/lib/missions.ts @@ -315,9 +315,12 @@ export const EVIDENCE_ALLOWED_EXTENSIONS = [ export const EVIDENCE_MAX_BYTES = 25 * 1024 * 1024; +// Post-amendement 2026-05-15 bis: the explicit workflow is gone from the +// UI — the state column survives in the DB but the labels describe the +// implicit lifecycle (driven by which side has written data). export const MISSION_TEST_STATE_LABEL: Record = { - pending: 'Pending', - executed: 'Executed', + pending: 'Not started', + executed: 'Awaiting review', reviewed_by_blue: 'Reviewed', skipped: 'Skipped', blocked: 'Blocked', diff --git a/frontend/src/pages/MissionTestPage.tsx b/frontend/src/pages/MissionTestPage.tsx index 8cba87c..9b81b49 100644 --- a/frontend/src/pages/MissionTestPage.tsx +++ b/frontend/src/pages/MissionTestPage.tsx @@ -28,21 +28,13 @@ 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 { ApiError, apiDelete, apiFetch, apiGet, apiPut } from '@/lib/api'; import { useAuth } from '@/lib/auth'; import { EVIDENCE_ALLOWED_EXTENSIONS, EVIDENCE_MAX_BYTES, MISSION_TEST_STATE_ACCENT, MISSION_TEST_STATE_LABEL, - VALID_TEST_TRANSITIONS, missionKeys, missionTestKeys, type ActivityResponse, @@ -50,7 +42,6 @@ import { type DetectionLevelList, type MissionTestDetail, type MissionTestEvidence, - type TestTransitionPayload, type UpdateMissionTestPayload, } from '@/lib/missions'; @@ -624,18 +615,6 @@ export function MissionTestPage() { useActivityWatcher(missionId, testId, onActivityTouched); - const transition = useMutation({ - mutationFn: (body: TestTransitionPayload) => - apiPost( - `/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(() => { @@ -667,10 +646,6 @@ export function MissionTestPage() { return Failed to load mission test.; } - const allowedTransitions = VALID_TEST_TRANSITIONS[test.state] ?? []; - const transitionErr = - transition.error instanceof ApiError ? transition.error : null; - return (
@@ -693,37 +668,18 @@ export function MissionTestPage() { : ''}

+ {/* Post-amendement 2026-05-15 bis: no transition buttons. The pill + is a passive indicator of where the implicit lifecycle stands + (driven by who has written data). */}
{MISSION_TEST_STATE_LABEL[test.state]} - {allowedTransitions.map((target) => ( - - ))}
- {transitionErr && ( - - {transitionErr.payload && - typeof transitionErr.payload === 'object' && - 'message' in (transitionErr.payload as Record) - ? `${transitionErr.message} — ${(transitionErr.payload as { message?: string }).message}` - : transitionErr.message} - - )} -
{test.mitre_tags.map((tag) => ( diff --git a/tasks/spec.md b/tasks/spec.md index 7cde554..a0dccc7 100644 --- a/tasks/spec.md +++ b/tasks/spec.md @@ -53,7 +53,7 @@ Remplace l'aller-retour Excel actuel par une UI partagée temps réel, multi-rô - Saisie des résultats red (texte uniquement : commande, output, commentaires) avec horodatage auto au clic « Marquer exécuté » + override manuel. - Saisie des preuves blue : multi-fichiers (PNG/JPG/PDF/TXT/LOG/JSON/CSV/EVTX/ZIP, max 25 Mo/fichier, SHA256 stocké) + commentaires markdown + niveau de détection (taxonomie custom paramétrable par admin, seed par défaut : `detected_blocked / detected_alert / logged_only / not_detected`). - **Saisie côté blue — fiche de review étendue (amendement 2026-05-15)** : en plus du commentaire et du niveau de détection, la fiche de review d'un test capture les 5 champs additionnels que la blue maintenait en Excel — **`log_source`** (texte court : Firewall / NDR / Proxy / AV / EDR / …), **`siem_logs`** (texte long, extrait brut de logs collectés au SIEM), **sous-record cyber-incident** `(incident_at: timestamptz, incident_number: texte court, incident_recipient_email: email)` qui matérialise l'alerte envoyée à l'équipe SOC. Tous ces champs sont blue-side et gated par `mission.write_blue_fields`. -- Workflow par test instance : `pending → executed → reviewed_by_blue` + voies `skipped / blocked`. +- **Cycle de vie d'un test instance — implicite, piloté par les écritures (amendement 2026-05-15 bis)** : aucun bouton de transition exposé en UI. La colonne `state` du DB reste (`pending / executed / reviewed_by_blue / skipped / blocked`) mais elle est **auto-promue** par le service à chaque PUT : (1) toute écriture côté red sur un test `pending|skipped|blocked` fait passer à `executed` et auto-stamp `executed_at = now()` si absent ; (2) toute écriture côté blue sur un test `executed` fait passer à `reviewed_by_blue`. Une écriture blue sur un test `pending` n'auto-promote pas (seule la red implique l'exécution). L'endpoint `/transition` est conservé pour back-fill admin mais n'est plus appelé par l'UI. - Visibilité mission : whitebox totale pour la blue team dès la création (pas de masquage des procédures). - Édition concurrente : last-write-wins + indicateur « modifié par X il y a Ns » via polling léger. Conflits red/blue impossibles par construction (champs disjoints). - Notifications in-app uniquement (badge + liste), pas de SMTP.