Files
DECNET/tests/ttp/test_attack_catalog.py
anti 84699f89da feat(ttp): show canonical ATT&CK technique names in the TTPs UI
"T1595" alone is opaque; "T1595 — Active Scanning" tells you the
story at a glance. The names come from a backend-side static catalogue
pinned to the same ATT&CK release as the rule engine
(_ATTACK_RELEASE = "v15.1") — names are the canonical MITRE labels,
not author-supplied strings on rules, so a rule author can't typo a
name and the entire fleet sees the typo.

- New `decnet/ttp/attack_catalog.py` with `TECHNIQUE_NAMES` covering
  every technique_id + sub_technique_id emitted by `rules/ttp/`
  (R0001..R0058 → 69 IDs in the v0 pack).
- `IdentityTechniqueRow` / `TechniqueRollupRow` / `CampaignTechniqueRow`
  / `TTPTagDetailRow` gain optional `technique_name` /
  `sub_technique_name` fields. Repo + router populate them from the
  catalogue at row-construction time. None when an ID isn't in the
  catalogue — UI falls back to the bare ID.
- Coverage test (`tests/ttp/test_attack_catalog.py`) walks every
  YAML rule and asserts every emitted ID has a catalogue entry, so
  a future rule author who forgets to update the catalogue gets a
  loud failure rather than a silent UI fallback.

Frontend:
- `TTPsObservedSection` shows "T1595.002 — Active Scanning:
  Vulnerability Scanning" instead of just the ID, with overflow
  ellipsis + tooltip for narrow viewports. Inspector header /
  TECHNIQUE row also surface the names.
2026-05-02 03:10:07 -04:00

59 lines
1.9 KiB
Python

"""ATT&CK technique-name catalogue covers every ID emitted by the rule pack.
A rule author who adds a new technique to ``rules/ttp/`` must also
update ``decnet/ttp/attack_catalog.py`` in the same commit. Without
this test the UI silently falls back to the bare ID for unknown
techniques.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from decnet.ttp.attack_catalog import TECHNIQUE_NAMES, technique_name
_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "ttp"
def _all_technique_ids_in_rule_pack() -> set[str]:
ids: set[str] = set()
for path in sorted(_RULES_DIR.glob("R*.yaml")):
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
for emit in doc.get("emits", []) or []:
tid = emit.get("technique_id")
if isinstance(tid, str) and tid:
ids.add(tid)
sub = emit.get("sub_technique_id")
if isinstance(sub, str) and sub:
ids.add(sub)
return ids
def test_every_rule_pack_technique_has_a_catalogue_entry() -> None:
rule_ids = _all_technique_ids_in_rule_pack()
missing = sorted(rule_ids - TECHNIQUE_NAMES.keys())
assert not missing, (
"rules/ttp/ emits techniques absent from "
"decnet/ttp/attack_catalog.py: " + ", ".join(missing)
)
def test_technique_name_returns_canonical_label() -> None:
assert technique_name("T1595") == "Active Scanning"
assert technique_name("T1595.002") == "Active Scanning: Vulnerability Scanning"
def test_technique_name_unknown_id_returns_none() -> None:
assert technique_name("T9999") is None
assert technique_name(None) is None
assert technique_name("") is None
def test_catalogue_entries_are_non_empty_strings() -> None:
for tid, name in TECHNIQUE_NAMES.items():
assert isinstance(name, str) and name.strip(), (
f"empty / non-string name for {tid!r}: {name!r}"
)