From 84699f89da3fe2d30a50fc3693f6159336ff9de0 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 2 May 2026 03:10:07 -0400 Subject: [PATCH] feat(ttp): show canonical ATT&CK technique names in the TTPs UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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. --- decnet/ttp/attack_catalog.py | 115 ++++++++++++++++++ decnet/web/db/models/ttp.py | 16 ++- decnet/web/db/sqlmodel_repo/ttp.py | 11 ++ decnet/web/router/ttp/api_get_tag_details.py | 10 +- decnet_web/src/components/TTPInspector.tsx | 22 +++- .../src/components/TTPsObservedSection.tsx | 25 +++- tests/ttp/test_attack_catalog.py | 58 +++++++++ 7 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 decnet/ttp/attack_catalog.py create mode 100644 tests/ttp/test_attack_catalog.py diff --git a/decnet/ttp/attack_catalog.py b/decnet/ttp/attack_catalog.py new file mode 100644 index 00000000..c95788c2 --- /dev/null +++ b/decnet/ttp/attack_catalog.py @@ -0,0 +1,115 @@ +"""ATT&CK technique-id → display-name catalogue. + +Pinned to the same ATT&CK release the rule engine emits on +(``v15.1`` per ``decnet/ttp/impl/rule_engine.py:_ATTACK_RELEASE``). The +operator UI uses these names to render "T1595 — Active Scanning" +instead of just "T1595" in the TTPs-observed rollup and the per-tag +inspector. Names are the canonical MITRE labels, not author-supplied +strings on rules — keeping them here means a rule author can't typo a +technique name and the entire fleet sees the typo. + +Bumping ``_ATTACK_RELEASE`` requires reviewing this file: any +techniques that were renamed need their entries updated in the same +commit. See TTP_TAGGING.md §"Hard parts §8 ATT&CK matrix drift". + +Coverage policy: every technique_id / sub_technique_id appearing in +``rules/ttp/`` MUST have an entry here. The +``tests/ttp/test_attack_catalog.py`` coverage test enforces this so a +rule author who adds a new technique gets a loud failure rather than +a silent UI fallback. +""" +from __future__ import annotations + +from typing import Final + +# Top-level techniques + sub-techniques referenced by `rules/ttp/` +# (R0001..R0058). Names from MITRE ATT&CK Enterprise v15.1. +TECHNIQUE_NAMES: Final[dict[str, str]] = { + # ── Top-level techniques ───────────────────────────────────────── + "T1003": "OS Credential Dumping", + "T1016": "System Network Configuration Discovery", + "T1027": "Obfuscated Files or Information", + "T1029": "Scheduled Transfer", + "T1033": "System Owner/User Discovery", + "T1036": "Masquerading", + "T1046": "Network Service Discovery", + "T1049": "System Network Connections Discovery", + "T1053": "Scheduled Task/Job", + "T1059": "Command and Scripting Interpreter", + "T1070": "Indicator Removal", + "T1071": "Application Layer Protocol", + "T1078": "Valid Accounts", + "T1082": "System Information Discovery", + "T1083": "File and Directory Discovery", + "T1087": "Account Discovery", + "T1090": "Proxy", + "T1098": "Account Manipulation", + "T1105": "Ingress Tool Transfer", + "T1110": "Brute Force", + "T1135": "Network Share Discovery", + "T1136": "Create Account", + "T1190": "Exploit Public-Facing Application", + "T1204": "User Execution", + "T1213": "Data from Information Repositories", + "T1482": "Domain Trust Discovery", + "T1485": "Data Destruction", + "T1486": "Data Encrypted for Impact", + "T1496": "Resource Hijacking", + "T1505": "Server Software Component", + "T1548": "Abuse Elevation Control Mechanism", + "T1552": "Unsecured Credentials", + "T1557": "Adversary-in-the-Middle", + "T1566": "Phishing", + "T1567": "Exfiltration Over Web Service", + "T1586": "Compromise Accounts", + "T1588": "Obtain Capabilities", + "T1595": "Active Scanning", + "T1602": "Data from Configuration Repository", + "T1611": "Escape to Host", + # ── Sub-techniques ─────────────────────────────────────────────── + "T1003.008": "OS Credential Dumping: /etc/passwd and /etc/shadow", + "T1036.005": "Masquerading: Match Legitimate Name or Location", + "T1053.003": "Scheduled Task/Job: Cron", + "T1059.004": "Command and Scripting Interpreter: Unix Shell", + "T1070.003": "Indicator Removal: Clear Command History", + "T1071.001": "Application Layer Protocol: Web Protocols", + "T1071.003": "Application Layer Protocol: Mail Protocols", + "T1078.001": "Valid Accounts: Default Accounts", + "T1087.002": "Account Discovery: Domain Account", + "T1098.004": "Account Manipulation: SSH Authorized Keys", + "T1110.001": "Brute Force: Password Guessing", + "T1110.003": "Brute Force: Password Spraying", + "T1110.004": "Brute Force: Credential Stuffing", + "T1136.001": "Create Account: Local Account", + "T1204.002": "User Execution: Malicious File", + "T1505.003": "Server Software Component: Web Shell", + "T1548.001": "Abuse Elevation Control Mechanism: Setuid and Setgid", + "T1548.003": "Abuse Elevation Control Mechanism: Sudo and Sudo Caching", + "T1552.001": "Unsecured Credentials: Credentials In Files", + "T1552.007": "Unsecured Credentials: Container API", + "T1557.001": "Adversary-in-the-Middle: LLMNR/NBT-NS Poisoning and SMB Relay", + "T1566.001": "Phishing: Spearphishing Attachment", + "T1566.002": "Phishing: Spearphishing Link", + "T1566.003": "Phishing: Spearphishing via Service", + "T1586.002": "Compromise Accounts: Email Accounts", + "T1588.001": "Obtain Capabilities: Malware", + "T1588.002": "Obtain Capabilities: Tool", + "T1595.002": "Active Scanning: Vulnerability Scanning", + "T1602.002": "Data from Configuration Repository: Network Device Configuration Dump", +} + + +def technique_name(technique_id: str | None) -> str | None: + """Return the canonical ATT&CK display name for *technique_id*. + + ``None`` for unknown IDs — the UI falls back to showing the bare + ID. Adding a rule that emits an unknown technique should be a + deploy-time loud failure (see ``tests/ttp/test_attack_catalog.py``) + rather than a silent UI fallback in production. + """ + if not technique_id: + return None + return TECHNIQUE_NAMES.get(technique_id) + + +__all__ = ["TECHNIQUE_NAMES", "technique_name"] diff --git a/decnet/web/db/models/ttp.py b/decnet/web/db/models/ttp.py index 8dbf7d69..151e1e97 100644 --- a/decnet/web/db/models/ttp.py +++ b/decnet/web/db/models/ttp.py @@ -250,7 +250,9 @@ class TechniqueRollupRow(BaseModel): across the fleet with a count and a most-recent-seen timestamp.""" technique_id: str + technique_name: Optional[str] = None sub_technique_id: Optional[str] = None + sub_technique_name: Optional[str] = None tactic: str count: int last_seen: datetime @@ -259,10 +261,18 @@ class TechniqueRollupRow(BaseModel): class IdentityTechniqueRow(BaseModel): """One row of the by-identity / by-attacker / by-session endpoints — a distinct (technique, sub_technique) tuple within the requested - scope, with an aggregate count and first/last-seen timestamps.""" + scope, with an aggregate count and first/last-seen timestamps. + + ``technique_name`` / ``sub_technique_name`` come from + :mod:`decnet.ttp.attack_catalog` (canonical ATT&CK labels for the + pinned release). ``None`` when the ID isn't in the catalogue — + the UI falls back to showing the bare ID. + """ technique_id: str + technique_name: Optional[str] = None sub_technique_id: Optional[str] = None + sub_technique_name: Optional[str] = None tactic: str count: int first_seen: datetime @@ -287,7 +297,9 @@ class TTPTagDetailRow(BaseModel): decky_id: Optional[str] = None tactic: str technique_id: str + technique_name: Optional[str] = None sub_technique_id: Optional[str] = None + sub_technique_name: Optional[str] = None confidence: float rule_id: str rule_version: int @@ -301,7 +313,9 @@ class CampaignTechniqueRow(BaseModel): across at least one Identity rolled up into the campaign.""" technique_id: str + technique_name: Optional[str] = None sub_technique_id: Optional[str] = None + sub_technique_name: Optional[str] = None tactic: str count: int identity_count: int diff --git a/decnet/web/db/sqlmodel_repo/ttp.py b/decnet/web/db/sqlmodel_repo/ttp.py index f8b88efb..adb24b20 100644 --- a/decnet/web/db/sqlmodel_repo/ttp.py +++ b/decnet/web/db/sqlmodel_repo/ttp.py @@ -20,6 +20,7 @@ from typing import Any from sqlalchemy import func, select from sqlmodel import col +from decnet.ttp.attack_catalog import technique_name as _technique_name from decnet.web.db.models import ( Attacker, AttackerIdentity, @@ -113,7 +114,9 @@ class TTPMixin(_MixinBase): return [ IdentityTechniqueRow( technique_id=r.technique_id, + technique_name=_technique_name(r.technique_id), sub_technique_id=r.sub_technique_id, + sub_technique_name=_technique_name(r.sub_technique_id), tactic=r.tactic, count=r.count, first_seen=r.first_seen, @@ -149,7 +152,9 @@ class TTPMixin(_MixinBase): return [ IdentityTechniqueRow( technique_id=r.technique_id, + technique_name=_technique_name(r.technique_id), sub_technique_id=r.sub_technique_id, + sub_technique_name=_technique_name(r.sub_technique_id), tactic=r.tactic, count=r.count, first_seen=r.first_seen, @@ -191,7 +196,9 @@ class TTPMixin(_MixinBase): return [ CampaignTechniqueRow( technique_id=r.technique_id, + technique_name=_technique_name(r.technique_id), sub_technique_id=r.sub_technique_id, + sub_technique_name=_technique_name(r.sub_technique_id), tactic=r.tactic, count=r.count, identity_count=r.identity_count, @@ -225,7 +232,9 @@ class TTPMixin(_MixinBase): return [ IdentityTechniqueRow( technique_id=r.technique_id, + technique_name=_technique_name(r.technique_id), sub_technique_id=r.sub_technique_id, + sub_technique_name=_technique_name(r.sub_technique_id), tactic=r.tactic, count=r.count, first_seen=r.first_seen, @@ -396,7 +405,9 @@ class TTPMixin(_MixinBase): return [ TechniqueRollupRow( technique_id=r.technique_id, + technique_name=_technique_name(r.technique_id), sub_technique_id=r.sub_technique_id, + sub_technique_name=_technique_name(r.sub_technique_id), tactic=r.tactic, count=r.count, last_seen=r.last_seen, diff --git a/decnet/web/router/ttp/api_get_tag_details.py b/decnet/web/router/ttp/api_get_tag_details.py index 0d624e7c..e8542ad9 100644 --- a/decnet/web/router/ttp/api_get_tag_details.py +++ b/decnet/web/router/ttp/api_get_tag_details.py @@ -25,6 +25,7 @@ from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, status from decnet.telemetry import traced as _traced +from decnet.ttp.attack_catalog import technique_name as _technique_name from decnet.web.db.models import TTPTagDetailRow from decnet.web.dependencies import repo, require_viewer @@ -71,4 +72,11 @@ async def api_ttp_tag_details( sub_technique_id=sub_technique_id, limit=limit, ) - return [TTPTagDetailRow(**row) for row in rows] + return [ + TTPTagDetailRow( + **row, + technique_name=_technique_name(row.get("technique_id")), + sub_technique_name=_technique_name(row.get("sub_technique_id")), + ) + for row in rows + ] diff --git a/decnet_web/src/components/TTPInspector.tsx b/decnet_web/src/components/TTPInspector.tsx index 72e0c59b..d1a7b1d4 100644 --- a/decnet_web/src/components/TTPInspector.tsx +++ b/decnet_web/src/components/TTPInspector.tsx @@ -26,7 +26,9 @@ export interface TTPTagDetailRow { decky_id: string | null; tactic: string; technique_id: string; + technique_name: string | null; sub_technique_id: string | null; + sub_technique_name: string | null; confidence: number; rule_id: string; rule_version: number; @@ -42,6 +44,8 @@ interface Props { uuid: string; techniqueId: string; subTechniqueId: string | null; + techniqueName: string | null; + subTechniqueName: string | null; tactic: string; count: number; confidenceMax: number; @@ -49,7 +53,8 @@ interface Props { } const TTPInspector: React.FC = ({ - scope, uuid, techniqueId, subTechniqueId, tactic, count, confidenceMax, onClose, + scope, uuid, techniqueId, subTechniqueId, techniqueName, subTechniqueName, + tactic, count, confidenceMax, onClose, }) => { const panelRef = useRef(null); useEscapeKey(onClose, true); @@ -90,7 +95,9 @@ const TTPInspector: React.FC = ({ return () => { cancelled = true; }; }, [scope, uuid, techniqueId, subTechniqueId]); - const label = subTechniqueId ?? techniqueId; + const id = subTechniqueId ?? techniqueId; + const name = subTechniqueName ?? techniqueName; + const headerLabel = name ? `${id} — ${name}` : id; return (
= ({

- {label} + {headerLabel}