@fanboynz/network-scanner 3.0.3 → 3.1.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,15 +26,57 @@ 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
- ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
32
- ['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
33
- ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
34
- ['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0"],
35
- ['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"],
36
- ['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"],
37
- ['safari', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15"]
59
+ // Chrome version uses the reduced 'X.0.0.0' privacy-preserving form (per
60
+ // Chrome's UA reduction since v101). The full build (148.0.7778.217) is
61
+ // not exposed via UA in modern Chrome UA-Client-Hints carries that
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.
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"],
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"],
68
+ ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"],
69
+ // Firefox 151 stable (user-confirmed: 151.0.2). UA convention is to use
70
+ // major.minor for both rv: and Firefox/ — patch level is not customary
71
+ // in the UA string and matches existing nwss style.
72
+ ['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0"],
73
+ ['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0"],
74
+ ['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0"],
75
+ // Safari 19.5 estimated current stable as of late May 2026 (macOS Tahoe).
76
+ // Not user-confirmed; adjust if a more recent Safari version is verified.
77
+ // The Version/X.Y prefix is what fingerprinters key off; Safari/605.1.15
78
+ // is the long-stable WebKit version string (~unchanged since Safari 17).
79
+ ['safari', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.5 Safari/605.1.15"]
38
80
  ]));
39
81
 
40
82
  // GPU pool — realistic vendor/renderer combos per OS (used for WebGL spoofing)
@@ -166,7 +208,7 @@ function generateRealisticFingerprint(userAgent, domain = '') {
166
208
  // Generate OS-appropriate hardware specs
167
209
  const profiles = {
168
210
  windows: {
169
- deviceMemory: [8, 16], // Common Windows configurations
211
+ deviceMemory: [8], // caps at 8 (navigator.deviceMemory max); matches HTTP Sec-CH-Device-Memory on >=8GB hosts
170
212
  hardwareConcurrency: [4, 6, 8], // Typical consumer CPUs
171
213
  platform: 'Win32',
172
214
  timezone: 'America/New_York',
@@ -178,7 +220,7 @@ function generateRealisticFingerprint(userAgent, domain = '') {
178
220
  ]
179
221
  },
180
222
  mac: {
181
- deviceMemory: [8, 16], // MacBook/iMac typical configs
223
+ deviceMemory: [8], // caps at 8 (see Windows profile note)
182
224
  hardwareConcurrency: [8, 10], // Apple Silicon M1/M2 cores
183
225
  platform: 'MacIntel',
184
226
  timezone: 'America/Los_Angeles',
@@ -190,7 +232,7 @@ function generateRealisticFingerprint(userAgent, domain = '') {
190
232
  ]
191
233
  },
192
234
  linux: {
193
- deviceMemory: [8, 16],
235
+ deviceMemory: [8],
194
236
  hardwareConcurrency: [4, 8, 12], // Wide variety on Linux
195
237
  platform: 'Linux x86_64',
196
238
  timezone: 'America/New_York',
@@ -320,7 +362,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
320
362
  // same value regardless of which evaluate runs first.
321
363
  const realistic = generateRealisticFingerprint(ua, siteDomain);
322
364
 
323
- await page.evaluateOnNewDocument((userAgent, debugEnabled, gpuConfig, seededCores) => {
365
+ await page.evaluateOnNewDocument((userAgent, debugEnabled, gpuConfig, seededCores, chromeBuild, chromeGrease) => {
324
366
 
325
367
  // Apply inline error suppression first
326
368
  (function() {
@@ -590,7 +632,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
590
632
  // consistent number. Was hardcoded "146.0.0.0" which lied
591
633
  // any time the UA was rotated to a different Chrome major.
592
634
  const m = userAgent.match(/Chrome\/(\d+)/);
593
- const major = m ? m[1] : '146';
635
+ const major = m ? m[1] : '148';
594
636
  return {
595
637
  name: "Chrome",
596
638
  version: `${major}.0.0.0`,
@@ -868,13 +910,22 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
868
910
  if (!userAgent.includes('Chrome/')) return; // Only for Chrome UAs
869
911
 
870
912
  const chromeMatch = userAgent.match(/Chrome\/(\d+)/);
871
- const majorVersion = chromeMatch ? chromeMatch[1] : '145';
913
+ const majorVersion = chromeMatch ? chromeMatch[1] : '148';
872
914
 
873
915
  let platform = 'Windows';
874
- 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';
875
920
  let architecture = 'x86';
876
921
  let model = '';
877
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;
878
929
  if (userAgent.includes('Macintosh') || userAgent.includes('Mac OS X')) {
879
930
  platform = 'macOS';
880
931
  platformVersion = '13.5.0';
@@ -885,10 +936,12 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
885
936
  architecture = 'x86';
886
937
  }
887
938
 
939
+ // Order + grease must match real Chrome of this major exactly
940
+ // (deterministic GREASE): Chromium, Google Chrome, <grease>.
888
941
  const brands = [
889
- { brand: 'Not:A-Brand', version: '99' },
942
+ { brand: 'Chromium', version: majorVersion },
890
943
  { brand: 'Google Chrome', version: majorVersion },
891
- { brand: 'Chromium', version: majorVersion }
944
+ { brand: chromeGrease, version: '99' }
892
945
  ];
893
946
 
894
947
  const uaData = {
@@ -903,11 +956,20 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
903
956
  architecture: architecture,
904
957
  bitness: bitness,
905
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'],
906
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,
907
969
  fullVersionList: [
908
- { brand: 'Not:A-Brand', version: '99.0.0.0' },
909
- { brand: 'Google Chrome', version: majorVersion + '.0.7632.160' },
910
- { brand: 'Chromium', version: majorVersion + '.0.7632.160' }
970
+ { brand: 'Chromium', version: majorVersion + '.0.' + chromeBuild },
971
+ { brand: 'Google Chrome', version: majorVersion + '.0.' + chromeBuild },
972
+ { brand: chromeGrease, version: '99.0.0.0' }
911
973
  ]
912
974
  };
913
975
  // Only return requested hints
@@ -1390,7 +1452,20 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1390
1452
  // simulation (interact / ghost-cursor) both should be true; we
1391
1453
  // default to true so a site checking before our synthetic
1392
1454
  // gestures fire still sees a 'user activated' page.
1393
- if (navigator && !navigator.userActivation) {
1455
+ //
1456
+ // ALWAYS override — the prior guard `if (!navigator.userActivation)`
1457
+ // meant this code only fired when the property was missing.
1458
+ // But navigator.userActivation has existed in Chrome since
1459
+ // version 88 (Jan 2021) — modern Chrome (the UA we spoof)
1460
+ // always has it natively. The guard therefore prevented the
1461
+ // spoof from ever firing in normal use; real headless Chrome's
1462
+ // hasBeenActive=false / isActive=false leaked through, an
1463
+ // easy bot signal that many modern detectors check
1464
+ // (DataDome, PerimeterX, the AdsCore-integrated popunder
1465
+ // loaders like blazyload.min.js which probe userActivation 6×
1466
+ // to gate window.open). Drop the guard so we always shadow
1467
+ // the prototype-level getter with our true/true values.
1468
+ if (navigator) {
1394
1469
  try {
1395
1470
  const userActivation = {
1396
1471
  hasBeenActive: true,
@@ -1455,17 +1530,80 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1455
1530
  // `navigator.connection === navigator.connection`; previously
1456
1531
  // the getter returned a new object on every read, which a
1457
1532
  // tracker could detect by comparing references.
1533
+ //
1534
+ // Per-domain seeded values (FNV-1a hash of hostname): hardcoded
1535
+ // '4g/wifi/50/10' across every site was a cross-publisher
1536
+ // tracking axis -- a fingerprinter aggregating across N sites
1537
+ // running the same script saw identical connection values
1538
+ // everywhere, useful as a stable identity signal. Domain-seeding
1539
+ // breaks that while keeping values stable per-domain (real
1540
+ // navigator.connection IS stable per-session; per-navigation
1541
+ // randomness would be its own anomaly). Same pattern as the
1542
+ // Battery API fix in c1affe4.
1458
1543
  safeExecute(() => {
1459
1544
  if (!navigator.connection) {
1545
+ let h = 0x811c9dc5;
1546
+ const domain = (window.location && window.location.hostname) || '';
1547
+ for (let i = 0; i < domain.length; i++) {
1548
+ h = ((h ^ domain.charCodeAt(i)) * 0x01000193) >>> 0;
1549
+ }
1550
+ // 4g 75%, 3g 18%, 2g 5%, slow-2g 2% — distribution biased
1551
+ // toward modern broadband since most page loads happen there.
1552
+ const etRoll = (h >>> 4) % 100;
1553
+ const effectiveType = etRoll < 75 ? '4g'
1554
+ : etRoll < 93 ? '3g'
1555
+ : etRoll < 98 ? '2g'
1556
+ : 'slow-2g';
1557
+ // rtt + downlink MUST correlate with effectiveType — the
1558
+ // W3C Network Information API defines effectiveType as a
1559
+ // CLASSIFICATION of rtt/downlink ranges, so producing
1560
+ // slow-2g with 32.5 Mbps downlink (as the prior uncorrelated
1561
+ // version did) is physically impossible in real Chrome. A
1562
+ // detector cross-checking effectiveType against rtt/downlink
1563
+ // magnitudes catches that trivially. Ranges below match the
1564
+ // spec's boundaries:
1565
+ // slow-2g: rtt > 2000ms, downlink < 0.05 Mbps
1566
+ // 2g: rtt > 1400ms, downlink < 0.07 Mbps
1567
+ // 3g: rtt > 270ms, downlink < 0.7 Mbps
1568
+ // 4g: rtt < 270ms, downlink >= 0.7 Mbps
1569
+ const rttRange = effectiveType === '4g' ? [25, 250]
1570
+ : effectiveType === '3g' ? [275, 1400]
1571
+ : effectiveType === '2g' ? [1425, 2000]
1572
+ : /* slow-2g */ [2025, 5000];
1573
+ const dlRange = effectiveType === '4g' ? [1.0, 50.0]
1574
+ : effectiveType === '3g' ? [0.1, 0.7]
1575
+ : effectiveType === '2g' ? [0.05, 0.07]
1576
+ : /* slow-2g */ [0.01, 0.05];
1577
+ // 25ms-bucketed rtt (Chrome's privacy rounding granularity)
1578
+ const rttRaw = rttRange[0] + (((h >>> 8) % 1000) / 1000) * (rttRange[1] - rttRange[0]);
1579
+ const rtt = Math.round(rttRaw / 25) * 25;
1580
+ // 0.025-precision downlink (Chrome's privacy rounding)
1581
+ const dlRaw = dlRange[0] + (((h >>> 16) % 1000) / 1000) * (dlRange[1] - dlRange[0]);
1582
+ const downlink = Math.round(dlRaw * 40) / 40;
1583
+ // saveData ~5% — most users don't enable Data Saver
1584
+ const saveData = ((h >>> 24) & 0xff) < 13;
1585
+ // NetworkInformation extends EventTarget — real Chrome has
1586
+ // dispatchEvent in addition to add/removeEventListener.
1587
+ // Returning true per the EventTarget spec for the
1588
+ // no-listeners case (no preventDefault called).
1460
1589
  const connectionInfoStable = {
1461
- effectiveType: '4g',
1462
- rtt: 50,
1463
- downlink: 10,
1464
- saveData: false,
1465
- type: 'wifi',
1590
+ effectiveType,
1591
+ rtt,
1592
+ downlink,
1593
+ saveData,
1466
1594
  addEventListener: () => {},
1467
- removeEventListener: () => {}
1595
+ removeEventListener: () => {},
1596
+ dispatchEvent: () => true
1468
1597
  };
1598
+ // NetworkInformation.type is DEPRECATED and only exposed on
1599
+ // mobile Chrome. On desktop Chrome (Windows/Mac/Linux) it's
1600
+ // not present — `'type' in navigator.connection` returns
1601
+ // false. Only include it when spoofing a mobile UA, else
1602
+ // its presence is a fingerprint tell against the desktop
1603
+ // platform our UA collection currently advertises.
1604
+ if (/Android|iPhone|iPad|iPod|Mobile/i.test(userAgent || '')) {
1605
+ connectionInfoStable.type = ((h >>> 12) % 10) < 7 ? 'wifi' : 'cellular';
1606
+ }
1469
1607
  Object.defineProperty(navigator, 'connection', {
1470
1608
  get: () => connectionInfoStable
1471
1609
  });
@@ -1481,49 +1619,146 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1481
1619
  }
1482
1620
  }, 'pdfViewerEnabled spoofing');
1483
1621
 
1484
- // speechSynthesis headless returns empty voices array
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.)
1627
+
1628
+ // AudioContext fingerprint spoofing — intercept the actual READ
1629
+ // surface fingerprinters care about.
1630
+ //
1631
+ // Previous spoof modified default frequency / threshold values on
1632
+ // OscillatorNode / DynamicsCompressor at create time. That was
1633
+ // essentially useless: real audio fingerprinters do
1634
+ // const osc = ctx.createOscillator();
1635
+ // osc.frequency.value = 1000; // ← OVERWRITES our noise
1636
+ // ... render ... ctx.startRendering().then(buf => hash(buf.getChannelData(0)));
1637
+ // The noise we put on the default 440 Hz was blown away the
1638
+ // moment the probe set its own value, and never affected the
1639
+ // rendered output the fingerprinter actually hashes.
1640
+ //
1641
+ // Correct attack surface: AudioBuffer.prototype.getChannelData.
1642
+ // EVERY audio fingerprinter ends up reading the rendered buffer
1643
+ // via this method (it's the only way to get Float32Array data
1644
+ // out). Wrap it at the prototype so all AudioBuffers (from
1645
+ // OfflineAudioContext.startRendering, from AudioBufferSourceNode,
1646
+ // from decodeAudioData, etc.) get the same treatment.
1647
+ //
1648
+ // Noise design:
1649
+ // - sparse (every 100th sample) so audio remains perceptually
1650
+ // identical if actually played
1651
+ // - tiny magnitude (±0.00005) so the hash differs but the wave
1652
+ // shape doesn't
1653
+ // - deterministic per session (audioSeed) so repeated renders
1654
+ // of the same audio produce the same noised output
1655
+ // - per-(buffer, channel) idempotency via WeakMap: calling
1656
+ // getChannelData(0) twice on the same buffer returns the
1657
+ // same data (real Chrome's getChannelData returns a stable
1658
+ // Float32Array view; double-noising on second call would be
1659
+ // detectable)
1485
1660
  safeExecute(() => {
1486
- if (window.speechSynthesis) {
1487
- const origGetVoices = speechSynthesis.getVoices.bind(speechSynthesis);
1488
- speechSynthesis.getVoices = function() {
1489
- const voices = origGetVoices();
1490
- if (voices.length === 0) {
1491
- return [{
1492
- default: true, lang: 'en-US', localService: true,
1493
- name: 'Microsoft David - English (United States)', voiceURI: 'Microsoft David - English (United States)'
1494
- }, {
1495
- default: false, lang: 'en-US', localService: true,
1496
- name: 'Microsoft Zira - English (United States)', voiceURI: 'Microsoft Zira - English (United States)'
1497
- }];
1661
+ if (typeof AudioBuffer === 'undefined' || !AudioBuffer.prototype || typeof AudioBuffer.prototype.getChannelData !== 'function') return;
1662
+
1663
+ const audioSeed = Math.floor(Math.random() * 2147483647);
1664
+ const noisedChannels = new WeakMap(); // buffer -> Set<channel>
1665
+
1666
+ // Shared noise function so getChannelData and copyFromChannel
1667
+ // produce noise at the SAME source-channel positions
1668
+ // (mod 100). A detector comparing a value from one method
1669
+ // against the same source position read via the other method
1670
+ // sees consistent noised values — no cross-method anomaly.
1671
+ const noiseAt = (channel, srcIdx) => {
1672
+ const n = ((audioSeed ^ srcIdx ^ (channel * 31)) * 1103515245 + 12345) & 0x7fffffff;
1673
+ return (n / 0x7fffffff) * 0.0001 - 0.00005;
1674
+ };
1675
+
1676
+ const origGetChannelData = AudioBuffer.prototype.getChannelData;
1677
+ const wrappedGetChannelData = function(channel) {
1678
+ const data = origGetChannelData.call(this, channel);
1679
+ // Cap large buffers — per-sample loop on multi-MB Float32Arrays
1680
+ // is too slow. 1M samples ≈ 22.6s of 44.1kHz mono audio; way
1681
+ // beyond what fingerprinters use (typically 44k = 1s).
1682
+ if (data.length > 1000000) return data;
1683
+
1684
+ let channelSet = noisedChannels.get(this);
1685
+ if (!channelSet) {
1686
+ channelSet = new Set();
1687
+ noisedChannels.set(this, channelSet);
1688
+ }
1689
+ if (!channelSet.has(channel)) {
1690
+ for (let i = 0; i < data.length; i += 100) {
1691
+ data[i] = Math.max(-1, Math.min(1, data[i] + noiseAt(channel, i)));
1692
+ }
1693
+ channelSet.add(channel);
1694
+ }
1695
+ return data;
1696
+ };
1697
+ maskAsNative(wrappedGetChannelData, 'getChannelData');
1698
+ AudioBuffer.prototype.getChannelData = wrappedGetChannelData;
1699
+
1700
+ // copyFromChannel — alternative read API. Without wrapping it,
1701
+ // a fingerprinter calling `buffer.copyFromChannel(dest, 0, 0)`
1702
+ // instead of `buffer.getChannelData(0)` gets the unmodified
1703
+ // canonical Chrome data, fully bypassing our getChannelData
1704
+ // noise. Same defense logic applied here; noise aligned by
1705
+ // SOURCE channel position (startInChannel + destination
1706
+ // offset) so cross-method consistency holds.
1707
+ if (typeof AudioBuffer.prototype.copyFromChannel === 'function') {
1708
+ const origCopyFromChannel = AudioBuffer.prototype.copyFromChannel;
1709
+ const wrappedCopyFromChannel = function(destination, channelNumber, startInChannel) {
1710
+ origCopyFromChannel.call(this, destination, channelNumber, startInChannel);
1711
+ if (!destination || destination.length > 1000000) return;
1712
+ // If getChannelData has ALREADY noised this (buffer,
1713
+ // channel) in-place, origCopyFromChannel just copied the
1714
+ // already-noised data into `destination`. Adding our own
1715
+ // noise on top would produce 2× noise -- detectable via
1716
+ // cross-method consistency probes
1717
+ // (data[i] === dest[i] should hold). Skip when previously
1718
+ // noised.
1719
+ const channelSet = noisedChannels.get(this);
1720
+ if (channelSet && channelSet.has(channelNumber)) return;
1721
+ const startSrc = startInChannel || 0;
1722
+ // Align noise positions to the same mod-100 source-channel
1723
+ // grid getChannelData uses. firstNoisedSrc is the smallest
1724
+ // multiple of 100 >= startSrc; map back to destination index.
1725
+ const firstNoisedSrc = Math.ceil(startSrc / 100) * 100;
1726
+ const firstNoisedDest = firstNoisedSrc - startSrc;
1727
+ for (let destI = firstNoisedDest; destI < destination.length; destI += 100) {
1728
+ const srcIdx = startSrc + destI;
1729
+ destination[destI] = Math.max(-1, Math.min(1, destination[destI] + noiseAt(channelNumber, srcIdx)));
1498
1730
  }
1499
- return voices;
1500
1731
  };
1732
+ maskAsNative(wrappedCopyFromChannel, 'copyFromChannel');
1733
+ AudioBuffer.prototype.copyFromChannel = wrappedCopyFromChannel;
1501
1734
  }
1502
- }, 'speechSynthesis spoofing');
1503
1735
 
1504
- // AudioContextheadless has distinct audio processing fingerprint
1505
- safeExecute(() => {
1506
- if (window.AudioContext || window.webkitAudioContext) {
1507
- const OrigAudioContext = window.AudioContext || window.webkitAudioContext;
1508
- const origCreateOscillator = OrigAudioContext.prototype.createOscillator;
1509
- const origCreateDynamicsCompressor = OrigAudioContext.prototype.createDynamicsCompressor;
1510
-
1511
- // Inject deterministic noise into audio output — consistent per session
1512
- const audioNoiseSeed = Math.random() * 0.01 - 0.005;
1513
- const compNoiseSeed = Math.random() * 0.1 - 0.05;
1514
-
1515
- OrigAudioContext.prototype.createOscillator = function() {
1516
- const osc = origCreateOscillator.call(this);
1517
- const origFreq = osc.frequency.value;
1518
- osc.frequency.value = origFreq + audioNoiseSeed;
1519
- return osc;
1520
- };
1521
- OrigAudioContext.prototype.createDynamicsCompressor = function() {
1522
- const comp = origCreateDynamicsCompressor.call(this);
1523
- const origThreshold = comp.threshold.value;
1524
- comp.threshold.value = origThreshold + compNoiseSeed;
1525
- return comp;
1736
+ // copyToChannelpage WRITES to the buffer, overwriting any
1737
+ // existing contents (including our in-place noise from a prior
1738
+ // getChannelData call). Without this wrap, the noisedChannels
1739
+ // flag remained set but the underlying data was fresh -- next
1740
+ // getChannelData would SKIP noise (channelSet.has(ch) === true)
1741
+ // and return the page's probe pattern unnoised. A fingerprinter
1742
+ // priming the buffer with a known pattern then re-reading via
1743
+ // getChannelData would see the canonical (unspoofed) values,
1744
+ // confirming our spoof isn't actually noising.
1745
+ // Fix: clear the noisedChannels flag for the written channel
1746
+ // before forwarding the write, so the next read re-noises.
1747
+ if (typeof AudioBuffer.prototype.copyToChannel === 'function') {
1748
+ const origCopyToChannel = AudioBuffer.prototype.copyToChannel;
1749
+ const wrappedCopyToChannel = function(source, channelNumber, startInChannel) {
1750
+ // Forward FIRST. If the original throws (invalid args,
1751
+ // detached buffer, etc.) the buffer is unchanged and we
1752
+ // must NOT clear the noisedChannels flag — otherwise the
1753
+ // next getChannelData would re-noise already-noised data,
1754
+ // stacking 2x noise that drifts on subsequent reads.
1755
+ const result = origCopyToChannel.call(this, source, channelNumber, startInChannel);
1756
+ const channelSet = noisedChannels.get(this);
1757
+ if (channelSet) channelSet.delete(channelNumber);
1758
+ return result;
1526
1759
  };
1760
+ maskAsNative(wrappedCopyToChannel, 'copyToChannel');
1761
+ AudioBuffer.prototype.copyToChannel = wrappedCopyToChannel;
1527
1762
  }
1528
1763
  }, 'AudioContext fingerprint spoofing');
1529
1764
 
@@ -1846,6 +2081,109 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1846
2081
  };
1847
2082
  }, 'performance timing obfuscation');
1848
2083
 
2084
+ // PerformanceNavigationTiming jitter — wrap entries returned by
2085
+ // performance.getEntriesByType('navigation') with a Proxy that adds
2086
+ // ±0.5ms jitter to each timing field. Headless Chrome's navigation
2087
+ // timings are suspiciously deterministic (no UI competing for the
2088
+ // main thread, no GC stalls from background rendering); adding
2089
+ // small jitter makes the per-phase durations look human-like.
2090
+ // Probed by some ad-network fingerprinters (e.g. ct.captcha-delivery,
2091
+ // popunder loaders sampling for fraud heuristics) as a 'robotic
2092
+ // timing' signal. Per-entry jitter offsets cached via WeakMap so
2093
+ // repeated reads on the same entry stay consistent (real navigation
2094
+ // timing values are stable per navigation).
2095
+ //
2096
+ // Scope: navigation entries only. 'resource' entries (per-subresource
2097
+ // timing) and PerformanceObserver-delivered entries are NOT wrapped
2098
+ // — significant additional complexity for marginal gain since
2099
+ // typical fingerprinters check only navigation timing.
2100
+ safeExecute(() => {
2101
+ if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') return;
2102
+
2103
+ const TIMING_FIELDS = new Set([
2104
+ 'fetchStart', 'startTime', 'duration', 'redirectStart', 'redirectEnd',
2105
+ 'workerStart', // service-worker startup timing (was missing — partial-coverage tell)
2106
+ 'domainLookupStart', 'domainLookupEnd',
2107
+ 'connectStart', 'connectEnd', 'secureConnectionStart',
2108
+ 'requestStart', 'responseStart', 'responseEnd',
2109
+ 'domInteractive', 'domContentLoadedEventStart', 'domContentLoadedEventEnd',
2110
+ 'domComplete', 'loadEventStart', 'loadEventEnd'
2111
+ ]);
2112
+ const wrappedEntries = new WeakMap();
2113
+
2114
+ const wrapEntry = (entry) => {
2115
+ if (entry == null || typeof entry !== 'object') return entry;
2116
+ const cached = wrappedEntries.get(entry);
2117
+ if (cached) return cached.proxy;
2118
+
2119
+ // Per-(entry, field) jitter cache so repeated reads of the
2120
+ // same timing field return the SAME jittered value (real
2121
+ // PerformanceNavigationTiming values are immutable per
2122
+ // navigation; jitter-noise on every read would be detectable
2123
+ // as anomalous). Don't jitter 0 -- those mean 'event never
2124
+ // occurred' (e.g. secureConnectionStart=0 for non-HTTPS);
2125
+ // adding noise would invent a connection that didn't happen.
2126
+ const jitterCache = new Map();
2127
+ const getJittered = (field, value) => {
2128
+ if (typeof value !== 'number' || value === 0) return value;
2129
+ let v = jitterCache.get(field);
2130
+ if (v === undefined) {
2131
+ v = value + (Math.random() - 0.5); // ±0.5ms
2132
+ jitterCache.set(field, v);
2133
+ }
2134
+ return v;
2135
+ };
2136
+
2137
+ // Per-property bound-method cache so accessing the same method
2138
+ // twice returns the same function identity (`p.toJSON === p.toJSON`
2139
+ // is true on real Chrome; without caching, Proxy returns a new
2140
+ // bound function every access — strict-equality probes catch us).
2141
+ // Bound methods also maskAsNative'd so their .toString() reports
2142
+ // '[native code]' rather than the bound-fn source.
2143
+ const methodCache = new Map();
2144
+
2145
+ const proxy = new Proxy(entry, {
2146
+ get(target, prop) {
2147
+ const value = target[prop];
2148
+ if (TIMING_FIELDS.has(prop)) return getJittered(prop, value);
2149
+ if (typeof value === 'function') {
2150
+ let m = methodCache.get(prop);
2151
+ if (m === undefined) {
2152
+ if (prop === 'toJSON') {
2153
+ // toJSON drives JSON.stringify; apply same per-field
2154
+ // jitter cache to the serialised object so direct-read
2155
+ // and JSON-roundtrip yield identical values.
2156
+ m = function() {
2157
+ const json = value.call(target);
2158
+ for (const f of TIMING_FIELDS) {
2159
+ if (f in json) json[f] = getJittered(f, json[f]);
2160
+ }
2161
+ return json;
2162
+ };
2163
+ } else {
2164
+ m = value.bind(target);
2165
+ }
2166
+ maskAsNative(m, prop);
2167
+ methodCache.set(prop, m);
2168
+ }
2169
+ return m;
2170
+ }
2171
+ return value;
2172
+ }
2173
+ });
2174
+ wrappedEntries.set(entry, { proxy, jitterCache, methodCache });
2175
+ return proxy;
2176
+ };
2177
+
2178
+ const origGetByType = performance.getEntriesByType;
2179
+ performance.getEntriesByType = function(type) {
2180
+ const entries = origGetByType.call(this, type);
2181
+ if (type === 'navigation') return entries.map(wrapEntry);
2182
+ return entries;
2183
+ };
2184
+ maskAsNative(performance.getEntriesByType, 'getEntriesByType');
2185
+ }, 'PerformanceNavigationTiming jitter');
2186
+
1849
2187
  // Canvas fingerprinting protection
1850
2188
  //
1851
2189
  safeExecute(() => {
@@ -1922,13 +2260,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1922
2260
 
1923
2261
  // Battery API spoofing
1924
2262
  //
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.
2268
+ //
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.)
1925
2277
  safeExecute(() => {
1926
2278
  if (navigator.getBattery) {
1927
2279
  const batteryState = {
1928
- charging: Math.random() > 0.5,
1929
- chargingTime: Math.random() > 0.5 ? Infinity : Math.floor(Math.random() * 3600),
1930
- dischargingTime: Math.floor(Math.random() * 7200),
1931
- level: Math.round((Math.random() * 0.7 + 0.25) * 100) / 100,
2280
+ charging: true,
2281
+ chargingTime: 0,
2282
+ dischargingTime: Infinity,
2283
+ level: 1,
1932
2284
  addEventListener: () => {},
1933
2285
  removeEventListener: () => {},
1934
2286
  dispatchEvent: () => true
@@ -1939,13 +2291,122 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1939
2291
  }
1940
2292
  }, 'battery API spoofing');
1941
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
+
1942
2393
  // Enhanced Mouse/Pointer Spoofing
1943
2394
  //
1944
2395
  safeExecute(() => {
1945
- // Spoof pointer capabilities
1946
- const spoofedTouchPoints = Math.random() > 0.7 ? 0 : Math.floor(Math.random() * 5) + 1;
2396
+ // Spoof pointer capabilities — UA-consistent. Previous code did
2397
+ // Math.random() > 0.7 ? 0 : Math.floor(Math.random() * 5) + 1
2398
+ // which randomly told the page 'this Windows Chrome has 3 touch
2399
+ // points' -- a fingerprinter cross-checking userAgent against
2400
+ // maxTouchPoints catches the invariant violation:
2401
+ // Windows/Mac/Linux Chrome -> maxTouchPoints = 0
2402
+ // iOS Safari, Android Chrome -> maxTouchPoints = 5 (typical)
2403
+ // Touch-laptop hybrid -> maxTouchPoints = 10 (rare)
2404
+ // Use the spoofed UA (userAgent variable in this closure) to
2405
+ // pick the right value. Mobile UAs get 5; desktop UAs get 0.
2406
+ const isMobileUA = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent || '');
2407
+ const spoofedTouchPoints = isMobileUA ? 5 : 0;
1947
2408
  if (navigator.maxTouchPoints !== undefined) {
1948
- safeDefinePropertyLocal(navigator, 'maxTouchPoints', {
2409
+ safeDefinePropertyLocal(navigator, 'maxTouchPoints', {
1949
2410
  get: () => spoofedTouchPoints
1950
2411
  });
1951
2412
  }
@@ -2067,21 +2528,24 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
2067
2528
 
2068
2529
  }, 'console error suppression');
2069
2530
 
2070
- // Hide source URL indicators (data: URLs reveal script injection)
2071
- safeExecute(() => {
2072
- const originalLocation = window.location;
2073
- Object.defineProperty(window, 'location', {
2074
- value: new Proxy(originalLocation, {
2075
- get: function(target, prop) {
2076
- if (prop === 'href' && target[prop] && target[prop].includes('data:')) {
2077
- return 'about:blank';
2078
- }
2079
- return target[prop];
2080
- }
2081
- }),
2082
- configurable: false
2083
- });
2084
- }, 'location URL masking');
2531
+ // NOTE: The previous `location URL masking` Proxy was removed.
2532
+ // It wrapped window.location in a Proxy to return 'about:blank' when
2533
+ // location.href.includes('data:') a bookmarklet/extension-injection
2534
+ // hider. nwss.js always navigates to real http(s) URLs via page.goto(),
2535
+ // so location.href never contains 'data:' and the spoof was dead code
2536
+ // for every real scan path. Costs it imposed before removal:
2537
+ // - `configurable: false` on window.location blocked page-side
2538
+ // navigation guards (Cloudflare rocket-loader, ad-script location
2539
+ // manipulation, popunder teardown) from redefining window.location.
2540
+ // - Proxy boundary visible via Object.getOwnPropertyDescriptor
2541
+ // (Proxy's value-as-non-spec-shape is a detectable surface).
2542
+ // - Page-side Object.defineProperty(location, 'href', ...) calls
2543
+ // hit the Proxy first, then forwarded to a spec-non-configurable
2544
+ // property, throwing "Cannot redefine property: href" — log noise
2545
+ // and a contributor to post-popup browser-unresponsive states on
2546
+ // popunder sites (eztv1.xyz scan reproduced this).
2547
+ // If a future use case actually loads via data: URL, reintroduce as a
2548
+ // get-trap-only Proxy with configurable:true so pages can override.
2085
2549
 
2086
2550
  // Bulk-mask all spoofed prototype methods so toString() returns "[native code]"
2087
2551
  // Must run AFTER all overrides are applied
@@ -2091,7 +2555,9 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
2091
2555
  [HTMLCanvasElement.prototype, ['getContext', 'toDataURL', 'toBlob']],
2092
2556
  [CanvasRenderingContext2D.prototype, ['getImageData', 'fillText', 'strokeText', 'measureText']],
2093
2557
  [EventTarget.prototype, ['addEventListener', 'removeEventListener']],
2094
- [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.
2095
2561
  ];
2096
2562
  if (typeof WebGL2RenderingContext !== 'undefined') {
2097
2563
  protoMasks.push([WebGL2RenderingContext.prototype, ['getParameter', 'getExtension']]);
@@ -2172,7 +2638,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
2172
2638
  }
2173
2639
  }, 'interaction-gated script trigger');
2174
2640
 
2175
- }, ua, forceDebug, selectedGpu, realistic.hardwareConcurrency);
2641
+ }, ua, forceDebug, selectedGpu, realistic.hardwareConcurrency, CHROME_BUILD, CHROME_GREASE_BRAND);
2176
2642
  } catch (stealthErr) {
2177
2643
  if (isSessionClosedError(stealthErr)) {
2178
2644
  if (forceDebug) console.log(formatLogMessage('debug', `Page closed during stealth injection: ${currentUrl}`));
@@ -2327,19 +2793,22 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
2327
2793
  // Platform, memory, and hardware spoofing combined for better V8 optimization
2328
2794
  // (moved into navigatorProps above);
2329
2795
 
2330
- const connectionInfo = {
2331
- effectiveType: ['slow-2g', '2g', '3g', '4g'][Math.floor(Math.random() * 4)],
2332
- type: Math.random() > 0.5 ? 'cellular' : 'wifi',
2333
- saveData: Math.random() > 0.8,
2334
- downlink: 1.5 + Math.random() * 8,
2335
- rtt: 50 + Math.random() * 200
2336
- };
2796
+ // navigator.connection spoof DELETED -- was dead code.
2797
+ // The UA-spoof block (applyUserAgentSpoofing's eOND, ~line 1452)
2798
+ // already defines navigator.connection earlier in the eOND
2799
+ // registration order, with Object.defineProperty (default
2800
+ // configurable: false). The previous re-spoof here used
2801
+ // safeDefinePropertyLocal which has an `existing?.configurable
2802
+ // === false` guard, so it silently failed every time the UA spoof
2803
+ // ran. Net effect: per-navigation random connection values
2804
+ // generated here NEVER reached navigator.connection -- the
2805
+ // hardcoded '4g'/wifi/50/10 from the UA block always won.
2806
+ // Removing this block has zero behavioural change in the typical
2807
+ // (UA + fingerprint_protection) case, and makes the code's actual
2808
+ // behaviour match what reading it would suggest. Per-domain
2809
+ // realistic-randomization of connection is a future improvement;
2810
+ // do it ONCE in the UA-spoof block, not twice with one of them dead.
2337
2811
 
2338
- // Connection type spoofing
2339
- safeDefinePropertyLocal(navigator, 'connection', {
2340
- get: () => connectionInfo
2341
- });
2342
-
2343
2812
  // Screen properties spoofing
2344
2813
  const screenSpoofProps = {};
2345
2814
  ['width', 'height', 'availWidth', 'availHeight', 'colorDepth', 'pixelDepth'].forEach(prop => {
@@ -2357,36 +2826,14 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
2357
2826
  };
2358
2827
  spoofNavigatorProperties(navigator, languageSpoofProps);
2359
2828
 
2360
- // Timezone spoofing
2361
- if (spoof.timezone && window.Intl?.DateTimeFormat) {
2362
- const OriginalDateTimeFormat = window.Intl.DateTimeFormat;
2363
- window.Intl.DateTimeFormat = function(...args) {
2364
- const instance = new OriginalDateTimeFormat(...args);
2365
- const originalResolvedOptions = instance.resolvedOptions;
2366
-
2367
- instance.resolvedOptions = function() {
2368
- const opts = originalResolvedOptions.call(this);
2369
- opts.timeZone = spoof.timezone;
2370
- return opts;
2371
- };
2372
- return instance;
2373
- };
2374
- Object.setPrototypeOf(window.Intl.DateTimeFormat, OriginalDateTimeFormat);
2375
-
2376
- // Timezone offset spoofing
2377
- const timezoneOffsets = {
2378
- 'America/New_York': 300,
2379
- 'America/Los_Angeles': 480,
2380
- 'Europe/London': 0,
2381
- 'America/Chicago': 360
2382
- };
2383
-
2384
- const originalGetTimezoneOffset = Date.prototype.getTimezoneOffset;
2385
- Date.prototype.getTimezoneOffset = function() {
2386
- return timezoneOffsets[spoof.timezone] || originalGetTimezoneOffset.call(this);
2387
- };
2388
- }
2389
-
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
+
2390
2837
  // Cookie and DNT spoofing
2391
2838
  if (spoof.cookieEnabled !== undefined) {
2392
2839
  safeDefinePropertyLocal(navigator, 'cookieEnabled', { get: () => spoof.cookieEnabled });
@@ -2396,6 +2843,22 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
2396
2843
  }
2397
2844
 
2398
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
+ }
2399
2862
  } catch (err) {
2400
2863
  if (isSessionClosedError(err)) {
2401
2864
  if (forceDebug) console.log(formatLogMessage('debug', `Page closed during fingerprint injection: ${currentUrl}`));
@@ -2573,6 +3036,13 @@ async function applyAllFingerprintSpoofing(page, siteConfig, forceDebug, current
2573
3036
  // module.exports only if a new external consumer appears.
2574
3037
  module.exports = {
2575
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,
2576
3046
  // Exposed for scripts/test-stealth.js so the harness can validate --ua=
2577
3047
  // against the canonical UA list (instead of duplicating the keys here).
2578
3048
  // The Map itself is frozen; consumers cannot mutate the spoof source.