fix(backend): AC-21.6 — matrix tactic_id returns TA-format (TA0007 not slug)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@ _TACTIC_IDS: dict[str, str] = {
|
|||||||
"TA0040": "impact",
|
"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] = {
|
TACTIC_NAMES: dict[str, str] = {
|
||||||
"initial-access": "Initial Access",
|
"initial-access": "Initial Access",
|
||||||
"execution": "Execution",
|
"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"])
|
subs.sort(key=lambda x: x["name"])
|
||||||
|
|
||||||
matrix: list[dict[str, Any]] = []
|
matrix: list[dict[str, Any]] = []
|
||||||
for tactic_id in _TACTIC_ORDER:
|
for slug in _TACTIC_ORDER:
|
||||||
techs = tactic_techs.get(tactic_id, [])
|
techs = tactic_techs.get(slug, [])
|
||||||
# Sort techniques alphabetically.
|
# Sort techniques alphabetically.
|
||||||
techs_sorted = sorted(techs, key=lambda x: x["name"])
|
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(
|
matrix.append(
|
||||||
{
|
{
|
||||||
"tactic_id": tactic_id,
|
"tactic_id": ta_id,
|
||||||
"tactic_name": tactic_name,
|
"tactic_name": tactic_name,
|
||||||
"techniques": [
|
"techniques": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -305,14 +305,14 @@ def test_get_matrix_returns_ordered_tactics(bundle_file: pathlib.Path) -> None:
|
|||||||
mitre_svc.load_bundle(bundle_file)
|
mitre_svc.load_bundle(bundle_file)
|
||||||
matrix = mitre_svc.get_matrix()
|
matrix = mitre_svc.get_matrix()
|
||||||
tactic_ids = [t["tactic_id"] for t in matrix]
|
tactic_ids = [t["tactic_id"] for t in matrix]
|
||||||
# initial-access must come before execution in canonical order.
|
# TA0001 (initial-access) must come before TA0002 (execution) in canonical order.
|
||||||
assert tactic_ids.index("initial-access") < tactic_ids.index("execution")
|
assert tactic_ids.index("TA0001") < tactic_ids.index("TA0002")
|
||||||
|
|
||||||
|
|
||||||
def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None:
|
def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None:
|
||||||
mitre_svc.load_bundle(bundle_file)
|
mitre_svc.load_bundle(bundle_file)
|
||||||
matrix = mitre_svc.get_matrix()
|
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)
|
t1059 = next((t for t in exec_tactic["techniques"] if t["id"] == "T1059"), None)
|
||||||
assert t1059 is not None
|
assert t1059 is not None
|
||||||
sub_ids = [s["id"] for s in t1059["subtechniques"]]
|
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:
|
def test_get_matrix_subtechniques_sorted_by_name(bundle_file: pathlib.Path) -> None:
|
||||||
mitre_svc.load_bundle(bundle_file)
|
mitre_svc.load_bundle(bundle_file)
|
||||||
matrix = mitre_svc.get_matrix()
|
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")
|
t1059 = next(t for t in exec_tactic["techniques"] if t["id"] == "T1059")
|
||||||
names = [s["name"] for s in t1059["subtechniques"]]
|
names = [s["name"] for s in t1059["subtechniques"]]
|
||||||
assert names == sorted(names)
|
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:
|
def test_get_matrix_techniques_sorted_by_name(bundle_file: pathlib.Path) -> None:
|
||||||
mitre_svc.load_bundle(bundle_file)
|
mitre_svc.load_bundle(bundle_file)
|
||||||
matrix = mitre_svc.get_matrix()
|
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"]]
|
names = [t["name"] for t in ia_tactic["techniques"]]
|
||||||
assert names == sorted(names)
|
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:
|
def test_get_matrix_technique_no_subtechniques(bundle_file: pathlib.Path) -> None:
|
||||||
mitre_svc.load_bundle(bundle_file)
|
mitre_svc.load_bundle(bundle_file)
|
||||||
matrix = mitre_svc.get_matrix()
|
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)
|
phishing = next((t for t in ia_tactic["techniques"] if t["id"] == "T1566"), None)
|
||||||
assert phishing is not None
|
assert phishing is not None
|
||||||
assert phishing["subtechniques"] == []
|
assert phishing["subtechniques"] == []
|
||||||
@@ -355,8 +355,8 @@ def test_matrix_endpoint_ok(
|
|||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert isinstance(data, list)
|
assert isinstance(data, list)
|
||||||
tactic_ids = [t["tactic_id"] for t in data]
|
tactic_ids = [t["tactic_id"] for t in data]
|
||||||
assert "initial-access" in tactic_ids
|
assert "TA0001" in tactic_ids # initial-access
|
||||||
assert "execution" in tactic_ids
|
assert "TA0002" in tactic_ids # execution
|
||||||
|
|
||||||
|
|
||||||
def test_matrix_endpoint_503_when_not_loaded(
|
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 official name uses lowercase 'and' — not title-cased."""
|
||||||
mitre_svc.load_bundle(bundle_file)
|
mitre_svc.load_bundle(bundle_file)
|
||||||
matrix = mitre_svc.get_matrix()
|
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 is not None
|
||||||
assert c2["tactic_name"] == "Command and Control"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user