test(ttp): E.2.9 UKC bridge bijection — pin tactic↔phase mapping, observable round-trip, lossy phases
Pre-target phases (RECONNAISSANCE/RESOURCE_DEVELOPMENT/WEAPONIZATION/ SOCIAL_ENGINEERING) and observable-but-unmappable phases (EXPLOITATION/ PIVOTING/OBJECTIVES, UKC-only concepts ATT&CK lacks tactics for) are pinned as lossy via _LOSSY_INVERSE_REFERENCE so a future contributor cannot 'fix' the asymmetry without tripping the suite.
This commit is contained in:
140
tests/clustering/test_ukc_bridge.py
Normal file
140
tests/clustering/test_ukc_bridge.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""E.2.9 — UKC bridge bijection tests.
|
||||
|
||||
Pins the ATT&CK tactic ↔ UKC phase mapping declared in
|
||||
:mod:`decnet.clustering.ukc`. Per ``development/TTP_TAGGING.md`` §UKC
|
||||
bridge:
|
||||
|
||||
* Every key in ``ATTACK_TACTIC_TO_UKC`` is a valid TA-prefixed string.
|
||||
* Every value is a member of :class:`UKCPhase`.
|
||||
* For every phase in :data:`OBSERVABLE_PHASES`, the inverse round-trips:
|
||||
``tactic_to_ukc_phase(ukc_phase_to_tactic(phase)) == phase``.
|
||||
* Phases NOT in :data:`OBSERVABLE_PHASES` (pre-target reconnaissance,
|
||||
resource development, weaponization, social engineering) MAY have a
|
||||
lossy inverse — the test pins which ones are lossy *and* the
|
||||
current inverse output, so a future contributor cannot accidentally
|
||||
"fix" the asymmetry without tripping the suite.
|
||||
|
||||
All assertions are GREEN today; the contract is fully implemented in
|
||||
``ukc.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.clustering.ukc import (
|
||||
ATTACK_TACTIC_TO_UKC,
|
||||
OBSERVABLE_PHASES,
|
||||
UKCPhase,
|
||||
tactic_to_ukc_phase,
|
||||
ukc_phase_to_tactic,
|
||||
)
|
||||
|
||||
|
||||
# Pinned reference for the inverse projection on phases that don't
|
||||
# round-trip cleanly. Two flavors:
|
||||
#
|
||||
# 1. Pre-target phases (RECONNAISSANCE, RESOURCE_DEVELOPMENT,
|
||||
# WEAPONIZATION, SOCIAL_ENGINEERING) — excluded from
|
||||
# OBSERVABLE_PHASES because no honeypot rule emits them. Lossy by
|
||||
# design.
|
||||
# 2. Observable-but-unmappable phases (EXPLOITATION, PIVOTING,
|
||||
# OBJECTIVES) — UKC-only concepts that ATT&CK does not have a
|
||||
# corresponding tactic for. Honeypot rules CAN emit these (they're
|
||||
# in OBSERVABLE_PHASES) but the inverse is undefined because
|
||||
# ATT&CK lacks the granularity.
|
||||
#
|
||||
# Pinning the literal output here freezes the asymmetry: a future
|
||||
# refactor that "rounds-trips" any of these phases trips the test.
|
||||
# See TTP_TAGGING.md §UKC bridge.
|
||||
_LOSSY_INVERSE_REFERENCE: dict[UKCPhase, str | None] = {
|
||||
# Pre-target (non-observable)
|
||||
UKCPhase.RECONNAISSANCE: "TA0043",
|
||||
UKCPhase.RESOURCE_DEVELOPMENT: "TA0042",
|
||||
UKCPhase.WEAPONIZATION: None,
|
||||
UKCPhase.SOCIAL_ENGINEERING: None,
|
||||
# Observable but not ATT&CK-mappable
|
||||
UKCPhase.EXPLOITATION: None,
|
||||
UKCPhase.PIVOTING: None,
|
||||
UKCPhase.OBJECTIVES: None,
|
||||
}
|
||||
|
||||
# Observable phases that DO round-trip cleanly. Excludes phases listed
|
||||
# in :data:`_LOSSY_INVERSE_REFERENCE` even when those phases are also
|
||||
# in :data:`OBSERVABLE_PHASES` — a round-trip is impossible for
|
||||
# UKC-only concepts that ATT&CK lacks a tactic for.
|
||||
_BIJECTIVE_OBSERVABLE_PHASES: frozenset[UKCPhase] = (
|
||||
OBSERVABLE_PHASES - frozenset(_LOSSY_INVERSE_REFERENCE.keys())
|
||||
)
|
||||
|
||||
|
||||
_TACTIC_RE = re.compile(r"^TA\d{4}$")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tactic", sorted(ATTACK_TACTIC_TO_UKC.keys()))
|
||||
def test_every_tactic_is_ta_prefixed(tactic: str) -> None:
|
||||
assert _TACTIC_RE.fullmatch(tactic), (
|
||||
f"tactic key {tactic!r} is not a TA-prefixed 4-digit code"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"phase",
|
||||
sorted(set(ATTACK_TACTIC_TO_UKC.values()), key=lambda p: p.value if isinstance(p, UKCPhase) else ""),
|
||||
)
|
||||
def test_every_value_is_ukc_phase(phase: UKCPhase) -> None:
|
||||
assert isinstance(phase, UKCPhase)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"phase", sorted(_BIJECTIVE_OBSERVABLE_PHASES, key=lambda p: p.value if isinstance(p, UKCPhase) else ""),
|
||||
)
|
||||
def test_observable_phase_round_trips(phase: UKCPhase) -> None:
|
||||
"""For phases a honeypot can observe, the inverse is a true bijection.
|
||||
|
||||
Concretely: ``ukc_phase_to_tactic(p)`` returns a tactic, and that
|
||||
tactic maps back to the same phase through ``tactic_to_ukc_phase``.
|
||||
"""
|
||||
tactic = ukc_phase_to_tactic(phase)
|
||||
assert tactic is not None, f"observable phase {phase} has no inverse tactic"
|
||||
assert tactic_to_ukc_phase(tactic) == phase
|
||||
|
||||
|
||||
_LOSSY_PARAMS: list[tuple[UKCPhase, str | None]] = sorted(
|
||||
_LOSSY_INVERSE_REFERENCE.items(), key=lambda kv: kv[0].value,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase,expected_tactic", _LOSSY_PARAMS)
|
||||
def test_pre_target_phases_pinned_inverse(
|
||||
phase: UKCPhase, expected_tactic: str | None,
|
||||
) -> None:
|
||||
"""Pre-target phases have an allowed-lossy inverse — pin current output.
|
||||
|
||||
These phases are excluded from :data:`OBSERVABLE_PHASES` and tag
|
||||
emission rules never assign them. The inverse is whatever
|
||||
``_UKC_TO_TACTIC`` happens to record (or ``None`` if the phase is
|
||||
not in the forward map at all). Freezing the literal value here
|
||||
means an accidental "let's make the inverse total" refactor trips
|
||||
the test, which is the right answer per the design doc.
|
||||
"""
|
||||
assert ukc_phase_to_tactic(phase) == expected_tactic
|
||||
|
||||
|
||||
def test_unknown_tactic_returns_none() -> None:
|
||||
assert tactic_to_ukc_phase("TA9999") is None
|
||||
|
||||
|
||||
def test_observable_phases_partition_matches_lossy_set() -> None:
|
||||
"""Sanity: every phase that appears as a forward value is either
|
||||
observable or in the pre-target lossy reference table. Nothing
|
||||
else. Catches a future contributor adding a new pre-target phase
|
||||
without updating this test's reference table.
|
||||
"""
|
||||
forward_phases = set(ATTACK_TACTIC_TO_UKC.values())
|
||||
accounted = OBSERVABLE_PHASES | set(_LOSSY_INVERSE_REFERENCE.keys())
|
||||
assert forward_phases <= accounted, (
|
||||
f"phase(s) {forward_phases - accounted} appear in the forward map "
|
||||
"but are neither observable nor listed in _LOSSY_INVERSE_REFERENCE"
|
||||
)
|
||||
Reference in New Issue
Block a user