@fanboynz/network-scanner 3.0.3 → 3.1.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.
@@ -28,13 +28,24 @@ const FINGERPRINT_CACHE_MAX = 500;
28
28
 
29
29
  // User agent collections with latest versions
30
30
  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"]
31
+ // 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
33
+ // not exposed via UA in modern Chrome UA-Client-Hints carries that
34
+ // separately. User confirmed Chrome 148 stable as of late May 2026.
35
+ ['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
+ ['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
+ ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"],
38
+ // Firefox 151 stable (user-confirmed: 151.0.2). UA convention is to use
39
+ // major.minor for both rv: and Firefox/ — patch level is not customary
40
+ // in the UA string and matches existing nwss style.
41
+ ['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0"],
42
+ ['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0"],
43
+ ['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0"],
44
+ // Safari 19.5 estimated current stable as of late May 2026 (macOS Tahoe).
45
+ // Not user-confirmed; adjust if a more recent Safari version is verified.
46
+ // The Version/X.Y prefix is what fingerprinters key off; Safari/605.1.15
47
+ // is the long-stable WebKit version string (~unchanged since Safari 17).
48
+ ['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
49
  ]));
39
50
 
40
51
  // GPU pool — realistic vendor/renderer combos per OS (used for WebGL spoofing)
@@ -590,7 +601,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
590
601
  // consistent number. Was hardcoded "146.0.0.0" which lied
591
602
  // any time the UA was rotated to a different Chrome major.
592
603
  const m = userAgent.match(/Chrome\/(\d+)/);
593
- const major = m ? m[1] : '146';
604
+ const major = m ? m[1] : '148';
594
605
  return {
595
606
  name: "Chrome",
596
607
  version: `${major}.0.0.0`,
@@ -906,8 +917,15 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
906
917
  platformVersion: platformVersion,
907
918
  fullVersionList: [
908
919
  { 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' }
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' }
911
929
  ]
912
930
  };
913
931
  // Only return requested hints
@@ -1390,7 +1408,20 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1390
1408
  // simulation (interact / ghost-cursor) both should be true; we
1391
1409
  // default to true so a site checking before our synthetic
1392
1410
  // gestures fire still sees a 'user activated' page.
1393
- if (navigator && !navigator.userActivation) {
1411
+ //
1412
+ // ALWAYS override — the prior guard `if (!navigator.userActivation)`
1413
+ // meant this code only fired when the property was missing.
1414
+ // But navigator.userActivation has existed in Chrome since
1415
+ // version 88 (Jan 2021) — modern Chrome (the UA we spoof)
1416
+ // always has it natively. The guard therefore prevented the
1417
+ // spoof from ever firing in normal use; real headless Chrome's
1418
+ // hasBeenActive=false / isActive=false leaked through, an
1419
+ // easy bot signal that many modern detectors check
1420
+ // (DataDome, PerimeterX, the AdsCore-integrated popunder
1421
+ // loaders like blazyload.min.js which probe userActivation 6×
1422
+ // to gate window.open). Drop the guard so we always shadow
1423
+ // the prototype-level getter with our true/true values.
1424
+ if (navigator) {
1394
1425
  try {
1395
1426
  const userActivation = {
1396
1427
  hasBeenActive: true,
@@ -1455,17 +1486,80 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1455
1486
  // `navigator.connection === navigator.connection`; previously
1456
1487
  // the getter returned a new object on every read, which a
1457
1488
  // tracker could detect by comparing references.
1489
+ //
1490
+ // Per-domain seeded values (FNV-1a hash of hostname): hardcoded
1491
+ // '4g/wifi/50/10' across every site was a cross-publisher
1492
+ // tracking axis -- a fingerprinter aggregating across N sites
1493
+ // running the same script saw identical connection values
1494
+ // everywhere, useful as a stable identity signal. Domain-seeding
1495
+ // breaks that while keeping values stable per-domain (real
1496
+ // navigator.connection IS stable per-session; per-navigation
1497
+ // randomness would be its own anomaly). Same pattern as the
1498
+ // Battery API fix in c1affe4.
1458
1499
  safeExecute(() => {
1459
1500
  if (!navigator.connection) {
1501
+ let h = 0x811c9dc5;
1502
+ const domain = (window.location && window.location.hostname) || '';
1503
+ for (let i = 0; i < domain.length; i++) {
1504
+ h = ((h ^ domain.charCodeAt(i)) * 0x01000193) >>> 0;
1505
+ }
1506
+ // 4g 75%, 3g 18%, 2g 5%, slow-2g 2% — distribution biased
1507
+ // toward modern broadband since most page loads happen there.
1508
+ const etRoll = (h >>> 4) % 100;
1509
+ const effectiveType = etRoll < 75 ? '4g'
1510
+ : etRoll < 93 ? '3g'
1511
+ : etRoll < 98 ? '2g'
1512
+ : 'slow-2g';
1513
+ // rtt + downlink MUST correlate with effectiveType — the
1514
+ // W3C Network Information API defines effectiveType as a
1515
+ // CLASSIFICATION of rtt/downlink ranges, so producing
1516
+ // slow-2g with 32.5 Mbps downlink (as the prior uncorrelated
1517
+ // version did) is physically impossible in real Chrome. A
1518
+ // detector cross-checking effectiveType against rtt/downlink
1519
+ // magnitudes catches that trivially. Ranges below match the
1520
+ // spec's boundaries:
1521
+ // slow-2g: rtt > 2000ms, downlink < 0.05 Mbps
1522
+ // 2g: rtt > 1400ms, downlink < 0.07 Mbps
1523
+ // 3g: rtt > 270ms, downlink < 0.7 Mbps
1524
+ // 4g: rtt < 270ms, downlink >= 0.7 Mbps
1525
+ const rttRange = effectiveType === '4g' ? [25, 250]
1526
+ : effectiveType === '3g' ? [275, 1400]
1527
+ : effectiveType === '2g' ? [1425, 2000]
1528
+ : /* slow-2g */ [2025, 5000];
1529
+ const dlRange = effectiveType === '4g' ? [1.0, 50.0]
1530
+ : effectiveType === '3g' ? [0.1, 0.7]
1531
+ : effectiveType === '2g' ? [0.05, 0.07]
1532
+ : /* slow-2g */ [0.01, 0.05];
1533
+ // 25ms-bucketed rtt (Chrome's privacy rounding granularity)
1534
+ const rttRaw = rttRange[0] + (((h >>> 8) % 1000) / 1000) * (rttRange[1] - rttRange[0]);
1535
+ const rtt = Math.round(rttRaw / 25) * 25;
1536
+ // 0.025-precision downlink (Chrome's privacy rounding)
1537
+ const dlRaw = dlRange[0] + (((h >>> 16) % 1000) / 1000) * (dlRange[1] - dlRange[0]);
1538
+ const downlink = Math.round(dlRaw * 40) / 40;
1539
+ // saveData ~5% — most users don't enable Data Saver
1540
+ const saveData = ((h >>> 24) & 0xff) < 13;
1541
+ // NetworkInformation extends EventTarget — real Chrome has
1542
+ // dispatchEvent in addition to add/removeEventListener.
1543
+ // Returning true per the EventTarget spec for the
1544
+ // no-listeners case (no preventDefault called).
1460
1545
  const connectionInfoStable = {
1461
- effectiveType: '4g',
1462
- rtt: 50,
1463
- downlink: 10,
1464
- saveData: false,
1465
- type: 'wifi',
1546
+ effectiveType,
1547
+ rtt,
1548
+ downlink,
1549
+ saveData,
1466
1550
  addEventListener: () => {},
1467
- removeEventListener: () => {}
1551
+ removeEventListener: () => {},
1552
+ dispatchEvent: () => true
1468
1553
  };
1554
+ // NetworkInformation.type is DEPRECATED and only exposed on
1555
+ // mobile Chrome. On desktop Chrome (Windows/Mac/Linux) it's
1556
+ // not present — `'type' in navigator.connection` returns
1557
+ // false. Only include it when spoofing a mobile UA, else
1558
+ // its presence is a fingerprint tell against the desktop
1559
+ // platform our UA collection currently advertises.
1560
+ if (/Android|iPhone|iPad|iPod|Mobile/i.test(userAgent || '')) {
1561
+ connectionInfoStable.type = ((h >>> 12) % 10) < 7 ? 'wifi' : 'cellular';
1562
+ }
1469
1563
  Object.defineProperty(navigator, 'connection', {
1470
1564
  get: () => connectionInfoStable
1471
1565
  });
@@ -1501,29 +1595,140 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1501
1595
  }
1502
1596
  }, 'speechSynthesis spoofing');
1503
1597
 
1504
- // AudioContext headless has distinct audio processing fingerprint
1598
+ // AudioContext fingerprint spoofing intercept the actual READ
1599
+ // surface fingerprinters care about.
1600
+ //
1601
+ // Previous spoof modified default frequency / threshold values on
1602
+ // OscillatorNode / DynamicsCompressor at create time. That was
1603
+ // essentially useless: real audio fingerprinters do
1604
+ // const osc = ctx.createOscillator();
1605
+ // osc.frequency.value = 1000; // ← OVERWRITES our noise
1606
+ // ... render ... ctx.startRendering().then(buf => hash(buf.getChannelData(0)));
1607
+ // The noise we put on the default 440 Hz was blown away the
1608
+ // moment the probe set its own value, and never affected the
1609
+ // rendered output the fingerprinter actually hashes.
1610
+ //
1611
+ // Correct attack surface: AudioBuffer.prototype.getChannelData.
1612
+ // EVERY audio fingerprinter ends up reading the rendered buffer
1613
+ // via this method (it's the only way to get Float32Array data
1614
+ // out). Wrap it at the prototype so all AudioBuffers (from
1615
+ // OfflineAudioContext.startRendering, from AudioBufferSourceNode,
1616
+ // from decodeAudioData, etc.) get the same treatment.
1617
+ //
1618
+ // Noise design:
1619
+ // - sparse (every 100th sample) so audio remains perceptually
1620
+ // identical if actually played
1621
+ // - tiny magnitude (±0.00005) so the hash differs but the wave
1622
+ // shape doesn't
1623
+ // - deterministic per session (audioSeed) so repeated renders
1624
+ // of the same audio produce the same noised output
1625
+ // - per-(buffer, channel) idempotency via WeakMap: calling
1626
+ // getChannelData(0) twice on the same buffer returns the
1627
+ // same data (real Chrome's getChannelData returns a stable
1628
+ // Float32Array view; double-noising on second call would be
1629
+ // detectable)
1505
1630
  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;
1631
+ if (typeof AudioBuffer === 'undefined' || !AudioBuffer.prototype || typeof AudioBuffer.prototype.getChannelData !== 'function') return;
1632
+
1633
+ const audioSeed = Math.floor(Math.random() * 2147483647);
1634
+ const noisedChannels = new WeakMap(); // buffer -> Set<channel>
1635
+
1636
+ // Shared noise function so getChannelData and copyFromChannel
1637
+ // produce noise at the SAME source-channel positions
1638
+ // (mod 100). A detector comparing a value from one method
1639
+ // against the same source position read via the other method
1640
+ // sees consistent noised values — no cross-method anomaly.
1641
+ const noiseAt = (channel, srcIdx) => {
1642
+ const n = ((audioSeed ^ srcIdx ^ (channel * 31)) * 1103515245 + 12345) & 0x7fffffff;
1643
+ return (n / 0x7fffffff) * 0.0001 - 0.00005;
1644
+ };
1645
+
1646
+ const origGetChannelData = AudioBuffer.prototype.getChannelData;
1647
+ const wrappedGetChannelData = function(channel) {
1648
+ const data = origGetChannelData.call(this, channel);
1649
+ // Cap large buffers — per-sample loop on multi-MB Float32Arrays
1650
+ // is too slow. 1M samples ≈ 22.6s of 44.1kHz mono audio; way
1651
+ // beyond what fingerprinters use (typically 44k = 1s).
1652
+ if (data.length > 1000000) return data;
1653
+
1654
+ let channelSet = noisedChannels.get(this);
1655
+ if (!channelSet) {
1656
+ channelSet = new Set();
1657
+ noisedChannels.set(this, channelSet);
1658
+ }
1659
+ if (!channelSet.has(channel)) {
1660
+ for (let i = 0; i < data.length; i += 100) {
1661
+ data[i] = Math.max(-1, Math.min(1, data[i] + noiseAt(channel, i)));
1662
+ }
1663
+ channelSet.add(channel);
1664
+ }
1665
+ return data;
1666
+ };
1667
+ maskAsNative(wrappedGetChannelData, 'getChannelData');
1668
+ AudioBuffer.prototype.getChannelData = wrappedGetChannelData;
1669
+
1670
+ // copyFromChannel — alternative read API. Without wrapping it,
1671
+ // a fingerprinter calling `buffer.copyFromChannel(dest, 0, 0)`
1672
+ // instead of `buffer.getChannelData(0)` gets the unmodified
1673
+ // canonical Chrome data, fully bypassing our getChannelData
1674
+ // noise. Same defense logic applied here; noise aligned by
1675
+ // SOURCE channel position (startInChannel + destination
1676
+ // offset) so cross-method consistency holds.
1677
+ if (typeof AudioBuffer.prototype.copyFromChannel === 'function') {
1678
+ const origCopyFromChannel = AudioBuffer.prototype.copyFromChannel;
1679
+ const wrappedCopyFromChannel = function(destination, channelNumber, startInChannel) {
1680
+ origCopyFromChannel.call(this, destination, channelNumber, startInChannel);
1681
+ if (!destination || destination.length > 1000000) return;
1682
+ // If getChannelData has ALREADY noised this (buffer,
1683
+ // channel) in-place, origCopyFromChannel just copied the
1684
+ // already-noised data into `destination`. Adding our own
1685
+ // noise on top would produce 2× noise -- detectable via
1686
+ // cross-method consistency probes
1687
+ // (data[i] === dest[i] should hold). Skip when previously
1688
+ // noised.
1689
+ const channelSet = noisedChannels.get(this);
1690
+ if (channelSet && channelSet.has(channelNumber)) return;
1691
+ const startSrc = startInChannel || 0;
1692
+ // Align noise positions to the same mod-100 source-channel
1693
+ // grid getChannelData uses. firstNoisedSrc is the smallest
1694
+ // multiple of 100 >= startSrc; map back to destination index.
1695
+ const firstNoisedSrc = Math.ceil(startSrc / 100) * 100;
1696
+ const firstNoisedDest = firstNoisedSrc - startSrc;
1697
+ for (let destI = firstNoisedDest; destI < destination.length; destI += 100) {
1698
+ const srcIdx = startSrc + destI;
1699
+ destination[destI] = Math.max(-1, Math.min(1, destination[destI] + noiseAt(channelNumber, srcIdx)));
1700
+ }
1520
1701
  };
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;
1702
+ maskAsNative(wrappedCopyFromChannel, 'copyFromChannel');
1703
+ AudioBuffer.prototype.copyFromChannel = wrappedCopyFromChannel;
1704
+ }
1705
+
1706
+ // copyToChannel — page WRITES to the buffer, overwriting any
1707
+ // existing contents (including our in-place noise from a prior
1708
+ // getChannelData call). Without this wrap, the noisedChannels
1709
+ // flag remained set but the underlying data was fresh -- next
1710
+ // getChannelData would SKIP noise (channelSet.has(ch) === true)
1711
+ // and return the page's probe pattern unnoised. A fingerprinter
1712
+ // priming the buffer with a known pattern then re-reading via
1713
+ // getChannelData would see the canonical (unspoofed) values,
1714
+ // confirming our spoof isn't actually noising.
1715
+ // Fix: clear the noisedChannels flag for the written channel
1716
+ // before forwarding the write, so the next read re-noises.
1717
+ if (typeof AudioBuffer.prototype.copyToChannel === 'function') {
1718
+ const origCopyToChannel = AudioBuffer.prototype.copyToChannel;
1719
+ const wrappedCopyToChannel = function(source, channelNumber, startInChannel) {
1720
+ // Forward FIRST. If the original throws (invalid args,
1721
+ // detached buffer, etc.) the buffer is unchanged and we
1722
+ // must NOT clear the noisedChannels flag — otherwise the
1723
+ // next getChannelData would re-noise already-noised data,
1724
+ // stacking 2x noise that drifts on subsequent reads.
1725
+ const result = origCopyToChannel.call(this, source, channelNumber, startInChannel);
1726
+ const channelSet = noisedChannels.get(this);
1727
+ if (channelSet) channelSet.delete(channelNumber);
1728
+ return result;
1526
1729
  };
1730
+ maskAsNative(wrappedCopyToChannel, 'copyToChannel');
1731
+ AudioBuffer.prototype.copyToChannel = wrappedCopyToChannel;
1527
1732
  }
1528
1733
  }, 'AudioContext fingerprint spoofing');
1529
1734
 
@@ -1846,6 +2051,109 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1846
2051
  };
1847
2052
  }, 'performance timing obfuscation');
1848
2053
 
2054
+ // PerformanceNavigationTiming jitter — wrap entries returned by
2055
+ // performance.getEntriesByType('navigation') with a Proxy that adds
2056
+ // ±0.5ms jitter to each timing field. Headless Chrome's navigation
2057
+ // timings are suspiciously deterministic (no UI competing for the
2058
+ // main thread, no GC stalls from background rendering); adding
2059
+ // small jitter makes the per-phase durations look human-like.
2060
+ // Probed by some ad-network fingerprinters (e.g. ct.captcha-delivery,
2061
+ // popunder loaders sampling for fraud heuristics) as a 'robotic
2062
+ // timing' signal. Per-entry jitter offsets cached via WeakMap so
2063
+ // repeated reads on the same entry stay consistent (real navigation
2064
+ // timing values are stable per navigation).
2065
+ //
2066
+ // Scope: navigation entries only. 'resource' entries (per-subresource
2067
+ // timing) and PerformanceObserver-delivered entries are NOT wrapped
2068
+ // — significant additional complexity for marginal gain since
2069
+ // typical fingerprinters check only navigation timing.
2070
+ safeExecute(() => {
2071
+ if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') return;
2072
+
2073
+ const TIMING_FIELDS = new Set([
2074
+ 'fetchStart', 'startTime', 'duration', 'redirectStart', 'redirectEnd',
2075
+ 'workerStart', // service-worker startup timing (was missing — partial-coverage tell)
2076
+ 'domainLookupStart', 'domainLookupEnd',
2077
+ 'connectStart', 'connectEnd', 'secureConnectionStart',
2078
+ 'requestStart', 'responseStart', 'responseEnd',
2079
+ 'domInteractive', 'domContentLoadedEventStart', 'domContentLoadedEventEnd',
2080
+ 'domComplete', 'loadEventStart', 'loadEventEnd'
2081
+ ]);
2082
+ const wrappedEntries = new WeakMap();
2083
+
2084
+ const wrapEntry = (entry) => {
2085
+ if (entry == null || typeof entry !== 'object') return entry;
2086
+ const cached = wrappedEntries.get(entry);
2087
+ if (cached) return cached.proxy;
2088
+
2089
+ // Per-(entry, field) jitter cache so repeated reads of the
2090
+ // same timing field return the SAME jittered value (real
2091
+ // PerformanceNavigationTiming values are immutable per
2092
+ // navigation; jitter-noise on every read would be detectable
2093
+ // as anomalous). Don't jitter 0 -- those mean 'event never
2094
+ // occurred' (e.g. secureConnectionStart=0 for non-HTTPS);
2095
+ // adding noise would invent a connection that didn't happen.
2096
+ const jitterCache = new Map();
2097
+ const getJittered = (field, value) => {
2098
+ if (typeof value !== 'number' || value === 0) return value;
2099
+ let v = jitterCache.get(field);
2100
+ if (v === undefined) {
2101
+ v = value + (Math.random() - 0.5); // ±0.5ms
2102
+ jitterCache.set(field, v);
2103
+ }
2104
+ return v;
2105
+ };
2106
+
2107
+ // Per-property bound-method cache so accessing the same method
2108
+ // twice returns the same function identity (`p.toJSON === p.toJSON`
2109
+ // is true on real Chrome; without caching, Proxy returns a new
2110
+ // bound function every access — strict-equality probes catch us).
2111
+ // Bound methods also maskAsNative'd so their .toString() reports
2112
+ // '[native code]' rather than the bound-fn source.
2113
+ const methodCache = new Map();
2114
+
2115
+ const proxy = new Proxy(entry, {
2116
+ get(target, prop) {
2117
+ const value = target[prop];
2118
+ if (TIMING_FIELDS.has(prop)) return getJittered(prop, value);
2119
+ if (typeof value === 'function') {
2120
+ let m = methodCache.get(prop);
2121
+ if (m === undefined) {
2122
+ if (prop === 'toJSON') {
2123
+ // toJSON drives JSON.stringify; apply same per-field
2124
+ // jitter cache to the serialised object so direct-read
2125
+ // and JSON-roundtrip yield identical values.
2126
+ m = function() {
2127
+ const json = value.call(target);
2128
+ for (const f of TIMING_FIELDS) {
2129
+ if (f in json) json[f] = getJittered(f, json[f]);
2130
+ }
2131
+ return json;
2132
+ };
2133
+ } else {
2134
+ m = value.bind(target);
2135
+ }
2136
+ maskAsNative(m, prop);
2137
+ methodCache.set(prop, m);
2138
+ }
2139
+ return m;
2140
+ }
2141
+ return value;
2142
+ }
2143
+ });
2144
+ wrappedEntries.set(entry, { proxy, jitterCache, methodCache });
2145
+ return proxy;
2146
+ };
2147
+
2148
+ const origGetByType = performance.getEntriesByType;
2149
+ performance.getEntriesByType = function(type) {
2150
+ const entries = origGetByType.call(this, type);
2151
+ if (type === 'navigation') return entries.map(wrapEntry);
2152
+ return entries;
2153
+ };
2154
+ maskAsNative(performance.getEntriesByType, 'getEntriesByType');
2155
+ }, 'PerformanceNavigationTiming jitter');
2156
+
1849
2157
  // Canvas fingerprinting protection
1850
2158
  //
1851
2159
  safeExecute(() => {
@@ -1922,13 +2230,36 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1922
2230
 
1923
2231
  // Battery API spoofing
1924
2232
  //
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.
2239
+ //
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.
1925
2249
  safeExecute(() => {
1926
2250
  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;
1927
2258
  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,
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
1932
2263
  addEventListener: () => {},
1933
2264
  removeEventListener: () => {},
1934
2265
  dispatchEvent: () => true
@@ -1942,10 +2273,20 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1942
2273
  // Enhanced Mouse/Pointer Spoofing
1943
2274
  //
1944
2275
  safeExecute(() => {
1945
- // Spoof pointer capabilities
1946
- const spoofedTouchPoints = Math.random() > 0.7 ? 0 : Math.floor(Math.random() * 5) + 1;
2276
+ // Spoof pointer capabilities — UA-consistent. Previous code did
2277
+ // Math.random() > 0.7 ? 0 : Math.floor(Math.random() * 5) + 1
2278
+ // which randomly told the page 'this Windows Chrome has 3 touch
2279
+ // points' -- a fingerprinter cross-checking userAgent against
2280
+ // maxTouchPoints catches the invariant violation:
2281
+ // Windows/Mac/Linux Chrome -> maxTouchPoints = 0
2282
+ // iOS Safari, Android Chrome -> maxTouchPoints = 5 (typical)
2283
+ // Touch-laptop hybrid -> maxTouchPoints = 10 (rare)
2284
+ // Use the spoofed UA (userAgent variable in this closure) to
2285
+ // pick the right value. Mobile UAs get 5; desktop UAs get 0.
2286
+ const isMobileUA = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent || '');
2287
+ const spoofedTouchPoints = isMobileUA ? 5 : 0;
1947
2288
  if (navigator.maxTouchPoints !== undefined) {
1948
- safeDefinePropertyLocal(navigator, 'maxTouchPoints', {
2289
+ safeDefinePropertyLocal(navigator, 'maxTouchPoints', {
1949
2290
  get: () => spoofedTouchPoints
1950
2291
  });
1951
2292
  }
@@ -2067,21 +2408,24 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
2067
2408
 
2068
2409
  }, 'console error suppression');
2069
2410
 
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');
2411
+ // NOTE: The previous `location URL masking` Proxy was removed.
2412
+ // It wrapped window.location in a Proxy to return 'about:blank' when
2413
+ // location.href.includes('data:') a bookmarklet/extension-injection
2414
+ // hider. nwss.js always navigates to real http(s) URLs via page.goto(),
2415
+ // so location.href never contains 'data:' and the spoof was dead code
2416
+ // for every real scan path. Costs it imposed before removal:
2417
+ // - `configurable: false` on window.location blocked page-side
2418
+ // navigation guards (Cloudflare rocket-loader, ad-script location
2419
+ // manipulation, popunder teardown) from redefining window.location.
2420
+ // - Proxy boundary visible via Object.getOwnPropertyDescriptor
2421
+ // (Proxy's value-as-non-spec-shape is a detectable surface).
2422
+ // - Page-side Object.defineProperty(location, 'href', ...) calls
2423
+ // hit the Proxy first, then forwarded to a spec-non-configurable
2424
+ // property, throwing "Cannot redefine property: href" — log noise
2425
+ // and a contributor to post-popup browser-unresponsive states on
2426
+ // popunder sites (eztv1.xyz scan reproduced this).
2427
+ // If a future use case actually loads via data: URL, reintroduce as a
2428
+ // get-trap-only Proxy with configurable:true so pages can override.
2085
2429
 
2086
2430
  // Bulk-mask all spoofed prototype methods so toString() returns "[native code]"
2087
2431
  // Must run AFTER all overrides are applied
@@ -2327,19 +2671,22 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
2327
2671
  // Platform, memory, and hardware spoofing combined for better V8 optimization
2328
2672
  // (moved into navigatorProps above);
2329
2673
 
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
- };
2674
+ // navigator.connection spoof DELETED -- was dead code.
2675
+ // The UA-spoof block (applyUserAgentSpoofing's eOND, ~line 1452)
2676
+ // already defines navigator.connection earlier in the eOND
2677
+ // registration order, with Object.defineProperty (default
2678
+ // configurable: false). The previous re-spoof here used
2679
+ // safeDefinePropertyLocal which has an `existing?.configurable
2680
+ // === false` guard, so it silently failed every time the UA spoof
2681
+ // ran. Net effect: per-navigation random connection values
2682
+ // generated here NEVER reached navigator.connection -- the
2683
+ // hardcoded '4g'/wifi/50/10 from the UA block always won.
2684
+ // Removing this block has zero behavioural change in the typical
2685
+ // (UA + fingerprint_protection) case, and makes the code's actual
2686
+ // behaviour match what reading it would suggest. Per-domain
2687
+ // realistic-randomization of connection is a future improvement;
2688
+ // do it ONCE in the UA-spoof block, not twice with one of them dead.
2337
2689
 
2338
- // Connection type spoofing
2339
- safeDefinePropertyLocal(navigator, 'connection', {
2340
- get: () => connectionInfo
2341
- });
2342
-
2343
2690
  // Screen properties spoofing
2344
2691
  const screenSpoofProps = {};
2345
2692
  ['width', 'height', 'availWidth', 'availHeight', 'colorDepth', 'pixelDepth'].forEach(prop => {