nsauditor-ai 0.2.55 → 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 (42) hide show
  1. package/README.md +76 -62
  2. package/cli.mjs +210 -179
  3. package/mcp_server.mjs +71 -31
  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 -1
  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 -30
  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/ee_load.mjs +45 -0
  26. package/utils/executive_report.mjs +48 -8
  27. package/utils/export_csv.mjs +14 -22
  28. package/utils/mcp_call_digest.mjs +31 -0
  29. package/utils/net_validation.mjs +107 -37
  30. package/utils/output_dir.mjs +70 -0
  31. package/utils/plugin_discovery.mjs +4 -2
  32. package/utils/ports_spec.mjs +46 -0
  33. package/utils/report_inputs.mjs +64 -1
  34. package/utils/report_md.mjs +51 -112
  35. package/utils/sarif.mjs +33 -93
  36. package/utils/scan_delta.mjs +514 -38
  37. package/utils/scan_delta_view.mjs +5 -3
  38. package/utils/scan_history.mjs +149 -0
  39. package/utils/scheduler.mjs +17 -5
  40. package/utils/service_flags.mjs +363 -0
  41. package/utils/watch_cycle.mjs +37 -0
  42. package/utils/webhook.mjs +2 -1
package/mcp_server.mjs CHANGED
@@ -12,11 +12,12 @@ import { dirname, join } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { homedir } from 'node:os';
14
14
  import { randomUUID } from 'node:crypto';
15
+ import { responseDigest, RECEIPT_MARKER } from './utils/mcp_call_digest.mjs';
15
16
  import { appendFile, mkdir, chmod } from 'node:fs/promises';
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
19
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
- import { resolveAndValidate } from './utils/net_validation.mjs';
20
+ import { resolveAndValidate, classifyAddress, canonicalIp, allowAllHosts } from './utils/net_validation.mjs';
20
21
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
22
  import {
22
23
  CallToolRequestSchema,
@@ -175,10 +176,11 @@ export function buildScanCloudRegionIntent(regions) {
175
176
  // 1. Embedded in the response text (Claude cannot omit; it's the payload)
176
177
  // 2. Persisted to ~/.nsauditor/mcp-calls.log (append-only, mode 0600)
177
178
  //
178
- // Customer verification: paste the UUID from Claude into
179
- // nsauditor-ai mcp verify-call <uuid>
180
- // If it appears in the local log → real call. If not → hallucinated.
181
- // (See cli.mjs `verify-call` subcommand.)
179
+ // Customer verification: save the response (footer included) and run
180
+ // nsauditor-ai mcp verify-call <uuid> --response <file>
181
+ // The id alone proves only that this server issued it once — an AI client that has seen an earlier response can paste
182
+ // a real id under a fabricated one. So the server also logs a digest of each response BODY (utils/mcp_call_digest.mjs),
183
+ // and verify-call recomputes it from the saved text (1.3.0 (s6)). See cli.mjs `verify-call`.
182
184
  const MCP_CALL_LOG_PATH = join(homedir(), '.nsauditor', 'mcp-calls.log');
183
185
 
184
186
  async function recordToolCall(toolName) {
@@ -200,11 +202,20 @@ async function recordToolCall(toolName) {
200
202
  }
201
203
 
202
204
  function appendCallSentinel(text, callId) {
205
+ // The digest of the BODY — the text the receipt is appended to — is what binds this response to its id. It is
206
+ // logged, never printed: a digest in the footer would replay together with the id. Best-effort like the id line: a
207
+ // failed write leaves the call verifiable only as "issued", never as a false ✓.
208
+ try {
209
+ _nodeFs.appendFileSync(MCP_CALL_LOG_PATH, JSON.stringify({ call_id: callId, digest: responseDigest(text) }) + '\n', { encoding: 'utf8' });
210
+ } catch (err) {
211
+ process.stderr.write(`[nsauditor-mcp] call-log digest write failed: ${err.message}\n`);
212
+ }
203
213
  return (
204
- `${text}\n\n── Verified MCP call ──\n` +
214
+ `${text}\n\n${RECEIPT_MARKER}\n` +
205
215
  `call_id: ${callId}\n` +
206
- `Verify (proves Claude actually called this server, not hallucinated):\n` +
207
- ` nsauditor-ai mcp verify-call ${callId}`
216
+ `To check that this exact response came from this server, save the whole response (footer included)\n` +
217
+ `to a file and run:\n` +
218
+ ` nsauditor-ai mcp verify-call ${callId} --response <file>`
208
219
  );
209
220
  }
210
221
 
@@ -244,7 +255,23 @@ export const TOOLS = [
244
255
  {
245
256
  name: 'scan_host',
246
257
  description:
247
- 'Run a full plugin scan on a target host and return structured results including service detection, OS fingerprinting, and security findings.',
258
+ 'Run a full plugin scan on a target host and return structured results: service detection, OS fingerprinting, and'
259
+ + ' the flags the service checks leave on each service record — weak SSH algorithms, SNMP default community, weak '
260
+ + 'TLS protocols / ciphers and a self-signed certificate, the MCP server checks, and anonymous FTP login and DNS '
261
+ + 'zone transfer only when the server\'s environment enables them (FTP_CHECK_ANON; DNS_CHECK_AXFR with '
262
+ + 'DNS_AXFR_DOMAIN — both off by default). The records also carry the HTTP probe\'s methods (006, dangerousMethods; '
263
+ + 'methodsTested false is not tested, never "none"), the NetBIOS/SMB null-session check (014, nullSessionAllowed; '
264
+ + 'opt-in, SMB_NULL_SESSION) and the TLS-certificate (040, certAudit) and debug-endpoint (050, tribeHealth) audits. For a domain-name target the DNS-security audit (060, dnsSecurity) lands '
265
+ + 'on a 53/udp record if the scan found one, else in the conclusion\'s evidence; an IP-address target is DECLINED '
266
+ + '(manifest: skipped, with the reason; markdown: NOT TESTED), so DNS posture is unknown, not clean. Opt-in checks carry '
267
+ + 'axfrTested / anonymousLoginTested / nullSessionTested; a null result is NOT TESTED, never "refused". Enterprise\'s '
268
+ + 'zero-trust assessment (1023) reaches the conclusion only as one score line in its evidence, not its '
269
+ + 'per-dimension findings. It does NOT look up '
270
+ + 'CVEs and does NOT run the Enterprise analysis agents or exploit intelligence — so zero findings here is NOT a '
271
+ + 'statement that the host has no known vulnerabilities. For CVEs, call get_vulnerabilities (Pro) with each '
272
+ + 'service\'s cpe where it names a concrete version; a service whose cpe is null gets no lookup, so its CVE coverage'
273
+ + ' is unknown, not clean. The CLI scan (Enterprise package, Pro or Enterprise licence) runs the CVE mapper, the '
274
+ + 'analysis agents and, with a KEV / EPSS store, exploit intelligence.',
248
275
  inputSchema: {
249
276
  type: 'object',
250
277
  properties: {
@@ -335,7 +362,7 @@ export const TOOLS = [
335
362
  },
336
363
  pluginName: {
337
364
  type: 'string',
338
- description: 'Plugin name or ID to run (e.g. "ssh_scanner" or "002")',
365
+ description: 'Plugin ID (e.g. "002") or its full name as list_plugins returns it, in any case (e.g. "SSH Scanner")',
339
366
  },
340
367
  },
341
368
  required: ['host', 'port', 'pluginName'],
@@ -379,38 +406,48 @@ export const TOOLS = [
379
406
 
380
407
  /**
381
408
  * Validate host to prevent SSRF via loopback, link-local, or cloud metadata.
382
- * Performs DNS resolution to defeat rebinding / encoded-IP bypasses.
409
+ *
410
+ * A literal is classified by VALUE in any spelling (net_validation.classifyAddress); a name is
411
+ * resolved and EVERY answer is classified — in both arms of NSA_ALLOW_ALL_HOSTS. Allow-all admits
412
+ * private ranges only: loopback, unspecified, link-local and metadata stay refused, by literal or
413
+ * by answer. A name that does not resolve is refused in both arms.
414
+ *
415
+ * ⚠️ LIMIT (stated, 1.3.0): this does NOT pin the resolved address. It returns the NAME, and each
416
+ * plugin resolves it again when it connects, so a name whose answer changes between this check
417
+ * and the scan (DNS rebinding) is not caught. Pinning is boarded for a later release; it is not in 1.3.0.
418
+ * Before 1.3.0 this comment said the resolution "defeats rebinding"; it never did.
383
419
  * @param {string} host
384
420
  * @returns {Promise<string>} normalised hostname
385
421
  */
386
422
  export async function validateHost(host) {
387
423
  const h = String(host).trim().toLowerCase();
388
424
  if (!h) throw new Error('Empty host');
389
- // Fast-path: reject decimal-encoded loopback IPs (127.0.0.0/8).
390
- // Other private/link-local ranges (RFC 1918, 169.254.x.x) are caught by the regex below
391
- // and by the DNS resolveAndValidate() layer for all encoding forms.
425
+ const notAllowed = () => new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
426
+ // Cheap pre-check for the obvious spellings; the classifier below is the authority.
392
427
  const _isAllDigits = /^\d+$/.test(h);
393
428
  const _n = _isAllDigits && h.length <= 10 ? Number(h) : -1;
394
429
  const isDecimalLoopback = _n >= 0x7F000000 && _n <= 0x7FFFFFFF;
395
430
  if (isDecimalLoopback || /^(localhost|127\.|0\.|::1|0\.0\.0\.0|169\.254\.|fe80:|metadata\.google)/i.test(h)) {
396
- throw new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
431
+ throw notAllowed();
397
432
  }
398
433
 
399
434
  // Cloud-sentinel hosts (aws/gcp/azure) are scoping tokens, NOT DNS names — they
400
435
  // route to the cloud-scanner plugins (probe_service enforces the cloud-intent
401
436
  // gate downstream; the CLI whitelists them the same way in scanSingleHost).
402
- // Skip the SSRF DNS resolution for them so the sentinel path is reachable
403
- // (otherwise dns.lookup('aws') ENOTFOUND is misread as a blocked address).
437
+ // They are never resolved (dns.lookup('aws') would be ENOTFOUND).
404
438
  if (isCloudSentinelHost(h)) return h;
405
439
 
406
- // DNS resolution check — catches rebinding, decimal/octal IPs, IPv6-mapped addrs.
407
- // NSA_ALLOW_ALL_HOSTS=1 bypasses RFC 1918 checks for local network auditing.
408
- if (!process.env.NSA_ALLOW_ALL_HOSTS) {
409
- try {
410
- await resolveAndValidate(h);
411
- } catch (err) {
412
- throw new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
413
- }
440
+ // NSA_ALLOW_ALL_HOSTS (1/true/yes/on only) admits PRIVATE ranges for local-network auditing.
441
+ const allowPrivate = allowAllHosts();
442
+ const cls = classifyAddress(h);
443
+ if (cls === 'always' || (cls === 'private' && !allowPrivate)) throw notAllowed();
444
+ if (canonicalIp(h)) return h; // a permitted literal — nothing to resolve
445
+
446
+ try {
447
+ await resolveAndValidate(h, { allowPrivate });
448
+ } catch (err) {
449
+ if (/blocked/.test(String(err && err.message))) throw notAllowed();
450
+ throw new Error(`Host could not be resolved via MCP: ${h}`);
414
451
  }
415
452
  return h;
416
453
  }
@@ -456,6 +493,8 @@ export async function handleScanHost(args) {
456
493
  host: output.host,
457
494
  conclusion: output.conclusion,
458
495
  toolVersion: TOOL_VERSION,
496
+ // Build 4 (Gate 3-A F-1): the manifest, so an audit that did not run — 060's decline of an IP address — is said.
497
+ manifest: output.manifest ?? [],
459
498
  });
460
499
  }
461
500
  } catch { /* swallow — markdown is best-effort */ }
@@ -464,7 +503,9 @@ export async function handleScanHost(args) {
464
503
  host: output.host,
465
504
  conclusion: output.conclusion ?? null,
466
505
  manifest: output.manifest ?? [],
467
- pluginsRan: output.results?.length ?? 0,
506
+ // Plugins that RAN, from the manifest — not output.results, which holds one wrapped run per port plus
507
+ // error / timeout / gate-skip envelopes. Same count scan_cloud reports.
508
+ pluginsRan: (output.manifest || []).filter((m) => m.status === 'ran').length,
468
509
  markdown,
469
510
  };
470
511
  }
@@ -657,7 +698,7 @@ export async function handleProbeService(args) {
657
698
  const s = String(host).trim().toLowerCase();
658
699
  throw new Error(
659
700
  `Plugin ${plugin.id} (${plugin.name}) is a network plugin — it does not run against the ` +
660
- `'${s}' cloud sentinel. Use a network host/IP/CIDR, or a cloud auditor with host: "${s}".`,
701
+ `'${s}' cloud sentinel. Use a network host or IP address, or a cloud auditor with host: "${s}".`,
661
702
  );
662
703
  }
663
704
  const hostKind = isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network';
@@ -862,10 +903,9 @@ export function createServer() {
862
903
  };
863
904
  }
864
905
 
865
- // CE 0.1.36 (Thread L Phase 2): mint a per-call sentinel UUID and
866
- // log it BEFORE the Pro-gate so even denials prove the call hit
867
- // the server. Fabricated responses cannot include a UUID that
868
- // exists in the customer's local log file.
906
+ // CE 0.1.36 (Thread L Phase 2): mint a per-call UUID and log it BEFORE the Pro-gate, so even a denial shows the
907
+ // call hit the server. The id alone does NOT authenticate a response — a fabricated response can carry a real id
908
+ // copied from an earlier one — so the receipt also logs a digest of the body (appendCallSentinel, 1.3.0 (s6)).
869
909
  const callId = await recordToolCall(name);
870
910
 
871
911
  // Gate Pro-tier tools at the MCP dispatch layer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.55",
3
+ "version": "0.2.57",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -274,6 +274,16 @@ async function callPlugin(mod, host, ctx, priorOutputs = null, cliOpts = {}) {
274
274
  });
275
275
  }
276
276
 
277
+ // A UDP row opens its port only when it ANSWERED (1.3.0, B6-4c). The row's own `status` decides when it carries one (the
278
+ // port scanner's 'open' / 'no-response'); otherwise only POSITIVE text counts, never a negative that CONTAINS a positive
279
+ // phrase: "No UDP response" matched `udp response` and "No SNMP response for community …" matched `snmp response`, so a
280
+ // plugin gated on `udp_open` read 'ran' over a silent port.
281
+ const UDP_NEGATIVE = /^no\b|no response|\berror\b/;
282
+ function udpRowAnswered(d, info) {
283
+ if (d?.status != null) return String(d.status).toLowerCase() === "open";
284
+ return !UDP_NEGATIVE.test(info) && /udp response|snmp response|sysdescr|pdu/.test(info);
285
+ }
286
+
277
287
  // Heuristics to update context from any plugin's result
278
288
  function updateContextFromResult(mod, result, ctx) {
279
289
  try {
@@ -336,9 +346,9 @@ function updateContextFromResult(mod, result, ctx) {
336
346
  }
337
347
  }
338
348
 
339
- // UDP hints (rare)
349
+ // UDP hints (rare) — only a row that ANSWERED opens its port (B6-4c, `udpRowAnswered`).
340
350
  if (proto === "udp" && Number.isFinite(port)) {
341
- if (/udp response|snmp response|sysdescr|pdu/.test(info)) {
351
+ if (udpRowAnswered(d, info)) {
342
352
  ctx.udpOpen.add(port);
343
353
  }
344
354
  }
@@ -373,7 +383,8 @@ function updateContextFromResult(mod, result, ctx) {
373
383
  }
374
384
  if (id === "007" || name.includes("snmp")) {
375
385
  const first = rows[0];
376
- if (/snmp response/.test(safeLower(first?.probe_info || "")) && Number.isFinite(first?.probe_port)) {
386
+ const firstInfo = safeLower(first?.probe_info || "");
387
+ if (udpRowAnswered(first, firstInfo) && /snmp response/.test(firstInfo) && Number.isFinite(first?.probe_port)) {
377
388
  ctx.udpOpen.add(Number(first.probe_port));
378
389
  }
379
390
  }
@@ -390,7 +401,8 @@ function updateContextFromResult(mod, result, ctx) {
390
401
  */
391
402
  export function describeSkipReason(mod, ctx) {
392
403
  const req = mod?.requirements || {};
393
- if (req.host === 'up' && !ctx.hostUp) return 'host not up';
404
+ // No probe answered, which is no evidence either way — never "down" (1.3.0 lane 1 F).
405
+ if (req.host === 'up' && !ctx.hostUp) return 'no evidence the host is up';
394
406
  if (req.host === 'down' && ctx.hostUp) return 'host is up (requires down)';
395
407
  if (Array.isArray(req.tcp_open) && req.tcp_open.length) {
396
408
  const missing = req.tcp_open.filter(p => !ctx.tcpOpen.has(p));
@@ -690,6 +702,30 @@ export class PluginManager {
690
702
  return mergeResultObjects(plugin, filtered);
691
703
  }
692
704
 
705
+ /**
706
+ * The adapter registry handed to the concluder, by plugin ID (1.3.0): every loaded plugin's `conclude` — named or on
707
+ * its default object, as discovery attaches it — with its authoritativePorts and cloudProvider. Ids are a
708
+ * correctness key here, so a built-in wins: CE over EE over a custom NSAUDITOR_PLUGIN_PATH plugin that reuses an id
709
+ * (which would otherwise capture that plugin's records).
710
+ */
711
+ _concluderAdapters() {
712
+ const RANK = { ce: 0, ee: 1, custom: 2 };
713
+ const map = new Map();
714
+ for (const p of this.plugins || []) {
715
+ if (isConcluder(p) || typeof p?.conclude !== 'function') continue;
716
+ const id = String(p.id);
717
+ const rank = RANK[p._source] ?? 1;
718
+ const prev = map.get(id);
719
+ if (prev && prev.rank <= rank) {
720
+ if (prev.rank < rank) vlog(`plugin id ${id} from ${p._source} ignored for the concluder: a ${prev.source} plugin owns it`);
721
+ continue;
722
+ }
723
+ map.set(id, { rank, source: p._source ?? null, name: p.name ?? null, conclude: p.conclude.bind(p),
724
+ authoritativePorts: p.authoritativePorts ?? null, cloudProvider: p.cloudProvider ?? null });
725
+ }
726
+ return map;
727
+ }
728
+
693
729
  async runConcluder(resultsArray) {
694
730
  const concluder =
695
731
  this.plugins.find((p) => p.id === "008") ||
@@ -702,16 +738,17 @@ export class PluginManager {
702
738
  }
703
739
 
704
740
  try {
741
+ const adapters = this._concluderAdapters();
705
742
  // Support both signatures:
706
- // 1) run(pluginResults)
707
- // 2) run(host, port, { results })
743
+ // 1) run(pluginResults, { adapters })
744
+ // 2) run(host, port, { results, adapters })
708
745
  let conclusion;
709
746
  if (concluder.run.length >= 3) {
710
747
  vlog("Running Result Concluder with plugin results (opts.results signature):", JSON.stringify(resultsArray, null, 2));
711
- conclusion = await concluder.run(null, 0, { results: resultsArray, context: withBaseContext({}) });
748
+ conclusion = await concluder.run(null, 0, { results: resultsArray, context: withBaseContext({}), adapters });
712
749
  } else {
713
750
  vlog("Running Result Concluder with plugin results (single-arg signature):", JSON.stringify(resultsArray, null, 2));
714
- conclusion = await concluder.run(resultsArray);
751
+ conclusion = await concluder.run(resultsArray, { adapters });
715
752
  }
716
753
 
717
754
  vlog("Result Concluder raw output:", JSON.stringify(conclusion, null, 2));
@@ -795,6 +832,9 @@ export class PluginManager {
795
832
  reason: describeSkipReason(mod, ctx),
796
833
  duration_ms: 0,
797
834
  });
835
+ // Requested and NOT measured: the same three-state map the run path writes below, so a
836
+ // late consumer cannot read a gated-out upstream as one nobody requested (D26-3).
837
+ ctx.pluginRunStatus.set(String(mod.id || ''), 'skipped');
798
838
  continue;
799
839
  }
800
840
 
@@ -807,6 +847,7 @@ export class PluginManager {
807
847
  reason: `missing capabilities: ${(mod.requiredCapabilities || []).join(',')}`,
808
848
  duration_ms: 0,
809
849
  });
850
+ ctx.pluginRunStatus.set(String(mod.id || ''), 'skipped');
810
851
  continue;
811
852
  }
812
853