From a824df06b215c1bb048e15fdb3cf2535228edd10 Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 27 May 2026 21:30:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(backend):=20AC-21.6=20=E2=80=94=20matrix=20?= =?UTF-8?q?tactic=5Fid=20returns=20TA-format=20(TA0007=20not=20slug)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mitre.py: add _SLUG_TO_TA_ID reverse map; _build_matrix() now emits tactic_id as TA-id (e.g. "TA0007") so frontend can send it back verbatim in PATCH tactic_ids - test_mitre.py: update all matrix assertions to use TA-ids; add test_get_matrix_tactic_id_is_ta_format regression guard Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/mitre.py | 13 +++++++++---- backend/tests/test_mitre.py | 27 ++++++++++++++++++--------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/backend/app/services/mitre.py b/backend/app/services/mitre.py index 62909f5..bd916e6 100644 --- a/backend/app/services/mitre.py +++ b/backend/app/services/mitre.py @@ -42,6 +42,9 @@ _TACTIC_IDS: dict[str, str] = { "TA0040": "impact", } +# Reverse: slug → TA-id (derived from _TACTIC_IDS, used by _build_matrix). +_SLUG_TO_TA_ID: dict[str, str] = {v: k for k, v in _TACTIC_IDS.items()} + TACTIC_NAMES: dict[str, str] = { "initial-access": "Initial Access", "execution": "Execution", @@ -114,14 +117,16 @@ def _build_matrix(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: subs.sort(key=lambda x: x["name"]) matrix: list[dict[str, Any]] = [] - for tactic_id in _TACTIC_ORDER: - techs = tactic_techs.get(tactic_id, []) + for slug in _TACTIC_ORDER: + techs = tactic_techs.get(slug, []) # Sort techniques alphabetically. techs_sorted = sorted(techs, key=lambda x: x["name"]) - tactic_name = TACTIC_NAMES.get(tactic_id, tactic_id.replace("-", " ").title()) + tactic_name = TACTIC_NAMES.get(slug, slug.replace("-", " ").title()) + # Expose TA-id so the frontend can send tactic_ids back in PATCH unchanged. + ta_id = _SLUG_TO_TA_ID.get(slug, slug) matrix.append( { - "tactic_id": tactic_id, + "tactic_id": ta_id, "tactic_name": tactic_name, "techniques": [ { diff --git a/backend/tests/test_mitre.py b/backend/tests/test_mitre.py index 39ba563..cbb2840 100644 --- a/backend/tests/test_mitre.py +++ b/backend/tests/test_mitre.py @@ -305,14 +305,14 @@ 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") + # TA0001 (initial-access) must come before TA0002 (execution) in canonical order. + assert tactic_ids.index("TA0001") < tactic_ids.index("TA0002") 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") + exec_tactic = next(t for t in matrix if t["tactic_id"] == "TA0002") 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"]] @@ -323,7 +323,7 @@ def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None: 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") + exec_tactic = next(t for t in matrix if t["tactic_id"] == "TA0002") 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) @@ -332,7 +332,7 @@ def test_get_matrix_subtechniques_sorted_by_name(bundle_file: pathlib.Path) -> N 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") + ia_tactic = next(t for t in matrix if t["tactic_id"] == "TA0001") names = [t["name"] for t in ia_tactic["techniques"]] assert names == sorted(names) @@ -340,7 +340,7 @@ def test_get_matrix_techniques_sorted_by_name(bundle_file: pathlib.Path) -> None 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") + ia_tactic = next(t for t in matrix if t["tactic_id"] == "TA0001") phishing = next((t for t in ia_tactic["techniques"] if t["id"] == "T1566"), None) assert phishing is not None assert phishing["subtechniques"] == [] @@ -355,8 +355,8 @@ def test_matrix_endpoint_ok( 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 + assert "TA0001" in tactic_ids # initial-access + assert "TA0002" in tactic_ids # execution def test_matrix_endpoint_503_when_not_loaded( @@ -389,6 +389,15 @@ def test_get_matrix_command_and_control_display_name(bundle_file: pathlib.Path) """MITRE official name uses lowercase 'and' — not title-cased.""" mitre_svc.load_bundle(bundle_file) matrix = mitre_svc.get_matrix() - c2 = next((t for t in matrix if t["tactic_id"] == "command-and-control"), None) + c2 = next((t for t in matrix if t["tactic_id"] == "TA0011"), None) assert c2 is not None assert c2["tactic_name"] == "Command and Control" + + +def test_get_matrix_tactic_id_is_ta_format(bundle_file: pathlib.Path) -> None: + """Matrix tactic_id must use TA-format so frontend can send it back in PATCH tactic_ids.""" + mitre_svc.load_bundle(bundle_file) + matrix = mitre_svc.get_matrix() + for entry in matrix: + tid = entry["tactic_id"] + assert tid.startswith("TA"), f"tactic_id {tid!r} must be TA-format, not a slug"