Skip to content

test(stealth): measure legacy and stealth visibility against third-party fingerprinting and bot-detection oracles - #1225

Open
vringar wants to merge 2 commits into
feat/stealth-js-instrument-v2from
test/visibility-oracles
Open

vringar wants to merge 2 commits into
feat/stealth-js-instrument-v2from
test/visibility-oracles

Conversation

@vringar

@vringar vringar commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1154. Split out of it so that PR stays focused on the instrument.

Two experiments that measure how visible the legacy and stealth JS instruments are to off-the-shelf, third-party oracles, rather than to probes we wrote ourselves. Both were built to settle a claim the #1154 description used to make with no artifact behind it. Both refuted it.

What was claimed, and what is actually true

The old #1154 body asserted a differential FingerprintJS oracle showing stealth byte-identical to vanilla while legacy differed. There was no such harness anywhere in git history. Measured:

oracle legacy vs baseline stealth vs baseline
FingerprintJS 5.2.0 (42 components) identical — claim refuted identical
BotD 2.0.0 + fpscanner 1.0.8 (185 signals) identical — no signal differs 10 signals differ, all navigator.webdriver, in stealth's favour

FingerprintJS reads values, and the legacy wrappers are value-transparent, so it cannot see legacy at all. Neither can the two OSS bot detectors, for a concrete and documented reason: BotD's only native-toString read is Function.prototype.bind and it never compares the string; its detectErrorTrace provokes an error inside BotD's own code; its bot-globals roster has no getInstrumentJS; and fpscanner's one prototype-shape check reads Navigator.prototype, which legacy never touches because it patches the navigator instance.

This is a coverage property of these oracles, not innocence in the instrument. Measured in the same page load, via a separate namespace never counted as an oracle signal, all four instrumentation tells from Krumnow, Jonker & Karsch (arXiv:2205.08890) fire on legacy and none fire on stealth: window.getInstrumentJS present, [native code] gone from four wrapped natives, a getInstrumentJS/instrumentFunction/< frame atop a provoked stack, and prototype flattening taking HTMLCanvasElement.prototype from 10 own properties to 351.

What this does not claim

Stealth does not make OpenWPM undetectable as automation, and a test asserts that so these modules cannot be repurposed as evidence for the opposite. fpscanner's fast bot detection returns true in all four arms, and the paper's Table 4 display-less signature is present everywhere. Those are automation tells, inherent to running under Selenium in a display-less environment — not something the JS instrument causes or the stealth instrument can fix.

Method

Four arms — plain Firefox (bare Selenium, no extension), OpenWPM baseline, legacy with the project's own collection_fingerprinting preset, and stealth. The plain-Firefox arm exists to attribute signals rather than assume: it differs from OpenWPM baseline on zero signals, which is what licenses calling every firing signal an automation tell.

Each arm runs twice first as a validity gate. Unstable signals are excluded and reported with their observed values — two canvas probes were excluded after a control run outside OpenWPM entirely showed Firefox re-seeds canvas noise per profile, with all anti-fingerprinting prefs off. Consequence, recorded rather than hidden: canvas image data is not measurable in this environment, so if legacy leaks into rendered canvas bytes, these experiments could not see it.

Every arm-set run is listed in the reports, including the discarded ones and why — including a probe defect that would have reported a vacuous canvas comparison, since FingerprintJS v5 deliberately returns "skipped" for canvas on Firefox 120+.

Contents

  • test/test_fingerprintjs_stamp.py (6 tests), test/test_bot_detection.py (9 tests) — 15 passing, ~5 min
  • test/test_pages/vendor/ — FingerprintJS 5.2.0, BotD 2.0.0, fpscanner 1.0.8, all MIT, vendored byte-identical to npm with SHA-256 asserted in-test so the hashes reproduce upstream. Telemetry disabled where the library has any.
  • docs/developers/FingerprintJS-Visibility-Experiment.md, docs/developers/Bot-Detector-Visibility-Experiment.md — in-repo, because tests citing a gitignored report is a broken citation.

Review the reports first; the tests pin what the reports measured.

Copilot AI lite review requested due to automatic review settings August 25, 2026 16:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds two hermetic, reproducible experiments to measure how detectable OpenWPM’s legacy vs stealth JavaScript instrumentation is to off-the-shelf third-party fingerprinting / bot-detection libraries (FingerprintJS, BotD, fpscanner), with pinned vendored fixtures and in-repo measurement reports backing the updated claims in the stacked stealth work.

Changes:

  • Vendor and SHA-pin FingerprintJS 5.2.0, BotD 2.0.0, and fpscanner 1.0.8 bundles for hermetic browser tests (with telemetry/monitoring disabled where applicable).
  • Add two probe pages plus two pytest harnesses implementing validity gates, activity controls, and per-signal comparisons across baseline/legacy/stealth (and a plain-Firefox control arm for bot detection).
  • Add developer-facing experiment reports documenting method, results, and caveats.

Reviewed changes

Copilot reviewed 6 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/test_pages/vendor/README.md Documents provenance, pinning, and hermeticity constraints for vendored oracle bundles.
test/test_pages/vendor/fingerprintjs-5.2.0.umd.min.js Vendored FingerprintJS oracle bundle (pinned artifact).
test/test_pages/vendor/fingerprintjs-5.2.0.LICENSE.txt FingerprintJS MIT license text.
test/test_pages/vendor/botd-2.0.0.esm.js Vendored BotD oracle bundle (pinned artifact).
test/test_pages/vendor/botd-2.0.0.LICENSE.txt BotD MIT license text.
test/test_pages/vendor/fpscanner-1.0.8.es.js Vendored fpscanner oracle bundle (pinned artifact).
test/test_pages/vendor/fpscanner-1.0.8.LICENSE.txt fpscanner MIT license text.
test/test_pages/fingerprintjs_stamp.html Probe page that runs FingerprintJS and publishes per-component comparison keys.
test/test_pages/bot_detection.html Probe page that runs BotD + fpscanner and publishes per-signal outputs plus paper probes.
test/test_fingerprintjs_stamp.py 3-arm FingerprintJS harness with bundle integrity check, validity gate, and activity control.
test/test_bot_detection.py 4-arm bot-detection harness (incl. plain Selenium control) with bundle integrity check, validity gate, and classifications.
docs/developers/FingerprintJS-Visibility-Experiment.md Recorded FingerprintJS experiment write-up and results used to justify pinned expectations.
docs/developers/Bot-Detector-Visibility-Experiment.md Recorded BotD/fpscanner experiment write-up and results used to justify pinned expectations.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +50 to +54
try {
return JSON.stringify(value);
} catch (e) {
return "__unserializable__:" + e.message;
}
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 4.51306% with 402 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.87%. Comparing base (823e81e) to head (a368115).

Files with missing lines Patch % Lines
test/test_bot_detection.py 3.89% 247 Missing ⚠️
test/test_fingerprintjs_stamp.py 5.48% 155 Missing ⚠️
Additional details and impacted files
@@                        Coverage Diff                        @@
##           feat/stealth-js-instrument-v2    #1225      +/-   ##
=================================================================
- Coverage                          53.71%   49.87%   -3.85%     
=================================================================
  Files                                 42       44       +2     
  Lines                               4969     5390     +421     
=================================================================
+ Hits                                2669     2688      +19     
- Misses                              2300     2702     +402     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@vringar
vringar force-pushed the test/visibility-oracles branch from 92cc7ce to 2c3cb1a Compare September 6, 2026 15:46
@vringar
vringar force-pushed the feat/stealth-js-instrument-v2 branch from 1b1acbc to aa07987 Compare September 6, 2026 16:01
@vringar
vringar force-pushed the test/visibility-oracles branch from 2c3cb1a to e9955c4 Compare September 6, 2026 16:01
@vringar
vringar force-pushed the feat/stealth-js-instrument-v2 branch from aa07987 to 134f292 Compare September 6, 2026 16:05
@vringar
vringar force-pushed the test/visibility-oracles branch from e9955c4 to 4a59c99 Compare September 6, 2026 16:05
@vringar
vringar force-pushed the feat/stealth-js-instrument-v2 branch from 134f292 to fdc7bd9 Compare September 6, 2026 16:08
@vringar
vringar force-pushed the test/visibility-oracles branch from 4a59c99 to 89c7014 Compare September 6, 2026 16:08
@vringar
vringar force-pushed the feat/stealth-js-instrument-v2 branch from fdc7bd9 to 7f92ee2 Compare September 6, 2026 16:13
@vringar
vringar force-pushed the test/visibility-oracles branch from 89c7014 to 2512acc Compare September 6, 2026 16:13
@vringar
vringar force-pushed the feat/stealth-js-instrument-v2 branch from 7f92ee2 to 07306a7 Compare September 6, 2026 19:34
@vringar
vringar force-pushed the test/visibility-oracles branch from 2512acc to ad8b33e Compare September 6, 2026 19:34
@vringar
vringar force-pushed the feat/stealth-js-instrument-v2 branch from 07306a7 to d98499e Compare September 6, 2026 20:23
@vringar
vringar force-pushed the test/visibility-oracles branch from ad8b33e to cbfc48c Compare September 6, 2026 20:23
…h and legacy

The PR body and the reviewer's guide both asserted that the FingerprintJS stamp
is identical under stealth and differs under the legacy instrument, with no
artifact anywhere in the repository to back it. This is that experiment.

Result: the claim is half true, and the persuasive half is false.

- stealth == baseline: CONFIRMED. Identical on all 42 built-in components and
  the 4 reproducible extra probes, visitorId included.
- legacy != baseline: REFUTED. Legacy is also byte-identical to baseline on
  every reproducible probe. All arms produce the same visitorId. There is no
  component on which legacy differs from baseline.

The activity control rules out a dead experiment: while FingerprintJS computes
its stamp, baseline records 0 rows, stealth 124 rows over 46 symbols and legacy
92 rows over 39 symbols, including HTMLCanvasElement.toDataURL,
CanvasRenderingContext2D.fillText, OscillatorNode.start and window.screen.*.
Both instruments were live and watching; neither perturbed the result.

Legacy's invisibility here is a property of the oracle, not of the instrument.
FingerprintJS OSS reads values, and the legacy wrappers are value-transparent.
The bundle contains one isFunctionNative call site, guarding window.print in a
Safari-detection helper Gecko never reaches, and performs no integrity check on
any instrumented surface. The vectors that do expose legacy -- toString on
wrapped natives, prototype shape, arity and name drift -- are bot-detection
vectors, and FingerprintJS OSS is not a bot detector. TestStealthDetectability
remains the correct evidence for that half of the argument.

Two probes are excluded as environment noise: both halves of the forced-render
canvas. A control run outside OpenWPM entirely -- bare Selenium, stock Firefox
154, no extension -- shows Firefox re-seeds canvas noise per profile: three
loads in one profile are byte-identical, two fresh profiles differ, with
fingerprintingProtection, resistFingerprinting and randomization all false and a
-CanvasRandomization override in place. OpenWPM gives every launch a fresh
profile and no pref pins it, so canvas image data is not measurable here. If
legacy leaks into rendered canvas bytes, this experiment could not see it; that
limitation is recorded rather than papered over.

FingerprintJS v5 stabilizes its canvas source by returning "skipped" on Firefox
120+, which would have made the comparison vacuous on the most heavily
instrumented surface in the preset; the forced-render probe uses the library's
own getUnstableCanvasFingerprint export to get past that, and is what surfaced
the per-profile randomization.

The bundle is vendored byte-identical to npm so its SHA-256 reproduces upstream,
pinned and asserted in-test, and loaded with monitoring disabled to suppress the
1-in-1000 telemetry request the library otherwise makes.
…ot detectors

The reviewer's guide wanted to claim that off-the-shelf bot detection triggers
on the legacy instrument but cannot distinguish stealth from an uninstrumented
browser. This measures that against two vendored MIT oracles, BotD 2.0.0 and
fpscanner 1.0.8, over four arms: plain Firefox, OpenWPM baseline, legacy and
stealth.

The first half of that claim is false, and the second half is false in the
other direction.

Legacy vs baseline: not one of the 185 oracle signals differs. Both oracles
read the legacy instrument identically to an uninstrumented OpenWPM browser,
while legacy demonstrably wrapped canvas, navigator and screen during the same
page load (103 rows over 32 symbols).

That is a coverage property of these oracles, not innocence in the instrument.
BotD's only native-toString read is Function.prototype.bind, and its
detectFunctionBind never compares the string -- it fires only when bind is
missing. Its detectErrorTrace provokes an error inside BotD's own code and
regex-matches PhantomJS. Its distinctive-properties roster does not include
getInstrumentJS. fpscanner's single prototype-shape check reads
Navigator.prototype, which legacy never touches: legacy patches the navigator
instance, so that check reads the same value in every arm.

Legacy is still detectable, and the same page load proves it. A separate
paper.* namespace of 35 probes -- never counted as an oracle signal -- fires
all four instrumentation tells catalogued in Krumnow, Jonker & Karsch
(arXiv:2205.08890): window.getInstrumentJS is present, [native code] is gone
from four wrapped natives, a getInstrumentJS/instrumentFunction/< frame sits at
the top of a provoked stack trace, and prototype flattening takes
HTMLCanvasElement.prototype from 10 own properties to 351. Stealth fires none
of them.

Stealth vs baseline: 10 signals differ, all of them navigator.webdriver, and
they move in stealth's favour -- BotD's overall verdict flips from bot to
not-bot. Stealth is measurably less bot-like than an uninstrumented OpenWPM
browser, not indistinguishable from it. The iframe reading flips too, so the
frame protection holds in a freshly created iframe.

Stealth does not make OpenWPM undetectable as automation, and a test asserts
that so this module cannot be repurposed as evidence for the opposite:
fpscanner's fast bot detection returns true in all four arms via the UTC
timezone signal, and the display-less signature from the paper's Table 4 --
availTop and availLeft at zero, WebGL absent -- is present everywhere.

The plain-Firefox arm exists to attribute signals rather than assume them. It
differs from OpenWPM baseline on zero signals, which is what licenses calling
every firing signal an automation tell rather than an OpenWPM tell, and shows
the extension's mere presence adds nothing either oracle can see.

Both bundles are vendored byte-identical to npm so their hashes reproduce
upstream, pinned and asserted in-test. Both ship ESM only, so the page loads
them as modules rather than repacking them. BotD is loaded with monitoring
disabled; fpscanner has no network call site.
@vringar
vringar force-pushed the feat/stealth-js-instrument-v2 branch from d98499e to 823e81e Compare September 6, 2026 20:59
@vringar
vringar force-pushed the test/visibility-oracles branch from cbfc48c to a368115 Compare September 6, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants