feat(ttp): E.1.8 UKC bridge contract — ATTACK_TACTIC_TO_UKC + tactic_to_ukc_phase + inverse

This commit is contained in:
2026-05-01 07:12:00 -04:00
parent b5a19301a2
commit cfbfaabfcd
2 changed files with 70 additions and 0 deletions

View File

@@ -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)