@fanboynz/network-scanner 3.0.1 → 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.
@@ -162,7 +162,10 @@ jobs:
162
162
  uses: softprops/action-gh-release@v2
163
163
  with:
164
164
  tag_name: v${{ steps.version.outputs.version }}
165
- name: v${{ steps.version.outputs.version }}
165
+ # Date suffix matches the convention used by the backfilled
166
+ # historical releases (v2.0.10..v2.0.66) so the Releases page
167
+ # shows the release date inline without a click-through.
168
+ name: v${{ steps.version.outputs.version }} (${{ steps.version.outputs.date }})
166
169
  body: ${{ inputs.release_notes_source == 'changelog' && steps.changelog_notes.outputs.notes || steps.manual_notes.outputs.notes }}
167
170
  draft: false
168
171
  prerelease: ${{ inputs.prerelease }}
package/CHANGELOG.md CHANGED
@@ -2,6 +2,74 @@
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
+
39
+ ## [3.0.2] - 2026-05-25
40
+
41
+ ### Security
42
+ - **Credentials redacted in `lib/proxy.js` 'Invalid proxy URL' warn** — `getProxyArgs` echoed the raw user-configured `proxyUrl` when parseProxyUrl returned null. For a URL like `socks5://user:pass@host:port` that fails parse (mistyped protocol, port out of range, etc.) this emitted the full credentials to stderr. Regex-strips the `user:pass@` segment (handles both scheme-prefixed and bare host:port forms) before logging. Same redaction policy as `getProxyInfo()` and the socks-relay logs already fixed in 3.0.1. The new port-range validation in this release expanded the trigger surface (one more parse-failure path) which made me find the leak.
43
+ - **`applyProxyAuth` debug log redacted** — the `Auth set for USER@host:port` debug-only log line emitted the raw username. Now `[redacted]@host:port`. Same leak class as above, third site of the same kind.
44
+
45
+ ### Added
46
+ - **`scripts/test-stealth.js --format=json`** (already shipped in 3.0.1, listed here only because the harness gained a real consumer via the next item) — `getRelayStats()` exposed from `lib/socks-relay.js`, returning `[{key, port, activeConnections, errors}]` per active relay (`key` with the username segment stripped for safety, IPv6-aware). Diagnostic surface for answering "is the proxy slow because the upstream is saturated or because the scan is opening too many parallel tunnels?" without enabling `forceDebug`.
47
+ - **`delay_uncapped: true` site-config flag** — lifts the 2s post-networkidle delay cap; honors the configured `delay` up to half the per-URL timeout. Targets sites with setTimeout-deferred lazy ad/tracker loaders (weather.com / cbssports.com class) where late requests fire well past the standard window. Default behavior unchanged (still 2s) so fast sites stay fast.
48
+
49
+ ### Fixed
50
+ - **Race: late-completing dig/whois validations were orphaned.** Per-URL async nettools handlers were scheduled via fire-and-forget `setImmediate(() => netToolsHandler(...))`; if the handler's full async chain (dig spawn + match check + addMatchedDomain) resolved AFTER the result snapshot ran, the addMatchedDomain call landed in a Set that was no longer referenced by any in-flight result. Most visible symptom: domains appearing in the end-of-scan "Fresh dig:" list with no corresponding rule in the output. Now tracked via `trackNetToolsHandler` (closure over per-URL `pendingNetTools[]`) and drained via `drainPendingNetTools()` with a 3s hard cap (`TIMEOUTS.NETTOOLS_DRAIN_TIMEOUT`), called BEFORE `formatRules` at all three snapshot sites (dry-run, success, partial-success/catch path). All three setImmediate call sites (popup observer, main request handler, secondary request handler) migrated.
51
+ - **Race: scan-exit hang up to ~100s when a dig/whois lookup hung.** Four `setTimeout`s in `lib/nettools.js` (outer exec timer, overall 65s timer, whois progressive retry delay up to ~30s, whois server-switch delay ~8s) were not `unref`'d, so a genuinely-hung lookup that survived the new 3s drain could hold the Node event loop alive for the remainder. All four now `unref`'d with defensive `typeof timer.unref === 'function'` guards; the previously-unref'd inner SIGKILL tail-timer makes 5/5 setTimeout sites in the module now safe for scan-exit. Natural-completion paths still `clearTimeout` on resolution, so this only affects the hung-process case.
52
+ - **`parseProxyUrl` accepted ports > 65535.** Now rejects ports outside 1-65535 at parse time, surfacing misconfiguration immediately instead of passing an invalid value to Chromium and getting an opaque downstream error.
53
+ - **`@version 1.1.0` JSDoc** in `lib/proxy.js` was stale (const said `1.2.0`). Aligned to 1.2.0; the const + export then went away in the export trim — see Improved.
54
+ - **Site-config `delay` field was a no-op.** `nwss.js` per-URL handler hardcoded `const delayMs = DEFAULT_DELAY` regardless of `siteConfig.delay`. Now reads `siteConfig.delay || DEFAULT_DELAY`. Visible only with the new `delay_uncapped: true` flag (without it, the configured value is still capped at 2s as before).
55
+ - **"Something went wrong when opening your profile" popup in `--keep-open` headful mode.** `--disable-sync` was conditionally dropped when `--keep-open` was set, which let Chrome's sync subsystem initialise against our temp `userDataDir` (which has no real profile), error out, and pop a modal that blocked the page until dismissed. Three-flag fix: `--disable-sync` is now always-on (was the only one of five `--keep-open`-conditional flags actually causing user-visible breakage), plus `--allow-browser-signin=false` and `AccountConsistencyMirror,AccountConsistencyDice` appended to the existing `--disable-features=` list as defence in depth across Chromium's multiple account-subsystem entry points. The other four conditional-on-keep-open flags (`--disable-component-extensions-with-background-pages`, `--disable-component-update`, `--disable-background-networking`, `--disable-extensions`) stay conditional so user-loaded extensions and live inspection still work normally.
56
+ - **Race: `socks-relay.ensureRelay` concurrent-init created orphan servers.** Two concurrent callers for the same upstream both passed the `_relays.get(key)` check, both created `net.Server` listeners, both raced to `_relays.set` — second overwrote first, first server was orphaned (listening forever, never closed by `closeAllRelays`). Not triggered by current usage (proxy.js's `prepareSocksRelays` uses a sequential await loop) but a latent bug for future parallel-init paths. Fix: singleflight via new `_pendingRelays` Map; second caller for an in-flight upstream rides the existing promise. Cleanup uses `.finally()` on the returned promise (not try/finally inside the IIFE) so a hypothetical sync-throw in the init body can't leave a permanent rejected entry in `_pendingRelays`. Mirrors the `pendingDigLookups`/`pendingWhoisLookups` pattern in `lib/nettools.js`.
57
+ - **Race: handshake watchdog firing during upstream connect orphaned the upstream socket.** `HANDSHAKE_TIMEOUT_MS = 10000` vs `SocksClient.createConnection` timeout = `20000` left a 10-second window where the watchdog could fire mid-await, destroy the client, and set `settled = true`. When the upstream connect then resolved into a fresh socket, the subsequent `cleanup()` short-circuited via the settled guard, leaving an open TCP connection to the upstream that was never destroyed — held alive until OS-level timeout or remote close. Fix: disarm the watchdog at the `phase = 'connecting'` transition (client has completed its part of the handshake; `SocksClient`'s own 20s timeout covers the upstream connect), plus a defence-in-depth `if (settled) destroy + return` after `upstreamSock = info.socket` for any other path that could call cleanup before upstreamSock registers.
58
+ - **Race: `closeAllRelays` didn't wait for in-flight `ensureRelay` inits.** A relay whose `listen()` completed AFTER `closeAllRelays` snapshotted `_relays` landed in `_relays` unowned by the close pass — leaked until next call or process exit. Pre-existing, more visible after `_pendingRelays` became a separate Map for the singleflight. Fix: `await Promise.allSettled(Array.from(_pendingRelays.values()))` at the head of `closeAllRelays` so the snapshot is guaranteed-complete. `allSettled` (not `all`) because rejected inits have already cleaned up their `_pendingRelays` entries via `.finally()`.
59
+
60
+ ### Improved
61
+ - **socks-relay handshake buffer cap** (`MAX_HANDSHAKE_BYTES = 4096`) on pre-piping growth. Prior code absorbed arbitrary bytes for the full 10s handshake watchdog window, letting a hostile/buggy local process pin memory by drip-feeding garbage. Sends a protocol-appropriate failure reply per phase before closing.
62
+ - **socks-relay TCP keep-alive on upstream socket** (`setKeepAlive(true, 60000)`). Catches silently-dead upstreams (NAT timeout, mobile-tower drop, proxy crash without FIN/RST) in ~12 minutes (60s idle + kernel-default 9 × 75s probes) instead of the Linux default ~2 hours. Comment is honest about the kernel-default probe math — `60000` is `TCP_KEEPIDLE` only, not the full detection time.
63
+ - **socks-relay auth-misconfig warn** — `ensureRelay` warns once per unique upstream when `username && !password`, since RFC 1929 auth will almost certainly fail. Surfaces the misconfiguration at relay start instead of as opaque per-request failures inside `forceDebug`-gated logs.
64
+ - **socks-relay `server.maxConnections = 256` cap** per relay. Sheds excess Chromium connections at the TCP-accept layer (where HTTP retry handles them cleanly) instead of letting all N tunnels open to the upstream and have the provider silently drop past-quota ones — which looks to the scan like random missed requests.
65
+ - **socks-relay per-relay error counter** tracked in `relayEntry.errors`, bumped on `SocksClient.createConnection` failures, surfaced via `getRelayStats()` as the `errors` field. Lets a post-scan reader see "X of N upstream connects failed" without re-running with forceDebug.
66
+ - **socks-relay graceful drain on `closeAllRelays`** — `DRAIN_TIMEOUT_MS = 2000` window via `Promise.race(closePromise, drainTimeout)` for in-flight tunnels to flush their last response bytes into Chromium / Puppeteer. Stragglers past 2s get force-destroyed (server.close callback then fires immediately). SIGINT mid-scan no longer amputates in-flight responses, but a hung tunnel can't block exit beyond 2s. Drain timer `unref`'d so it doesn't hold the event loop open when the close-promise wins the race.
67
+ - **`lib/proxy.js` exports trimmed 12 → 8** — removed `getModuleInfo`, `PROXY_MODULE_VERSION`, `SUPPORTED_PROTOCOLS`, `getConfiguredProxy` (zero external callers in each case, grep-verified). Mirrors the same trim already done in `lib/cloudflare.js`. `SUPPORTED_PROTOCOLS` and `getConfiguredProxy` stay as module-local since they're used internally.
68
+ - **`lib/proxy.js` code cleanup** — two `require('./socks-relay')` calls consolidated into one destructured import (with `closeAllRelays` renamed inline), `net` module require hoisted from `testProxy()` body to top of file, `applyProxyAuth` JSDoc enumerates the 5 distinct `false` return scenarios (caller treating false as "auth failed" would incorrectly retry on the SOCKS5 → relay handles it case).
69
+
70
+ ### CI
71
+ - **GitHub Release names now include date suffix** (`v3.0.2 (YYYY-MM-DD)`), matching the convention used by the backfilled v2.0.10 through v2.0.66 releases. Auto-applied via the already-computed `steps.version.outputs.date` in `softprops/action-gh-release`.
72
+
5
73
  ## [3.0.1] - 2026-05-24
6
74
 
7
75
  ### 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/lib/nettools.js CHANGED
@@ -418,6 +418,12 @@ function execFileWithTimeout(cmd, args, timeout = 10000) {
418
418
 
419
419
  reject(new Error(`Command timeout after ${timeout}ms: ${cmd} ${args.join(' ')}`));
420
420
  }, timeout);
421
+ // unref the outer timeout too — a hung dig/whois firing AFTER the
422
+ // per-URL drain (3s cap) already returned would otherwise hold the
423
+ // event loop alive for up to `timeout` (5-10s) on scan exit. The exec
424
+ // callback / 'error' handler still clear it via the existing
425
+ // clearTimeout, so this only matters for the genuinely-hung case.
426
+ if (typeof timer.unref === 'function') timer.unref();
421
427
 
422
428
  // Handle child process errors
423
429
  child.on('error', (err) => {
@@ -790,7 +796,17 @@ async function whoisLookupWithRetry(domain = '', timeout = 10000, whoisServer =
790
796
  console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Adding ${actualDelay}ms progressive delay before retry ${retryCount + 1} (base: ${baseDelay}ms + extra: ${extraDelay}ms)...`));
791
797
  }
792
798
  }
793
- await new Promise(resolve => setTimeout(resolve, actualDelay));
799
+ // unref the retry-delay timer so a pending backoff (up to ~30s on
800
+ // late attempts) can't hold the event loop alive past scan exit
801
+ // when the per-URL drain has already returned. If the process is
802
+ // still otherwise busy, the timer fires normally; if it's the only
803
+ // thing left, the process exits and the now-pointless retry result
804
+ // never lands. Same pattern as the execFile/overall timers
805
+ // unref'd in 83209d4.
806
+ await new Promise(resolve => {
807
+ const t = setTimeout(resolve, actualDelay);
808
+ if (typeof t.unref === 'function') t.unref();
809
+ });
794
810
  } else if (serverIndex > 0 && retryCount === 0 && whoisDelay > 0) {
795
811
  // Add delay before trying a new server (but not the very first server)
796
812
  if (debugMode) {
@@ -800,7 +816,11 @@ async function whoisLookupWithRetry(domain = '', timeout = 10000, whoisServer =
800
816
  console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Adding ${whoisDelay}ms delay before trying new server...`));
801
817
  }
802
818
  }
803
- await new Promise(resolve => setTimeout(resolve, whoisDelay));
819
+ // Same unref rationale as the retry-delay timer above.
820
+ await new Promise(resolve => {
821
+ const t = setTimeout(resolve, whoisDelay);
822
+ if (typeof t.unref === 'function') t.unref();
823
+ });
804
824
  } else if (debugMode && whoisDelay === 0) {
805
825
  // Log when delay is skipped due to whoisDelay being 0
806
826
  if (logFunc) {
@@ -1232,6 +1252,12 @@ function createNetToolsHandler(config) {
1232
1252
  })(),
1233
1253
  new Promise((_, reject) => {
1234
1254
  overallTimeoutId = setTimeout(() => reject(new Error('NetTools overall timeout')), 65000);
1255
+ // unref so a still-pending overall timeout (handler returned via
1256
+ // drain at 3s but the lookup is technically still in-flight) can't
1257
+ // hold the event loop alive for the full 65s on scan exit. The
1258
+ // finally on the inner promise still clearTimeouts on natural
1259
+ // completion, so this only matters for the genuinely-hung case.
1260
+ if (typeof overallTimeoutId.unref === 'function') overallTimeoutId.unref();
1235
1261
  })
1236
1262
  ]).catch(err => {
1237
1263
  if (forceDebug) {