Files
mimic/backend/app/services/simulation_workflow.py
Knacky 53755a31d6 feat(backend): c2 callbacks + execute endpoints (sprint 8 M2)
- Add C2Error exception to adapter ABC
- Add promote_to_in_progress() helper to simulation_workflow (pending→in_progress)
- Flesh out MythicAdapter: list_callbacks() (GraphQL query) + create_task() (mutation)
- Expand FakeAdapter to 3 deterministic callbacks; switch task store to per-instance
- Add GET /api/engagements/<id>/c2/callbacks — lists active callbacks via adapter
- Add POST /api/simulations/<id>/c2/execute — issues tasks, stores C2Task rows,
  auto-transitions pending→in_progress, blocks on done (409)
- Both endpoints: SOC=403, 503 no-key, 502 adapter error, sanitized error messages
- Add requests-mock==1.12.1 to requirements.txt
- 42 new tests (342 total, 300 M1 baseline preserved green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:38:07 +02:00

243 lines
8.8 KiB
Python

"""Simulation business logic: PATCH rules and state machine transitions."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from flask import jsonify
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/tactic_ids handled separately).
REDTEAM_FIELDS = frozenset(
{
"name",
"description",
"commands",
"prerequisites",
"executed_at",
"execution_result",
}
)
SOC_FIELDS = frozenset({"log_source", "logs", "soc_comment", "incident_number"})
_ALLOWED_TRANSITIONS: dict[str, dict[str, set[str]]] = {
"review_required": {
"from": {"pending", "in_progress"},
"roles": {"admin", "redteam"},
},
"done": {
"from": {"review_required"},
"roles": {"admin", "redteam", "soc"},
},
}
def _is_non_empty(value: Any) -> bool:
"""Return True if value counts as "filled" for auto-transition purposes."""
if value is None:
return False
if isinstance(value, str) and value == "":
return False
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)
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 _resolve_tactic_ids(
tactic_ids: list[str],
) -> tuple[list[str] | None, tuple[Any, int] | None]:
"""Validate and deduplicate tactic TA-ids.
Returns (deduped_list, None) on success or (None, error_tuple) on failure.
Bundle does not need to be loaded — validation is against the hardcoded _TACTIC_IDS map.
"""
from backend.app.services import mitre as mitre_svc
seen: dict[str, None] = dict.fromkeys(tactic_ids)
for tid in seen:
if mitre_svc.lookup_tactic(tid) is None:
return None, (jsonify({"error": f"unknown tactic id: {tid}"}), 400)
return list(seen), None
def _maybe_activate_engagement(simulation: Simulation) -> None:
"""If simulation's engagement is planned, advance it to active.
Caller must commit — do not commit here to avoid double-commit.
"""
from backend.app.models.engagement import Engagement, EngagementStatus
engagement: Engagement | None = getattr(simulation, "engagement", None)
if engagement is not None and engagement.status == EngagementStatus.PLANNED:
engagement.status = EngagementStatus.ACTIVE
db.session.add(engagement)
def promote_to_in_progress(simulation: Simulation) -> None:
"""Transition simulation pending → in_progress if it is currently pending.
Also advances the engagement planned → active via _maybe_activate_engagement.
No-op when the simulation is already in any other status.
Caller must commit.
"""
if simulation.status == SimulationStatus.PENDING:
simulation.status = SimulationStatus.IN_PROGRESS
simulation.updated_at = datetime.now(UTC)
_maybe_activate_engagement(simulation)
def apply_patch(
simulation: Simulation, payload: dict[str, Any], user: User
) -> tuple[Any, int] | None:
"""Apply a validated PATCH payload to a simulation.
Returns a (response, status_code) tuple on error, or None on success
(caller is responsible for committing).
"""
# Done guard — applies to ALL roles before any RBAC check.
if simulation.status == SimulationStatus.DONE:
return jsonify({"error": "simulation is done — reopen first"}), 409
role = user.role.value
if role == "soc":
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, technique_ids, or tactic_ids.
redteam_keys_in_payload = (
REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}
) & payload.keys()
if redteam_keys_in_payload:
return jsonify({"error": "soc cannot edit redteam fields"}), 403
for field in SOC_FIELDS:
if field in payload:
setattr(simulation, field, payload[field])
else:
# admin / redteam path.
redteam_keys_present = REDTEAM_FIELDS & payload.keys()
# Validate executed_at upfront before any writes.
executed_at_value: datetime | None = None
if "executed_at" in redteam_keys_present:
val = payload["executed_at"]
if val is not None:
if not isinstance(val, str):
return jsonify({"error": "invalid executed_at"}), 400
try:
executed_at_value = datetime.fromisoformat(val)
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
# Validate and deduplicate tactic_ids upfront.
resolved_tactic_ids: list[str] | None = None
if "tactic_ids" in payload:
raw_tids = payload["tactic_ids"]
if not isinstance(raw_tids, list):
return jsonify({"error": "tactic_ids must be a list"}), 400
resolved_tactic_ids, err = _resolve_tactic_ids(raw_tids)
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 resolved tactic_ids.
if resolved_tactic_ids is not None:
simulation.tactic_ids = resolved_tactic_ids
# 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.
# Triggers when any redteam scalar has a non-empty value, technique_ids or tactic_ids 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 not auto_trigger and "tactic_ids" in payload:
auto_trigger = len(payload["tactic_ids"]) > 0
if simulation.status == SimulationStatus.PENDING and auto_trigger:
simulation.status = SimulationStatus.IN_PROGRESS
_maybe_activate_engagement(simulation)
simulation.updated_at = datetime.now(UTC)
return None
def transition(
simulation: Simulation, to_status: str, user: User
) -> tuple[Any, int] | None:
"""Attempt a manual transition. Returns error tuple or None on success."""
# Special case: done → review_required (Reopen), allowed for all 3 roles.
if to_status == "review_required" and simulation.status == SimulationStatus.DONE:
simulation.status = SimulationStatus.REVIEW_REQUIRED
simulation.updated_at = datetime.now(UTC)
db.session.commit()
return None
rule = _ALLOWED_TRANSITIONS.get(to_status)
if rule is None:
return jsonify({"error": "invalid transition"}), 409
if simulation.status.value not in rule["from"]:
return jsonify({"error": "invalid transition"}), 409
if user.role.value not in rule["roles"]:
return jsonify({"error": "Forbidden"}), 403
simulation.status = SimulationStatus(to_status)
simulation.updated_at = datetime.now(UTC)
db.session.commit()
return None