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:
10
Makefile
10
Makefile
@@ -32,8 +32,16 @@ ifndef PASS
|
||||
endif
|
||||
docker exec $(CONTAINER) flask create-admin $(USER) $(PASS)
|
||||
|
||||
MITRE_URL ?= https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json
|
||||
|
||||
update-mitre:
|
||||
@echo "MITRE update: Sprint 2+"
|
||||
@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
|
||||
|
||||
test-backend:
|
||||
docker exec $(CONTAINER) pytest -q backend/tests/
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
|
||||
from backend.app.api import auth_bp, engagements_bp, users_bp
|
||||
from backend.app.api import auth_bp, engagements_bp, simulations_bp, users_bp
|
||||
from backend.app.cli import register_cli
|
||||
from backend.app.config import Config, TestConfig
|
||||
from backend.app.errors import register_error_handlers
|
||||
@@ -36,6 +36,10 @@ def create_app(config_object: object | None = None) -> Flask:
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(engagements_bp)
|
||||
app.register_blueprint(simulations_bp)
|
||||
|
||||
from backend.app.services import mitre as mitre_svc
|
||||
mitre_svc.load_bundle()
|
||||
|
||||
register_error_handlers(app)
|
||||
register_cli(app)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""API blueprints."""
|
||||
from backend.app.api.auth import auth_bp
|
||||
from backend.app.api.engagements import engagements_bp
|
||||
from backend.app.api.simulations import simulations_bp
|
||||
from backend.app.api.users import users_bp
|
||||
|
||||
__all__ = ["auth_bp", "users_bp", "engagements_bp"]
|
||||
__all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp"]
|
||||
|
||||
141
backend/app/api/simulations.py
Normal file
141
backend/app/api/simulations.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Simulation CRUD + workflow endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from backend.app.auth import login_required, role_required
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import Engagement
|
||||
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||
from backend.app.serializers import serialize_simulation
|
||||
from backend.app.services import simulation_workflow
|
||||
|
||||
simulations_bp = Blueprint("simulations", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nested under /api/engagements/<eid>/simulations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@simulations_bp.get("/api/engagements/<int:eid>/simulations")
|
||||
@login_required
|
||||
def list_simulations(eid: int):
|
||||
engagement = db.session.get(Engagement, eid)
|
||||
if engagement is None:
|
||||
return jsonify({"error": "Engagement not found"}), 404
|
||||
sims = (
|
||||
Simulation.query.filter_by(engagement_id=eid)
|
||||
.order_by(Simulation.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return jsonify([serialize_simulation(s) for s in sims]), 200
|
||||
|
||||
|
||||
@simulations_bp.post("/api/engagements/<int:eid>/simulations")
|
||||
@role_required("admin", "redteam")
|
||||
def create_simulation(eid: int):
|
||||
engagement = db.session.get(Engagement, eid)
|
||||
if engagement is None:
|
||||
return jsonify({"error": "Engagement not found"}), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
|
||||
sim = Simulation(
|
||||
engagement_id=eid,
|
||||
name=name,
|
||||
status=SimulationStatus.PENDING,
|
||||
created_at=datetime.now(UTC),
|
||||
created_by_id=g.current_user.id,
|
||||
)
|
||||
db.session.add(sim)
|
||||
db.session.commit()
|
||||
return jsonify(serialize_simulation(sim)), 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flat /api/simulations/<sid>
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@simulations_bp.get("/api/simulations/<int:sid>")
|
||||
@login_required
|
||||
def get_simulation(sid: int):
|
||||
sim = db.session.get(Simulation, sid)
|
||||
if sim is None:
|
||||
return jsonify({"error": "Simulation not found"}), 404
|
||||
return jsonify(serialize_simulation(sim)), 200
|
||||
|
||||
|
||||
@simulations_bp.patch("/api/simulations/<int:sid>")
|
||||
@login_required
|
||||
def update_simulation(sid: int):
|
||||
sim = db.session.get(Simulation, sid)
|
||||
if sim is None:
|
||||
return jsonify({"error": "Simulation not found"}), 404
|
||||
|
||||
user = g.current_user
|
||||
if user.role.value not in ("admin", "redteam", "soc"):
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not data:
|
||||
return jsonify(serialize_simulation(sim)), 200
|
||||
|
||||
err = simulation_workflow.apply_patch(sim, data, user)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(serialize_simulation(sim)), 200
|
||||
|
||||
|
||||
@simulations_bp.delete("/api/simulations/<int:sid>")
|
||||
@role_required("admin", "redteam")
|
||||
def delete_simulation(sid: int):
|
||||
sim = db.session.get(Simulation, sid)
|
||||
if sim is None:
|
||||
return jsonify({"error": "Simulation not found"}), 404
|
||||
db.session.delete(sim)
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
@simulations_bp.post("/api/simulations/<int:sid>/transition")
|
||||
@login_required
|
||||
def transition_simulation(sid: int):
|
||||
sim = db.session.get(Simulation, sid)
|
||||
if sim is None:
|
||||
return jsonify({"error": "Simulation not found"}), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
to_status = data.get("to", "")
|
||||
|
||||
err = simulation_workflow.transition(sim, to_status, g.current_user)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
return jsonify(serialize_simulation(sim)), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MITRE autocomplete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@simulations_bp.get("/api/mitre/techniques")
|
||||
@login_required
|
||||
def mitre_techniques():
|
||||
from backend.app.services import mitre as mitre_svc
|
||||
|
||||
if not mitre_svc.mitre_loaded:
|
||||
return jsonify({"error": "mitre bundle not loaded"}), 503
|
||||
|
||||
q = request.args.get("q", "").strip()
|
||||
results = mitre_svc.search(q)
|
||||
return jsonify(results), 200
|
||||
@@ -1,5 +1,6 @@
|
||||
"""SQLAlchemy models."""
|
||||
from backend.app.models.engagement import Engagement, EngagementStatus
|
||||
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||
from backend.app.models.user import User, UserRole
|
||||
|
||||
__all__ = ["User", "UserRole", "Engagement", "EngagementStatus"]
|
||||
__all__ = ["User", "UserRole", "Engagement", "EngagementStatus", "Simulation", "SimulationStatus"]
|
||||
|
||||
62
backend/app/models/simulation.py
Normal file
62
backend/app/models/simulation.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Simulation model."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from backend.app.extensions import db
|
||||
|
||||
|
||||
class SimulationStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
REVIEW_REQUIRED = "review_required"
|
||||
DONE = "done"
|
||||
|
||||
|
||||
class Simulation(db.Model): # type: ignore[name-defined]
|
||||
__tablename__ = "simulations"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
engagement_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("engagements.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
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)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
commands = db.Column(db.Text, nullable=True)
|
||||
prerequisites = db.Column(db.Text, nullable=True)
|
||||
executed_at = db.Column(db.DateTime, nullable=True)
|
||||
execution_result = db.Column(db.Text, nullable=True)
|
||||
log_source = db.Column(db.Text, nullable=True)
|
||||
logs = db.Column(db.Text, nullable=True)
|
||||
soc_comment = db.Column(db.Text, nullable=True)
|
||||
incident_number = db.Column(db.String(128), nullable=True)
|
||||
status = db.Column(
|
||||
db.Enum(SimulationStatus, name="simulation_status"),
|
||||
nullable=False,
|
||||
default=SimulationStatus.PENDING,
|
||||
)
|
||||
created_at = db.Column(
|
||||
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at = db.Column(db.DateTime, nullable=True)
|
||||
created_by_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
engagement = db.relationship(
|
||||
"Engagement",
|
||||
backref=db.backref("simulations", cascade="all, delete-orphan", lazy="dynamic"),
|
||||
)
|
||||
created_by = db.relationship("User", backref="simulations", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Simulation {self.id} {self.name!r}>"
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from backend.app.models import Engagement, User
|
||||
from backend.app.models.simulation import Simulation
|
||||
|
||||
|
||||
def serialize_user(user: User) -> dict[str, Any]:
|
||||
@@ -19,6 +20,31 @@ def serialize_user_brief(user: User) -> dict[str, Any]:
|
||||
return {"id": user.id, "username": user.username}
|
||||
|
||||
|
||||
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,
|
||||
"description": simulation.description,
|
||||
"commands": simulation.commands,
|
||||
"prerequisites": simulation.prerequisites,
|
||||
"executed_at": simulation.executed_at.isoformat() if simulation.executed_at else None,
|
||||
"execution_result": simulation.execution_result,
|
||||
"log_source": simulation.log_source,
|
||||
"logs": simulation.logs,
|
||||
"soc_comment": simulation.soc_comment,
|
||||
"incident_number": simulation.incident_number,
|
||||
"status": simulation.status.value,
|
||||
"created_at": simulation.created_at.isoformat() if simulation.created_at else None,
|
||||
"updated_at": simulation.updated_at.isoformat() if simulation.updated_at else None,
|
||||
"created_by": serialize_user_brief(simulation.created_by) # type: ignore[arg-type]
|
||||
if simulation.created_by
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_engagement(engagement: Engagement) -> dict[str, Any]:
|
||||
return {
|
||||
"id": engagement.id,
|
||||
|
||||
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
|
||||
795203
backend/data/mitre/enterprise-attack.json
Normal file
795203
backend/data/mitre/enterprise-attack.json
Normal file
File diff suppressed because it is too large
Load Diff
59
backend/migrations/versions/0002_add_simulations.py
Normal file
59
backend/migrations/versions/0002_add_simulations.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""add simulations table
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-05-26 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"simulations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("engagement_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("mitre_technique_id", sa.String(length=32), nullable=True),
|
||||
sa.Column("mitre_technique_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("commands", sa.Text(), nullable=True),
|
||||
sa.Column("prerequisites", sa.Text(), nullable=True),
|
||||
sa.Column("executed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("execution_result", sa.Text(), nullable=True),
|
||||
sa.Column("log_source", sa.Text(), nullable=True),
|
||||
sa.Column("logs", sa.Text(), nullable=True),
|
||||
sa.Column("soc_comment", sa.Text(), nullable=True),
|
||||
sa.Column("incident_number", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum("pending", "in_progress", "review_required", "done", name="simulation_status"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["engagement_id"], ["engagements.id"], ondelete="CASCADE",
|
||||
name="fk_simulations_engagement_id_engagements",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_id"], ["users.id"], ondelete="RESTRICT",
|
||||
name="fk_simulations_created_by_id_users",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_simulations_engagement_id", "simulations", ["engagement_id"])
|
||||
op.create_index("ix_simulations_created_by_id", "simulations", ["created_by_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_simulations_created_by_id", table_name="simulations")
|
||||
op.drop_index("ix_simulations_engagement_id", table_name="simulations")
|
||||
op.drop_table("simulations")
|
||||
sa.Enum(name="simulation_status").drop(op.get_bind(), checkfirst=True)
|
||||
247
backend/tests/test_mitre.py
Normal file
247
backend/tests/test_mitre.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""MITRE service and endpoint tests. Uses a tiny fixture bundle, not the 40 MB file."""
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture STIX bundle (minimal, 4 techniques including one sub-technique)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_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": "Phishing",
|
||||
"external_references": [
|
||||
{"source_name": "mitre-attack", "external_id": "T1566"}
|
||||
],
|
||||
"kill_chain_phases": [{"phase_name": "initial-access", "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"},
|
||||
],
|
||||
},
|
||||
{
|
||||
# Revoked — must be excluded from index.
|
||||
"type": "attack-pattern",
|
||||
"name": "Old Technique",
|
||||
"revoked": True,
|
||||
"external_references": [
|
||||
{"source_name": "mitre-attack", "external_id": "T9999"}
|
||||
],
|
||||
"kill_chain_phases": [],
|
||||
},
|
||||
{
|
||||
# Not an attack-pattern — must be ignored.
|
||||
"type": "relationship",
|
||||
"name": "Ignored",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_mitre():
|
||||
"""Reset the MITRE service state between tests."""
|
||||
original_loaded = mitre_svc.mitre_loaded
|
||||
original_index = list(mitre_svc._index)
|
||||
yield
|
||||
mitre_svc.mitre_loaded = original_loaded
|
||||
mitre_svc._index = original_index
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for load_bundle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_load_bundle_missing_file() -> None:
|
||||
mitre_svc.load_bundle(pathlib.Path("/nonexistent/path.json"))
|
||||
assert mitre_svc.mitre_loaded is False
|
||||
|
||||
|
||||
def test_load_bundle_invalid_json(tmp_path: pathlib.Path) -> None:
|
||||
bad = tmp_path / "bad.json"
|
||||
bad.write_text("{ not json }", encoding="utf-8")
|
||||
mitre_svc.load_bundle(bad)
|
||||
assert mitre_svc.mitre_loaded is False
|
||||
|
||||
|
||||
def test_load_bundle_excludes_revoked(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
ids = [e["id"] for e in mitre_svc._index]
|
||||
assert "T9999" not in ids
|
||||
|
||||
|
||||
def test_load_bundle_includes_subtechniques(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
ids = [e["id"] for e in mitre_svc._index]
|
||||
assert "T1059.001" in ids
|
||||
|
||||
|
||||
def test_load_bundle_extracts_tactics(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
t1078 = next(e for e in mitre_svc._index if e["id"] == "T1078")
|
||||
assert "initial-access" in t1078["tactics"]
|
||||
assert "persistence" in t1078["tactics"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_search_exact_id_first(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
results = mitre_svc.search("T1059")
|
||||
assert results[0]["id"] == "T1059"
|
||||
|
||||
|
||||
def test_search_prefix_id(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
results = mitre_svc.search("T105")
|
||||
ids = [r["id"] for r in results]
|
||||
assert "T1059" in ids
|
||||
assert "T1059.001" in ids
|
||||
|
||||
|
||||
def test_search_name_substring(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
results = mitre_svc.search("phish")
|
||||
assert any(r["id"] == "T1566" for r in results)
|
||||
|
||||
|
||||
def test_search_case_insensitive(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
results = mitre_svc.search("POWERSHELL")
|
||||
assert any(r["id"] == "T1059.001" for r in results)
|
||||
|
||||
|
||||
def test_search_limit(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
results = mitre_svc.search("T", limit=2)
|
||||
assert len(results) <= 2
|
||||
|
||||
|
||||
def test_search_empty_query(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
assert mitre_svc.search("") == []
|
||||
|
||||
|
||||
def test_search_ranking_order(bundle_file: pathlib.Path) -> None:
|
||||
"""exact-id > prefix-id > name match."""
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
results = mitre_svc.search("T1059")
|
||||
# T1059 must come before T1059.001 (prefix match)
|
||||
ids = [r["id"] for r in results]
|
||||
assert ids.index("T1059") < ids.index("T1059.001")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mitre_endpoint_503_when_not_loaded(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
mitre_svc.mitre_loaded = False
|
||||
mitre_svc._index = []
|
||||
resp = client.get("/api/mitre/techniques?q=T1059", headers=_h(redteam_token))
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["error"] == "mitre bundle not loaded"
|
||||
|
||||
|
||||
def test_mitre_endpoint_returns_results(
|
||||
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
|
||||
) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
resp = client.get("/api/mitre/techniques?q=T1059", headers=_h(redteam_token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert isinstance(data, list)
|
||||
assert any(r["id"] == "T1059" for r in data)
|
||||
|
||||
|
||||
def test_mitre_endpoint_requires_auth(client: FlaskClient) -> None:
|
||||
resp = client.get("/api/mitre/techniques?q=T1059")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_mitre_endpoint_all_roles_can_access(
|
||||
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/techniques?q=T1059", headers=_h(token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_mitre_endpoint_max_20_results(
|
||||
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
|
||||
) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
resp = client.get("/api/mitre/techniques?q=T", headers=_h(redteam_token))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.get_json()) <= 20
|
||||
|
||||
|
||||
def test_mitre_endpoint_includes_tactics(
|
||||
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
|
||||
) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
resp = client.get("/api/mitre/techniques?q=T1566", headers=_h(redteam_token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data) >= 1
|
||||
phishing = next((r for r in data if r["id"] == "T1566"), None)
|
||||
assert phishing is not None
|
||||
assert "initial-access" in phishing["tactics"]
|
||||
236
backend/tests/test_simulations_crud.py
Normal file
236
backend/tests/test_simulations_crud.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Simulation CRUD tests: create, list, get, delete, cascade."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.app.extensions import db
|
||||
from backend.app.models import User
|
||||
from backend.app.models.simulation import Simulation
|
||||
from backend.tests.conftest import auth_headers as _h
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(token),
|
||||
json={"name": "Op Alpha", "start_date": "2026-06-01"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def _make_sim(client: FlaskClient, token: str, eid: int, **kw) -> dict:
|
||||
payload = {"name": "Sim 1", **kw}
|
||||
resp = client.post(
|
||||
f"/api/engagements/{eid}/simulations", headers=_h(token), json=payload
|
||||
)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_simulation_as_redteam(
|
||||
client: FlaskClient, redteam_user: User, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
body = _make_sim(client, redteam_token, eng["id"])
|
||||
assert body["name"] == "Sim 1"
|
||||
assert body["status"] == "pending"
|
||||
assert body["engagement_id"] == eng["id"]
|
||||
assert body["created_by"] == {"id": redteam_user.id, "username": "redteam1"}
|
||||
|
||||
|
||||
def test_create_simulation_as_admin(
|
||||
client: FlaskClient, admin_user: User, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
body = _make_sim(client, admin_token, eng["id"])
|
||||
assert body["created_by"]["username"] == "admin1"
|
||||
|
||||
|
||||
def test_create_simulation_soc_forbidden(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
resp = client.post(
|
||||
f"/api/engagements/{eng['id']}/simulations",
|
||||
headers=_h(soc_token),
|
||||
json={"name": "x"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_create_simulation_unauth(client: FlaskClient, redteam_token: str) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
resp = client.post(
|
||||
f"/api/engagements/{eng['id']}/simulations", json={"name": "x"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_create_simulation_missing_name(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
resp = client.post(
|
||||
f"/api/engagements/{eng['id']}/simulations",
|
||||
headers=_h(redteam_token),
|
||||
json={"name": ""},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_create_simulation_engagement_not_found(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
"/api/engagements/9999/simulations",
|
||||
headers=_h(redteam_token),
|
||||
json={"name": "x"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_simulations_empty(client: FlaskClient, redteam_token: str) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
resp = client.get(
|
||||
f"/api/engagements/{eng['id']}/simulations", headers=_h(redteam_token)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == []
|
||||
|
||||
|
||||
def test_list_simulations_ordered_desc(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
_make_sim(client, redteam_token, eng["id"], name="First")
|
||||
_make_sim(client, redteam_token, eng["id"], name="Second")
|
||||
resp = client.get(
|
||||
f"/api/engagements/{eng['id']}/simulations", headers=_h(redteam_token)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
items = resp.get_json()
|
||||
assert len(items) == 2
|
||||
# Most recent first
|
||||
assert items[0]["name"] == "Second"
|
||||
assert items[1]["name"] == "First"
|
||||
|
||||
|
||||
def test_list_simulations_soc_can_read(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
_make_sim(client, redteam_token, eng["id"])
|
||||
resp = client.get(
|
||||
f"/api/engagements/{eng['id']}/simulations", headers=_h(soc_token)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.get_json()) == 1
|
||||
|
||||
|
||||
def test_list_simulations_engagement_not_found(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
resp = client.get(
|
||||
"/api/engagements/9999/simulations", headers=_h(redteam_token)
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Get
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_simulation_ok(client: FlaskClient, redteam_token: str) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["id"] == sim["id"]
|
||||
|
||||
|
||||
def test_get_simulation_404(client: FlaskClient, redteam_token: str) -> None:
|
||||
resp = client.get("/api/simulations/9999", headers=_h(redteam_token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_simulation_soc_can_read(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(soc_token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_simulation_redteam(client: FlaskClient, redteam_token: str) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
resp = client.delete(
|
||||
f"/api/simulations/{sim['id']}", headers=_h(redteam_token)
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_delete_simulation_admin(
|
||||
client: FlaskClient, redteam_token: str, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
resp = client.delete(f"/api/simulations/{sim['id']}", headers=_h(admin_token))
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_delete_simulation_soc_forbidden(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
resp = client.delete(f"/api/simulations/{sim['id']}", headers=_h(soc_token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_delete_simulation_404(client: FlaskClient, redteam_token: str) -> None:
|
||||
resp = client.delete("/api/simulations/9999", headers=_h(redteam_token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cascade_delete_engagement_removes_simulations(
|
||||
app, client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
sim_id = sim["id"]
|
||||
|
||||
resp = client.delete(
|
||||
f"/api/engagements/{eng['id']}", headers=_h(redteam_token)
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
with app.app_context():
|
||||
assert db.session.get(Simulation, sim_id) is None
|
||||
272
backend/tests/test_simulations_patch.py
Normal file
272
backend/tests/test_simulations_patch.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""Simulation PATCH tests: auto-transition, RBAC field-level, SOC restrictions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.tests.conftest import auth_headers as _h
|
||||
|
||||
|
||||
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(token),
|
||||
json={"name": "Op Beta", "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": "Test Sim"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def _patch(client: FlaskClient, token: str, sid: int, payload: dict) -> dict:
|
||||
resp = client.patch(
|
||||
f"/api/simulations/{sid}", headers=_h(token), json=payload
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-transition pending → in_progress (AC-8.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patch_triggers_auto_transition(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> 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"], {"description": "some desc"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "in_progress"
|
||||
|
||||
|
||||
def test_patch_name_triggers_auto_transition(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _patch(client, redteam_token, sim["id"], {"name": "Updated name"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "in_progress"
|
||||
|
||||
|
||||
def test_patch_commands_triggers_auto_transition(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _patch(client, redteam_token, sim["id"], {"commands": "cmd1\ncmd2"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "in_progress"
|
||||
|
||||
|
||||
def test_patch_null_value_does_not_trigger_auto_transition(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _patch(client, redteam_token, sim["id"], {"description": None})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "pending"
|
||||
|
||||
|
||||
def test_patch_empty_string_does_not_trigger_auto_transition(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _patch(client, redteam_token, sim["id"], {"description": ""})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "pending"
|
||||
|
||||
|
||||
def test_patch_admin_triggers_auto_transition(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
sim = _make_sim(client, admin_token, eng["id"])
|
||||
|
||||
resp = _patch(client, admin_token, sim["id"], {"execution_result": "success"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "in_progress"
|
||||
|
||||
|
||||
def test_patch_soc_does_not_trigger_auto_transition(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
"""SOC patch on review_required must not trigger auto-transition."""
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
# Manually advance to review_required.
|
||||
client.post(
|
||||
f"/api/simulations/{sim['id']}/transition",
|
||||
headers=_h(redteam_token),
|
||||
json={"to": "review_required"},
|
||||
)
|
||||
|
||||
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "looks good"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "review_required"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field updates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patch_updates_commands_as_text(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
commands = "whoami\nnet user\nipconfig"
|
||||
|
||||
resp = _patch(client, redteam_token, sim["id"], {"commands": commands})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["commands"] == commands
|
||||
|
||||
|
||||
def test_patch_updates_executed_at(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _patch(
|
||||
client, redteam_token, sim["id"], {"executed_at": "2026-06-01T12:00:00"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "2026-06-01" in resp.get_json()["executed_at"]
|
||||
|
||||
|
||||
def test_patch_invalid_executed_at(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _patch(
|
||||
client, redteam_token, sim["id"], {"executed_at": "not-a-date"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["error"] == "invalid executed_at"
|
||||
|
||||
|
||||
def test_patch_clear_executed_at(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
_patch(client, redteam_token, sim["id"], {"executed_at": "2026-06-01T12:00:00"})
|
||||
|
||||
resp = _patch(client, redteam_token, sim["id"], {"executed_at": None})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["executed_at"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SOC RBAC field-level (AC-9.1, AC-9.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_soc_cannot_patch_before_review_required(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
assert sim["status"] == "pending"
|
||||
|
||||
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "hello"})
|
||||
assert resp.status_code == 403
|
||||
assert "not ready" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_soc_cannot_patch_in_progress(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
_patch(client, redteam_token, sim["id"], {"description": "in progress now"})
|
||||
|
||||
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "hello"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_soc_can_patch_when_review_required(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
client.post(
|
||||
f"/api/simulations/{sim['id']}/transition",
|
||||
headers=_h(redteam_token),
|
||||
json={"to": "review_required"},
|
||||
)
|
||||
|
||||
resp = _patch(
|
||||
client,
|
||||
soc_token,
|
||||
sim["id"],
|
||||
{"soc_comment": "Detected", "log_source": "SIEM", "incident_number": "INC-001"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["soc_comment"] == "Detected"
|
||||
assert body["log_source"] == "SIEM"
|
||||
assert body["incident_number"] == "INC-001"
|
||||
|
||||
|
||||
def test_soc_can_patch_when_done(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
client.post(
|
||||
f"/api/simulations/{sim['id']}/transition",
|
||||
headers=_h(redteam_token),
|
||||
json={"to": "review_required"},
|
||||
)
|
||||
client.post(
|
||||
f"/api/simulations/{sim['id']}/transition",
|
||||
headers=_h(soc_token),
|
||||
json={"to": "done"},
|
||||
)
|
||||
|
||||
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "Final note"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_soc_cannot_edit_redteam_fields(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
client.post(
|
||||
f"/api/simulations/{sim['id']}/transition",
|
||||
headers=_h(redteam_token),
|
||||
json={"to": "review_required"},
|
||||
)
|
||||
|
||||
resp = _patch(client, soc_token, sim["id"], {"description": "redteam field"})
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()["error"] == "soc cannot edit redteam fields"
|
||||
|
||||
|
||||
def test_patch_simulation_404(client: FlaskClient, redteam_token: str) -> None:
|
||||
resp = _patch(client, redteam_token, 9999, {"name": "x"})
|
||||
assert resp.status_code == 404
|
||||
192
backend/tests/test_simulations_workflow.py
Normal file
192
backend/tests/test_simulations_workflow.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Simulation workflow / state machine tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.tests.conftest import auth_headers as _h
|
||||
|
||||
|
||||
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||
resp = client.post(
|
||||
"/api/engagements",
|
||||
headers=_h(token),
|
||||
json={"name": "Op Gamma", "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": "Workflow Sim"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def _transition(client: FlaskClient, token: str, sid: int, to: str):
|
||||
return client.post(
|
||||
f"/api/simulations/{sid}/transition",
|
||||
headers=_h(token),
|
||||
json={"to": to},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valid transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_transition_to_review_required_from_pending(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
assert sim["status"] == "pending"
|
||||
|
||||
resp = _transition(client, redteam_token, sim["id"], "review_required")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "review_required"
|
||||
|
||||
|
||||
def test_transition_to_review_required_from_in_progress(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
# Auto-advance to in_progress
|
||||
client.patch(
|
||||
f"/api/simulations/{sim['id']}",
|
||||
headers=_h(redteam_token),
|
||||
json={"description": "started"},
|
||||
)
|
||||
|
||||
resp = _transition(client, redteam_token, sim["id"], "review_required")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "review_required"
|
||||
|
||||
|
||||
def test_transition_to_done_by_redteam(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
_transition(client, redteam_token, sim["id"], "review_required")
|
||||
|
||||
resp = _transition(client, redteam_token, sim["id"], "done")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "done"
|
||||
|
||||
|
||||
def test_transition_to_done_by_soc(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
_transition(client, redteam_token, sim["id"], "review_required")
|
||||
|
||||
resp = _transition(client, soc_token, sim["id"], "done")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "done"
|
||||
|
||||
|
||||
def test_transition_to_done_by_admin(
|
||||
client: FlaskClient, admin_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, admin_token)
|
||||
sim = _make_sim(client, admin_token, eng["id"])
|
||||
_transition(client, admin_token, sim["id"], "review_required")
|
||||
|
||||
resp = _transition(client, admin_token, sim["id"], "done")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["status"] == "done"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invalid transitions (AC-11.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_transition_done_from_pending_rejected(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _transition(client, redteam_token, sim["id"], "done")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_transition_to_pending_rejected(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
_transition(client, redteam_token, sim["id"], "review_required")
|
||||
|
||||
resp = _transition(client, redteam_token, sim["id"], "pending")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_transition_to_in_progress_rejected(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _transition(client, redteam_token, sim["id"], "in_progress")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_transition_unknown_status_rejected(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _transition(client, redteam_token, sim["id"], "nonexistent")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_transition_review_required_from_done_rejected(
|
||||
client: FlaskClient, redteam_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
_transition(client, redteam_token, sim["id"], "review_required")
|
||||
_transition(client, redteam_token, sim["id"], "done")
|
||||
|
||||
resp = _transition(client, redteam_token, sim["id"], "review_required")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RBAC by role
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_soc_cannot_transition_to_review_required(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _transition(client, soc_token, sim["id"], "review_required")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_soc_cannot_transition_to_done_from_pending(
|
||||
client: FlaskClient, redteam_token: str, soc_token: str
|
||||
) -> None:
|
||||
eng = _make_engagement(client, redteam_token)
|
||||
sim = _make_sim(client, redteam_token, eng["id"])
|
||||
|
||||
resp = _transition(client, soc_token, sim["id"], "done")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_transition_simulation_404(client: FlaskClient, redteam_token: str) -> None:
|
||||
resp = _transition(client, redteam_token, 9999, "review_required")
|
||||
assert resp.status_code == 404
|
||||
@@ -12,6 +12,7 @@ WORKDIR /app
|
||||
COPY backend/requirements.txt ./backend/
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
COPY backend/ ./backend/
|
||||
COPY backend/data/ ./backend/data/
|
||||
COPY --from=frontend-build /app/frontend/dist ./backend/app/static
|
||||
|
||||
ENV FLASK_APP=backend.app:create_app
|
||||
|
||||
Reference in New Issue
Block a user