browser-commander 0.16.0 → 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 (62) 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 +22 -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-version-modification.test.js +70 -0
  50. package/tests/unit/scripts/check-web-archive.test.js +191 -0
  51. package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
  52. package/tests/unit/scripts/ci-workflow-policy.test.js +217 -28
  53. package/tests/unit/scripts/debug-print.test.js +67 -0
  54. package/tests/unit/scripts/jscpd-config.test.js +141 -0
  55. package/tests/unit/scripts/pre-commit-config.test.js +209 -0
  56. package/tests/unit/scripts/read-manifest.test.js +194 -0
  57. package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
  58. package/tests/unit/scripts/use-module-adoption.test.js +74 -0
  59. package/tests/unit/scripts/use-module-integration.test.js +64 -0
  60. package/tests/unit/scripts/use-module.test.js +182 -0
  61. package/.husky/pre-commit +0 -1
  62. package/.prettierrc +0 -10
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Translate a fingerprint profile into CDP `Emulation` commands.
3
+ *
4
+ * These are the overrides Chrome itself enforces. They apply to workers and to
5
+ * outgoing HTTP headers, not only to the main world, which is what makes them
6
+ * strictly better than patching JavaScript properties. Anything that has no
7
+ * command here needs a page init script instead; `limitations.js` records why.
8
+ */
9
+
10
+ function userAgentMetadata(profile) {
11
+ const data = profile.userAgentData;
12
+ if (!data) {
13
+ return undefined;
14
+ }
15
+ // platform, platformVersion, architecture, model and mobile are required by
16
+ // the protocol; Chrome rejects the command when any of them is missing.
17
+ const metadata = {
18
+ platform: data.platform ?? '',
19
+ platformVersion: data.platformVersion ?? '',
20
+ architecture: data.architecture ?? '',
21
+ model: data.model ?? '',
22
+ mobile: data.mobile ?? false,
23
+ };
24
+ if (data.brands) {
25
+ metadata.brands = data.brands.map((entry) => ({ ...entry }));
26
+ }
27
+ if (data.fullVersionList) {
28
+ metadata.fullVersionList = data.fullVersionList.map((entry) => ({
29
+ ...entry,
30
+ }));
31
+ }
32
+ if (data.bitness !== undefined) {
33
+ metadata.bitness = data.bitness;
34
+ }
35
+ if (data.fullVersion !== undefined) {
36
+ // Deprecated in the protocol, but `fullVersionList` does not cover the
37
+ // `uaFullVersion` hint: without this the page still reads the real Chrome
38
+ // build number. See analysis-artifacts/ua-hints-detail.json.
39
+ metadata.fullVersion = data.fullVersion;
40
+ }
41
+ if (data.wow64 !== undefined) {
42
+ metadata.wow64 = data.wow64;
43
+ }
44
+ if (data.formFactors !== undefined) {
45
+ metadata.formFactors = [...data.formFactors];
46
+ }
47
+ return metadata;
48
+ }
49
+
50
+ function emulatedMediaFeatures(profile) {
51
+ const features = [];
52
+ if (profile.reducedMotion !== undefined) {
53
+ features.push({
54
+ name: 'prefers-reduced-motion',
55
+ value: profile.reducedMotion,
56
+ });
57
+ }
58
+ if (profile.forcedColors !== undefined) {
59
+ features.push({ name: 'forced-colors', value: profile.forcedColors });
60
+ }
61
+ if (profile.colorScheme !== undefined) {
62
+ features.push({ name: 'prefers-color-scheme', value: profile.colorScheme });
63
+ }
64
+ return features;
65
+ }
66
+
67
+ function deviceMetrics(profile) {
68
+ const { viewport, screen } = profile;
69
+ if (!viewport && !screen) {
70
+ return undefined;
71
+ }
72
+ const params = {
73
+ // 0 means "no override" for the viewport, so a profile that only sets
74
+ // screen dimensions still leaves the real window size alone.
75
+ width: viewport?.width ?? 0,
76
+ height: viewport?.height ?? 0,
77
+ deviceScaleFactor: viewport?.deviceScaleFactor ?? 0,
78
+ mobile: viewport?.mobile ?? false,
79
+ };
80
+ if (screen?.width !== undefined) {
81
+ params.screenWidth = screen.width;
82
+ params.screenHeight = screen.height;
83
+ }
84
+ return params;
85
+ }
86
+
87
+ /**
88
+ * Build the ordered CDP command list for a profile.
89
+ *
90
+ * @param {object} profile Normalized fingerprint profile.
91
+ * @returns {Array<{method: string, params: object}>} Commands to send in order.
92
+ */
93
+ export function buildCdpEmulationCommands(profile) {
94
+ if (!profile || typeof profile !== 'object') {
95
+ throw new TypeError('profile must be a normalized fingerprint profile');
96
+ }
97
+ const commands = [];
98
+
99
+ if (profile.userAgent !== undefined || profile.acceptLanguage !== undefined) {
100
+ const params = {};
101
+ if (profile.userAgent !== undefined) {
102
+ params.userAgent = profile.userAgent;
103
+ }
104
+ if (profile.acceptLanguage !== undefined) {
105
+ params.acceptLanguage = profile.acceptLanguage;
106
+ }
107
+ if (profile.platform !== undefined) {
108
+ params.platform = profile.platform;
109
+ }
110
+ const metadata = userAgentMetadata(profile);
111
+ if (metadata) {
112
+ params.userAgentMetadata = metadata;
113
+ }
114
+ // userAgent is a required parameter even when only the language changes.
115
+ if (params.userAgent === undefined) {
116
+ params.userAgent = '';
117
+ }
118
+ commands.push({ method: 'Emulation.setUserAgentOverride', params });
119
+ }
120
+
121
+ if (profile.timezoneId !== undefined) {
122
+ commands.push({
123
+ method: 'Emulation.setTimezoneOverride',
124
+ params: { timezoneId: profile.timezoneId },
125
+ });
126
+ }
127
+
128
+ if (profile.locale !== undefined) {
129
+ commands.push({
130
+ method: 'Emulation.setLocaleOverride',
131
+ params: { locale: profile.locale },
132
+ });
133
+ }
134
+
135
+ if (profile.hardwareConcurrency !== undefined) {
136
+ commands.push({
137
+ method: 'Emulation.setHardwareConcurrencyOverride',
138
+ params: { hardwareConcurrency: profile.hardwareConcurrency },
139
+ });
140
+ }
141
+
142
+ const metrics = deviceMetrics(profile);
143
+ if (metrics) {
144
+ commands.push({
145
+ method: 'Emulation.setDeviceMetricsOverride',
146
+ params: metrics,
147
+ });
148
+ }
149
+
150
+ if (profile.maxTouchPoints !== undefined) {
151
+ commands.push({
152
+ method: 'Emulation.setTouchEmulationEnabled',
153
+ params: {
154
+ enabled: profile.maxTouchPoints > 0,
155
+ maxTouchPoints: Math.max(profile.maxTouchPoints, 1),
156
+ },
157
+ });
158
+ }
159
+
160
+ const features = emulatedMediaFeatures(profile);
161
+ if (features.length > 0) {
162
+ commands.push({
163
+ method: 'Emulation.setEmulatedMedia',
164
+ params: { features },
165
+ });
166
+ }
167
+
168
+ if (profile.geolocation !== undefined) {
169
+ commands.push({
170
+ method: 'Emulation.setGeolocationOverride',
171
+ params: { ...profile.geolocation },
172
+ });
173
+ }
174
+
175
+ return commands;
176
+ }
@@ -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
+ }