@fanboynz/network-scanner 3.1.0 → 3.2.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.
@@ -103,54 +103,6 @@ class DomainCache {
103
103
  return wasNew;
104
104
  }
105
105
 
106
- /**
107
- * Combined check-and-mark in one pass. Functionally equivalent to
108
- * isDomainAlreadyDetected() followed by markDomainAsDetected(), but with
109
- * one Set.has() call instead of two. (JS is single-threaded so all three
110
- * variants are individually atomic; this one is just cheaper.)
111
- * @param {string} domain - Domain to check and potentially mark
112
- * @returns {boolean} True if domain was ALREADY detected (should skip), false if NEW (should process)
113
- */
114
- checkAndMark(domain) {
115
- if (!domain || typeof domain !== 'string') {
116
- return false;
117
- }
118
-
119
- const wasAlreadyDetected = this.cache.has(domain);
120
-
121
- if (wasAlreadyDetected) {
122
- // Domain already exists - update skip stats and return true (should skip)
123
- this.stats.totalSkipped++;
124
- this.stats.cacheHits++;
125
-
126
- if (this.enableLogging) {
127
- console.log(formatLogMessage('debug', `${this.logPrefix} Cache HIT: ${domain} (skipped)`));
128
- }
129
- return true; // Already detected, should skip
130
- }
131
-
132
- // Domain is NEW - mark it as detected
133
- this.stats.cacheMisses++;
134
-
135
- this.cache.add(domain);
136
- this.stats.totalDetected++;
137
-
138
- if (this.enableLogging) {
139
- console.log(formatLogMessage('debug', `${this.logPrefix} Cache MISS: ${domain} (processing and marked, cache size: ${this.cache.size})`));
140
- }
141
-
142
- // Check size after the add so an overflow only fires eviction once per
143
- // overflowing call (using targetCacheSize precomputed in the constructor).
144
- if (this.cache.size > this.maxCacheSize) {
145
- const toRemove = this.cache.size - this.targetCacheSize;
146
- if (toRemove > 0) {
147
- this.clearOldestEntries(toRemove);
148
- }
149
- }
150
-
151
- return false; // New domain, should process
152
- }
153
-
154
106
  /**
155
107
  * Clear oldest entries from cache (FIFO eviction). Set iteration order is
156
108
  * guaranteed insertion order per ES2015, so this genuinely evicts oldest-
@@ -208,45 +160,6 @@ class DomainCache {
208
160
  return this.cache.has(domain);
209
161
  }
210
162
 
211
- /**
212
- * Add multiple domains to cache at once. Uses a single .size delta to
213
- * count actually-new entries (skipping per-domain .has() calls), and
214
- * runs the size-overflow eviction check once after the batch instead of
215
- * per-domain. For a batch of N domains this is N .has() calls saved and
216
- * up to N redundant cap checks collapsed to one.
217
- * @param {Array<string>} domains - Array of domains to add
218
- * @returns {number} Number of domains actually added (excludes duplicates)
219
- */
220
- markMultipleDomainsAsDetected(domains) {
221
- if (!Array.isArray(domains) || domains.length === 0) {
222
- return 0;
223
- }
224
-
225
- const startSize = this.cache.size;
226
- for (let i = 0; i < domains.length; i++) {
227
- const d = domains[i];
228
- if (d && typeof d === 'string') {
229
- this.cache.add(d);
230
- }
231
- }
232
- const addedCount = this.cache.size - startSize;
233
- this.stats.totalDetected += addedCount;
234
-
235
- if (this.enableLogging && addedCount > 0) {
236
- console.log(formatLogMessage('debug', `${this.logPrefix} Batch added ${addedCount} new domains (cache size: ${this.cache.size})`));
237
- }
238
-
239
- // One eviction sweep at the end, mirroring the single-add overflow check.
240
- if (this.cache.size > this.maxCacheSize) {
241
- const toRemove = this.cache.size - this.targetCacheSize;
242
- if (toRemove > 0) {
243
- this.clearOldestEntries(toRemove);
244
- }
245
- }
246
-
247
- return addedCount;
248
- }
249
-
250
163
  /**
251
164
  * Create bound helper functions for easy integration with existing code
252
165
  * @returns {object} Object with bound helper functions
@@ -255,7 +168,6 @@ class DomainCache {
255
168
  return {
256
169
  isDomainAlreadyDetected: this.isDomainAlreadyDetected.bind(this),
257
170
  markDomainAsDetected: this.markDomainAsDetected.bind(this),
258
- checkAndMark: this.checkAndMark.bind(this),
259
171
  getSkippedCount: () => this.stats.totalSkipped,
260
172
  getCacheSize: () => this.cache.size,
261
173
  getStats: this.getStats.bind(this)
@@ -273,8 +185,7 @@ let globalDomainCache = null;
273
185
  *
274
186
  * NOTE: `options` is honored ONLY on the first call (the call that actually
275
187
  * constructs the singleton). Subsequent calls return the existing instance
276
- * regardless of what's passed. If you need different settings, call
277
- * resetGlobalCache() first or use `new DomainCache(options)` directly.
188
+ * regardless of what's passed; options are fixed at first construction.
278
189
  *
279
190
  * Under debug logging, a warning fires if a later caller passes options
280
191
  * that don't match the live instance — silent drift is a recurring source
@@ -295,7 +206,7 @@ function getGlobalDomainCache(options = {}) {
295
206
  (options.enableLogging !== undefined && options.enableLogging !== globalDomainCache.enableLogging) ||
296
207
  (options.logPrefix !== undefined && options.logPrefix !== globalDomainCache.logPrefix);
297
208
  if (drifted) {
298
- console.log(formatLogMessage('debug', `${globalDomainCache.logPrefix} getGlobalDomainCache called with options that differ from the live singleton; ignored (call resetGlobalCache() first to apply new options)`));
209
+ console.log(formatLogMessage('debug', `${globalDomainCache.logPrefix} getGlobalDomainCache called with options that differ from the live singleton; ignored (options are fixed at first construction)`));
299
210
  }
300
211
  }
301
212
  return globalDomainCache;
@@ -312,36 +223,17 @@ function createGlobalHelpers(options = {}) {
312
223
  }
313
224
 
314
225
  /**
315
- * Reset the global cache (useful for testing or manual resets)
316
- */
317
- function resetGlobalCache() {
318
- if (globalDomainCache) {
319
- globalDomainCache.clear();
320
- }
321
- globalDomainCache = null;
322
- }
323
-
324
- /**
325
- * Legacy wrapper functions for backward compatibility
326
- * These match the original function signatures from nwss.js
226
+ * Legacy wrapper for backward compatibility.
327
227
  *
328
- * NOTE: getTotalDomainsSkipped and getDetectedDomainsCount are the only
329
- * ones kept they're used directly by nwss.js for end-of-scan stats.
330
- * Previously-defined isDomainAlreadyDetected / markDomainAsDetected /
331
- * checkAndMark wrappers were removed: nwss.js calls those via
332
- * createGlobalHelpers() now and repo-wide grep confirmed zero remaining
333
- * external callers of the legacy wrappers.
228
+ * getDetectedDomainsCount is the only one kept — nwss.js reads it for the
229
+ * end-of-scan "unique domains cached" stat. getTotalDomainsSkipped was
230
+ * removed: its value was always 0 because the global cache's skip-check
231
+ * (isDomainAlreadyDetected) is never called cross-URL dedup is handled by
232
+ * nettools' processed-domain sets / smart-cache / the per-URL set — so the
233
+ * stat was misleading. The isDomainAlreadyDetected / markDomainAsDetected /
234
+ * checkAndMark wrappers were likewise removed; nwss.js uses createGlobalHelpers().
334
235
  */
335
236
 
336
- /**
337
- * Get total domains skipped (legacy wrapper)
338
- * @returns {number} Number of domains skipped
339
- */
340
- function getTotalDomainsSkipped() {
341
- const cache = getGlobalDomainCache();
342
- return cache.stats.totalSkipped;
343
- }
344
-
345
237
  /**
346
238
  * Get detected domains cache size (legacy wrapper)
347
239
  * @returns {number} Size of the detected domains cache
@@ -352,15 +244,10 @@ function getDetectedDomainsCount() {
352
244
  }
353
245
 
354
246
  module.exports = {
355
- // Main class
356
- DomainCache,
357
-
358
- // Global cache functions
359
- getGlobalDomainCache,
247
+ // Global cache helpers — createGlobalHelpers feeds nwss.js's per-domain
248
+ // marking; getDetectedDomainsCount feeds the end-of-scan "unique domains
249
+ // cached" stat. (DomainCache / getGlobalDomainCache stay internal — no
250
+ // external consumer; construct via createGlobalHelpers.)
360
251
  createGlobalHelpers,
361
- resetGlobalCache,
362
-
363
- // Legacy wrappers still used by nwss.js for end-of-scan stats
364
- getTotalDomainsSkipped,
365
252
  getDetectedDomainsCount
366
253
  };
@@ -26,12 +26,43 @@ function seededRandom(seed) {
26
26
  const _fingerprintCache = new Map();
27
27
  const FINGERPRINT_CACHE_MAX = 500;
28
28
 
29
+ // The build portion of the Chrome full version (major.0.BUILD). The reduced
30
+ // UA string deliberately hides this (Chrome/148.0.0.0), but the full-version
31
+ // Client Hints — Sec-CH-UA-Full-Version, -Full-Version-List, and
32
+ // userAgentData.getHighEntropyValues(['fullVersionList','uaFullVersion']) —
33
+ // expose the REAL build. Single source of truth so the HTTP headers (set in
34
+ // nwss.js, which imports this) and the JS high-entropy spoof can't disagree;
35
+ // a server cross-checking the two would catch a mismatch. The major comes
36
+ // from the UA string at each call site, so on a Chrome bump update BOTH the
37
+ // USER_AGENT_COLLECTIONS major AND this build.
38
+ //
39
+ // We deliberately spoof the current STABLE Chrome (148), NOT whatever build
40
+ // puppeteer happens to bundle (currently 149, ahead of Stable) — the spoof
41
+ // must blend with the real-world population, and almost nobody runs 149 yet.
42
+ // 7778.217 is a real 148 Stable build (verified against a live Chrome 148
43
+ // desktop). The bundled 149 binary still works; only the claimed identity is
44
+ // pinned to Stable.
45
+ const CHROME_BUILD = '7778.217';
46
+
47
+ // Chrome's UA-CH GREASE brand. Despite the name, GREASE is NOT random per
48
+ // request — Chromium derives the greasy brand string AND the brand-list order
49
+ // deterministically from the major version, so every real Chrome 148 emits the
50
+ // exact same Sec-CH-UA. Anti-bot detectors exploit this: a spoofer that uses a
51
+ // stale/wrong grease ("Not:A-Brand" instead of 148's "Not/A)Brand") or the
52
+ // wrong order is instantly flagged. Real Chrome 148 order is Chromium, Google
53
+ // Chrome, <grease>. Both the string AND the order are version-coupled — update
54
+ // alongside the major when bumping (verify against a real Chrome of that major).
55
+ const CHROME_GREASE_BRAND = 'Not/A)Brand';
56
+
29
57
  // User agent collections with latest versions
30
58
  const USER_AGENT_COLLECTIONS = Object.freeze(new Map([
31
59
  // Chrome version uses the reduced 'X.0.0.0' privacy-preserving form (per
32
- // Chrome's UA reduction since v101). The full build (148.0.7778.179) is
60
+ // Chrome's UA reduction since v101). The full build (148.0.7778.217) is
33
61
  // not exposed via UA in modern Chrome — UA-Client-Hints carries that
34
- // separately. User confirmed Chrome 148 stable as of late May 2026.
62
+ // separately (see CHROME_BUILD). Pinned to the current STABLE major (148),
63
+ // which is what the real-world population runs — NOT the newer build
64
+ // puppeteer bundles; we want to blend in, not advertise an ahead-of-Stable
65
+ // version. Bump when Stable advances, alongside CHROME_BUILD.
35
66
  ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"],
36
67
  ['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"],
37
68
  ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"],
@@ -177,7 +208,7 @@ function generateRealisticFingerprint(userAgent, domain = '') {
177
208
  // Generate OS-appropriate hardware specs
178
209
  const profiles = {
179
210
  windows: {
180
- deviceMemory: [8, 16], // Common Windows configurations
211
+ deviceMemory: [8], // caps at 8 (navigator.deviceMemory max); matches HTTP Sec-CH-Device-Memory on >=8GB hosts
181
212
  hardwareConcurrency: [4, 6, 8], // Typical consumer CPUs
182
213
  platform: 'Win32',
183
214
  timezone: 'America/New_York',
@@ -189,7 +220,7 @@ function generateRealisticFingerprint(userAgent, domain = '') {
189
220
  ]
190
221
  },
191
222
  mac: {
192
- deviceMemory: [8, 16], // MacBook/iMac typical configs
223
+ deviceMemory: [8], // caps at 8 (see Windows profile note)
193
224
  hardwareConcurrency: [8, 10], // Apple Silicon M1/M2 cores
194
225
  platform: 'MacIntel',
195
226
  timezone: 'America/Los_Angeles',
@@ -201,7 +232,7 @@ function generateRealisticFingerprint(userAgent, domain = '') {
201
232
  ]
202
233
  },
203
234
  linux: {
204
- deviceMemory: [8, 16],
235
+ deviceMemory: [8],
205
236
  hardwareConcurrency: [4, 8, 12], // Wide variety on Linux
206
237
  platform: 'Linux x86_64',
207
238
  timezone: 'America/New_York',
@@ -331,7 +362,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
331
362
  // same value regardless of which evaluate runs first.
332
363
  const realistic = generateRealisticFingerprint(ua, siteDomain);
333
364
 
334
- await page.evaluateOnNewDocument((userAgent, debugEnabled, gpuConfig, seededCores) => {
365
+ await page.evaluateOnNewDocument((userAgent, debugEnabled, gpuConfig, seededCores, chromeBuild, chromeGrease) => {
335
366
 
336
367
  // Apply inline error suppression first
337
368
  (function() {
@@ -879,13 +910,22 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
879
910
  if (!userAgent.includes('Chrome/')) return; // Only for Chrome UAs
880
911
 
881
912
  const chromeMatch = userAgent.match(/Chrome\/(\d+)/);
882
- const majorVersion = chromeMatch ? chromeMatch[1] : '145';
913
+ const majorVersion = chromeMatch ? chromeMatch[1] : '148';
883
914
 
884
915
  let platform = 'Windows';
885
- let platformVersion = '15.0.0';
916
+ // 19.0.0 = current Windows 11 mapping (verified against a real
917
+ // Chrome 148 desktop; the old 15.0.0 was an older Win11 build).
918
+ // MUST match nwss.js's Sec-CH-UA-Platform-Version header.
919
+ let platformVersion = '19.0.0';
886
920
  let architecture = 'x86';
887
921
  let model = '';
888
922
  let bitness = '64';
923
+ // wow64 is true only for a 32-bit Chrome on 64-bit Windows. Our
924
+ // spoofed configs are all 64-bit (bitness '64'), so it's false on
925
+ // every platform — but the value must be PRESENT: the server's
926
+ // accept-ch requests sec-ch-ua-wow64 and trackers call
927
+ // getHighEntropyValues(['wow64']); an undefined return is a tell.
928
+ const wow64 = false;
889
929
  if (userAgent.includes('Macintosh') || userAgent.includes('Mac OS X')) {
890
930
  platform = 'macOS';
891
931
  platformVersion = '13.5.0';
@@ -896,10 +936,12 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
896
936
  architecture = 'x86';
897
937
  }
898
938
 
939
+ // Order + grease must match real Chrome of this major exactly
940
+ // (deterministic GREASE): Chromium, Google Chrome, <grease>.
899
941
  const brands = [
900
- { brand: 'Not:A-Brand', version: '99' },
942
+ { brand: 'Chromium', version: majorVersion },
901
943
  { brand: 'Google Chrome', version: majorVersion },
902
- { brand: 'Chromium', version: majorVersion }
944
+ { brand: chromeGrease, version: '99' }
903
945
  ];
904
946
 
905
947
  const uaData = {
@@ -914,18 +956,20 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
914
956
  architecture: architecture,
915
957
  bitness: bitness,
916
958
  model: model,
959
+ wow64: wow64,
960
+ // Real Chrome (128+) always exposes this for desktop. Omitting
961
+ // it returned undefined to any site requesting form-factors.
962
+ formFactors: ['Desktop'],
917
963
  platformVersion: platformVersion,
964
+ // uaFullVersion (deprecated but still requested via
965
+ // sec-ch-ua-full-version) and fullVersionList both carry the
966
+ // real build, sourced from CHROME_BUILD (passed in as
967
+ // chromeBuild) so HTTP headers and JS can't drift apart.
968
+ uaFullVersion: majorVersion + '.0.' + chromeBuild,
918
969
  fullVersionList: [
919
- { brand: 'Not:A-Brand', version: '99.0.0.0' },
920
- // Build number 7778.179 matches real Chrome 148 stable
921
- // (per user-confirmed installed version as of late May 2026).
922
- // Prior 7632.160 was Chrome 145's build — outdated for 148.
923
- // If Chrome major bumps in future, update this build number
924
- // too — fullVersionList being inconsistent with the major
925
- // version is a fingerprint-detection signal (a real Chrome
926
- // 148 install would never report a 7632.x build).
927
- { brand: 'Google Chrome', version: majorVersion + '.0.7778.179' },
928
- { brand: 'Chromium', version: majorVersion + '.0.7778.179' }
970
+ { brand: 'Chromium', version: majorVersion + '.0.' + chromeBuild },
971
+ { brand: 'Google Chrome', version: majorVersion + '.0.' + chromeBuild },
972
+ { brand: chromeGrease, version: '99.0.0.0' }
929
973
  ]
930
974
  };
931
975
  // Only return requested hints
@@ -1575,25 +1619,11 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1575
1619
  }
1576
1620
  }, 'pdfViewerEnabled spoofing');
1577
1621
 
1578
- // speechSynthesis headless returns empty voices array
1579
- safeExecute(() => {
1580
- if (window.speechSynthesis) {
1581
- const origGetVoices = speechSynthesis.getVoices.bind(speechSynthesis);
1582
- speechSynthesis.getVoices = function() {
1583
- const voices = origGetVoices();
1584
- if (voices.length === 0) {
1585
- return [{
1586
- default: true, lang: 'en-US', localService: true,
1587
- name: 'Microsoft David - English (United States)', voiceURI: 'Microsoft David - English (United States)'
1588
- }, {
1589
- default: false, lang: 'en-US', localService: true,
1590
- name: 'Microsoft Zira - English (United States)', voiceURI: 'Microsoft Zira - English (United States)'
1591
- }];
1592
- }
1593
- return voices;
1594
- };
1595
- }
1596
- }, 'speechSynthesis spoofing');
1622
+ // (speechSynthesis voices are spoofed in a single, fuller block later
1623
+ // in this evaluate — "speechSynthesis voices spoofing" — which installs
1624
+ // the complete claimed-OS voice set with instanceof-correct objects. An
1625
+ // earlier 2-voice fallback override lived here and was redundant: the
1626
+ // later block replaced it unconditionally on every injection. Removed.)
1597
1627
 
1598
1628
  // AudioContext fingerprint spoofing — intercept the actual READ
1599
1629
  // surface fingerprinters care about.
@@ -2230,36 +2260,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
2230
2260
 
2231
2261
  // Battery API spoofing
2232
2262
  //
2233
- // Previously: `Math.random()` for every field, fired on every
2234
- // evaluateOnNewDocument injection (i.e. every page load). Battery
2235
- // 'level' jumping from 0.42 to 0.87 to 0.31 across navigations on
2236
- // the same site is anomalous -- real battery state changes slowly
2237
- // (minutes, not seconds). Detector noting battery delta across
2238
- // reloads catches us.
2263
+ // Report the plugged-in / fully-charged default: charging=true,
2264
+ // level=1, chargingTime=0, dischargingTime=Infinity. This is what a
2265
+ // real Chrome desktop reports (verified against a live reference) AND
2266
+ // what the largest slice of the population shows (desktops + plugged-in
2267
+ // laptops), so it blends with the majority.
2239
2268
  //
2240
- // Now: FNV-1a hash of window.location.hostname seeds all four
2241
- // fields. Stable per-domain across navigations; varies across
2242
- // sites (so cross-publisher correlation isn't trivial via this
2243
- // signal). Also corrects two real-Chrome invariants the old
2244
- // spoof violated:
2245
- // - charging=true -> chargingTime finite, dischargingTime = Infinity
2246
- // - charging=false -> chargingTime = Infinity, dischargingTime finite
2247
- // The old spoof could produce 'both finite' which never happens
2248
- // in real Chrome.
2269
+ // Battery is a known fingerprinting vector. The prior approach
2270
+ // per-domain-randomized a partial level (0.25..0.94) and the charging
2271
+ // state but a partial battery level is MORE identifying than the
2272
+ // common plugged-in state, and "Desktop" form-factor + a discharging
2273
+ // battery is mildly contradictory. The fixed plugged-in default is
2274
+ // both the least-surprising value and carries zero cross-domain entropy.
2275
+ // (Earlier history: an even-older spoof used Math.random() per field,
2276
+ // which made level jump between reloads also anomalous.)
2249
2277
  safeExecute(() => {
2250
2278
  if (navigator.getBattery) {
2251
- // FNV-1a 32-bit hash of the hostname -- cheap, deterministic.
2252
- let h = 0x811c9dc5;
2253
- const domain = (window.location && window.location.hostname) || '';
2254
- for (let i = 0; i < domain.length; i++) {
2255
- h = ((h ^ domain.charCodeAt(i)) * 0x01000193) >>> 0;
2256
- }
2257
- const charging = (h & 1) === 1;
2258
2279
  const batteryState = {
2259
- charging,
2260
- chargingTime: charging ? (((h >>> 4) % 3540) + 60) : Infinity, // 60..3600s when charging
2261
- dischargingTime: charging ? Infinity : (((h >>> 8) % 6600) + 600), // 600..7200s when on battery
2262
- level: Math.round((((h >>> 16) % 70) + 25)) / 100, // 0.25..0.94, 2-decimal precision
2280
+ charging: true,
2281
+ chargingTime: 0,
2282
+ dischargingTime: Infinity,
2283
+ level: 1,
2263
2284
  addEventListener: () => {},
2264
2285
  removeEventListener: () => {},
2265
2286
  dispatchEvent: () => true
@@ -2270,6 +2291,105 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
2270
2291
  }
2271
2292
  }, 'battery API spoofing');
2272
2293
 
2294
+ // navigator.bluetooth (Web Bluetooth). Real Chrome ALWAYS exposes the
2295
+ // Bluetooth object — even with no adapter, where getAvailability()
2296
+ // resolves false. Headless Chrome omits it entirely, so `'bluetooth'
2297
+ // in navigator` returning false is a headless tell. Provide a minimal
2298
+ // stub (only when missing) so the presence check passes; report no
2299
+ // adapter (false) — honest for a server, common for a real desktop,
2300
+ // and avoids claiming hardware a requestDevice() probe couldn't back.
2301
+ safeExecute(() => {
2302
+ if (!navigator.bluetooth) {
2303
+ const bt = {
2304
+ getAvailability: () => Promise.resolve(false),
2305
+ getDevices: () => Promise.resolve([]),
2306
+ requestDevice: () => Promise.reject(
2307
+ new DOMException('User cancelled the requestDevice() chooser.', 'NotFoundError')),
2308
+ addEventListener: () => {},
2309
+ removeEventListener: () => {},
2310
+ dispatchEvent: () => true
2311
+ };
2312
+ if (typeof maskAsNative === 'function') {
2313
+ maskAsNative(bt.getAvailability, 'getAvailability');
2314
+ maskAsNative(bt.requestDevice, 'requestDevice');
2315
+ maskAsNative(bt.getDevices, 'getDevices');
2316
+ }
2317
+ Object.defineProperty(navigator, 'bluetooth', { get: () => bt, configurable: true });
2318
+ }
2319
+ }, 'bluetooth API spoofing');
2320
+
2321
+ // SpeechSynthesis voices. Real Chrome ships an OS-dependent voice set;
2322
+ // a non-Windows headless host can't have the Microsoft SAPI voices a
2323
+ // Windows UA implies, so the short native list (2 voices here) reading
2324
+ // back contradicts the claimed OS. Provide the canonical set for the
2325
+ // claimed OS (verified vs a live Windows Chrome reference). Voice
2326
+ // objects are built on SpeechSynthesisVoice.prototype with OWN data
2327
+ // properties, so `voice instanceof SpeechSynthesisVoice` passes AND
2328
+ // name/lang/localService read back the spoofed values (own props shadow
2329
+ // the prototype getters). getVoices() returns a fresh array per call,
2330
+ // like real Chrome.
2331
+ safeExecute(() => {
2332
+ if (!window.speechSynthesis) return;
2333
+ const SSV = window.SpeechSynthesisVoice;
2334
+ const isWin = /Windows/.test(userAgent);
2335
+ const mk = (name, lang, local) => {
2336
+ const data = { voiceURI: name, name, lang, localService: local, default: false };
2337
+ if (SSV && SSV.prototype) {
2338
+ const v = Object.create(SSV.prototype);
2339
+ for (const k in data) {
2340
+ Object.defineProperty(v, k, { value: data[k], enumerable: true, configurable: true, writable: k === 'default' });
2341
+ }
2342
+ return v;
2343
+ }
2344
+ return data;
2345
+ };
2346
+ // Microsoft SAPI voices are Windows-only; the Google network voices
2347
+ // are the same set across platforms.
2348
+ const ms = isWin ? [
2349
+ mk('Microsoft David - English (United States)', 'en-US', true),
2350
+ mk('Microsoft Mark - English (United States)', 'en-US', true),
2351
+ mk('Microsoft Zira - English (United States)', 'en-US', true)
2352
+ ] : [];
2353
+ const google = [
2354
+ ['Google Deutsch', 'de-DE'], ['Google US English', 'en-US'],
2355
+ ['Google UK English Female', 'en-GB'], ['Google UK English Male', 'en-GB'],
2356
+ ['Google español', 'es-ES'], ['Google español de Estados Unidos', 'es-US'],
2357
+ ['Google français', 'fr-FR'], ['Google हिन्दी', 'hi-IN'],
2358
+ ['Google Bahasa Indonesia', 'id-ID'], ['Google italiano', 'it-IT'],
2359
+ ['Google 日本語', 'ja-JP'], ['Google 한국의', 'ko-KR'],
2360
+ ['Google Nederlands', 'nl-NL'], ['Google polski', 'pl-PL'],
2361
+ ['Google português do Brasil', 'pt-BR'], ['Google русский', 'ru-RU'],
2362
+ ['Google 普通话(中国大陆)', 'zh-CN'], ['Google 粤語(香港)', 'zh-HK'],
2363
+ ['Google 國語(臺灣)', 'zh-TW']
2364
+ ].map(([n, l]) => mk(n, l, false));
2365
+ const voices = ms.concat(google);
2366
+ if (voices[0]) voices[0].default = true;
2367
+ window.speechSynthesis.getVoices = function getVoices() { return voices.slice(); };
2368
+ if (typeof maskAsNative === 'function') maskAsNative(window.speechSynthesis.getVoices, 'getVoices');
2369
+ }, 'speechSynthesis voices spoofing');
2370
+
2371
+ // Web Share API (navigator.share / canShare). Present on real DESKTOP
2372
+ // Chrome (since 89) but absent in headless, so `'share' in navigator`
2373
+ // returning false contradicts a desktop UA. Provide stubs only when
2374
+ // missing. canShare() mirrors Chrome's "is there shareable data?"
2375
+ // result; share() requires transient user activation, which automation
2376
+ // never has, so real Chrome rejects with NotAllowedError — match that.
2377
+ safeExecute(() => {
2378
+ if (typeof navigator.canShare !== 'function') {
2379
+ const canShare = function canShare(data) {
2380
+ if (!data) return false;
2381
+ return !!(data.url || data.text || data.title || (data.files && data.files.length));
2382
+ };
2383
+ const share = function share() {
2384
+ return Promise.reject(new DOMException(
2385
+ 'Must be handling a user gesture to perform a share request.', 'NotAllowedError'));
2386
+ };
2387
+ if (typeof maskAsNative === 'function') { maskAsNative(canShare, 'canShare'); maskAsNative(share, 'share'); }
2388
+ Object.defineProperty(navigator, 'canShare', { value: canShare, configurable: true, writable: true });
2389
+ Object.defineProperty(navigator, 'share', { value: share, configurable: true, writable: true });
2390
+ }
2391
+ }, 'web share API spoofing');
2392
+
2273
2393
  // Enhanced Mouse/Pointer Spoofing
2274
2394
  //
2275
2395
  safeExecute(() => {
@@ -2435,7 +2555,9 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
2435
2555
  [HTMLCanvasElement.prototype, ['getContext', 'toDataURL', 'toBlob']],
2436
2556
  [CanvasRenderingContext2D.prototype, ['getImageData', 'fillText', 'strokeText', 'measureText']],
2437
2557
  [EventTarget.prototype, ['addEventListener', 'removeEventListener']],
2438
- [Date.prototype, ['getTimezoneOffset']],
2558
+ // Date.prototype.getTimezoneOffset is no longer overridden (timezone
2559
+ // is done via CDP emulateTimezone), so the native function needs no
2560
+ // masking — masking a native fn is at best a no-op, at worst a wrap.
2439
2561
  ];
2440
2562
  if (typeof WebGL2RenderingContext !== 'undefined') {
2441
2563
  protoMasks.push([WebGL2RenderingContext.prototype, ['getParameter', 'getExtension']]);
@@ -2516,7 +2638,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
2516
2638
  }
2517
2639
  }, 'interaction-gated script trigger');
2518
2640
 
2519
- }, ua, forceDebug, selectedGpu, realistic.hardwareConcurrency);
2641
+ }, ua, forceDebug, selectedGpu, realistic.hardwareConcurrency, CHROME_BUILD, CHROME_GREASE_BRAND);
2520
2642
  } catch (stealthErr) {
2521
2643
  if (isSessionClosedError(stealthErr)) {
2522
2644
  if (forceDebug) console.log(formatLogMessage('debug', `Page closed during stealth injection: ${currentUrl}`));
@@ -2704,36 +2826,14 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
2704
2826
  };
2705
2827
  spoofNavigatorProperties(navigator, languageSpoofProps);
2706
2828
 
2707
- // Timezone spoofing
2708
- if (spoof.timezone && window.Intl?.DateTimeFormat) {
2709
- const OriginalDateTimeFormat = window.Intl.DateTimeFormat;
2710
- window.Intl.DateTimeFormat = function(...args) {
2711
- const instance = new OriginalDateTimeFormat(...args);
2712
- const originalResolvedOptions = instance.resolvedOptions;
2713
-
2714
- instance.resolvedOptions = function() {
2715
- const opts = originalResolvedOptions.call(this);
2716
- opts.timeZone = spoof.timezone;
2717
- return opts;
2718
- };
2719
- return instance;
2720
- };
2721
- Object.setPrototypeOf(window.Intl.DateTimeFormat, OriginalDateTimeFormat);
2722
-
2723
- // Timezone offset spoofing
2724
- const timezoneOffsets = {
2725
- 'America/New_York': 300,
2726
- 'America/Los_Angeles': 480,
2727
- 'Europe/London': 0,
2728
- 'America/Chicago': 360
2729
- };
2730
-
2731
- const originalGetTimezoneOffset = Date.prototype.getTimezoneOffset;
2732
- Date.prototype.getTimezoneOffset = function() {
2733
- return timezoneOffsets[spoof.timezone] || originalGetTimezoneOffset.call(this);
2734
- };
2735
- }
2736
-
2829
+ // Timezone is handled at the CDP level via page.emulateTimezone() (set
2830
+ // in applyFingerprintProtection, Node side) — NOT here. JS-patching
2831
+ // Intl.resolvedOptions + Date.getTimezoneOffset was actively harmful: it
2832
+ // left the real Date object in the host's true zone, so the formatted
2833
+ // time / getHours() / Date.toString() contradicted the claimed zone, and
2834
+ // the hardcoded offsets ignored DST. emulateTimezone makes every Date and
2835
+ // Intl read consistent, so no JS override is needed (or wanted) here.
2836
+
2737
2837
  // Cookie and DNT spoofing
2738
2838
  if (spoof.cookieEnabled !== undefined) {
2739
2839
  safeDefinePropertyLocal(navigator, 'cookieEnabled', { get: () => spoof.cookieEnabled });
@@ -2743,6 +2843,22 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
2743
2843
  }
2744
2844
 
2745
2845
  }, { spoof, debugEnabled: forceDebug });
2846
+
2847
+ // Timezone: use CDP-level emulation (Emulation.setTimezoneOverride) instead
2848
+ // of JS patching. The old JS overrides (Intl.resolvedOptions + Date.proto
2849
+ // getTimezoneOffset) left the REAL Date object in the host's true zone, so
2850
+ // Date.toString(), getHours(), and an Intl format of the same instant all
2851
+ // contradicted the claimed zone (verified: claimed America/New_York while
2852
+ // Date reported the host zone — an 8-hour hour gap, a textbook tell).
2853
+ // emulateTimezone changes the browser's ACTUAL timezone, so Date, Intl, and
2854
+ // getTimezoneOffset are all consistent (and DST-correct).
2855
+ if (spoof.timezone) {
2856
+ try {
2857
+ await page.emulateTimezone(spoof.timezone);
2858
+ } catch (tzErr) {
2859
+ if (forceDebug) console.log(formatLogMessage('debug', `emulateTimezone(${spoof.timezone}) failed for ${currentUrl}: ${tzErr.message}`));
2860
+ }
2861
+ }
2746
2862
  } catch (err) {
2747
2863
  if (isSessionClosedError(err)) {
2748
2864
  if (forceDebug) console.log(formatLogMessage('debug', `Page closed during fingerprint injection: ${currentUrl}`));
@@ -2920,6 +3036,13 @@ async function applyAllFingerprintSpoofing(page, siteConfig, forceDebug, current
2920
3036
  // module.exports only if a new external consumer appears.
2921
3037
  module.exports = {
2922
3038
  applyAllFingerprintSpoofing,
3039
+ // Build portion of the Chrome full version — nwss.js uses it to keep the
3040
+ // Sec-CH-UA-Full-Version* HTTP headers consistent with the JS high-entropy
3041
+ // spoof. Single source of truth for the build that the reduced UA hides.
3042
+ CHROME_BUILD,
3043
+ // GREASE brand string — nwss.js uses it for the Sec-CH-UA / -Full-Version-List
3044
+ // headers so the HTTP brand list matches the JS brands (and real Chrome).
3045
+ CHROME_GREASE_BRAND,
2923
3046
  // Exposed for scripts/test-stealth.js so the harness can validate --ua=
2924
3047
  // against the canonical UA list (instead of duplicating the keys here).
2925
3048
  // The Map itself is frozen; consumers cannot mutate the spoof source.