invisible_playwright/tests/test_fingerprint_surface.py
feder-cr a950537f0a ci: macOS gate tolerates the runner's missing WebGL; add verify-cloak
The firefox-10 build gated green on all 5 targets but both macOS gate legs
failed. The cloak/webgl guards hard-required a live WebGL context, and macOS
GitHub runners expose none in the CI session (no software-GL fallback, unlike
Linux llvmpipe and Windows WARP). The cloak renders fine there anyway, which the
non-blank screenshot proves, so on the mac legs the WebGL-present check now
self-skips and the cocoa cloak is validated via the screenshot plus CGWindowAlpha.
The gamma masking guard skips on mac too (platform-agnostic C++, covered on Linux
and Windows).

verify-cloak.yml re-runs these guards against a prior build run's artifacts with
no rebuild, so a test-only fix like this is validated against the real binaries
in minutes instead of a 3h rebuild.
2026-06-11 17:18:02 +02:00

311 lines
12 KiB
Python

"""Fingerprint surface tests — replicate the checks performed by the canonical
anti-bot detection libraries against an OFFLINE browser session.
Each test asserts the SAME thing the upstream detector would flag. A pass
here means our patched build appears human to that detector; a fail
means a real stealth hole that anti-bot kits would exploit in production.
Detector libraries studied (all FOSS, MIT-licensed):
- github.com/fingerprintjs/BotD — 19 detectors, the most
widely deployed client-side
bot detector
- github.com/abrahamjuliot/creepjs — headless / stealth / lies
modules
- github.com/fingerprintjs/fingerprintjs — canvas / audio / color /
touch consistency
- github.com/antoinevastel/fpscanner — UA / platform / oscpu
cross-checks
- bot.sannysoft.com — classic Puppeteer harness
Everything runs against `about:blank` with NO network and NO proxy. The
suite is intended to be part of the release-gate: pre-push hook runs
`pytest -m e2e` and these tests must be green on every release.
Run only this file:
pytest tests/test_fingerprint_surface.py -m e2e -v
"""
from __future__ import annotations
import re
import sys
import pytest
from invisible_playwright import InvisiblePlaywright
# ────────────────────────────────────────────────────────────────────
# Inline PIN — a coherent mid-range Windows desktop. Not user-config:
# these specific values are what the surface tests assert against.
# Keep PIN small (only fields that JS exposes) and stable across runs.
# ────────────────────────────────────────────────────────────────────
PIN = {
"screen.width": 1920,
"screen.height": 1080,
"screen.avail_width": 1920,
"screen.avail_height": 1040,
"screen.dpr": 1.0,
"hardware.concurrency": 8,
"audio.sample_rate": 48000,
"audio.max_channel_count": 2,
}
@pytest.fixture(scope="module")
def page(firefox_binary):
"""One headless browser shared across the whole module.
~20s startup paid once, then every test runs in ~50ms."""
with InvisiblePlaywright(
seed=42,
pin=PIN,
binary_path=firefox_binary,
headless=True,
) as browser:
ctx = browser.new_context()
p = ctx.new_page()
p.goto("about:blank", timeout=30_000)
yield p
def _ev(page, expr):
return page.evaluate(expr)
# ===========================================================================
# sannysoft.com — classic Puppeteer detection harness
# ===========================================================================
@pytest.mark.e2e
def test_sannysoft_chrome_object_consistency(page):
"""Firefox UA + window.chrome present = bot-framework leak."""
if "Firefox" in _ev(page, "navigator.userAgent"):
assert not _ev(page, "typeof window.chrome !== 'undefined'")
@pytest.mark.e2e
def test_sannysoft_permissions_query_works(page):
"""navigator.permissions.query() must return a proper PermissionStatus."""
ok = _ev(page, """async () => {
if (!navigator.permissions || !navigator.permissions.query) return false;
try {
const r = await navigator.permissions.query({name: 'notifications'});
return r && typeof r.state === 'string';
} catch (e) { return false; }
}""")
assert ok
@pytest.mark.e2e
def test_sannysoft_iframe_chrome_not_leaked(page):
"""iframe.contentWindow.chrome must not leak on Firefox UA."""
if "Firefox" not in _ev(page, "navigator.userAgent"):
pytest.skip("Firefox-only invariant")
leaks = _ev(page, """() => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
const is = typeof iframe.contentWindow.chrome !== 'undefined';
document.body.removeChild(iframe);
return is;
}""")
assert not leaks
@pytest.mark.e2e
def test_sannysoft_iframe_languages_not_empty(page):
"""Iframe-scope navigator.languages must have ≥1 entry."""
n = _ev(page, """() => {
const f = document.createElement('iframe');
f.style.display = 'none';
document.body.appendChild(f);
const len = f.contentWindow.navigator.languages.length;
document.body.removeChild(f);
return len;
}""")
assert n > 0
# ===========================================================================
# FingerprintJS — fingerprint surface coherence
# ===========================================================================
@pytest.mark.e2e
def test_fpjs_canvas_2d_context_returns_valid(page):
ok = _ev(page, """() => {
const c = document.createElement('canvas');
c.width = 100; c.height = 100;
const ctx = c.getContext('2d');
if (!ctx) return false;
ctx.fillText('test', 10, 10);
const data = c.toDataURL();
return data.length > 100 && data.startsWith('data:image/png;base64');
}""")
assert ok
@pytest.mark.e2e
def test_fpjs_audio_context_works(page):
ok = _ev(page, """async () => {
try {
const ctx = new (window.OfflineAudioContext ||
window.webkitOfflineAudioContext)(1, 5000, 44100);
const osc = ctx.createOscillator();
osc.connect(ctx.destination);
osc.start(0);
const buf = await ctx.startRendering();
return buf && buf.length > 0;
} catch (e) { return false; }
}""")
assert ok
@pytest.mark.e2e
def test_fpjs_color_gamut_query_works(page):
"""matchMedia('(color-gamut: ...)') must match at least srgb."""
ok = _ev(page, """matchMedia('(color-gamut: srgb)').matches ||
matchMedia('(color-gamut: p3)').matches ||
matchMedia('(color-gamut: rec2020)').matches""")
assert ok
@pytest.mark.e2e
def test_fpjs_screen_color_depth_realistic(page):
"""Atypical color depths are headless-distinctive."""
cd = _ev(page, "screen.colorDepth")
assert cd in (24, 30, 32)
# ===========================================================================
# PIN-locked surfaces (the values declared in PIN above)
# ===========================================================================
@pytest.mark.e2e
def test_pin_screen_width_lands_in_screen_object(page):
assert _ev(page, "screen.width") == PIN["screen.width"]
@pytest.mark.e2e
def test_pin_screen_height_lands_in_screen_object(page):
assert _ev(page, "screen.height") == PIN["screen.height"]
@pytest.mark.e2e
def test_pin_hardware_concurrency_lands_in_navigator(page):
assert (_ev(page, "navigator.hardwareConcurrency")
== PIN["hardware.concurrency"])
@pytest.mark.e2e
def test_pin_audio_sample_rate_lands_in_AudioContext(page):
assert _ev(page,
"(new (window.AudioContext||window.webkitAudioContext)()).sampleRate"
) == PIN["audio.sample_rate"]
@pytest.mark.e2e
def test_pin_audio_max_channels_lands_in_destination(page):
assert _ev(page,
"(new (window.AudioContext||window.webkitAudioContext)())"
".destination.maxChannelCount"
) == PIN["audio.max_channel_count"]
# ===========================================================================
# fpscanner-style cross-checks
# ===========================================================================
@pytest.mark.e2e
def test_fpscanner_ua_vs_platform_consistent(page):
"""UA OS substring must agree with navigator.platform OS substring."""
ua = _ev(page, "navigator.userAgent")
platform = _ev(page, "navigator.platform")
if "Windows" in ua:
assert "Win" in platform, f"UA Win but platform={platform!r}"
elif "Mac" in ua:
assert "Mac" in platform
elif "Linux" in ua:
assert "Linux" in platform or "X11" in platform
@pytest.mark.e2e
def test_fpscanner_no_userAgentData_on_firefox(page):
"""navigator.userAgentData is Chromium-only. Presence on Firefox UA = bot."""
if "Firefox" in _ev(page, "navigator.userAgent"):
assert not _ev(page, "'userAgentData' in navigator")
# ===========================================================================
# WebGL masking-detector guard (pixelscan getFixedRedBox / webglHash)
#
# pixelscan flags "fingerprint masking" on the WebGL readPixels output. We
# reproduce ITS probe locally (the fingerprintjs gradient triangle) and check
# the structural signature it keys on: our stealth readPixels noise MUST be a
# coherent, monotonic gamma remap (smooth, ~0 spikes), NOT isolated +-1 flips
# (which read as unnatural high-frequency noise and were flagged as masking).
# This is the CI-safe local stand-in for pixelscan's server-side check; it
# guards the gamma fix from ever silently regressing to the +-1 algorithm.
# ===========================================================================
_WEBGL_MASKING_PROBE = """() => {
const c = document.createElement('canvas');
const gl = c.getContext('webgl') || c.getContext('experimental-webgl');
if (!gl) return { error: 'no-webgl' };
const vs = 'attribute vec2 a;uniform vec2 o;varying vec2 v;' +
'void main(){v=a+o;gl_Position=vec4(a,0,1);}';
const fs = 'precision mediump float;varying vec2 v;' +
'void main(){gl_FragColor=vec4(v,0,1);}';
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-0.2,-0.9,0, 0.4,-0.26,0, 0,0.732134444,0]), gl.STATIC_DRAW);
const p = gl.createProgram();
const s1 = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(s1, vs); gl.compileShader(s1);
const s2 = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(s2, fs); gl.compileShader(s2);
gl.attachShader(p, s1); gl.attachShader(p, s2); gl.linkProgram(p); gl.useProgram(p);
const loc = gl.getAttribLocation(p, 'a'); gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 3, gl.FLOAT, false, 0, 0);
const off = gl.getUniformLocation(p, 'o'); gl.uniform2f(off, 1, 1);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
const px = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
// count small local extrema (|delta|<=3 to both horizontal neighbours, same
// sign) — the +-1-noise signature; a smooth/monotonic render has ~none.
let spikes = 0;
for (let y = 0; y < h; y++) {
for (let x = 1; x < w - 1; x++) {
for (let ch = 0; ch < 3; ch++) {
const i = (y * w + x) * 4 + ch; const val = px[i];
if (val === 0) continue;
const dl = val - px[i - 4], dr = val - px[i + 4];
if (dl * dr > 0 && Math.abs(dl) <= 3 && Math.abs(dr) <= 3) spikes++;
}
}
}
return { spikes: spikes, dims: w + 'x' + h };
}"""
@pytest.mark.e2e
def test_webgl_readpixels_no_masking_signature(page):
"""Stealth WebGL readPixels noise must be a coherent gamma remap (smooth),
not isolated +-1 flips. +-1 noise on the smooth gradient triangle produced
~300+ 'spikes' and pixelscan flagged it as masking; the gamma remap leaves
the gradient smooth (~0 spikes). Regression guard for the gamma fix."""
res = _ev(page, _WEBGL_MASKING_PROBE)
if res.get("error") == "no-webgl" and sys.platform == "darwin":
pytest.skip(
"macOS CI runners expose no WebGL (no software-GL fallback); the gamma "
"readPixels remap is platform-agnostic C++ and is exercised by the Linux "
"(Xvfb/llvmpipe) and Windows (WARP) gates."
)
assert "error" not in res, f"WebGL probe failed: {res}"
# genuine / gamma -> ~0; the rejected +-1 algorithm produced ~320.
assert res["spikes"] < 30, (
f"WebGL readPixels shows {res['spikes']} high-frequency noise spikes "
f"(pixelscan-maskable); the stealth noise must be a smooth gamma remap."
)