nsauditor-ai 0.1.30 → 0.1.31

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
@@ -15,6 +15,43 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
15
15
 
16
16
  **Zero Data Exfiltration by design.** NSAuditor AI works fully offline. AI analysis, CVE correlation, and continuous monitoring all happen locally. External calls (to AI APIs, NVD, etc.) are opt-in and use your own API keys. We never see your scan data.
17
17
 
18
+ ## What's New (0.1.31) — security release
19
+
20
+ **MCP server authentication is now required.** Pre-0.1.31, the local MCP server (stdio transport) accepted any incoming JSON-RPC frames — any process running as your user could spawn it and use the Pro/Enterprise tools (which include the AWS-talking shadow-admin path detectors that ship in `@nsasoft/nsauditor-ai-ee@0.3.4`). 0.1.31 closes this gap with a per-operator shared-secret check at startup.
21
+
22
+ **Breaking change for existing operators**: after upgrading, run `nsauditor-ai mcp install-key` once. Without this step, the MCP server refuses to start and Claude Desktop will report a connection failure. The error message points back at this command.
23
+
24
+ ```bash
25
+ npm install -g nsauditor-ai@0.1.31
26
+ nsauditor-ai mcp install-key # generates a 256-bit key, persists it, prints Claude Desktop config snippet
27
+ ```
28
+
29
+ **What's in the box (EE-SEC.1):**
30
+
31
+ - `nsauditor-ai mcp install-key` — generates a 256-bit auth key, stores it in macOS Keychain (or `~/.nsauditor/.env` mode 0600 elsewhere), prints a paste-ready Claude Desktop config snippet. Run once per machine.
32
+ - `nsauditor-ai mcp status` / `print-key --confirm` / `rotate-key --confirm` — inspect, reveal (TTY-only by default), and rotate the key.
33
+ - **`keychain:` indirection on macOS** — the printed config snippet uses `"NSA_MCP_AUTH_KEY": "keychain:NSA_MCP_AUTH_KEY"` instead of the literal key. The MCP server resolves the placeholder at startup; **the secret never lands in your `claude_desktop_config.json`** (which is mode 0644 on macOS by default — readable by other local users and any sandboxed app). On Linux/Windows where there's no Keychain equivalent, the snippet falls back to the literal key with a `chmod 600` warning.
34
+ - **`NSA_MCP_AUTH_DISABLE=1`** escape hatch for CI / dev — emits a stderr warning every startup so you don't forget; a louder warning fires when DISABLE is set AND no key was ever installed.
35
+ - Multi-source resolver mirrors the existing license-key pattern: env → Keychain → file. Constant-time key comparison via `crypto.timingSafeEqual`. Two-reviewer cycle (general code review + network-security-audit lens) caught and folded 2 CRITICAL + 5 MEDIUM findings same-session before commit; full threat model documented in [`utils/mcp_auth.mjs`](./utils/mcp_auth.mjs).
36
+
37
+ See the [MCP Server § Authentication section](#authentication-required-new-in-0131) for the full setup walkthrough, troubleshooting, and the threat-model table.
38
+
39
+ ---
40
+
41
+ ## What's New in Enterprise Edition (0.3.3)
42
+
43
+ The Enterprise Edition (`@nsasoft/nsauditor-ai-ee`) shipped a 0.3.3 point release on 2026-05-08. CE stays at 0.1.30 — no bump required, the EE upgrade is single-line: `npm install -g @nsasoft/nsauditor-ai-ee@latest`. The release closes a Critical false-clean SOC 2 reporting bug that mirrored the AWS-side bug fixed in 0.3.2 — this time in the Azure cloud scanner — and extends mapped SOC 2 coverage to multi-cloud:
44
+
45
+ - **Azure plugin (022) finding-shape rewrite** — the EE-0.3.2.1 cloud-finding harvester only recognized one canonical shape (`{resource, severity, issues[]}`); plugin 022 was emitting `{severity, finding, resource}` (singular `finding`). Findings reached the engine but were silently dropped — Azure customers running `--compliance soc2` saw "6/6 covered controls passing" against subscriptions with real RBAC + NSG + Storage issues. Caught at internal dogfood against a live Azure subscription.
46
+ - **GCP plugin (021) preventive shape port** — plugin 021 had the same `{finding}` singular shape; pre-emptively migrated to the canonical shape so the same bug doesn't re-emerge for GCP customers in v0.4.0 when GCP `mapsToFindings` rules ship.
47
+ - **Azure → SOC 2 mapping rules added** — RBAC `Owner | Contributor | User Access Administrator at subscription scope` → CC6.1 (3 patterns); NSG `0.0.0.0/0 → port` anchored regex → CC6.6; Storage `allowBlobPublicAccess` + `enableHttpsTrafficOnly` → C1.1. CC6.1, CC6.6, and C1.1 now have *both* AWS-side and Azure-side evidence rows — actual multi-cloud SOC 2 evidence.
48
+ - **Drift detector extended to all three cloud plugins** — every `azure-cloud-scanner` `titlePattern` in `soc2.json` is now asserted to match at least one canonical issue string the plugin emits, and vice versa. The class-of-bug that produced two false-clean variants in successive releases is now structurally closed.
49
+ - **Pre-publish gate fixes** — `@azure/arm-authorization` peer-dep was pinned at `^10.0.0` (latest published is 9.0.0; clean installs would have failed); `@azure/arm-storage` was missing from `optionalDependencies` (Storage audit silently no-op'd on clean install). Both caught at the pre-publish clean-tarball smoke and corrected before ship.
50
+
51
+ See [EE README](https://www.npmjs.com/package/@nsasoft/nsauditor-ai-ee) for the full 0.3.3 changelog and Azure scan walkthrough.
52
+
53
+ ---
54
+
18
55
  ## What's New (0.1.30)
19
56
 
20
57
  The 0.1.30 line is a paired release with `@nsasoft/nsauditor-ai-ee@0.3.2` that closes the customer-onboarding gap and a critical false-clean SOC 2 reporting bug:
@@ -60,7 +97,7 @@ NSAuditor AI is available in three editions:
60
97
  | Advanced CTEM + trend analysis | — | ✅ | ✅ |
61
98
  | Cloud scanners (AWS/GCP/Azure) | — | — | ✅ |
62
99
  | Zero Trust assessment | — | — | ✅ |
63
- | SOC 2 compliance (8 covered + 5 partial controls) | — | — | ✅ |
100
+ | SOC 2 compliance (8 covered + 5 partial controls; AWS + Azure evidence streams) | — | — | ✅ |
64
101
  | SLA/MTTR tracking + compensating controls | — | — | ✅ |
65
102
  | Recurring-scan attestation (Type II evidence) | — | — | ✅ |
66
103
  | GRC platform connector (Vanta) | — | — | ✅ |
@@ -182,7 +219,7 @@ Results land in `./out/<host>_<timestamp>/`:
182
219
  |---|---|---|---|
183
220
  | 020 | AWS Cloud Scanner | Enterprise | Security group + IAM policy analysis |
184
221
  | 021 | GCP Cloud Scanner | Enterprise | Firewall rules + IAM bindings |
185
- | 022 | Azure Cloud Scanner | Enterprise | NSG rules + RBAC analysis |
222
+ | 022 | Azure Cloud Scanner | Enterprise | NSG rules + RBAC role assignments + Storage account hardening; SOC 2 mapping (CC6.1, CC6.6, C1.1) shipped in EE 0.3.3 |
186
223
  | 023 | Zero Trust Checker | Enterprise | Segmentation, encryption, identity, lateral movement scoring |
187
224
  | — | SOC 2 Compliance Engine | Enterprise | AICPA TSC 2017 control mapping, chain-of-custody, RFC 3161 timestamps, suppression workflow |
188
225
  | — | SLA & MTTR Tracking | Enterprise | Per-severity SLA targets, compensating-control flow, finding lifecycle |
@@ -292,7 +329,46 @@ npx nsauditor-ai-mcp
292
329
  | `compliance_check` | Compliance mapping with gap analysis |
293
330
  | `export_report` | Generate formatted compliance report |
294
331
 
295
- Security: SSRF protection on all host inputs (blocks RFC 1918, loopback, fc00::/7, cloud metadata), port validation (1–65535), CPE format enforcement, dependency injection for test isolation.
332
+ Security: SSRF protection on all host inputs (blocks RFC 1918, loopback, fc00::/7, cloud metadata), port validation (1–65535), CPE format enforcement, dependency injection for test isolation. **Server-startup authentication required as of 0.1.31** — see next section.
333
+
334
+ ### Authentication (required, NEW in 0.1.31)
335
+
336
+ The MCP server uses stdio transport, which means it runs as a child process of whatever client launches it. Without authentication, **any process running as your user could spawn the server and use its tools** — including the Pro/Enterprise tools that talk to AWS, generate compliance reports, and access your scan history. 0.1.31 closes this gap with a per-operator shared-secret check at server startup.
337
+
338
+ **One-time setup** (run once per machine after `npm install -g nsauditor-ai`):
339
+
340
+ ```bash
341
+ nsauditor-ai mcp install-key
342
+ ```
343
+
344
+ This generates a 256-bit auth key, stores it in the macOS Keychain (or `~/.nsauditor/.env` mode 0600 on Linux/Windows), and prints the Claude Desktop config snippet for you to paste. **The MCP server refuses to start unless the env-presented key matches the stored key** (constant-time compare; mismatch produces an actionable error pointing at this command).
345
+
346
+ **Inspect / verify**:
347
+
348
+ ```bash
349
+ nsauditor-ai mcp status # shows storage source WITHOUT printing the key
350
+ nsauditor-ai mcp print-key --confirm # reveals the key (use sparingly; refuses non-TTY output)
351
+ nsauditor-ai mcp rotate-key --confirm # generates a new key (invalidates old one immediately)
352
+ ```
353
+
354
+ **Why the Claude Desktop config snippet uses `keychain:` indirection on macOS**: the printed snippet looks like `"NSA_MCP_AUTH_KEY": "keychain:NSA_MCP_AUTH_KEY"` rather than the literal key value. The MCP server resolves the placeholder from your Keychain at startup. Net effect: **the secret never lands in `~/Library/Application Support/Claude/claude_desktop_config.json`** (which is mode 0644 by default — readable by other local users and any macOS app with Documents/Application Support entitlement). On Linux/Windows where there's no Keychain equivalent, the snippet uses the literal key with an explicit `chmod 600` warning.
355
+
356
+ **Threat model — what this defends, what it doesn't**:
357
+
358
+ | Threat | Defended? |
359
+ |---|---|
360
+ | Malicious npm post-install / browser extension running as you spawning the server | ✅ — attacker cannot read your Keychain without GUI prompt |
361
+ | Other users on a shared dev box / CI runner | ✅ — key is per-operator |
362
+ | Future HTTP/SSE transport network exposure | ✅ — key gates server startup, not network |
363
+ | Attacker with full operator code-exec AND can suppress macOS Keychain prompts | ⚠ partial — recent macOS versions log Keychain-access denial events |
364
+ | Debugger-attach memory snooping | ⚠ out of scope (any shared-secret auth has this limit) |
365
+ | Linux env-var visibility in `/proc/<pid>/environ` | ⚠ partial — only literal-key configs leak; the macOS keychain: indirection avoids this entirely |
366
+
367
+ **Escape hatch for CI / dev** (operator-acknowledged risk; emits a stderr warning every startup):
368
+
369
+ ```bash
370
+ NSA_MCP_AUTH_DISABLE=1 nsauditor-ai-mcp
371
+ ```
296
372
 
297
373
  ### Claude Desktop Setup
298
374
 
@@ -300,6 +376,7 @@ First install the package globally:
300
376
 
301
377
  ```bash
302
378
  npm install -g nsauditor-ai
379
+ nsauditor-ai mcp install-key # NEW in 0.1.31 — required before MCP server will start
303
380
  ```
304
381
 
305
382
  Then add this to your `claude_desktop_config.json` (Settings → Developer → Edit Config):
@@ -308,11 +385,11 @@ Then add this to your `claude_desktop_config.json` (Settings → Developer → E
308
385
  {
309
386
  "mcpServers": {
310
387
  "nsauditor-ai": {
311
- "command": "node",
312
- "args": ["/path/to/global/node_modules/nsauditor-ai/mcp_server.mjs"],
388
+ "command": "nsauditor-ai-mcp",
313
389
  "env": {
390
+ "NSA_MCP_AUTH_KEY": "keychain:NSA_MCP_AUTH_KEY",
314
391
  "AI_PROVIDER": "claude",
315
- "ANTHROPIC_API_KEY": "your-key-here",
392
+ "ANTHROPIC_API_KEY": "keychain:ANTHROPIC_API_KEY",
316
393
  "NSA_ALLOW_ALL_HOSTS": "1",
317
394
  "PLUGIN_TIMEOUT_MS": "5000"
318
395
  }
@@ -321,8 +398,9 @@ Then add this to your `claude_desktop_config.json` (Settings → Developer → E
321
398
  }
322
399
  ```
323
400
 
324
- Find your global install path with `npm root -g`, then append `/nsauditor-ai/mcp_server.mjs`.
401
+ The exact `NSA_MCP_AUTH_KEY` value to paste is printed by `nsauditor-ai mcp install-key` on macOS it's the `keychain:NSA_MCP_AUTH_KEY` placeholder shown above; on Linux/Windows it's the literal key value (and you should `chmod 600` your config file).
325
402
 
403
+ - `NSA_MCP_AUTH_KEY` — **required as of 0.1.31** (see Authentication section above)
326
404
  - `NSA_ALLOW_ALL_HOSTS=1` — required to scan private/RFC 1918 addresses (e.g., `192.168.x.x`)
327
405
  - `PLUGIN_TIMEOUT_MS=5000` — reduces per-plugin timeout to 5s so the full scan completes within Claude Desktop's 60s MCP limit
328
406
  - `AI_PROVIDER` and API key — optional, enables AI-powered analysis of scan results
@@ -330,9 +408,24 @@ Find your global install path with `npm root -g`, then append `/nsauditor-ai/mcp
330
408
  ### Claude Code Setup
331
409
 
332
410
  ```bash
333
- claude mcp add nsauditor-ai -- npx nsauditor-ai-mcp
411
+ nsauditor-ai mcp install-key # NEW in 0.1.31 — required before MCP server will start
412
+ claude mcp add nsauditor-ai \
413
+ --env NSA_MCP_AUTH_KEY=keychain:NSA_MCP_AUTH_KEY \
414
+ -- npx nsauditor-ai-mcp
334
415
  ```
335
416
 
417
+ (On Linux/Windows, replace the `keychain:NSA_MCP_AUTH_KEY` placeholder with the literal key printed by `install-key`.)
418
+
419
+ ### Troubleshooting MCP authentication
420
+
421
+ **"MCP authentication is not configured"** at server startup → run `nsauditor-ai mcp install-key`. If you set `NSA_MCP_AUTH_DISABLE=1` in CI by intent, that's fine — but check that you didn't forget it in your shell rc.
422
+
423
+ **"NSA_MCP_AUTH_KEY env var is not set, but a key is configured in storage"** → the server found a key in your Keychain (or `~/.nsauditor/.env`) but the spawning client didn't pass `NSA_MCP_AUTH_KEY` in the env block. Update your Claude Desktop / Claude Code config to include the env value (use `nsauditor-ai mcp install-key` output as a reference snippet).
424
+
425
+ **"NSA_MCP_AUTH_KEY env var does not match the key configured in storage"** → most often means you ran `nsauditor-ai mcp rotate-key --confirm` but didn't update Claude Desktop config with the new key. Run `nsauditor-ai mcp status` to confirm storage source, then either re-paste the new key or use `keychain:NSA_MCP_AUTH_KEY` indirection (macOS only) so future rotations don't require a config change.
426
+
427
+ **"MCP_AUTH uses keychain: indirection but the referenced Keychain entry could not be read"** → typically a headless macOS / SSH-only CI runner where there's no GUI session to approve Keychain access. Replace the `keychain:` placeholder with the literal key value (or move auth to `~/.nsauditor/.env` with mode 0600).
428
+
336
429
  ---
337
430
 
338
431
  ## Secure Credential Storage
package/cli.mjs CHANGED
@@ -853,6 +853,18 @@ License subcommands:
853
853
  nsauditor-ai license --plugins List discovered plugins grouped by source
854
854
  (CE / EE / custom) with active-or-required-tier
855
855
 
856
+ MCP server-auth subcommands (EE-SEC.1):
857
+ nsauditor-ai mcp install-key Generate a new MCP auth key, persist (Keychain
858
+ on macOS, ~/.nsauditor/.env elsewhere), print
859
+ Claude Desktop config snippet. Run ONCE per
860
+ machine; without this the MCP server refuses
861
+ to start (anti-spoofing for Pro/Enterprise tools).
862
+ nsauditor-ai mcp install-key <KEY> Persist a caller-supplied key (e.g., enterprise-
863
+ managed secret). Validates shape before storing.
864
+ nsauditor-ai mcp print-key --confirm Reveal the stored key (use with care)
865
+ nsauditor-ai mcp rotate-key Replace the stored key with a fresh one
866
+ nsauditor-ai mcp status Show storage source without revealing the key
867
+
856
868
  Security subcommands (macOS Keychain):
857
869
  nsauditor-ai security set <KEY> Store a secret (read from stdin)
858
870
  nsauditor-ai security delete <KEY> Remove a secret
@@ -861,6 +873,9 @@ Security subcommands (macOS Keychain):
861
873
 
862
874
  Environment:
863
875
  NSAUDITOR_LICENSE_KEY Pro/Enterprise license JWT (env var; takes precedence)
876
+ NSA_MCP_AUTH_KEY MCP server auth key — read by mcp_server at startup;
877
+ client supplies via Claude Desktop config env block
878
+ NSA_MCP_AUTH_DISABLE=1 Skip MCP auth check (CI/dev escape hatch — emits warn)
864
879
  NSA_ALLOW_ALL_HOSTS=1 Permit RFC1918 / loopback (local-network auditing)
865
880
  CLOUD_PROVIDER=aws|gcp|azure Required for cloud scanner plugins (020/021/022/023/030)
866
881
  AI_PROVIDER=openai|claude|ollama AI provider for report generation
@@ -1051,6 +1066,218 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1051
1066
  process.exit(0);
1052
1067
  }
1053
1068
 
1069
+ // EE-SEC.1: MCP server authentication management. Generates, stores,
1070
+ // and inspects the shared secret that authorizes Claude Desktop (or
1071
+ // any MCP client) to call the local MCP server. Without this, any
1072
+ // process running as the operator could spawn the server and call
1073
+ // the Pro/Enterprise tools — including the AWS-talking shadow-admin
1074
+ // path detectors in EE 0.3.4. See utils/mcp_auth.mjs for the full
1075
+ // threat model.
1076
+ if (cmd === 'mcp') {
1077
+ const {
1078
+ generateMcpAuthKey,
1079
+ validateMcpAuthKeyShape,
1080
+ persistMcpAuthKey,
1081
+ reportMcpAuthSource,
1082
+ MCP_AUTH_ENV_VAR,
1083
+ MCP_AUTH_DISABLE_ENV_VAR,
1084
+ } = await import('./utils/mcp_auth.mjs');
1085
+
1086
+ const rawArgs = process.argv.slice(2);
1087
+ const subCmd = rawArgs[1]; // install-key | print-key | rotate-key | status
1088
+
1089
+ function printConfigSnippet(key, persistedLocation) {
1090
+ // Claude Desktop config snippet — the canonical client integration
1091
+ // for stdio MCP servers. Operators paste this into
1092
+ // ~/Library/Application Support/Claude/claude_desktop_config.json
1093
+ // (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows).
1094
+ //
1095
+ // Reviewer 2 CRITICAL #2 fold: when the key was persisted to
1096
+ // macOS Keychain, emit a `keychain:` indirection in the snippet
1097
+ // instead of the literal key. Claude Desktop's config file is
1098
+ // typically world-readable on macOS (default umask 0644); baking
1099
+ // the secret into it defeats the per-operator threat model.
1100
+ // The MCP server resolves `keychain:LABEL` at startup via
1101
+ // resolveSecret() (utils/keychain.mjs:105), pulling the actual
1102
+ // value from Keychain — secret never leaves the secure store.
1103
+ //
1104
+ // On Linux/Windows there's no equivalent secret store today; the
1105
+ // snippet uses the literal key value with an explicit chmod warning.
1106
+ const onKeychain = typeof persistedLocation === 'string' && persistedLocation.includes('Keychain');
1107
+ const envValue = onKeychain ? `keychain:${MCP_AUTH_ENV_VAR}` : key;
1108
+
1109
+ console.log('');
1110
+ console.log('Add this to your Claude Desktop config (claude_desktop_config.json):');
1111
+ console.log('');
1112
+ console.log(' {');
1113
+ console.log(' "mcpServers": {');
1114
+ console.log(' "nsauditor-ai": {');
1115
+ console.log(' "command": "nsauditor-ai-mcp",');
1116
+ console.log(` "env": { "${MCP_AUTH_ENV_VAR}": "${envValue}" }`);
1117
+ console.log(' }');
1118
+ console.log(' }');
1119
+ console.log(' }');
1120
+ console.log('');
1121
+ if (onKeychain) {
1122
+ console.log(`The "${envValue}" placeholder tells the MCP server to resolve the actual`);
1123
+ console.log('key from your macOS Keychain at startup. The secret value never leaves');
1124
+ console.log("the Keychain and is never written to your Claude Desktop config file.");
1125
+ console.log('');
1126
+ console.log('On a headless macOS / CI runner where Keychain access is unavailable,');
1127
+ console.log(`replace the placeholder with the literal key (run \`nsauditor-ai mcp`);
1128
+ console.log(`print-key --confirm\` to retrieve it).`);
1129
+ } else {
1130
+ console.log(`⚠ The literal key value is now in your Claude Desktop config file.`);
1131
+ console.log(` On a multi-user system, ensure that file is mode 0600 (operator-only).`);
1132
+ console.log(` On Linux, also note that the spawned MCP server's env block is`);
1133
+ console.log(` visible to other users via /proc — same caveat as any env-based secret.`);
1134
+ }
1135
+ console.log('');
1136
+ console.log(`The configured key is also stored at: ${persistedLocation || '<see install-key output>'}`);
1137
+ console.log('The MCP server compares the env-resolved key against the stored key at');
1138
+ console.log('startup and refuses to start on mismatch — so anyone trying to spawn the');
1139
+ console.log('server without the right key fails immediately.');
1140
+ }
1141
+
1142
+ if (subCmd === 'install-key') {
1143
+ // Accept either a caller-supplied key (for restoring from backup
1144
+ // or aligning with an enterprise-managed secret) or generate a
1145
+ // fresh one. Both paths persist via the same multi-source storage
1146
+ // chain used for license keys.
1147
+ let key = rawArgs[2];
1148
+ let generated = false;
1149
+ if (!key || key.startsWith('-')) {
1150
+ key = generateMcpAuthKey();
1151
+ generated = true;
1152
+ } else {
1153
+ const validation = validateMcpAuthKeyShape(key);
1154
+ if (!validation.ok) {
1155
+ console.error(`✗ Key rejected: ${validation.reason}`);
1156
+ console.error(` Expected format: nsa_mcp_<43-char-base64url>`);
1157
+ console.error(` Generate a fresh key with: nsauditor-ai mcp install-key`);
1158
+ process.exit(1);
1159
+ }
1160
+ }
1161
+
1162
+ const persisted = await persistMcpAuthKey(key);
1163
+ if (!persisted.ok) {
1164
+ console.error(`✗ Failed to persist MCP auth key: ${persisted.error}`);
1165
+ console.error(` Fall-back: set ${MCP_AUTH_ENV_VAR} env var manually:`);
1166
+ console.error(` export ${MCP_AUTH_ENV_VAR}="${key}"`);
1167
+ process.exit(1);
1168
+ }
1169
+
1170
+ if (persisted.warning) {
1171
+ console.warn(`⚠ ${persisted.warning}`);
1172
+ }
1173
+ console.log(`✓ MCP auth key ${generated ? 'generated and ' : ''}installed`);
1174
+ console.log(` Stored at: ${persisted.location}`);
1175
+ printConfigSnippet(key, persisted.location);
1176
+ } else if (subCmd === 'rotate-key') {
1177
+ // Generate a fresh key and persist over the old one. Old key is
1178
+ // immediately invalid — operator must update Claude Desktop
1179
+ // config to match. Reviewer 1 MEDIUM #3 fold: gate behind
1180
+ // --confirm to prevent accidental Claude-disconnect when an
1181
+ // operator typos `r` instead of `i` (rotate-key is keyboard-
1182
+ // adjacent to install-key). For SOC 2 audit windows where
1183
+ // availability matters, the extra keystroke is the right trade.
1184
+ const confirmed = rawArgs.includes('--confirm');
1185
+ if (!confirmed) {
1186
+ console.error(`✗ \`mcp rotate-key\` immediately invalidates the existing key.`);
1187
+ console.error(` Any running Claude Desktop session will fail until you update`);
1188
+ console.error(` the config with the new key value. Re-run with --confirm:`);
1189
+ console.error(` nsauditor-ai mcp rotate-key --confirm`);
1190
+ process.exit(2);
1191
+ }
1192
+ const key = generateMcpAuthKey();
1193
+ const persisted = await persistMcpAuthKey(key);
1194
+ if (!persisted.ok) {
1195
+ console.error(`✗ Failed to persist rotated MCP auth key: ${persisted.error}`);
1196
+ process.exit(1);
1197
+ }
1198
+ if (persisted.warning) {
1199
+ console.warn(`⚠ ${persisted.warning}`);
1200
+ }
1201
+ console.log(`✓ MCP auth key rotated`);
1202
+ console.log(` Stored at: ${persisted.location}`);
1203
+ console.log('');
1204
+ console.log(' ⚠ The OLD key is now invalid. Update your Claude Desktop config NOW.');
1205
+ printConfigSnippet(key, persisted.location);
1206
+ } else if (subCmd === 'print-key') {
1207
+ // Reveal the stored key — gated behind --confirm to defend
1208
+ // against accidental shell-history capture. Reviewer 2 CRITICAL #1
1209
+ // fold: write the key to STDERR (not stdout) so accidental output
1210
+ // redirection (`> command.log`) doesn't slurp the secret into a
1211
+ // log file. Also refuse when stdout is non-TTY (pipe) unless
1212
+ // --force is added — guards against silent capture by command-
1213
+ // substitution (e.g., `KEY=$(nsauditor-ai mcp print-key --confirm)`
1214
+ // where the key is then echo'd into a script's history).
1215
+ const confirmed = rawArgs.includes('--confirm');
1216
+ const forced = rawArgs.includes('--force');
1217
+ if (!confirmed) {
1218
+ console.error(`✗ \`mcp print-key\` reveals a secret to your terminal.`);
1219
+ console.error(` Re-run with --confirm if that's what you intended:`);
1220
+ console.error(` nsauditor-ai mcp print-key --confirm`);
1221
+ console.error(` Note that the key will be captured in shell history and any`);
1222
+ console.error(` active screen-share / tmux scrollback. Prefer copying directly`);
1223
+ console.error(` from the install-key output, or use \`keychain:\` indirection`);
1224
+ console.error(` in your Claude Desktop config (see \`mcp install-key\` output).`);
1225
+ process.exit(2);
1226
+ }
1227
+ if (!process.stderr.isTTY && !forced) {
1228
+ console.error(`✗ \`mcp print-key --confirm\` refuses non-TTY output (likely a pipe`);
1229
+ console.error(` or redirection). Add --force to override; this almost certainly`);
1230
+ console.error(` means the key would land in a script/log file unintentionally.`);
1231
+ process.exit(2);
1232
+ }
1233
+ const { resolveMcpAuthKey } = await import('./utils/mcp_auth.mjs');
1234
+ const key = await resolveMcpAuthKey();
1235
+ if (!key) {
1236
+ console.error(`✗ No MCP auth key configured.`);
1237
+ console.error(` Generate one with: nsauditor-ai mcp install-key`);
1238
+ process.exit(1);
1239
+ }
1240
+ // Write to STDERR (not stdout) so `> file.log` redirections don't
1241
+ // capture the secret. The TTY-check above ensures stderr IS a
1242
+ // visible terminal (otherwise we'd refuse). Operators copy from
1243
+ // the visible terminal output, not from a redirected stdout.
1244
+ process.stderr.write(`${key}\n`);
1245
+ } else if (subCmd === 'status') {
1246
+ // Report which storage source the resolver currently honors,
1247
+ // WITHOUT printing the key value. Safe to run in screen-share,
1248
+ // logs, etc.
1249
+ const result = await reportMcpAuthSource();
1250
+ if (result.source === 'unconfigured') {
1251
+ console.log(`✗ MCP authentication is not configured.`);
1252
+ console.log(` Generate a key with: nsauditor-ai mcp install-key`);
1253
+ if (process.env[MCP_AUTH_DISABLE_ENV_VAR] === '1') {
1254
+ console.log('');
1255
+ console.log(` ⚠ ${MCP_AUTH_DISABLE_ENV_VAR}=1 is set — server will start without auth.`);
1256
+ }
1257
+ process.exit(1);
1258
+ } else {
1259
+ console.log(`✓ MCP auth key configured`);
1260
+ console.log(` Source: ${result.source}${result.detail ? ` (${result.detail})` : ''}`);
1261
+ if (process.env[MCP_AUTH_DISABLE_ENV_VAR] === '1') {
1262
+ console.log('');
1263
+ console.log(` ⚠ ${MCP_AUTH_DISABLE_ENV_VAR}=1 is set — server will start without auth.`);
1264
+ }
1265
+ }
1266
+ } else {
1267
+ console.log('Usage:');
1268
+ console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
1269
+ console.log(' nsauditor-ai mcp install-key <KEY> Persist a caller-supplied key (e.g., from backup)');
1270
+ console.log(' nsauditor-ai mcp print-key --confirm Reveal the stored key (use with care)');
1271
+ console.log(' nsauditor-ai mcp rotate-key Replace the stored key with a fresh one');
1272
+ console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
1273
+ console.log('');
1274
+ console.log('Environment variables:');
1275
+ console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
1276
+ console.log(` ${MCP_AUTH_DISABLE_ENV_VAR}=1 Skip auth check (CI/dev escape hatch — emits stderr warning)`);
1277
+ }
1278
+ process.exit(0);
1279
+ }
1280
+
1054
1281
  if (cmd === 'security') {
1055
1282
  const { keychainSet, keychainDelete, keychainList, keychainGet } = await import('./utils/keychain.mjs');
1056
1283
  const rawArgs = process.argv.slice(2);
package/mcp_server.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
23
23
  import { resolveCapabilities } from './utils/capabilities.mjs';
24
24
  import { buildMarkdownReport } from './utils/report_md.mjs';
25
+ import { authorizeMcpServerStartup } from './utils/mcp_auth.mjs';
25
26
 
26
27
  const _require = createRequire(import.meta.url);
27
28
  const { version: TOOL_VERSION } = _require('./package.json');
@@ -399,6 +400,42 @@ const isMainModule =
399
400
  process.argv[1].endsWith('mcp_server'));
400
401
 
401
402
  if (isMainModule) {
403
+ // EE-SEC.1: enforce MCP server authentication BEFORE accepting any
404
+ // tool calls. Pre-fold any process running as the operator could
405
+ // spawn the server and call Pro/Enterprise tools — including the
406
+ // AWS-talking shadow-admin path detectors that ship in EE 0.3.4.
407
+ // Now: refuse to start unless the env-provided NSA_MCP_AUTH_KEY
408
+ // matches the operator's configured key (set via
409
+ // `nsauditor-ai mcp install-key`). Constant-time compare; honors
410
+ // NSA_MCP_AUTH_DISABLE=1 escape hatch with stderr warning.
411
+ //
412
+ // Stdio-MCP: stdout is reserved for JSON-RPC frames, so all
413
+ // operator-facing diagnostic text MUST go to stderr.
414
+ const authResult = await authorizeMcpServerStartup();
415
+ if (!authResult.ok) {
416
+ process.stderr.write(`✗ ${authResult.error}\n`);
417
+ process.exit(1);
418
+ }
419
+ if (authResult.bypassed) {
420
+ if (authResult.bypassedReason === 'unconfigured') {
421
+ // CRITICAL #2 fold (Reviewer 1): louder signal when DISABLE=1
422
+ // is set but no key was ever installed — this is almost always
423
+ // an operator who set DISABLE in their shell rc and forgot.
424
+ process.stderr.write(
425
+ `⚠ MCP authentication disabled via NSA_MCP_AUTH_DISABLE=1, ` +
426
+ `AND no key has ever been installed. This is almost certainly ` +
427
+ `unintentional. Either run \`nsauditor-ai mcp install-key\` to set up ` +
428
+ `auth properly, or remove the DISABLE env var if you didn't mean to set it. ` +
429
+ `Anyone with code-execution as $USER can call MCP tools right now.\n`,
430
+ );
431
+ } else {
432
+ process.stderr.write(
433
+ `⚠ MCP authentication disabled via NSA_MCP_AUTH_DISABLE=1. ` +
434
+ `Anyone with code-execution as $USER can call MCP tools.\n`,
435
+ );
436
+ }
437
+ }
438
+
402
439
  // Verify license JWT before accepting MCP requests — upgrades _tier from
403
440
  // prefix-based to cryptographically verified.
404
441
  await loadLicense();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,532 @@
1
+ // utils/mcp_auth.mjs
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // EE-SEC.1 — MCP server authentication.
4
+ //
5
+ // The MCP server (`mcp_server.mjs`) uses stdio transport, which means the
6
+ // server runs as a subprocess of whatever client launched it (Claude
7
+ // Desktop, Claude Code, custom IDE integration). Pre-EE-SEC.1, ANY
8
+ // process running as the operator could `child_process.spawn('node',
9
+ // ['mcp_server.mjs'])` and call the Pro/Enterprise tools — including
10
+ // the AWS-talking shadow-admin path detectors that ship in EE 0.3.4.
11
+ //
12
+ // Threat model the gap enables:
13
+ // - Code-execution-as-operator escalation: a malicious npm post-install,
14
+ // a compromised browser extension, a hostile dev tool can spawn the
15
+ // server and use its tools to scan the operator's AWS account,
16
+ // mutate finding queues, or exfiltrate license-gated SOC 2 evidence.
17
+ // - Multi-user shared machines: any user on a shared dev box / CI
18
+ // runner can launch the MCP server as themselves.
19
+ // - Future HTTP transport risk: if SSE/HTTP is ever added, the same
20
+ // gap becomes network-exposed.
21
+ //
22
+ // Mitigation: shared-secret authentication. The operator runs
23
+ // `nsauditor-ai mcp install-key` once, which generates a 256-bit
24
+ // random key, persists it via the same multi-source storage chain
25
+ // already used for license keys (env → macOS Keychain → ~/.nsauditor/.env),
26
+ // and prints a Claude Desktop config snippet that places the key in
27
+ // the spawned-process env. The MCP server resolves the EXPECTED key
28
+ // at startup from storage, reads the PRESENTED key from
29
+ // process.env.NSA_MCP_AUTH_KEY, and refuses to start unless they
30
+ // match (constant-time compare).
31
+ //
32
+ // What this defends, what it doesn't:
33
+ // ✅ Defends against malicious code running as the operator that tries
34
+ // to spawn the MCP server — the attacker doesn't have the key
35
+ // because reading it requires Keychain GUI prompt approval (macOS)
36
+ // or read-access to ~/.nsauditor/.env (which is mode 0600).
37
+ // ✅ Defends against shared-machine other-user attacks — the key is
38
+ // per-operator in their Keychain, not in a world-readable file.
39
+ // ⚠ Does NOT defend against an attacker with full operator-level code
40
+ // execution AND the ability to suppress macOS Keychain prompts.
41
+ // Recent macOS versions log Keychain-access denial events; SIEM
42
+ // pipelines should alarm on those.
43
+ // ⚠ Does NOT defend against debugger-attach memory snooping — the
44
+ // resolved key lives in MCP server process memory. Out of scope
45
+ // for v1; same fundamental limitation as any shared-secret auth.
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ import { promises as fsp } from 'node:fs';
49
+ import { homedir, platform } from 'node:os';
50
+ import { dirname, join } from 'node:path';
51
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
52
+ import dotenv from 'dotenv';
53
+
54
+ import { keychainGet, keychainSet, resolveSecret } from './keychain.mjs';
55
+
56
+ // ── Constants ────────────────────────────────────────────────────────────────
57
+
58
+ // Storage key name. Kept distinct from NSAUDITOR_LICENSE_KEY so the two
59
+ // secrets are independently rotatable and the env-var pollution in
60
+ // `ps -ef` listings (Linux) is named explicitly enough that operators
61
+ // understand what it is.
62
+ export const MCP_AUTH_ENV_VAR = 'NSA_MCP_AUTH_KEY';
63
+
64
+ // Escape hatch env var. When set to "1", the MCP server skips the auth
65
+ // check entirely (with a stderr warning). For CI/dev environments where
66
+ // the operator accepts the risk.
67
+ export const MCP_AUTH_DISABLE_ENV_VAR = 'NSA_MCP_AUTH_DISABLE';
68
+
69
+ // Keychain account name (under service=nsauditor-ai). Same value as the
70
+ // env var name — keeps the storage and resolution paths symmetrically
71
+ // named so operators don't have to remember two strings.
72
+ const MCP_AUTH_KEYCHAIN_ACCOUNT = MCP_AUTH_ENV_VAR;
73
+
74
+ // Key prefix. Lets operators distinguish from license keys (`pro_eyJ...`,
75
+ // `enterprise_eyJ...`) at a glance. 32 bytes of entropy → 43 chars
76
+ // base64url after the prefix.
77
+ export const MCP_AUTH_KEY_PREFIX = 'nsa_mcp_';
78
+
79
+ // Entropy width. 32 bytes = 256 bits, matches AES-256 / SHA-256 strength.
80
+ const MCP_AUTH_KEY_ENTROPY_BYTES = 32;
81
+
82
+ // One-shot permissive-mode warning per file path per process. Same
83
+ // pattern as license.mjs so a license + MCP-auth resolution in the
84
+ // same run doesn't double-warn on the shared file.
85
+ const _permissiveWarnedPaths = new Set();
86
+
87
+ // ── Key generation ───────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Generate a fresh MCP auth key.
91
+ *
92
+ * Format: `nsa_mcp_<43-char-base64url>`. 256 bits of entropy from
93
+ * crypto.randomBytes() — same entropy class as Anthropic's session
94
+ * tokens. URL-safe (no padding) so it's safe to paste into JSON
95
+ * config files and shell quoted strings without escaping.
96
+ *
97
+ * @returns {string} The new key.
98
+ */
99
+ export function generateMcpAuthKey() {
100
+ const raw = randomBytes(MCP_AUTH_KEY_ENTROPY_BYTES).toString('base64url');
101
+ return `${MCP_AUTH_KEY_PREFIX}${raw}`;
102
+ }
103
+
104
+ /**
105
+ * Validate that a key has the expected shape. Used by the install
106
+ * command to reject typos before persisting them. Does NOT validate
107
+ * cryptographic provenance — there is none; the key is a shared
108
+ * secret, not a JWT.
109
+ *
110
+ * @param {string} key
111
+ * @returns {{ ok: true } | { ok: false, reason: string }}
112
+ */
113
+ export function validateMcpAuthKeyShape(key) {
114
+ if (typeof key !== 'string') {
115
+ return { ok: false, reason: 'key must be a string' };
116
+ }
117
+ if (!key.startsWith(MCP_AUTH_KEY_PREFIX)) {
118
+ return { ok: false, reason: `key must start with "${MCP_AUTH_KEY_PREFIX}"` };
119
+ }
120
+ const body = key.slice(MCP_AUTH_KEY_PREFIX.length);
121
+ // base64url body should be ~43 chars for 32 bytes (no padding).
122
+ // Accept 40-50 to allow for future entropy bumps; reject obvious typos.
123
+ if (body.length < 40 || body.length > 50) {
124
+ return { ok: false, reason: `key body length ${body.length} not in 40..50 (expected ~43 for 256-bit entropy)` };
125
+ }
126
+ if (!/^[A-Za-z0-9_-]+$/.test(body)) {
127
+ // Reviewer 1 MEDIUM #4 fold: the most common cause of this error
128
+ // in practice is paste-mistakes (trailing newline from a wrapped
129
+ // Slack message, surrounding quotes, leading whitespace). Call
130
+ // those out specifically so the operator doesn't go hunting for
131
+ // a base64url problem.
132
+ const hint = /\s/.test(body) || /[\r\n]/.test(body)
133
+ ? ' (likely a copy-paste issue: trailing newline or surrounding whitespace)'
134
+ : /["']/.test(body)
135
+ ? ' (likely a copy-paste issue: surrounding quotes)'
136
+ : '';
137
+ return { ok: false, reason: `key body must be base64url (A-Z, a-z, 0-9, _, -)${hint}` };
138
+ }
139
+ return { ok: true };
140
+ }
141
+
142
+ // ── Resolver chain ───────────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * Resolve the MCP auth key from storage. Mirrors the license-key
146
+ * resolver (license.mjs:resolveLicenseKey) — same env → Keychain →
147
+ * file precedence so operators can use the same mental model and
148
+ * the same dotfile/keychain entries for both secrets.
149
+ *
150
+ * Resolution order (first non-empty wins):
151
+ * 1. process.env.NSA_MCP_AUTH_KEY — CI/CD takes precedence
152
+ * 2. macOS Keychain (service=nsauditor-ai, account=NSA_MCP_AUTH_KEY)
153
+ * — set by `nsauditor-ai mcp install-key` on macOS
154
+ * 3. $XDG_CONFIG_HOME/nsauditor/.env (or ~/.nsauditor/.env)
155
+ * — universal file fallback
156
+ *
157
+ * @param {object} [opts]
158
+ * @param {string} [opts._homeFileOverride] — test seam.
159
+ * @param {Function} [opts._keychainGet] — test seam.
160
+ * @returns {Promise<string|null>} The key, or null if no source had one.
161
+ */
162
+ export async function resolveMcpAuthKey(opts = {}) {
163
+ // 1. env var
164
+ if (process.env[MCP_AUTH_ENV_VAR]) return process.env[MCP_AUTH_ENV_VAR];
165
+
166
+ // 2. macOS Keychain
167
+ const kget = opts._keychainGet ?? keychainGet;
168
+ try {
169
+ const fromKeychain = await kget(MCP_AUTH_KEYCHAIN_ACCOUNT);
170
+ if (fromKeychain) return fromKeychain;
171
+ } catch { /* keychain unavailable — fall through */ }
172
+
173
+ // 3. ~/.nsauditor/.env
174
+ const filePath = opts._homeFileOverride ?? defaultMcpAuthFilePath();
175
+ try {
176
+ const stat = await fsp.stat(filePath);
177
+ if (platform() !== 'win32' && stat.isFile() && (stat.mode & 0o077) !== 0) {
178
+ if (!_permissiveWarnedPaths.has(filePath)) {
179
+ const modeStr = (stat.mode & 0o777).toString(8).padStart(3, '0');
180
+ // Use stderr — stdio-MCP requires stdout to be JSON-RPC only.
181
+ process.stderr.write(`⚠ ${filePath} has permissive mode ${modeStr} — recommend chmod 0600\n`);
182
+ _permissiveWarnedPaths.add(filePath);
183
+ }
184
+ }
185
+ const buf = await fsp.readFile(filePath, 'utf8');
186
+ const parsed = dotenv.parse(buf);
187
+ if (parsed[MCP_AUTH_ENV_VAR]) return parsed[MCP_AUTH_ENV_VAR];
188
+ } catch { /* file missing / unreadable — fall through */ }
189
+
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * Report which source the resolver currently honors WITHOUT printing
195
+ * the key value. Used by `nsauditor-ai mcp status` so operators can
196
+ * verify their setup without exposing the secret to shell history /
197
+ * tmux scrollback / screen-share.
198
+ *
199
+ * @param {object} [opts] — same test seams as resolveMcpAuthKey.
200
+ * @returns {Promise<{ source: 'env'|'keychain'|'file'|'unconfigured', detail?: string }>}
201
+ */
202
+ export async function reportMcpAuthSource(opts = {}) {
203
+ if (process.env[MCP_AUTH_ENV_VAR]) {
204
+ return { source: 'env', detail: MCP_AUTH_ENV_VAR };
205
+ }
206
+ const kget = opts._keychainGet ?? keychainGet;
207
+ try {
208
+ const fromKeychain = await kget(MCP_AUTH_KEYCHAIN_ACCOUNT);
209
+ if (fromKeychain) {
210
+ return { source: 'keychain', detail: 'macOS Keychain (service=nsauditor-ai)' };
211
+ }
212
+ } catch { /* fall through */ }
213
+ const filePath = opts._homeFileOverride ?? defaultMcpAuthFilePath();
214
+ try {
215
+ const buf = await fsp.readFile(filePath, 'utf8');
216
+ const parsed = dotenv.parse(buf);
217
+ if (parsed[MCP_AUTH_ENV_VAR]) {
218
+ return { source: 'file', detail: filePath };
219
+ }
220
+ } catch { /* fall through */ }
221
+ return { source: 'unconfigured' };
222
+ }
223
+
224
+ function defaultMcpAuthFilePath() {
225
+ if (process.env.XDG_CONFIG_HOME) {
226
+ return join(process.env.XDG_CONFIG_HOME, 'nsauditor', '.env');
227
+ }
228
+ return join(homedir(), '.nsauditor', '.env');
229
+ }
230
+
231
+ // ── Persistence ──────────────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * Persist an MCP auth key to platform-appropriate storage. Mirrors
235
+ * persistLicenseKey in license.mjs — same Keychain-first-on-darwin
236
+ * routing with file fallback, same mode-0600 file write, same merge
237
+ * semantics that preserve other env vars in the dotenv file.
238
+ *
239
+ * @param {string} key — full prefixed key (`nsa_mcp_...`).
240
+ * @param {object} [opts]
241
+ * @param {string} [opts._platform] — test seam.
242
+ * @param {Function} [opts._keychainSet] — test seam.
243
+ * @param {string} [opts._homeFileOverride] — test seam.
244
+ * @returns {Promise<{ok: true, location: string, warning?: string} | {ok: false, error: string}>}
245
+ */
246
+ export async function persistMcpAuthKey(key, opts = {}) {
247
+ const validation = validateMcpAuthKeyShape(key);
248
+ if (!validation.ok) {
249
+ return { ok: false, error: `persistMcpAuthKey: ${validation.reason}` };
250
+ }
251
+
252
+ const plat = opts._platform ?? platform();
253
+ const kset = opts._keychainSet ?? keychainSet;
254
+
255
+ // 1. macOS: try Keychain first.
256
+ let keychainFallbackReason = null;
257
+ if (plat === 'darwin') {
258
+ try {
259
+ await kset(MCP_AUTH_KEYCHAIN_ACCOUNT, key);
260
+ return { ok: true, location: 'macOS Keychain (service=nsauditor-ai)' };
261
+ } catch (err) {
262
+ keychainFallbackReason = err && err.message ? err.message : String(err);
263
+ }
264
+ }
265
+
266
+ // 2. File-based storage (Linux, Windows, macOS Keychain fallback).
267
+ try {
268
+ const filePath = opts._homeFileOverride ?? defaultMcpAuthFilePath();
269
+ const dir = dirname(filePath);
270
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
271
+ // Reviewer 2 MEDIUM #1 fold: `mkdir mode` only applies on FIRST
272
+ // creation (Node mirrors POSIX semantics). If the dir already
273
+ // existed (e.g., license-key flow created it as 0755), the 0700
274
+ // mode is silently ignored. Explicit chmod after mkdir guarantees
275
+ // the secret-bearing parent dir is operator-only on POSIX.
276
+ if (plat !== 'win32') {
277
+ try {
278
+ await fsp.chmod(dir, 0o700);
279
+ } catch { /* best effort; some FSes (NFS) reject chmod */ }
280
+ }
281
+
282
+ let existingContent = '';
283
+ try {
284
+ existingContent = await fsp.readFile(filePath, 'utf8');
285
+ } catch { /* missing file — create one */ }
286
+
287
+ const newContent = mergeMcpAuthIntoEnvFile(existingContent, key);
288
+ await fsp.writeFile(filePath, newContent, { mode: 0o600 });
289
+ if (plat !== 'win32') {
290
+ await fsp.chmod(filePath, 0o600);
291
+ }
292
+
293
+ const result = { ok: true, location: filePath };
294
+ if (keychainFallbackReason !== null) {
295
+ result.warning =
296
+ `macOS Keychain unavailable (${keychainFallbackReason}); fell back to file storage. ` +
297
+ `Re-run after granting Keychain access for stronger protection.`;
298
+ }
299
+ return result;
300
+ } catch (err) {
301
+ return { ok: false, error: err.message };
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Merge an MCP auth key into a dotenv-format file content, preserving
307
+ * every OTHER line. Mirrors mergeLicenseIntoEnvFile (license.mjs:242)
308
+ * — same multi-occurrence safety and CRLF preservation.
309
+ *
310
+ * - If a NSA_MCP_AUTH_KEY line already exists, replace the first
311
+ * occurrence and remove duplicates (corrupted-file defense).
312
+ * - If the file is empty, write a header comment.
313
+ * - Otherwise append.
314
+ *
315
+ * Exported for test coverage.
316
+ * @internal
317
+ */
318
+ export function mergeMcpAuthIntoEnvFile(existingContent, key) {
319
+ // Reviewer 1 MEDIUM #5 fold: escape regex metacharacters in
320
+ // MCP_AUTH_ENV_VAR before interpolating. Today the constant is
321
+ // `NSA_MCP_AUTH_KEY` (no specials), so this is defense-in-depth
322
+ // — but a future rename to e.g. `NSA-MCP.AUTH+KEY` would silently
323
+ // break the merge without this guard. License version uses a
324
+ // literal regex, so it doesn't have this risk; this version
325
+ // interpolates because the env-var name is exported as a constant.
326
+ const escaped = MCP_AUTH_ENV_VAR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
327
+ const KEY_LINE_RE = new RegExp(
328
+ `^[ \\t]*${escaped}[ \\t]*=[^\\r\\n]*$`,
329
+ 'gm',
330
+ );
331
+ const newLine = `${MCP_AUTH_ENV_VAR}=${key}`;
332
+
333
+ const matches = existingContent.match(KEY_LINE_RE);
334
+ if (matches && matches.length > 0) {
335
+ let firstReplaced = false;
336
+ let merged = existingContent.replace(KEY_LINE_RE, () => {
337
+ if (firstReplaced) return '__NSAUDITOR_PURGE__';
338
+ firstReplaced = true;
339
+ return newLine;
340
+ });
341
+ merged = merged.replace(/__NSAUDITOR_PURGE__\r?\n?/g, '');
342
+ return merged;
343
+ }
344
+
345
+ if (existingContent.trim().length === 0) {
346
+ return `# NSAuditor AI MCP auth key — set via \`nsauditor-ai mcp install-key\`\n${newLine}\n`;
347
+ }
348
+
349
+ const sep = existingContent.endsWith('\n') ? '' : '\n';
350
+ return `${existingContent}${sep}${newLine}\n`;
351
+ }
352
+
353
+ // ── Constant-time comparison ─────────────────────────────────────────────────
354
+
355
+ /**
356
+ * Compare two MCP auth keys in constant time. Prevents timing-channel
357
+ * attacks where an attacker measures comparison duration to learn
358
+ * shared-secret bytes. For stdio transport the timing channel is
359
+ * already noisy (IPC pipe scheduling) but this is free correctness
360
+ * and matters for the future HTTP transport branch.
361
+ *
362
+ * Returns false (not an exception) if either argument is not a string
363
+ * or lengths differ — same shape as a normal mismatch from the
364
+ * caller's perspective.
365
+ *
366
+ * @param {string} expected
367
+ * @param {string} presented
368
+ * @returns {boolean}
369
+ */
370
+ export function constantTimeMcpKeyEquals(expected, presented) {
371
+ if (typeof expected !== 'string' || typeof presented !== 'string') return false;
372
+ if (expected.length !== presented.length) return false;
373
+ // Buffer.byteLength is needed because timingSafeEqual requires
374
+ // equal-byte-length buffers; for ASCII keys (which ours always are
375
+ // — base64url charset is ASCII) byteLength === length. Belt-and-
376
+ // suspenders: encode explicitly.
377
+ const a = Buffer.from(expected, 'utf8');
378
+ const b = Buffer.from(presented, 'utf8');
379
+ if (a.length !== b.length) return false;
380
+ return timingSafeEqual(a, b);
381
+ }
382
+
383
+ // ── Server-side enforcement ──────────────────────────────────────────────────
384
+
385
+ /**
386
+ * Determine whether the MCP server should accept incoming requests.
387
+ * Called once at startup from mcp_server.mjs — if the result is
388
+ * `{ ok: false }`, the caller MUST exit before connecting transport.
389
+ *
390
+ * Routing:
391
+ * - NSA_MCP_AUTH_DISABLE=1 → ok: true with `bypassed: true`. The
392
+ * `bypassedReason` field distinguishes operator-acknowledged
393
+ * bypass-with-key (`'configured'`) from bypass-without-any-key
394
+ * (`'unconfigured'`) so the server stderr can flag the latter
395
+ * loudly — that's the case where an operator forgot they had
396
+ * DISABLE=1 in their shell rc and never ran install-key
397
+ * (Reviewer 1 CRITICAL #2 fold).
398
+ * - Storage has no key → ok: false with actionable error pointing
399
+ * at `nsauditor-ai mcp install-key`. Refuse to start.
400
+ * - Storage has key, NSA_MCP_AUTH_KEY env unset → ok: false with
401
+ * "did you forget to update Claude Desktop config" hint.
402
+ * - Storage has key, NSA_MCP_AUTH_KEY env set, mismatch → ok: false
403
+ * with "did you forget to update after `mcp rotate-key`" hint.
404
+ * - Match → ok: true.
405
+ *
406
+ * EE-SEC.1.1 fold (Reviewer 2 CRITICAL #2): the presented env value
407
+ * is passed through `resolveSecret()` so the operator can use the
408
+ * `keychain:LABEL` indirection in their Claude Desktop config —
409
+ * keeping the secret in the Keychain instead of baking it into the
410
+ * world-readable config file. macOS-only feature; on Linux/Windows
411
+ * the env var holds the literal key as before.
412
+ *
413
+ * @param {object} [opts]
414
+ * @param {string} [opts._homeFileOverride] — test seam.
415
+ * @param {Function} [opts._keychainGet] — test seam.
416
+ * @param {Function} [opts._resolveSecret] — test seam for keychain: prefix resolution.
417
+ * @param {Record<string, string|undefined>} [opts._env] — test seam (default process.env).
418
+ * @returns {Promise<{ ok: true, bypassed?: boolean, bypassedReason?: 'configured'|'unconfigured' } | { ok: false, error: string }>}
419
+ */
420
+ export async function authorizeMcpServerStartup(opts = {}) {
421
+ const env = opts._env ?? process.env;
422
+
423
+ // Resolve EXPECTED key from storage FIRST so the bypass branch can
424
+ // tell whether a key was ever installed. We deliberately do NOT use
425
+ // resolveMcpAuthKey here: that includes the env-var branch, but we
426
+ // want env-var to participate ONLY for the PRESENTED key, never for
427
+ // the "configured" baseline (using env for both would let an
428
+ // attacker self-validate by setting NSA_MCP_AUTH_KEY in the spawned
429
+ // server's env).
430
+ let expected = null;
431
+ const kget = opts._keychainGet ?? keychainGet;
432
+ try {
433
+ const fromKeychain = await kget(MCP_AUTH_KEYCHAIN_ACCOUNT);
434
+ if (fromKeychain) expected = fromKeychain;
435
+ } catch { /* keychain unavailable */ }
436
+
437
+ if (!expected) {
438
+ const filePath = opts._homeFileOverride ?? defaultMcpAuthFilePath();
439
+ try {
440
+ const buf = await fsp.readFile(filePath, 'utf8');
441
+ const parsed = dotenv.parse(buf);
442
+ // MEDIUM #1 fold (Reviewer 1): treat empty-string parsed value
443
+ // ("NSA_MCP_AUTH_KEY=") as "no key configured" — consistent with
444
+ // the env-var branch in resolveMcpAuthKey which uses falsy check.
445
+ if (parsed[MCP_AUTH_ENV_VAR]) expected = parsed[MCP_AUTH_ENV_VAR];
446
+ } catch { /* file missing — leaves expected null */ }
447
+ }
448
+
449
+ // CRITICAL #2 fold (Reviewer 1): bypass routing now distinguishes
450
+ // bootstrap-state (no key has ever been installed) from operational
451
+ // bypass (operator has a key but explicitly disabled). The server
452
+ // emits a different stderr warning for each case so an operator
453
+ // who forgot DISABLE=1 in their shell rc sees a louder signal.
454
+ if (env[MCP_AUTH_DISABLE_ENV_VAR] === '1') {
455
+ return {
456
+ ok: true,
457
+ bypassed: true,
458
+ bypassedReason: expected ? 'configured' : 'unconfigured',
459
+ };
460
+ }
461
+
462
+ if (!expected) {
463
+ return {
464
+ ok: false,
465
+ error:
466
+ `MCP authentication is not configured. Run \`nsauditor-ai mcp install-key\` ` +
467
+ `to generate a key, then add it to your Claude Desktop config under ` +
468
+ `env: { "${MCP_AUTH_ENV_VAR}": "..." }. ` +
469
+ `If you intentionally want to run without auth (e.g., in CI), set ` +
470
+ `${MCP_AUTH_DISABLE_ENV_VAR}=1 — note that anyone with code execution ` +
471
+ `as your user can then call MCP tools.`,
472
+ };
473
+ }
474
+
475
+ const presentedRaw = env[MCP_AUTH_ENV_VAR];
476
+ if (!presentedRaw) {
477
+ return {
478
+ ok: false,
479
+ error:
480
+ `MCP authentication failed: ${MCP_AUTH_ENV_VAR} env var is not set, but a key ` +
481
+ `is configured in storage. Did you forget to update your Claude Desktop config? ` +
482
+ `Run \`nsauditor-ai mcp print-key --confirm\` to retrieve the configured key, ` +
483
+ `or use the keychain: indirection (see \`nsauditor-ai mcp install-key\` output).`,
484
+ };
485
+ }
486
+
487
+ // EE-SEC.1.1: resolve `keychain:LABEL` prefix in the presented env
488
+ // value so operators can keep the secret in Keychain (macOS) and
489
+ // reference it from the world-readable Claude Desktop config file.
490
+ // For literal-string env values, resolveSecret returns the string
491
+ // unchanged. _resolveSecret test seam allows hermetic injection.
492
+ const _resolve = opts._resolveSecret ?? resolveSecret;
493
+ let presented;
494
+ try {
495
+ presented = await _resolve(presentedRaw);
496
+ } catch {
497
+ presented = null;
498
+ }
499
+ if (!presented) {
500
+ // The keychain: prefix was used but the Keychain entry is missing
501
+ // / locked / inaccessible. Distinguish from plain mismatch so the
502
+ // operator can debug.
503
+ if (typeof presentedRaw === 'string' && presentedRaw.startsWith('keychain:')) {
504
+ return {
505
+ ok: false,
506
+ error:
507
+ `MCP authentication failed: ${MCP_AUTH_ENV_VAR} uses keychain: indirection ` +
508
+ `but the referenced Keychain entry could not be read. ` +
509
+ `Run \`nsauditor-ai mcp status\` to verify the entry exists. ` +
510
+ `On a headless macOS / CI runner, replace the indirection with the literal ` +
511
+ `key value or move auth to ~/.nsauditor/.env.`,
512
+ };
513
+ }
514
+ return {
515
+ ok: false,
516
+ error: `MCP authentication failed: ${MCP_AUTH_ENV_VAR} env var resolved to empty.`,
517
+ };
518
+ }
519
+
520
+ if (!constantTimeMcpKeyEquals(expected, presented)) {
521
+ return {
522
+ ok: false,
523
+ error:
524
+ `MCP authentication failed: the ${MCP_AUTH_ENV_VAR} env var does not match the ` +
525
+ `key configured in storage. Did you forget to update Claude Desktop config after ` +
526
+ `\`nsauditor-ai mcp rotate-key\`? ` +
527
+ `Run \`nsauditor-ai mcp status\` to see which storage source is active.`,
528
+ };
529
+ }
530
+
531
+ return { ok: true };
532
+ }