feat(backend): sprint 2 — simulations + MITRE ATT&CK
- Simulation model with full field set (redteam + SOC sides) and cascade delete - Alembic migration 0002 for simulations table - simulation_workflow service: PATCH RBAC field-level + auto-transition pending→in_progress + state machine - mitre service: STIX bundle loader (boot-safe) + ranked search (exact-id > prefix-id > name) - 7 new API endpoints: list/create/get/patch/delete simulations, transition, MITRE autocomplete - serialize_simulation added to serializers.py - Makefile update-mitre target with real curl + optional docker restart - Dockerfile updated to copy backend/data/ into image - MITRE enterprise-attack.json bundle committed (~45 MB) - 67 new tests (total 130 passing), ruff clean, mypy introduces no new errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
100
backend/app/services/mitre.py
Normal file
100
backend/app/services/mitre.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""MITRE ATT&CK bundle loader and search service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
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"
|
||||
|
||||
mitre_loaded: bool = False
|
||||
_index: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def _extract_tactics(obj: dict[str, Any]) -> list[str]:
|
||||
phases = obj.get("kill_chain_phases") or []
|
||||
return [
|
||||
p["phase_name"]
|
||||
for p in phases
|
||||
if isinstance(p, dict) and "phase_name" in p
|
||||
]
|
||||
|
||||
|
||||
def _get_external_id(obj: dict[str, Any]) -> str | None:
|
||||
for ref in obj.get("external_references") or []:
|
||||
if isinstance(ref, dict) and ref.get("source_name") == "mitre-attack":
|
||||
return ref.get("external_id")
|
||||
return None
|
||||
|
||||
|
||||
def load_bundle(path: Path | None = None) -> None:
|
||||
"""Load the MITRE bundle into memory. Called once at app boot."""
|
||||
global mitre_loaded, _index
|
||||
bundle_path = path or _BUNDLE_PATH
|
||||
|
||||
try:
|
||||
raw = bundle_path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
except FileNotFoundError:
|
||||
logger.warning("MITRE bundle not found at %s — autocomplete disabled", bundle_path)
|
||||
mitre_loaded = False
|
||||
return
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("MITRE bundle parse error: %s — autocomplete disabled", exc)
|
||||
mitre_loaded = False
|
||||
return
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for obj in data.get("objects") or []:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
if obj.get("type") != "attack-pattern":
|
||||
continue
|
||||
if obj.get("revoked") or obj.get("x_mitre_deprecated"):
|
||||
continue
|
||||
ext_id = _get_external_id(obj)
|
||||
if not ext_id:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"id": ext_id,
|
||||
"name": obj.get("name", ""),
|
||||
"tactics": _extract_tactics(obj),
|
||||
}
|
||||
)
|
||||
|
||||
_index = entries
|
||||
mitre_loaded = True
|
||||
logger.info("MITRE bundle loaded: %d techniques", len(_index))
|
||||
|
||||
|
||||
def search(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Return up to `limit` techniques matching `query`.
|
||||
|
||||
Ranking: exact id > prefix id > substring name (case-insensitive).
|
||||
"""
|
||||
q = query.strip().upper()
|
||||
if not q:
|
||||
return []
|
||||
|
||||
exact: list[dict[str, Any]] = []
|
||||
prefix: list[dict[str, Any]] = []
|
||||
name_match: list[dict[str, Any]] = []
|
||||
|
||||
for entry in _index:
|
||||
tech_id = entry["id"].upper()
|
||||
tech_name = entry["name"].upper()
|
||||
|
||||
if tech_id == q:
|
||||
exact.append(entry)
|
||||
elif tech_id.startswith(q):
|
||||
prefix.append(entry)
|
||||
elif q in tech_name:
|
||||
name_match.append(entry)
|
||||
|
||||
combined = exact + prefix + name_match
|
||||
return combined[:limit]
|
||||
129
backend/app/services/simulation_workflow.py
Normal file
129
backend/app/services/simulation_workflow.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""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
|
||||
|
||||
REDTEAM_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"mitre_technique_id",
|
||||
"mitre_technique_name",
|
||||
"description",
|
||||
"commands",
|
||||
"prerequisites",
|
||||
"executed_at",
|
||||
"execution_result",
|
||||
}
|
||||
)
|
||||
|
||||
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"},
|
||||
"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 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).
|
||||
"""
|
||||
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()
|
||||
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: apply all fields present.
|
||||
redteam_keys_present = REDTEAM_FIELDS & payload.keys()
|
||||
|
||||
for field in redteam_keys_present:
|
||||
if field == "executed_at":
|
||||
val = payload["executed_at"]
|
||||
if val is None:
|
||||
simulation.executed_at = None
|
||||
else:
|
||||
if not isinstance(val, str):
|
||||
return jsonify({"error": "invalid executed_at"}), 400
|
||||
try:
|
||||
simulation.executed_at = datetime.fromisoformat(val)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid executed_at"}), 400
|
||||
else:
|
||||
setattr(simulation, field, payload[field])
|
||||
|
||||
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
|
||||
):
|
||||
simulation.status = SimulationStatus.IN_PROGRESS
|
||||
|
||||
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."""
|
||||
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
|
||||
Reference in New Issue
Block a user