nsauditor-ai 0.2.22 → 0.2.23

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/README.md CHANGED
@@ -17,7 +17,7 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
17
17
 
18
18
  ## What's New
19
19
 
20
- **Latest: CE 0.2.22 + Enterprise 0.31.8** (July 2026) — paired bump for the Enterprise "GAP-1 positive-substrate polishframework-aware PCI Req 10.5.1 caveat restoration + SOC 2 GRC-push hygiene" cycle. No new framework, no new plugins (still 28), all seven coverage matrices unchanged; no CE engine change. Requires CE 0.2.8+. Full detail in **[CHANGELOG.md](./CHANGELOG.md)**.
20
+ **Latest: CE 0.2.23 + Enterprise 0.31.9** (July 2026) — **operator bug-fix cycle (real CE engine/CLI/MCP change).** Fixes three operator-reported bugs from a live test: **(BUG2b) cloud-scan scope integrity** cloud auditor plugins ran during a `--host 192.168.1.1` **network** scan; a cloud auditor now runs **only** on its own cloud sentinel (`aws`/`gcp`/`azure`), never on a network host, never via credentials-in-env, and never via an explicit `--plugins`/MCP `probe_service` selection (`--host` is the sole cloud-intent signal); **(BUG1) AI-conclusion robustness** — the AI conclusion no longer aborts on cloud-scale payloads (payload-scaled timeout, 120s→600s, `NSA_AI_TIMEOUT_MS`-overridable) and writes a **visible** `scan_response_ai.txt` stub on failure instead of failing quiet; **(BUG2a)** the AI bail message names the unresolved API key instead of misreporting "AI_ENABLED=false". Externally reviewed across multiple rounds → 🟢 PASS. Paired **Enterprise 0.31.9** (GAP-1 #3 RoC positive-substrate render-site parity). No new framework, no new plugins (still 28), all seven coverage matrices unchanged. Requires CE 0.2.8+. Full detail in **[CHANGELOG.md](./CHANGELOG.md)**.
21
21
 
22
22
  → See a sample EE scan output: **[walk-through with synthetic Acme Corp AWS account](https://www.nsauditor.com/ai/docs/sample-scan/)** (no signup required)
23
23
 
@@ -66,6 +66,16 @@ If you're heading into a **SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS C
66
66
  → **[See sample EE scan output](https://www.nsauditor.com/ai/docs/sample-scan/)** — full evidence pack against synthetic Acme Corp AWS account (no signup required)
67
67
  → **[Buy NSAuditor AI Enterprise Edition](https://www.nsauditor.com/ai/pricing/)** — $2k / $5k / $10k+ per year for 5 / 25 / unlimited seats + custom SLA. Onboarding call included.
68
68
 
69
+ ### Prefer to buy through AWS Marketplace?
70
+
71
+ Enterprise Edition is also available as an **[AWS Marketplace container listing](https://aws.amazon.com/marketplace/pp?sku=etar8knc8dx7bshizrrnnjbzi)** — same product, same local ES256 license key, billed through your AWS account (consolidated billing / EDP drawdown, procurement-friendly; custom Enterprise terms via AWS Private Offers). *Launching now in limited availability — if the listing shows "not available," it hasn't gone fully public yet: [contact us](https://www.nsauditor.com/support.html) for immediate access via a Private Offer.*
72
+
73
+ How Marketplace fulfillment works (ZDE and air-gap preserved — no runtime AWS dependency at scan time):
74
+
75
+ 1. **Subscribe** on the listing (tiers `base` / `growth` / `scale` mirror the 5 / 25 / unlimited-seat plans).
76
+ 2. **Register** your email + AWS account ID at the URL shown in the listing's usage instructions — your **ES256 license key arrives by email**.
77
+ 3. **Pull and run** the Docker image from the Marketplace registry (commands in the listing's usage instructions and your license email) with `NSAUDITOR_LICENSE_KEY=<your key>`. One tier-agnostic image — upgrades are just a new license key, never a new image. The container runs fully offline after that; billing lives in AWS, enforcement is your local key.
78
+
69
79
  ### Feature comparison
70
80
 
71
81
  | | Community (Free) | Pro ($49/mo) | Enterprise ($2k+/yr) |
package/cli.mjs CHANGED
@@ -18,6 +18,7 @@ import { buildSarifLog } from './utils/sarif.mjs';
18
18
  import { buildCsv } from './utils/export_csv.mjs';
19
19
  import { buildMarkdownReport } from './utils/report_md.mjs';
20
20
  import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE } from './utils/scan_history.mjs';
21
+ import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine } from './utils/ai_stage.mjs';
21
22
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
22
23
  import { resolveCapabilities, hasCapability, inferRequiredTier } from './utils/capabilities.mjs';
23
24
  import { createScheduler } from './utils/scheduler.mjs';
@@ -185,7 +186,8 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
185
186
  console.warn('[OpenAI] No conclusion.summary available; skipping.');
186
187
  return {
187
188
  file_paths: { folder: outDir, plain: null, ai_json: null, raw_json: null, html: null, admin_html: null },
188
- ai_conclusion: null
189
+ ai_conclusion: null,
190
+ ai_status: 'no_summary',
189
191
  };
190
192
  }
191
193
 
@@ -306,10 +308,13 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
306
308
  // --- Bail out early if sending disabled -----------------------------------
307
309
  const providerLabel = aiProvider === 'claude' ? 'Claude' : aiProvider === 'ollama' ? 'Ollama' : 'OpenAI';
308
310
  if (!sendEnabled || !key) {
309
- console.log(`[${providerLabel}] AI_ENABLED=false; not sending. Model=${model}`);
311
+ // BUG2(a): distinguish "AI disabled" from "AI enabled but key unresolved" —
312
+ // the old single message misdiagnosed a keychain/env key miss as AI_ENABLED=false.
313
+ console.log(aiBailMessage({ sendEnabled, key, aiProvider, model }));
310
314
  return {
311
315
  file_paths: { folder: outDir, plain: null, ai_json: null, raw_json: adminRawPath, html: null, admin_html: adminHtmlPath },
312
- ai_conclusion: null
316
+ ai_conclusion: null,
317
+ ai_status: sendEnabled ? 'key_unresolved' : 'skipped',
313
318
  };
314
319
  }
315
320
 
@@ -335,14 +340,32 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
335
340
 
336
341
  // --- Send to AI provider ---------------------------------------------------
337
342
  let aiConclusionText = null;
343
+ // review fold 8: contain the serialization — hoisting userContent above the
344
+ // try (so the catch can name the timeout) must not let a JSON.stringify throw
345
+ // (e.g. a circular payload) escape maybeSendToOpenAI uncaught. (payloadForAI is
346
+ // already serialized once at the aiPayloadPath write above, so this is
347
+ // defensive; the fallback keeps the timeout math + stub path alive.)
348
+ let userContent;
349
+ try {
350
+ userContent = `Scan payload:\n${JSON.stringify(payloadForAI, null, 2)}`;
351
+ } catch (e) {
352
+ console.warn(`[${providerLabel}] Failed to serialize AI payload:`, e?.message || e);
353
+ userContent = `Scan payload:\n${String(payloadForAI?.summary ?? '(unserializable payload)')}`;
354
+ }
355
+ // AbortController timeout — prevents the pipeline hanging on a stalled AI provider.
356
+ // BUG1: scale with payload size (cloud-scale 28-40KB payloads exceeded the old
357
+ // flat 120s default and aborted); an explicit NSA_AI_TIMEOUT_MS still wins.
358
+ // Hoisted above the try so the catch's fail-visible stub can name the timeout.
359
+ const AI_TIMEOUT_MS = computeAiTimeoutMs({
360
+ payloadBytes: Buffer.byteLength(userContent, 'utf8'), // optional fold: UTF-8 bytes, not UTF-16 units
361
+ host,
362
+ envOverride: process.env.NSA_AI_TIMEOUT_MS,
363
+ });
338
364
  try {
339
365
  console.log(`[${providerLabel}] Sending summary, model:`, model);
340
366
 
341
367
  let resp;
342
- const userContent = `Scan payload:\n${JSON.stringify(payloadForAI, null, 2)}`;
343
368
 
344
- // AbortController timeout — prevents the pipeline hanging on a stalled AI provider.
345
- const AI_TIMEOUT_MS = Number(process.env.NSA_AI_TIMEOUT_MS) || 120_000; // 2 min default
346
369
  const ac = new AbortController();
347
370
  const aiTimer = setTimeout(() => ac.abort(), AI_TIMEOUT_MS);
348
371
 
@@ -350,7 +373,9 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
350
373
  if (aiProvider === 'claude') {
351
374
  // --- Claude (Anthropic) ---
352
375
  const { default: Anthropic } = await import('@anthropic-ai/sdk');
353
- const client = new Anthropic({ apiKey: key });
376
+ // R-3: maxRetries:0 so the outer AbortController/timeout can't race the
377
+ // SDK's own internal retry loop (which would multiply the wall-clock).
378
+ const client = new Anthropic({ apiKey: key, maxRetries: 0 });
354
379
 
355
380
  resp = await client.messages.create({
356
381
  model,
@@ -359,7 +384,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
359
384
  messages: [
360
385
  { role: 'user', content: userContent }
361
386
  ]
362
- }, { signal: ac.signal });
387
+ }, { signal: ac.signal, timeout: AI_TIMEOUT_MS });
363
388
 
364
389
  console.log(`[${providerLabel}] Response id:`, resp?.id ?? '(unknown)');
365
390
 
@@ -373,7 +398,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
373
398
  // --- Ollama (OpenAI-compatible API) ---
374
399
  const { default: OpenAI } = await import('openai');
375
400
  const ollamaBase = process.env.OLLAMA_BASE_URL || 'http://localhost:11434/v1';
376
- const client = new OpenAI({ baseURL: ollamaBase, apiKey: key });
401
+ const client = new OpenAI({ baseURL: ollamaBase, apiKey: key, maxRetries: 0 }); // R-3
377
402
 
378
403
  resp = await client.chat.completions.create({
379
404
  model,
@@ -381,7 +406,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
381
406
  { role: 'system', content: promptText },
382
407
  { role: 'user', content: userContent }
383
408
  ]
384
- }, { signal: ac.signal });
409
+ }, { signal: ac.signal, timeout: AI_TIMEOUT_MS });
385
410
 
386
411
  console.log(`[${providerLabel}] Response id:`, resp?.id ?? '(unknown)');
387
412
 
@@ -389,7 +414,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
389
414
  } else {
390
415
  // --- OpenAI ---
391
416
  const { default: OpenAI } = await import('openai');
392
- const client = new OpenAI({ apiKey: key });
417
+ const client = new OpenAI({ apiKey: key, maxRetries: 0 }); // R-3
393
418
 
394
419
  if (client.responses?.create) {
395
420
  resp = await client.responses.create({
@@ -398,7 +423,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
398
423
  { role: 'system', content: promptText },
399
424
  { role: 'user', content: userContent }
400
425
  ]
401
- }, { signal: ac.signal });
426
+ }, { signal: ac.signal, timeout: AI_TIMEOUT_MS });
402
427
  } else if (client.chat?.completions?.create) {
403
428
  resp = await client.chat.completions.create({
404
429
  model,
@@ -406,7 +431,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
406
431
  { role: 'system', content: promptText },
407
432
  { role: 'user', content: userContent }
408
433
  ]
409
- }, { signal: ac.signal });
434
+ }, { signal: ac.signal, timeout: AI_TIMEOUT_MS });
410
435
  } else {
411
436
  throw new Error('OpenAI SDK: neither responses.create nor chat.completions.create is available.');
412
437
  }
@@ -491,13 +516,17 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
491
516
  html: aiHtmlPath,
492
517
  admin_html: adminHtmlPath
493
518
  },
494
- ai_conclusion: aiConclusionText
519
+ ai_conclusion: aiConclusionText,
520
+ // review fold D: a 200 with no extractable text is NOT "ok" — the
521
+ // end-of-scan summary must not read "OK" when there is no conclusion.
522
+ ai_status: (typeof aiConclusionText === 'string' && aiConclusionText.trim()) ? 'ok' : 'empty',
495
523
  };
496
524
  } catch (err) {
497
- console.error(`[${providerLabel}] Send failed:`, err?.stack || err?.message || String(err));
525
+ const errMsg = String(err?.message || err);
526
+ console.error(`[${providerLabel}] Send failed:`, err?.stack || errMsg);
498
527
  try {
499
528
  await fsp.writeFile(aiErrPath, JSON.stringify({
500
- error: String(err?.message || err),
529
+ error: errMsg,
501
530
  stack: err?.stack || null,
502
531
  provider: aiProvider,
503
532
  model
@@ -507,16 +536,38 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
507
536
  console.warn(`[${providerLabel}] Also failed to write error file:`, e?.message || e);
508
537
  }
509
538
 
539
+ // BUG1 (fail-VISIBLE): also write a human-readable scan_response_ai.txt stub
540
+ // so the operator sees WHY there is no AI conclusion (the JSON error file +
541
+ // the console.error above both scroll away in a 3-cloud run). Names the error
542
+ // and, on a timeout/abort, the NSA_AI_TIMEOUT_MS remedy.
543
+ let aiFailureStubPath = null;
544
+ try {
545
+ await fsp.writeFile(aiTxtPath, aiFailureStubText({
546
+ host, model, providerLabel, errorMessage: errMsg,
547
+ timeoutMs: AI_TIMEOUT_MS, whenIso: new Date().toISOString(),
548
+ }), 'utf8');
549
+ aiFailureStubPath = aiTxtPath;
550
+ console.log(`[${providerLabel}] Wrote AI failure stub:`, aiTxtPath);
551
+ } catch (e) {
552
+ console.warn(`[${providerLabel}] Also failed to write AI failure stub:`, e?.message || e);
553
+ }
554
+
510
555
  return {
511
556
  file_paths: {
512
557
  folder: outDir,
558
+ // review fold 16: keep `plain` NULL on failure — a downstream consumer
559
+ // uses `plain != null` as an AI-success proxy. The visible stub is
560
+ // surfaced on its own `ai_failure_stub` key + via ai_status/ai_error.
513
561
  plain: null,
514
562
  ai_json: null,
563
+ ai_failure_stub: aiFailureStubPath,
515
564
  raw_json: adminRawPath,
516
565
  html: null,
517
566
  admin_html: adminHtmlPath
518
567
  },
519
- ai_conclusion: null
568
+ ai_conclusion: null,
569
+ ai_status: 'failed',
570
+ ai_error: errMsg,
520
571
  };
521
572
  }
522
573
  }
@@ -705,14 +756,17 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
705
756
  }
706
757
  } catch { /* EE not installed — CE proceeds unchanged */ }
707
758
 
708
- const { file_paths: ai_file_paths, ai_conclusion } = await maybeSendToOpenAI({ host, results, conclusion, promptMode, outDir });
759
+ const { file_paths: ai_file_paths, ai_conclusion, ai_status, ai_error } = await maybeSendToOpenAI({ host, results, conclusion, promptMode, outDir });
760
+ // BUG1 (fail-VISIBLE): a one-line end-of-scan AI status so a failed/aborted AI
761
+ // conclusion is not silently swallowed under the per-stage logs.
762
+ console.log(aiSummaryLine({ host, ai_status, ai_error }));
709
763
 
710
764
  // --- Scan history: record & compare ---
711
765
  let scanDiff = null;
712
766
  try {
713
767
  const outRoot = toCleanPath(process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out').replace(/\.[^/.]+$/, '') || 'out';
714
768
  const services = conclusion?.result?.services ?? [];
715
- const findingsCount = services.reduce((n, svc) => {
769
+ const serviceFindingsCount = services.reduce((n, svc) => {
716
770
  if (svc.anonymousLogin === true) n++;
717
771
  if (svc.axfrAllowed === true) n++;
718
772
  if (Array.isArray(svc.weakAlgorithms)) n += svc.weakAlgorithms.length;
@@ -721,6 +775,15 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
721
775
  if (Array.isArray(cves)) n += cves.length;
722
776
  return n;
723
777
  }, 0);
778
+ // review fold R-1: cloud plugins emit findings on `results[].result.findings`,
779
+ // NOT as service-level attrs — so a cloud (--host aws) scan recorded
780
+ // findingsCount:0 in scan_history over a 201-finding scan (a false-clean
781
+ // history channel). Roll cloud findings into findingsCount + surface the split.
782
+ const cloudFindingsCount = (results || []).reduce((n, r) => {
783
+ const f = r?.result?.findings;
784
+ return n + (Array.isArray(f) ? f.length : 0);
785
+ }, 0);
786
+ const findingsCount = serviceFindingsCount + cloudFindingsCount;
724
787
 
725
788
  const scanSummary = {
726
789
  timestamp: new Date().toISOString(),
@@ -729,6 +792,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
729
792
  openPorts: services.filter((s) => s.status === 'open').map((s) => s.port),
730
793
  os: conclusion?.result?.host?.os ?? null,
731
794
  findingsCount,
795
+ cloudFindingsCount,
732
796
  services: services.map((s) => ({
733
797
  port: s.port, protocol: s.protocol ?? 'tcp',
734
798
  service: s.service ?? null, version: s.version ?? null,
@@ -755,7 +819,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
755
819
  console.warn('[ScanHistory] Failed to record/compare scan:', err?.message || err);
756
820
  }
757
821
 
758
- return { host, results, conclusion, ai_file_paths, ai_conclusion, scanDiff };
822
+ return { host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff };
759
823
  }
760
824
 
761
825
  /* -------------------- CI/CD severity threshold helpers ------------------- */
@@ -930,6 +994,13 @@ Cloud-scan hosts:
930
994
  single cloud and is therefore skipped under this
931
995
  auto-scope; to run it, select it explicitly
932
996
  (--plugins 023,...) or scan a network host/CIDR.
997
+ INVERSE (cloud scope integrity): a cloud auditor runs
998
+ ONLY on its own sentinel host. On a NETWORK host (IP /
999
+ CIDR / hostname) the cloud-scanner plugins NEVER run —
1000
+ not via --plugins all, not via an explicit --plugins
1001
+ 1020, and not because cloud credentials are present in
1002
+ the environment. '--host' is the sole cloud-scan
1003
+ trigger; credentials are a capability, not intent.
933
1004
 
934
1005
  Examples:
935
1006
  nsauditor-ai scan --host 10.0.0.1 --plugins all
package/mcp_server.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  ListToolsRequestSchema,
24
24
  } from '@modelcontextprotocol/sdk/types.js';
25
25
  import { buildRegionIntent } from './utils/region_intent.mjs';
26
+ import { excludeMismatchedCloudPlugins, isCloudSentinelHost } from './utils/sentinel_scope.mjs';
26
27
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
27
28
  import { resolveCapabilities } from './utils/capabilities.mjs';
28
29
  import { buildMarkdownReport } from './utils/report_md.mjs';
@@ -343,6 +344,13 @@ export async function validateHost(host) {
343
344
  throw new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
344
345
  }
345
346
 
347
+ // Cloud-sentinel hosts (aws/gcp/azure) are scoping tokens, NOT DNS names — they
348
+ // route to the cloud-scanner plugins (probe_service enforces the cloud-intent
349
+ // gate downstream; the CLI whitelists them the same way in scanSingleHost).
350
+ // Skip the SSRF DNS resolution for them so the sentinel path is reachable
351
+ // (otherwise dns.lookup('aws') ENOTFOUND is misread as a blocked address).
352
+ if (isCloudSentinelHost(h)) return h;
353
+
346
354
  // DNS resolution check — catches rebinding, decimal/octal IPs, IPv6-mapped addrs.
347
355
  // NSA_ALLOW_ALL_HOSTS=1 bypasses RFC 1918 checks for local network auditing.
348
356
  if (!process.env.NSA_ALLOW_ALL_HOSTS) {
@@ -564,7 +572,36 @@ export async function handleProbeService(args) {
564
572
  throw new Error(`Unknown plugin: ${args.pluginName}`);
565
573
  }
566
574
 
567
- const result = await pm._runOne(plugin, host, args.port);
575
+ // M-1 (BUG2b): probe_service is the LIVE MCP single-plugin route (dispatches
576
+ // via _runOne). It must honor the same cloud-intent contract as run()/runByName
577
+ // — a cloud auditor runs IFF the host is ITS sentinel; a network host (or the
578
+ // wrong cloud) must NEVER trigger it, even with cloud creds in the env.
579
+ const gate = excludeMismatchedCloudPlugins([plugin], host);
580
+ if (gate.skipped.length) {
581
+ const need = plugin.cloudProvider;
582
+ throw new Error(
583
+ `Plugin ${plugin.id} (${plugin.name}) is a ${need} cloud auditor — it does not run against ` +
584
+ `${gate.sentinel ? `the '${gate.sentinel}' cloud` : `network host '${host}'`}. ` +
585
+ `For a cloud audit use the scan_cloud tool (providers: ["${need}"]), or call probe_service ` +
586
+ `with host: "${need}". Cloud auditors run only on their own cloud; credentials in the ` +
587
+ `environment are a capability, not an intent signal.`,
588
+ );
589
+ }
590
+ // M1-SSRF (fold): validateHost whitelists the cloud-sentinel literals past the
591
+ // SSRF DNS check. That is safe ONLY because a sentinel host must reach a CLOUD
592
+ // plugin (which ignores `host`). A NETWORK plugin (no cloudProvider) routed a
593
+ // sentinel host would socket.connect(port, 'aws') without SSRF re-validation —
594
+ // a bypass if the literal resolves internally. Reject it, restoring the
595
+ // invariant that scopeSelectionForHost enforces on the pm.run() path.
596
+ if (isCloudSentinelHost(host) && !plugin.cloudProvider) {
597
+ const s = String(host).trim().toLowerCase();
598
+ throw new Error(
599
+ `Plugin ${plugin.id} (${plugin.name}) is a network plugin — it does not run against the ` +
600
+ `'${s}' cloud sentinel. Use a network host/IP/CIDR, or a cloud auditor with host: "${s}".`,
601
+ );
602
+ }
603
+ const hostKind = isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network';
604
+ const result = await pm._runOne(plugin, host, args.port, { hostKind });
568
605
  return result;
569
606
  }
570
607
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -20,7 +20,7 @@ import { pathToFileURL, fileURLToPath } from "url";
20
20
  import { discoverPlugins } from './utils/plugin_discovery.mjs';
21
21
  import { getTierFromEnv } from './utils/license.mjs';
22
22
  import { resolveCapabilities } from './utils/capabilities.mjs';
23
- import { scopeSelectionForHost, scopeSelectionForProviders } from './utils/sentinel_scope.mjs';
23
+ import { scopeSelectionForHost, scopeSelectionForProviders, excludeMismatchedCloudPlugins, isCloudSentinelHost } from './utils/sentinel_scope.mjs';
24
24
  import { mapLimit } from './utils/concurrency.mjs';
25
25
 
26
26
  const __filename = fileURLToPath(import.meta.url);
@@ -588,7 +588,25 @@ export class PluginManager {
588
588
  return await this.runConcluder(resultsArg);
589
589
  }
590
590
 
591
- const arr = await this._runAcrossPorts(plugin, host, opts);
591
+ // BUG2(b): runByName (a public PluginManager entry) honors the same
592
+ // cloud-intent contract as run() — a cloud auditor runs ONLY on its own
593
+ // sentinel host — and threads opts.hostKind. Defense-in-depth for direct
594
+ // library consumers; the LIVE MCP single-plugin route (probe_service →
595
+ // _runOne) is guarded at its own call site in mcp_server.handleProbeService.
596
+ const rb = excludeMismatchedCloudPlugins([plugin], host);
597
+ if (rb.skipped.length) {
598
+ const need = plugin.cloudProvider;
599
+ console.error(
600
+ `Plugin ${plugin.id} (${plugin.name}) is a ${need} cloud auditor — it runs only on --host ${need} ` +
601
+ `(host '${host}' is ${rb.sentinel ? `the '${rb.sentinel}' cloud` : 'a network target'}). Skipping.`,
602
+ );
603
+ return { id: plugin.id, name: plugin.name, result: { up: false, skipped: true, data: [] } };
604
+ }
605
+ const rbOpts = {
606
+ ...opts,
607
+ hostKind: isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network',
608
+ };
609
+ const arr = await this._runAcrossPorts(plugin, host, rbOpts);
592
610
  const filtered = arr.filter(Boolean);
593
611
  if (filtered.length === 0) return { id: plugin.id, name: plugin.name, result: { up: false, data: [] } };
594
612
  if (filtered.length === 1) return filtered[0];
@@ -769,6 +787,17 @@ export class PluginManager {
769
787
  opts = maybeOpts || {};
770
788
  }
771
789
 
790
+ // BUG2(b): tag the host kind so plugins + the EE cloud gate can honor the
791
+ // "--host is the sole cloud-intent signal" contract as defense-in-depth
792
+ // (alongside the network-host exclusion below). Cloned so the caller's opts
793
+ // object — shared across concurrent hosts in the CLI multi-host path — is
794
+ // never mutated; hostKind stays a scalar string so the shallow opts spread
795
+ // in callPlugin (:152) / _runOne (:517) forwards it verbatim to every plugin.
796
+ opts = {
797
+ ...opts,
798
+ hostKind: isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network',
799
+ };
800
+
772
801
  // Sentinel-host scoping: on --host aws|gcp|azure with the implicit `all`,
773
802
  // run only that cloud's plugins; skip other clouds + non-cloud plugins.
774
803
  const scope = scopeSelectionForHost(selection, host, rawSpec);
@@ -790,16 +819,70 @@ export class PluginManager {
790
819
  }
791
820
  }
792
821
 
822
+ // BUG2(b): a cloud auditor (any plugin with a `cloudProvider` tag) runs IFF
823
+ // the host is ITS sentinel — the symmetric inverse of the sentinel scoping
824
+ // above. Fires for the implicit `all` AND an explicit `--plugins 1020`
825
+ // selection: a network scan must NEVER audit a cloud estate (even with cloud
826
+ // creds in the env), and a `--host gcp` scan must not run an explicitly-listed
827
+ // AWS auditor. '--host' is the sole cloud-intent signal — no escape hatch.
828
+ const netScope = excludeMismatchedCloudPlugins(selection, host);
829
+ // review fold R-5: excluded cloud plugins are removed from `selection` before
830
+ // dispatch, so without a manifest entry they leave no machine-readable trace
831
+ // in pm.run()'s result. (The requirements/capability skip classes DO push a
832
+ // manifest entry; the sentinel-scope skip at :812 does not — a separate
833
+ // pre-existing gap, not addressed here.) A `skipped` entry surfaces the
834
+ // exclusion to MCP scan_host responses + pm.run() library consumers (the CLI
835
+ // does not currently render the manifest). One entry per excluded plugin.
836
+ const cloudSkipManifest = netScope.skipped.map((p) => ({
837
+ id: String(p?.id || ''),
838
+ name: p?.name || 'Plugin',
839
+ status: 'skipped',
840
+ reason: `${p?.cloudProvider || 'cloud'} cloud auditor requires --host ${p?.cloudProvider || 'aws|gcp|azure'} ` +
841
+ `(host '${host}' is ${netScope.sentinel ? `the '${netScope.sentinel}' cloud` : 'a network target'})`,
842
+ duration_ms: 0,
843
+ }));
844
+ if (netScope.excludedCloud && netScope.skipped.length) {
845
+ selection = netScope.selected;
846
+ const ids = netScope.skipped.map((p) => p?.id ?? '?').join(', ');
847
+ if (netScope.sentinel) {
848
+ // foreign-cloud plugin explicitly selected on a mismatched sentinel host
849
+ const foreign = [...new Set(netScope.skipped.map((p) => p?.cloudProvider).filter(Boolean))].join('/');
850
+ console.error(
851
+ `Host '${host}' is the '${netScope.sentinel}' cloud → skipping ${netScope.skipped.length} ` +
852
+ `non-${netScope.sentinel} cloud auditor(s) [${ids}] (${foreign} auditors require --host ${foreign}). ` +
853
+ `A cloud auditor runs only on its own sentinel host — '--host' is the sole cloud-scan trigger.`,
854
+ );
855
+ } else {
856
+ console.error(
857
+ `Host '${host}' is a network target → skipping ${netScope.skipped.length} cloud auditor(s) ` +
858
+ `[${ids}]: cloud plugins require a cloud sentinel host (--host aws|gcp|azure). ` +
859
+ `Credentials in the environment are a capability, not an intent signal — '--host' is the ` +
860
+ `sole cloud-scan trigger. (To audit a cloud, run e.g. \`--host aws\`.)`,
861
+ );
862
+ }
863
+ // review fold 4: if the exclusion emptied the selection, say so loudly — an
864
+ // empty result is NOT a clean pass (mirrors the sentinel zero-match warning).
865
+ if (selection.length === 0) {
866
+ console.error(
867
+ `WARNING: after excluding cloud auditors for host '${host}', ZERO plugins remain — ` +
868
+ `NOTHING was audited (an empty result is NOT a clean pass). To audit a cloud, use --host aws|gcp|azure.`,
869
+ );
870
+ }
871
+ }
872
+
793
873
  // Decide execution mode
794
874
  const anyOrchestratedSignals = selection.some((p) => p?.priority != null || p?.requirements != null);
795
875
  const orchestrate = opts.orchestrate !== false && anyOrchestratedSignals;
796
876
 
797
877
  let results = [];
798
878
  let manifest = [];
879
+ // R-5: surface excluded cloud auditors as skipped manifest entries (before
880
+ // the dispatched plugins), so the exclusion is machine-visible, not stderr-only.
881
+ if (cloudSkipManifest.length) manifest.push(...cloudSkipManifest);
799
882
  if (orchestrate) {
800
883
  const orch = await this._runOrchestrated(host, selection, opts);
801
884
  results = orch.results;
802
- manifest = orch.manifest;
885
+ manifest = manifest.concat(orch.manifest);
803
886
  } else {
804
887
  // Legacy path: simple run across ports for each plugin (except concluder)
805
888
  const toRun = selection.filter((p) => !isConcluder(p));
@@ -884,7 +967,11 @@ export class PluginManager {
884
967
  return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status: 'skipped', reason: `missing capabilities: ${(mod.requiredCapabilities || []).join(',')}`, duration_ms: 0 }, outputs: [] };
885
968
  }
886
969
  const startMs = Date.now();
887
- const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, timeoutMs });
970
+ // BUG2(b) fold B: thread hostKind on the MCP/cloud-parallel path too, so
971
+ // the defense-in-depth signal is present on EVERY cloud dispatch route
972
+ // (selection here is already scoped to each plugin's cloudProvider).
973
+ const cloudHostKind = mod.cloudProvider ? `cloud:${mod.cloudProvider}` : opts.hostKind;
974
+ const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, timeoutMs, hostKind: cloudHostKind });
888
975
  const duration_ms = Date.now() - startMs;
889
976
  let status = 'ran';
890
977
  let reason = null;
@@ -0,0 +1,129 @@
1
+ // utils/ai_stage.mjs
2
+ //
3
+ // Pure, testable helpers for the CLI AI-conclusion stage (cli.mjs
4
+ // maybeSendToOpenAI). Extracted so the BUG1 (payload-scaled timeout +
5
+ // fail-visible failure) and BUG2(a) (disabled-vs-key bail message) logic can be
6
+ // unit-tested without importing the CLI entrypoint. No I/O, no side effects.
7
+
8
+ /** Display label for an AI provider id. */
9
+ export function aiProviderLabel(aiProvider) {
10
+ return aiProvider === 'claude' ? 'Claude' : aiProvider === 'ollama' ? 'Ollama' : 'OpenAI';
11
+ }
12
+
13
+ /** The env var an operator sets for a provider's key (null for keyless ollama). */
14
+ export function aiKeyEnvVar(aiProvider) {
15
+ if (aiProvider === 'claude') return 'ANTHROPIC_API_KEY';
16
+ if (aiProvider === 'ollama') return null;
17
+ return 'OPENAI_API_KEY';
18
+ }
19
+
20
+ /**
21
+ * BUG2(a): the AI-stage bail message. The old code printed a single
22
+ * "AI_ENABLED=false; not sending" for BOTH `!sendEnabled` and `!key`, so an
23
+ * operator who set AI_ENABLED=true but whose key failed to resolve (unset env or
24
+ * a missing `keychain:<label>` entry) was told AI was disabled — a misdiagnosis.
25
+ * This distinguishes the two, and on a key miss names the provider-specific env
26
+ * var + the keychain-reference possibility.
27
+ *
28
+ * @returns {string} the log line to print at the bail site.
29
+ */
30
+ export function aiBailMessage({ sendEnabled, key, aiProvider, model }) {
31
+ const label = aiProviderLabel(aiProvider);
32
+ if (!sendEnabled) {
33
+ return `[${label}] AI_ENABLED is not set to true — skipping AI conclusion. Model=${model}`;
34
+ }
35
+ // sendEnabled === true but the key did not resolve.
36
+ const envVar = aiKeyEnvVar(aiProvider);
37
+ const ref = envVar
38
+ ? `\`${envVar}\` is unset/empty, or its \`keychain:<label>\` reference did not resolve`
39
+ : 'the provider key did not resolve';
40
+ return `[${label}] AI_ENABLED=true but the API key could not be resolved — ${ref}. Skipping AI conclusion. Model=${model}`;
41
+ }
42
+
43
+ /**
44
+ * BUG1: scale the AI-call timeout with the payload size. The fixed 120s
45
+ * AbortController aborted cloud-scale scans (AWS 28KB / Azure 40KB payloads with
46
+ * 200+ findings) whose 16k-token conclusions take longer than 2 min to generate,
47
+ * while a small 4-5KB network-host payload finished well inside 120s. An explicit
48
+ * NSA_AI_TIMEOUT_MS (envOverride) always wins. Otherwise: floor 120s (preserves
49
+ * network-host behavior), +15s per KB of payload, ceiling 600s (10 min).
50
+ *
51
+ * @param {{payloadBytes?: number, host?: string, envOverride?: string|number}} o
52
+ * @returns {number} timeout in ms.
53
+ */
54
+ export function computeAiTimeoutMs({ payloadBytes = 0, host = '', envOverride } = {}) {
55
+ const explicit = Number(envOverride);
56
+ // optional fold: an explicit override wins, truncated to an integer ms (a
57
+ // fractional value would otherwise be passed verbatim to setTimeout/the SDK).
58
+ if (Number.isFinite(explicit) && explicit > 0) return Math.trunc(explicit);
59
+ const FLOOR_MS = 120_000; // 2 min — unchanged for small network-host payloads
60
+ const CEIL_MS = 600_000; // 10 min hard cap
61
+ const MS_PER_KB = 15_000;
62
+ const kb = Math.max(0, Number(payloadBytes) || 0) / 1024;
63
+ const scaled = FLOOR_MS + Math.round(kb * MS_PER_KB);
64
+ return Math.min(CEIL_MS, Math.max(FLOOR_MS, scaled));
65
+ }
66
+
67
+ /**
68
+ * BUG1 (fail-VISIBLE): on an AI failure the pipeline used to write only a JSON
69
+ * error file + a console.error that scrolls away in a 3-cloud run, so the
70
+ * operator just saw "no AI conclusion". This produces a human-readable
71
+ * scan_response_ai.txt body that names the error and — when it looks like a
72
+ * timeout/abort — the NSA_AI_TIMEOUT_MS remedy.
73
+ *
74
+ * @returns {string} the scan_response_ai.txt body for the failure case.
75
+ */
76
+ export function aiFailureStubText({ host, model, providerLabel, errorMessage, timeoutMs, whenIso }) {
77
+ // review fold 9 + R-3: match the local AbortController abort ("Request was
78
+ // aborted.") AND the SDK request-timeout ("Request timed out." — the
79
+ // APIConnectionTimeoutError the SDK `timeout` option raises, racing the
80
+ // AbortController per fold-10) — both cases NSA_AI_TIMEOUT_MS addresses. We do
81
+ // NOT match the raw undici "Connect Timeout Error" / socket ETIMEDOUT strings
82
+ // (raising the local timeout is the wrong remedy for a TCP-connect failure).
83
+ // Caveat: the Anthropic SDK reclassifies a connect-timeout as "Request timed
84
+ // out." — so on the Claude path a connect failure may still surface the remedy;
85
+ // that is acceptable (a wrapped SDK timeout is plausibly the local window).
86
+ const aborted = /\babort|request timed out/i.test(String(errorMessage || ''));
87
+ const lines = [
88
+ `Model: ${model}`,
89
+ `Provider: ${providerLabel}`,
90
+ `When: ${whenIso}`,
91
+ `Host: ${host}`,
92
+ ``,
93
+ `==== ${providerLabel} Conclusion: FAILED ====`,
94
+ `The AI conclusion could NOT be generated for this scan.`,
95
+ `Error: ${errorMessage}`,
96
+ ];
97
+ if (aborted) {
98
+ const secs = Math.round((Number(timeoutMs) || 0) / 1000);
99
+ lines.push(
100
+ ``,
101
+ `This looks like a TIMEOUT — the request was aborted${secs ? ` after ~${secs}s` : ''}. Large`,
102
+ `cloud-scan payloads can exceed the AI timeout. Re-run with a longer timeout, e.g.:`,
103
+ ` NSA_AI_TIMEOUT_MS=600000 nsauditor-ai --host <target> ...`,
104
+ );
105
+ }
106
+ return lines.join('\n');
107
+ }
108
+
109
+ /**
110
+ * One-line end-of-scan summary so an AI failure/skip is visible without scrolling
111
+ * the per-stage logs. `ai_status` covers EVERY value maybeSendToOpenAI returns:
112
+ * 'ok' — a conclusion was generated
113
+ * 'empty' — provider returned 200 but no extractable text (NOT ok)
114
+ * 'skipped' — AI disabled (AI_ENABLED not true)
115
+ * 'key_unresolved'— AI enabled but the API key did not resolve
116
+ * 'no_summary' — no scan summary to send
117
+ * 'failed' — the provider call threw (carries ai_error as the cause)
118
+ * Any unknown status falls to the FAILED branch (fail-visible default).
119
+ */
120
+ export function aiSummaryLine({ host, ai_status, ai_error }) {
121
+ switch (ai_status) {
122
+ case 'ok': return `AI conclusion: OK (${host})`;
123
+ case 'empty': return `AI conclusion: EMPTY — provider returned no text (${host})`;
124
+ case 'skipped': return `AI conclusion: SKIPPED — AI_ENABLED not set (${host})`;
125
+ case 'key_unresolved': return `AI conclusion: SKIPPED — API key could not be resolved (${host})`;
126
+ case 'no_summary': return `AI conclusion: SKIPPED — no scan summary to send (${host})`;
127
+ default: return `AI conclusion: FAILED (${host})${ai_error ? ` — ${ai_error}` : ''}`;
128
+ }
129
+ }
@@ -31,6 +31,10 @@ export async function recordScan(outputDir, summary) {
31
31
  openPorts: Array.isArray(summary.openPorts) ? summary.openPorts : [],
32
32
  os: summary.os ?? null,
33
33
  findingsCount: summary.findingsCount ?? 0,
34
+ // review re-fold R-1: persist the cloud/service split so a cloud (--host aws)
35
+ // scan's findings are machine-visible in history (findingsCount already
36
+ // includes them; this surfaces how many came from cloud auditors).
37
+ cloudFindingsCount: summary.cloudFindingsCount ?? 0,
34
38
  services: Array.isArray(summary.services) ? summary.services.map((s) => ({
35
39
  port: s.port ?? null,
36
40
  protocol: s.protocol ?? 'tcp',
@@ -4,8 +4,14 @@
4
4
  // (`aws`/`gcp`/`azure`) and the plugin selection is the IMPLICIT `all`, run only
5
5
  // the plugins tagged with that provider (`plugin.cloudProvider`), skipping the
6
6
  // other clouds + the non-cloud network plugins (which would otherwise probe the
7
- // literal string "aws" and emit unreachable-host noise). Explicit plugin
8
- // selections are always honored as-is.
7
+ // literal string "aws" and emit unreachable-host noise).
8
+ //
9
+ // scopeSelectionForHost honors an EXPLICIT plugin selection as-is (it only scopes
10
+ // the implicit `all`). The CLOUD-INTENT contract itself, however, is NOT waivable
11
+ // by explicit selection: excludeMismatchedCloudPlugins (BUG2b) strips any cloud
12
+ // auditor whose cloudProvider does not match the host's sentinel — including an
13
+ // explicitly-listed foreign-cloud plugin and every cloud plugin on a network host.
14
+ // '--host' is the sole cloud-scan trigger; credentials/selection are not intent.
9
15
 
10
16
  export const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
11
17
 
@@ -41,6 +47,39 @@ export function scopeSelectionForHost(selection, host, spec) {
41
47
  return { selected, skipped, scoped: true, provider };
42
48
  }
43
49
 
50
+ /**
51
+ * BUG2(b) (operator-confirmed contract 2026-07-03): the INVERSE of
52
+ * scopeSelectionForHost — a cloud auditor plugin runs IF AND ONLY IF the host is
53
+ * ITS OWN cloud sentinel. `--host` is the sole cloud-intent signal; credentials
54
+ * in the environment are a capability, never intent; there is NO escape hatch
55
+ * (not the implicit `all`, not an explicit `--plugins 1020` selection).
56
+ *
57
+ * Strips every cloud-tagged plugin whose `cloudProvider` does NOT match the
58
+ * host's sentinel:
59
+ * - NETWORK host (IP / CIDR / hostname → sentinel === null): ALL cloud plugins
60
+ * are foreign → all stripped (the reported router-scan bug).
61
+ * - SENTINEL host P (aws/gcp/azure): a foreign-cloud plugin (e.g. an AWS
62
+ * auditor explicitly selected on `--host gcp`) is stripped; P's own plugins
63
+ * and non-cloud plugins are kept. (Complements scopeSelectionForHost, which
64
+ * only scopes the implicit `all` — this also covers explicit selections.)
65
+ *
66
+ * @param {Array<object>} selection resolved plugin objects (each may have .cloudProvider)
67
+ * @param {string} host
68
+ * @returns {{selected: object[], skipped: object[], sentinel: string|null, excludedCloud: boolean}}
69
+ */
70
+ export function excludeMismatchedCloudPlugins(selection, host) {
71
+ const sentinel = isCloudSentinelHost(host) ? String(host).trim().toLowerCase() : null;
72
+ const selected = [];
73
+ const skipped = [];
74
+ for (const p of selection) {
75
+ // non-cloud plugins (no cloudProvider) always survive; a cloud plugin
76
+ // survives only when the host IS its sentinel.
77
+ if (p && p.cloudProvider && p.cloudProvider !== sentinel) skipped.push(p);
78
+ else selected.push(p);
79
+ }
80
+ return { selected, skipped, sentinel, excludedCloud: skipped.length > 0 };
81
+ }
82
+
44
83
  /**
45
84
  * Multi-cloud generalization of scopeSelectionForHost: scope a resolved plugin
46
85
  * selection to the union of the given providers (by each plugin's cloudProvider