feat(ttp): fetch + verify MITRE ATT&CK LICENSE alongside the bundle
MITRE's ATT&CK Terms of Use require reproducing their copyright + license alongside any cached copy of ATT&CK data. Today we ship the bundle but not the license — this commit closes that compliance gap. - attack_version.py pins ATTACK_LICENSE_URL + ATTACK_LICENSE_SHA256 + ATTACK_LICENSE_FILENAME, sourced from the same attack-stix-data repo as the bundle. - attack_stix.py:_fetch_license downloads LICENSE.txt next to the bundle. License sha mismatch is logged + refreshed (license text gets occasional formatting tweaks; not a security event), unlike the bundle which stays fail-closed. - _ensure_license is the compliance ratchet: resolve_bundle_path refuses to return without LICENSE.txt on disk. Override-mode (DECNET_ATTACK_BUNDLE) checks for a sibling LICENSE.txt first, then DECNET_ATTACK_LICENSE, then the cache dir. - python -m decnet.ttp.attack_stix license prints the cached license to stdout for operator audit. - loaded_license_path() exposes the active license path read-only. - tests/ttp/test_attack_license.py covers happy paths (sibling + explicit env), refusal when DECNET_ATTACK_LICENSE points at a missing file, the CLI subcommand, and the pinned-sha shape.
This commit is contained in:
131
tests/ttp/test_attack_license.py
Normal file
131
tests/ttp/test_attack_license.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""MITRE ATT&CK Terms of Use compliance: LICENSE.txt is fetched, verified, and required.
|
||||
|
||||
Bundle and license live side-by-side in the cache dir. The bundle is
|
||||
fail-closed on hash mismatch (drift = mistagging risk); the license
|
||||
is logged-and-refreshed on hash mismatch (drift = MITRE updated the
|
||||
text, not a security event), but its *presence* is mandatory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.ttp import attack_stix
|
||||
from decnet.ttp.attack_version import (
|
||||
ATTACK_LICENSE_FILENAME,
|
||||
ATTACK_LICENSE_SHA256,
|
||||
)
|
||||
|
||||
_REPO_BUNDLE = Path(__file__).resolve().parents[2] / "enterprise-attack-19.0.json"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_loader_state() -> None:
|
||||
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()
|
||||
|
||||
|
||||
def _write_dummy_license(path: Path) -> str:
|
||||
text = "placeholder license content for tests"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
|
||||
def test_license_filename_constant() -> None:
|
||||
assert ATTACK_LICENSE_FILENAME == "LICENSE.txt"
|
||||
|
||||
|
||||
def test_resolve_bundle_path_with_override_and_sibling_license(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Operator points DECNET_ATTACK_BUNDLE at a file with LICENSE.txt next to it — happy path."""
|
||||
bundle = tmp_path / "enterprise-attack-19.0.json"
|
||||
bundle.write_bytes(_REPO_BUNDLE.read_bytes())
|
||||
license_path = tmp_path / ATTACK_LICENSE_FILENAME
|
||||
_write_dummy_license(license_path)
|
||||
|
||||
monkeypatch.setenv("DECNET_ATTACK_BUNDLE", str(bundle))
|
||||
monkeypatch.delenv("DECNET_ATTACK_LICENSE", raising=False)
|
||||
# Empty cache dir so override-mode resolves license from sibling.
|
||||
monkeypatch.setenv("DECNET_ATTACK_CACHE_DIR", str(tmp_path / "cache"))
|
||||
|
||||
resolved = attack_stix.resolve_bundle_path()
|
||||
assert resolved == bundle
|
||||
assert attack_stix.loaded_license_path() == license_path
|
||||
|
||||
|
||||
def test_resolve_bundle_path_via_decnet_attack_license_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""DECNET_ATTACK_LICENSE points to an arbitrary path — accepted."""
|
||||
bundle = tmp_path / "bundle" / "enterprise-attack-19.0.json"
|
||||
bundle.parent.mkdir()
|
||||
bundle.write_bytes(_REPO_BUNDLE.read_bytes())
|
||||
explicit_license = tmp_path / "license_elsewhere.txt"
|
||||
_write_dummy_license(explicit_license)
|
||||
|
||||
monkeypatch.setenv("DECNET_ATTACK_BUNDLE", str(bundle))
|
||||
monkeypatch.setenv("DECNET_ATTACK_LICENSE", str(explicit_license))
|
||||
monkeypatch.setenv("DECNET_ATTACK_CACHE_DIR", str(tmp_path / "cache"))
|
||||
|
||||
resolved = attack_stix.resolve_bundle_path()
|
||||
assert resolved == bundle
|
||||
assert attack_stix.loaded_license_path() == explicit_license
|
||||
|
||||
|
||||
def test_decnet_attack_license_pointing_to_missing_file_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
bundle = tmp_path / "enterprise-attack-19.0.json"
|
||||
bundle.write_bytes(_REPO_BUNDLE.read_bytes())
|
||||
monkeypatch.setenv("DECNET_ATTACK_BUNDLE", str(bundle))
|
||||
monkeypatch.setenv("DECNET_ATTACK_LICENSE", str(tmp_path / "nope.txt"))
|
||||
monkeypatch.setenv("DECNET_ATTACK_CACHE_DIR", str(tmp_path / "cache"))
|
||||
|
||||
with pytest.raises(attack_stix.AttackBundleError) as exc:
|
||||
attack_stix.resolve_bundle_path()
|
||||
assert "Terms of Use" in str(exc.value)
|
||||
|
||||
|
||||
def test_loaded_license_path_returns_none_when_absent(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("DECNET_ATTACK_BUNDLE", raising=False)
|
||||
monkeypatch.delenv("DECNET_ATTACK_LICENSE", raising=False)
|
||||
monkeypatch.setenv("DECNET_ATTACK_CACHE_DIR", str(tmp_path))
|
||||
assert attack_stix.loaded_license_path() is None
|
||||
|
||||
|
||||
def test_pinned_license_sha_matches_repo_committed_text() -> None:
|
||||
"""The pinned hash in attack_version.py is a 64-char lowercase hex sha256."""
|
||||
assert len(ATTACK_LICENSE_SHA256) == 64
|
||||
assert all(c in "0123456789abcdef" for c in ATTACK_LICENSE_SHA256)
|
||||
|
||||
|
||||
def test_cli_license_subcommand_prints_cached_license(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
license_path = tmp_path / ATTACK_LICENSE_FILENAME
|
||||
license_path.write_text("MITRE Corporation grants you a license\n", encoding="utf-8")
|
||||
monkeypatch.setenv("DECNET_ATTACK_LICENSE", str(license_path))
|
||||
|
||||
rc = attack_stix.main(["license"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "MITRE Corporation" in out
|
||||
|
||||
|
||||
def test_cli_license_returns_nonzero_when_not_present(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("DECNET_ATTACK_LICENSE", raising=False)
|
||||
monkeypatch.delenv("DECNET_ATTACK_BUNDLE", raising=False)
|
||||
monkeypatch.setenv("DECNET_ATTACK_CACHE_DIR", str(tmp_path / "empty"))
|
||||
|
||||
rc = attack_stix.main(["license"])
|
||||
assert rc == 1
|
||||
Reference in New Issue
Block a user