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.
This commit is contained in:
58
tests/ttp/test_attack_catalog.py
Normal file
58
tests/ttp/test_attack_catalog.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""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}"
|
||||
)
|
||||
Reference in New Issue
Block a user