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