nsauditor-ai 0.2.22 → 0.2.24

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,9 @@ 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.24 + Enterprise 0.31.10** (July 2026) — **multi-cloud scope-integrity fix (correctness).** Closes a false-clean in the multi-cloud one-liner: `--host aws,gcp,azure` (and a `--host-file` of cloud sentinels) under a stale / tool-implied `CLOUD_PROVIDER` silently skipped the un-covered cloud legs and reported them **"audited-clean" over zero API calls**. The CLI now reconciles `CLOUD_PROVIDER` against the requested cloud legs — **fail-fast** on a mismatch (a pinned provider that misses a leg is a hard error, not a silent skip), else **imply the union** of the legs (so `--aws-profile prod --host aws,gcp,azure` works) and classifies a gate-skipped provider as `'skipped'` (dropped from `auditedProviders`, surfaced as `audited:false`, never a bare "audited, 0 findings"). TDD + externally reviewed across two rounds → 🟢 SHIP-CLEAR. Paired **Enterprise 0.31.10** (the EE-side GCP scope-integrity gate for 1024/1025 + the T4 GCP positive-substrate curation). 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
+
22
+ **Prior: 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
23
 
22
24
  → 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
25
 
@@ -66,6 +68,16 @@ If you're heading into a **SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS C
66
68
  → **[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
69
  → **[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
70
 
71
+ ### Prefer to buy through AWS Marketplace?
72
+
73
+ 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.*
74
+
75
+ How Marketplace fulfillment works (ZDE and air-gap preserved — no runtime AWS dependency at scan time):
76
+
77
+ 1. **Subscribe** on the listing (tiers `base` / `growth` / `scale` mirror the 5 / 25 / unlimited-seat plans).
78
+ 2. **Register** your email + AWS account ID at the URL shown in the listing's usage instructions — your **ES256 license key arrives by email**.
79
+ 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.
80
+
69
81
  ### Feature comparison
70
82
 
71
83
  | | 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 ------------------- */
@@ -848,7 +912,8 @@ Usage:
848
912
 
849
913
  Scan options:
850
914
  --host, --ip, --target <h> Target host, IP, or CIDR
851
- --host-file <path> File with one host per line
915
+ --host-file <path> File with one host per line (cloud sentinels in the
916
+ file reconcile CLOUD_PROVIDER the same as --host)
852
917
  --env <path> Load a dotenv (KEY=value) file for this scan (per-account
853
918
  credentials). Override-on; missing file = hard error.
854
919
  --aws-profile <name> Use a named profile from the OS-default ~/.aws/credentials.
@@ -920,7 +985,11 @@ Cloud-scan hosts:
920
985
  characters — your shell treats | as a pipe. Sentinels are
921
986
  not DNS-resolved; they route the scan to the matching
922
987
  cloud-scanner plugins via the provider's control-plane
923
- API, and imply CLOUD_PROVIDER=<host> when unset. With
988
+ API, and imply CLOUD_PROVIDER to the cloud leg(s) when
989
+ unset ('aws,gcp,azure' → CLOUD_PROVIDER=aws,gcp,azure).
990
+ A CLOUD_PROVIDER already pinned to a cloud that does NOT
991
+ cover every requested leg is a hard error (fail-fast),
992
+ not a silent skip of the uncovered legs. With
924
993
  --plugins all the scan AUTO-SCOPES to only that cloud's
925
994
  plugins (plugins not applicable to this host are skipped +
926
995
  logged — other-cloud plugins run on their OWN --host pass,
@@ -930,6 +999,13 @@ Cloud-scan hosts:
930
999
  single cloud and is therefore skipped under this
931
1000
  auto-scope; to run it, select it explicitly
932
1001
  (--plugins 023,...) or scan a network host/CIDR.
1002
+ INVERSE (cloud scope integrity): a cloud auditor runs
1003
+ ONLY on its own sentinel host. On a NETWORK host (IP /
1004
+ CIDR / hostname) the cloud-scanner plugins NEVER run —
1005
+ not via --plugins all, not via an explicit --plugins
1006
+ 1020, and not because cloud credentials are present in
1007
+ the environment. '--host' is the sole cloud-scan
1008
+ trigger; credentials are a capability, not intent.
933
1009
 
934
1010
  Examples:
935
1011
  nsauditor-ai scan --host 10.0.0.1 --plugins all
@@ -957,6 +1033,16 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
957
1033
  console.error('Error: --aws-profile requires a profile name (e.g. --aws-profile prod)');
958
1034
  process.exit(2);
959
1035
  }
1036
+ // Resolve the scan host list up-front (scan command only) so the CLOUD_PROVIDER
1037
+ // reconcile below (resolveScanEnv) sees --host-file cloud-sentinel legs too. With
1038
+ // --host-file the `host` arg is undefined, so without this a host-file of cloud
1039
+ // sentinels would bypass the fail-fast/imply reconcile entirely. Reused as the
1040
+ // scan target below (no double-parse); a parse failure is left to the scan-time
1041
+ // resolution so the existing error path + exit code are unchanged.
1042
+ let resolvedHosts = null;
1043
+ if (cmd === 'scan' && hostFile) {
1044
+ try { resolvedHosts = await parseHostFile(hostFile); } catch { /* defer to scan-time resolution */ }
1045
+ }
960
1046
  try {
961
1047
  const { resolveScanEnv } = await import('./utils/env_loader.mjs');
962
1048
  const fsm = await import('node:fs');
@@ -964,6 +1050,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
964
1050
  envPath: typeof args.env === 'string' ? args.env : undefined,
965
1051
  awsProfile: typeof args.awsProfile === 'string' ? args.awsProfile : undefined,
966
1052
  host,
1053
+ hosts: resolvedHosts ?? undefined,
967
1054
  env: process.env,
968
1055
  fileExists: (p) => fsm.existsSync(p),
969
1056
  readFile: (p) => fsm.readFileSync(p, 'utf8'),
@@ -1841,7 +1928,10 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1841
1928
  // Resolve host list
1842
1929
  let hosts;
1843
1930
  if (hostFile) {
1844
- hosts = await parseHostFile(hostFile);
1931
+ // Reuse the up-front resolution done for the CLOUD_PROVIDER reconcile (avoids a
1932
+ // second parse + duplicate suspicious-line warnings); re-parse only if that was
1933
+ // skipped/failed so a genuine host-file error still surfaces here as before.
1934
+ hosts = resolvedHosts ?? await parseHostFile(hostFile);
1845
1935
  } else if (host) {
1846
1936
  hosts = await parseHostArg(host);
1847
1937
  } else {
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.24",
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];
@@ -710,9 +728,15 @@ export class PluginManager {
710
728
  const wrappedRuns = await callPlugin(mod, host, ctx, outputs, opts);
711
729
  const duration_ms = Date.now() - startMs;
712
730
 
713
- // Determine manifest status from the plugin results
731
+ // Determine manifest status from the plugin results. A gate-skip envelope
732
+ // ({ up:false, skipped:true, ... }) carries neither `timedOut` nor `error`,
733
+ // so classify it as 'skipped' (not 'ran') iff nothing actually ran and there
734
+ // was no timeout/error — otherwise a self-skipped plugin would be miscounted
735
+ // as audited. Timeout/error still win over a skip; a skip+real-run mix is 'ran'.
714
736
  let status = 'ran';
715
737
  let reason = null;
738
+ let sawRealRun = false;
739
+ let skipReason = null;
716
740
  for (const wrapped of wrappedRuns) {
717
741
  if (wrapped.result?.timedOut) {
718
742
  status = 'timeout';
@@ -720,8 +744,16 @@ export class PluginManager {
720
744
  } else if (wrapped.result?.error && status !== 'timeout') {
721
745
  status = 'error';
722
746
  reason = wrapped.result.error;
747
+ } else if (wrapped.result?.skipped === true) {
748
+ skipReason = wrapped.result.reason || wrapped.result.skipReason || 'skipped by plugin gate';
749
+ } else {
750
+ sawRealRun = true;
723
751
  }
724
752
  }
753
+ if (status === 'ran' && skipReason != null && !sawRealRun) {
754
+ status = 'skipped';
755
+ reason = skipReason;
756
+ }
725
757
 
726
758
  manifest.push({
727
759
  id: String(mod.id || ''),
@@ -769,6 +801,17 @@ export class PluginManager {
769
801
  opts = maybeOpts || {};
770
802
  }
771
803
 
804
+ // BUG2(b): tag the host kind so plugins + the EE cloud gate can honor the
805
+ // "--host is the sole cloud-intent signal" contract as defense-in-depth
806
+ // (alongside the network-host exclusion below). Cloned so the caller's opts
807
+ // object — shared across concurrent hosts in the CLI multi-host path — is
808
+ // never mutated; hostKind stays a scalar string so the shallow opts spread
809
+ // in callPlugin (:152) / _runOne (:517) forwards it verbatim to every plugin.
810
+ opts = {
811
+ ...opts,
812
+ hostKind: isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network',
813
+ };
814
+
772
815
  // Sentinel-host scoping: on --host aws|gcp|azure with the implicit `all`,
773
816
  // run only that cloud's plugins; skip other clouds + non-cloud plugins.
774
817
  const scope = scopeSelectionForHost(selection, host, rawSpec);
@@ -790,16 +833,70 @@ export class PluginManager {
790
833
  }
791
834
  }
792
835
 
836
+ // BUG2(b): a cloud auditor (any plugin with a `cloudProvider` tag) runs IFF
837
+ // the host is ITS sentinel — the symmetric inverse of the sentinel scoping
838
+ // above. Fires for the implicit `all` AND an explicit `--plugins 1020`
839
+ // selection: a network scan must NEVER audit a cloud estate (even with cloud
840
+ // creds in the env), and a `--host gcp` scan must not run an explicitly-listed
841
+ // AWS auditor. '--host' is the sole cloud-intent signal — no escape hatch.
842
+ const netScope = excludeMismatchedCloudPlugins(selection, host);
843
+ // review fold R-5: excluded cloud plugins are removed from `selection` before
844
+ // dispatch, so without a manifest entry they leave no machine-readable trace
845
+ // in pm.run()'s result. (The requirements/capability skip classes DO push a
846
+ // manifest entry; the sentinel-scope skip at :812 does not — a separate
847
+ // pre-existing gap, not addressed here.) A `skipped` entry surfaces the
848
+ // exclusion to MCP scan_host responses + pm.run() library consumers (the CLI
849
+ // does not currently render the manifest). One entry per excluded plugin.
850
+ const cloudSkipManifest = netScope.skipped.map((p) => ({
851
+ id: String(p?.id || ''),
852
+ name: p?.name || 'Plugin',
853
+ status: 'skipped',
854
+ reason: `${p?.cloudProvider || 'cloud'} cloud auditor requires --host ${p?.cloudProvider || 'aws|gcp|azure'} ` +
855
+ `(host '${host}' is ${netScope.sentinel ? `the '${netScope.sentinel}' cloud` : 'a network target'})`,
856
+ duration_ms: 0,
857
+ }));
858
+ if (netScope.excludedCloud && netScope.skipped.length) {
859
+ selection = netScope.selected;
860
+ const ids = netScope.skipped.map((p) => p?.id ?? '?').join(', ');
861
+ if (netScope.sentinel) {
862
+ // foreign-cloud plugin explicitly selected on a mismatched sentinel host
863
+ const foreign = [...new Set(netScope.skipped.map((p) => p?.cloudProvider).filter(Boolean))].join('/');
864
+ console.error(
865
+ `Host '${host}' is the '${netScope.sentinel}' cloud → skipping ${netScope.skipped.length} ` +
866
+ `non-${netScope.sentinel} cloud auditor(s) [${ids}] (${foreign} auditors require --host ${foreign}). ` +
867
+ `A cloud auditor runs only on its own sentinel host — '--host' is the sole cloud-scan trigger.`,
868
+ );
869
+ } else {
870
+ console.error(
871
+ `Host '${host}' is a network target → skipping ${netScope.skipped.length} cloud auditor(s) ` +
872
+ `[${ids}]: cloud plugins require a cloud sentinel host (--host aws|gcp|azure). ` +
873
+ `Credentials in the environment are a capability, not an intent signal — '--host' is the ` +
874
+ `sole cloud-scan trigger. (To audit a cloud, run e.g. \`--host aws\`.)`,
875
+ );
876
+ }
877
+ // review fold 4: if the exclusion emptied the selection, say so loudly — an
878
+ // empty result is NOT a clean pass (mirrors the sentinel zero-match warning).
879
+ if (selection.length === 0) {
880
+ console.error(
881
+ `WARNING: after excluding cloud auditors for host '${host}', ZERO plugins remain — ` +
882
+ `NOTHING was audited (an empty result is NOT a clean pass). To audit a cloud, use --host aws|gcp|azure.`,
883
+ );
884
+ }
885
+ }
886
+
793
887
  // Decide execution mode
794
888
  const anyOrchestratedSignals = selection.some((p) => p?.priority != null || p?.requirements != null);
795
889
  const orchestrate = opts.orchestrate !== false && anyOrchestratedSignals;
796
890
 
797
891
  let results = [];
798
892
  let manifest = [];
893
+ // R-5: surface excluded cloud auditors as skipped manifest entries (before
894
+ // the dispatched plugins), so the exclusion is machine-visible, not stderr-only.
895
+ if (cloudSkipManifest.length) manifest.push(...cloudSkipManifest);
799
896
  if (orchestrate) {
800
897
  const orch = await this._runOrchestrated(host, selection, opts);
801
898
  results = orch.results;
802
- manifest = orch.manifest;
899
+ manifest = manifest.concat(orch.manifest);
803
900
  } else {
804
901
  // Legacy path: simple run across ports for each plugin (except concluder)
805
902
  const toRun = selection.filter((p) => !isConcluder(p));
@@ -884,14 +981,31 @@ export class PluginManager {
884
981
  return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status: 'skipped', reason: `missing capabilities: ${(mod.requiredCapabilities || []).join(',')}`, duration_ms: 0 }, outputs: [] };
885
982
  }
886
983
  const startMs = Date.now();
887
- const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, timeoutMs });
984
+ // BUG2(b) fold B: thread hostKind on the MCP/cloud-parallel path too, so
985
+ // the defense-in-depth signal is present on EVERY cloud dispatch route
986
+ // (selection here is already scoped to each plugin's cloudProvider).
987
+ const cloudHostKind = mod.cloudProvider ? `cloud:${mod.cloudProvider}` : opts.hostKind;
988
+ const wrappedRuns = await callPlugin(mod, host, ctx, [], { ...opts, timeoutMs, hostKind: cloudHostKind });
888
989
  const duration_ms = Date.now() - startMs;
990
+ // Same classifier as _runOrchestrated: a gate-skip envelope
991
+ // ({ up:false, skipped:true, ... }) → 'skipped' (not 'ran') iff nothing ran
992
+ // and there was no timeout/error, so a self-skipped cloud is not counted as
993
+ // audited (auditedProviders derives from status==='ran' at :1034).
994
+ // NB: the `!sawRealRun` term is defensive-only on this path — cloud plugins
995
+ // are runStrategy:'single' (one wrappedRun), so a skip+real-run MIX cannot
996
+ // arise here (unlike the multi-port network path in _runOrchestrated, which
997
+ // has the mutation-pinned mix test). Kept for uniformity with that classifier.
889
998
  let status = 'ran';
890
999
  let reason = null;
1000
+ let sawRealRun = false;
1001
+ let skipReason = null;
891
1002
  for (const w of wrappedRuns) {
892
1003
  if (w.result?.timedOut) { status = 'timeout'; reason = w.result.error || `timed out after ${timeoutMs}ms`; }
893
1004
  else if (w.result?.error && status !== 'timeout') { status = 'error'; reason = w.result.error; }
1005
+ else if (w.result?.skipped === true) { skipReason = w.result.reason || w.result.skipReason || 'skipped by plugin gate'; }
1006
+ else { sawRealRun = true; }
894
1007
  }
1008
+ if (status === 'ran' && skipReason != null && !sawRealRun) { status = 'skipped'; reason = skipReason; }
895
1009
  return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status, reason, duration_ms }, outputs: wrappedRuns };
896
1010
  } catch (err) {
897
1011
  return { manifest: { id: String(mod.id || ''), name: mod.name || 'Plugin', status: 'error', reason: String(err?.message || err), duration_ms: 0 }, outputs: [] };
@@ -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
+ }
@@ -48,17 +48,43 @@ function providerMatchesHost(effective, hostProvider) {
48
48
  return tokens.includes(hostProvider);
49
49
  }
50
50
 
51
+ // The distinct cloud-sentinel legs implied by the scan target, in first-appearance
52
+ // order, lowercased + deduped. Mirrors the CLI's host-file-XOR-host dispatch
53
+ // precedence: a pre-resolved `hosts` array (the --host-file path) is the
54
+ // authoritative source when present; otherwise the `--host` string (single 'aws'
55
+ // or a CSV 'aws,gcp,azure') is used. The two are NEVER unioned — the scan
56
+ // dispatches exactly one source, so reconciling both would over-widen
57
+ // CLOUD_PROVIDER and could false-throw on a leg that is never dispatched.
58
+ // Non-sentinel tokens (IPs / CIDRs / hostnames) are ignored; [] means no cloud
59
+ // leg (a pure network scan needs no CLOUD_PROVIDER).
60
+ function sentinelLegs(host, hosts) {
61
+ const tokens = Array.isArray(hosts)
62
+ ? hosts.slice()
63
+ : (typeof host === 'string' ? host.split(',') : []);
64
+ const legs = [];
65
+ for (const raw of tokens) {
66
+ const t = String(raw).trim().toLowerCase();
67
+ if (CLOUD_SENTINELS.has(t) && !legs.includes(t)) legs.push(t);
68
+ }
69
+ return legs;
70
+ }
71
+
51
72
  /**
52
73
  * @param {object} args
53
74
  * @param {string} [args.envPath] value of --env
54
75
  * @param {string} [args.awsProfile] value of --aws-profile
55
- * @param {string} [args.host] --host value (for sentinel CLOUD_PROVIDER implication)
76
+ * @param {string} [args.host] --host value: a single sentinel ('aws'), a
77
+ * CSV of sentinels ('aws,gcp,azure'), or a
78
+ * network host — for CLOUD_PROVIDER implication
79
+ * @param {string[]} [args.hosts] pre-resolved host list (the --host-file path,
80
+ * where `host` is undefined); reconciled the
81
+ * same way as a CSV `host`
56
82
  * @param {object} args.env snapshot of current process.env (read-only)
57
83
  * @param {(p:string)=>boolean} args.fileExists
58
84
  * @param {(p:string)=>string} args.readFile
59
85
  * @returns {{set: Record<string,string>, unset: string[]}}
60
86
  */
61
- export function resolveScanEnv({ envPath, awsProfile, host, env = {}, fileExists, readFile }) {
87
+ export function resolveScanEnv({ envPath, awsProfile, host, hosts, env = {}, fileExists, readFile }) {
62
88
  const set = {};
63
89
  const unset = [];
64
90
 
@@ -79,6 +105,16 @@ export function resolveScanEnv({ envPath, awsProfile, host, env = {}, fileExists
79
105
  Object.assign(set, dotenv.parse(content));
80
106
  }
81
107
 
108
+ // Capture whether the OPERATOR (not the tool) pinned CLOUD_PROVIDER — from the
109
+ // --env file (step 1) or the shell env — BEFORE the --aws-profile implication
110
+ // in step 2. This is what decides throw-vs-imply for sentinel legs (step 3):
111
+ // • operator-pinned provider that misses a host leg → conflict → fail-fast;
112
+ // • unpinned (or only tool/profile-implied) → imply the UNION of the legs.
113
+ // Without this snapshot, --aws-profile's implied bare 'aws' (step 2) would look
114
+ // like an operator pin and wrongly THROW on a `--host aws,gcp,azure` run.
115
+ const operatorPinned = providerAlreadySet(set, env);
116
+ const operatorProvider = operatorPinned ? effectiveProvider(set, env) : '';
117
+
82
118
  // 2. --aws-profile: AWS_PROFILE wins; clear explicit keys; load ~/.aws/config; imply aws.
83
119
  if (typeof awsProfile === 'string' && awsProfile.length) {
84
120
  set.AWS_PROFILE = awsProfile;
@@ -90,23 +126,34 @@ export function resolveScanEnv({ envPath, awsProfile, host, env = {}, fileExists
90
126
  if (!providerAlreadySet(set, env)) set.CLOUD_PROVIDER = 'aws';
91
127
  }
92
128
 
93
- // 3. Sentinel host implies its provider when CLOUD_PROVIDER is still unset.
94
- // Fail-fast on a host vs CLOUD_PROVIDER contradiction: if the effective
95
- // provider (--env file + shell env) is set to a cloud that does NOT match
96
- // the host, scoping would select host-plugins that then all self-skip on
97
- // the awsScanSkipReason gate ZERO plugins run a silent "clean" report.
98
- if (typeof host === 'string' && CLOUD_SENTINELS.has(host.trim().toLowerCase())) {
99
- const hostProvider = host.trim().toLowerCase();
100
- const effective = effectiveProvider(set, env);
101
- if (effective && !providerMatchesHost(effective, hostProvider)) {
102
- throw new Error(
103
- `--host '${hostProvider}' conflicts with CLOUD_PROVIDER='${effective}': the host and the ` +
104
- `effective cloud provider do not match, which would silently skip every plugin (an empty ` +
105
- `"clean" report). Resolve by dropping the conflicting CLOUD_PROVIDER, or scan the matching host ` +
106
- `(--host ${effective.split(',')[0].trim()}).`,
107
- );
129
+ // 3. Sentinel host leg(s) imply their provider(s) when the operator hasn't
130
+ // pinned CLOUD_PROVIDER; fail-fast when a pinned provider does NOT cover
131
+ // every leg. Handles a single sentinel ('aws'), a CSV ('aws,gcp,azure'),
132
+ // AND a --host-file resolved list (`hosts`) uniformly each is a set of
133
+ // legs. A leg that the effective provider does not cover would silently
134
+ // self-skip on the awsScanSkipReason/gcpScanSkipReason gate ZERO plugins
135
+ // run on that leg → a silent "clean" report for a whole cloud.
136
+ const legs = sentinelLegs(host, hosts);
137
+ if (legs.length) {
138
+ if (operatorPinned) {
139
+ const uncovered = legs.filter((leg) => !providerMatchesHost(operatorProvider, leg));
140
+ if (uncovered.length) {
141
+ throw new Error(
142
+ `--host '${legs.join(',')}' conflicts with CLOUD_PROVIDER='${operatorProvider}': the host ` +
143
+ `leg(s) [${uncovered.join(', ')}] are not covered by the effective cloud provider, so every ` +
144
+ `plugin on ${uncovered.length > 1 ? 'those legs' : 'that leg'} would silently self-skip (an ` +
145
+ `empty "clean" report). Resolve by dropping the conflicting CLOUD_PROVIDER, or set ` +
146
+ `CLOUD_PROVIDER to include ${uncovered.join(',')} (e.g. CLOUD_PROVIDER=${legs.join(',')}).`,
147
+ );
148
+ }
149
+ // pinned provider covers every leg → leave the operator's value untouched
150
+ } else {
151
+ // Unpinned (or only tool/profile-implied): imply the UNION of the legs so
152
+ // each cloud leg runs. This overrides a bare 'aws' that step 2's
153
+ // --aws-profile implication may have written — 'aws' ⊆ the union, and the
154
+ // aws leg still resolves the profile via AWS_PROFILE.
155
+ set.CLOUD_PROVIDER = legs.join(',');
108
156
  }
109
- if (!providerAlreadySet(set, env)) set.CLOUD_PROVIDER = hostProvider;
110
157
  }
111
158
 
112
159
  return { set, unset };
@@ -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