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