@fanboynz/network-scanner 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/CLAUDE.md +2 -1
- package/README.md +33 -5
- package/eslint.config.mjs +13 -1
- package/lib/browserhealth.js +28 -94
- package/lib/dns.js +238 -0
- package/lib/domain-cache.js +14 -127
- package/lib/fingerprint.js +220 -97
- package/lib/fingerprint.md +94 -0
- package/lib/ghost-cursor.js +29 -11
- package/lib/interaction.js +4 -0
- package/lib/nettools.js +154 -51
- package/lib/output.js +24 -13
- package/lib/proxy.js +6 -2
- package/lib/redirect.js +4 -1
- package/lib/smart-cache.js +9 -1
- package/lib/socks-relay.js +14 -9
- package/lib/validate_rules.js +16 -1
- package/nwss.1 +76 -15
- package/nwss.js +389 -113
- package/package.json +1 -1
package/lib/domain-cache.js
CHANGED
|
@@ -103,54 +103,6 @@ class DomainCache {
|
|
|
103
103
|
return wasNew;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
107
|
-
* Combined check-and-mark in one pass. Functionally equivalent to
|
|
108
|
-
* isDomainAlreadyDetected() followed by markDomainAsDetected(), but with
|
|
109
|
-
* one Set.has() call instead of two. (JS is single-threaded so all three
|
|
110
|
-
* variants are individually atomic; this one is just cheaper.)
|
|
111
|
-
* @param {string} domain - Domain to check and potentially mark
|
|
112
|
-
* @returns {boolean} True if domain was ALREADY detected (should skip), false if NEW (should process)
|
|
113
|
-
*/
|
|
114
|
-
checkAndMark(domain) {
|
|
115
|
-
if (!domain || typeof domain !== 'string') {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const wasAlreadyDetected = this.cache.has(domain);
|
|
120
|
-
|
|
121
|
-
if (wasAlreadyDetected) {
|
|
122
|
-
// Domain already exists - update skip stats and return true (should skip)
|
|
123
|
-
this.stats.totalSkipped++;
|
|
124
|
-
this.stats.cacheHits++;
|
|
125
|
-
|
|
126
|
-
if (this.enableLogging) {
|
|
127
|
-
console.log(formatLogMessage('debug', `${this.logPrefix} Cache HIT: ${domain} (skipped)`));
|
|
128
|
-
}
|
|
129
|
-
return true; // Already detected, should skip
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Domain is NEW - mark it as detected
|
|
133
|
-
this.stats.cacheMisses++;
|
|
134
|
-
|
|
135
|
-
this.cache.add(domain);
|
|
136
|
-
this.stats.totalDetected++;
|
|
137
|
-
|
|
138
|
-
if (this.enableLogging) {
|
|
139
|
-
console.log(formatLogMessage('debug', `${this.logPrefix} Cache MISS: ${domain} (processing and marked, cache size: ${this.cache.size})`));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Check size after the add so an overflow only fires eviction once per
|
|
143
|
-
// overflowing call (using targetCacheSize precomputed in the constructor).
|
|
144
|
-
if (this.cache.size > this.maxCacheSize) {
|
|
145
|
-
const toRemove = this.cache.size - this.targetCacheSize;
|
|
146
|
-
if (toRemove > 0) {
|
|
147
|
-
this.clearOldestEntries(toRemove);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return false; // New domain, should process
|
|
152
|
-
}
|
|
153
|
-
|
|
154
106
|
/**
|
|
155
107
|
* Clear oldest entries from cache (FIFO eviction). Set iteration order is
|
|
156
108
|
* guaranteed insertion order per ES2015, so this genuinely evicts oldest-
|
|
@@ -208,45 +160,6 @@ class DomainCache {
|
|
|
208
160
|
return this.cache.has(domain);
|
|
209
161
|
}
|
|
210
162
|
|
|
211
|
-
/**
|
|
212
|
-
* Add multiple domains to cache at once. Uses a single .size delta to
|
|
213
|
-
* count actually-new entries (skipping per-domain .has() calls), and
|
|
214
|
-
* runs the size-overflow eviction check once after the batch instead of
|
|
215
|
-
* per-domain. For a batch of N domains this is N .has() calls saved and
|
|
216
|
-
* up to N redundant cap checks collapsed to one.
|
|
217
|
-
* @param {Array<string>} domains - Array of domains to add
|
|
218
|
-
* @returns {number} Number of domains actually added (excludes duplicates)
|
|
219
|
-
*/
|
|
220
|
-
markMultipleDomainsAsDetected(domains) {
|
|
221
|
-
if (!Array.isArray(domains) || domains.length === 0) {
|
|
222
|
-
return 0;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const startSize = this.cache.size;
|
|
226
|
-
for (let i = 0; i < domains.length; i++) {
|
|
227
|
-
const d = domains[i];
|
|
228
|
-
if (d && typeof d === 'string') {
|
|
229
|
-
this.cache.add(d);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
const addedCount = this.cache.size - startSize;
|
|
233
|
-
this.stats.totalDetected += addedCount;
|
|
234
|
-
|
|
235
|
-
if (this.enableLogging && addedCount > 0) {
|
|
236
|
-
console.log(formatLogMessage('debug', `${this.logPrefix} Batch added ${addedCount} new domains (cache size: ${this.cache.size})`));
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// One eviction sweep at the end, mirroring the single-add overflow check.
|
|
240
|
-
if (this.cache.size > this.maxCacheSize) {
|
|
241
|
-
const toRemove = this.cache.size - this.targetCacheSize;
|
|
242
|
-
if (toRemove > 0) {
|
|
243
|
-
this.clearOldestEntries(toRemove);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return addedCount;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
163
|
/**
|
|
251
164
|
* Create bound helper functions for easy integration with existing code
|
|
252
165
|
* @returns {object} Object with bound helper functions
|
|
@@ -255,7 +168,6 @@ class DomainCache {
|
|
|
255
168
|
return {
|
|
256
169
|
isDomainAlreadyDetected: this.isDomainAlreadyDetected.bind(this),
|
|
257
170
|
markDomainAsDetected: this.markDomainAsDetected.bind(this),
|
|
258
|
-
checkAndMark: this.checkAndMark.bind(this),
|
|
259
171
|
getSkippedCount: () => this.stats.totalSkipped,
|
|
260
172
|
getCacheSize: () => this.cache.size,
|
|
261
173
|
getStats: this.getStats.bind(this)
|
|
@@ -273,8 +185,7 @@ let globalDomainCache = null;
|
|
|
273
185
|
*
|
|
274
186
|
* NOTE: `options` is honored ONLY on the first call (the call that actually
|
|
275
187
|
* constructs the singleton). Subsequent calls return the existing instance
|
|
276
|
-
* regardless of what's passed
|
|
277
|
-
* resetGlobalCache() first or use `new DomainCache(options)` directly.
|
|
188
|
+
* regardless of what's passed; options are fixed at first construction.
|
|
278
189
|
*
|
|
279
190
|
* Under debug logging, a warning fires if a later caller passes options
|
|
280
191
|
* that don't match the live instance — silent drift is a recurring source
|
|
@@ -295,7 +206,7 @@ function getGlobalDomainCache(options = {}) {
|
|
|
295
206
|
(options.enableLogging !== undefined && options.enableLogging !== globalDomainCache.enableLogging) ||
|
|
296
207
|
(options.logPrefix !== undefined && options.logPrefix !== globalDomainCache.logPrefix);
|
|
297
208
|
if (drifted) {
|
|
298
|
-
console.log(formatLogMessage('debug', `${globalDomainCache.logPrefix} getGlobalDomainCache called with options that differ from the live singleton; ignored (
|
|
209
|
+
console.log(formatLogMessage('debug', `${globalDomainCache.logPrefix} getGlobalDomainCache called with options that differ from the live singleton; ignored (options are fixed at first construction)`));
|
|
299
210
|
}
|
|
300
211
|
}
|
|
301
212
|
return globalDomainCache;
|
|
@@ -312,36 +223,17 @@ function createGlobalHelpers(options = {}) {
|
|
|
312
223
|
}
|
|
313
224
|
|
|
314
225
|
/**
|
|
315
|
-
*
|
|
316
|
-
*/
|
|
317
|
-
function resetGlobalCache() {
|
|
318
|
-
if (globalDomainCache) {
|
|
319
|
-
globalDomainCache.clear();
|
|
320
|
-
}
|
|
321
|
-
globalDomainCache = null;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Legacy wrapper functions for backward compatibility
|
|
326
|
-
* These match the original function signatures from nwss.js
|
|
226
|
+
* Legacy wrapper for backward compatibility.
|
|
327
227
|
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
228
|
+
* getDetectedDomainsCount is the only one kept — nwss.js reads it for the
|
|
229
|
+
* end-of-scan "unique domains cached" stat. getTotalDomainsSkipped was
|
|
230
|
+
* removed: its value was always 0 because the global cache's skip-check
|
|
231
|
+
* (isDomainAlreadyDetected) is never called — cross-URL dedup is handled by
|
|
232
|
+
* nettools' processed-domain sets / smart-cache / the per-URL set — so the
|
|
233
|
+
* stat was misleading. The isDomainAlreadyDetected / markDomainAsDetected /
|
|
234
|
+
* checkAndMark wrappers were likewise removed; nwss.js uses createGlobalHelpers().
|
|
334
235
|
*/
|
|
335
236
|
|
|
336
|
-
/**
|
|
337
|
-
* Get total domains skipped (legacy wrapper)
|
|
338
|
-
* @returns {number} Number of domains skipped
|
|
339
|
-
*/
|
|
340
|
-
function getTotalDomainsSkipped() {
|
|
341
|
-
const cache = getGlobalDomainCache();
|
|
342
|
-
return cache.stats.totalSkipped;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
237
|
/**
|
|
346
238
|
* Get detected domains cache size (legacy wrapper)
|
|
347
239
|
* @returns {number} Size of the detected domains cache
|
|
@@ -352,15 +244,10 @@ function getDetectedDomainsCount() {
|
|
|
352
244
|
}
|
|
353
245
|
|
|
354
246
|
module.exports = {
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
getGlobalDomainCache,
|
|
247
|
+
// Global cache helpers — createGlobalHelpers feeds nwss.js's per-domain
|
|
248
|
+
// marking; getDetectedDomainsCount feeds the end-of-scan "unique domains
|
|
249
|
+
// cached" stat. (DomainCache / getGlobalDomainCache stay internal — no
|
|
250
|
+
// external consumer; construct via createGlobalHelpers.)
|
|
360
251
|
createGlobalHelpers,
|
|
361
|
-
resetGlobalCache,
|
|
362
|
-
|
|
363
|
-
// Legacy wrappers still used by nwss.js for end-of-scan stats
|
|
364
|
-
getTotalDomainsSkipped,
|
|
365
252
|
getDetectedDomainsCount
|
|
366
253
|
};
|
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.
|