Skip to content

Commit 9a5c541

Browse files
committed
feat: update CLI commands and README for attack-to-excel and attack-changelog
1 parent 7fd2865 commit 9a5c541

7 files changed

Lines changed: 257 additions & 147 deletions

File tree

mitreattack/attackToExcel/README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,40 @@ It also provides a means to access ATT&CK data as [Pandas](https://pandas.pydata
1010
Print full usage instructions:
1111

1212
```shell
13-
python3 attackToExcel.py -h
13+
attack-to-excel --help
1414
```
1515

1616
Example execution:
1717

1818
```shell
19-
python3 attackToExcel.py
19+
attack-to-excel from-stix
2020
```
2121

2222
Build a excel files corresponding to a specific domain and version of ATT&CK:
2323

2424
```shell
25-
python3 attackToExcel -domain mobile-attack -version v5.0
25+
attack-to-excel from-stix --domain mobile-attack --version v5.0
2626
```
2727

2828
Build Excel files for all ATT&CK domains from a release. If local STIX files
2929
are missing under `attack-releases/stix-2.0/v19.0`, they are downloaded
3030
temporarily for the export:
3131

3232
```shell
33-
attackToExcel_cli --all-domains -version v19.0
33+
attack-to-excel from-release --version v19.0
3434
```
3535

3636
To persist release STIX files before exporting, use `download_attack_stix`:
3737

3838
```shell
3939
download_attack_stix -v 19.0
40-
attackToExcel_cli --all-domains -version v19.0
40+
attack-to-excel from-release --version v19.0
41+
```
42+
43+
Build Excel files for selected ATT&CK domains from a release:
44+
45+
```shell
46+
attack-to-excel from-release --version v19.0 --domains mobile-attack --domains ics-attack
4147
```
4248

4349
### Module

mitreattack/attackToExcel/attackToExcel.py

Lines changed: 133 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
"""Functions to convert ATT&CK STIX data to Excel, as well as entrypoint for attackToExcel_cli."""
1+
"""Functions to convert ATT&CK STIX data to Excel, as well as entrypoint for attack-to-excel."""
22

3-
import argparse
43
import os
54
import re
65
import tempfile
@@ -10,8 +9,10 @@
109

1110
import pandas as pd
1211
import requests
12+
import typer
1313
from loguru import logger
1414
from stix2 import MemoryStore
15+
from typing_extensions import Annotated
1516

1617
from mitreattack import release_info
1718

@@ -38,6 +39,11 @@ class DomainConfig:
3839
}
3940
ATTACK_DOMAINS = tuple(DOMAIN_CONFIGS)
4041
VALID_STIX_VERSIONS = ("2.0", "2.1")
42+
app = typer.Typer(
43+
add_completion=False,
44+
no_args_is_help=True,
45+
help="Download ATT&CK data from MITRE/CTI and convert it to excel spreadsheets.",
46+
)
4147

4248

4349
def normalize_attack_version(version: str) -> str:
@@ -586,99 +592,135 @@ def export(
586592
write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir)
587593

588594

589-
def main(argv=None):
590-
"""Entrypoint for attackToExcel_cli."""
591-
parser = argparse.ArgumentParser(
592-
description="Download ATT&CK data from MITRE/CTI and convert it to excel spreadsheets"
593-
)
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-
)
599-
parser.add_argument(
600-
"-domain",
601-
type=str,
602-
choices=ATTACK_DOMAINS,
603-
default="enterprise-attack",
604-
help="which domain of ATT&CK to convert",
605-
)
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-
)
613-
parser.add_argument(
614-
"-version",
615-
type=str,
616-
help="which version of ATT&CK to convert. If omitted, builds the latest version",
617-
)
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-
)
625-
parser.add_argument(
626-
"-output",
627-
type=str,
628-
default=None,
629-
help="output directory. If omitted writes to a subfolder of the current directory depending on "
630-
"the domain and version",
631-
)
632-
parser.add_argument(
633-
"-remote",
634-
type=str,
635-
default=None,
636-
help="remote url of an ATT&CK workbench server.",
637-
)
638-
parser.add_argument(
639-
"-stix-file",
640-
type=str,
641-
default=None,
642-
help="Path to a local STIX file containing ATT&CK data for a domain, by default None",
643-
)
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
595+
def _validate_cli_value(value: str, allowed_values: tuple[str, ...], label: str) -> str:
596+
"""Return a CLI value after validating it against an allowed set."""
597+
if value not in allowed_values:
598+
allowed_values_text = ", ".join(allowed_values)
599+
raise typer.BadParameter(f"Invalid {label}: {value}. Expected one of: {allowed_values_text}")
600+
return value
601+
602+
603+
@app.command("from-stix")
604+
def from_stix_cli(
605+
domain: Annotated[
606+
str,
607+
typer.Option(
608+
"--domain",
609+
help="ATT&CK domain STIX bundle to convert.",
610+
),
611+
] = "enterprise-attack",
612+
version: Annotated[
613+
Optional[str],
614+
typer.Option(
615+
"--version",
616+
help="Which version of ATT&CK to convert. If omitted, builds the latest version.",
617+
),
618+
] = None,
619+
output: Annotated[
620+
str,
621+
typer.Option(
622+
"--output",
623+
help=(
624+
"Output directory. If omitted writes to a subfolder of the current directory depending on the domain "
625+
"and version."
626+
),
627+
),
628+
] = ".",
629+
remote: Annotated[
630+
Optional[str],
631+
typer.Option(
632+
"--remote",
633+
help="Remote URL of an ATT&CK Workbench server.",
634+
),
635+
] = None,
636+
stix_file: Annotated[
637+
Optional[str],
638+
typer.Option(
639+
"--stix-file",
640+
help="Path to a local STIX file containing ATT&CK data for a domain.",
641+
),
642+
] = None,
643+
):
644+
"""Convert one ATT&CK domain STIX bundle to Excel."""
645+
domain = _validate_cli_value(domain, ATTACK_DOMAINS, "ATT&CK domain")
646+
647+
if remote and stix_file:
648+
raise typer.BadParameter("--remote and --stix-file are mutually exclusive")
673649

674650
export(
675-
domain=args.domain,
676-
version=args.version,
677-
output_dir=args.output or ".",
678-
remote=args.remote,
679-
stix_file=args.stix_file,
651+
domain=domain,
652+
version=version,
653+
output_dir=output,
654+
remote=remote,
655+
stix_file=stix_file,
656+
)
657+
658+
659+
@app.command("from-release")
660+
def from_release_cli(
661+
version: Annotated[
662+
Optional[str],
663+
typer.Option(
664+
"--version",
665+
help="Which ATT&CK release version to convert. If omitted, builds the latest version.",
666+
),
667+
] = None,
668+
domains: Annotated[
669+
Optional[List[str]],
670+
typer.Option(
671+
"--domains",
672+
help="ATT&CK release domain to include. Can be specified multiple times.",
673+
),
674+
] = None,
675+
stix_version: Annotated[
676+
str,
677+
typer.Option(
678+
"--stix-version",
679+
help="STIX release tree to use.",
680+
),
681+
] = "2.0",
682+
stix_base_dir: Annotated[
683+
Optional[str],
684+
typer.Option(
685+
"--stix-base-dir",
686+
help="Directory containing release STIX files.",
687+
),
688+
] = None,
689+
output: Annotated[
690+
str,
691+
typer.Option(
692+
"--output",
693+
help="Parent output directory.",
694+
),
695+
] = "output",
696+
versioned_output_dir: Annotated[
697+
bool,
698+
typer.Option(
699+
"--versioned-output-dir",
700+
help="Preserve domain-version output folders.",
701+
),
702+
] = False,
703+
):
704+
"""Convert ATT&CK release domain bundles to Excel."""
705+
stix_version = _validate_cli_value(stix_version, VALID_STIX_VERSIONS, "STIX version")
706+
selected_domains = [
707+
_validate_cli_value(selected_domain, ATTACK_DOMAINS, "ATT&CK domain") for selected_domain in domains or []
708+
]
709+
710+
export_release(
711+
version=version,
712+
stix_version=stix_version,
713+
output_dir=output,
714+
stix_base_dir=stix_base_dir,
715+
domains=selected_domains or None,
716+
versioned_output_dir=versioned_output_dir,
680717
)
681718

682719

720+
def main(argv=None):
721+
"""Entrypoint for attack-to-excel."""
722+
app(args=argv, prog_name="attack-to-excel")
723+
724+
683725
if __name__ == "__main__":
684726
main()

mitreattack/diffStix/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,17 @@ diff_stix -v --show-key --html-file output/changelog.html --html-file-detailed o
5656
Generate release changelog artifacts for one ATT&CK version pair:
5757
5858
```shell
59-
attack_changelog --old-version 17.1 --new-version 18.0
59+
attack-changelog --old-version 17.1 --new-version 18.0
6060
```
6161
62-
The `attack_changelog` command reads local release data from `attack-releases/stix-2.0/v{version}` by default.
62+
The `attack-changelog` command reads local release data from `attack-releases/stix-2.0/v{version}` by default.
6363
If either requested release is missing, it downloads the needed STIX bundles into a temporary directory and
6464
removes them when generation is complete.
6565
It always writes detailed HTML, JSON, and Navigator layer artifacts under `output/v{old_version}-v{new_version}`.
6666
It can also generate `changelog.md` or `index.html` if needed by passing the corresponding flags:
6767
6868
```shell
69-
attack_changelog --old-version 17.1 --new-version 18.0 \
69+
attack-changelog --old-version 17.1 --new-version 18.0 \
7070
--markdown-file \
7171
--html-file
7272
```

mitreattack/diffStix/attack_changelog.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def get_artifact_link_prefix(old_version: str, new_version: str, *, attack_websi
159159

160160

161161
def get_parsed_args():
162-
"""Parse command line arguments for the attack_changelog command."""
162+
"""Parse command line arguments for the attack-changelog command."""
163163
parser = argparse.ArgumentParser(
164164
description="Generate ATT&CK changelog artifacts for a single ATT&CK release pair."
165165
)
@@ -359,7 +359,7 @@ def generate_attack_changelog(
359359

360360

361361
def main():
362-
"""Entrypoint for the attack_changelog console command."""
362+
"""Entrypoint for the attack-changelog console command."""
363363
args = get_parsed_args()
364364
generate_attack_changelog(
365365
old_version=args.old_version,

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ repository = "https://github.com/mitre-attack/mitreattack-python/"
4141
documentation = "https://mitreattack-python.readthedocs.io/"
4242

4343
[project.scripts]
44-
attackToExcel_cli = 'mitreattack.attackToExcel.attackToExcel:main'
44+
attack-to-excel = 'mitreattack.attackToExcel.attackToExcel:main'
4545
layerExporter_cli = 'mitreattack.navlayers.layerExporter_cli:main'
4646
layerGenerator_cli = 'mitreattack.navlayers.layerGenerator_cli:main'
4747
indexToMarkdown_cli = 'mitreattack.collections.index_to_markdown:main'
4848
collectionToIndex_cli = 'mitreattack.collections.collection_to_index:main'
4949
diff_stix = 'mitreattack.diffStix.changelog_helper:main'
50-
attack_changelog = 'mitreattack.diffStix.attack_changelog:main'
50+
attack-changelog = 'mitreattack.diffStix.attack_changelog:main'
5151
download_attack_stix = 'mitreattack.download_stix:app'
5252

5353
[project.optional-dependencies]

tests/changelog/cli/test_attack_changelog.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for the attack_changelog CLI wrapper."""
1+
"""Tests for the attack-changelog CLI wrapper."""
22

33
import argparse
44
import sys
@@ -17,7 +17,7 @@ def test_normalize_release_version_accepts_plain_and_prefixed_versions():
1717

1818
def test_get_parsed_args_requires_release_versions(monkeypatch):
1919
"""The command requires exactly one old and one new ATT&CK release version."""
20-
monkeypatch.setattr(sys, "argv", ["attack_changelog"])
20+
monkeypatch.setattr(sys, "argv", ["attack-changelog"])
2121

2222
with pytest.raises(SystemExit):
2323
attack_changelog.get_parsed_args()
@@ -29,7 +29,7 @@ def test_get_parsed_args_defaults_and_options(monkeypatch):
2929
sys,
3030
"argv",
3131
[
32-
"attack_changelog",
32+
"attack-changelog",
3333
"--old-version",
3434
"17.1",
3535
"--new-version",
@@ -67,7 +67,7 @@ def test_get_parsed_args_allows_markdown_and_html_flags_without_values(monkeypat
6767
sys,
6868
"argv",
6969
[
70-
"attack_changelog",
70+
"attack-changelog",
7171
"--old-version",
7272
"17.1",
7373
"--new-version",
@@ -88,7 +88,7 @@ def test_get_parsed_args_defaults_output_dir_and_omits_optional_outputs(monkeypa
8888
monkeypatch.setattr(
8989
sys,
9090
"argv",
91-
["attack_changelog", "--old-version", "17.1", "--new-version", "18.0"],
91+
["attack-changelog", "--old-version", "17.1", "--new-version", "18.0"],
9292
)
9393

9494
args = attack_changelog.get_parsed_args()

0 commit comments

Comments
 (0)