Compare commits
9 Commits
e1d9738f23
...
27573f5228
| Author | SHA1 | Date | |
|---|---|---|---|
| 27573f5228 | |||
|
|
b001f57774 | ||
|
|
df8a6b605b | ||
|
|
393b6ed416 | ||
|
|
4596f09e71 | ||
|
|
39f4076a81 | ||
|
|
771483f3b0 | ||
|
|
673b25e0b0 | ||
|
|
b5ea2929de |
31
CHANGELOG.md
31
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/<sid>` 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)
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
429
backend/tests/test_simulations_techniques.py
Normal file
429
backend/tests/test_simulations_techniques.py
Normal file
@@ -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]) == []
|
||||
@@ -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');
|
||||
|
||||
195
e2e/tests/us13-multi-techniques.spec.ts
Normal file
195
e2e/tests/us13-multi-techniques.spec.ts
Normal file
@@ -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<Simulation> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
260
e2e/tests/us14-techniques-tags.spec.ts
Normal file
260
e2e/tests/us14-techniques-tags.spec.ts
Normal file
@@ -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<Simulation> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
506
e2e/tests/us15-mitre-matrix-modal.spec.ts
Normal file
506
e2e/tests/us15-mitre-matrix-modal.spec.ts
Normal file
@@ -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<Simulation> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
282
e2e/tests/us16-regression-sprint2.spec.ts
Normal file
282
e2e/tests/us16-regression-sprint2.spec.ts
Normal file
@@ -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<Simulation> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<MitreTechnique[]> {
|
||||
const { data } = await apiClient.get<MitreTechnique[]>('/mitre/techniques', {
|
||||
@@ -7,3 +7,8 @@ export async function searchMitreTechniques(query: string): Promise<MitreTechniq
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMitreMatrix(): Promise<MitreTactic[]> {
|
||||
const { data } = await apiClient.get<MitreTactic[]>('/mitre/matrix');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
346
frontend/src/components/MitreMatrixModal.tsx
Normal file
346
frontend/src/components/MitreMatrixModal.tsx
Normal file
@@ -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<string>,
|
||||
): 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<Map<string, { id: string; name: string }>>(
|
||||
() => new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])),
|
||||
);
|
||||
const [expandedTechniques, setExpandedTechniques] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(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<HTMLElement>(
|
||||
'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<string>();
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/60"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="matrix-modal-title"
|
||||
className="relative bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden flex flex-col"
|
||||
style={{ width: '1200px' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-xl py-md border-b border-hairline flex-shrink-0">
|
||||
<h2 id="matrix-modal-title" className="text-[20px] font-medium text-ink">
|
||||
MITRE ATT&CK Matrix
|
||||
</h2>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Filter techniques…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="text-input w-64"
|
||||
aria-label="Filter techniques"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto px-xl py-md">
|
||||
{isLoading && <LoadingState label="Loading MITRE matrix…" />}
|
||||
{isError && (
|
||||
<ErrorState
|
||||
message={extractApiError(error, 'Could not load MITRE matrix')}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && matrix && (
|
||||
<div className="flex gap-sm" style={{ minWidth: 'max-content' }}>
|
||||
{matrix.map((tactic) => {
|
||||
const selectedCount = techniqueInTactic(tactic.techniques, new Set(selectedMap.keys()));
|
||||
|
||||
// Filter techniques for this tactic
|
||||
const visibleTechniques = tactic.techniques.filter((tech) => {
|
||||
if (!searchLower) return true;
|
||||
const techMatch =
|
||||
tech.id.toLowerCase().includes(searchLower) ||
|
||||
tech.name.toLowerCase().includes(searchLower);
|
||||
const subMatch = tech.subtechniques.some(
|
||||
(s) =>
|
||||
s.id.toLowerCase().includes(searchLower) ||
|
||||
s.name.toLowerCase().includes(searchLower),
|
||||
);
|
||||
return techMatch || subMatch;
|
||||
});
|
||||
|
||||
if (visibleTechniques.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tactic.tactic_id}
|
||||
className="flex-shrink-0"
|
||||
style={{ width: '220px' }}
|
||||
>
|
||||
{/* Tactic header */}
|
||||
<div className="bg-cloud rounded-t-md px-sm py-xs border border-hairline border-b-0">
|
||||
<div className="text-[11px] uppercase tracking-[0.5px] text-graphite font-medium leading-none">
|
||||
{tactic.tactic_name}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="text-[11px] text-primary-deep font-medium mt-xxs">
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Techniques */}
|
||||
<div className="border border-hairline rounded-b-md overflow-hidden">
|
||||
{visibleTechniques.map((tech, techIdx) => {
|
||||
const isSelected = selectedMap.has(tech.id);
|
||||
const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id);
|
||||
const hasSubtechniques = tech.subtechniques.length > 0;
|
||||
const isLast = techIdx === visibleTechniques.length - 1;
|
||||
|
||||
// Filter subtechniques when searching
|
||||
const visibleSubs = searchLower
|
||||
? tech.subtechniques.filter(
|
||||
(s) =>
|
||||
s.id.toLowerCase().includes(searchLower) ||
|
||||
s.name.toLowerCase().includes(searchLower),
|
||||
)
|
||||
: tech.subtechniques;
|
||||
|
||||
return (
|
||||
<div key={tech.id} className={!isLast ? 'border-b border-hairline' : ''}>
|
||||
{/* Technique row */}
|
||||
<div
|
||||
className={`flex items-start px-sm py-xs text-[13px] ${
|
||||
isSelected ? 'bg-primary text-canvas' : 'bg-canvas text-ink hover:bg-cloud'
|
||||
}`}
|
||||
>
|
||||
{/* Chevron — expand/collapse, does NOT toggle selection */}
|
||||
{hasSubtechniques ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`}
|
||||
onClick={() => toggleExpand(tech.id)}
|
||||
className={`mr-xxs flex-shrink-0 text-[11px] w-4 leading-none mt-[1px] ${
|
||||
isSelected ? 'text-canvas' : 'text-graphite'
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="mr-xxs w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Label — click toggles selection */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTechnique(tech.id, tech.name)}
|
||||
className={`text-left leading-snug flex-1 min-w-0 ${
|
||||
isSelected ? 'text-canvas' : 'text-ink'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{tech.id}</span>
|
||||
<br />
|
||||
<span className={isSelected ? 'text-canvas/80' : 'text-charcoal'}>
|
||||
{tech.name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtechniques — shown when expanded */}
|
||||
{isExpanded &&
|
||||
visibleSubs.map((sub) => {
|
||||
const isSubSelected = selectedMap.has(sub.id);
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
onClick={() => toggleTechnique(sub.id, sub.name)}
|
||||
className={`w-full text-left pl-md pr-sm py-xxs text-[12px] border-t border-hairline leading-snug ${
|
||||
isSubSelected
|
||||
? 'bg-primary-soft text-primary-deep'
|
||||
: 'bg-cloud text-charcoal hover:bg-fog'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{sub.id}</span>
|
||||
{' — '}
|
||||
{sub.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-md px-xl py-md border-t border-hairline flex-shrink-0">
|
||||
<button type="button" className="btn-outline-ink" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleApply}
|
||||
disabled={isLoading || isError || (totalSelected === 0 && initialSelection.length === 0)}
|
||||
>
|
||||
{totalSelected === 0
|
||||
? 'Clear all'
|
||||
: `Apply ${totalSelected} technique${totalSelected !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(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<HTMLInputElement>) => {
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
33
frontend/src/components/MitreTechniqueTag.tsx
Normal file
33
frontend/src/components/MitreTechniqueTag.tsx
Normal file
@@ -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 (
|
||||
<span
|
||||
data-testid="mitre-technique-tag"
|
||||
className="inline-flex items-center gap-xxs bg-primary-soft text-primary-deep rounded-full px-md py-xxs text-[14px]"
|
||||
>
|
||||
<span className="font-medium">{technique.id}</span>
|
||||
<span className="text-primary-deep opacity-75"> — {technique.name}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${technique.id}`}
|
||||
onClick={onRemove}
|
||||
className="ml-xxs text-primary-deep opacity-60 hover:opacity-100 leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/MitreTechniquesField.tsx
Normal file
135
frontend/src/components/MitreTechniquesField.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col gap-sm">
|
||||
{/* Tag list */}
|
||||
{value.length === 0 ? (
|
||||
<p className="text-[14px] text-graphite">
|
||||
No techniques selected — use the matrix or the quick search to add.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-sm" data-testid="techniques-tag-list">
|
||||
{value.map((t) => (
|
||||
<MitreTechniqueTag
|
||||
key={t.id}
|
||||
technique={t}
|
||||
onRemove={() => handleRemove(t.id)}
|
||||
disabled={disabled || isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons — hidden in read-only mode */}
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline"
|
||||
onClick={() => {
|
||||
setShowPicker(false);
|
||||
setShowMatrix(true);
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
Add technique
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={() => setShowPicker((v) => !v)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Quick search
|
||||
</button>
|
||||
{isPending && (
|
||||
<span className="text-[13px] text-graphite">Saving…</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Quick Search picker */}
|
||||
{showPicker && !disabled && (
|
||||
<div className="max-w-md">
|
||||
<MitreTechniquePicker
|
||||
onSelect={(technique) => {
|
||||
handleSelect(technique);
|
||||
setShowPicker(false);
|
||||
}}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Matrix modal */}
|
||||
<MitreMatrixModal
|
||||
isOpen={showMatrix}
|
||||
initialSelection={value}
|
||||
onApply={handleMatrixApply}
|
||||
onCancel={() => setShowMatrix(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,12 +90,17 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
|
||||
<Link
|
||||
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
|
||||
className="text-ink font-medium hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sim.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal text-[14px]">
|
||||
{sim.mitre_technique_id ?? '—'}
|
||||
{sim.techniques.length === 0
|
||||
? '—'
|
||||
: sim.techniques.length === 1
|
||||
? sim.techniques[0].id
|
||||
: `${sim.techniques[0].id} +${sim.techniques.length - 1}`}
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<SimulationStatusBadge status={sim.status} />
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="MITRE Technique" htmlFor="sim-mitre">
|
||||
<MitreTechniquePicker
|
||||
techniqueId={rt.mitre_technique_id}
|
||||
techniqueName={rt.mitre_technique_name}
|
||||
onChange={(id, name) =>
|
||||
setRt({ ...rt, mitre_technique_id: id, mitre_technique_name: name })
|
||||
}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">MITRE Techniques</span>
|
||||
<MitreTechniquesField
|
||||
value={simulation?.techniques ?? []}
|
||||
simulationId={simulationId as number}
|
||||
engagementId={engagementId as number}
|
||||
disabled={rtDisabled}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Description" htmlFor="sim-description">
|
||||
<TextArea
|
||||
|
||||
241
frontend/tests/MitreMatrixModal.test.tsx
Normal file
241
frontend/tests/MitreMatrixModal.test.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { MitreMatrixModal } from '@/components/MitreMatrixModal';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { MitreTactic, MitreTechnique } from '@/api/types';
|
||||
|
||||
const MATRIX: MitreTactic[] = [
|
||||
{
|
||||
tactic_id: 'TA0001',
|
||||
tactic_name: 'Initial Access',
|
||||
techniques: [
|
||||
{
|
||||
id: 'T1078',
|
||||
name: 'Valid Accounts',
|
||||
subtechniques: [
|
||||
{ id: 'T1078.001', name: 'Default Accounts' },
|
||||
{ id: 'T1078.002', name: 'Domain Accounts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'T1190',
|
||||
name: 'Exploit Public-Facing Application',
|
||||
subtechniques: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tactic_id: 'TA0002',
|
||||
tactic_name: 'Execution',
|
||||
techniques: [
|
||||
{
|
||||
id: 'T1059',
|
||||
name: 'Command and Scripting Interpreter',
|
||||
subtechniques: [{ id: 'T1059.001', name: 'PowerShell' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SELECTION: MitreTechnique[] = [
|
||||
{ id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] },
|
||||
];
|
||||
|
||||
describe('MitreMatrixModal', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/mitre/matrix').reply(200, MATRIX);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('returns null when isOpen=false', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<MitreMatrixModal isOpen={false} initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders dialog with tactic columns when open', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Initial Access')).toBeInTheDocument();
|
||||
expect(screen.getByText('Execution')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders techniques for each tactic', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('T1078')).toBeInTheDocument();
|
||||
expect(screen.getByText('T1059')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Apply button calls onApply with selected techniques', async () => {
|
||||
const onApply = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
// Click the label button for T1078 to select it
|
||||
const t1078Btn = screen.getAllByRole('button').find(
|
||||
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
||||
);
|
||||
await user.click(t1078Btn!);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Apply/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'T1078' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('Cancel button calls onCancel without onApply', async () => {
|
||||
const onApply = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onApply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Escape key calls onCancel', async () => {
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows initial selection as selected', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
// T1078 should show selected count in tactic header
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search filter narrows visible techniques', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Filter techniques/i);
|
||||
await user.type(searchInput, 'T1059');
|
||||
|
||||
// T1059 column should be visible, T1078 should not
|
||||
expect(screen.queryByText('T1078')).toBeNull();
|
||||
expect(screen.getByText('T1059')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('chevron expands subtechniques', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
// Subtechniques should not be visible initially
|
||||
expect(screen.queryByText(/Default Accounts/)).toBeNull();
|
||||
|
||||
// Click the expand chevron for T1078
|
||||
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i });
|
||||
await user.click(expandBtn);
|
||||
|
||||
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Apply button shows technique count', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply 1 technique/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Apply button is disabled when no techniques selected and no initial selection', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
// Label is "Clear all" when totalSelected === 0, but it's disabled when initialSelection is also empty
|
||||
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
||||
expect(applyBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Apply button shows "Clear all" and stays enabled when initial selection is deselected', async () => {
|
||||
const onApply = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={onApply} onCancel={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
// Deselect T1078 (it was pre-selected)
|
||||
const t1078Btn = screen.getAllByRole('button').find(
|
||||
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
||||
);
|
||||
await user.click(t1078Btn!);
|
||||
|
||||
// Button should show "Clear all" and be enabled (user explicitly clearing the list)
|
||||
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
|
||||
expect(applyBtn).not.toBeDisabled();
|
||||
await user.click(applyBtn);
|
||||
expect(onApply).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('backdrop click calls onCancel', async () => {
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
// Click the backdrop (the fixed inset div behind the modal)
|
||||
const backdrop = document.querySelector('.bg-ink\\/60') as HTMLElement;
|
||||
if (backdrop) await user.click(backdrop);
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,41 +28,14 @@ describe('MitreTechniquePicker', () => {
|
||||
|
||||
it('renders input with placeholder', () => {
|
||||
vi.useRealTimers();
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/Search by ID or name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows preselected value when techniqueId and name provided', () => {
|
||||
vi.useRealTimers();
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId="T1059"
|
||||
techniqueName="Command and Scripting Interpreter"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole('combobox') as HTMLInputElement;
|
||||
expect(input.value).toContain('T1059');
|
||||
expect(input.value).toContain('Command and Scripting Interpreter');
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
vi.useRealTimers();
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
disabled
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} disabled />);
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -70,22 +43,14 @@ describe('MitreTechniquePicker', () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
await user.type(input, 'T');
|
||||
|
||||
// Before debounce fires
|
||||
expect(mock.history.get.length).toBe(0);
|
||||
|
||||
// Advance past debounce
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
|
||||
await waitFor(() => expect(mock.history.get.length).toBeGreaterThan(0));
|
||||
@@ -95,13 +60,7 @@ describe('MitreTechniquePicker', () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
@@ -117,18 +76,12 @@ describe('MitreTechniquePicker', () => {
|
||||
expect(options[0].textContent).toContain('T1059');
|
||||
});
|
||||
|
||||
it('selecting a result calls onChange with id and name', async () => {
|
||||
it('selecting a result calls onSelect with technique object', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const onChange = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={onSelect} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
@@ -140,20 +93,16 @@ describe('MitreTechniquePicker', () => {
|
||||
const options = screen.getAllByRole('option');
|
||||
await user.click(options[0]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('T1059', 'Command and Scripting Interpreter');
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'T1059', name: 'Command and Scripting Interpreter' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('populates input display string after selection', async () => {
|
||||
it('resets input to empty after selection (one-shot)', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox') as HTMLInputElement;
|
||||
await user.click(input);
|
||||
@@ -165,22 +114,16 @@ describe('MitreTechniquePicker', () => {
|
||||
const options = screen.getAllByRole('option');
|
||||
await user.click(options[0]);
|
||||
|
||||
expect(input.value).toContain('T1059');
|
||||
expect(input.value).toContain('Command and Scripting Interpreter');
|
||||
// Input must be reset after selection
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('keyboard ArrowDown + Enter selects item', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const onChange = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={onSelect} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
@@ -192,20 +135,14 @@ describe('MitreTechniquePicker', () => {
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Escape closes the dropdown', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
@@ -219,37 +156,11 @@ describe('MitreTechniquePicker', () => {
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('typing while techniqueId is null does not reset inputValue between keystrokes', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, []);
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('combobox') as HTMLInputElement;
|
||||
await user.click(input);
|
||||
await user.type(input, 'T10');
|
||||
|
||||
// Input must retain the full typed value — no mid-stroke reset
|
||||
expect(input.value).toBe('T10');
|
||||
});
|
||||
|
||||
it('shows inline error when API returns 503', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(503, { error: 'mitre bundle not loaded' });
|
||||
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
|
||||
|
||||
renderWithProviders(
|
||||
<MitreTechniquePicker
|
||||
techniqueId={null}
|
||||
techniqueName={null}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
renderWithProviders(<MitreTechniquePicker onSelect={vi.fn()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await user.click(input);
|
||||
|
||||
41
frontend/tests/MitreTechniqueTag.test.tsx
Normal file
41
frontend/tests/MitreTechniqueTag.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MitreTechniqueTag } from '@/components/MitreTechniqueTag';
|
||||
import { renderWithProviders } from './utils';
|
||||
|
||||
const TECHNIQUE = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
|
||||
|
||||
describe('MitreTechniqueTag', () => {
|
||||
it('renders id and name', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText('T1059')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Command and Scripting Interpreter/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows remove button when not disabled', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /Remove T1059/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking × calls onRemove', async () => {
|
||||
const onRemove = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreTechniqueTag technique={TECHNIQUE} onRemove={onRemove} />,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Remove T1059/i }));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('hides remove button when disabled', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} disabled />,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
148
frontend/tests/MitreTechniquesField.test.tsx
Normal file
148
frontend/tests/MitreTechniquesField.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
|
||||
const T1059: MitreTechnique = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
|
||||
const T1078: MitreTechnique = { id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] };
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: 'redteam', created_at: '2026-01-01' },
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: false,
|
||||
isRedteam: true,
|
||||
isSoc: false,
|
||||
canEditEngagements: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('MitreTechniquesField', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows empty state message when no techniques', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
expect(screen.getByText(/No techniques selected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tags for each technique', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[T1059, T1078]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
expect(screen.getAllByTestId('mitre-technique-tag')).toHaveLength(2);
|
||||
expect(screen.getByText('T1059')).toBeInTheDocument();
|
||||
expect(screen.getByText('T1078')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Add technique and Quick search buttons when not disabled', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /Add technique/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Quick search/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides action buttons when disabled', () => {
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[T1059]} simulationId={7} engagementId={42} disabled />,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Add technique/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Quick search/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('× button on tag calls PATCH with technique removed', async () => {
|
||||
mock.onPatch('/simulations/7').reply(200, {
|
||||
id: 7, engagement_id: 42, name: 'test', techniques: [],
|
||||
description: null, commands: null, prerequisites: null,
|
||||
executed_at: null, execution_result: null, log_source: null,
|
||||
logs: null, soc_comment: null, incident_number: null,
|
||||
status: 'pending', created_at: '2026-01-01', updated_at: null,
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
});
|
||||
// also mock GET simulations list for invalidation
|
||||
mock.onGet('/engagements/42/simulations').reply(200, []);
|
||||
mock.onGet('/simulations/7').reply(200, {
|
||||
id: 7, engagement_id: 42, name: 'test', techniques: [],
|
||||
description: null, commands: null, prerequisites: null,
|
||||
executed_at: null, execution_result: null, log_source: null,
|
||||
logs: null, soc_comment: null, incident_number: null,
|
||||
status: 'pending', created_at: '2026-01-01', updated_at: null,
|
||||
created_by: { id: 1, username: 'alice' },
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[T1059, T1078]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
|
||||
const removeBtn = screen.getByRole('button', { name: /Remove T1059/i });
|
||||
await user.click(removeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mock.history.patch.length).toBe(1);
|
||||
const body = JSON.parse(mock.history.patch[0].data as string);
|
||||
expect(body.technique_ids).toEqual(['T1078']);
|
||||
});
|
||||
});
|
||||
|
||||
it('Quick search toggle shows picker input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Quick search/i }));
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dedup: selecting an already-present technique does not PATCH', async () => {
|
||||
mock.onGet('/mitre/techniques').reply(200, [T1059]);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[T1059]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
|
||||
// Open the quick-search picker
|
||||
await user.click(screen.getByRole('button', { name: /Quick search/i }));
|
||||
const combobox = screen.getByRole('combobox');
|
||||
expect(combobox).toBeInTheDocument();
|
||||
|
||||
// Type to trigger the search (debounce is 200ms but fake timers not needed — mock responds immediately)
|
||||
await user.type(combobox, 'T1059');
|
||||
|
||||
// Wait for the option to appear in the listbox
|
||||
const option = await screen.findByRole('option', { name: /T1059/i });
|
||||
expect(option).toBeInTheDocument();
|
||||
|
||||
// Select it via pointerDown (mirrors the component's onPointerDown handler)
|
||||
await user.pointer({ target: option, keys: '[MouseLeft>]' });
|
||||
|
||||
// Dedup guard should have fired — no PATCH should have been sent
|
||||
expect(mock.history.patch.length).toBe(0);
|
||||
});
|
||||
|
||||
it('opens matrix modal when Add technique is clicked', async () => {
|
||||
mock.onGet('/mitre/matrix').reply(200, []);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Add technique/i }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,7 @@ const BASE_SIM: Simulation = {
|
||||
id: 7,
|
||||
engagement_id: 42,
|
||||
name: 'Recon test',
|
||||
mitre_technique_id: null,
|
||||
mitre_technique_name: null,
|
||||
techniques: [],
|
||||
description: 'Some description',
|
||||
commands: 'whoami\nipconfig',
|
||||
prerequisites: null,
|
||||
|
||||
@@ -11,8 +11,7 @@ const SIMULATIONS: Simulation[] = [
|
||||
id: 1,
|
||||
engagement_id: 42,
|
||||
name: 'Lateral movement test',
|
||||
mitre_technique_id: 'T1021',
|
||||
mitre_technique_name: 'Remote Services',
|
||||
techniques: [{ id: 'T1021', name: 'Remote Services', tactics: ['lateral-movement'] }],
|
||||
description: null,
|
||||
commands: null,
|
||||
prerequisites: null,
|
||||
|
||||
@@ -4,6 +4,34 @@ Recurring mistakes and the rule we adopted so the same issue doesn't bite twice.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 3 (closed 2026-05-27)
|
||||
|
||||
### Process — Spec-review 2-pass after team-lead edits (team-lead)
|
||||
**Context** : Sprint 3 spec-reviewer ran a first pass on my drafted plan, flagged 5 items, I edited the plan to address 4 of them. Spec-reviewer ran a 2nd pass and caught 2 critical gaps I'd missed in my edits (REDTEAM_FIELDS update + SQLite batch_alter_table for nullable=False / drop_column). Backend-builder was already mid-implementation when the 2nd pass arrived — I dispatched an urgent addendum SendMessage.
|
||||
**Lesson** : after editing the plan in response to spec-reviewer's notes, always run a 2nd spec-review pass before dispatching builders. The fixes themselves can introduce new gaps. Cheaper than urgent addenda mid-sprint. Cost: one extra read-only pass; benefit: no addenda churn.
|
||||
|
||||
### Process — Avoid embedding builder summaries that look like new dispatches (team-lead)
|
||||
**Context** : The frontend dispatch brief contained a "BACKEND-BUILDER SUMMARY" inline section to give the frontend the API contract. The backend-builder also received this message (team routing) and interpreted the embedded summary as a fresh dispatch — they re-read it, found one ambiguity I'd resolved differently in the frontend brief than in the original backend brief (503 vs 400 on bundle unloaded), and pushed a fix commit `673b25e` independently. Net positive but a coordination cost.
|
||||
**Lesson** : when embedding another builder's summary as inline context, prefix the section with "DO NOT ACT ON THIS — INLINE CONTEXT ONLY" or use a clear visual separator (`---`) plus a header that makes scope obvious. Builders inherit the entire message — they don't know which parts are addressed to them.
|
||||
|
||||
### Process — Explicit "Ajouter un test" in brief means a real test, not just code (team-lead)
|
||||
**Context** : Sprint 3 post-review dispatch to backend-builder explicitly said "Test : ajouter un assert que… NOT NULL après upgrade" and "Test : un assert dans test_mitre.py qui vérifie… 'Command and Control'". Backend-builder fixed the code in commit `4596f09` but added zero new tests (162 → 162). I bounced back with a SendMessage; backend-builder added the tests in `393b6ed` (164/164).
|
||||
**Lesson** : the discipline of "if the brief says 'add a test', the test is non-negotiable" must be enforced. Don't accept a fix-commit that doesn't include the regression tests requested in the brief — bounce back via SendMessage. Builders may otherwise treat tests as "if I have time" while only delivering the production change.
|
||||
|
||||
### Engineering — SQLite Alembic migrations require batch_alter_table for ALTER + DROP COLUMN (backend-builder)
|
||||
**Context** : Spec-reviewer flagged that the migration brief mentioned `alter_column nullable=False` and `drop_column` without specifying `op.batch_alter_table(...)`. SQLite doesn't support either operation natively — without batch mode, the migration crashes at runtime. Backend-builder initially skipped the `nullable=False` step entirely with a comment "model + app logic enforces it"; code-reviewer pushed back ("batch mode rebuilds the table and does support the change — that's its purpose"). Final fix wraps the step in `batch_alter_table`.
|
||||
**Lesson** : on SQLite, ANY operation that mutates a column type, nullability, or schema beyond ADD COLUMN must go through `with op.batch_alter_table(table) as batch_op: batch_op.alter_column(...)`. Don't accept "model enforces it" as a substitute for DDL-level constraint — a fresh DB initialised from migrations alone won't have the constraint.
|
||||
|
||||
### Engineering — Real migration round-trip > pure unit test (backend-builder)
|
||||
**Context** : Backend-builder's initial migration backfill test was tautological — it inlined a `_backfill` Python helper and tested the helper against itself, never invoking the real Alembic `upgrade()`. Code-reviewer flagged it. Fix: load the migration module via `importlib.util.spec_from_file_location`, patch `alembic.op._proxy` with a live `Operations` context, run `upgrade()` against in-memory SQLite, then `sqlalchemy.inspect` the resulting schema.
|
||||
**Lesson** : a migration test that doesn't invoke `command.upgrade()` (or the equivalent `Operations` runner against the real migration module) tests nothing about the actual migration path. Use `alembic.runtime.migration.MigrationContext` + `alembic.operations.Operations` to instantiate a real runner against an in-memory engine.
|
||||
|
||||
### UX — Modal Apply 0 disambiguation (frontend-builder)
|
||||
**Context** : MitreMatrixModal initially labelled the Apply button as "Apply " (trailing space, no count) when 0 techniques were selected, while the button stayed enabled. A click with 0 selected and a non-empty current list would silently clear all techniques. Code-reviewer flagged. Final design: `disabled` when both counts are zero (nothing to do); label switches to "Clear all" when the user wants to wipe a non-empty list (count=0 but initial selection non-empty); standard "Apply N technique(s)" otherwise.
|
||||
**Lesson** : for any "Apply"/"Confirm"/"Save" button whose effect depends on the diff between local and remote state, enumerate the three cases — no-op (disable), destructive intent (relabel to confirm), normal (count + verb) — before shipping. The trailing-space label is a code smell that exposes missing edge-case handling.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 2 (closed 2026-05-26)
|
||||
|
||||
### Testing — Vitest module hoisting (frontend-builder)
|
||||
|
||||
414
tasks/todo.md
414
tasks/todo.md
@@ -1,252 +1,290 @@
|
||||
# Sprint 2 — Simulations + MITRE ATT&CK
|
||||
# Sprint 3 — MITRE matrix modal + multi-technique simulations
|
||||
|
||||
**Branche** : `sprint/2-simulations`
|
||||
**Statut** : 🟢 SPRINT COMPLET — 32 acceptance tests sprint 2 verts, code-review traité (2 MAJOR + 2 MINOR + 2 NITs fixés), PR prête
|
||||
**Base** : `main` (sprint 1 mergé en `7fc79cc`)
|
||||
**Objectif** : livrer les simulations (CRUD + workflow Pending→In progress→Review required→Done) à l'intérieur d'un engagement, avec autocomplete MITRE ATT&CK alimenté par un bundle STIX local. C'est le cœur métier — l'app remplace enfin le fichier Excel partagé redteam/SOC.
|
||||
**Branche** : `sprint/3-mitre-matrix`
|
||||
**Statut** : 🟢 SPRINT COMPLET — 105/105 sprint 3 e2e verts, code-review traité, PR prête
|
||||
**Base** : `main` @ `e1d9738`
|
||||
**Objectif** : remplacer la sélection MITRE mono-technique de sprint 2 par une sélection multi-techniques avec deux modes complémentaires : autocomplete (rapide) et matrice cliquable (exploration). Les techniques choisies s'affichent comme tags sur la simulation.
|
||||
|
||||
---
|
||||
|
||||
## 0. Évolution SPEC.md à acter en début de sprint
|
||||
|
||||
SPEC.md § Simulation dit aujourd'hui "Type d'attaque MITRE correspondant (peut être une liste de référence)" au singulier. Le team-lead met à jour cette ligne en début de sprint (pas de PR séparée) pour refléter le scope multi-techniques. Texte cible :
|
||||
|
||||
> Types d'attaque MITRE correspondants (multi-techniques) — sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale.
|
||||
|
||||
L'évolution est tracée dans CHANGELOG.md § Changed du sprint 3.
|
||||
|
||||
---
|
||||
|
||||
## 1. User stories
|
||||
|
||||
### US-7 — En tant que redteam, je crée une simulation dans un engagement
|
||||
**Pourquoi** : c'est la feature centrale du sprint 2.
|
||||
### US-13 — En tant que redteam, je sélectionne plusieurs techniques MITRE par simulation
|
||||
**Pourquoi** : un test couvre souvent plusieurs TTPs (ex : Initial Access → Discovery → Execution). Mono-technique limite la description réelle d'un test.
|
||||
|
||||
**Critères d'acceptation**
|
||||
- [ ] AC-7.1 : `POST /api/engagements/<eid>/simulations {name}` (admin|redteam) → 201 + simulation `{id, engagement_id, name, status: "pending", ...}`. `name` requis, non vide.
|
||||
- [ ] AC-7.2 : autres rôles (soc) → 403.
|
||||
- [ ] AC-7.3 : engagement inexistant → 404. Engagement existant mais aucune simulation → liste vide.
|
||||
- [ ] AC-7.4 : `GET /api/engagements/<eid>/simulations` (auth) → liste des simulations de l'engagement, ordonnée `created_at desc`.
|
||||
- [ ] AC-7.5 : page `/engagements/:eid` (EngagementDetailPage) remplace le placeholder Sprint 2 par une section "Simulations" : liste (colonnes: name, MITRE id, status badge, executed_at) + bouton "Nouvelle simulation" pour admin/redteam.
|
||||
- [ ] AC-7.6 : depuis cette liste, click sur une ligne → ouvre `/engagements/:eid/simulations/:sid/edit` (page d'édition role-aware, unique URL pour view+edit).
|
||||
- [ ] AC-13.1 : modèle `Simulation` n'a plus `mitre_technique_id` ni `mitre_technique_name` (scalaires). Remplacés par `techniques` (colonne JSON, liste d'objets `{id: str, name: str}`, défaut `[]`).
|
||||
- [ ] AC-13.2 : migration Alembic `0003_simulation_techniques_array.py` :
|
||||
- ajoute la colonne `techniques` (JSON)
|
||||
- backfill les simulations existantes : si `mitre_technique_id` non null → `techniques = [{id, name}]`, sinon `techniques = []`
|
||||
- drop les deux anciennes colonnes
|
||||
- migration réversible (downgrade : prendre le premier élément, ré-injecter dans les scalaires, drop `techniques`)
|
||||
- [ ] AC-13.3 : sérialisation simulation expose `techniques: [{id, name, tactics: [...]}]` — le backend enrichit chaque entrée avec ses `tactics` depuis le service MITRE au moment du serialize (snapshot d'`id`+`name` en DB, tactics dérivées au runtime depuis le bundle).
|
||||
- [ ] AC-13.4 : `PATCH /api/simulations/<sid>` accepte `{technique_ids: ["T1059", "T1078"]}` (liste d'IDs string). Backend valide chaque ID contre le bundle MITRE, résout `name`, écrit `[{id, name}]` en DB. ID inconnu → 400 `{error: "unknown technique id: T9999"}`.
|
||||
- [ ] AC-13.5 : la règle d'auto-transition `pending → in_progress` s'applique aussi à `technique_ids` quand la liste reçue est non vide.
|
||||
|
||||
### US-8 — En tant que redteam, je renseigne les détails techniques d'une simulation
|
||||
**Pourquoi** : c'est la trace de ce que la redteam a exécuté.
|
||||
### US-14 — En tant que redteam, je vois et retire les techniques d'une simulation sous forme de tags
|
||||
**Pourquoi** : visualiser rapidement la couverture TTP d'un test.
|
||||
|
||||
**Critères d'acceptation**
|
||||
- [ ] AC-8.1 : `PATCH /api/simulations/<sid>` (admin|redteam) accepte les champs redteam : `name`, `mitre_technique_id`, `mitre_technique_name`, `description`, `commands` (texte multiligne, une commande par ligne), `prerequisites`, `executed_at` (ISO datetime), `execution_result`. Champs partiels OK.
|
||||
- [ ] AC-8.2 : règle d'auto-transition pending → in_progress. Trigger PRÉCIS : `PATCH /api/simulations/<sid>` par admin|redteam où **le payload JSON contient au moins une clé parmi les champs redteam** (`name`, `mitre_technique_id`, `mitre_technique_name`, `description`, `commands`, `prerequisites`, `executed_at`, `execution_result`) **dont la valeur n'est ni `null` ni une string vide ni une liste vide**, ET status courant == `pending`. La comparaison se fait sur le payload entrant — pas sur l'état final de la simulation. Un PATCH qui ne ré-envoie qu'un champ inchangé (ex: même `name`) déclenche quand même la transition, car c'est une action explicite "la redteam saisit". L'auto-transition ne se déclenche jamais sur un PATCH `soc`.
|
||||
- [ ] AC-8.3 : `commands` est stocké en colonne `text` (chaîne multiligne, une commande par ligne). Sérialisation API = texte brut tel que stocké. Le frontend affiche dans un `<textarea>`.
|
||||
- [ ] AC-8.4 : `executed_at` valide ISO 8601 ou null. Si invalide → 400 `{error: "invalid executed_at"}`.
|
||||
- [ ] AC-8.5 : page `/engagements/:eid/simulations/:sid` affiche un formulaire avec deux sections visibles ("Red Team" et "SOC"). Pour admin/redteam, les deux sections sont éditables. Validation client : `name` non vide.
|
||||
- [ ] AC-8.6 : autocomplete MITRE dans le champ "Technique" — voir US-10.
|
||||
- [ ] AC-14.1 : sur `SimulationFormPage`, à la place du seul `MitreTechniquePicker` du sprint 2, un composant `MitreTechniquesField` affiche :
|
||||
- Liste des techniques sélectionnées sous forme de chips/tags (id + name, ex : `T1059 — Command and Scripting Interpreter`), avec un `×` cliquable pour retirer chaque technique.
|
||||
- Bouton "Add technique" qui ouvre la modale matrice (US-15).
|
||||
- Bouton "Quick search" qui ouvre l'autocomplete existant (réutilisation du `MitreTechniquePicker`) en mode "ajoute à la liste" (sélection = append, pas replace).
|
||||
- État vide : message "No techniques selected — use the matrix or the quick search to add."
|
||||
- [ ] AC-14.2 : retirer un tag (× sur le chip) déclenche un PATCH immédiat (auto-save) avec la liste mise à jour. La modale matrice (US-15) auto-save aussi via "Apply". Le picker Quick Search auto-save chaque sélection. Toast `'Techniques updated'` sur succès, toast erreur sinon. Pas de bouton Save manuel pour les techniques.
|
||||
- [ ] AC-14.3 : sur `SimulationList` (table dans EngagementDetailPage), la colonne "MITRE" affiche un compteur + premier tag (ex : `T1059 +2` si 3 techniques sélectionnées). Si la liste est vide, afficher `—`.
|
||||
- [ ] AC-14.4 : ordre des tags dans la simulation préservé entre lecture et écriture (pas de tri imposé côté serveur).
|
||||
- [ ] AC-14.5 : tags affichés avec les couleurs/spacing DESIGN.md (`bg-primary-soft`, `text-primary-deep`, `rounded-full`, `px-md py-xxs`).
|
||||
|
||||
### US-9 — En tant qu'analyste SOC, je remplis ma partie de la simulation
|
||||
**Pourquoi** : le SOC documente la détection sans toucher au scope redteam.
|
||||
### US-15 — En tant que redteam, j'ouvre la matrice MITRE ATT&CK pour explorer et sélectionner des techniques
|
||||
**Pourquoi** : l'autocomplete est efficace si on sait ce qu'on cherche ; la matrice est nécessaire pour "voir ce qui existe" et combiner par tactique.
|
||||
|
||||
**Critères d'acceptation**
|
||||
- [ ] AC-9.1 : `PATCH /api/simulations/<sid>` envoyé par un user `soc` n'accepte QUE les champs SOC : `log_source`, `logs`, `soc_comment`, `incident_number`. Si la requête contient un champ redteam → 403 `{error: "soc cannot edit redteam fields"}`.
|
||||
- [ ] AC-9.2 : un user `soc` ne peut PATCH une simulation que si son status est `review_required` ou `done`. Avant ça → 403 `{error: "simulation not ready for SOC review"}`.
|
||||
- [ ] AC-9.3 : page `/engagements/:eid/simulations/:sid` pour un user `soc` : la section "Red Team" est rendue en read-only (champs grisés) ; la section "SOC" est éditable.
|
||||
- [ ] AC-9.4 : si la simulation est en `pending` ou `in_progress` et qu'un soc visite la page, un bandeau "Simulation pas encore en revue — la redteam doit la marquer comme 'Review required' avant que vous puissiez intervenir" s'affiche, les champs SOC sont désactivés.
|
||||
- [ ] AC-15.1 : nouvel endpoint `GET /api/mitre/matrix` (auth, tous rôles) → tree `[{tactic_id, tactic_name, techniques: [{id, name, subtechniques: [{id, name}]}]}]`. Chaque technique top-level embarque ses sub-techniques (`T1059` → `[T1059.001, T1059.002, ...]`). Ordre des tactiques = ordre canonique MITRE Enterprise (Initial Access → Execution → Persistence → ... → Exfiltration → Impact). 503 si bundle non chargé.
|
||||
- [ ] AC-15.2 : composant `MitreMatrixModal` :
|
||||
- Modal large (≥ 1100px), scroll vertical interne.
|
||||
- Layout horizontal en colonnes : 1 colonne par tactique. Header de colonne = nom de la tactique + compteur de techniques sélectionnées dans cette tactique (sub-techniques incluses).
|
||||
- Chaque technique top-level = bouton/cellule cliquable. État sélectionné visible (`bg-primary` + texte blanc).
|
||||
- Si la technique a des sub-techniques (`subtechniques.length > 0`), un chevron (▸/▾) précède le nom. Click sur le chevron = expand/collapse (n'affecte PAS la sélection). Click sur le label = toggle sélection de la technique top-level.
|
||||
- Sub-techniques affichées en cascade indentée sous leur parent quand expand. Cliquables individuellement (toggle de la sub). État visuel distinct : `bg-primary-soft` quand sélectionnée, indent `pl-md`, font-size légèrement plus petit.
|
||||
- Sélectionner une sub-technique ne sélectionne PAS le parent (les deux sont indépendants côté data). Mais le compteur de tactique somme parent + subs sélectionnées.
|
||||
- Champ de recherche en haut du modal qui filtre les techniques affichées (case-insensitive sur id ET name). Quand le filtre matche une sub-technique, son parent est automatiquement expand pour la rendre visible.
|
||||
- Boutons en footer : "Cancel" (ferme sans appliquer), "Apply N techniques" (compteur = total parents + subs sélectionnés).
|
||||
- [ ] AC-15.3 : la modale est ouverte depuis le bouton "Add technique" de US-14. Elle reçoit en input la liste actuelle de techniques sélectionnées et travaille sur une copie locale ; "Apply" déclenche directement le PATCH (auto-save, cf AC-14.2) et ferme la modale ; "Cancel" jette le diff local.
|
||||
- [ ] AC-15.4 : Escape ferme la modale (= Cancel). Click sur le backdrop = Cancel.
|
||||
- [ ] AC-15.5 : a11y V1 — **scope minimal explicite** : (1) focus initial sur le champ recherche à l'ouverture, (2) Tab cycle entre les éléments focusables de la modale (wrap : dernier élément → premier), (3) Escape ferme = onCancel, (4) ARIA `role="dialog"` + `aria-labelledby` sur le titre. Full WAI-ARIA dialog conformance (live regions, focus restoration au close, screen reader announcements détaillés) **out of scope V1** — c'est une dette assumée à reprendre dans un sprint a11y dédié.
|
||||
|
||||
### US-10 — En tant que redteam, j'autocomplète une technique MITRE ATT&CK
|
||||
**Pourquoi** : éviter de taper l'id à la main, garantir la cohérence.
|
||||
|
||||
**Critères d'acceptation**
|
||||
- [ ] AC-10.1 : `make update-mitre` télécharge le bundle STIX 2.1 Enterprise depuis `https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json` et l'écrit dans `backend/data/mitre/enterprise-attack.json`. Le bundle est COMMITTÉ dans le repo (`make build` reste autosuffisant). `make update-mitre` reste l'unique méthode de rafraîchissement et le diff résultant est committé manuellement.
|
||||
- [ ] AC-10.2 : `GET /api/mitre/techniques?q=<query>` (auth, tous rôles) → liste max 20 résultats `[{id, name, tactics: ["initial-access", ...]}]`. Recherche full-text sur `id` (ex: "T1059") OU `name` (ex: "Command and Scripting Interpreter"), case-insensitive, ordonnée : match exact id > match préfixe id > match nom.
|
||||
- [ ] AC-10.3 : si le bundle local est absent → endpoint répond 503 `{error: "mitre bundle not loaded"}`. Le team-lead documente `make update-mitre` dans le README.
|
||||
- [ ] AC-10.4 : sous-techniques (id format `T1059.001`) incluses dans l'index.
|
||||
- [ ] AC-10.5 : composant frontend `MitreTechniquePicker` : input + dropdown des matches (debounce 200ms, navigation clavier ↑↓ + Enter, Escape ferme le dropdown), affichage `T1059 — Command and Scripting Interpreter (initial-access)`. La sélection d'une suggestion remplit `mitre_technique_id` ET `mitre_technique_name` du form. Pas de fallback free-text : si l'utilisateur tape sans sélectionner, le champ technique reste vide en sortie de form (id et name `null`).
|
||||
|
||||
### US-11 — En tant qu'utilisateur, je transitionne le workflow d'une simulation
|
||||
**Pourquoi** : la coordination redteam ↔ soc passe par le statut.
|
||||
|
||||
**Critères d'acceptation**
|
||||
- [ ] AC-11.1 : `POST /api/simulations/<sid>/transition {to: "review_required"}` → 200, requiert status courant ∈ {`pending`, `in_progress`} et role ∈ {admin, redteam}. Refuse les autres transitions → 409 `{error: "invalid transition"}`.
|
||||
- [ ] AC-11.2 : `POST /api/simulations/<sid>/transition {to: "done"}` → 200, requiert status courant == `review_required` et role ∈ {admin, redteam, soc}. Autres transitions → 409.
|
||||
- [ ] AC-11.3 : aucune transition arrière (ex: done → pending) n'est permise. Pas de transition `→ pending` ni `→ in_progress` via cet endpoint (le passage à `in_progress` est strictement automatique cf AC-8.2).
|
||||
- [ ] AC-11.4 : sur la page d'édition simulation, deux boutons contextuels :
|
||||
- Pour admin/redteam, status ∈ {pending, in_progress} : bouton "Marquer en revue".
|
||||
- Pour admin/redteam/soc, status == review_required : bouton "Clôturer".
|
||||
- Sinon : boutons cachés.
|
||||
- [ ] AC-11.5 : après transition réussie, la query simulation et la liste sont invalidées (TanStack Query), le badge se met à jour.
|
||||
|
||||
### US-12 — En tant qu'admin ou redteam, je supprime une simulation
|
||||
**Critères d'acceptation**
|
||||
- [ ] AC-12.1 : `DELETE /api/simulations/<sid>` (admin|redteam) → 204.
|
||||
- [ ] AC-12.2 : `soc` → 403.
|
||||
- [ ] AC-12.3 : suppression d'engagement (cascade) supprime toutes ses simulations.
|
||||
- [ ] AC-12.4 : bouton "Supprimer" sur la page d'édition (admin/redteam uniquement), avec confirmation modal.
|
||||
### US-16 — En tant que user (tous rôles), j'utilise les autres fonctionnalités sans régression
|
||||
**Critères d'acceptation** (régression)
|
||||
- [ ] AC-16.1 : workflow sprint 2 (auto-transition, transitions manuelles, RBAC SOC) inchangé — tous les ACs sprint 2 (US-7 → US-12) continuent de passer.
|
||||
- [ ] AC-16.2 : l'ancien `MitreTechniquePicker` est conservé dans la base de code MAIS sa signature passe en clean rewrite (`onSelect({id, name})` au lieu de `onChange(id, name)`), wrappé par `MitreTechniquesField` en mode append.
|
||||
- [ ] AC-16.3 : aucune e2e sprint 1/sprint 2 ne casse. Quelques assertions sprint 2 (US-8 et US-10) qui validaient le mono-technique sont mises à jour pour refléter la liste.
|
||||
|
||||
---
|
||||
|
||||
## 2. Brief technique — Backend Builder
|
||||
|
||||
**Scope strict** : `backend/`, `docker/`, `Makefile` (target `update-mitre`).
|
||||
**Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs (team-lead).
|
||||
|
||||
### Livrables
|
||||
|
||||
**Modèle `Simulation`** (`backend/app/models/simulation.py`)
|
||||
| Champ | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | int PK | |
|
||||
| engagement_id | int FK Engagement, CASCADE | requis |
|
||||
| name | str, NOT NULL | redteam-side |
|
||||
| mitre_technique_id | str, nullable | ex "T1059" / "T1059.001" |
|
||||
| mitre_technique_name | str, nullable | snapshot pour résilience aux maj MITRE |
|
||||
| description | text, nullable | redteam-side |
|
||||
| commands | text, nullable | chaîne multiligne, une commande par ligne — pas de JSON |
|
||||
| prerequisites | text, nullable | redteam-side |
|
||||
| executed_at | datetime, nullable | redteam-side |
|
||||
| execution_result | text, nullable | redteam-side |
|
||||
| log_source | text, nullable | soc-side |
|
||||
| logs | text, nullable | soc-side |
|
||||
| soc_comment | text, nullable | soc-side |
|
||||
| incident_number | str, nullable | soc-side |
|
||||
| status | enum(pending/in_progress/review_required/done), défaut `pending` | |
|
||||
| created_at | datetime | |
|
||||
| updated_at | datetime, nullable | mis à jour à chaque PATCH |
|
||||
| created_by_id | int FK User | |
|
||||
- Remplacer `mitre_technique_id`, `mitre_technique_name` (str nullable) par :
|
||||
```python
|
||||
techniques: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
|
||||
```
|
||||
- Stockage : `[{"id": "T1059", "name": "Command and Scripting Interpreter"}, ...]`. Pas de `tactics` en DB (dérivé au serialize).
|
||||
|
||||
**Migration Alembic** `0002_add_simulations.py` — table `simulations` + FK indexes (`engagement_id`, `created_by_id`).
|
||||
**Service workflow** (`backend/app/services/simulation_workflow.py`) — **mise à jour RBAC field-level OBLIGATOIRE**
|
||||
- Dans le `REDTEAM_FIELDS` frozenset existant : **retirer** `"mitre_technique_id"` et `"mitre_technique_name"`, **ajouter** `"technique_ids"`.
|
||||
- Sans ce changement : un user soc qui PATCH avec `{technique_ids: [...]}` reçoit un silent no-op (champ ignoré) au lieu du 403 attendu. La gate field-level RBAC pour `technique_ids` repose intégralement sur ce frozenset.
|
||||
- Le `SOC_FIELDS` frozenset reste inchangé.
|
||||
- Tester explicitement : `test_simulations_techniques.py` doit inclure "SOC PATCH technique_ids → 403" (cf. liste de tests plus bas).
|
||||
|
||||
**Endpoints** (nouveau blueprint `backend/app/api/simulations.py`)
|
||||
- `GET /api/engagements/<eid>/simulations` — list, auth, all roles
|
||||
- `POST /api/engagements/<eid>/simulations` — create, admin|redteam
|
||||
- `GET /api/simulations/<sid>` — get, auth
|
||||
- `PATCH /api/simulations/<sid>` — update avec RBAC field-level (voir AC-8/9)
|
||||
- `DELETE /api/simulations/<sid>` — admin|redteam
|
||||
- `POST /api/simulations/<sid>/transition` — state machine
|
||||
- `GET /api/mitre/techniques?q=` — autocomplete (200 OK + array, 503 si bundle absent)
|
||||
**Migration Alembic `0003_simulation_techniques_array.py`**
|
||||
- Upgrade :
|
||||
1. Ajouter colonne `techniques` (JSON, nullable=True temporaire, default `'[]'`) — `op.add_column` direct OK.
|
||||
2. Data migration : pour chaque ligne, si `mitre_technique_id` IS NOT NULL → set `techniques = '[{"id":"<id>","name":"<name>"}]'`, sinon `'[]'`.
|
||||
3. ALTER column `techniques` → nullable=False — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** car SQLite ne supporte pas ALTER COLUMN nativement.
|
||||
4. Drop columns `mitre_technique_id`, `mitre_technique_name` — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** (même raison : SQLite ne supporte pas DROP COLUMN hors batch mode).
|
||||
- Downgrade : symétrique avec les MÊMES guards batch_alter_table pour les étapes ALTER/DROP. Recrée les 2 colonnes, prend le premier élément de `techniques` si non vide, drop `techniques`.
|
||||
- Pattern à suivre : la migration `0002_add_simulations.py` (sprint 2) — vérifier le style batch_alter_table déjà en place.
|
||||
|
||||
**Serializer** : retourne `created_by={id, username}` (pattern existant). `commands` → string brut (tel que stocké en DB, peut être `null` ou chaîne multiligne).
|
||||
|
||||
**Service workflow** (`backend/app/services/simulation_workflow.py`)
|
||||
- `apply_patch(simulation, payload, user)` :
|
||||
- sépare champs redteam vs soc
|
||||
- vérifie RBAC field-level
|
||||
- détecte auto-transition pending → in_progress (AC-8.2)
|
||||
- applique le patch + commit
|
||||
- `transition(simulation, to_status, user)` :
|
||||
- vérifie state machine (transitions autorisées)
|
||||
- vérifie RBAC role
|
||||
- met à jour status + updated_at
|
||||
**Serializer** (`backend/app/serializers.py`)
|
||||
- `serialize_simulation(sim)` :
|
||||
- Avant retour, enrichir chaque tag avec `tactics` depuis `mitre_svc.get_tactics(id)`. Si la technique a été retirée du bundle MITRE entre-temps, `tactics = []` (gracieux).
|
||||
- `commands` reste tel quel (text brut, inchangé sprint 2).
|
||||
|
||||
**Service MITRE** (`backend/app/services/mitre.py`)
|
||||
- Au boot de l'app : tente de charger `backend/data/mitre/enterprise-attack.json` en mémoire ; si absent ou parse error → flag `mitre_loaded = False` (logue warning, app démarre quand même).
|
||||
- Indexe les objets STIX `type == "attack-pattern"` : extract `external_id` (T-id), `name`, `kill_chain_phases[].phase_name`.
|
||||
- Fonction `search(query, limit=20)` : ranking par exact-id > prefix-id > substring-name.
|
||||
|
||||
**`Makefile`** : remplacer le no-op de `update-mitre` par :
|
||||
```makefile
|
||||
MITRE_URL ?= https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json
|
||||
update-mitre:
|
||||
@mkdir -p backend/data/mitre
|
||||
@curl -fsSL "$(MITRE_URL)" -o backend/data/mitre/enterprise-attack.json
|
||||
@echo "MITRE bundle updated"
|
||||
@if docker ps --format '{{.Names}}' | grep -q "^$(CONTAINER)$$"; then \
|
||||
echo "Restarting $(CONTAINER) to reload MITRE bundle..."; \
|
||||
docker restart $(CONTAINER); \
|
||||
fi
|
||||
- Étendre l'index avec un dict `tactics_by_technique: dict[str, list[str]]` pour lookup O(1) au serialize.
|
||||
- Nouvelle fonction `get_tactics(technique_id: str) -> list[str]`.
|
||||
- Nouvelle fonction `lookup_name(technique_id: str) -> str | None` — utilisée par l'endpoint PATCH pour résoudre le name côté serveur (le client n'envoie que les IDs).
|
||||
- Nouvelle fonction `get_matrix() -> list[dict]` :
|
||||
```json
|
||||
[
|
||||
{"tactic_id": "TA0001", "tactic_name": "Initial Access",
|
||||
"techniques": [
|
||||
{"id": "T1078", "name": "Valid Accounts",
|
||||
"subtechniques": [{"id": "T1078.001", "name": "Default Accounts"}, ...]},
|
||||
...
|
||||
]},
|
||||
...
|
||||
]
|
||||
```
|
||||
Sub-techniques embarquées sous chaque parent (relation STIX `subtechnique-of` dans le bundle). Si la technique n'en a pas, `subtechniques: []`.
|
||||
Ordre des tactiques : canonical MITRE Enterprise order (12 tactics). Lecture depuis les objets STIX `x-mitre-tactic` ordonnés par `x_mitre_shortname` natif OU constante module-level hardcodée si plus simple.
|
||||
Ordre des techniques au sein d'une tactique : alphabétique par `name` (déterministe, lisible).
|
||||
|
||||
**Dockerfile** : copier `backend/data/mitre/` dans l'image (présent dans le repo, donc fonctionne au premier build).
|
||||
**API** (`backend/app/api/simulations.py`)
|
||||
- `GET /api/mitre/matrix` — nouvel endpoint, 200 + tree, 503 si bundle absent.
|
||||
- `PATCH /api/simulations/<sid>` : le payload accepte maintenant `technique_ids: list[str]` à la place de `mitre_technique_id` + `mitre_technique_name`. Validation : tous les IDs doivent exister dans le bundle (400 sinon), `name` snapshot servi par `lookup_name`. Pas de rétrocompat avec les anciens champs scalaires (clean break — pas d'utilisateur externe).
|
||||
- **Dedup serveur** : avant écriture en DB, dédupliquer la liste `technique_ids` en préservant l'ordre (`list(dict.fromkeys(technique_ids))`). Le client peut envoyer accidentellement des doublons (race UI ou bug), le serveur ne doit jamais persister deux fois la même technique.
|
||||
- Auto-transition (AC-13.5) : un `technique_ids` non vide (≥1 élément) compte comme redteam-side filled, déclenche `pending → in_progress`. Liste vide = pas de trigger.
|
||||
|
||||
**Bundle MITRE** : committé dans le repo à `backend/data/mitre/enterprise-attack.json`. Le backend-builder l'inclut dans son premier commit via `make update-mitre`.
|
||||
**Tests pytest**
|
||||
- `test_simulations_techniques.py` (nouveau) :
|
||||
- Création + PATCH `technique_ids` → simulation a la bonne liste, sérialisation expose `techniques` avec `tactics`.
|
||||
- PATCH avec ID inconnu → 400.
|
||||
- Auto-transition sur `technique_ids` non vide.
|
||||
- Retirer toutes les techniques (`technique_ids: []`) → pas de trigger d'auto-transition (cohérent avec règle "valeur vide").
|
||||
- **Dedup** : PATCH avec `technique_ids: ["T1059", "T1078", "T1059"]` → DB ne stocke que 2 entrées, ordre préservé (`T1059` en premier).
|
||||
- `test_mitre.py` (existant) — ajouter :
|
||||
- `get_matrix()` renvoie les bonnes tactiques dans le bon ordre.
|
||||
- `lookup_name(unknown)` → None.
|
||||
- `get_tactics(known)` → liste correcte (≥1 tactique).
|
||||
- `test_simulations_crud.py` + `test_simulations_patch.py` + `test_simulations_workflow.py` (existants) — adapter toute assertion qui touchait `mitre_technique_id` / `mitre_technique_name`.
|
||||
- Migration : test que les anciennes simulations en `pending` avec un id mono-tech sont upgradées en `techniques: [{id, name}]` (fixture inline ou test direct sur Alembic).
|
||||
|
||||
**Tests pytest** (`backend/tests/`)
|
||||
- `test_simulations_crud.py` : create + list + get + delete + cascade, RBAC create/delete.
|
||||
- `test_simulations_patch.py` : auto-transition pending→in_progress, RBAC field-level soc, blocage soc avant review_required (AC-9.2).
|
||||
- `test_simulations_workflow.py` : transitions valides/invalides, RBAC par transition.
|
||||
- `test_mitre.py` : load bundle (fixture mini), search ranking, endpoint 503 si pas chargé, sous-techniques incluses.
|
||||
|
||||
Tous les tests existants doivent rester verts. Lint ruff + mypy clean.
|
||||
|
||||
### Règles
|
||||
- Pas de touche au frontend.
|
||||
- Pas d'invention de dépendances (pas besoin d'en ajouter).
|
||||
- Renvoyer le summary attendu (cf. `.claude/agents/backend-builder.md`).
|
||||
**Quality bar** : ruff + mypy clean, tous les tests existants + nouveaux verts.
|
||||
|
||||
---
|
||||
|
||||
## 3. Brief technique — Frontend Builder
|
||||
|
||||
**Scope strict** : `frontend/` UNIQUEMENT. Interdiction de toucher `e2e/`.
|
||||
**Scope strict** : `frontend/` uniquement.
|
||||
|
||||
**Note process (lesson learned sprint 2)** : avant de marquer la tâche terminée, lance le dev server et screenshot (a) la matrice modale ouverte avec ≥3 techniques sélectionnées et (b) la simulation form avec ≥2 tags affichés. Joins-les à ton summary final.
|
||||
|
||||
### Livrables
|
||||
|
||||
**Types** (`frontend/src/api/types.ts`) : ajouter `Simulation`, `SimulationStatus`, `MitreTechnique`, et les payloads PATCH/POST.
|
||||
**Types** (`frontend/src/api/types.ts`)
|
||||
- `MitreTechnique`: `{id: string, name: string, tactics: string[]}` (déjà existant pour le picker — réutiliser, ajouter `tactics` si manquant).
|
||||
- Ajouter `MitreTactic`: `{tactic_id: string, tactic_name: string, techniques: MitreMatrixTechnique[]}` avec `MitreMatrixTechnique = {id: string, name: string, subtechniques: {id: string, name: string}[]}`.
|
||||
- `Simulation.techniques: MitreTechnique[]` à la place de `mitre_technique_id` + `mitre_technique_name`. PATCH payload : `{technique_ids: string[]}`.
|
||||
|
||||
**Client API** (`frontend/src/api/simulations.ts`, `frontend/src/api/mitre.ts`)
|
||||
- `listSimulations(engagementId)`, `createSimulation(engagementId, {name})`, `getSimulation(id)`, `updateSimulation(id, patch)`, `deleteSimulation(id)`, `transitionSimulation(id, to)`.
|
||||
- `searchMitreTechniques(query)`.
|
||||
**API client** (`frontend/src/api/mitre.ts`)
|
||||
- `searchMitreTechniques(q)` — existant, garder.
|
||||
- `getMitreMatrix()` — nouveau, GET `/api/mitre/matrix`.
|
||||
|
||||
**Hooks TanStack Query** (`frontend/src/hooks/useSimulations.ts`)
|
||||
- `useEngagementSimulations(engagementId)`, `useSimulation(id)`, mutations `useCreateSimulation`, `useUpdateSimulation`, `useDeleteSimulation`, `useTransitionSimulation`.
|
||||
- Invalidation : transition + update + delete invalident `["simulations", id]` et `["engagements", eid, "simulations"]`.
|
||||
**Hooks** (`frontend/src/hooks/useMitre.ts`)
|
||||
- `useMitreSearch(q, enabled)` — existant, garder.
|
||||
- `useMitreMatrix(enabled)` — nouveau hook TanStack Query, `staleTime: Infinity` (la matrice ne change qu'avec `make update-mitre` + redémarrage).
|
||||
|
||||
**Hook `useMitre`** : `useMitreSearch(query, enabled)` (debounce géré côté composant, hook sans staleTime court — cache 5min).
|
||||
**Composants**
|
||||
|
||||
- **`MitreTechniqueTag.tsx`** (nouveau) : chip affichant `{id} — {name}` avec un bouton `×`. Props : `technique: MitreTechnique`, `onRemove: () => void`, `disabled?: boolean`.
|
||||
|
||||
- **`MitreTechniquesField.tsx`** (nouveau, dans `frontend/src/components/`) : conteneur qui orchestre la sélection multi-tech avec **auto-save** (PATCH déclenché par chaque add/remove/Apply).
|
||||
- Props : `value: MitreTechnique[]`, `simulationId: number`, `disabled?: boolean`. (Pas de `onChange` du parent — le composant fait son propre PATCH via `useUpdateSimulation`.)
|
||||
- UI : liste de `<MitreTechniqueTag>` + 2 boutons "Add technique" (ouvre matrix) et "Quick search" (ouvre/toggle picker autocomplete inline).
|
||||
- Dédup : si l'utilisateur essaye d'ajouter une technique déjà présente, no-op silencieux (pas de PATCH non plus).
|
||||
- Auto-save : chaque mutation (× sur tag, Apply matrice, sélection Quick Search) déclenche `useUpdateSimulation` avec `{technique_ids: [...]}`. Toast succès `'Techniques updated'`, toast erreur sinon. Pendant le PATCH : disable l'interaction (les × deviennent grisés, les boutons disabled).
|
||||
|
||||
- **`MitreMatrixModal.tsx`** (nouveau) : modale matrice avec sub-techniques expand/collapse.
|
||||
- Props : `isOpen: boolean`, `initialSelection: MitreTechnique[]`, `onApply: (selection: MitreTechnique[]) => void`, `onCancel: () => void`.
|
||||
- État local : (a) copie de `initialSelection` mutée par les toggles, (b) `expandedTechniques: Set<string>` pour les IDs parents dépliés.
|
||||
- Layout : flex horizontal scrollable, 1 colonne par tactique. Largeur fixe 220px par colonne pour cohérence visuelle.
|
||||
- Chevron `▸/▾` à gauche du nom des techniques qui ont des sub-techniques (`subtechniques.length > 0`). Click chevron = toggle expand (mute le set `expandedTechniques`), ne modifie PAS la sélection.
|
||||
- Click sur le label d'une technique top-level = toggle sa sélection (le chevron ne se déclenche pas dans ce cas — séparer les zones cliquables).
|
||||
- Sub-techniques rendues en cascade indentée sous leur parent quand expand : `pl-md text-[12px] bg-cloud rounded` (vs parent `text-[14px]`). Cliquables individuellement, sélection indépendante du parent.
|
||||
- Compteur header de tactique = nombre de techniques **parents + subs** sélectionnées dans cette tactique.
|
||||
- Champ recherche en haut : filtre case-insensitive sur id ET name. Une sub-technique matchée force l'expand de son parent (modifie automatiquement `expandedTechniques`).
|
||||
- Modale : `position: fixed`, backdrop `bg-ink/60`, container `bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden`.
|
||||
- Footer : "Cancel" (jette les changements locaux + ferme), "Apply N techniques" (compteur total ; click → onApply renvoie la liste complète, parent fait le PATCH auto-save US-14.2).
|
||||
- Focus trap (scope minimal V1, cf AC-15.5) :
|
||||
- `useEffect` au mount → `searchInputRef.current?.focus()`.
|
||||
- `onKeyDown` au niveau du container modale :
|
||||
- `Tab` sans shift sur le dernier élément focusable → `preventDefault()` + focus le premier.
|
||||
- `Shift+Tab` sur le premier → `preventDefault()` + focus le dernier.
|
||||
- Récupérer la liste des focusables via `container.querySelectorAll('a, button, input, [tabindex]:not([tabindex="-1"])')`, ignorer ceux `disabled` ou `hidden`.
|
||||
- Pas de focus restoration ni de live region — out of scope V1.
|
||||
- Pas de dépendance npm.
|
||||
- Escape → onCancel. Click backdrop → onCancel.
|
||||
|
||||
- **`MitreTechniquePicker.tsx`** (existant, sprint 2) : clean rewrite de la signature.
|
||||
- Avant : `onChange(id: string | null, name: string | null)` qui remplaçait la valeur.
|
||||
- Après : `onSelect({id, name})` — un seul match sélectionné, le parent (MitreTechniquesField) gère l'append + le dédup.
|
||||
- Plus de prop `techniqueId`/`techniqueName` en entrée (le picker est désormais un sélecteur "one-shot" qui se réinitialise après chaque sélection).
|
||||
|
||||
**Pages**
|
||||
- `EngagementDetailPage.tsx` : remplacer le placeholder (lignes 74-81) par `<SimulationList engagementId={eng.id} />`. Conserver le reste.
|
||||
- `SimulationFormPage.tsx` (`/engagements/:eid/simulations/new` et `/engagements/:eid/simulations/:sid/edit`) :
|
||||
- Layout en deux cards : "Red Team" et "SOC".
|
||||
- Champs redteam : name, MitreTechniquePicker, description, commands (textarea, une commande par ligne, envoyé tel quel — pas de split), prerequisites, executed_at (datetime-local input), execution_result.
|
||||
- Champs SOC : log_source, logs, soc_comment, incident_number.
|
||||
- Boutons en footer : "Save", "Marquer en revue" (si AC-11.4), "Clôturer" (si AC-11.4), "Supprimer" (modal de confirmation, admin/redteam).
|
||||
- Mode création (`new`) : seul `name` requis ; après création, redirige sur `/engagements/:eid/simulations/:sid/edit`.
|
||||
|
||||
**Composants** (`frontend/src/components/`)
|
||||
- `SimulationList.tsx` : table tri par created_at desc, colonnes (Name, MITRE, Status badge, Executed at), bouton "Nouvelle" si admin/redteam, ligne cliquable → navigate edit page.
|
||||
- `SimulationStatusBadge.tsx` : variant du StatusBadge existant si possible (factoriser), 4 couleurs (pending=fog, in_progress=primary-soft, review_required=bloom-coral, done=storm-deep). Si le StatusBadge existant n'est pas factorisable proprement, créer un nouveau composant — pas d'over-engineering.
|
||||
- `MitreTechniquePicker.tsx` : input + dropdown, debounce 200ms (`useDebouncedValue` ou util inline), navigation clavier (↑/↓/Enter/Escape), affichage `T1059 — Command and Scripting Interpreter (initial-access)`. Loading state inline.
|
||||
- `ConfirmDialog.tsx` : modal générique de confirmation (utilisée pour delete).
|
||||
- **`SimulationFormPage.tsx`** : remplacer le `<MitreTechniquePicker>` standalone par un `<MitreTechniquesField simulationId={sim.id}>`. Le state `rt.techniques` disparait du form (les techniques ont leur propre cycle de save via le champ lui-même — auto-save). Le bouton "Save Red Team" continue de batcher tous les AUTRES champs (name, description, commands, etc.) mais ne touche pas aux techniques. Affichage read-only (rôle SOC) : afficher les tags sans `×`, boutons Add/Quick Search masqués (`disabled` prop).
|
||||
|
||||
**Routing** (`App.tsx`)
|
||||
- Ajouter `/engagements/:eid/simulations/new` (auth, admin|redteam)
|
||||
- Ajouter `/engagements/:eid/simulations/:sid/edit` (auth, all roles, RBAC champs interne)
|
||||
- **`SimulationList.tsx`** : colonne MITRE — afficher `techniques[0]?.id + (techniques.length > 1 ? ` +${techniques.length - 1}` : '')`. Si `techniques` est vide, afficher `—`.
|
||||
|
||||
**Tests Vitest** (`frontend/tests/`)
|
||||
- `SimulationList.test.tsx` : loading/error/empty + bouton "Nouvelle" gated par role.
|
||||
- `MitreTechniquePicker.test.tsx` : autocomplete debounce, sélection met à jour, navigation clavier.
|
||||
- `SimulationFormPage.test.tsx` : rôle redteam → tous champs éditables ; rôle soc → champs RT disabled, soc-side enabled si status review_required, bandeau si pending.
|
||||
- `SimulationStatusBadge.test.tsx` : 4 variants.
|
||||
**Tests Vitest**
|
||||
- `MitreTechniqueTag.test.tsx` — render id+name, click × appelle onRemove.
|
||||
- `MitreTechniquesField.test.tsx` — affiche tags, "Add technique" ouvre le modal matrix, "Quick search" ouvre le picker, dédup silencieuse, remove via × appelle onChange avec liste mise à jour.
|
||||
- `MitreMatrixModal.test.tsx` — render colonnes par tactique, click toggle sélection, Apply renvoie liste, Cancel jette, Escape ferme, search filtre.
|
||||
- Adapter `MitreTechniquePicker.test.tsx` (sprint 2) à la nouvelle signature `onSelect`.
|
||||
- Adapter `SimulationFormPage.test.tsx` (sprint 2) — assertions sur `techniques` array au lieu de scalaire.
|
||||
|
||||
**Quality bar** : typecheck + lint + vitest clean.
|
||||
|
||||
### Règles
|
||||
- Lit le summary du backend EN PREMIER (contrat API).
|
||||
- Pas d'invention d'endpoints. Mismatch → escalade au team-lead.
|
||||
- Réutiliser `LoadingState`, `ErrorState`, `EmptyState`, `Toast`, `FormField`, `StatusBadge` existants. NE PAS dupliquer.
|
||||
- Respect DESIGN.md (utiliser tokens Tailwind existants — pas de couleurs hardcodées).
|
||||
- Pas de CDN remote.
|
||||
- Lit le summary backend EN PREMIER.
|
||||
- Pas d'invention d'endpoints — `GET /api/mitre/matrix` est le seul nouveau, déjà spec'd.
|
||||
- Réutiliser `LoadingState`, `ErrorState`, `ConfirmDialog`, `useToast`, action bar pattern (sprint 2) existants.
|
||||
- Respect DESIGN.md tokens (palette + spacing). Tags = `bg-primary-soft text-primary-deep rounded-full px-md py-xxs gap-xxs text-[14px]`.
|
||||
- Pas de nouvelle dépendance npm sans escalade au team-lead.
|
||||
|
||||
---
|
||||
|
||||
## 4. Definition of Done — Sprint 2
|
||||
## 4. Brief — Test verifier
|
||||
|
||||
- [ ] Tous les critères AC-7 → AC-12 passent.
|
||||
- [ ] `pytest` (existing 63 + nouveaux ~25) tous verts. `ruff`, `mypy` clean.
|
||||
- [ ] `npm run typecheck`, `lint`, `test` clean frontend.
|
||||
- [ ] Playwright suite (existing 36 + nouveaux ~15) verte.
|
||||
- [ ] `make build` + `make start` + `make update-mitre` + workflow simulation complet manuel OK.
|
||||
- [ ] Code-reviewer (Opus) sans BLOCKER ouvert.
|
||||
- [ ] `SPEC.md` (section Simulation enrichie si besoin), `README.md` (mention `make update-mitre` + workflow), `CHANGELOG.md` à jour.
|
||||
- [ ] PR ouverte sur `sprint/2-simulations`, récap synthétique team-lead, validation utilisateur.
|
||||
E2e Playwright. Un fichier par US :
|
||||
- `us13-multi-techniques.spec.ts` — AC-13.1 → AC-13.5 (focus API + données)
|
||||
- `us14-techniques-tags.spec.ts` — AC-14.1 → AC-14.5 (UI tags + remove)
|
||||
- `us15-mitre-matrix-modal.spec.ts` — AC-15.1 → AC-15.5 (modal interaction + a11y)
|
||||
- `us16-regression-sprint2.spec.ts` — re-exécuter les ACs critiques sprint 2 (auto-transition US-8, workflow US-11, SOC restrictions US-9) avec le nouveau modèle.
|
||||
|
||||
Mettre à jour les e2e sprint 2 qui assertaient `mitre_technique_id` / `mitre_technique_name` scalaires (US-8, US-10 selon le grep).
|
||||
|
||||
---
|
||||
|
||||
## 5. Décisions arrêtées (utilisateur 2026-05-26)
|
||||
## 5. Definition of Done — Sprint 3
|
||||
|
||||
1. **Source MITRE** : `https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json` (default team-lead).
|
||||
2. **MITRE bundle dans le repo** : COMMITTÉ (`backend/data/mitre/enterprise-attack.json` versionné, `make build` autosuffisant).
|
||||
3. **Commands storage** : colonne `text` multiligne, une commande par ligne, transport tel quel.
|
||||
4. **Workflow auto-transition** pending→in_progress : déclenchée par toute PATCH admin/redteam touchant ≥1 champ redteam à valeur non vide (default team-lead).
|
||||
5. **Page simulation** : UNE page d'édition role-aware (`/engagements/:eid/simulations/:sid/edit`), pas de page détail séparée.
|
||||
6. **Suppression cascade** : delete engagement → delete simulations (default team-lead).
|
||||
7. **SOC restriction status** : soc ne peut PATCH que si status ∈ {review_required, done}.
|
||||
8. **Sous-techniques MITRE** : incluses dans l'autocomplete (T1059.001 visible) (default team-lead).
|
||||
- [ ] Tous les AC US-13 → US-16 passent.
|
||||
- [ ] Backend tests verts (`pytest -q`). Ruff + mypy clean.
|
||||
- [ ] Frontend tests verts (`npm run test -- --run`). Typecheck + lint clean.
|
||||
- [ ] E2e Playwright suite verte (sprint 1 + 2 + 3).
|
||||
- [ ] Migration Alembic testée upgrade + downgrade.
|
||||
- [ ] SPEC.md mis à jour (multi-techniques acté).
|
||||
- [ ] README.md mis à jour (mention matrice + multi-tech dans la description workflow).
|
||||
- [ ] CHANGELOG.md sprint 3 entry sous [Unreleased].
|
||||
- [ ] Code-reviewer sans BLOCKER.
|
||||
- [ ] **Frontend-builder a screenshot la matrice modale + la simulation form avec tags AVANT de marquer la tâche terminée (lesson learned sprint 2).**
|
||||
- [ ] PR ouverte + récap synthétique team-lead.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plan d'exécution (séquence)
|
||||
## 6. Décisions arrêtées (utilisateur 2026-05-27)
|
||||
|
||||
1. ✅ User a validé les 8 décisions §5 (2026-05-26).
|
||||
2. ✅ **Spec-reviewer** : APPROVED WITH NOTES (4 items mineurs corrigés avant dispatch).
|
||||
3. ✅ **Backend-builder** : commit `006c4c2` (67 nouveaux tests, 130 passing).
|
||||
4. ✅ **Frontend-builder** : commit `765bb5a` (41 nouveaux tests, 61 passing).
|
||||
5. ✅ **Code-reviewer** : 2 MAJOR + 4 MINOR + 3 NITs → 2 commits de fix (`83bf60f` backend, `c9032a9`+`cf0e8a8` frontend).
|
||||
6. ✅ **Test-verifier** : 32/32 sprint 2 verts, commits `da905cc` + `54e90f7` (AC-4.9 refresh).
|
||||
7. 🟡 **Team-lead** : récap + PR en cours.
|
||||
1. **Storage multi-tech** : colonne JSON `[{id, name}]` (KISS, pattern `commands` sprint 2).
|
||||
2. **Sub-techniques dans la matrice** : OUI, affichées avec expand/collapse par technique parent. Sub-techniques sont aussi accessibles via Quick Search en plus.
|
||||
3. **API shape** : `PATCH` reçoit `{technique_ids: ["T1059", "T1059.001", ...]}` — IDs uniquement (parents et subs au même niveau dans la liste). Backend résout names depuis le bundle.
|
||||
4. **Rétrocompat** : migration backfill `[{id, name}]` depuis les scalaires. Pas de rétrocompat API.
|
||||
5. **MitreTechniquePicker** : clean rewrite de la signature (`onSelect({id, name})`).
|
||||
6. **Matrix layout** : colonnes par tactique, 220px fixe, scroll horizontal global.
|
||||
7. **Apply de la modale matrice** : auto-save immédiat (PATCH déclenché par `MitreTechniquesField` quand le modal renvoie sa liste via `onApply`). Add/remove via tag × ou Quick Search aussi auto-save.
|
||||
8. **Sprint 4 framing** (anticipation, NE PAS implémenter dans sprint 3) : Dark mode (toggle + tokens dark + persistence) + Hygiène process UI (`design-reviewer` agent + screenshot mandatory dans brief frontend-builder). Connecteur C2 reporté au-delà. Les builders sprint 3 N'ajoutent PAS de tokens dark, N'invoquent PAS le design-reviewer (qui n'existe pas encore). Seule la lesson `screenshots mandatory` est déjà appliquée en sprint 3 dans le brief frontend (§3).
|
||||
|
||||
Branche unique : `sprint/2-simulations`.
|
||||
---
|
||||
|
||||
## 7. Plan d'exécution
|
||||
|
||||
1. ✅ User a validé les 8 décisions §6 (2026-05-27).
|
||||
2. ✅ Team-lead a mis à jour SPEC.md (§0).
|
||||
3. ✅ Spec-reviewer : APPROVED WITH NOTES après 2 passes (5 items au total, tous traités).
|
||||
4. ✅ Backend-builder : commits `b5ea292` + `673b25e` (model + migration + matrix endpoint + 503 unloaded, 162 passing).
|
||||
5. ✅ Frontend-builder : commit `771483f` (MitreTechniquesField + MitreMatrixModal + tags + auto-save + screenshots, 84 passing).
|
||||
6. ✅ Code-reviewer : APPROVED WITH NITS (2 MINORs + 4 NITs).
|
||||
7. ✅ Post-review fixes : `4596f09` + `393b6ed` backend (164 passing) + `39f4076` frontend (86 passing).
|
||||
8. ✅ Test-verifier : commit `df8a6b6` (105/106 sprint 3 e2e verts, 1 pré-existant sprint 1 — DB pollution, non-régression).
|
||||
9. 🟡 Team-lead : récap + PR en cours.
|
||||
4. 🔵 Backend-builder : modèle + migration + endpoints + tests.
|
||||
5. 🔵 Frontend-builder : composants + page update + tests Vitest. Screenshots obligatoires avant "done".
|
||||
6. 🔵 Code-reviewer : LSP-first.
|
||||
7. 🔵 Test-verifier : e2e US-13 → US-16 + adaptation sprint 2.
|
||||
8. 🟢 Team-lead : PR + récap.
|
||||
|
||||
Reference in New Issue
Block a user