feat(ttp): E.3.8 R0049-R0053 canary fingerprint cohort

5 YAMLs for the canary-fingerprint cohort per Appendix B / A.9:
navigator.webdriver flag, automation canvas/audio/WebGL hash match,
WebRTC IP leak, TZ/lang vs geo mismatch, platform inconsistency.
CanaryFingerprintLifter (E.3.11) consumes by rule_id.

test_canary_rules.py: YAML-present + inert-in-v0 + xfail(strict)
gated on E.3.11.
This commit is contained in:
2026-05-01 09:21:01 -04:00
parent 1ad15470a1
commit dc1867315d
6 changed files with 156 additions and 0 deletions

18
rules/ttp/R0049.yaml Normal file
View File

@@ -0,0 +1,18 @@
rule_id: R0049
rule_version: 1
name: navigator_webdriver_flag
description: |
navigator.webdriver === true in the canary fingerprint payload —
Selenium / Puppeteer / Playwright telltale.
applies_to:
- canary_fingerprint
match:
kind: lifter:canary_webdriver
signal: navigator.webdriver_true
emits:
- tactic: TA0002
technique_id: T1059
confidence: 0.85
evidence_fields:
- navigator_webdriver
- ua_signature

28
rules/ttp/R0050.yaml Normal file
View File

@@ -0,0 +1,28 @@
rule_id: R0050
rule_version: 1
name: automation_canvas_audio_hash
description: |
Canvas / audio / WebGL fingerprint hash matches a known automation
tooling cohort (Puppeteer / Playwright / Selenium / curl-impersonate).
applies_to:
- canary_fingerprint
match:
kind: lifter:canary_automation_hash
catalogues:
- puppeteer
- playwright
- selenium
- curl_impersonate
emits:
- tactic: TA0002
technique_id: T1059
confidence: 0.85
- tactic: TA0042
technique_id: T1588
sub_technique_id: T1588.002
confidence: 0.85
evidence_fields:
- canvas_hash
- audio_hash
- webgl_hash
- matched_tool

21
rules/ttp/R0051.yaml Normal file
View File

@@ -0,0 +1,21 @@
rule_id: R0051
rule_version: 1
name: webrtc_ip_leak
description: |
WebRTC-discovered private IP doesn't match the source-IP geo —
classic VPN/proxy obfuscation tell. CanaryFingerprintLifter
composes the leak with the IP geo lookup.
applies_to:
- canary_fingerprint
match:
kind: lifter:canary_webrtc_leak
require_geo_mismatch: true
emits:
- tactic: TA0011
technique_id: T1090
confidence: 0.85
evidence_fields:
- webrtc_local_ip
- source_ip
- source_country
- leak_country

18
rules/ttp/R0052.yaml Normal file
View File

@@ -0,0 +1,18 @@
rule_id: R0052
rule_version: 1
name: tz_lang_geo_mismatch
description: |
Browser timezone or accept-language doesn't match source-IP geo —
another proxy/VPN tell.
applies_to:
- canary_fingerprint
match:
kind: lifter:canary_tz_lang_mismatch
emits:
- tactic: TA0011
technique_id: T1090
confidence: 0.7
evidence_fields:
- browser_timezone
- browser_language
- source_country

19
rules/ttp/R0053.yaml Normal file
View File

@@ -0,0 +1,19 @@
rule_id: R0053
rule_version: 1
name: platform_inconsistency
description: |
navigator.platform / userAgent / WebGL renderer disagree —
classic hand-built crawler with mismatched stealth shimming.
applies_to:
- canary_fingerprint
match:
kind: lifter:canary_platform_inconsistency
emits:
- tactic: TA0005
technique_id: T1036
confidence: 0.8
evidence_fields:
- navigator_platform
- user_agent
- webgl_renderer
- mismatch_pairs

View File

@@ -0,0 +1,52 @@
"""R0049-R0053 — canary fingerprint cohort.
CanaryFingerprintLifter (E.3.11) parses the fingerprint payload
(navigator/webdriver flag, canvas/audio/WebGL hashes, WebRTC IPs,
TZ/language/geo composite) and emits per Appendix A.9. The v0
:class:`RuleEngine` cannot navigate a structured fingerprint blob —
these rules are inert under the regex matcher.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from decnet.ttp.impl.rule_engine import RuleEngine
from decnet.ttp.store.base import RuleState
from decnet.ttp.store.impl.filesystem import _parse_and_compile
from tests.ttp.rule_precision.conftest import CorpusRow, make_event
CohortLoader = Callable[[str], list[CorpusRow]]
_RULE_IDS = [f"R{n:04d}" for n in range(49, 54)]
@pytest.mark.parametrize("rule_id", _RULE_IDS)
def test_rule_yaml_present(rule_id: str) -> None:
path = Path("rules/ttp") / f"{rule_id}.yaml"
assert path.exists(), f"missing YAML: {path}"
compiled = _parse_and_compile(path, RuleState())
assert compiled.rule_id == rule_id
@pytest.mark.parametrize("rule_id", _RULE_IDS)
async def test_lifter_bound_inert_in_v0(
rule_id: str,
precision_engine: RuleEngine,
corpus_loader: CohortLoader,
) -> None:
fired: set[str] = set()
for row in corpus_loader("canary"):
tags = await precision_engine.evaluate(make_event(row))
fired.update(tag.rule_id for tag in tags)
assert rule_id not in fired
@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)")