feat(ttp): load MITRE ATT&CK from official STIX 2.1 bundle
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.
This commit is contained in:
@@ -1,20 +1,43 @@
|
||||
"""ATT&CK technique-name catalogue covers every ID emitted by the rule pack.
|
||||
"""Every technique ID emitted by ``rules/ttp/`` must resolve in the loaded ATT&CK STIX bundle.
|
||||
|
||||
A rule author who adds a new technique to ``rules/ttp/`` must also
|
||||
update ``decnet/ttp/attack_catalog.py`` in the same commit. Without
|
||||
this test the UI silently falls back to the bare ID for unknown
|
||||
techniques.
|
||||
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.attack_catalog import TECHNIQUE_NAMES, technique_name
|
||||
|
||||
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]:
|
||||
@@ -31,12 +54,12 @@ def _all_technique_ids_in_rule_pack() -> set[str]:
|
||||
return ids
|
||||
|
||||
|
||||
def test_every_rule_pack_technique_has_a_catalogue_entry() -> None:
|
||||
def test_every_rule_pack_technique_resolves_in_bundle() -> None:
|
||||
rule_ids = _all_technique_ids_in_rule_pack()
|
||||
missing = sorted(rule_ids - TECHNIQUE_NAMES.keys())
|
||||
missing = sorted(t for t in rule_ids if not attack_stix.technique_exists(t))
|
||||
assert not missing, (
|
||||
"rules/ttp/ emits techniques absent from "
|
||||
"decnet/ttp/attack_catalog.py: " + ", ".join(missing)
|
||||
f"rules/ttp/ emits techniques absent from ATT&CK Enterprise "
|
||||
f"v{attack_stix.ATTACK_BUNDLE_VERSION}: {missing}"
|
||||
)
|
||||
|
||||
|
||||
@@ -51,8 +74,37 @@ def test_technique_name_unknown_id_returns_none() -> None:
|
||||
assert technique_name("") is None
|
||||
|
||||
|
||||
def test_catalogue_entries_are_non_empty_strings() -> None:
|
||||
for tid, name in TECHNIQUE_NAMES.items():
|
||||
assert isinstance(name, str) and name.strip(), (
|
||||
f"empty / non-string name for {tid!r}: {name!r}"
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user