Skip to content

Commit 61f4ffd

Browse files
committed
Merge branch 'main' into semantic-release
2 parents 3d43662 + 7b5b8c6 commit 61f4ffd

8 files changed

Lines changed: 194 additions & 20 deletions

File tree

.github/dependabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ updates:
88
- package-ecosystem: "pip" # See documentation for possible values
99
directory: "/" # Location of package manifests
1010
schedule:
11-
interval: "weekly"
11+
interval: "monthly"
1212
day: "saturday"
1313
time: "09:00"
1414
timezone: "America/New_York"

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## 5.4.1 (2026-02-11)
4+
5+
### Refactor
6+
- update relationships for detection strategies, analytics and log sources in the excel files
7+
- add typing-extensions dependency
8+
9+
## 5.4.0 (2026-01-27)
10+
11+
### Feat
12+
13+
- add relationships for detection strategies, analytics and log sources in the excel files
14+
15+
### Fix
16+
17+
- **PR184**: reduce typeChecker noise when passing a float to score()
18+
319
## 5.3.0 (2025-11-13)
420

521
### Feat

docs/RELEASE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and [GitHub Actions](https://github.com/mitre-attack/mitreattack-python/actions)
1616
- It will also update the `CHANGELOG.md` with all commit messages that are compatible with [Conventional Commits](https://www.conventionalcommits.org).
1717
- NOTE: You should double-check the generated `CHANGELOG.md` file and make sure it looks good.
1818
- Update other metadata as needed in `pyproject.toml` (dependencies, etc.).
19+
- `poetry update --with dev --with docs`
1920

2021
## 3. Local Validation (Recommended)
2122

@@ -36,6 +37,13 @@ poetry install --with=dev
3637
poetry run ruff check
3738
poetry run ruff format --check
3839

40+
# Build docs
41+
# This is managed directly by the Readthedocs site which is configured to watch our repository, but we should test it locally too
42+
# https://app.readthedocs.org/projects/mitreattack-python/
43+
cd docs/
44+
poetry run python -m sphinx -T -b html -d _build/doctrees -D language=en . _build/html
45+
cd ..
46+
3947
# Run tests
4048
poetry run pytest --cov=mitreattack --cov-report html
4149

docs/conf.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212

1313
# -- Project information -----------------------------------------------------
1414
project = "mitreattack-python"
15-
copyright = "2025, The MITRE Corporation"
16-
version = "5.3.0"
17-
release = "5.3.0"
15+
copyright = "2026, The MITRE Corporation"
16+
version = "5.4.1"
17+
release = "5.4.1"
1818

1919
# -- General configuration ---------------------------------------------------
2020
extensions = [

mitreattack/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from PIL import __version__
66
from . import attackToExcel, collections, navlayers
77

8-
__version__ = "5.3.0"
8+
__version__ = "5.4.1"
99

1010
__all__ = [
1111
"attackToExcel",

mitreattack/attackToExcel/attackToExcel.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,27 @@ def build_dataframes(src: MemoryStore, domain: str) -> Dict:
147147
return df
148148

149149

150-
def write_excel(dataframes: Dict, domain: str, version: Optional[str] = None, output_dir: str = ".") -> List:
150+
def build_ds_an_lg_relationships(dataframes: Dict) -> Dict[str, pd.DataFrame]:
151+
"""Build detection-mappings.xlsx with a single DS → Analytic → LogSource sheet."""
152+
ds_an = dataframes["detectionstrategies"].get("detectionstrategies-analytic", pd.DataFrame())
153+
154+
an_ls = dataframes["analytics"].get("analytic-logsource", pd.DataFrame())
155+
156+
if ds_an.empty or an_ls.empty:
157+
combined = pd.DataFrame()
158+
else:
159+
combined = ds_an.merge(
160+
an_ls,
161+
on=["analytic_id", "analytic_name", "platforms"],
162+
how="left",
163+
)
164+
165+
return {"ds_an_ls": combined}
166+
167+
168+
def write_excel(
169+
dataframes: Dict, domain: str, src: MemoryStore, version: Optional[str] = None, output_dir: str = "."
170+
) -> List:
151171
"""Given a set of dataframes from build_dataframes, write the ATT&CK dataset to output directory.
152172
153173
Parameters
@@ -156,6 +176,9 @@ def write_excel(dataframes: Dict, domain: str, version: Optional[str] = None, ou
156176
A dictionary of pandas dataframes as built by build_dataframes()
157177
domain : str
158178
Domain of ATT&CK the dataframes correspond to, e.g "enterprise-attack"
179+
src : stix2.MemoryStore
180+
A STIX bundle containing ATT&CK data for a domain already loaded into memory.
181+
Mutually exclusive with `remote` and `stix_file`.
159182
version : str, optional
160183
The version of ATT&CK the dataframes correspond to, e.g "v8.1".
161184
If omitted, the output files will not be labelled with the version number, by default None
@@ -181,6 +204,10 @@ def write_excel(dataframes: Dict, domain: str, version: Optional[str] = None, ou
181204
os.makedirs(output_directory)
182205
# master dataset file
183206
master_fp = os.path.join(output_directory, f"{domain_version_string}.xlsx")
207+
208+
ds_an_ls_df = stixToDf.detectionStrategiesAnalyticsLogSourcesDf(src)
209+
add_ds_an_ls_to = {"detectionstrategies", "analytics", "datacomponents"}
210+
184211
with pd.ExcelWriter(path=master_fp, engine="xlsxwriter") as master_writer:
185212
# master list of citations
186213
citations = pd.DataFrame()
@@ -199,6 +226,14 @@ def write_excel(dataframes: Dict, domain: str, version: Optional[str] = None, ou
199226
for sheet_name in object_data:
200227
logger.debug(f"Writing sheet to {fp}: {sheet_name}")
201228
object_data[sheet_name].to_excel(object_writer, sheet_name=sheet_name, index=False)
229+
230+
# Write Detection strategy - Analytics - Log sources file
231+
if (
232+
object_type in add_ds_an_ls_to
233+
and isinstance(ds_an_ls_df, pd.DataFrame)
234+
and not ds_an_ls_df.empty
235+
):
236+
ds_an_ls_df.to_excel(object_writer, sheet_name="defensive mappings", index=False)
202237
written_files.append(fp)
203238

204239
# add citations to master citations list
@@ -285,13 +320,16 @@ def write_excel(dataframes: Dict, domain: str, version: Optional[str] = None, ou
285320

286321
written_files.append(fp)
287322

323+
if isinstance(ds_an_ls_df, pd.DataFrame) and not ds_an_ls_df.empty:
324+
ds_an_ls_df.to_excel(master_writer, sheet_name="defensive mappings", index=False)
288325
# remove duplicate citations and add sheet to master file
289326
logger.debug(f"Writing sheet to {master_fp}: citations")
290327
citations.drop_duplicates(subset="reference", ignore_index=True).sort_values("reference").to_excel(
291328
master_writer, sheet_name="citations", index=False
292329
)
293330

294331
written_files.append(master_fp)
332+
295333
for thefile in written_files:
296334
logger.info(f"Excel file created: {thefile}")
297335
return written_files
@@ -364,11 +402,11 @@ def export(
364402
major_version = int(match.group(1))
365403
if major_version < 18:
366404
dataframes = build_dataframes_pre_v18(src=mem_store, domain=domain)
367-
write_excel(dataframes=dataframes, domain=domain, version=version, output_dir=output_dir)
405+
write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir)
368406
return
369407

370408
dataframes = build_dataframes(src=mem_store, domain=domain)
371-
write_excel(dataframes=dataframes, domain=domain, version=version, output_dir=output_dir)
409+
write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir)
372410

373411

374412
def main():

mitreattack/attackToExcel/stixToDf.py

Lines changed: 121 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -367,16 +367,67 @@ def analyticsToDf(src):
367367
analytics = src.query([Filter("type", "=", "x-mitre-analytic")])
368368
analytics = remove_revoked_deprecated(analytics)
369369

370+
# Detection strategies (needed for analytics to detection strategies relationship)
371+
detection_strategies = src.query([Filter("type", "=", "x-mitre-detection-strategy")])
372+
detection_strategies = remove_revoked_deprecated(detection_strategies)
373+
370374
dataframes = {}
371375
if analytics:
372376
analytic_rows = []
377+
logsource_rows = []
378+
analytic_to_ds_rows = []
379+
380+
# analytics to detection strategies
381+
analytic_to_ds_map = {}
382+
for ds in detection_strategies:
383+
for analytic_id in ds.get("x_mitre_analytic_refs", []):
384+
analytic_to_ds_map.setdefault(analytic_id, []).append(
385+
{
386+
"detection_strategy_attack_id": ds["external_references"][0]["external_id"],
387+
"detection_strategy_id": ds["id"],
388+
"detection_strategy_name": ds.get("name", ""),
389+
}
390+
)
391+
373392
for analytic in tqdm(analytics, desc="parsing analytics"):
374393
analytic_rows.append(parseBaseStix(analytic))
375394

395+
# log-source relationship table rows
396+
for logsrc in analytic.get("x_mitre_log_source_references", []):
397+
data_comp_id = logsrc.get("x_mitre_data_component_ref", "")
398+
data_comp = src.get(data_comp_id)
399+
data_comp_name = data_comp.get("name", "") if data_comp else ""
400+
data_comp_attack_id = data_comp["external_references"][0]["external_id"]
401+
402+
logsource_rows.append(
403+
{
404+
"analytic_id": analytic["id"],
405+
"analytic_name": analytic["external_references"][0]["external_id"],
406+
"data_component_id": data_comp_id,
407+
"data_component_name": data_comp_name,
408+
"data_component_attack_id": data_comp_attack_id,
409+
"log_source_name": logsrc.get("name", ""),
410+
"channel": logsrc.get("channel", ""),
411+
"platforms": ", ".join(sorted(analytic.get("x_mitre_platforms", []))),
412+
}
413+
)
414+
415+
# detection strategies relationship table rows
416+
for ds_info in analytic_to_ds_map.get(analytic["id"], []):
417+
analytic_to_ds_rows.append(
418+
{
419+
"analytic_id": analytic["id"],
420+
"analytic_name": analytic["external_references"][0]["external_id"],
421+
"detection_strategy_id": ds_info["detection_strategy_id"],
422+
"detection_strategy_attack_id": ds_info["detection_strategy_attack_id"],
423+
"detection_strategy_name": ds_info["detection_strategy_name"],
424+
"platforms": ", ".join(sorted(analytic.get("x_mitre_platforms", []))),
425+
}
426+
)
427+
428+
dataframes["analytics"] = pd.DataFrame(analytic_rows).sort_values("name")
429+
376430
citations = get_citations(analytics)
377-
dataframes = {
378-
"analytics": pd.DataFrame(analytic_rows).sort_values("name"),
379-
}
380431
if not citations.empty:
381432
dataframes["citations"] = citations.sort_values("reference")
382433

@@ -398,20 +449,36 @@ def detectionstrategiesToDf(src):
398449
dataframes = {}
399450
if detection_strategies:
400451
detection_strategy_rows = []
452+
rel_rows = []
401453
for detection_strategy in tqdm(detection_strategies, desc="parsing detection strategies"):
402-
detection_strategy_rows.append(parseBaseStix(detection_strategy))
454+
row = parseBaseStix(detection_strategy)
455+
row["analytic_refs"] = "; ".join(detection_strategy.get("x_mitre_analytic_refs", []))
456+
detection_strategy_rows.append(row)
457+
458+
# analytics relationship table rows
459+
for analytic_id in detection_strategy.get("x_mitre_analytic_refs", []):
460+
analytic_obj = src.get(analytic_id)
461+
462+
rel_rows.append(
463+
{
464+
"detection_strategy_attack_id": detection_strategy["external_references"][0]["external_id"],
465+
"detection_strategy_id": detection_strategy["id"],
466+
"detection_strategy_name": detection_strategy.get("name", ""),
467+
"analytic_id": analytic_id,
468+
"analytic_name": analytic_obj["external_references"][0]["external_id"],
469+
"platforms": ", ".join(sorted(analytic_obj.get("x_mitre_platforms", []))),
470+
}
471+
)
472+
473+
# Build main dataframes
474+
dataframes["detectionstrategies"] = pd.DataFrame(detection_strategy_rows).sort_values("name")
403475

404476
citations = get_citations(detection_strategies)
405-
dataframes = {
406-
"detectionstrategies": pd.DataFrame(detection_strategy_rows).sort_values("name"),
407-
}
408477
if not citations.empty:
409-
if "citations" in dataframes: # append to existing citations from references
410-
dataframes["citations"] = citations.sort_values("reference")
478+
dataframes["citations"] = citations.sort_values("reference")
411479

412480
else:
413481
logger.warning("No detection strategies found - nothing to parse")
414-
415482
return dataframes
416483

417484

@@ -461,6 +528,50 @@ def softwareToDf(src):
461528
return dataframes
462529

463530

531+
def detectionStrategiesAnalyticsLogSourcesDf(src):
532+
"""Build a single DS -> LogSource -> Analytic dataframe directly from STIX."""
533+
detection_strategies = src.query([Filter("type", "=", "x-mitre-detection-strategy")])
534+
detection_strategies = remove_revoked_deprecated(detection_strategies)
535+
536+
analytics = src.query([Filter("type", "=", "x-mitre-analytic")])
537+
analytics = remove_revoked_deprecated(analytics)
538+
analytics_by_id = {a["id"]: a for a in analytics}
539+
540+
rows = []
541+
for ds in detection_strategies:
542+
ds_attack_id = ds.get("external_references", [{}])[0].get("external_id", "")
543+
ds_id = ds.get("id", "")
544+
ds_name = ds.get("name", "")
545+
546+
for analytic_id in ds.get("x_mitre_analytic_refs", []):
547+
analytic = analytics_by_id.get(analytic_id)
548+
analytic_attack_id = analytic["external_references"][0]["external_id"]
549+
platforms = ", ".join(sorted(analytic.get("x_mitre_platforms", [])))
550+
551+
logsrc_refs = analytic.get("x_mitre_log_source_references", [])
552+
for logsrc in logsrc_refs:
553+
data_comp_id = logsrc.get("x_mitre_data_component_ref", "")
554+
data_comp = src.get(data_comp_id)
555+
556+
rows.append(
557+
{
558+
"detection_strategy_attack_id": ds_attack_id,
559+
"detection_strategy_id": ds_id,
560+
"detection_strategy_name": ds_name,
561+
"analytic_id": analytic_id,
562+
"analytic_name": analytic_attack_id,
563+
"platforms": platforms,
564+
"log_source_name": logsrc.get("name", ""),
565+
"channel": logsrc.get("channel", ""),
566+
"data_component_id": data_comp_id,
567+
"data_component_name": (data_comp.get("name", "") if data_comp else ""),
568+
"data_component_attack_id": data_comp["external_references"][0]["external_id"],
569+
}
570+
)
571+
572+
return pd.DataFrame(rows)
573+
574+
464575
def groupsToDf(src):
465576
"""Parse STIX groups from the given data and return corresponding pandas dataframes.
466577

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "mitreattack-python"
33
description = "MITRE ATT&CK python library"
4-
version = "5.3.0"
4+
version = "5.4.1"
55
authors = [{ name = "MITRE ATT&CK", email = "attack@mitre.org" }]
66
license = { text = "Apache-2.0" }
77
readme = "README.md"
@@ -30,6 +30,7 @@ dependencies = [
3030
"tabulate>=0.9.0",
3131
"tqdm>=4.66.1",
3232
"typer>=0.9.0",
33+
"typing-extensions>=4.0.0",
3334
"wheel>=0.41.2",
3435
"xlsxwriter>=3.1.8",
3536
]
@@ -126,4 +127,4 @@ version_files = [
126127
"docs/conf.py:^version = ['\"](.*)['\"]",
127128
"docs/conf.py:^release = ['\"](.*)['\"]",
128129
"mitreattack/__init__.py:^__version__ = ['\"](.*)['\"]",
129-
]
130+
]

0 commit comments

Comments
 (0)