diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml
index 27491b29..d490585e 100644
--- a/.github/workflows/lint-and-test.yml
+++ b/.github/workflows/lint-and-test.yml
@@ -47,4 +47,4 @@ jobs:
run: uv sync --all-extras
- name: Run pytest
- run: uv run pytest --cov=mitreattack
+ run: uv run pytest --cov=mitreattack --durations=20
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index fda140fd..e6d51516 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -51,11 +51,14 @@ Run `just` with no arguments to see all available commands. Here are the most co
```bash
just lint # Run pre-commit hooks (ruff format) on all files
-just test # Run tests
+just test # Run the full test suite, matching CI expectations
+just test-fast # Run the fast local subset, excluding integration and slow tests
just test-cov # Run tests with coverage report
just build # Build the package
```
+Use `just test-fast` while iterating locally on changes that do not need full STIX-backed export or other slow integration coverage. Tests or setup steps that normally take longer than 10 seconds should be marked `slow`, so they are skipped by `just test-fast`. Before opening a PR, run `just test`; GitHub Actions also runs the full suite with coverage.
+
To run STIX-backed tests against specific local bundles, pass the bundle paths to pytest:
```bash
diff --git a/justfile b/justfile
index 85544c9f..f5ab2ded 100644
--- a/justfile
+++ b/justfile
@@ -35,6 +35,10 @@ ruff-format:
test:
uv run pytest
+# Run the fast local test subset, excluding integration and slow tests
+test-fast:
+ uv run pytest -m "not integration and not slow"
+
# Run tests with coverage
test-cov:
uv run pytest --cov=mitreattack
diff --git a/mitreattack/attackToExcel/README.md b/mitreattack/attackToExcel/README.md
index b3920890..f86ee61b 100644
--- a/mitreattack/attackToExcel/README.md
+++ b/mitreattack/attackToExcel/README.md
@@ -10,19 +10,40 @@ It also provides a means to access ATT&CK data as [Pandas](https://pandas.pydata
Print full usage instructions:
```shell
-python3 attackToExcel.py -h
+attack-to-excel --help
```
Example execution:
```shell
-python3 attackToExcel.py
+attack-to-excel from-stix
```
Build a excel files corresponding to a specific domain and version of ATT&CK:
```shell
-python3 attackToExcel -domain mobile-attack -version v5.0
+attack-to-excel from-stix --domain mobile-attack --version v5.0
+```
+
+Build Excel files for all ATT&CK domains from a release. If local STIX files
+are missing under `attack-releases/stix-2.0/v19.0`, they are downloaded
+temporarily for the export:
+
+```shell
+attack-to-excel from-release --version v19.0
+```
+
+To persist release STIX files before exporting, use `download_attack_stix`:
+
+```shell
+download_attack_stix -v 19.0
+attack-to-excel from-release --version v19.0
+```
+
+Build Excel files for selected ATT&CK domains from a release:
+
+```shell
+attack-to-excel from-release --version v19.0 --domains mobile-attack --domains ics-attack
```
### Module
@@ -35,6 +56,14 @@ import mitreattack.attackToExcel.attackToExcel as attackToExcel
attackToExcel.export("mobile-attack", "v5.0", "/path/to/export/folder")
```
+Example execution targeting all release domains:
+
+```python
+import mitreattack.attackToExcel.attackToExcel as attackToExcel
+
+attackToExcel.export_release(version="v19.0", output_dir="output")
+```
+
## Interfaces
### attackToExcel
@@ -48,6 +77,7 @@ overview of the available methods follows.
|build_dataframes| `src`: MemoryStore or other stix2 DataSource object holding domain data
`domain`: domain of ATT&CK that `src` corresponds to| Builds a Pandas DataFrame collection as a dictionary, with keys for each type, based on the ATT&CK data provided|
|write_excel| `dataframes`: pandas DataFrame dictionary (generated by build_dataframes)
`domain`: domain of ATT&CK that `dataframes` corresponds to
`version`: optional parameter indicating which version of ATT&CK is in use
`output_dir`: optional parameter specifying output directory| Writes out DataFrame based ATT&CK data to excel files|
|export| `domain`: the domain of ATT&CK to download
`version`: optional parameter specifying which version of ATT&CK to download
`output_dir`: optional parameter specifying output directory| Downloads ATT&CK data from MITRE/CTI and exports it to Excel spreadsheets |
+|export_release| `version`: optional ATT&CK release version
`stix_version`: STIX release tree, such as "2.0" or "2.1"
`output_dir`: parent output directory
`stix_base_dir`: optional directory containing release STIX files
`domains`: optional list of domains
`versioned_output_dir`: preserve domain-version output folders| Exports a full ATT&CK release to Excel spreadsheets, downloading missing STIX files temporarily when needed |
### stixToDf
diff --git a/mitreattack/attackToExcel/attackToExcel.py b/mitreattack/attackToExcel/attackToExcel.py
index b9ccf0b6..b69c0eb0 100644
--- a/mitreattack/attackToExcel/attackToExcel.py
+++ b/mitreattack/attackToExcel/attackToExcel.py
@@ -1,20 +1,216 @@
-"""Functions to convert ATT&CK STIX data to Excel, as well as entrypoint for attackToExcel_cli."""
+"""Functions to convert ATT&CK STIX data to Excel, as well as entrypoint for attack-to-excel."""
-import argparse
import os
import re
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
from typing import Dict, List, Optional
import pandas as pd
import requests
+import typer
from loguru import logger
from stix2 import MemoryStore
+from typing_extensions import Annotated
+
+from mitreattack import release_info
# import mitreattack.attackToExcel.stixToDf as stixToDf
from mitreattack.attackToExcel import stixToDf
+from mitreattack.download_stix import download_domains
INVALID_CHARACTERS = ["\\", "/", "*", "[", "]", ":", "?"]
SUB_CHARACTERS = ["\\", "/"]
+ATTACK_RELEASES_DIR = Path("attack-releases")
+
+
+@dataclass(frozen=True)
+class DomainConfig:
+ """Domain-specific names for STIX downloads and Excel exports."""
+
+ download_name: str
+
+
+DOMAIN_CONFIGS = {
+ "enterprise-attack": DomainConfig(download_name="enterprise"),
+ "mobile-attack": DomainConfig(download_name="mobile"),
+ "ics-attack": DomainConfig(download_name="ics"),
+}
+ATTACK_DOMAINS = tuple(DOMAIN_CONFIGS)
+VALID_STIX_VERSIONS = ("2.0", "2.1")
+app = typer.Typer(
+ add_completion=False,
+ no_args_is_help=True,
+ help="Download ATT&CK data from MITRE/CTI and convert it to excel spreadsheets.",
+)
+
+
+def normalize_attack_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 _version_without_prefix(version: str) -> str:
+ """Return an ATT&CK release version without the leading ``v`` folder prefix."""
+ return normalize_attack_version(version).removeprefix("v")
+
+
+def _default_release_dir(version: str, stix_version: str) -> Path:
+ """Return the default local STIX release directory."""
+ return ATTACK_RELEASES_DIR / f"stix-{stix_version}" / normalize_attack_version(version)
+
+
+def _validate_release_domains(domains: Optional[List[str]]) -> List[str]:
+ """Return validated ATT&CK release export domains."""
+ if not domains:
+ return list(ATTACK_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(ATTACK_DOMAINS)
+ raise ValueError(f"Invalid ATT&CK domain(s): {invalid_domains_text}. Expected one of: {expected_domains_text}")
+
+ return normalized_domains
+
+
+def _release_stix_file(release_dir: Path, domain: str) -> Path:
+ """Return the expected STIX bundle path for a domain in a release directory."""
+ return release_dir / f"{domain}.json"
+
+
+def _move_versioned_exports_to_domain_dir(output_dir: Path, domain: str, version: str):
+ """Move versioned Excel exports into the unversioned domain folder."""
+ versioned_dir = output_dir / f"{domain}-{version}"
+ domain_dir = output_dir / domain
+
+ if not versioned_dir.is_dir():
+ return
+
+ domain_dir.mkdir(parents=True, exist_ok=True)
+ for source_path in versioned_dir.iterdir():
+ if not source_path.is_file():
+ continue
+
+ target_path = domain_dir / source_path.name
+ if target_path.exists():
+ target_path.unlink()
+ source_path.replace(target_path)
+
+ versioned_dir.rmdir()
+
+
+def _download_missing_release_domains(
+ *,
+ missing_domains: List[str],
+ version: str,
+ stix_version: str,
+ temporary_directory: str,
+) -> Path:
+ """Download missing STIX domain bundles into a temporary release tree."""
+ temp_stix_dir = Path(temporary_directory) / f"stix-{stix_version}"
+ download_domains(
+ domains=[DOMAIN_CONFIGS[domain].download_name for domain in missing_domains],
+ download_dir=str(temp_stix_dir),
+ all_versions=False,
+ stix_version=stix_version,
+ attack_versions=[_version_without_prefix(version)],
+ )
+ return temp_stix_dir / normalize_attack_version(version)
+
+
+def export_release(
+ version: Optional[str] = None,
+ stix_version: str = "2.0",
+ output_dir: str = "output",
+ stix_base_dir: Optional[str] = None,
+ domains: Optional[List[str]] = None,
+ versioned_output_dir: bool = False,
+):
+ """Export one ATT&CK release to Excel for one or more domains."""
+ 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}")
+
+ has_explicit_local_stix_base_dir = stix_base_dir is not None or os.environ.get("STIX_BASE_DIR") is not None
+ attack_version = normalize_attack_version(version) if version else None
+ release_version = attack_version or normalize_attack_version(release_info.LATEST_VERSION)
+ release_domains = _validate_release_domains(domains)
+ local_release_dir = Path(
+ stix_base_dir or os.environ.get("STIX_BASE_DIR") or _default_release_dir(release_version, stix_version)
+ )
+ local_release_dir = local_release_dir.resolve()
+ release_output_dir = (
+ Path(output_dir)
+ if has_explicit_local_stix_base_dir and attack_version is None
+ else Path(output_dir) / release_version
+ )
+
+ local_stix_files = {domain: _release_stix_file(local_release_dir, domain) for domain in release_domains}
+ missing_domains = [domain for domain, stix_file in local_stix_files.items() if not stix_file.is_file()]
+
+ if not missing_domains:
+ _export_release_domains(
+ version=attack_version,
+ output_dir=release_output_dir,
+ stix_files=local_stix_files,
+ versioned_output_dir=versioned_output_dir,
+ )
+ return
+
+ if attack_version is None:
+ missing_domains_text = ", ".join(missing_domains)
+ raise FileNotFoundError(
+ f"Missing local STIX file(s) for domain(s): {missing_domains_text}. "
+ "Pass --version to download missing ATT&CK release bundles."
+ )
+
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ temporary_release_dir = _download_missing_release_domains(
+ missing_domains=missing_domains,
+ version=attack_version,
+ stix_version=stix_version,
+ temporary_directory=temporary_directory,
+ )
+ stix_files = {
+ domain: local_stix_files[domain]
+ if domain not in missing_domains
+ else _release_stix_file(temporary_release_dir, domain)
+ for domain in release_domains
+ }
+ _export_release_domains(
+ version=attack_version,
+ output_dir=release_output_dir,
+ stix_files=stix_files,
+ versioned_output_dir=versioned_output_dir,
+ )
+
+
+def _export_release_domains(
+ *,
+ version: Optional[str],
+ output_dir: Path,
+ stix_files: Dict[str, Path],
+ versioned_output_dir: bool,
+):
+ """Export resolved release STIX files to Excel."""
+ for domain, stix_file in stix_files.items():
+ logger.info(f"Exporting {domain} to Excel from {stix_file}")
+ export(domain=domain, version=version, output_dir=str(output_dir), stix_file=str(stix_file))
+
+ if not versioned_output_dir:
+ _move_versioned_exports_to_domain_dir(output_dir=output_dir, domain=domain, version=version)
def get_stix_data(
@@ -409,48 +605,135 @@ def export(
write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir)
-def main():
- """Entrypoint for attackToExcel_cli."""
- parser = argparse.ArgumentParser(
- description="Download ATT&CK data from MITRE/CTI and convert it to excel spreadsheets"
- )
- parser.add_argument(
- "-domain",
- type=str,
- choices=["enterprise-attack", "mobile-attack", "ics-attack"],
- default="enterprise-attack",
- help="which domain of ATT&CK to convert",
- )
- parser.add_argument(
- "-version",
- type=str,
- help="which version of ATT&CK to convert. If omitted, builds the latest version",
- )
- parser.add_argument(
- "-output",
- type=str,
- default=".",
- help="output directory. If omitted writes to a subfolder of the current directory depending on "
- "the domain and version",
- )
- parser.add_argument(
- "-remote",
- type=str,
- default=None,
- help="remote url of an ATT&CK workbench server.",
- )
- parser.add_argument(
- "-stix-file",
- type=str,
- default=None,
- help="Path to a local STIX file containing ATT&CK data for a domain, by default None",
- )
- args = parser.parse_args()
+def _validate_cli_value(value: str, allowed_values: tuple[str, ...], label: str) -> str:
+ """Return a CLI value after validating it against an allowed set."""
+ if value not in allowed_values:
+ allowed_values_text = ", ".join(allowed_values)
+ raise typer.BadParameter(f"Invalid {label}: {value}. Expected one of: {allowed_values_text}")
+ return value
+
+
+@app.command("from-stix")
+def from_stix_cli(
+ domain: Annotated[
+ str,
+ typer.Option(
+ "--domain",
+ help="ATT&CK domain STIX bundle to convert.",
+ ),
+ ] = "enterprise-attack",
+ version: Annotated[
+ Optional[str],
+ typer.Option(
+ "--version",
+ help="Which version of ATT&CK to convert. If omitted, builds the latest version.",
+ ),
+ ] = None,
+ output: Annotated[
+ str,
+ typer.Option(
+ "--output",
+ help=(
+ "Output directory. If omitted writes to a subfolder of the current directory depending on the domain "
+ "and version."
+ ),
+ ),
+ ] = ".",
+ remote: Annotated[
+ Optional[str],
+ typer.Option(
+ "--remote",
+ help="Remote URL of an ATT&CK Workbench server.",
+ ),
+ ] = None,
+ stix_file: Annotated[
+ Optional[str],
+ typer.Option(
+ "--stix-file",
+ help="Path to a local STIX file containing ATT&CK data for a domain.",
+ ),
+ ] = None,
+):
+ """Convert one ATT&CK domain STIX bundle to Excel."""
+ domain = _validate_cli_value(domain, ATTACK_DOMAINS, "ATT&CK domain")
+
+ if remote and stix_file:
+ raise typer.BadParameter("--remote and --stix-file are mutually exclusive")
export(
- domain=args.domain, version=args.version, output_dir=args.output, remote=args.remote, stix_file=args.stix_file
+ domain=domain,
+ version=version,
+ output_dir=output,
+ remote=remote,
+ stix_file=stix_file,
)
+@app.command("from-release")
+def from_release_cli(
+ version: Annotated[
+ Optional[str],
+ typer.Option(
+ "--version",
+ help="Which ATT&CK release version to convert. If omitted, builds the latest version.",
+ ),
+ ] = None,
+ domains: Annotated[
+ Optional[List[str]],
+ typer.Option(
+ "--domains",
+ help="ATT&CK release domain to include. Can be specified multiple times.",
+ ),
+ ] = None,
+ stix_version: Annotated[
+ str,
+ typer.Option(
+ "--stix-version",
+ help="STIX release tree to use.",
+ ),
+ ] = "2.0",
+ stix_base_dir: Annotated[
+ Optional[str],
+ typer.Option(
+ "--stix-base-dir",
+ help="Directory containing release STIX files.",
+ ),
+ ] = None,
+ output: Annotated[
+ str,
+ typer.Option(
+ "--output",
+ help="Parent output directory.",
+ ),
+ ] = "output",
+ versioned_output_dir: Annotated[
+ bool,
+ typer.Option(
+ "--versioned-output-dir",
+ help="Preserve domain-version output folders.",
+ ),
+ ] = False,
+):
+ """Convert ATT&CK release domain bundles to Excel."""
+ stix_version = _validate_cli_value(stix_version, VALID_STIX_VERSIONS, "STIX version")
+ selected_domains = [
+ _validate_cli_value(selected_domain, ATTACK_DOMAINS, "ATT&CK domain") for selected_domain in domains or []
+ ]
+
+ export_release(
+ version=version,
+ stix_version=stix_version,
+ output_dir=output,
+ stix_base_dir=stix_base_dir,
+ domains=selected_domains or None,
+ versioned_output_dir=versioned_output_dir,
+ )
+
+
+def main(argv=None):
+ """Entrypoint for attack-to-excel."""
+ app(args=argv, prog_name="attack-to-excel")
+
+
if __name__ == "__main__":
main()
diff --git a/mitreattack/diffStix/README.md b/mitreattack/diffStix/README.md
index 4d765225..6f4c3909 100644
--- a/mitreattack/diffStix/README.md
+++ b/mitreattack/diffStix/README.md
@@ -56,17 +56,17 @@ diff_stix -v --show-key --html-file output/changelog.html --html-file-detailed o
Generate release changelog artifacts for one ATT&CK version pair:
```shell
-attack_changelog --old-version 17.1 --new-version 18.0
+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.
+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 \
+attack-changelog --old-version 17.1 --new-version 18.0 \
--markdown-file \
--html-file
```
diff --git a/mitreattack/diffStix/attack_changelog.py b/mitreattack/diffStix/attack_changelog.py
index c387f625..aae16c06 100644
--- a/mitreattack/diffStix/attack_changelog.py
+++ b/mitreattack/diffStix/attack_changelog.py
@@ -159,7 +159,7 @@ def get_artifact_link_prefix(old_version: str, new_version: str, *, attack_websi
def get_parsed_args():
- """Parse command line arguments for the attack_changelog command."""
+ """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."
)
@@ -359,7 +359,7 @@ def generate_attack_changelog(
def main():
- """Entrypoint for the attack_changelog console command."""
+ """Entrypoint for the attack-changelog console command."""
args = get_parsed_args()
generate_attack_changelog(
old_version=args.old_version,
diff --git a/mitreattack/diffStix/changelog_helper.py b/mitreattack/diffStix/changelog_helper.py
index 99cd0404..fe88b70a 100644
--- a/mitreattack/diffStix/changelog_helper.py
+++ b/mitreattack/diffStix/changelog_helper.py
@@ -10,7 +10,7 @@
import textwrap
from dataclasses import dataclass
from pathlib import Path
-from typing import Dict, List, Optional
+from typing import Any, Dict, List, Optional
import markdown
import requests
@@ -1429,7 +1429,7 @@ def get_changes_dict(self):
"""Return dict format summarizing detected differences."""
logger.info("Generating changes info")
- changes_dict = {}
+ changes_dict: Dict[str, Any] = {}
for domain in self.domains:
changes_dict[domain] = {}
diff --git a/pyproject.toml b/pyproject.toml
index c6dfdf4a..88fa981d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,13 +41,13 @@ repository = "https://github.com/mitre-attack/mitreattack-python/"
documentation = "https://mitreattack-python.readthedocs.io/"
[project.scripts]
-attackToExcel_cli = 'mitreattack.attackToExcel.attackToExcel:main'
+attack-to-excel = 'mitreattack.attackToExcel.attackToExcel:main'
layerExporter_cli = 'mitreattack.navlayers.layerExporter_cli:main'
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'
+attack-changelog = 'mitreattack.diffStix.attack_changelog:main'
download_attack_stix = 'mitreattack.download_stix:app'
[project.optional-dependencies]
@@ -55,7 +55,7 @@ dev = [
"check-wheel-contents>=0.6.1",
"commitizen>=4.9.1",
"pre-commit>=4.0.0",
- "pytest>=8.4.2",
+ "pytest>=9.0",
"pytest-cov>=7.0.0",
"pytest-dotenv>=0.5.2",
"python-semantic-release>=10.5.0",
@@ -80,8 +80,13 @@ package = true
module-name = "mitreattack"
module-root = ""
-[tool.pytest.ini_options]
+[tool.pytest]
+minversion = "9.0"
testpaths = ["tests"]
+markers = [
+ "integration: tests that exercise real external-size data or full integration paths",
+ "slow: tests that are intentionally excluded from default fast runs",
+]
[tool.ruff]
line-length = 120
diff --git a/tests/changelog/cli/test_attack_changelog.py b/tests/changelog/cli/test_attack_changelog.py
index 94109adc..e271ca83 100644
--- a/tests/changelog/cli/test_attack_changelog.py
+++ b/tests/changelog/cli/test_attack_changelog.py
@@ -1,4 +1,4 @@
-"""Tests for the attack_changelog CLI wrapper."""
+"""Tests for the attack-changelog CLI wrapper."""
import argparse
import sys
@@ -17,7 +17,7 @@ def test_normalize_release_version_accepts_plain_and_prefixed_versions():
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"])
+ monkeypatch.setattr(sys, "argv", ["attack-changelog"])
with pytest.raises(SystemExit):
attack_changelog.get_parsed_args()
@@ -29,7 +29,7 @@ def test_get_parsed_args_defaults_and_options(monkeypatch):
sys,
"argv",
[
- "attack_changelog",
+ "attack-changelog",
"--old-version",
"17.1",
"--new-version",
@@ -67,7 +67,7 @@ def test_get_parsed_args_allows_markdown_and_html_flags_without_values(monkeypat
sys,
"argv",
[
- "attack_changelog",
+ "attack-changelog",
"--old-version",
"17.1",
"--new-version",
@@ -88,7 +88,7 @@ def test_get_parsed_args_defaults_output_dir_and_omits_optional_outputs(monkeypa
monkeypatch.setattr(
sys,
"argv",
- ["attack_changelog", "--old-version", "17.1", "--new-version", "18.0"],
+ ["attack-changelog", "--old-version", "17.1", "--new-version", "18.0"],
)
args = attack_changelog.get_parsed_args()
diff --git a/tests/changelog/conftest.py b/tests/changelog/conftest.py
index 11344a44..a3d415de 100644
--- a/tests/changelog/conftest.py
+++ b/tests/changelog/conftest.py
@@ -1,1946 +1,808 @@
-"""Shared fixtures for changelog helper tests.
-
-This file contains fixtures specific to changelog_helper.py testing functionality.
-Shared fixtures (STIX data, layers, etc.) are imported from the parent conftest.py.
-"""
-
-import json
-import uuid
-from datetime import datetime
-from pathlib import Path
-from unittest.mock import Mock
-
-import pytest
-
-from mitreattack.diffStix.changelog_helper import DiffStix
-
-# Import test utilities
-from tests.changelog.test_utils import (
- assert_basic_markdown_structure,
- assert_diffstix_data_structure_valid,
- assert_json_structure_valid,
- assert_layer_structure_valid,
- create_layer_file_paths,
- create_test_output_file_paths,
- validate_comprehensive_output_generation,
- validate_json_file_content,
- validate_layer_file_content,
- validate_markdown_file_content,
- validate_output_format_consistency,
-)
-
-# Import shared fixtures from parent conftest.py
-# These fixtures are used by pytest's fixture discovery system even though they appear unused
-from tests.conftest import (
- # Core data infrastructure (used by multiple test suites)
- _download_attack_stix_data,
- attack_stix_dir,
- layer_v3_all,
- layer_v43,
- memstore_enterprise_latest,
- memstore_ics_latest,
- memstore_mobile_latest,
- mitre_attack_data_enterprise,
- mitre_attack_data_ics,
- mitre_attack_data_mobile,
- stix_file_enterprise_latest,
- stix_file_ics_latest,
- stix_file_mobile_latest,
-)
-
-# Export imported fixtures for pytest discovery
-__all__ = [
- "attack_stix_dir",
- "stix_file_enterprise_latest",
- "stix_file_mobile_latest",
- "stix_file_ics_latest",
- "memstore_enterprise_latest",
- "memstore_mobile_latest",
- "memstore_ics_latest",
- "mitre_attack_data_enterprise",
- "mitre_attack_data_mobile",
- "mitre_attack_data_ics",
- "layer_v3_all",
- "layer_v43",
- "mitre_identity",
- "mitre_marking_definition",
- "mock_stix_object_factory",
- "mock_relationship_factory",
- "sample_technique_object",
- "sample_subtechnique_object",
- "sample_malware_object",
- "sample_tool_object",
- "sample_group_object",
- "sample_mitigation_object",
- "sample_campaign_object",
- "sample_data_source_object",
- "sample_data_component_object",
- "sample_asset_object",
- "sample_group_uses_malware_relationship",
- "sample_group_uses_tool_relationship",
- "sample_group_uses_technique_relationship",
- "sample_malware_uses_technique_relationship",
- "sample_tool_uses_technique_relationship",
- "sample_campaign_uses_malware_relationship",
- "sample_campaign_uses_tool_relationship",
- "sample_campaign_uses_technique_relationship",
- "sample_campaign_attributed_to_group_relationship",
- "sample_mitigation_mitigates_technique_relationship",
- "sample_subtechnique_of_technique_relationship",
- "sample_data_component_detects_technique_relationship",
- "sample_technique_targets_asset_relationship",
- "sample_revoked_by_relationship",
- "diffstix_with_version_scenarios",
- "empty_changes_diffstix",
- "large_dataset_diffstix",
- "setup_test_directories",
- # Enhanced assertion helper fixtures
- "assert_markdown_structure",
- "assert_json_structure",
- "assert_layer_structure",
- "assert_diffstix_structure",
- "validate_comprehensive_outputs",
- "validate_format_consistency",
- # File path creation helper fixtures
- "create_output_paths",
- "create_layer_paths",
- # File validation helper fixtures
- "validate_markdown_file",
- "validate_json_file",
- "validate_layer_file",
- # CLI argument testing helper fixtures
- "setup_monkeypatch_args",
-]
-
-
-# ========================================
-# Standard ATT&CK STIX Object Constants
-# ========================================
-
-# Standard MITRE identity object used across all ATT&CK objects
-MITRE_IDENTITY_ID = "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5"
-MITRE_IDENTITY = {
- "type": "identity",
- "id": MITRE_IDENTITY_ID,
- "name": "The MITRE Corporation",
- "description": "",
- "created": "2017-06-01T00:00:00.000Z",
- "modified": "2025-03-19T15:00:40.855Z",
- "identity_class": "organization",
- "object_marking_refs": ["marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168"],
- "x_mitre_attack_spec_version": "3.2.0",
-}
-
-# Standard marking definition (copyright year should be updated annually)
-MITRE_MARKING_DEFINITION_ID = "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168"
-MITRE_MARKING_DEFINITION = {
- "type": "marking-definition",
- "id": MITRE_MARKING_DEFINITION_ID,
- "definition": {
- "statement": "Copyright 2015-2025, The MITRE Corporation. MITRE ATT&CK and ATT&CK are registered trademarks of The MITRE Corporation."
- },
- "created": "2017-06-01T00:00:00.000Z",
- "created_by_ref": "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5",
- "definition_type": "statement",
-}
-
-# Valid ATT&CK relationship types and their allowed source/target combinations
-ATTACK_RELATIONSHIP_RULES = {
- "uses": [
- ("intrusion-set", "malware"),
- ("intrusion-set", "tool"),
- ("intrusion-set", "attack-pattern"),
- ("malware", "attack-pattern"),
- ("tool", "attack-pattern"),
- ("campaign", "malware"),
- ("campaign", "tool"),
- ("campaign", "attack-pattern"),
- ],
- "attributed-to": [("campaign", "intrusion-set")],
- "mitigates": [("course-of-action", "attack-pattern")],
- "subtechnique-of": [("attack-pattern", "attack-pattern")],
- "detects": [("x-mitre-data-component", "attack-pattern")],
- "targets": [("attack-pattern", "x-mitre-asset")],
- "revoked-by": [
- ("any", "any") # Any type can be revoked by same type
- ],
-}
-
-
-# ========================================
-# Standard ATT&CK STIX Object Fixtures
-# ========================================
-
-
-@pytest.fixture
-def mitre_identity():
- """Return the standard MITRE identity object used across all ATT&CK objects."""
- return MITRE_IDENTITY.copy()
-
-
-@pytest.fixture
-def mitre_marking_definition():
- """Return the standard ATT&CK marking definition object."""
- return MITRE_MARKING_DEFINITION.copy()
-
-
-# ========================================
-# Enhanced Mock Object and Relationship Factory Fixtures
-# ========================================
-
-
-@pytest.fixture
-def mock_stix_object_factory():
- """Create accurate STIX 2.0 compliant ATT&CK objects with configurable parameters.
-
- This factory generates STIX objects that closely match the structure and fields
- of real ATT&CK objects from the MITRE CTI repository.
- """
-
- def _create_stix_object(
- stix_type="attack-pattern",
- name="Test Object",
- attack_id="T9999",
- stix_id=None,
- version="1.0",
- created=None,
- modified=None,
- revoked=False,
- deprecated=False,
- contributors=None,
- obj_type=None,
- external_refs=None,
- kill_chain_phases=None,
- is_subtechnique=None,
- platforms=None,
- domains=None,
- aliases=None,
- labels=None,
- attack_spec_version="3.2.0",
- **kwargs,
- ):
- # Generate unique ID if not provided
- if stix_id is None:
- stix_id = f"{(obj_type or stix_type)}--{uuid.uuid4()}"
-
- # Generate realistic timestamps
- default_created = created or datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
- default_modified = modified or default_created
-
- # Auto-detect subtechnique if not explicitly specified
- if is_subtechnique is None and attack_id and "." in attack_id:
- is_subtechnique = True
-
- # Base STIX 2.0 object structure
- obj = {
- "type": obj_type or stix_type,
- "id": stix_id,
- "spec_version": "2.0",
- "created": default_created,
- "modified": default_modified,
- "created_by_ref": MITRE_IDENTITY_ID,
- "name": name,
- "description": f"Description for {name}",
- "object_marking_refs": [MITRE_MARKING_DEFINITION_ID],
- "x_mitre_attack_spec_version": attack_spec_version,
- "x_mitre_version": version,
- "x_mitre_modified_by_ref": MITRE_IDENTITY_ID,
- }
-
- # Add revoked/deprecated status
- if revoked:
- obj["revoked"] = True
- if deprecated:
- obj["x_mitre_deprecated"] = True
-
- # Add domains (default to enterprise)
- if domains is None:
- domains = ["enterprise-attack"]
- obj["x_mitre_domains"] = domains
-
- # Add contributors if provided
- if contributors:
- obj["x_mitre_contributors"] = contributors
-
- # Object type-specific fields
- effective_type = obj_type or stix_type
-
- if effective_type == "attack-pattern":
- _add_attack_pattern_fields(obj, attack_id, is_subtechnique, kill_chain_phases, platforms)
- elif effective_type == "intrusion-set":
- _add_intrusion_set_fields(obj, attack_id, aliases)
- elif effective_type in ["malware", "tool"]:
- _add_software_fields(obj, attack_id, effective_type, aliases, platforms, labels)
- elif effective_type == "course-of-action":
- _add_mitigation_fields(obj, attack_id)
- elif effective_type == "campaign":
- _add_campaign_fields(obj, attack_id, aliases)
- elif effective_type.startswith("x-mitre-"):
- _add_custom_mitre_fields(obj, attack_id, effective_type)
-
- # Add external references
- if external_refs is not None:
- obj["external_references"] = external_refs
- elif attack_id:
- obj["external_references"] = _generate_external_references(attack_id, effective_type, is_subtechnique)
-
- # Apply any additional custom fields
- obj.update(kwargs)
-
- return obj
-
- def _add_attack_pattern_fields(obj, attack_id, is_subtechnique, kill_chain_phases, platforms):
- """Add attack-pattern specific fields."""
- if is_subtechnique:
- obj["x_mitre_is_subtechnique"] = True
-
- # Default kill chain phases for techniques
- if kill_chain_phases is None:
- kill_chain_phases = [{"kill_chain_name": "mitre-attack", "phase_name": "execution"}]
- obj["kill_chain_phases"] = kill_chain_phases
-
- # Default platforms
- if platforms is None:
- platforms = ["Windows", "macOS", "Linux"]
- obj["x_mitre_platforms"] = platforms
-
- # Add typical technique fields
- obj["x_mitre_data_sources"] = ["Process: Process Creation", "Command: Command Execution"]
- obj["x_mitre_detection"] = f"Detection guidance for {obj['name']}"
-
- def _add_intrusion_set_fields(obj, attack_id, aliases):
- """Add intrusion-set specific fields."""
- if aliases is None:
- aliases = [obj["name"]]
- obj["aliases"] = aliases
-
- def _add_software_fields(obj, attack_id, software_type, aliases, platforms, labels):
- """Add malware/tool specific fields."""
- if labels is None:
- labels = [software_type]
- obj["labels"] = labels
-
- if aliases:
- obj["x_mitre_aliases"] = aliases
-
- if platforms is None:
- platforms = ["Windows"]
- obj["x_mitre_platforms"] = platforms
-
- def _add_mitigation_fields(obj, attack_id):
- """Add course-of-action specific fields."""
- # Mitigations don't have additional special fields beyond the base ones
- pass
-
- def _add_campaign_fields(obj, attack_id, aliases):
- """Add campaign specific fields."""
- if aliases:
- obj["aliases"] = aliases
-
- def _add_custom_mitre_fields(obj, attack_id, object_type):
- """Add fields for custom MITRE object types."""
- # These objects have varying structures - add basic fields
- if object_type == "x-mitre-tactic":
- obj["x_mitre_shortname"] = attack_id.lower() if attack_id else "test-tactic"
-
- def _generate_external_references(attack_id, object_type, is_subtechnique):
- """Generate appropriate external references based on object type."""
- # Determine URL path based on object type
- if object_type in ["malware", "tool"]:
- url_path = "software"
- elif object_type == "intrusion-set":
- url_path = "groups"
- elif object_type == "course-of-action":
- url_path = "mitigations"
- elif object_type == "campaign":
- url_path = "campaigns"
- else:
- url_path = "techniques"
-
- # Handle subtechnique URL format
- if is_subtechnique and "." in attack_id:
- base_technique, sub_id = attack_id.split(".", 1)
- url = f"https://attack.mitre.org/{url_path}/{base_technique}/{sub_id}"
- else:
- url = f"https://attack.mitre.org/{url_path}/{attack_id}"
-
- return [
- {
- "source_name": "mitre-attack",
- "external_id": attack_id,
- "url": url,
- }
- ]
-
- return _create_stix_object
-
-
-@pytest.fixture
-def mock_relationship_factory():
- """Create accurate STIX 2.0 compliant ATT&CK relationship objects.
-
- This factory generates relationship objects that match the structure
- of real ATT&CK relationships and validates relationship types.
- """
-
- def _create_relationship(
- source_ref=None,
- target_ref=None,
- relationship_type="uses",
- source_name="mitre-attack",
- relationship_id=None,
- created=None,
- modified=None,
- description=None,
- external_refs=None,
- attack_spec_version="3.2.0",
- validate_relationship=True,
- **kwargs,
- ):
- # Generate default source/target refs if not provided
- if source_ref is None:
- source_ref = f"attack-pattern--{uuid.uuid4()}"
- if target_ref is None:
- if relationship_type == "mitigates":
- target_ref = f"attack-pattern--{uuid.uuid4()}"
- elif relationship_type == "uses":
- target_ref = f"attack-pattern--{uuid.uuid4()}"
- else:
- target_ref = f"attack-pattern--{uuid.uuid4()}"
-
- # Validate relationship type if requested
- if validate_relationship:
- _validate_relationship_types(source_ref, target_ref, relationship_type)
-
- # Generate timestamps
- default_created = created or datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
- default_modified = modified or default_created
-
- # Generate unique ID
- if relationship_id is None:
- relationship_id = f"relationship--{uuid.uuid4()}"
-
- # Base STIX 2.0 relationship structure
- obj = {
- "type": "relationship",
- "id": relationship_id,
- "spec_version": "2.0",
- "created": default_created,
- "modified": default_modified,
- "created_by_ref": MITRE_IDENTITY_ID,
- "relationship_type": relationship_type,
- "source_ref": source_ref,
- "target_ref": target_ref,
- "object_marking_refs": [MITRE_MARKING_DEFINITION_ID],
- "x_mitre_attack_spec_version": attack_spec_version,
- "x_mitre_modified_by_ref": MITRE_IDENTITY_ID,
- }
-
- # Add description if provided
- if description:
- obj["description"] = description
-
- # Add external references
- if external_refs is not None:
- obj["external_references"] = external_refs
- else:
- obj["external_references"] = [
- {"source_name": source_name, "description": f"ATT&CK {relationship_type} relationship"}
- ]
-
- # Apply any additional custom fields
- obj.update(kwargs)
-
- return obj
-
- def _validate_relationship_types(source_ref, target_ref, relationship_type):
- """Validate that the relationship type is valid for the given source/target types."""
- # Extract types from STIX IDs
- source_type = source_ref.split("--")[0] if "--" in source_ref else source_ref
- target_type = target_ref.split("--")[0] if "--" in target_ref else target_ref
-
- # Check if relationship type exists in our rules
- if relationship_type not in ATTACK_RELATIONSHIP_RULES:
- # Allow unknown relationship types for flexibility in testing
- return
-
- # Check if the source/target combination is valid
- valid_combinations = ATTACK_RELATIONSHIP_RULES[relationship_type]
-
- # Special handling for "revoked-by" which allows any type
- if relationship_type == "revoked-by":
- return # Any combination is valid for revoked-by
-
- # Check if this specific combination is allowed
- for valid_source, valid_target in valid_combinations:
- if source_type == valid_source and target_type == valid_target:
- return
-
- # If we get here, the combination isn't explicitly allowed
- # For testing flexibility, we'll allow it but could add warnings
- pass
-
- return _create_relationship
-
-
-# ========================================
-# Mock DiffStix Fixtures
-# ========================================
-
-
-@pytest.fixture
-def diffstix_data():
- """Provide standard attack domains data structure for enterprise/mobile/ics."""
- return {
- "old": {
- "enterprise-attack": {
- "attack_objects": {
- "techniques": {},
- "software": {},
- "groups": {},
- "campaigns": {},
- "assets": {},
- "mitigations": {},
- "datasources": {},
- "datacomponents": {},
- },
- "relationships": {
- "subtechniques": {},
- "revoked-by": {},
- "mitigations": {},
- "detections": {},
- },
- "attack_release_version": "16.1",
- "stix_datastore": None,
- },
- "mobile-attack": {
- "attack_objects": {
- "techniques": {},
- "software": {},
- "groups": {},
- "campaigns": {},
- "assets": {},
- "mitigations": {},
- "datasources": {},
- "datacomponents": {},
- },
- "relationships": {
- "subtechniques": {},
- "revoked-by": {},
- "mitigations": {},
- "detections": {},
- },
- "attack_release_version": "16.1",
- "stix_datastore": None,
- },
- "ics-attack": {
- "attack_objects": {
- "techniques": {},
- "software": {},
- "groups": {},
- "campaigns": {},
- "assets": {},
- "mitigations": {},
- "datasources": {},
- "datacomponents": {},
- },
- "relationships": {
- "subtechniques": {},
- "revoked-by": {},
- "mitigations": {},
- "detections": {},
- },
- "attack_release_version": "16.1",
- "stix_datastore": None,
- },
- },
- "new": {
- "enterprise-attack": {
- "attack_objects": {
- "techniques": {},
- "software": {},
- "groups": {},
- "campaigns": {},
- "assets": {},
- "mitigations": {},
- "datasources": {},
- "datacomponents": {},
- },
- "relationships": {
- "subtechniques": {},
- "revoked-by": {},
- "mitigations": {},
- "detections": {},
- },
- "attack_release_version": "17.0",
- "stix_datastore": None,
- },
- "mobile-attack": {
- "attack_objects": {
- "techniques": {},
- "software": {},
- "groups": {},
- "campaigns": {},
- "assets": {},
- "mitigations": {},
- "datasources": {},
- "datacomponents": {},
- },
- "relationships": {
- "subtechniques": {},
- "revoked-by": {},
- "mitigations": {},
- "detections": {},
- },
- "attack_release_version": "17.0",
- "stix_datastore": None,
- },
- "ics-attack": {
- "attack_objects": {
- "techniques": {},
- "software": {},
- "groups": {},
- "campaigns": {},
- "assets": {},
- "mitigations": {},
- "datasources": {},
- "datacomponents": {},
- },
- "relationships": {
- "subtechniques": {},
- "revoked-by": {},
- "mitigations": {},
- "detections": {},
- },
- "attack_release_version": "17.0",
- "stix_datastore": None,
- },
- },
- "changes": {
- "techniques": {},
- "software": {},
- "groups": {},
- "campaigns": {},
- "assets": {},
- "mitigations": {},
- "datasources": {},
- "datacomponents": {},
- },
- }
-
-
-@pytest.fixture
-def mock_diffstix(diffstix_data):
- """Pre-configured DiffStix mock with standard data structures."""
- mock_diffstix = Mock(spec=DiffStix)
- mock_diffstix.data = diffstix_data.copy()
- mock_diffstix.types = [
- "techniques",
- "software",
- "groups",
- "campaigns",
- "assets",
- "mitigations",
- "datasources",
- "datacomponents",
- ]
- mock_diffstix.domains = ["enterprise-attack", "mobile-attack", "ics-attack"]
- mock_diffstix.verbose = False
- mock_diffstix.release_contributors = {}
- mock_diffstix.unchanged = False
- mock_diffstix.site_prefix = ""
- mock_diffstix.show_key = False
- mock_diffstix.include_contributors = False
-
- # Add domain and type mappings
- mock_diffstix.domain_to_domain_label = {
- "enterprise-attack": "Enterprise",
- "mobile-attack": "Mobile",
- "ics-attack": "ICS",
- }
- mock_diffstix.attack_type_to_title = {
- "techniques": "Techniques",
- "software": "Software",
- "groups": "Groups",
- "campaigns": "Campaigns",
- "assets": "Assets",
- "mitigations": "Mitigations",
- "datasources": "Data Sources",
- "datacomponents": "Data Components",
- }
- mock_diffstix.section_headers = {}
- for obj_type in mock_diffstix.types:
- mock_diffstix.section_headers[obj_type] = {
- "additions": f"New {mock_diffstix.attack_type_to_title[obj_type]}",
- "major_version_changes": "Major Version Changes",
- "minor_version_changes": "Minor Version Changes",
- "other_version_changes": "Other Version Changes",
- "patches": "Patches",
- "deprecations": "Deprecations",
- "revocations": "Revocations",
- "deletions": "Deletions",
- "unchanged": "Unchanged",
- }
-
- return mock_diffstix
-
-
-# ========================================
-# Mock STIX Object Fixtures
-# ========================================
-
-
-@pytest.fixture
-def sample_technique_object(mock_stix_object_factory):
- """Sample technique STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="attack-pattern",
- name="Test Technique",
- attack_id="T1234",
- version="1.0",
- kill_chain_phases=[{"kill_chain_name": "mitre-attack", "phase_name": "execution"}],
- platforms=["Windows", "macOS", "Linux"],
- )
-
-
-@pytest.fixture
-def sample_subtechnique_object(mock_stix_object_factory):
- """Sample subtechnique STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="attack-pattern",
- name="Test Subtechnique",
- attack_id="T1234.001",
- version="1.0",
- kill_chain_phases=[{"kill_chain_name": "mitre-attack", "phase_name": "execution"}],
- platforms=["Windows"],
- )
-
-
-@pytest.fixture
-def sample_malware_object(mock_stix_object_factory):
- """Sample malware STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="malware",
- name="Test Malware",
- attack_id="S1234",
- version="1.0",
- obj_type="malware",
- aliases=["TestMalware", "Evil Software"],
- platforms=["Windows", "Linux"],
- )
-
-
-@pytest.fixture
-def sample_tool_object(mock_stix_object_factory):
- """Sample tool STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="tool",
- name="Test Tool",
- attack_id="S5678",
- version="1.0",
- obj_type="tool",
- aliases=["TestTool", "Utility"],
- platforms=["Windows", "macOS", "Linux"],
- )
-
-
-@pytest.fixture
-def sample_group_object(mock_stix_object_factory):
- """Sample group STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="intrusion-set",
- name="Test Group",
- attack_id="G1234",
- version="1.0",
- obj_type="intrusion-set",
- aliases=["Test Group", "APT-Test", "Group X"],
- )
-
-
-@pytest.fixture
-def sample_mitigation_object(mock_stix_object_factory):
- """Sample mitigation STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="course-of-action",
- name="Test Mitigation",
- attack_id="M1234",
- version="1.0",
- obj_type="course-of-action",
- )
-
-
-@pytest.fixture
-def sample_campaign_object(mock_stix_object_factory):
- """Sample campaign STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="campaign",
- name="Test Campaign",
- attack_id="C1234",
- version="1.0",
- obj_type="campaign",
- aliases=["Operation Test", "Test Campaign"],
- )
-
-
-@pytest.fixture
-def sample_data_source_object(mock_stix_object_factory):
- """Sample data source STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="x-mitre-data-source",
- name="Test Data Source",
- attack_id="DS1234",
- version="1.0",
- obj_type="x-mitre-data-source",
- )
-
-
-@pytest.fixture
-def sample_data_component_object(mock_stix_object_factory):
- """Sample data component STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="x-mitre-data-component",
- name="Test Data Component",
- attack_id="DC1234",
- version="1.0",
- obj_type="x-mitre-data-component",
- )
-
-
-@pytest.fixture
-def sample_asset_object(mock_stix_object_factory):
- """Sample asset STIX object for testing."""
- return mock_stix_object_factory(
- stix_type="x-mitre-asset",
- name="Test Asset",
- attack_id="A1234",
- version="1.0",
- obj_type="x-mitre-asset",
- )
-
-
-# ========================================
-# Sample Relationship Fixtures
-# ========================================
-
-
-@pytest.fixture
-def sample_group_uses_malware_relationship(mock_relationship_factory, sample_group_object, sample_malware_object):
- """Sample relationship: intrusion-set uses malware."""
- return mock_relationship_factory(
- source_ref=sample_group_object["id"],
- target_ref=sample_malware_object["id"],
- relationship_type="uses",
- description=f"{sample_group_object['name']} uses {sample_malware_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_group_uses_tool_relationship(mock_relationship_factory, sample_group_object, sample_tool_object):
- """Sample relationship: intrusion-set uses tool."""
- return mock_relationship_factory(
- source_ref=sample_group_object["id"],
- target_ref=sample_tool_object["id"],
- relationship_type="uses",
- description=f"{sample_group_object['name']} uses {sample_tool_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_group_uses_technique_relationship(mock_relationship_factory, sample_group_object, sample_technique_object):
- """Sample relationship: intrusion-set uses attack-pattern."""
- return mock_relationship_factory(
- source_ref=sample_group_object["id"],
- target_ref=sample_technique_object["id"],
- relationship_type="uses",
- description=f"{sample_group_object['name']} uses {sample_technique_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_malware_uses_technique_relationship(
- mock_relationship_factory, sample_malware_object, sample_technique_object
-):
- """Sample relationship: malware uses attack-pattern."""
- return mock_relationship_factory(
- source_ref=sample_malware_object["id"],
- target_ref=sample_technique_object["id"],
- relationship_type="uses",
- description=f"{sample_malware_object['name']} uses {sample_technique_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_tool_uses_technique_relationship(mock_relationship_factory, sample_tool_object, sample_technique_object):
- """Sample relationship: tool uses attack-pattern."""
- return mock_relationship_factory(
- source_ref=sample_tool_object["id"],
- target_ref=sample_technique_object["id"],
- relationship_type="uses",
- description=f"{sample_tool_object['name']} uses {sample_technique_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_campaign_uses_malware_relationship(mock_relationship_factory, sample_campaign_object, sample_malware_object):
- """Sample relationship: campaign uses malware."""
- return mock_relationship_factory(
- source_ref=sample_campaign_object["id"],
- target_ref=sample_malware_object["id"],
- relationship_type="uses",
- description=f"{sample_campaign_object['name']} uses {sample_malware_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_campaign_uses_tool_relationship(mock_relationship_factory, sample_campaign_object, sample_tool_object):
- """Sample relationship: campaign uses tool."""
- return mock_relationship_factory(
- source_ref=sample_campaign_object["id"],
- target_ref=sample_tool_object["id"],
- relationship_type="uses",
- description=f"{sample_campaign_object['name']} uses {sample_tool_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_campaign_uses_technique_relationship(
- mock_relationship_factory, sample_campaign_object, sample_technique_object
-):
- """Sample relationship: campaign uses attack-pattern."""
- return mock_relationship_factory(
- source_ref=sample_campaign_object["id"],
- target_ref=sample_technique_object["id"],
- relationship_type="uses",
- description=f"{sample_campaign_object['name']} uses {sample_technique_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_campaign_attributed_to_group_relationship(
- mock_relationship_factory, sample_campaign_object, sample_group_object
-):
- """Sample relationship: campaign attributed-to intrusion-set."""
- return mock_relationship_factory(
- source_ref=sample_campaign_object["id"],
- target_ref=sample_group_object["id"],
- relationship_type="attributed-to",
- description=f"{sample_campaign_object['name']} attributed to {sample_group_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_mitigation_mitigates_technique_relationship(
- mock_relationship_factory, sample_mitigation_object, sample_technique_object
-):
- """Sample relationship: course-of-action mitigates attack-pattern."""
- return mock_relationship_factory(
- source_ref=sample_mitigation_object["id"],
- target_ref=sample_technique_object["id"],
- relationship_type="mitigates",
- description=f"{sample_mitigation_object['name']} mitigates {sample_technique_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_subtechnique_of_technique_relationship(
- mock_relationship_factory, sample_subtechnique_object, sample_technique_object
-):
- """Sample relationship: attack-pattern subtechnique-of attack-pattern."""
- return mock_relationship_factory(
- source_ref=sample_subtechnique_object["id"],
- target_ref=sample_technique_object["id"],
- relationship_type="subtechnique-of",
- description=f"{sample_subtechnique_object['name']} is a subtechnique of {sample_technique_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_data_component_detects_technique_relationship(
- mock_relationship_factory, sample_data_component_object, sample_technique_object
-):
- """Sample relationship: x-mitre-data-component detects attack-pattern."""
- return mock_relationship_factory(
- source_ref=sample_data_component_object["id"],
- target_ref=sample_technique_object["id"],
- relationship_type="detects",
- description=f"{sample_data_component_object['name']} detects {sample_technique_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_technique_targets_asset_relationship(
- mock_relationship_factory, sample_technique_object, sample_asset_object
-):
- """Sample relationship: attack-pattern targets x-mitre-asset."""
- return mock_relationship_factory(
- source_ref=sample_technique_object["id"],
- target_ref=sample_asset_object["id"],
- relationship_type="targets",
- description=f"{sample_technique_object['name']} targets {sample_asset_object['name']}",
- )
-
-
-@pytest.fixture
-def sample_revoked_by_relationship(mock_relationship_factory, sample_technique_object):
- """Sample relationship: attack-pattern revoked-by attack-pattern."""
- # Create a replacement technique for the revoked-by relationship
- replacement_technique = {
- "id": "attack-pattern--12345678-1234-5678-9abc-123456789012",
- "name": "Replacement Technique",
- }
- return mock_relationship_factory(
- source_ref=sample_technique_object["id"],
- target_ref=replacement_technique["id"],
- relationship_type="revoked-by",
- description=f"{sample_technique_object['name']} revoked by {replacement_technique['name']}",
- )
-
-
-# ========================================
-# Enhanced Fixtures for Advanced Coverage
-# Useful for HTML output and behavioral testing
-# ========================================
-
-
-@pytest.fixture
-def sample_deepdiff_data():
- """Sample DeepDiff output for testing detailed HTML generation."""
- return {
- "values_changed": {
- "root['description']": {"old_value": "Old description text", "new_value": "New description text"},
- "root['x_mitre_version']": {"old_value": "1.0", "new_value": "1.1"},
- },
- "iterable_item_added": {
- "root['kill_chain_phases'][1]": {"kill_chain_name": "mitre-attack", "phase_name": "persistence"}
- },
- "iterable_item_removed": {"root['x_mitre_platforms'][0]": "Windows"},
- "dictionary_item_added": {"root['x_mitre_data_sources']": ["Process monitoring"]},
- "dictionary_item_removed": {"root['old_field']": "removed_value"},
- }
-
-
-@pytest.fixture
-def complex_diffstix_with_all_changes(diffstix_data, mock_stix_object_factory):
- """DiffStix instance with all possible change types for comprehensive testing."""
- mock_diffstix = Mock(spec=DiffStix)
- mock_diffstix.data = diffstix_data.copy()
-
- # Add comprehensive test data for all change types
- test_objects = {
- # Technique with all relationship types
- "T1001": mock_stix_object_factory(
- name="Test Addition Technique", attack_id="T1001", stix_type="attack-pattern"
- ),
- "T1002": mock_stix_object_factory(
- name="Test Version Change Technique", attack_id="T1002", version="2.0", stix_type="attack-pattern"
- ),
- "T1003": mock_stix_object_factory(
- name="Test Revoked Technique", attack_id="T1003", revoked=True, stix_type="attack-pattern"
- ),
- "T1004": mock_stix_object_factory(
- name="Test Deprecated Technique", attack_id="T1004", deprecated=True, stix_type="attack-pattern"
- ),
- # Subtechnique
- "T1001.001": mock_stix_object_factory(
- name="Test Subtechnique", attack_id="T1001.001", stix_type="attack-pattern", is_subtechnique=True
- ),
- # Software
- "S1001": mock_stix_object_factory(
- name="Test Software", attack_id="S1001", stix_type="malware", obj_type="malware"
- ),
- }
-
- # Add revoked_by field to revoked objects
- revoking_object = mock_stix_object_factory(
- name="Replacement Technique", attack_id="T9999", stix_type="attack-pattern"
- )
- test_objects["T1003"]["revoked_by"] = revoking_object
-
- # Populate all change types
- for domain in ["enterprise-attack", "mobile-attack", "ics-attack"]:
- mock_diffstix.data["changes"]["techniques"] = {
- domain: {
- "additions": [test_objects["T1001"]],
- "major_version_changes": [test_objects["T1002"]],
- "minor_version_changes": [],
- "other_version_changes": [],
- "patches": [],
- "revocations": [test_objects["T1003"]],
- "deprecations": [test_objects["T1004"]],
- "deletions": [],
- "unchanged": [],
- }
- }
-
- mock_diffstix.data["changes"]["software"] = {
- domain: {
- "additions": [test_objects["S1001"]],
- "major_version_changes": [],
- "minor_version_changes": [],
- "other_version_changes": [],
- "patches": [],
- "revocations": [],
- "deprecations": [],
- "deletions": [],
- "unchanged": [],
- }
- }
-
- # Set up mock attributes
- mock_diffstix.domains = ["enterprise-attack", "mobile-attack", "ics-attack"]
- mock_diffstix.types = [
- "techniques",
- "software",
- "groups",
- "campaigns",
- "assets",
- "mitigations",
- "datasources",
- "datacomponents",
- ]
- mock_diffstix.site_prefix = "https://attack.mitre.org"
- mock_diffstix.show_key = True
- mock_diffstix.include_contributors = True
- mock_diffstix.release_contributors = {"Test Contributor": 1}
-
- # Add mappings
- mock_diffstix.domain_to_domain_label = {
- "enterprise-attack": "Enterprise",
- "mobile-attack": "Mobile",
- "ics-attack": "ICS",
- }
- mock_diffstix.attack_type_to_title = {
- "techniques": "Techniques",
- "software": "Software",
- "groups": "Groups",
- "campaigns": "Campaigns",
- "assets": "Assets",
- "mitigations": "Mitigations",
- "datasources": "Data Sources",
- "datacomponents": "Data Components",
- }
- mock_diffstix.section_headers = {}
- for obj_type in mock_diffstix.types:
- mock_diffstix.section_headers[obj_type] = {
- "additions": f"New {mock_diffstix.attack_type_to_title[obj_type]}",
- "major_version_changes": "Major Version Changes",
- "minor_version_changes": "Minor Version Changes",
- "other_version_changes": "Other Version Changes",
- "patches": "Patches",
- "deprecations": "Deprecations",
- "revocations": "Revocations",
- "deletions": "Deletions",
- "unchanged": "Unchanged",
- }
-
- return mock_diffstix
-
-
-@pytest.fixture
-def minimal_stix_bundles(mock_stix_object_factory, mock_relationship_factory):
- """Create comprehensive STIX bundles for thorough changelog testing.
-
- Includes 2-3 objects of each major type and various change scenarios:
- - Techniques (including subtechniques)
- - Software (malware/tools)
- - Groups, Campaigns, Mitigations
- - Data Sources, Data Components, Assets
- - Multiple relationship types
- - All change types: additions, modifications, revocations, deprecations, deletions
- """
- # ========================================
- # OLD BUNDLE OBJECTS (baseline state)
- # ========================================
-
- # Techniques (2 regular + 1 subtechnique)
- old_technique1 = mock_stix_object_factory(
- name="Existing Technique One", attack_id="T9001", version="1.0", stix_type="attack-pattern"
- )
- old_technique2 = mock_stix_object_factory(
- name="Technique To Be Revoked", attack_id="T9002", version="1.0", stix_type="attack-pattern"
- )
- old_subtechnique = mock_stix_object_factory(
- name="Existing Subtechnique",
- attack_id="T9001.001",
- version="1.0",
- stix_type="attack-pattern",
- is_subtechnique=True,
- )
-
- # Software (2 malware + 1 tool)
- old_malware1 = mock_stix_object_factory(
- name="Existing Malware One", attack_id="S9001", version="1.0", stix_type="malware", obj_type="malware"
- )
- old_malware2 = mock_stix_object_factory(
- name="Malware To Be Deprecated", attack_id="S9002", version="1.0", stix_type="malware", obj_type="malware"
- )
- old_tool = mock_stix_object_factory(
- name="Existing Tool", attack_id="S9003", version="1.0", stix_type="tool", obj_type="tool"
- )
-
- # Groups (2)
- old_group1 = mock_stix_object_factory(
- name="Existing Group One", attack_id="G9001", version="1.0", stix_type="intrusion-set", obj_type="intrusion-set"
- )
- old_group2 = mock_stix_object_factory(
- name="Group To Be Modified",
- attack_id="G9002",
- version="1.0",
- stix_type="intrusion-set",
- obj_type="intrusion-set",
- )
-
- # Campaigns (2)
- old_campaign1 = mock_stix_object_factory(
- name="Existing Campaign One", attack_id="C9001", version="1.0", stix_type="campaign", obj_type="campaign"
- )
- old_campaign2 = mock_stix_object_factory(
- name="Campaign To Be Deleted", attack_id="C9002", version="1.0", stix_type="campaign", obj_type="campaign"
- )
-
- # Mitigations (2)
- old_mitigation1 = mock_stix_object_factory(
- name="Existing Mitigation One",
- attack_id="M9001",
- version="1.0",
- stix_type="course-of-action",
- obj_type="course-of-action",
- )
- old_mitigation2 = mock_stix_object_factory(
- name="Mitigation To Be Modified",
- attack_id="M9002",
- version="1.0",
- stix_type="course-of-action",
- obj_type="course-of-action",
- )
-
- # Data Sources (2)
- old_datasource1 = mock_stix_object_factory(
- name="Existing Data Source One",
- attack_id="DS9001",
- version="1.0",
- stix_type="x-mitre-data-source",
- obj_type="x-mitre-data-source",
- )
- old_datasource2 = mock_stix_object_factory(
- name="Data Source To Be Modified",
- attack_id="DS9002",
- version="1.0",
- stix_type="x-mitre-data-source",
- obj_type="x-mitre-data-source",
- )
-
- # Data Components (2) - linked to data sources
- old_datacomponent1 = mock_stix_object_factory(
- name="Existing Data Component One",
- attack_id="DC9001",
- version="1.0",
- stix_type="x-mitre-data-component",
- obj_type="x-mitre-data-component",
- )
-
- old_datacomponent2 = mock_stix_object_factory(
- name="Data Component To Be Modified",
- attack_id="DC9002",
- version="1.0",
- stix_type="x-mitre-data-component",
- obj_type="x-mitre-data-component",
- )
-
- # Assets (2)
- old_asset1 = mock_stix_object_factory(
- name="Existing Asset One", attack_id="A9001", version="1.0", stix_type="x-mitre-asset", obj_type="x-mitre-asset"
- )
- old_asset2 = mock_stix_object_factory(
- name="Asset To Be Modified",
- attack_id="A9002",
- version="1.0",
- stix_type="x-mitre-asset",
- obj_type="x-mitre-asset",
- )
-
- # Relationships in old bundle
- old_relationship1 = mock_relationship_factory(
- source_ref=old_group1["id"], target_ref=old_malware1["id"], relationship_type="uses"
- )
- old_relationship2 = mock_relationship_factory(
- source_ref=old_malware1["id"], target_ref=old_technique1["id"], relationship_type="uses"
- )
- old_relationship3 = mock_relationship_factory(
- source_ref=old_subtechnique["id"], target_ref=old_technique1["id"], relationship_type="subtechnique-of"
- )
- old_relationship4 = mock_relationship_factory(
- source_ref=old_mitigation1["id"], target_ref=old_technique1["id"], relationship_type="mitigates"
- )
- old_relationship5 = mock_relationship_factory(
- source_ref=old_datacomponent1["id"], target_ref=old_technique1["id"], relationship_type="detects"
- )
-
- # ========================================
- # NEW BUNDLE OBJECTS (with changes)
- # ========================================
-
- # Unchanged objects (copied to new bundle)
- new_technique1 = old_technique1.copy() # Unchanged
- new_subtechnique = old_subtechnique.copy() # Unchanged
- new_malware1 = old_malware1.copy() # Unchanged
- new_tool = old_tool.copy() # Unchanged
- new_group1 = old_group1.copy() # Unchanged
- new_campaign1 = old_campaign1.copy() # Unchanged
- new_mitigation1 = old_mitigation1.copy() # Unchanged
- new_datasource1 = old_datasource1.copy() # Unchanged
- new_datacomponent1 = old_datacomponent1.copy() # Unchanged
- new_asset1 = old_asset1.copy() # Unchanged
-
- # Modified objects (version changes)
- new_group2_modified = old_group2.copy()
- new_group2_modified["x_mitre_version"] = "1.1"
- new_group2_modified["modified"] = "2025-01-15T12:00:00.000Z"
- new_group2_modified["description"] = "Updated description for modified group"
-
- new_mitigation2_modified = old_mitigation2.copy()
- new_mitigation2_modified["x_mitre_version"] = "1.1"
- new_mitigation2_modified["modified"] = "2025-01-15T12:00:00.000Z"
-
- new_datasource2_modified = old_datasource2.copy()
- new_datasource2_modified["x_mitre_version"] = "1.1"
- new_datasource2_modified["modified"] = "2025-01-15T12:00:00.000Z"
-
- new_datacomponent2_modified = old_datacomponent2.copy()
- new_datacomponent2_modified["x_mitre_version"] = "1.1"
- new_datacomponent2_modified["modified"] = "2025-01-15T12:00:00.000Z"
-
- new_asset2_modified = old_asset2.copy()
- new_asset2_modified["x_mitre_version"] = "1.1"
- new_asset2_modified["modified"] = "2025-01-15T12:00:00.000Z"
-
- # Revoked object with replacement technique
- replacement_technique = mock_stix_object_factory(
- name="Replacement for Revoked Technique", attack_id="T9999", version="1.0", stix_type="attack-pattern"
- )
- new_technique2_revoked = old_technique2.copy()
- new_technique2_revoked["revoked"] = True
- new_technique2_revoked["x_mitre_version"] = "1.1"
- new_technique2_revoked["modified"] = "2025-01-15T12:00:00.000Z"
-
- # Deprecated object
- new_malware2_deprecated = old_malware2.copy()
- new_malware2_deprecated["x_mitre_deprecated"] = True
- new_malware2_deprecated["x_mitre_version"] = "1.1"
- new_malware2_deprecated["modified"] = "2025-01-15T12:00:00.000Z"
-
- # New additions (only in new bundle)
- new_technique_added = mock_stix_object_factory(
- name="Brand New Technique", attack_id="T9100", version="1.0", stix_type="attack-pattern"
- )
- new_malware_added = mock_stix_object_factory(
- name="Brand New Malware", attack_id="S9100", version="1.0", stix_type="malware", obj_type="malware"
- )
- new_group_added = mock_stix_object_factory(
- name="Brand New Group", attack_id="G9100", version="1.0", stix_type="intrusion-set", obj_type="intrusion-set"
- )
- new_campaign_added = mock_stix_object_factory(
- name="Brand New Campaign", attack_id="C9100", version="1.0", stix_type="campaign", obj_type="campaign"
- )
- new_mitigation_added = mock_stix_object_factory(
- name="Brand New Mitigation",
- attack_id="M9100",
- version="1.0",
- stix_type="course-of-action",
- obj_type="course-of-action",
- )
- new_datasource_added = mock_stix_object_factory(
- name="Brand New Data Source",
- attack_id="DS9100",
- version="1.0",
- stix_type="x-mitre-data-source",
- obj_type="x-mitre-data-source",
- )
-
- # Relationships in new bundle (some unchanged, some new)
- new_relationship1 = old_relationship1.copy() # Unchanged
- new_relationship2 = old_relationship2.copy() # Unchanged
- new_relationship3 = old_relationship3.copy() # Unchanged
- new_relationship4 = old_relationship4.copy() # Unchanged
- new_relationship5 = old_relationship5.copy() # Unchanged
-
- # New relationships
- new_relationship6 = mock_relationship_factory(
- source_ref=new_group_added["id"], target_ref=new_malware_added["id"], relationship_type="uses"
- )
- new_relationship7 = mock_relationship_factory(
- source_ref=new_campaign_added["id"], target_ref=new_group_added["id"], relationship_type="attributed-to"
- )
- new_relationship8 = mock_relationship_factory(
- source_ref=new_mitigation_added["id"], target_ref=new_technique_added["id"], relationship_type="mitigates"
- )
- # Revoked-by relationship
- new_relationship9 = mock_relationship_factory(
- source_ref=new_technique2_revoked["id"], target_ref=replacement_technique["id"], relationship_type="revoked-by"
- )
-
- # ========================================
- # CREATE BUNDLES
- # ========================================
-
- old_objects = [
- MITRE_IDENTITY,
- MITRE_MARKING_DEFINITION,
- old_technique1,
- old_technique2,
- old_subtechnique,
- old_malware1,
- old_malware2,
- old_tool,
- old_group1,
- old_group2,
- old_campaign1,
- old_campaign2,
- old_mitigation1,
- old_mitigation2,
- old_datasource1,
- old_datasource2,
- old_datacomponent1,
- old_datacomponent2,
- old_asset1,
- old_asset2,
- old_relationship1,
- old_relationship2,
- old_relationship3,
- old_relationship4,
- old_relationship5,
- ]
-
- new_objects = [
- MITRE_IDENTITY,
- MITRE_MARKING_DEFINITION,
- # Unchanged objects
- new_technique1,
- new_subtechnique,
- new_malware1,
- new_tool,
- new_group1,
- new_campaign1,
- new_mitigation1,
- new_datasource1,
- new_datacomponent1,
- new_asset1,
- # Modified objects
- new_group2_modified,
- new_mitigation2_modified,
- new_datasource2_modified,
- new_datacomponent2_modified,
- new_asset2_modified,
- # Revoked/deprecated objects
- new_technique2_revoked,
- new_malware2_deprecated,
- # New additions
- new_technique_added,
- new_malware_added,
- new_group_added,
- new_campaign_added,
- new_mitigation_added,
- new_datasource_added,
- replacement_technique,
- # Relationships
- new_relationship1,
- new_relationship2,
- new_relationship3,
- new_relationship4,
- new_relationship5,
- new_relationship6,
- new_relationship7,
- new_relationship8,
- new_relationship9,
- # Note: old_campaign2 is deleted (not in new bundle)
- ]
-
- old_bundle = {"type": "bundle", "id": f"bundle--{uuid.uuid4()}", "objects": old_objects}
- new_bundle = {"type": "bundle", "id": f"bundle--{uuid.uuid4()}", "objects": new_objects}
-
- # ========================================
- # EXPECTED CHANGES STRUCTURE
- # ========================================
-
- expected_changes = {
- "additions": [
- new_technique_added,
- new_malware_added,
- new_group_added,
- new_campaign_added,
- new_mitigation_added,
- new_datasource_added,
- replacement_technique,
- ],
- "minor_version_changes": [
- new_group2_modified,
- new_mitigation2_modified,
- new_datasource2_modified,
- new_datacomponent2_modified,
- new_asset2_modified,
- ],
- "revocations": [new_technique2_revoked],
- "deprecations": [new_malware2_deprecated],
- "deletions": [old_campaign2], # Deleted from new bundle
- "new_relationships": [new_relationship6, new_relationship7, new_relationship8, new_relationship9],
- }
-
- return {
- "old": old_bundle,
- "new": new_bundle,
- "expected_changes": expected_changes,
- }
-
-
-@pytest.fixture
-def lightweight_diffstix(minimal_stix_bundles, tmp_path):
- """Create a DiffStix instance with minimal test data for fast testing."""
- # Create directory structure that DiffStix expects
- old_dir = tmp_path / "old"
- new_dir = tmp_path / "new"
- old_dir.mkdir()
- new_dir.mkdir()
-
- # Write test bundles to domain-specific files
- old_file = old_dir / "enterprise-attack.json"
- new_file = new_dir / "enterprise-attack.json"
-
- with open(old_file, "w") as f:
- json.dump(minimal_stix_bundles["old"], f)
-
- with open(new_file, "w") as f:
- json.dump(minimal_stix_bundles["new"], f)
-
- # Create DiffStix with test data
- return DiffStix(
- domains=["enterprise-attack"],
- old=str(old_dir),
- new=str(new_dir),
- show_key=False,
- verbose=False,
- include_contributors=False,
- )
-
-
-# ========================================
-# Fixtures for ATT&CK Navigator Layers
-# ========================================
-
-
-@pytest.fixture
-def mock_layers_dict():
- """Mock layers dictionary for testing layer file generation."""
- return {
- "enterprise-attack": {
- "versions": {"layer": "4.5", "navigator": "5.0.0", "attack": "17.0"},
- "name": "Test Enterprise Updates",
- "description": "Test enterprise layer description",
- "domain": "enterprise-attack",
- "techniques": [
- {
- "techniqueID": "T1001",
- "tactic": "initial-access",
- "enabled": True,
- "color": "#a1d99b",
- "comment": "addition",
- }
- ],
- "sorting": 0,
- "hideDisabled": False,
- "legendItems": [{"color": "#a1d99b", "label": "additions: New objects"}],
- "showTacticRowBackground": True,
- "tacticRowBackground": "#205b8f",
- "selectTechniquesAcrossTactics": True,
- },
- "mobile-attack": {
- "versions": {"layer": "4.5", "navigator": "5.0.0", "attack": "17.0"},
- "name": "Test Mobile Updates",
- "description": "Test mobile layer description",
- "domain": "mobile-attack",
- "techniques": [],
- "sorting": 0,
- "hideDisabled": False,
- "legendItems": [],
- "showTacticRowBackground": True,
- "tacticRowBackground": "#205b8f",
- "selectTechniquesAcrossTactics": True,
- },
- "ics-attack": {
- "versions": {"layer": "4.5", "navigator": "5.0.0", "attack": "17.0"},
- "name": "Test ICS Updates",
- "description": "Test ICS layer description",
- "domain": "ics-attack",
- "techniques": [],
- "sorting": 0,
- "hideDisabled": False,
- "legendItems": [],
- "showTacticRowBackground": True,
- "tacticRowBackground": "#205b8f",
- "selectTechniquesAcrossTactics": True,
- },
- }
-
-
-# ========================================
-# Real Data Testing Fixtures
-# ========================================
-
-
-@pytest.fixture(scope="session")
-def golden_161_170_changelog_dir():
- """Path to golden files directory."""
- base_path = Path(__file__).parent.parent.parent
- return base_path / "tests/resources/changelog-v16.1_to_v17.0"
-
-
-@pytest.fixture(scope="session")
-def generated_161_170_diffstix(tmp_path_factory) -> DiffStix:
- """Create and cache a DiffStix instance for reuse across tests."""
- versions_param = ["16.1", "17.0"]
- result_paths = _download_attack_stix_data(versions_param, tmp_path_factory)
- return DiffStix(
- domains=["enterprise-attack", "mobile-attack", "ics-attack"],
- old=result_paths["16.1"],
- new=result_paths["17.0"],
- show_key=True,
- verbose=False,
- include_contributors=True,
- )
-
-
-# ========================================
-# Reusable DiffStix Test Scenario Fixtures
-# ========================================
-
-
-@pytest.fixture
-def diffstix_with_version_scenarios(minimal_stix_bundles, tmp_path):
- """Create factory for DiffStix instances with different version scenarios."""
-
- def _create_diffstix(old_version="16.1", new_version=None):
- import uuid
-
- # Create unique subdirectory for this diffstix instance
- instance_dir = tmp_path / f"diffstix_{uuid.uuid4().hex[:8]}"
- instance_dir.mkdir()
- old_dir = instance_dir / "old"
- new_dir = instance_dir / "new"
- old_dir.mkdir()
- new_dir.mkdir()
-
- old_bundle = minimal_stix_bundles["old"].copy()
- new_bundle = minimal_stix_bundles["new"].copy()
-
- with open(old_dir / "enterprise-attack.json", "w") as f:
- json.dump(old_bundle, f)
- with open(new_dir / "enterprise-attack.json", "w") as f:
- json.dump(new_bundle, f)
-
- diffstix = DiffStix(
- domains=["enterprise-attack"],
- old=str(old_dir),
- new=str(new_dir),
- show_key=False,
- verbose=False,
- include_contributors=False,
- )
-
- # Set version data for testing
- diffstix.data["old"]["enterprise-attack"]["attack_release_version"] = old_version
- diffstix.data["new"]["enterprise-attack"]["attack_release_version"] = new_version
-
- return diffstix
-
- return _create_diffstix
-
-
-@pytest.fixture
-def empty_changes_diffstix(tmp_path):
- """Create DiffStix instance with identical old/new bundles for testing no-change scenarios."""
- bundle_id = str(uuid.uuid4())
- object_id = str(uuid.uuid4())
-
- identical_bundle = {
- "type": "bundle",
- "id": f"bundle--{bundle_id}",
- "objects": [
- MITRE_IDENTITY,
- MITRE_MARKING_DEFINITION,
- {
- "type": "attack-pattern",
- "id": f"attack-pattern--{object_id}",
- "spec_version": "2.0",
- "created": "2023-01-01T00:00:00.000Z",
- "modified": "2023-01-01T00:00:00.000Z",
- "created_by_ref": MITRE_IDENTITY_ID,
- "name": "Test Technique",
- "description": "Test technique for no-change scenario",
- "kill_chain_phases": [{"kill_chain_name": "mitre-attack", "phase_name": "execution"}],
- "external_references": [
- {
- "source_name": "mitre-attack",
- "external_id": "T9999",
- "url": "https://attack.mitre.org/techniques/T9999",
- }
- ],
- "object_marking_refs": [MITRE_MARKING_DEFINITION_ID],
- "x_mitre_attack_spec_version": "3.2.0",
- "x_mitre_version": "1.0",
- "x_mitre_modified_by_ref": MITRE_IDENTITY_ID,
- "x_mitre_domains": ["enterprise-attack"],
- "x_mitre_platforms": ["Windows"],
- },
- ],
- }
-
- # Create directories
- old_dir = tmp_path / "old"
- new_dir = tmp_path / "new"
- old_dir.mkdir()
- new_dir.mkdir()
-
- # Write identical bundles
- with open(old_dir / "enterprise-attack.json", "w") as f:
- json.dump(identical_bundle, f)
- with open(new_dir / "enterprise-attack.json", "w") as f:
- json.dump(identical_bundle, f)
-
- # Create DiffStix instance
- return DiffStix(
- domains=["enterprise-attack"],
- old=str(old_dir),
- new=str(new_dir),
- show_key=False,
- verbose=False,
- include_contributors=False,
- )
-
-
-@pytest.fixture
-def large_dataset_diffstix(mock_stix_object_factory, tmp_path):
- """Create DiffStix instance with larger test dataset (50+ objects)."""
- # Create larger test dataset
- old_objects = [MITRE_IDENTITY, MITRE_MARKING_DEFINITION]
- new_objects = [MITRE_IDENTITY, MITRE_MARKING_DEFINITION]
-
- # Create 50 techniques in old version
- for i in range(50):
- old_objects.append(mock_stix_object_factory(name=f"Technique {i}", attack_id=f"T{1000 + i}", version="1.0"))
-
- # Create modified techniques + new ones in new version
- for i in range(50):
- # First 25 are modified versions
- if i < 25:
- modified_technique = mock_stix_object_factory(
- name=f"Technique {i} Modified", attack_id=f"T{1000 + i}", version="1.1"
- )
- new_objects.append(modified_technique)
- else:
- # Last 25 are unchanged
- new_objects.append(mock_stix_object_factory(name=f"Technique {i}", attack_id=f"T{1000 + i}", version="1.0"))
-
- # Add 10 completely new techniques
- for i in range(10):
- new_objects.append(mock_stix_object_factory(name=f"New Technique {i}", attack_id=f"T{2000 + i}", version="1.0"))
-
- # Create bundles
- old_bundle = {"type": "bundle", "id": f"bundle--{uuid.uuid4()}", "objects": old_objects}
- new_bundle = {"type": "bundle", "id": f"bundle--{uuid.uuid4()}", "objects": new_objects}
-
- # Create directories
- old_dir = tmp_path / "old"
- new_dir = tmp_path / "new"
- old_dir.mkdir()
- new_dir.mkdir()
-
- # Write bundles
- with open(old_dir / "enterprise-attack.json", "w") as f:
- json.dump(old_bundle, f)
- with open(new_dir / "enterprise-attack.json", "w") as f:
- json.dump(new_bundle, f)
-
- # Create DiffStix instance
- return DiffStix(
- domains=["enterprise-attack"],
- old=str(old_dir),
- new=str(new_dir),
- show_key=False,
- verbose=False,
- include_contributors=False,
- )
-
-
-# ========================================
-# Test Directory Setup Utilities
-# ========================================
-
-
-@pytest.fixture
-def setup_test_directories():
- """Set up test directories with STIX bundles for specified domains.
-
- This fixture provides a reusable method to create old/new directory
- structures with STIX bundle files for testing changelog functionality.
-
- Returns
- -------
- callable
- Function that takes (tmp_path, minimal_stix_bundles, domains, custom_bundles=None, write_files=True) and
- returns (old_dir_path, new_dir_path) as strings
- """
-
- def _setup_directories(tmp_path, minimal_stix_bundles, domains, custom_bundles=None, write_files=True):
- """Set up test directories with STIX bundles for specified domains.
-
- Parameters
- ----------
- tmp_path : pathlib.Path
- pytest tmp_path fixture
- minimal_stix_bundles : dict
- dict with 'old' and 'new' STIX bundles (used if custom_bundles is None)
- domains : list of str
- list of domain names (e.g. ['enterprise-attack', 'mobile-attack'])
- custom_bundles : dict, optional
- dict with 'old' and 'new' custom content to write instead of minimal_stix_bundles
- Can contain raw strings for invalid JSON or custom bundle objects
- write_files : bool, optional
- Whether to write files to the directories (default True)
- If False, only creates empty directories
-
- Returns
- -------
- tuple of str
- (old_dir_path, new_dir_path) as strings
- """
- old_dir = tmp_path / "old"
- new_dir = tmp_path / "new"
- old_dir.mkdir()
- new_dir.mkdir()
-
- if write_files:
- # Use custom bundles if provided, otherwise use minimal_stix_bundles
- bundles_to_use = custom_bundles if custom_bundles is not None else minimal_stix_bundles
-
- for domain in domains:
- # Handle old bundle content
- old_content = bundles_to_use["old"]
- with open(old_dir / f"{domain}.json", "w") as f:
- if isinstance(old_content, str):
- # Raw string content (e.g., invalid JSON)
- f.write(old_content)
- else:
- # JSON object
- json.dump(old_content, f)
-
- # Handle new bundle content
- new_content = bundles_to_use["new"]
- with open(new_dir / f"{domain}.json", "w") as f:
- if isinstance(new_content, str):
- # Raw string content (e.g., invalid JSON)
- f.write(new_content)
- else:
- # JSON object
- json.dump(new_content, f)
-
- return str(old_dir), str(new_dir)
-
- return _setup_directories
-
-
-# ========================================
-# Enhanced Assertion Helper Fixtures
-# ========================================
-
-
-@pytest.fixture
-def assert_markdown_structure():
- """Assert markdown content has expected basic structure."""
- return assert_basic_markdown_structure
-
-
-@pytest.fixture
-def assert_json_structure():
- """Assert JSON data has expected structure for changelog output."""
- return assert_json_structure_valid
-
-
-@pytest.fixture
-def assert_layer_structure():
- """Assert layer data has expected ATT&CK Navigator structure."""
- return assert_layer_structure_valid
-
-
-@pytest.fixture
-def assert_diffstix_structure():
- """Assert DiffStix instance has valid data structure."""
- return assert_diffstix_data_structure_valid
-
-
-@pytest.fixture
-def validate_comprehensive_outputs():
- """Validate comprehensive output generation scenario."""
- return validate_comprehensive_output_generation
-
-
-@pytest.fixture
-def validate_format_consistency():
- """Validate that all output formats are consistent."""
- return validate_output_format_consistency
-
-
-# ========================================
-# File Path Creation Helper Fixtures
-# ========================================
-
-
-@pytest.fixture
-def create_output_paths():
- """Create standard test output file paths."""
- return create_test_output_file_paths
-
-
-@pytest.fixture
-def create_layer_paths():
- """Create layer file paths for specified domains."""
- return create_layer_file_paths
-
-
-# ========================================
-# File Validation Helper Fixtures
-# ========================================
-
-
-@pytest.fixture
-def validate_markdown_file():
- """Validate markdown file content and return it."""
- return validate_markdown_file_content
-
-
-@pytest.fixture
-def validate_json_file():
- """Validate JSON file content and return it."""
- return validate_json_file_content
-
-
-@pytest.fixture
-def validate_layer_file():
- """Validate layer file content and return it."""
- return validate_layer_file_content
-
-
-# ========================================
-# CLI Argument Testing Helper Fixtures
-# ========================================
-
-
-@pytest.fixture
-def setup_monkeypatch_args():
- """Set up monkeypatch for CLI argument testing."""
-
- def _setup_args(argv_list, monkeypatch):
- """Set up sys.argv with monkeypatch for argument parsing tests."""
- import sys
-
- monkeypatch.setattr(sys, "argv", argv_list)
-
- return _setup_args
+"""Shared fixtures for changelog helper tests.
+
+This file contains fixtures specific to changelog_helper.py testing functionality.
+Shared fixtures (STIX data, layers, etc.) are imported from the parent conftest.py.
+"""
+
+import json
+import uuid
+from pathlib import Path
+from unittest.mock import Mock
+
+import pytest
+
+from mitreattack.diffStix.changelog_helper import DiffStix
+
+# Import test utilities
+from tests.changelog.test_utils import (
+ assert_basic_markdown_structure,
+ assert_diffstix_data_structure_valid,
+ assert_json_structure_valid,
+ assert_layer_structure_valid,
+ create_layer_file_paths,
+ create_test_output_file_paths,
+ validate_comprehensive_output_generation,
+ validate_json_file_content,
+ validate_layer_file_content,
+ validate_markdown_file_content,
+ validate_output_format_consistency,
+)
+
+# Import shared fixtures from parent conftest.py
+# These fixtures are used by pytest's fixture discovery system even though they appear unused
+from tests.conftest import (
+ # Core data infrastructure (used by multiple test suites)
+ _download_attack_stix_data,
+ attack_stix_dir,
+ layer_v3_all,
+ layer_v43,
+ memstore_enterprise_latest,
+ memstore_ics_latest,
+ memstore_mobile_latest,
+ mitre_attack_data_enterprise,
+ mitre_attack_data_ics,
+ mitre_attack_data_mobile,
+ stix_file_enterprise_latest,
+ stix_file_ics_latest,
+ stix_file_mobile_latest,
+)
+from tests.fixtures.stix_objects import (
+ MITRE_IDENTITY,
+ MITRE_IDENTITY_ID,
+ MITRE_MARKING_DEFINITION,
+ MITRE_MARKING_DEFINITION_ID,
+)
+
+# Export imported fixtures for pytest discovery
+__all__ = [
+ "attack_stix_dir",
+ "stix_file_enterprise_latest",
+ "stix_file_mobile_latest",
+ "stix_file_ics_latest",
+ "memstore_enterprise_latest",
+ "memstore_mobile_latest",
+ "memstore_ics_latest",
+ "mitre_attack_data_enterprise",
+ "mitre_attack_data_mobile",
+ "mitre_attack_data_ics",
+ "layer_v3_all",
+ "layer_v43",
+ "diffstix_with_version_scenarios",
+ "empty_changes_diffstix",
+ "large_dataset_diffstix",
+ # Enhanced assertion helper fixtures
+ "assert_markdown_structure",
+ "assert_json_structure",
+ "assert_layer_structure",
+ "assert_diffstix_structure",
+ "validate_comprehensive_outputs",
+ "validate_format_consistency",
+ # File path creation helper fixtures
+ "create_output_paths",
+ "create_layer_paths",
+ # File validation helper fixtures
+ "validate_markdown_file",
+ "validate_json_file",
+ "validate_layer_file",
+ # CLI argument testing helper fixtures
+ "setup_monkeypatch_args",
+]
+
+
+# ========================================
+# Mock DiffStix Fixtures
+# ========================================
+
+
+@pytest.fixture
+def diffstix_data():
+ """Provide standard attack domains data structure for enterprise/mobile/ics."""
+ return {
+ "old": {
+ "enterprise-attack": {
+ "attack_objects": {
+ "techniques": {},
+ "software": {},
+ "groups": {},
+ "campaigns": {},
+ "assets": {},
+ "mitigations": {},
+ "datasources": {},
+ "datacomponents": {},
+ },
+ "relationships": {
+ "subtechniques": {},
+ "revoked-by": {},
+ "mitigations": {},
+ "detections": {},
+ },
+ "attack_release_version": "16.1",
+ "stix_datastore": None,
+ },
+ "mobile-attack": {
+ "attack_objects": {
+ "techniques": {},
+ "software": {},
+ "groups": {},
+ "campaigns": {},
+ "assets": {},
+ "mitigations": {},
+ "datasources": {},
+ "datacomponents": {},
+ },
+ "relationships": {
+ "subtechniques": {},
+ "revoked-by": {},
+ "mitigations": {},
+ "detections": {},
+ },
+ "attack_release_version": "16.1",
+ "stix_datastore": None,
+ },
+ "ics-attack": {
+ "attack_objects": {
+ "techniques": {},
+ "software": {},
+ "groups": {},
+ "campaigns": {},
+ "assets": {},
+ "mitigations": {},
+ "datasources": {},
+ "datacomponents": {},
+ },
+ "relationships": {
+ "subtechniques": {},
+ "revoked-by": {},
+ "mitigations": {},
+ "detections": {},
+ },
+ "attack_release_version": "16.1",
+ "stix_datastore": None,
+ },
+ },
+ "new": {
+ "enterprise-attack": {
+ "attack_objects": {
+ "techniques": {},
+ "software": {},
+ "groups": {},
+ "campaigns": {},
+ "assets": {},
+ "mitigations": {},
+ "datasources": {},
+ "datacomponents": {},
+ },
+ "relationships": {
+ "subtechniques": {},
+ "revoked-by": {},
+ "mitigations": {},
+ "detections": {},
+ },
+ "attack_release_version": "17.0",
+ "stix_datastore": None,
+ },
+ "mobile-attack": {
+ "attack_objects": {
+ "techniques": {},
+ "software": {},
+ "groups": {},
+ "campaigns": {},
+ "assets": {},
+ "mitigations": {},
+ "datasources": {},
+ "datacomponents": {},
+ },
+ "relationships": {
+ "subtechniques": {},
+ "revoked-by": {},
+ "mitigations": {},
+ "detections": {},
+ },
+ "attack_release_version": "17.0",
+ "stix_datastore": None,
+ },
+ "ics-attack": {
+ "attack_objects": {
+ "techniques": {},
+ "software": {},
+ "groups": {},
+ "campaigns": {},
+ "assets": {},
+ "mitigations": {},
+ "datasources": {},
+ "datacomponents": {},
+ },
+ "relationships": {
+ "subtechniques": {},
+ "revoked-by": {},
+ "mitigations": {},
+ "detections": {},
+ },
+ "attack_release_version": "17.0",
+ "stix_datastore": None,
+ },
+ },
+ "changes": {
+ "techniques": {},
+ "software": {},
+ "groups": {},
+ "campaigns": {},
+ "assets": {},
+ "mitigations": {},
+ "datasources": {},
+ "datacomponents": {},
+ },
+ }
+
+
+@pytest.fixture
+def mock_diffstix(diffstix_data):
+ """Pre-configured DiffStix mock with standard data structures."""
+ mock_diffstix = Mock(spec=DiffStix)
+ mock_diffstix.data = diffstix_data.copy()
+ mock_diffstix.types = [
+ "techniques",
+ "software",
+ "groups",
+ "campaigns",
+ "assets",
+ "mitigations",
+ "datasources",
+ "datacomponents",
+ ]
+ mock_diffstix.domains = ["enterprise-attack", "mobile-attack", "ics-attack"]
+ mock_diffstix.verbose = False
+ mock_diffstix.release_contributors = {}
+ mock_diffstix.unchanged = False
+ mock_diffstix.site_prefix = ""
+ mock_diffstix.show_key = False
+ mock_diffstix.include_contributors = False
+
+ # Add domain and type mappings
+ mock_diffstix.domain_to_domain_label = {
+ "enterprise-attack": "Enterprise",
+ "mobile-attack": "Mobile",
+ "ics-attack": "ICS",
+ }
+ mock_diffstix.attack_type_to_title = {
+ "techniques": "Techniques",
+ "software": "Software",
+ "groups": "Groups",
+ "campaigns": "Campaigns",
+ "assets": "Assets",
+ "mitigations": "Mitigations",
+ "datasources": "Data Sources",
+ "datacomponents": "Data Components",
+ }
+ mock_diffstix.section_headers = {}
+ for obj_type in mock_diffstix.types:
+ mock_diffstix.section_headers[obj_type] = {
+ "additions": f"New {mock_diffstix.attack_type_to_title[obj_type]}",
+ "major_version_changes": "Major Version Changes",
+ "minor_version_changes": "Minor Version Changes",
+ "other_version_changes": "Other Version Changes",
+ "patches": "Patches",
+ "deprecations": "Deprecations",
+ "revocations": "Revocations",
+ "deletions": "Deletions",
+ "unchanged": "Unchanged",
+ }
+
+ return mock_diffstix
+
+
+# ========================================
+# Enhanced Fixtures for Advanced Coverage
+# Useful for HTML output and behavioral testing
+# ========================================
+
+
+@pytest.fixture
+def sample_deepdiff_data():
+ """Sample DeepDiff output for testing detailed HTML generation."""
+ return {
+ "values_changed": {
+ "root['description']": {"old_value": "Old description text", "new_value": "New description text"},
+ "root['x_mitre_version']": {"old_value": "1.0", "new_value": "1.1"},
+ },
+ "iterable_item_added": {
+ "root['kill_chain_phases'][1]": {"kill_chain_name": "mitre-attack", "phase_name": "persistence"}
+ },
+ "iterable_item_removed": {"root['x_mitre_platforms'][0]": "Windows"},
+ "dictionary_item_added": {"root['x_mitre_data_sources']": ["Process monitoring"]},
+ "dictionary_item_removed": {"root['old_field']": "removed_value"},
+ }
+
+
+@pytest.fixture
+def complex_diffstix_with_all_changes(diffstix_data, mock_stix_object_factory):
+ """DiffStix instance with all possible change types for comprehensive testing."""
+ mock_diffstix = Mock(spec=DiffStix)
+ mock_diffstix.data = diffstix_data.copy()
+
+ # Add comprehensive test data for all change types
+ test_objects = {
+ # Technique with all relationship types
+ "T1001": mock_stix_object_factory(
+ name="Test Addition Technique", attack_id="T1001", stix_type="attack-pattern"
+ ),
+ "T1002": mock_stix_object_factory(
+ name="Test Version Change Technique", attack_id="T1002", version="2.0", stix_type="attack-pattern"
+ ),
+ "T1003": mock_stix_object_factory(
+ name="Test Revoked Technique", attack_id="T1003", revoked=True, stix_type="attack-pattern"
+ ),
+ "T1004": mock_stix_object_factory(
+ name="Test Deprecated Technique", attack_id="T1004", deprecated=True, stix_type="attack-pattern"
+ ),
+ # Subtechnique
+ "T1001.001": mock_stix_object_factory(
+ name="Test Subtechnique", attack_id="T1001.001", stix_type="attack-pattern", is_subtechnique=True
+ ),
+ # Software
+ "S1001": mock_stix_object_factory(
+ name="Test Software", attack_id="S1001", stix_type="malware", obj_type="malware"
+ ),
+ }
+
+ # Add revoked_by field to revoked objects
+ revoking_object = mock_stix_object_factory(
+ name="Replacement Technique", attack_id="T9999", stix_type="attack-pattern"
+ )
+ test_objects["T1003"]["revoked_by"] = revoking_object
+
+ # Populate all change types
+ for domain in ["enterprise-attack", "mobile-attack", "ics-attack"]:
+ mock_diffstix.data["changes"]["techniques"] = {
+ domain: {
+ "additions": [test_objects["T1001"]],
+ "major_version_changes": [test_objects["T1002"]],
+ "minor_version_changes": [],
+ "other_version_changes": [],
+ "patches": [],
+ "revocations": [test_objects["T1003"]],
+ "deprecations": [test_objects["T1004"]],
+ "deletions": [],
+ "unchanged": [],
+ }
+ }
+
+ mock_diffstix.data["changes"]["software"] = {
+ domain: {
+ "additions": [test_objects["S1001"]],
+ "major_version_changes": [],
+ "minor_version_changes": [],
+ "other_version_changes": [],
+ "patches": [],
+ "revocations": [],
+ "deprecations": [],
+ "deletions": [],
+ "unchanged": [],
+ }
+ }
+
+ # Set up mock attributes
+ mock_diffstix.domains = ["enterprise-attack", "mobile-attack", "ics-attack"]
+ mock_diffstix.types = [
+ "techniques",
+ "software",
+ "groups",
+ "campaigns",
+ "assets",
+ "mitigations",
+ "datasources",
+ "datacomponents",
+ ]
+ mock_diffstix.site_prefix = "https://attack.mitre.org"
+ mock_diffstix.show_key = True
+ mock_diffstix.include_contributors = True
+ mock_diffstix.release_contributors = {"Test Contributor": 1}
+
+ # Add mappings
+ mock_diffstix.domain_to_domain_label = {
+ "enterprise-attack": "Enterprise",
+ "mobile-attack": "Mobile",
+ "ics-attack": "ICS",
+ }
+ mock_diffstix.attack_type_to_title = {
+ "techniques": "Techniques",
+ "software": "Software",
+ "groups": "Groups",
+ "campaigns": "Campaigns",
+ "assets": "Assets",
+ "mitigations": "Mitigations",
+ "datasources": "Data Sources",
+ "datacomponents": "Data Components",
+ }
+ mock_diffstix.section_headers = {}
+ for obj_type in mock_diffstix.types:
+ mock_diffstix.section_headers[obj_type] = {
+ "additions": f"New {mock_diffstix.attack_type_to_title[obj_type]}",
+ "major_version_changes": "Major Version Changes",
+ "minor_version_changes": "Minor Version Changes",
+ "other_version_changes": "Other Version Changes",
+ "patches": "Patches",
+ "deprecations": "Deprecations",
+ "revocations": "Revocations",
+ "deletions": "Deletions",
+ "unchanged": "Unchanged",
+ }
+
+ return mock_diffstix
+
+
+@pytest.fixture
+def lightweight_diffstix(minimal_stix_bundles, tmp_path):
+ """Create a DiffStix instance with minimal test data for fast testing."""
+ # Create directory structure that DiffStix expects
+ old_dir = tmp_path / "old"
+ new_dir = tmp_path / "new"
+ old_dir.mkdir()
+ new_dir.mkdir()
+
+ # Write test bundles to domain-specific files
+ old_file = old_dir / "enterprise-attack.json"
+ new_file = new_dir / "enterprise-attack.json"
+
+ with open(old_file, "w") as f:
+ json.dump(minimal_stix_bundles["old"], f)
+
+ with open(new_file, "w") as f:
+ json.dump(minimal_stix_bundles["new"], f)
+
+ # Create DiffStix with test data
+ return DiffStix(
+ domains=["enterprise-attack"],
+ old=str(old_dir),
+ new=str(new_dir),
+ show_key=False,
+ verbose=False,
+ include_contributors=False,
+ )
+
+
+# ========================================
+# Fixtures for ATT&CK Navigator Layers
+# ========================================
+
+
+@pytest.fixture
+def mock_layers_dict():
+ """Mock layers dictionary for testing layer file generation."""
+ return {
+ "enterprise-attack": {
+ "versions": {"layer": "4.5", "navigator": "5.0.0", "attack": "17.0"},
+ "name": "Test Enterprise Updates",
+ "description": "Test enterprise layer description",
+ "domain": "enterprise-attack",
+ "techniques": [
+ {
+ "techniqueID": "T1001",
+ "tactic": "initial-access",
+ "enabled": True,
+ "color": "#a1d99b",
+ "comment": "addition",
+ }
+ ],
+ "sorting": 0,
+ "hideDisabled": False,
+ "legendItems": [{"color": "#a1d99b", "label": "additions: New objects"}],
+ "showTacticRowBackground": True,
+ "tacticRowBackground": "#205b8f",
+ "selectTechniquesAcrossTactics": True,
+ },
+ "mobile-attack": {
+ "versions": {"layer": "4.5", "navigator": "5.0.0", "attack": "17.0"},
+ "name": "Test Mobile Updates",
+ "description": "Test mobile layer description",
+ "domain": "mobile-attack",
+ "techniques": [],
+ "sorting": 0,
+ "hideDisabled": False,
+ "legendItems": [],
+ "showTacticRowBackground": True,
+ "tacticRowBackground": "#205b8f",
+ "selectTechniquesAcrossTactics": True,
+ },
+ "ics-attack": {
+ "versions": {"layer": "4.5", "navigator": "5.0.0", "attack": "17.0"},
+ "name": "Test ICS Updates",
+ "description": "Test ICS layer description",
+ "domain": "ics-attack",
+ "techniques": [],
+ "sorting": 0,
+ "hideDisabled": False,
+ "legendItems": [],
+ "showTacticRowBackground": True,
+ "tacticRowBackground": "#205b8f",
+ "selectTechniquesAcrossTactics": True,
+ },
+ }
+
+
+# ========================================
+# Real Data Testing Fixtures
+# ========================================
+
+
+@pytest.fixture(scope="session")
+def golden_161_170_changelog_dir():
+ """Path to golden files directory."""
+ base_path = Path(__file__).parent.parent.parent
+ return base_path / "tests/resources/changelog-v16.1_to_v17.0"
+
+
+@pytest.fixture(scope="session")
+def generated_161_170_diffstix(tmp_path_factory) -> DiffStix:
+ """Create and cache a DiffStix instance for reuse across tests."""
+ versions_param = ["16.1", "17.0"]
+ result_paths = _download_attack_stix_data(versions_param, tmp_path_factory)
+ return DiffStix(
+ domains=["enterprise-attack", "mobile-attack", "ics-attack"],
+ old=result_paths["16.1"],
+ new=result_paths["17.0"],
+ show_key=True,
+ verbose=False,
+ include_contributors=True,
+ )
+
+
+# ========================================
+# Reusable DiffStix Test Scenario Fixtures
+# ========================================
+
+
+@pytest.fixture
+def diffstix_with_version_scenarios(minimal_stix_bundles, tmp_path):
+ """Create factory for DiffStix instances with different version scenarios."""
+
+ def _create_diffstix(old_version="16.1", new_version=None):
+ import uuid
+
+ # Create unique subdirectory for this diffstix instance
+ instance_dir = tmp_path / f"diffstix_{uuid.uuid4().hex[:8]}"
+ instance_dir.mkdir()
+ old_dir = instance_dir / "old"
+ new_dir = instance_dir / "new"
+ old_dir.mkdir()
+ new_dir.mkdir()
+
+ old_bundle = minimal_stix_bundles["old"].copy()
+ new_bundle = minimal_stix_bundles["new"].copy()
+
+ with open(old_dir / "enterprise-attack.json", "w") as f:
+ json.dump(old_bundle, f)
+ with open(new_dir / "enterprise-attack.json", "w") as f:
+ json.dump(new_bundle, f)
+
+ diffstix = DiffStix(
+ domains=["enterprise-attack"],
+ old=str(old_dir),
+ new=str(new_dir),
+ show_key=False,
+ verbose=False,
+ include_contributors=False,
+ )
+
+ # Set version data for testing
+ diffstix.data["old"]["enterprise-attack"]["attack_release_version"] = old_version
+ diffstix.data["new"]["enterprise-attack"]["attack_release_version"] = new_version
+
+ return diffstix
+
+ return _create_diffstix
+
+
+@pytest.fixture
+def empty_changes_diffstix(tmp_path):
+ """Create DiffStix instance with identical old/new bundles for testing no-change scenarios."""
+ bundle_id = str(uuid.uuid4())
+ object_id = str(uuid.uuid4())
+
+ identical_bundle = {
+ "type": "bundle",
+ "id": f"bundle--{bundle_id}",
+ "objects": [
+ MITRE_IDENTITY,
+ MITRE_MARKING_DEFINITION,
+ {
+ "type": "attack-pattern",
+ "id": f"attack-pattern--{object_id}",
+ "spec_version": "2.0",
+ "created": "2023-01-01T00:00:00.000Z",
+ "modified": "2023-01-01T00:00:00.000Z",
+ "created_by_ref": MITRE_IDENTITY_ID,
+ "name": "Test Technique",
+ "description": "Test technique for no-change scenario",
+ "kill_chain_phases": [{"kill_chain_name": "mitre-attack", "phase_name": "execution"}],
+ "external_references": [
+ {
+ "source_name": "mitre-attack",
+ "external_id": "T9999",
+ "url": "https://attack.mitre.org/techniques/T9999",
+ }
+ ],
+ "object_marking_refs": [MITRE_MARKING_DEFINITION_ID],
+ "x_mitre_attack_spec_version": "3.2.0",
+ "x_mitre_version": "1.0",
+ "x_mitre_modified_by_ref": MITRE_IDENTITY_ID,
+ "x_mitre_domains": ["enterprise-attack"],
+ "x_mitre_platforms": ["Windows"],
+ },
+ ],
+ }
+
+ # Create directories
+ old_dir = tmp_path / "old"
+ new_dir = tmp_path / "new"
+ old_dir.mkdir()
+ new_dir.mkdir()
+
+ # Write identical bundles
+ with open(old_dir / "enterprise-attack.json", "w") as f:
+ json.dump(identical_bundle, f)
+ with open(new_dir / "enterprise-attack.json", "w") as f:
+ json.dump(identical_bundle, f)
+
+ # Create DiffStix instance
+ return DiffStix(
+ domains=["enterprise-attack"],
+ old=str(old_dir),
+ new=str(new_dir),
+ show_key=False,
+ verbose=False,
+ include_contributors=False,
+ )
+
+
+@pytest.fixture
+def large_dataset_diffstix(mock_stix_object_factory, tmp_path):
+ """Create DiffStix instance with larger test dataset (50+ objects)."""
+ # Create larger test dataset
+ old_objects = [MITRE_IDENTITY, MITRE_MARKING_DEFINITION]
+ new_objects = [MITRE_IDENTITY, MITRE_MARKING_DEFINITION]
+
+ # Create 50 techniques in old version
+ for i in range(50):
+ old_objects.append(mock_stix_object_factory(name=f"Technique {i}", attack_id=f"T{1000 + i}", version="1.0"))
+
+ # Create modified techniques + new ones in new version
+ for i in range(50):
+ # First 25 are modified versions
+ if i < 25:
+ modified_technique = mock_stix_object_factory(
+ name=f"Technique {i} Modified", attack_id=f"T{1000 + i}", version="1.1"
+ )
+ new_objects.append(modified_technique)
+ else:
+ # Last 25 are unchanged
+ new_objects.append(mock_stix_object_factory(name=f"Technique {i}", attack_id=f"T{1000 + i}", version="1.0"))
+
+ # Add 10 completely new techniques
+ for i in range(10):
+ new_objects.append(mock_stix_object_factory(name=f"New Technique {i}", attack_id=f"T{2000 + i}", version="1.0"))
+
+ # Create bundles
+ old_bundle = {"type": "bundle", "id": f"bundle--{uuid.uuid4()}", "objects": old_objects}
+ new_bundle = {"type": "bundle", "id": f"bundle--{uuid.uuid4()}", "objects": new_objects}
+
+ # Create directories
+ old_dir = tmp_path / "old"
+ new_dir = tmp_path / "new"
+ old_dir.mkdir()
+ new_dir.mkdir()
+
+ # Write bundles
+ with open(old_dir / "enterprise-attack.json", "w") as f:
+ json.dump(old_bundle, f)
+ with open(new_dir / "enterprise-attack.json", "w") as f:
+ json.dump(new_bundle, f)
+
+ # Create DiffStix instance
+ return DiffStix(
+ domains=["enterprise-attack"],
+ old=str(old_dir),
+ new=str(new_dir),
+ show_key=False,
+ verbose=False,
+ include_contributors=False,
+ )
+
+
+# ========================================
+# Enhanced Assertion Helper Fixtures
+# ========================================
+
+
+@pytest.fixture
+def assert_markdown_structure():
+ """Assert markdown content has expected basic structure."""
+ return assert_basic_markdown_structure
+
+
+@pytest.fixture
+def assert_json_structure():
+ """Assert JSON data has expected structure for changelog output."""
+ return assert_json_structure_valid
+
+
+@pytest.fixture
+def assert_layer_structure():
+ """Assert layer data has expected ATT&CK Navigator structure."""
+ return assert_layer_structure_valid
+
+
+@pytest.fixture
+def assert_diffstix_structure():
+ """Assert DiffStix instance has valid data structure."""
+ return assert_diffstix_data_structure_valid
+
+
+@pytest.fixture
+def validate_comprehensive_outputs():
+ """Validate comprehensive output generation scenario."""
+ return validate_comprehensive_output_generation
+
+
+@pytest.fixture
+def validate_format_consistency():
+ """Validate that all output formats are consistent."""
+ return validate_output_format_consistency
+
+
+# ========================================
+# File Path Creation Helper Fixtures
+# ========================================
+
+
+@pytest.fixture
+def create_output_paths():
+ """Create standard test output file paths."""
+ return create_test_output_file_paths
+
+
+@pytest.fixture
+def create_layer_paths():
+ """Create layer file paths for specified domains."""
+ return create_layer_file_paths
+
+
+# ========================================
+# File Validation Helper Fixtures
+# ========================================
+
+
+@pytest.fixture
+def validate_markdown_file():
+ """Validate markdown file content and return it."""
+ return validate_markdown_file_content
+
+
+@pytest.fixture
+def validate_json_file():
+ """Validate JSON file content and return it."""
+ return validate_json_file_content
+
+
+@pytest.fixture
+def validate_layer_file():
+ """Validate layer file content and return it."""
+ return validate_layer_file_content
+
+
+# ========================================
+# CLI Argument Testing Helper Fixtures
+# ========================================
+
+
+@pytest.fixture
+def setup_monkeypatch_args():
+ """Set up monkeypatch for CLI argument testing."""
+
+ def _setup_args(argv_list, monkeypatch):
+ """Set up sys.argv with monkeypatch for argument parsing tests."""
+ import sys
+
+ monkeypatch.setattr(sys, "argv", argv_list)
+
+ return _setup_args
diff --git a/tests/changelog/integration/test_regression_baseline.py b/tests/changelog/integration/test_regression_baseline.py
index 2f5c0460..39cffa49 100644
--- a/tests/changelog/integration/test_regression_baseline.py
+++ b/tests/changelog/integration/test_regression_baseline.py
@@ -6,9 +6,12 @@
import json
+import pytest
+
from mitreattack.diffStix.changelog_helper import AttackChangesEncoder
+@pytest.mark.slow
class TestRegressionBaseline:
"""Regression tests using real v16.1→v17.0 data and golden files."""
diff --git a/tests/conftest.py b/tests/conftest.py
index 5e58f6c8..4df5caf2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -13,6 +13,8 @@
from .resources.testing_data import example_layer_v3_all, example_layer_v43_dict
+pytest_plugins = ["tests.fixtures.stix_objects"]
+
STIX_LOCATION_ENV_VARS = {
"enterprise": "STIX_LOCATION_ENTERPRISE",
"mobile": "STIX_LOCATION_MOBILE",
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 00000000..a2052b33
--- /dev/null
+++ b/tests/fixtures/__init__.py
@@ -0,0 +1 @@
+"""Shared pytest fixture plugins for mitreattack-python tests."""
diff --git a/tests/fixtures/stix_objects.py b/tests/fixtures/stix_objects.py
new file mode 100644
index 00000000..f1e25e0a
--- /dev/null
+++ b/tests/fixtures/stix_objects.py
@@ -0,0 +1,886 @@
+"""Reusable synthetic ATT&CK STIX object fixtures for tests."""
+
+import json
+import uuid
+from datetime import datetime
+
+import pytest
+from stix2 import MemoryStore
+
+MITRE_IDENTITY_ID = "identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5"
+MITRE_IDENTITY = {
+ "type": "identity",
+ "id": MITRE_IDENTITY_ID,
+ "name": "The MITRE Corporation",
+ "description": "",
+ "created": "2017-06-01T00:00:00.000Z",
+ "modified": "2025-03-19T15:00:40.855Z",
+ "identity_class": "organization",
+ "object_marking_refs": ["marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168"],
+ "x_mitre_attack_spec_version": "3.2.0",
+}
+
+MITRE_MARKING_DEFINITION_ID = "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168"
+MITRE_MARKING_DEFINITION = {
+ "type": "marking-definition",
+ "id": MITRE_MARKING_DEFINITION_ID,
+ "definition": {
+ "statement": "Copyright 2015-2025, The MITRE Corporation. MITRE ATT&CK and ATT&CK are registered trademarks of The MITRE Corporation."
+ },
+ "created": "2017-06-01T00:00:00.000Z",
+ "created_by_ref": MITRE_IDENTITY_ID,
+ "definition_type": "statement",
+}
+
+ATTACK_RELATIONSHIP_RULES = {
+ "uses": [
+ ("intrusion-set", "malware"),
+ ("intrusion-set", "tool"),
+ ("intrusion-set", "attack-pattern"),
+ ("malware", "attack-pattern"),
+ ("tool", "attack-pattern"),
+ ("campaign", "malware"),
+ ("campaign", "tool"),
+ ("campaign", "attack-pattern"),
+ ],
+ "attributed-to": [("campaign", "intrusion-set")],
+ "mitigates": [("course-of-action", "attack-pattern")],
+ "subtechnique-of": [("attack-pattern", "attack-pattern")],
+ "detects": [("x-mitre-data-component", "attack-pattern")],
+ "targets": [("attack-pattern", "x-mitre-asset")],
+ "revoked-by": [("any", "any")],
+}
+
+
+@pytest.fixture
+def mitre_identity():
+ """Return the standard MITRE identity object used across ATT&CK objects."""
+ return MITRE_IDENTITY.copy()
+
+
+@pytest.fixture
+def mitre_marking_definition():
+ """Return the standard ATT&CK marking definition object."""
+ return MITRE_MARKING_DEFINITION.copy()
+
+
+@pytest.fixture
+def mock_stix_object_factory():
+ """Create STIX 2.0 compliant ATT&CK objects with configurable fields."""
+
+ def _create_stix_object(
+ stix_type="attack-pattern",
+ name="Test Object",
+ attack_id="T9999",
+ stix_id=None,
+ version="1.0",
+ created=None,
+ modified=None,
+ revoked=False,
+ deprecated=False,
+ contributors=None,
+ obj_type=None,
+ external_refs=None,
+ kill_chain_phases=None,
+ is_subtechnique=None,
+ platforms=None,
+ domains=None,
+ aliases=None,
+ labels=None,
+ attack_spec_version="3.2.0",
+ **kwargs,
+ ):
+ if stix_id is None:
+ stix_id = f"{obj_type or stix_type}--{uuid.uuid4()}"
+
+ default_created = created or datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+ default_modified = modified or default_created
+
+ if is_subtechnique is None and attack_id and "." in attack_id:
+ is_subtechnique = True
+
+ obj = {
+ "type": obj_type or stix_type,
+ "id": stix_id,
+ "spec_version": "2.0",
+ "created": default_created,
+ "modified": default_modified,
+ "created_by_ref": MITRE_IDENTITY_ID,
+ "name": name,
+ "description": f"Description for {name}",
+ "object_marking_refs": [MITRE_MARKING_DEFINITION_ID],
+ "x_mitre_attack_spec_version": attack_spec_version,
+ "x_mitre_version": version,
+ "x_mitre_modified_by_ref": MITRE_IDENTITY_ID,
+ "x_mitre_domains": domains or ["enterprise-attack"],
+ }
+
+ if revoked:
+ obj["revoked"] = True
+ if deprecated:
+ obj["x_mitre_deprecated"] = True
+ if contributors:
+ obj["x_mitre_contributors"] = contributors
+
+ effective_type = obj_type or stix_type
+ if effective_type == "attack-pattern":
+ if is_subtechnique:
+ obj["x_mitre_is_subtechnique"] = True
+ obj["kill_chain_phases"] = kill_chain_phases or [
+ {"kill_chain_name": "mitre-attack", "phase_name": "execution"}
+ ]
+ obj["x_mitre_platforms"] = platforms or ["Windows", "macOS", "Linux"]
+ obj["x_mitre_data_sources"] = ["Process: Process Creation", "Command: Command Execution"]
+ obj["x_mitre_detection"] = f"Detection guidance for {obj['name']}"
+ elif effective_type == "intrusion-set":
+ obj["aliases"] = aliases or [obj["name"]]
+ elif effective_type in {"malware", "tool"}:
+ obj["labels"] = labels or [effective_type]
+ if aliases:
+ obj["x_mitre_aliases"] = aliases
+ obj["x_mitre_platforms"] = platforms or ["Windows"]
+ elif effective_type == "campaign" and aliases:
+ obj["aliases"] = aliases
+ elif effective_type == "x-mitre-tactic":
+ obj["x_mitre_shortname"] = attack_id.lower() if attack_id else "test-tactic"
+
+ if external_refs is not None:
+ obj["external_references"] = external_refs
+ elif attack_id:
+ obj["external_references"] = _generate_external_references(attack_id, effective_type, is_subtechnique)
+
+ obj.update(kwargs)
+ return obj
+
+ return _create_stix_object
+
+
+def _generate_external_references(attack_id, object_type, is_subtechnique):
+ if object_type in {"malware", "tool"}:
+ url_path = "software"
+ elif object_type == "intrusion-set":
+ url_path = "groups"
+ elif object_type == "course-of-action":
+ url_path = "mitigations"
+ elif object_type == "campaign":
+ url_path = "campaigns"
+ else:
+ url_path = "techniques"
+
+ if is_subtechnique and "." in attack_id:
+ base_technique, sub_id = attack_id.split(".", 1)
+ url = f"https://attack.mitre.org/{url_path}/{base_technique}/{sub_id}"
+ else:
+ url = f"https://attack.mitre.org/{url_path}/{attack_id}"
+
+ return [{"source_name": "mitre-attack", "external_id": attack_id, "url": url}]
+
+
+@pytest.fixture
+def mock_relationship_factory():
+ """Create STIX 2.0 compliant ATT&CK relationship objects."""
+
+ def _create_relationship(
+ source_ref=None,
+ target_ref=None,
+ relationship_type="uses",
+ source_name="mitre-attack",
+ relationship_id=None,
+ created=None,
+ modified=None,
+ description=None,
+ external_refs=None,
+ attack_spec_version="3.2.0",
+ validate_relationship=True,
+ **kwargs,
+ ):
+ source_ref = source_ref or f"attack-pattern--{uuid.uuid4()}"
+ target_ref = target_ref or f"attack-pattern--{uuid.uuid4()}"
+
+ if validate_relationship:
+ _validate_relationship_types(source_ref, target_ref, relationship_type)
+
+ default_created = created or datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+ default_modified = modified or default_created
+
+ obj = {
+ "type": "relationship",
+ "id": relationship_id or f"relationship--{uuid.uuid4()}",
+ "spec_version": "2.0",
+ "created": default_created,
+ "modified": default_modified,
+ "created_by_ref": MITRE_IDENTITY_ID,
+ "relationship_type": relationship_type,
+ "source_ref": source_ref,
+ "target_ref": target_ref,
+ "object_marking_refs": [MITRE_MARKING_DEFINITION_ID],
+ "x_mitre_attack_spec_version": attack_spec_version,
+ "x_mitre_modified_by_ref": MITRE_IDENTITY_ID,
+ }
+
+ if description:
+ obj["description"] = description
+
+ obj["external_references"] = external_refs or [
+ {"source_name": source_name, "description": f"ATT&CK {relationship_type} relationship"}
+ ]
+ obj.update(kwargs)
+ return obj
+
+ return _create_relationship
+
+
+def _validate_relationship_types(source_ref, target_ref, relationship_type):
+ source_type = source_ref.split("--")[0] if "--" in source_ref else source_ref
+ target_type = target_ref.split("--")[0] if "--" in target_ref else target_ref
+
+ if relationship_type not in ATTACK_RELATIONSHIP_RULES or relationship_type == "revoked-by":
+ return
+
+ for valid_source, valid_target in ATTACK_RELATIONSHIP_RULES[relationship_type]:
+ if source_type == valid_source and target_type == valid_target:
+ return
+
+
+@pytest.fixture
+def sample_technique_object(mock_stix_object_factory):
+ """Sample technique STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="attack-pattern",
+ name="Test Technique",
+ attack_id="T1234",
+ version="1.0",
+ kill_chain_phases=[{"kill_chain_name": "mitre-attack", "phase_name": "execution"}],
+ platforms=["Windows", "macOS", "Linux"],
+ )
+
+
+@pytest.fixture
+def sample_subtechnique_object(mock_stix_object_factory):
+ """Sample subtechnique STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="attack-pattern",
+ name="Test Subtechnique",
+ attack_id="T1234.001",
+ version="1.0",
+ kill_chain_phases=[{"kill_chain_name": "mitre-attack", "phase_name": "execution"}],
+ platforms=["Windows"],
+ )
+
+
+@pytest.fixture
+def sample_malware_object(mock_stix_object_factory):
+ """Sample malware STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="malware",
+ name="Test Malware",
+ attack_id="S1234",
+ version="1.0",
+ obj_type="malware",
+ aliases=["TestMalware", "Evil Software"],
+ platforms=["Windows", "Linux"],
+ )
+
+
+@pytest.fixture
+def sample_tool_object(mock_stix_object_factory):
+ """Sample tool STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="tool",
+ name="Test Tool",
+ attack_id="S5678",
+ version="1.0",
+ obj_type="tool",
+ aliases=["TestTool", "Utility"],
+ platforms=["Windows", "macOS", "Linux"],
+ )
+
+
+@pytest.fixture
+def sample_group_object(mock_stix_object_factory):
+ """Sample group STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="intrusion-set",
+ name="Test Group",
+ attack_id="G1234",
+ version="1.0",
+ obj_type="intrusion-set",
+ aliases=["Test Group", "APT-Test", "Group X"],
+ )
+
+
+@pytest.fixture
+def sample_mitigation_object(mock_stix_object_factory):
+ """Sample mitigation STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="course-of-action",
+ name="Test Mitigation",
+ attack_id="M1234",
+ version="1.0",
+ obj_type="course-of-action",
+ )
+
+
+@pytest.fixture
+def sample_campaign_object(mock_stix_object_factory):
+ """Sample campaign STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="campaign",
+ name="Test Campaign",
+ attack_id="C1234",
+ version="1.0",
+ obj_type="campaign",
+ aliases=["Operation Test", "Test Campaign"],
+ )
+
+
+@pytest.fixture
+def sample_data_source_object(mock_stix_object_factory):
+ """Sample data source STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="x-mitre-data-source",
+ name="Test Data Source",
+ attack_id="DS1234",
+ version="1.0",
+ obj_type="x-mitre-data-source",
+ )
+
+
+@pytest.fixture
+def sample_data_component_object(mock_stix_object_factory):
+ """Sample data component STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="x-mitre-data-component",
+ name="Test Data Component",
+ attack_id="DC1234",
+ version="1.0",
+ obj_type="x-mitre-data-component",
+ )
+
+
+@pytest.fixture
+def sample_asset_object(mock_stix_object_factory):
+ """Sample asset STIX object for testing."""
+ return mock_stix_object_factory(
+ stix_type="x-mitre-asset",
+ name="Test Asset",
+ attack_id="A1234",
+ version="1.0",
+ obj_type="x-mitre-asset",
+ )
+
+
+@pytest.fixture
+def sample_group_uses_malware_relationship(mock_relationship_factory, sample_group_object, sample_malware_object):
+ """Sample relationship: intrusion-set uses malware."""
+ return mock_relationship_factory(
+ source_ref=sample_group_object["id"],
+ target_ref=sample_malware_object["id"],
+ relationship_type="uses",
+ description=f"{sample_group_object['name']} uses {sample_malware_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_group_uses_tool_relationship(mock_relationship_factory, sample_group_object, sample_tool_object):
+ """Sample relationship: intrusion-set uses tool."""
+ return mock_relationship_factory(
+ source_ref=sample_group_object["id"],
+ target_ref=sample_tool_object["id"],
+ relationship_type="uses",
+ description=f"{sample_group_object['name']} uses {sample_tool_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_group_uses_technique_relationship(mock_relationship_factory, sample_group_object, sample_technique_object):
+ """Sample relationship: intrusion-set uses attack-pattern."""
+ return mock_relationship_factory(
+ source_ref=sample_group_object["id"],
+ target_ref=sample_technique_object["id"],
+ relationship_type="uses",
+ description=f"{sample_group_object['name']} uses {sample_technique_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_malware_uses_technique_relationship(
+ mock_relationship_factory, sample_malware_object, sample_technique_object
+):
+ """Sample relationship: malware uses attack-pattern."""
+ return mock_relationship_factory(
+ source_ref=sample_malware_object["id"],
+ target_ref=sample_technique_object["id"],
+ relationship_type="uses",
+ description=f"{sample_malware_object['name']} uses {sample_technique_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_tool_uses_technique_relationship(mock_relationship_factory, sample_tool_object, sample_technique_object):
+ """Sample relationship: tool uses attack-pattern."""
+ return mock_relationship_factory(
+ source_ref=sample_tool_object["id"],
+ target_ref=sample_technique_object["id"],
+ relationship_type="uses",
+ description=f"{sample_tool_object['name']} uses {sample_technique_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_campaign_uses_malware_relationship(mock_relationship_factory, sample_campaign_object, sample_malware_object):
+ """Sample relationship: campaign uses malware."""
+ return mock_relationship_factory(
+ source_ref=sample_campaign_object["id"],
+ target_ref=sample_malware_object["id"],
+ relationship_type="uses",
+ description=f"{sample_campaign_object['name']} uses {sample_malware_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_campaign_uses_tool_relationship(mock_relationship_factory, sample_campaign_object, sample_tool_object):
+ """Sample relationship: campaign uses tool."""
+ return mock_relationship_factory(
+ source_ref=sample_campaign_object["id"],
+ target_ref=sample_tool_object["id"],
+ relationship_type="uses",
+ description=f"{sample_campaign_object['name']} uses {sample_tool_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_campaign_uses_technique_relationship(
+ mock_relationship_factory, sample_campaign_object, sample_technique_object
+):
+ """Sample relationship: campaign uses attack-pattern."""
+ return mock_relationship_factory(
+ source_ref=sample_campaign_object["id"],
+ target_ref=sample_technique_object["id"],
+ relationship_type="uses",
+ description=f"{sample_campaign_object['name']} uses {sample_technique_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_campaign_attributed_to_group_relationship(
+ mock_relationship_factory, sample_campaign_object, sample_group_object
+):
+ """Sample relationship: campaign attributed-to intrusion-set."""
+ return mock_relationship_factory(
+ source_ref=sample_campaign_object["id"],
+ target_ref=sample_group_object["id"],
+ relationship_type="attributed-to",
+ description=f"{sample_campaign_object['name']} attributed to {sample_group_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_mitigation_mitigates_technique_relationship(
+ mock_relationship_factory, sample_mitigation_object, sample_technique_object
+):
+ """Sample relationship: course-of-action mitigates attack-pattern."""
+ return mock_relationship_factory(
+ source_ref=sample_mitigation_object["id"],
+ target_ref=sample_technique_object["id"],
+ relationship_type="mitigates",
+ description=f"{sample_mitigation_object['name']} mitigates {sample_technique_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_subtechnique_of_technique_relationship(
+ mock_relationship_factory, sample_subtechnique_object, sample_technique_object
+):
+ """Sample relationship: attack-pattern subtechnique-of attack-pattern."""
+ return mock_relationship_factory(
+ source_ref=sample_subtechnique_object["id"],
+ target_ref=sample_technique_object["id"],
+ relationship_type="subtechnique-of",
+ description=f"{sample_subtechnique_object['name']} is a subtechnique of {sample_technique_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_data_component_detects_technique_relationship(
+ mock_relationship_factory, sample_data_component_object, sample_technique_object
+):
+ """Sample relationship: x-mitre-data-component detects attack-pattern."""
+ return mock_relationship_factory(
+ source_ref=sample_data_component_object["id"],
+ target_ref=sample_technique_object["id"],
+ relationship_type="detects",
+ description=f"{sample_data_component_object['name']} detects {sample_technique_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_technique_targets_asset_relationship(
+ mock_relationship_factory, sample_technique_object, sample_asset_object
+):
+ """Sample relationship: attack-pattern targets x-mitre-asset."""
+ return mock_relationship_factory(
+ source_ref=sample_technique_object["id"],
+ target_ref=sample_asset_object["id"],
+ relationship_type="targets",
+ description=f"{sample_technique_object['name']} targets {sample_asset_object['name']}",
+ )
+
+
+@pytest.fixture
+def sample_revoked_by_relationship(mock_relationship_factory, sample_technique_object):
+ """Sample relationship: attack-pattern revoked-by attack-pattern."""
+ replacement_technique = {
+ "id": "attack-pattern--12345678-1234-5678-9abc-123456789012",
+ "name": "Replacement Technique",
+ }
+ return mock_relationship_factory(
+ source_ref=sample_technique_object["id"],
+ target_ref=replacement_technique["id"],
+ relationship_type="revoked-by",
+ description=f"{sample_technique_object['name']} revoked by {replacement_technique['name']}",
+ )
+
+
+@pytest.fixture
+def attack_bundle_factory():
+ """Create a STIX bundle from provided objects."""
+
+ def _create_bundle(objects, include_mitre_objects=True):
+ bundle_objects = list(objects)
+ if include_mitre_objects:
+ bundle_objects = [MITRE_IDENTITY, MITRE_MARKING_DEFINITION, *bundle_objects]
+ return {"type": "bundle", "id": f"bundle--{uuid.uuid4()}", "objects": bundle_objects}
+
+ return _create_bundle
+
+
+@pytest.fixture
+def attack_memstore_factory(attack_bundle_factory):
+ """Create a MemoryStore from provided STIX objects."""
+
+ def _create_memstore(objects, include_mitre_objects=True):
+ bundle = attack_bundle_factory(objects, include_mitre_objects=include_mitre_objects)
+ return MemoryStore(stix_data=bundle["objects"])
+
+ return _create_memstore
+
+
+@pytest.fixture
+def minimal_stix_bundles(mock_stix_object_factory, mock_relationship_factory, attack_bundle_factory):
+ """Create small old/new ATT&CK STIX bundles for changelog-style scenarios."""
+ old_technique1 = mock_stix_object_factory(
+ name="Existing Technique One", attack_id="T9001", version="1.0", stix_type="attack-pattern"
+ )
+ old_technique2 = mock_stix_object_factory(
+ name="Technique To Be Revoked", attack_id="T9002", version="1.0", stix_type="attack-pattern"
+ )
+ old_subtechnique = mock_stix_object_factory(
+ name="Existing Subtechnique",
+ attack_id="T9001.001",
+ version="1.0",
+ stix_type="attack-pattern",
+ is_subtechnique=True,
+ )
+ old_malware1 = mock_stix_object_factory(
+ name="Existing Malware One", attack_id="S9001", version="1.0", stix_type="malware", obj_type="malware"
+ )
+ old_malware2 = mock_stix_object_factory(
+ name="Malware To Be Deprecated", attack_id="S9002", version="1.0", stix_type="malware", obj_type="malware"
+ )
+ old_tool = mock_stix_object_factory(
+ name="Existing Tool", attack_id="S9003", version="1.0", stix_type="tool", obj_type="tool"
+ )
+ old_group1 = mock_stix_object_factory(
+ name="Existing Group One", attack_id="G9001", version="1.0", stix_type="intrusion-set", obj_type="intrusion-set"
+ )
+ old_group2 = mock_stix_object_factory(
+ name="Group To Be Modified",
+ attack_id="G9002",
+ version="1.0",
+ stix_type="intrusion-set",
+ obj_type="intrusion-set",
+ )
+ old_campaign1 = mock_stix_object_factory(
+ name="Existing Campaign One", attack_id="C9001", version="1.0", stix_type="campaign", obj_type="campaign"
+ )
+ old_campaign2 = mock_stix_object_factory(
+ name="Campaign To Be Deleted", attack_id="C9002", version="1.0", stix_type="campaign", obj_type="campaign"
+ )
+ old_mitigation1 = mock_stix_object_factory(
+ name="Existing Mitigation One",
+ attack_id="M9001",
+ version="1.0",
+ stix_type="course-of-action",
+ obj_type="course-of-action",
+ )
+ old_mitigation2 = mock_stix_object_factory(
+ name="Mitigation To Be Modified",
+ attack_id="M9002",
+ version="1.0",
+ stix_type="course-of-action",
+ obj_type="course-of-action",
+ )
+ old_datasource1 = mock_stix_object_factory(
+ name="Existing Data Source One",
+ attack_id="DS9001",
+ version="1.0",
+ stix_type="x-mitre-data-source",
+ obj_type="x-mitre-data-source",
+ )
+ old_datasource2 = mock_stix_object_factory(
+ name="Data Source To Be Modified",
+ attack_id="DS9002",
+ version="1.0",
+ stix_type="x-mitre-data-source",
+ obj_type="x-mitre-data-source",
+ )
+ old_datacomponent1 = mock_stix_object_factory(
+ name="Existing Data Component One",
+ attack_id="DC9001",
+ version="1.0",
+ stix_type="x-mitre-data-component",
+ obj_type="x-mitre-data-component",
+ )
+ old_datacomponent2 = mock_stix_object_factory(
+ name="Data Component To Be Modified",
+ attack_id="DC9002",
+ version="1.0",
+ stix_type="x-mitre-data-component",
+ obj_type="x-mitre-data-component",
+ )
+ old_asset1 = mock_stix_object_factory(
+ name="Existing Asset One", attack_id="A9001", version="1.0", stix_type="x-mitre-asset", obj_type="x-mitre-asset"
+ )
+ old_asset2 = mock_stix_object_factory(
+ name="Asset To Be Modified",
+ attack_id="A9002",
+ version="1.0",
+ stix_type="x-mitre-asset",
+ obj_type="x-mitre-asset",
+ )
+
+ old_relationship1 = mock_relationship_factory(
+ source_ref=old_group1["id"], target_ref=old_malware1["id"], relationship_type="uses"
+ )
+ old_relationship2 = mock_relationship_factory(
+ source_ref=old_malware1["id"], target_ref=old_technique1["id"], relationship_type="uses"
+ )
+ old_relationship3 = mock_relationship_factory(
+ source_ref=old_subtechnique["id"], target_ref=old_technique1["id"], relationship_type="subtechnique-of"
+ )
+ old_relationship4 = mock_relationship_factory(
+ source_ref=old_mitigation1["id"], target_ref=old_technique1["id"], relationship_type="mitigates"
+ )
+ old_relationship5 = mock_relationship_factory(
+ source_ref=old_datacomponent1["id"], target_ref=old_technique1["id"], relationship_type="detects"
+ )
+
+ new_technique1 = old_technique1.copy()
+ new_subtechnique = old_subtechnique.copy()
+ new_malware1 = old_malware1.copy()
+ new_tool = old_tool.copy()
+ new_group1 = old_group1.copy()
+ new_campaign1 = old_campaign1.copy()
+ new_mitigation1 = old_mitigation1.copy()
+ new_datasource1 = old_datasource1.copy()
+ new_datacomponent1 = old_datacomponent1.copy()
+ new_asset1 = old_asset1.copy()
+
+ new_group2_modified = old_group2.copy()
+ new_group2_modified["x_mitre_version"] = "1.1"
+ new_group2_modified["modified"] = "2025-01-15T12:00:00.000Z"
+ new_group2_modified["description"] = "Updated description for modified group"
+
+ new_mitigation2_modified = old_mitigation2.copy()
+ new_mitigation2_modified["x_mitre_version"] = "1.1"
+ new_mitigation2_modified["modified"] = "2025-01-15T12:00:00.000Z"
+
+ new_datasource2_modified = old_datasource2.copy()
+ new_datasource2_modified["x_mitre_version"] = "1.1"
+ new_datasource2_modified["modified"] = "2025-01-15T12:00:00.000Z"
+
+ new_datacomponent2_modified = old_datacomponent2.copy()
+ new_datacomponent2_modified["x_mitre_version"] = "1.1"
+ new_datacomponent2_modified["modified"] = "2025-01-15T12:00:00.000Z"
+
+ new_asset2_modified = old_asset2.copy()
+ new_asset2_modified["x_mitre_version"] = "1.1"
+ new_asset2_modified["modified"] = "2025-01-15T12:00:00.000Z"
+
+ replacement_technique = mock_stix_object_factory(
+ name="Replacement for Revoked Technique", attack_id="T9999", version="1.0", stix_type="attack-pattern"
+ )
+ new_technique2_revoked = old_technique2.copy()
+ new_technique2_revoked["revoked"] = True
+ new_technique2_revoked["x_mitre_version"] = "1.1"
+ new_technique2_revoked["modified"] = "2025-01-15T12:00:00.000Z"
+
+ new_malware2_deprecated = old_malware2.copy()
+ new_malware2_deprecated["x_mitre_deprecated"] = True
+ new_malware2_deprecated["x_mitre_version"] = "1.1"
+ new_malware2_deprecated["modified"] = "2025-01-15T12:00:00.000Z"
+
+ new_technique_added = mock_stix_object_factory(
+ name="Brand New Technique", attack_id="T9100", version="1.0", stix_type="attack-pattern"
+ )
+ new_malware_added = mock_stix_object_factory(
+ name="Brand New Malware", attack_id="S9100", version="1.0", stix_type="malware", obj_type="malware"
+ )
+ new_group_added = mock_stix_object_factory(
+ name="Brand New Group", attack_id="G9100", version="1.0", stix_type="intrusion-set", obj_type="intrusion-set"
+ )
+ new_campaign_added = mock_stix_object_factory(
+ name="Brand New Campaign", attack_id="C9100", version="1.0", stix_type="campaign", obj_type="campaign"
+ )
+ new_mitigation_added = mock_stix_object_factory(
+ name="Brand New Mitigation",
+ attack_id="M9100",
+ version="1.0",
+ stix_type="course-of-action",
+ obj_type="course-of-action",
+ )
+ new_datasource_added = mock_stix_object_factory(
+ name="Brand New Data Source",
+ attack_id="DS9100",
+ version="1.0",
+ stix_type="x-mitre-data-source",
+ obj_type="x-mitre-data-source",
+ )
+
+ new_relationship1 = old_relationship1.copy()
+ new_relationship2 = old_relationship2.copy()
+ new_relationship3 = old_relationship3.copy()
+ new_relationship4 = old_relationship4.copy()
+ new_relationship5 = old_relationship5.copy()
+ new_relationship6 = mock_relationship_factory(
+ source_ref=new_group_added["id"], target_ref=new_malware_added["id"], relationship_type="uses"
+ )
+ new_relationship7 = mock_relationship_factory(
+ source_ref=new_campaign_added["id"], target_ref=new_group_added["id"], relationship_type="attributed-to"
+ )
+ new_relationship8 = mock_relationship_factory(
+ source_ref=new_mitigation_added["id"], target_ref=new_technique_added["id"], relationship_type="mitigates"
+ )
+ new_relationship9 = mock_relationship_factory(
+ source_ref=new_technique2_revoked["id"], target_ref=replacement_technique["id"], relationship_type="revoked-by"
+ )
+
+ old_bundle = attack_bundle_factory(
+ [
+ old_technique1,
+ old_technique2,
+ old_subtechnique,
+ old_malware1,
+ old_malware2,
+ old_tool,
+ old_group1,
+ old_group2,
+ old_campaign1,
+ old_campaign2,
+ old_mitigation1,
+ old_mitigation2,
+ old_datasource1,
+ old_datasource2,
+ old_datacomponent1,
+ old_datacomponent2,
+ old_asset1,
+ old_asset2,
+ old_relationship1,
+ old_relationship2,
+ old_relationship3,
+ old_relationship4,
+ old_relationship5,
+ ]
+ )
+ new_bundle = attack_bundle_factory(
+ [
+ new_technique1,
+ new_subtechnique,
+ new_malware1,
+ new_tool,
+ new_group1,
+ new_campaign1,
+ new_mitigation1,
+ new_datasource1,
+ new_datacomponent1,
+ new_asset1,
+ new_group2_modified,
+ new_mitigation2_modified,
+ new_datasource2_modified,
+ new_datacomponent2_modified,
+ new_asset2_modified,
+ new_technique2_revoked,
+ new_malware2_deprecated,
+ new_technique_added,
+ new_malware_added,
+ new_group_added,
+ new_campaign_added,
+ new_mitigation_added,
+ new_datasource_added,
+ replacement_technique,
+ new_relationship1,
+ new_relationship2,
+ new_relationship3,
+ new_relationship4,
+ new_relationship5,
+ new_relationship6,
+ new_relationship7,
+ new_relationship8,
+ new_relationship9,
+ ]
+ )
+
+ expected_changes = {
+ "additions": [
+ new_technique_added,
+ new_malware_added,
+ new_group_added,
+ new_campaign_added,
+ new_mitigation_added,
+ new_datasource_added,
+ replacement_technique,
+ ],
+ "minor_version_changes": [
+ new_group2_modified,
+ new_mitigation2_modified,
+ new_datasource2_modified,
+ new_datacomponent2_modified,
+ new_asset2_modified,
+ ],
+ "revocations": [new_technique2_revoked],
+ "deprecations": [new_malware2_deprecated],
+ "deletions": [old_campaign2],
+ "new_relationships": [new_relationship6, new_relationship7, new_relationship8, new_relationship9],
+ }
+
+ return {"old": old_bundle, "new": new_bundle, "expected_changes": expected_changes}
+
+
+@pytest.fixture
+def setup_test_directories():
+ """Create old/new directories with STIX bundle files for selected domains."""
+
+ def _setup_directories(tmp_path, minimal_stix_bundles, domains, custom_bundles=None, write_files=True):
+ old_dir = tmp_path / "old"
+ new_dir = tmp_path / "new"
+ old_dir.mkdir()
+ new_dir.mkdir()
+
+ if write_files:
+ bundles_to_use = custom_bundles if custom_bundles is not None else minimal_stix_bundles
+ for domain in domains:
+ _write_bundle(old_dir / f"{domain}.json", bundles_to_use["old"])
+ _write_bundle(new_dir / f"{domain}.json", bundles_to_use["new"])
+
+ return str(old_dir), str(new_dir)
+
+ return _setup_directories
+
+
+def _write_bundle(path, content):
+ with open(path, "w") as f:
+ if isinstance(content, str):
+ f.write(content)
+ else:
+ json.dump(content, f)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index d6cbb7cb..aefa30ac 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -13,12 +13,22 @@
from pathlib import Path
import pytest
+from click import unstyle
+from typer.testing import CliRunner
-from mitreattack.navlayers import Layer
+from mitreattack.attackToExcel import attackToExcel
+from mitreattack.navlayers import Layer, layerExporter_cli
from mitreattack.navlayers.layerExporter_cli import main as LEC_main
from mitreattack.navlayers.layerGenerator_cli import main as LGC_main
+@pytest.fixture
+def attack_to_excel_runner():
+ """Return a CLI runner for the attack-to-excel Typer app."""
+ return CliRunner()
+
+
+@pytest.mark.slow
def test_export_svg(tmp_path: Path, layer_v43: Layer, stix_file_enterprise_latest: str):
"""Test SVG Export capabilities from CLI."""
demo_file = tmp_path / "demo_file.json"
@@ -42,10 +52,21 @@ def test_export_svg(tmp_path: Path, layer_v43: Layer, stix_file_enterprise_lates
assert test_export_svg_file.exists()
-def test_export_excel(tmp_path: Path, layer_v43: Layer, stix_file_enterprise_latest: str):
- """Test excel export capabilities from CLI."""
+def test_export_excel(monkeypatch, tmp_path: Path, layer_v43: Layer):
+ """Test excel export argument wiring from CLI."""
demo_file = tmp_path / "demo_file.json"
test_export_xlsx_file = tmp_path / "test_export_excel.xlsx"
+ calls = {}
+
+ class FakeToExcel:
+ def __init__(self, **kwargs):
+ calls["init"] = kwargs
+
+ def to_xlsx(self, layer, filepath):
+ calls["to_xlsx"] = {"layer": layer, "filepath": filepath}
+ Path(filepath).write_text("xlsx", encoding="utf-8")
+
+ monkeypatch.setattr(layerExporter_cli, "ToExcel", FakeToExcel)
layer_v43.to_file(str(demo_file))
LEC_main(
@@ -56,12 +77,18 @@ def test_export_excel(tmp_path: Path, layer_v43: Layer, stix_file_enterprise_lat
"--source",
"local",
"--resource",
- stix_file_enterprise_latest,
+ "enterprise-attack.json",
"--output",
str(test_export_xlsx_file),
]
)
+ assert calls["init"] == {
+ "domain": "enterprise-attack",
+ "source": "local",
+ "resource": "enterprise-attack.json",
+ }
+ assert calls["to_xlsx"]["filepath"] == str(test_export_xlsx_file)
assert test_export_xlsx_file.exists()
@@ -105,6 +132,7 @@ def test_generate_overview_software(tmp_path: Path, stix_file_mobile_latest: str
assert output_layer_file.exists()
+@pytest.mark.slow
def test_generate_overview_mitigation(tmp_path: Path, stix_file_enterprise_latest: str):
"""Test CLI mitigation overview generation."""
output_layer_file = tmp_path / "test_overview_mitigation.json"
@@ -125,6 +153,7 @@ def test_generate_overview_mitigation(tmp_path: Path, stix_file_enterprise_lates
assert output_layer_file.exists()
+@pytest.mark.slow
def test_generate_overview_datasource(tmp_path: Path, stix_file_enterprise_latest: str):
"""Test CLI datasource overview generation."""
output_layer_file = tmp_path / "test_overview_datasource.json"
@@ -145,6 +174,7 @@ def test_generate_overview_datasource(tmp_path: Path, stix_file_enterprise_lates
assert output_layer_file.exists()
+@pytest.mark.slow
def test_generate_mapped_group(tmp_path: Path, stix_file_enterprise_latest: str):
"""Test CLI group mapped generation (APT1)."""
output_layer_file = tmp_path / "test_mapped_group.json"
@@ -165,6 +195,7 @@ def test_generate_mapped_group(tmp_path: Path, stix_file_enterprise_latest: str)
assert output_layer_file.exists()
+@pytest.mark.slow
def test_generate_mapped_software(tmp_path: Path, stix_file_enterprise_latest: str):
"""Test CLI software mapped generation (S0202)."""
output_layer_file = tmp_path / "test_mapped_software.json"
@@ -205,6 +236,7 @@ def test_generate_mapped_mitigation(tmp_path: Path, stix_file_mobile_latest: str
assert output_layer_file.exists()
+@pytest.mark.slow
def test_generate_mapped_datasource(tmp_path: Path, stix_file_enterprise_latest: str):
"""Test CLI datasource mapped generation."""
output_layer_file = tmp_path / "test_mapped_datasource.json"
@@ -225,6 +257,167 @@ def test_generate_mapped_datasource(tmp_path: Path, stix_file_enterprise_latest:
assert output_layer_file.exists()
+def test_attack_to_excel_cli_from_stix_exports(monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner):
+ """from-stix should call export with parsed single-domain options."""
+ calls = {}
+
+ def fake_export(**kwargs):
+ calls["export"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ result = attack_to_excel_runner.invoke(
+ attackToExcel.app, ["from-stix", "--domain", "mobile-attack", "--version", "v19.0", "--output", str(tmp_path)]
+ )
+
+ assert result.exit_code == 0
+ assert calls["export"] == {
+ "domain": "mobile-attack",
+ "version": "v19.0",
+ "output_dir": str(tmp_path),
+ "remote": None,
+ "stix_file": None,
+ }
+
+
+def test_attack_to_excel_cli_from_stix_rejects_multiple_sources(attack_to_excel_runner: CliRunner):
+ """from-stix should reject ambiguous STIX source options."""
+ result = attack_to_excel_runner.invoke(
+ attackToExcel.app,
+ ["from-stix", "--remote", "http://localhost:3000", "--stix-file", "enterprise-attack.json"],
+ )
+
+ assert result.exit_code != 0
+ assert "mutually exclusive" in result.output
+
+
+def test_attack_to_excel_cli_from_release_all_domains(monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner):
+ """from-release should support release batch export options."""
+ calls = {}
+
+ def fake_export_release(**kwargs):
+ calls["export_release"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "export_release", fake_export_release)
+
+ result = attack_to_excel_runner.invoke(
+ attackToExcel.app,
+ [
+ "from-release",
+ "--version",
+ "v19.0",
+ "--stix-version",
+ "2.0",
+ "--output",
+ str(tmp_path),
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert calls["export_release"] == {
+ "version": "v19.0",
+ "stix_version": "2.0",
+ "output_dir": str(tmp_path),
+ "stix_base_dir": None,
+ "domains": None,
+ "versioned_output_dir": False,
+ }
+
+
+def test_attack_to_excel_cli_from_release_selected_domains(
+ monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner
+):
+ """from-release should pass selected release domains to release export."""
+ calls = {}
+
+ def fake_export_release(**kwargs):
+ calls["export_release"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "export_release", fake_export_release)
+
+ result = attack_to_excel_runner.invoke(
+ attackToExcel.app,
+ [
+ "from-release",
+ "--domains",
+ "mobile-attack",
+ "--domains",
+ "ics-attack",
+ "--output",
+ str(tmp_path),
+ "--versioned-output-dir",
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert calls["export_release"]["domains"] == ["mobile-attack", "ics-attack"]
+ assert calls["export_release"]["versioned_output_dir"] is True
+
+
+def test_attack_to_excel_cli_from_release_defaults_output_to_output_dir(monkeypatch, attack_to_excel_runner: CliRunner):
+ """from-release should use the release export default output directory."""
+ calls = {}
+
+ def fake_export_release(**kwargs):
+ calls["export_release"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "export_release", fake_export_release)
+
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-release"])
+
+ assert result.exit_code == 0
+ assert calls["export_release"]["output_dir"] == "output"
+
+
+def test_attack_to_excel_cli_rejects_root_legacy_all_domains(attack_to_excel_runner: CliRunner):
+ """Root-level legacy batch options should no longer dispatch exports."""
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["--all-domains"])
+
+ assert result.exit_code != 0
+ assert "No such option" in result.output
+
+
+def test_attack_to_excel_cli_rejects_root_legacy_domain(attack_to_excel_runner: CliRunner):
+ """Root-level legacy single-domain options should no longer dispatch exports."""
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["--domain", "mobile-attack"])
+
+ assert result.exit_code != 0
+ assert "No such option" in result.output
+
+
+def test_attack_to_excel_cli_help_lists_subcommands(attack_to_excel_runner: CliRunner):
+ """attack-to-excel help should expose the from-stix and from-release subcommands."""
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["--help"])
+ root_help = unstyle(result.output)
+
+ assert result.exit_code == 0
+ assert "from-stix" in root_help
+ assert "from-release" in root_help
+
+ from_stix_help = attack_to_excel_runner.invoke(attackToExcel.app, ["from-stix", "--help"])
+ from_stix_output = unstyle(from_stix_help.output)
+ assert from_stix_help.exit_code == 0
+ assert "--domain" in from_stix_output
+ assert "--remote" in from_stix_output
+ assert "--stix-file" in from_stix_output
+
+ from_release_help = attack_to_excel_runner.invoke(attackToExcel.app, ["from-release", "--help"])
+ from_release_output = unstyle(from_release_help.output)
+ assert from_release_help.exit_code == 0
+ assert "--domains" in from_release_output
+ assert "--stix-version" in from_release_output
+ assert "--stix-base-dir" in from_release_output
+
+
+def test_attack_to_excel_cli_no_args_shows_help(attack_to_excel_runner: CliRunner):
+ """attack-to-excel without a subcommand should show help instead of exporting."""
+ result = attack_to_excel_runner.invoke(attackToExcel.app, [])
+
+ assert result.exit_code == 0
+ assert "from-stix" in result.output
+ assert "from-release" in result.output
+
+
@pytest.mark.skip("layerGenerator_cli does not support ICS domain yet")
def test_generate_batch_group(tmp_path: Path, stix_file_ics_latest: str):
"""Test CLI group batch generation."""
@@ -267,6 +460,7 @@ def test_generate_batch_software(tmp_path: Path, stix_file_ics_latest: str):
assert output_layers_dir.is_dir()
+@pytest.mark.slow
def test_generate_batch_mitigation(tmp_path: Path, stix_file_enterprise_latest: str):
"""Test CLI mitigation batch generation."""
output_layers_dir = tmp_path / "test_batch_mitigation"
@@ -287,6 +481,7 @@ def test_generate_batch_mitigation(tmp_path: Path, stix_file_enterprise_latest:
assert output_layers_dir.is_dir()
+@pytest.mark.slow
def test_generate_batch_datasource(tmp_path: Path, stix_file_enterprise_latest: str):
"""Test CLI datasource batch generation."""
output_layers_dir = tmp_path / "test_batch_datasource"
diff --git a/tests/test_layers.py b/tests/test_layers.py
index 9b7f2d82..45cdb33f 100644
--- a/tests/test_layers.py
+++ b/tests/test_layers.py
@@ -80,6 +80,7 @@ def test_config_load(tmp_path: Path, memstore_enterprise_latest: MemoryStore):
assert Path(svg_output).exists()
+@pytest.mark.slow
def test_aggregate(tmp_path: Path, memstore_enterprise_latest: MemoryStore):
"""Test aggregate layer exports (agg configurations are present in each layer)."""
listing = [
diff --git a/tests/test_to_excel.py b/tests/test_to_excel.py
index 4b00aa0a..40edb1db 100644
--- a/tests/test_to_excel.py
+++ b/tests/test_to_excel.py
@@ -1,86 +1,431 @@
-"""
-Tests for ATT&CK to Excel export functionality.
-
-This module contains tests for verifying that ATT&CK domains (enterprise, mobile, ICS, legacy)
-are correctly exported to Excel spreadsheets using the attackToExcel module.
-"""
+"""Tests for ATT&CK to Excel export behavior."""
+from dataclasses import dataclass
from pathlib import Path
+import pandas as pd
+import pytest
import stix2
-from loguru import logger
+from openpyxl import load_workbook
from mitreattack.attackToExcel import attackToExcel
-# tmp_path is a built-in pytest tixture
-# https://docs.pytest.org/en/7.1.x/how-to/tmp_path.html
+def _object_data(object_type: str, rows=None, citations=None):
+ return {
+ object_type: pd.DataFrame(rows or [{"ID": f"{object_type}-1", "name": f"{object_type} one"}]),
+ "citations": pd.DataFrame(citations or [{"reference": "alpha", "citation": "Alpha Citation"}]),
+ }
-def check_excel_files_exist(excel_folder: Path, domain: str):
- """
- Check that all expected Excel files for the given ATT&CK domain exist in the specified folder.
- Parameters
- ----------
- excel_folder : Path
- The directory containing the exported Excel files.
- domain : str
- The ATT&CK domain (e.g., "enterprise-attack", "mobile-attack", "ics-attack").
+def _sheet_names(path: Path):
+ workbook = load_workbook(path, read_only=True)
+ try:
+ return workbook.sheetnames
+ finally:
+ workbook.close()
- Raises
- ------
- AssertionError
- If any expected file does not exist.
- Notes
- -----
- For "ics-attack", also checks for the existence of the assets file.
- """
- assert (excel_folder / f"{domain}.xlsx").exists()
- if domain == "ics-attack":
- # Only ICS has Assets
- assert (excel_folder / f"{domain}-assets.xlsx").exists()
- assert (excel_folder / f"{domain}-datacomponents.xlsx").exists()
- assert (excel_folder / f"{domain}-campaigns.xlsx").exists()
- assert (excel_folder / f"{domain}-groups.xlsx").exists()
- assert (excel_folder / f"{domain}-matrices.xlsx").exists()
- assert (excel_folder / f"{domain}-mitigations.xlsx").exists()
- assert (excel_folder / f"{domain}-relationships.xlsx").exists()
- assert (excel_folder / f"{domain}-software.xlsx").exists()
- assert (excel_folder / f"{domain}-tactics.xlsx").exists()
- assert (excel_folder / f"{domain}-techniques.xlsx").exists()
- assert (excel_folder / f"{domain}-analytics.xlsx").exists()
- assert (excel_folder / f"{domain}-detectionstrategies.xlsx").exists()
+def _sheet_rows(path: Path, sheet_name: str):
+ workbook = load_workbook(path, read_only=True, data_only=True)
+ try:
+ return [tuple(cell for cell in row) for row in workbook[sheet_name].iter_rows(values_only=True)]
+ finally:
+ workbook.close()
-def test_enterprise_latest(tmp_path: Path, memstore_enterprise_latest: stix2.MemoryStore):
- """Test most recent enterprise to excel spreadsheet functionality."""
- logger.debug(f"{tmp_path=}")
- domain = "enterprise-attack"
+@dataclass
+class FakeMergeRange:
+ """Small stand-in for matrix merge range objects."""
- attackToExcel.export(domain=domain, output_dir=str(tmp_path), mem_store=memstore_enterprise_latest)
+ data: str = "Merged Header"
+ leftCol: int = 1
+ rightCol: int = 2
+ format: dict | None = None
- excel_folder = tmp_path / domain
- check_excel_files_exist(excel_folder=excel_folder, domain=domain)
+ def to_excel_format(self):
+ """Return the Excel cell range used by the matrix writer."""
+ return "A4:B4"
-def test_mobile_latest(tmp_path: Path, memstore_mobile_latest: stix2.MemoryStore):
- """Test most recent mobile to excel spreadsheet functionality."""
- logger.debug(f"{tmp_path=}")
- domain = "mobile-attack"
+def test_export_with_memstore_uses_current_dataframe_builder(
+ monkeypatch,
+ tmp_path: Path,
+ attack_memstore_factory,
+ sample_technique_object,
+):
+ """Current exports should build v18+ dataframes and pass them to write_excel."""
+ mem_store = attack_memstore_factory([sample_technique_object])
+ dataframes = {"techniques": _object_data("techniques")}
+ calls = {}
- attackToExcel.export(domain="mobile-attack", output_dir=str(tmp_path), mem_store=memstore_mobile_latest)
+ def fake_build_dataframes(**kwargs):
+ calls["build_dataframes"] = kwargs
+ return dataframes
+
+ def fake_build_dataframes_pre_v18(**kwargs):
+ calls["build_dataframes_pre_v18"] = kwargs
+ return {}
+
+ def fake_write_excel(**kwargs):
+ calls["write_excel"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "build_dataframes", fake_build_dataframes)
+ monkeypatch.setattr(attackToExcel, "build_dataframes_pre_v18", fake_build_dataframes_pre_v18)
+ monkeypatch.setattr(attackToExcel, "write_excel", fake_write_excel)
+
+ attackToExcel.export(domain="enterprise-attack", output_dir=str(tmp_path), mem_store=mem_store)
+
+ assert calls["build_dataframes"] == {"src": mem_store, "domain": "enterprise-attack"}
+ assert "build_dataframes_pre_v18" not in calls
+ assert calls["write_excel"] == {
+ "dataframes": dataframes,
+ "domain": "enterprise-attack",
+ "src": mem_store,
+ "version": None,
+ "output_dir": str(tmp_path),
+ }
+
+
+def test_export_with_pre_v18_version_uses_legacy_dataframe_builder(
+ monkeypatch,
+ tmp_path: Path,
+ attack_memstore_factory,
+ sample_technique_object,
+):
+ """Pre-v18 exports should use the legacy dataframe builder."""
+ mem_store = attack_memstore_factory([sample_technique_object])
+ dataframes = {"techniques": _object_data("techniques")}
+ calls = {}
+
+ def fake_build_dataframes(**kwargs):
+ calls["build_dataframes"] = kwargs
+ return {}
+
+ def fake_build_dataframes_pre_v18(**kwargs):
+ calls["build_dataframes_pre_v18"] = kwargs
+ return dataframes
+
+ def fake_write_excel(**kwargs):
+ calls["write_excel"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "build_dataframes", fake_build_dataframes)
+ monkeypatch.setattr(attackToExcel, "build_dataframes_pre_v18", fake_build_dataframes_pre_v18)
+ monkeypatch.setattr(attackToExcel, "write_excel", fake_write_excel)
+
+ attackToExcel.export(domain="enterprise-attack", version="v17.0", output_dir=str(tmp_path), mem_store=mem_store)
+
+ assert calls["build_dataframes_pre_v18"] == {"src": mem_store, "domain": "enterprise-attack"}
+ assert "build_dataframes" not in calls
+ assert calls["write_excel"]["version"] == "v17.0"
+ assert calls["write_excel"]["dataframes"] is dataframes
+
+
+def test_export_rejects_multiple_stix_sources(attack_memstore_factory, sample_technique_object):
+ """Export should reject ambiguous STIX source inputs before building dataframes."""
+ mem_store = attack_memstore_factory([sample_technique_object])
+
+ with pytest.raises(TypeError, match="Exactly zero or one"):
+ attackToExcel.export(remote="http://localhost:3000", stix_file="bundle.json", mem_store=mem_store)
+
+
+def test_normalize_attack_version_adds_missing_prefix():
+ """ATT&CK release versions should be normalized to release directory names."""
+ assert attackToExcel.normalize_attack_version("19.0") == "v19.0"
+ assert attackToExcel.normalize_attack_version("v19.0") == "v19.0"
+
+
+def test_export_release_uses_existing_local_stix_files(tmp_path: Path, monkeypatch):
+ """Release export should use existing local STIX files without downloading."""
+ stix_base_dir = tmp_path / "attack-releases" / "stix-2.0" / "v19.0"
+ stix_base_dir.mkdir(parents=True)
+ for domain in ["enterprise-attack", "mobile-attack"]:
+ (stix_base_dir / f"{domain}.json").write_text("{}", encoding="utf-8")
+
+ calls = {}
+
+ def fake_download_domains(**kwargs):
+ calls.setdefault("downloads", []).append(kwargs)
+
+ def fake_export(**kwargs):
+ calls.setdefault("exports", []).append(kwargs)
+
+ monkeypatch.setattr(attackToExcel, "download_domains", fake_download_domains)
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ attackToExcel.export_release(
+ version="19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(tmp_path / "output"),
+ domains=["enterprise-attack", "mobile-attack"],
+ )
+
+ assert "downloads" not in calls
+ assert [call["domain"] for call in calls["exports"]] == ["enterprise-attack", "mobile-attack"]
+ assert calls["exports"][0]["stix_file"] == str(stix_base_dir / "enterprise-attack.json")
+ assert calls["exports"][0]["version"] == "v19.0"
+ assert calls["exports"][0]["output_dir"] == str(tmp_path / "output" / "v19.0")
+
+
+def test_export_release_with_explicit_local_stix_base_dir_without_version_is_unversioned(tmp_path: Path, monkeypatch):
+ """Explicit local STIX bundle directories should not be labelled as ATT&CK releases unless a version is given."""
+ stix_base_dir = tmp_path / "attack-releases" / "stix-2.0" / "attackwb"
+ stix_base_dir.mkdir(parents=True)
+ for domain in ["enterprise-attack", "mobile-attack"]:
+ (stix_base_dir / f"{domain}.json").write_text("{}", encoding="utf-8")
+
+ calls = {}
+
+ def fake_export(**kwargs):
+ calls.setdefault("exports", []).append(kwargs)
+
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ attackToExcel.export_release(
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(tmp_path / "output" / "attackwb"),
+ domains=["enterprise-attack", "mobile-attack"],
+ )
+
+ assert [call["domain"] for call in calls["exports"]] == ["enterprise-attack", "mobile-attack"]
+ assert calls["exports"][0]["version"] is None
+ assert calls["exports"][0]["output_dir"] == str(tmp_path / "output" / "attackwb")
+ assert calls["exports"][1]["version"] is None
+ assert calls["exports"][1]["output_dir"] == str(tmp_path / "output" / "attackwb")
- excel_folder = tmp_path / domain
- check_excel_files_exist(excel_folder=excel_folder, domain=domain)
+def test_export_release_downloads_only_missing_domains_to_temporary_directory(tmp_path: Path, monkeypatch):
+ """Missing release STIX files should be downloaded per missing domain into a temporary tree."""
+ stix_base_dir = tmp_path / "attack-releases" / "stix-2.0" / "v19.0"
+ stix_base_dir.mkdir(parents=True)
+ (stix_base_dir / "enterprise-attack.json").write_text("{}", encoding="utf-8")
+ calls = {}
-def test_ics_latest(tmp_path: Path, memstore_ics_latest: stix2.MemoryStore):
- """Test most recent ics to excel spreadsheet functionality."""
- logger.debug(f"{tmp_path=}")
- domain = "ics-attack"
+ def fake_download_domains(**kwargs):
+ calls["download"] = kwargs
+ release_dir = Path(kwargs["download_dir"]) / "v19.0"
+ release_dir.mkdir(parents=True)
+ for domain in kwargs["domains"]:
+ (release_dir / f"{domain}-attack.json").write_text("{}", encoding="utf-8")
- attackToExcel.export(domain="ics-attack", output_dir=str(tmp_path), mem_store=memstore_ics_latest)
+ def fake_export(**kwargs):
+ calls.setdefault("exports", []).append(kwargs)
+ assert Path(kwargs["stix_file"]).exists()
+
+ monkeypatch.setattr(attackToExcel, "download_domains", fake_download_domains)
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ attackToExcel.export_release(
+ version="v19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(tmp_path / "output"),
+ domains=["enterprise-attack", "mobile-attack", "ics-attack"],
+ )
+
+ assert calls["download"]["domains"] == ["mobile", "ics"]
+ assert calls["download"]["all_versions"] is False
+ assert calls["download"]["stix_version"] == "2.0"
+ assert calls["download"]["attack_versions"] == ["19.0"]
+ assert calls["exports"][0]["stix_file"] == str(stix_base_dir / "enterprise-attack.json")
+ assert calls["exports"][1]["stix_file"].endswith("stix-2.0/v19.0/mobile-attack.json")
+ assert calls["exports"][2]["stix_file"].endswith("stix-2.0/v19.0/ics-attack.json")
+ assert not Path(calls["exports"][1]["stix_file"]).exists()
+
+
+def test_export_release_moves_versioned_outputs_to_domain_directory(tmp_path: Path, monkeypatch):
+ """Default release export should flatten domain-version folders into domain folders."""
+
+ def fake_export(**kwargs):
+ output_dir = Path(kwargs["output_dir"])
+ versioned_dir = output_dir / f"{kwargs['domain']}-{kwargs['version']}"
+ versioned_dir.mkdir(parents=True)
+ (versioned_dir / f"{kwargs['domain']}-{kwargs['version']}.xlsx").write_text("excel", encoding="utf-8")
+
+ stix_base_dir = tmp_path / "stix"
+ stix_base_dir.mkdir()
+ (stix_base_dir / "enterprise-attack.json").write_text("{}", encoding="utf-8")
+
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ attackToExcel.export_release(
+ version="v19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(tmp_path / "output"),
+ domains=["enterprise-attack"],
+ )
+
+ assert not (tmp_path / "output" / "v19.0" / "enterprise-attack-v19.0").exists()
+ assert (tmp_path / "output" / "v19.0" / "enterprise-attack" / "enterprise-attack-v19.0.xlsx").exists()
+
+
+def test_export_release_rejects_invalid_domain():
+ """Release export should validate selected ATT&CK domains."""
+ with pytest.raises(ValueError, match="Invalid ATT&CK domain"):
+ attackToExcel.export_release(domains=["pre-attack"])
+
+
+def test_write_excel_creates_expected_workbooks(monkeypatch, tmp_path: Path, attack_memstore_factory):
+ """write_excel should create a master workbook and object-specific workbooks."""
+ monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame())
+ mem_store = attack_memstore_factory([])
+ dataframes = {
+ "techniques": _object_data("techniques"),
+ "groups": _object_data("groups"),
+ }
+
+ written_files = attackToExcel.write_excel(
+ dataframes=dataframes,
+ domain="enterprise-attack",
+ src=mem_store,
+ output_dir=str(tmp_path),
+ )
+
+ output_folder = tmp_path / "enterprise-attack"
+ assert set(map(Path, written_files)) == {
+ output_folder / "enterprise-attack-techniques.xlsx",
+ output_folder / "enterprise-attack-groups.xlsx",
+ output_folder / "enterprise-attack.xlsx",
+ }
+ assert _sheet_names(output_folder / "enterprise-attack.xlsx") == ["techniques", "groups", "citations"]
+
+
+def test_write_excel_skips_empty_object_data(monkeypatch, tmp_path: Path, attack_memstore_factory):
+ """Empty object data should not produce an object workbook."""
+ monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame())
+ mem_store = attack_memstore_factory([])
+ dataframes = {
+ "techniques": {},
+ "groups": _object_data("groups"),
+ }
+
+ attackToExcel.write_excel(
+ dataframes=dataframes,
+ domain="enterprise-attack",
+ src=mem_store,
+ output_dir=str(tmp_path),
+ )
+
+ output_folder = tmp_path / "enterprise-attack"
+ assert not (output_folder / "enterprise-attack-techniques.xlsx").exists()
+ assert (output_folder / "enterprise-attack-groups.xlsx").exists()
+
+
+def test_write_excel_dedupes_and_sorts_citations(monkeypatch, tmp_path: Path, attack_memstore_factory):
+ """Master citations should be deduped by reference and sorted by reference."""
+ monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame())
+ mem_store = attack_memstore_factory([])
+ dataframes = {
+ "techniques": _object_data(
+ "techniques",
+ citations=[
+ {"reference": "zeta", "citation": "Zeta Citation"},
+ {"reference": "alpha", "citation": "Alpha Citation"},
+ ],
+ ),
+ "groups": _object_data("groups", citations=[{"reference": "alpha", "citation": "Duplicate Alpha"}]),
+ }
+
+ attackToExcel.write_excel(
+ dataframes=dataframes,
+ domain="enterprise-attack",
+ src=mem_store,
+ output_dir=str(tmp_path),
+ )
+
+ rows = _sheet_rows(tmp_path / "enterprise-attack" / "enterprise-attack.xlsx", "citations")
+ assert rows == [
+ ("reference", "citation"),
+ ("alpha", "Alpha Citation"),
+ ("zeta", "Zeta Citation"),
+ ]
+
+
+def test_write_excel_adds_defensive_mappings_sheet(monkeypatch, tmp_path: Path, attack_memstore_factory):
+ """Defensive mappings should be added to relevant object workbooks and the master workbook."""
+ defensive_mappings = pd.DataFrame([{"analytic_id": "AN0001", "analytic_name": "Analytic One"}])
+ monkeypatch.setattr(
+ attackToExcel.stixToDf,
+ "detectionStrategiesAnalyticsLogSourcesDf",
+ lambda src: defensive_mappings,
+ )
+ mem_store = attack_memstore_factory([])
+ dataframes = {
+ "detectionstrategies": _object_data("detectionstrategies"),
+ "analytics": _object_data("analytics"),
+ "datacomponents": _object_data("datacomponents"),
+ }
+
+ attackToExcel.write_excel(
+ dataframes=dataframes,
+ domain="enterprise-attack",
+ src=mem_store,
+ output_dir=str(tmp_path),
+ )
+
+ output_folder = tmp_path / "enterprise-attack"
+ assert "defensive mappings" in _sheet_names(output_folder / "enterprise-attack.xlsx")
+ assert "defensive mappings" in _sheet_names(output_folder / "enterprise-attack-detectionstrategies.xlsx")
+ assert "defensive mappings" in _sheet_names(output_folder / "enterprise-attack-analytics.xlsx")
+ assert "defensive mappings" in _sheet_names(output_folder / "enterprise-attack-datacomponents.xlsx")
+
+
+def test_write_excel_sanitizes_matrix_sheet_names_and_applies_merges(
+ monkeypatch,
+ tmp_path: Path,
+ attack_memstore_factory,
+):
+ """Matrix sheet names should be Excel-safe and merged headers should be applied."""
+ monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame())
+ mem_store = attack_memstore_factory([])
+ matrix_name = "Bad/Matrix:Name?With[Chars]*AndAVeryLongSuffix"
+ second_matrix_name = "Second/Matrix:Name?With[Chars]*AndAVeryLongSuffix"
+ dataframes = {
+ "techniques": _object_data("techniques"),
+ "matrices": (
+ [
+ {
+ "name": matrix_name,
+ "matrix": pd.DataFrame([{"tactic one": "Technique", "tactic two": "Other"}]),
+ "columns": 2,
+ "merge": [
+ FakeMergeRange(format={"name": "tacticHeader", "format": {"bold": True}}),
+ ],
+ }
+ ],
+ [
+ {
+ "name": second_matrix_name,
+ "matrix": pd.DataFrame([{"tactic one": "Subtechnique", "tactic two": "Other"}]),
+ "columns": 2,
+ "merge": [],
+ }
+ ],
+ ),
+ }
+
+ attackToExcel.write_excel(
+ dataframes=dataframes,
+ domain="enterprise-attack",
+ src=mem_store,
+ output_dir=str(tmp_path),
+ )
+
+ output_folder = tmp_path / "enterprise-attack"
+ matrix_sheets = _sheet_names(output_folder / "enterprise-attack-matrices.xlsx")
+ assert len(matrix_sheets) == 2
+ assert all(sheet.endswith("...") for sheet in matrix_sheets)
+ assert all(not any(character in sheet for character in attackToExcel.INVALID_CHARACTERS) for sheet in matrix_sheets)
+ assert matrix_sheets[0] in _sheet_names(output_folder / "enterprise-attack.xlsx")
+
+
+@pytest.mark.integration
+@pytest.mark.slow
+def test_enterprise_latest_smoke(tmp_path: Path, memstore_enterprise_latest: stix2.MemoryStore):
+ """A full Enterprise export smoke test is available for explicit integration runs."""
+ domain = "enterprise-attack"
+
+ attackToExcel.export(domain=domain, output_dir=str(tmp_path), mem_store=memstore_enterprise_latest)
excel_folder = tmp_path / domain
- check_excel_files_exist(excel_folder=excel_folder, domain=domain)
+ assert (excel_folder / f"{domain}.xlsx").exists()
+ assert (excel_folder / f"{domain}-techniques.xlsx").exists()
diff --git a/uv.lock b/uv.lock
index ddbd677e..0d378d0c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -699,7 +699,7 @@ requires-dist = [
{ name = "pillow", specifier = ">=10.1.0" },
{ name = "pooch", specifier = ">=1.7.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" },
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.2" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-dotenv", marker = "extra == 'dev'", specifier = ">=0.5.2" },
{ name = "python-dateutil", specifier = ">=2.8.2" },