@fanboynz/network-scanner 3.0.0 → 3.0.2

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.
@@ -26,13 +26,6 @@ function seededRandom(seed) {
26
26
  const _fingerprintCache = new Map();
27
27
  const FINGERPRINT_CACHE_MAX = 500;
28
28
 
29
- // Type-specific property spoofing functions for monomorphic optimization
30
- // Built-in properties that should not be modified
31
- const BUILT_IN_PROPERTIES = new Set([
32
- 'href', 'origin', 'protocol', 'host', 'hostname', 'port', 'pathname', 'search', 'hash',
33
- 'constructor', 'prototype', '__proto__', 'toString', 'valueOf', 'assign', 'reload', 'replace'
34
- ]);
35
-
36
29
  // User agent collections with latest versions
37
30
  const USER_AGENT_COLLECTIONS = Object.freeze(new Map([
38
31
  ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
@@ -77,15 +70,53 @@ const GPU_POOL = {
77
70
  };
78
71
 
79
72
  /**
80
- * Select a GPU from the pool based on user agent string.
81
- * Called once per browser session so the GPU stays consistent across page loads.
73
+ * Select a GPU from the pool based on user agent string. When `domain` is
74
+ * provided, selection is deterministic per-domain (seeded) -- a tracker
75
+ * logging UNMASKED_RENDERER_WEBGL sees the SAME machine across reloads of
76
+ * the same site. Without `domain`, falls back to Math.random for ad-hoc
77
+ * callers. Matches the same per-domain consistency that
78
+ * generateRealisticFingerprint uses for the rest of the spoof set.
82
79
  */
83
- function selectGpuForUserAgent(userAgentString) {
80
+ function selectGpuForUserAgent(userAgentString, domain = '') {
84
81
  let osKey = 'windows';
85
82
  if (userAgentString && (userAgentString.includes('Macintosh') || userAgentString.includes('Mac OS X'))) osKey = 'mac';
86
83
  else if (userAgentString && (userAgentString.includes('X11; Linux') || userAgentString.includes('Ubuntu'))) osKey = 'linux';
87
84
  const pool = GPU_POOL[osKey];
88
- return pool[Math.floor(Math.random() * pool.length)];
85
+ // Distinct seed suffix so the GPU pick doesn't collide with the
86
+ // fingerprint generator's advance sequence for the same domain.
87
+ const rand = domain ? seededRandom(domain + ':gpu') : Math.random;
88
+ return pool[Math.floor(rand() * pool.length)];
89
+ }
90
+
91
+ /**
92
+ * One-shot "is this browser dead?" guard. Three of the four spoof entry
93
+ * points (applyUserAgentSpoofing, applyBraveSpoofing,
94
+ * applyFingerprintProtection) had this exact try/catch block inline:
95
+ *
96
+ * try {
97
+ * if (!page.browser().connected || page.isClosed()) return;
98
+ * if (page.browser().process()?.killed) return;
99
+ * } catch { return; }
100
+ *
101
+ * Three copies meant any Puppeteer API change (like the v25
102
+ * isConnected -> connected swap) needed three fixes. Now one.
103
+ *
104
+ * simulateHumanBehavior is NOT migrated here -- it has different debug
105
+ * log messages per failure mode ("page closed" vs "browser
106
+ * disconnected") that this helper doesn't preserve. Could plumb a
107
+ * callback for the log but the existing inline form is fine for one
108
+ * caller.
109
+ */
110
+ function isBrowserDead(page) {
111
+ try {
112
+ if (!page || page.isClosed()) return true;
113
+ const browser = page.browser();
114
+ if (!browser.connected) return true;
115
+ if (browser.process()?.killed) return true;
116
+ return false;
117
+ } catch {
118
+ return true; // any failure reading browser state -> treat as dead
119
+ }
89
120
  }
90
121
 
91
122
  /**
@@ -103,67 +134,21 @@ function isSessionClosedError(err) {
103
134
  }
104
135
 
105
136
  /**
106
- * Safely defines a property with comprehensive error handling
107
- */
108
- function safeDefineProperty(target, property, descriptor, options = {}) {
109
- if (BUILT_IN_PROPERTIES.has(property)) {
110
- if (options.debug) console.log(`[fingerprint] Skipping built-in property: ${property}`);
111
- return false;
112
- }
113
-
114
- try {
115
- const existing = Object.getOwnPropertyDescriptor(target, property);
116
- if (existing?.configurable === false) {
117
- if (options.debug) console.log(`[fingerprint] Cannot modify non-configurable: ${property}`);
118
- return false;
119
- }
120
-
121
- Object.defineProperty(target, property, descriptor);
122
- return true;
123
- } catch (err) {
124
- if (options.debug) console.log(`[fingerprint] Failed to define ${property}: ${err.message}`);
125
- return false;
126
- }
127
- }
128
-
129
- /**
130
- * Safely executes spoofing operations with error handling
131
- */
132
- function safeSpoofingExecution(spoofFunction, description, options = {}) {
133
- try {
134
- spoofFunction();
135
- return true;
136
- } catch (err) {
137
- if (options.debug) console.log(`[fingerprint] ${description} failed: ${err.message}`);
138
- return false;
139
- }
140
- }
141
-
142
- /**
143
- * Generates realistic screen resolutions based on common monitor sizes
144
- */
145
- function getRealisticScreenResolution() {
146
- const commonResolutions = [
147
- { width: 1920, height: 1080 },
148
- { width: 1366, height: 768 },
149
- { width: 1440, height: 900 },
150
- { width: 1536, height: 864 },
151
- { width: 1600, height: 900 },
152
- { width: 2560, height: 1440 },
153
- { width: 1280, height: 720 },
154
- { width: 3440, height: 1440 }
155
- ];
156
- return commonResolutions[Math.floor(Math.random() * commonResolutions.length)];
157
- }
158
-
159
- /**
160
- * Generates randomized but realistic browser fingerprint values
161
- * When domain is provided, values are deterministic per-domain (consistent across reloads)
137
+ * Generates randomized but realistic browser fingerprint values.
138
+ * When domain is provided, values are deterministic per-(domain, userAgent)
139
+ * tuple -- consistent across reloads, but distinct across UA rotation so
140
+ * a re-scan with `userAgent: 'firefox'` doesn't reuse a previous
141
+ * `userAgent: 'chrome'` cache entry (which would ship Mac/Win-platform
142
+ * values under a Firefox UA, etc.).
162
143
  */
163
144
  function generateRealisticFingerprint(userAgent, domain = '') {
164
- // Return cached fingerprint if same domain visited again
165
- if (domain) {
166
- const cached = _fingerprintCache.get(domain);
145
+ // Cache key includes UA so a domain scanned twice with different UAs
146
+ // doesn't get the first UA's OS-mismatched fingerprint reused for the
147
+ // second. The `|` separator can't appear in a hostname (RFC 952/RFC
148
+ // 1123 allow only LDH) so it's collision-safe.
149
+ const cacheKey = domain ? `${domain}|${userAgent}` : '';
150
+ if (cacheKey) {
151
+ const cached = _fingerprintCache.get(cacheKey);
167
152
  if (cached) return cached;
168
153
  }
169
154
 
@@ -239,12 +224,12 @@ function generateRealisticFingerprint(userAgent, domain = '') {
239
224
  doNotTrack: null // Most users don't enable DNT
240
225
  };
241
226
 
242
- // Cache for this domain
243
- if (domain) {
227
+ // Cache for this (domain, userAgent) tuple. Same key as the lookup above.
228
+ if (cacheKey) {
244
229
  if (_fingerprintCache.size >= FINGERPRINT_CACHE_MAX) {
245
230
  _fingerprintCache.delete(_fingerprintCache.keys().next().value);
246
231
  }
247
- _fingerprintCache.set(domain, fingerprint);
232
+ _fingerprintCache.set(cacheKey, fingerprint);
248
233
  }
249
234
 
250
235
  return fingerprint;
@@ -257,7 +242,7 @@ async function validatePageForInjection(page, currentUrl, forceDebug) {
257
242
  try {
258
243
  if (!page || page.isClosed()) return false;
259
244
 
260
- if (!page.browser().isConnected()) {
245
+ if (!page.browser().connected) {
261
246
  if (forceDebug) console.log(formatLogMessage('debug', `Page validation failed - browser disconnected: ${currentUrl}`));
262
247
  return false;
263
248
  }
@@ -277,14 +262,18 @@ async function validatePageForInjection(page, currentUrl, forceDebug) {
277
262
  */
278
263
  async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl) {
279
264
  if (!siteConfig.userAgent) return;
265
+ // Type guard: callers (including config-driven paths) might pass non-string
266
+ // values by accident (e.g. an array, an object). Without this guard, the
267
+ // .toLowerCase() call below would throw and crash the whole spoof
268
+ // pipeline for this URL with no actionable error.
269
+ if (typeof siteConfig.userAgent !== 'string') {
270
+ if (forceDebug) console.log(formatLogMessage('debug', `Invalid userAgent type for ${currentUrl}: expected string, got ${typeof siteConfig.userAgent}`));
271
+ return;
272
+ }
280
273
 
281
274
  if (forceDebug) console.log(formatLogMessage('debug', `User agent spoofing: ${siteConfig.userAgent}`));
282
275
 
283
- // Browser connection check
284
- try {
285
- if (!page.browser().isConnected() || page.isClosed()) return;
286
- if (page.browser().process()?.killed) return;
287
- } catch { return; }
276
+ if (isBrowserDead(page)) return;
288
277
 
289
278
  // Validate page state before injection
290
279
  if (!(await validatePageForInjection(page, currentUrl, forceDebug))) return;
@@ -303,11 +292,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
303
292
  if (forceDebug) console.log(formatLogMessage('debug', `Applying stealth protection for ${currentUrl}`));
304
293
 
305
294
  try {
306
- // Select GPU once per session stays consistent across all page loads
307
- const selectedGpu = selectGpuForUserAgent(ua);
295
+ // Derive site domain once -- used to seed the per-domain GPU pick
296
+ // (so a tracker logging UNMASKED_RENDERER_WEBGL sees one machine per
297
+ // site) AND to pre-compute hardware-concurrency from the SAME
298
+ // realistic-fingerprint generator that applyFingerprintProtection
299
+ // uses. That kills the order-dependent visible-value bug: previously
300
+ // hardwareConcurrency was random in the UA block (line 819) and
301
+ // domain-seeded in applyFingerprintProtection -- whichever evaluate
302
+ // ran second won. Now both paths produce the same value.
303
+ let siteDomain = '';
304
+ try { siteDomain = new URL(currentUrl).hostname; } catch (_) {}
305
+
306
+ const selectedGpu = selectGpuForUserAgent(ua, siteDomain);
308
307
  if (forceDebug) console.log(formatLogMessage('debug', `Selected GPU: ${selectedGpu.vendor} / ${selectedGpu.renderer}`));
309
308
 
310
- await page.evaluateOnNewDocument((userAgent, debugEnabled, gpuConfig) => {
309
+ // Pre-compute hardware-concurrency from the (cached, domain-seeded)
310
+ // realistic fingerprint so the UA-spoof block uses the same value
311
+ // applyFingerprintProtection will later -- no order dependency,
312
+ // same value regardless of which evaluate runs first.
313
+ const realistic = generateRealisticFingerprint(ua, siteDomain);
314
+
315
+ await page.evaluateOnNewDocument((userAgent, debugEnabled, gpuConfig, seededCores) => {
311
316
 
312
317
  // Apply inline error suppression first
313
318
  (function() {
@@ -315,35 +320,53 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
315
320
  const originalWindowError = window.onerror;
316
321
 
317
322
  function shouldSuppressFingerprintError(message) {
323
+ // Suppression list, ordered specific -> general.
324
+ // .some() stops at first match, so a broken specific just means
325
+ // the general catchers below do the work instead.
326
+ //
327
+ // Fixed (was using `\\.` / `\\d` / `\\(` / `\\$` -- DOUBLE
328
+ // backslashes in source). Each `\\X` parses as
329
+ // literal-backslash + wildcard-X, requiring a literal `\` in
330
+ // the error text -- which never appears. So those entries
331
+ // silently never matched anything; only the general catchers
332
+ // below were actually suppressing those error families.
333
+ // Switched to single-backslash form (literal-X escapes) so
334
+ // each specific entry now matches its intended error class
335
+ // for accurate debug logging of WHICH pattern matched.
336
+ //
337
+ // Also dropped:
338
+ // - /Failed to load resource.*40[34]/i -- fully subsumed by
339
+ // the broader /[45]\d{2}/i below.
318
340
  const patterns = [
319
341
  /\.closest is not a function/i,
320
342
  /\.querySelector is not a function/i,
321
343
  /\.addEventListener is not a function/i,
322
- /Cannot read propert(y|ies) of null \\(reading 'fp'\\)/i,
323
- /Cannot read propert(y|ies) of undefined \\(reading 'fp'\\)/i,
344
+ /Cannot read propert(y|ies) of null \(reading 'fp'\)/i,
345
+ /Cannot read propert(y|ies) of undefined \(reading 'fp'\)/i,
324
346
  /Cannot redefine property: href/i,
325
347
  /Cannot redefine property: __webdriver_script_func/i,
326
348
  /Cannot redefine property: webdriver/i,
327
- /Cannot read propert(y|ies) of undefined \\(reading 'toLowerCase'\\)/i,
328
- /\\.toLowerCase is not a function/i,
349
+ /Cannot read propert(y|ies) of undefined \(reading 'toLowerCase'\)/i,
350
+ /\.toLowerCase is not a function/i,
329
351
  /fp is not defined/i,
330
352
  /fingerprint is not defined/i,
331
353
  /FingerprintJS is not defined/i,
332
- /\\$ is not defined/i,
354
+ /\$ is not defined/i,
333
355
  /jQuery is not defined/i,
334
356
  /_ is not defined/i,
335
- /Failed to load resource.*server responded with a status of [45]\\d{2}/i,
357
+ /Failed to load resource.*server responded with a status of [45]\d{2}/i,
336
358
  /Failed to fetch/i,
337
359
  /(webdriver|callPhantom|_phantom|__nightmare|_selenium) is not defined/i,
338
360
  /Failed to execute 'observe' on 'IntersectionObserver'.*parameter 1 is not of type 'Element'/i,
339
361
  /tz check/i,
340
- /new window\\.Error.*<anonymous>/i,
341
- /Failed to load resource.*server responded with a status of 40[34]/i,
362
+ /new window\.Error.*<anonymous>/i,
342
363
  /Blocked script execution in 'about:blank'.*sandboxed.*allow-scripts/i,
343
364
  /Page JavaScript error:/i,
344
365
  /^[a-zA-Z0-9_$]+\[.*\]\s+is not a function/i,
345
366
  /^[a-zA-Z0-9_$]+\(.*\)\s+is not a function/i,
346
367
  /^[a-zA-Z0-9_$]+\.[a-zA-Z0-9_$]+.*is not a function/i,
368
+ // General catchers — kept last so the specific entries
369
+ // above can match first for accurate debug attribution.
347
370
  /Failed to load resource/i,
348
371
  /is not defined/i,
349
372
  /is not a function/i
@@ -383,6 +406,30 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
383
406
  }
384
407
  return fn;
385
408
  }
409
+
410
+ // Wrapper-constructor identity preserver. Function.name and
411
+ // Function.length are own data properties of every function
412
+ // object, NOT inherited via prototype chain -- so even after
413
+ // Object.setPrototypeOf(wrapper, OriginalCtor), the wrapper
414
+ // still has its own name (empty for anonymous expressions) and
415
+ // length (count of leading formal params). A bot detector that
416
+ // checks `Error.name === 'Error'` would see '' on our spoof.
417
+ //
418
+ // Real Chrome's Function.name has descriptor
419
+ // {value: ..., writable: false, enumerable: false, configurable: true}
420
+ // -- match that shape so getOwnPropertyDescriptor checks pass too.
421
+ function preserveCtorIdentity(wrapper, original) {
422
+ try {
423
+ Object.defineProperty(wrapper, 'name', {
424
+ value: original.name, writable: false, enumerable: false, configurable: true
425
+ });
426
+ Object.defineProperty(wrapper, 'length', {
427
+ value: original.length, writable: false, enumerable: false, configurable: true
428
+ });
429
+ } catch (e) {
430
+ if (debugEnabled) console.log(`[fingerprint] preserveCtorIdentity failed: ${e.message}`);
431
+ }
432
+ }
386
433
 
387
434
  Function.prototype.toString = function() {
388
435
  if (nativeFunctionStore.has(this)) {
@@ -393,10 +440,14 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
393
440
  // Protect the toString override itself
394
441
  nativeFunctionStore.set(Function.prototype.toString, 'toString');
395
442
 
396
- // Create safe property definition helper
443
+ // Create safe property definition helper.
444
+ // configurable: true is a DEFAULT (not a force) -- if a caller
445
+ // wants to lock a property (configurable: false) the spread won't
446
+ // override their explicit value. Previously `{ ...descriptor,
447
+ // configurable: true }` silently overrode any caller intent.
397
448
  function safeDefinePropertyLocal(target, property, descriptor) {
398
449
  const builtInProps = new Set(['href', 'origin', 'protocol', 'host', 'hostname', 'port', 'pathname', 'search', 'hash', 'constructor', 'prototype', '__proto__', 'toString', 'valueOf', 'assign', 'reload', 'replace']);
399
-
450
+
400
451
  if (builtInProps.has(property)) {
401
452
  if (debugEnabled) console.log(`[fingerprint] Skipping built-in property: ${property}`);
402
453
  return false;
@@ -410,8 +461,8 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
410
461
  }
411
462
 
412
463
  Object.defineProperty(target, property, {
413
- ...descriptor,
414
- configurable: true
464
+ configurable: true,
465
+ ...descriptor
415
466
  });
416
467
  return true;
417
468
  } catch (err) {
@@ -473,13 +524,21 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
473
524
  '$cdc_asdjflasutopfhvcZLmcfl_', '$chrome_asyncScriptInfo', '__$webdriverAsyncExecutor'
474
525
  ];
475
526
 
527
+ // Just delete -- DO NOT re-add as undefined-returning getters.
528
+ // The previous version did:
529
+ // delete window[prop];
530
+ // safeDefinePropertyLocal(window, prop, { get: () => undefined });
531
+ // which made `window.callPhantom` return undefined (good) but ALSO
532
+ // made `'callPhantom' in window` return TRUE (bad) -- the property
533
+ // exists on the object even if the getter returns undefined. Real
534
+ // Chrome doesn't have these props at all, so `in`-operator and
535
+ // Object.getOwnPropertyNames probes saw a present property and
536
+ // flagged us as a bot. This own-goal was caught by
537
+ // scripts/test-stealth.js sannysoft (PHANTOM_PROPERTIES and
538
+ // SELENIUM_DRIVER cells went red because of it).
476
539
  automationProps.forEach(prop => {
477
- try {
478
- delete window[prop];
479
- delete navigator[prop];
480
- safeDefinePropertyLocal(window, prop, { get: () => undefined });
481
- safeDefinePropertyLocal(navigator, prop, { get: () => undefined });
482
- } catch (e) {}
540
+ try { delete window[prop]; } catch (e) {}
541
+ try { delete navigator[prop]; } catch (e) {}
483
542
  });
484
543
  }, 'automation properties removal');
485
544
 
@@ -501,12 +560,21 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
501
560
  postMessage: () => {},
502
561
  disconnect: () => {}
503
562
  }),
504
- getManifest: () => ({
505
- name: "Chrome",
506
- version: "146.0.0.0",
507
- manifest_version: 3,
508
- description: "Chrome Browser"
509
- }),
563
+ getManifest: () => {
564
+ // Derive Chrome version from the spoofed UA so a tracker
565
+ // cross-checking navigator.userAgent's Chrome version
566
+ // against chrome.runtime.getManifest().version sees a
567
+ // consistent number. Was hardcoded "146.0.0.0" which lied
568
+ // any time the UA was rotated to a different Chrome major.
569
+ const m = userAgent.match(/Chrome\/(\d+)/);
570
+ const major = m ? m[1] : '146';
571
+ return {
572
+ name: "Chrome",
573
+ version: `${major}.0.0.0`,
574
+ manifest_version: 3,
575
+ description: "Chrome Browser"
576
+ };
577
+ },
510
578
  getURL: (path) => `chrome-extension://invalid/${path}`,
511
579
  id: undefined,
512
580
  getPlatformInfo: (callback) => callback({
@@ -571,11 +639,22 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
571
639
  });
572
640
  }
573
641
 
574
- // Make chrome object non-enumerable to match real Chrome
642
+ // The old comment claimed "non-enumerable to match real Chrome" --
643
+ // but real Chrome's window.chrome property descriptor is
644
+ // {value: ..., writable: true, enumerable: true, configurable: true}.
645
+ // The previous writable:false + enumerable:false were themselves
646
+ // fingerprintable tells: a bot detector reading
647
+ // Object.getOwnPropertyDescriptor(window, 'chrome')
648
+ // would see {writable:false, enumerable:false} and know this
649
+ // isn't real Chrome. `window.chrome = 'x'` would also silently
650
+ // fail (vs succeed on real Chrome). Match real Chrome's
651
+ // descriptor instead. The defineProperty is kept (rather than
652
+ // removed entirely) so re-injection on reload doesn't lose the
653
+ // descriptor shape if anything earlier tightened it.
575
654
  Object.defineProperty(window, 'chrome', {
576
655
  value: window.chrome,
577
- writable: false,
578
- enumerable: false,
656
+ writable: true,
657
+ enumerable: true,
579
658
  configurable: true
580
659
  });
581
660
 
@@ -649,6 +728,30 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
649
728
  // Safari typically has no plugins in modern versions
650
729
  plugins = [];
651
730
  }
731
+ // Each plugin object needs to identify as a Plugin instance so
732
+ // `navigator.plugins[i].toString() === '[object Plugin]'` passes.
733
+ // (Sannysoft's actual PluginArray check tests this -- found via
734
+ // scripts/test-stealth.js sannysoft.) Resolve Plugin.prototype the
735
+ // same way we resolve PluginArray.prototype below: prefer the
736
+ // global, fall back to navigator.plugins[0]'s prototype if a real
737
+ // plugin exists, fall back to just Symbol.toStringTag if neither.
738
+ let pluginProto = null;
739
+ try {
740
+ if (typeof Plugin !== 'undefined' && Plugin.prototype) {
741
+ pluginProto = Plugin.prototype;
742
+ } else if (navigator.plugins && navigator.plugins[0]) {
743
+ pluginProto = Object.getPrototypeOf(navigator.plugins[0]);
744
+ }
745
+ } catch (e) {}
746
+ plugins = plugins.map(p => {
747
+ const wrapped = Object.assign(Object.create(pluginProto || Object.prototype), p);
748
+ if (!pluginProto) {
749
+ // Last-ditch: at least toString() returns "[object Plugin]"
750
+ wrapped[Symbol.toStringTag] = 'Plugin';
751
+ }
752
+ return wrapped;
753
+ });
754
+
652
755
  // Create proper array-like object with enumerable indices and length
653
756
  // Create proper PluginArray-like object with required methods
654
757
  const pluginsArray = {};
@@ -671,6 +774,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
671
774
  pluginsArray[Symbol.iterator] = function*() { for (const p of plugins) yield p; };
672
775
  pluginsArray[Symbol.toStringTag] = 'PluginArray';
673
776
 
777
+ // Make `navigator.plugins instanceof PluginArray` evaluate true.
778
+ // Multiple ways to get PluginArray.prototype, in order of
779
+ // preference -- evaluateOnNewDocument can fire before all globals
780
+ // are bound, so we don't assume `PluginArray` is in scope. Falls
781
+ // back to inheriting from the existing navigator.plugins's own
782
+ // prototype, which is ALWAYS a real PluginArray.prototype on
783
+ // any DOM-bearing context.
784
+ try {
785
+ let pluginArrayProto = null;
786
+ if (typeof PluginArray !== 'undefined' && PluginArray.prototype) {
787
+ pluginArrayProto = PluginArray.prototype;
788
+ } else if (navigator.plugins) {
789
+ pluginArrayProto = Object.getPrototypeOf(navigator.plugins);
790
+ }
791
+ if (pluginArrayProto && pluginArrayProto !== Object.prototype) {
792
+ Object.setPrototypeOf(pluginsArray, pluginArrayProto);
793
+ }
794
+ } catch (e) {
795
+ if (debugEnabled) console.log(`[fingerprint] PluginArray prototype setup failed: ${e.message}`);
796
+ }
797
+
674
798
  safeDefinePropertyLocal(navigator, 'plugins', { get: () => pluginsArray });
675
799
  }, 'plugins spoofing');
676
800
 
@@ -813,12 +937,17 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
813
937
  }
814
938
  }, 'enhanced OS fingerprinting protection');
815
939
 
816
- // Hardware concurrency spoofing (universal coverage)
817
- //
940
+ // Hardware concurrency spoofing (universal coverage).
941
+ // Uses the domain-seeded value passed in from the Node-side
942
+ // generateRealisticFingerprint call -- previously did its own
943
+ // Math.random() pick from [4,6,8,12], which conflicted with the
944
+ // domain-seeded value set later by applyFingerprintProtection.
945
+ // Whichever evaluate ran second won, producing an order-dependent
946
+ // visible value. Now both paths use the same seeded value, so
947
+ // double-spoof becomes idempotent.
818
948
  safeExecute(() => {
819
- const spoofedCores = [4, 6, 8, 12][Math.floor(Math.random() * 4)];
820
949
  const hardwareProps = {
821
- hardwareConcurrency: { get: () => spoofedCores }
950
+ hardwareConcurrency: { get: () => seededCores }
822
951
  };
823
952
  spoofNavigatorProperties(navigator, hardwareProps);
824
953
  }, 'hardware concurrency spoofing');
@@ -889,12 +1018,24 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
889
1018
 
890
1019
  window.Error.prototype = OriginalError.prototype;
891
1020
  Object.setPrototypeOf(window.Error, OriginalError);
1021
+ preserveCtorIdentity(window.Error, OriginalError);
892
1022
 
893
- // Copy static properties
1023
+ // Forward static properties via getter/setter pairs instead of
1024
+ // value-copy, so live mutations to OriginalError (e.g. page
1025
+ // code doing `Error.stackTraceLimit = 100`) propagate through
1026
+ // the wrapper. Previously the value-copy froze a snapshot at
1027
+ // injection time; a tracker that mutated stackTraceLimit and
1028
+ // then read it back from the wrapped Error would see the old
1029
+ // value -- a real fingerprint tell.
894
1030
  ['captureStackTrace', 'stackTraceLimit', 'prepareStackTrace'].forEach(prop => {
895
- if (OriginalError[prop]) {
896
- try { window.Error[prop] = OriginalError[prop]; } catch (e) {}
897
- }
1031
+ try {
1032
+ Object.defineProperty(window.Error, prop, {
1033
+ get: () => OriginalError[prop],
1034
+ set: (v) => { OriginalError[prop] = v; },
1035
+ configurable: true,
1036
+ enumerable: false
1037
+ });
1038
+ } catch (e) {}
898
1039
  });
899
1040
  }, 'Error stack protection');
900
1041
 
@@ -1161,19 +1302,25 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1161
1302
  }
1162
1303
  }, 'window dimension spoofing');
1163
1304
 
1164
- // navigator.connection — missing or incomplete in headless
1305
+ // navigator.connection — missing or incomplete in headless.
1306
+ // Object literal hoisted out of the getter so identity is stable
1307
+ // across reads. Real Chrome's NetworkInformation instance has
1308
+ // `navigator.connection === navigator.connection`; previously
1309
+ // the getter returned a new object on every read, which a
1310
+ // tracker could detect by comparing references.
1165
1311
  safeExecute(() => {
1166
1312
  if (!navigator.connection) {
1313
+ const connectionInfoStable = {
1314
+ effectiveType: '4g',
1315
+ rtt: 50,
1316
+ downlink: 10,
1317
+ saveData: false,
1318
+ type: 'wifi',
1319
+ addEventListener: () => {},
1320
+ removeEventListener: () => {}
1321
+ };
1167
1322
  Object.defineProperty(navigator, 'connection', {
1168
- get: () => ({
1169
- effectiveType: '4g',
1170
- rtt: 50,
1171
- downlink: 10,
1172
- saveData: false,
1173
- type: 'wifi',
1174
- addEventListener: () => {},
1175
- removeEventListener: () => {}
1176
- })
1323
+ get: () => connectionInfoStable
1177
1324
  });
1178
1325
  }
1179
1326
  }, 'connection API spoofing');
@@ -1254,7 +1401,10 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1254
1401
  return img;
1255
1402
  };
1256
1403
  window.Image.prototype = OrigImage.prototype;
1257
- Object.defineProperty(window, 'Image', { writable: true, configurable: true });
1404
+ preserveCtorIdentity(window.Image, OrigImage);
1405
+ // (Note: the prior Object.defineProperty(window, 'Image', ...) here
1406
+ // was a no-op -- changed descriptor flags without setting value.
1407
+ // Removed; the wrapper is already assigned to window.Image above.)
1258
1408
  }, 'broken image dimension spoofing');
1259
1409
 
1260
1410
  // Fetch Request Headers Normalization
@@ -1345,6 +1495,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1345
1495
  };
1346
1496
  Object.setPrototypeOf(window.RTCPeerConnection, OriginalRTC);
1347
1497
  window.RTCPeerConnection.prototype = OriginalRTC.prototype;
1498
+ preserveCtorIdentity(window.RTCPeerConnection, OriginalRTC);
1348
1499
  }
1349
1500
  }, 'WebRTC spoofing');
1350
1501
 
@@ -1587,8 +1738,9 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1587
1738
  return new OriginalPointerEvent(type, enhancedDict);
1588
1739
  };
1589
1740
  Object.setPrototypeOf(window.PointerEvent, OriginalPointerEvent);
1741
+ preserveCtorIdentity(window.PointerEvent, OriginalPointerEvent);
1590
1742
  }
1591
-
1743
+
1592
1744
  // Spoof touch capabilities for mobile detection evasion
1593
1745
  if (!window.TouchEvent && Math.random() > 0.8) {
1594
1746
  // 20% chance to add touch support to confuse fingerprinters
@@ -1612,6 +1764,8 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1612
1764
  };
1613
1765
  return new originalWheelEvent(type, enhancedDict);
1614
1766
  };
1767
+ Object.setPrototypeOf(window.WheelEvent, originalWheelEvent);
1768
+ preserveCtorIdentity(window.WheelEvent, originalWheelEvent);
1615
1769
  }
1616
1770
 
1617
1771
  }, 'enhanced mouse/pointer spoofing');
@@ -1744,7 +1898,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1744
1898
  }
1745
1899
  }, 'interaction-gated script trigger');
1746
1900
 
1747
- }, ua, forceDebug, selectedGpu);
1901
+ }, ua, forceDebug, selectedGpu, realistic.hardwareConcurrency);
1748
1902
  } catch (stealthErr) {
1749
1903
  if (isSessionClosedError(stealthErr)) {
1750
1904
  if (forceDebug) console.log(formatLogMessage('debug', `Page closed during stealth injection: ${currentUrl}`));
@@ -1762,12 +1916,8 @@ async function applyBraveSpoofing(page, siteConfig, forceDebug, currentUrl) {
1762
1916
  if (!siteConfig.isBrave) return;
1763
1917
 
1764
1918
  if (forceDebug) console.log(formatLogMessage('debug', `Brave spoofing enabled for ${currentUrl}`));
1765
-
1766
- // Browser connection check
1767
- try {
1768
- if (!page.browser().isConnected() || page.isClosed()) return;
1769
- if (page.browser().process()?.killed) return;
1770
- } catch { return; }
1919
+
1920
+ if (isBrowserDead(page)) return;
1771
1921
 
1772
1922
  // Validate page state before injection
1773
1923
  if (!(await validatePageForInjection(page, currentUrl, forceDebug))) return;
@@ -1814,12 +1964,8 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
1814
1964
  if (!fingerprintSetting) return;
1815
1965
 
1816
1966
  if (forceDebug) console.log(formatLogMessage('debug', `Fingerprint protection enabled for ${currentUrl}`));
1817
-
1818
- // Browser connection check
1819
- try {
1820
- if (!page.browser().isConnected() || page.isClosed()) return;
1821
- if (page.browser().process()?.killed) return;
1822
- } catch { return; }
1967
+
1968
+ if (isBrowserDead(page)) return;
1823
1969
 
1824
1970
  // Validate page state before injection
1825
1971
  if (!(await validatePageForInjection(page, currentUrl, forceDebug))) return;
@@ -1864,14 +2010,30 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
1864
2010
  }
1865
2011
  }
1866
2012
 
2013
+ // Mirror of the helper defined inside applyUserAgentSpoofing's
2014
+ // evaluate -- both behave identically: skip built-ins, refuse to
2015
+ // touch non-configurable, default (not force) configurable:true so
2016
+ // explicit caller intent is preserved. Previously this copy
2017
+ // diverged: it had no built-in check AND it force-overrode
2018
+ // configurable:true regardless of caller. Now consistent.
1867
2019
  function safeDefinePropertyLocal(target, property, descriptor) {
2020
+ const builtInProps = new Set(['href', 'origin', 'protocol', 'host', 'hostname', 'port', 'pathname', 'search', 'hash', 'constructor', 'prototype', '__proto__', 'toString', 'valueOf', 'assign', 'reload', 'replace']);
2021
+
2022
+ if (builtInProps.has(property)) {
2023
+ if (debugEnabled) console.log(`[fingerprint] Skipping built-in property: ${property}`);
2024
+ return false;
2025
+ }
2026
+
1868
2027
  try {
1869
2028
  const existing = Object.getOwnPropertyDescriptor(target, property);
1870
- if (existing?.configurable === false) return false;
1871
-
2029
+ if (existing?.configurable === false) {
2030
+ if (debugEnabled) console.log(`[fingerprint] Cannot modify non-configurable: ${property}`);
2031
+ return false;
2032
+ }
2033
+
1872
2034
  Object.defineProperty(target, property, {
1873
- ...descriptor,
1874
- configurable: true
2035
+ configurable: true,
2036
+ ...descriptor
1875
2037
  });
1876
2038
  return true;
1877
2039
  } catch (err) {
@@ -1982,7 +2144,7 @@ async function simulateHumanBehavior(page, forceDebug) {
1982
2144
  }
1983
2145
 
1984
2146
  // Check if browser is still connected
1985
- if (!page.browser().isConnected()) {
2147
+ if (!page.browser().connected) {
1986
2148
  if (forceDebug) console.log(formatLogMessage('debug', `Human behavior simulation skipped - browser disconnected`));
1987
2149
  return;
1988
2150
  }
@@ -2128,16 +2290,17 @@ async function applyAllFingerprintSpoofing(page, siteConfig, forceDebug, current
2128
2290
  }
2129
2291
  }
2130
2292
 
2293
+ // Public surface kept narrow on purpose -- only what nwss.js actually
2294
+ // imports. Internal helpers (generateRealisticFingerprint,
2295
+ // applyUserAgentSpoofing, applyBraveSpoofing, applyFingerprintProtection,
2296
+ // simulateHumanBehavior, selectGpuForUserAgent, isBrowserDead,
2297
+ // isSessionClosedError, validatePageForInjection, seededRandom,
2298
+ // DEFAULT_PLATFORM, DEFAULT_TIMEZONE) stay as module-local; move back to
2299
+ // module.exports only if a new external consumer appears.
2131
2300
  module.exports = {
2132
- generateRealisticFingerprint,
2133
- getRealisticScreenResolution,
2134
- applyUserAgentSpoofing,
2135
- applyBraveSpoofing,
2136
- applyFingerprintProtection,
2137
2301
  applyAllFingerprintSpoofing,
2138
- simulateHumanBehavior,
2139
- safeDefineProperty,
2140
- safeSpoofingExecution,
2141
- DEFAULT_PLATFORM,
2142
- DEFAULT_TIMEZONE
2302
+ // Exposed for scripts/test-stealth.js so the harness can validate --ua=
2303
+ // against the canonical UA list (instead of duplicating the keys here).
2304
+ // The Map itself is frozen; consumers cannot mutate the spoof source.
2305
+ USER_AGENT_COLLECTIONS
2143
2306
  };