diff --git a/CHANGELOG.md b/CHANGELOG.md index d94e2c2..8861e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Added — Sprint 3 (Multi-technique simulations + MITRE matrix modal) + +**Backend** (164 pytest passing) +- `Simulation.techniques` JSON column replaces the scalar `mitre_technique_id` / `mitre_technique_name` pair. Stored as `[{"id", "name"}]`; tactics are derived at serialize time from the MITRE service (snapshot pattern survives bundle updates). +- Alembic migration `0003_simulation_techniques_array.py` — reversible upgrade (backfill from scalars → drop scalars → enforce `NOT NULL` via `batch_alter_table`) and symmetric downgrade. +- `PATCH /api/simulations/` now accepts `{technique_ids: ["T1059", "T1059.001", ...]}` (flat list of T-IDs, parents and subs at the same level). Server validates each ID against the bundle (400 on unknown), deduplicates while preserving order, resolves names, and rejects SOC payloads (403). Returns 503 if the bundle isn't loaded. +- `GET /api/mitre/matrix` — new endpoint returning the full Enterprise tree `[{tactic_id, tactic_name, techniques: [{id, name, subtechniques: [{id, name}]}]}]`. Tactics in canonical order (Initial Access → Impact). Techniques sorted alphabetically per tactic; sub-techniques nested under their parent via dot-ID detection. +- `mitre_svc` extended with `get_tactics(id)`, `lookup_name(id)`, `get_matrix()`, and a `TACTIC_NAMES` constant fixing the cosmetic `"Command And Control"` → `"Command and Control"` (MITRE canonical capitalisation). +- `REDTEAM_FIELDS | {"technique_ids"}` SOC gate in `simulation_workflow.apply_patch` preserves the sprint 2 field-level RBAC pattern. +- Auto-transition `pending → in_progress` extended: triggers when `technique_ids` is non-empty (consistent with the "non-empty value" rule from sprint 2). Empty list does not trigger. + +**Frontend** (86 vitest passing) +- `MitreTechniquesField` orchestrates multi-technique selection with **auto-save** — every add (Quick Search / matrix Apply) and every remove (× on tag chip) triggers a PATCH via `useUpdateSimulation`. Toast feedback on success/error; UI disabled during the in-flight PATCH; silent dedup if the user re-adds an already-present technique. +- `MitreTechniqueTag` — chip component (`bg-primary-soft text-primary-deep rounded-full`) with an × remove button. +- `MitreMatrixModal` — full-width modal, one column per tactic (220px fixed), horizontal scroll. Each technique top-level is clickable (toggle); a chevron expands/collapses sub-techniques rendered in cascade. Search filter (case-insensitive on id + name) auto-expands the parent of a matched sub-technique. Tactic header shows a "N selected" counter (parents + subs). Footer: Cancel + "Apply N technique(s)" (or "Clear all" when N=0 and there's an existing selection). Focus trap V1: search input auto-focus on open, Tab cycles within the modal, Escape and backdrop click both = Cancel. +- `MitreTechniquePicker` (sprint 2) clean-rewritten to a one-shot `onSelect({id, name})` signature; no incoming value props. The picker resets after each selection — the parent (`MitreTechniquesField`) handles append + dedup. +- `SimulationList` MITRE column displays `T1059 +2` when 3 techniques are selected (first id + remainder counter) or `—` when empty. +- `SimulationFormPage` — `MitreTechniquesField` replaces the old standalone `MitreTechniquePicker`. The technique state moves out of the RT form (independent auto-save cycle); the Save Red Team button still batches the other RT fields. + +**Acceptance tests** (Playwright) +- 4 new spec files: `us13-multi-techniques.spec.ts`, `us14-techniques-tags.spec.ts`, `us15-mitre-matrix-modal.spec.ts`, `us16-regression-sprint2.spec.ts` — all ACs (AC-13.1 → AC-16.3) pass. +- Sprint 2 specs `us8-simulation-redteam-fill.spec.ts` and `us10-mitre-autocomplete.spec.ts` adapted to the new `techniques: []` array (no more scalar field assertions). + +### Changed +- 2026-05-27 — SPEC.md § Simulation: "Type d'attaque MITRE correspondant" (singular) → "Types d'attaque MITRE correspondants (multi-techniques) — sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale. Sub-techniques supportées." +- 2026-05-27 — Breaking API change: `mitre_technique_id` and `mitre_technique_name` removed from the `Simulation` payload (both directions). Replaced by `techniques: [{id, name, tactics}]` in responses and `technique_ids: string[]` in PATCH requests. No backwards-compatibility shim (no external consumer at this stage). + +--- + +## [Sprint 2] — Simulations + MITRE ATT&CK (merged 2026-05-27) + ### Added — Sprint 2 (Simulations + MITRE ATT&CK) **Backend** (Flask + SQLAlchemy, 131 pytest passing) diff --git a/README.md b/README.md index e9dd2cc..7a35150 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs. -> Status: **Sprint 2 — Simulations + MITRE ATT&CK**. The Purple Team workflow (RedTeam fills test → marks for review → SOC documents detection → closes) is now end-to-end testable in the UI, with MITRE technique autocomplete for TTP tagging. +> Status: **Sprint 3 — Multi-technique simulations + MITRE matrix modal**. A simulation can now be tagged with multiple MITRE techniques (top-level and sub-techniques) via either autocomplete or a clickable ATT&CK matrix modal. Tags auto-save on add/remove; the rest of the Sprint 2 Purple Team workflow (workflow states, RBAC, etc.) is unchanged. --- @@ -138,9 +138,9 @@ npm run dev # http://localhost:5173 with /api proxied to :5000 Tests: ```bash -cd backend && pytest -q # 131 tests -cd frontend && npm run test -- --run # 63 tests -cd e2e && npx playwright test # 68 tests (needs container up) +cd backend && pytest -q # 164 tests +cd frontend && npm run test -- --run # 86 tests +cd e2e && npx playwright test # 105 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6) ``` --- diff --git a/backend/app/api/simulations.py b/backend/app/api/simulations.py index c6fd5cb..d5a2f2a 100644 --- a/backend/app/api/simulations.py +++ b/backend/app/api/simulations.py @@ -121,7 +121,7 @@ def transition_simulation(sid: int): # --------------------------------------------------------------------------- -# MITRE autocomplete +# MITRE autocomplete + matrix # --------------------------------------------------------------------------- @@ -136,3 +136,14 @@ def mitre_techniques(): q = request.args.get("q", "").strip() results = mitre_svc.search(q) return jsonify(results), 200 + + +@simulations_bp.get("/api/mitre/matrix") +@login_required +def mitre_matrix(): + from backend.app.services import mitre as mitre_svc + + if not mitre_svc.mitre_loaded: + return jsonify({"error": "mitre bundle not loaded"}), 503 + + return jsonify(mitre_svc.get_matrix()), 200 diff --git a/backend/app/models/simulation.py b/backend/app/models/simulation.py index 17b0663..74d99df 100644 --- a/backend/app/models/simulation.py +++ b/backend/app/models/simulation.py @@ -25,8 +25,7 @@ class Simulation(db.Model): # type: ignore[name-defined] index=True, ) name = db.Column(db.String(255), nullable=False) - mitre_technique_id = db.Column(db.String(32), nullable=True) - mitre_technique_name = db.Column(db.String(255), nullable=True) + techniques = db.Column(db.JSON, nullable=False, default=list) description = db.Column(db.Text, nullable=True) commands = db.Column(db.Text, nullable=True) prerequisites = db.Column(db.Text, nullable=True) diff --git a/backend/app/serializers.py b/backend/app/serializers.py index 7985614..d54e9cc 100644 --- a/backend/app/serializers.py +++ b/backend/app/serializers.py @@ -20,13 +20,22 @@ def serialize_user_brief(user: User) -> dict[str, Any]: return {"id": user.id, "username": user.username} +def _enrich_techniques(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Attach tactics to each {id, name} snapshot from the MITRE service.""" + from backend.app.services import mitre as mitre_svc + + return [ + {"id": t["id"], "name": t["name"], "tactics": mitre_svc.get_tactics(t["id"])} + for t in (raw or []) + ] + + def serialize_simulation(simulation: Simulation) -> dict[str, Any]: return { "id": simulation.id, "engagement_id": simulation.engagement_id, "name": simulation.name, - "mitre_technique_id": simulation.mitre_technique_id, - "mitre_technique_name": simulation.mitre_technique_name, + "techniques": _enrich_techniques(simulation.techniques or []), "description": simulation.description, "commands": simulation.commands, "prerequisites": simulation.prerequisites, diff --git a/backend/app/services/mitre.py b/backend/app/services/mitre.py index 1c52498..6dd91e2 100644 --- a/backend/app/services/mitre.py +++ b/backend/app/services/mitre.py @@ -8,11 +8,45 @@ from typing import Any logger = logging.getLogger(__name__) -# Absolute path to the committed bundle. _BUNDLE_PATH = Path(__file__).parent.parent.parent / "data" / "mitre" / "enterprise-attack.json" +# Canonical Enterprise tactic order (12 tactics). +_TACTIC_ORDER = [ + "initial-access", + "execution", + "persistence", + "privilege-escalation", + "defense-evasion", + "credential-access", + "discovery", + "lateral-movement", + "collection", + "command-and-control", + "exfiltration", + "impact", +] + +TACTIC_NAMES: dict[str, str] = { + "initial-access": "Initial Access", + "execution": "Execution", + "persistence": "Persistence", + "privilege-escalation": "Privilege Escalation", + "defense-evasion": "Defense Evasion", + "credential-access": "Credential Access", + "discovery": "Discovery", + "lateral-movement": "Lateral Movement", + "collection": "Collection", + "command-and-control": "Command and Control", + "exfiltration": "Exfiltration", + "impact": "Impact", +} + mitre_loaded: bool = False _index: list[dict[str, Any]] = [] +_tactics_by_technique: dict[str, list[str]] = {} +_name_by_id: dict[str, str] = {} +# matrix: list of tactic dicts (built once at load time) +_matrix: list[dict[str, Any]] = [] def _extract_tactics(obj: dict[str, Any]) -> list[str]: @@ -20,7 +54,7 @@ def _extract_tactics(obj: dict[str, Any]) -> list[str]: return [ p["phase_name"] for p in phases - if isinstance(p, dict) and "phase_name" in p + if isinstance(p, dict) and "phase_name" in p and p.get("kill_chain_name") == "mitre-attack" ] @@ -31,9 +65,65 @@ def _get_external_id(obj: dict[str, Any]) -> str | None: return None +def _is_subtechnique(tech_id: str) -> bool: + return "." in tech_id + + +def _parent_id(sub_id: str) -> str: + return sub_id.split(".")[0] + + +def _build_matrix(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Build the tactic → techniques → subtechniques tree.""" + # Group top-level techniques by tactic. + tactic_techs: dict[str, list[dict[str, Any]]] = {t: [] for t in _TACTIC_ORDER} + + for entry in entries: + if _is_subtechnique(entry["id"]): + continue + for tactic in entry["tactics"]: + if tactic in tactic_techs: + tactic_techs[tactic].append(entry) + + # Attach sub-techniques to their parents. + parent_subs: dict[str, list[dict[str, Any]]] = {} + for entry in entries: + if not _is_subtechnique(entry["id"]): + continue + pid = _parent_id(entry["id"]) + parent_subs.setdefault(pid, []).append({"id": entry["id"], "name": entry["name"]}) + + # Sort subs alphabetically by name. + for subs in parent_subs.values(): + subs.sort(key=lambda x: x["name"]) + + matrix: list[dict[str, Any]] = [] + for tactic_id in _TACTIC_ORDER: + techs = tactic_techs.get(tactic_id, []) + # Sort techniques alphabetically. + techs_sorted = sorted(techs, key=lambda x: x["name"]) + tactic_name = TACTIC_NAMES.get(tactic_id, tactic_id.replace("-", " ").title()) + matrix.append( + { + "tactic_id": tactic_id, + "tactic_name": tactic_name, + "techniques": [ + { + "id": t["id"], + "name": t["name"], + "subtechniques": parent_subs.get(t["id"], []), + } + for t in techs_sorted + ], + } + ) + + return matrix + + def load_bundle(path: Path | None = None) -> None: """Load the MITRE bundle into memory. Called once at app boot.""" - global mitre_loaded, _index + global mitre_loaded, _index, _tactics_by_technique, _name_by_id, _matrix bundle_path = path or _BUNDLE_PATH try: @@ -49,6 +139,9 @@ def load_bundle(path: Path | None = None) -> None: return entries: list[dict[str, Any]] = [] + tactics_map: dict[str, list[str]] = {} + name_map: dict[str, str] = {} + for obj in data.get("objects") or []: if not isinstance(obj, dict): continue @@ -59,19 +152,35 @@ def load_bundle(path: Path | None = None) -> None: ext_id = _get_external_id(obj) if not ext_id: continue - entries.append( - { - "id": ext_id, - "name": obj.get("name", ""), - "tactics": _extract_tactics(obj), - } - ) + tactics = _extract_tactics(obj) + name = obj.get("name", "") + entries.append({"id": ext_id, "name": name, "tactics": tactics}) + tactics_map[ext_id] = tactics + name_map[ext_id] = name _index = entries + _tactics_by_technique = tactics_map + _name_by_id = name_map + _matrix = _build_matrix(entries) mitre_loaded = True logger.info("MITRE bundle loaded: %d techniques", len(_index)) +def get_tactics(technique_id: str) -> list[str]: + """Return tactic list for a technique id; empty list if unknown.""" + return _tactics_by_technique.get(technique_id, []) + + +def lookup_name(technique_id: str) -> str | None: + """Return the name for a technique id, or None if not in the bundle.""" + return _name_by_id.get(technique_id) + + +def get_matrix() -> list[dict[str, Any]]: + """Return the full tactic → techniques → subtechniques tree.""" + return _matrix + + def search(query: str, limit: int = 20) -> list[dict[str, Any]]: """Return up to `limit` techniques matching `query`. diff --git a/backend/app/services/simulation_workflow.py b/backend/app/services/simulation_workflow.py index 535ccfd..2df406d 100644 --- a/backend/app/services/simulation_workflow.py +++ b/backend/app/services/simulation_workflow.py @@ -10,11 +10,10 @@ from backend.app.extensions import db from backend.app.models import User from backend.app.models.simulation import Simulation, SimulationStatus +# Fields only admin/redteam may write (excluding technique_ids which is handled separately). REDTEAM_FIELDS = frozenset( { "name", - "mitre_technique_id", - "mitre_technique_name", "description", "commands", "prerequisites", @@ -25,8 +24,6 @@ REDTEAM_FIELDS = frozenset( SOC_FIELDS = frozenset({"log_source", "logs", "soc_comment", "incident_number"}) -# Transitions allowed via POST /transition endpoint (manual only). -# auto pending→in_progress is handled in apply_patch, not here. _ALLOWED_TRANSITIONS: dict[str, dict[str, set[str]]] = { "review_required": { "from": {"pending", "in_progress"}, @@ -48,6 +45,30 @@ def _is_non_empty(value: Any) -> bool: return not (isinstance(value, list) and len(value) == 0) +def _resolve_technique_ids( + technique_ids: list[str], +) -> tuple[list[dict[str, str]] | None, tuple[Any, int] | None]: + """Validate and resolve technique IDs to [{id, name}] snapshots. + + Returns (resolved_list, None) on success or (None, error_tuple) on failure. + Deduplicates while preserving order. + """ + from backend.app.services import mitre as mitre_svc + + if not mitre_svc.mitre_loaded: + return None, (jsonify({"error": "mitre bundle not loaded"}), 503) + + # Dedup, preserve order. + seen: dict[str, None] = dict.fromkeys(technique_ids) + resolved: list[dict[str, str]] = [] + for tid in seen: + name = mitre_svc.lookup_name(tid) + if name is None: + return None, (jsonify({"error": f"unknown technique id: {tid}"}), 400) + resolved.append({"id": tid, "name": name}) + return resolved, None + + def apply_patch( simulation: Simulation, payload: dict[str, Any], user: User ) -> tuple[Any, int] | None: @@ -59,15 +80,14 @@ def apply_patch( role = user.role.value if role == "soc": - # SOC can only patch when status allows it. if simulation.status not in ( SimulationStatus.REVIEW_REQUIRED, SimulationStatus.DONE, ): return jsonify({"error": "simulation not ready for SOC review"}), 403 - # SOC must not send redteam fields. - redteam_keys_in_payload = REDTEAM_FIELDS & payload.keys() + # SOC must not send redteam fields or technique_ids. + redteam_keys_in_payload = (REDTEAM_FIELDS | {"technique_ids"}) & payload.keys() if redteam_keys_in_payload: return jsonify({"error": "soc cannot edit redteam fields"}), 403 @@ -76,10 +96,10 @@ def apply_patch( setattr(simulation, field, payload[field]) else: - # admin / redteam: apply all fields present. + # admin / redteam path. redteam_keys_present = REDTEAM_FIELDS & payload.keys() - # Validate executed_at before any writes so a bad value causes no partial mutation. + # Validate executed_at upfront before any writes. executed_at_value: datetime | None = None if "executed_at" in redteam_keys_present: val = payload["executed_at"] @@ -91,21 +111,39 @@ def apply_patch( except ValueError: return jsonify({"error": "invalid executed_at"}), 400 + # Validate and resolve technique_ids upfront. + resolved_techniques: list[dict[str, str]] | None = None + if "technique_ids" in payload: + raw_ids = payload["technique_ids"] + if not isinstance(raw_ids, list): + return jsonify({"error": "technique_ids must be a list"}), 400 + resolved_techniques, err = _resolve_technique_ids(raw_ids) + if err is not None: + return err + + # Apply scalar redteam fields. for field in redteam_keys_present: if field == "executed_at": simulation.executed_at = executed_at_value else: setattr(simulation, field, payload[field]) + # Apply resolved techniques. + if resolved_techniques is not None: + simulation.techniques = resolved_techniques + + # Apply SOC fields (admin/redteam may also write them). for field in SOC_FIELDS: if field in payload: setattr(simulation, field, payload[field]) - # Auto-transition pending → in_progress: at least one redteam field with - # a non-empty value in the *incoming payload*. - if simulation.status == SimulationStatus.PENDING and any( - _is_non_empty(payload[k]) for k in redteam_keys_present - ): + # Auto-transition pending → in_progress. + # Triggers when any redteam scalar has a non-empty value, OR technique_ids is non-empty. + auto_trigger = any(_is_non_empty(payload[k]) for k in redteam_keys_present) + if not auto_trigger and "technique_ids" in payload: + auto_trigger = len(payload["technique_ids"]) > 0 + + if simulation.status == SimulationStatus.PENDING and auto_trigger: simulation.status = SimulationStatus.IN_PROGRESS simulation.updated_at = datetime.now(UTC) diff --git a/backend/migrations/versions/0003_simulation_techniques_array.py b/backend/migrations/versions/0003_simulation_techniques_array.py new file mode 100644 index 0000000..1447f84 --- /dev/null +++ b/backend/migrations/versions/0003_simulation_techniques_array.py @@ -0,0 +1,73 @@ +"""replace scalar MITRE columns with techniques JSON array + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-05-27 00:00:00.000000 +""" +import json + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text + + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + + # 1. Add techniques column (nullable while we backfill). + op.add_column("simulations", sa.Column("techniques", sa.Text(), nullable=True)) + + # 2. Backfill: scalar → JSON array. + rows = bind.execute( + text("SELECT id, mitre_technique_id, mitre_technique_name FROM simulations") + ).fetchall() + for row in rows: + if row[1]: # mitre_technique_id is not null + val = json.dumps([{"id": row[1], "name": row[2] or ""}]) + else: + val = "[]" + bind.execute( + text("UPDATE simulations SET techniques = :v WHERE id = :id"), + {"v": val, "id": row[0]}, + ) + + # 3. Make NOT NULL now that every row has a value. + with op.batch_alter_table("simulations") as batch_op: + batch_op.alter_column("techniques", existing_type=sa.Text(), nullable=False) + + # 4. Drop old scalar columns. + with op.batch_alter_table("simulations") as batch_op: + batch_op.drop_column("mitre_technique_id") + batch_op.drop_column("mitre_technique_name") + + +def downgrade(): + bind = op.get_bind() + + # 1. Re-add scalar columns. + with op.batch_alter_table("simulations") as batch_op: + batch_op.add_column(sa.Column("mitre_technique_id", sa.String(length=32), nullable=True)) + batch_op.add_column(sa.Column("mitre_technique_name", sa.String(length=255), nullable=True)) + + # 2. Back-fill: take first element of techniques array. + rows = bind.execute(text("SELECT id, techniques FROM simulations")).fetchall() + for row in rows: + techniques = json.loads(row[1] or "[]") + if techniques: + first = techniques[0] + bind.execute( + text( + "UPDATE simulations SET mitre_technique_id = :tid, mitre_technique_name = :tname WHERE id = :id" + ), + {"tid": first.get("id"), "tname": first.get("name"), "id": row[0]}, + ) + + # 3. Drop techniques column. + with op.batch_alter_table("simulations") as batch_op: + batch_op.drop_column("techniques") diff --git a/backend/tests/test_mitre.py b/backend/tests/test_mitre.py index a31c3cd..39ba563 100644 --- a/backend/tests/test_mitre.py +++ b/backend/tests/test_mitre.py @@ -33,6 +33,14 @@ _FIXTURE_BUNDLE = { ], "kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}], }, + { + "type": "attack-pattern", + "name": "Python", + "external_references": [ + {"source_name": "mitre-attack", "external_id": "T1059.006"} + ], + "kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}], + }, { "type": "attack-pattern", "name": "Phishing", @@ -62,6 +70,14 @@ _FIXTURE_BUNDLE = { ], "kill_chain_phases": [], }, + { + "type": "attack-pattern", + "name": "Application Layer Protocol", + "external_references": [ + {"source_name": "mitre-attack", "external_id": "T1071"} + ], + "kill_chain_phases": [{"phase_name": "command-and-control", "kill_chain_name": "mitre-attack"}], + }, { # Not an attack-pattern — must be ignored. "type": "relationship", @@ -76,9 +92,15 @@ def _reset_mitre(): """Reset the MITRE service state between tests.""" original_loaded = mitre_svc.mitre_loaded original_index = list(mitre_svc._index) + original_tactics = dict(mitre_svc._tactics_by_technique) + original_names = dict(mitre_svc._name_by_id) + original_matrix = list(mitre_svc._matrix) yield mitre_svc.mitre_loaded = original_loaded mitre_svc._index = original_index + mitre_svc._tactics_by_technique = original_tactics + mitre_svc._name_by_id = original_names + mitre_svc._matrix = original_matrix @pytest.fixture() @@ -96,7 +118,7 @@ def bundle_file(tmp_path: pathlib.Path) -> pathlib.Path: def test_load_bundle_success(bundle_file: pathlib.Path) -> None: mitre_svc.load_bundle(bundle_file) assert mitre_svc.mitre_loaded is True - assert len(mitre_svc._index) == 4 # 5 objects minus 1 revoked = 4 + assert len(mitre_svc._index) == 6 # 7 attack-patterns minus 1 revoked = 6 def test_load_bundle_missing_file() -> None: @@ -245,3 +267,128 @@ def test_mitre_endpoint_includes_tactics( phishing = next((r for r in data if r["id"] == "T1566"), None) assert phishing is not None assert "initial-access" in phishing["tactics"] + + +# --------------------------------------------------------------------------- +# Sprint 3: get_tactics, lookup_name, get_matrix +# --------------------------------------------------------------------------- + + +def test_get_tactics_known(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + tactics = mitre_svc.get_tactics("T1078") + assert "initial-access" in tactics + assert "persistence" in tactics + + +def test_get_tactics_unknown_returns_empty(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + assert mitre_svc.get_tactics("T0000") == [] + + +def test_lookup_name_known(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + assert mitre_svc.lookup_name("T1059") == "Command and Scripting Interpreter" + + +def test_lookup_name_subtechnique(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + assert mitre_svc.lookup_name("T1059.001") == "PowerShell" + + +def test_lookup_name_unknown_returns_none(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + assert mitre_svc.lookup_name("T0000") is None + + +def test_get_matrix_returns_ordered_tactics(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + matrix = mitre_svc.get_matrix() + tactic_ids = [t["tactic_id"] for t in matrix] + # initial-access must come before execution in canonical order. + assert tactic_ids.index("initial-access") < tactic_ids.index("execution") + + +def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + matrix = mitre_svc.get_matrix() + exec_tactic = next(t for t in matrix if t["tactic_id"] == "execution") + t1059 = next((t for t in exec_tactic["techniques"] if t["id"] == "T1059"), None) + assert t1059 is not None + sub_ids = [s["id"] for s in t1059["subtechniques"]] + assert "T1059.001" in sub_ids + assert "T1059.006" in sub_ids + + +def test_get_matrix_subtechniques_sorted_by_name(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + matrix = mitre_svc.get_matrix() + exec_tactic = next(t for t in matrix if t["tactic_id"] == "execution") + t1059 = next(t for t in exec_tactic["techniques"] if t["id"] == "T1059") + names = [s["name"] for s in t1059["subtechniques"]] + assert names == sorted(names) + + +def test_get_matrix_techniques_sorted_by_name(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + matrix = mitre_svc.get_matrix() + ia_tactic = next(t for t in matrix if t["tactic_id"] == "initial-access") + names = [t["name"] for t in ia_tactic["techniques"]] + assert names == sorted(names) + + +def test_get_matrix_technique_no_subtechniques(bundle_file: pathlib.Path) -> None: + mitre_svc.load_bundle(bundle_file) + matrix = mitre_svc.get_matrix() + ia_tactic = next(t for t in matrix if t["tactic_id"] == "initial-access") + phishing = next((t for t in ia_tactic["techniques"] if t["id"] == "T1566"), None) + assert phishing is not None + assert phishing["subtechniques"] == [] + + +def test_matrix_endpoint_ok( + client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path +) -> None: + mitre_svc.load_bundle(bundle_file) + resp = client.get("/api/mitre/matrix", headers=_h(redteam_token)) + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, list) + tactic_ids = [t["tactic_id"] for t in data] + assert "initial-access" in tactic_ids + assert "execution" in tactic_ids + + +def test_matrix_endpoint_503_when_not_loaded( + client: FlaskClient, redteam_token: str +) -> None: + mitre_svc.mitre_loaded = False + resp = client.get("/api/mitre/matrix", headers=_h(redteam_token)) + assert resp.status_code == 503 + + +def test_matrix_endpoint_requires_auth(client: FlaskClient) -> None: + resp = client.get("/api/mitre/matrix") + assert resp.status_code == 401 + + +def test_matrix_endpoint_all_roles( + client: FlaskClient, + redteam_token: str, + soc_token: str, + admin_token: str, + bundle_file: pathlib.Path, +) -> None: + mitre_svc.load_bundle(bundle_file) + for token in (redteam_token, soc_token, admin_token): + resp = client.get("/api/mitre/matrix", headers=_h(token)) + assert resp.status_code == 200 + + +def test_get_matrix_command_and_control_display_name(bundle_file: pathlib.Path) -> None: + """MITRE official name uses lowercase 'and' — not title-cased.""" + mitre_svc.load_bundle(bundle_file) + matrix = mitre_svc.get_matrix() + c2 = next((t for t in matrix if t["tactic_id"] == "command-and-control"), None) + assert c2 is not None + assert c2["tactic_name"] == "Command and Control" diff --git a/backend/tests/test_simulations_techniques.py b/backend/tests/test_simulations_techniques.py new file mode 100644 index 0000000..f010aa0 --- /dev/null +++ b/backend/tests/test_simulations_techniques.py @@ -0,0 +1,429 @@ +"""Sprint 3 — multi-technique simulation tests (AC-13).""" +from __future__ import annotations + +import json +import pathlib + +import pytest +from flask.testing import FlaskClient + +from backend.app.services import mitre as mitre_svc +from backend.tests.conftest import auth_headers as _h + +# --------------------------------------------------------------------------- +# Minimal STIX fixture (reused from test_mitre.py pattern) +# --------------------------------------------------------------------------- + +_FIXTURE_BUNDLE = { + "type": "bundle", + "objects": [ + { + "type": "attack-pattern", + "name": "Command and Scripting Interpreter", + "external_references": [{"source_name": "mitre-attack", "external_id": "T1059"}], + "kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}], + }, + { + "type": "attack-pattern", + "name": "PowerShell", + "external_references": [{"source_name": "mitre-attack", "external_id": "T1059.001"}], + "kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}], + }, + { + "type": "attack-pattern", + "name": "Valid Accounts", + "external_references": [{"source_name": "mitre-attack", "external_id": "T1078"}], + "kill_chain_phases": [ + {"phase_name": "initial-access", "kill_chain_name": "mitre-attack"}, + {"phase_name": "persistence", "kill_chain_name": "mitre-attack"}, + ], + }, + ], +} + + +@pytest.fixture(autouse=True) +def _reset_mitre(): + original_loaded = mitre_svc.mitre_loaded + original_index = list(mitre_svc._index) + original_tactics = dict(mitre_svc._tactics_by_technique) + original_names = dict(mitre_svc._name_by_id) + original_matrix = list(mitre_svc._matrix) + yield + mitre_svc.mitre_loaded = original_loaded + mitre_svc._index = original_index + mitre_svc._tactics_by_technique = original_tactics + mitre_svc._name_by_id = original_names + mitre_svc._matrix = original_matrix + + +@pytest.fixture() +def bundle_file(tmp_path: pathlib.Path) -> pathlib.Path: + p = tmp_path / "enterprise-attack.json" + p.write_text(json.dumps(_FIXTURE_BUNDLE), encoding="utf-8") + return p + + +@pytest.fixture() +def loaded_bundle(bundle_file: pathlib.Path) -> pathlib.Path: + mitre_svc.load_bundle(bundle_file) + return bundle_file + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_engagement(client: FlaskClient, token: str) -> dict: + resp = client.post( + "/api/engagements", + headers=_h(token), + json={"name": "Op Sprint3", "start_date": "2026-06-01"}, + ) + assert resp.status_code == 201 + return resp.get_json() + + +def _make_sim(client: FlaskClient, token: str, eid: int) -> dict: + resp = client.post( + f"/api/engagements/{eid}/simulations", + headers=_h(token), + json={"name": "Technique Test"}, + ) + assert resp.status_code == 201 + return resp.get_json() + + +def _patch(client: FlaskClient, token: str, sid: int, payload: dict): + return client.patch(f"/api/simulations/{sid}", headers=_h(token), json=payload) + + +# --------------------------------------------------------------------------- +# AC-13.1 — new simulation has techniques = [] +# --------------------------------------------------------------------------- + + +def test_new_simulation_has_empty_techniques( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + assert sim["techniques"] == [] + + +# --------------------------------------------------------------------------- +# AC-13.3 — serializer enriches techniques with tactics +# --------------------------------------------------------------------------- + + +def test_techniques_enriched_with_tactics( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1078"]}) + + resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token)) + assert resp.status_code == 200 + techs = resp.get_json()["techniques"] + assert len(techs) == 1 + assert techs[0]["id"] == "T1078" + assert "initial-access" in techs[0]["tactics"] + assert "persistence" in techs[0]["tactics"] + + +def test_techniques_with_unknown_id_returns_empty_tactics( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + """If a technique was removed from the bundle after save, tactics gracefully = [].""" + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + # Bypass service, write directly an id not in the bundle. + from backend.app.extensions import db + from backend.app.models.simulation import Simulation + + with client.application.app_context(): + s = db.session.get(Simulation, sim["id"]) + s.techniques = [{"id": "T0000", "name": "Removed Technique"}] + db.session.commit() + + resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token)) + techs = resp.get_json()["techniques"] + assert techs[0]["tactics"] == [] + + +# --------------------------------------------------------------------------- +# AC-13.4 — PATCH technique_ids +# --------------------------------------------------------------------------- + + +def test_patch_technique_ids_sets_techniques( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T1078"]}) + assert resp.status_code == 200 + techs = resp.get_json()["techniques"] + assert len(techs) == 2 + ids = [t["id"] for t in techs] + assert "T1059" in ids + assert "T1078" in ids + + +def test_patch_technique_ids_resolves_name( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]}) + assert resp.status_code == 200 + tech = resp.get_json()["techniques"][0] + assert tech["name"] == "Command and Scripting Interpreter" + + +def test_patch_technique_ids_unknown_returns_400( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T9999"]}) + assert resp.status_code == 400 + assert "unknown technique id: T9999" in resp.get_json()["error"] + + +def test_patch_technique_ids_partial_unknown_rejected( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + # One valid, one unknown — whole request rejected. + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T9999"]}) + assert resp.status_code == 400 + + +def test_patch_technique_ids_includes_subtechnique( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059.001"]}) + assert resp.status_code == 200 + techs = resp.get_json()["techniques"] + assert techs[0]["id"] == "T1059.001" + assert techs[0]["name"] == "PowerShell" + + +def test_patch_technique_ids_replaces_list( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]}) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1078"]}) + assert resp.status_code == 200 + ids = [t["id"] for t in resp.get_json()["techniques"]] + assert ids == ["T1078"] + + +def test_patch_technique_ids_empty_clears_list( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]}) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": []}) + assert resp.status_code == 200 + assert resp.get_json()["techniques"] == [] + + +def test_patch_technique_ids_not_list_returns_400( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": "T1059"}) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Dedup (spec-reviewer note: AC-13.4) +# --------------------------------------------------------------------------- + + +def test_patch_technique_ids_deduplicates( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch( + client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T1078", "T1059"]} + ) + assert resp.status_code == 200 + techs = resp.get_json()["techniques"] + assert len(techs) == 2 + # Order preserved: T1059 first. + assert techs[0]["id"] == "T1059" + assert techs[1]["id"] == "T1078" + + +# --------------------------------------------------------------------------- +# AC-13.5 — auto-transition on technique_ids +# --------------------------------------------------------------------------- + + +def test_technique_ids_non_empty_triggers_auto_transition( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + assert sim["status"] == "pending" + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]}) + assert resp.status_code == 200 + assert resp.get_json()["status"] == "in_progress" + + +def test_technique_ids_empty_does_not_trigger_auto_transition( + client: FlaskClient, redteam_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": []}) + assert resp.status_code == 200 + assert resp.get_json()["status"] == "pending" + + +# --------------------------------------------------------------------------- +# Bundle not loaded — 503 on technique_ids PATCH +# --------------------------------------------------------------------------- + + +def test_patch_technique_ids_bundle_not_loaded_returns_503( + client: FlaskClient, redteam_token: str +) -> None: + """When MITRE bundle is absent, PATCH with technique_ids must return 503.""" + mitre_svc.mitre_loaded = False + mitre_svc._index = [] + mitre_svc._name_by_id = {} + + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]}) + assert resp.status_code == 503 + assert resp.get_json()["error"] == "mitre bundle not loaded" + + +# --------------------------------------------------------------------------- +# SOC cannot patch technique_ids (it's a redteam field) +# --------------------------------------------------------------------------- + + +def test_soc_cannot_patch_technique_ids( + client: FlaskClient, redteam_token: str, soc_token: str, loaded_bundle +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + # Advance to review_required so SOC can touch the simulation at all. + client.post( + f"/api/simulations/{sim['id']}/transition", + headers=_h(redteam_token), + json={"to": "review_required"}, + ) + + resp = _patch(client, soc_token, sim["id"], {"technique_ids": ["T1059"]}) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Migration backfill test (inline, no Alembic runner needed) +# --------------------------------------------------------------------------- + + +def test_migration_backfill_logic() -> None: + """Verify the backfill logic used in upgrade(): scalar → [{id, name}].""" + import json as _json + + def _backfill(tech_id, tech_name): + if tech_id: + return _json.loads(_json.dumps([{"id": tech_id, "name": tech_name or ""}])) + return [] + + assert _backfill("T1059", "Command and Scripting Interpreter") == [ + {"id": "T1059", "name": "Command and Scripting Interpreter"} + ] + assert _backfill(None, None) == [] + assert _backfill("T1059", None) == [{"id": "T1059", "name": ""}] + + +def test_migration_0003_techniques_not_null_after_upgrade() -> None: + """Run migration 0003 upgrade() against a real SQLite DB and assert techniques is NOT NULL.""" + import importlib + import json as _json + + import sqlalchemy as _sa + from alembic.operations import Operations + from alembic.runtime.migration import MigrationContext + + engine = _sa.create_engine("sqlite:///:memory:") + with engine.begin() as conn: + # Create the pre-migration schema (0002 state). + conn.execute(_sa.text( + "CREATE TABLE simulations (" + " id INTEGER PRIMARY KEY," + " mitre_technique_id VARCHAR(32)," + " mitre_technique_name VARCHAR(255)" + ")" + )) + conn.execute(_sa.text( + "INSERT INTO simulations (id, mitre_technique_id, mitre_technique_name)" + " VALUES (1, 'T1059', 'Command and Scripting Interpreter')" + )) + conn.execute(_sa.text( + "INSERT INTO simulations (id, mitre_technique_id, mitre_technique_name)" + " VALUES (2, NULL, NULL)" + )) + + # Run upgrade() via Alembic Operations context. + with engine.begin() as conn: + ctx = MigrationContext.configure(conn, opts={"as_sql": False}) + ops = Operations(ctx) + + # Patch the module-level proxy so the migration's op.* calls work. + import alembic.op as _op_module + _op_module._proxy = ops # type: ignore[attr-defined] + + spec = importlib.util.spec_from_file_location( + "mig_0003", + "/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-3-mitre-matrix/backend/migrations/versions/0003_simulation_techniques_array.py", + ) + assert spec is not None and spec.loader is not None + mig = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mig) # type: ignore[union-attr] + mig.upgrade() + + # Verify schema: techniques column exists and is NOT NULL. + insp = _sa.inspect(engine) + cols = {c["name"]: c for c in insp.get_columns("simulations")} + assert "techniques" in cols, "techniques column must exist after upgrade" + assert cols["techniques"]["nullable"] is False, "techniques must be NOT NULL after upgrade" + assert "mitre_technique_id" not in cols + assert "mitre_technique_name" not in cols + + # Verify data was backfilled correctly. + with engine.connect() as conn: + rows = conn.execute(_sa.text("SELECT id, techniques FROM simulations ORDER BY id")).fetchall() + assert _json.loads(rows[0][1]) == [{"id": "T1059", "name": "Command and Scripting Interpreter"}] + assert _json.loads(rows[1][1]) == [] diff --git a/e2e/tests/us10-mitre-autocomplete.spec.ts b/e2e/tests/us10-mitre-autocomplete.spec.ts index dbb29a8..ac51db1 100644 --- a/e2e/tests/us10-mitre-autocomplete.spec.ts +++ b/e2e/tests/us10-mitre-autocomplete.spec.ts @@ -128,7 +128,7 @@ test.describe('US-10 — MITRE autocomplete', () => { expect(subtech.name).toBeTruthy(); }); - test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection fills both fields', async ({ + test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection appends tag', async ({ page, context, }) => { @@ -137,12 +137,14 @@ test.describe('US-10 — MITRE autocomplete', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + // Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search" + await page.getByRole('button', { name: /quick search/i }).click(); + const picker = page.getByRole('combobox', { name: /mitre technique/i }); await expect(picker).toBeVisible(); // Type a query — after debounce (200ms) the dropdown opens with results await picker.fill('T1059'); - // Wait for dropdown to appear (debounce + network) const listbox = page.getByRole('listbox', { name: /mitre techniques/i }); await expect(listbox).toBeVisible({ timeout: 5_000 }); @@ -157,13 +159,14 @@ test.describe('US-10 — MITRE autocomplete', () => { await picker.press('ArrowDown'); await picker.press('Enter'); - // After selection the dropdown closes and input shows the selected value + // Sprint 3: after selection the picker resets (one-shot append mode). + // The tag T1059 should appear in the techniques field. await expect(listbox).not.toBeVisible(); - const inputValue = await picker.inputValue(); - expect(inputValue).toMatch(/T1059/); - expect(inputValue).toMatch(/—/); + await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059'); - // Escape closes the dropdown + // Escape closes the dropdown (re-open picker to test Escape) + await page.getByRole('button', { name: /quick search/i }).click(); await picker.fill('T1'); await expect(listbox).toBeVisible({ timeout: 5_000 }); await picker.press('Escape'); diff --git a/e2e/tests/us13-multi-techniques.spec.ts b/e2e/tests/us13-multi-techniques.spec.ts new file mode 100644 index 0000000..982c44a --- /dev/null +++ b/e2e/tests/us13-multi-techniques.spec.ts @@ -0,0 +1,195 @@ +/** + * US-13 — redteam selects multiple MITRE techniques per simulation. + * Covers AC-13.1 → AC-13.5 (API / data contract focus). + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; + +const REDTEAM_USER = 'us13-redteam'; +const SOC_USER = 'us13-soc'; +const PASS = 'us13-pass-strong'; + +interface Simulation { + id: number; + status: string; + techniques: { id: string; name: string; tactics: string[] }[]; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-13 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`); + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +test.describe('US-13 — multi-technique simulations', () => { + let redteamToken: string; + let socToken: string; + let engagementId: number; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + await ensureUser(SOC_USER, PASS, 'soc'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + socToken = (await login(SOC_USER, PASS)).token; + const eng = await createEngagement(redteamToken, { + name: 'US-13 Engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + for (const u of [REDTEAM_USER, SOC_USER]) { + await deleteUserByUsername(tok, u); + } + } catch { + /* noop */ + } + }); + + test('AC-13.1 — simulation serialisation has techniques array, not scalar MITRE fields', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-13.1 sim'); + + expect(Array.isArray(sim.techniques)).toBe(true); + expect(sim.techniques).toHaveLength(0); + expect(sim).not.toHaveProperty('mitre_technique_id'); + expect(sim).not.toHaveProperty('mitre_technique_name'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-13.2 — migration: new simulations start with techniques = []', async () => { + // Migration is tested implicitly: every new simulation created via POST must + // return techniques: [] (no scalar columns present). + const sim = await createSimulation(redteamToken, engagementId, 'AC-13.2 migration sim'); + expect(sim.techniques).toEqual([]); + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-13.3 — serialisation enriches each technique entry with tactics from bundle', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-13.3 sim'); + const client = makeClient(redteamToken); + + const r = await client.patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059', 'T1078'], + }); + expect(r.status).toBe(200); + + const techniques: { id: string; name: string; tactics: string[] }[] = r.data.techniques; + expect(techniques).toHaveLength(2); + + // Each entry has id, name, and tactics (derived from bundle at serialize time) + for (const t of techniques) { + expect(t).toHaveProperty('id'); + expect(t).toHaveProperty('name'); + expect(Array.isArray(t.tactics)).toBe(true); + expect(t.tactics.length).toBeGreaterThan(0); + } + + const t1059 = techniques.find((t) => t.id === 'T1059'); + expect(t1059).toBeTruthy(); + expect(t1059!.name).toBe('Command and Scripting Interpreter'); + expect(t1059!.tactics).toContain('execution'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-13.4 — PATCH technique_ids: valid IDs stored, unknown ID → 400', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-13.4 sim'); + const client = makeClient(redteamToken); + + // Valid IDs + const rOk = await client.patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059', 'T1078', 'T1566'], + }); + expect(rOk.status).toBe(200); + const ids = (rOk.data.techniques as { id: string }[]).map((t) => t.id); + expect(ids).toContain('T1059'); + expect(ids).toContain('T1078'); + expect(ids).toContain('T1566'); + + // Unknown ID → 400 + const rBad = await client.patch(`/simulations/${sim.id}`, { + technique_ids: ['T9999'], + }); + expect(rBad.status).toBe(400); + expect(rBad.data.error).toMatch(/unknown technique id.*T9999/i); + + // Dedup: sending T1059 twice keeps only one entry in order + const rDedup = await client.patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059', 'T1078', 'T1059'], + }); + expect(rDedup.status).toBe(200); + const dedupIds = (rDedup.data.techniques as { id: string }[]).map((t) => t.id); + expect(dedupIds).toEqual(['T1059', 'T1078']); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-13.4 — SOC PATCH technique_ids → 403', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-13.4 soc block'); + // Advance to review_required so SOC can attempt a patch + const rtClient = makeClient(redteamToken); + await rtClient.patch(`/simulations/${sim.id}`, { name: 'trigger' }); + await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + + const socClient = makeClient(socToken); + const r = await socClient.patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059'], + }); + expect(r.status).toBe(403); + expect(r.data.error).toMatch(/soc cannot edit redteam fields/i); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-13.5 — auto-transition pending→in_progress triggered by non-empty technique_ids', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-13.5 auto-transition'); + expect(sim.status).toBe('pending'); + const client = makeClient(redteamToken); + + // Non-empty technique_ids → triggers auto-transition + const r = await client.patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059'], + }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('in_progress'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-13.5 — empty technique_ids does NOT trigger auto-transition', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-13.5 no-trigger'); + expect(sim.status).toBe('pending'); + const client = makeClient(redteamToken); + + const r = await client.patch(`/simulations/${sim.id}`, { + technique_ids: [], + }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('pending'); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us14-techniques-tags.spec.ts b/e2e/tests/us14-techniques-tags.spec.ts new file mode 100644 index 0000000..593caf8 --- /dev/null +++ b/e2e/tests/us14-techniques-tags.spec.ts @@ -0,0 +1,260 @@ +/** + * US-14 — redteam views and removes techniques as tags. + * Covers AC-14.1 → AC-14.5 (UI tags + auto-save). + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us14-redteam'; +const SOC_USER = 'us14-soc'; +const PASS = 'us14-pass-strong'; + +interface Simulation { + id: number; + status: string; + techniques: { id: string; name: string; tactics: string[] }[]; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-14 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status}`); + return r.data as Simulation; +} + +async function patchTechniques( + token: string, + simId: number, + techniqueIds: string[], +): Promise { + const client = makeClient(token); + const r = await client.patch(`/simulations/${simId}`, { technique_ids: techniqueIds }); + if (r.status !== 200) throw new Error(`patch techniques: ${r.status}`); +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +test.describe('US-14 — technique tags UI', () => { + let redteamToken: string; + let socToken: string; + let engagementId: number; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + await ensureUser(SOC_USER, PASS, 'soc'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + socToken = (await login(SOC_USER, PASS)).token; + const eng = await createEngagement(redteamToken, { + name: 'US-14 Engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + for (const u of [REDTEAM_USER, SOC_USER]) { + await deleteUserByUsername(tok, u); + } + } catch { + /* noop */ + } + }); + + test('AC-14.1 — MitreTechniquesField shows tags, Add technique + Quick search buttons, empty state', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-14.1 empty'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Empty state message visible when no techniques + await expect( + page.getByText(/no techniques selected.*matrix.*quick search/i), + ).toBeVisible(); + + // Add technique and Quick search buttons present + await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-14.1 — tags show id + name + × button', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-14.1 tags'); + await patchTechniques(redteamToken, sim.id, ['T1059', 'T1078']); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + const tagList = page.getByTestId('techniques-tag-list'); + await expect(tagList).toBeVisible(); + + // Both techniques appear as tags + const tags = tagList.getByTestId('mitre-technique-tag'); + await expect(tags).toHaveCount(2); + + // First tag contains T1059 + await expect(tagList).toContainText('T1059'); + await expect(tagList).toContainText('T1078'); + + // × buttons present for redteam + await expect(page.getByRole('button', { name: /remove T1059/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /remove T1078/i })).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-14.2 — removing a tag triggers auto-save PATCH (toast + tag disappears)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-14.2 remove tag'); + await patchTechniques(redteamToken, sim.id, ['T1059', 'T1078']); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Wait for tags to render + await expect(page.getByTestId('techniques-tag-list')).toBeVisible(); + await expect(page.getByRole('button', { name: /remove T1059/i })).toBeVisible(); + + // Click × on T1059 + await page.getByRole('button', { name: /remove T1059/i }).click(); + + // Toast "Techniques updated" should appear + await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 }); + + // T1059 tag is gone, T1078 remains + await expect(page.getByTestId('techniques-tag-list')).not.toContainText('T1059'); + await expect(page.getByTestId('techniques-tag-list')).toContainText('T1078'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-14.2 — Quick search: selecting technique appends as tag + auto-save', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-14.2 quick search'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /quick search/i }).click(); + + const picker = page.getByRole('combobox', { name: /mitre technique/i }); + await picker.fill('T1059'); + + const listbox = page.getByRole('listbox', { name: /mitre techniques/i }); + await expect(listbox).toBeVisible({ timeout: 5_000 }); + + await picker.press('ArrowDown'); + await picker.press('Enter'); + + // Tag appears and auto-save toast + await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-14.3 — SimulationList MITRE column shows first id + +N counter', async ({ + page, + context, + }) => { + const simEmpty = await createSimulation(redteamToken, engagementId, 'AC-14.3 empty'); + const simOne = await createSimulation(redteamToken, engagementId, 'AC-14.3 one'); + await patchTechniques(redteamToken, simOne.id, ['T1059']); + const simThree = await createSimulation(redteamToken, engagementId, 'AC-14.3 three'); + await patchTechniques(redteamToken, simThree.id, ['T1059', 'T1078', 'T1566']); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}`); + + // Empty → shows "—" + const rowEmpty = page.getByRole('row', { name: /AC-14.3 empty/i }); + await expect(rowEmpty).toContainText('—'); + + // One technique → shows id only + const rowOne = page.getByRole('row', { name: /AC-14.3 one/i }); + await expect(rowOne).toContainText('T1059'); + + // Three techniques → shows "T1059 +2" + const rowThree = page.getByRole('row', { name: /AC-14.3 three/i }); + await expect(rowThree).toContainText('T1059'); + await expect(rowThree).toContainText('+2'); + + await deleteSimulation(redteamToken, simEmpty.id); + await deleteSimulation(redteamToken, simOne.id); + await deleteSimulation(redteamToken, simThree.id); + }); + + test('AC-14.4 — order of tags preserved between read and write', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-14.4 order'); + // Patch with specific order T1566 → T1059 → T1078 + await patchTechniques(redteamToken, sim.id, ['T1566', 'T1059', 'T1078']); + + // Verify via API + const r = await makeClient(redteamToken).get(`/simulations/${sim.id}`); + const ids = (r.data.techniques as { id: string }[]).map((t) => t.id); + expect(ids).toEqual(['T1566', 'T1059', 'T1078']); + + // Verify via UI — tags appear in insertion order + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + const tags = page.getByTestId('mitre-technique-tag'); + await expect(tags).toHaveCount(3); + await expect(tags.nth(0)).toContainText('T1566'); + await expect(tags.nth(1)).toContainText('T1059'); + await expect(tags.nth(2)).toContainText('T1078'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-14.5 — tags styled with DESIGN.md tokens (bg-primary-soft, rounded-full)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-14.5 style'); + await patchTechniques(redteamToken, sim.id, ['T1059']); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + const tag = page.getByTestId('mitre-technique-tag').first(); + await expect(tag).toBeVisible(); + + // Verify styling classes are present on the tag element + const cls = await tag.getAttribute('class'); + expect(cls).toMatch(/bg-primary-soft/); + expect(cls).toMatch(/rounded-full/); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us15-mitre-matrix-modal.spec.ts b/e2e/tests/us15-mitre-matrix-modal.spec.ts new file mode 100644 index 0000000..a0ac810 --- /dev/null +++ b/e2e/tests/us15-mitre-matrix-modal.spec.ts @@ -0,0 +1,506 @@ +/** + * US-15 — redteam explores and selects techniques via the MITRE ATT&CK matrix modal. + * Covers AC-15.1 → AC-15.5. + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us15-redteam'; +const PASS = 'us15-pass-strong'; + +interface Simulation { + id: number; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-15 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`); + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +test.describe('US-15 — MITRE matrix modal', () => { + let redteamToken: string; + let engagementId: number; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + const eng = await createEngagement(redteamToken, { + name: 'US-15 Engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + await deleteUserByUsername(tok, REDTEAM_USER); + } catch { + /* noop */ + } + }); + + test('AC-15.1 — GET /api/mitre/matrix returns tactic tree with correct structure', async () => { + const client = makeClient(redteamToken); + const r = await client.get('/mitre/matrix'); + expect(r.status).toBe(200); + expect(Array.isArray(r.data)).toBe(true); + + // At least the 12 canonical MITRE Enterprise tactics + expect(r.data.length).toBeGreaterThanOrEqual(12); + + const first = r.data[0]; + expect(first).toHaveProperty('tactic_id'); + expect(first).toHaveProperty('tactic_name'); + expect(Array.isArray(first.techniques)).toBe(true); + + // First tactic must be "Initial Access" (canonical order) + expect(first.tactic_name).toBe('Initial Access'); + + // Each technique has id, name, subtechniques array + const tech = first.techniques[0]; + expect(tech).toHaveProperty('id'); + expect(tech).toHaveProperty('name'); + expect(Array.isArray(tech.subtechniques)).toBe(true); + + // A technique with known sub-techniques: T1059 is in Execution + const execTactic = (r.data as { tactic_name: string; techniques: { id: string; subtechniques: { id: string; name: string }[] }[] }[]).find( + (t) => t.tactic_name === 'Execution', + ); + expect(execTactic).toBeTruthy(); + const t1059 = execTactic!.techniques.find((t) => t.id === 'T1059'); + expect(t1059).toBeTruthy(); + expect(t1059!.subtechniques.length).toBeGreaterThan(0); + // T1059.001 should be a known sub-technique + const sub = t1059!.subtechniques.find((s) => s.id === 'T1059.001'); + expect(sub).toBeTruthy(); + expect(sub!.name).toBeTruthy(); + }); + + test('AC-15.1 — tactic canonical order is correct (Initial Access first, Impact last)', async () => { + const client = makeClient(redteamToken); + const r = await client.get('/mitre/matrix'); + expect(r.status).toBe(200); + + const tacticNames = (r.data as { tactic_name: string }[]).map((t) => t.tactic_name); + expect(tacticNames[0]).toBe('Initial Access'); + expect(tacticNames[tacticNames.length - 1]).toBe('Impact'); + + // Verify Exfiltration appears before Impact + const exfilIdx = tacticNames.indexOf('Exfiltration'); + const impactIdx = tacticNames.indexOf('Impact'); + expect(exfilIdx).toBeGreaterThan(-1); + expect(exfilIdx).toBeLessThan(impactIdx); + }); + + test('AC-15.2 — modal layout: columns per tactic, tactic header, technique cells', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 layout'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Open the matrix modal via "Add technique" + await page.getByRole('button', { name: /add technique/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Modal title + await expect(dialog.getByRole('heading', { name: /mitre att&?ck matrix/i })).toBeVisible(); + + // Search / filter input present and focused + const searchInput = dialog.getByLabel(/filter techniques/i); + await expect(searchInput).toBeVisible(); + + // At least one tactic column visible — check for "Initial Access" and "Execution" + await expect(dialog).toContainText('Initial Access'); + await expect(dialog).toContainText('Execution'); + + // T1059 technique cell visible in Execution column + await expect(dialog).toContainText('T1059'); + + // Cancel button present + await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.2 — selecting technique updates Apply button counter', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 select'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Use search to isolate T1059 so there's only the label button visible + // The chevron has aria-label "Expand T1059"; we use filter to get the label button + const searchInput = dialog.getByLabel(/filter techniques/i); + await searchInput.fill('T1059'); + // Wait for filter to apply — only T1059 and its subtechniques should be visible + await expect(dialog).toContainText('Command and Scripting Interpreter'); + + // The label button (selection) is the one containing the technique name text + // Filter explicitly excludes the chevron (aria-label="Expand T1059") + const techLabelBtn = dialog + .getByRole('button', { name: /command and scripting interpreter/i }) + .first(); + await techLabelBtn.click(); + + // Apply button should now show count = 1 + await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible(); + + // Click again to deselect + await techLabelBtn.click(); + + // When 0 selected and no initial selection: footer shows disabled "Clear all" + await expect(dialog.getByRole('button', { name: /clear all/i })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /apply \d+ technique/i })).not.toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.2 — subtechnique expand/collapse via chevron', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 expand'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Expand T1059 via chevron button (▸ Expand T1059) + const expandBtn = dialog.getByRole('button', { name: /expand T1059/i }); + await expect(expandBtn).toBeVisible(); + await expandBtn.click(); + + // Sub-technique T1059.001 should now be visible + await expect(dialog).toContainText('T1059.001'); + + // Collapse it + const collapseBtn = dialog.getByRole('button', { name: /collapse T1059/i }); + await collapseBtn.click(); + await expect(dialog).not.toContainText('T1059.001'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.2 — search filters techniques, auto-expands parent when sub matches', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 search'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + const searchInput = dialog.getByLabel(/filter techniques/i); + + // Search by sub-technique ID — parent should auto-expand + await searchInput.fill('T1059.001'); + await expect(dialog).toContainText('T1059.001'); + + // Search by name (case-insensitive) + await searchInput.fill('powershell'); + await expect(dialog).toContainText('PowerShell'); + + // Clear search — techniques come back + await searchInput.fill(''); + await expect(dialog).toContainText('T1059'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.2 — tactic header shows selected count when techniques selected', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 counter'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Initially no "selected" counter visible + await expect(dialog).not.toContainText('1 selected'); + + // Use search to isolate T1059 so we can click the label button, not the chevron + const searchInput = dialog.getByLabel(/filter techniques/i); + await searchInput.fill('T1059'); + await expect(dialog).toContainText('Command and Scripting Interpreter'); + + // The label button contains the technique name; the chevron has aria-label="Expand T1059" + await dialog + .getByRole('button', { name: /command and scripting interpreter/i }) + .first() + .click(); + + // Tactic header for Execution should now show "1 selected" + await expect(dialog).toContainText('1 selected'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.3 — Apply auto-saves techniques and closes modal', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 apply'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + const searchInput = dialog.getByLabel(/filter techniques/i); + + // Select T1059 via label button (not chevron) — filter to isolate + await searchInput.fill('T1059'); + await expect(dialog).toContainText('Command and Scripting Interpreter'); + await dialog + .getByRole('button', { name: /command and scripting interpreter/i }) + .first() + .click(); + + // Select T1566 (Phishing) — no subtechniques, so only one button + await searchInput.fill('T1566'); + await expect(dialog).toContainText('T1566'); + await dialog.getByRole('button', { name: /phishing/i }).first().click(); + + // Apply (2 techniques selected) + const applyBtn = dialog.getByRole('button', { name: /apply \d+ technique/i }); + await expect(applyBtn).toBeVisible(); + await applyBtn.click(); + + // Modal closes + await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + + // Auto-save toast appears + await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 }); + + // Tags appear in the tag list + const tagList = page.getByTestId('techniques-tag-list'); + await expect(tagList).toContainText('T1059'); + await expect(tagList).toContainText('T1566'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.3 — modal receives current selection as initial state', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 initial'); + + // Seed T1059 via API before opening the UI + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059'], + }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Apply button should already show 1 technique (from initial selection) + await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible(); + + // Cancel to discard + await dialog.getByRole('button', { name: /^cancel$/i }).click(); + await expect(dialog).not.toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.3 — Cancel discards local changes (no PATCH fired)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 cancel'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Select a technique via label button (filter to avoid hitting chevron) + const searchInput = dialog.getByLabel(/filter techniques/i); + await searchInput.fill('T1059'); + await expect(dialog).toContainText('Command and Scripting Interpreter'); + await dialog + .getByRole('button', { name: /command and scripting interpreter/i }) + .first() + .click(); + await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible(); + + // Cancel instead of Apply + await dialog.getByRole('button', { name: /^cancel$/i }).click(); + await expect(dialog).not.toBeVisible(); + + // No toast, no PATCH fired — empty state message still visible (0 techniques) + await expect(page.getByText(/techniques updated/i)).not.toBeVisible(); + await expect(page.getByText(/no techniques selected/i)).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.4 — Escape key closes modal (Cancel behaviour)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 escape'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Select something to confirm Cancel semantics on Escape + const searchInput = dialog.getByLabel(/filter techniques/i); + await searchInput.fill('T1059'); + await expect(dialog).toContainText('Command and Scripting Interpreter'); + await dialog + .getByRole('button', { name: /command and scripting interpreter/i }) + .first() + .click(); + await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible({ timeout: 3_000 }); + + // No PATCH fired — empty state message still visible (no techniques added) + await expect(page.getByText(/no techniques selected/i)).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.4 — backdrop click closes modal (Cancel behaviour)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 backdrop'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Click outside the modal container (top-left corner of viewport, which is the backdrop) + await page.mouse.click(5, 5); + await expect(dialog).not.toBeVisible({ timeout: 3_000 }); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.5 — a11y: role=dialog + aria-labelledby, search input focused on open', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 a11y'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // role="dialog" is set (getByRole('dialog') already asserts this) + // aria-modal attribute + const ariaModal = await dialog.getAttribute('aria-modal'); + expect(ariaModal).toBe('true'); + + // aria-labelledby points to the modal title + const labelledBy = await dialog.getAttribute('aria-labelledby'); + expect(labelledBy).toBeTruthy(); + const titleEl = page.locator(`#${labelledBy}`); + await expect(titleEl).toContainText(/mitre att&?ck matrix/i); + + // Search input is focused immediately after open + const searchInput = dialog.getByLabel(/filter techniques/i); + await expect(searchInput).toBeFocused({ timeout: 2_000 }); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-15.5 — a11y: Tab wraps within modal (focus trap)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 focus-trap'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByRole('button', { name: /add technique/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Tab through enough elements to hit the wrap point + // (we don't know exact count, but Shift+Tab from the first focused element + // should stay inside the modal — not land outside) + const searchInput = dialog.getByLabel(/filter techniques/i); + await expect(searchInput).toBeFocused({ timeout: 2_000 }); + + // Shift+Tab from the first element (search) should wrap to Cancel or Apply + await page.keyboard.press('Shift+Tab'); + // The focused element must still be inside the dialog + const focusedOutsideDialog = await page.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]'); + return dialog ? !dialog.contains(document.activeElement) : true; + }); + expect(focusedOutsideDialog).toBe(false); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us16-regression-sprint2.spec.ts b/e2e/tests/us16-regression-sprint2.spec.ts new file mode 100644 index 0000000..30a873a --- /dev/null +++ b/e2e/tests/us16-regression-sprint2.spec.ts @@ -0,0 +1,282 @@ +/** + * US-16 — regression: sprint 2 features still work under the sprint 3 model. + * Covers AC-16.1 → AC-16.3. + * + * This file re-exercises critical sprint 2 ACs that are most likely to break + * due to the scalar→array MITRE migration: + * - Auto-transition pending→in_progress (AC-8.2 / AC-13.5) + * - Manual workflow transitions + badge update (AC-11.x) + * - SOC field-level RBAC (AC-9.x) + * - MitreTechniquePicker still accessible via Quick Search (AC-16.2) + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us16-redteam'; +const SOC_USER = 'us16-soc'; +const PASS = 'us16-pass-strong'; + +interface Simulation { + id: number; + status: string; + techniques: { id: string; name: string; tactics: string[] }[]; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-16 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`); + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +test.describe('US-16 — sprint 2 regression', () => { + let redteamToken: string; + let socToken: string; + let engagementId: number; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + await ensureUser(SOC_USER, PASS, 'soc'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + socToken = (await login(SOC_USER, PASS)).token; + const eng = await createEngagement(redteamToken, { + name: 'US-16 Engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + for (const u of [REDTEAM_USER, SOC_USER]) { + await deleteUserByUsername(tok, u); + } + } catch { + /* noop */ + } + }); + + // AC-16.1 — workflow sprint 2: auto-transition, manual transitions, SOC RBAC + + test('AC-16.1 — auto-transition pending→in_progress triggered by PATCH with non-empty technique_ids', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 auto-transition'); + expect(sim.status).toBe('pending'); + + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059'], + }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('in_progress'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-16.1 — auto-transition triggered by non-technique redteam PATCH (name)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 auto-name'); + expect(sim.status).toBe('pending'); + + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + name: 'trigger by name', + }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('in_progress'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-16.1 — manual transition in_progress→review_required→closed', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 workflow'); + const rtClient = makeClient(redteamToken); + + // Trigger in_progress + await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 workflow' }); + + // in_progress → review_required + const r1 = await rtClient.post(`/simulations/${sim.id}/transition`, { + to: 'review_required', + }); + expect(r1.status).toBe(200); + expect(r1.data.status).toBe('review_required'); + + // review_required → done + const r2 = await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'done' }); + expect(r2.status).toBe(200); + expect(r2.data.status).toBe('done'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-16.1 — SOC cannot PATCH technique_ids (403)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc block'); + const rtClient = makeClient(redteamToken); + + // Advance to review_required so SOC has access + await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 soc block' }); + await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + + const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059'], + }); + expect(r.status).toBe(403); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-16.1 — SOC can PATCH soc_comment without affecting status', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc comment'); + const rtClient = makeClient(redteamToken); + + // Advance to review_required + await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 soc comment' }); + await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + + const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { + soc_comment: 'Looks good, close it.', + }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('review_required'); + expect(r.data.soc_comment).toBe('Looks good, close it.'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-16.1 — SOC cannot transition pending simulation', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc transition'); + + const r = await makeClient(socToken).post(`/simulations/${sim.id}/transition`, { + to: 'review_required', + }); + expect(r.status).toBe(403); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-16.1 — workflow badge updates in UI without page reload', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 badge'); + const rtClient = makeClient(redteamToken); + await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 badge' }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + const badge = page.getByTestId('simulation-status-badge'); + await expect(badge).toBeVisible(); + await expect(badge).toHaveAttribute('data-status', 'in_progress'); + + // Trigger transition via button + await page.getByRole('button', { name: /mark for review/i }).click(); + await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 }); + + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-16.2 — MitreTechniquePicker still accessible via Quick Search (clean rewrite onSelect) + + test('AC-16.2 — MitreTechniquePicker accessible via Quick Search, appends tag on selection', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.2 picker'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Quick Search button reveals the picker + await page.getByRole('button', { name: /quick search/i }).click(); + const picker = page.getByRole('combobox', { name: /mitre technique/i }); + await expect(picker).toBeVisible(); + + // Type to search + await picker.fill('T1078'); + const listbox = page.getByRole('listbox', { name: /mitre techniques/i }); + await expect(listbox).toBeVisible({ timeout: 5_000 }); + + // Keyboard select + await picker.press('ArrowDown'); + await picker.press('Enter'); + + // Tag appears (onSelect one-shot mode — appends to list) + await expect(page.getByTestId('techniques-tag-list')).toContainText('T1078', { + timeout: 5_000, + }); + + // Auto-save toast + await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 }); + + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-16.3 — no sprint 1/2 e2e broken: spot-check key assertions with new model + + test('AC-16.3 — simulation serialisation has techniques array (not scalar MITRE fields)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.3 schema'); + + // New schema: techniques array + expect(Array.isArray(sim.techniques)).toBe(true); + expect(sim).not.toHaveProperty('mitre_technique_id'); + expect(sim).not.toHaveProperty('mitre_technique_name'); + + // PATCH technique_ids → techniques array in response + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + technique_ids: ['T1059', 'T1078'], + }); + expect(r.status).toBe(200); + expect(Array.isArray(r.data.techniques)).toBe(true); + expect(r.data.techniques).toHaveLength(2); + expect(r.data.techniques[0].id).toBe('T1059'); + expect(r.data.techniques[0].name).toBeTruthy(); + expect(Array.isArray(r.data.techniques[0].tactics)).toBe(true); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-16.3 — edit form Red Team section still has name, description, commands fields', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-16.3 form fields'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Red Team section + await expect(page.getByRole('heading', { name: /red team/i })).toBeVisible(); + + // Core fields still present + await expect(page.locator('#sim-name')).toBeVisible(); + await expect(page.locator('#sim-description')).toBeVisible(); + await expect(page.locator('#sim-commands')).toBeVisible(); + + // Save Red Team button still present + await expect(page.getByRole('button', { name: /save red team/i })).toBeVisible(); + + // MitreTechniquesField buttons present + await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us8-simulation-redteam-fill.spec.ts b/e2e/tests/us8-simulation-redteam-fill.spec.ts index a8f91c2..013c8ef 100644 --- a/e2e/tests/us8-simulation-redteam-fill.spec.ts +++ b/e2e/tests/us8-simulation-redteam-fill.spec.ts @@ -79,8 +79,7 @@ test.describe('US-8 — redteam fill simulation details', () => { const patch = { name: 'Updated name', - mitre_technique_id: 'T1059', - mitre_technique_name: 'Command and Scripting Interpreter', + technique_ids: ['T1059'], description: 'Some description', commands: 'cmd /c whoami\ncmd /c ipconfig', prerequisites: 'Admin shell', @@ -90,8 +89,10 @@ test.describe('US-8 — redteam fill simulation details', () => { const r = await client.patch(`/simulations/${sim.id}`, patch); expect(r.status).toBe(200); expect(r.data.name).toBe('Updated name'); - expect(r.data.mitre_technique_id).toBe('T1059'); - expect(r.data.mitre_technique_name).toBe('Command and Scripting Interpreter'); + // sprint 3: techniques array replaces scalar scalars + expect(Array.isArray(r.data.techniques)).toBe(true); + expect(r.data.techniques[0].id).toBe('T1059'); + expect(r.data.techniques[0].name).toBe('Command and Scripting Interpreter'); expect(r.data.description).toBe('Some description'); expect(r.data.commands).toBe('cmd /c whoami\ncmd /c ipconfig'); expect(r.data.prerequisites).toBe('Admin shell'); @@ -199,7 +200,7 @@ test.describe('US-8 — redteam fill simulation details', () => { await deleteSimulation(redteamToken, sim.id); }); - test('AC-8.6 — MITRE technique picker is present on the edit form', async ({ + test('AC-8.6 — MITRE technique picker accessible via Quick search on the edit form', async ({ page, context, }) => { @@ -208,6 +209,8 @@ test.describe('US-8 — redteam fill simulation details', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + // Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search" + await page.getByRole('button', { name: /quick search/i }).click(); // MitreTechniquePicker renders an input with combobox role await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible(); diff --git a/frontend/src/api/mitre.ts b/frontend/src/api/mitre.ts index 4750d62..aadd042 100644 --- a/frontend/src/api/mitre.ts +++ b/frontend/src/api/mitre.ts @@ -1,5 +1,5 @@ import { apiClient } from './client'; -import type { MitreTechnique } from './types'; +import type { MitreTactic, MitreTechnique } from './types'; export async function searchMitreTechniques(query: string): Promise { const { data } = await apiClient.get('/mitre/techniques', { @@ -7,3 +7,8 @@ export async function searchMitreTechniques(query: string): Promise { + const { data } = await apiClient.get('/mitre/matrix'); + return data; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d640768..6c73e3e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -61,12 +61,28 @@ export interface MitreTechnique { tactics: string[]; } +export interface MitreMatrixSubtechnique { + id: string; + name: string; +} + +export interface MitreMatrixTechnique { + id: string; + name: string; + subtechniques: MitreMatrixSubtechnique[]; +} + +export interface MitreTactic { + tactic_id: string; + tactic_name: string; + techniques: MitreMatrixTechnique[]; +} + export interface Simulation { id: number; engagement_id: number; name: string; - mitre_technique_id: string | null; - mitre_technique_name: string | null; + techniques: MitreTechnique[]; description: string | null; commands: string | null; prerequisites: string | null; @@ -88,8 +104,7 @@ export interface SimulationCreateInput { export interface SimulationPatchInput { name?: string; - mitre_technique_id?: string | null; - mitre_technique_name?: string | null; + technique_ids?: string[]; description?: string | null; commands?: string | null; prerequisites?: string | null; diff --git a/frontend/src/components/MitreMatrixModal.tsx b/frontend/src/components/MitreMatrixModal.tsx new file mode 100644 index 0000000..f7aae3a --- /dev/null +++ b/frontend/src/components/MitreMatrixModal.tsx @@ -0,0 +1,346 @@ +import { useEffect, useRef, useState } from 'react'; +import { LoadingState } from './LoadingState'; +import { ErrorState } from './ErrorState'; +import { extractApiError } from '@/api/client'; +import { useMitreMatrix } from '@/hooks/useMitre'; +import type { MitreTechnique } from '@/api/types'; + +interface MitreMatrixModalProps { + isOpen: boolean; + initialSelection: MitreTechnique[]; + onApply: (selection: MitreTechnique[]) => void; + onCancel: () => void; +} + +function techniqueInTactic( + tacticTechniques: { id: string; subtechniques: { id: string }[] }[], + selection: Set, +): number { + let count = 0; + for (const t of tacticTechniques) { + if (selection.has(t.id)) count++; + for (const s of t.subtechniques) { + if (selection.has(s.id)) count++; + } + } + return count; +} + +export function MitreMatrixModal({ + isOpen, + initialSelection, + onApply, + onCancel, +}: MitreMatrixModalProps): JSX.Element | null { + const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen); + + // Selected IDs → Map id → {id, name} for reconstruct + const [selectedMap, setSelectedMap] = useState>( + () => new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])), + ); + const [expandedTechniques, setExpandedTechniques] = useState>(new Set()); + const [search, setSearch] = useState(''); + + const containerRef = useRef(null); + const searchInputRef = useRef(null); + + // Reset local state when modal opens with new initialSelection + useEffect(() => { + if (isOpen) { + setSelectedMap(new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }]))); + setExpandedTechniques(new Set()); + setSearch(''); + } + }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + // Focus search input on open + useEffect(() => { + if (isOpen) { + // Small delay lets the DOM render before focus + setTimeout(() => searchInputRef.current?.focus(), 0); + } + }, [isOpen]); + + // Escape closes modal + useEffect(() => { + if (!isOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isOpen, onCancel]); + + const getFocusableElements = () => { + if (!containerRef.current) return []; + return Array.from( + containerRef.current.querySelectorAll( + 'a, button, input, [tabindex]:not([tabindex="-1"])', + ), + ).filter((el) => !(el as HTMLButtonElement | HTMLInputElement).disabled && !el.hidden && el.tabIndex !== -1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Tab') return; + const focusables = getFocusableElements(); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + if (!isOpen) return null; + + const toggleTechnique = (id: string, name: string) => { + setSelectedMap((prev) => { + const next = new Map(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.set(id, { id, name }); + } + return next; + }); + }; + + const toggleExpand = (id: string) => { + setExpandedTechniques((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const searchLower = search.toLowerCase().trim(); + + // Figure out which technique IDs should be auto-expanded due to a sub-technique match + const autoExpanded = new Set(); + if (searchLower && matrix) { + for (const tactic of matrix) { + for (const tech of tactic.techniques) { + const subMatch = tech.subtechniques.some( + (s) => s.id.toLowerCase().includes(searchLower) || s.name.toLowerCase().includes(searchLower), + ); + if (subMatch) autoExpanded.add(tech.id); + } + } + } + + const handleApply = () => { + // Reconstruct MitreTechnique[] from selected IDs. + // tactics are not available here; parent will use what it has or send [] + const selection: MitreTechnique[] = Array.from(selectedMap.values()).map((t) => ({ + id: t.id, + name: t.name, + tactics: [], + })); + onApply(selection); + }; + + const totalSelected = selectedMap.size; + + return ( +
+ {/* Backdrop */} + + ); +} diff --git a/frontend/src/components/MitreTechniquePicker.tsx b/frontend/src/components/MitreTechniquePicker.tsx index a86ccbc..5d0106b 100644 --- a/frontend/src/components/MitreTechniquePicker.tsx +++ b/frontend/src/components/MitreTechniquePicker.tsx @@ -1,55 +1,26 @@ -import { - useEffect, - useRef, - useState, - type KeyboardEvent, -} from 'react'; +import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; import { extractApiError } from '@/api/client'; import type { MitreTechnique } from '@/api/types'; import { useMitreSearch } from '@/hooks/useMitre'; interface MitreTechniquePickerProps { - techniqueId: string | null; - techniqueName: string | null; - onChange: (id: string | null, name: string | null) => void; + onSelect: (technique: MitreTechnique) => void; disabled?: boolean; } -function formatOption(t: MitreTechnique): string { - const tacticList = t.tactics.length > 0 ? ` (${t.tactics[0]})` : ''; - return `${t.id} — ${t.name}${tacticList}`; -} - const DEBOUNCE_MS = 200; export function MitreTechniquePicker({ - techniqueId, - techniqueName, - onChange, + onSelect, disabled = false, }: MitreTechniquePickerProps): JSX.Element { - const [inputValue, setInputValue] = useState( - techniqueId && techniqueName ? `${techniqueId} — ${techniqueName}` : '', - ); + const [inputValue, setInputValue] = useState(''); const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const debounceRef = useRef | null>(null); const containerRef = useRef(null); const listRef = useRef(null); - // True once we've synced the first real techniqueId from props (parent/API load). - // After that we stop reacting to null, so keystrokes that emit onChange(null,null) - // don't propagate back and wipe the input mid-stroke. - const hasHydratedFromProps = useRef(false); - - useEffect(() => { - if (techniqueId && techniqueName) { - setInputValue(`${techniqueId} — ${techniqueName}`); - hasHydratedFromProps.current = true; - } else if (!techniqueId && !hasHydratedFromProps.current) { - setInputValue(''); - } - }, [techniqueId, techniqueName]); const { data: results, isFetching, isError, error } = useMitreSearch(query, open); @@ -57,8 +28,6 @@ export function MitreTechniquePicker({ const handleInputChange = (value: string) => { setInputValue(value); - // Clear the selection when user starts typing - onChange(null, null); setOpen(true); setActiveIndex(-1); @@ -69,11 +38,12 @@ export function MitreTechniquePicker({ }; const selectItem = (item: MitreTechnique) => { - setInputValue(formatOption(item)); - onChange(item.id, item.name); + onSelect(item); + // Reset to empty after selection — parent handles append + dedup + setInputValue(''); + setQuery(''); setOpen(false); setActiveIndex(-1); - setQuery(''); }; const handleKeyDown = (e: KeyboardEvent) => { @@ -98,7 +68,6 @@ export function MitreTechniquePicker({ } }; - // Scroll active item into view useEffect(() => { if (activeIndex >= 0 && listRef.current) { const el = listRef.current.children[activeIndex] as HTMLElement | undefined; @@ -106,7 +75,6 @@ export function MitreTechniquePicker({ } }, [activeIndex]); - // Close dropdown on click outside useEffect(() => { const onPointerDown = (e: PointerEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { @@ -127,13 +95,11 @@ export function MitreTechniquePicker({ aria-expanded={open} aria-controls={listboxId} aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined} - aria-label="MITRE technique" + aria-label="Search MITRE technique" className="text-input" value={inputValue} onChange={(e) => handleInputChange(e.target.value)} - onFocus={() => { - if (!techniqueId) setOpen(true); - }} + onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} disabled={disabled} placeholder="Search by ID or name (e.g. T1059)" @@ -174,7 +140,6 @@ export function MitreTechniquePicker({ i === activeIndex ? 'bg-primary-soft text-ink' : 'text-ink hover:bg-cloud' }`} onPointerDown={(e) => { - // Prevent input blur before we handle the click e.preventDefault(); selectItem(item); }} diff --git a/frontend/src/components/MitreTechniqueTag.tsx b/frontend/src/components/MitreTechniqueTag.tsx new file mode 100644 index 0000000..a4d15be --- /dev/null +++ b/frontend/src/components/MitreTechniqueTag.tsx @@ -0,0 +1,33 @@ +import type { MitreTechnique } from '@/api/types'; + +interface MitreTechniqueTagProps { + technique: MitreTechnique; + onRemove: () => void; + disabled?: boolean; +} + +export function MitreTechniqueTag({ + technique, + onRemove, + disabled = false, +}: MitreTechniqueTagProps): JSX.Element { + return ( + + {technique.id} + — {technique.name} + {!disabled && ( + + )} + + ); +} diff --git a/frontend/src/components/MitreTechniquesField.tsx b/frontend/src/components/MitreTechniquesField.tsx new file mode 100644 index 0000000..b454fee --- /dev/null +++ b/frontend/src/components/MitreTechniquesField.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import { extractApiError } from '@/api/client'; +import type { MitreTechnique } from '@/api/types'; +import { useUpdateSimulation } from '@/hooks/useSimulations'; +import { useToast } from '@/hooks/useToast'; +import { MitreTechniqueTag } from './MitreTechniqueTag'; +import { MitreTechniquePicker } from './MitreTechniquePicker'; +import { MitreMatrixModal } from './MitreMatrixModal'; + +interface MitreTechniquesFieldProps { + value: MitreTechnique[]; + simulationId: number; + engagementId: number; + disabled?: boolean; +} + +export function MitreTechniquesField({ + value, + simulationId, + engagementId, + disabled = false, +}: MitreTechniquesFieldProps): JSX.Element { + const [showMatrix, setShowMatrix] = useState(false); + const [showPicker, setShowPicker] = useState(false); + + const { push } = useToast(); + const updateMutation = useUpdateSimulation(simulationId, engagementId); + + const save = async (techniques: MitreTechnique[]) => { + try { + await updateMutation.mutateAsync({ + technique_ids: techniques.map((t) => t.id), + }); + push('Techniques updated', 'success'); + } catch (err) { + push(extractApiError(err, 'Could not update techniques'), 'error'); + } + }; + + const handleRemove = (id: string) => { + const next = value.filter((t) => t.id !== id); + void save(next); + }; + + const handleSelect = (technique: MitreTechnique) => { + // Dedup: no-op if already present + if (value.some((t) => t.id === technique.id)) return; + const next = [...value, technique]; + void save(next); + }; + + const handleMatrixApply = (selection: MitreTechnique[]) => { + setShowMatrix(false); + // Merge: preserve existing tactics on items already in value, fill from selection otherwise. + // The backend re-enriches tactics at serialize time, so the exact tactics here don't matter. + const merged = selection.map((s) => { + const existing = value.find((v) => v.id === s.id); + return existing ?? s; + }); + void save(merged); + }; + + const isPending = updateMutation.isPending; + + return ( +
+ {/* Tag list */} + {value.length === 0 ? ( +

+ No techniques selected — use the matrix or the quick search to add. +

+ ) : ( +
+ {value.map((t) => ( + handleRemove(t.id)} + disabled={disabled || isPending} + /> + ))} +
+ )} + + {/* Action buttons — hidden in read-only mode */} + {!disabled && ( +
+ + + {isPending && ( + Saving… + )} +
+ )} + + {/* Inline Quick Search picker */} + {showPicker && !disabled && ( +
+ { + handleSelect(technique); + setShowPicker(false); + }} + disabled={isPending} + /> +
+ )} + + {/* Matrix modal */} + setShowMatrix(false)} + /> +
+ ); +} diff --git a/frontend/src/components/SimulationList.tsx b/frontend/src/components/SimulationList.tsx index 210a1f6..94ded36 100644 --- a/frontend/src/components/SimulationList.tsx +++ b/frontend/src/components/SimulationList.tsx @@ -90,12 +90,17 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme e.stopPropagation()} > {sim.name} - {sim.mitre_technique_id ?? '—'} + {sim.techniques.length === 0 + ? '—' + : sim.techniques.length === 1 + ? sim.techniques[0].id + : `${sim.techniques[0].id} +${sim.techniques.length - 1}`} diff --git a/frontend/src/hooks/useMitre.ts b/frontend/src/hooks/useMitre.ts index 18d736f..f1d8985 100644 --- a/frontend/src/hooks/useMitre.ts +++ b/frontend/src/hooks/useMitre.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { searchMitreTechniques } from '@/api/mitre'; +import { getMitreMatrix, searchMitreTechniques } from '@/api/mitre'; export function useMitreSearch(query: string, enabled: boolean) { return useQuery({ @@ -9,3 +9,12 @@ export function useMitreSearch(query: string, enabled: boolean) { staleTime: 5 * 60 * 1000, }); } + +export function useMitreMatrix(enabled: boolean) { + return useQuery({ + queryKey: ['mitre', 'matrix'], + queryFn: getMitreMatrix, + enabled, + staleTime: Infinity, + }); +} diff --git a/frontend/src/pages/SimulationFormPage.tsx b/frontend/src/pages/SimulationFormPage.tsx index 2e274d9..c852226 100644 --- a/frontend/src/pages/SimulationFormPage.tsx +++ b/frontend/src/pages/SimulationFormPage.tsx @@ -16,12 +16,10 @@ import { LoadingState } from '@/components/LoadingState'; import { ErrorState } from '@/components/ErrorState'; import { SimulationStatusBadge } from '@/components/SimulationStatusBadge'; import { ConfirmDialog } from '@/components/ConfirmDialog'; -import { MitreTechniquePicker } from '@/components/MitreTechniquePicker'; +import { MitreTechniquesField } from '@/components/MitreTechniquesField'; interface RedteamFormState { name: string; - mitre_technique_id: string | null; - mitre_technique_name: string | null; description: string; commands: string; prerequisites: string; @@ -38,8 +36,6 @@ interface SocFormState { const EMPTY_RT: RedteamFormState = { name: '', - mitre_technique_id: null, - mitre_technique_name: null, description: '', commands: '', prerequisites: '', @@ -81,8 +77,6 @@ export function SimulationFormPage(): JSX.Element { const s = detail.data; setRt({ name: s.name, - mitre_technique_id: s.mitre_technique_id, - mitre_technique_name: s.mitre_technique_name, description: s.description ?? '', commands: s.commands ?? '', prerequisites: s.prerequisites ?? '', @@ -154,8 +148,6 @@ export function SimulationFormPage(): JSX.Element { } const patch: SimulationPatchInput = { name: rt.name.trim(), - mitre_technique_id: rt.mitre_technique_id ?? null, - mitre_technique_name: rt.mitre_technique_name ?? null, description: rt.description.trim() || null, commands: rt.commands.trim() || null, prerequisites: rt.prerequisites.trim() || null, @@ -314,16 +306,15 @@ export function SimulationFormPage(): JSX.Element { /> - - - setRt({ ...rt, mitre_technique_id: id, mitre_technique_name: name }) - } +
+ MITRE Techniques + - +