@fanboynz/network-scanner 3.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  All notable changes to the Network Scanner (nwss.js) project.
4
4
 
5
+ ## [3.1.1] - 2026-05-30
6
+
7
+ ### Changed
8
+ - **Fingerprint identity pinned to Stable Chrome 148**, not whatever Chrome-for-Testing puppeteer bundles (currently 149, ahead of Stable). The spoof must blend with the real-world population; claiming an unreleased build is itself a tell. The Chrome major + build (`CHROME_BUILD`) + GREASE brand (`CHROME_GREASE_BRAND`) are now single constants — see `lib/fingerprint.md`.
9
+ - **UA Client Hints made fully consistent and matched to real Chrome 148** (verified field-for-field against a live desktop): brand-list order + GREASE string (`Not/A)Brand`), and the full-version build (`148.0.7778.217`) sourced from one place so JS `getHighEntropyValues` and the HTTP `Sec-CH-UA-Full-Version*` headers can't drift. Added `wow64`, `model`, `formFactors`, `uaFullVersion`, and `Sec-CH-UA-WoW64`/`-Model`/`-Form-Factors` headers; Windows `platformVersion` → `19.0.0`.
10
+ - **`navigator.deviceMemory` and `Sec-CH-Device-Memory` both pinned to `8`** (consistent JS↔HTTP), hiding the host's real RAM; `hardwareConcurrency` reports 4–8 (hides datacenter core count).
11
+ - **Dependencies**: puppeteer / puppeteer-core 25.1.0, lru-cache 11.5.1.
12
+
13
+ ### Fixed
14
+ - **Timezone is now spoofed via CDP `emulateTimezone`** instead of JS overrides, so `Date`, `Intl`, and `getTimezoneOffset` are all consistent and DST-correct. The old JS patching left the real `Date` in the host zone — an 8-hour `Date`-vs-`Intl` contradiction and a leaked host timezone.
15
+ - **Closed several headless tells**: Battery now reports the plugged-in default (`charging:true, level:1`); `navigator.bluetooth`, `navigator.share`/`canShare` stubs added (present in real Chrome, absent in headless); `speechSynthesis.getVoices()` returns the claimed-OS voice set (`instanceof`-correct).
16
+ - **proxy**: a string `proxy_bypass`/`socks5_bypass` (instead of an array) no longer throws `bypass.join is not a function` in the browser-launch path.
17
+ - **socks-relay**: a client that disconnects during the upstream-connect await is now handled, so a tunnel isn't opened for a gone client and the watchdog clears immediately.
18
+ - **smart-cache**: the memory-check and auto-save `setInterval`s are now `unref`'d, so an error path that skips `destroy()` can no longer hang the process.
19
+
20
+ ### Removed
21
+ - Dead code: `browserhealth` `testNetworkCapability` + `purgeStaleTrackers` (zero callers), and a redundant 2-voice `speechSynthesis` block superseded by the full voice set.
22
+
23
+ ### Added
24
+ - **`lib/fingerprint.md`** — fingerprint spoofing coverage tables (surfaces, mitigations, gating flags) and known limitations.
25
+
26
+ ## [3.1.0] - 2026-05-29
27
+
28
+ ### Added
29
+ - **`realistic_click`** site flag — denser mouse approach, hold tremor, and mouseup drift for sites that score click realism.
30
+ - **`interact_click_count`** site override for popunder-discovery click volume (default content-click count also raised 2 → 3).
31
+ - **`clear_sitedata_full_on_reload`** site flag — full storage clear between reloads; quick mode now also clears localStorage/sessionStorage.
32
+ - **regex-tool rewritten** as a real `filterRegex` builder/tester: literal↔standard↔JSON conversion, multi-pattern + `regex_and`, and testing against real request URLs (matching mirrors the scanner exactly).
33
+ - **Fingerprint coverage**: per-domain-seeded Battery / `navigator.connection` values, `AudioBuffer` fingerprint defeat, `PerformanceNavigationTiming` jitter, `userActivation`; UA strings bumped to Chrome 148 / Firefox 151 / Safari 19.5.
34
+
35
+ ### Changed
36
+ - **`userAgent` now defaults to `"chrome"`** when a site doesn't set one — previously sites without it leaked the bundled `HeadlessChrome` UA.
37
+ - **`Sec-CH-UA` headers and the curl content-fetch UA derive from the single UA source**, so Client Hints can't drift from `navigator.userAgent`.
38
+ - **VPN configs force scan concurrency to 1** — the shared system routing table isn't concurrency-safe.
39
+ - **Interaction time ceiling scales with the work envelope** (click count / `realistic_click`) instead of a flat 15s.
40
+
41
+ ### Fixed
42
+ - **Per-URL timeout scales** with site timeout/delay/reload (+8s recovery grace) instead of a flat 75s that discarded partial-match recovery on multi-URL scans.
43
+ - **Interaction hard cap is now actually enforced** (was cooperative, overshooting to 20s+ under concurrency).
44
+ - **WireGuard** inline temp-config leaked the private key on failed connect and broke retries; temp dir is now per-PID so concurrent processes can't wipe each other's config.
45
+ - **nettools**: fixed a dig dedup race (concurrent same-domain double lookups); whois no longer discards valid records over non-fatal stderr.
46
+ - **Orphan resource leaks** on `Promise.race` timeout (cdp.js, clear_sitedata.js, browserhealth.js) and several un-`unref`'d `setTimeout` handles.
47
+ - **Config keys validated at startup** with boolean-like coercion, preventing silent misconfiguration.
48
+
49
+ ### Security
50
+ - **OpenVPN** `pkill`/`ping`/`curl` calls moved from shell-interpolated `execSync` to `spawnSync` arg arrays (command-injection).
51
+ - **WireGuard/OpenVPN interface & connection names validated** against a strict charset before use in paths/commands.
52
+
53
+ ### Performance
54
+ - **adblock**: O(1) exact-domain lookup for `$third-party` / `$first-party` rules.
55
+ - Parallelized site-data clearing and window-cleanup checks.
56
+ - Removed dead code across cdp, domain-cache, searchstring, compress, adblock-rust, and nettools.
57
+
5
58
  ## [3.0.3] - 2026-05-26
6
59
 
7
60
  ### Improved
@@ -607,16 +607,6 @@ function untrackPage(page) {
607
607
  pageUsageTracker.delete(page);
608
608
  }
609
609
 
610
- /**
611
- * No-op since the trackers were migrated to WeakMap — GC reclaims dead-page
612
- * entries automatically when Puppeteer drops its internal references. Kept
613
- * exported so the ~7 callers in nwss.js continue to compile; safe to delete
614
- * entirely once those callsites are scrubbed.
615
- */
616
- function purgeStaleTrackers() {
617
- // intentionally empty
618
- }
619
-
620
610
  /**
621
611
  * Quick browser responsiveness test for use during page setup
622
612
  * Designed to catch browser degradation between operations
@@ -637,82 +627,6 @@ async function isQuicklyResponsive(browserInstance, timeout = 3000) {
637
627
  }
638
628
  }
639
629
 
640
- /**
641
- * Tests if browser can handle network operations (like Network.enable)
642
- * Creates a test page and attempts basic network setup
643
- * @param {import('puppeteer').Browser} browserInstance - Puppeteer browser instance
644
- * @param {number} timeout - Timeout in milliseconds (default: 10000)
645
- * @returns {Promise<object>} Network capability test result
646
- */
647
- async function testNetworkCapability(browserInstance, timeout = 10000) {
648
- const result = {
649
- capable: false,
650
- error: null,
651
- responseTime: 0
652
- };
653
-
654
- const startTime = Date.now();
655
- let testPage = null;
656
- // Hoisted so the catch can attach an orphan-close chain. Promise.race
657
- // cannot cancel browser.newPage() — if the race times out, the underlying
658
- // call may still resolve to a real Page tab nothing references. Same
659
- // pattern as cdp.js (commit 0772ccd) and clear_sitedata.js (commit 780b443).
660
- let testPagePromise = null;
661
-
662
- try {
663
- // Create test page
664
- testPagePromise = browserInstance.newPage();
665
- testPage = await raceWithTimeout(
666
- testPagePromise,
667
- timeout,
668
- 'Test page creation timeout'
669
- );
670
-
671
- // Test network operations (the critical operation that's failing)
672
- await raceWithTimeout(
673
- testPage.setRequestInterception(true),
674
- timeout,
675
- 'Network.enable test timeout'
676
- );
677
-
678
- // Turn off interception. Symmetric to the enable above — Network.disable
679
- // can hang for the same CDP reasons, so it needs the same watchdog.
680
- await raceWithTimeout(
681
- testPage.setRequestInterception(false),
682
- timeout,
683
- 'Network.disable test timeout'
684
- );
685
- result.capable = true;
686
- result.responseTime = Date.now() - startTime;
687
-
688
- } catch (error) {
689
- // Orphan cleanup: if testPage is null but newPage() was started, the
690
- // race timed out before assignment. Close the orphan when it arrives.
691
- if (!testPage && testPagePromise) {
692
- testPagePromise.then(p => p.close().catch(() => {})).catch(() => {});
693
- }
694
- result.error = error.message;
695
- result.responseTime = Date.now() - startTime;
696
-
697
- // Classify the error type
698
- if (error.message.includes('Network.enable') ||
699
- error.message.includes('timed out') ||
700
- error.message.includes('Protocol error')) {
701
- result.error = `Network capability test failed: ${error.message}`;
702
- }
703
- } finally {
704
- if (testPage && !testPage.isClosed()) {
705
- try {
706
- await testPage.close();
707
- } catch (closeErr) {
708
- /* ignore cleanup errors */
709
- }
710
- }
711
- }
712
-
713
- return result;
714
- }
715
-
716
630
  /**
717
631
  * Checks if browser instance is still responsive
718
632
  * @param {import('puppeteer').Browser} browserInstance - Puppeteer browser instance
@@ -758,8 +672,8 @@ async function checkBrowserHealth(browserInstance, timeout = 8000) {
758
672
 
759
673
  // Test 4: Create a single test page to verify both browser functionality AND network capability
760
674
  let testPage = null;
761
- // Same orphan-cleanup pattern as testNetworkCapability above + cdp.js +
762
- // clear_sitedata.js. Promise.race can't cancel newPage() — if the race
675
+ // Same orphan-cleanup pattern as cdp.js + clear_sitedata.js.
676
+ // Promise.race can't cancel newPage() — if the race
763
677
  // times out the underlying call may still produce a Page tab nothing
764
678
  // references → leaked tab.
765
679
  let testPagePromise = null;
@@ -1282,7 +1196,6 @@ module.exports = {
1282
1196
  performGroupWindowCleanup,
1283
1197
  performRealtimeWindowCleanup,
1284
1198
  trackPageForRealtime,
1285
- testNetworkCapability,
1286
1199
  isQuicklyResponsive,
1287
1200
  performHealthAssessment,
1288
1201
  monitorBrowserHealth,
@@ -1290,6 +1203,5 @@ module.exports = {
1290
1203
  isCriticalProtocolError,
1291
1204
  updatePageUsage,
1292
1205
  untrackPage,
1293
- cleanupPageBeforeReload,
1294
- purgeStaleTrackers
1206
+ cleanupPageBeforeReload
1295
1207
  };
@@ -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.
@@ -0,0 +1,94 @@
1
+ # `lib/fingerprint.js` — Fingerprint Spoofing Coverage
2
+
3
+ Bot-detection evasion for the scanner's headless Chromium. The goal is to make a
4
+ scanned page see a coherent, real-Chrome **Stable** desktop profile rather than a
5
+ headless/automation signature — and, just as important, to keep every spoofed
6
+ value **internally consistent** (JS ↔ HTTP, claimed-value ↔ observable reality)
7
+ so a detector cross-checking two surfaces can't catch a mismatch.
8
+
9
+ ## How it works
10
+
11
+ Spoofing is applied per page, before navigation, by `applyAllFingerprintSpoofing(page, siteConfig, …)`, which runs three stages:
12
+
13
+ | Stage | Gate (siteConfig) | What it covers |
14
+ |---|---|---|
15
+ | `applyUserAgentSpoofing` | **`userAgent`** (defaults to `"chrome"`) | Browser identity, automation/headless tells, and the bulk of the navigator/JS-API suite |
16
+ | `applyBraveSpoofing` | Brave-mode only | Brave-specific surfaces |
17
+ | `applyFingerprintProtection` | **`fingerprint_protection`** (`true` \| `"random"`) | Hardware fingerprint *values* (canvas/WebGL/audio noise, screen, memory) + CDP timezone. `"random"` seeds them per-domain (stable per site, varies across sites) |
18
+
19
+ HTTP **Client Hints** request headers are set separately in `nwss.js` (gated on a `chrome` userAgent). Identity is pinned to **Stable Chrome** via two constants in `fingerprint.js` (`CHROME_BUILD`, `CHROME_GREASE_BRAND`) + the major in `USER_AGENT_COLLECTIONS` — see `feedback_chrome_spoof_version_bump`.
20
+
21
+ **Gate legend:** `UA` = runs with `userAgent` set (on by default) · `FP` = runs with `fingerprint_protection` · `HTTP` = request header set in nwss.js.
22
+
23
+ ## Browser identity
24
+
25
+ | Surface | Mitigation | Gate |
26
+ |---|---|---|
27
+ | `navigator.userAgent` / `appVersion` | Pinned to Stable Chrome 148 desktop UA | UA |
28
+ | `navigator.userAgentData` (brands, platform, mobile) | Spoofed; brand order + GREASE string match real Chrome of the major exactly | UA |
29
+ | `getHighEntropyValues()` | Full set: architecture, bitness, model, **wow64**, platformVersion, **uaFullVersion**, fullVersionList, **formFactors** — build from `CHROME_BUILD`, consistent with HTTP | UA |
30
+ | `navigator.platform` / `vendor` / `productSub` / `vendorSub` | Spoofed UA-consistent (`Win32`, `Google Inc.`, `20030107`, `""`) | UA |
31
+ | `Sec-CH-UA`, `-Platform`, `-Platform-Version`, `-Mobile`, `-Arch`, `-Bitness`, `-WoW64`, `-Model`, `-Full-Version`, `-Full-Version-List`, `-Form-Factors` | Set to match the JS values (same brand order/grease/build) | HTTP |
32
+
33
+ ## Automation & headless tells
34
+
35
+ | Surface | Mitigation | Gate |
36
+ |---|---|---|
37
+ | `navigator.webdriver` | Forced `false` (launch flag + JS) | UA |
38
+ | `cdc_…` / `$cdc_…` / selenium / phantom props | Removed | UA |
39
+ | `window.chrome` + `chrome.runtime` | Provided / simulated | UA |
40
+ | `<html webdriver>` attribute | Stripped | UA |
41
+ | `navigator.plugins` / `mimeTypes` | Native 5-PDF set preserved (matches real Chrome) | UA |
42
+ | `navigator.bluetooth` | Stub added (`getAvailability()→false`) — real Chrome always exposes it | UA |
43
+ | `navigator.share` / `canShare` | Stubs added (Web Share; absent in headless) | UA |
44
+ | `speechSynthesis.getVoices()` | Claimed-OS voice set (Windows → Microsoft + Google, 22 voices) | UA |
45
+ | `Notification.permission` / `permissions.query` | `default` / consistent results | UA |
46
+ | `navigator.userActivation` / `getInstalledRelatedApps` / `document.hasStorageAccess` | Stubs (present in real Chrome) | UA |
47
+
48
+ ## Hardware & rendering
49
+
50
+ | Surface | Mitigation | Gate |
51
+ |---|---|---|
52
+ | WebGL `UNMASKED_VENDOR/RENDERER` | Spoofed GPU from an OS-appropriate pool (per-domain seeded) | UA + FP |
53
+ | Canvas (`toDataURL`/`getImageData`) | Per-canvas noise (WeakMap-cached) | UA + FP |
54
+ | AudioContext / `AudioBuffer` | `getChannelData`/`copyFromChannel` intercepted to defeat audio fingerprint | UA + FP |
55
+ | Fonts (`measureText`/offset probes) | Normalized font metrics | UA |
56
+ | `screen.*` (width/height/avail/colorDepth) | Spoofed (1920×1080, colorDepth 24) | UA + FP |
57
+ | `navigator.hardwareConcurrency` | Spoofed down to 4–8 (hides datacenter core count; no HTTP counterpart) | FP |
58
+ | `navigator.deviceMemory` (JS) + `Sec-CH-Device-Memory` (HTTP) | Both pinned to **8** (hides 32 GB host; JS = HTTP, gated together on FP) | FP / HTTP |
59
+ | `PerformanceNavigationTiming` | Jittered to defeat timing fingerprint | UA |
60
+
61
+ ## Sensors, locale & network
62
+
63
+ | Surface | Mitigation | Gate |
64
+ |---|---|---|
65
+ | Battery Status API | Plugged-in default (`charging:true, level:1, dischargingTime:Infinity`) — blends with the majority | UA |
66
+ | `navigator.connection` (rtt/downlink/effectiveType) | **Native** (left untouched when present) — truthful to the real network so it survives a timing cross-check | — |
67
+ | `navigator.languages` / `language` | `["en-US","en"]` / `en-US` | UA |
68
+ | **Timezone** (`Date`, `Intl`, `getTimezoneOffset`) | CDP `emulateTimezone()` — makes all three consistent + DST-correct (replaced broken JS overrides) | FP |
69
+ | `matchMedia` hover/pointer/color-scheme | Desktop-consistent (`hover`, `fine` pointer) | UA |
70
+ | `maxTouchPoints` | UA-consistent (`0` on desktop) | UA |
71
+ | WebRTC ICE candidates | All candidates stripped → no STUN public-IP leak past the proxy | UA |
72
+ | `mediaDevices.enumerateDevices` | Plausible device set | UA |
73
+
74
+ ## Anti-introspection
75
+
76
+ | Surface | Mitigation | Gate |
77
+ |---|---|---|
78
+ | `Function.prototype.toString` | Every overridden function masked to `function X() { [native code] }` (bulk + per-instance) | UA |
79
+ | `Error.stack` / `prepareStackTrace` | Sanitized so injected frames don't leak | UA |
80
+ | Console error noise from spoofs | Suppressed | UA |
81
+
82
+ ## Known limitations (not fixable at the browser layer)
83
+
84
+ | Vector | Why it's out of scope | Mitigation |
85
+ |---|---|---|
86
+ | **IP reputation** | A datacenter IP is the single biggest tell; no JS/header spoof touches it | Residential **proxy/VPN** (`lib/proxy.js`, `lib/wireguard_vpn.js`, `lib/openvpn_vpn.js`) |
87
+ | **TLS (JA3/JA4) + HTTP/2 fingerprint** | Negotiated below the JS layer | Puppeteer's Chromium already presents a genuine Chrome stack; a MITM proxy can alter it |
88
+ | **Timezone vs exit-IP geolocation** | Timezone is now internally consistent, but the *chosen* zone should match the proxy's country | Per-proxy geo config (not yet wired) |
89
+ | **Behavioural / mouse dynamics** | Statistical, not a property | `interact` / `ghost-cursor` config (`lib/interaction.js`) |
90
+
91
+ ## Verification
92
+
93
+ - **`scripts/test-stealth.js`** — automated smoke test against sannysoft / creepjs / browserleaks. Run before/after a spoof change and diff.
94
+ - **Manual reference diff** — launch with the spoof applied and compare each surface against a real Chrome of the pinned major (the coverage above was validated field-for-field against a live Chrome 148 desktop). The unspoofed deviations are deliberate: `hardwareConcurrency`/`deviceMemory` downscaled to hide the host, and `connection` left native.
package/lib/proxy.js CHANGED
@@ -253,8 +253,12 @@ function getProxyArgs(siteConfig, forceDebug = false) {
253
253
  console.warn(formatLogMessage('proxy', `proxy_remote_dns ignored: SOCKS4 cannot do proxy-side DNS resolution (use SOCKS5)`));
254
254
  }
255
255
 
256
- // Bypass list: domains that skip the proxy
257
- const bypass = siteConfig.proxy_bypass || siteConfig.socks5_bypass || [];
256
+ // Bypass list: domains that skip the proxy. Accept either an array (the
257
+ // documented form) or a single string — a bare "localhost" used to throw
258
+ // `bypass.join is not a function` here, in the browser-launch path. Same
259
+ // string-or-array tolerance as the dig/whois siteConfig fields.
260
+ const rawBypass = siteConfig.proxy_bypass || siteConfig.socks5_bypass || [];
261
+ const bypass = Array.isArray(rawBypass) ? rawBypass : [rawBypass];
258
262
  if (bypass.length > 0) {
259
263
  args.push(`--proxy-bypass-list=${bypass.join(';')}`);
260
264
  }
@@ -93,10 +93,16 @@ class SmartCache {
93
93
  this._setupAutoSave();
94
94
  }
95
95
 
96
- // Set up memory monitoring
96
+ // Set up memory monitoring. unref'd so this always-on housekeeping timer
97
+ // can never hold the event loop open past scan completion — destroy()
98
+ // clears it promptly on the normal path, but unref guarantees a clean
99
+ // exit on any path that skips destroy() (e.g. an unhandled throw before
100
+ // nwss reaches its cleanup). Matches the unref convention applied to
101
+ // every other Node-side timer in the codebase.
97
102
  this.memoryCheckInterval = setInterval(() => {
98
103
  this._checkMemoryPressure();
99
104
  }, this.options.memoryCheckInterval);
105
+ if (typeof this.memoryCheckInterval.unref === 'function') this.memoryCheckInterval.unref();
100
106
  }
101
107
 
102
108
  /**
@@ -1137,9 +1143,11 @@ class SmartCache {
1137
1143
  * @private
1138
1144
  */
1139
1145
  _setupAutoSave() {
1146
+ // unref'd for the same reason as memoryCheckInterval — never block exit.
1140
1147
  this.autoSaveInterval = setInterval(() => {
1141
1148
  this.savePersistentCache();
1142
1149
  }, this.options.autoSaveInterval);
1150
+ if (typeof this.autoSaveInterval.unref === 'function') this.autoSaveInterval.unref();
1143
1151
  }
1144
1152
 
1145
1153
  /**
@@ -227,13 +227,11 @@ function handleClient(client, upstream, forceDebug, relay) {
227
227
 
228
228
  upstreamSock = info.socket;
229
229
  // Safety net: if cleanup() ran while we were awaiting the upstream
230
- // connect (some path other than the handshake watchdog — e.g. a
231
- // 'close' event on the client during pause), settled is true and
232
- // cleanup's settled guard would short-circuit a future call,
233
- // orphaning this freshly-connected upstream socket. Destroy it
234
- // here directly. With Fix #1a moving the watchdog clearTimeout to
235
- // the 'connecting' transition this is currently unreachable, but
236
- // cheap to keep as defense-in-depth against future code paths.
230
+ // connect, settled is true and cleanup's settled guard would
231
+ // short-circuit a future call, orphaning this freshly-connected
232
+ // upstream socket so destroy it here directly. Reachable when the
233
+ // client emits 'error' or 'close' during the await (both wired to
234
+ // cleanup at handler setup), e.g. Chromium disconnects mid-connect.
237
235
  if (settled) {
238
236
  try { upstreamSock.destroy(); } catch (_) {}
239
237
  return;
@@ -250,8 +248,8 @@ function handleClient(client, upstream, forceDebug, relay) {
250
248
  try { upstreamSock.setKeepAlive(true, 60000); } catch (_) {}
251
249
  upstreamSock.on('error', cleanup);
252
250
  upstreamSock.on('close', cleanup);
253
- client.on('error', cleanup);
254
- client.on('close', cleanup);
251
+ // client 'error' and 'close' are wired once at handler setup (bottom
252
+ // of handleClient) and cover all phases — not re-attached here.
255
253
 
256
254
  // SOCKS5 success (BND.ADDR 0.0.0.0:0 — Chromium ignores it for CONNECT)
257
255
  client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
@@ -273,6 +271,13 @@ function handleClient(client, upstream, forceDebug, relay) {
273
271
 
274
272
  client.on('data', onData);
275
273
  client.on('error', cleanup);
274
+ // Attach 'close' HERE (not after piping starts) so it covers the whole
275
+ // lifetime, including the up-to-20s upstream-connect await. A client that
276
+ // disconnects cleanly mid-connect now sets settled=true, letting the
277
+ // post-connect `if (settled)` net destroy the freshly-opened upstream
278
+ // socket instead of piping into a dead client; and a close mid-handshake
279
+ // clears the watchdog immediately rather than leaving it to fire later.
280
+ client.on('close', cleanup);
276
281
  }
277
282
 
278
283
  // SOCKS5 failure reply (valid only before piping starts).
package/nwss.js CHANGED
@@ -13,7 +13,7 @@ const dnsPromises = require('node:dns/promises');
13
13
  const { createGrepHandler, validateGrepAvailability } = require('./lib/grep');
14
14
  const { compressMultipleFiles, formatFileSize } = require('./lib/compress');
15
15
  const { parseSearchStrings, createResponseHandler } = require('./lib/searchstring');
16
- const { applyAllFingerprintSpoofing, USER_AGENT_COLLECTIONS } = require('./lib/fingerprint');
16
+ const { applyAllFingerprintSpoofing, USER_AGENT_COLLECTIONS, CHROME_BUILD, CHROME_GREASE_BRAND } = require('./lib/fingerprint');
17
17
  const { formatRules, handleOutput, getFormatDescription } = require('./lib/output');
18
18
  // Curl functionality (replace searchstring curl handler)
19
19
  const { validateCurlAvailability, createCurlHandler: createCurlModuleHandler } = require('./lib/curl');
@@ -2750,7 +2750,7 @@ function setupFrameHandling(page, forceDebug) {
2750
2750
  if (!useObscura && siteConfig.userAgent && siteConfig.userAgent.toLowerCase().includes('chrome')) {
2751
2751
  const userAgentKey = siteConfig.userAgent.toLowerCase();
2752
2752
  let platform = 'Windows';
2753
- let platformVersion = '15.0.0';
2753
+ let platformVersion = '19.0.0'; // Win11 — MUST match fingerprint.js's userAgentData platformVersion
2754
2754
  let arch = 'x86';
2755
2755
 
2756
2756
  if (userAgentKey === 'chrome_mac') {
@@ -2769,21 +2769,46 @@ function setupFrameHandling(page, forceDebug) {
2769
2769
  // never drift out of sync with navigator.userAgent. The version
2770
2770
  // used to be hardcoded ('146') while the UA list moved to 148 —
2771
2771
  // a detector cross-checking UA vs Sec-CH-UA saw the mismatch.
2772
- // Chrome's UA-reduction means the full version is "<major>.0.0.0".
2772
+ // The full-version hints carry the REAL build (major.0.BUILD) — the
2773
+ // reduced UA hides it, these reveal it. Build comes from
2774
+ // lib/fingerprint's CHROME_BUILD, the same source the JS
2775
+ // getHighEntropyValues spoof uses, so HTTP and JS can't disagree.
2773
2776
  const browserUa = USER_AGENT_COLLECTIONS.get(userAgentKey) || '';
2774
2777
  const chromeMajor = (browserUa.match(/Chrome\/(\d+)/) || [])[1] || '148';
2775
- const fullVer = `${chromeMajor}.0.0.0`;
2778
+ const fullVer = `${chromeMajor}.0.${CHROME_BUILD}`;
2776
2779
 
2777
- await page.setExtraHTTPHeaders({
2778
- 'Sec-CH-UA': `"Not:A-Brand";v="99", "Google Chrome";v="${chromeMajor}", "Chromium";v="${chromeMajor}"`,
2780
+ const chHeaders = {
2781
+ // Brand list order + grease string match real Chrome of this major
2782
+ // exactly (deterministic GREASE): Chromium, Google Chrome, <grease>.
2783
+ // Same order/grease the JS brands spoof uses, so HTTP and JS agree.
2784
+ 'Sec-CH-UA': `"Chromium";v="${chromeMajor}", "Google Chrome";v="${chromeMajor}", "${CHROME_GREASE_BRAND}";v="99"`,
2779
2785
  'Sec-CH-UA-Platform': `"${platform}"`,
2780
2786
  'Sec-CH-UA-Platform-Version': `"${platformVersion}"`,
2781
2787
  'Sec-CH-UA-Mobile': '?0',
2782
2788
  'Sec-CH-UA-Arch': `"${arch}"`,
2783
2789
  'Sec-CH-UA-Bitness': '"64"',
2790
+ 'Sec-CH-UA-WoW64': '?0',
2791
+ 'Sec-CH-UA-Model': '""',
2784
2792
  'Sec-CH-UA-Full-Version': `"${fullVer}"`,
2785
- 'Sec-CH-UA-Full-Version-List': `"Not:A-Brand";v="99.0.0.0", "Google Chrome";v="${fullVer}", "Chromium";v="${fullVer}"`
2786
- });
2793
+ 'Sec-CH-UA-Full-Version-List': `"Chromium";v="${fullVer}", "Google Chrome";v="${fullVer}", "${CHROME_GREASE_BRAND}";v="99.0.0.0"`,
2794
+ // Real Chrome (128+) sends this for desktop; pairs with the
2795
+ // formFactors value in fingerprint.js's getHighEntropyValues spoof.
2796
+ 'Sec-CH-UA-Form-Factors': '"Desktop"'
2797
+ };
2798
+ // Sec-CH-Device-Memory must mirror the JS navigator.deviceMemory
2799
+ // override (8) so a server reading BOTH can't cross-check a mismatch.
2800
+ // That JS override lives in applyFingerprintProtection, so it only
2801
+ // runs when fingerprint_protection is set — gate the header the same
2802
+ // way. Without this gate, a userAgent-only site (no fp_protection)
2803
+ // would get JS deviceMemory = the real host RAM (e.g. 32) but HTTP
2804
+ // = 8, a fresh mismatch. With fp off we send neither and both sides
2805
+ // report the native value, which is also consistent. (RAM isn't
2806
+ // server-observable, so spoofing it down hides datacenter specs with
2807
+ // nothing external to contradict — unlike rtt, which we leave native.)
2808
+ if (siteConfig.fingerprint_protection) {
2809
+ chHeaders['Sec-CH-Device-Memory'] = '8';
2810
+ }
2811
+ await page.setExtraHTTPHeaders(chHeaders);
2787
2812
  }
2788
2813
  } catch (fingerprintErr) {
2789
2814
  if (fingerprintErr.message.includes('Session closed') ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanboynz/network-scanner",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "A Puppeteer-based network scanner for analyzing web traffic, generating adblock filter rules, and identifying third-party requests. Features include fingerprint spoofing, Cloudflare bypass, content analysis with curl/grep, and multiple output formats.",
5
5
  "main": "nwss.js",
6
6
  "scripts": {