From 324ba63c9f8ecaf6cff6a3f938e5c73b8c1e0adc Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Thu, 7 May 2026 14:06:53 -0500 Subject: [PATCH 1/4] fix: correct Sphinx build command in Read the Docs configuration --- .readthedocs.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f567792..a5c97a4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -12,7 +12,8 @@ build: - pip install uv - uv sync --extra docs build: - - uv run sphinx-build -b html docs $READTHEDOCS_OUTPUT/html + html: + - uv run sphinx-build -b html docs $READTHEDOCS_OUTPUT/html sphinx: configuration: docs/conf.py From f684d40c95bf1f17df78040b1787ac2d5d30ef72 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Thu, 7 May 2026 14:07:10 -0500 Subject: [PATCH 2/4] docs: update README to include badges --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 26fa0a9..a5bc365 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # mitreattack-python +[![PyPI version](https://img.shields.io/pypi/v/mitreattack-python.svg)](https://pypi.org/project/mitreattack-python/) [![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) [![License](https://img.shields.io/pypi/l/mitreattack-python.svg)](https://github.com/mitre-attack/mitreattack-python/blob/main/LICENSE) [![Docs](https://img.shields.io/readthedocs/mitreattack-python.svg)](https://mitreattack-python.readthedocs.io/) [![Lint and Test](https://img.shields.io/github/actions/workflow/status/mitre-attack/mitreattack-python/lint-and-test.yml?label=lint%20%26%20test)](https://github.com/mitre-attack/mitreattack-python/actions/workflows/lint-and-test.yml) [![Release and Publish](https://img.shields.io/github/actions/workflow/status/mitre-attack/mitreattack-python/release-and-publish.yml?branch=main&label=release)](https://github.com/mitre-attack/mitreattack-python/actions/workflows/release-and-publish.yml) + This repository contains a library of Python tools and utilities for working with ATT&CK data. For more information, see the [full documentation](https://mitreattack-python.readthedocs.io/) on ReadTheDocs. From 99d43f36abf1978b645a6611b816674625c02952 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Thu, 7 May 2026 14:46:34 -0500 Subject: [PATCH 3/4] feat: add overwrite option for Excel file exports - Introduced an `--overwrite` flag in CLI commands to allow users to replace existing Excel files during export. - Enhanced logging to inform users when existing files are being overwritten. - Updated the `write_excel` function to handle overwriting of existing files and log actions accordingly. - Modified tests to cover scenarios for overwriting existing files and logging behavior. - Updated dependencies to include `click` version 8.1.8 for improved CLI functionality. --- mitreattack/attackToExcel/README.md | 11 +- mitreattack/attackToExcel/attackToExcel.py | 271 ++++++++++++-- pyproject.toml | 1 + tests/test_cli.py | 150 ++++++++ tests/test_to_excel.py | 406 ++++++++++++++++++++- uv.lock | 2 + 6 files changed, 788 insertions(+), 53 deletions(-) diff --git a/mitreattack/attackToExcel/README.md b/mitreattack/attackToExcel/README.md index f86ee61..a775cb8 100644 --- a/mitreattack/attackToExcel/README.md +++ b/mitreattack/attackToExcel/README.md @@ -46,6 +46,11 @@ Build Excel files for selected ATT&CK domains from a release: attack-to-excel from-release --version v19.0 --domains mobile-attack --domains ics-attack ``` +`attack-to-excel` refuses to run when generated output directories already +contain Excel files. Pass `--overwrite` to `from-stix` or `from-release` to +replace existing Excel files. Pass `-v` or `--verbose` to show debug logs, +including sheet-level write messages. + ### Module Example execution targeting a specific domain and version: @@ -75,9 +80,9 @@ overview of the available methods follows. |:------------|:----------|:------| |get_stix_data|`domain`: the domain of ATT&CK to fetch data from
`version`: optional parameter indicating which version to fetch data from (such as "v8.1"). If omitted retrieves the most recent version of ATT&CK.
`remote`: optional parameter that provides a URL of a remote ATT&CK Workbench instance to grab data from.| Retrieves the ATT&CK STIX data for the specified version and returns it as a MemoryStore object| |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 | +|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
`overwrite`: optional parameter allowing existing Excel files to be replaced| 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
`overwrite`: optional parameter allowing existing Excel files to be replaced| 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
`overwrite`: optional parameter allowing existing Excel files to be replaced| 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 b69c0eb..149ed77 100644 --- a/mitreattack/attackToExcel/attackToExcel.py +++ b/mitreattack/attackToExcel/attackToExcel.py @@ -2,11 +2,13 @@ import os import re +import sys import tempfile from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional +import click import pandas as pd import requests import typer @@ -90,13 +92,72 @@ def _release_stix_file(release_dir: Path, domain: str) -> Path: return release_dir / f"{domain}.json" -def _move_versioned_exports_to_domain_dir(output_dir: Path, domain: str, version: str): +def _domain_version_string(domain: str, version: Optional[str]) -> str: + """Return the folder and filename prefix used for one domain export.""" + return f"{domain}-{version}" if version else domain + + +def _excel_output_dir(output_dir: Path, domain: str, version: Optional[str]) -> Path: + """Return the directory that one domain export writes Excel files into.""" + return output_dir / _domain_version_string(domain, version) + + +def _release_staging_output_dir(output_dir: Path) -> Path: + """Return the parent directory for staged release Excel files.""" + return output_dir / "tmp" / "staged-excel-files" + + +def _existing_excel_files(directory: Path) -> List[Path]: + """Return existing Excel files directly under a generated output directory.""" + if not directory.is_dir(): + return [] + return sorted(path for path in directory.glob("*.xlsx") if path.is_file()) + + +def _raise_if_excel_files_exist(paths: List[Path]): + """Refuse to continue when generated output would overwrite existing Excel files.""" + if not paths: + return + + files_text = "\n".join(f" - {path}" for path in paths) + raise FileExistsError( + "Refusing to overwrite existing Excel file(s). " + "Move or delete these files, choose a different output directory, or pass --overwrite to replace them:\n" + f"{files_text}" + ) + + +def _log_excel_overwrite(path: Path | str, overwrite: bool): + """Log when an existing Excel file is about to be replaced.""" + excel_path = Path(path) + if overwrite and excel_path.is_file(): + logger.info(f"Overwriting existing Excel file: {excel_path}") + + +def _log_excel_overwrites(paths: List[Path]): + """Log a preflight summary of existing Excel files that will be replaced.""" + if not paths: + return + + logger.info("Existing Excel files will be overwritten:") + for path in paths: + logger.info(f"Overwriting existing Excel file: {path}") + + +def _move_versioned_exports_to_domain_dir( + output_dir: Path, + domain: str, + version: Optional[str], + overwrite: bool = False, + staging_output_dir: Optional[Path] = None, +) -> List[Path]: """Move versioned Excel exports into the unversioned domain folder.""" - versioned_dir = output_dir / f"{domain}-{version}" + versioned_dir = _excel_output_dir(staging_output_dir or output_dir, domain, version) domain_dir = output_dir / domain + moved_files = [] if not versioned_dir.is_dir(): - return + return moved_files domain_dir.mkdir(parents=True, exist_ok=True) for source_path in versioned_dir.iterdir(): @@ -105,10 +166,15 @@ def _move_versioned_exports_to_domain_dir(output_dir: Path, domain: str, version target_path = domain_dir / source_path.name if target_path.exists(): + if not overwrite: + _raise_if_excel_files_exist([target_path]) + logger.debug(f"Replacing existing Excel file: {target_path}") target_path.unlink() source_path.replace(target_path) + moved_files.append(target_path) versioned_dir.rmdir() + return moved_files def _download_missing_release_domains( @@ -137,6 +203,7 @@ def export_release( stix_base_dir: Optional[str] = None, domains: Optional[List[str]] = None, versioned_output_dir: bool = False, + overwrite: bool = False, ): """Export one ATT&CK release to Excel for one or more domains.""" if stix_version not in VALID_STIX_VERSIONS: @@ -160,12 +227,24 @@ def export_release( 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()] + existing_release_excel_files = _existing_release_excel_files( + output_dir=release_output_dir, + domains=release_domains, + version=attack_version, + versioned_output_dir=versioned_output_dir, + ) + if overwrite: + _log_excel_overwrites(existing_release_excel_files) + else: + _raise_if_excel_files_exist(existing_release_excel_files) + 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, + overwrite=overwrite, ) return @@ -194,23 +273,67 @@ def export_release( output_dir=release_output_dir, stix_files=stix_files, versioned_output_dir=versioned_output_dir, + overwrite=overwrite, ) +def _existing_release_excel_files( + *, + output_dir: Path, + domains: List[str], + version: Optional[str], + versioned_output_dir: bool, +) -> List[Path]: + """Return existing Excel files that a release export could overwrite.""" + existing_files = [] + for domain in domains: + candidate_dirs = [_excel_output_dir(output_dir, domain, version)] + if not versioned_output_dir: + candidate_dirs.append(output_dir / domain) + + for candidate_dir in candidate_dirs: + existing_files.extend(_existing_excel_files(candidate_dir)) + + return sorted(set(existing_files)) + + def _export_release_domains( *, version: Optional[str], output_dir: Path, stix_files: Dict[str, Path], versioned_output_dir: bool, + overwrite: 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)) + domain_output_dir = output_dir if versioned_output_dir else _release_staging_output_dir(output_dir) + if not versioned_output_dir: + logger.info( + f"Writing staged Excel files for {domain} to {_excel_output_dir(domain_output_dir, domain, version)}" + ) + + export( + domain=domain, + version=version, + output_dir=str(domain_output_dir), + stix_file=str(stix_file), + overwrite=overwrite, + log_written_files=versioned_output_dir, + ) if not versioned_output_dir: - _move_versioned_exports_to_domain_dir(output_dir=output_dir, domain=domain, version=version) + logger.info(f"Moving staged Excel files for {domain} to {output_dir / domain}") + moved_files = _move_versioned_exports_to_domain_dir( + output_dir=output_dir, + domain=domain, + version=version, + overwrite=overwrite, + staging_output_dir=domain_output_dir, + ) + for moved_file in moved_files: + logger.info(f"Excel file written: {moved_file}") def get_stix_data( @@ -362,7 +485,13 @@ def build_ds_an_lg_relationships(dataframes: Dict) -> Dict[str, pd.DataFrame]: def write_excel( - dataframes: Dict, domain: str, src: MemoryStore, version: Optional[str] = None, output_dir: str = "." + dataframes: Dict, + domain: str, + src: MemoryStore, + version: Optional[str] = None, + output_dir: str = ".", + overwrite: bool = False, + log_written_files: bool = True, ) -> List: """Given a set of dataframes from build_dataframes, write the ATT&CK dataset to output directory. @@ -381,6 +510,10 @@ def write_excel( output_dir : str, optional The directory to write the excel files to. If omitted writes to a subfolder of the current directory depending on specified domain and version, by default "." + overwrite : bool, optional + Whether to replace existing Excel files in the generated output directory, by default False + log_written_files : bool, optional + Whether to log each written Excel file path, by default True Returns ------- @@ -391,15 +524,15 @@ def write_excel( # master list of files that have been written written_files = [] # set up output directory - if version: - domain_version_string = f"{domain}-{version}" - else: - domain_version_string = domain - output_directory = os.path.join(output_dir, domain_version_string) - if not os.path.exists(output_directory): - os.makedirs(output_directory) + domain_version_string = _domain_version_string(domain, version) + output_directory_path = _excel_output_dir(Path(output_dir), domain, version) + if not overwrite: + _raise_if_excel_files_exist(_existing_excel_files(output_directory_path)) + output_directory_path.mkdir(parents=True, exist_ok=True) + output_directory = str(output_directory_path) # master dataset file master_fp = os.path.join(output_directory, f"{domain_version_string}.xlsx") + _log_excel_overwrite(master_fp, overwrite=overwrite) ds_an_ls_df = stixToDf.detectionStrategiesAnalyticsLogSourcesDf(src) add_ds_an_ls_to = {"detectionstrategies", "analytics", "datacomponents"} @@ -418,6 +551,7 @@ def write_excel( continue # write the dataframes for the object type into named sheets + _log_excel_overwrite(fp, overwrite=overwrite) with pd.ExcelWriter(fp) as object_writer: for sheet_name in object_data: logger.debug(f"Writing sheet to {fp}: {sheet_name}") @@ -441,6 +575,7 @@ def write_excel( object_data[object_type].to_excel(master_writer, sheet_name=object_type, index=False) else: # handle matrix special formatting + _log_excel_overwrite(fp, overwrite=overwrite) with pd.ExcelWriter(fp, engine="xlsxwriter") as matrix_writer: # Combine both matrix types combined = object_data[0] + object_data[1] @@ -526,8 +661,9 @@ def write_excel( written_files.append(master_fp) - for thefile in written_files: - logger.info(f"Excel file created: {thefile}") + if log_written_files: + for thefile in written_files: + logger.info(f"Excel file written: {thefile}") return written_files @@ -538,6 +674,8 @@ def export( remote: Optional[str] = None, stix_file: Optional[str] = None, mem_store: Optional[MemoryStore] = None, + overwrite: bool = False, + log_written_files: bool = True, ): """Download ATT&CK data from MITRE/CTI and convert it to Excel spreadsheets. @@ -564,6 +702,10 @@ def export( A STIX bundle containing ATT&CK data for a domain already loaded into memory. Mutually exclusive with `remote` and `stix_file`. By default None + overwrite : bool, optional + Whether to replace existing Excel files in the generated output directory, by default False + log_written_files : bool, optional + Whether to log each written Excel file path, by default True Raises ------ @@ -582,6 +724,9 @@ def export( get_stix_from_github = remote is None and stix_file is None and mem_store is None + if not overwrite: + _raise_if_excel_files_exist(_existing_excel_files(_excel_output_dir(Path(output_dir), domain, version))) + if get_stix_from_github or remote or stix_file: mem_store = get_stix_data(domain=domain, version=version, remote=remote, stix_file=stix_file) @@ -598,11 +743,27 @@ def export( major_version = int(match.group(1)) if major_version < 18: dataframes = build_dataframes_pre_v18(src=mem_store, domain=domain) - write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir) + write_excel( + dataframes=dataframes, + domain=domain, + src=mem_store, + version=version, + output_dir=output_dir, + overwrite=overwrite, + log_written_files=log_written_files, + ) return dataframes = build_dataframes(src=mem_store, domain=domain) - write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir) + write_excel( + dataframes=dataframes, + domain=domain, + src=mem_store, + version=version, + output_dir=output_dir, + overwrite=overwrite, + log_written_files=log_written_files, + ) def _validate_cli_value(value: str, allowed_values: tuple[str, ...], label: str) -> str: @@ -653,20 +814,40 @@ def from_stix_cli( help="Path to a local STIX file containing ATT&CK data for a domain.", ), ] = None, + overwrite: Annotated[ + bool, + typer.Option( + "--overwrite", + help="Replace existing Excel files in the generated output directory.", + ), + ] = False, + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help="Show debug log messages.", + ), + ] = False, ): """Convert one ATT&CK domain STIX bundle to Excel.""" + _configure_cli_logging(verbose=verbose) 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=domain, - version=version, - output_dir=output, - remote=remote, - stix_file=stix_file, - ) + try: + export( + domain=domain, + version=version, + output_dir=output, + remote=remote, + stix_file=stix_file, + overwrite=overwrite, + ) + except FileExistsError as error: + raise click.ClickException(str(error)) from error @app.command("from-release") @@ -713,21 +894,47 @@ def from_release_cli( help="Preserve domain-version output folders.", ), ] = False, + overwrite: Annotated[ + bool, + typer.Option( + "--overwrite", + help="Replace existing Excel files in generated output directories.", + ), + ] = False, + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help="Show debug log messages.", + ), + ] = False, ): """Convert ATT&CK release domain bundles to Excel.""" + _configure_cli_logging(verbose=verbose) 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, - ) + try: + 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, + overwrite=overwrite, + ) + except FileExistsError as error: + raise click.ClickException(str(error)) from error + + +def _configure_cli_logging(verbose: bool = False): + """Configure attack-to-excel CLI output for user-facing progress logs.""" + logger.remove() + logger.add(sys.stderr, level="DEBUG" if verbose else "INFO") def main(argv=None): diff --git a/pyproject.toml b/pyproject.toml index fae300a..49b0031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ + "click>=8.1.8", "colour>=0.1.5", "deepdiff>=6.6.0", "drawsvg>=2.4.0", diff --git a/tests/test_cli.py b/tests/test_cli.py index aefa30a..4cf1099 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -277,9 +277,61 @@ def fake_export(**kwargs): "output_dir": str(tmp_path), "remote": None, "stix_file": None, + "overwrite": False, } +def test_attack_to_excel_cli_from_stix_passes_overwrite(monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner): + """from-stix should pass overwrite requests to export.""" + calls = {} + + def fake_export(**kwargs): + calls["export"] = kwargs + + monkeypatch.setattr(attackToExcel, "export", fake_export) + + result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-stix", "--output", str(tmp_path), "--overwrite"]) + + assert result.exit_code == 0 + assert calls["export"]["overwrite"] is True + + +def test_attack_to_excel_cli_from_stix_configures_verbose_logging( + monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner +): + """from-stix should enable debug logging when verbose is requested.""" + calls = {} + + def fake_configure_cli_logging(**kwargs): + calls["logging"] = kwargs + + def fake_export(**kwargs): + calls["export"] = kwargs + + monkeypatch.setattr(attackToExcel, "_configure_cli_logging", fake_configure_cli_logging) + monkeypatch.setattr(attackToExcel, "export", fake_export) + + result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-stix", "--output", str(tmp_path), "--verbose"]) + + assert result.exit_code == 0 + assert calls["logging"] == {"verbose": True} + + +def test_attack_to_excel_cli_from_stix_reports_existing_excel_file(monkeypatch, attack_to_excel_runner: CliRunner): + """from-stix should report existing Excel files without a traceback.""" + + def fake_export(**kwargs): + raise FileExistsError("Refusing to overwrite existing Excel file(s). Pass --overwrite to replace them.") + + monkeypatch.setattr(attackToExcel, "export", fake_export) + + result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-stix"]) + + assert result.exit_code != 0 + assert "Refusing to overwrite existing Excel file" in result.output + assert "--overwrite" in result.output + + 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( @@ -321,9 +373,71 @@ def fake_export_release(**kwargs): "stix_base_dir": None, "domains": None, "versioned_output_dir": False, + "overwrite": False, } +def test_attack_to_excel_cli_from_release_passes_overwrite( + monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner +): + """from-release should pass overwrite requests 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", + "--output", + str(tmp_path), + "--overwrite", + ], + ) + + assert result.exit_code == 0 + assert calls["export_release"]["overwrite"] is True + + +def test_attack_to_excel_cli_from_release_configures_verbose_logging( + monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner +): + """from-release should enable debug logging when verbose is requested.""" + calls = {} + + def fake_configure_cli_logging(**kwargs): + calls["logging"] = kwargs + + def fake_export_release(**kwargs): + calls["export_release"] = kwargs + + monkeypatch.setattr(attackToExcel, "_configure_cli_logging", fake_configure_cli_logging) + monkeypatch.setattr(attackToExcel, "export_release", fake_export_release) + + result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-release", "--output", str(tmp_path), "-v"]) + + assert result.exit_code == 0 + assert calls["logging"] == {"verbose": True} + + +def test_attack_to_excel_cli_from_release_reports_existing_excel_file(monkeypatch, attack_to_excel_runner: CliRunner): + """from-release should report existing Excel files without a traceback.""" + + def fake_export_release(**kwargs): + raise FileExistsError("Refusing to overwrite existing Excel file(s). Pass --overwrite to replace them.") + + monkeypatch.setattr(attackToExcel, "export_release", fake_export_release) + + result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-release"]) + + assert result.exit_code != 0 + assert "Refusing to overwrite existing Excel file" in result.output + assert "--overwrite" in result.output + + def test_attack_to_excel_cli_from_release_selected_domains( monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner ): @@ -400,6 +514,8 @@ def test_attack_to_excel_cli_help_lists_subcommands(attack_to_excel_runner: CliR assert "--domain" in from_stix_output assert "--remote" in from_stix_output assert "--stix-file" in from_stix_output + assert "--overwrite" in from_stix_output + assert "--verbose" 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) @@ -407,6 +523,8 @@ def test_attack_to_excel_cli_help_lists_subcommands(attack_to_excel_runner: CliR assert "--domains" in from_release_output assert "--stix-version" in from_release_output assert "--stix-base-dir" in from_release_output + assert "--overwrite" in from_release_output + assert "--verbose" in from_release_output def test_attack_to_excel_cli_no_args_shows_help(attack_to_excel_runner: CliRunner): @@ -418,6 +536,38 @@ def test_attack_to_excel_cli_no_args_shows_help(attack_to_excel_runner: CliRunne assert "from-release" in result.output +def test_attack_to_excel_configures_info_logging_by_default(monkeypatch): + """The installed attack-to-excel entrypoint should hide debug logs by default.""" + calls = {} + + def fake_add(*args, **kwargs): + calls["add"] = {"args": args, "kwargs": kwargs} + + monkeypatch.setattr(attackToExcel.logger, "remove", lambda: calls.setdefault("remove", True)) + monkeypatch.setattr(attackToExcel.logger, "add", fake_add) + + attackToExcel._configure_cli_logging(verbose=False) + + assert calls["remove"] is True + assert calls["add"]["kwargs"]["level"] == "INFO" + + +def test_attack_to_excel_configures_debug_logging_when_verbose(monkeypatch): + """Verbose CLI runs should include debug logs.""" + calls = {} + + def fake_add(*args, **kwargs): + calls["add"] = {"args": args, "kwargs": kwargs} + + monkeypatch.setattr(attackToExcel.logger, "remove", lambda: calls.setdefault("remove", True)) + monkeypatch.setattr(attackToExcel.logger, "add", fake_add) + + attackToExcel._configure_cli_logging(verbose=True) + + assert calls["remove"] is True + assert calls["add"]["kwargs"]["level"] == "DEBUG" + + @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.""" diff --git a/tests/test_to_excel.py b/tests/test_to_excel.py index 40edb1d..b470457 100644 --- a/tests/test_to_excel.py +++ b/tests/test_to_excel.py @@ -34,6 +34,64 @@ def _sheet_rows(path: Path, sheet_name: str): workbook.close() +def _write_stix_files(stix_base_dir: Path, domains): + stix_base_dir.mkdir(parents=True, exist_ok=True) + for domain in domains: + (stix_base_dir / f"{domain}.json").write_text("{}", encoding="utf-8") + + +def _release_output_dir(output_dir: Path, version: str | None = None) -> Path: + return output_dir / version if version else output_dir + + +def _staging_output_dir(output_dir: Path, version: str | None = None) -> Path: + return _release_output_dir(output_dir, version) / "tmp" / "staged-excel-files" + + +def _domain_export_dir( + output_dir: Path, domain: str, version: str | None = None, *, versioned_output_dir: bool = False +): + release_output_dir = _release_output_dir(output_dir, version) + domain_dir_name = f"{domain}-{version}" if version and versioned_output_dir else domain + return release_output_dir / domain_dir_name + + +def _write_existing_release_excel( + output_dir: Path, + domain: str, + version: str | None = None, + *, + versioned_output_dir: bool = False, +): + domain_dir = _domain_export_dir( + output_dir, + domain, + version, + versioned_output_dir=versioned_output_dir, + ) + domain_dir.mkdir(parents=True, exist_ok=True) + workbook_name = f"{domain}-{version}.xlsx" if version else f"{domain}.xlsx" + existing_file = domain_dir / workbook_name + existing_file.write_text("existing", encoding="utf-8") + return existing_file + + +def _make_fake_release_export(*, calls=None, log_marker=None): + def fake_export(**kwargs): + if calls is not None: + calls.append(kwargs) + if log_marker is not None: + log_marker() + + output_dir = Path(kwargs["output_dir"]) + domain_version_string = f"{kwargs['domain']}-{kwargs['version']}" if kwargs["version"] else kwargs["domain"] + versioned_dir = output_dir / domain_version_string + versioned_dir.mkdir(parents=True) + (versioned_dir / f"{domain_version_string}.xlsx").write_text("new", encoding="utf-8") + + return fake_export + + @dataclass class FakeMergeRange: """Small stand-in for matrix merge range objects.""" @@ -84,6 +142,8 @@ def fake_write_excel(**kwargs): "src": mem_store, "version": None, "output_dir": str(tmp_path), + "overwrite": False, + "log_written_files": True, } @@ -119,6 +179,61 @@ def fake_write_excel(**kwargs): assert "build_dataframes" not in calls assert calls["write_excel"]["version"] == "v17.0" assert calls["write_excel"]["dataframes"] is dataframes + assert calls["write_excel"]["overwrite"] is False + assert calls["write_excel"]["log_written_files"] is True + + +def test_export_refuses_existing_excel_file_before_building_dataframes( + monkeypatch, + tmp_path: Path, + attack_memstore_factory, + sample_technique_object, +): + """Export should refuse existing Excel files before building or writing dataframes.""" + output_directory = tmp_path / "enterprise-attack" + output_directory.mkdir() + existing_file = output_directory / "enterprise-attack-techniques.xlsx" + existing_file.write_text("existing", encoding="utf-8") + mem_store = attack_memstore_factory([sample_technique_object]) + calls = [] + + def fake_build_dataframes(**kwargs): + calls.append(kwargs) + return {"techniques": _object_data("techniques")} + + monkeypatch.setattr(attackToExcel, "build_dataframes", fake_build_dataframes) + + with pytest.raises(FileExistsError, match="Refusing to overwrite existing Excel file"): + attackToExcel.export(domain="enterprise-attack", output_dir=str(tmp_path), mem_store=mem_store) + + assert calls == [] + + +def test_export_overwrite_allows_existing_excel_file( + monkeypatch, + tmp_path: Path, + attack_memstore_factory, + sample_technique_object, +): + """Export should build and write when overwrite is explicitly requested.""" + output_directory = tmp_path / "enterprise-attack" + output_directory.mkdir() + (output_directory / "enterprise-attack-techniques.xlsx").write_text("existing", encoding="utf-8") + mem_store = attack_memstore_factory([sample_technique_object]) + calls = {} + + def fake_build_dataframes(**kwargs): + return {"techniques": _object_data("techniques")} + + def fake_write_excel(**kwargs): + calls["write_excel"] = kwargs + + monkeypatch.setattr(attackToExcel, "build_dataframes", fake_build_dataframes) + monkeypatch.setattr(attackToExcel, "write_excel", fake_write_excel) + + attackToExcel.export(domain="enterprise-attack", output_dir=str(tmp_path), mem_store=mem_store, overwrite=True) + + assert calls["write_excel"]["overwrite"] is True def test_export_rejects_multiple_stix_sources(attack_memstore_factory, sample_technique_object): @@ -138,9 +253,7 @@ def test_normalize_attack_version_adds_missing_prefix(): 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") + _write_stix_files(stix_base_dir, ["enterprise-attack", "mobile-attack"]) calls = {} @@ -164,15 +277,14 @@ def fake_export(**kwargs): 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") + assert calls["exports"][0]["output_dir"] == str(_staging_output_dir(tmp_path / "output", "v19.0")) + assert calls["exports"][0]["overwrite"] is False 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") + _write_stix_files(stix_base_dir, ["enterprise-attack", "mobile-attack"]) calls = {} @@ -189,9 +301,10 @@ def fake_export(**kwargs): 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"][0]["output_dir"] == str(_staging_output_dir(tmp_path / "output" / "attackwb")) + assert calls["exports"][0]["overwrite"] is False assert calls["exports"][1]["version"] is None - assert calls["exports"][1]["output_dir"] == str(tmp_path / "output" / "attackwb") + assert calls["exports"][1]["output_dir"] == str(_staging_output_dir(tmp_path / "output" / "attackwb")) def test_export_release_downloads_only_missing_domains_to_temporary_directory(tmp_path: Path, monkeypatch): @@ -234,28 +347,213 @@ def fake_export(**kwargs): 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.""" + stix_base_dir = tmp_path / "stix" + _write_stix_files(stix_base_dir, ["enterprise-attack"]) + + monkeypatch.setattr(attackToExcel, "export", _make_fake_release_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() + + +@pytest.mark.parametrize("versioned_output_dir", [False, True]) +def test_export_release_refuses_existing_excel_file_before_exporting( + tmp_path: Path, + monkeypatch, + versioned_output_dir: bool, +): + """Release export should check all selected domain output folders before exporting anything.""" + output_dir = tmp_path / "output" + _write_existing_release_excel( + output_dir, + "mobile-attack", + "v19.0", + versioned_output_dir=versioned_output_dir, + ) + + stix_base_dir = tmp_path / "stix" + _write_stix_files(stix_base_dir, ["enterprise-attack", "mobile-attack"]) + + calls = [] 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") + calls.append(kwargs) + + monkeypatch.setattr(attackToExcel, "export", fake_export) + + with pytest.raises(FileExistsError, match="Refusing to overwrite existing Excel file"): + attackToExcel.export_release( + version="v19.0", + stix_base_dir=str(stix_base_dir), + output_dir=str(output_dir), + domains=["enterprise-attack", "mobile-attack"], + versioned_output_dir=versioned_output_dir, + ) + + assert calls == [] + + +@pytest.mark.parametrize("versioned_output_dir", [False, True]) +def test_export_release_overwrite_allows_existing_excel_file( + tmp_path: Path, + monkeypatch, + versioned_output_dir: bool, +): + """Release export should proceed when overwrite is explicitly requested.""" + output_dir = tmp_path / "output" + _write_existing_release_excel( + output_dir, + "mobile-attack", + "v19.0", + versioned_output_dir=versioned_output_dir, + ) stix_base_dir = tmp_path / "stix" - stix_base_dir.mkdir() - (stix_base_dir / "enterprise-attack.json").write_text("{}", encoding="utf-8") + _write_stix_files(stix_base_dir, ["mobile-attack"]) + + calls = [] + + def fake_export(**kwargs): + calls.append(kwargs) 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"), + output_dir=str(output_dir), + domains=["mobile-attack"], + versioned_output_dir=versioned_output_dir, + overwrite=True, + ) + + assert calls[0]["overwrite"] is True + assert calls[0]["log_written_files"] is versioned_output_dir + + +def test_export_release_logs_each_flattened_file_overwrite(tmp_path: Path, monkeypatch): + """Release export should log target overwrites before staging files.""" + output_dir = tmp_path / "output" + existing_file = _write_existing_release_excel(output_dir, "enterprise-attack", "v19.0") + + stix_base_dir = tmp_path / "stix" + _write_stix_files(stix_base_dir, ["enterprise-attack"]) + log_messages = [] + + monkeypatch.setattr( + attackToExcel, + "export", + _make_fake_release_export(log_marker=lambda: log_messages.append("fake export started")), + ) + monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append) + + attackToExcel.export_release( + version="v19.0", + stix_base_dir=str(stix_base_dir), + output_dir=str(output_dir), domains=["enterprise-attack"], + overwrite=True, ) - 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() + overwrite_message = f"Overwriting existing Excel file: {existing_file}" + assert "Existing Excel files will be overwritten:" in log_messages + assert overwrite_message in log_messages + assert log_messages.index(overwrite_message) < log_messages.index("fake export started") + + +def test_export_release_logs_staging_and_final_move_directories(tmp_path: Path, monkeypatch): + """Default release export should identify staged writes and final output moves.""" + output_dir = tmp_path / "output" + stix_base_dir = tmp_path / "stix" + _write_stix_files(stix_base_dir, ["enterprise-attack"]) + log_messages = [] + + monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export()) + monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append) + + attackToExcel.export_release( + version="v19.0", + stix_base_dir=str(stix_base_dir), + output_dir=str(output_dir), + domains=["enterprise-attack"], + overwrite=True, + ) + + staging_dir = _staging_output_dir(output_dir, "v19.0") / "enterprise-attack-v19.0" + assert f"Writing staged Excel files for enterprise-attack to {staging_dir}" in log_messages + assert f"Moving staged Excel files for enterprise-attack to {output_dir / 'v19.0' / 'enterprise-attack'}" in ( + log_messages + ) + + +def test_export_release_logs_written_files_after_final_move(tmp_path: Path, monkeypatch): + """Release export should report final output paths after staged files are moved.""" + output_dir = tmp_path / "output" + stix_base_dir = tmp_path / "stix" + _write_stix_files(stix_base_dir, ["enterprise-attack"]) + log_messages = [] + + monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export()) + monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append) + + attackToExcel.export_release( + version="v19.0", + stix_base_dir=str(stix_base_dir), + output_dir=str(output_dir), + domains=["enterprise-attack"], + ) + + staged_file = _staging_output_dir(output_dir, "v19.0") / "enterprise-attack-v19.0" / "enterprise-attack-v19.0.xlsx" + final_file = output_dir / "v19.0" / "enterprise-attack" / "enterprise-attack-v19.0.xlsx" + assert f"Excel file written: {final_file}" in log_messages + assert f"Excel file written: {staged_file}" not in log_messages + + +def test_export_release_uses_tmp_staging_directory(tmp_path: Path, monkeypatch): + """Default release export should stage workbooks under tmp/staged-excel-files.""" + output_dir = tmp_path / "output" + stix_base_dir = tmp_path / "stix" + _write_stix_files(stix_base_dir, ["enterprise-attack"]) + calls = [] + + monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export(calls=calls)) + + attackToExcel.export_release( + version="v19.0", + stix_base_dir=str(stix_base_dir), + output_dir=str(output_dir), + domains=["enterprise-attack"], + ) + + staging_output_dir = _staging_output_dir(output_dir, "v19.0") + assert calls[0]["output_dir"] == str(staging_output_dir) + assert not (staging_output_dir / "enterprise-attack-v19.0").exists() + assert (output_dir / "v19.0" / "enterprise-attack" / "enterprise-attack-v19.0.xlsx").exists() + + +def test_export_release_moves_unversioned_staged_outputs_to_domain_directory(tmp_path: Path, monkeypatch): + """Unversioned release exports should move staged domain folders to final domain folders.""" + output_dir = tmp_path / "output" / "attackwb" + stix_base_dir = tmp_path / "stix" + _write_stix_files(stix_base_dir, ["enterprise-attack"]) + + monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export()) + + attackToExcel.export_release( + stix_base_dir=str(stix_base_dir), + output_dir=str(output_dir), + domains=["enterprise-attack"], + ) + + assert not (_staging_output_dir(output_dir) / "enterprise-attack").exists() + assert (output_dir / "enterprise-attack" / "enterprise-attack.xlsx").exists() def test_export_release_rejects_invalid_domain(): @@ -289,6 +587,78 @@ def test_write_excel_creates_expected_workbooks(monkeypatch, tmp_path: Path, att assert _sheet_names(output_folder / "enterprise-attack.xlsx") == ["techniques", "groups", "citations"] +def test_write_excel_logs_each_file_overwrite(monkeypatch, tmp_path: Path, attack_memstore_factory): + """write_excel should log each generated workbook replaced by overwrite.""" + monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame()) + mem_store = attack_memstore_factory([]) + output_folder = tmp_path / "enterprise-attack" + output_folder.mkdir() + existing_files = [ + output_folder / "enterprise-attack.xlsx", + output_folder / "enterprise-attack-techniques.xlsx", + output_folder / "enterprise-attack-groups.xlsx", + ] + for existing_file in existing_files: + existing_file.write_text("existing", encoding="utf-8") + + log_messages = [] + monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append) + + attackToExcel.write_excel( + dataframes={ + "techniques": _object_data("techniques"), + "groups": _object_data("groups"), + }, + domain="enterprise-attack", + src=mem_store, + output_dir=str(tmp_path), + overwrite=True, + ) + + for existing_file in existing_files: + assert f"Overwriting existing Excel file: {existing_file}" in log_messages + + +def test_write_excel_logs_files_written(monkeypatch, tmp_path: Path, attack_memstore_factory): + """write_excel should use wording that applies to created and overwritten workbooks.""" + monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame()) + mem_store = attack_memstore_factory([]) + log_messages = [] + + monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append) + + attackToExcel.write_excel( + dataframes={"techniques": _object_data("techniques")}, + domain="enterprise-attack", + src=mem_store, + output_dir=str(tmp_path), + ) + + output_folder = tmp_path / "enterprise-attack" + assert f"Excel file written: {output_folder / 'enterprise-attack-techniques.xlsx'}" in log_messages + assert f"Excel file written: {output_folder / 'enterprise-attack.xlsx'}" in log_messages + assert not any("Excel file created:" in message for message in log_messages) + + +def test_write_excel_can_suppress_written_file_logs(monkeypatch, tmp_path: Path, attack_memstore_factory): + """Release staging can suppress staged-path written logs.""" + monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame()) + mem_store = attack_memstore_factory([]) + log_messages = [] + + monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append) + + attackToExcel.write_excel( + dataframes={"techniques": _object_data("techniques")}, + domain="enterprise-attack", + src=mem_store, + output_dir=str(tmp_path), + log_written_files=False, + ) + + assert not any("Excel file written:" in message for message in log_messages) + + 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()) diff --git a/uv.lock b/uv.lock index 6642b7e..7d000a9 100644 --- a/uv.lock +++ b/uv.lock @@ -641,6 +641,7 @@ name = "mitreattack-python" version = "6.0.0" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "colour" }, { name = "deepdiff" }, { name = "drawsvg" }, @@ -687,6 +688,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "check-wheel-contents", marker = "extra == 'dev'", specifier = ">=0.6.1" }, + { name = "click", specifier = ">=8.1.8" }, { name = "colour", specifier = ">=0.1.5" }, { name = "commitizen", marker = "extra == 'dev'", specifier = ">=4.9.1" }, { name = "deepdiff", specifier = ">=6.6.0" }, From 2c9caa4153ba08d21f42aa9984ff6a5181edf0b3 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Thu, 7 May 2026 15:27:32 -0500 Subject: [PATCH 4/4] test: normalize CLI error output assertions --- tests/test_cli.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4cf1099..414bc77 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -328,8 +328,9 @@ def fake_export(**kwargs): result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-stix"]) assert result.exit_code != 0 - assert "Refusing to overwrite existing Excel file" in result.output - assert "--overwrite" in result.output + output = unstyle(result.output) + assert "Refusing to overwrite existing Excel file" in output + assert "--overwrite" in output def test_attack_to_excel_cli_from_stix_rejects_multiple_sources(attack_to_excel_runner: CliRunner): @@ -434,8 +435,9 @@ def fake_export_release(**kwargs): result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-release"]) assert result.exit_code != 0 - assert "Refusing to overwrite existing Excel file" in result.output - assert "--overwrite" in result.output + output = unstyle(result.output) + assert "Refusing to overwrite existing Excel file" in output + assert "--overwrite" in output def test_attack_to_excel_cli_from_release_selected_domains(