nsauditor-ai 0.2.56 → 0.2.57

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.
Files changed (40) hide show
  1. package/README.md +41 -40
  2. package/cli.mjs +171 -147
  3. package/mcp_server.mjs +64 -37
  4. package/package.json +1 -1
  5. package/plugin_manager.mjs +49 -8
  6. package/plugins/040_tls_cert_auditor.mjs +176 -56
  7. package/plugins/050_tribe_health.mjs +13 -34
  8. package/plugins/060_dns_sec_auditor.mjs +24 -30
  9. package/plugins/dns_scanner.mjs +16 -8
  10. package/plugins/ftp_banner_check.mjs +21 -13
  11. package/plugins/host_up_check.mjs +31 -72
  12. package/plugins/http_probe.mjs +93 -25
  13. package/plugins/mcp_scanner.mjs +15 -2
  14. package/plugins/netbios_scanner.mjs +32 -13
  15. package/plugins/opensearch_scanner.mjs +4 -0
  16. package/plugins/port_scanner.mjs +29 -49
  17. package/plugins/result_concluder.mjs +122 -31
  18. package/plugins/snmp_scanner.mjs +20 -8
  19. package/plugins/syn_scanner.mjs +2 -1
  20. package/plugins/upnp_scanner.mjs +82 -13
  21. package/plugins/webapp_detector.mjs +82 -15
  22. package/utils/conclusion_utils.mjs +13 -1
  23. package/utils/cookie_redaction.mjs +50 -0
  24. package/utils/delta_reporter.mjs +70 -25
  25. package/utils/executive_report.mjs +34 -7
  26. package/utils/export_csv.mjs +14 -22
  27. package/utils/mcp_call_digest.mjs +31 -0
  28. package/utils/net_validation.mjs +107 -37
  29. package/utils/output_dir.mjs +4 -2
  30. package/utils/plugin_discovery.mjs +4 -2
  31. package/utils/ports_spec.mjs +46 -0
  32. package/utils/report_md.mjs +49 -125
  33. package/utils/sarif.mjs +33 -93
  34. package/utils/scan_delta.mjs +173 -18
  35. package/utils/scan_delta_view.mjs +3 -3
  36. package/utils/scan_history.mjs +149 -0
  37. package/utils/scheduler.mjs +17 -5
  38. package/utils/service_flags.mjs +363 -0
  39. package/utils/watch_cycle.mjs +37 -0
  40. package/utils/webhook.mjs +2 -1
@@ -24,7 +24,9 @@ export default {
24
24
  let banner = '';
25
25
  let up = false;
26
26
  let anonState = 'banner'; // banner | send-user | send-pass | done
27
- let anonymousLogin = false;
27
+ // 1.3.0 (s3): null unless MEASURED; anonTested is true when it was, else the reason it was not.
28
+ let anonymousLogin = null;
29
+ let anonTested = anonEnabled ? 'no-answer' : 'opt-in-off';
28
30
  let bannerCollected = false;
29
31
  let resolved = false;
30
32
  const safeResolve = (value) => { if (resolved) return; resolved = true; resolve(value); };
@@ -77,7 +79,7 @@ export default {
77
79
  response_banner: parsed.bannerTrimmed || null
78
80
  }];
79
81
 
80
- if (anonEnabled && anonymousLogin) {
82
+ if (anonEnabled && anonymousLogin === true) {
81
83
  evidenceRows.push({
82
84
  probe_protocol: 'tcp',
83
85
  probe_port: port,
@@ -94,9 +96,8 @@ export default {
94
96
  data: evidenceRows
95
97
  };
96
98
 
97
- if (anonEnabled) {
98
- result.anonymousLogin = anonymousLogin;
99
- }
99
+ result.anonymousLogin = anonymousLogin;
100
+ result.anonymousLoginTested = anonTested;
100
101
 
101
102
  dlog(`FTP Banner Check result: up=${up}, program=${parsed.program}, version=${parsed.version}, os=${parsed.os}, anonymousLogin=${anonymousLogin}, banner=${parsed.bannerTrimmed || 'none'}`);
102
103
  return result;
@@ -138,20 +139,22 @@ export default {
138
139
  } else if (response.startsWith('230')) {
139
140
  // Logged in without password
140
141
  anonymousLogin = true;
142
+ anonTested = true;
141
143
  anonState = 'done';
142
144
  socket.end();
143
145
  } else {
144
- // Unexpected response, treat as denied
146
+ // The server answered USER with something else — a refusal it made, so a measurement
147
+ anonymousLogin = false;
148
+ anonTested = true;
145
149
  anonState = 'done';
146
150
  socket.end();
147
151
  }
148
152
  } else if (anonState === 'send-pass') {
149
153
  const response = chunk.trim();
150
154
  dlog(`Anon PASS response: ${response}`);
151
- if (response.startsWith('230')) {
152
- anonymousLogin = true;
153
- }
154
- // 530/421 or anything else = denied
155
+ // 230 = accepted; 530/421 or anything else the server answered = refused. Either is a measurement.
156
+ anonymousLogin = response.startsWith('230');
157
+ anonTested = true;
155
158
  anonState = 'done';
156
159
  socket.end();
157
160
  }
@@ -170,6 +173,8 @@ export default {
170
173
  program: 'Unknown',
171
174
  version: 'Unknown',
172
175
  os: null,
176
+ anonymousLogin: null,
177
+ anonymousLoginTested: anonTested,
173
178
  data: [{
174
179
  probe_protocol: 'tcp',
175
180
  probe_port: port,
@@ -194,6 +199,8 @@ export default {
194
199
  program: 'Unknown',
195
200
  version: 'Unknown',
196
201
  os: null,
202
+ anonymousLogin: null,
203
+ anonymousLoginTested: anonTested,
197
204
  data: [{
198
205
  probe_protocol: 'tcp',
199
206
  probe_port: port,
@@ -237,9 +244,10 @@ export async function conclude({ host, result }) {
237
244
  authoritative: true
238
245
  };
239
246
 
240
- if (result?.anonymousLogin != null) {
241
- record.anonymousLogin = result.anonymousLogin;
242
- }
247
+ // 1.3.0 (s3): the result and its tested state, on every FTP record — null with no state is a result an earlier release
248
+ // wrote, which did not record whether the check ran.
249
+ record.anonymousLogin = result?.anonymousLogin ?? null;
250
+ record.anonymousLoginTested = result?.anonymousLoginTested ?? null;
243
251
 
244
252
  return [record];
245
253
  }
@@ -1,5 +1,6 @@
1
1
  // plugins/host_up_check.mjs
2
- // Real plugin to check if a host is up or down using ICMP (ping), TCP (common ports), and UDP (high closed port) probes.
2
+ // Real plugin that looks for evidence a host is up — ICMP (ping), TCP (common ports) and UDP (a high, likely-closed
3
+ // port). No answer is recorded as no evidence, never as down.
3
4
  // Updated to prioritize ICMP TTL-based OS detection, with TCP probes refining ambiguous TTLs (e.g., TTL 64) using banners and port heuristics.
4
5
  // Extracts router name and version from port 443 banner (e.g., Netgear R8000) and includes in result.
5
6
  // Returns { up: boolean, os: string|null, router_info: { name: string|null, version: string|null }|null, data: [{ probe_protocol, probe_port, probe_info, response_banner }] }.
@@ -76,7 +77,7 @@ const OS_SPECIFICITY = {
76
77
  export default {
77
78
  id: '005',
78
79
  name: 'Host Up Check',
79
- description: 'Checks if the host is up or down using ICMP, TCP (common ports), and UDP (high closed port) probes with enhanced OS detection.',
80
+ description: 'Looks for evidence that the host is up using ICMP, TCP (common ports) and UDP (a high, likely-closed port) probes, with enhanced OS detection; no answer is no evidence, not down.',
80
81
  priority: 20,
81
82
  requirements: { host: "down" }, // run when ping hasn't marked it UP (ping blocked/filtered)
82
83
  protocols: ['tcp', 'udp', 'icmp'],
@@ -245,88 +246,46 @@ export default {
245
246
  });
246
247
  }
247
248
 
248
- // UDP Probe (send to likely closed high port)
249
+ // UDP Probe (send to a likely-closed high port).
250
+ // 1.3.0 lane 1 F: a successful send is NOT evidence that the host is up — it says only that the
251
+ // local stack accepted the datagram. The leg used to set `up` in the send callback, clear the wait
252
+ // and close the socket, so a host that never answered read UP and the ICMP port-unreachable that
253
+ // follows a send (the real order on loopback) was discarded with the socket. Evidence of UP is that
254
+ // port-unreachable or a reply. Silence until the timeout is recorded as NO EVIDENCE, never as down,
255
+ // and costs up to UDP_REPLY_WAIT_MS on every run of this plugin where nothing answers on UDP.
256
+ // Settles once: the first of reply / port-unreachable / error / timeout writes the one UDP row.
249
257
  const udpPort = 54321;
258
+ const UDP_REPLY_WAIT_MS = 3000;
250
259
  await new Promise((resolve) => {
251
260
  dlog(`Attempting UDP probe on ${host}:${udpPort}`);
252
261
  const socket = dgram.createSocket('udp4');
253
- const timeoutId = setTimeout(() => {
254
- data.push({
255
- probe_protocol: 'udp',
256
- probe_port: udpPort,
257
- probe_info: 'Timeout - host possibly down',
258
- response_banner: null
259
- });
262
+ let settled = false;
263
+ const settle = (probe_info, answered) => {
264
+ if (settled) return;
265
+ settled = true;
266
+ clearTimeout(timeoutId);
267
+ if (answered) up = true;
268
+ data.push({ probe_protocol: 'udp', probe_port: udpPort, probe_info, response_banner: null });
260
269
  socket.close();
261
270
  resolve();
262
- }, 3000);
271
+ };
272
+ const timeoutId = setTimeout(
273
+ () => settle(`No reply within ${UDP_REPLY_WAIT_MS / 1000} s - no evidence either way`, false),
274
+ UDP_REPLY_WAIT_MS
275
+ );
263
276
 
277
+ socket.on('message', () => settle('UDP reply - host up', true));
264
278
  socket.on('error', (err) => {
265
- clearTimeout(timeoutId);
266
- if (err.code === 'ECONNREFUSED') {
267
- up = true;
268
- data.push({
269
- probe_protocol: 'udp',
270
- probe_port: udpPort,
271
- probe_info: 'ICMP Port Unreachable - host up',
272
- response_banner: null
273
- });
274
- } else {
275
- data.push({
276
- probe_protocol: 'udp',
277
- probe_port: udpPort,
278
- probe_info: `Error: ${err.code} - ${err.message}`,
279
- response_banner: null
280
- });
281
- }
282
- socket.close();
283
- resolve();
279
+ if (err.code === 'ECONNREFUSED') settle('ICMP Port Unreachable - host up', true);
280
+ else settle(`Error: ${err.code} - ${err.message}`, false);
284
281
  });
285
282
 
286
283
  socket.connect(udpPort, host, (err) => {
287
- if (err) {
288
- clearTimeout(timeoutId);
289
- data.push({
290
- probe_protocol: 'udp',
291
- probe_port: udpPort,
292
- probe_info: `Connect error: ${err.message}`,
293
- response_banner: null
294
- });
295
- socket.close();
296
- resolve();
297
- return;
298
- }
299
-
284
+ if (err) return settle(`Connect error: ${err.message}`, false);
300
285
  socket.send(Buffer.alloc(0), (err) => {
301
- clearTimeout(timeoutId);
302
- if (err) {
303
- if (err.code === 'ECONNREFUSED') {
304
- up = true;
305
- data.push({
306
- probe_protocol: 'udp',
307
- probe_port: udpPort,
308
- probe_info: 'ICMP Port Unreachable - host up',
309
- response_banner: null
310
- });
311
- } else {
312
- data.push({
313
- probe_protocol: 'udp',
314
- probe_port: udpPort,
315
- probe_info: `Send error: ${err.message}`,
316
- response_banner: null
317
- });
318
- }
319
- } else {
320
- up = true;
321
- data.push({
322
- probe_protocol: 'udp',
323
- probe_port: udpPort,
324
- probe_info: 'Send successful, no error - host up (port may be open)',
325
- response_banner: null
326
- });
327
- }
328
- socket.close();
329
- resolve();
286
+ if (!err) return; // sent — now wait for a reply, a port-unreachable or the timeout
287
+ if (err.code === 'ECONNREFUSED') settle('ICMP Port Unreachable - host up', true);
288
+ else settle(`Send error: ${err.message}`, false);
330
289
  });
331
290
  });
332
291
  });
@@ -6,16 +6,31 @@ import http from 'node:http';
6
6
  import https from 'node:https';
7
7
  import { promisify } from 'node:util';
8
8
  import { execFile } from 'node:child_process';
9
+ import { redactSetCookie, redactLocation } from '../utils/cookie_redaction.mjs';
9
10
 
10
11
  const execFileP = promisify(execFile);
11
12
 
12
13
  const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.HTTP_DEBUG || ""));
13
14
  function dlog(...a) { if (DEBUG) console.log("[http-probe]", ...a); }
14
15
 
16
+ /**
17
+ * The response headers 006 keeps on its record — ONE (1.3.0 item 1, ruled): Strict-Transport-Security, for Enterprise's
18
+ * Missing-HSTS check. A header map is a channel into every consumer of the record (artifacts, AI payloads, the webhook,
19
+ * GRC), so the set is held by a census over the real concluder's output (tests/service_flag_table.test.mjs), never
20
+ * widened silently.
21
+ */
22
+ export const RECORD_HEADERS = Object.freeze(['strict-transport-security']);
23
+ const recordHeaders = (h) => Object.fromEntries(RECORD_HEADERS.filter((k) => h?.[k] != null).map((k) => [k, String(h[k])]));
24
+
15
25
  function buildBanner(status, headers) {
16
26
  const lines = [];
17
27
  lines.push(`${status.code} ${status.message}`);
18
- // Capture the most useful fingerprinting headers
28
+ // Capture the most useful fingerprinting headers. Each VALUE kept here reaches the record, the artifacts and the AI
29
+ // prompt, so none may be a secret (1.3.0, the audit seat's ruling): a Set-Cookie value is the target's session token —
30
+ // its NAME is kept, its value redacted; a Location query can carry an SSO ticket or an OAuth code — the URL is kept
31
+ // without it. The rest describe the product or its configuration: an auth scheme and realm, a server string, a framing
32
+ // policy, a media type.
33
+ const redact = { 'set-cookie': redactSetCookie, location: redactLocation };
19
34
  const pick = [
20
35
  'www-authenticate',
21
36
  'server',
@@ -27,10 +42,11 @@ function buildBanner(status, headers) {
27
42
  for (const k of pick) {
28
43
  const v = headers[k];
29
44
  if (!v) continue;
45
+ const shown = redact[k] ?? String;
30
46
  if (Array.isArray(v)) {
31
- for (const vv of v) lines.push(`${k}: ${vv}`);
47
+ for (const vv of v) lines.push(`${k}: ${shown(vv)}`);
32
48
  } else {
33
- lines.push(`${k}: ${v}`);
49
+ lines.push(`${k}: ${shown(v)}`);
34
50
  }
35
51
  }
36
52
  return lines.join('\r\n').slice(0, 512); // Limit banner size
@@ -152,6 +168,10 @@ export default {
152
168
  const req = mod.request(reqOpts, async (res) => {
153
169
  const headers = res.headers;
154
170
  const status = { code: res.statusCode, message: res.statusMessage };
171
+ // 1.3.0 item 1: the allowlisted headers, on an HTTPS response only — a user agent ignores Strict-Transport-Security
172
+ // over plain HTTP (RFC 6797 §8.1). Set here, where a response ARRIVED: no response, no `headers` key at all, since an
173
+ // empty map would read as "the header is missing".
174
+ if (isHttps) result.headers = recordHeaders(headers);
155
175
 
156
176
  // Initial "up" via response
157
177
  result.up = true;
@@ -260,31 +280,79 @@ export default {
260
280
 
261
281
  // Keep OS null unless we have a trustworthy hint; HTTP headers rarely reveal OS reliably.
262
282
 
263
- // HTTP method testing via OPTIONS
283
+ // HTTP method testing via OPTIONS. `methodsTested` means AN ALLOW HEADER WAS READ — the probe has no other way to see
284
+ // the method list, so an OPTIONS answer without Allow (a 405, a 200) and no answer at all are both NOT TESTED:
285
+ // methodsTested false and null arrays, never [] (1.3.0, the audit seat's ruling; all three read [] before, so "not
286
+ // tested" read as "none dangerous"). Do not widen "tested" to "answered": a per-method probe is a different capability.
287
+ let optResult = null;
264
288
  try {
265
- const optResult = await probeOptions(host, port, isHttps, allowInsecure, timeoutMs);
266
- if (optResult?.allow) {
267
- const methods = optResult.allow.split(',').map(m => m.trim().toUpperCase()).filter(Boolean);
268
- const dangerous = methods.filter(m => ['PUT', 'DELETE', 'TRACE', 'CONNECT'].includes(m));
269
- result.allowedMethods = methods;
270
- result.dangerousMethods = dangerous;
271
- if (dangerous.length > 0) {
272
- result.data.push({
273
- probe_protocol: isHttps ? 'https' : 'http',
274
- probe_port: port,
275
- probe_info: `WARNING: Dangerous HTTP methods enabled: ${dangerous.join(', ')}`,
276
- response_banner: `Allow: ${optResult.allow}`
277
- });
278
- }
279
- } else {
280
- result.allowedMethods = [];
281
- result.dangerousMethods = [];
282
- }
289
+ optResult = await probeOptions(host, port, isHttps, allowInsecure, timeoutMs);
283
290
  } catch {
284
- result.allowedMethods = [];
285
- result.dangerousMethods = [];
291
+ optResult = null;
292
+ }
293
+ if (!optResult || !String(optResult.allow || '').trim()) {
294
+ result.methodsTested = false;
295
+ result.allowedMethods = null;
296
+ result.dangerousMethods = null;
297
+ result.data.push({
298
+ probe_protocol: isHttps ? 'https' : 'http',
299
+ probe_port: port,
300
+ probe_info: optResult
301
+ ? `OPTIONS answered ${optResult.status} with no Allow header — HTTP methods not tested`
302
+ : 'OPTIONS was not answered — HTTP methods not tested',
303
+ response_banner: null
304
+ });
305
+ } else {
306
+ const methods = String(optResult.allow || '').split(',').map(m => m.trim().toUpperCase()).filter(Boolean);
307
+ const dangerous = methods.filter(m => ['PUT', 'DELETE', 'TRACE', 'CONNECT'].includes(m));
308
+ result.methodsTested = true;
309
+ result.allowedMethods = methods;
310
+ result.dangerousMethods = dangerous;
311
+ if (dangerous.length > 0) {
312
+ result.data.push({
313
+ probe_protocol: isHttps ? 'https' : 'http',
314
+ probe_port: port,
315
+ probe_info: `WARNING: Dangerous HTTP methods enabled: ${dangerous.join(', ')}`,
316
+ response_banner: `Allow: ${optResult.allow}`
317
+ });
318
+ }
286
319
  }
287
320
 
288
321
  return result;
289
322
  },
290
- };
323
+ };
324
+ /**
325
+ * Concluder adapter (1.3.0 lane 3 (a4), the audit seat's Option A). The HTTP probe had no adapter, so its methods result
326
+ * never reached a service record. Its record lands EXACTLY where the concluder's fallback put it — the http / https key,
327
+ * the same program, version, status and source — so no service identity, CPE or Services-detected count moves across
328
+ * 1.2.0 -> 1.3.0; it adds only the methods result. `methodsTested` is true only when an Allow header was READ; otherwise
329
+ * both arrays are null (NOT TESTED, never "none"). Merging into the tcp key is a separate, boarded decision.
330
+ */
331
+ export function conclude({ result }) {
332
+ const rows = Array.isArray(result?.data) ? result.data : [];
333
+ const row = rows.find(Boolean) || {};
334
+ let status = result?.up ? 'open' : 'unknown';
335
+ if (row?.probe_info && /refused|ECONNREFUSED/i.test(String(row.probe_info))) status = 'closed';
336
+ return [{
337
+ port: Number(row?.probe_port ?? result?.port ?? 0),
338
+ protocol: row?.probe_protocol || result?.protocol || 'tcp',
339
+ service: 'http',
340
+ program: result?.program || 'Unknown',
341
+ version: result?.version || 'Unknown',
342
+ status,
343
+ info: row?.probe_info || null,
344
+ banner: row?.response_banner || null,
345
+ source: 'http',
346
+ evidence: rows,
347
+ // 1.3.0 item 1: the allowlisted response headers, on the HTTPS record only and only when a response arrived (run()
348
+ // sets `headers` there and nowhere else) — filtered again here, so the record can never carry a header outside
349
+ // RECORD_HEADERS whatever the result holds.
350
+ ...(row?.probe_protocol === 'https' && result?.headers && typeof result.headers === 'object'
351
+ ? { headers: recordHeaders(result.headers) } : {}),
352
+ ...('methodsTested' in (result || {}) ? {
353
+ methodsTested: result.methodsTested === true,
354
+ allowedMethods: result.methodsTested === true ? result.allowedMethods : null,
355
+ dangerousMethods: result.methodsTested === true ? result.dangerousMethods : null,
356
+ } : {}),
357
+ }];
358
+ }
@@ -42,6 +42,17 @@ export const MCP_CANDIDATE_PORTS = [1967, 3000, 3005, 5173, 6274, 6277, 8000, 80
42
42
  // network-reachable. Detection on a non-loopback target = MEDIUM finding.
43
43
  export const MCP_INSPECTOR_PORTS = new Set([5173, 6274, 6277]);
44
44
 
45
+ // The grade of each security flag buildFindings() sets, declared beside the code that sets it (the research grades in
46
+ // its comments). The shared service-flag table (utils/service_flags.mjs) reads this, so every reader grades a flag the
47
+ // moment it is added here — and tests/service_flag_table.test.mjs fails if buildFindings sets a flag this does not grade.
48
+ export const MCP_FLAG_SEVERITY = Object.freeze({
49
+ mcpAnonymousAccess: 'Critical',
50
+ mcpAnonymousToolList: 'Critical',
51
+ mcpCleartextTransport: 'High',
52
+ mcpDeprecatedProtocol: 'High',
53
+ mcpInspectorExposed: 'Medium',
54
+ });
55
+
45
56
  // MCP RPC method paths (in priority order — try canonical mountpoints first)
46
57
  export const MCP_PROBE_PATHS = ['/', '/mcp', '/jsonrpc', '/sse', '/messages'];
47
58
 
@@ -454,8 +465,10 @@ export function conclude({ result }) {
454
465
  detection.tools.length ? `tools=${detection.tools.length}` : null,
455
466
  ].filter(Boolean).join(' '),
456
467
  authoritative: true,
457
- // Security flags. scan_host returns them on this record; the Markdown report, SARIF, CSV and --fail-on do NOT read
458
- // them yet (1.2.0 build 3 states it; boarded for 1.2.1).
468
+ // The producer's own name for its records — the shared service-flag table keys the MCP rows on it (1.3.0 (s1) B).
469
+ source: 'mcp',
470
+ // Security flags, graded by MCP_FLAG_SEVERITY through the shared service-flag table — the Markdown report, SARIF,
471
+ // the CSV and --fail-on all read them there (1.3.0 (s1)).
459
472
  ...flags,
460
473
  // Evidence fields per N.5 FindingSchema
461
474
  evidence: { cwe, owasp, mitre },
@@ -452,19 +452,23 @@ async function probeMdnsSmb(){
452
452
  }
453
453
 
454
454
  // SMB2 null session probe — attempts anonymous auth, IPC$ tree connect, and share/user enum
455
- async function probeNullSession(host){
456
- const result = { nullSessionAllowed: false, shares: [], users: [] };
457
- if (!ENABLE_NULL_SESSION) return result;
455
+ // `port`, `enabled` and `timeoutMs` default to production's 445, SMB_NULL_SESSION and SMB_NULL_SESSION_TIMEOUT; they are
456
+ // parameters so the outcomes can be driven against a loopback listener.
457
+ export async function probeNullSession(host, { port = 445, enabled = ENABLE_NULL_SESSION, timeoutMs = NULL_SESSION_TIMEOUT } = {}){
458
+ // 1.3.0 (s3): null unless MEASURED; nullSessionTested is true when it was, else the reason it was not.
459
+ const result = { nullSessionAllowed: null, nullSessionTested: enabled ? 'no-answer' : 'opt-in-off', shares: [], users: [] };
460
+ if (!enabled) return result;
461
+ const measured = (allowed) => { result.nullSessionAllowed = allowed; result.nullSessionTested = true; };
458
462
 
459
463
  return new Promise(resolve => {
460
464
  let resolved = false;
461
465
  const safeResolve = (v) => { if (resolved) return; resolved = true; resolve(v); };
462
466
 
463
- const sock = net.createConnection({ host, port: 445 });
467
+ const sock = net.createConnection({ host, port });
464
468
  const to = setTimeout(() => {
465
469
  try { sock.destroy(); } catch {}
466
470
  safeResolve(result);
467
- }, NULL_SESSION_TIMEOUT);
471
+ }, timeoutMs);
468
472
 
469
473
  let phase = 'negotiate'; // negotiate | session-setup-1 | session-setup-2 | tree-connect | done
470
474
  let sessionIdLo = 0;
@@ -517,23 +521,25 @@ async function probeNullSession(host){
517
521
  // Some servers accept directly (guest/anonymous)
518
522
  sessionIdLo = hdr.sessionIdLo;
519
523
  sessionIdHi = hdr.sessionIdHi;
520
- result.nullSessionAllowed = true;
524
+ measured(true);
521
525
  phase = 'tree-connect';
522
526
  try { sock.write(wrapNbss(buildSmb2TreeConnect(host, sessionIdLo, sessionIdHi))); } catch { finish(); }
523
527
  } else {
524
- // ACCESS_DENIED or other — null session not allowed
528
+ // ACCESS_DENIED or other — null session not allowed (the server answered: a measurement)
525
529
  dlog('null-session: session setup denied, status=0x' + hdr.status.toString(16));
530
+ measured(false);
526
531
  finish();
527
532
  }
528
533
  } else if (phase === 'session-setup-2') {
529
534
  if (hdr.status === STATUS_SUCCESS) {
530
- result.nullSessionAllowed = true;
535
+ measured(true);
531
536
  sessionIdLo = hdr.sessionIdLo; // H2 fix: unconditional assign, not ||
532
537
  sessionIdHi = hdr.sessionIdHi;
533
538
  phase = 'tree-connect';
534
539
  try { sock.write(wrapNbss(buildSmb2TreeConnect(host, sessionIdLo, sessionIdHi))); } catch { finish(); }
535
540
  } else {
536
541
  dlog('null-session: session auth denied, status=0x' + hdr.status.toString(16));
542
+ measured(false);
537
543
  finish();
538
544
  }
539
545
  } else if (phase === 'tree-connect') {
@@ -638,30 +644,39 @@ export default {
638
644
  }
639
645
  }catch{ /* best-effort only */ }
640
646
 
641
- // SMB2 null session enumeration (opt-in via SMB_NULL_SESSION env)
642
- let nullSessionAllowed = false;
647
+ // SMB2 null session enumeration (opt-in via SMB_NULL_SESSION env). 1.3.0 (s3): null unless measured, with its state.
648
+ let nullSessionAllowed = null;
649
+ let nullSessionTested = ENABLE_NULL_SESSION ? 'no-answer' : 'opt-in-off';
643
650
  let shares = [];
644
651
  let users = [];
645
652
 
646
653
  try {
647
654
  const ns = await probeNullSession(host);
648
655
  nullSessionAllowed = ns.nullSessionAllowed;
656
+ nullSessionTested = ns.nullSessionTested;
649
657
  shares = ns.shares;
650
658
  users = ns.users;
651
- if (nullSessionAllowed) {
659
+ if (nullSessionAllowed === true) {
652
660
  data.push({
653
661
  probe_protocol: 'tcp',
654
662
  probe_port: 445,
655
663
  probe_info: 'WARNING: SMB null session authentication succeeded',
656
664
  response_banner: `Null session allowed. Shares: ${shares.length}, Users: ${users.length}`
657
665
  });
658
- } else if (ENABLE_NULL_SESSION) {
666
+ } else if (nullSessionAllowed === false) {
659
667
  data.push({
660
668
  probe_protocol: 'tcp',
661
669
  probe_port: 445,
662
670
  probe_info: 'SMB null session denied (good)',
663
671
  response_banner: null
664
672
  });
673
+ } else if (ENABLE_NULL_SESSION) {
674
+ data.push({
675
+ probe_protocol: 'tcp',
676
+ probe_port: 445,
677
+ probe_info: 'SMB null session not measured — the exchange did not complete',
678
+ response_banner: null
679
+ });
665
680
  }
666
681
  } catch {
667
682
  if (ENABLE_NULL_SESSION) {
@@ -680,6 +695,7 @@ export default {
680
695
  version,
681
696
  type: 'netbios/smb',
682
697
  nullSessionAllowed,
698
+ nullSessionTested,
683
699
  shares,
684
700
  users,
685
701
  data
@@ -706,7 +722,9 @@ export async function conclude({ host, result }){
706
722
  program: result?.program || 'Unknown',
707
723
  version: result?.version || 'Unknown',
708
724
  status, info, banner,
709
- nullSessionAllowed: result?.nullSessionAllowed ?? false,
725
+ // 1.3.0 (s3): null unless measured — the old default `false` read "refused" for a check that never ran.
726
+ nullSessionAllowed: result?.nullSessionAllowed ?? null,
727
+ nullSessionTested: result?.nullSessionTested ?? null,
710
728
  shares: result?.shares ?? [],
711
729
  users: result?.users ?? [],
712
730
  source: 'netbios',
@@ -725,6 +743,7 @@ export async function conclude({ host, result }){
725
743
  status: 'open', info: 'WARNING: SMB null session allowed — anonymous enumeration possible',
726
744
  banner: `Shares: ${(result.shares||[]).join(', ') || 'none'}, Users: ${(result.users||[]).join(', ') || 'none'}`,
727
745
  nullSessionAllowed: true,
746
+ nullSessionTested: true,
728
747
  shares: result?.shares ?? [],
729
748
  users: result?.users ?? [],
730
749
  source: 'netbios',
@@ -128,8 +128,12 @@ export default {
128
128
  const agent = await buildAgentIfNeeded(scheme, insecure);
129
129
  const ctrl = new AbortController();
130
130
  const to = setTimeout(() => ctrl.abort(), timeoutMs);
131
+ // 1.3.0 lane 2 (R2): never follow a redirect. The banner and version are read at `/`; a 3xx is
132
+ // the target's answer. Followed, a target could send the scanner to any host — loopback and
133
+ // cloud metadata included — with no SSRF check at the hop.
131
134
  const res = await doFetch(url, {
132
135
  method: 'GET',
136
+ redirect: 'manual',
133
137
  headers: {
134
138
  'User-Agent': 'opensearch-js/3.4.0 (opensearch-scanner)'
135
139
  },