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

@@ -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",
@@ -76,9 +84,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 +110,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) == 5 # 6 attack-patterns minus 1 revoked = 5
def test_load_bundle_missing_file() -> None:
@@ -245,3 +259,119 @@ 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