leapfrog-mcp 0.6.2 → 0.6.3

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 (57) hide show
  1. package/dist/adaptive-wait.d.ts +72 -0
  2. package/dist/adaptive-wait.js +695 -0
  3. package/dist/api-intelligence.d.ts +82 -0
  4. package/dist/api-intelligence.js +575 -0
  5. package/dist/captcha-solver.d.ts +26 -0
  6. package/dist/captcha-solver.js +547 -0
  7. package/dist/cdp-connector.d.ts +33 -0
  8. package/dist/cdp-connector.js +176 -0
  9. package/dist/consent-dismiss.d.ts +33 -0
  10. package/dist/consent-dismiss.js +358 -0
  11. package/dist/crash-recovery.d.ts +74 -0
  12. package/dist/crash-recovery.js +242 -0
  13. package/dist/domain-knowledge.d.ts +149 -0
  14. package/dist/domain-knowledge.js +449 -0
  15. package/dist/harness-intelligence.d.ts +65 -0
  16. package/dist/harness-intelligence.js +432 -0
  17. package/dist/humanize-fingerprint.d.ts +42 -0
  18. package/dist/humanize-fingerprint.js +161 -0
  19. package/dist/humanize-mouse.d.ts +95 -0
  20. package/dist/humanize-mouse.js +275 -0
  21. package/dist/humanize-pause.d.ts +48 -0
  22. package/dist/humanize-pause.js +111 -0
  23. package/dist/humanize-scroll.d.ts +67 -0
  24. package/dist/humanize-scroll.js +185 -0
  25. package/dist/humanize-typing.d.ts +60 -0
  26. package/dist/humanize-typing.js +258 -0
  27. package/dist/humanize-utils.d.ts +62 -0
  28. package/dist/humanize-utils.js +100 -0
  29. package/dist/intervention.d.ts +65 -0
  30. package/dist/intervention.js +591 -0
  31. package/dist/logger.d.ts +13 -0
  32. package/dist/logger.js +47 -0
  33. package/dist/network-intelligence.d.ts +70 -0
  34. package/dist/network-intelligence.js +424 -0
  35. package/dist/page-classifier.d.ts +33 -0
  36. package/dist/page-classifier.js +1000 -0
  37. package/dist/paginate.d.ts +42 -0
  38. package/dist/paginate.js +693 -0
  39. package/dist/recording.d.ts +72 -0
  40. package/dist/recording.js +934 -0
  41. package/dist/script-executor.d.ts +31 -0
  42. package/dist/script-executor.js +249 -0
  43. package/dist/session-hud.d.ts +20 -0
  44. package/dist/session-hud.js +134 -0
  45. package/dist/snapshot-differ.d.ts +26 -0
  46. package/dist/snapshot-differ.js +225 -0
  47. package/dist/ssrf.d.ts +28 -0
  48. package/dist/ssrf.js +290 -0
  49. package/dist/stealth-audit.d.ts +27 -0
  50. package/dist/stealth-audit.js +719 -0
  51. package/dist/stealth.d.ts +195 -0
  52. package/dist/stealth.js +1157 -0
  53. package/dist/tab-manager.d.ts +14 -0
  54. package/dist/tab-manager.js +306 -0
  55. package/dist/tiles-coordinator.d.ts +106 -0
  56. package/dist/tiles-coordinator.js +358 -0
  57. package/package.json +1 -1
@@ -0,0 +1,1157 @@
1
+ // ─── Anti-Bot Evasion ─────────────────────────────────────────────────────
2
+ //
3
+ // Stealth patches for headless Chromium to avoid bot detection.
4
+ // Three modes controlled by LEAP_STEALTH env var:
5
+ //
6
+ // LEAP_STEALTH=true (default) — full stealth: automation removal + identity faking
7
+ // LEAP_STEALTH=passive — passive only: remove automation signals, do NOT fake identity
8
+ // LEAP_STEALTH=false — stealth completely disabled
9
+ //
10
+ // Passive mode was introduced because advanced fingerprinters (CreepJS) detect
11
+ // INCONSISTENCIES from identity faking (fake plugins, WebGL, platform, etc.)
12
+ // as "lies." A browser that simply removes automation signals without faking
13
+ // identity scores 0% lies / 0% bot, versus 33% lies / 20% bot with full stealth.
14
+ //
15
+ // Standalone module — no cross-dependencies on logger or session-manager.
16
+ //
17
+ // ─── Evasion Index ────────────────────────────────────────────────────────
18
+ //
19
+ // Category A — Automation Signal Removal (PASSIVE + ACTIVE):
20
+ // These remove evidence that a browser is automated.
21
+ // 2. navigator.webdriver = true → init script, re-applied every navigation
22
+ // 14. sourceurl stripping (Playwright injects sourceURL comments)
23
+ // 15. Runtime.enable CDP detection → Error.prepareStackTrace filter
24
+ // - Playwright globals cleanup (__pwInitScripts, __playwright, etc.)
25
+ // - ChromeDriver property removal
26
+ // - framenavigated webdriver re-deletion listener
27
+ //
28
+ // Category B — Identity Faking (ACTIVE ONLY):
29
+ // These create a fake identity. Advanced fingerprinters detect these as lies.
30
+ // 1. HeadlessChrome in Client Hints brands → userAgentData override
31
+ // 4. SwiftShader WebGL vendor/renderer → WebGL1/2 override
32
+ // 5. Connection RTT = 0 → navigator.connection override
33
+ // 7. outerHeight === innerHeight → fake chrome offset (85px)
34
+ // 8. 0 mime types → MimeTypeArray spoof
35
+ // 9. Platform mismatch with custom UA → inferPlatformFromUA()
36
+ // 10. chrome.app emulation
37
+ // 11. iframe contentWindow protection
38
+ // 12. media codecs spoofing (canPlayType override)
39
+ // 13. document.hasFocus() override
40
+ // 16. Permissions.prototype.query override
41
+ // 17. AudioContext fingerprint noise
42
+ // 18. WebRTC IP filtering
43
+ // 19. Font enumeration spoofing
44
+ // - navigator.plugins spoofing (5 fake plugins)
45
+ // - navigator.languages override
46
+ // - navigator.hardwareConcurrency/deviceMemory spoofing
47
+ // - Notification.permission override
48
+ // - Canvas fingerprint noise (session-seeded PRNG)
49
+ // - chrome.runtime/loadTimes/csi emulation
50
+ // - Worker/SharedWorker UA leak prevention
51
+ //
52
+ // Already passing (do not touch):
53
+ // - UA string looks like real Chrome 136
54
+ // - window.chrome present, 5 plugins, languages correct
55
+ // - No Selenium/PhantomJS markers (all 17 absent)
56
+ // - DevTools Protocol not detected
57
+ // - Canvas fingerprint consistent across iframes
58
+ // - Battery API realistic
59
+ // ── rebrowser-patches integration note ────────────────────────────────────
60
+ // rebrowser-patches (https://github.com/nicedoc/rebrowser-patches) patches
61
+ // Playwright's CDP layer to avoid Runtime.enable detection. If we integrate
62
+ // it in the future, the entry point is:
63
+ // 1. Replace playwright-core import with rebrowser-playwright
64
+ // 2. Call `rebrowser.patch()` before any browser launch in session-manager
65
+ // 3. The CDP stealth script (getCdpStealthScript) would become redundant
66
+ // Evaluate as a dependency change in a dedicated PR — do not install inline.
67
+ // ──────────────────────────────────────────────────────────────────────────
68
+ export class StealthMode {
69
+ /**
70
+ * Strip sourceURL comments from init script text.
71
+ * Playwright injects `//# sourceURL=__playwright_evaluation_script__` into
72
+ * eval'd scripts which bot detectors look for. This strips any sourceURL
73
+ * or sourceMappingURL directives from our init scripts before injection.
74
+ */
75
+ sanitizeSourceURL(script) {
76
+ return script.replace(/\/\/[#@]\s*source(Mapping)?URL\s*=\s*[^\n]*/g, '');
77
+ }
78
+ /**
79
+ * Chromium launch args that reduce automation fingerprinting.
80
+ *
81
+ * In passive mode, only automation-hiding args are included.
82
+ * In active mode, GPU-related args for WebGL faking are also added.
83
+ */
84
+ getLaunchArgs() {
85
+ const mode = this.getMode();
86
+ // Category A args — remove automation signals (passive + active)
87
+ const args = [
88
+ // P0 #2: Suppress navigator.webdriver at Blink level
89
+ "--disable-blink-features=AutomationControlled",
90
+ // BUG-003: Disable AutomationControlled feature flag to prevent
91
+ // "HeadlessChrome" from appearing in Client Hints brands
92
+ "--disable-features=AutomationControlled",
93
+ // Standard headless-mode hardening
94
+ "--disable-dev-shm-usage",
95
+ "--no-first-run",
96
+ "--no-default-browser-check",
97
+ ];
98
+ // Category B args — GPU/identity faking (active only)
99
+ if (mode === 'active') {
100
+ // P1 #4: Force real GPU rendering instead of SwiftShader
101
+ // SwiftShader shows "Google SwiftShader" in UNMASKED_VENDOR/RENDERER
102
+ args.push("--use-gl=angle", "--use-angle=default");
103
+ }
104
+ return args;
105
+ }
106
+ /**
107
+ * Realistic Chrome user agent string for macOS.
108
+ */
109
+ getDefaultUserAgent() {
110
+ return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
111
+ }
112
+ /**
113
+ * Infer the correct navigator.platform value from a user-agent string.
114
+ * Prevents P2 #9: platform mismatch when custom UA says Windows but
115
+ * platform says MacIntel.
116
+ */
117
+ inferPlatformFromUA(ua) {
118
+ if (/Windows/i.test(ua))
119
+ return "Win32";
120
+ if (/Macintosh|Mac OS X/i.test(ua))
121
+ return "MacIntel";
122
+ if (/Linux/i.test(ua))
123
+ return "Linux x86_64";
124
+ if (/CrOS/i.test(ua))
125
+ return "Linux x86_64";
126
+ if (/Android/i.test(ua))
127
+ return "Linux armv8l";
128
+ if (/iPhone|iPad/i.test(ua))
129
+ return "iPhone";
130
+ return "MacIntel"; // safe default
131
+ }
132
+ /**
133
+ * Returns a random delay in ms (200-500) for dialog auto-dismiss.
134
+ * P1 #6: Instant dismiss (< 30ms) is a headless signal.
135
+ * Call this from dialog handlers in session-manager and tab-manager.
136
+ */
137
+ getDialogDelay() {
138
+ return 200 + Math.floor(Math.random() * 300);
139
+ }
140
+ /**
141
+ * Derive the correct navigator.platform string from process.platform.
142
+ * Used as the fallback when no UA string is available to infer from.
143
+ */
144
+ getPlatformFromProcess() {
145
+ switch (process.platform) {
146
+ case "win32": return "Win32";
147
+ case "darwin": return "MacIntel";
148
+ case "linux": return "Linux x86_64";
149
+ default: return "MacIntel";
150
+ }
151
+ }
152
+ /**
153
+ * Extract the Chrome major version number from a UA string.
154
+ * Falls back to 136 (current default UA) if not found.
155
+ */
156
+ extractChromeMajorVersion(ua) {
157
+ const match = ua.match(/Chrome\/(\d+)\./);
158
+ return match ? parseInt(match[1], 10) : 136;
159
+ }
160
+ /**
161
+ * Generate a numeric seed from a string (simple djb2 hash).
162
+ * Used to seed per-session PRNG for canvas/audio noise determinism.
163
+ */
164
+ hashSeed(str) {
165
+ let hash = 5381;
166
+ for (let i = 0; i < str.length; i++) {
167
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
168
+ }
169
+ return Math.abs(hash);
170
+ }
171
+ // ────────────────────────────────────────────────────────────────────────
172
+ // Init Script — Split into Passive (Category A) and Active (Category B)
173
+ // ────────────────────────────────────────────────────────────────────────
174
+ /**
175
+ * Category A: Passive init script — removes automation signals only.
176
+ * These patches make an automated browser look like a non-automated browser
177
+ * WITHOUT faking its identity. Safe against advanced fingerprinters like CreepJS
178
+ * because they don't introduce detectable inconsistencies ("lies").
179
+ *
180
+ * Patches included:
181
+ * - Playwright globals cleanup (__pwInitScripts, __playwright, etc.)
182
+ * - navigator.webdriver deletion (P0 #2)
183
+ * - ChromeDriver property removal
184
+ * - sourceURL stripping (#14)
185
+ */
186
+ getPassiveInitScript() {
187
+ return `
188
+ // ────────────────────────────────────────────────────────────────────
189
+ // BUG FIX: __pwInitScripts race condition
190
+ // Delete Playwright automation globals IMMEDIATELY at the top of the
191
+ // very first init script. This closes the window where page JS or
192
+ // subsequent init scripts could observe __pwInitScripts before the
193
+ // separate cleanup script runs. Critical for v0.6.0 which adds 3+
194
+ // more init scripts, widening the race window.
195
+ // ────────────────────────────────────────────────────────────────────
196
+ (function() {
197
+ var globals = ['__pwInitScripts', '__playwright__binding__', '__playwright'];
198
+ for (var i = 0; i < globals.length; i++) {
199
+ try { delete window[globals[i]]; } catch(e) {}
200
+ try {
201
+ Object.defineProperty(window, globals[i], {
202
+ get: function() { return undefined; },
203
+ set: function() {},
204
+ configurable: true,
205
+ });
206
+ } catch(e) {}
207
+ }
208
+ })();
209
+
210
+ // ────────────────────────────────────────────────────────────────────
211
+ // P0 #2: Hide navigator.webdriver
212
+ // BUG-004: Use delete + defineProperty + prototype patch for full coverage.
213
+ // Some detection scripts check the prototype directly or use 'in' operator.
214
+ // The --disable-blink-features=AutomationControlled launch arg should
215
+ // handle this, but QA found it does not always take effect. This
216
+ // redundant override ensures webdriver is hidden on every navigation.
217
+ // ────────────────────────────────────────────────────────────────────
218
+ try {
219
+ delete Object.getPrototypeOf(navigator).webdriver;
220
+ delete Navigator.prototype.webdriver;
221
+ delete navigator.webdriver;
222
+ } catch (e) { /* already deleted or non-configurable */ }
223
+ if ('webdriver' in navigator) {
224
+ try {
225
+ Object.defineProperty(navigator, 'webdriver', {
226
+ get: () => undefined,
227
+ configurable: true,
228
+ enumerable: false,
229
+ });
230
+ } catch (e) { /* best effort */ }
231
+ }
232
+
233
+ // ────────────────────────────────────────────────────────────────────
234
+ // Remove ChromeDriver detection property
235
+ // ────────────────────────────────────────────────────────────────────
236
+ delete window.cdc_adoQpoasnfa76pfcZLmcfl_;
237
+
238
+ // ────────────────────────────────────────────────────────────────────
239
+ // #14: sourceurl stripping
240
+ // Playwright injects //# sourceURL= comments into evaluated scripts.
241
+ // Detection sites look for these as automation fingerprints.
242
+ // We override Error.prepareStackTrace to strip sourceURL references.
243
+ // ────────────────────────────────────────────────────────────────────
244
+ (function() {
245
+ const origPrepareStackTrace = Error.prepareStackTrace;
246
+ Error.prepareStackTrace = function(error, callSites) {
247
+ if (origPrepareStackTrace) {
248
+ return origPrepareStackTrace(error, callSites);
249
+ }
250
+ const stack = callSites
251
+ .filter(site => {
252
+ const filename = site.getFileName() || '';
253
+ return !filename.startsWith('pptr:') &&
254
+ !filename.includes('__playwright') &&
255
+ !filename.includes('sourceURL');
256
+ })
257
+ .map(site => ' at ' + site.toString())
258
+ .join('\\n');
259
+ return error.toString() + '\\n' + stack;
260
+ };
261
+ })();
262
+ `;
263
+ }
264
+ /**
265
+ * Category B: Active init script — identity faking patches.
266
+ * These create a fake browser identity (plugins, WebGL, platform, etc.).
267
+ * Advanced fingerprinters like CreepJS can detect these as "lies" because
268
+ * they create inconsistencies between reported and actual values.
269
+ *
270
+ * Only applied when LEAP_STEALTH=true (active mode, the default).
271
+ *
272
+ * Patches included:
273
+ * - Session-seeded PRNG (mulberry32)
274
+ * - Client Hints brands override (P0 #1)
275
+ * - chrome.app/runtime/loadTimes/csi emulation (#10)
276
+ * - Permissions.prototype.query override (P2 #16)
277
+ * - navigator.plugins spoofing (5 fake plugins)
278
+ * - MimeTypeArray spoof (P2 #8)
279
+ * - navigator.languages override
280
+ * - navigator.platform override (P2 #9)
281
+ * - hardwareConcurrency/deviceMemory spoofing
282
+ * - Notification.permission override
283
+ * - WebGL vendor/renderer override (P1 #4)
284
+ * - Connection RTT override (P1 #5)
285
+ * - outerHeight/outerWidth fake (P2 #7)
286
+ * - document.hasFocus() override (#13)
287
+ * - Media codecs spoofing (#12)
288
+ * - Canvas fingerprint noise (Phase 2.2)
289
+ * - AudioContext fingerprint noise (P3 #17)
290
+ * - WebRTC IP filtering (P3 #18)
291
+ * - Font enumeration spoofing (P3 #19)
292
+ * - iframe contentWindow protection (#11)
293
+ * - Worker/SharedWorker UA leak prevention
294
+ */
295
+ getActiveInitScript(platform, ua, fingerprint, sessionSeed) {
296
+ const chromeVersion = ua
297
+ ? this.extractChromeMajorVersion(ua)
298
+ : this.extractChromeMajorVersion(this.getDefaultUserAgent());
299
+ // Phase 2.1/2.5: Use fingerprint values when available, otherwise defaults
300
+ const webglVendor = fingerprint?.webgl?.vendor ?? 'Google Inc. (Apple)';
301
+ const webglRenderer = fingerprint?.webgl?.renderer ?? 'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)';
302
+ const deviceMemory = fingerprint?.deviceMemory ?? 8;
303
+ const hardwareConcurrency = fingerprint?.hardwareConcurrency ?? 8;
304
+ // Phase 2.2/2.3: Session seed for deterministic PRNG (canvas + audio noise)
305
+ const seed = sessionSeed ?? this.hashSeed(ua ?? 'default-session');
306
+ return `
307
+ // ────────────────────────────────────────────────────────────────────
308
+ // Phase 2.2/2.3: Session-seeded PRNG (mulberry32)
309
+ // Deterministic within a session — same canvas/audio operations produce
310
+ // identical output. Prevents tampering detection from non-deterministic noise.
311
+ // ────────────────────────────────────────────────────────────────────
312
+ var __leapSeed = ${seed} >>> 0;
313
+ function __leapRandom() {
314
+ __leapSeed |= 0; __leapSeed = __leapSeed + 0x6D2B79F5 | 0;
315
+ var t = Math.imul(__leapSeed ^ __leapSeed >>> 15, 1 | __leapSeed);
316
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
317
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
318
+ }
319
+
320
+ // ────────────────────────────────────────────────────────────────────
321
+ // P0 #1 + BUG-003 (consolidated): Client Hints brands override
322
+ // Phase 2.6: Merged two overlapping patches into one clean replacement
323
+ // of the entire NavigatorUAData interface.
324
+ // ────────────────────────────────────────────────────────────────────
325
+ if (navigator.userAgentData) {
326
+ const cleanBrands = [
327
+ { brand: "Chromium", version: "${chromeVersion}" },
328
+ { brand: "Google Chrome", version: "${chromeVersion}" },
329
+ { brand: "Not_A Brand", version: "24" },
330
+ ];
331
+ const cleanFullBrands = [
332
+ { brand: "Chromium", version: "${chromeVersion}.0.0.0" },
333
+ { brand: "Google Chrome", version: "${chromeVersion}.0.0.0" },
334
+ { brand: "Not_A Brand", version: "24.0.0.0" },
335
+ ];
336
+
337
+ const cleanUAData = {
338
+ brands: cleanBrands,
339
+ mobile: false,
340
+ platform: ${JSON.stringify(platform.startsWith("Win") ? "Windows" : platform.startsWith("Mac") ? "macOS" : platform.startsWith("Linux") ? "Linux" : "macOS")},
341
+ getHighEntropyValues: function(hints) {
342
+ return Promise.resolve({
343
+ brands: cleanFullBrands,
344
+ mobile: false,
345
+ platform: this.platform,
346
+ platformVersion: ${JSON.stringify(platform.startsWith("Win") ? "10.0.0" : platform.startsWith("Mac") ? "15.0.0" : "6.6.0")},
347
+ architecture: "x86",
348
+ bitness: "64",
349
+ model: "",
350
+ uaFullVersion: "${chromeVersion}.0.0.0",
351
+ fullVersionList: cleanFullBrands,
352
+ wow64: false,
353
+ });
354
+ },
355
+ toJSON: function() {
356
+ return {
357
+ brands: cleanBrands,
358
+ mobile: false,
359
+ platform: this.platform,
360
+ };
361
+ },
362
+ };
363
+
364
+ Object.defineProperty(navigator, 'userAgentData', {
365
+ get: () => cleanUAData,
366
+ configurable: true,
367
+ });
368
+ }
369
+
370
+ // ────────────────────────────────────────────────────────────────────
371
+ // Fake window.chrome object (already passing — preserve)
372
+ // Includes chrome.app emulation (puppeteer-stealth module #1)
373
+ // ────────────────────────────────────────────────────────────────────
374
+ if (!window.chrome) {
375
+ Object.defineProperty(window, 'chrome', {
376
+ value: {},
377
+ configurable: true,
378
+ writable: true,
379
+ });
380
+ }
381
+
382
+ // #10: chrome.app emulation — real Chrome always has this object
383
+ if (!window.chrome.app) {
384
+ window.chrome.app = {
385
+ isInstalled: false,
386
+ InstallState: {
387
+ DISABLED: 'disabled',
388
+ INSTALLED: 'installed',
389
+ NOT_INSTALLED: 'not_installed',
390
+ },
391
+ RunningState: {
392
+ CANNOT_RUN: 'cannot_run',
393
+ READY_TO_RUN: 'ready_to_run',
394
+ RUNNING: 'running',
395
+ },
396
+ getDetails: function() { return null; },
397
+ getIsInstalled: function() { return false; },
398
+ installState: function(callback) {
399
+ if (typeof callback === 'function') {
400
+ callback('not_installed');
401
+ }
402
+ return 'not_installed';
403
+ },
404
+ runningState: function() { return 'cannot_run'; },
405
+ };
406
+ }
407
+
408
+ // chrome.runtime — already passing, preserve exact shape
409
+ if (!window.chrome.runtime) {
410
+ window.chrome.runtime = {
411
+ onConnect: { addListener() {}, removeListener() {} },
412
+ onMessage: { addListener() {}, removeListener() {} },
413
+ connect() { return { onDisconnect: { addListener() {} } }; },
414
+ sendMessage() {},
415
+ };
416
+ }
417
+
418
+ // chrome.loadTimes — already passing, preserve
419
+ if (!window.chrome.loadTimes) {
420
+ window.chrome.loadTimes = function() {
421
+ return {
422
+ commitLoadTime: Date.now() / 1000 - 2,
423
+ connectionInfo: 'h2',
424
+ finishDocumentLoadTime: Date.now() / 1000 - 0.5,
425
+ finishLoadTime: Date.now() / 1000 - 0.1,
426
+ firstPaintAfterLoadTime: 0,
427
+ firstPaintTime: Date.now() / 1000 - 1.5,
428
+ navigationType: 'Other',
429
+ npnNegotiatedProtocol: 'h2',
430
+ requestTime: Date.now() / 1000 - 2.5,
431
+ startLoadTime: Date.now() / 1000 - 2,
432
+ wasAlternateProtocolAvailable: false,
433
+ wasFetchedViaSpdy: true,
434
+ wasNpnNegotiated: true,
435
+ };
436
+ };
437
+ }
438
+
439
+ // chrome.csi — already passing, preserve
440
+ if (!window.chrome.csi) {
441
+ window.chrome.csi = function() {
442
+ return {
443
+ onloadT: Date.now(),
444
+ pageT: Date.now() - performance.timing.navigationStart,
445
+ startE: performance.timing.navigationStart,
446
+ tran: 15,
447
+ };
448
+ };
449
+ }
450
+
451
+ // ────────────────────────────────────────────────────────────────────
452
+ // P2: Comprehensive Permissions.prototype.query override
453
+ // Bot detection scripts query various permissions and check for
454
+ // unexpected throws or inconsistent states. Real browsers return
455
+ // "prompt" for most permissions and "granted" for geolocation.
456
+ // Defends against: CreepJS permissions fingerprint, FingerprintJS Pro
457
+ // ────────────────────────────────────────────────────────────────────
458
+ (function() {
459
+ const originalQuery = window.Permissions.prototype.query;
460
+ const promptPermissions = [
461
+ 'notifications', 'push', 'midi', 'camera', 'microphone',
462
+ 'speaker', 'device-info', 'background-fetch', 'background-sync',
463
+ 'bluetooth', 'persistent-storage', 'ambient-light-sensor',
464
+ 'accelerometer', 'gyroscope', 'magnetometer', 'screen-wake-lock',
465
+ 'nfc', 'display-capture', 'idle-detection', 'periodic-background-sync'
466
+ ];
467
+ window.Permissions.prototype.query = function(desc) {
468
+ if (promptPermissions.includes(desc.name)) {
469
+ return Promise.resolve({ state: 'prompt', onchange: null });
470
+ }
471
+ if (desc.name === 'geolocation') {
472
+ return Promise.resolve({ state: 'granted', onchange: null });
473
+ }
474
+ return originalQuery.call(this, desc);
475
+ };
476
+ })();
477
+
478
+ // ────────────────────────────────────────────────────────────────────
479
+ // Fix navigator.plugins (5 fake plugins)
480
+ // ────────────────────────────────────────────────────────────────────
481
+ Object.defineProperty(navigator, 'plugins', {
482
+ get: () => {
483
+ const fakePlugins = [
484
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
485
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
486
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
487
+ { name: 'Chromium PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
488
+ { name: 'Chromium PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
489
+ ];
490
+ const pluginArray = Object.create(PluginArray.prototype);
491
+ for (let i = 0; i < fakePlugins.length; i++) {
492
+ const p = Object.create(Plugin.prototype);
493
+ Object.defineProperties(p, {
494
+ name: { value: fakePlugins[i].name, enumerable: true },
495
+ filename: { value: fakePlugins[i].filename, enumerable: true },
496
+ description: { value: fakePlugins[i].description, enumerable: true },
497
+ length: { value: 0, enumerable: true },
498
+ });
499
+ pluginArray[i] = p;
500
+ }
501
+ Object.defineProperty(pluginArray, 'length', { value: fakePlugins.length });
502
+ pluginArray.item = (index) => pluginArray[index] || null;
503
+ pluginArray.namedItem = (name) => {
504
+ for (let i = 0; i < fakePlugins.length; i++) {
505
+ if (pluginArray[i].name === name) return pluginArray[i];
506
+ }
507
+ return null;
508
+ };
509
+ pluginArray.refresh = () => {};
510
+ return pluginArray;
511
+ },
512
+ configurable: true,
513
+ });
514
+
515
+ // ────────────────────────────────────────────────────────────────────
516
+ // P2 #8: Spoof MimeTypeArray (headless reports 0 mime types)
517
+ // Real Chrome always has at least 2 PDF-related mime types.
518
+ // ────────────────────────────────────────────────────────────────────
519
+ Object.defineProperty(navigator, 'mimeTypes', {
520
+ get: () => {
521
+ const fakeMimes = [
522
+ { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format', enabledPlugin: navigator.plugins[0] },
523
+ { type: 'text/pdf', suffixes: 'pdf', description: 'Portable Document Format', enabledPlugin: navigator.plugins[0] },
524
+ ];
525
+ const mimeArray = Object.create(MimeTypeArray.prototype);
526
+ for (let i = 0; i < fakeMimes.length; i++) {
527
+ const m = Object.create(MimeType.prototype);
528
+ Object.defineProperties(m, {
529
+ type: { value: fakeMimes[i].type, enumerable: true },
530
+ suffixes: { value: fakeMimes[i].suffixes, enumerable: true },
531
+ description: { value: fakeMimes[i].description, enumerable: true },
532
+ enabledPlugin: { value: fakeMimes[i].enabledPlugin, enumerable: true },
533
+ });
534
+ mimeArray[i] = m;
535
+ mimeArray[fakeMimes[i].type] = m;
536
+ }
537
+ Object.defineProperty(mimeArray, 'length', { value: fakeMimes.length });
538
+ mimeArray.item = (index) => mimeArray[index] || null;
539
+ mimeArray.namedItem = (name) => mimeArray[name] || null;
540
+ return mimeArray;
541
+ },
542
+ configurable: true,
543
+ });
544
+
545
+ // ────────────────────────────────────────────────────────────────────
546
+ // Fix navigator.languages
547
+ // ────────────────────────────────────────────────────────────────────
548
+ Object.defineProperty(navigator, 'languages', {
549
+ get: () => ['en-US', 'en'],
550
+ configurable: true,
551
+ });
552
+
553
+ // ────────────────────────────────────────────────────────────────────
554
+ // P2 #9: Override navigator.platform — uses UA-inferred value
555
+ // Prevents mismatch when custom UA says Windows but platform says MacIntel.
556
+ // ────────────────────────────────────────────────────────────────────
557
+ Object.defineProperty(navigator, 'platform', {
558
+ get: () => ${JSON.stringify(platform)},
559
+ configurable: true,
560
+ });
561
+
562
+ // ────────────────────────────────────────────────────────────────────
563
+ // Phase 2.5: hardwareConcurrency from per-session fingerprint
564
+ // ────────────────────────────────────────────────────────────────────
565
+ Object.defineProperty(navigator, 'hardwareConcurrency', {
566
+ get: () => ${hardwareConcurrency},
567
+ configurable: true,
568
+ });
569
+
570
+ // ────────────────────────────────────────────────────────────────────
571
+ // Phase 2.5: deviceMemory from per-session fingerprint
572
+ // ────────────────────────────────────────────────────────────────────
573
+ Object.defineProperty(navigator, 'deviceMemory', {
574
+ get: () => ${deviceMemory},
575
+ configurable: true,
576
+ });
577
+
578
+ // ────────────────────────────────────────────────────────────────────
579
+ // Notification.permission returns 'default'
580
+ // ────────────────────────────────────────────────────────────────────
581
+ Object.defineProperty(Notification, 'permission', {
582
+ get: () => 'default',
583
+ configurable: true,
584
+ });
585
+
586
+ // ────────────────────────────────────────────────────────────────────
587
+ // P1 #4: WebGL vendor/renderer override (SwiftShader → real GPU)
588
+ // Phase 2.1: WebGL vendor/renderer from per-session fingerprint (9 GPU models)
589
+ // ────────────────────────────────────────────────────────────────────
590
+ (function() {
591
+ const WEBGL_VENDOR = ${JSON.stringify(webglVendor)};
592
+ const WEBGL_RENDERER = ${JSON.stringify(webglRenderer)};
593
+ const UNMASKED_VENDOR_WEBGL = 0x9245;
594
+ const UNMASKED_RENDERER_WEBGL = 0x9246;
595
+
596
+ function patchWebGLContext(proto) {
597
+ if (!proto) return;
598
+ const origGetParameter = proto.getParameter;
599
+ proto.getParameter = function(param) {
600
+ if (param === UNMASKED_VENDOR_WEBGL) return WEBGL_VENDOR;
601
+ if (param === UNMASKED_RENDERER_WEBGL) return WEBGL_RENDERER;
602
+ return origGetParameter.call(this, param);
603
+ };
604
+ }
605
+
606
+ if (typeof WebGLRenderingContext !== 'undefined') {
607
+ patchWebGLContext(WebGLRenderingContext.prototype);
608
+ }
609
+ if (typeof WebGL2RenderingContext !== 'undefined') {
610
+ patchWebGLContext(WebGL2RenderingContext.prototype);
611
+ }
612
+ })();
613
+
614
+ // ────────────────────────────────────────────────────────────────────
615
+ // P1 #5: Connection RTT = 0 → override navigator.connection
616
+ // ────────────────────────────────────────────────────────────────────
617
+ if (navigator.connection) {
618
+ const connectionOverrides = {
619
+ rtt: 50,
620
+ downlink: 10,
621
+ effectiveType: '4g',
622
+ saveData: false,
623
+ };
624
+ for (const [key, value] of Object.entries(connectionOverrides)) {
625
+ try {
626
+ Object.defineProperty(navigator.connection, key, {
627
+ get: () => value,
628
+ configurable: true,
629
+ });
630
+ } catch (e) {
631
+ // Some browsers seal this — graceful skip
632
+ }
633
+ }
634
+ }
635
+
636
+ // ────────────────────────────────────────────────────────────────────
637
+ // P2 #7: outerHeight === innerHeight → headless has no chrome
638
+ // ────────────────────────────────────────────────────────────────────
639
+ (function() {
640
+ const chromeHeight = 85;
641
+ const chromeWidth = 0;
642
+
643
+ Object.defineProperty(window, 'outerHeight', {
644
+ get: () => window.innerHeight + chromeHeight,
645
+ configurable: true,
646
+ });
647
+ Object.defineProperty(window, 'outerWidth', {
648
+ get: () => window.innerWidth + chromeWidth,
649
+ configurable: true,
650
+ });
651
+ })();
652
+
653
+ // ────────────────────────────────────────────────────────────────────
654
+ // #13: document.hasFocus() override
655
+ // ────────────────────────────────────────────────────────────────────
656
+ Document.prototype.hasFocus = function() {
657
+ return true;
658
+ };
659
+
660
+ // ────────────────────────────────────────────────────────────────────
661
+ // #12: Media codecs spoofing (canPlayType override)
662
+ // ────────────────────────────────────────────────────────────────────
663
+ (function() {
664
+ const codecResponses = {
665
+ 'audio/mpeg': 'probably',
666
+ 'audio/wav': 'probably',
667
+ 'audio/ogg; codecs="vorbis"': 'probably',
668
+ 'audio/mp4; codecs="mp4a.40.2"': 'probably',
669
+ 'audio/webm; codecs="opus"': 'probably',
670
+ 'video/mp4; codecs="avc1.42E01E"': 'probably',
671
+ 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"': 'probably',
672
+ 'video/webm; codecs="vp8"': 'probably',
673
+ 'video/webm; codecs="vp8, vorbis"': 'probably',
674
+ 'video/webm; codecs="vp9"': 'probably',
675
+ 'video/ogg; codecs="theora"': 'probably',
676
+ };
677
+
678
+ const origCanPlayType = HTMLMediaElement.prototype.canPlayType;
679
+ HTMLMediaElement.prototype.canPlayType = function(type) {
680
+ if (codecResponses[type]) return codecResponses[type];
681
+ return origCanPlayType.call(this, type);
682
+ };
683
+ })();
684
+
685
+ // ────────────────────────────────────────────────────────────────────
686
+ // Phase 2.2: Canvas fingerprint noise — session-seeded PRNG
687
+ // ────────────────────────────────────────────────────────────────────
688
+ const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
689
+ HTMLCanvasElement.prototype.toDataURL = function(type, quality) {
690
+ const ctx = this.getContext('2d');
691
+ if (ctx && this.width > 0 && this.height > 0) {
692
+ try {
693
+ const imageData = ctx.getImageData(0, 0, Math.min(this.width, 16), Math.min(this.height, 16));
694
+ const data = imageData.data;
695
+ var canvasSeed = ${seed} >>> 0;
696
+ for (var ci = 0; ci < Math.min(data.length, 32); ci++) { canvasSeed = (canvasSeed + data[ci]) | 0; }
697
+ function canvasRandom() {
698
+ canvasSeed |= 0; canvasSeed = canvasSeed + 0x6D2B79F5 | 0;
699
+ var t = Math.imul(canvasSeed ^ canvasSeed >>> 15, 1 | canvasSeed);
700
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
701
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
702
+ }
703
+ for (let i = 0; i < Math.min(data.length, 64); i += 16) {
704
+ const channel = i + Math.floor(canvasRandom() * 3);
705
+ const delta = canvasRandom() > 0.5 ? 1 : -1;
706
+ data[channel] = Math.max(0, Math.min(255, data[channel] + delta));
707
+ }
708
+ ctx.putImageData(imageData, 0, 0);
709
+ } catch (e) {
710
+ // SecurityError from cross-origin canvas — skip silently
711
+ }
712
+ }
713
+ return origToDataURL.call(this, type, quality);
714
+ };
715
+
716
+ // ────────────────────────────────────────────────────────────────────
717
+ // P3: AudioContext fingerprint noise — session-seeded PRNG
718
+ // ────────────────────────────────────────────────────────────────────
719
+ (function() {
720
+ var audioSeed = ${seed} >>> 0;
721
+ function audioRandom() {
722
+ audioSeed |= 0; audioSeed = audioSeed + 0x6D2B79F5 | 0;
723
+ var t = Math.imul(audioSeed ^ audioSeed >>> 15, 1 | audioSeed);
724
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
725
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
726
+ }
727
+
728
+ const originalGetChannelData = AudioBuffer.prototype.getChannelData;
729
+ AudioBuffer.prototype.getChannelData = function(channel) {
730
+ const data = originalGetChannelData.call(this, channel);
731
+ if (this.length < 4096 && this.sampleRate === 44100) {
732
+ audioSeed = (${seed} + channel * 7919) >>> 0;
733
+ for (let i = 0; i < data.length; i++) {
734
+ data[i] += (audioRandom() * 0.0002 - 0.0001);
735
+ }
736
+ }
737
+ return data;
738
+ };
739
+
740
+ if (typeof AnalyserNode !== 'undefined') {
741
+ const originalGetFloat = AnalyserNode.prototype.getFloatFrequencyData;
742
+ AnalyserNode.prototype.getFloatFrequencyData = function(array) {
743
+ originalGetFloat.call(this, array);
744
+ audioSeed = ${seed} >>> 0;
745
+ for (let i = 0; i < array.length; i++) {
746
+ array[i] += (audioRandom() * 0.1 - 0.05);
747
+ }
748
+ };
749
+ }
750
+ })();
751
+
752
+ // ────────────────────────────────────────────────────────────────────
753
+ // P3: WebRTC leak prevention
754
+ // ────────────────────────────────────────────────────────────────────
755
+ (function() {
756
+ const originalRTC = window.RTCPeerConnection;
757
+ if (originalRTC) {
758
+ window.RTCPeerConnection = function(config, constraints) {
759
+ if (config && config.iceServers) {
760
+ // Allow configured TURN servers
761
+ } else {
762
+ config = config || {};
763
+ config.iceServers = [];
764
+ }
765
+ const pc = new originalRTC(config, constraints);
766
+ const originalAddEvent = pc.addEventListener.bind(pc);
767
+ pc.addEventListener = function(type, listener, options) {
768
+ if (type === 'icecandidate') {
769
+ const wrappedListener = function(event) {
770
+ if (event.candidate && event.candidate.candidate) {
771
+ if (/((10\\.)|(172\\.(1[6-9]|2\\d|3[01])\\.)|(192\\.168\\.))/.test(event.candidate.candidate)) {
772
+ return;
773
+ }
774
+ }
775
+ listener.call(this, event);
776
+ };
777
+ return originalAddEvent(type, wrappedListener, options);
778
+ }
779
+ return originalAddEvent(type, listener, options);
780
+ };
781
+ return pc;
782
+ };
783
+ window.RTCPeerConnection.prototype = originalRTC.prototype;
784
+ Object.keys(originalRTC).forEach(function(key) {
785
+ window.RTCPeerConnection[key] = originalRTC[key];
786
+ });
787
+ }
788
+ })();
789
+
790
+ // ────────────────────────────────────────────────────────────────────
791
+ // P3: Font enumeration spoofing
792
+ // ────────────────────────────────────────────────────────────────────
793
+ (function() {
794
+ var standardFonts = ['Arial', 'Arial Black', 'Comic Sans MS', 'Courier New', 'Georgia', 'Impact', 'Times New Roman', 'Trebuchet MS', 'Verdana', 'Lucida Console', 'Tahoma', 'Palatino Linotype'];
795
+ if (document.fonts && document.fonts.check) {
796
+ var originalCheck = document.fonts.check.bind(document.fonts);
797
+ document.fonts.check = function(font, text) {
798
+ var fontName = font.replace(/[\\d.]+px\\s*/, '').replace(/["']/g, '').trim();
799
+ if (standardFonts.some(function(f) { return fontName.toLowerCase().includes(f.toLowerCase()); })) {
800
+ return true;
801
+ }
802
+ var hash = 0;
803
+ for (var i = 0; i < fontName.length; i++) {
804
+ hash = ((hash << 5) - hash + fontName.charCodeAt(i)) | 0;
805
+ }
806
+ return (Math.abs(hash) % 100) < 70;
807
+ };
808
+ }
809
+ })();
810
+
811
+ // ────────────────────────────────────────────────────────────────────
812
+ // #11: iframe contentWindow protection
813
+ // ────────────────────────────────────────────────────────────────────
814
+ (function() {
815
+ const origHTMLIFrameElement = Object.getOwnPropertyDescriptor(
816
+ HTMLIFrameElement.prototype, 'contentWindow'
817
+ );
818
+ if (origHTMLIFrameElement && origHTMLIFrameElement.get) {
819
+ Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
820
+ get: function() {
821
+ const win = origHTMLIFrameElement.get.call(this);
822
+ if (win) {
823
+ try {
824
+ if ('webdriver' in win.navigator) {
825
+ delete Object.getPrototypeOf(win.navigator).webdriver;
826
+ }
827
+ } catch (e) {
828
+ // Cross-origin iframe — cannot access, which is fine
829
+ }
830
+ }
831
+ return win;
832
+ },
833
+ configurable: true,
834
+ });
835
+ }
836
+ })();
837
+
838
+ // ────────────────────────────────────────────────────────────────────
839
+ // BUG-3: Worker/SharedWorker UA leak prevention
840
+ // ────────────────────────────────────────────────────────────────────
841
+ (function() {
842
+ var targetPlatform = ${JSON.stringify(platform)};
843
+
844
+ function buildWorkerPreamble() {
845
+ return 'Object.defineProperty(self.navigator, "webdriver", { get: function() { return undefined; }, configurable: true, enumerable: false });' +
846
+ 'try { Object.defineProperty(self.navigator, "platform", { get: function() { return "' + targetPlatform + '"; }, configurable: true }); } catch(e) {}';
847
+ }
848
+
849
+ if (typeof Worker !== 'undefined') {
850
+ var OriginalWorker = Worker;
851
+ var preamble = buildWorkerPreamble();
852
+ window.Worker = function(scriptURL, options) {
853
+ var url = String(scriptURL);
854
+ var isModule = options && options.type === 'module';
855
+ if (!isModule && !url.startsWith('blob:')) {
856
+ try {
857
+ var wrapperCode = preamble + ';importScripts("' + url.replace(/"/g, '\\\\"') + '");';
858
+ var blob = new Blob([wrapperCode], { type: 'application/javascript' });
859
+ var blobURL = URL.createObjectURL(blob);
860
+ var worker = new OriginalWorker(blobURL, options);
861
+ URL.revokeObjectURL(blobURL);
862
+ return worker;
863
+ } catch(e) {
864
+ // Fall through to original constructor on any error
865
+ }
866
+ }
867
+ return new OriginalWorker(scriptURL, options);
868
+ };
869
+ window.Worker.prototype = OriginalWorker.prototype;
870
+ try {
871
+ Object.defineProperty(window.Worker, 'length', { value: OriginalWorker.length });
872
+ Object.defineProperty(window.Worker, 'name', { value: 'Worker' });
873
+ } catch(e) {}
874
+ }
875
+
876
+ if (typeof SharedWorker !== 'undefined') {
877
+ var OriginalSharedWorker = SharedWorker;
878
+ var sharedPreamble = buildWorkerPreamble();
879
+ window.SharedWorker = function(scriptURL, options) {
880
+ var url = String(scriptURL);
881
+ var nameOrOpts = options;
882
+ var isModule = nameOrOpts && typeof nameOrOpts === 'object' && nameOrOpts.type === 'module';
883
+ if (!isModule && !url.startsWith('blob:')) {
884
+ try {
885
+ var wrapperCode = sharedPreamble + ';importScripts("' + url.replace(/"/g, '\\\\"') + '");';
886
+ var blob = new Blob([wrapperCode], { type: 'application/javascript' });
887
+ var blobURL = URL.createObjectURL(blob);
888
+ var worker = new OriginalSharedWorker(blobURL, nameOrOpts);
889
+ URL.revokeObjectURL(blobURL);
890
+ return worker;
891
+ } catch(e) {}
892
+ }
893
+ return new OriginalSharedWorker(scriptURL, nameOrOpts);
894
+ };
895
+ window.SharedWorker.prototype = OriginalSharedWorker.prototype;
896
+ try {
897
+ Object.defineProperty(window.SharedWorker, 'length', { value: OriginalSharedWorker.length });
898
+ Object.defineProperty(window.SharedWorker, 'name', { value: 'SharedWorker' });
899
+ } catch(e) {}
900
+ }
901
+ })();
902
+ `;
903
+ }
904
+ /**
905
+ * JavaScript to inject via page.addInitScript() that patches
906
+ * common bot-detection vectors in the page's execution context.
907
+ *
908
+ * This script runs BEFORE page JavaScript on every navigation,
909
+ * including in iframes (Playwright propagates addInitScript to all frames).
910
+ *
911
+ * In passive mode, only Category A (automation signal removal) patches are applied.
912
+ * In active mode, both Category A and Category B (identity faking) patches are applied.
913
+ */
914
+ getInitScript(platform, ua, fingerprint, sessionSeed, modeOverride) {
915
+ const mode = modeOverride ?? this.getMode();
916
+ if (mode === 'off')
917
+ return '';
918
+ // Passive patches always included (both passive and active modes)
919
+ let script = this.getPassiveInitScript();
920
+ // Active patches only in active mode
921
+ if (mode === 'active') {
922
+ const safePlatform = platform ?? this.getPlatformFromProcess();
923
+ script += '\n' + this.getActiveInitScript(safePlatform, ua, fingerprint, sessionSeed);
924
+ }
925
+ return script;
926
+ }
927
+ /**
928
+ * Remove Playwright-specific global variables that bot detection scripts
929
+ * trivially check for. These are injected by Playwright's init script
930
+ * mechanism and are dead giveaways of automation.
931
+ */
932
+ getPlaywrightGlobalsCleanupScript() {
933
+ return `
934
+ // ────────────────────────────────────────────────────────────────────
935
+ // Phase 1.2: Delete Playwright automation globals
936
+ // __pwInitScripts, __playwright__binding__, __playwright are injected
937
+ // by Playwright's bootstrapper and are trivially detectable.
938
+ // ────────────────────────────────────────────────────────────────────
939
+ (function() {
940
+ var globals = ['__pwInitScripts', '__playwright__binding__', '__playwright'];
941
+ for (var i = 0; i < globals.length; i++) {
942
+ try { delete window[globals[i]]; } catch(e) {}
943
+ try {
944
+ Object.defineProperty(window, globals[i], {
945
+ get: function() { return undefined; },
946
+ set: function() {},
947
+ configurable: true,
948
+ });
949
+ } catch(e) {}
950
+ }
951
+ })();
952
+ `;
953
+ }
954
+ /**
955
+ * P1: Runtime.enable CDP detection bypass init script.
956
+ * Default ON — disable with LEAP_CDP_STEALTH=false.
957
+ *
958
+ * When Playwright sends Runtime.enable via CDP, bot detection scripts
959
+ * can observe Playwright/DevTools stack frames in Error.prepareStackTrace.
960
+ * This patch intercepts prepareStackTrace and filters out any frames
961
+ * originating from __playwright, pptr:, or devtools:// — making the
962
+ * stack trace indistinguishable from a real browser.
963
+ *
964
+ * This is separate from the #14 sourceURL stripping patch because it
965
+ * performs a deeper filter (also strips devtools:// frames) and is
966
+ * opt-in due to potential debugging interference.
967
+ *
968
+ * Defends against: Patchright/rebrowser CDP detection, DataDome Runtime.enable check
969
+ */
970
+ getCdpStealthScript() {
971
+ return `
972
+ // ────────────────────────────────────────────────────────────────────
973
+ // P1: Runtime.enable CDP detection bypass (default ON, LEAP_CDP_STEALTH=false to disable)
974
+ // ────────────────────────────────────────────────────────────────────
975
+ (function() {
976
+ const originalPrepareStackTrace = Error.prepareStackTrace;
977
+ Error.prepareStackTrace = function(error, stack) {
978
+ const filtered = stack.filter(function(frame) {
979
+ const fileName = frame.getFileName() || '';
980
+ return !fileName.includes('__playwright') &&
981
+ !fileName.includes('pptr:') &&
982
+ !fileName.includes('devtools://');
983
+ });
984
+ if (originalPrepareStackTrace) {
985
+ return originalPrepareStackTrace(error, filtered);
986
+ }
987
+ return error.toString() + '\\n' + filtered.map(function(f) { return ' at ' + f.toString(); }).join('\\n');
988
+ };
989
+ })();
990
+ `;
991
+ }
992
+ /**
993
+ * Whether CDP stealth mode is enabled.
994
+ * Default ON — disable with LEAP_CDP_STEALTH=false.
995
+ * The Error.prepareStackTrace filter hides Playwright/DevTools stack frames
996
+ * from bot detection scripts. Safe for production; only disable if you need
997
+ * raw stack traces for debugging.
998
+ */
999
+ isCdpStealthEnabled() {
1000
+ return process.env.LEAP_CDP_STEALTH !== "false";
1001
+ }
1002
+ /**
1003
+ * Apply the stealth init script to a Playwright page.
1004
+ * Call this immediately after page creation, before navigating.
1005
+ *
1006
+ * Respects the current stealth mode:
1007
+ * - passive: applies only Category A patches (automation signal removal)
1008
+ * - active: applies Category A + Category B (identity faking)
1009
+ *
1010
+ * Also applies:
1011
+ * - Playwright globals cleanup (__pwInitScripts, __playwright__binding__, __playwright)
1012
+ * - CDP stealth script (default ON, disable via LEAP_CDP_STEALTH=false)
1013
+ */
1014
+ async applyToPage(page, userAgent, fingerprint, modeOverride) {
1015
+ const mode = modeOverride ?? this.getMode();
1016
+ if (mode === 'off')
1017
+ return;
1018
+ // BUG-1 fix: Always infer platform from the UA string that the browser is actually using.
1019
+ const effectiveUA = userAgent ?? this.getDefaultUserAgent();
1020
+ const platform = this.inferPlatformFromUA(effectiveUA);
1021
+ // Derive a session seed from the fingerprint for deterministic PRNG (Phase 2.2/2.3)
1022
+ const sessionSeed = fingerprint
1023
+ ? this.hashSeed(fingerprint.userAgent + fingerprint.webgl.renderer)
1024
+ : this.hashSeed(userAgent ?? 'default-session');
1025
+ // Sanitize sourceURL comments from all init scripts before injection (Phase 1.3)
1026
+ await page.addInitScript(this.sanitizeSourceURL(this.getInitScript(platform, userAgent, fingerprint, sessionSeed, mode)));
1027
+ // Remove trivially detectable Playwright globals that leak automation context
1028
+ await page.addInitScript(this.sanitizeSourceURL(this.getPlaywrightGlobalsCleanupScript()));
1029
+ // P1: CDP stealth (default ON, disable via LEAP_CDP_STEALTH=false)
1030
+ // CDP stealth is Category A (automation signal removal) — applied in both modes
1031
+ if (this.isCdpStealthEnabled()) {
1032
+ await page.addInitScript(this.sanitizeSourceURL(this.getCdpStealthScript()));
1033
+ }
1034
+ // P0 #2 post-navigation fix: Playwright re-adds navigator.webdriver AFTER
1035
+ // init scripts run. This listener deletes it after every frame load so both
1036
+ // `typeof navigator.webdriver === "undefined"` AND `'webdriver' in navigator === false`.
1037
+ // This is Category A — applied in both passive and active modes.
1038
+ page.on("framenavigated", async (frame) => {
1039
+ try {
1040
+ await frame.evaluate(() => {
1041
+ try {
1042
+ delete Object.getPrototypeOf(navigator).webdriver;
1043
+ delete Navigator.prototype.webdriver;
1044
+ delete navigator.webdriver;
1045
+ }
1046
+ catch (e) { /* cross-origin or already deleted */ }
1047
+ });
1048
+ }
1049
+ catch { /* frame may be detached */ }
1050
+ });
1051
+ }
1052
+ /**
1053
+ * Get the current stealth mode.
1054
+ * 'active' — full stealth (automation removal + identity faking). Default.
1055
+ * 'passive' — remove automation signals only, no identity faking.
1056
+ * 'off' — stealth completely disabled.
1057
+ */
1058
+ getMode() {
1059
+ const val = process.env.LEAP_STEALTH?.toLowerCase();
1060
+ if (val === 'false' || val === 'off')
1061
+ return 'off';
1062
+ if (val === 'passive')
1063
+ return 'passive';
1064
+ if (val === 'auto')
1065
+ return 'active'; // auto mode: base is active, bandit overrides per-navigation
1066
+ return 'active'; // default — backwards compatible (true, unset, or any other value)
1067
+ }
1068
+ /**
1069
+ * Whether the stealth mode is bandit-driven (LEAP_STEALTH=auto).
1070
+ * When true, the EXP3 bandit selects the stealth mode per-domain
1071
+ * per-navigation instead of using a fixed mode.
1072
+ *
1073
+ * When LEAP_STEALTH is unset or explicitly set to true/passive/false,
1074
+ * the user's choice takes precedence and the bandit is advisory only
1075
+ * (it still records outcomes for learning, but doesn't override the mode).
1076
+ */
1077
+ isBanditMode() {
1078
+ const val = process.env.LEAP_STEALTH?.toLowerCase();
1079
+ return val === 'auto';
1080
+ }
1081
+ /**
1082
+ * Whether stealth mode is enabled (passive or active).
1083
+ * Returns true unless LEAP_STEALTH=false/off is set.
1084
+ * Backwards compatible — callers that only need to know "is stealth on at all?"
1085
+ * can keep using this method.
1086
+ */
1087
+ isEnabled() {
1088
+ return this.getMode() !== 'off';
1089
+ }
1090
+ /**
1091
+ * Build Sec-CH-UA header value from a Chrome version and platform.
1092
+ * Phase 2.4: These HTTP headers are sent BEFORE JS runs, so they must
1093
+ * be set via extraHTTPHeaders to stay in sync with the JS-side override.
1094
+ */
1095
+ buildSecChUaHeaders(chromeVersion, platform) {
1096
+ let chPlatform = "macOS";
1097
+ if (platform.startsWith("Win"))
1098
+ chPlatform = "Windows";
1099
+ else if (platform.startsWith("Linux"))
1100
+ chPlatform = "Linux";
1101
+ return {
1102
+ "Sec-CH-UA": `"Chromium";v="${chromeVersion}", "Google Chrome";v="${chromeVersion}", "Not_A Brand";v="24"`,
1103
+ "Sec-CH-UA-Mobile": "?0",
1104
+ "Sec-CH-UA-Platform": `"${chPlatform}"`,
1105
+ };
1106
+ }
1107
+ /**
1108
+ * BrowserContext options to merge into context creation.
1109
+ * Returns stealth-appropriate defaults (user agent, locale, timezone, headers).
1110
+ *
1111
+ * In passive mode, returns minimal options (locale/timezone for consistency)
1112
+ * but does NOT set a faked userAgent or Sec-CH-UA headers.
1113
+ *
1114
+ * In active mode, returns the full set including faked UA and headers.
1115
+ *
1116
+ * BUG-005 / P0 #3 FIX: When a custom user agent is provided, we still return
1117
+ * locale/timezone/extraHTTPHeaders — only the userAgent field is omitted.
1118
+ */
1119
+ getContextOptions(customUserAgent, fingerprint) {
1120
+ const mode = this.getMode();
1121
+ if (mode === 'off') {
1122
+ return {};
1123
+ }
1124
+ // Passive mode: minimal context options — no identity faking
1125
+ if (mode === 'passive') {
1126
+ return {
1127
+ locale: "en-US",
1128
+ timezoneId: "America/New_York",
1129
+ extraHTTPHeaders: {
1130
+ "Accept-Language": "en-US,en;q=0.9",
1131
+ },
1132
+ };
1133
+ }
1134
+ // Active mode: full stealth context options
1135
+ const ua = customUserAgent ?? this.getDefaultUserAgent();
1136
+ const chromeVersion = this.extractChromeMajorVersion(ua);
1137
+ const platform = this.inferPlatformFromUA(ua);
1138
+ // Phase 2.4: Sync Sec-CH-UA HTTP headers with fingerprint/UA
1139
+ const secChUaHeaders = this.buildSecChUaHeaders(chromeVersion, platform);
1140
+ const opts = {
1141
+ locale: "en-US",
1142
+ timezoneId: "America/New_York",
1143
+ extraHTTPHeaders: {
1144
+ "Accept-Language": "en-US,en;q=0.9",
1145
+ ...secChUaHeaders,
1146
+ },
1147
+ };
1148
+ // Only set the default UA if no custom one was provided.
1149
+ // Custom UA is applied by session-manager after merging these options.
1150
+ if (!customUserAgent) {
1151
+ opts.userAgent = this.getDefaultUserAgent();
1152
+ }
1153
+ return opts;
1154
+ }
1155
+ }
1156
+ export const stealth = new StealthMode();
1157
+ export default stealth;