nsauditor-ai 0.1.91 → 0.1.93

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,8 +17,10 @@ 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.1.91 + Enterprise 0.16.0** (May 2026)
20
+ **Latest: CE 0.1.93 + Enterprise 0.16.2** (May 2026)
21
21
 
22
+ - ☁️ **MCP `scan_cloud` tool: audit cloud accounts directly** (CE 0.1.93 — paired EE 0.16.2) — *(Enterprise)* a dedicated MCP tool so you can say *"Audit my AWS account"* or *"Audit my AWS and Azure accounts"* in Claude Desktop / Claude Code, with **no network host** — the server scopes the run to only the requested clouds' EE plugins. The MCP analog of the CLI `--host aws --plugins all` scoping. Enterprise-gated (`enterpriseMCP`) with plugin-layer defense-in-depth, and **anti-false-clean reporting** — a cloud is reported "audited" only if a plugin actually ran; a missing-credential / skipped cloud is surfaced as an explicit note + `audited:false` (R-HIGH caught + folded by the `audit-cloud-plugin-false-negatives` review). Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
23
+ - 🔌 **MCP `NSA_ENV_FILE`: per-environment `.env` for the MCP server** (CE 0.1.92 — paired EE 0.16.1) — the MCP analog of 0.16.0's CLI `--env`. Point the MCP server at a specific account/cloud by setting **`NSA_ENV_FILE`** to a dotenv path in your Claude Desktop / Claude Code config instead of inlining every credential; the server loads that environment's cloud credentials + `CLOUD_PROVIDER` at startup. Loaded **after** the MCP auth gate + license (scan-target vars only; the auth key + license are ignored if present in the file), **fail-fast** on a missing / INI / set-but-empty path, and treated as the **authoritative scan target** — leftover ambient provider credentials the file doesn't set are cleared, closing a false-clean (a partial file could otherwise silently scan a leftover account) caught + folded by the `audit-cloud-plugin-false-negatives` review. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
22
24
  - 🎯 **Per-account scanning: `--env` / `--aws-profile` + sentinel-host plugin scoping** (CE 0.1.91 — paired EE 0.16.0) — audit many cloud accounts one at a time without shell-export juggling. **`--env <path>`** loads a per-scan dotenv credentials file (override-on; fail-fast on a missing file; an INI/`~/.aws/credentials` file is detected and redirected to `--aws-profile`). **`--aws-profile <name>`** uses a named profile from the OS-default `~/.aws/credentials` (clears stale explicit keys, sets `AWS_SDK_LOAD_CONFIG=1`, implies `CLOUD_PROVIDER=aws`). On a cloud-sentinel host, **`--host aws|gcp|azure` + `--plugins all` auto-scopes** to only that cloud's plugins (other clouds + non-cloud plugins are skipped and logged); explicit `--plugins` lists are unaffected. A host↔`CLOUD_PROVIDER` conflict fails fast (no silent empty "clean" report), and a zero-match sentinel scope warns loudly. EE 0.16.0 adds a declarative `cloudProvider` field to all 27 cloud plugins. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
23
25
  - 🛡️ **Cross-cloud scope hotfix** (CE 0.1.90 — paired EE 0.15.9) — EE 0.15.9 moves the AWS-plugin `CLOUD_PROVIDER` gate from `preflight()` to `run()`: the 0.15.8 gate was placed where the scan orchestrator never calls it, so the cross-cloud bleed persisted; it's now on the load-bearing `run()` path (a `CLOUD_PROVIDER=gcp|azure` scan with AWS creds present no longer bleeds AWS resources into the GCP/Azure packs), validated via a run()-path test + a real-creds local proof. CE is a paired no-op bump. Plugin count UNCHANGED at 28; all six matrices UNCHANGED.
24
26
  - 🛡️ **Cloud-plugin scoping fixes** (CE 0.1.89 — paired EE 0.15.8) — EE 0.15.8 closes two issues surfaced by the 0.15.7 full multi-cloud smoke: the AWS plugins now gate on `CLOUD_PROVIDER` (a `CLOUD_PROVIDER=gcp|azure` scan with AWS creds present no longer bleeds AWS resources into the GCP/Azure attestation packs), and the GCP IAM/storage auditor now surfaces an evidence-gap instead of a false-clean PASS when a policy can't be read. CE is a paired no-op bump (no CE code change). Plugin count UNCHANGED at 28; all six coverage matrices UNCHANGED.
@@ -412,6 +414,7 @@ npx nsauditor-ai-mcp
412
414
 
413
415
  | Tool | Purpose |
414
416
  |---|---|
417
+ | `scan_cloud` | Audit one or more cloud accounts (AWS / GCP / Azure) using server-configured credentials; no network host. "Audit my AWS account" / "Audit my AWS and Azure accounts". |
415
418
  | `start_assessment` | Multi-host orchestrated assessment workflow |
416
419
  | `prioritize_risks` | Cross-host risk prioritization |
417
420
  | `compliance_check` | Compliance mapping with gap analysis |
@@ -486,10 +489,9 @@ Then add this to your `claude_desktop_config.json` (Settings → Developer → E
486
489
  "command": "nsauditor-ai-mcp",
487
490
  "env": {
488
491
  "NSA_MCP_AUTH_KEY": "keychain:NSA_MCP_AUTH_KEY",
492
+ "NSA_ENV_FILE": "~/envs/prod-aws.env",
489
493
  "AI_PROVIDER": "claude",
490
- "ANTHROPIC_API_KEY": "keychain:ANTHROPIC_API_KEY",
491
- "NSA_ALLOW_ALL_HOSTS": "1",
492
- "PLUGIN_TIMEOUT_MS": "5000"
494
+ "ANTHROPIC_API_KEY": "keychain:ANTHROPIC_API_KEY"
493
495
  }
494
496
  }
495
497
  }
@@ -503,6 +505,32 @@ The exact `NSA_MCP_AUTH_KEY` value to paste is printed by `nsauditor-ai mcp inst
503
505
  - `PLUGIN_TIMEOUT_MS=5000` — reduces per-plugin timeout to 5s so the full scan completes within Claude Desktop's 60s MCP limit
504
506
  - `AI_PROVIDER` and API key — optional, enables AI-powered analysis of scan results
505
507
 
508
+ #### `NSA_ENV_FILE` — point the MCP server at an environment file
509
+
510
+ Instead of inlining every scan variable in the config above, set **`NSA_ENV_FILE`** to a
511
+ dotenv file and keep the cloud credentials, `CLOUD_PROVIDER`, and scan tuning there. To scan
512
+ a different account or cloud, change the one path (or swap the file) and restart Claude Desktop —
513
+ no JSON editing.
514
+
515
+ ```bash
516
+ # ~/envs/prod-aws.env (chmod 600 — this holds credentials)
517
+ CLOUD_PROVIDER=aws
518
+ AWS_ACCESS_KEY_ID=AKIA...
519
+ AWS_SECRET_ACCESS_KEY=...
520
+ NSA_ALLOW_ALL_HOSTS=1
521
+ PLUGIN_TIMEOUT_MS=5000
522
+ ```
523
+
524
+ - The file is loaded at server startup; values in it **override** the same keys in the config `env` block.
525
+ - **Fail-fast:** if the path is missing or points at an AWS credentials/INI file, the server refuses
526
+ to start (it will not silently fall back to ambient credentials and scan the wrong account). The
527
+ error is written to the MCP server's stderr log.
528
+ - **The file is the authoritative scan target:** ambient provider credentials (e.g. an old account's
529
+ `AWS_*` keys still in the config `env` block) that the file does **not** set are cleared, so a partial
530
+ file can't silently scan a leftover account. Instance-role / ADC identity is untouched.
531
+ - `NSA_MCP_AUTH_KEY` and `NSAUDITOR_LICENSE_KEY` are resolved **before** the file and must stay
532
+ inline (or in `~/.nsauditor/.env`); if present in `NSA_ENV_FILE` they are ignored.
533
+
506
534
  ### Claude Code Setup
507
535
 
508
536
  ```bash
@@ -512,6 +540,15 @@ claude mcp add nsauditor-ai \
512
540
  -- npx nsauditor-ai-mcp
513
541
  ```
514
542
 
543
+ To target an environment via the file, add it as an env value:
544
+
545
+ ```bash
546
+ claude mcp add nsauditor-ai \
547
+ --env NSA_MCP_AUTH_KEY=keychain:NSA_MCP_AUTH_KEY \
548
+ --env NSA_ENV_FILE=~/envs/prod-aws.env \
549
+ -- npx nsauditor-ai-mcp
550
+ ```
551
+
515
552
  (On Linux/Windows, replace the `keychain:NSA_MCP_AUTH_KEY` placeholder with the literal key printed by `install-key`.)
516
553
 
517
554
  ### Troubleshooting MCP authentication
package/mcp_server.mjs CHANGED
@@ -54,6 +54,12 @@ export function _requireProCapability(toolName) {
54
54
  return requireProCapability(toolName);
55
55
  }
56
56
 
57
+ /** @internal Test-only. Returns the Enterprise-gate denial object (or null if allowed). */
58
+ export function _requireEnterpriseCapability(toolName) {
59
+ if (process.env.NODE_ENV === 'production') throw new Error('_requireEnterpriseCapability is test-only and disabled in production');
60
+ return requireEnterpriseCapability(toolName);
61
+ }
62
+
57
63
  function requireProCapability(toolName) {
58
64
  if (_capabilities.proMCP) return null; // Pro/Enterprise: allow
59
65
  return {
@@ -65,6 +71,17 @@ function requireProCapability(toolName) {
65
71
  };
66
72
  }
67
73
 
74
+ function requireEnterpriseCapability(toolName) {
75
+ if (_capabilities.enterpriseMCP) return null; // Enterprise: allow
76
+ return {
77
+ content: [{
78
+ type: 'text',
79
+ text: `🔒 **${toolName}** (cloud account auditing) requires an Enterprise license.\n\nView Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n**CE tools available:** scan_host, list_plugins`,
80
+ }],
81
+ isError: true,
82
+ };
83
+ }
84
+
68
85
  // ---------------------------------------------------------------------------
69
86
  // Lazy singletons — initialised on first use, overridable for tests
70
87
  // ---------------------------------------------------------------------------
@@ -176,6 +193,22 @@ const TOOLS = [
176
193
  required: ['host'],
177
194
  },
178
195
  },
196
+ {
197
+ name: 'scan_cloud',
198
+ description:
199
+ 'Audit one or more cloud accounts (AWS / GCP / Azure) for security & compliance posture using the credentials configured in the server environment. No network host required. Requires an Enterprise license. Use for "audit my AWS account" / "audit my AWS and Azure accounts".',
200
+ inputSchema: {
201
+ type: 'object',
202
+ properties: {
203
+ providers: {
204
+ type: 'array',
205
+ items: { type: 'string', enum: ['aws', 'gcp', 'azure'] },
206
+ description: 'Which cloud(s) to audit. Omit to audit all clouds the server is configured for.',
207
+ },
208
+ },
209
+ required: [],
210
+ },
211
+ },
179
212
  {
180
213
  name: 'probe_service',
181
214
  description:
@@ -313,6 +346,80 @@ export async function handleScanHost(args) {
313
346
  };
314
347
  }
315
348
 
349
+ const VALID_PROVIDERS = ['aws', 'gcp', 'azure'];
350
+
351
+ export async function handleScanCloud(args) {
352
+ // Validate providers; default → all three.
353
+ let providers = VALID_PROVIDERS.slice();
354
+ if (args && args.providers != null) {
355
+ if (!Array.isArray(args.providers)) {
356
+ throw new Error('providers must be an array of "aws" | "gcp" | "azure"');
357
+ }
358
+ const requested = args.providers.map((p) => String(p).trim().toLowerCase());
359
+ for (const p of requested) {
360
+ if (!VALID_PROVIDERS.includes(p)) {
361
+ throw new Error(`unknown provider '${p}' (valid: ${VALID_PROVIDERS.join(', ')})`);
362
+ }
363
+ }
364
+ if (requested.length > 0) providers = requested;
365
+ }
366
+
367
+ const pm = await getPluginManager();
368
+
369
+ // Save/set/restore CLOUD_PROVIDER so the per-plugin .includes() gates pass and
370
+ // the cross-cloud bleed gate is satisfied — restored in finally so the tool
371
+ // never leaves the long-lived server's env mutated for the next call.
372
+ const savedProvider = process.env.CLOUD_PROVIDER;
373
+ let output;
374
+ try {
375
+ process.env.CLOUD_PROVIDER = providers.join(',');
376
+ output = await pm.runCloud(providers);
377
+ } finally {
378
+ if (savedProvider === undefined) delete process.env.CLOUD_PROVIDER;
379
+ else process.env.CLOUD_PROVIDER = savedProvider;
380
+ }
381
+
382
+ // Render a Markdown summary (best-effort, same as scan_host).
383
+ let markdown = null;
384
+ try {
385
+ if (output.conclusion) {
386
+ markdown = buildMarkdownReport({ host: output.host, conclusion: output.conclusion, toolVersion: TOOL_VERSION });
387
+ }
388
+ } catch { /* swallow — markdown is best-effort */ }
389
+
390
+ // Anti-false-clean: a requested cloud is "audited" ONLY if >=1 of its plugins
391
+ // actually completed (manifest status 'ran'). Surface every NOT-effectively-
392
+ // audited provider with a specific reason — never a silent empty "clean".
393
+ const providerStatus = output.providerStatus || {};
394
+ const auditedProviders = output.auditedProviders || [];
395
+ const notes = [];
396
+ for (const p of providers) {
397
+ const s = providerStatus[p] || { available: 0, ran: 0, skipped: 0, errored: 0 };
398
+ if (s.ran > 0) continue; // effectively audited (clean or not)
399
+ if (s.available === 0) {
400
+ notes.push(`${p}: no cloud plugins available — Enterprise EE plugins not installed (an empty result is NOT a clean pass)`);
401
+ } else if (s.errored > 0) {
402
+ notes.push(`${p}: ${s.errored} plugin(s) ran but errored (e.g. credentials not configured) — NOT audited (an empty result is NOT a clean pass)`);
403
+ } else {
404
+ notes.push(`${p}: ${s.skipped} plugin(s) present but skipped (requirements/capability not met) — NOT audited`);
405
+ }
406
+ }
407
+
408
+ // Honest count: completed audits, NOT error/skip envelopes.
409
+ const pluginsRan = (output.manifest || []).filter((m) => m.status === 'ran').length;
410
+
411
+ return {
412
+ providers,
413
+ audited: auditedProviders.length > 0,
414
+ auditedProviders,
415
+ conclusion: output.conclusion ?? null,
416
+ manifest: output.manifest ?? [],
417
+ pluginsRan,
418
+ markdown,
419
+ ...(notes.length ? { notes } : {}),
420
+ };
421
+ }
422
+
316
423
  export async function handleProbeService(args) {
317
424
  if (!args?.host || typeof args.host !== 'string') {
318
425
  throw new Error('Missing required parameter: host');
@@ -371,6 +478,7 @@ export async function handleListPlugins() {
371
478
  /** Map tool name to handler. Exported for testing. */
372
479
  export const toolHandlers = {
373
480
  scan_host: handleScanHost,
481
+ scan_cloud: handleScanCloud,
374
482
  probe_service: handleProbeService,
375
483
  get_vulnerabilities: handleGetVulnerabilities,
376
484
  list_plugins: handleListPlugins,
@@ -429,6 +537,19 @@ export function createServer() {
429
537
  }
430
538
  }
431
539
 
540
+ // Gate the Enterprise cloud-audit tool at the MCP dispatch layer.
541
+ if (name === 'scan_cloud') {
542
+ const denied = requireEnterpriseCapability(name);
543
+ if (denied) {
544
+ return {
545
+ ...denied,
546
+ content: denied.content.map((c) =>
547
+ c.type === 'text' ? { ...c, text: appendCallSentinel(c.text, callId) } : c,
548
+ ),
549
+ };
550
+ }
551
+ }
552
+
432
553
  try {
433
554
  const result = await handler(args ?? {});
434
555
 
@@ -592,6 +713,27 @@ export async function startStdioServer() {
592
713
  _tier = getTierFromEnv();
593
714
  _capabilities = resolveCapabilities(_tier);
594
715
 
716
+ // NSA_ENV_FILE: load the per-environment dotenv file AFTER auth + license
717
+ // (so it carries scan-target vars only and can alter neither) and BEFORE
718
+ // createServer()/connect. plugin_manager.mjs captures PLUGIN_TIMEOUT_MS in a
719
+ // module-level const at import (first tool call), so the file MUST be applied
720
+ // before any tool call can import it. Fail-fast: a missing / INI file refuses
721
+ // startup rather than silently scanning ambient credentials (wrong-account →
722
+ // a false "clean"). stdout is JSON-RPC, so diagnostics go to stderr.
723
+ try {
724
+ const fsm = await import('node:fs');
725
+ const { applyScanEnvFile } = await import('./utils/mcp_env_file.mjs');
726
+ applyScanEnvFile({
727
+ env: process.env,
728
+ fileExists: (p) => fsm.existsSync(p),
729
+ readFile: (p) => fsm.readFileSync(p, 'utf8'),
730
+ log: (m) => process.stderr.write(`[nsauditor-mcp] ${m}\n`),
731
+ });
732
+ } catch (err) {
733
+ process.stderr.write(`✗ NSA_ENV_FILE: ${err.message}\n`);
734
+ process.exit(1);
735
+ }
736
+
595
737
  const server = createServer();
596
738
  const transport = new StdioServerTransport();
597
739
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
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 } from './utils/sentinel_scope.mjs';
23
+ import { scopeSelectionForHost, scopeSelectionForProviders } from './utils/sentinel_scope.mjs';
24
24
 
25
25
  const __filename = fileURLToPath(import.meta.url);
26
26
 
@@ -852,6 +852,59 @@ export class PluginManager {
852
852
  ai_out_path: null,
853
853
  };
854
854
  }
855
+
856
+ /**
857
+ * Cloud-account audit: scope the plugin run to the union of the given cloud
858
+ * providers (by each plugin's `cloudProvider` field), with no network host.
859
+ * Sibling of run() that scopes by provider-set instead of by sentinel host,
860
+ * then reuses the same orchestrate + conclude path. Returns the run()-shaped
861
+ * output. The cloud plugins ignore `host`; the synthetic label just identifies
862
+ * the report. An empty selection returns an empty result (surfaced as a note
863
+ * by the caller) rather than a silent clean.
864
+ *
865
+ * @param {string[]} providers e.g. ['aws'] or ['aws','azure']
866
+ * @param {object} [opts]
867
+ */
868
+ async runCloud(providers, opts = {}) {
869
+ const reqProviders = (providers || []).map((p) => String(p).trim().toLowerCase());
870
+ const host = `cloud:${reqProviders.join('+') || 'none'}`;
871
+ const all = this.plugins.slice();
872
+ const { selected } = scopeSelectionForProviders(all, reqProviders);
873
+
874
+ // Seed per-provider accounting for EVERY requested provider, so a provider
875
+ // with zero plugins is visible (available:0), not just silently absent.
876
+ const providerStatus = {};
877
+ for (const p of reqProviders) providerStatus[p] = { available: 0, ran: 0, skipped: 0, errored: 0 };
878
+
879
+ if (selected.length === 0) {
880
+ // Parity with the sentinel-host path in run(): an empty scope is NOT a clean pass.
881
+ console.error(
882
+ `WARNING: scan_cloud scope [${reqProviders.join(', ') || '(none)'}] matched 0 cloud plugins — ` +
883
+ `NOTHING was audited (an empty result is NOT a clean pass).`,
884
+ );
885
+ return { host, results: [], conclusion: null, manifest: [], providerStatus, auditedProviders: [],
886
+ ai: null, ai_meta: null, ai_error: null, ai_out_path: null };
887
+ }
888
+
889
+ const orch = await this._runOrchestrated(host, selected, opts);
890
+ const conclusion = await this.runConcluder(orch.results);
891
+
892
+ // Per-provider effectiveness from the manifest (ids match the selected set).
893
+ const manifestById = new Map((orch.manifest || []).map((m) => [String(m.id), m]));
894
+ for (const p of selected) {
895
+ const prov = p.cloudProvider;
896
+ if (!providerStatus[prov]) providerStatus[prov] = { available: 0, ran: 0, skipped: 0, errored: 0 };
897
+ providerStatus[prov].available++;
898
+ const m = manifestById.get(String(p.id));
899
+ if (m && m.status === 'ran') providerStatus[prov].ran++;
900
+ else if (m && m.status === 'skipped') providerStatus[prov].skipped++;
901
+ else providerStatus[prov].errored++;
902
+ }
903
+ const auditedProviders = Object.keys(providerStatus).filter((p) => providerStatus[p].ran > 0);
904
+
905
+ return { host, results: orch.results, conclusion, manifest: orch.manifest, providerStatus, auditedProviders,
906
+ ai: null, ai_meta: null, ai_error: null, ai_out_path: null };
907
+ }
855
908
  }
856
909
 
857
910
  export default PluginManager;
@@ -0,0 +1,89 @@
1
+ // utils/mcp_env_file.mjs
2
+ //
3
+ // Loads the per-environment dotenv file named by NSA_ENV_FILE into env for the
4
+ // MCP server, reusing the CLI's resolveScanEnv() (same dotenv parse, tilde-
5
+ // expansion, INI-detection, fail-fast). Called from startStdioServer() AFTER
6
+ // auth + license are resolved, so the file carries SCAN-TARGET vars only (cloud
7
+ // creds / CLOUD_PROVIDER / scan tuning) and can neither bypass the auth gate nor
8
+ // escalate the license tier. NSA_MCP_AUTH_KEY / NSAUDITOR_LICENSE_KEY in the file
9
+ // are intentionally ignored (they resolve before the file is read).
10
+ //
11
+ // The file is the AUTHORITATIVE scan-target selector: any explicit provider
12
+ // credential present in the ambient env but NOT set by the file is "leftover"
13
+ // (e.g. a previous account's keys still in the Claude Desktop env block) and is
14
+ // CLEARED — otherwise a partial file would let the scan silently run against the
15
+ // wrong account and report "clean" (the worst outcome an audit tool can produce).
16
+ // Instance-role / ADC identity (which has no env var) is untouched. This mirrors
17
+ // how the CLI --aws-profile path already clears the explicit AWS keys.
18
+
19
+ import { resolveScanEnv } from './env_loader.mjs';
20
+
21
+ // Resolved before NSA_ENV_FILE loads → stripped if present in the file.
22
+ const RESERVED_KEYS = new Set(['NSA_MCP_AUTH_KEY', 'NSAUDITOR_LICENSE_KEY']);
23
+
24
+ // Explicit per-provider credential env vars. Cleared if the file does not set them.
25
+ const PROVIDER_CRED_KEYS = [
26
+ 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_PROFILE',
27
+ 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_PROJECT_ID', 'GCLOUD_PROJECT',
28
+ 'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_SECRET', 'AZURE_SUBSCRIPTION_ID',
29
+ ];
30
+
31
+ /**
32
+ * @param {object} a
33
+ * @param {Record<string,string>} a.env process.env (mutated in place; file wins)
34
+ * @param {(p:string)=>boolean} a.fileExists
35
+ * @param {(p:string)=>string} a.readFile
36
+ * @param {(m:string)=>void} [a.log] stderr sink — key NAMES only, never values
37
+ * @returns {{applied: string[], ignored: string[], cleared: string[]}}
38
+ * @throws if NSA_ENV_FILE is set-but-empty, or points at a missing/INI file
39
+ */
40
+ export function applyScanEnvFile({ env, fileExists, readFile, log = () => {} }) {
41
+ const envPath = env.NSA_ENV_FILE;
42
+ if (envPath == null) return { applied: [], ignored: [], cleared: [] }; // truly unset → no-op
43
+ if (typeof envPath !== 'string' || envPath.trim() === '') {
44
+ // set-but-empty (e.g. an unresolved config-template var): the operator
45
+ // clearly intended a file. Fail-fast rather than silently scan ambient creds.
46
+ throw new Error(
47
+ 'NSA_ENV_FILE is set but empty — refusing to start; unset it or give a real path ' +
48
+ '(an empty value would silently scan ambient credentials)',
49
+ );
50
+ }
51
+
52
+ // Only envPath is set → resolveScanEnv reduces to: resolve + tilde-expand →
53
+ // fail-fast if missing → reject INI → dotenv.parse → { set, unset: [] }.
54
+ const { set } = resolveScanEnv({ envPath, env, fileExists, readFile });
55
+
56
+ const ignored = Object.keys(set).filter((k) => RESERVED_KEYS.has(k));
57
+ for (const k of ignored) delete set[k];
58
+
59
+ const applied = Object.keys(set);
60
+ Object.assign(env, set); // override-on — the file is the environment selector
61
+
62
+ // Anti-false-clean: clear leftover ambient provider creds the file did NOT set.
63
+ const cleared = [];
64
+ for (const k of PROVIDER_CRED_KEYS) {
65
+ if (!(k in set) && env[k] != null && env[k] !== '') {
66
+ delete env[k];
67
+ cleared.push(k);
68
+ }
69
+ }
70
+
71
+ if (ignored.length) {
72
+ log(
73
+ `ignored ${ignored.join(', ')} from NSA_ENV_FILE — auth/license resolve ` +
74
+ `before the file; set them inline or in ~/.nsauditor/.env`,
75
+ );
76
+ }
77
+ if (cleared.length) {
78
+ log(
79
+ `cleared ambient ${cleared.join(', ')} not set by NSA_ENV_FILE ` +
80
+ `(the file is the authoritative scan target — refusing to use leftover credentials)`,
81
+ );
82
+ }
83
+ log(
84
+ `Loaded scan environment from ${envPath} ` +
85
+ `(${applied.length} keys: ${applied.join(', ') || 'none'})`,
86
+ );
87
+
88
+ return { applied, ignored, cleared };
89
+ }
@@ -40,3 +40,24 @@ export function scopeSelectionForHost(selection, host, spec) {
40
40
  }
41
41
  return { selected, skipped, scoped: true, provider };
42
42
  }
43
+
44
+ /**
45
+ * Multi-cloud generalization of scopeSelectionForHost: scope a resolved plugin
46
+ * selection to the union of the given providers (by each plugin's cloudProvider
47
+ * field). Used by pluginManager.runCloud() / the MCP scan_cloud tool. Network
48
+ * plugins (no cloudProvider) and non-requested clouds land in `skipped`.
49
+ *
50
+ * @param {Array<object>} selection resolved plugin objects (each may have .cloudProvider)
51
+ * @param {string[]} providers e.g. ['aws'] or ['aws','azure']
52
+ * @returns {{selected: object[], skipped: object[], providers: string[]}}
53
+ */
54
+ export function scopeSelectionForProviders(selection, providers) {
55
+ const set = new Set((providers || []).map((p) => String(p).trim().toLowerCase()));
56
+ const selected = [];
57
+ const skipped = [];
58
+ for (const p of selection) {
59
+ if (p && p.cloudProvider && set.has(p.cloudProvider)) selected.push(p);
60
+ else skipped.push(p);
61
+ }
62
+ return { selected, skipped, providers: [...set] };
63
+ }