@fanboynz/network-scanner 3.0.0 → 3.0.1
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/.github/workflows/npm-publish.yml +6 -1
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +22 -0
- package/README.md +17 -0
- package/lib/browserexit.js +4 -4
- package/lib/browserhealth.js +1 -1
- package/lib/fingerprint.js +320 -157
- package/lib/proxy.js +9 -2
- package/lib/redirect.js +1 -1
- package/lib/socks-relay.js +15 -2
- package/nwss.js +3 -3
- package/package.json +1 -1
- package/scripts/test-stealth.js +281 -0
|
@@ -48,9 +48,14 @@ jobs:
|
|
|
48
48
|
fetch-depth: 0
|
|
49
49
|
|
|
50
50
|
- name: Setup Node.js
|
|
51
|
+
# Must be >=22.12.0 to satisfy package.json engines.node and to
|
|
52
|
+
# support require()-of-ESM for puppeteer 25 (the project's
|
|
53
|
+
# current floor). Bumping below this re-introduces the EBADENGINE
|
|
54
|
+
# warnings seen on 20.x runners and will fail npm publish for
|
|
55
|
+
# consumers on the same Node version.
|
|
51
56
|
uses: actions/setup-node@v5
|
|
52
57
|
with:
|
|
53
|
-
node-version: '
|
|
58
|
+
node-version: '22'
|
|
54
59
|
registry-url: 'https://registry.npmjs.org'
|
|
55
60
|
|
|
56
61
|
- run: npm ci
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Network Scanner (nwss.js) project.
|
|
4
4
|
|
|
5
|
+
## [3.0.1] - 2026-05-24
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
- **Proxy credentials redacted in debug logs** — `lib/proxy.js` `getProxyInfo()` now replaces the `username:password@` segment with `[redacted]@` before logging; `lib/socks-relay.js` strips the username from both the relay-startup log (`auth: [redacted]` / `no auth`) and the close log (regex-trims the `:username` suffix from the relay key, IPv6-safe). Prior output exposed SOCKS5 credentials to anyone the user shared a debug dump, screenshot, or support ticket with.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `scripts/test-stealth.js` — stealth smoke-test harness. Launches Puppeteer with `applyAllFingerprintSpoofing` applied and reports what bot.sannysoft.com / creepjs / browserleaks.com/javascript concluded. Flags: `--headful`, `--no-spoof` (baseline), `--ua=<family>` (validated against `USER_AGENT_COLLECTIONS`), `--format=json` (stable schema for diff/jq A/B), `--help`, positional target filtering. `PUPPETEER_NO_SANDBOX=1` env-var opt-in for CI/root containers (sandbox is on by default). Caught 3 real bugs that 5 rounds of static review missed.
|
|
12
|
+
- `USER_AGENT_COLLECTIONS` exported from `lib/fingerprint.js` — single source of truth for valid UA families, consumed by the test harness so the list isn't duplicated.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **Puppeteer 25 compatibility** — `browser.isConnected()` (removed in Puppeteer 25 per [puppeteer#14910](https://github.com/puppeteer/puppeteer/pull/14910)) replaced with the `browser.connected` property at 14 call sites across 6 files. Compatible with both Puppeteer 24 and 25.
|
|
16
|
+
- **Fingerprint own-goal — PHANTOM_PROPERTIES + SELENIUM_DRIVER** — spoofing did `delete window[prop]` followed by `defineProperty(prop, { get: () => undefined })`. The undefined-returning getters left the properties detectable via the `in` operator, defeating the delete. Now only deletes. (caught by `scripts/test-stealth.js` sannysoft)
|
|
17
|
+
- **`navigator.plugins instanceof PluginArray` failed** — the spoof returned a plain array. Now `Object.setPrototypeOf(pluginsArray, PluginArray.prototype)` with fallback to `Object.getPrototypeOf(navigator.plugins)` for environments where `PluginArray` isn't a global.
|
|
18
|
+
- **`navigator.plugins[0].toString() === '[object Plugin]'` failed** — plain plugin objects returned `[object Object]`. Each plugin now wraps via `Object.create(Plugin.prototype)` with `Symbol.toStringTag` fallback.
|
|
19
|
+
- **`window.chrome` descriptor was a fingerprinting tell** — had `writable: false, enumerable: false`; real Chrome has both `true`. Aligned.
|
|
20
|
+
- **`_fingerprintCache` cross-UA poisoning** — was keyed by domain only, so the same domain visited under a different UA returned cached values from the wrong OS. Now keyed by `${domain}|${userAgent}`.
|
|
21
|
+
- **7 broken regex patterns** in the fingerprint error-suppression list — double backslashes (`\\.X`) parsed as literal-backslash + wildcard and never matched real errors. All 7 repaired.
|
|
22
|
+
- Constructor `.name` / `.length` preserved through 5 wrapper sites (Error, Image, RTCPeerConnection, PointerEvent, WheelEvent) — wrapped ctors had `.name = ''` and `.length = 0`, a fingerprinting tell.
|
|
23
|
+
- `Error` static properties (`stackTraceLimit`, `captureStackTrace`, `prepareStackTrace`) forward to the OriginalError via live getter/setter instead of snapshot-copy (snapshot diverged once any caller mutated the wrapped Error).
|
|
24
|
+
- `navigator.connection` fallback returns a closure-captured stable object — was re-allocating per call, so object identity changed every access.
|
|
25
|
+
- `chrome.runtime.getManifest()` derives version from the spoofed UA instead of returning a hardcoded older version.
|
|
26
|
+
|
|
27
|
+
### Improved
|
|
28
|
+
- `isBrowserDead` helper extracted — deduped 3 spoof sites that hand-rolled the same `isConnected`/`closed` check.
|
|
29
|
+
- `preserveCtorIdentity` helper added — applied at the 5 wrapper sites above.
|
|
30
|
+
- GPU pool seeded by `domain + ':gpu'` (was just `domain`) — keeps per-domain GPU stable while decoupling it from any other per-domain seed we might add.
|
|
31
|
+
- 10 dead module-level exports trimmed from `lib/fingerprint.js`.
|
|
32
|
+
- `safeDefinePropertyLocal` forces `configurable: true` instead of merging it from the caller's descriptor (caller-side opt-in was unreliable).
|
|
33
|
+
|
|
5
34
|
## [3.0.0] - 2026-05-23
|
|
6
35
|
|
|
7
36
|
### Changed
|
package/CLAUDE.md
CHANGED
|
@@ -66,6 +66,28 @@ node nwss.js --dry-run # Preview without network calls
|
|
|
66
66
|
node nwss.js --headful # Launch with browser GUI
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
## Stealth Testing
|
|
70
|
+
|
|
71
|
+
`scripts/test-stealth.js` is a smoke-test harness for the fingerprint spoofing
|
|
72
|
+
stack. Launches Puppeteer with `applyAllFingerprintSpoofing` applied (same
|
|
73
|
+
call shape nwss.js uses), navigates to public bot-detection pages, and
|
|
74
|
+
reports what they concluded. Use it to A/B a stealth change — run before the
|
|
75
|
+
edit, run after, diff. Found 3 real bugs that 5 rounds of static review
|
|
76
|
+
missed (PHANTOM/SELENIUM own-goal, PluginArray instanceof, Plugin toString).
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
node scripts/test-stealth.js # all targets, human-readable
|
|
80
|
+
node scripts/test-stealth.js sannysoft # one target
|
|
81
|
+
node scripts/test-stealth.js --no-spoof # baseline (spoof disabled)
|
|
82
|
+
node scripts/test-stealth.js --format=json # machine-readable for diff/jq
|
|
83
|
+
node scripts/test-stealth.js --help # full flag list
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Set `PUPPETEER_NO_SANDBOX=1` when running as root (CI containers). Off by
|
|
87
|
+
default so local dev doesn't silently drop the sandbox. The harness depends
|
|
88
|
+
on `USER_AGENT_COLLECTIONS` exported from `lib/fingerprint.js` — keep that
|
|
89
|
+
export in sync if the UA list changes.
|
|
90
|
+
|
|
69
91
|
## Files to Ignore
|
|
70
92
|
|
|
71
93
|
- `node_modules/**`
|
package/README.md
CHANGED
|
@@ -790,4 +790,21 @@ your_username ALL=(root) NOPASSWD: /usr/bin/wg-quick, /usr/bin/wg
|
|
|
790
790
|
- Ghost-cursor (`cursor_mode: "ghost"`) is optional — install with `npm i ghost-cursor`. Falls back to built-in mouse if not installed
|
|
791
791
|
- Ghost-cursor duration defaults to `interact_duration` (or 2000ms), capped by the 15s hard timeout
|
|
792
792
|
|
|
793
|
+
## Stealth Testing
|
|
794
|
+
|
|
795
|
+
`scripts/test-stealth.js` is a developer-facing smoke test for the fingerprint spoofing stack in `lib/fingerprint.js`. It launches Puppeteer with the same `applyAllFingerprintSpoofing` call that `nwss.js` uses, navigates to public bot-detection pages, and reports what they concluded — pass/warn/fail counts from sannysoft, trust score from creepjs, raw navigator values from browserleaks.
|
|
796
|
+
|
|
797
|
+
Use it to A/B a stealth change: run before the edit, run after, diff the output. Not a unit test — it doesn't assert; it reports.
|
|
798
|
+
|
|
799
|
+
```bash
|
|
800
|
+
node scripts/test-stealth.js # all targets, human-readable
|
|
801
|
+
node scripts/test-stealth.js sannysoft # one target only
|
|
802
|
+
node scripts/test-stealth.js --no-spoof # baseline (spoof disabled)
|
|
803
|
+
node scripts/test-stealth.js --ua=firefox # spoof a different UA family
|
|
804
|
+
node scripts/test-stealth.js --format=json # machine-readable for diff/jq
|
|
805
|
+
node scripts/test-stealth.js --help # full flag list
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
Set `PUPPETEER_NO_SANDBOX=1` when running as root (CI containers, some Docker setups). Off by default so local dev doesn't silently drop the Chromium sandbox.
|
|
809
|
+
|
|
793
810
|
---
|
package/lib/browserexit.js
CHANGED
|
@@ -159,7 +159,7 @@ async function cleanupUserDataDir(userDataDir, forceDebug = false) {
|
|
|
159
159
|
*/
|
|
160
160
|
async function gracefulBrowserCleanup(browser, forceDebug = false) {
|
|
161
161
|
// FIX: Check browser connection before operations
|
|
162
|
-
if (!browser || !browser.
|
|
162
|
+
if (!browser || !browser.connected) {
|
|
163
163
|
if (forceDebug) console.log(formatLogMessage('debug', `${BROWSER_TAG} Browser not connected, skipping cleanup`));
|
|
164
164
|
return;
|
|
165
165
|
}
|
|
@@ -198,7 +198,7 @@ async function gracefulBrowserCleanup(browser, forceDebug = false) {
|
|
|
198
198
|
|
|
199
199
|
// FIX: Check browser is still connected before closing
|
|
200
200
|
try {
|
|
201
|
-
if (browser.
|
|
201
|
+
if (browser.connected) {
|
|
202
202
|
await browser.close();
|
|
203
203
|
if (forceDebug) console.log(formatLogMessage('debug', `${BROWSER_TAG} Browser closed successfully`));
|
|
204
204
|
} else {
|
|
@@ -336,7 +336,7 @@ async function forceBrowserKill(browser, forceDebug = false) {
|
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
try {
|
|
339
|
-
if (browser.
|
|
339
|
+
if (browser.connected) {
|
|
340
340
|
browser.disconnect();
|
|
341
341
|
if (forceDebug) console.log(formatLogMessage('debug', `${BROWSER_TAG} Browser connection disconnected`));
|
|
342
342
|
}
|
|
@@ -443,7 +443,7 @@ async function handleBrowserExit(browser, options = {}) {
|
|
|
443
443
|
// isConnected() to false — in that case the nuclear path would just
|
|
444
444
|
// murder other people's puppeteer-chrome instances for no gain.
|
|
445
445
|
let stillConnected = false;
|
|
446
|
-
try { stillConnected = browser.
|
|
446
|
+
try { stillConnected = browser.connected; } catch (_) {}
|
|
447
447
|
if (stillConnected) {
|
|
448
448
|
if (forceDebug) console.log(formatLogMessage('debug', `${BROWSER_TAG} Targeted force kill didn't take — escalating to nuclear cleanup`));
|
|
449
449
|
await killAllPuppeteerChrome(forceDebug);
|
package/lib/browserhealth.js
CHANGED
|
@@ -919,7 +919,7 @@ async function testBrowserConnectivity(browserInstance, timeout = 2500) {
|
|
|
919
919
|
|
|
920
920
|
try {
|
|
921
921
|
// Test 1: Basic browser connection
|
|
922
|
-
const isConnected = browserInstance.
|
|
922
|
+
const isConnected = browserInstance.connected;
|
|
923
923
|
connectivityResult.connected = isConnected;
|
|
924
924
|
|
|
925
925
|
if (!isConnected) {
|
package/lib/fingerprint.js
CHANGED
|
@@ -26,13 +26,6 @@ function seededRandom(seed) {
|
|
|
26
26
|
const _fingerprintCache = new Map();
|
|
27
27
|
const FINGERPRINT_CACHE_MAX = 500;
|
|
28
28
|
|
|
29
|
-
// Type-specific property spoofing functions for monomorphic optimization
|
|
30
|
-
// Built-in properties that should not be modified
|
|
31
|
-
const BUILT_IN_PROPERTIES = new Set([
|
|
32
|
-
'href', 'origin', 'protocol', 'host', 'hostname', 'port', 'pathname', 'search', 'hash',
|
|
33
|
-
'constructor', 'prototype', '__proto__', 'toString', 'valueOf', 'assign', 'reload', 'replace'
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
29
|
// User agent collections with latest versions
|
|
37
30
|
const USER_AGENT_COLLECTIONS = Object.freeze(new Map([
|
|
38
31
|
['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
|
|
@@ -77,15 +70,53 @@ const GPU_POOL = {
|
|
|
77
70
|
};
|
|
78
71
|
|
|
79
72
|
/**
|
|
80
|
-
* Select a GPU from the pool based on user agent string.
|
|
81
|
-
*
|
|
73
|
+
* Select a GPU from the pool based on user agent string. When `domain` is
|
|
74
|
+
* provided, selection is deterministic per-domain (seeded) -- a tracker
|
|
75
|
+
* logging UNMASKED_RENDERER_WEBGL sees the SAME machine across reloads of
|
|
76
|
+
* the same site. Without `domain`, falls back to Math.random for ad-hoc
|
|
77
|
+
* callers. Matches the same per-domain consistency that
|
|
78
|
+
* generateRealisticFingerprint uses for the rest of the spoof set.
|
|
82
79
|
*/
|
|
83
|
-
function selectGpuForUserAgent(userAgentString) {
|
|
80
|
+
function selectGpuForUserAgent(userAgentString, domain = '') {
|
|
84
81
|
let osKey = 'windows';
|
|
85
82
|
if (userAgentString && (userAgentString.includes('Macintosh') || userAgentString.includes('Mac OS X'))) osKey = 'mac';
|
|
86
83
|
else if (userAgentString && (userAgentString.includes('X11; Linux') || userAgentString.includes('Ubuntu'))) osKey = 'linux';
|
|
87
84
|
const pool = GPU_POOL[osKey];
|
|
88
|
-
|
|
85
|
+
// Distinct seed suffix so the GPU pick doesn't collide with the
|
|
86
|
+
// fingerprint generator's advance sequence for the same domain.
|
|
87
|
+
const rand = domain ? seededRandom(domain + ':gpu') : Math.random;
|
|
88
|
+
return pool[Math.floor(rand() * pool.length)];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One-shot "is this browser dead?" guard. Three of the four spoof entry
|
|
93
|
+
* points (applyUserAgentSpoofing, applyBraveSpoofing,
|
|
94
|
+
* applyFingerprintProtection) had this exact try/catch block inline:
|
|
95
|
+
*
|
|
96
|
+
* try {
|
|
97
|
+
* if (!page.browser().connected || page.isClosed()) return;
|
|
98
|
+
* if (page.browser().process()?.killed) return;
|
|
99
|
+
* } catch { return; }
|
|
100
|
+
*
|
|
101
|
+
* Three copies meant any Puppeteer API change (like the v25
|
|
102
|
+
* isConnected -> connected swap) needed three fixes. Now one.
|
|
103
|
+
*
|
|
104
|
+
* simulateHumanBehavior is NOT migrated here -- it has different debug
|
|
105
|
+
* log messages per failure mode ("page closed" vs "browser
|
|
106
|
+
* disconnected") that this helper doesn't preserve. Could plumb a
|
|
107
|
+
* callback for the log but the existing inline form is fine for one
|
|
108
|
+
* caller.
|
|
109
|
+
*/
|
|
110
|
+
function isBrowserDead(page) {
|
|
111
|
+
try {
|
|
112
|
+
if (!page || page.isClosed()) return true;
|
|
113
|
+
const browser = page.browser();
|
|
114
|
+
if (!browser.connected) return true;
|
|
115
|
+
if (browser.process()?.killed) return true;
|
|
116
|
+
return false;
|
|
117
|
+
} catch {
|
|
118
|
+
return true; // any failure reading browser state -> treat as dead
|
|
119
|
+
}
|
|
89
120
|
}
|
|
90
121
|
|
|
91
122
|
/**
|
|
@@ -103,67 +134,21 @@ function isSessionClosedError(err) {
|
|
|
103
134
|
}
|
|
104
135
|
|
|
105
136
|
/**
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
const existing = Object.getOwnPropertyDescriptor(target, property);
|
|
116
|
-
if (existing?.configurable === false) {
|
|
117
|
-
if (options.debug) console.log(`[fingerprint] Cannot modify non-configurable: ${property}`);
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
Object.defineProperty(target, property, descriptor);
|
|
122
|
-
return true;
|
|
123
|
-
} catch (err) {
|
|
124
|
-
if (options.debug) console.log(`[fingerprint] Failed to define ${property}: ${err.message}`);
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Safely executes spoofing operations with error handling
|
|
131
|
-
*/
|
|
132
|
-
function safeSpoofingExecution(spoofFunction, description, options = {}) {
|
|
133
|
-
try {
|
|
134
|
-
spoofFunction();
|
|
135
|
-
return true;
|
|
136
|
-
} catch (err) {
|
|
137
|
-
if (options.debug) console.log(`[fingerprint] ${description} failed: ${err.message}`);
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Generates realistic screen resolutions based on common monitor sizes
|
|
144
|
-
*/
|
|
145
|
-
function getRealisticScreenResolution() {
|
|
146
|
-
const commonResolutions = [
|
|
147
|
-
{ width: 1920, height: 1080 },
|
|
148
|
-
{ width: 1366, height: 768 },
|
|
149
|
-
{ width: 1440, height: 900 },
|
|
150
|
-
{ width: 1536, height: 864 },
|
|
151
|
-
{ width: 1600, height: 900 },
|
|
152
|
-
{ width: 2560, height: 1440 },
|
|
153
|
-
{ width: 1280, height: 720 },
|
|
154
|
-
{ width: 3440, height: 1440 }
|
|
155
|
-
];
|
|
156
|
-
return commonResolutions[Math.floor(Math.random() * commonResolutions.length)];
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Generates randomized but realistic browser fingerprint values
|
|
161
|
-
* When domain is provided, values are deterministic per-domain (consistent across reloads)
|
|
137
|
+
* Generates randomized but realistic browser fingerprint values.
|
|
138
|
+
* When domain is provided, values are deterministic per-(domain, userAgent)
|
|
139
|
+
* tuple -- consistent across reloads, but distinct across UA rotation so
|
|
140
|
+
* a re-scan with `userAgent: 'firefox'` doesn't reuse a previous
|
|
141
|
+
* `userAgent: 'chrome'` cache entry (which would ship Mac/Win-platform
|
|
142
|
+
* values under a Firefox UA, etc.).
|
|
162
143
|
*/
|
|
163
144
|
function generateRealisticFingerprint(userAgent, domain = '') {
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
145
|
+
// Cache key includes UA so a domain scanned twice with different UAs
|
|
146
|
+
// doesn't get the first UA's OS-mismatched fingerprint reused for the
|
|
147
|
+
// second. The `|` separator can't appear in a hostname (RFC 952/RFC
|
|
148
|
+
// 1123 allow only LDH) so it's collision-safe.
|
|
149
|
+
const cacheKey = domain ? `${domain}|${userAgent}` : '';
|
|
150
|
+
if (cacheKey) {
|
|
151
|
+
const cached = _fingerprintCache.get(cacheKey);
|
|
167
152
|
if (cached) return cached;
|
|
168
153
|
}
|
|
169
154
|
|
|
@@ -239,12 +224,12 @@ function generateRealisticFingerprint(userAgent, domain = '') {
|
|
|
239
224
|
doNotTrack: null // Most users don't enable DNT
|
|
240
225
|
};
|
|
241
226
|
|
|
242
|
-
// Cache for this domain
|
|
243
|
-
if (
|
|
227
|
+
// Cache for this (domain, userAgent) tuple. Same key as the lookup above.
|
|
228
|
+
if (cacheKey) {
|
|
244
229
|
if (_fingerprintCache.size >= FINGERPRINT_CACHE_MAX) {
|
|
245
230
|
_fingerprintCache.delete(_fingerprintCache.keys().next().value);
|
|
246
231
|
}
|
|
247
|
-
_fingerprintCache.set(
|
|
232
|
+
_fingerprintCache.set(cacheKey, fingerprint);
|
|
248
233
|
}
|
|
249
234
|
|
|
250
235
|
return fingerprint;
|
|
@@ -257,7 +242,7 @@ async function validatePageForInjection(page, currentUrl, forceDebug) {
|
|
|
257
242
|
try {
|
|
258
243
|
if (!page || page.isClosed()) return false;
|
|
259
244
|
|
|
260
|
-
if (!page.browser().
|
|
245
|
+
if (!page.browser().connected) {
|
|
261
246
|
if (forceDebug) console.log(formatLogMessage('debug', `Page validation failed - browser disconnected: ${currentUrl}`));
|
|
262
247
|
return false;
|
|
263
248
|
}
|
|
@@ -277,14 +262,18 @@ async function validatePageForInjection(page, currentUrl, forceDebug) {
|
|
|
277
262
|
*/
|
|
278
263
|
async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl) {
|
|
279
264
|
if (!siteConfig.userAgent) return;
|
|
265
|
+
// Type guard: callers (including config-driven paths) might pass non-string
|
|
266
|
+
// values by accident (e.g. an array, an object). Without this guard, the
|
|
267
|
+
// .toLowerCase() call below would throw and crash the whole spoof
|
|
268
|
+
// pipeline for this URL with no actionable error.
|
|
269
|
+
if (typeof siteConfig.userAgent !== 'string') {
|
|
270
|
+
if (forceDebug) console.log(formatLogMessage('debug', `Invalid userAgent type for ${currentUrl}: expected string, got ${typeof siteConfig.userAgent}`));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
280
273
|
|
|
281
274
|
if (forceDebug) console.log(formatLogMessage('debug', `User agent spoofing: ${siteConfig.userAgent}`));
|
|
282
275
|
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
if (!page.browser().isConnected() || page.isClosed()) return;
|
|
286
|
-
if (page.browser().process()?.killed) return;
|
|
287
|
-
} catch { return; }
|
|
276
|
+
if (isBrowserDead(page)) return;
|
|
288
277
|
|
|
289
278
|
// Validate page state before injection
|
|
290
279
|
if (!(await validatePageForInjection(page, currentUrl, forceDebug))) return;
|
|
@@ -303,11 +292,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
303
292
|
if (forceDebug) console.log(formatLogMessage('debug', `Applying stealth protection for ${currentUrl}`));
|
|
304
293
|
|
|
305
294
|
try {
|
|
306
|
-
//
|
|
307
|
-
|
|
295
|
+
// Derive site domain once -- used to seed the per-domain GPU pick
|
|
296
|
+
// (so a tracker logging UNMASKED_RENDERER_WEBGL sees one machine per
|
|
297
|
+
// site) AND to pre-compute hardware-concurrency from the SAME
|
|
298
|
+
// realistic-fingerprint generator that applyFingerprintProtection
|
|
299
|
+
// uses. That kills the order-dependent visible-value bug: previously
|
|
300
|
+
// hardwareConcurrency was random in the UA block (line 819) and
|
|
301
|
+
// domain-seeded in applyFingerprintProtection -- whichever evaluate
|
|
302
|
+
// ran second won. Now both paths produce the same value.
|
|
303
|
+
let siteDomain = '';
|
|
304
|
+
try { siteDomain = new URL(currentUrl).hostname; } catch (_) {}
|
|
305
|
+
|
|
306
|
+
const selectedGpu = selectGpuForUserAgent(ua, siteDomain);
|
|
308
307
|
if (forceDebug) console.log(formatLogMessage('debug', `Selected GPU: ${selectedGpu.vendor} / ${selectedGpu.renderer}`));
|
|
309
308
|
|
|
310
|
-
|
|
309
|
+
// Pre-compute hardware-concurrency from the (cached, domain-seeded)
|
|
310
|
+
// realistic fingerprint so the UA-spoof block uses the same value
|
|
311
|
+
// applyFingerprintProtection will later -- no order dependency,
|
|
312
|
+
// same value regardless of which evaluate runs first.
|
|
313
|
+
const realistic = generateRealisticFingerprint(ua, siteDomain);
|
|
314
|
+
|
|
315
|
+
await page.evaluateOnNewDocument((userAgent, debugEnabled, gpuConfig, seededCores) => {
|
|
311
316
|
|
|
312
317
|
// Apply inline error suppression first
|
|
313
318
|
(function() {
|
|
@@ -315,35 +320,53 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
315
320
|
const originalWindowError = window.onerror;
|
|
316
321
|
|
|
317
322
|
function shouldSuppressFingerprintError(message) {
|
|
323
|
+
// Suppression list, ordered specific -> general.
|
|
324
|
+
// .some() stops at first match, so a broken specific just means
|
|
325
|
+
// the general catchers below do the work instead.
|
|
326
|
+
//
|
|
327
|
+
// Fixed (was using `\\.` / `\\d` / `\\(` / `\\$` -- DOUBLE
|
|
328
|
+
// backslashes in source). Each `\\X` parses as
|
|
329
|
+
// literal-backslash + wildcard-X, requiring a literal `\` in
|
|
330
|
+
// the error text -- which never appears. So those entries
|
|
331
|
+
// silently never matched anything; only the general catchers
|
|
332
|
+
// below were actually suppressing those error families.
|
|
333
|
+
// Switched to single-backslash form (literal-X escapes) so
|
|
334
|
+
// each specific entry now matches its intended error class
|
|
335
|
+
// for accurate debug logging of WHICH pattern matched.
|
|
336
|
+
//
|
|
337
|
+
// Also dropped:
|
|
338
|
+
// - /Failed to load resource.*40[34]/i -- fully subsumed by
|
|
339
|
+
// the broader /[45]\d{2}/i below.
|
|
318
340
|
const patterns = [
|
|
319
341
|
/\.closest is not a function/i,
|
|
320
342
|
/\.querySelector is not a function/i,
|
|
321
343
|
/\.addEventListener is not a function/i,
|
|
322
|
-
/Cannot read propert(y|ies) of null
|
|
323
|
-
/Cannot read propert(y|ies) of undefined
|
|
344
|
+
/Cannot read propert(y|ies) of null \(reading 'fp'\)/i,
|
|
345
|
+
/Cannot read propert(y|ies) of undefined \(reading 'fp'\)/i,
|
|
324
346
|
/Cannot redefine property: href/i,
|
|
325
347
|
/Cannot redefine property: __webdriver_script_func/i,
|
|
326
348
|
/Cannot redefine property: webdriver/i,
|
|
327
|
-
/Cannot read propert(y|ies) of undefined
|
|
328
|
-
|
|
349
|
+
/Cannot read propert(y|ies) of undefined \(reading 'toLowerCase'\)/i,
|
|
350
|
+
/\.toLowerCase is not a function/i,
|
|
329
351
|
/fp is not defined/i,
|
|
330
352
|
/fingerprint is not defined/i,
|
|
331
353
|
/FingerprintJS is not defined/i,
|
|
332
|
-
|
|
354
|
+
/\$ is not defined/i,
|
|
333
355
|
/jQuery is not defined/i,
|
|
334
356
|
/_ is not defined/i,
|
|
335
|
-
/Failed to load resource.*server responded with a status of [45]
|
|
357
|
+
/Failed to load resource.*server responded with a status of [45]\d{2}/i,
|
|
336
358
|
/Failed to fetch/i,
|
|
337
359
|
/(webdriver|callPhantom|_phantom|__nightmare|_selenium) is not defined/i,
|
|
338
360
|
/Failed to execute 'observe' on 'IntersectionObserver'.*parameter 1 is not of type 'Element'/i,
|
|
339
361
|
/tz check/i,
|
|
340
|
-
/new window
|
|
341
|
-
/Failed to load resource.*server responded with a status of 40[34]/i,
|
|
362
|
+
/new window\.Error.*<anonymous>/i,
|
|
342
363
|
/Blocked script execution in 'about:blank'.*sandboxed.*allow-scripts/i,
|
|
343
364
|
/Page JavaScript error:/i,
|
|
344
365
|
/^[a-zA-Z0-9_$]+\[.*\]\s+is not a function/i,
|
|
345
366
|
/^[a-zA-Z0-9_$]+\(.*\)\s+is not a function/i,
|
|
346
367
|
/^[a-zA-Z0-9_$]+\.[a-zA-Z0-9_$]+.*is not a function/i,
|
|
368
|
+
// General catchers — kept last so the specific entries
|
|
369
|
+
// above can match first for accurate debug attribution.
|
|
347
370
|
/Failed to load resource/i,
|
|
348
371
|
/is not defined/i,
|
|
349
372
|
/is not a function/i
|
|
@@ -383,6 +406,30 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
383
406
|
}
|
|
384
407
|
return fn;
|
|
385
408
|
}
|
|
409
|
+
|
|
410
|
+
// Wrapper-constructor identity preserver. Function.name and
|
|
411
|
+
// Function.length are own data properties of every function
|
|
412
|
+
// object, NOT inherited via prototype chain -- so even after
|
|
413
|
+
// Object.setPrototypeOf(wrapper, OriginalCtor), the wrapper
|
|
414
|
+
// still has its own name (empty for anonymous expressions) and
|
|
415
|
+
// length (count of leading formal params). A bot detector that
|
|
416
|
+
// checks `Error.name === 'Error'` would see '' on our spoof.
|
|
417
|
+
//
|
|
418
|
+
// Real Chrome's Function.name has descriptor
|
|
419
|
+
// {value: ..., writable: false, enumerable: false, configurable: true}
|
|
420
|
+
// -- match that shape so getOwnPropertyDescriptor checks pass too.
|
|
421
|
+
function preserveCtorIdentity(wrapper, original) {
|
|
422
|
+
try {
|
|
423
|
+
Object.defineProperty(wrapper, 'name', {
|
|
424
|
+
value: original.name, writable: false, enumerable: false, configurable: true
|
|
425
|
+
});
|
|
426
|
+
Object.defineProperty(wrapper, 'length', {
|
|
427
|
+
value: original.length, writable: false, enumerable: false, configurable: true
|
|
428
|
+
});
|
|
429
|
+
} catch (e) {
|
|
430
|
+
if (debugEnabled) console.log(`[fingerprint] preserveCtorIdentity failed: ${e.message}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
386
433
|
|
|
387
434
|
Function.prototype.toString = function() {
|
|
388
435
|
if (nativeFunctionStore.has(this)) {
|
|
@@ -393,10 +440,14 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
393
440
|
// Protect the toString override itself
|
|
394
441
|
nativeFunctionStore.set(Function.prototype.toString, 'toString');
|
|
395
442
|
|
|
396
|
-
// Create safe property definition helper
|
|
443
|
+
// Create safe property definition helper.
|
|
444
|
+
// configurable: true is a DEFAULT (not a force) -- if a caller
|
|
445
|
+
// wants to lock a property (configurable: false) the spread won't
|
|
446
|
+
// override their explicit value. Previously `{ ...descriptor,
|
|
447
|
+
// configurable: true }` silently overrode any caller intent.
|
|
397
448
|
function safeDefinePropertyLocal(target, property, descriptor) {
|
|
398
449
|
const builtInProps = new Set(['href', 'origin', 'protocol', 'host', 'hostname', 'port', 'pathname', 'search', 'hash', 'constructor', 'prototype', '__proto__', 'toString', 'valueOf', 'assign', 'reload', 'replace']);
|
|
399
|
-
|
|
450
|
+
|
|
400
451
|
if (builtInProps.has(property)) {
|
|
401
452
|
if (debugEnabled) console.log(`[fingerprint] Skipping built-in property: ${property}`);
|
|
402
453
|
return false;
|
|
@@ -410,8 +461,8 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
410
461
|
}
|
|
411
462
|
|
|
412
463
|
Object.defineProperty(target, property, {
|
|
413
|
-
|
|
414
|
-
|
|
464
|
+
configurable: true,
|
|
465
|
+
...descriptor
|
|
415
466
|
});
|
|
416
467
|
return true;
|
|
417
468
|
} catch (err) {
|
|
@@ -473,13 +524,21 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
473
524
|
'$cdc_asdjflasutopfhvcZLmcfl_', '$chrome_asyncScriptInfo', '__$webdriverAsyncExecutor'
|
|
474
525
|
];
|
|
475
526
|
|
|
527
|
+
// Just delete -- DO NOT re-add as undefined-returning getters.
|
|
528
|
+
// The previous version did:
|
|
529
|
+
// delete window[prop];
|
|
530
|
+
// safeDefinePropertyLocal(window, prop, { get: () => undefined });
|
|
531
|
+
// which made `window.callPhantom` return undefined (good) but ALSO
|
|
532
|
+
// made `'callPhantom' in window` return TRUE (bad) -- the property
|
|
533
|
+
// exists on the object even if the getter returns undefined. Real
|
|
534
|
+
// Chrome doesn't have these props at all, so `in`-operator and
|
|
535
|
+
// Object.getOwnPropertyNames probes saw a present property and
|
|
536
|
+
// flagged us as a bot. This own-goal was caught by
|
|
537
|
+
// scripts/test-stealth.js sannysoft (PHANTOM_PROPERTIES and
|
|
538
|
+
// SELENIUM_DRIVER cells went red because of it).
|
|
476
539
|
automationProps.forEach(prop => {
|
|
477
|
-
try {
|
|
478
|
-
|
|
479
|
-
delete navigator[prop];
|
|
480
|
-
safeDefinePropertyLocal(window, prop, { get: () => undefined });
|
|
481
|
-
safeDefinePropertyLocal(navigator, prop, { get: () => undefined });
|
|
482
|
-
} catch (e) {}
|
|
540
|
+
try { delete window[prop]; } catch (e) {}
|
|
541
|
+
try { delete navigator[prop]; } catch (e) {}
|
|
483
542
|
});
|
|
484
543
|
}, 'automation properties removal');
|
|
485
544
|
|
|
@@ -501,12 +560,21 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
501
560
|
postMessage: () => {},
|
|
502
561
|
disconnect: () => {}
|
|
503
562
|
}),
|
|
504
|
-
getManifest: () =>
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
563
|
+
getManifest: () => {
|
|
564
|
+
// Derive Chrome version from the spoofed UA so a tracker
|
|
565
|
+
// cross-checking navigator.userAgent's Chrome version
|
|
566
|
+
// against chrome.runtime.getManifest().version sees a
|
|
567
|
+
// consistent number. Was hardcoded "146.0.0.0" which lied
|
|
568
|
+
// any time the UA was rotated to a different Chrome major.
|
|
569
|
+
const m = userAgent.match(/Chrome\/(\d+)/);
|
|
570
|
+
const major = m ? m[1] : '146';
|
|
571
|
+
return {
|
|
572
|
+
name: "Chrome",
|
|
573
|
+
version: `${major}.0.0.0`,
|
|
574
|
+
manifest_version: 3,
|
|
575
|
+
description: "Chrome Browser"
|
|
576
|
+
};
|
|
577
|
+
},
|
|
510
578
|
getURL: (path) => `chrome-extension://invalid/${path}`,
|
|
511
579
|
id: undefined,
|
|
512
580
|
getPlatformInfo: (callback) => callback({
|
|
@@ -571,11 +639,22 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
571
639
|
});
|
|
572
640
|
}
|
|
573
641
|
|
|
574
|
-
//
|
|
642
|
+
// The old comment claimed "non-enumerable to match real Chrome" --
|
|
643
|
+
// but real Chrome's window.chrome property descriptor is
|
|
644
|
+
// {value: ..., writable: true, enumerable: true, configurable: true}.
|
|
645
|
+
// The previous writable:false + enumerable:false were themselves
|
|
646
|
+
// fingerprintable tells: a bot detector reading
|
|
647
|
+
// Object.getOwnPropertyDescriptor(window, 'chrome')
|
|
648
|
+
// would see {writable:false, enumerable:false} and know this
|
|
649
|
+
// isn't real Chrome. `window.chrome = 'x'` would also silently
|
|
650
|
+
// fail (vs succeed on real Chrome). Match real Chrome's
|
|
651
|
+
// descriptor instead. The defineProperty is kept (rather than
|
|
652
|
+
// removed entirely) so re-injection on reload doesn't lose the
|
|
653
|
+
// descriptor shape if anything earlier tightened it.
|
|
575
654
|
Object.defineProperty(window, 'chrome', {
|
|
576
655
|
value: window.chrome,
|
|
577
|
-
writable:
|
|
578
|
-
enumerable:
|
|
656
|
+
writable: true,
|
|
657
|
+
enumerable: true,
|
|
579
658
|
configurable: true
|
|
580
659
|
});
|
|
581
660
|
|
|
@@ -649,6 +728,30 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
649
728
|
// Safari typically has no plugins in modern versions
|
|
650
729
|
plugins = [];
|
|
651
730
|
}
|
|
731
|
+
// Each plugin object needs to identify as a Plugin instance so
|
|
732
|
+
// `navigator.plugins[i].toString() === '[object Plugin]'` passes.
|
|
733
|
+
// (Sannysoft's actual PluginArray check tests this -- found via
|
|
734
|
+
// scripts/test-stealth.js sannysoft.) Resolve Plugin.prototype the
|
|
735
|
+
// same way we resolve PluginArray.prototype below: prefer the
|
|
736
|
+
// global, fall back to navigator.plugins[0]'s prototype if a real
|
|
737
|
+
// plugin exists, fall back to just Symbol.toStringTag if neither.
|
|
738
|
+
let pluginProto = null;
|
|
739
|
+
try {
|
|
740
|
+
if (typeof Plugin !== 'undefined' && Plugin.prototype) {
|
|
741
|
+
pluginProto = Plugin.prototype;
|
|
742
|
+
} else if (navigator.plugins && navigator.plugins[0]) {
|
|
743
|
+
pluginProto = Object.getPrototypeOf(navigator.plugins[0]);
|
|
744
|
+
}
|
|
745
|
+
} catch (e) {}
|
|
746
|
+
plugins = plugins.map(p => {
|
|
747
|
+
const wrapped = Object.assign(Object.create(pluginProto || Object.prototype), p);
|
|
748
|
+
if (!pluginProto) {
|
|
749
|
+
// Last-ditch: at least toString() returns "[object Plugin]"
|
|
750
|
+
wrapped[Symbol.toStringTag] = 'Plugin';
|
|
751
|
+
}
|
|
752
|
+
return wrapped;
|
|
753
|
+
});
|
|
754
|
+
|
|
652
755
|
// Create proper array-like object with enumerable indices and length
|
|
653
756
|
// Create proper PluginArray-like object with required methods
|
|
654
757
|
const pluginsArray = {};
|
|
@@ -671,6 +774,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
671
774
|
pluginsArray[Symbol.iterator] = function*() { for (const p of plugins) yield p; };
|
|
672
775
|
pluginsArray[Symbol.toStringTag] = 'PluginArray';
|
|
673
776
|
|
|
777
|
+
// Make `navigator.plugins instanceof PluginArray` evaluate true.
|
|
778
|
+
// Multiple ways to get PluginArray.prototype, in order of
|
|
779
|
+
// preference -- evaluateOnNewDocument can fire before all globals
|
|
780
|
+
// are bound, so we don't assume `PluginArray` is in scope. Falls
|
|
781
|
+
// back to inheriting from the existing navigator.plugins's own
|
|
782
|
+
// prototype, which is ALWAYS a real PluginArray.prototype on
|
|
783
|
+
// any DOM-bearing context.
|
|
784
|
+
try {
|
|
785
|
+
let pluginArrayProto = null;
|
|
786
|
+
if (typeof PluginArray !== 'undefined' && PluginArray.prototype) {
|
|
787
|
+
pluginArrayProto = PluginArray.prototype;
|
|
788
|
+
} else if (navigator.plugins) {
|
|
789
|
+
pluginArrayProto = Object.getPrototypeOf(navigator.plugins);
|
|
790
|
+
}
|
|
791
|
+
if (pluginArrayProto && pluginArrayProto !== Object.prototype) {
|
|
792
|
+
Object.setPrototypeOf(pluginsArray, pluginArrayProto);
|
|
793
|
+
}
|
|
794
|
+
} catch (e) {
|
|
795
|
+
if (debugEnabled) console.log(`[fingerprint] PluginArray prototype setup failed: ${e.message}`);
|
|
796
|
+
}
|
|
797
|
+
|
|
674
798
|
safeDefinePropertyLocal(navigator, 'plugins', { get: () => pluginsArray });
|
|
675
799
|
}, 'plugins spoofing');
|
|
676
800
|
|
|
@@ -813,12 +937,17 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
813
937
|
}
|
|
814
938
|
}, 'enhanced OS fingerprinting protection');
|
|
815
939
|
|
|
816
|
-
// Hardware concurrency spoofing (universal coverage)
|
|
817
|
-
//
|
|
940
|
+
// Hardware concurrency spoofing (universal coverage).
|
|
941
|
+
// Uses the domain-seeded value passed in from the Node-side
|
|
942
|
+
// generateRealisticFingerprint call -- previously did its own
|
|
943
|
+
// Math.random() pick from [4,6,8,12], which conflicted with the
|
|
944
|
+
// domain-seeded value set later by applyFingerprintProtection.
|
|
945
|
+
// Whichever evaluate ran second won, producing an order-dependent
|
|
946
|
+
// visible value. Now both paths use the same seeded value, so
|
|
947
|
+
// double-spoof becomes idempotent.
|
|
818
948
|
safeExecute(() => {
|
|
819
|
-
const spoofedCores = [4, 6, 8, 12][Math.floor(Math.random() * 4)];
|
|
820
949
|
const hardwareProps = {
|
|
821
|
-
hardwareConcurrency: { get: () =>
|
|
950
|
+
hardwareConcurrency: { get: () => seededCores }
|
|
822
951
|
};
|
|
823
952
|
spoofNavigatorProperties(navigator, hardwareProps);
|
|
824
953
|
}, 'hardware concurrency spoofing');
|
|
@@ -889,12 +1018,24 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
889
1018
|
|
|
890
1019
|
window.Error.prototype = OriginalError.prototype;
|
|
891
1020
|
Object.setPrototypeOf(window.Error, OriginalError);
|
|
1021
|
+
preserveCtorIdentity(window.Error, OriginalError);
|
|
892
1022
|
|
|
893
|
-
//
|
|
1023
|
+
// Forward static properties via getter/setter pairs instead of
|
|
1024
|
+
// value-copy, so live mutations to OriginalError (e.g. page
|
|
1025
|
+
// code doing `Error.stackTraceLimit = 100`) propagate through
|
|
1026
|
+
// the wrapper. Previously the value-copy froze a snapshot at
|
|
1027
|
+
// injection time; a tracker that mutated stackTraceLimit and
|
|
1028
|
+
// then read it back from the wrapped Error would see the old
|
|
1029
|
+
// value -- a real fingerprint tell.
|
|
894
1030
|
['captureStackTrace', 'stackTraceLimit', 'prepareStackTrace'].forEach(prop => {
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1031
|
+
try {
|
|
1032
|
+
Object.defineProperty(window.Error, prop, {
|
|
1033
|
+
get: () => OriginalError[prop],
|
|
1034
|
+
set: (v) => { OriginalError[prop] = v; },
|
|
1035
|
+
configurable: true,
|
|
1036
|
+
enumerable: false
|
|
1037
|
+
});
|
|
1038
|
+
} catch (e) {}
|
|
898
1039
|
});
|
|
899
1040
|
}, 'Error stack protection');
|
|
900
1041
|
|
|
@@ -1161,19 +1302,25 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1161
1302
|
}
|
|
1162
1303
|
}, 'window dimension spoofing');
|
|
1163
1304
|
|
|
1164
|
-
// navigator.connection — missing or incomplete in headless
|
|
1305
|
+
// navigator.connection — missing or incomplete in headless.
|
|
1306
|
+
// Object literal hoisted out of the getter so identity is stable
|
|
1307
|
+
// across reads. Real Chrome's NetworkInformation instance has
|
|
1308
|
+
// `navigator.connection === navigator.connection`; previously
|
|
1309
|
+
// the getter returned a new object on every read, which a
|
|
1310
|
+
// tracker could detect by comparing references.
|
|
1165
1311
|
safeExecute(() => {
|
|
1166
1312
|
if (!navigator.connection) {
|
|
1313
|
+
const connectionInfoStable = {
|
|
1314
|
+
effectiveType: '4g',
|
|
1315
|
+
rtt: 50,
|
|
1316
|
+
downlink: 10,
|
|
1317
|
+
saveData: false,
|
|
1318
|
+
type: 'wifi',
|
|
1319
|
+
addEventListener: () => {},
|
|
1320
|
+
removeEventListener: () => {}
|
|
1321
|
+
};
|
|
1167
1322
|
Object.defineProperty(navigator, 'connection', {
|
|
1168
|
-
get: () =>
|
|
1169
|
-
effectiveType: '4g',
|
|
1170
|
-
rtt: 50,
|
|
1171
|
-
downlink: 10,
|
|
1172
|
-
saveData: false,
|
|
1173
|
-
type: 'wifi',
|
|
1174
|
-
addEventListener: () => {},
|
|
1175
|
-
removeEventListener: () => {}
|
|
1176
|
-
})
|
|
1323
|
+
get: () => connectionInfoStable
|
|
1177
1324
|
});
|
|
1178
1325
|
}
|
|
1179
1326
|
}, 'connection API spoofing');
|
|
@@ -1254,7 +1401,10 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1254
1401
|
return img;
|
|
1255
1402
|
};
|
|
1256
1403
|
window.Image.prototype = OrigImage.prototype;
|
|
1257
|
-
|
|
1404
|
+
preserveCtorIdentity(window.Image, OrigImage);
|
|
1405
|
+
// (Note: the prior Object.defineProperty(window, 'Image', ...) here
|
|
1406
|
+
// was a no-op -- changed descriptor flags without setting value.
|
|
1407
|
+
// Removed; the wrapper is already assigned to window.Image above.)
|
|
1258
1408
|
}, 'broken image dimension spoofing');
|
|
1259
1409
|
|
|
1260
1410
|
// Fetch Request Headers Normalization
|
|
@@ -1345,6 +1495,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1345
1495
|
};
|
|
1346
1496
|
Object.setPrototypeOf(window.RTCPeerConnection, OriginalRTC);
|
|
1347
1497
|
window.RTCPeerConnection.prototype = OriginalRTC.prototype;
|
|
1498
|
+
preserveCtorIdentity(window.RTCPeerConnection, OriginalRTC);
|
|
1348
1499
|
}
|
|
1349
1500
|
}, 'WebRTC spoofing');
|
|
1350
1501
|
|
|
@@ -1587,8 +1738,9 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1587
1738
|
return new OriginalPointerEvent(type, enhancedDict);
|
|
1588
1739
|
};
|
|
1589
1740
|
Object.setPrototypeOf(window.PointerEvent, OriginalPointerEvent);
|
|
1741
|
+
preserveCtorIdentity(window.PointerEvent, OriginalPointerEvent);
|
|
1590
1742
|
}
|
|
1591
|
-
|
|
1743
|
+
|
|
1592
1744
|
// Spoof touch capabilities for mobile detection evasion
|
|
1593
1745
|
if (!window.TouchEvent && Math.random() > 0.8) {
|
|
1594
1746
|
// 20% chance to add touch support to confuse fingerprinters
|
|
@@ -1612,6 +1764,8 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1612
1764
|
};
|
|
1613
1765
|
return new originalWheelEvent(type, enhancedDict);
|
|
1614
1766
|
};
|
|
1767
|
+
Object.setPrototypeOf(window.WheelEvent, originalWheelEvent);
|
|
1768
|
+
preserveCtorIdentity(window.WheelEvent, originalWheelEvent);
|
|
1615
1769
|
}
|
|
1616
1770
|
|
|
1617
1771
|
}, 'enhanced mouse/pointer spoofing');
|
|
@@ -1744,7 +1898,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1744
1898
|
}
|
|
1745
1899
|
}, 'interaction-gated script trigger');
|
|
1746
1900
|
|
|
1747
|
-
}, ua, forceDebug, selectedGpu);
|
|
1901
|
+
}, ua, forceDebug, selectedGpu, realistic.hardwareConcurrency);
|
|
1748
1902
|
} catch (stealthErr) {
|
|
1749
1903
|
if (isSessionClosedError(stealthErr)) {
|
|
1750
1904
|
if (forceDebug) console.log(formatLogMessage('debug', `Page closed during stealth injection: ${currentUrl}`));
|
|
@@ -1762,12 +1916,8 @@ async function applyBraveSpoofing(page, siteConfig, forceDebug, currentUrl) {
|
|
|
1762
1916
|
if (!siteConfig.isBrave) return;
|
|
1763
1917
|
|
|
1764
1918
|
if (forceDebug) console.log(formatLogMessage('debug', `Brave spoofing enabled for ${currentUrl}`));
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
try {
|
|
1768
|
-
if (!page.browser().isConnected() || page.isClosed()) return;
|
|
1769
|
-
if (page.browser().process()?.killed) return;
|
|
1770
|
-
} catch { return; }
|
|
1919
|
+
|
|
1920
|
+
if (isBrowserDead(page)) return;
|
|
1771
1921
|
|
|
1772
1922
|
// Validate page state before injection
|
|
1773
1923
|
if (!(await validatePageForInjection(page, currentUrl, forceDebug))) return;
|
|
@@ -1814,12 +1964,8 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
|
|
|
1814
1964
|
if (!fingerprintSetting) return;
|
|
1815
1965
|
|
|
1816
1966
|
if (forceDebug) console.log(formatLogMessage('debug', `Fingerprint protection enabled for ${currentUrl}`));
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
try {
|
|
1820
|
-
if (!page.browser().isConnected() || page.isClosed()) return;
|
|
1821
|
-
if (page.browser().process()?.killed) return;
|
|
1822
|
-
} catch { return; }
|
|
1967
|
+
|
|
1968
|
+
if (isBrowserDead(page)) return;
|
|
1823
1969
|
|
|
1824
1970
|
// Validate page state before injection
|
|
1825
1971
|
if (!(await validatePageForInjection(page, currentUrl, forceDebug))) return;
|
|
@@ -1864,14 +2010,30 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
|
|
|
1864
2010
|
}
|
|
1865
2011
|
}
|
|
1866
2012
|
|
|
2013
|
+
// Mirror of the helper defined inside applyUserAgentSpoofing's
|
|
2014
|
+
// evaluate -- both behave identically: skip built-ins, refuse to
|
|
2015
|
+
// touch non-configurable, default (not force) configurable:true so
|
|
2016
|
+
// explicit caller intent is preserved. Previously this copy
|
|
2017
|
+
// diverged: it had no built-in check AND it force-overrode
|
|
2018
|
+
// configurable:true regardless of caller. Now consistent.
|
|
1867
2019
|
function safeDefinePropertyLocal(target, property, descriptor) {
|
|
2020
|
+
const builtInProps = new Set(['href', 'origin', 'protocol', 'host', 'hostname', 'port', 'pathname', 'search', 'hash', 'constructor', 'prototype', '__proto__', 'toString', 'valueOf', 'assign', 'reload', 'replace']);
|
|
2021
|
+
|
|
2022
|
+
if (builtInProps.has(property)) {
|
|
2023
|
+
if (debugEnabled) console.log(`[fingerprint] Skipping built-in property: ${property}`);
|
|
2024
|
+
return false;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
1868
2027
|
try {
|
|
1869
2028
|
const existing = Object.getOwnPropertyDescriptor(target, property);
|
|
1870
|
-
if (existing?.configurable === false)
|
|
1871
|
-
|
|
2029
|
+
if (existing?.configurable === false) {
|
|
2030
|
+
if (debugEnabled) console.log(`[fingerprint] Cannot modify non-configurable: ${property}`);
|
|
2031
|
+
return false;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1872
2034
|
Object.defineProperty(target, property, {
|
|
1873
|
-
|
|
1874
|
-
|
|
2035
|
+
configurable: true,
|
|
2036
|
+
...descriptor
|
|
1875
2037
|
});
|
|
1876
2038
|
return true;
|
|
1877
2039
|
} catch (err) {
|
|
@@ -1982,7 +2144,7 @@ async function simulateHumanBehavior(page, forceDebug) {
|
|
|
1982
2144
|
}
|
|
1983
2145
|
|
|
1984
2146
|
// Check if browser is still connected
|
|
1985
|
-
if (!page.browser().
|
|
2147
|
+
if (!page.browser().connected) {
|
|
1986
2148
|
if (forceDebug) console.log(formatLogMessage('debug', `Human behavior simulation skipped - browser disconnected`));
|
|
1987
2149
|
return;
|
|
1988
2150
|
}
|
|
@@ -2128,16 +2290,17 @@ async function applyAllFingerprintSpoofing(page, siteConfig, forceDebug, current
|
|
|
2128
2290
|
}
|
|
2129
2291
|
}
|
|
2130
2292
|
|
|
2293
|
+
// Public surface kept narrow on purpose -- only what nwss.js actually
|
|
2294
|
+
// imports. Internal helpers (generateRealisticFingerprint,
|
|
2295
|
+
// applyUserAgentSpoofing, applyBraveSpoofing, applyFingerprintProtection,
|
|
2296
|
+
// simulateHumanBehavior, selectGpuForUserAgent, isBrowserDead,
|
|
2297
|
+
// isSessionClosedError, validatePageForInjection, seededRandom,
|
|
2298
|
+
// DEFAULT_PLATFORM, DEFAULT_TIMEZONE) stay as module-local; move back to
|
|
2299
|
+
// module.exports only if a new external consumer appears.
|
|
2131
2300
|
module.exports = {
|
|
2132
|
-
generateRealisticFingerprint,
|
|
2133
|
-
getRealisticScreenResolution,
|
|
2134
|
-
applyUserAgentSpoofing,
|
|
2135
|
-
applyBraveSpoofing,
|
|
2136
|
-
applyFingerprintProtection,
|
|
2137
2301
|
applyAllFingerprintSpoofing,
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
DEFAULT_TIMEZONE
|
|
2302
|
+
// Exposed for scripts/test-stealth.js so the harness can validate --ua=
|
|
2303
|
+
// against the canonical UA list (instead of duplicating the keys here).
|
|
2304
|
+
// The Map itself is frozen; consumers cannot mutate the spoof source.
|
|
2305
|
+
USER_AGENT_COLLECTIONS
|
|
2143
2306
|
};
|
package/lib/proxy.js
CHANGED
|
@@ -335,7 +335,14 @@ async function testProxy(siteConfig, timeoutMs = 5000) {
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
/**
|
|
338
|
-
* Returns human-readable proxy info string for logging.
|
|
338
|
+
* Returns human-readable proxy info string for logging. The auth portion
|
|
339
|
+
* is REDACTED -- previously the username was emitted verbatim, which
|
|
340
|
+
* meant any error log line carrying this value (see nwss.js's
|
|
341
|
+
* ERR_SOCKS_CONNECTION_FAILED handler) leaked the proxy username to
|
|
342
|
+
* stderr / support tickets / screenshots. Password was already absent
|
|
343
|
+
* here. We keep an explicit `[redacted]@` marker when auth was configured
|
|
344
|
+
* so the reader still knows "yes, credentials were attached" without
|
|
345
|
+
* disclosing what they were.
|
|
339
346
|
*
|
|
340
347
|
* @param {object} siteConfig
|
|
341
348
|
* @returns {string}
|
|
@@ -347,7 +354,7 @@ function getProxyInfo(siteConfig) {
|
|
|
347
354
|
const parsed = parseProxyUrl(proxyUrl);
|
|
348
355
|
if (!parsed) return 'invalid';
|
|
349
356
|
|
|
350
|
-
const auth = parsed.username ?
|
|
357
|
+
const auth = parsed.username ? '[redacted]@' : '';
|
|
351
358
|
return `${parsed.protocol}://${auth}${parsed.host}:${parsed.port}`;
|
|
352
359
|
}
|
|
353
360
|
|
package/lib/redirect.js
CHANGED
|
@@ -62,7 +62,7 @@ async function navigateWithRedirectHandling(page, currentUrl, siteConfig, gotoOp
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// Check if browser is still connected
|
|
65
|
-
if (!page.browser().
|
|
65
|
+
if (!page.browser().connected) {
|
|
66
66
|
if (forceDebug) {
|
|
67
67
|
console.log(formatLogMessage('debug', 'JS redirect detector skipped - browser disconnected'));
|
|
68
68
|
}
|
package/lib/socks-relay.js
CHANGED
|
@@ -234,7 +234,12 @@ async function ensureRelay(upstream, forceDebug = false) {
|
|
|
234
234
|
const port = server.address().port;
|
|
235
235
|
_relays.set(key, { server, port, activeSockets });
|
|
236
236
|
if (forceDebug) {
|
|
237
|
-
|
|
237
|
+
// auth status is kept as a presence flag only -- previously printed
|
|
238
|
+
// the raw username, which leaked into shared debug output (support
|
|
239
|
+
// tickets, screenshots, gists). Same redaction policy as the
|
|
240
|
+
// proxy.js getProxyInfo() change.
|
|
241
|
+
const authTag = upstream.username ? ' (auth: [redacted])' : ' (no auth)';
|
|
242
|
+
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} 127.0.0.1:${port} -> ${upstream.host}:${upstream.port}${authTag}`));
|
|
238
243
|
}
|
|
239
244
|
return port;
|
|
240
245
|
}
|
|
@@ -259,7 +264,15 @@ async function closeAllRelays(forceDebug = false) {
|
|
|
259
264
|
await new Promise((res) => {
|
|
260
265
|
try { r.server.close(() => res()); } catch (_) { res(); }
|
|
261
266
|
});
|
|
262
|
-
if (forceDebug)
|
|
267
|
+
if (forceDebug) {
|
|
268
|
+
// upstreamKey embeds the username (host:port:username), so the raw
|
|
269
|
+
// key would leak it in debug output. Strip just the trailing
|
|
270
|
+
// `:username` segment for display; using a regex (not split-on-':')
|
|
271
|
+
// so IPv6 hosts with embedded colons (e.g. 2001:db8::1:1080:user)
|
|
272
|
+
// aren't mangled. The relay identity stays unambiguous from host+port.
|
|
273
|
+
const displayKey = key.replace(/:[^:]*$/, '');
|
|
274
|
+
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} closed relay for ${displayKey}`));
|
|
275
|
+
}
|
|
263
276
|
}
|
|
264
277
|
_relays.clear();
|
|
265
278
|
}
|
package/nwss.js
CHANGED
|
@@ -2323,7 +2323,7 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
2323
2323
|
let browserResponsive = false;
|
|
2324
2324
|
try {
|
|
2325
2325
|
// Check if browser is still connected before attempting health check
|
|
2326
|
-
if (!browserInstance.
|
|
2326
|
+
if (!browserInstance.connected) {
|
|
2327
2327
|
throw new Error('Browser not connected');
|
|
2328
2328
|
}
|
|
2329
2329
|
|
|
@@ -5713,7 +5713,7 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
5713
5713
|
console.log(messageColors.info('Browser kept open.') + ' Close the browser window or press Ctrl+C to exit.');
|
|
5714
5714
|
const cleanup = async () => {
|
|
5715
5715
|
try {
|
|
5716
|
-
if (browser.
|
|
5716
|
+
if (browser.connected) await browser.close();
|
|
5717
5717
|
} catch {}
|
|
5718
5718
|
process.exit(0);
|
|
5719
5719
|
};
|
|
@@ -5731,7 +5731,7 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
5731
5731
|
|
|
5732
5732
|
// Enhanced final validation for Puppeteer 23.x
|
|
5733
5733
|
try {
|
|
5734
|
-
const isStillConnected = browser.
|
|
5734
|
+
const isStillConnected = browser.connected;
|
|
5735
5735
|
if (forceDebug) console.log(formatLogMessage('debug', `Browser connection status before cleanup: ${isStillConnected}`));
|
|
5736
5736
|
} catch (connErr) {
|
|
5737
5737
|
if (forceDebug) console.log(formatLogMessage('debug', `Browser connection check failed: ${connErr.message}`));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fanboynz/network-scanner",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
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": {
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Stealth integration smoke test.
|
|
4
|
+
*
|
|
5
|
+
* Launches Puppeteer, applies the project's full fingerprint spoofing stack
|
|
6
|
+
* (lib/fingerprint.js's applyAllFingerprintSpoofing), navigates to public
|
|
7
|
+
* bot-detection test pages, and reports what the page concluded about us.
|
|
8
|
+
*
|
|
9
|
+
* Purpose: replace "I think the spoof works" theoretical reviews with real
|
|
10
|
+
* signal -- which checks pass, which fail, which moved after a fingerprint
|
|
11
|
+
* change. Run before and after a stealth-related commit to A/B the impact.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node scripts/test-stealth.js # all targets, human-readable
|
|
15
|
+
* node scripts/test-stealth.js sannysoft # one target
|
|
16
|
+
* node scripts/test-stealth.js --headful # show browser GUI
|
|
17
|
+
* node scripts/test-stealth.js --no-spoof # baseline (no fingerprint protection)
|
|
18
|
+
* node scripts/test-stealth.js --ua=firefox # change UA family
|
|
19
|
+
* node scripts/test-stealth.js --format=json # machine-readable output
|
|
20
|
+
* node scripts/test-stealth.js --help # show usage
|
|
21
|
+
*
|
|
22
|
+
* Environment:
|
|
23
|
+
* PUPPETEER_NO_SANDBOX=1 pass --no-sandbox --disable-setuid-sandbox to
|
|
24
|
+
* Chromium. Required when running as root (CI
|
|
25
|
+
* containers, some Docker setups). Off by default
|
|
26
|
+
* so local dev doesn't silently drop the sandbox.
|
|
27
|
+
*
|
|
28
|
+
* Targets (extend by adding to TARGETS below):
|
|
29
|
+
* sannysoft https://bot.sannysoft.com/ — classic fingerprint tests
|
|
30
|
+
* creepjs https://abrahamjuliot.github.io/creepjs/ — modern fingerprint suite
|
|
31
|
+
* browserleaks https://browserleaks.com/javascript — JS env probe
|
|
32
|
+
*
|
|
33
|
+
* Output: one line per target with PASS / WARN / FAIL counts (where parseable),
|
|
34
|
+
* plus a short summary of any explicit detection markers ("Bot detected",
|
|
35
|
+
* "Headless", etc.) found in the page text. With --format=json, emits a single
|
|
36
|
+
* JSON object suitable for piping to diff/jq for before/after comparison.
|
|
37
|
+
*
|
|
38
|
+
* This is a SMOKE test, not a unit test. It doesn't make assertions; it
|
|
39
|
+
* reports what the page reports. Use the output to decide if a stealth
|
|
40
|
+
* change moved the needle.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
'use strict';
|
|
44
|
+
|
|
45
|
+
const puppeteer = require('puppeteer');
|
|
46
|
+
const path = require('path');
|
|
47
|
+
const {
|
|
48
|
+
applyAllFingerprintSpoofing,
|
|
49
|
+
USER_AGENT_COLLECTIONS
|
|
50
|
+
} = require(path.resolve(__dirname, '..', 'lib', 'fingerprint'));
|
|
51
|
+
|
|
52
|
+
const args = process.argv.slice(2);
|
|
53
|
+
const HELP = args.includes('--help') || args.includes('-h');
|
|
54
|
+
const HEADFUL = args.includes('--headful');
|
|
55
|
+
const NO_SPOOF = args.includes('--no-spoof');
|
|
56
|
+
const UA_FLAG = (args.find(a => a.startsWith('--ua=')) || '').slice(5) || 'chrome';
|
|
57
|
+
const FORMAT = (args.find(a => a.startsWith('--format=')) || '').slice(9) || 'text';
|
|
58
|
+
const filterTargets = args.filter(a => !a.startsWith('-'));
|
|
59
|
+
// Anything starting with '-' is a flag claim; we validate the known set
|
|
60
|
+
// below so typos like "-headful" or "--no_spoof" don't silently no-op.
|
|
61
|
+
const flagArgs = args.filter(a => a.startsWith('-'));
|
|
62
|
+
const KNOWN_FLAGS = new Set(['--headful', '--no-spoof', '--help', '-h']);
|
|
63
|
+
const KNOWN_FLAG_PREFIXES = ['--ua=', '--format='];
|
|
64
|
+
|
|
65
|
+
const TARGETS = [
|
|
66
|
+
{
|
|
67
|
+
name: 'sannysoft',
|
|
68
|
+
url: 'https://bot.sannysoft.com/',
|
|
69
|
+
// Parse the result tables. Sannysoft uses td.passed / td.failed / td.warn.
|
|
70
|
+
extract: async (page) => {
|
|
71
|
+
return await page.evaluate(() => {
|
|
72
|
+
const cells = Array.from(document.querySelectorAll('td'));
|
|
73
|
+
const out = { passed: 0, failed: 0, warn: 0, total: 0, failures: [] };
|
|
74
|
+
for (const c of cells) {
|
|
75
|
+
const cls = c.className || '';
|
|
76
|
+
if (cls.includes('passed')) { out.passed++; out.total++; }
|
|
77
|
+
else if (cls.includes('failed')) {
|
|
78
|
+
out.failed++; out.total++;
|
|
79
|
+
// Try to capture the row label for context
|
|
80
|
+
const row = c.closest('tr');
|
|
81
|
+
const label = row?.querySelector('td')?.textContent?.trim() || '?';
|
|
82
|
+
out.failures.push(label);
|
|
83
|
+
}
|
|
84
|
+
else if (cls.includes('warn')) { out.warn++; out.total++; }
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'creepjs',
|
|
92
|
+
url: 'https://abrahamjuliot.github.io/creepjs/',
|
|
93
|
+
extract: async (page) => {
|
|
94
|
+
// CreepJS surfaces a trust score in the page. Wait briefly for the
|
|
95
|
+
// async fingerprinting tests to complete.
|
|
96
|
+
await page.waitForSelector('#fingerprint-data', { timeout: 30000 }).catch(() => {});
|
|
97
|
+
await new Promise(r => setTimeout(r, 8000)); // give async tests time
|
|
98
|
+
return await page.evaluate(() => {
|
|
99
|
+
const text = document.body.innerText || '';
|
|
100
|
+
// CreepJS reports a "Trust Score" percentage and individual signal entries.
|
|
101
|
+
const trustMatch = text.match(/Trust Score[:\s]+(\d+(?:\.\d+)?)\s*%/i);
|
|
102
|
+
const lieMatch = text.match(/lies[:\s]+(\d+)/i);
|
|
103
|
+
const botMatch = text.match(/bot[:\s]+(true|false)/i);
|
|
104
|
+
return {
|
|
105
|
+
trustScore: trustMatch ? parseFloat(trustMatch[1]) : null,
|
|
106
|
+
lies: lieMatch ? parseInt(lieMatch[1], 10) : null,
|
|
107
|
+
botDetected: botMatch ? botMatch[1] === 'true' : null,
|
|
108
|
+
excerpt: text.split('\n').slice(0, 15).join('\n').slice(0, 400)
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'browserleaks',
|
|
115
|
+
url: 'https://browserleaks.com/javascript',
|
|
116
|
+
extract: async (page) => {
|
|
117
|
+
return await page.evaluate(() => {
|
|
118
|
+
// browserleaks shows the values; we just capture the navigator-related ones
|
|
119
|
+
// and report which look anomalous.
|
|
120
|
+
return {
|
|
121
|
+
userAgent: navigator.userAgent,
|
|
122
|
+
platform: navigator.platform,
|
|
123
|
+
webdriver: navigator.webdriver,
|
|
124
|
+
languages: JSON.stringify(navigator.languages),
|
|
125
|
+
hardwareConcurrency: navigator.hardwareConcurrency,
|
|
126
|
+
deviceMemory: navigator.deviceMemory,
|
|
127
|
+
plugins: navigator.plugins?.length,
|
|
128
|
+
chromeRuntime: typeof window.chrome?.runtime,
|
|
129
|
+
chromeRuntimeVersion: (() => { try { return window.chrome?.runtime?.getManifest?.()?.version; } catch (e) { return 'error'; } })(),
|
|
130
|
+
windowChromeDescriptor: (() => {
|
|
131
|
+
const d = Object.getOwnPropertyDescriptor(window, 'chrome');
|
|
132
|
+
return d ? `writable=${d.writable},enumerable=${d.enumerable},configurable=${d.configurable}` : 'no-descriptor';
|
|
133
|
+
})(),
|
|
134
|
+
errorName: Error.name,
|
|
135
|
+
errorLength: Error.length,
|
|
136
|
+
rtcName: window.RTCPeerConnection?.name,
|
|
137
|
+
imageName: window.Image?.name
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
function printHelp() {
|
|
145
|
+
console.log(`Usage: node scripts/test-stealth.js [options] [target...]
|
|
146
|
+
|
|
147
|
+
Options:
|
|
148
|
+
--headful launch with browser GUI visible
|
|
149
|
+
--no-spoof baseline run — skip applyAllFingerprintSpoofing
|
|
150
|
+
--ua=<family> UA family to spoof (default: chrome)
|
|
151
|
+
valid: ${Array.from(USER_AGENT_COLLECTIONS.keys()).join(', ')}
|
|
152
|
+
--format=<fmt> output format: text (default) | json
|
|
153
|
+
--help, -h show this message
|
|
154
|
+
|
|
155
|
+
Environment:
|
|
156
|
+
PUPPETEER_NO_SANDBOX=1 pass --no-sandbox to Chromium (required in some CI)
|
|
157
|
+
|
|
158
|
+
Targets: ${TARGETS.map(t => t.name).join(', ')} (default: all)`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function formatResult(target, result) {
|
|
162
|
+
const lines = [`\n=== ${target.name} (${target.url}) ===`];
|
|
163
|
+
if (target.name === 'sannysoft') {
|
|
164
|
+
lines.push(` passed: ${result.passed} | warn: ${result.warn} | failed: ${result.failed} | total: ${result.total}`);
|
|
165
|
+
if (result.failures.length) {
|
|
166
|
+
lines.push(` failure rows: ${result.failures.slice(0, 10).join(', ')}${result.failures.length > 10 ? ` ... +${result.failures.length - 10} more` : ''}`);
|
|
167
|
+
}
|
|
168
|
+
} else if (target.name === 'creepjs') {
|
|
169
|
+
lines.push(` trust score: ${result.trustScore ?? 'n/a'}%`);
|
|
170
|
+
lines.push(` lies detected: ${result.lies ?? 'n/a'}`);
|
|
171
|
+
lines.push(` bot flagged: ${result.botDetected ?? 'n/a'}`);
|
|
172
|
+
if (result.excerpt) lines.push(` excerpt:\n ${result.excerpt.split('\n').join('\n ')}`);
|
|
173
|
+
} else if (target.name === 'browserleaks') {
|
|
174
|
+
for (const [k, v] of Object.entries(result)) {
|
|
175
|
+
lines.push(` ${k.padEnd(24)} ${v}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return lines.join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
(async () => {
|
|
182
|
+
if (HELP) { printHelp(); process.exit(0); }
|
|
183
|
+
|
|
184
|
+
// Validate --ua= against the canonical UA list. Previously a typo like
|
|
185
|
+
// --ua=opera silently fell through to applyUserAgentSpoofing's "unknown UA,
|
|
186
|
+
// no-op" path, producing run results that looked spoofed but weren't.
|
|
187
|
+
if (!USER_AGENT_COLLECTIONS.has(UA_FLAG)) {
|
|
188
|
+
console.error(`Invalid --ua=${UA_FLAG}. Valid: ${Array.from(USER_AGENT_COLLECTIONS.keys()).join(', ')}`);
|
|
189
|
+
process.exit(2);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!['text', 'json'].includes(FORMAT)) {
|
|
193
|
+
console.error(`Invalid --format=${FORMAT}. Valid: text, json`);
|
|
194
|
+
process.exit(2);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Reject unrecognised flags before we launch a browser. Typos like
|
|
198
|
+
// "-headful" or "--no_spoof" used to silently no-op and produce a
|
|
199
|
+
// misleading "spoof on" run that wasn't actually spoofed.
|
|
200
|
+
const badFlags = flagArgs.filter(f =>
|
|
201
|
+
!KNOWN_FLAGS.has(f) && !KNOWN_FLAG_PREFIXES.some(p => f.startsWith(p))
|
|
202
|
+
);
|
|
203
|
+
if (badFlags.length) {
|
|
204
|
+
console.error(`Unrecognised flag(s): ${badFlags.join(', ')}. See --help.`);
|
|
205
|
+
process.exit(2);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const targetsToRun = filterTargets.length
|
|
209
|
+
? TARGETS.filter(t => filterTargets.includes(t.name))
|
|
210
|
+
: TARGETS;
|
|
211
|
+
|
|
212
|
+
if (targetsToRun.length === 0) {
|
|
213
|
+
console.error(`No targets matched. Available: ${TARGETS.map(t => t.name).join(', ')}`);
|
|
214
|
+
process.exit(2);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (FORMAT === 'text') {
|
|
218
|
+
console.log(`Stealth test config: spoof=${!NO_SPOOF}, ua=${UA_FLAG}, headful=${HEADFUL}`);
|
|
219
|
+
console.log(`Targets: ${targetsToRun.map(t => t.name).join(', ')}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Sandbox is on by default; opt out via env var rather than baking
|
|
223
|
+
// --no-sandbox into the launch line. CI-as-root needs it; local dev should
|
|
224
|
+
// not silently drop the sandbox just because the test happens to start it.
|
|
225
|
+
const launchArgs = ['--disable-blink-features=AutomationControlled'];
|
|
226
|
+
if (process.env.PUPPETEER_NO_SANDBOX === '1') {
|
|
227
|
+
launchArgs.push('--no-sandbox', '--disable-setuid-sandbox');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const browser = await puppeteer.launch({
|
|
231
|
+
headless: !HEADFUL,
|
|
232
|
+
args: launchArgs
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Collected for JSON output (and to support a future --fail-on-detection
|
|
236
|
+
// exit code without restructuring the loop).
|
|
237
|
+
const collected = [];
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
for (const target of targetsToRun) {
|
|
241
|
+
const page = await browser.newPage();
|
|
242
|
+
const started = Date.now();
|
|
243
|
+
try {
|
|
244
|
+
if (!NO_SPOOF) {
|
|
245
|
+
// Apply the same spoofing stack nwss.js uses for real scans.
|
|
246
|
+
await applyAllFingerprintSpoofing(page,
|
|
247
|
+
{ userAgent: UA_FLAG, fingerprint_protection: 'random' },
|
|
248
|
+
false,
|
|
249
|
+
target.url
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
await page.goto(target.url, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
253
|
+
const result = await target.extract(page);
|
|
254
|
+
collected.push({ name: target.name, url: target.url, ok: true, durationMs: Date.now() - started, result });
|
|
255
|
+
if (FORMAT === 'text') console.log(formatResult(target, result));
|
|
256
|
+
} catch (err) {
|
|
257
|
+
collected.push({ name: target.name, url: target.url, ok: false, durationMs: Date.now() - started, error: err.message });
|
|
258
|
+
if (FORMAT === 'text') {
|
|
259
|
+
console.error(`\n=== ${target.name} (${target.url}) ===`);
|
|
260
|
+
console.error(` ERROR: ${err.message}`);
|
|
261
|
+
}
|
|
262
|
+
} finally {
|
|
263
|
+
await page.close().catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
await browser.close().catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (FORMAT === 'json') {
|
|
271
|
+
// Single object, not NDJSON — easier to diff with `jq` or `diff` between
|
|
272
|
+
// before/after runs. Schema is stable: top-level config + targets[].
|
|
273
|
+
process.stdout.write(JSON.stringify({
|
|
274
|
+
config: { spoof: !NO_SPOOF, ua: UA_FLAG, headful: HEADFUL, noSandbox: process.env.PUPPETEER_NO_SANDBOX === '1' },
|
|
275
|
+
targets: collected
|
|
276
|
+
}, null, 2) + '\n');
|
|
277
|
+
}
|
|
278
|
+
})().catch(err => {
|
|
279
|
+
console.error('test-stealth fatal:', err);
|
|
280
|
+
process.exit(1);
|
|
281
|
+
});
|