From cfbfaabfcdb9a9e34572d2c208ee5c2a2bbbb334 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 07:12:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(ttp):=20E.1.8=20UKC=20bridge=20contract=20?= =?UTF-8?q?=E2=80=94=20ATTACK=5FTACTIC=5FTO=5FUKC=20+=20tactic=5Fto=5Fukc?= =?UTF-8?q?=5Fphase=20+=20inverse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- decnet/clustering/ukc.py | 68 ++++++++++++++++++++++++++++++++++++++ development/TTP_TAGGING.md | 2 ++ 2 files changed, 70 insertions(+) diff --git a/decnet/clustering/ukc.py b/decnet/clustering/ukc.py index 0a54710d..5e603a31 100644 --- a/decnet/clustering/ukc.py +++ b/decnet/clustering/ukc.py @@ -106,3 +106,71 @@ def stage_of(phase: UKCPhase) -> str: if phase in STAGE_THROUGH: return "through" return "out" + + +# MITRE ATT&CK tactic ID -> UKC phase. Covers the 14 enterprise tactics +# plus the four ICS tactics referenced by Appendix A.7 (Conpot, MQTT). +# Adding additional ICS tactics is a one-line addition. See +# TTP_TAGGING.md "UKC bridge". +ATTACK_TACTIC_TO_UKC: dict[str, UKCPhase] = { + # Enterprise + "TA0043": UKCPhase.RECONNAISSANCE, # Reconnaissance + "TA0042": UKCPhase.RESOURCE_DEVELOPMENT, # Resource Development + "TA0001": UKCPhase.DELIVERY, # Initial Access + "TA0002": UKCPhase.EXECUTION, # Execution + "TA0003": UKCPhase.PERSISTENCE, # Persistence + "TA0004": UKCPhase.PRIVILEGE_ESCALATION, # Privilege Escalation + "TA0005": UKCPhase.DEFENSE_EVASION, # Defense Evasion + "TA0006": UKCPhase.CREDENTIAL_ACCESS, # Credential Access + "TA0007": UKCPhase.DISCOVERY, # Discovery + "TA0008": UKCPhase.LATERAL_MOVEMENT, # Lateral Movement + "TA0009": UKCPhase.COLLECTION, # Collection + "TA0011": UKCPhase.COMMAND_AND_CONTROL, # Command and Control + "TA0010": UKCPhase.EXFILTRATION, # Exfiltration + "TA0040": UKCPhase.IMPACT, # Impact + # ICS — first-class projection so MQTT / Conpot / Modbus tags + # don't drop out of campaign rollups when the clusterer projects + # tactic to phase. ICS uses an independent tactic-ID range. + "TA0100": UKCPhase.COLLECTION, # ICS: Collection + "TA0102": UKCPhase.DISCOVERY, # ICS: Discovery + "TA0105": UKCPhase.IMPACT, # ICS: Impact + "TA0106": UKCPhase.IMPACT, # ICS: Impair Process Control +} + + +def tactic_to_ukc_phase(tactic: str) -> UKCPhase | None: + """Map an ATT&CK tactic ID (e.g. ``"TA0001"``) to a :class:`UKCPhase`. + + Returns ``None`` for unknown tactics. The map is closed-over the + enterprise + ICS tactics referenced by the rule pack; a tactic + outside that set is a contributor bug, not a runtime miss. + """ + return ATTACK_TACTIC_TO_UKC.get(tactic) + + +# Inverse map, built once at import time. Several enterprise tactics +# would collide (e.g. both TA0009 and TA0100 map to COLLECTION); the +# enterprise tactic wins because it's listed first in +# ATTACK_TACTIC_TO_UKC, which dict comprehension preserves via +# last-write semantics — so we iterate in reverse to keep the FIRST +# occurrence per phase. Pre-target phases (RECONNAISSANCE, +# RESOURCE_DEVELOPMENT, WEAPONIZATION, SOCIAL_ENGINEERING) that are +# not in OBSERVABLE_PHASES are deliberately lossy on the inverse — +# TTP tags must never assign them, so projecting back to a tactic +# is undefined. See TTP_TAGGING.md §UKC bridge. +_UKC_TO_TACTIC: dict[UKCPhase, str] = { + phase: tactic + for tactic, phase in reversed(list(ATTACK_TACTIC_TO_UKC.items())) +} + + +def ukc_phase_to_tactic(phase: UKCPhase) -> str | None: + """Map a :class:`UKCPhase` back to an ATT&CK tactic ID. + + Lossy on phases outside :data:`OBSERVABLE_PHASES` — pre-target + phases (e.g. ``RECONNAISSANCE``, ``WEAPONIZATION``) return + ``None`` because no rule emits them, so the inverse is + undefined by design. The CDD test in E.2.9 pins which phases + are lossy. + """ + return _UKC_TO_TACTIC.get(phase) diff --git a/development/TTP_TAGGING.md b/development/TTP_TAGGING.md index e40734ed..309aa8f7 100644 --- a/development/TTP_TAGGING.md +++ b/development/TTP_TAGGING.md @@ -2334,6 +2334,8 @@ unrelated events. **E.1.8 — UKC bridge contract** (`decnet/clustering/ukc.py`) +**Status:** ✅ done. + - `ATTACK_TACTIC_TO_UKC: dict[str, UKCPhase]` — the static map from the body of this doc. - `def tactic_to_ukc_phase(tactic: str) -> UKCPhase | None`.