nsauditor-ai 0.1.90 β†’ 0.1.92

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.90 + Enterprise 0.15.9** (May 2026)
20
+ **Latest: CE 0.1.92 + Enterprise 0.16.1** (May 2026)
21
21
 
22
+ - πŸ”Œ **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.
23
+ - 🎯 **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.
22
24
  - πŸ›‘οΈ **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.
23
25
  - πŸ›‘οΈ **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.
24
26
  - ☁️ **GCP SDK refresh** (CE 0.1.88 β€” paired EE 0.15.7) β€” EE 0.15.7 re-applies the GCP SDK major bump (`@google-cloud/compute` ^6 / `@google-cloud/iam` ^2 / `googleapis` ^173) on the pure-ADC credential path, validated live against a test-infra GCP project (first live GCP audit: 3 CRITICAL firewall findings on compute@6). Compute-client SA-impersonation is explicitly unsupported on compute@6 (documented in-code + gated to plan-later); pure-ADC and key-file paths are fully supported. Also folds the plugin-1021 project-resolution fix. CE is a paired no-op bump (no CE code change). Plugin count UNCHANGED at 28; all six coverage matrices UNCHANGED.
@@ -485,10 +487,9 @@ Then add this to your `claude_desktop_config.json` (Settings β†’ Developer β†’ E
485
487
  "command": "nsauditor-ai-mcp",
486
488
  "env": {
487
489
  "NSA_MCP_AUTH_KEY": "keychain:NSA_MCP_AUTH_KEY",
490
+ "NSA_ENV_FILE": "~/envs/prod-aws.env",
488
491
  "AI_PROVIDER": "claude",
489
- "ANTHROPIC_API_KEY": "keychain:ANTHROPIC_API_KEY",
490
- "NSA_ALLOW_ALL_HOSTS": "1",
491
- "PLUGIN_TIMEOUT_MS": "5000"
492
+ "ANTHROPIC_API_KEY": "keychain:ANTHROPIC_API_KEY"
492
493
  }
493
494
  }
494
495
  }
@@ -502,6 +503,32 @@ The exact `NSA_MCP_AUTH_KEY` value to paste is printed by `nsauditor-ai mcp inst
502
503
  - `PLUGIN_TIMEOUT_MS=5000` β€” reduces per-plugin timeout to 5s so the full scan completes within Claude Desktop's 60s MCP limit
503
504
  - `AI_PROVIDER` and API key β€” optional, enables AI-powered analysis of scan results
504
505
 
506
+ #### `NSA_ENV_FILE` β€” point the MCP server at an environment file
507
+
508
+ Instead of inlining every scan variable in the config above, set **`NSA_ENV_FILE`** to a
509
+ dotenv file and keep the cloud credentials, `CLOUD_PROVIDER`, and scan tuning there. To scan
510
+ a different account or cloud, change the one path (or swap the file) and restart Claude Desktop β€”
511
+ no JSON editing.
512
+
513
+ ```bash
514
+ # ~/envs/prod-aws.env (chmod 600 β€” this holds credentials)
515
+ CLOUD_PROVIDER=aws
516
+ AWS_ACCESS_KEY_ID=AKIA...
517
+ AWS_SECRET_ACCESS_KEY=...
518
+ NSA_ALLOW_ALL_HOSTS=1
519
+ PLUGIN_TIMEOUT_MS=5000
520
+ ```
521
+
522
+ - The file is loaded at server startup; values in it **override** the same keys in the config `env` block.
523
+ - **Fail-fast:** if the path is missing or points at an AWS credentials/INI file, the server refuses
524
+ to start (it will not silently fall back to ambient credentials and scan the wrong account). The
525
+ error is written to the MCP server's stderr log.
526
+ - **The file is the authoritative scan target:** ambient provider credentials (e.g. an old account's
527
+ `AWS_*` keys still in the config `env` block) that the file does **not** set are cleared, so a partial
528
+ file can't silently scan a leftover account. Instance-role / ADC identity is untouched.
529
+ - `NSA_MCP_AUTH_KEY` and `NSAUDITOR_LICENSE_KEY` are resolved **before** the file and must stay
530
+ inline (or in `~/.nsauditor/.env`); if present in `NSA_ENV_FILE` they are ignored.
531
+
505
532
  ### Claude Code Setup
506
533
 
507
534
  ```bash
@@ -511,6 +538,15 @@ claude mcp add nsauditor-ai \
511
538
  -- npx nsauditor-ai-mcp
512
539
  ```
513
540
 
541
+ To target an environment via the file, add it as an env value:
542
+
543
+ ```bash
544
+ claude mcp add nsauditor-ai \
545
+ --env NSA_MCP_AUTH_KEY=keychain:NSA_MCP_AUTH_KEY \
546
+ --env NSA_ENV_FILE=~/envs/prod-aws.env \
547
+ -- npx nsauditor-ai-mcp
548
+ ```
549
+
514
550
  (On Linux/Windows, replace the `keychain:NSA_MCP_AUTH_KEY` placeholder with the literal key printed by `install-key`.)
515
551
 
516
552
  ### Troubleshooting MCP authentication
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import '../cli.mjs';
2
+ import { main } from '../cli.mjs';
3
+ main().catch((err) => { console.error(err?.stack || err); process.exit(1); });
package/cli.mjs CHANGED
@@ -5,6 +5,7 @@ import { buildHtmlReport } from './utils/report_html.mjs';
5
5
  import fsp from 'node:fs/promises';
6
6
  import { dirname } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { realpathSync } from 'node:fs';
8
9
  import { createRequire } from 'node:module';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -522,7 +523,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
522
523
 
523
524
  /* ------------------------------- CLI ----------------------------------- */
524
525
 
525
- async function parseArgs(argv) {
526
+ export async function parseArgs(argv) {
526
527
  const args = { cmd: 'scan', host: undefined, plugins: 'all', insecureHttps: false };
527
528
  const a = argv.slice(2);
528
529
  // Help: bare `--help`/`-h`/`help` or completely empty invocation.
@@ -604,6 +605,15 @@ async function parseArgs(argv) {
604
605
  const complianceScopeVal = get('compliance-scope') || get('compliance_scope');
605
606
  args.complianceScope = (complianceScopeVal && complianceScopeVal !== true) ? complianceScopeVal : null;
606
607
 
608
+ // Per-scan account selection (EE 0.16.x). `--env <path>` loads a dotenv
609
+ // (override-on) for this scan; `--aws-profile <name>` selects a named profile
610
+ // from ~/.aws/credentials. Both are wired into main() via resolveScanEnv().
611
+ // get() returns undefined (absent), true (value-less flag), or the string value.
612
+ const envVal = get('env');
613
+ args.env = envVal === undefined ? undefined : envVal; // string path, or true if value-less
614
+ const awsProfileVal = get('aws-profile');
615
+ args.awsProfile = awsProfileVal === undefined ? undefined : awsProfileVal;
616
+
607
617
  return args;
608
618
  }
609
619
 
@@ -804,8 +814,9 @@ function maxSeverityInConclusion(conclusion) {
804
814
  return max;
805
815
  }
806
816
 
807
- async function main() {
808
- const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope } = await parseArgs(process.argv);
817
+ export async function main() {
818
+ const args = await parseArgs(process.argv);
819
+ const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope } = args;
809
820
 
810
821
  // Version: handled before license verification so it works without a key.
811
822
  // CE-0.1.30.1 β€” closes the discovery-flag UX gap where pre-fix
@@ -833,6 +844,10 @@ Usage:
833
844
  Scan options:
834
845
  --host, --ip, --target <h> Target host, IP, or CIDR
835
846
  --host-file <path> File with one host per line
847
+ --env <path> Load a dotenv (KEY=value) file for this scan (per-account
848
+ credentials). Override-on; missing file = hard error.
849
+ --aws-profile <name> Use a named profile from the OS-default ~/.aws/credentials.
850
+ Implies CLOUD_PROVIDER=aws; overrides explicit AWS_* keys.
836
851
  --plugins <list|all> Plugins to run (e.g. 001,003,020 or "all"; default: all)
837
852
  --ports <range> Override port list (e.g. 22,80,443 or 1-1000)
838
853
  --out <dir> Output directory for scan artifacts
@@ -890,10 +905,15 @@ Environment:
890
905
  Cloud-scan hosts:
891
906
  --host aws | gcp | azure Sentinel literals (case-insensitive). These are not
892
907
  DNS-resolved; they route the scan to the matching
893
- cloud-scanner plugin via the provider's control-plane
894
- API. Pair with --plugins 020,030 (or similar) β€” using
895
- --plugins all on a sentinel host produces noisy
896
- unreachable-host findings from non-cloud plugins.
908
+ cloud-scanner plugins via the provider's control-plane
909
+ API, and imply CLOUD_PROVIDER=<host> when unset. With
910
+ --plugins all the scan AUTO-SCOPES to only that cloud's
911
+ plugins (other clouds + non-cloud plugins are skipped and
912
+ the skip is logged) β€” so --plugins all is safe here.
913
+ Note: the composite zero-trust checker (1023) has no
914
+ single cloud and is therefore skipped under this
915
+ auto-scope; to run it, select it explicitly
916
+ (--plugins 023,...) or scan a network host/CIDR.
897
917
 
898
918
  Examples:
899
919
  nsauditor-ai scan --host 10.0.0.1 --plugins all
@@ -907,6 +927,37 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
907
927
  process.exit(0);
908
928
  }
909
929
 
930
+ // Per-scan environment selection (--env / --aws-profile) + sentinel-host
931
+ // implied CLOUD_PROVIDER. The eager `import 'dotenv/config'` at the top of
932
+ // this file already loaded the default cwd `.env` (override-off) for
933
+ // import-time consumers; this layers the explicitly-selected env ON TOP
934
+ // (override-on) before license + dispatch.
935
+ if (args.env === true) {
936
+ console.error('Error: --env requires a file path (e.g. --env ~/envs/prod.env)');
937
+ process.exit(2);
938
+ }
939
+ if (args.awsProfile === true) {
940
+ console.error('Error: --aws-profile requires a profile name (e.g. --aws-profile prod)');
941
+ process.exit(2);
942
+ }
943
+ try {
944
+ const { resolveScanEnv } = await import('./utils/env_loader.mjs');
945
+ const fsm = await import('node:fs');
946
+ const patch = resolveScanEnv({
947
+ envPath: typeof args.env === 'string' ? args.env : undefined,
948
+ awsProfile: typeof args.awsProfile === 'string' ? args.awsProfile : undefined,
949
+ host,
950
+ env: process.env,
951
+ fileExists: (p) => fsm.existsSync(p),
952
+ readFile: (p) => fsm.readFileSync(p, 'utf8'),
953
+ });
954
+ Object.assign(process.env, patch.set); // override-on
955
+ for (const k of patch.unset) delete process.env[k];
956
+ } catch (err) {
957
+ console.error(`Error: ${err.message}`);
958
+ process.exit(1);
959
+ }
960
+
910
961
  // Verify license JWT at startup (~5ms for ES256). Populates _verifiedTier
911
962
  // so all subsequent getTierFromEnv() calls return the cryptographically
912
963
  // validated tier instead of relying on prefix detection alone.
@@ -2004,7 +2055,18 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2004
2055
  }
2005
2056
  }
2006
2057
 
2007
- main().catch((err) => {
2008
- console.error(err?.stack || err);
2009
- process.exit(1);
2010
- });
2058
+ // Only auto-run main() when this file is the process entrypoint. Importing
2059
+ // cli.mjs (e.g. unit tests pulling in `parseArgs`) must NOT trigger a scan.
2060
+ const _isEntrypoint = (() => {
2061
+ try {
2062
+ return !!process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
2063
+ } catch {
2064
+ return false;
2065
+ }
2066
+ })();
2067
+ if (_isEntrypoint) {
2068
+ main().catch((err) => {
2069
+ console.error(err?.stack || err);
2070
+ process.exit(1);
2071
+ });
2072
+ }
package/mcp_server.mjs CHANGED
@@ -592,6 +592,27 @@ export async function startStdioServer() {
592
592
  _tier = getTierFromEnv();
593
593
  _capabilities = resolveCapabilities(_tier);
594
594
 
595
+ // NSA_ENV_FILE: load the per-environment dotenv file AFTER auth + license
596
+ // (so it carries scan-target vars only and can alter neither) and BEFORE
597
+ // createServer()/connect. plugin_manager.mjs captures PLUGIN_TIMEOUT_MS in a
598
+ // module-level const at import (first tool call), so the file MUST be applied
599
+ // before any tool call can import it. Fail-fast: a missing / INI file refuses
600
+ // startup rather than silently scanning ambient credentials (wrong-account β†’
601
+ // a false "clean"). stdout is JSON-RPC, so diagnostics go to stderr.
602
+ try {
603
+ const fsm = await import('node:fs');
604
+ const { applyScanEnvFile } = await import('./utils/mcp_env_file.mjs');
605
+ applyScanEnvFile({
606
+ env: process.env,
607
+ fileExists: (p) => fsm.existsSync(p),
608
+ readFile: (p) => fsm.readFileSync(p, 'utf8'),
609
+ log: (m) => process.stderr.write(`[nsauditor-mcp] ${m}\n`),
610
+ });
611
+ } catch (err) {
612
+ process.stderr.write(`βœ— NSA_ENV_FILE: ${err.message}\n`);
613
+ process.exit(1);
614
+ }
615
+
595
616
  const server = createServer();
596
617
  const transport = new StdioServerTransport();
597
618
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.90",
3
+ "version": "0.1.92",
4
4
  "description": "Modular AI-assisted network security audit platform β€” Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -20,6 +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
24
 
24
25
  const __filename = fileURLToPath(import.meta.url);
25
26
 
@@ -749,15 +750,38 @@ export class PluginManager {
749
750
  let selection;
750
751
  let opts;
751
752
 
753
+ let rawSpec;
752
754
  if (specOrOptions && typeof specOrOptions === "object" && !Array.isArray(specOrOptions)) {
753
755
  const { plugins = "all", ...rest } = specOrOptions;
756
+ rawSpec = plugins;
754
757
  selection = this._resolveSelection(plugins);
755
758
  opts = rest;
756
759
  } else {
760
+ rawSpec = specOrOptions;
757
761
  selection = this._resolveSelection(specOrOptions);
758
762
  opts = maybeOpts || {};
759
763
  }
760
764
 
765
+ // Sentinel-host scoping: on --host aws|gcp|azure with the implicit `all`,
766
+ // run only that cloud's plugins; skip other clouds + non-cloud plugins.
767
+ const scope = scopeSelectionForHost(selection, host, rawSpec);
768
+ if (scope.scoped) {
769
+ selection = scope.selected;
770
+ if (scope.selected.length === 0) {
771
+ console.error(
772
+ `WARNING: --host '${scope.provider}' matched 0 ${scope.provider.toUpperCase()} plugins β€” ` +
773
+ `NOTHING was audited. Ensure the ${scope.provider.toUpperCase()} plugins are installed and ` +
774
+ `licensed (an empty result is NOT a clean pass).`,
775
+ );
776
+ } else if (scope.skipped.length) {
777
+ console.error(
778
+ `Cloud host '${scope.provider}' β†’ running ${scope.selected.length} ` +
779
+ `${scope.provider.toUpperCase()} plugin(s); skipping ${scope.skipped.length} ` +
780
+ `non-${scope.provider} plugin(s) (other clouds + non-cloud).`,
781
+ );
782
+ }
783
+ }
784
+
761
785
  // Decide execution mode
762
786
  const anyOrchestratedSignals = selection.some((p) => p?.priority != null || p?.requirements != null);
763
787
  const orchestrate = opts.orchestrate !== false && anyOrchestratedSignals;
@@ -0,0 +1,113 @@
1
+ // utils/env_loader.mjs
2
+ //
3
+ // Pure resolver for the per-scan environment selection flags (--env / --aws-profile)
4
+ // and the sentinel-host implied CLOUD_PROVIDER. Returns an env "patch"
5
+ // ({ set, unset }) that the CLI applies to process.env. Fail-fast: throws on a
6
+ // missing --env file or an INI/credentials file mistakenly passed to --env.
7
+ //
8
+ // fs is injected (readFile/fileExists) so the precedence + error rules unit-test
9
+ // without a real filesystem.
10
+
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import dotenv from 'dotenv';
14
+
15
+ const CLOUD_SENTINELS = new Set(['aws', 'gcp', 'azure']);
16
+ const AWS_EXPLICIT_KEYS = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN'];
17
+
18
+ function expandTilde(p) {
19
+ if (typeof p !== 'string') return p;
20
+ if (p === '~') return os.homedir();
21
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
22
+ return p;
23
+ }
24
+
25
+ // INI signature: a [section] header line, OR no KEY=value line at all.
26
+ function looksLikeIni(content) {
27
+ const hasSection = /^\s*\[[^\]]+\]\s*$/m.test(content);
28
+ const hasKeyValue = /^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=/m.test(content);
29
+ return hasSection || !hasKeyValue;
30
+ }
31
+
32
+ function providerAlreadySet(set, env) {
33
+ const v = set.CLOUD_PROVIDER ?? env.CLOUD_PROVIDER;
34
+ return v != null && String(v).trim() !== '';
35
+ }
36
+
37
+ // The effective CLOUD_PROVIDER after merging the --env file (set) + shell env.
38
+ function effectiveProvider(set, env) {
39
+ const v = set.CLOUD_PROVIDER ?? env.CLOUD_PROVIDER;
40
+ return v == null ? '' : String(v).trim().toLowerCase();
41
+ }
42
+
43
+ // Lenient match (mirrors awsScanSkipReason CSV semantics): the effective provider
44
+ // matches the host if it equals the host OR is a CSV that includes the host.
45
+ function providerMatchesHost(effective, hostProvider) {
46
+ if (!effective) return true; // unset β†’ no contradiction
47
+ const tokens = effective.split(',').map((t) => t.trim()).filter(Boolean);
48
+ return tokens.includes(hostProvider);
49
+ }
50
+
51
+ /**
52
+ * @param {object} args
53
+ * @param {string} [args.envPath] value of --env
54
+ * @param {string} [args.awsProfile] value of --aws-profile
55
+ * @param {string} [args.host] --host value (for sentinel CLOUD_PROVIDER implication)
56
+ * @param {object} args.env snapshot of current process.env (read-only)
57
+ * @param {(p:string)=>boolean} args.fileExists
58
+ * @param {(p:string)=>string} args.readFile
59
+ * @returns {{set: Record<string,string>, unset: string[]}}
60
+ */
61
+ export function resolveScanEnv({ envPath, awsProfile, host, env = {}, fileExists, readFile }) {
62
+ const set = {};
63
+ const unset = [];
64
+
65
+ // 1. --env dotenv file (override-on applied by caller via Object.assign).
66
+ if (typeof envPath === 'string' && envPath.length) {
67
+ const resolved = path.resolve(expandTilde(envPath));
68
+ if (!fileExists(resolved)) {
69
+ throw new Error(`--env file not found: ${resolved} (fail-fast: refusing to fall back to ambient credentials)`);
70
+ }
71
+ const content = readFile(resolved);
72
+ if (looksLikeIni(content)) {
73
+ throw new Error(
74
+ `${resolved} looks like an AWS credentials / INI file (has [section] headers / no KEY=value lines), ` +
75
+ `not a dotenv file. Use --aws-profile <name> instead (with AWS_SHARED_CREDENTIALS_FILE in an --env file ` +
76
+ `if the path is non-default).`,
77
+ );
78
+ }
79
+ Object.assign(set, dotenv.parse(content));
80
+ }
81
+
82
+ // 2. --aws-profile: AWS_PROFILE wins; clear explicit keys; load ~/.aws/config; imply aws.
83
+ if (typeof awsProfile === 'string' && awsProfile.length) {
84
+ set.AWS_PROFILE = awsProfile;
85
+ set.AWS_SDK_LOAD_CONFIG = '1';
86
+ for (const k of AWS_EXPLICIT_KEYS) {
87
+ delete set[k]; // a key from the --env file must not survive the profile
88
+ unset.push(k);
89
+ }
90
+ if (!providerAlreadySet(set, env)) set.CLOUD_PROVIDER = 'aws';
91
+ }
92
+
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
+ );
108
+ }
109
+ if (!providerAlreadySet(set, env)) set.CLOUD_PROVIDER = hostProvider;
110
+ }
111
+
112
+ return { set, unset };
113
+ }
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ // utils/sentinel_scope.mjs
2
+ //
3
+ // Sentinel-host plugin scoping. When the scan target is a cloud-sentinel host
4
+ // (`aws`/`gcp`/`azure`) and the plugin selection is the IMPLICIT `all`, run only
5
+ // the plugins tagged with that provider (`plugin.cloudProvider`), skipping the
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.
9
+
10
+ export const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
11
+
12
+ export function isCloudSentinelHost(host) {
13
+ return typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.trim().toLowerCase());
14
+ }
15
+
16
+ // spec is the ORIGINAL plugin spec ('all' | undefined | null β†’ implicit;
17
+ // array or CSV string β†’ explicit). Only the implicit case is scoped.
18
+ function isImplicitAll(spec) {
19
+ if (spec == null) return true;
20
+ if (typeof spec === 'string') return spec.trim().toLowerCase() === 'all';
21
+ return false; // arrays are always explicit
22
+ }
23
+
24
+ /**
25
+ * @param {Array<object>} selection resolved plugin objects (each may have .cloudProvider)
26
+ * @param {string} host
27
+ * @param {string|string[]} spec original selection spec
28
+ * @returns {{selected: object[], skipped: object[], scoped: boolean, provider: string|null}}
29
+ */
30
+ export function scopeSelectionForHost(selection, host, spec) {
31
+ if (!isCloudSentinelHost(host) || !isImplicitAll(spec)) {
32
+ return { selected: selection, skipped: [], scoped: false, provider: null };
33
+ }
34
+ const provider = host.trim().toLowerCase();
35
+ const selected = [];
36
+ const skipped = [];
37
+ for (const p of selection) {
38
+ if (p && p.cloudProvider === provider) selected.push(p);
39
+ else skipped.push(p);
40
+ }
41
+ return { selected, skipped, scoped: true, provider };
42
+ }