@fanboynz/network-scanner 3.0.0 → 3.0.2

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.
@@ -48,9 +48,14 @@ jobs:
48
48
  fetch-depth: 0
49
49
 
50
50
  - name: Setup Node.js
51
+ # Must be >=22.12.0 to satisfy package.json engines.node and to
52
+ # support require()-of-ESM for puppeteer 25 (the project's
53
+ # current floor). Bumping below this re-introduces the EBADENGINE
54
+ # warnings seen on 20.x runners and will fail npm publish for
55
+ # consumers on the same Node version.
51
56
  uses: actions/setup-node@v5
52
57
  with:
53
- node-version: '20'
58
+ node-version: '22'
54
59
  registry-url: 'https://registry.npmjs.org'
55
60
 
56
61
  - run: npm ci
@@ -157,7 +162,10 @@ jobs:
157
162
  uses: softprops/action-gh-release@v2
158
163
  with:
159
164
  tag_name: v${{ steps.version.outputs.version }}
160
- 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 }})
161
169
  body: ${{ inputs.release_notes_source == 'changelog' && steps.changelog_notes.outputs.notes || steps.manual_notes.outputs.notes }}
162
170
  draft: false
163
171
  prerelease: ${{ inputs.prerelease }}
package/CHANGELOG.md CHANGED
@@ -2,6 +2,69 @@
2
2
 
3
3
  All notable changes to the Network Scanner (nwss.js) project.
4
4
 
5
+ ## [3.0.2] - 2026-05-25
6
+
7
+ ### Security
8
+ - **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.
9
+ - **`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.
10
+
11
+ ### Added
12
+ - **`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`.
13
+ - **`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.
14
+
15
+ ### Fixed
16
+ - **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.
17
+ - **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.
18
+ - **`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.
19
+ - **`@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.
20
+ - **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).
21
+ - **"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.
22
+ - **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`.
23
+ - **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.
24
+ - **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()`.
25
+
26
+ ### Improved
27
+ - **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.
28
+ - **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.
29
+ - **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.
30
+ - **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.
31
+ - **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.
32
+ - **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.
33
+ - **`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.
34
+ - **`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).
35
+
36
+ ### CI
37
+ - **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`.
38
+
39
+ ## [3.0.1] - 2026-05-24
40
+
41
+ ### Security
42
+ - **Proxy credentials redacted in debug logs** — `lib/proxy.js` `getProxyInfo()` now replaces the `username:password@` segment with `[redacted]@` before logging; `lib/socks-relay.js` strips the username from both the relay-startup log (`auth: [redacted]` / `no auth`) and the close log (regex-trims the `:username` suffix from the relay key, IPv6-safe). Prior output exposed SOCKS5 credentials to anyone the user shared a debug dump, screenshot, or support ticket with.
43
+
44
+ ### Added
45
+ - `scripts/test-stealth.js` — stealth smoke-test harness. Launches Puppeteer with `applyAllFingerprintSpoofing` applied and reports what bot.sannysoft.com / creepjs / browserleaks.com/javascript concluded. Flags: `--headful`, `--no-spoof` (baseline), `--ua=<family>` (validated against `USER_AGENT_COLLECTIONS`), `--format=json` (stable schema for diff/jq A/B), `--help`, positional target filtering. `PUPPETEER_NO_SANDBOX=1` env-var opt-in for CI/root containers (sandbox is on by default). Caught 3 real bugs that 5 rounds of static review missed.
46
+ - `USER_AGENT_COLLECTIONS` exported from `lib/fingerprint.js` — single source of truth for valid UA families, consumed by the test harness so the list isn't duplicated.
47
+
48
+ ### Fixed
49
+ - **Puppeteer 25 compatibility** — `browser.isConnected()` (removed in Puppeteer 25 per [puppeteer#14910](https://github.com/puppeteer/puppeteer/pull/14910)) replaced with the `browser.connected` property at 14 call sites across 6 files. Compatible with both Puppeteer 24 and 25.
50
+ - **Fingerprint own-goal — PHANTOM_PROPERTIES + SELENIUM_DRIVER** — spoofing did `delete window[prop]` followed by `defineProperty(prop, { get: () => undefined })`. The undefined-returning getters left the properties detectable via the `in` operator, defeating the delete. Now only deletes. (caught by `scripts/test-stealth.js` sannysoft)
51
+ - **`navigator.plugins instanceof PluginArray` failed** — the spoof returned a plain array. Now `Object.setPrototypeOf(pluginsArray, PluginArray.prototype)` with fallback to `Object.getPrototypeOf(navigator.plugins)` for environments where `PluginArray` isn't a global.
52
+ - **`navigator.plugins[0].toString() === '[object Plugin]'` failed** — plain plugin objects returned `[object Object]`. Each plugin now wraps via `Object.create(Plugin.prototype)` with `Symbol.toStringTag` fallback.
53
+ - **`window.chrome` descriptor was a fingerprinting tell** — had `writable: false, enumerable: false`; real Chrome has both `true`. Aligned.
54
+ - **`_fingerprintCache` cross-UA poisoning** — was keyed by domain only, so the same domain visited under a different UA returned cached values from the wrong OS. Now keyed by `${domain}|${userAgent}`.
55
+ - **7 broken regex patterns** in the fingerprint error-suppression list — double backslashes (`\\.X`) parsed as literal-backslash + wildcard and never matched real errors. All 7 repaired.
56
+ - Constructor `.name` / `.length` preserved through 5 wrapper sites (Error, Image, RTCPeerConnection, PointerEvent, WheelEvent) — wrapped ctors had `.name = ''` and `.length = 0`, a fingerprinting tell.
57
+ - `Error` static properties (`stackTraceLimit`, `captureStackTrace`, `prepareStackTrace`) forward to the OriginalError via live getter/setter instead of snapshot-copy (snapshot diverged once any caller mutated the wrapped Error).
58
+ - `navigator.connection` fallback returns a closure-captured stable object — was re-allocating per call, so object identity changed every access.
59
+ - `chrome.runtime.getManifest()` derives version from the spoofed UA instead of returning a hardcoded older version.
60
+
61
+ ### Improved
62
+ - `isBrowserDead` helper extracted — deduped 3 spoof sites that hand-rolled the same `isConnected`/`closed` check.
63
+ - `preserveCtorIdentity` helper added — applied at the 5 wrapper sites above.
64
+ - GPU pool seeded by `domain + ':gpu'` (was just `domain`) — keeps per-domain GPU stable while decoupling it from any other per-domain seed we might add.
65
+ - 10 dead module-level exports trimmed from `lib/fingerprint.js`.
66
+ - `safeDefinePropertyLocal` forces `configurable: true` instead of merging it from the caller's descriptor (caller-side opt-in was unreliable).
67
+
5
68
  ## [3.0.0] - 2026-05-23
6
69
 
7
70
  ### Changed
package/CLAUDE.md CHANGED
@@ -66,6 +66,28 @@ node nwss.js --dry-run # Preview without network calls
66
66
  node nwss.js --headful # Launch with browser GUI
67
67
  ```
68
68
 
69
+ ## Stealth Testing
70
+
71
+ `scripts/test-stealth.js` is a smoke-test harness for the fingerprint spoofing
72
+ stack. Launches Puppeteer with `applyAllFingerprintSpoofing` applied (same
73
+ call shape nwss.js uses), navigates to public bot-detection pages, and
74
+ reports what they concluded. Use it to A/B a stealth change — run before the
75
+ edit, run after, diff. Found 3 real bugs that 5 rounds of static review
76
+ missed (PHANTOM/SELENIUM own-goal, PluginArray instanceof, Plugin toString).
77
+
78
+ ```bash
79
+ node scripts/test-stealth.js # all targets, human-readable
80
+ node scripts/test-stealth.js sannysoft # one target
81
+ node scripts/test-stealth.js --no-spoof # baseline (spoof disabled)
82
+ node scripts/test-stealth.js --format=json # machine-readable for diff/jq
83
+ node scripts/test-stealth.js --help # full flag list
84
+ ```
85
+
86
+ Set `PUPPETEER_NO_SANDBOX=1` when running as root (CI containers). Off by
87
+ default so local dev doesn't silently drop the sandbox. The harness depends
88
+ on `USER_AGENT_COLLECTIONS` exported from `lib/fingerprint.js` — keep that
89
+ export in sync if the UA list changes.
90
+
69
91
  ## Files to Ignore
70
92
 
71
93
  - `node_modules/**`
package/README.md CHANGED
@@ -790,4 +790,21 @@ your_username ALL=(root) NOPASSWD: /usr/bin/wg-quick, /usr/bin/wg
790
790
  - Ghost-cursor (`cursor_mode: "ghost"`) is optional — install with `npm i ghost-cursor`. Falls back to built-in mouse if not installed
791
791
  - Ghost-cursor duration defaults to `interact_duration` (or 2000ms), capped by the 15s hard timeout
792
792
 
793
+ ## Stealth Testing
794
+
795
+ `scripts/test-stealth.js` is a developer-facing smoke test for the fingerprint spoofing stack in `lib/fingerprint.js`. It launches Puppeteer with the same `applyAllFingerprintSpoofing` call that `nwss.js` uses, navigates to public bot-detection pages, and reports what they concluded — pass/warn/fail counts from sannysoft, trust score from creepjs, raw navigator values from browserleaks.
796
+
797
+ Use it to A/B a stealth change: run before the edit, run after, diff the output. Not a unit test — it doesn't assert; it reports.
798
+
799
+ ```bash
800
+ node scripts/test-stealth.js # all targets, human-readable
801
+ node scripts/test-stealth.js sannysoft # one target only
802
+ node scripts/test-stealth.js --no-spoof # baseline (spoof disabled)
803
+ node scripts/test-stealth.js --ua=firefox # spoof a different UA family
804
+ node scripts/test-stealth.js --format=json # machine-readable for diff/jq
805
+ node scripts/test-stealth.js --help # full flag list
806
+ ```
807
+
808
+ Set `PUPPETEER_NO_SANDBOX=1` when running as root (CI containers, some Docker setups). Off by default so local dev doesn't silently drop the Chromium sandbox.
809
+
793
810
  ---
@@ -159,7 +159,7 @@ async function cleanupUserDataDir(userDataDir, forceDebug = false) {
159
159
  */
160
160
  async function gracefulBrowserCleanup(browser, forceDebug = false) {
161
161
  // FIX: Check browser connection before operations
162
- if (!browser || !browser.isConnected()) {
162
+ if (!browser || !browser.connected) {
163
163
  if (forceDebug) console.log(formatLogMessage('debug', `${BROWSER_TAG} Browser not connected, skipping cleanup`));
164
164
  return;
165
165
  }
@@ -198,7 +198,7 @@ async function gracefulBrowserCleanup(browser, forceDebug = false) {
198
198
 
199
199
  // FIX: Check browser is still connected before closing
200
200
  try {
201
- if (browser.isConnected()) {
201
+ if (browser.connected) {
202
202
  await browser.close();
203
203
  if (forceDebug) console.log(formatLogMessage('debug', `${BROWSER_TAG} Browser closed successfully`));
204
204
  } else {
@@ -336,7 +336,7 @@ async function forceBrowserKill(browser, forceDebug = false) {
336
336
  }
337
337
 
338
338
  try {
339
- if (browser.isConnected()) {
339
+ if (browser.connected) {
340
340
  browser.disconnect();
341
341
  if (forceDebug) console.log(formatLogMessage('debug', `${BROWSER_TAG} Browser connection disconnected`));
342
342
  }
@@ -443,7 +443,7 @@ async function handleBrowserExit(browser, options = {}) {
443
443
  // isConnected() to false — in that case the nuclear path would just
444
444
  // murder other people's puppeteer-chrome instances for no gain.
445
445
  let stillConnected = false;
446
- try { stillConnected = browser.isConnected(); } catch (_) {}
446
+ try { stillConnected = browser.connected; } catch (_) {}
447
447
  if (stillConnected) {
448
448
  if (forceDebug) console.log(formatLogMessage('debug', `${BROWSER_TAG} Targeted force kill didn't take — escalating to nuclear cleanup`));
449
449
  await killAllPuppeteerChrome(forceDebug);
@@ -919,7 +919,7 @@ async function testBrowserConnectivity(browserInstance, timeout = 2500) {
919
919
 
920
920
  try {
921
921
  // Test 1: Basic browser connection
922
- const isConnected = browserInstance.isConnected();
922
+ const isConnected = browserInstance.connected;
923
923
  connectivityResult.connected = isConnected;
924
924
 
925
925
  if (!isConnected) {