@fanboynz/network-scanner 3.0.2 → 3.0.3
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 +34 -0
- package/lib/fingerprint.js +318 -44
- package/package.json +1 -1
- package/scripts/test-stealth.js +39 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Network Scanner (nwss.js) project.
|
|
4
4
|
|
|
5
|
+
## [3.0.3] - 2026-05-26
|
|
6
|
+
|
|
7
|
+
### Improved
|
|
8
|
+
- **3 DataDome-targeted gaps closed in `lib/fingerprint.js`** (inside `applyFingerprintProtection`, so gated on `siteConfig.fingerprint_protection` like every other spoof in that function):
|
|
9
|
+
- **`Notification.permission` static property** now returns `'default'` (real Chrome's no-granted-permission state). Previously only `Notification.requestPermission()` (the method) was patched; the static property still returned the headless default `'denied'` — a live tell for DataDome and similar detectors that read it directly.
|
|
10
|
+
- **`screen.orientation` interface** is now provided as a stable `{type: 'landscape-primary', angle: 0, addEventListener, lock, unlock, ...}` object when missing. Modern browsers always expose ScreenOrientation; absence is a "real browser?" check signal.
|
|
11
|
+
- **`<html>` `webdriver` DOM attribute** stripped if present. Defensive — modern Puppeteer with `ignoreDefaultArgs: ['--enable-automation']` doesn't emit this, but older driver setups do, and detectors check both `navigator.webdriver` AND `documentElement.getAttribute('webdriver')`. Appended to the existing `'webdriver removal'` safeExecute block so all webdriver cleanup lives together.
|
|
12
|
+
|
|
13
|
+
Targeted at sites running DataDome's `ct.captcha-delivery.com/i.js` (and similar fingerprint suites: PerimeterX, Akamai Bot Manager). Most other surfaces these detectors probe were already covered (chrome.app/csi/loadTimes, userAgentData, maxTouchPoints, permissions.query, WebGL UNMASKED_VENDOR/RENDERER, etc.). `scripts/test-stealth.js sannysoft` regression smoke holds at 29 passed / 1 warn / 0 failed (the warn is `CHR_DEBUG_TOOLS`, a CDP-attached signal that's fundamental to Puppeteer and unrelated to these additions). JS-only spoofing can't address TLS fingerprint, HTTP/2 fingerprint, IP reputation, or behavioural analysis — those still depend on proxy choice and `interact` / `ghost-cursor` config.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **`scripts/test-stealth.js` now reports warn-row labels** for sannysoft, not just failure-row labels. Previously a cell moving from `passed` → `warn` between runs was invisible (only the count changed), making soft-regression debugging require `--headful`. Now the warn-row table contents print inline so you can see e.g. `warn rows: CHR_DEBUG_TOOLS` directly. Schema additive: result object gains a `warnings: string[]` array alongside the existing `failures: string[]`.
|
|
17
|
+
- **`scripts/test-stealth.js` extracts CreepJS's actual current metrics** instead of stale `Trust Score` regex that returned `n/a` for every field. New extracted fields: `fpId` (CreepJS's stable fingerprint hash, lets you A/B before/after a spoof change), `isChromium` (engine identification), `headlessPct` (HARD headless detection score, lower = better), `likeHeadlessPct` (SOFT headless signals), `stealthPct` (spoof-detection probes score, HIGHER = better since it means our spoofs LOOK convincing). Formatter prints all five with directionality hints inline. Excerpt now 40 lines / 2KB (was 15 / 400 bytes) so future UI rotations are debuggable from the output without `--headful`.
|
|
18
|
+
- **Additional headless-mode spoofs in `lib/fingerprint.js`** (all inside `applyFingerprintProtection`, gated on `siteConfig.fingerprint_protection`):
|
|
19
|
+
- **`matchMedia` hover/pointer queries**: `(any-hover: hover)`, `(any-hover: none)`, `(any-pointer: fine)`, `(any-pointer: none)`, `(any-pointer: coarse)` plus the legacy non-`any-` aliases. Headless Chrome reports no hover device and no fine pointer (no mouse hardware); detectors probe these as a binary 'real desktop hardware?' signal. Pass-through for all other queries (responsive, color-scheme, reduced-motion, etc.).
|
|
20
|
+
- **`screenLeft` / `screenTop` mirror `screenX` / `screenY`**. Real Chrome exposes these as identical-value legacy aliases; spoofers often leave them undefined or 0, which is inconsistent with the non-zero `screenX/Y` our existing patch produces.
|
|
21
|
+
- **Modern Chrome API stubs**: `document.hasStorageAccess()` → `Promise<true>`, `navigator.userActivation` → `{hasBeenActive: true, isActive: true}`, `navigator.getInstalledRelatedApps()` → `Promise<[]>`. Each gated on absence check so real-Chrome paths skip the override.
|
|
22
|
+
|
|
23
|
+
Honest measurement: CreepJS's specific `headless score` did NOT move after these additions (stayed at 67%). My prior estimate of '~-10 to -15 percentage points' was over-optimistic — CreepJS apparently doesn't weight matchMedia hover/pointer heavily in its headless calculation. The additions are still correct spoofs that close real fingerprint gaps and likely help against DataDome / PerimeterX which use different scoring; they're net-positive but score-neutral against CreepJS specifically. The remaining ~67% headless detection is architectural (CDP attachment, software-rasterizer GPU, no real mouse cursor) and can't be lowered without `--headful`.
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
- **WebRTC public-IP leak closed** in `lib/fingerprint.js` (`applyFingerprintProtection`). The previous local-IP filter only stripped RFC1918 private ranges (`10.x / 172.16-31.x / 192.168.x`), missing `srflx` (STUN-discovered PUBLIC IP), `prflx`, `relay`, and host candidates with non-RFC1918 addresses (CGNAT 100.64.0.0/10, link-local IPv6, real public IPs on bare-metal hosts). STUN traffic is UDP and **bypasses the SOCKS5 proxy entirely**, so the leaked IP was the real host IP regardless of proxy config — visible to any page that listened on `icecandidate` events. Caught by `test-stealth.js creepjs` which surfaced the candidate string `122.252.155.250 typ srflx` and the corresponding `ip:` field in its WebRTC panel. Fix: strip EVERY ICE candidate; deliver only the null-candidate sentinel (end-of-gathering signal). Side note: the property-based `pc.onicecandidate = fn` setter was also broken (stored handler but never wired it up); now mirrors the same filter as the addEventListener path. Side effect: any site that REQUIRES functional WebRTC peer connections sees ICE gathering produce zero candidates. For nwss.js's scanning use case this is correct.
|
|
27
|
+
|
|
28
|
+
### Stealth hardening (toString masking)
|
|
29
|
+
- **Added 8 session-introduced spoofs to `Function.prototype.toString` bulk masking** (`matchMedia`, `hasStorageAccess`, `getInstalledRelatedApps`, `userActivation` getter, `Notification.permission` getter, `screen.orientation` getter, `screenLeft`/`screenTop` getters). Without this, each new spoof was detectable via `.toString()` returning the override source instead of `[native code]`.
|
|
30
|
+
- **Masked per-instance WebRTC `onicecandidate` getter/setter + `addEventListener` wrap.** The bulk-mask block only runs once at injection; per-RTCPeerConnection closures created inside the factory weren't covered. A site doing `Object.getOwnPropertyDescriptor(pc, 'onicecandidate').get.toString()` could see the spoof.
|
|
31
|
+
- **Spoofed `navigator.productSub` + `vendorSub`** (UA-aware: `'20030107'` for Chrome/Safari/etc., `'20100101'` for Firefox; `vendorSub` always `''`). Companion legacy properties to the already-spoofed `vendor`/`product`. Common bot-detection signal since anti-detection libraries often spoof UA but forget these. `vendor`/`product` getters also added to the maskAsNative list (pre-existing oversight folded in).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **`validatePageForInjection`'s 1.5s race timer is now `unref`'d.** Last remaining Node-side `setTimeout` that wasn't unref'd; could hold the event loop alive for up to 1.5s past scan completion. All Node-side timers in `lib/fingerprint.js`, `lib/nettools.js`, and `lib/socks-relay.js` are now unref'd.
|
|
35
|
+
|
|
36
|
+
### Performance
|
|
37
|
+
- **Canvas noise application now cached per `HTMLCanvasElement`** via WeakMap. `toDataURL` and `toBlob` previously did a `getImageData` + `putImageData` round-trip on every call (~500k iterations for size-capped canvases) to bake noise into the export. Now the round-trip runs once per canvas; subsequent exports skip it (the canvas backing store still has the noised pixels from the first call). Trade-off: animated canvases that redraw between exports won't have new content re-noised — acceptable for the common fingerprinter pattern (single probe → single toDataURL).
|
|
38
|
+
|
|
5
39
|
## [3.0.2] - 2026-05-25
|
|
6
40
|
|
|
7
41
|
### Security
|
package/lib/fingerprint.js
CHANGED
|
@@ -248,7 +248,15 @@ async function validatePageForInjection(page, currentUrl, forceDebug) {
|
|
|
248
248
|
}
|
|
249
249
|
await Promise.race([
|
|
250
250
|
page.evaluate(() => document.readyState || 'loading'),
|
|
251
|
-
new Promise((_, reject) =>
|
|
251
|
+
new Promise((_, reject) => {
|
|
252
|
+
// unref so a still-pending race timer (page.evaluate won) doesn't
|
|
253
|
+
// hold the Node event loop alive for up to 1.5s past scan exit.
|
|
254
|
+
// Same pattern as the nettools timer unrefs in 83209d4 / 0c5d644;
|
|
255
|
+
// closes the last known node-side unref'd setTimeout in the
|
|
256
|
+
// fingerprint module.
|
|
257
|
+
const t = setTimeout(() => reject(new Error('Page evaluation timeout')), 1500);
|
|
258
|
+
if (typeof t.unref === 'function') t.unref();
|
|
259
|
+
})
|
|
252
260
|
]);
|
|
253
261
|
return true;
|
|
254
262
|
} catch (validationErr) {
|
|
@@ -506,6 +514,21 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
506
514
|
configurable: true,
|
|
507
515
|
enumerable: true
|
|
508
516
|
});
|
|
517
|
+
|
|
518
|
+
// Belt-and-suspenders: some older Chromium-driver setups leave a
|
|
519
|
+
// `webdriver` attribute on the <html> element (different surface
|
|
520
|
+
// from navigator.webdriver). DataDome and similar detectors
|
|
521
|
+
// check both. Modern Puppeteer with ignoreDefaultArgs:
|
|
522
|
+
// ['--enable-automation'] (set in nwss.js's createBrowser) doesn't
|
|
523
|
+
// emit this attribute, so this is defensive against rare edge
|
|
524
|
+
// cases. documentElement should exist by evaluateOnNewDocument
|
|
525
|
+
// time; wrapped in optional-chaining + try/catch for the contexts
|
|
526
|
+
// where it doesn't.
|
|
527
|
+
try {
|
|
528
|
+
if (document?.documentElement?.hasAttribute('webdriver')) {
|
|
529
|
+
document.documentElement.removeAttribute('webdriver');
|
|
530
|
+
}
|
|
531
|
+
} catch (_) {}
|
|
509
532
|
}, 'webdriver removal');
|
|
510
533
|
|
|
511
534
|
// Remove automation properties
|
|
@@ -814,16 +837,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
814
837
|
safeExecute(() => {
|
|
815
838
|
let vendor = 'Google Inc.';
|
|
816
839
|
let product = 'Gecko';
|
|
817
|
-
|
|
840
|
+
// productSub is a legacy Mozilla-era property: '20030107' for
|
|
841
|
+
// every browser EXCEPT Firefox (which uses '20100101'). Real
|
|
842
|
+
// Chrome / Safari / etc. all report '20030107'. Real Firefox
|
|
843
|
+
// reports '20100101'. Common bot-detection signal because
|
|
844
|
+
// anti-detection libraries often spoof UA but forget this.
|
|
845
|
+
// vendorSub is always '' across all browsers (legacy/unused).
|
|
846
|
+
let productSub = '20030107';
|
|
847
|
+
const vendorSub = '';
|
|
848
|
+
|
|
818
849
|
if (userAgent.includes('Firefox')) {
|
|
819
850
|
vendor = '';
|
|
851
|
+
productSub = '20100101';
|
|
820
852
|
} else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
|
|
821
853
|
vendor = 'Apple Computer, Inc.';
|
|
822
854
|
}
|
|
823
|
-
|
|
855
|
+
|
|
824
856
|
const vendorProps = {
|
|
825
857
|
vendor: { get: () => vendor },
|
|
826
|
-
product: { get: () => product }
|
|
858
|
+
product: { get: () => product },
|
|
859
|
+
productSub: { get: () => productSub },
|
|
860
|
+
vendorSub: { get: () => vendorSub }
|
|
827
861
|
};
|
|
828
862
|
spoofNavigatorProperties(navigator, vendorProps);
|
|
829
863
|
}, 'vendor/product spoofing');
|
|
@@ -1270,6 +1304,23 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1270
1304
|
if (window.Notification && Notification.requestPermission) {
|
|
1271
1305
|
Notification.requestPermission = () => Promise.resolve('default');
|
|
1272
1306
|
}
|
|
1307
|
+
|
|
1308
|
+
// Notification.permission STATIC PROPERTY — distinct from the
|
|
1309
|
+
// requestPermission() method patched above. DataDome and similar
|
|
1310
|
+
// detectors read Notification.permission directly. Real Chrome
|
|
1311
|
+
// with no granted permission returns 'default'; headless Chrome
|
|
1312
|
+
// returns 'denied'. Without this override the prior block (which
|
|
1313
|
+
// only patched the method) leaves the static property as a live
|
|
1314
|
+
// headless tell. Wrapped in try/catch because some embedded
|
|
1315
|
+
// contexts make Notification non-configurable.
|
|
1316
|
+
if (window.Notification) {
|
|
1317
|
+
try {
|
|
1318
|
+
Object.defineProperty(Notification, 'permission', {
|
|
1319
|
+
get: () => 'default',
|
|
1320
|
+
configurable: true
|
|
1321
|
+
});
|
|
1322
|
+
} catch (_) {}
|
|
1323
|
+
}
|
|
1273
1324
|
}, 'permissions API spoofing');
|
|
1274
1325
|
|
|
1275
1326
|
// Media Device Spoofing
|
|
@@ -1299,9 +1350,105 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1299
1350
|
const sY = Math.floor(Math.random() * 50) + 20;
|
|
1300
1351
|
Object.defineProperty(window, 'screenX', { get: () => sX });
|
|
1301
1352
|
Object.defineProperty(window, 'screenY', { get: () => sY });
|
|
1353
|
+
// screenLeft / screenTop are the legacy IE-era aliases for
|
|
1354
|
+
// screenX / screenY; real Chrome exposes them as identical
|
|
1355
|
+
// values. Headless / spoofers often leave them undefined or 0,
|
|
1356
|
+
// which is a tell since the screenX/Y patch above changed
|
|
1357
|
+
// those values to be non-zero. Mirror them explicitly so the
|
|
1358
|
+
// four properties stay consistent.
|
|
1359
|
+
Object.defineProperty(window, 'screenLeft', { get: () => sX });
|
|
1360
|
+
Object.defineProperty(window, 'screenTop', { get: () => sY });
|
|
1302
1361
|
}
|
|
1303
1362
|
}, 'window dimension spoofing');
|
|
1304
1363
|
|
|
1364
|
+
// Modern Chrome API stubs — these APIs exist in real desktop
|
|
1365
|
+
// Chrome but may be missing or wrong in headless. Detectors check
|
|
1366
|
+
// presence + minimal shape as a 'real browser?' signal. Each one
|
|
1367
|
+
// is wrapped individually so a failure on one doesn't break the
|
|
1368
|
+
// others, and the existence checks let real-Chrome paths
|
|
1369
|
+
// (where the property already exists with the right value) skip
|
|
1370
|
+
// the override entirely.
|
|
1371
|
+
safeExecute(() => {
|
|
1372
|
+
// Document.hasStorageAccess() — Storage Access API. Real Chrome
|
|
1373
|
+
// returns a Promise<boolean>; resolve with true to mimic the
|
|
1374
|
+
// common 'storage already accessible (top-level context)' state.
|
|
1375
|
+
if (document && typeof document.hasStorageAccess !== 'function') {
|
|
1376
|
+
try {
|
|
1377
|
+
Object.defineProperty(document, 'hasStorageAccess', {
|
|
1378
|
+
value: () => Promise.resolve(true),
|
|
1379
|
+
configurable: true,
|
|
1380
|
+
writable: true
|
|
1381
|
+
});
|
|
1382
|
+
} catch (_) {}
|
|
1383
|
+
}
|
|
1384
|
+
}, 'document.hasStorageAccess stub');
|
|
1385
|
+
|
|
1386
|
+
safeExecute(() => {
|
|
1387
|
+
// navigator.userActivation — UserActivation interface tracking
|
|
1388
|
+
// whether the page has had a user gesture. Real Chrome exposes
|
|
1389
|
+
// {hasBeenActive: bool, isActive: bool}. After interaction
|
|
1390
|
+
// simulation (interact / ghost-cursor) both should be true; we
|
|
1391
|
+
// default to true so a site checking before our synthetic
|
|
1392
|
+
// gestures fire still sees a 'user activated' page.
|
|
1393
|
+
if (navigator && !navigator.userActivation) {
|
|
1394
|
+
try {
|
|
1395
|
+
const userActivation = {
|
|
1396
|
+
hasBeenActive: true,
|
|
1397
|
+
isActive: true
|
|
1398
|
+
};
|
|
1399
|
+
Object.defineProperty(navigator, 'userActivation', {
|
|
1400
|
+
get: () => userActivation,
|
|
1401
|
+
configurable: true
|
|
1402
|
+
});
|
|
1403
|
+
} catch (_) {}
|
|
1404
|
+
}
|
|
1405
|
+
}, 'navigator.userActivation stub');
|
|
1406
|
+
|
|
1407
|
+
safeExecute(() => {
|
|
1408
|
+
// navigator.getInstalledRelatedApps() — Chrome-specific API
|
|
1409
|
+
// returning installed PWAs/native apps related to the current
|
|
1410
|
+
// origin. Real Chrome has this as a function; absence is a tell
|
|
1411
|
+
// for non-Chrome / headless. Return empty array (the common
|
|
1412
|
+
// no-related-apps case) — same as a fresh real-Chrome profile.
|
|
1413
|
+
if (navigator && typeof navigator.getInstalledRelatedApps !== 'function') {
|
|
1414
|
+
try {
|
|
1415
|
+
Object.defineProperty(navigator, 'getInstalledRelatedApps', {
|
|
1416
|
+
value: () => Promise.resolve([]),
|
|
1417
|
+
configurable: true,
|
|
1418
|
+
writable: true
|
|
1419
|
+
});
|
|
1420
|
+
} catch (_) {}
|
|
1421
|
+
}
|
|
1422
|
+
}, 'navigator.getInstalledRelatedApps stub');
|
|
1423
|
+
|
|
1424
|
+
// screen.orientation — modern browsers expose a ScreenOrientation
|
|
1425
|
+
// interface ({type, angle, addEventListener, lock, unlock, ...}).
|
|
1426
|
+
// Missing entirely in some headless contexts; presence + shape are
|
|
1427
|
+
// checked by DataDome and similar detectors as a "real browser"
|
|
1428
|
+
// signal. The values below mirror real desktop Chrome's landscape
|
|
1429
|
+
// primary orientation; lock()/unlock() match real-Chrome behaviour
|
|
1430
|
+
// when no fullscreen element is active (lock rejects with
|
|
1431
|
+
// NotSupportedError, unlock is a no-op). Object identity is stable
|
|
1432
|
+
// (hoisted out of the getter) so reference-equality checks pass.
|
|
1433
|
+
safeExecute(() => {
|
|
1434
|
+
if (!window.screen.orientation) {
|
|
1435
|
+
const orientation = {
|
|
1436
|
+
type: 'landscape-primary',
|
|
1437
|
+
angle: 0,
|
|
1438
|
+
onchange: null,
|
|
1439
|
+
addEventListener: () => {},
|
|
1440
|
+
removeEventListener: () => {},
|
|
1441
|
+
dispatchEvent: () => false,
|
|
1442
|
+
lock: () => Promise.reject(new Error('NotSupportedError')),
|
|
1443
|
+
unlock: () => {}
|
|
1444
|
+
};
|
|
1445
|
+
Object.defineProperty(window.screen, 'orientation', {
|
|
1446
|
+
get: () => orientation,
|
|
1447
|
+
configurable: true
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
}, 'screen.orientation spoofing');
|
|
1451
|
+
|
|
1305
1452
|
// navigator.connection — missing or incomplete in headless.
|
|
1306
1453
|
// Object literal hoisted out of the getter so identity is stable
|
|
1307
1454
|
// across reads. Real Chrome's NetworkInformation instance has
|
|
@@ -1447,50 +1594,143 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1447
1594
|
}
|
|
1448
1595
|
}, 'image loading obfuscation');
|
|
1449
1596
|
|
|
1450
|
-
// CSS Media Query Spoofing
|
|
1597
|
+
// CSS Media Query Spoofing — specifically the hardware-presence
|
|
1598
|
+
// queries that distinguish a real desktop browser from headless.
|
|
1599
|
+
//
|
|
1600
|
+
// Headless Chrome reports NO hover device and NO fine pointer
|
|
1601
|
+
// (there's no mouse hardware attached). matchMedia('(any-hover:
|
|
1602
|
+
// hover)') returns matches=false in headless, matches=true in
|
|
1603
|
+
// real desktop. CreepJS and DataDome both probe these queries
|
|
1604
|
+
// explicitly as a hard binary 'is this real browser hardware?'
|
|
1605
|
+
// signal — one of the biggest single contributors to CreepJS's
|
|
1606
|
+
// headless-detection score.
|
|
1451
1607
|
//
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1608
|
+
// Pass-through for all OTHER queries (max-width, prefers-color-
|
|
1609
|
+
// scheme, etc.) so legitimate responsive-design checks still work.
|
|
1610
|
+
// Screen-dimension queries naturally reflect the already-spoofed
|
|
1611
|
+
// screen.width/height — no extra handling needed for those.
|
|
1612
|
+
safeExecute(() => {
|
|
1613
|
+
if (typeof window.matchMedia !== 'function') return;
|
|
1614
|
+
const origMatchMedia = window.matchMedia;
|
|
1615
|
+
// Make a fake MediaQueryList that quacks like the real thing.
|
|
1616
|
+
// Real Chrome's MediaQueryList implements EventTarget, so the
|
|
1617
|
+
// listener methods need to exist as callable no-ops.
|
|
1618
|
+
const fakeMql = (query, matches) => ({
|
|
1619
|
+
matches,
|
|
1620
|
+
media: query,
|
|
1621
|
+
onchange: null,
|
|
1622
|
+
addEventListener: () => {},
|
|
1623
|
+
removeEventListener: () => {},
|
|
1624
|
+
addListener: () => {}, // deprecated alias but still present in Chrome
|
|
1625
|
+
removeListener: () => {},
|
|
1626
|
+
dispatchEvent: () => false
|
|
1627
|
+
});
|
|
1628
|
+
window.matchMedia = function(query) {
|
|
1629
|
+
const q = String(query || '').toLowerCase();
|
|
1630
|
+
// Spoof to "yes, mouse + hover hardware present" — the real
|
|
1631
|
+
// desktop answer. Match both `(any-hover: hover)` and the
|
|
1632
|
+
// legacy `(hover: hover)`; same for pointer.
|
|
1633
|
+
if (q.includes('any-hover: hover') || q.includes('hover: hover')) {
|
|
1634
|
+
return fakeMql(query, true);
|
|
1635
|
+
}
|
|
1636
|
+
if (q.includes('any-hover: none') || q.includes('hover: none')) {
|
|
1637
|
+
return fakeMql(query, false);
|
|
1638
|
+
}
|
|
1639
|
+
if (q.includes('any-pointer: fine') || q.includes('pointer: fine')) {
|
|
1640
|
+
return fakeMql(query, true);
|
|
1641
|
+
}
|
|
1642
|
+
if (q.includes('any-pointer: none') || q.includes('pointer: none')) {
|
|
1643
|
+
return fakeMql(query, false);
|
|
1644
|
+
}
|
|
1645
|
+
if (q.includes('any-pointer: coarse') || q.includes('pointer: coarse')) {
|
|
1646
|
+
return fakeMql(query, false); // desktop = no coarse touch pointer
|
|
1647
|
+
}
|
|
1648
|
+
// Anything else falls through to real matchMedia (responsive
|
|
1649
|
+
// queries, color-scheme, reduced-motion, etc. all behave normally).
|
|
1650
|
+
return origMatchMedia.call(this, query);
|
|
1651
|
+
};
|
|
1652
|
+
}, 'matchMedia hover/pointer spoofing');
|
|
1455
1653
|
|
|
1456
1654
|
// Enhanced WebRTC Spoofing
|
|
1457
1655
|
//
|
|
1656
|
+
// Previously stripped only RFC1918 private IPs from ICE candidates,
|
|
1657
|
+
// which leaked the STUN-discovered public IP (`typ srflx`) — visible
|
|
1658
|
+
// in CreepJS's WebRTC section and trivially also probed by DataDome
|
|
1659
|
+
// and other modern fingerprint suites. STUN traffic is UDP, so it
|
|
1660
|
+
// bypasses the SOCKS5 proxy entirely, meaning the real host IP
|
|
1661
|
+
// reaches the fingerprinter regardless of proxy config.
|
|
1662
|
+
//
|
|
1663
|
+
// Fix: strip EVERY ICE candidate (host / srflx / prflx / relay /
|
|
1664
|
+
// mDNS). The scanner never needs functional WebRTC peer connections,
|
|
1665
|
+
// so complete suppression is the right trade-off — the page sees
|
|
1666
|
+
// ICE gathering complete with zero candidates, indistinguishable
|
|
1667
|
+
// from a real browser with no usable network interfaces. The
|
|
1668
|
+
// null-candidate sentinel (end-of-gathering signal) still fires so
|
|
1669
|
+
// calling code that awaits ICE-complete doesn't hang.
|
|
1458
1670
|
safeExecute(() => {
|
|
1459
1671
|
if (window.RTCPeerConnection) {
|
|
1460
1672
|
const OriginalRTC = window.RTCPeerConnection;
|
|
1673
|
+
// Filter helper hoisted so both addEventListener and the
|
|
1674
|
+
// property-handler paths apply identical filtering.
|
|
1675
|
+
const stripCandidate = (event) => !event.candidate;
|
|
1676
|
+
|
|
1461
1677
|
window.RTCPeerConnection = function(...args) {
|
|
1462
1678
|
const pc = new OriginalRTC(...args);
|
|
1463
|
-
|
|
1464
|
-
// Intercept onicecandidate to strip local IP addresses
|
|
1679
|
+
|
|
1465
1680
|
const origAddEventListener = pc.addEventListener.bind(pc);
|
|
1466
|
-
|
|
1681
|
+
// Named function (not anonymous) so maskAsNative gets a sensible
|
|
1682
|
+
// 'addEventListener' name when reporting [native code]. The
|
|
1683
|
+
// bulk-mask block at end of applyFingerprintProtection masks
|
|
1684
|
+
// window-level functions ONCE; per-instance functions created
|
|
1685
|
+
// inside this factory (one new closure per `new RTCPeerConnection()`)
|
|
1686
|
+
// would slip through unmasked — detectable via
|
|
1687
|
+
// pc.addEventListener.toString(). Mask each per-instance to
|
|
1688
|
+
// close that.
|
|
1689
|
+
const addEventListenerWrap = function(type, listener, ...rest) {
|
|
1467
1690
|
if (type === 'icecandidate') {
|
|
1468
1691
|
const wrappedListener = function(event) {
|
|
1469
|
-
if (event.
|
|
1470
|
-
// Strip candidates containing local/private IPs
|
|
1471
|
-
const c = event.candidate.candidate;
|
|
1472
|
-
if (c.includes('.local') || /(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\./.test(c)) {
|
|
1473
|
-
return; // suppress local IP candidates
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
listener.call(this, event);
|
|
1692
|
+
if (stripCandidate(event)) listener.call(this, event);
|
|
1477
1693
|
};
|
|
1478
1694
|
return origAddEventListener(type, wrappedListener, ...rest);
|
|
1479
1695
|
}
|
|
1480
1696
|
return origAddEventListener(type, listener, ...rest);
|
|
1481
1697
|
};
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1698
|
+
maskAsNative(addEventListenerWrap, 'addEventListener');
|
|
1699
|
+
pc.addEventListener = addEventListenerWrap;
|
|
1700
|
+
|
|
1701
|
+
// Property-based handler. Previously this just stored the
|
|
1702
|
+
// handler in a local variable and never wired it up — pages
|
|
1703
|
+
// setting `pc.onicecandidate = fn` got NO events at all,
|
|
1704
|
+
// detectable as a mismatch vs addEventListener (which DID
|
|
1705
|
+
// fire). Now we forward the wrapped handler to the underlying
|
|
1706
|
+
// setter so both paths behave identically: the page sees only
|
|
1707
|
+
// the null-candidate sentinel. Both get/set extracted to named
|
|
1708
|
+
// consts so maskAsNative can wrap them — without this, a probe
|
|
1709
|
+
// doing `Object.getOwnPropertyDescriptor(pc, 'onicecandidate').get.toString()`
|
|
1710
|
+
// would see the arrow-function source instead of [native code].
|
|
1711
|
+
let _userHandler = null;
|
|
1712
|
+
const getOnIceCandidate = function() { return _userHandler; };
|
|
1713
|
+
const setOnIceCandidate = function(handler) {
|
|
1714
|
+
_userHandler = handler;
|
|
1715
|
+
// Use the prototype setter so we don't infinite-loop on
|
|
1716
|
+
// our own defineProperty. The wrapped handler applies
|
|
1717
|
+
// the same filter as addEventListener.
|
|
1718
|
+
const proto = Object.getPrototypeOf(pc);
|
|
1719
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'onicecandidate');
|
|
1720
|
+
if (desc && desc.set) {
|
|
1721
|
+
desc.set.call(pc, typeof handler === 'function'
|
|
1722
|
+
? function(event) { if (stripCandidate(event)) handler.call(this, event); }
|
|
1723
|
+
: handler);
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
maskAsNative(getOnIceCandidate, 'get onicecandidate');
|
|
1727
|
+
maskAsNative(setOnIceCandidate, 'set onicecandidate');
|
|
1485
1728
|
Object.defineProperty(pc, 'onicecandidate', {
|
|
1486
|
-
get:
|
|
1487
|
-
set:
|
|
1488
|
-
_onicecandidateHandler = handler;
|
|
1489
|
-
// No-op — the addEventListener wrapper above handles filtering
|
|
1490
|
-
},
|
|
1729
|
+
get: getOnIceCandidate,
|
|
1730
|
+
set: setOnIceCandidate,
|
|
1491
1731
|
configurable: true
|
|
1492
1732
|
});
|
|
1493
|
-
|
|
1733
|
+
|
|
1494
1734
|
return pc;
|
|
1495
1735
|
};
|
|
1496
1736
|
Object.setPrototypeOf(window.RTCPeerConnection, OriginalRTC);
|
|
@@ -1635,29 +1875,46 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1635
1875
|
return imageData;
|
|
1636
1876
|
};
|
|
1637
1877
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1878
|
+
// Cache of canvases already noised in their current bytes. toDataURL
|
|
1879
|
+
// and toBlob both do a getImageData + putImageData round-trip to
|
|
1880
|
+
// bake noise into the canvas before the actual export. Each
|
|
1881
|
+
// round-trip is O(width × height) -- ~2M iterations for a 1920x1080
|
|
1882
|
+
// canvas, capped at 500k pixels via the size guard below.
|
|
1883
|
+
//
|
|
1884
|
+
// Repeated calls on the SAME canvas don't need re-noising: the
|
|
1885
|
+
// first call already wrote noised pixel data into the canvas via
|
|
1886
|
+
// putImageData. Skip the round-trip for subsequent calls; the
|
|
1887
|
+
// canvas backing store still has the noised content. Trade-off:
|
|
1888
|
+
// if a page redraws between calls (canvas.drawImage, fillRect,
|
|
1889
|
+
// etc.), the new content won't be re-noised. Acceptable for the
|
|
1890
|
+
// common fingerprinter pattern (draw probe content -> single
|
|
1891
|
+
// toDataURL -> compare to known signature); pathological for
|
|
1892
|
+
// animated canvases that re-toDataURL per frame. WeakMap so a
|
|
1893
|
+
// GC'd canvas drops its noise-cache entry automatically.
|
|
1894
|
+
const noisedCanvases = new WeakMap();
|
|
1895
|
+
|
|
1896
|
+
const applyCanvasNoise = function(canvas) {
|
|
1897
|
+
if (noisedCanvases.has(canvas)) return;
|
|
1641
1898
|
try {
|
|
1642
|
-
const ctx =
|
|
1643
|
-
if (ctx &&
|
|
1644
|
-
const imageData = ctx.getImageData(0, 0,
|
|
1899
|
+
const ctx = canvas.getContext('2d');
|
|
1900
|
+
if (ctx && canvas.width > 0 && canvas.height > 0 && canvas.width * canvas.height < 500000) {
|
|
1901
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
1645
1902
|
ctx.putImageData(imageData, 0, 0);
|
|
1903
|
+
noisedCanvases.set(canvas, true);
|
|
1646
1904
|
}
|
|
1647
1905
|
} catch (e) {} // WebGL or other context — skip
|
|
1906
|
+
};
|
|
1907
|
+
|
|
1908
|
+
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
|
1909
|
+
HTMLCanvasElement.prototype.toDataURL = function(...args) {
|
|
1910
|
+
applyCanvasNoise(this);
|
|
1648
1911
|
return originalToDataURL.apply(this, args);
|
|
1649
1912
|
};
|
|
1650
1913
|
|
|
1651
1914
|
const originalToBlob = HTMLCanvasElement.prototype.toBlob;
|
|
1652
1915
|
if (originalToBlob) {
|
|
1653
1916
|
HTMLCanvasElement.prototype.toBlob = function(callback, ...args) {
|
|
1654
|
-
|
|
1655
|
-
const ctx = this.getContext('2d');
|
|
1656
|
-
if (ctx && this.width > 0 && this.height > 0 && this.width * this.height < 500000) {
|
|
1657
|
-
const imageData = ctx.getImageData(0, 0, this.width, this.height);
|
|
1658
|
-
ctx.putImageData(imageData, 0, 0);
|
|
1659
|
-
}
|
|
1660
|
-
} catch (e) {}
|
|
1917
|
+
applyCanvasNoise(this);
|
|
1661
1918
|
return originalToBlob.call(this, callback, ...args);
|
|
1662
1919
|
};
|
|
1663
1920
|
}
|
|
@@ -1856,10 +2113,17 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1856
2113
|
if (typeof window.fetch === 'function') maskAsNative(window.fetch, 'fetch');
|
|
1857
2114
|
if (typeof window.PointerEvent === 'function') maskAsNative(window.PointerEvent, 'PointerEvent');
|
|
1858
2115
|
if (typeof console.debug === 'function') maskAsNative(console.debug, 'debug');
|
|
2116
|
+
// Methods added in this session — same toString-tampering concern.
|
|
2117
|
+
if (typeof window.matchMedia === 'function') maskAsNative(window.matchMedia, 'matchMedia');
|
|
2118
|
+
if (typeof document.hasStorageAccess === 'function') maskAsNative(document.hasStorageAccess, 'hasStorageAccess');
|
|
2119
|
+
if (typeof navigator.getInstalledRelatedApps === 'function') maskAsNative(navigator.getInstalledRelatedApps, 'getInstalledRelatedApps');
|
|
1859
2120
|
|
|
1860
2121
|
// Mask property getters on navigator
|
|
2122
|
+
// 'userActivation' added in this session; 'productSub'/'vendorSub'
|
|
2123
|
+
// are this commit's additions alongside the existing vendor/product.
|
|
1861
2124
|
const navProps = ['userAgentData', 'connection', 'pdfViewerEnabled', 'webdriver',
|
|
1862
|
-
'hardwareConcurrency', 'deviceMemory', 'platform', 'maxTouchPoints'
|
|
2125
|
+
'hardwareConcurrency', 'deviceMemory', 'platform', 'maxTouchPoints',
|
|
2126
|
+
'userActivation', 'vendor', 'product', 'productSub', 'vendorSub'];
|
|
1863
2127
|
navProps.forEach(prop => {
|
|
1864
2128
|
// Check both instance and prototype (webdriver lives on prototype)
|
|
1865
2129
|
const desc = Object.getOwnPropertyDescriptor(navigator, prop)
|
|
@@ -1867,8 +2131,18 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1867
2131
|
if (desc?.get) maskAsNative(desc.get, 'get ' + prop);
|
|
1868
2132
|
});
|
|
1869
2133
|
|
|
1870
|
-
// Mask
|
|
1871
|
-
|
|
2134
|
+
// Mask Notification.permission getter (static property, added this session).
|
|
2135
|
+
if (typeof Notification !== 'undefined') {
|
|
2136
|
+
const npDesc = Object.getOwnPropertyDescriptor(Notification, 'permission');
|
|
2137
|
+
if (npDesc?.get) maskAsNative(npDesc.get, 'get permission');
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// Mask screen.orientation getter (added this session).
|
|
2141
|
+
const orientDesc = Object.getOwnPropertyDescriptor(window.screen, 'orientation');
|
|
2142
|
+
if (orientDesc?.get) maskAsNative(orientDesc.get, 'get orientation');
|
|
2143
|
+
|
|
2144
|
+
// Mask window property getters — screenLeft/Top added this session.
|
|
2145
|
+
['screenX', 'screenY', 'screenLeft', 'screenTop', 'outerWidth', 'outerHeight'].forEach(prop => {
|
|
1872
2146
|
const desc = Object.getOwnPropertyDescriptor(window, prop);
|
|
1873
2147
|
if (desc?.get) maskAsNative(desc.get, 'get ' + prop);
|
|
1874
2148
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fanboynz/network-scanner",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
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": {
|
package/scripts/test-stealth.js
CHANGED
|
@@ -70,7 +70,7 @@ const TARGETS = [
|
|
|
70
70
|
extract: async (page) => {
|
|
71
71
|
return await page.evaluate(() => {
|
|
72
72
|
const cells = Array.from(document.querySelectorAll('td'));
|
|
73
|
-
const out = { passed: 0, failed: 0, warn: 0, total: 0, failures: [] };
|
|
73
|
+
const out = { passed: 0, failed: 0, warn: 0, total: 0, failures: [], warnings: [] };
|
|
74
74
|
for (const c of cells) {
|
|
75
75
|
const cls = c.className || '';
|
|
76
76
|
if (cls.includes('passed')) { out.passed++; out.total++; }
|
|
@@ -81,7 +81,14 @@ const TARGETS = [
|
|
|
81
81
|
const label = row?.querySelector('td')?.textContent?.trim() || '?';
|
|
82
82
|
out.failures.push(label);
|
|
83
83
|
}
|
|
84
|
-
else if (cls.includes('warn')) {
|
|
84
|
+
else if (cls.includes('warn')) {
|
|
85
|
+
out.warn++; out.total++;
|
|
86
|
+
// Capture warn-row labels too so a soft regression (cell moving
|
|
87
|
+
// passed -> warn) is debuggable without --headful.
|
|
88
|
+
const row = c.closest('tr');
|
|
89
|
+
const label = row?.querySelector('td')?.textContent?.trim() || '?';
|
|
90
|
+
out.warnings.push(label);
|
|
91
|
+
}
|
|
85
92
|
}
|
|
86
93
|
return out;
|
|
87
94
|
});
|
|
@@ -97,15 +104,29 @@ const TARGETS = [
|
|
|
97
104
|
await new Promise(r => setTimeout(r, 8000)); // give async tests time
|
|
98
105
|
return await page.evaluate(() => {
|
|
99
106
|
const text = document.body.innerText || '';
|
|
100
|
-
// CreepJS
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
// CreepJS's actual stealth-relevant outputs are in a "Headless"
|
|
108
|
+
// section as percentages (e.g. "67% headless", "40% stealth",
|
|
109
|
+
// "44% like headless"), not the "Trust Score" label the old
|
|
110
|
+
// regex expected. Engine identification comes from "chromium:
|
|
111
|
+
// true/false" in the same block. Lower headless % and higher
|
|
112
|
+
// stealth % are better for evasion.
|
|
113
|
+
const headlessMatch = text.match(/(\d+(?:\.\d+)?)\s*%\s+headless\b/i);
|
|
114
|
+
const likeHeadlessMatch = text.match(/(\d+(?:\.\d+)?)\s*%\s+like\s+headless/i);
|
|
115
|
+
const stealthMatch = text.match(/(\d+(?:\.\d+)?)\s*%\s+stealth\b/i);
|
|
116
|
+
const chromiumMatch = text.match(/chromium\s*:\s*(true|false)/i);
|
|
117
|
+
// FP ID is CreepJS's stable fingerprint hash — same value across
|
|
118
|
+
// reloads if the fingerprint is unchanged; lets you A/B before
|
|
119
|
+
// and after a spoof change.
|
|
120
|
+
const fpIdMatch = text.match(/FP\s*ID\s*:?\s*([0-9a-f]{16,})/i);
|
|
104
121
|
return {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
headlessPct: headlessMatch ? parseFloat(headlessMatch[1]) : null,
|
|
123
|
+
likeHeadlessPct: likeHeadlessMatch ? parseFloat(likeHeadlessMatch[1]) : null,
|
|
124
|
+
stealthPct: stealthMatch ? parseFloat(stealthMatch[1]) : null,
|
|
125
|
+
isChromium: chromiumMatch ? chromiumMatch[1] === 'true' : null,
|
|
126
|
+
fpId: fpIdMatch ? fpIdMatch[1].slice(0, 16) : null,
|
|
127
|
+
// Larger excerpt (40 lines, up to 2KB) so a future UI rotation
|
|
128
|
+
// is debuggable from the output without --headful.
|
|
129
|
+
excerpt: text.split('\n').slice(0, 40).join('\n').slice(0, 2000)
|
|
109
130
|
};
|
|
110
131
|
});
|
|
111
132
|
}
|
|
@@ -165,10 +186,15 @@ function formatResult(target, result) {
|
|
|
165
186
|
if (result.failures.length) {
|
|
166
187
|
lines.push(` failure rows: ${result.failures.slice(0, 10).join(', ')}${result.failures.length > 10 ? ` ... +${result.failures.length - 10} more` : ''}`);
|
|
167
188
|
}
|
|
189
|
+
if (result.warnings && result.warnings.length) {
|
|
190
|
+
lines.push(` warn rows: ${result.warnings.slice(0, 10).join(', ')}${result.warnings.length > 10 ? ` ... +${result.warnings.length - 10} more` : ''}`);
|
|
191
|
+
}
|
|
168
192
|
} else if (target.name === 'creepjs') {
|
|
169
|
-
lines.push(`
|
|
170
|
-
lines.push(`
|
|
171
|
-
lines.push(`
|
|
193
|
+
lines.push(` FP ID: ${result.fpId ?? 'n/a'} (stable across reloads if fingerprint unchanged)`);
|
|
194
|
+
lines.push(` engine chromium: ${result.isChromium ?? 'n/a'}`);
|
|
195
|
+
lines.push(` headless score: ${result.headlessPct ?? 'n/a'}% (lower = better; 0% = real browser)`);
|
|
196
|
+
lines.push(` like-headless: ${result.likeHeadlessPct ?? 'n/a'}% (lower = better; soft headless signals)`);
|
|
197
|
+
lines.push(` stealth score: ${result.stealthPct ?? 'n/a'}% (lower = better; % likely to be using anti-detection tooling)`);
|
|
172
198
|
if (result.excerpt) lines.push(` excerpt:\n ${result.excerpt.split('\n').join('\n ')}`);
|
|
173
199
|
} else if (target.name === 'browserleaks') {
|
|
174
200
|
for (const [k, v] of Object.entries(result)) {
|