feat(ttp): E.3.11 CanaryFingerprintLifter (R0049-R0053)

Browser-payload derivations per Appendix A.9: navigator.webdriver flag,
canvas/audio/WebGL automation hash matches (Puppeteer/Playwright/
Selenium/curl-impersonate), WebRTC IP leak, TZ/language vs source-IP
geo mismatch, navigator.platform vs userAgent vs WebGL renderer
inconsistency.

Evidence shape pinned to CanaryFingerprintEvidence (metric +
matched_signature) — raw fingerprint blobs (canvas hashes, full UAs,
navigator.platform values) explicitly NOT carried into TTPTag.evidence
per TTP_TAGGING.md §'Hard parts §7' (enrichment vs tag boundary). The
identity-merge guard rail is preserved: composite fp.id matches across
IPs are NOT a TTP, so no rule fires on the bare hash.

Tests: tests/ttp/test_canary_fingerprint_lifter.py per-rule positive +
negative + evidence-shape guard + state modulation.
tests/ttp/rule_precision/test_canary_rules.py xfail flipped to real
precision (R0049/R0050/R0051/R0053 H-band ≥95%; R0052 M-band ≥80%).
This commit is contained in:
2026-05-01 20:25:57 -04:00
parent 7865e71aa9
commit f211d394e6
7 changed files with 340 additions and 15 deletions

View File

@@ -1,2 +1,7 @@
{"source_kind": "canary_fingerprint", "payload": {"ua_signature": "HeadlessChrome/119", "navigator_webdriver": true}, "expected_rule_ids": ["R0049"], "label": "webdriver_flag"}
{"source_kind": "canary_fingerprint", "payload": {"canvas_audio_hash_match": "puppeteer"}, "expected_rule_ids": ["R0050"], "label": "puppeteer_canvas"}
{"source_kind": "canary_fingerprint", "payload": {"canvas_audio_hash_match": "selenium"}, "expected_rule_ids": ["R0050"], "label": "selenium_canvas"}
{"source_kind": "canary_fingerprint", "payload": {"webrtc_geo_mismatch": true}, "expected_rule_ids": ["R0051"], "label": "webrtc_leak"}
{"source_kind": "canary_fingerprint", "payload": {"tz_mismatch_zones": 7}, "expected_rule_ids": ["R0052"], "label": "tz_mismatch"}
{"source_kind": "canary_fingerprint", "payload": {"platform_ua_inconsistent": true}, "expected_rule_ids": ["R0053"], "label": "platform_inconsistent"}
{"source_kind": "canary_fingerprint", "payload": {"ua_signature": "Mozilla/5.0", "navigator_webdriver": false}, "expected_rule_ids": [], "label": "negative_browser"}

View File

@@ -44,9 +44,45 @@ async def test_lifter_bound_inert_in_v0(
assert rule_id not in fired
def _build_lifter() -> "CanaryFingerprintLifter":
from decnet.ttp.impl.canary_fingerprint_lifter import (
CanaryFingerprintLifter,
)
from tests.ttp._stub_store import StubRuleStore
rules = [
_parse_and_compile(Path("rules/ttp") / f"{rid}.yaml", RuleState())
for rid in _RULE_IDS
]
lifter = CanaryFingerprintLifter(StubRuleStore(compiled=rules))
for rule in rules:
lifter._index.install(rule)
return lifter
@pytest.mark.parametrize("rule_id", _RULE_IDS)
@pytest.mark.xfail(
strict=True, reason="impl phase E.3.11 (CanaryFingerprintLifter)",
)
def test_canary_rule_precision(rule_id: str) -> None:
pytest.fail(f"{rule_id}: CanaryFingerprintLifter not yet shipped (E.3.11)")
def test_canary_rule_precision(
rule_id: str,
corpus_loader: CohortLoader,
) -> None:
"""E.3.11 — drive CanaryFingerprintLifter over the labelled corpus
and assert per-rule precision (H-band rules → ≥95%, M-band → ≥80%).
R0052 is M-band (0.7 confidence); the rest are H-band.
"""
import asyncio
from tests.ttp.rule_precision.conftest import precision_for
rows = corpus_loader("canary")
if not rows:
pytest.skip("no canary corpus available")
lifter = _build_lifter()
fired: dict[str, list[str]] = {}
for row in rows:
tags = asyncio.run(lifter.tag(make_event(row)))
fired[row.label] = [tag.rule_id for tag in tags]
precision, _tp, _fp = precision_for(rule_id, rows, fired)
threshold = 0.80 if rule_id == "R0052" else 0.95
assert precision >= threshold, (
f"{rule_id} precision {precision:.2f} < {threshold} on canary corpus"
)

View File

@@ -0,0 +1,146 @@
"""Per-rule unit tests for :class:`CanaryFingerprintLifter` (E.3.11).
Pins the predicates for R0049R0053 and the
:class:`~decnet.web.db.models.ttp.CanaryFingerprintEvidence` shape
contract — raw fingerprint blobs MUST NOT leak into emitted tags.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
import pytest
from decnet.ttp.base import TaggerEvent
from decnet.ttp.impl.canary_fingerprint_lifter import CanaryFingerprintLifter
from decnet.ttp.impl.rule_engine import CompiledRule
from decnet.ttp.store.base import RuleState
from decnet.ttp.store.impl.filesystem import _parse_and_compile
from tests.ttp._stub_store import StubRuleStore
_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" / "ttp"
def _compile(rule_id: str, state: RuleState | None = None) -> CompiledRule:
return _parse_and_compile(
_RULES_DIR / f"{rule_id}.yaml", state or RuleState(),
)
def _ev(payload: dict[str, Any]) -> TaggerEvent:
return TaggerEvent(
source_kind="canary_fingerprint",
source_id="src-canary",
attacker_uuid="att1",
identity_uuid=None,
session_id=None,
decky_id=None,
payload=payload,
)
def _make_lifter(rule_ids: list[str]) -> CanaryFingerprintLifter:
rules = [_compile(rid) for rid in rule_ids]
lifter = CanaryFingerprintLifter(StubRuleStore(compiled=rules))
for rule in rules:
lifter._index.install(rule)
return lifter
@pytest.mark.parametrize(
"rule_id,payload,techniques",
[
("R0049", {"navigator_webdriver": True}, {"T1059"}),
(
"R0050",
{"canvas_audio_hash_match": "puppeteer"},
{"T1059", "T1588"},
),
("R0051", {"webrtc_geo_mismatch": True}, {"T1090"}),
("R0052", {"tz_mismatch_zones": 5}, {"T1090"}),
("R0052", {"lang_country_mismatch": True}, {"T1090"}),
("R0053", {"platform_ua_inconsistent": True}, {"T1036"}),
],
)
def test_rule_fires_on_positive(
rule_id: str,
payload: dict[str, Any],
techniques: set[str],
) -> None:
lifter = _make_lifter([rule_id])
out = asyncio.run(lifter.tag(_ev(payload)))
assert out, f"{rule_id} did not fire on positive payload"
fired = {tag.technique_id for tag in out}
assert fired == techniques
def test_evidence_shape_only_metric_and_signature() -> None:
"""PII / blob-leak guard: emitted evidence keys ⊆ {metric, matched_signature}.
Raw canvas hashes, navigator props, full UA strings must NEVER make
it into TTPTag.evidence — they live on Attacker.fingerprints
(enrichment), not on the tag (TTP_TAGGING.md §"Hard parts §7").
"""
lifter = _make_lifter(["R0049"])
out = asyncio.run(lifter.tag(_ev({
"navigator_webdriver": True,
"canvas_hash": "should-not-appear-in-evidence",
"user_agent": "should-not-appear-in-evidence",
})))
assert out
for tag in out:
assert set(tag.evidence) <= {"metric", "matched_signature"}, (
f"unexpected evidence keys: {tag.evidence!r}"
)
def test_webdriver_false_no_fire() -> None:
lifter = _make_lifter(["R0049"])
out = asyncio.run(lifter.tag(_ev({"navigator_webdriver": False})))
assert out == []
def test_automation_hash_unknown_tool_no_fire() -> None:
lifter = _make_lifter(["R0050"])
out = asyncio.run(lifter.tag(_ev({
"canvas_audio_hash_match": "some_random_browser",
})))
assert out == []
def test_tz_below_threshold_no_fire() -> None:
lifter = _make_lifter(["R0052"])
out = asyncio.run(lifter.tag(_ev({"tz_mismatch_zones": 1})))
assert out == []
def test_disabled_state_no_emit() -> None:
rule = _compile("R0049", RuleState(state="disabled"))
lifter = CanaryFingerprintLifter(StubRuleStore())
lifter._index.install(rule)
out = asyncio.run(lifter.tag(_ev({"navigator_webdriver": True})))
assert out == []
def test_empty_payload_no_errors(caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
lifter = _make_lifter(["R0049", "R0050", "R0051", "R0052", "R0053"])
out = asyncio.run(lifter.tag(_ev({})))
assert out == []
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
def test_owns_only_canary_prefix() -> None:
behavioral = _compile("R0031")
canary = _compile("R0049")
lifter = CanaryFingerprintLifter(
StubRuleStore(compiled=[behavioral, canary]),
)
asyncio.run(lifter._index.hydrate_from(
lifter._store, predicate=lifter._owns, # type: ignore[arg-type]
))
assert lifter._index.get("R0049") is not None
assert lifter._index.get("R0031") is None

View File

@@ -42,7 +42,7 @@ def _make_lifter(cls: type[TolerantTagger]) -> TolerantTagger:
Implemented lifters (E.3.9E.3.12) take a :class:`RuleStore`; the
still-empty IdentityLifter / CredentialLifter (E.3.13) take no args.
"""
if cls in {BehavioralLifter, IntelLifter}:
if cls in {BehavioralLifter, IntelLifter, CanaryFingerprintLifter}:
return cls(StubRuleStore()) # type: ignore[call-arg]
return cls()

View File

@@ -24,7 +24,7 @@ from tests.ttp._stub_store import StubRuleStore
def _instantiate(cls: type[TolerantTagger]) -> TolerantTagger:
if cls in {BehavioralLifter, IntelLifter}:
if cls in {BehavioralLifter, IntelLifter, CanaryFingerprintLifter}:
return cls(StubRuleStore()) # type: ignore[call-arg]
return cls()