Skip to content

Commit 089da48

Browse files
committed
feat: enhance attackToExcel CLI with all-domains export functionality and related tests
1 parent 910cd72 commit 089da48

4 files changed

Lines changed: 469 additions & 5 deletions

File tree

mitreattack/attackToExcel/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ Build a excel files corresponding to a specific domain and version of ATT&CK:
2525
python3 attackToExcel -domain mobile-attack -version v5.0
2626
```
2727

28+
Build Excel files for all ATT&CK domains from a release. If local STIX files
29+
are missing under `attack-releases/stix-2.0/v19.0`, they are downloaded
30+
temporarily for the export:
31+
32+
```shell
33+
attackToExcel_cli --all-domains -version v19.0
34+
```
35+
36+
To persist release STIX files before exporting, use `download_attack_stix`:
37+
38+
```shell
39+
download_attack_stix -v 19.0
40+
attackToExcel_cli --all-domains -version v19.0
41+
```
42+
2843
### Module
2944

3045
Example execution targeting a specific domain and version:
@@ -35,6 +50,14 @@ import mitreattack.attackToExcel.attackToExcel as attackToExcel
3550
attackToExcel.export("mobile-attack", "v5.0", "/path/to/export/folder")
3651
```
3752

53+
Example execution targeting all release domains:
54+
55+
```python
56+
import mitreattack.attackToExcel.attackToExcel as attackToExcel
57+
58+
attackToExcel.export_release(version="v19.0", output_dir="output")
59+
```
60+
3861
## Interfaces
3962

4063
### attackToExcel
@@ -48,6 +71,7 @@ overview of the available methods follows.
4871
|build_dataframes| `src`: MemoryStore or other stix2 DataSource object holding domain data<br> `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|
4972
|write_excel| `dataframes`: pandas DataFrame dictionary (generated by build_dataframes) <br> `domain`: domain of ATT&CK that `dataframes` corresponds to <br> `version`: optional parameter indicating which version of ATT&CK is in use <br> `output_dir`: optional parameter specifying output directory| Writes out DataFrame based ATT&CK data to excel files|
5073
|export| `domain`: the domain of ATT&CK to download <br> `version`: optional parameter specifying which version of ATT&CK to download <br> `output_dir`: optional parameter specifying output directory| Downloads ATT&CK data from MITRE/CTI and exports it to Excel spreadsheets |
74+
|export_release| `version`: optional ATT&CK release version <br> `stix_version`: STIX release tree, such as "2.0" or "2.1" <br> `output_dir`: parent output directory <br> `stix_base_dir`: optional directory containing release STIX files <br> `domains`: optional list of domains <br> `versioned_output_dir`: preserve domain-version output folders| Exports a full ATT&CK release to Excel spreadsheets, downloading missing STIX files temporarily when needed |
5175

5276
### stixToDf
5377

mitreattack/attackToExcel/attackToExcel.py

Lines changed: 233 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,195 @@
33
import argparse
44
import os
55
import re
6+
import tempfile
7+
from dataclasses import dataclass
8+
from pathlib import Path
69
from typing import Dict, List, Optional
710

811
import pandas as pd
912
import requests
1013
from loguru import logger
1114
from stix2 import MemoryStore
1215

16+
from mitreattack import release_info
17+
1318
# import mitreattack.attackToExcel.stixToDf as stixToDf
1419
from mitreattack.attackToExcel import stixToDf
20+
from mitreattack.download_stix import download_domains
1521

1622
INVALID_CHARACTERS = ["\\", "/", "*", "[", "]", ":", "?"]
1723
SUB_CHARACTERS = ["\\", "/"]
24+
ATTACK_RELEASES_DIR = Path("attack-releases")
25+
26+
27+
@dataclass(frozen=True)
28+
class DomainConfig:
29+
"""Domain-specific names for STIX downloads and Excel exports."""
30+
31+
download_name: str
32+
33+
34+
DOMAIN_CONFIGS = {
35+
"enterprise-attack": DomainConfig(download_name="enterprise"),
36+
"mobile-attack": DomainConfig(download_name="mobile"),
37+
"ics-attack": DomainConfig(download_name="ics"),
38+
}
39+
ATTACK_DOMAINS = tuple(DOMAIN_CONFIGS)
40+
VALID_STIX_VERSIONS = ("2.0", "2.1")
41+
42+
43+
def normalize_attack_version(version: str) -> str:
44+
"""Return an ATT&CK release version with the leading ``v`` folder prefix."""
45+
return version if version.startswith("v") else f"v{version}"
46+
47+
48+
def _version_without_prefix(version: str) -> str:
49+
"""Return an ATT&CK release version without the leading ``v`` folder prefix."""
50+
return normalize_attack_version(version).removeprefix("v")
51+
52+
53+
def _default_release_dir(version: str, stix_version: str) -> Path:
54+
"""Return the default local STIX release directory."""
55+
return ATTACK_RELEASES_DIR / f"stix-{stix_version}" / normalize_attack_version(version)
56+
57+
58+
def _validate_release_domains(domains: Optional[List[str]]) -> List[str]:
59+
"""Return validated ATT&CK release export domains."""
60+
if not domains:
61+
return list(ATTACK_DOMAINS)
62+
63+
normalized_domains = []
64+
invalid_domains = []
65+
for domain in domains:
66+
if domain not in DOMAIN_CONFIGS:
67+
if domain not in invalid_domains:
68+
invalid_domains.append(domain)
69+
continue
70+
71+
if domain not in normalized_domains:
72+
normalized_domains.append(domain)
73+
74+
if invalid_domains:
75+
invalid_domains_text = ", ".join(invalid_domains)
76+
expected_domains_text = ", ".join(ATTACK_DOMAINS)
77+
raise ValueError(f"Invalid ATT&CK domain(s): {invalid_domains_text}. Expected one of: {expected_domains_text}")
78+
79+
return normalized_domains
80+
81+
82+
def _release_stix_file(release_dir: Path, domain: str) -> Path:
83+
"""Return the expected STIX bundle path for a domain in a release directory."""
84+
return release_dir / f"{domain}.json"
85+
86+
87+
def _move_versioned_exports_to_domain_dir(output_dir: Path, domain: str, version: str):
88+
"""Move versioned Excel exports into the unversioned domain folder."""
89+
versioned_dir = output_dir / f"{domain}-{version}"
90+
domain_dir = output_dir / domain
91+
92+
if not versioned_dir.is_dir():
93+
return
94+
95+
domain_dir.mkdir(parents=True, exist_ok=True)
96+
for source_path in versioned_dir.iterdir():
97+
if not source_path.is_file():
98+
continue
99+
100+
target_path = domain_dir / source_path.name
101+
if target_path.exists():
102+
target_path.unlink()
103+
source_path.replace(target_path)
104+
105+
versioned_dir.rmdir()
106+
107+
108+
def _download_missing_release_domains(
109+
*,
110+
missing_domains: List[str],
111+
version: str,
112+
stix_version: str,
113+
temporary_directory: str,
114+
) -> Path:
115+
"""Download missing STIX domain bundles into a temporary release tree."""
116+
temp_stix_dir = Path(temporary_directory) / f"stix-{stix_version}"
117+
download_domains(
118+
domains=[DOMAIN_CONFIGS[domain].download_name for domain in missing_domains],
119+
download_dir=str(temp_stix_dir),
120+
all_versions=False,
121+
stix_version=stix_version,
122+
attack_versions=[_version_without_prefix(version)],
123+
)
124+
return temp_stix_dir / normalize_attack_version(version)
125+
126+
127+
def export_release(
128+
version: Optional[str] = None,
129+
stix_version: str = "2.0",
130+
output_dir: str = "output",
131+
stix_base_dir: Optional[str] = None,
132+
domains: Optional[List[str]] = None,
133+
versioned_output_dir: bool = False,
134+
):
135+
"""Export one ATT&CK release to Excel for one or more domains."""
136+
if stix_version not in VALID_STIX_VERSIONS:
137+
expected_stix_versions = ", ".join(VALID_STIX_VERSIONS)
138+
raise ValueError(f"Invalid STIX version: {stix_version}. Expected one of: {expected_stix_versions}")
139+
140+
attack_version = normalize_attack_version(version or release_info.LATEST_VERSION)
141+
release_domains = _validate_release_domains(domains)
142+
local_release_dir = Path(
143+
stix_base_dir or os.environ.get("STIX_BASE_DIR") or _default_release_dir(attack_version, stix_version)
144+
)
145+
local_release_dir = local_release_dir.resolve()
146+
release_output_dir = Path(output_dir) / attack_version
147+
148+
local_stix_files = {domain: _release_stix_file(local_release_dir, domain) for domain in release_domains}
149+
missing_domains = [domain for domain, stix_file in local_stix_files.items() if not stix_file.is_file()]
150+
151+
if not missing_domains:
152+
_export_release_domains(
153+
version=attack_version,
154+
output_dir=release_output_dir,
155+
stix_files=local_stix_files,
156+
versioned_output_dir=versioned_output_dir,
157+
)
158+
return
159+
160+
with tempfile.TemporaryDirectory() as temporary_directory:
161+
temporary_release_dir = _download_missing_release_domains(
162+
missing_domains=missing_domains,
163+
version=attack_version,
164+
stix_version=stix_version,
165+
temporary_directory=temporary_directory,
166+
)
167+
stix_files = {
168+
domain: local_stix_files[domain]
169+
if domain not in missing_domains
170+
else _release_stix_file(temporary_release_dir, domain)
171+
for domain in release_domains
172+
}
173+
_export_release_domains(
174+
version=attack_version,
175+
output_dir=release_output_dir,
176+
stix_files=stix_files,
177+
versioned_output_dir=versioned_output_dir,
178+
)
179+
180+
181+
def _export_release_domains(
182+
*,
183+
version: str,
184+
output_dir: Path,
185+
stix_files: Dict[str, Path],
186+
versioned_output_dir: bool,
187+
):
188+
"""Export resolved release STIX files to Excel."""
189+
for domain, stix_file in stix_files.items():
190+
logger.info(f"Exporting {domain} to Excel from {stix_file}")
191+
export(domain=domain, version=version, output_dir=str(output_dir), stix_file=str(stix_file))
192+
193+
if not versioned_output_dir:
194+
_move_versioned_exports_to_domain_dir(output_dir=output_dir, domain=domain, version=version)
18195

19196

20197
def get_stix_data(
@@ -409,27 +586,46 @@ def export(
409586
write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir)
410587

411588

412-
def main():
589+
def main(argv=None):
413590
"""Entrypoint for attackToExcel_cli."""
414591
parser = argparse.ArgumentParser(
415592
description="Download ATT&CK data from MITRE/CTI and convert it to excel spreadsheets"
416593
)
594+
parser.add_argument(
595+
"--all-domains",
596+
action="store_true",
597+
help="export Excel files for all ATT&CK domains from a local or downloaded release",
598+
)
417599
parser.add_argument(
418600
"-domain",
419601
type=str,
420-
choices=["enterprise-attack", "mobile-attack", "ics-attack"],
602+
choices=ATTACK_DOMAINS,
421603
default="enterprise-attack",
422604
help="which domain of ATT&CK to convert",
423605
)
606+
parser.add_argument(
607+
"--domains",
608+
type=str,
609+
nargs="+",
610+
choices=ATTACK_DOMAINS,
611+
help="which ATT&CK domains to convert in --all-domains mode",
612+
)
424613
parser.add_argument(
425614
"-version",
426615
type=str,
427616
help="which version of ATT&CK to convert. If omitted, builds the latest version",
428617
)
618+
parser.add_argument(
619+
"--stix-version",
620+
type=str,
621+
choices=VALID_STIX_VERSIONS,
622+
default="2.0",
623+
help="STIX release tree to use in --all-domains mode",
624+
)
429625
parser.add_argument(
430626
"-output",
431627
type=str,
432-
default=".",
628+
default=None,
433629
help="output directory. If omitted writes to a subfolder of the current directory depending on "
434630
"the domain and version",
435631
)
@@ -445,10 +641,42 @@ def main():
445641
default=None,
446642
help="Path to a local STIX file containing ATT&CK data for a domain, by default None",
447643
)
448-
args = parser.parse_args()
644+
parser.add_argument(
645+
"--stix-base-dir",
646+
type=str,
647+
default=None,
648+
help="directory containing release STIX files for --all-domains mode",
649+
)
650+
parser.add_argument(
651+
"--versioned-output-dir",
652+
action="store_true",
653+
help="preserve domain-version output folders in --all-domains mode",
654+
)
655+
args = parser.parse_args(args=argv)
656+
657+
if args.domains and not args.all_domains:
658+
parser.error("--domains can only be used with --all-domains")
659+
660+
if args.all_domains:
661+
if args.remote or args.stix_file:
662+
parser.error("--all-domains cannot be combined with -remote or -stix-file")
663+
664+
export_release(
665+
version=args.version,
666+
stix_version=args.stix_version,
667+
output_dir=args.output or "output",
668+
stix_base_dir=args.stix_base_dir,
669+
domains=args.domains,
670+
versioned_output_dir=args.versioned_output_dir,
671+
)
672+
return
449673

450674
export(
451-
domain=args.domain, version=args.version, output_dir=args.output, remote=args.remote, stix_file=args.stix_file
675+
domain=args.domain,
676+
version=args.version,
677+
output_dir=args.output or ".",
678+
remote=args.remote,
679+
stix_file=args.stix_file,
452680
)
453681

454682

0 commit comments

Comments
 (0)