Files
DECNET/tests/ttp/test_attack_license.py
anti a3f1cea2d6 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.
2026-05-09 06:17:46 -04:00

132 lines
4.9 KiB
Python

"""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