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 +101 -8
- package/cli.mjs +227 -0
- package/mcp_server.mjs +37 -0
- package/package.json +1 -1
- package/utils/mcp_auth.mjs +532 -0
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
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|