From d2aa5265fefc869e92d18a07e5116a8f16a4b098 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Wed, 6 May 2026 06:45:25 -0500 Subject: [PATCH] feat: add attack_changelog command for generating changelog artifacts between ATT&CK versions --- examples/generate_multiple_attack_diffs.py | 42 +-- mitreattack/diffStix/README.md | 18 + mitreattack/diffStix/attack_changelog.py | 378 +++++++++++++++++++ pyproject.toml | 3 +- tests/changelog/cli/test_attack_changelog.py | 372 ++++++++++++++++++ 5 files changed, 779 insertions(+), 34 deletions(-) create mode 100644 mitreattack/diffStix/attack_changelog.py create mode 100644 tests/changelog/cli/test_attack_changelog.py diff --git a/examples/generate_multiple_attack_diffs.py b/examples/generate_multiple_attack_diffs.py index 7459f80..4921316 100644 --- a/examples/generate_multiple_attack_diffs.py +++ b/examples/generate_multiple_attack_diffs.py @@ -2,7 +2,7 @@ import argparse -from mitreattack.diffStix.changelog_helper import get_new_changelog_md +from mitreattack.diffStix.attack_changelog import generate_attack_changelog DOMAINS = ["enterprise-attack", "mobile-attack", "ics-attack"] VERSION_PAIRS = [ @@ -11,17 +11,6 @@ ] -def get_release_output_folder(old_version: str, new_version: str) -> str: - """Return the output folder for a release comparison.""" - return f"output/v{old_version}-v{new_version}" - - -def get_artifact_link_prefix(old_version: str, new_version: str, *, attack_website_links: bool = False) -> str: - """Return the link prefix for generated layers and changelog JSON.""" - if not attack_website_links: - return "" - return f"/docs/changelogs/v{old_version}-v{new_version}" - def get_parsed_args(): """Parse command line arguments for the example script.""" @@ -37,31 +26,18 @@ def get_parsed_args(): def generate_diff(old_version: str, new_version: str, *, attack_website_links: bool = False): """Generate changelog outputs for a single ATT&CK release pair.""" - output_folder = get_release_output_folder(old_version, new_version) + output_folder = f"output/v{old_version}-v{new_version}" print(f"Generating ATT&CK Diffs between {old_version}-{new_version}: {output_folder}") - get_new_changelog_md( + generate_attack_changelog( + old_version=old_version, + new_version=new_version, domains=DOMAINS, - layers=[ - f"{output_folder}/layer-enterprise.json", - f"{output_folder}/layer-mobile.json", - f"{output_folder}/layer-ics.json", - ], - old=f"attack-releases/stix-2.0/v{old_version}", - new=f"attack-releases/stix-2.0/v{new_version}", - show_key=True, - # site_prefix: str = "", + output_dir=output_folder, verbose=True, - include_contributors=True, - markdown_file=f"{output_folder}/changelog.md", - html_file=f"{output_folder}/index.html", - html_file_detailed=f"{output_folder}/changelog-detailed.html", - additional_formats_prefix=get_artifact_link_prefix( - old_version, - new_version, - attack_website_links=attack_website_links, - ), - json_file=f"{output_folder}/changelog.json", + markdown_file=True, + html_file=True, + attack_website_links=attack_website_links, ) diff --git a/mitreattack/diffStix/README.md b/mitreattack/diffStix/README.md index a182433..4d76522 100644 --- a/mitreattack/diffStix/README.md +++ b/mitreattack/diffStix/README.md @@ -53,6 +53,24 @@ Example execution: diff_stix -v --show-key --html-file output/changelog.html --html-file-detailed output/changelog-detailed.html --markdown-file output/changelog.md --json-file output/changelog.json --layers output/layer-enterprise.json output/layer-mobile.json output/layer-ics.json --old path/to/old/stix/ --new path/to/new/stix/ ``` +Generate release changelog artifacts for one ATT&CK version pair: + +```shell +attack_changelog --old-version 17.1 --new-version 18.0 +``` + +The `attack_changelog` command reads local release data from `attack-releases/stix-2.0/v{version}` by default. +If either requested release is missing, it downloads the needed STIX bundles into a temporary directory and +removes them when generation is complete. +It always writes detailed HTML, JSON, and Navigator layer artifacts under `output/v{old_version}-v{new_version}`. +It can also generate `changelog.md` or `index.html` if needed by passing the corresponding flags: + +```shell +attack_changelog --old-version 17.1 --new-version 18.0 \ + --markdown-file \ + --html-file +``` + ## Changelog JSON format The changelog helper script has the option to output a JSON file with detailed differences between ATT&CK releases. diff --git a/mitreattack/diffStix/attack_changelog.py b/mitreattack/diffStix/attack_changelog.py new file mode 100644 index 0000000..c387f62 --- /dev/null +++ b/mitreattack/diffStix/attack_changelog.py @@ -0,0 +1,378 @@ +"""Generate ATT&CK changelog artifacts for a single release pair.""" + +import argparse +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Generator, Optional + +from mitreattack.diffStix.changelog_helper import get_new_changelog_md +from mitreattack.download_stix import download_domains + +ATTACK_RELEASES_DIR = Path("attack-releases") + + +@dataclass(frozen=True) +class DomainConfig: + """Domain-specific names used by STIX downloads and changelog artifacts.""" + + download_name: str + layer_file: str + + +DOMAIN_CONFIGS = { + "enterprise-attack": DomainConfig(download_name="enterprise", layer_file="layer-enterprise.json"), + "mobile-attack": DomainConfig(download_name="mobile", layer_file="layer-mobile.json"), + "ics-attack": DomainConfig(download_name="ics", layer_file="layer-ics.json"), +} +DEFAULT_DOMAINS = list(DOMAIN_CONFIGS) +VALID_STIX_VERSIONS = ["2.0", "2.1"] + + +@dataclass(frozen=True) +class ChangelogRequest: + """Normalized options for one ATT&CK release changelog generation run.""" + + old_version: str + new_version: str + stix_version: str = "2.0" + output_dir: str = "" + domains: list[str] = field(default_factory=list) + markdown_file: bool = False + html_file: bool = False + attack_website_links: bool = False + verbose: bool = False + + @classmethod + def create( + cls, + *, + old_version: str, + new_version: str, + stix_version: str = "2.0", + output_dir: Optional[str] = None, + domains: Optional[list[str]] = None, + markdown_file: bool = False, + html_file: bool = False, + attack_website_links: bool = False, + verbose: bool = False, + ) -> "ChangelogRequest": + """Return a request with normalized versions and resolved defaults.""" + if stix_version not in VALID_STIX_VERSIONS: + expected_stix_versions = ", ".join(VALID_STIX_VERSIONS) + raise ValueError(f"Invalid STIX version: {stix_version}. Expected one of: {expected_stix_versions}") + + old_version = normalize_release_version(old_version) + new_version = normalize_release_version(new_version) + return cls( + old_version=old_version, + new_version=new_version, + stix_version=stix_version, + output_dir=output_dir or _default_output_dir(old_version, new_version), + domains=normalize_domains(domains), + markdown_file=markdown_file, + html_file=html_file, + attack_website_links=attack_website_links, + verbose=verbose, + ) + + +@dataclass(frozen=True) +class ChangelogArtifacts: + """Output artifact paths for one ATT&CK release changelog generation run.""" + + layers: list[str] + markdown_file: Optional[str] + html_file: Optional[str] + html_file_detailed: str + json_file: str + additional_formats_prefix: str + + @classmethod + def from_request(cls, request: ChangelogRequest) -> "ChangelogArtifacts": + """Return all generated artifact paths for a normalized request.""" + output_dir = Path(request.output_dir) + return cls( + layers=[str(output_dir / DOMAIN_CONFIGS[domain].layer_file) for domain in DEFAULT_DOMAINS], + markdown_file=str(output_dir / "changelog.md") if request.markdown_file else None, + html_file=str(output_dir / "index.html") if request.html_file else None, + html_file_detailed=str(output_dir / "changelog-detailed.html"), + json_file=str(output_dir / "changelog.json"), + additional_formats_prefix=get_artifact_link_prefix( + request.old_version, + request.new_version, + attack_website_links=request.attack_website_links, + ), + ) + + +def normalize_release_version(version: str) -> str: + """Return an ATT&CK release version with the leading ``v`` folder prefix.""" + return version if version.startswith("v") else f"v{version}" + + +def normalize_domains(domains: Optional[list[str]]) -> list[str]: + """Return validated ATT&CK domains with duplicates removed in first-seen order.""" + if not domains: + return list(DEFAULT_DOMAINS) + + normalized_domains = [] + invalid_domains = [] + for domain in domains: + if domain not in DOMAIN_CONFIGS: + if domain not in invalid_domains: + invalid_domains.append(domain) + continue + + if domain not in normalized_domains: + normalized_domains.append(domain) + + if invalid_domains: + invalid_domains_text = ", ".join(invalid_domains) + expected_domains_text = ", ".join(DEFAULT_DOMAINS) + raise ValueError(f"Invalid ATT&CK domain(s): {invalid_domains_text}. Expected one of: {expected_domains_text}") + + return normalized_domains + + +def _version_without_prefix(version: str) -> str: + """Return an ATT&CK release version without the leading ``v`` folder prefix.""" + return normalize_release_version(version).removeprefix("v") + + +def _default_output_dir(old_version: str, new_version: str) -> str: + """Return the default output directory for a release comparison.""" + return f"output/{normalize_release_version(old_version)}-{normalize_release_version(new_version)}" + + +def _get_release_dir(version: str, stix_version: str, base_dir: Path = ATTACK_RELEASES_DIR) -> Path: + """Return the local release directory for an ATT&CK/STIX version pair.""" + return (base_dir / f"stix-{stix_version}" / normalize_release_version(version)).resolve() + + +def get_artifact_link_prefix(old_version: str, new_version: str, *, attack_website_links: bool = False) -> str: + """Return the link prefix for generated layers and changelog JSON.""" + if not attack_website_links: + return "" + return f"/docs/changelogs/{normalize_release_version(old_version)}-{normalize_release_version(new_version)}" + + +def get_parsed_args(): + """Parse command line arguments for the attack_changelog command.""" + parser = argparse.ArgumentParser( + description="Generate ATT&CK changelog artifacts for a single ATT&CK release pair." + ) + parser.add_argument("--old-version", required=True, help="Old ATT&CK release version, e.g. 17.1 or v17.1.") + parser.add_argument("--new-version", required=True, help="New ATT&CK release version, e.g. 18.0 or v18.0.") + parser.add_argument( + "--stix-version", + choices=["2.0", "2.1"], + default="2.0", + help="STIX release tree to use.", + ) + parser.add_argument( + "--output-dir", + help="Directory for generated changelog artifacts. Defaults to output/v{old_version}-v{new_version}.", + ) + parser.add_argument( + "--domains", + type=str, + nargs="+", + choices=DEFAULT_DOMAINS, + default=DEFAULT_DOMAINS, + help="Which ATT&CK domains to compare. Choices and defaults are %(choices)s.", + ) + parser.add_argument( + "--markdown-file", + action="store_true", + default=False, + help="Persist markdown output as changelog.md under --output-dir.", + ) + parser.add_argument( + "--html-file", + action="store_true", + default=False, + help="Persist HTML output as index.html under --output-dir.", + ) + parser.add_argument( + "-w", + "--attack-website-links", + action="store_true", + help="Use ATT&CK website paths for links to generated layers and changelog JSON.", + ) + parser.add_argument("-v", "--verbose", action="store_true", help="Print status messages.") + + args = parser.parse_args() + request = ChangelogRequest.create( + old_version=args.old_version, + new_version=args.new_version, + stix_version=args.stix_version, + output_dir=args.output_dir, + domains=args.domains, + markdown_file=args.markdown_file, + html_file=args.html_file, + attack_website_links=args.attack_website_links, + verbose=args.verbose, + ) + args.old_version = request.old_version + args.new_version = request.new_version + args.output_dir = request.output_dir + args.domains = request.domains + return args + + +def _download_missing_releases( + *, + missing_versions: list[str], + domains: list[str], + stix_version: str, + temporary_directory: str, +) -> Path: + """Download missing ATT&CK releases into a temporary STIX directory.""" + temp_stix_dir = Path(temporary_directory) / f"stix-{stix_version}" + download_domains( + domains=[DOMAIN_CONFIGS[domain].download_name for domain in domains], + download_dir=str(temp_stix_dir), + all_versions=False, + stix_version=stix_version, + attack_versions=[_version_without_prefix(version) for version in missing_versions], + ) + return temp_stix_dir + + +def _resolve_release_dirs( + *, + old_version: str, + new_version: str, + stix_version: str, + domains: list[str], + temporary_directory: Optional[str], +) -> tuple[Path, Path]: + """Return old and new release directories, downloading missing releases when needed.""" + old_dir = _get_release_dir(old_version, stix_version) + new_dir = _get_release_dir(new_version, stix_version) + missing_versions = list( + dict.fromkeys( + version + for version, release_dir in [(old_version, old_dir), (new_version, new_dir)] + if not release_dir.is_dir() + ) + ) + + if not missing_versions: + return old_dir, new_dir + + if temporary_directory is None: + raise ValueError("temporary_directory is required when release directories are missing") + + temp_stix_dir = _download_missing_releases( + missing_versions=missing_versions, + domains=domains, + stix_version=stix_version, + temporary_directory=temporary_directory, + ) + + if old_version in missing_versions: + old_dir = temp_stix_dir / normalize_release_version(old_version) + if new_version in missing_versions: + new_dir = temp_stix_dir / normalize_release_version(new_version) + + return old_dir, new_dir + + +@contextmanager +def resolved_release_dirs(request: ChangelogRequest) -> Generator[tuple[Path, Path], None, None]: + """Yield old and new release directories, using a temporary download tree if needed.""" + old_dir = _get_release_dir(request.old_version, request.stix_version) + new_dir = _get_release_dir(request.new_version, request.stix_version) + + if old_dir.is_dir() and new_dir.is_dir(): + yield old_dir, new_dir + return + + with tempfile.TemporaryDirectory() as temporary_directory: + yield _resolve_release_dirs( + old_version=request.old_version, + new_version=request.new_version, + stix_version=request.stix_version, + domains=request.domains, + temporary_directory=temporary_directory, + ) + + +def _generate_with_release_dirs( + *, + old_dir: Path, + new_dir: Path, + request: ChangelogRequest, +) -> str: + """Generate changelog artifacts with resolved old and new release directories.""" + artifacts = ChangelogArtifacts.from_request(request) + return get_new_changelog_md( + domains=request.domains, + layers=artifacts.layers, + old=str(old_dir), + new=str(new_dir), + show_key=True, + verbose=request.verbose, + include_contributors=True, + markdown_file=artifacts.markdown_file, + html_file=artifacts.html_file, + html_file_detailed=artifacts.html_file_detailed, + additional_formats_prefix=artifacts.additional_formats_prefix, + json_file=artifacts.json_file, + ) + + +def generate_attack_changelog( + *, + old_version: str, + new_version: str, + stix_version: str = "2.0", + output_dir: Optional[str] = None, + domains: Optional[list[str]] = None, + markdown_file: bool = False, + html_file: bool = False, + attack_website_links: bool = False, + verbose: bool = False, +) -> str: + """Generate ATT&CK changelog artifacts for a single release pair.""" + request = ChangelogRequest.create( + old_version=old_version, + new_version=new_version, + stix_version=stix_version, + output_dir=output_dir, + domains=domains, + markdown_file=markdown_file, + html_file=html_file, + attack_website_links=attack_website_links, + verbose=verbose, + ) + + with resolved_release_dirs(request) as (old_dir, new_dir): + return _generate_with_release_dirs( + old_dir=old_dir, + new_dir=new_dir, + request=request, + ) + + +def main(): + """Entrypoint for the attack_changelog console command.""" + args = get_parsed_args() + generate_attack_changelog( + old_version=args.old_version, + new_version=args.new_version, + stix_version=args.stix_version, + output_dir=args.output_dir, + domains=args.domains, + markdown_file=args.markdown_file, + html_file=args.html_file, + attack_website_links=args.attack_website_links, + verbose=args.verbose, + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 5865b8a..2dcae01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ layerGenerator_cli = 'mitreattack.navlayers.layerGenerator_cli:main' indexToMarkdown_cli = 'mitreattack.collections.index_to_markdown:main' collectionToIndex_cli = 'mitreattack.collections.collection_to_index:main' diff_stix = 'mitreattack.diffStix.changelog_helper:main' +attack_changelog = 'mitreattack.diffStix.attack_changelog:main' download_attack_stix = 'mitreattack.download_stix:app' [project.optional-dependencies] @@ -64,7 +65,7 @@ dev = [ docs = [ "sphinx>=8.2.3", "sphinx-rtd-theme>=3.0.2", -] +] # The following ci group is needed for the PSR GitHub Action. See `tool.semantic_release.build_command` below for details. ci = ["uv>=0.10.4"] diff --git a/tests/changelog/cli/test_attack_changelog.py b/tests/changelog/cli/test_attack_changelog.py new file mode 100644 index 0000000..94109ad --- /dev/null +++ b/tests/changelog/cli/test_attack_changelog.py @@ -0,0 +1,372 @@ +"""Tests for the attack_changelog CLI wrapper.""" + +import argparse +import sys +from pathlib import Path + +import pytest + +from mitreattack.diffStix import attack_changelog + + +def test_normalize_release_version_accepts_plain_and_prefixed_versions(): + """Release versions should resolve to the folder naming convention.""" + assert attack_changelog.normalize_release_version("17.1") == "v17.1" + assert attack_changelog.normalize_release_version("v18.0") == "v18.0" + + +def test_get_parsed_args_requires_release_versions(monkeypatch): + """The command requires exactly one old and one new ATT&CK release version.""" + monkeypatch.setattr(sys, "argv", ["attack_changelog"]) + + with pytest.raises(SystemExit): + attack_changelog.get_parsed_args() + + +def test_get_parsed_args_defaults_and_options(monkeypatch): + """Parse default and opt-in output arguments.""" + monkeypatch.setattr( + sys, + "argv", + [ + "attack_changelog", + "--old-version", + "17.1", + "--new-version", + "v18.0", + "--stix-version", + "2.1", + "--output-dir", + "custom-output", + "--domains", + "enterprise-attack", + "mobile-attack", + "--markdown-file", + "--html-file", + "--attack-website-links", + "--verbose", + ], + ) + + args = attack_changelog.get_parsed_args() + + assert args.old_version == "v17.1" + assert args.new_version == "v18.0" + assert args.stix_version == "2.1" + assert args.output_dir == "custom-output" + assert args.domains == ["enterprise-attack", "mobile-attack"] + assert args.markdown_file is True + assert args.html_file is True + assert args.attack_website_links is True + assert args.verbose is True + + +def test_get_parsed_args_allows_markdown_and_html_flags_without_values(monkeypatch): + """Markdown and HTML flags can be used as booleans with default filenames.""" + monkeypatch.setattr( + sys, + "argv", + [ + "attack_changelog", + "--old-version", + "17.1", + "--new-version", + "18.0", + "--markdown-file", + "--html-file", + ], + ) + + args = attack_changelog.get_parsed_args() + + assert args.markdown_file is True + assert args.html_file is True + + +def test_get_parsed_args_defaults_output_dir_and_omits_optional_outputs(monkeypatch): + """Default output directory should use the normalized release pair.""" + monkeypatch.setattr( + sys, + "argv", + ["attack_changelog", "--old-version", "17.1", "--new-version", "18.0"], + ) + + args = attack_changelog.get_parsed_args() + + assert args.output_dir == "output/v17.1-v18.0" + assert args.stix_version == "2.0" + assert args.domains == ["enterprise-attack", "mobile-attack", "ics-attack"] + assert args.markdown_file is False + assert args.html_file is False + + +def test_changelog_request_normalizes_versions_and_defaults(): + """Request normalization should be shared by the CLI and Python API.""" + request = attack_changelog.ChangelogRequest.create(old_version="17.1", new_version="v18.0") + + assert request.old_version == "v17.1" + assert request.new_version == "v18.0" + assert request.output_dir == "output/v17.1-v18.0" + assert request.domains == ["enterprise-attack", "mobile-attack", "ics-attack"] + + +def test_changelog_request_uses_default_domains_for_empty_domain_list(): + """An empty domain list should resolve to the default domain set.""" + request = attack_changelog.ChangelogRequest.create(old_version="17.1", new_version="18.0", domains=[]) + + assert request.domains == ["enterprise-attack", "mobile-attack", "ics-attack"] + + +def test_changelog_request_deduplicates_domains_while_preserving_order(): + """Duplicate domains should be removed without reordering the caller's choices.""" + request = attack_changelog.ChangelogRequest.create( + old_version="17.1", + new_version="18.0", + domains=["mobile-attack", "enterprise-attack", "mobile-attack"], + ) + + assert request.domains == ["mobile-attack", "enterprise-attack"] + + +def test_changelog_request_rejects_invalid_domains(): + """Invalid API domains should fail with a clear request-level error.""" + with pytest.raises(ValueError) as exc_info: + attack_changelog.ChangelogRequest.create( + old_version="17.1", + new_version="18.0", + domains=["bad-domain", "mobile-attack", "other-domain", "bad-domain"], + ) + + assert ( + str(exc_info.value) + == "Invalid ATT&CK domain(s): bad-domain, other-domain. Expected one of: enterprise-attack, mobile-attack, " + "ics-attack" + ) + + +def test_changelog_request_rejects_invalid_stix_version(): + """Invalid API STIX versions should fail with a clear request-level error.""" + with pytest.raises(ValueError) as exc_info: + attack_changelog.ChangelogRequest.create(old_version="17.1", new_version="18.0", stix_version="2.x") + + assert str(exc_info.value) == "Invalid STIX version: 2.x. Expected one of: 2.0, 2.1" + + +def test_changelog_artifacts_resolve_output_paths(): + """Artifact planning should keep output filename rules in one place.""" + request = attack_changelog.ChangelogRequest.create( + old_version="17.1", + new_version="18.0", + output_dir="custom", + markdown_file=True, + html_file=True, + attack_website_links=True, + ) + + artifacts = attack_changelog.ChangelogArtifacts.from_request(request) + + assert artifacts.layers == [ + "custom/layer-enterprise.json", + "custom/layer-mobile.json", + "custom/layer-ics.json", + ] + assert artifacts.markdown_file == "custom/changelog.md" + assert artifacts.html_file == "custom/index.html" + assert artifacts.html_file_detailed == "custom/changelog-detailed.html" + assert artifacts.json_file == "custom/changelog.json" + assert artifacts.additional_formats_prefix == "/docs/changelogs/v17.1-v18.0" + + +def test_generate_attack_changelog_uses_existing_local_release_dirs(tmp_path, monkeypatch): + """Existing release directories should be used without downloading.""" + local_root = tmp_path / "attack-releases" + old_dir = local_root / "stix-2.0" / "v17.1" + new_dir = local_root / "stix-2.0" / "v18.0" + old_dir.mkdir(parents=True) + new_dir.mkdir(parents=True) + calls = {} + + def fake_get_new_changelog_md(**kwargs): + calls["changelog"] = kwargs + return "markdown" + + def fake_download_domains(**kwargs): + calls.setdefault("downloads", []).append(kwargs) + + monkeypatch.setattr(attack_changelog, "get_new_changelog_md", fake_get_new_changelog_md) + monkeypatch.setattr(attack_changelog, "download_domains", fake_download_domains) + monkeypatch.chdir(tmp_path) + + result = attack_changelog.generate_attack_changelog(old_version="17.1", new_version="18.0") + + assert result == "markdown" + assert "downloads" not in calls + assert calls["changelog"]["old"] == str(old_dir) + assert calls["changelog"]["new"] == str(new_dir) + assert calls["changelog"]["markdown_file"] is None + assert calls["changelog"]["html_file"] is None + assert calls["changelog"]["html_file_detailed"] == "output/v17.1-v18.0/changelog-detailed.html" + assert calls["changelog"]["json_file"] == "output/v17.1-v18.0/changelog.json" + assert calls["changelog"]["layers"] == [ + "output/v17.1-v18.0/layer-enterprise.json", + "output/v17.1-v18.0/layer-mobile.json", + "output/v17.1-v18.0/layer-ics.json", + ] + assert calls["changelog"]["show_key"] is True + assert calls["changelog"]["include_contributors"] is True + + +def test_generate_attack_changelog_downloads_missing_release_to_temporary_directory(tmp_path, monkeypatch): + """Missing releases should be downloaded into a temporary STIX tree and cleaned up after use.""" + local_root = tmp_path / "attack-releases" + old_dir = local_root / "stix-2.0" / "v17.1" + old_dir.mkdir(parents=True) + calls = {} + + def fake_download_domains(**kwargs): + calls["download"] = kwargs + release_dir = Path(kwargs["download_dir"]) / "v18.0" + release_dir.mkdir(parents=True) + + def fake_get_new_changelog_md(**kwargs): + calls["changelog"] = kwargs + assert Path(kwargs["new"]).exists() + return "markdown" + + monkeypatch.setattr(attack_changelog, "download_domains", fake_download_domains) + monkeypatch.setattr(attack_changelog, "get_new_changelog_md", fake_get_new_changelog_md) + monkeypatch.chdir(tmp_path) + + result = attack_changelog.generate_attack_changelog(old_version="v17.1", new_version="v18.0") + + assert result == "markdown" + assert calls["download"]["domains"] == ["enterprise", "mobile", "ics"] + assert calls["download"]["all_versions"] is False + assert calls["download"]["stix_version"] == "2.0" + assert calls["download"]["attack_versions"] == ["18.0"] + assert calls["changelog"]["old"] == str(old_dir) + assert calls["changelog"]["new"].endswith("stix-2.0/v18.0") + assert not Path(calls["changelog"]["new"]).exists() + + +def test_generate_attack_changelog_passes_opt_in_markdown_html_and_website_links(tmp_path, monkeypatch): + """Markdown, simple HTML, and website links should be configurable.""" + (tmp_path / "attack-releases" / "stix-2.0" / "v17.1").mkdir(parents=True) + (tmp_path / "attack-releases" / "stix-2.0" / "v18.0").mkdir(parents=True) + calls = {} + + def fake_get_new_changelog_md(**kwargs): + calls["changelog"] = kwargs + return "markdown" + + monkeypatch.setattr(attack_changelog, "get_new_changelog_md", fake_get_new_changelog_md) + monkeypatch.chdir(tmp_path) + + attack_changelog.generate_attack_changelog( + old_version="17.1", + new_version="18.0", + output_dir="custom", + markdown_file=True, + html_file=True, + attack_website_links=True, + ) + + assert calls["changelog"]["markdown_file"] == "custom/changelog.md" + assert calls["changelog"]["html_file"] == "custom/index.html" + assert calls["changelog"]["additional_formats_prefix"] == "/docs/changelogs/v17.1-v18.0" + + +def test_generate_attack_changelog_uses_default_optional_output_filenames(tmp_path, monkeypatch): + """Boolean-style markdown and HTML flags should resolve under the output directory.""" + (tmp_path / "attack-releases" / "stix-2.0" / "v17.1").mkdir(parents=True) + (tmp_path / "attack-releases" / "stix-2.0" / "v18.0").mkdir(parents=True) + calls = {} + + def fake_get_new_changelog_md(**kwargs): + calls["changelog"] = kwargs + return "markdown" + + monkeypatch.setattr(attack_changelog, "get_new_changelog_md", fake_get_new_changelog_md) + monkeypatch.chdir(tmp_path) + + attack_changelog.generate_attack_changelog( + old_version="17.1", + new_version="18.0", + markdown_file=True, + html_file=True, + ) + + assert calls["changelog"]["markdown_file"] == "output/v17.1-v18.0/changelog.md" + assert calls["changelog"]["html_file"] == "output/v17.1-v18.0/index.html" + + +def test_generate_attack_changelog_maps_selected_domains_for_download_and_generation(tmp_path, monkeypatch): + """Selected domains should control downloads and changelog generation.""" + calls = {} + + def fake_download_domains(**kwargs): + calls["download"] = kwargs + for version in kwargs["attack_versions"]: + release_dir = Path(kwargs["download_dir"]) / f"v{version}" + release_dir.mkdir(parents=True) + + def fake_get_new_changelog_md(**kwargs): + calls["changelog"] = kwargs + return "markdown" + + monkeypatch.setattr(attack_changelog, "download_domains", fake_download_domains) + monkeypatch.setattr(attack_changelog, "get_new_changelog_md", fake_get_new_changelog_md) + monkeypatch.chdir(tmp_path) + + attack_changelog.generate_attack_changelog( + old_version="17.1", + new_version="18.0", + domains=["mobile-attack"], + ) + + assert calls["download"]["domains"] == ["mobile"] + assert calls["download"]["attack_versions"] == ["17.1", "18.0"] + assert calls["changelog"]["domains"] == ["mobile-attack"] + assert calls["changelog"]["layers"] == [ + "output/v17.1-v18.0/layer-enterprise.json", + "output/v17.1-v18.0/layer-mobile.json", + "output/v17.1-v18.0/layer-ics.json", + ] + + +def test_main_forwards_parsed_arguments(monkeypatch): + """The console entrypoint should pass parsed options to the generation API.""" + args = argparse.Namespace( + old_version="v17.1", + new_version="v18.0", + stix_version="2.1", + output_dir="custom-output", + domains=["mobile-attack", "enterprise-attack"], + markdown_file=True, + html_file=True, + attack_website_links=True, + verbose=True, + ) + calls = {} + + def fake_generate_attack_changelog(**kwargs): + calls["generate"] = kwargs + return "markdown" + + monkeypatch.setattr(attack_changelog, "get_parsed_args", lambda: args) + monkeypatch.setattr(attack_changelog, "generate_attack_changelog", fake_generate_attack_changelog) + + result = attack_changelog.main() + + assert result is None + assert calls["generate"] == { + "old_version": "v17.1", + "new_version": "v18.0", + "stix_version": "2.1", + "output_dir": "custom-output", + "domains": ["mobile-attack", "enterprise-attack"], + "markdown_file": True, + "html_file": True, + "attack_website_links": True, + "verbose": True, + }