feat(backend): sprint 3 — multi-technique simulations + MITRE matrix

- Simulation model: replace mitre_technique_id/name scalars with techniques JSON column [{id, name}]
- Alembic migration 0003: add techniques, backfill from scalars, drop old columns (reversible)
- MITRE service: add get_tactics(), lookup_name(), get_matrix() with canonical tactic order and sub-technique nesting
- serializer: enrich techniques with tactics from service at serialize time (graceful empty tactics if bundle outdated)
- simulation_workflow: PATCH now accepts technique_ids list, validates against bundle, deduplicates preserving order, auto-transitions on non-empty list
- simulations API: add GET /api/mitre/matrix endpoint (503 if bundle absent)
- test_mitre.py: updated _reset_mitre fixture, added T1059.006 sub-technique, 14 new tests for get_tactics/lookup_name/get_matrix/matrix endpoint
- test_simulations_techniques.py: 20 new tests covering AC-13.1 to AC-13.5 (create, PATCH, dedup, auto-transition, SOC blocked, migration backfill logic)

Total: 161 tests passing. ruff clean. mypy: no new errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-27 03:56:02 +02:00
parent e1d9738f23
commit b5ea2929de
8 changed files with 737 additions and 30 deletions

View File

@@ -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,27 @@ 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
# 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 +77,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 +93,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 +108,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)