@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 +53 -0
- package/lib/browserhealth.js +3 -91
- package/lib/fingerprint.js +220 -97
- package/lib/fingerprint.md +94 -0
- package/lib/proxy.js +6 -2
- package/lib/smart-cache.js +9 -1
- package/lib/socks-relay.js +14 -9
- package/nwss.js +33 -8
- package/package.json +1 -1
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
|
package/lib/browserhealth.js
CHANGED
|
@@ -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
|
|
762
|
-
//
|
|
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
|
};
|
package/lib/fingerprint.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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] : '
|
|
913
|
+
const majorVersion = chromeMatch ? chromeMatch[1] : '148';
|
|
883
914
|
|
|
884
915
|
let platform = 'Windows';
|
|
885
|
-
|
|
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: '
|
|
942
|
+
{ brand: 'Chromium', version: majorVersion },
|
|
901
943
|
{ brand: 'Google Chrome', version: majorVersion },
|
|
902
|
-
{ brand:
|
|
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: '
|
|
920
|
-
|
|
921
|
-
|
|
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
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
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
|
-
//
|
|
2234
|
-
//
|
|
2235
|
-
//
|
|
2236
|
-
// the
|
|
2237
|
-
//
|
|
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
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
//
|
|
2245
|
-
//
|
|
2246
|
-
//
|
|
2247
|
-
//
|
|
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:
|
|
2261
|
-
dischargingTime:
|
|
2262
|
-
level:
|
|
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
|
-
|
|
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
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/smart-cache.js
CHANGED
|
@@ -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
|
/**
|
package/lib/socks-relay.js
CHANGED
|
@@ -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
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
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
|
|
254
|
-
|
|
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 = '
|
|
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
|
-
//
|
|
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
|
|
2778
|
+
const fullVer = `${chromeMajor}.0.${CHROME_BUILD}`;
|
|
2776
2779
|
|
|
2777
|
-
|
|
2778
|
-
|
|
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': `"
|
|
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.
|
|
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": {
|