@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 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
@@ -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) => setTimeout(() => reject(new Error('Page evaluation timeout')), 1500))
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
- // CSS media query — pass through normally. Screen dimensions are already spoofed
1453
- // so matchMedia results will naturally reflect the spoofed values.
1454
- // No modification needed here.
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
- pc.addEventListener = function(type, listener, ...rest) {
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.candidate && event.candidate.candidate) {
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
- // Also intercept the property-based handler
1484
- let _onicecandidateHandler = null;
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: () => _onicecandidateHandler,
1487
- set: (handler) => {
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
- const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
1639
- HTMLCanvasElement.prototype.toDataURL = function(...args) {
1640
- // Apply same deterministic noise by reading through spoofed getImageData
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 = this.getContext('2d');
1643
- if (ctx && this.width > 0 && this.height > 0 && this.width * this.height < 500000) {
1644
- const imageData = ctx.getImageData(0, 0, this.width, this.height);
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
- try {
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 window property getters
1871
- ['screenX', 'screenY', 'outerWidth', 'outerHeight'].forEach(prop => {
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.2",
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": {
@@ -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')) { out.warn++; out.total++; }
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 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);
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
- 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)
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(` trust score: ${result.trustScore ?? 'n/a'}%`);
170
- lines.push(` lies detected: ${result.lies ?? 'n/a'}`);
171
- lines.push(` bot flagged: ${result.botDetected ?? 'n/a'}`);
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)) {