Replace the hand-maintained TECHNIQUE_NAMES dict (pinned to v15.1) with a runtime loader that reads the official enterprise-attack-N.json STIX bundle. Version bumps now require only updating attack_version.py; sub-technique parents, tactic IDs, and kill-chain phases all come from MITRE's published data. - decnet/ttp/attack_version.py pins version 19.0 + sha256 + URL - decnet/ttp/attack_stix.py is the lazy STIX loader. Resolution order: DECNET_ATTACK_BUNDLE env -> ~/.cache/decnet/attack/ -> fetch from the pinned MITRE GitHub URL. SHA-256 verified before parse; mismatch fails closed. - decnet/ttp/attack_catalog.py collapses to a shim re-exporting technique_name() so the ~9 router/repo call sites don't churn. - python -m decnet.ttp.attack_stix fetch warms the cache and can print sha256 for version-bump workflows. - test_attack_catalog.py now asserts every rule-emitted ID resolves in the loaded bundle (same contract, real source) and exercises the SHA-256-mismatch fail-closed path.
111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
"""Every technique ID emitted by ``rules/ttp/`` must resolve in the loaded ATT&CK STIX bundle.
|
|
|
|
The shim in :mod:`decnet.ttp.attack_catalog` now reads names from the
|
|
official MITRE ATT&CK Enterprise STIX bundle (loader at
|
|
:mod:`decnet.ttp.attack_stix`). This test enforces the same invariant
|
|
that the old hand-maintained dict did — a rule author who adds a
|
|
technique that isn't in the pinned ATT&CK release gets a loud failure
|
|
at deploy time rather than a silent UI fallback.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from decnet.ttp import attack_stix
|
|
from decnet.ttp.attack_catalog import technique_name
|
|
|
|
_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "ttp"
|
|
_REPO_BUNDLE = Path(__file__).resolve().parents[2] / "enterprise-attack-19.0.json"
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _use_repo_bundle(monkeypatch_module: pytest.MonkeyPatch) -> None:
|
|
"""Pin DECNET_ATTACK_BUNDLE to the in-repo copy for hermetic tests."""
|
|
monkeypatch_module.setenv("DECNET_ATTACK_BUNDLE", str(_REPO_BUNDLE))
|
|
# Reset the lazy singleton so the env var is honored.
|
|
attack_stix._data = None
|
|
attack_stix._loaded_path = None
|
|
attack_stix._attack_pattern_by_id.cache_clear()
|
|
attack_stix._tactic_by_id.cache_clear()
|
|
attack_stix._tactic_by_short_name.cache_clear()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def monkeypatch_module() -> pytest.MonkeyPatch:
|
|
mp = pytest.MonkeyPatch()
|
|
yield mp
|
|
mp.undo()
|
|
|
|
|
|
def _all_technique_ids_in_rule_pack() -> set[str]:
|
|
ids: set[str] = set()
|
|
for path in sorted(_RULES_DIR.glob("R*.yaml")):
|
|
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
for emit in doc.get("emits", []) or []:
|
|
tid = emit.get("technique_id")
|
|
if isinstance(tid, str) and tid:
|
|
ids.add(tid)
|
|
sub = emit.get("sub_technique_id")
|
|
if isinstance(sub, str) and sub:
|
|
ids.add(sub)
|
|
return ids
|
|
|
|
|
|
def test_every_rule_pack_technique_resolves_in_bundle() -> None:
|
|
rule_ids = _all_technique_ids_in_rule_pack()
|
|
missing = sorted(t for t in rule_ids if not attack_stix.technique_exists(t))
|
|
assert not missing, (
|
|
f"rules/ttp/ emits techniques absent from ATT&CK Enterprise "
|
|
f"v{attack_stix.ATTACK_BUNDLE_VERSION}: {missing}"
|
|
)
|
|
|
|
|
|
def test_technique_name_returns_canonical_label() -> None:
|
|
assert technique_name("T1595") == "Active Scanning"
|
|
assert technique_name("T1595.002") == "Active Scanning: Vulnerability Scanning"
|
|
|
|
|
|
def test_technique_name_unknown_id_returns_none() -> None:
|
|
assert technique_name("T9999") is None
|
|
assert technique_name(None) is None
|
|
assert technique_name("") is None
|
|
|
|
|
|
def test_subtechnique_format_matches_legacy() -> None:
|
|
# Spot-check the historical "Parent: Child" rendering.
|
|
assert technique_name("T1059.004") == (
|
|
"Command and Scripting Interpreter: Unix Shell"
|
|
)
|
|
assert technique_name("T1110.001") == "Brute Force: Password Guessing"
|
|
|
|
|
|
def test_assert_known_technique_ids_raises_on_missing() -> None:
|
|
with pytest.raises(attack_stix.AttackBundleError) as exc:
|
|
attack_stix.assert_known_technique_ids(
|
|
["T1059", "T9999"], source="test_assert"
|
|
)
|
|
assert "T9999" in str(exc.value)
|
|
assert "T1059" not in str(exc.value)
|
|
|
|
|
|
def test_tactic_lookup_by_id_and_short_name() -> None:
|
|
assert attack_stix.tactic_name("TA0001") == "Initial Access"
|
|
assert attack_stix.tactic_name("initial-access") == "Initial Access"
|
|
assert attack_stix.tactic_id_for_short_name("initial-access") == "TA0001"
|
|
assert attack_stix.tactic_exists("TA0001")
|
|
assert not attack_stix.tactic_exists("TA9999")
|
|
|
|
|
|
def test_sha256_mismatch_refuses_to_load(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
bogus = tmp_path / "enterprise-attack-19.0.json"
|
|
bogus.write_bytes(b'{"type":"bundle","id":"bundle--x","objects":[]}')
|
|
monkeypatch.setenv("DECNET_ATTACK_BUNDLE", str(bogus))
|
|
with pytest.raises(attack_stix.AttackBundleError) as exc:
|
|
attack_stix._verify_sha256(bogus)
|
|
assert "does not match" in str(exc.value)
|