Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new “stealth” JavaScript instrumentation mode for OpenWPM that aims to be less detectable by websites, adds a detection test page + test suite, and extends schema/types/build plumbing to support new instrumentation settings.
Changes:
- Add a stealth instrumentation entry point (
/stealth.js) using FirefoxexportFunction+Proxy, plus associated settings/error-handling modules. - Add end-to-end stealth/legacy detection tests (
test/test_stealth.py) and a detection harness page (stealth_detection.html), plus config validation for mutual exclusion. - Extend JS instrumentation settings schema and generate TypeScript typings from JSON Schema during extension build.
Reviewed changes
Copilot reviewed 16 out of 20 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
test/test_stealth.py |
Adds pytest coverage for stealth undetectability and data capture, plus a legacy-detectable control. |
test/test_pages/stealth_detection.html |
Adds a local detection-vectors page that reports results via DOM attributes for Selenium. |
test/test_dataclass_validations.py |
Adds validation test ensuring stealth_js_instrument and js_instrument are mutually exclusive. |
schemas/js_instrument_settings.schema.json |
Extends the schema with depth and overwrittenProperties/structured propertiesToInstrument. |
openwpm/config.py |
Adds stealth_js_instrument flag and enforces mutual exclusion with legacy JS instrumentation. |
Extension/webpack.config.js |
Adds a new webpack entry for the stealth bundle. |
Extension/src/types/js_instrument_settings.d.ts |
Generated typings for the updated instrumentation schema. |
Extension/src/types/javascript-instrument.d.ts |
Declares the privileged exportFunction global for TypeScript. |
Extension/src/stealth/stealth.ts |
New stealth content script entry that intercepts frames and wraps functions via Proxy. |
Extension/src/stealth/settings.ts |
Defines stealth instrumentation targets/settings (e.g., Navigator.webdriver override). |
Extension/src/stealth/instrument.ts |
Core stealth instrumentation logic: property discovery, wrapper injection, logging, and overwrite support. |
Extension/src/stealth/error.ts |
Error cloning/stack sanitization helpers for stealth behavior. |
Extension/src/feature.ts |
Wires stealth_js_instrument into extension startup/config dispatch. |
Extension/src/background/javascript-instrument.ts |
Adds legacy mode toggle and registers /stealth.js vs /content.js accordingly. |
Extension/package.json |
Adds json-schema-to-typescript and a schema→types generation build step. |
Extension/package-lock.json |
Locks new build-time dependencies for schema→types generation. |
Extension/eslint.config.mjs |
Ignores the new stealth bundle output. |
Extension/.gitignore |
Ignores the new stealth bundle output. |
.gitignore |
Adds ignore rules for Crosslink-managed local state. |
.dockerignore |
Ignores compiled JS under Extension/src/stealth/ for Docker builds. |
Files not reviewed (1)
- Extension/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1154 +/- ##
==========================================
- Coverage 62.31% 53.71% -8.61%
==========================================
Files 40 42 +2
Lines 3930 4969 +1039
==========================================
+ Hits 2449 2669 +220
- Misses 1481 2300 +819 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
3e82973 to
eecc4f6
Compare
eecc4f6 to
11be71e
Compare
11be71e to
e94f541
Compare
41a3d74 to
a0bacce
Compare
db55b0f to
a095371
Compare
a095371 to
411b4af
Compare
…acy behind consent env var at 2026-06-14T22:33:56Z
168440a to
a59a87e
Compare
812f535 to
53de7ba
Compare
76c6a05 to
a749a6c
Compare
78af90b to
814f175
Compare
a749a6c to
77b435c
Compare
814f175 to
bb782bb
Compare
bb782bb to
f9d60d6
Compare
77b435c to
8ea8dcc
Compare
f9d60d6 to
2e9da22
Compare
8ea8dcc to
31ea6f3
Compare
2e9da22 to
e82c482
Compare
31ea6f3 to
a396765
Compare
e82c482 to
81fd97c
Compare
14de4fb to
9cc1d8c
Compare
9cc1d8c to
c282d53
Compare
Introduce an undetectable JavaScript instrument that runs in the isolated content-script world and exposes native-looking wrappers to the page across the Xray membrane, leaving no page-observable footprint. - Wrappers are handed to the page via exportFunction so the page sees genuine native functions; their toString reports [native code] and their name/arity match the originals (codegen arity forwarders), defeating the standard detection vectors. - Instrumentation redefines properties on interface prototypes while preserving the original descriptor shape (enumerability, configurability, getter/setter layout), so the page cannot distinguish an instrumented property from a native one. - Capture is attributed by interface via a new receiver column on the javascript table (schema.sql + parquet_schema.py), recording which prototype a call was observed on. - Add the stealth settings type, an ambient declaration for the Xray exportFunction global (Extension/src/types/xray.d.ts), and the stealth_js_instrument flag, which is mutually exclusive with js_instrument (ConfigError if both are enabled). Covered by detection tests and error-drift tests in test/test_stealth.py asserting an instrumented page is indistinguishable from an uninstrumented one, including across thrown errors.
Add the stealth_js_instrument_settings field so a custom instrumentation surface can be supplied at runtime; when it is left as None the instrument falls back to its bundled fingerprinting set, so out-of-the-box behaviour is unchanged. - Add the BrowserParams.stealth_js_instrument_settings override field and the corresponding cleaned_stealth_js_instrument_settings carrier on the internal params. - Validate and clean the custom surface via clean_stealth_js_instrumentation_settings, only when stealth is enabled, so a surface left on a browser with stealth off is inert. - Wire the cleaned surface through TaskManager into browser_params.json and surface it in the platform configuration printout. Unlike the legacy js_instrument_settings dotted path, stealth resolves object as a bare global name. Covered by configurability tests in test/test_stealth.py, including attribution, shared-prototype, function-arg, prevent-sets, and error-drift page fixtures.
Add openwpm/utilities/js_settings_migrator.py, which translates a legacy js_instrument_settings config (including recursive entries, which stealth cannot honour) into an equivalent flat, non-recursive stealth_js_instrument_settings config. - Launches a lightweight Firefox and replays the legacy descent over the live object graph; the descent logic lives in openwpm/utilities/walker.js, read at runtime and shipped as package data. - Every legacy member is accounted for: representable members become stealth entries, and members that have no hookable interface prototype (plain Object/Array nodes, universal-prototype inherited members) are surfaced as an UntranslatedEntry rather than dropped silently. - Members that share a prototype but request disjoint symbols are consolidated into a single interface-attributed shared-prototype entry, recovering the exact requested members via the receiver column in post-processing. - preventSets is dropped on translation: blocking a page's writes buys no capture fidelity across the Xray boundary and would only distort the measurement. - Resolves geckodriver explicitly, the same way deploy_firefox does, so the two stay in sync. Covered by golden migration tests in test/test_stealth.py asserting exact input-to-output config, including the UntranslatedEntry list and the shared-prototype union path.
Add an opt-in --capture-universal-members flag to the legacy-to-stealth migrator. By default, inherited methods owned by a universal base prototype (Object.prototype, Function.prototype, Array.prototype — toString, valueOf, hasOwnProperty, ...) are surfaced as UntranslatedEntry rather than instrumented, because hooking them would fire on nearly every receiver on the page and flood the log with noise. When the flag is set, those universal methods are instead captured as interface-attributed shared-prototype entries hooked once on the owning universal prototype, restoring parity with legacy's full prototype-chain walk at the cost of the extra log volume. Also gitignore the generated Extension/src/types/js_instrument_settings.d.ts: the JSON schema is the single source of truth and the .d.ts is regenerated from it on every build. Covered by tests in test/test_stealth.py exercising both the default (untranslated) and opt-in (captured) behaviour.
Add the reStructuredText documentation for the stealth instrument: - docs/developers/adr/0001-retain-legacy-js-instrument.rst — an architecture decision record explaining why the legacy js_instrument is retained alongside stealth, framing detectability and tamper resilience as independent axes and scoping the residual detection surface to the legacy instrument. - docs/developers/Stealth-Instrumentation.rst — the mechanism reference (isolated content-script world, exportFunction across the Xray membrane, native toString passthrough, descriptor-preserving prototype instrumentation, interface-attributed capture) with an honest disruptability section. - docs/developers/Stealth-Requirements.rst — the X1-X3 / D1-D9 requirement and detection-vector list, each tied to its proving test in test/test_stealth.py via literalinclude. - docs/Stealth-Instrument.rst — a user-facing guide on enabling and configuring the instrument, with the stealth_js_instrument flag and the stealth_js_instrument_settings override. Detection and disruption claims are anchored to the test vectors so the docs and the tests cross-reference each other. Wired into docs/index.rst.
…itch (scaffolding) Groundwork for reusing the legacy JS-instrument suite as the oracle for the stealth instrument. This commit adds the machinery only: every existing test class is still legacy-only, so the suite asserts exactly what it did before. The round trip is legacy-format settings -> migrator -> stealth capture -> back-projection -> the *original* legacy assertions. Nothing about what is asserted may change; the value of the suite is that it is the legacy oracle. Three properties enforce that: - The comparison body of OpenWPMJSTest._check_calls is byte-identical to its previous content. Only the row source moved, behind a new _js_rows() hook, so a reviewer can diff those lines and see that no assertion was touched. - get_config asserts the transpile produced no untranslated members. An untranslated member disqualifies a class from stealth mode; it is never a licence to shrink the expected set. - Back-projection drops the stealth-only `receiver` column and nothing else. Any further normalization is a defang and needs explicit sign-off; the contract is stated in the module docstring and pinned by test_js_data_backprojector.py, which asserts the absence of transformation (order, duplicates, every other column, and the drop list itself) at least as hard as its presence. Stealth is opt-in: STEALTH_UNSUPPORTED_REASON defaults to a non-None string, so a class runs in stealth mode only when someone removes it with a justification. All seven classes in test_js_instrument.py carry a specific reason (the document.cookie migrator gap, the missing stealth failure-propagation path, or reliance on window.instrumentJS, which stealth deliberately does not provide). A skip is a documented gap, not a pass. Settings hoisted to a JS_INSTRUMENT_SETTINGS class attribute are deep-copied on handout: clean_js_instrumentation_settings aliases the caller's property list into its output and _merge_settings extends it in place, so sharing one list across visits would let one visit mutate what the next one sees. No current config reaches that path, but it is one duplicate `object` key away. Transpiling launches a headless Firefox per call, so results are memoized for the process lifetime, keyed on the serialized settings. Verified: test_js_instrument.py 7 passed before, 7 passed + 7 stealth-skipped after, same test names and same wall clock; test_stealth.py 104 passed; test_js_data_backprojector.py 9 passed (pyonly).
…t's browser D12 asserts that configuring a custom instrumentation surface does not create a detection hook. The background script injects the surface as window.openWpmStealthInstrumentSettings from a script registered through browser.contentScripts.register, so the assignment lands in the extension's isolated compartment as an Xray expando: stealth.js is registered the same way and reads it back, while page script cannot see it. The probe checks the page's own window four ways, and the row runs under test_custom_settings_stay_undetectable so the global is genuinely injected while the page looks for it. Also records the browser the legacy_detectable ratchet was last verified against, which was measured on Firefox 150 and re-verified since.
What this is
A stealth JavaScript instrument for OpenWPM: a drop-in alternative to the legacy
js_instrumentthat captures the same JavaScript-API surface undetectably. A page cannot tell it is being instrumented (no enumerable wrappers, native-looking.length/toString, no leaked helpers), so it resists the detection-and-then-evasion attacks that legacy instrumentation is vulnerable to.Based on Krumnow, Jonker & Karsch, "How gullible are web measurement tools?" (arXiv:2205.08890, 2022).
How to review this PR
This PR is based directly on
master(v0.36.0, Firefox 154); the crosslink/agent-tooling base PR #1214 and the JS/TS dependency upgrade #1195 have both landed, so the diff here is stealth-only. The stack is deliberately shaped so each commit stands on its own — correct as of itself, green onmaster, and reviewable in isolation.Suggested reading order. Start with the
docs(stealth)commit (5th of 9) for intent — in particulardocs/developers/adr/0001-retain-legacy-js-instrument.rst, which records why legacy stays and stealth is additive. Then review the code commits bottom-up fromfeat(stealth): add the stealth JavaScript instrument, which is the engine. The four commits above docs are two small fixes, the parametric-suite scaffolding, and a Firefox 154 re-verification note.feat(stealth): add the stealth JavaScript instrumentExtension/src/stealth/*.ts(exportFunctionforwarding across the Xray membrane, native-arity codegen forwarders, nativetoStringpassthrough, descriptor-preserving prototype instrumentation, interface-attributed capture), the settings type + ambientexportFunctiondeclaration, the new nullablereceivercolumn (SQLite + Parquet), and the detection / error-drift / frame-protection (D10) tests that prove undetectability. Bundled surface — no runtime config yet.feat(stealth): make the stealth instrumentation surface configurablestealth_js_instrument_settings. Trace the wiring:BrowserParams→ clean →deploy_firefox→ extension. Config tests.feat(stealth): add the legacy-to-stealth settings migratoropenwpm/utilities/js_settings_migrator.py): translates a legacyjs_instrumentconfig into a stealth config, expandsrecursive(which the runtime rejects, pointing here) by replaying the descent over the live object graph (walker.js), and surfaces every untranslatable member as anUntranslatedEntry. Also adds a config-validation guard that rejectslogSettings.recursivein stealth settings (ConfigError, pointing at this tool).feat(stealth): add the --capture-universal-members migrator flagObject/Function/Array.prototypemembers (toString/valueOf/…) for strict legacy-recursive parity. Pure additive — default behavior is unchanged.docs(stealth): document the stealth JavaScript instrumentliteralincludeto the test vectors the code commits add (which is why docs sits at the top). Also adds agraphvizdocs-build dependency. Read this first for intent.Config
stealth_js_instrument: bool— enable the stealth instrument. Mutually exclusive withjs_instrument(raisesConfigErrorif both set).stealth_js_instrument_settings— optional custom capture surface; falls back to the bundled default.logSettings.recursiveis rejected at config time (use the migrator instead).python -m openwpm.utilities.js_settings_migrator <legacy_config.json> [--output <stealth_config.json>] [--firefox-binary <path>] [--capture-universal-members]emits a stealth config + a report of every member it could not translate.Capture parity vs legacy (known, intentional differences)
javascripttable; per-call fidelity is identical for instrumented APIs.addEventListener) are captured page-wide but attributed by receiver interface, recorded in the newreceivercolumn — filter by(symbol, receiver)in post-processing.document.cookieis captured (HTTP cookies unaffected either way).window.name,localStorage, andsessionStorageproperty access are also captured by the bundled default surface (verified byTestStealthWindowName).docs/developers/Stealth-Instrumentation.rst.Verification
Undetectability: detection/disruption probes across the D1–D9 vectors and the D10 frame-protection suite (16 sub-requirements), re-verified on Firefox 154 (51 tests, 0 failures): 8 paired legacy controls still trip, all 30 stealth rows pass. Manual cross-realm / creation-race / indexed-frame-access probes confirm no detection gap (frame instrumentation fires synchronously inside the hooked
appendChild).An earlier revision of this description claimed a differential FingerprintJS oracle showed stealth byte-identical to vanilla while legacy differed. That claim had no artifact behind it, and it has since been measured and is wrong in direction: FingerprintJS reads values, and the legacy wrappers are value-transparent, so legacy is byte-identical to baseline too — the oracle does not discriminate. Likewise, the off-the-shelf OSS bot detectors BotD and fpscanner do not distinguish legacy from baseline on any of 185 signals, because none of their checks read the surfaces legacy perturbs. What does discriminate is the paper's instrumentation tells (
window.getInstrumentJS,[native code]loss, wrapper stack frames, prototype flattening), which fire on legacy and not on stealth. Both experiments, including their limitations, are indocs/developers/.Coverage/fidelity: repeated adversarial review per commit (FAIL-before / PASS-after), including golden migration tests pinning exact legacy→stealth output, a regression test for the narrow shared-prototype config, and a guard pinning the universal-members default-OFF classification.
Changes in this revision
Cross-version narrative for reviewers who saw an earlier round — the per-commit messages describe only the final artifact, so the deltas live here:
.rstdocsliteralincludetest methods that the code commits introduce, so docs sits above those commits and every commit builds in isolation. Later commits have since been added on top, so docs is no longer the final commit — read it first for intent, then review the code bottom-up..claude//.crosslink/config went out as its own PR (chore: configure crosslink for OpenWPM (repo-scoped rules) #1214, merged 2026-08-02), so this PR's diff is stealth-only.ResidueEntry→UntranslatedEntry. The migrator type and all prose now read as "untranslated" / "untranslatable", saying what a member is rather than what it leftover-isn't.literalinclude; the tests back-reference the docs.recursiveat config time.error.tscomputed a cleaned stack but never assigned it. The cleaned stack is now applied; error-drift tests assert no extension frames leak across an instrument-origin throw. (Note: the primary reason stacks stay clean is the Xray/exportFunctionboundary, which strips extension frames before the page sees them; the explicit cleaning is defense-in-depth.)nonExistingPropertiesToInstrument; negatives → affirmatives; geckodriver-resolution comment reworded to the consistency rationale).walker.jsextracted. The inline object-graph walker JS string was pulled out of the migrator into a reviewable/lintablewalker.js, shipped as package data.preventSetsdropped in the migrator. Blocking a page's property writes buys no capture fidelity across the Xray boundary and would distort the measurement, so the migrator warns and drops it on translation.instanceof/name/message, custom-subclass collapse, async rejection) and golden migration tests (exact input→output config, including theUntranslatedEntrylist and the shared-prototype union path).exportFunctiondeclaration (wasTS2304); regenerated lockfile forjson-schema-to-typescript; removed a stale trackedExtension/bundled/stealth.jsbuild artifact; gitignored the schema-generatedjs_instrument_settings.d.ts(the schema is the single source of truth).