browser-commander 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.changeset/config.json +1 -1
  2. package/.changeset/merged-loud-river.md +7 -0
  3. package/.jscpd-baseline.json +257 -0
  4. package/.jscpd.json +1 -3
  5. package/.prettierignore +1 -0
  6. package/CHANGELOG.md +8 -0
  7. package/eslint.config.js +23 -1
  8. package/package.json +13 -24
  9. package/scripts/changeset-version.mjs +2 -6
  10. package/scripts/create-github-release.mjs +7 -8
  11. package/scripts/create-manual-changeset.mjs +7 -8
  12. package/scripts/format-github-release.mjs +6 -7
  13. package/scripts/format-release-notes.mjs +7 -8
  14. package/scripts/instant-version-bump.mjs +7 -8
  15. package/scripts/publish-to-npm.mjs +7 -8
  16. package/scripts/setup-npm.mjs +2 -6
  17. package/scripts/version-and-commit.mjs +7 -8
  18. package/src/browser/browser-cookie-credentials.js +3 -1
  19. package/src/browser/browser-cookies.js +6 -2
  20. package/src/browser/browser-profiles.js +1 -1
  21. package/src/browser/launch-options.js +31 -8
  22. package/src/browser/launcher.js +16 -0
  23. package/src/exports.js +34 -0
  24. package/src/fingerprint/apply.js +116 -0
  25. package/src/fingerprint/automation-parity.js +224 -0
  26. package/src/fingerprint/cdp-overrides.js +176 -0
  27. package/src/fingerprint/derive.js +104 -0
  28. package/src/fingerprint/init-payload.js +181 -0
  29. package/src/fingerprint/init-script.js +104 -0
  30. package/src/fingerprint/limitations.js +107 -0
  31. package/src/fingerprint/limitations.json +96 -0
  32. package/src/fingerprint/presets.js +244 -0
  33. package/src/fingerprint/profile.js +428 -0
  34. package/src/tests/index.js +2 -1
  35. package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
  36. package/tests/helpers/mocks.js +4 -2
  37. package/tests/helpers/repo.js +74 -0
  38. package/tests/unit/browser/launch-options.test.js +9 -1
  39. package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
  40. package/tests/unit/fingerprint/apply.test.js +271 -0
  41. package/tests/unit/fingerprint/automation-parity.test.js +212 -0
  42. package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
  43. package/tests/unit/fingerprint/derive.test.js +105 -0
  44. package/tests/unit/fingerprint/init-script.test.js +357 -0
  45. package/tests/unit/fingerprint/limitations.test.js +184 -0
  46. package/tests/unit/fingerprint/presets.test.js +135 -0
  47. package/tests/unit/fingerprint/profile.test.js +282 -0
  48. package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
  49. package/tests/unit/scripts/check-web-archive.test.js +191 -0
  50. package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
  51. package/tests/unit/scripts/ci-workflow-policy.test.js +193 -157
  52. package/tests/unit/scripts/debug-print.test.js +67 -0
  53. package/tests/unit/scripts/jscpd-config.test.js +141 -0
  54. package/tests/unit/scripts/pre-commit-config.test.js +209 -0
  55. package/tests/unit/scripts/read-manifest.test.js +194 -0
  56. package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
  57. package/tests/unit/scripts/use-module-adoption.test.js +74 -0
  58. package/tests/unit/scripts/use-module-integration.test.js +64 -0
  59. package/tests/unit/scripts/use-module.test.js +182 -0
  60. package/.husky/pre-commit +0 -1
  61. package/.prettierrc +0 -10
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Derive User-Agent Client Hints from a User-Agent string.
3
+ *
4
+ * This exists because of a measured trap: `Emulation.setUserAgentOverride`
5
+ * replaces the whole identity, so overriding `userAgent` *without*
6
+ * `userAgentMetadata` leaves `navigator.userAgentData.brands` empty and
7
+ * `getHighEntropyValues(['fullVersionList'])` returning `[]`. A real browser
8
+ * never reports that combination, so a bare UA override is a louder automation
9
+ * signal than the default UA it replaced. See
10
+ * `docs/case-studies/issue-79/analysis-artifacts/ua-hints-detail.json`.
11
+ *
12
+ * Deriving is best effort. Chrome's GREASE brand -- the `Not=A?Brand` entry --
13
+ * is generated from a per-version permutation table that this module does not
14
+ * reproduce; `limitations.js` records that.
15
+ */
16
+
17
+ const CHROME_VERSION = /Chrome\/(\d+)(?:\.(\d+)\.(\d+)\.(\d+))?/u;
18
+
19
+ const GREASE_BRAND = 'Not=A?Brand';
20
+ const GREASE_VERSION = '24';
21
+
22
+ function platformFromUserAgent(userAgent) {
23
+ if (/Windows NT/u.test(userAgent)) {
24
+ return { platform: 'Windows', architecture: 'x86', bitness: '64' };
25
+ }
26
+ if (/Android/u.test(userAgent)) {
27
+ return { platform: 'Android', architecture: '', bitness: '' };
28
+ }
29
+ if (/(Macintosh|Mac OS X)/u.test(userAgent)) {
30
+ return { platform: 'macOS', architecture: 'arm', bitness: '64' };
31
+ }
32
+ if (/CrOS/u.test(userAgent)) {
33
+ return { platform: 'Chrome OS', architecture: 'x86', bitness: '64' };
34
+ }
35
+ if (/(X11|Linux)/u.test(userAgent)) {
36
+ return { platform: 'Linux', architecture: 'x86', bitness: '64' };
37
+ }
38
+ return { platform: '', architecture: '', bitness: '' };
39
+ }
40
+
41
+ function platformVersionFromUserAgent(userAgent, platform) {
42
+ if (platform === 'Windows') {
43
+ // Chrome freezes the UA string at "Windows NT 10.0" and moves the real
44
+ // version into the platformVersion hint: 13+ means Windows 11.
45
+ return /Windows NT 10\.0/u.test(userAgent) ? '15.0.0' : '0.0.0';
46
+ }
47
+ if (platform === 'macOS') {
48
+ const match = /Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/u.exec(userAgent);
49
+ return match ? `${match[1]}.${match[2]}.${match[3] || '0'}` : '';
50
+ }
51
+ if (platform === 'Android') {
52
+ const match = /Android (\d+(?:\.\d+)*)/u.exec(userAgent);
53
+ return match ? match[1] : '';
54
+ }
55
+ return '';
56
+ }
57
+
58
+ /**
59
+ * Build a complete `userAgentData` block for a Chrome User-Agent string.
60
+ *
61
+ * @param {string} userAgent A Chrome or Chromium User-Agent string.
62
+ * @returns {object|null} Client hints, or `null` when the string does not name
63
+ * a Chrome version and there is nothing trustworthy to derive.
64
+ */
65
+ export function deriveUserAgentData(userAgent) {
66
+ if (typeof userAgent !== 'string') {
67
+ throw new TypeError('userAgent must be a string');
68
+ }
69
+ const version = CHROME_VERSION.exec(userAgent);
70
+ if (!version) {
71
+ return null;
72
+ }
73
+ const major = version[1];
74
+ const full = version[2]
75
+ ? version[0].slice('Chrome/'.length)
76
+ : `${major}.0.0.0`;
77
+ const { platform, architecture, bitness } = platformFromUserAgent(userAgent);
78
+ const mobile = /Mobile/u.test(userAgent);
79
+ const model = mobile
80
+ ? (/; ([^;)]+) Build\//u.exec(userAgent)?.[1] ?? '')
81
+ : '';
82
+
83
+ return {
84
+ brands: [
85
+ { brand: 'Chromium', version: major },
86
+ { brand: 'Google Chrome', version: major },
87
+ { brand: GREASE_BRAND, version: GREASE_VERSION },
88
+ ],
89
+ fullVersionList: [
90
+ { brand: 'Chromium', version: full },
91
+ { brand: 'Google Chrome', version: full },
92
+ { brand: GREASE_BRAND, version: `${GREASE_VERSION}.0.0.0` },
93
+ ],
94
+ fullVersion: full,
95
+ platform,
96
+ platformVersion: platformVersionFromUserAgent(userAgent, platform),
97
+ architecture,
98
+ bitness,
99
+ model,
100
+ mobile,
101
+ wow64: false,
102
+ formFactors: [mobile ? 'Mobile' : 'Desktop'],
103
+ };
104
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Shared fingerprint init payload -- the one copy the three implementations run.
3
+ *
4
+ * This file is not a module. It is source text: every implementation reads it
5
+ * verbatim and sends it to `Page.addScriptToEvaluateOnNewDocument`, which runs
6
+ * a classic script, so it must contain no `import` and no `export`.
7
+ *
8
+ * The same bytes live in three places, because npm, PyPI and crates.io each
9
+ * package one directory and none of them can reference a file outside it:
10
+ *
11
+ * - js/src/fingerprint/init-payload.js
12
+ * - python/src/browser_commander/fingerprint/init_payload.js
13
+ * - rust/src/fingerprint/init_payload.js
14
+ *
15
+ * `scripts/check-shared-fingerprint-assets.sh` fails the build when they differ.
16
+ * Editing one and forgetting the others is the failure mode that rotted
17
+ * selenium-stealth and playwright_stealth -- see
18
+ * docs/case-studies/issue-79/prior-art.md -- so the check exists to make a
19
+ * silent divergence impossible rather than unlikely.
20
+ */
21
+
22
+ /**
23
+ * Runs inside the page, before any page script, in every frame.
24
+ *
25
+ * @param {object} config Plain JSON produced by `buildInitScriptConfig`.
26
+ */
27
+ function fingerprintPayload(config) {
28
+ const scope = globalThis;
29
+ // Playwright and Puppeteer both re-inject init scripts on same-document
30
+ // navigations in some versions; patching twice would stack getters and make
31
+ // the descriptor chain observable.
32
+ const marker = '__browserCommanderFingerprint';
33
+ if (scope[marker]) {
34
+ return;
35
+ }
36
+ Object.defineProperty(scope, marker, {
37
+ value: true,
38
+ configurable: true,
39
+ enumerable: false,
40
+ writable: false,
41
+ });
42
+
43
+ const nativeSources = new WeakMap();
44
+ const functionToString = Function.prototype.toString;
45
+ const objectDefineProperty = Object.defineProperty;
46
+
47
+ /** Make a replacement report `[native code]`, like the accessor it replaces. */
48
+ function asNative(fn, name) {
49
+ try {
50
+ objectDefineProperty(fn, 'name', {
51
+ value: name,
52
+ configurable: true,
53
+ enumerable: false,
54
+ writable: false,
55
+ });
56
+ } catch {
57
+ // A frozen function name is not worth failing the whole patch over.
58
+ }
59
+ nativeSources.set(fn, `function ${name}() { [native code] }`);
60
+ return fn;
61
+ }
62
+
63
+ const patchedToString = function toString() {
64
+ const source = nativeSources.get(this);
65
+ return source === undefined ? functionToString.call(this) : source;
66
+ };
67
+ nativeSources.set(patchedToString, functionToString.call(functionToString));
68
+ Function.prototype.toString = patchedToString;
69
+
70
+ /**
71
+ * Replace a native accessor on a prototype, keeping the shape a real one has:
72
+ * an enumerable, configurable getter with no setter, named `get <property>`.
73
+ */
74
+ function defineNativeGetter(target, property, produce) {
75
+ if (!target) {
76
+ return;
77
+ }
78
+ const descriptor = Object.getOwnPropertyDescriptor(target, property);
79
+ if (descriptor && !descriptor.configurable) {
80
+ return;
81
+ }
82
+ const getter = asNative(() => produce(), `get ${property}`);
83
+ try {
84
+ objectDefineProperty(target, property, {
85
+ get: getter,
86
+ set: undefined,
87
+ enumerable: descriptor ? descriptor.enumerable : true,
88
+ configurable: true,
89
+ });
90
+ } catch {
91
+ // Some engines refuse redefinition on cross-origin-ish prototypes.
92
+ }
93
+ }
94
+
95
+ const navigatorPrototype = scope.Navigator && scope.Navigator.prototype;
96
+ const workerNavigatorPrototype =
97
+ scope.WorkerNavigator && scope.WorkerNavigator.prototype;
98
+
99
+ function defineNavigator(property, value) {
100
+ defineNativeGetter(navigatorPrototype, property, () => value);
101
+ defineNativeGetter(workerNavigatorPrototype, property, () => value);
102
+ }
103
+
104
+ if (config.webdriver !== undefined) {
105
+ defineNavigator('webdriver', config.webdriver);
106
+ }
107
+ if (config.deviceMemory !== undefined) {
108
+ defineNavigator('deviceMemory', config.deviceMemory);
109
+ }
110
+ if (config.hardwareConcurrency !== undefined) {
111
+ defineNavigator('hardwareConcurrency', config.hardwareConcurrency);
112
+ }
113
+ if (config.vendor !== undefined) {
114
+ defineNavigator('vendor', config.vendor);
115
+ }
116
+ if (config.platform !== undefined) {
117
+ defineNavigator('platform', config.platform);
118
+ }
119
+ if (config.maxTouchPoints !== undefined) {
120
+ defineNavigator('maxTouchPoints', config.maxTouchPoints);
121
+ }
122
+ if (config.doNotTrack !== undefined) {
123
+ defineNativeGetter(
124
+ navigatorPrototype,
125
+ 'doNotTrack',
126
+ () => config.doNotTrack
127
+ );
128
+ }
129
+ if (config.languages !== undefined) {
130
+ // navigator.languages is a frozen array in Chrome; hand out a fresh frozen
131
+ // copy so page code cannot mutate the one we hold.
132
+ defineNavigator('language', config.languages[0]);
133
+ defineNativeGetter(navigatorPrototype, 'languages', () =>
134
+ Object.freeze(config.languages.slice())
135
+ );
136
+ defineNativeGetter(workerNavigatorPrototype, 'languages', () =>
137
+ Object.freeze(config.languages.slice())
138
+ );
139
+ }
140
+
141
+ const screenPrototype = scope.Screen && scope.Screen.prototype;
142
+ if (config.screen) {
143
+ for (const property of Object.keys(config.screen)) {
144
+ const value = config.screen[property];
145
+ defineNativeGetter(screenPrototype, property, () => value);
146
+ }
147
+ }
148
+
149
+ if (config.webgl) {
150
+ // 0x9245/0x9246 are UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL from
151
+ // the WEBGL_debug_renderer_info extension; 0x1F00/0x1F01 are the plain
152
+ // VENDOR and RENDERER constants that need no extension at all.
153
+ const replacements = new Map();
154
+ if (config.webgl.unmaskedVendor !== undefined) {
155
+ replacements.set(0x9245, config.webgl.unmaskedVendor);
156
+ }
157
+ if (config.webgl.unmaskedRenderer !== undefined) {
158
+ replacements.set(0x9246, config.webgl.unmaskedRenderer);
159
+ }
160
+ if (config.webgl.vendor !== undefined) {
161
+ replacements.set(0x1f00, config.webgl.vendor);
162
+ }
163
+ if (config.webgl.renderer !== undefined) {
164
+ replacements.set(0x1f01, config.webgl.renderer);
165
+ }
166
+ for (const name of ['WebGLRenderingContext', 'WebGL2RenderingContext']) {
167
+ const prototype = scope[name] && scope[name].prototype;
168
+ if (!prototype || typeof prototype.getParameter !== 'function') {
169
+ continue;
170
+ }
171
+ const original = prototype.getParameter;
172
+ const patched = asNative(function getParameter(parameter) {
173
+ if (replacements.has(parameter)) {
174
+ return replacements.get(parameter);
175
+ }
176
+ return original.call(this, parameter);
177
+ }, 'getParameter');
178
+ prototype.getParameter = patched;
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Page init script for the fingerprint surfaces CDP cannot override.
3
+ *
4
+ * Everything here is strictly worse than a browser-enforced override: a
5
+ * JavaScript patch is visible to anyone who inspects the property descriptor
6
+ * carefully enough, and it does not reach workers or HTTP headers. It exists
7
+ * only for fields the `Emulation` domain has no command for -- see
8
+ * `limitations.js` for the measured list -- and for `connectBrowser`, where the
9
+ * launch switches were already fixed by whoever started the browser.
10
+ *
11
+ * The payload itself lives in `init-payload.js` and is read as source text
12
+ * rather than serialized from a function in this file, because Python and Rust
13
+ * run the same bytes. `String(fn)` has no equivalent there, and a hand
14
+ * translation of a patch this delicate would drift within a release.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+
18
+ /** The shared payload source, read once at import time. */
19
+ export const FINGERPRINT_PAYLOAD_SOURCE = readFileSync(
20
+ new URL('./init-payload.js', import.meta.url),
21
+ 'utf8'
22
+ );
23
+
24
+ const SCREEN_FIELDS = [
25
+ 'width',
26
+ 'height',
27
+ 'availWidth',
28
+ 'availHeight',
29
+ 'colorDepth',
30
+ 'pixelDepth',
31
+ ];
32
+
33
+ /**
34
+ * Decide what the init script still has to do once the browser-side overrides
35
+ * have been applied.
36
+ *
37
+ * @param {object} profile Normalized fingerprint profile.
38
+ * @param {object} [options]
39
+ * @param {boolean} [options.patchWebdriver] Force `navigator.webdriver` to
40
+ * `false` from JavaScript. Only needed when the browser was launched by
41
+ * somebody else and the automation switches can no longer be changed.
42
+ * @param {boolean} [options.patchLanguages] Also patch `navigator.languages`.
43
+ * @returns {object|null} Config for the payload, or `null` when nothing is left.
44
+ */
45
+ export function buildInitScriptConfig(profile, options = {}) {
46
+ const config = {};
47
+
48
+ if (options.patchWebdriver) {
49
+ config.webdriver = false;
50
+ }
51
+ if (profile.deviceMemory !== undefined) {
52
+ config.deviceMemory = profile.deviceMemory;
53
+ }
54
+ if (profile.vendor !== undefined) {
55
+ config.vendor = profile.vendor;
56
+ }
57
+ if (profile.doNotTrack !== undefined) {
58
+ config.doNotTrack = profile.doNotTrack;
59
+ }
60
+ if (options.patchLanguages && profile.languages !== undefined) {
61
+ config.languages = [...profile.languages];
62
+ }
63
+ if (profile.webgl !== undefined) {
64
+ config.webgl = { ...profile.webgl };
65
+ }
66
+ if (profile.screen !== undefined) {
67
+ const screen = {};
68
+ for (const field of SCREEN_FIELDS) {
69
+ if (profile.screen[field] !== undefined) {
70
+ screen[field] = profile.screen[field];
71
+ }
72
+ }
73
+ // width and height are already enforced by setDeviceMetricsOverride; the
74
+ // avail*/depth fields are not, so only those need patching.
75
+ delete screen.width;
76
+ delete screen.height;
77
+ if (Object.keys(screen).length > 0) {
78
+ config.screen = screen;
79
+ }
80
+ }
81
+
82
+ return Object.keys(config).length > 0 ? config : null;
83
+ }
84
+
85
+ /**
86
+ * Serialize the init script for a profile.
87
+ *
88
+ * @param {object} profile Normalized fingerprint profile.
89
+ * @param {object} [options] See `buildInitScriptConfig`.
90
+ * @returns {string|null} Script source, or `null` when no patching is needed.
91
+ */
92
+ export function buildFingerprintInitScript(profile, options = {}) {
93
+ if (!profile || typeof profile !== 'object') {
94
+ throw new TypeError('profile must be a normalized fingerprint profile');
95
+ }
96
+ const config = buildInitScriptConfig(profile, options);
97
+ if (!config) {
98
+ return null;
99
+ }
100
+ // The payload is wrapped in an IIFE so the declaration never becomes a
101
+ // property of the page's global object; a stray `fingerprintPayload` global
102
+ // would be a far louder signal than anything the payload hides.
103
+ return `(() => {\n${FINGERPRINT_PAYLOAD_SOURCE}\nfingerprintPayload(${JSON.stringify(config)});\n})();`;
104
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * What Browser Commander cannot make identical, and why.
3
+ *
4
+ * Issue 79 asks for the limitations to be stated clearly rather than implied,
5
+ * so every entry here names the surface, the mechanism that would be needed,
6
+ * the privacy consequence, and the evidence it rests on. Entries marked
7
+ * `measured` were reproduced in this repository; the artifacts are under
8
+ * `docs/case-studies/issue-79/analysis-artifacts/`.
9
+ *
10
+ * The catalogue itself lives in `limitations.json`, because Python and Rust
11
+ * publish the same eleven entries and a hand-copied paragraph of prose drifts
12
+ * as easily as a hand-copied patch. This module owns the JSON; the copies are
13
+ * checked byte for byte by `scripts/check-shared-fingerprint-assets.sh`.
14
+ *
15
+ * `severity` describes how much the limitation helps someone identify the
16
+ * browser as automated or as a specific machine:
17
+ * - `high`: on its own it identifies automation or the physical machine.
18
+ * - `medium`: it is a strong signal when combined with others.
19
+ * - `low`: it narrows the field but is common in real browsers too.
20
+ */
21
+ import { readFileSync } from 'node:fs';
22
+
23
+ /**
24
+ * @typedef {object} FingerprintLimitation
25
+ * @property {string} id Stable identifier.
26
+ * @property {string} surface What the page can observe.
27
+ * @property {'high'|'medium'|'low'} severity
28
+ * @property {'measured'|'documented'} evidence
29
+ * @property {string} detail What happens and why it cannot be fixed here.
30
+ * @property {string} [workaround] What a caller can do about it.
31
+ * @property {string} [reference] Artifact, source file or specification.
32
+ */
33
+
34
+ /** @type {ReadonlyArray<FingerprintLimitation>} */
35
+ export const FINGERPRINT_LIMITATIONS = Object.freeze(
36
+ JSON.parse(
37
+ readFileSync(new URL('./limitations.json', import.meta.url), 'utf8')
38
+ ).map((limitation) => Object.freeze(limitation))
39
+ );
40
+
41
+ /**
42
+ * Look a limitation up by id.
43
+ *
44
+ * @param {string} id
45
+ * @returns {FingerprintLimitation|undefined}
46
+ */
47
+ export function findFingerprintLimitation(id) {
48
+ return FINGERPRINT_LIMITATIONS.find((limitation) => limitation.id === id);
49
+ }
50
+
51
+ /**
52
+ * Limitations that apply to a specific profile.
53
+ *
54
+ * A profile that never touches WebGL does not need to hear about the WebGL
55
+ * limitation, and hiding the irrelevant entries is what makes the relevant ones
56
+ * worth reading.
57
+ *
58
+ * @param {object} profile Normalized fingerprint profile.
59
+ * @param {object} [options]
60
+ * @param {boolean} [options.headless] Whether the browser runs headless.
61
+ * @param {boolean} [options.attached] Whether the browser was launched by
62
+ * somebody else, so the automation switches are already fixed.
63
+ * @returns {ReadonlyArray<FingerprintLimitation>}
64
+ */
65
+ export function relevantFingerprintLimitations(profile = {}, options = {}) {
66
+ const always = new Set([
67
+ 'canvas-audio-font-follow-the-host',
68
+ 'network-layer-not-covered',
69
+ ]);
70
+ const conditions = {
71
+ 'automation-controlled-is-launch-only': () => options.attached === true,
72
+ 'no-cdp-device-memory-override': () => profile.deviceMemory !== undefined,
73
+ 'no-cdp-vendor-or-dnt-override': () =>
74
+ profile.vendor !== undefined || profile.doNotTrack !== undefined,
75
+ 'screen-depth-and-avail-not-emulated': () =>
76
+ profile.screen !== undefined &&
77
+ (profile.screen.colorDepth !== undefined ||
78
+ profile.screen.pixelDepth !== undefined ||
79
+ profile.screen.availWidth !== undefined ||
80
+ profile.screen.availHeight !== undefined),
81
+ 'webgl-strings-only': () => profile.webgl !== undefined,
82
+ 'grease-brand-not-reproduced': () => profile.userAgentData !== undefined,
83
+ 'touch-emulation-changes-pointer-media': () =>
84
+ profile.maxTouchPoints !== undefined && profile.maxTouchPoints > 0,
85
+ 'headless-is-distinguishable': () => options.headless === true,
86
+ // Everything a worker reads differently from its document: the fields no
87
+ // override reaches, plus the ones the page session keeps to itself.
88
+ 'init-script-does-not-reach-workers': () =>
89
+ [
90
+ 'deviceMemory',
91
+ 'vendor',
92
+ 'doNotTrack',
93
+ 'webgl',
94
+ 'platform',
95
+ 'languages',
96
+ 'hardwareConcurrency',
97
+ ].some((field) => profile[field] !== undefined),
98
+ };
99
+
100
+ return Object.freeze(
101
+ FINGERPRINT_LIMITATIONS.filter(
102
+ (limitation) =>
103
+ always.has(limitation.id) ||
104
+ (conditions[limitation.id] ? conditions[limitation.id]() : false)
105
+ )
106
+ );
107
+ }
@@ -0,0 +1,96 @@
1
+ [
2
+ {
3
+ "id": "automation-controlled-is-launch-only",
4
+ "surface": "navigator.webdriver",
5
+ "severity": "high",
6
+ "evidence": "measured",
7
+ "detail": "navigator.webdriver is the AutomationControlled Blink runtime feature. Chrome enables it for --enable-automation, --headless, --remote-debugging-pipe and an ephemeral --remote-debugging-port=0, and the only way to turn it back off is the launch switch --disable-blink-features=AutomationControlled. Emulation.setAutomationOverride({enabled:false}) is accepted over CDP but leaves navigator.webdriver true, in the same document and after navigation, so a browser somebody else already launched cannot be corrected from the protocol side.",
8
+ "workaround": "Launch the browser through this library, which passes the switch. When attaching to a browser somebody else started, apply the profile with the webdriver patch option, which installs a JavaScript getter instead: that is weaker, because it does not reach workers or a renderer that starts before the patch is installed.",
9
+ "reference": "content/child/runtime_features.cc; analysis-artifacts/remote-debugging-isolation.json"
10
+ },
11
+ {
12
+ "id": "no-cdp-device-memory-override",
13
+ "surface": "navigator.deviceMemory",
14
+ "severity": "medium",
15
+ "evidence": "measured",
16
+ "detail": "The Emulation domain has no deviceMemory command, so the value can only be patched in JavaScript. Workers and any code that reads the value before the init script runs still see the real amount of memory, rounded to the nearest power of two as the specification requires.",
17
+ "workaround": "Choose a profile whose deviceMemory matches the host where possible.",
18
+ "reference": "analysis-artifacts/cdp-override-coverage.json"
19
+ },
20
+ {
21
+ "id": "no-cdp-vendor-or-dnt-override",
22
+ "surface": "navigator.vendor, navigator.doNotTrack",
23
+ "severity": "low",
24
+ "evidence": "measured",
25
+ "detail": "Neither has an Emulation command; both are JavaScript patches only. navigator.vendor is \"Google Inc.\" on every Chromium build, so it rarely needs changing, but a profile that claims a non-Chromium browser has to patch it and the patch does not reach workers.",
26
+ "reference": "analysis-artifacts/cdp-override-coverage.json"
27
+ },
28
+ {
29
+ "id": "screen-depth-and-avail-not-emulated",
30
+ "surface": "screen.colorDepth, screen.pixelDepth, screen.availWidth, screen.availHeight",
31
+ "severity": "low",
32
+ "evidence": "measured",
33
+ "detail": "Emulation.setDeviceMetricsOverride controls screen.width and screen.height only. It sets availWidth and availHeight equal to them, so a profile that models an operating system taskbar has to patch those in JavaScript, and the colour depths are not emulated at all.",
34
+ "reference": "analysis-artifacts/cdp-override-coverage.json"
35
+ },
36
+ {
37
+ "id": "webgl-strings-only",
38
+ "surface": "WebGL renderer strings and driver limits",
39
+ "severity": "high",
40
+ "evidence": "documented",
41
+ "detail": "The unmasked vendor and renderer strings can be replaced in JavaScript, but the numeric driver limits next to them -- MAX_TEXTURE_SIZE, ALIASED_LINE_WIDTH_RANGE, the bit depths, the supported extension list -- come from the real GPU stack. Claiming an Apple GPU while reporting Mesa's limits is more identifying than not claiming anything.",
42
+ "workaround": "Either leave the WebGL strings alone or run on hardware that matches the profile you are claiming."
43
+ },
44
+ {
45
+ "id": "canvas-audio-font-follow-the-host",
46
+ "surface": "canvas and audio digests, font metrics",
47
+ "severity": "high",
48
+ "evidence": "documented",
49
+ "detail": "These are produced by the host GPU, audio stack and installed fonts. Browser Commander does not perturb them, because the usual countermeasure -- adding per-session noise -- is itself detectable: a real browser returns the same digest twice in a row, and a noised one does not.",
50
+ "workaround": "Match the host environment to the profile, for example by running in a container image with the font set you intend to claim."
51
+ },
52
+ {
53
+ "id": "grease-brand-not-reproduced",
54
+ "surface": "navigator.userAgentData.brands ordering",
55
+ "severity": "low",
56
+ "evidence": "documented",
57
+ "detail": "Chrome generates the GREASE entry in Sec-CH-UA from a per-version permutation of separators and ordering. Derived profiles use the common \"Not=A?Brand\";v=\"24\" form in a fixed position, which will not match every Chrome build exactly.",
58
+ "workaround": "Pass an explicit userAgentData.brands copied from the browser build you are modelling.",
59
+ "reference": "https://wicg.github.io/ua-client-hints/"
60
+ },
61
+ {
62
+ "id": "touch-emulation-changes-pointer-media",
63
+ "surface": "(pointer) and (hover) media queries",
64
+ "severity": "medium",
65
+ "evidence": "measured",
66
+ "detail": "Setting maxTouchPoints above zero goes through Emulation.setTouchEmulationEnabled, which also makes the primary pointer coarse and removes hover. That is correct for a phone and wrong for a desktop that happens to have a touchscreen.",
67
+ "workaround": "Leave maxTouchPoints unset on desktop profiles unless the pointer change is what you want.",
68
+ "reference": "analysis-artifacts/cdp-override-coverage.json"
69
+ },
70
+ {
71
+ "id": "headless-is-distinguishable",
72
+ "surface": "user agent, screen size, hover and pointer media queries, WebGL renderer",
73
+ "severity": "high",
74
+ "evidence": "measured",
75
+ "detail": "A real headless Chrome differs from a real headful Chrome in thirteen probe fields with no automation involved at all: the user agent and appVersion contain \"HeadlessChrome\" -- in the document, in an iframe and in a worker -- the screen is 800x600 and availWidth and availHeight follow it, the four hover and pointer media queries report no hover and no fine pointer, and WebGL falls back to SwiftShader. A headless browser is therefore compared against a headless reference, never against a headful one.",
76
+ "workaround": "Run headful, under a virtual display such as Xvfb if needed.",
77
+ "reference": "analysis-artifacts/parity-headless.json"
78
+ },
79
+ {
80
+ "id": "network-layer-not-covered",
81
+ "surface": "TLS and HTTP/2 fingerprints (JA3, JA4, Akamai h2 fingerprint)",
82
+ "severity": "high",
83
+ "evidence": "documented",
84
+ "detail": "The TLS ClientHello and the HTTP/2 SETTINGS frame identify the network stack, not the JavaScript environment, and CDP exposes no way to change either. They are identical between a real Chrome and an automated Chrome of the same build, so they are not an automation signal -- but they do pin the browser to a real Chrome version, and a profile that claims a different browser or version will not match at that layer.",
85
+ "workaround": "Keep the claimed browser and version close to the browser actually running."
86
+ },
87
+ {
88
+ "id": "init-script-does-not-reach-workers",
89
+ "surface": "Values patched in JavaScript, as seen from a Worker",
90
+ "severity": "medium",
91
+ "evidence": "measured",
92
+ "detail": "A worker has its own Navigator, and overrides set on the page session only partly reach it. Measured in a dedicated worker: userAgent, timezone and locale follow the profile, but platform, language, languages, hardwareConcurrency and deviceMemory all report the real host values, and no JavaScript patch is present because Page.addScriptToEvaluateOnNewDocument runs in documents only. Full worker parity is reachable over raw CDP -- replaying the Emulation commands on the worker session fixes languages and hardwareConcurrency, and evaluating the init script during the waitForDebuggerOnStart pause fixes the rest -- but that needs a sessionId, which neither engine accepts: Playwright newCDPSession takes only Page or Frame, and Puppeteer worker sessions are internal to its TargetManager.",
93
+ "workaround": "Avoid workloads that read the fingerprint from a worker, or drive Chrome over a raw CDP connection and replay the commands the CDP override builder returns on each attached worker session.",
94
+ "reference": "analysis-artifacts/worker-visibility.json"
95
+ }
96
+ ]