Skip to content

Commit bce27b9

Browse files
committed
feat: add pytest command-line options for STIX bundles and update documentation
1 parent 9522305 commit bce27b9

10 files changed

Lines changed: 263 additions & 57 deletions

File tree

conftest.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Pytest command-line options for mitreattack-python."""
2+
3+
4+
def pytest_addoption(parser):
5+
"""Register pytest options for selecting ATT&CK STIX test data."""
6+
parser.addoption(
7+
"--stix-enterprise",
8+
action="store",
9+
default=None,
10+
help="Path to an Enterprise ATT&CK STIX bundle to use in tests.",
11+
)
12+
parser.addoption(
13+
"--stix-mobile",
14+
action="store",
15+
default=None,
16+
help="Path to a Mobile ATT&CK STIX bundle to use in tests.",
17+
)
18+
parser.addoption(
19+
"--stix-ics",
20+
action="store",
21+
default=None,
22+
help="Path to an ICS ATT&CK STIX bundle to use in tests.",
23+
)
24+
parser.addoption(
25+
"--attack-version",
26+
action="store",
27+
default=None,
28+
help="ATT&CK release version to download and use for STIX-backed tests.",
29+
)
30+
parser.addoption(
31+
"--stix-version",
32+
action="store",
33+
choices=("2.0", "2.1"),
34+
default="2.0",
35+
help="STIX version to download when --attack-version is used. Defaults to 2.0.",
36+
)

docs/CONTRIBUTING.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ just test-cov # Run tests with coverage report
5656
just build # Build the package
5757
```
5858

59+
To run STIX-backed tests against specific local bundles, pass the bundle paths to pytest:
60+
61+
```bash
62+
uv run pytest \
63+
--stix-enterprise /path/to/enterprise-attack.json \
64+
--stix-mobile /path/to/mobile-attack.json \
65+
--stix-ics /path/to/ics-attack.json
66+
```
67+
68+
To have pytest download a specific ATT&CK release instead, use:
69+
70+
```bash
71+
uv run pytest --attack-version 16.1 --stix-version 2.1
72+
```
73+
5974
### Pull Requests
6075

6176
When making a pull request, please make sure to:

examples/generate_multiple_attack_diffs.py

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,75 @@
1+
"""Generate ATT&CK changelog outputs for multiple release pairs."""
2+
3+
import argparse
4+
15
from mitreattack.diffStix.changelog_helper import get_new_changelog_md
26

7+
DOMAINS = ["enterprise-attack", "mobile-attack", "ics-attack"]
8+
VERSION_PAIRS = [
9+
("17.1", "18.0"),
10+
("18.0", "18.1"),
11+
]
12+
13+
14+
def get_release_output_folder(old_version: str, new_version: str) -> str:
15+
"""Return the output folder for a release comparison."""
16+
return f"output/v{old_version}-v{new_version}"
17+
18+
19+
def get_artifact_link_prefix(old_version: str, new_version: str, *, attack_website_links: bool = False) -> str:
20+
"""Return the link prefix for generated layers and changelog JSON."""
21+
if not attack_website_links:
22+
return ""
23+
return f"/docs/changelogs/v{old_version}-v{new_version}"
24+
25+
26+
def get_parsed_args():
27+
"""Parse command line arguments for the example script."""
28+
parser = argparse.ArgumentParser(description="Generate ATT&CK changelog outputs for multiple release pairs.")
29+
parser.add_argument(
30+
"-w",
31+
"--attack-website-links",
32+
action="store_true",
33+
help="Use ATT&CK website paths for links to generated layers and changelog JSON.",
34+
)
35+
return parser.parse_args()
36+
37+
38+
def generate_diff(old_version: str, new_version: str, *, attack_website_links: bool = False):
39+
"""Generate changelog outputs for a single ATT&CK release pair."""
40+
output_folder = get_release_output_folder(old_version, new_version)
41+
print(f"Generating ATT&CK Diffs between {old_version}-{new_version}: {output_folder}")
42+
43+
get_new_changelog_md(
44+
domains=DOMAINS,
45+
layers=[
46+
f"{output_folder}/layer-enterprise.json",
47+
f"{output_folder}/layer-mobile.json",
48+
f"{output_folder}/layer-ics.json",
49+
],
50+
old=f"attack-releases/stix-2.0/v{old_version}",
51+
new=f"attack-releases/stix-2.0/v{new_version}",
52+
show_key=True,
53+
# site_prefix: str = "",
54+
verbose=True,
55+
include_contributors=True,
56+
markdown_file=f"{output_folder}/changelog.md",
57+
html_file=f"{output_folder}/index.html",
58+
html_file_detailed=f"{output_folder}/changelog-detailed.html",
59+
additional_formats_prefix=get_artifact_link_prefix(
60+
old_version,
61+
new_version,
62+
attack_website_links=attack_website_links,
63+
),
64+
json_file=f"{output_folder}/changelog.json",
65+
)
66+
367

468
def main():
5-
version_pairs = [
6-
("17.1", "18.0"),
7-
("18.0", "18.1"),
8-
]
9-
for version_pair in version_pairs:
10-
old_version = version_pair[0]
11-
new_version = version_pair[1]
12-
13-
output_folder = f"output/v{old_version}-v{new_version}"
14-
print(f"Generating ATT&CK Diffs between {old_version}-{new_version}: {output_folder}")
15-
16-
get_new_changelog_md(
17-
domains=["enterprise-attack", "mobile-attack", "ics-attack"],
18-
layers=[
19-
f"{output_folder}/layer-enterprise.json",
20-
f"{output_folder}/layer-mobile.json",
21-
f"{output_folder}/layer-ics.json",
22-
],
23-
old=f"attack-releases/stix-2.0/v{old_version}",
24-
new=f"attack-releases/stix-2.0/v{new_version}",
25-
show_key=True,
26-
# site_prefix: str = "",
27-
verbose=True,
28-
include_contributors=True,
29-
markdown_file=f"{output_folder}/changelog.md",
30-
html_file=f"{output_folder}/index.html",
31-
html_file_detailed=f"{output_folder}/changelog-detailed.html",
32-
json_file=f"{output_folder}/changelog.json",
33-
)
69+
"""Generate changelog outputs for all configured ATT&CK release pairs."""
70+
args = get_parsed_args()
71+
for old_version, new_version in VERSION_PAIRS:
72+
generate_diff(old_version, new_version, attack_website_links=args.attack_website_links)
3473

3574

3675
if __name__ == "__main__":

mitreattack/diffStix/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Print full usage instructions:
1414
# You must run `pip install mitreattack-python` in order to access the diff_stix command
1515
diff_stix --help
1616
usage: diff_stix [-h] [--old OLD] [--new NEW] [--domains {enterprise-attack,mobile-attack,ics-attack} [{enterprise-attack,mobile-attack,ics-attack} ...]] [--markdown-file MARKDOWN_FILE] [--html-file HTML_FILE] [--html-file-detailed HTML_FILE_DETAILED]
17-
[--json-file JSON_FILE] [--layers [LAYERS ...]] [--site_prefix SITE_PREFIX] [--unchanged] [--use-mitre-cti] [--show-key] [--contributors] [--no-contributors] [-v]
17+
[--json-file JSON_FILE] [--layers [LAYERS ...]] [--site_prefix SITE_PREFIX] [--additional-formats-prefix ADDITIONAL_FORMATS_PREFIX] [--unchanged] [--use-mitre-cti] [--show-key] [--contributors] [--no-contributors] [-v]
1818

1919
Create changelog reports on the differences between two versions of the ATT&CK content. Takes STIX bundles as input. For default operation, put enterprise-attack.json, mobile-attack.json, and ics-attack.json bundles in 'old' and 'new' folders for the script to compare.
2020

@@ -37,6 +37,8 @@ options:
3737
output/January_2023_Updates_Mobile.json, output/January_2023_Updates_ICS.json, output/January_2023_Updates_Pre.json
3838
--site_prefix SITE_PREFIX
3939
Prefix links in markdown output, e.g. [prefix]/techniques/T1484
40+
--additional-formats-prefix ADDITIONAL_FORMATS_PREFIX
41+
Prefix detailed HTML links to generated layers and changelog JSON.
4042
--unchanged Show objects without changes in the markdown output
4143
--use-mitre-cti Use content from the MITRE CTI repo for the -old data
4244
--show-key Add a key explaining the change types to the markdown

mitreattack/diffStix/changelog_helper.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,7 +1871,14 @@ def layers_dict_to_files(outfiles, layers):
18711871
json.dump(layers["ics-attack"], open(ics_attack_layer_file, "w"), indent=4)
18721872

18731873

1874-
def write_detailed_html(html_file_detailed: str, diffStix: DiffStix):
1874+
def _get_additional_format_href(filename: str, additional_formats_prefix: str = "") -> str:
1875+
"""Return a link to an additional changelog output file."""
1876+
if not additional_formats_prefix:
1877+
return filename
1878+
return f"{additional_formats_prefix.rstrip('/')}/{filename}"
1879+
1880+
1881+
def write_detailed_html(html_file_detailed: str, diffStix: DiffStix, additional_formats_prefix: str = ""):
18751882
"""Write a detailed HTML report of changes between ATT&CK versions.
18761883
18771884
Parameters
@@ -1880,6 +1887,8 @@ def write_detailed_html(html_file_detailed: str, diffStix: DiffStix):
18801887
File to write HTML for the detailed changelog.
18811888
diffStix : DiffStix
18821889
An instance of a DiffStix object.
1890+
additional_formats_prefix : str, optional
1891+
Prefix for links to generated layer and JSON files, by default "".
18831892
"""
18841893
old_version = diffStix.data["old"]["enterprise-attack"]["attack_release_version"]
18851894
new_version = diffStix.data["new"]["enterprise-attack"]["attack_release_version"]
@@ -1889,6 +1898,11 @@ def write_detailed_html(html_file_detailed: str, diffStix: DiffStix):
18891898
else:
18901899
header = f"<h1>ATT&CK Changes Between v{old_version} and new content</h1>"
18911900

1901+
enterprise_layer_href = _get_additional_format_href("layer-enterprise.json", additional_formats_prefix)
1902+
mobile_layer_href = _get_additional_format_href("layer-mobile.json", additional_formats_prefix)
1903+
ics_layer_href = _get_additional_format_href("layer-ics.json", additional_formats_prefix)
1904+
changelog_json_href = _get_additional_format_href("changelog.json", additional_formats_prefix)
1905+
18921906
frontmatter = [
18931907
textwrap.dedent(
18941908
"""\
@@ -1913,7 +1927,7 @@ def write_detailed_html(html_file_detailed: str, diffStix: DiffStix):
19131927
header,
19141928
markdown.markdown(diffStix.get_md_key()),
19151929
textwrap.dedent(
1916-
"""\
1930+
f"""\
19171931
<table class=diff summary=Legends>
19181932
<tr>
19191933
<td>
@@ -1929,11 +1943,11 @@ def write_detailed_html(html_file_detailed: str, diffStix: DiffStix):
19291943
<h2>Additional formats</h2>
19301944
<p>These ATT&CK Navigator layer files can be uploaded to ATT&CK Navigator manually.</p>
19311945
<ul>
1932-
<li><a href="layer-enterprise.json">Enterprise changes</a></li>
1933-
<li><a href="layer-mobile.json">Mobile changes</a></li>
1934-
<li><a href="layer-ics.json">ICS changes</a></li>
1946+
<li><a href="{enterprise_layer_href}">Enterprise changes</a></li>
1947+
<li><a href="{mobile_layer_href}">Mobile changes</a></li>
1948+
<li><a href="{ics_layer_href}">ICS changes</a></li>
19351949
</ul>
1936-
<p>This JSON file contains the machine readble output used to create this page: <a href="changelog.json">changelog.json</a></p>
1950+
<p>This JSON file contains the machine readble output used to create this page: <a href="{changelog_json_href}">changelog.json</a></p>
19371951
"""
19381952
),
19391953
]
@@ -2256,6 +2270,13 @@ def get_parsed_args():
22562270
help="Prefix links in markdown output, e.g. [prefix]/techniques/T1484",
22572271
)
22582272

2273+
parser.add_argument(
2274+
"--additional-formats-prefix",
2275+
type=str,
2276+
default="",
2277+
help="Prefix detailed HTML links to generated layers and changelog JSON.",
2278+
)
2279+
22592280
parser.add_argument(
22602281
"--unchanged",
22612282
action="store_true",
@@ -2331,6 +2352,7 @@ def get_new_changelog_md(
23312352
markdown_file: Optional[str] = None,
23322353
html_file: Optional[str] = None,
23332354
html_file_detailed: Optional[str] = None,
2355+
additional_formats_prefix: str = "",
23342356
json_file: Optional[str] = None,
23352357
) -> str:
23362358
"""Get a Markdown string representation of differences between two ATT&CK versions.
@@ -2365,6 +2387,8 @@ def get_new_changelog_md(
23652387
If set, writes an HTML file from the parsed markdown, by default None
23662388
html_file_detailed : str, optional
23672389
If set, writes a more detailed HTML page, by default None
2390+
additional_formats_prefix : str, optional
2391+
Prefix for detailed HTML links to generated layer and JSON files, by default "".
23682392
json_file : str, optional
23692393
If set, writes JSON file of the changes, by default None
23702394
@@ -2414,7 +2438,11 @@ def get_new_changelog_md(
24142438
if html_file_detailed:
24152439
Path(html_file_detailed).parent.mkdir(parents=True, exist_ok=True)
24162440
logger.info("Writing detailed updates to file")
2417-
write_detailed_html(html_file_detailed=html_file_detailed, diffStix=diffStix)
2441+
write_detailed_html(
2442+
html_file_detailed=html_file_detailed,
2443+
diffStix=diffStix,
2444+
additional_formats_prefix=additional_formats_prefix,
2445+
)
24182446

24192447
if layers:
24202448
if len(layers) == 0:
@@ -2457,6 +2485,7 @@ def main():
24572485
markdown_file=args.markdown_file,
24582486
html_file=args.html_file,
24592487
html_file_detailed=args.html_file_detailed,
2488+
additional_formats_prefix=args.additional_formats_prefix,
24602489
json_file=args.json_file,
24612490
)
24622491

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ package = true
7979
module-name = "mitreattack"
8080
module-root = ""
8181

82+
[tool.pytest.ini_options]
83+
testpaths = ["tests"]
84+
8285
[tool.ruff]
8386
line-length = 120
8487
extend-exclude = ["tests/resources/", "examples/", "__init__.py"]

tests/changelog/cli/test_argument_handling.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def _assert_default_args(self, args):
3838
assert args.verbose is False
3939
assert args.use_mitre_cti is False
4040
assert args.site_prefix == ""
41+
assert args.additional_formats_prefix == ""
4142

4243
def test_get_parsed_args_default_values(self, monkeypatch):
4344
"""Test default argument values."""
@@ -68,6 +69,8 @@ def test_get_parsed_args_all_options(self, monkeypatch):
6869
"layer3.json",
6970
"--site_prefix",
7071
"https://example.com",
72+
"--additional-formats-prefix",
73+
"/docs/changelogs/v16.1-v17.0",
7174
"--unchanged",
7275
"--show-key",
7376
"--no-contributors",
@@ -85,6 +88,7 @@ def test_get_parsed_args_all_options(self, monkeypatch):
8588
assert args.json_file == "test.json"
8689
assert args.layers == ["layer1.json", "layer2.json", "layer3.json"]
8790
assert args.site_prefix == "https://example.com"
91+
assert args.additional_formats_prefix == "/docs/changelogs/v16.1-v17.0"
8892
assert args.unchanged is True
8993
assert args.show_key is True
9094
assert args.contributors is False
@@ -182,6 +186,8 @@ def test_get_parsed_args_boolean_flags(self, flag, expected_attr, expected_value
182186
("--site_prefix", "https://custom.com", "site_prefix"),
183187
("--site_prefix", "", "site_prefix"), # Empty site prefix
184188
("--site_prefix", "https://example.com/", "site_prefix"), # With trailing slash
189+
("--additional-formats-prefix", "/docs/changelogs/v16.1-v17.0", "additional_formats_prefix"),
190+
("--additional-formats-prefix", "", "additional_formats_prefix"),
185191
],
186192
)
187193
def test_get_parsed_args_string_options(self, option, value, expected_attr, monkeypatch):

tests/changelog/formatting/test_html_output.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,40 @@ def test_write_detailed_html_basic(
7878
assert f"ATT&CK Changes Between v{old_version} and v{new_version}" in html_content
7979
assert "<h2>Techniques</h2>" in html_content or "<h1>" in html_content # Should have some header structure
8080

81+
def test_write_detailed_html_additional_format_links_default_to_relative(self, tmp_path, lightweight_diffstix):
82+
"""Test detailed HTML links to generated artifacts with relative paths by default."""
83+
html_file = tmp_path / "detailed.html"
84+
lightweight_diffstix.data["old"]["enterprise-attack"]["attack_release_version"] = "16.1"
85+
lightweight_diffstix.data["new"]["enterprise-attack"]["attack_release_version"] = "17.0"
86+
87+
write_detailed_html(str(html_file), lightweight_diffstix)
88+
89+
html_content = html_file.read_text(encoding="utf-8")
90+
assert '<a href="layer-enterprise.json">Enterprise changes</a>' in html_content
91+
assert '<a href="layer-mobile.json">Mobile changes</a>' in html_content
92+
assert '<a href="layer-ics.json">ICS changes</a>' in html_content
93+
assert '<a href="changelog.json">changelog.json</a>' in html_content
94+
95+
def test_write_detailed_html_additional_format_links_use_additional_formats_prefix(
96+
self, tmp_path, lightweight_diffstix
97+
):
98+
"""Test detailed HTML links to generated artifacts can be prefixed for website releases."""
99+
html_file = tmp_path / "detailed.html"
100+
lightweight_diffstix.data["old"]["enterprise-attack"]["attack_release_version"] = "16.1"
101+
lightweight_diffstix.data["new"]["enterprise-attack"]["attack_release_version"] = "17.0"
102+
103+
write_detailed_html(
104+
str(html_file),
105+
lightweight_diffstix,
106+
additional_formats_prefix="/docs/changelogs/v16.1-v17.0/",
107+
)
108+
109+
html_content = html_file.read_text(encoding="utf-8")
110+
assert '<a href="/docs/changelogs/v16.1-v17.0/layer-enterprise.json">Enterprise changes</a>' in html_content
111+
assert '<a href="/docs/changelogs/v16.1-v17.0/layer-mobile.json">Mobile changes</a>' in html_content
112+
assert '<a href="/docs/changelogs/v16.1-v17.0/layer-ics.json">ICS changes</a>' in html_content
113+
assert '<a href="/docs/changelogs/v16.1-v17.0/changelog.json">changelog.json</a>' in html_content
114+
81115
def test_html_document_structure(self):
82116
"""Test basic HTML document structure."""
83117
title = "ATT&CK Changes"

0 commit comments

Comments
 (0)