@fanboynz/network-scanner 2.0.65 → 2.0.66

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/lib/proxy.js CHANGED
@@ -18,6 +18,15 @@
18
18
  *
19
19
  * SOCKS5 with auth:
20
20
  * "proxy": "socks5://user:pass@127.0.0.1:1080"
21
+ * Chromium itself cannot authenticate SOCKS5 (crbug.com/256785), so
22
+ * this module auto-starts an in-process no-auth SOCKS5 relay
23
+ * (lib/socks-relay.js) that does the upstream RFC 1929 auth. Chromium
24
+ * connects to the local relay (no auth — which it CAN do) and the
25
+ * relay tunnels to the authenticated upstream. Transparent: keep the
26
+ * socks5://user:pass@host form in config. Requires prepareSocksRelays()
27
+ * to be awaited once before the scan loop (nwss.js does this).
28
+ * NOTE: socks4 with auth is still unsupported (userId-only,
29
+ * near-extinct) — use socks5 or an authenticated HTTP proxy.
21
30
  *
22
31
  * HTTP proxy (corporate):
23
32
  * "proxy": "http://proxy.corp.com:3128"
@@ -56,8 +65,9 @@
56
65
  */
57
66
 
58
67
  const { formatLogMessage } = require('./colorize');
68
+ const { ensureRelay, getRelayPort } = require('./socks-relay');
59
69
 
60
- const PROXY_MODULE_VERSION = '1.1.0';
70
+ const PROXY_MODULE_VERSION = '1.2.0';
61
71
  const SUPPORTED_PROTOCOLS = ['socks5', 'socks4', 'http', 'https'];
62
72
 
63
73
  const DEFAULT_PORTS = {
@@ -105,8 +115,12 @@ function parseProxyUrl(proxyUrl) {
105
115
  if (!host) return null;
106
116
 
107
117
  const port = parseInt(url.port, 10) || DEFAULT_PORTS[protocol] || 1080;
108
- const username = url.username ? decodeURIComponent(url.username) : null;
109
- const password = url.password ? decodeURIComponent(url.password) : null;
118
+ // decodeURIComponent throws URIError on a literal '%' that isn't a valid
119
+ // escape (e.g. a password containing '%'). Fall back to the raw value so
120
+ // an otherwise-valid proxy isn't rejected as "Invalid proxy URL".
121
+ const safeDecode = (v) => { try { return decodeURIComponent(v); } catch (_) { return v; } };
122
+ const username = url.username ? safeDecode(url.username) : null;
123
+ const password = url.password ? safeDecode(url.password) : null;
110
124
 
111
125
  return { protocol, host, port, username, password };
112
126
  } catch (_) {
@@ -124,6 +138,41 @@ function needsProxy(siteConfig) {
124
138
  return !!getConfiguredProxy(siteConfig);
125
139
  }
126
140
 
141
+ /**
142
+ * Pre-start local no-auth SOCKS5 relays for every distinct authenticated
143
+ * SOCKS5 upstream across the given site configs. Must be awaited ONCE
144
+ * before the scan loop — getProxyArgs() then does a pure sync lookup of
145
+ * the relay port, so the fragile per-batch browser-launch path stays
146
+ * synchronous.
147
+ *
148
+ * @param {object[]} siteConfigs
149
+ * @param {boolean} forceDebug
150
+ * @returns {Promise<number>} count of relays started
151
+ */
152
+ async function prepareSocksRelays(siteConfigs, forceDebug = false) {
153
+ let started = 0;
154
+ const seen = new Set();
155
+ for (const cfg of (siteConfigs || [])) {
156
+ const url = getConfiguredProxy(cfg);
157
+ if (!url) continue;
158
+ const parsed = parseProxyUrl(url);
159
+ // Only socks5 with credentials needs a relay. socks4-auth stays
160
+ // unsupported (near-extinct, userId-only); http/https auth works
161
+ // natively via page.authenticate().
162
+ if (!parsed || parsed.protocol !== 'socks5' || !parsed.username) continue;
163
+ const key = `${parsed.host}:${parsed.port}:${parsed.username}`;
164
+ if (seen.has(key)) continue;
165
+ seen.add(key);
166
+ try {
167
+ await ensureRelay(parsed, forceDebug);
168
+ started++;
169
+ } catch (e) {
170
+ console.warn(formatLogMessage('proxy', `Failed to start SOCKS5 auth relay for ${parsed.host}:${parsed.port}: ${e.message}`));
171
+ }
172
+ }
173
+ return started;
174
+ }
175
+
127
176
  /**
128
177
  * Returns Chromium launch arguments for the configured proxy.
129
178
  *
@@ -141,15 +190,45 @@ function getProxyArgs(siteConfig, forceDebug = false) {
141
190
  return [];
142
191
  }
143
192
 
193
+ // Authenticated SOCKS5: Chromium can't auth SOCKS, so point it at the
194
+ // local no-auth relay (started upfront by prepareSocksRelays) which does
195
+ // the upstream auth. Credentials never reach Chromium. The relay speaks
196
+ // SOCKS5 and forwards domain addresses, so the remote-DNS rule below
197
+ // still applies correctly to the localhost hop.
198
+ let effectiveHost = parsed.host;
199
+ let effectivePort = parsed.port;
200
+ let effectiveProto = parsed.protocol;
201
+ if (parsed.protocol === 'socks5' && parsed.username) {
202
+ const relayPort = getRelayPort(parsed);
203
+ if (relayPort) {
204
+ effectiveHost = '127.0.0.1';
205
+ effectivePort = relayPort;
206
+ const debug = forceDebug || siteConfig.proxy_debug || siteConfig.socks5_debug;
207
+ if (debug) {
208
+ console.log(formatLogMessage('proxy', `SOCKS5 auth via local relay 127.0.0.1:${relayPort} -> ${parsed.host}:${parsed.port}`));
209
+ }
210
+ } else {
211
+ // prepareSocksRelays should have started this; defensive only.
212
+ console.warn(formatLogMessage('proxy', `No SOCKS5 auth relay for ${parsed.host}:${parsed.port} — call prepareSocksRelays() before the scan. Connection will fail (Chromium can't auth SOCKS).`));
213
+ }
214
+ }
215
+
144
216
  const args = [
145
- `--proxy-server=${parsed.protocol}://${parsed.host}:${parsed.port}`
217
+ `--proxy-server=${effectiveProto}://${effectiveHost}:${effectivePort}`
146
218
  ];
147
219
 
148
- // Remote DNS: resolve hostnames through the proxy (prevents DNS leaks)
149
- // Only meaningful for SOCKS proxies; HTTP proxies resolve remotely by default
220
+ // Remote DNS: force proxy-side hostname resolution (prevents DNS leaks).
221
+ // SOCKS5 only it can carry a hostname to the proxy for remote
222
+ // resolution. SOCKS4 cannot (the protocol only accepts an IPv4 address;
223
+ // resolution must happen client-side), so applying MAP * ~NOTFOUND there
224
+ // makes Chromium's local resolver fail with nothing able to resolve the
225
+ // hostname — every request breaks. HTTP/HTTPS proxies resolve remotely
226
+ // by default and need no rule.
150
227
  const remoteDns = siteConfig.proxy_remote_dns ?? siteConfig.socks5_remote_dns;
151
- if ((parsed.protocol === 'socks5' || parsed.protocol === 'socks4') && remoteDns !== false) {
228
+ if (parsed.protocol === 'socks5' && remoteDns !== false) {
152
229
  args.push('--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1');
230
+ } else if (parsed.protocol === 'socks4' && remoteDns === true) {
231
+ console.warn(formatLogMessage('proxy', `proxy_remote_dns ignored: SOCKS4 cannot do proxy-side DNS resolution (use SOCKS5)`));
153
232
  }
154
233
 
155
234
  // Bypass list: domains that skip the proxy
@@ -182,6 +261,20 @@ async function applyProxyAuth(page, siteConfig, forceDebug = false) {
182
261
  const parsed = parseProxyUrl(proxyUrl);
183
262
  if (!parsed || !parsed.username) return false;
184
263
 
264
+ // Chromium can't authenticate SOCKS proxies, and page.authenticate() is
265
+ // HTTP-407-only. SOCKS5+creds is handled out-of-band by the local
266
+ // no-auth relay (prepareSocksRelays + getProxyArgs rewrite) — Chromium
267
+ // talks no-auth to 127.0.0.1, so there's nothing for page.authenticate
268
+ // to do here; return quietly. SOCKS4 auth (userId-only, near-extinct)
269
+ // stays genuinely unsupported.
270
+ if (parsed.protocol === 'socks5') {
271
+ return false; // relay handles upstream auth
272
+ }
273
+ if (parsed.protocol === 'socks4') {
274
+ console.warn(formatLogMessage('proxy', `SOCKS4 proxy auth is unsupported (use SOCKS5, which is auto-relayed, or an authenticated HTTP proxy).`));
275
+ return false;
276
+ }
277
+
185
278
  try {
186
279
  await page.authenticate({
187
280
  username: parsed.username,
@@ -265,9 +358,14 @@ function getModuleInfo() {
265
358
  return { version: PROXY_MODULE_VERSION, name: 'Proxy Handler' };
266
359
  }
267
360
 
361
+ // Re-export relay teardown so nwss.js cleanup paths can close listeners.
362
+ const { closeAllRelays: closeAllSocksRelays } = require('./socks-relay');
363
+
268
364
  module.exports = {
269
365
  parseProxyUrl,
270
366
  needsProxy,
367
+ prepareSocksRelays,
368
+ closeAllSocksRelays,
271
369
  getProxyArgs,
272
370
  applyProxyAuth,
273
371
  testProxy,
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Local no-auth SOCKS5 relay for authenticated SOCKS5 upstreams.
3
+ *
4
+ * Chromium cannot authenticate SOCKS5 proxies (crbug.com/256785 — it only
5
+ * implements the no-auth method 0x00; credentials in --proxy-server are
6
+ * discarded, and page.authenticate() is HTTP-407-only so it can't help —
7
+ * SOCKS auth happens at the TCP handshake before any HTTP).
8
+ *
9
+ * Workaround: run an in-process no-auth SOCKS5 server bound to 127.0.0.1.
10
+ * Chromium connects to it without auth (which it CAN do); for each
11
+ * connection we open an authenticated tunnel to the real upstream via the
12
+ * `socks` package (RFC 1929 user/pass) and pipe the two together. Domain
13
+ * address types are forwarded as hostnames so remote DNS still works
14
+ * end-to-end (no DNS leak).
15
+ *
16
+ * Relays are keyed by upstream identity and reused. closeAllRelays() must
17
+ * be called on scan exit / signal so listening sockets don't leak.
18
+ */
19
+
20
+ const net = require('net');
21
+ const { SocksClient } = require('socks');
22
+ const { formatLogMessage } = require('./colorize');
23
+
24
+ // upstreamKey -> { server, port, activeSockets:Set<net.Socket> }
25
+ const _relays = new Map();
26
+
27
+ function upstreamKey(u) {
28
+ return `${u.host}:${u.port}:${u.username || ''}`;
29
+ }
30
+
31
+ /**
32
+ * Handle one Chromium->relay connection: minimal SOCKS5 server handshake,
33
+ * then an authenticated upstream tunnel, then bidirectional pipe.
34
+ */
35
+ // Bail on a client that connects and never completes SOCKS5 negotiation.
36
+ // Generous enough for a Chromium loopback handshake (microseconds), short
37
+ // enough to catch a stalled / half-open client before the OS TCP keepalive
38
+ // notices (default ~2 hours on Linux).
39
+ const HANDSHAKE_TIMEOUT_MS = 10000;
40
+
41
+ function handleClient(client, upstream, forceDebug) {
42
+ let phase = 'greeting';
43
+ let buf = Buffer.alloc(0);
44
+ let upstreamSock = null;
45
+ let settled = false;
46
+ // Handshake-phase watchdog handle. Assigned after cleanup is declared so
47
+ // both references in this scope resolve unambiguously.
48
+ let handshakeTimer = null;
49
+
50
+ const cleanup = () => {
51
+ if (settled) return;
52
+ settled = true;
53
+ if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
54
+ try { client.destroy(); } catch (_) {}
55
+ if (upstreamSock) { try { upstreamSock.destroy(); } catch (_) {} }
56
+ };
57
+
58
+ // Bail on a client that connects and never completes SOCKS5 negotiation
59
+ // (stalled / half-open / non-SOCKS protocol). Without this, such a socket
60
+ // sits in activeSockets until the OS TCP keepalive notices — default
61
+ // ~2 hours on Linux. unref'd so a pending watchdog never holds the
62
+ // process alive after closeAllRelays().
63
+ handshakeTimer = setTimeout(() => {
64
+ if (phase !== 'piping') {
65
+ if (forceDebug) {
66
+ console.log(formatLogMessage('proxy', `[socks-relay] handshake timeout (phase=${phase}) — closing`));
67
+ }
68
+ cleanup();
69
+ }
70
+ }, HANDSHAKE_TIMEOUT_MS);
71
+ if (typeof handshakeTimer.unref === 'function') handshakeTimer.unref();
72
+
73
+ const onData = async (chunk) => {
74
+ buf = Buffer.concat([buf, chunk]);
75
+ try {
76
+ if (phase === 'greeting') {
77
+ // [0x05, NMETHODS, METHODS...]
78
+ if (buf.length < 2) return;
79
+ const nMethods = buf[1];
80
+ if (buf.length < 2 + nMethods) return;
81
+ const offered = buf.subarray(2, 2 + nMethods);
82
+ buf = buf.subarray(2 + nMethods);
83
+ // We only speak no-auth (0x00) to the local client. Chromium always
84
+ // offers it; if a client somehow didn't, reply "no acceptable
85
+ // methods" rather than violate the protocol by selecting unoffered.
86
+ if (!offered.includes(0x00)) {
87
+ try { client.write(Buffer.from([0x05, 0xFF])); } catch (_) {}
88
+ return cleanup();
89
+ }
90
+ client.write(Buffer.from([0x05, 0x00])); // select "no auth"
91
+ phase = 'request';
92
+ }
93
+
94
+ if (phase === 'request') {
95
+ // [0x05, CMD, 0x00, ATYP, ADDR..., PORT(2 BE)]
96
+ if (buf.length < 4) return;
97
+ if (buf[0] !== 0x05) { failReply(client, 0x01); return cleanup(); }
98
+ const cmd = buf[1];
99
+ const atyp = buf[3];
100
+ let host, port, hdrLen;
101
+
102
+ if (atyp === 0x01) { // IPv4
103
+ if (buf.length < 10) return;
104
+ host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
105
+ port = buf.readUInt16BE(8);
106
+ hdrLen = 10;
107
+ } else if (atyp === 0x03) { // domain
108
+ if (buf.length < 5) return;
109
+ const dLen = buf[4];
110
+ if (buf.length < 7 + dLen) return;
111
+ host = buf.subarray(5, 5 + dLen).toString('utf8');
112
+ port = buf.readUInt16BE(5 + dLen);
113
+ hdrLen = 7 + dLen;
114
+ } else if (atyp === 0x04) { // IPv6
115
+ if (buf.length < 22) return;
116
+ const seg = [];
117
+ for (let i = 0; i < 16; i += 2) seg.push(buf.readUInt16BE(4 + i).toString(16));
118
+ host = seg.join(':');
119
+ port = buf.readUInt16BE(20);
120
+ hdrLen = 22;
121
+ } else {
122
+ failReply(client, 0x08); // address type not supported
123
+ return cleanup();
124
+ }
125
+
126
+ if (cmd !== 0x01) { // only CONNECT
127
+ failReply(client, 0x07);
128
+ return cleanup();
129
+ }
130
+
131
+ // Hand the stream to .pipe() from here. Pause + detach this handler
132
+ // so a data event during the upstream connect can't re-enter.
133
+ phase = 'connecting';
134
+ client.pause();
135
+ client.off('data', onData);
136
+ const early = buf.subarray(hdrLen); // any bytes after the request header
137
+ buf = null;
138
+
139
+ let info;
140
+ try {
141
+ info = await SocksClient.createConnection({
142
+ proxy: {
143
+ host: upstream.host,
144
+ port: upstream.port,
145
+ type: 5,
146
+ userId: upstream.username,
147
+ password: upstream.password || '',
148
+ },
149
+ command: 'connect',
150
+ destination: { host, port },
151
+ timeout: 20000,
152
+ });
153
+ } catch (e) {
154
+ if (forceDebug) {
155
+ console.log(formatLogMessage('proxy', `[socks-relay] upstream connect failed (${host}:${port}): ${e.message}`));
156
+ }
157
+ failReply(client, 0x05); // connection refused
158
+ return cleanup();
159
+ }
160
+
161
+ upstreamSock = info.socket;
162
+ try { upstreamSock.setNoDelay(true); } catch (_) {}
163
+ upstreamSock.on('error', cleanup);
164
+ upstreamSock.on('close', cleanup);
165
+ client.on('error', cleanup);
166
+ client.on('close', cleanup);
167
+
168
+ // SOCKS5 success (BND.ADDR 0.0.0.0:0 — Chromium ignores it for CONNECT)
169
+ client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
170
+ if (early && early.length) upstreamSock.write(early);
171
+ client.pipe(upstreamSock);
172
+ upstreamSock.pipe(client);
173
+ client.resume();
174
+ phase = 'piping';
175
+ // Negotiation complete — disarm the handshake watchdog so a
176
+ // long-running download isn't killed mid-transfer.
177
+ if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
178
+ }
179
+ } catch (e) {
180
+ if (forceDebug) {
181
+ console.log(formatLogMessage('proxy', `[socks-relay] handler error: ${e.message}`));
182
+ }
183
+ cleanup();
184
+ }
185
+ };
186
+
187
+ client.on('data', onData);
188
+ client.on('error', cleanup);
189
+ }
190
+
191
+ // SOCKS5 failure reply (valid only before piping starts).
192
+ function failReply(client, code) {
193
+ try { client.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); } catch (_) {}
194
+ }
195
+
196
+ /**
197
+ * Ensure a relay exists for the given upstream; returns its local port.
198
+ * Idempotent — repeated calls for the same upstream reuse one relay.
199
+ *
200
+ * @param {{host:string,port:number,username:string,password:string}} upstream
201
+ * @param {boolean} forceDebug
202
+ * @returns {Promise<number>} local 127.0.0.1 port the relay listens on
203
+ */
204
+ async function ensureRelay(upstream, forceDebug = false) {
205
+ const key = upstreamKey(upstream);
206
+ const existing = _relays.get(key);
207
+ if (existing) return existing.port;
208
+
209
+ const activeSockets = new Set();
210
+ const server = net.createServer((clientSock) => {
211
+ // Disable Nagle: page scanning is full of small-packet phases (per-origin
212
+ // TLS handshakes, small XHR/API calls, the SOCKS handshake itself).
213
+ // Nagle + delayed-ACK adds ~40ms stalls on those; relays should not.
214
+ try { clientSock.setNoDelay(true); } catch (_) {}
215
+ activeSockets.add(clientSock);
216
+ clientSock.on('close', () => activeSockets.delete(clientSock));
217
+ handleClient(clientSock, upstream, forceDebug);
218
+ });
219
+
220
+ await new Promise((resolve, reject) => {
221
+ const onErr = (e) => reject(e);
222
+ server.once('error', onErr);
223
+ server.listen(0, '127.0.0.1', () => {
224
+ server.removeListener('error', onErr);
225
+ // Keep a listener so a late server error doesn't crash the process.
226
+ server.on('error', (e) => {
227
+ if (forceDebug) console.log(formatLogMessage('proxy', `[socks-relay] server error: ${e.message}`));
228
+ });
229
+ resolve();
230
+ });
231
+ });
232
+
233
+ const port = server.address().port;
234
+ _relays.set(key, { server, port, activeSockets });
235
+ if (forceDebug) {
236
+ console.log(formatLogMessage('proxy', `[socks-relay] 127.0.0.1:${port} -> ${upstream.host}:${upstream.port} (auth user "${upstream.username}")`));
237
+ }
238
+ return port;
239
+ }
240
+
241
+ /**
242
+ * Sync lookup of an already-started relay's port. Returns null if no relay
243
+ * has been started for this upstream (caller should have called ensureRelay
244
+ * upfront).
245
+ */
246
+ function getRelayPort(upstream) {
247
+ const r = _relays.get(upstreamKey(upstream));
248
+ return r ? r.port : null;
249
+ }
250
+
251
+ /**
252
+ * Tear down every relay: destroy in-flight sockets, close listeners.
253
+ * Safe to call multiple times.
254
+ */
255
+ async function closeAllRelays(forceDebug = false) {
256
+ for (const [key, r] of _relays) {
257
+ for (const s of r.activeSockets) { try { s.destroy(); } catch (_) {} }
258
+ await new Promise((res) => {
259
+ try { r.server.close(() => res()); } catch (_) { res(); }
260
+ });
261
+ if (forceDebug) console.log(formatLogMessage('proxy', `[socks-relay] closed relay for ${key}`));
262
+ }
263
+ _relays.clear();
264
+ }
265
+
266
+ module.exports = { ensureRelay, getRelayPort, closeAllRelays };
package/nwss.js CHANGED
@@ -9,6 +9,7 @@ const fs = require('fs');
9
9
  const os = require('os');
10
10
  const psl = require('psl');
11
11
  const path = require('path');
12
+ const dnsPromises = require('node:dns/promises');
12
13
  const { createGrepHandler, validateGrepAvailability } = require('./lib/grep');
13
14
  const { compressMultipleFiles, formatFileSize } = require('./lib/compress');
14
15
  const { parseSearchStrings, createResponseHandler, createCurlHandler } = require('./lib/searchstring');
@@ -50,7 +51,7 @@ const { isGhostCursorAvailable, createGhostCursor, ghostMove, ghostClick, ghostR
50
51
  const { createGlobalHelpers, getTotalDomainsSkipped, getDetectedDomainsCount } = require('./lib/domain-cache');
51
52
  const { createSmartCache } = require('./lib/smart-cache'); // Smart cache system
52
53
  const { clearPersistentCache } = require('./lib/smart-cache');
53
- const { needsProxy, getProxyArgs, applyProxyAuth, getProxyInfo, testProxy } = require('./lib/proxy');
54
+ const { needsProxy, getProxyArgs, applyProxyAuth, getProxyInfo, testProxy, prepareSocksRelays, closeAllSocksRelays } = require('./lib/proxy');
54
55
  // Dry run functionality
55
56
  const { initializeDryRunCollections, addDryRunMatch, addDryRunNetTools, processDryRunResults, writeDryRunOutput } = require('./lib/dry-run');
56
57
  // Enhanced site data clearing functionality
@@ -266,6 +267,13 @@ if (fs.existsSync(NWSSCONFIG_PATH)) {
266
267
  }
267
268
 
268
269
  const headfulMode = args.includes('--headful');
270
+ // Sites (esp. video/streaming) call element.requestFullscreen() on load or
271
+ // click. In --headful that hijacks the real Chrome window into true
272
+ // fullscreen, forcing a manual ESC. Neutralize the Fullscreen API by
273
+ // default so it can't. Harmless in headless (no screen — the API is
274
+ // already inert there), so default-on keeps headful consistent with the
275
+ // primary headless path. --allow-fullscreen restores native behavior.
276
+ const allowFullscreen = args.includes('--allow-fullscreen');
269
277
  const SOURCES_FOLDER = 'sources';
270
278
 
271
279
  let outputFile = null;
@@ -326,6 +334,31 @@ const cacheRequests = args.includes('--cache-requests');
326
334
  const dnsCacheMode = args.includes('--dns-cache');
327
335
  if (dnsCacheMode) enableDiskCache();
328
336
 
337
+ // DNS pre-check before page.goto() — default-on, --no-dns-precheck disables.
338
+ // Filters NXDOMAIN / unresolvable hostnames in <100ms before paying the
339
+ // ~5-15s Puppeteer + Cloudflare detection round-trip on each.
340
+ const dnsPrecheckEnabled = !args.includes('--no-dns-precheck');
341
+ const dnsPrecheckTimeoutMs = 2000;
342
+
343
+ // Per-scan cache of negative DNS lookups. OS resolvers don't always cache
344
+ // NXDOMAIN responses, and a scan can hit the same dead hostname many times
345
+ // (different URL paths on the same site). Positive results are left to the
346
+ // OS cache; failure-cache avoids repeated lookup latency for known-dead hosts.
347
+ // FIFO eviction at DNS_NEGATIVE_CACHE_MAX so pathological scans (thousands
348
+ // of unique dead hosts) can't grow the cache unboundedly. Same pattern as
349
+ // the rest of the codebase's in-memory caches.
350
+ const dnsNegativeCache = new Map(); // hostname -> { error, timestamp }
351
+ const DNS_NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
352
+ const DNS_NEGATIVE_CACHE_MAX = 1000;
353
+ let dnsPrecheckSkips = 0;
354
+
355
+ function dnsNegativeCacheSet(hostname, error) {
356
+ if (dnsNegativeCache.size >= DNS_NEGATIVE_CACHE_MAX) {
357
+ dnsNegativeCache.delete(dnsNegativeCache.keys().next().value);
358
+ }
359
+ dnsNegativeCache.set(hostname, { error, timestamp: Date.now() });
360
+ }
361
+
329
362
  let validateRulesFile = null;
330
363
  const validateRulesIndex = args.findIndex(arg => arg === '--validate-rules');
331
364
  if (validateRulesIndex !== -1 && args[validateRulesIndex + 1] && !args[validateRulesIndex + 1].startsWith('--')) {
@@ -643,6 +676,8 @@ General Options:
643
676
  --custom-json <file> Use a custom config JSON file instead of config.json
644
677
  --headful Launch browser with GUI (not headless)
645
678
  --keep-open Keep browser open after scan completes (use with --headful)
679
+ --allow-fullscreen Allow sites to use the Fullscreen API. By default it is
680
+ neutralized so sites can't hijack the window in --headful
646
681
  --use-puppeteer-core Use puppeteer-core with system Chrome instead of bundled Chromium
647
682
  --use-obscura Connect to running Obscura CDP server (ws://127.0.0.1:9222 or OBSCURA_WS env)
648
683
  Skips fingerprint injection — Obscura provides built-in stealth
@@ -659,6 +694,9 @@ General Options:
659
694
  Validation Options:
660
695
  --cache-requests Cache HTTP requests to avoid re-requesting same URLs within scan
661
696
  --dns-cache Persist dig/whois results to disk between runs (3hr/4hr TTL)
697
+ --no-dns-precheck Disable per-URL DNS resolution check before page navigation.
698
+ By default, URLs whose hostname doesn't resolve are skipped
699
+ immediately (saves ~5-15s of Puppeteer time per dead host).
662
700
  --validate-config Validate config.json file and exit
663
701
  --validate-rules [file] Validate rule file format (uses --output/--compare files if no file specified)
664
702
  --clean-rules [file] Clean rule files by removing invalid lines and optionally duplicates (uses --output/--compare files if no file specified)
@@ -1831,6 +1869,7 @@ function setupFrameHandling(page, forceDebug) {
1831
1869
  ovpnDisconnectAll(forceDebug);
1832
1870
  cleanupCloudflareCache();
1833
1871
  purgeStaleTrackers();
1872
+ try { await closeAllSocksRelays(forceDebug); } catch (_) {}
1834
1873
  }
1835
1874
 
1836
1875
  let siteCounter = 0;
@@ -2438,6 +2477,29 @@ function setupFrameHandling(page, forceDebug) {
2438
2477
  } else if (forceDebug) {
2439
2478
  console.log(formatLogMessage('debug', `Skipping fingerprint injection — Obscura provides built-in stealth`));
2440
2479
  }
2480
+
2481
+ // Neutralize the Fullscreen API before any page script runs so a
2482
+ // site can't force the real browser window fullscreen in --headful
2483
+ // (or trip an anti-bot check that reads document.fullscreenElement).
2484
+ // requestFullscreen is stubbed to a resolved no-op — which is also
2485
+ // how browsers already behave when it's called without a user
2486
+ // gesture, so this looks normal, not automated. fullscreenElement
2487
+ // stays null naturally since we never enter fullscreen.
2488
+ if (!allowFullscreen) {
2489
+ try {
2490
+ await page.evaluateOnNewDocument(() => {
2491
+ const noop = function () { return Promise.resolve(); };
2492
+ const legacyNoop = function () {};
2493
+ try { Element.prototype.requestFullscreen = noop; } catch (_) {}
2494
+ try { Element.prototype.webkitRequestFullscreen = legacyNoop; } catch (_) {}
2495
+ try { Element.prototype.webkitRequestFullScreen = legacyNoop; } catch (_) {}
2496
+ try { Element.prototype.mozRequestFullScreen = legacyNoop; } catch (_) {}
2497
+ try { Element.prototype.msRequestFullscreen = legacyNoop; } catch (_) {}
2498
+ });
2499
+ } catch (fsErr) {
2500
+ if (forceDebug) console.log(formatLogMessage('debug', `Fullscreen neutralization injection failed: ${fsErr.message}`));
2501
+ }
2502
+ }
2441
2503
 
2442
2504
  // Client Hints protection for Chrome user agents (skipped under Obscura — it sets its own)
2443
2505
  if (!useObscura && siteConfig.userAgent && siteConfig.userAgent.toLowerCase().includes('chrome')) {
@@ -4300,6 +4362,19 @@ function setupFrameHandling(page, forceDebug) {
4300
4362
  // Sort tasks so proxy groups are contiguous — direct connections first, then each proxy
4301
4363
  allTasks.sort((a, b) => proxyKeyFor(a.config).localeCompare(proxyKeyFor(b.config)));
4302
4364
 
4365
+ // Pre-start local no-auth SOCKS5 relays for any authenticated socks5://
4366
+ // upstreams. Done once here (the only async step) so getProxyArgs stays a
4367
+ // sync lookup in the per-batch browser-launch path. Chromium can't auth
4368
+ // SOCKS5; the relay does the upstream auth transparently.
4369
+ try {
4370
+ const relayCount = await prepareSocksRelays(sites, forceDebug);
4371
+ if (relayCount > 0 && !silentMode) {
4372
+ console.log(messageColors.processing(`Started ${relayCount} SOCKS5 auth relay(s)`));
4373
+ }
4374
+ } catch (relayErr) {
4375
+ console.warn(formatLogMessage('proxy', `SOCKS5 relay setup failed: ${relayErr.message}`));
4376
+ }
4377
+
4303
4378
  let results = [];
4304
4379
  let processedUrlCount = 0;
4305
4380
  let urlsSinceLastCleanup = 0;
@@ -4580,6 +4655,66 @@ function setupFrameHandling(page, forceDebug) {
4580
4655
  if (!silentMode) console.log(formatLogMessage('info', `Skipping ${task.url} — ${taskDomain} timed out ${DOMAIN_TIMEOUT_THRESHOLD} times`));
4581
4656
  return { url: task.url, rules: [], success: false, error: 'Domain repeatedly timed out', skipped: true };
4582
4657
  }
4658
+
4659
+ // DNS pre-check — fails fast on NXDOMAIN/unresolvable hosts before
4660
+ // we pay ~5-15s for Puppeteer navigation + Cloudflare detection.
4661
+ // Skips IP literals. Respects an in-memory negative cache so a dead
4662
+ // host hit by many URL paths only costs one DNS round-trip per TTL.
4663
+ //
4664
+ // Uses dns.resolve* (c-ares, async network I/O) NOT dns.lookup
4665
+ // (getaddrinfo, libuv threadpool). Under scan concurrency Puppeteer
4666
+ // saturates the default 4-slot threadpool with filesystem I/O, so
4667
+ // dns.lookup calls sit queued and blow the timeout while never
4668
+ // actually starting — wrongly skipping live domains. c-ares isn't
4669
+ // threadpool-bound so it's immune to that contention.
4670
+ if (dnsPrecheckEnabled && taskDomain && !/^[\d.:]+$|^\[/.test(taskDomain)) {
4671
+ const cached = dnsNegativeCache.get(taskDomain);
4672
+ if (cached && Date.now() - cached.timestamp < DNS_NEGATIVE_CACHE_TTL_MS) {
4673
+ dnsPrecheckSkips++;
4674
+ if (forceDebug) console.log(formatLogMessage('debug', `DNS pre-check (cached): ${taskDomain} — ${cached.error}`));
4675
+ return { url: task.url, rules: [], success: false, error: `DNS: ${cached.error}`, skipped: true };
4676
+ }
4677
+ const dnsResolve = async () => {
4678
+ // resolve4 first; on no-IPv4 (ENODATA / ENOTFOUND) fall back to
4679
+ // resolve6 so IPv6-only hosts aren't wrongly skipped. Only a
4680
+ // failure of BOTH means the host is genuinely unresolvable.
4681
+ // 2s timeout kept as a real safety net — with c-ares off the
4682
+ // threadpool it should now rarely fire.
4683
+ let timer;
4684
+ try {
4685
+ const timeoutP = new Promise((_, reject) => {
4686
+ timer = setTimeout(() => reject(new Error('DNS timeout')), dnsPrecheckTimeoutMs);
4687
+ });
4688
+ const resolveChain = dnsPromises.resolve4(taskDomain)
4689
+ .catch(() => dnsPromises.resolve6(taskDomain));
4690
+ await Promise.race([resolveChain, timeoutP]);
4691
+ } finally {
4692
+ if (timer) clearTimeout(timer);
4693
+ }
4694
+ };
4695
+ // c-ares transient codes — retry once so a momentary resolver
4696
+ // hiccup doesn't poison the negative cache for 5 minutes.
4697
+ const TRANSIENT = new Set(['ETIMEOUT', 'ESERVFAIL', 'EREFUSED', 'ECONNREFUSED']);
4698
+ try {
4699
+ try {
4700
+ await dnsResolve();
4701
+ } catch (firstErr) {
4702
+ const code = firstErr && firstErr.code;
4703
+ if (TRANSIENT.has(code) || (firstErr && firstErr.message === 'DNS timeout')) {
4704
+ if (forceDebug) console.log(formatLogMessage('debug', `DNS pre-check transient (${code || 'timeout'}) for ${taskDomain}, retrying once`));
4705
+ await dnsResolve();
4706
+ } else {
4707
+ throw firstErr;
4708
+ }
4709
+ }
4710
+ } catch (dnsErr) {
4711
+ const errCode = dnsErr.code || dnsErr.message || 'DNS resolve failed';
4712
+ dnsNegativeCacheSet(taskDomain, errCode);
4713
+ dnsPrecheckSkips++;
4714
+ if (forceDebug) console.log(formatLogMessage('debug', `DNS pre-check failed: ${taskDomain} — ${errCode}`));
4715
+ return { url: task.url, rules: [], success: false, error: `DNS: ${errCode}`, skipped: true };
4716
+ }
4717
+ }
4583
4718
  } catch {}
4584
4719
 
4585
4720
  // Per-URL timeout so a single hung processUrl can't block the batch
@@ -4896,6 +5031,9 @@ function setupFrameHandling(page, forceDebug) {
4896
5031
  if (cloudflareScanStats.errorPages > 0) {
4897
5032
  console.log(formatLogMessage('debug', `Cloudflare 5xx origin-error pages: ${cloudflareScanStats.errorPages} (no bypass possible — origin unreachable)`));
4898
5033
  }
5034
+ if (dnsPrecheckEnabled && dnsPrecheckSkips > 0) {
5035
+ console.log(formatLogMessage('debug', `DNS pre-check skipped: ${dnsPrecheckSkips} URL(s) via ${dnsNegativeCache.size} unresolvable host(s)`));
5036
+ }
4899
5037
  // Log smart cache statistics (if cache is enabled)
4900
5038
  // Adblock statistics
4901
5039
  if (adblockEnabled) {
@@ -5113,6 +5251,7 @@ function setupFrameHandling(page, forceDebug) {
5113
5251
  try { wgDisconnectAll(forceDebug); } catch (_) {}
5114
5252
  try { ovpnDisconnectAll(forceDebug); } catch (_) {}
5115
5253
  try { purgeStaleTrackers(); } catch (_) {}
5254
+ try { await closeAllSocksRelays(forceDebug); } catch (_) {}
5116
5255
 
5117
5256
  // Clean process termination
5118
5257
  if (forceDebug) console.log(formatLogMessage('debug', `About to exit process...`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanboynz/network-scanner",
3
- "version": "2.0.65",
3
+ "version": "2.0.66",
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": {
@@ -14,11 +14,12 @@
14
14
  "lru-cache": "^11.3.5",
15
15
  "p-limit": "^7.3.0",
16
16
  "psl": "^1.15.0",
17
- "puppeteer": ">=20.0.0"
17
+ "puppeteer": ">=20.0.0",
18
+ "socks": "^2.8.9"
18
19
  },
19
20
  "overrides": {
20
21
  "tar-fs": "3.1.1",
21
- "ws": "8.18.3",
22
+ "ws": ">=8.20.1",
22
23
  "yauzl": ">=3.2.1"
23
24
  },
24
25
  "keywords": [