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
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
 
@@ -247,17 +258,20 @@ export const TOOLS = [
247
258
  'Run a full plugin scan on a target host and return structured results: service detection, OS fingerprinting, and'
248
259
  + ' the flags the service checks leave on each service record — weak SSH algorithms, SNMP default community, weak '
249
260
  + 'TLS protocols / ciphers and a self-signed certificate, the MCP server checks, and anonymous FTP login and DNS '
250
- + 'zone transfer only when the server\'s environment enables those two checks (FTP_CHECK_ANON; DNS_CHECK_AXFR with '
251
- + 'DNS_AXFR_DOMAIN — both off by default). It runs, when their requirements are met, but does NOT return the '
252
- + 'results of the HTTP probe (006, dangerous HTTP methods), the NetBIOS/SMB scanner (014, null sessions and '
253
- + 'shares), the TLS-certificate (040), debug-endpoint (050) and DNS-security (060) auditors — use probe_service '
254
- + '(Pro) with that plugin — or, with the Enterprise package, its zero-trust assessment (1023). It does NOT look up '
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 '
255
270
  + 'CVEs and does NOT run the Enterprise analysis agents or exploit intelligence — so zero findings here is NOT a '
256
271
  + 'statement that the host has no known vulnerabilities. For CVEs, call get_vulnerabilities (Pro) with each '
257
272
  + 'service\'s cpe where it names a concrete version; a service whose cpe is null gets no lookup, so its CVE coverage'
258
- + ' is unknown, not clean. With the Enterprise package and a Pro or Enterprise licence, the CLI scan (nsauditor-ai '
259
- + 'scan --host <host>) runs the CVE mapper and the analysis agents, and joins exploit intelligence when a KEV / '
260
- + 'EPSS store is configured.',
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.',
261
275
  inputSchema: {
262
276
  type: 'object',
263
277
  properties: {
@@ -392,38 +406,48 @@ export const TOOLS = [
392
406
 
393
407
  /**
394
408
  * Validate host to prevent SSRF via loopback, link-local, or cloud metadata.
395
- * 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.
396
419
  * @param {string} host
397
420
  * @returns {Promise<string>} normalised hostname
398
421
  */
399
422
  export async function validateHost(host) {
400
423
  const h = String(host).trim().toLowerCase();
401
424
  if (!h) throw new Error('Empty host');
402
- // Fast-path: reject decimal-encoded loopback IPs (127.0.0.0/8).
403
- // Other private/link-local ranges (RFC 1918, 169.254.x.x) are caught by the regex below
404
- // 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.
405
427
  const _isAllDigits = /^\d+$/.test(h);
406
428
  const _n = _isAllDigits && h.length <= 10 ? Number(h) : -1;
407
429
  const isDecimalLoopback = _n >= 0x7F000000 && _n <= 0x7FFFFFFF;
408
430
  if (isDecimalLoopback || /^(localhost|127\.|0\.|::1|0\.0\.0\.0|169\.254\.|fe80:|metadata\.google)/i.test(h)) {
409
- throw new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
431
+ throw notAllowed();
410
432
  }
411
433
 
412
434
  // Cloud-sentinel hosts (aws/gcp/azure) are scoping tokens, NOT DNS names — they
413
435
  // route to the cloud-scanner plugins (probe_service enforces the cloud-intent
414
436
  // gate downstream; the CLI whitelists them the same way in scanSingleHost).
415
- // Skip the SSRF DNS resolution for them so the sentinel path is reachable
416
- // (otherwise dns.lookup('aws') ENOTFOUND is misread as a blocked address).
437
+ // They are never resolved (dns.lookup('aws') would be ENOTFOUND).
417
438
  if (isCloudSentinelHost(h)) return h;
418
439
 
419
- // DNS resolution check — catches rebinding, decimal/octal IPs, IPv6-mapped addrs.
420
- // NSA_ALLOW_ALL_HOSTS=1 bypasses RFC 1918 checks for local network auditing.
421
- if (!process.env.NSA_ALLOW_ALL_HOSTS) {
422
- try {
423
- await resolveAndValidate(h);
424
- } catch (err) {
425
- throw new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
426
- }
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}`);
427
451
  }
428
452
  return h;
429
453
  }
@@ -469,6 +493,8 @@ export async function handleScanHost(args) {
469
493
  host: output.host,
470
494
  conclusion: output.conclusion,
471
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 ?? [],
472
498
  });
473
499
  }
474
500
  } catch { /* swallow — markdown is best-effort */ }
@@ -477,7 +503,9 @@ export async function handleScanHost(args) {
477
503
  host: output.host,
478
504
  conclusion: output.conclusion ?? null,
479
505
  manifest: output.manifest ?? [],
480
- 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,
481
509
  markdown,
482
510
  };
483
511
  }
@@ -670,7 +698,7 @@ export async function handleProbeService(args) {
670
698
  const s = String(host).trim().toLowerCase();
671
699
  throw new Error(
672
700
  `Plugin ${plugin.id} (${plugin.name}) is a network plugin — it does not run against the ` +
673
- `'${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}".`,
674
702
  );
675
703
  }
676
704
  const hostKind = isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network';
@@ -875,10 +903,9 @@ export function createServer() {
875
903
  };
876
904
  }
877
905
 
878
- // CE 0.1.36 (Thread L Phase 2): mint a per-call sentinel UUID and
879
- // log it BEFORE the Pro-gate so even denials prove the call hit
880
- // the server. Fabricated responses cannot include a UUID that
881
- // 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)).
882
909
  const callId = await recordToolCall(name);
883
910
 
884
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.56",
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