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 +40 -4
- package/bin/nsauditor-ai.mjs +2 -1
- package/cli.mjs +73 -11
- package/mcp_server.mjs +21 -0
- package/package.json +1 -1
- package/plugin_manager.mjs +24 -0
- package/utils/env_loader.mjs +113 -0
- package/utils/mcp_env_file.mjs +89 -0
- package/utils/sentinel_scope.mjs +42 -0
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.
|
|
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
|
package/bin/nsauditor-ai.mjs
CHANGED
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
|
|
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
|
|
894
|
-
API
|
|
895
|
-
--plugins all
|
|
896
|
-
|
|
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().
|
|
2008
|
-
|
|
2009
|
-
|
|
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
package/plugin_manager.mjs
CHANGED
|
@@ -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
|
+
}
|