nsauditor-ai 0.1.31 → 0.1.33
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 +111 -1
- package/cli.mjs +287 -44
- package/mcp_server.mjs +42 -1
- package/package.json +1 -1
- package/utils/keychain.mjs +55 -0
- package/utils/license.mjs +17 -2
- package/utils/mcp_auth.mjs +283 -27
package/README.md
CHANGED
|
@@ -15,6 +15,64 @@ 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.33) — ⚠ MCP integration with Claude Desktop is unreliable
|
|
19
|
+
|
|
20
|
+
**Critical advisory for customers using NSAuditor AI through Claude Desktop's MCP integration.** During the maintainer's own integration test on 2026-05-10, we discovered that **Claude Desktop's AI fabricates scan results, plugin lists, vulnerability findings, and tier information without actually invoking the MCP tools** for our specific server. Other MCP servers in the same Claude Desktop config receive real `tools/call` invocations; ours does not.
|
|
21
|
+
|
|
22
|
+
**Empirical evidence**:
|
|
23
|
+
- `~/Library/Logs/Claude/main.log` shows multiple permission grants for `mcp__nsauditor-ai__list_plugins` and `mcp__nsauditor-ai__scan_host` on 2026-05-10
|
|
24
|
+
- `~/Library/Logs/Claude/mcp-server-nsauditor-ai.log` shows **zero** `"method":"tools/call"` entries on the same day
|
|
25
|
+
- Other servers in the same config logged real calls (ns-ftp:29, wp-publisher-netsecmag:14, ai-pr-distribution:6, sendgrid:3)
|
|
26
|
+
- When asked to scan 1.1.1.1, Claude Desktop returned a detailed report with plugin breakdown + Zero Trust score — entirely fabricated
|
|
27
|
+
|
|
28
|
+
**Likely cause**: Claude Desktop's MCP client appears to time out our server (which loads PluginManager + 32 plugins + license verify before responding). Claude (the AI) silently substitutes fabricated responses from training rather than surfacing the timeout. The hallucinations are convincingly formatted and indistinguishable from real output without log inspection.
|
|
29
|
+
|
|
30
|
+
**Mandatory verification — for any output you'd act on**:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Tier check (ground truth bypassing Claude AI synthesis):
|
|
34
|
+
nsauditor-ai mcp tier
|
|
35
|
+
|
|
36
|
+
# Real plugin scan (always hits the network):
|
|
37
|
+
nsauditor-ai scan --host <X> --plugins all --out <dir>
|
|
38
|
+
|
|
39
|
+
# Confirm Claude Desktop actually called the MCP server:
|
|
40
|
+
grep '"method":"tools/call"' ~/Library/Logs/Claude/mcp-server-nsauditor-ai.log | tail -5
|
|
41
|
+
# If main.log shows recent permission grants for nsauditor-ai tools but
|
|
42
|
+
# THIS file shows no matching tools/call entries, the responses you saw
|
|
43
|
+
# in Claude Desktop were AI-generated, NOT real.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**SOC 2 evidence and any compliance report MUST be generated via the CLI** — never via the Claude Desktop MCP integration — until this is resolved upstream. We're working on it (Thread L in `tasks/todo.md`): a per-call cryptographic sentinel, lazy-loaded plugin discovery to reduce startup latency, a `mcp verify-recent-call` diagnostic, and a bug report to Anthropic.
|
|
47
|
+
|
|
48
|
+
This advisory will be removed when the upstream Claude Desktop MCP routing issue is fixed and we ship a CE release whose `tools/call` invocations land reliably.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## What's New (0.1.32) — Claude Desktop integration overhaul + ground-truth diagnostics
|
|
53
|
+
|
|
54
|
+
The 0.1.32 line bundles three operational improvements driven by real customer-onboarding friction surfaced during the developer's own Claude Desktop integration test (2026-05-10):
|
|
55
|
+
|
|
56
|
+
- **`nsauditor-ai mcp install-key` now auto-generates a machine-specific Claude Desktop config snippet.** Reads `process.execPath` (the Node binary actually running) and derives the script path from `import.meta.url` — the printed JSON has absolute paths that work whether you're on system Node, homebrew, nvm, fnm, local-project install, Linux, or Windows. No install-type detective work, no PATH-misalignment failures. On macOS, the snippet uses `keychain:` indirection for **both** auth and license — secrets never land in the world-readable Claude Desktop config file.
|
|
57
|
+
- **License `keychain:` indirection.** `loadLicense()` and `resolveLicenseKey()` honor the `keychain:LABEL` prefix on the env-supplied value (mirrors the EE-SEC.1 MCP-auth pattern). Operators can put `"NSAUDITOR_LICENSE_KEY": "keychain:NSAUDITOR_LICENSE_KEY"` in their Claude Desktop env block; the JWT stays in macOS Keychain. Backward-compat: literal JWTs continue to work unchanged.
|
|
58
|
+
- **`nsauditor-ai mcp tier` ground-truth subcommand.** Customer-side check that prints the EXACT tier the spawned MCP server resolves to. We discovered Claude Desktop reports of "Current tier: CE" despite verified Pro/Enterprise license were caused by Claude (the AI) **synthesizing the tier text from training data + context** instead of actually calling `list_plugins` via MCP. The MCP server's resolution was always correct. `mcp tier` bypasses Claude's interpretive layer — paste the output into a support ticket to distinguish "MCP genuinely broken" from "Claude misreading."
|
|
59
|
+
- **MCP key rotation cadence (Thread I).** Optional 90-day rotation soft warning at server startup + `mcp status` (override via `NSA_MCP_AUTH_KEY_ROTATION_DAYS`, clamped to [7, 365]). SOC 2 CC6.1/CC6.7 reviewers expect a credential-rotation cadence; an unrotated MCP auth key is treated the same way as an unrotated IAM access key.
|
|
60
|
+
- **Keychain-locked vs Keychain-empty distinction.** New `keychain-locked` source variant in `mcp status` for headless macOS / SSH-only CI runners — instead of falling through silently to "unconfigured", operators get an actionable error with three GUI-free workarounds.
|
|
61
|
+
- **Atomic file writes** for `~/.nsauditor/.env` (`.tmp` + POSIX-rename) so concurrent readers + crash recovery never observe a truncated file.
|
|
62
|
+
- **Auto-mirror license file→Keychain on `mcp install-key`** (macOS) when the license is configured in `~/.nsauditor/.env` but absent from Keychain. Lets the printed snippet's `keychain:` indirection actually resolve, with original storage location preserved unchanged.
|
|
63
|
+
|
|
64
|
+
**Breaking change for existing 0.1.31 operators**: nothing breaks, but the recommended Claude Desktop config snippet has changed. Re-run `nsauditor-ai mcp install-key` after upgrading to get the new auto-generated snippet with absolute paths + license indirection. Old configs continue to work.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install -g nsauditor-ai@0.1.32
|
|
68
|
+
nsauditor-ai mcp install-key # prints the new snippet — paste into Claude Desktop config
|
|
69
|
+
nsauditor-ai mcp tier # confirm the actual MCP server tier (ground truth)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
See [Authentication](#authentication-required-new-in-0131) and [Troubleshooting MCP authentication](#troubleshooting-mcp-authentication) for full setup + diagnostics.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
18
76
|
## What's New (0.1.31) — security release
|
|
19
77
|
|
|
20
78
|
**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.
|
|
@@ -295,6 +353,25 @@ nsauditor-ai scan --host 192.168.1.0/24 --plugins all \
|
|
|
295
353
|
|
|
296
354
|
## MCP Server
|
|
297
355
|
|
|
356
|
+
> ## ⚠ CRITICAL ADVISORY (2026-05-10) — Claude Desktop hallucinates responses for this MCP server
|
|
357
|
+
>
|
|
358
|
+
> When you use NSAuditor AI through **Claude Desktop's** MCP integration, the AI may **fabricate scan results, plugin lists, vulnerability findings, and tier information without actually invoking the MCP tools**. We've confirmed this empirically: Claude Desktop's permission system shows tool calls being approved, but the actual `tools/call` JSON-RPC messages never reach our server (other MCP servers in the same config receive their calls correctly).
|
|
359
|
+
>
|
|
360
|
+
> **Mandatory verification for any output you'd act on**:
|
|
361
|
+
>
|
|
362
|
+
> ```bash
|
|
363
|
+
> # Real tier check (ground truth — bypasses Claude AI synthesis):
|
|
364
|
+
> nsauditor-ai mcp tier
|
|
365
|
+
>
|
|
366
|
+
> # Real scan (always hits the network):
|
|
367
|
+
> nsauditor-ai scan --host <X> --plugins all --out <dir>
|
|
368
|
+
>
|
|
369
|
+
> # Confirm Claude Desktop actually called the MCP server today:
|
|
370
|
+
> grep '"method":"tools/call"' ~/Library/Logs/Claude/mcp-server-nsauditor-ai.log | tail -5
|
|
371
|
+
> ```
|
|
372
|
+
>
|
|
373
|
+
> **SOC 2 evidence + compliance reports MUST be generated via the CLI** — never via the Claude Desktop MCP integration — until this is resolved upstream. Other MCP clients (Claude Code, custom MCP clients via the SDK) appear unaffected. See [What's New (0.1.33)](#whats-new-0133----mcp-integration-with-claude-desktop-is-unreliable) for full details.
|
|
374
|
+
|
|
298
375
|
Expose scanning capabilities to AI assistants via [Model Context Protocol](https://modelcontextprotocol.io):
|
|
299
376
|
|
|
300
377
|
```bash
|
|
@@ -362,7 +439,17 @@ nsauditor-ai mcp rotate-key --confirm # generates a new key (invalidates old
|
|
|
362
439
|
| Future HTTP/SSE transport network exposure | ✅ — key gates server startup, not network |
|
|
363
440
|
| Attacker with full operator code-exec AND can suppress macOS Keychain prompts | ⚠ partial — recent macOS versions log Keychain-access denial events |
|
|
364
441
|
| Debugger-attach memory snooping | ⚠ out of scope (any shared-secret auth has this limit) |
|
|
365
|
-
| Linux env-var visibility in `/proc/<pid>/environ` | ⚠ partial —
|
|
442
|
+
| Linux env-var visibility in `/proc/<pid>/environ` | ⚠ partial — see Linux note below |
|
|
443
|
+
|
|
444
|
+
**Linux note (`/proc/<pid>/environ`)**: on modern Linux, `/proc/<pid>/environ` is readable only by the process owner (the same user that spawned the MCP server). Other users on a multi-user system **cannot** read your MCP auth key from `/proc` under default kernel settings. The realistic remaining risks are:
|
|
445
|
+
|
|
446
|
+
- Container scenarios where multiple "users" share the same kernel UID (e.g., a Docker container running as root, with multiple processes inside) — the secret is visible to any process in the same UID namespace. Mitigation: run the MCP server in its own container / user.
|
|
447
|
+
- Audit/SIEM agents with broad read access (e.g., `auditd` configured to log child-process env). Mitigation: review your `auditd` rules; modern setups exclude env from logs by default.
|
|
448
|
+
- The legacy `ps eww` command on older POSIX systems (modern `ps` respects `/proc` permissions).
|
|
449
|
+
|
|
450
|
+
A shell-wrapper indirection script (read key from `~/.nsauditor/.env` at exec time, pass to child) was considered for v1 but does NOT solve the underlying issue: the spawned MCP server still needs the key in its env to perform the auth check, so it appears in `/proc/<server-pid>/environ` regardless of how the parent process obtained it. v2 may add libsecret integration on Linux to mirror the macOS Keychain indirection model.
|
|
451
|
+
|
|
452
|
+
**Rotation cadence (NEW in 0.1.32)**: keys older than 90 days emit a soft warning at every server startup AND in `nsauditor-ai mcp status` output. SOC 2 CC6.1 / CC6.7 reviewers expect a credential-rotation cadence; rotate with `nsauditor-ai mcp rotate-key --confirm` and update Claude Desktop config with the new key.
|
|
366
453
|
|
|
367
454
|
**Escape hatch for CI / dev** (operator-acknowledged risk; emits a stderr warning every startup):
|
|
368
455
|
|
|
@@ -426,6 +513,29 @@ claude mcp add nsauditor-ai \
|
|
|
426
513
|
|
|
427
514
|
**"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
515
|
|
|
516
|
+
**`mcp status` reports `keychain-locked`** (NEW in 0.1.32) → distinct from `unconfigured`: the Keychain entry exists but the security daemon refused to unlock without a GUI prompt. Same workarounds as the previous error: approve a Keychain GUI prompt, replace `keychain:` indirection with the literal key, or move auth to `~/.nsauditor/.env`. Pre-0.1.32 the resolver silently fell through to the file branch and reported `unconfigured` — the new state distinguishes "operator never ran install-key" from "operator did run it but Keychain is locked right now".
|
|
517
|
+
|
|
518
|
+
**`mcp status` shows `⚠ Created: ... — > 90d threshold`** (NEW in 0.1.32) → key is older than the 90-day rotation cadence. Run `nsauditor-ai mcp rotate-key --confirm` and update Claude Desktop config with the new key. Server emits the same warning to stderr at every startup.
|
|
519
|
+
|
|
520
|
+
**Claude Desktop reports "Current tier: CE" despite `nsauditor-ai license --status` showing Enterprise** (NEW in 0.1.32) → first run `nsauditor-ai mcp tier` to get the **ground-truth tier** the MCP server actually resolves at startup. If `mcp tier` reports `enterprise` but Claude Desktop's `list_plugins` says CE, **Claude (the AI) is synthesizing the tier text from context rather than calling the tool**. Force a real call by asking in a NEW Claude Desktop conversation:
|
|
521
|
+
|
|
522
|
+
> Use the `list_plugins` MCP tool right now and show me the raw tool response verbatim, including the exact text after the JSON.
|
|
523
|
+
|
|
524
|
+
Then check the MCP log to verify a real call happened:
|
|
525
|
+
|
|
526
|
+
```bash
|
|
527
|
+
grep '"method":"tools/call"' ~/Library/Logs/Claude/mcp-server-nsauditor-ai.log | tail -5
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
If `mcp tier` itself reports CE → genuine resolution failure. Inspect the license storage:
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
nsauditor-ai license --status
|
|
534
|
+
security find-generic-password -s nsauditor-ai -a NSAUDITOR_LICENSE_KEY -w 2>&1 | head -c 30
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
If license is in `~/.nsauditor/.env` but not in Keychain on macOS, re-run `nsauditor-ai mcp install-key` — the auto-mirror writes the license to Keychain so Claude Desktop's child process can read it via the `keychain:` indirection.
|
|
538
|
+
|
|
429
539
|
---
|
|
430
540
|
|
|
431
541
|
## Secure Credential Storage
|
package/cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { platform } from 'node:os';
|
|
11
12
|
import { openaiSimplePrompt, openaiPrompt as openaiProPrompt, openaiPromptOptimized } from './utils/prompts.mjs';
|
|
12
13
|
import { parseHostArg, parseHostFile } from './utils/host_iterator.mjs';
|
|
13
14
|
import { buildSarifLog } from './utils/sarif.mjs';
|
|
@@ -864,6 +865,9 @@ MCP server-auth subcommands (EE-SEC.1):
|
|
|
864
865
|
nsauditor-ai mcp print-key --confirm Reveal the stored key (use with care)
|
|
865
866
|
nsauditor-ai mcp rotate-key Replace the stored key with a fresh one
|
|
866
867
|
nsauditor-ai mcp status Show storage source without revealing the key
|
|
868
|
+
nsauditor-ai mcp tier Print actual MCP server tier (ground truth — bypasses
|
|
869
|
+
Claude AI synthesis when "list_plugins" reports
|
|
870
|
+
unexpected CE despite verified Pro/Enterprise license)
|
|
867
871
|
|
|
868
872
|
Security subcommands (macOS Keychain):
|
|
869
873
|
nsauditor-ai security set <KEY> Store a secret (read from stdin)
|
|
@@ -1086,57 +1090,159 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
1086
1090
|
const rawArgs = process.argv.slice(2);
|
|
1087
1091
|
const subCmd = rawArgs[1]; // install-key | print-key | rotate-key | status
|
|
1088
1092
|
|
|
1089
|
-
function printConfigSnippet(key, persistedLocation) {
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1093
|
+
async function printConfigSnippet(key, persistedLocation) {
|
|
1094
|
+
// Thread K (CE 0.1.32): generate a MACHINE-SPECIFIC config snippet
|
|
1095
|
+
// so customers don't have to figure out:
|
|
1096
|
+
// - which Node binary Claude Desktop should call (system /
|
|
1097
|
+
// homebrew / nvm / fnm — Claude Desktop's launchd PATH does
|
|
1098
|
+
// NOT include nvm/fnm bin dirs reliably)
|
|
1099
|
+
// - which absolute path to the .mjs script
|
|
1100
|
+
// - whether to use `keychain:` indirection (macOS only) or
|
|
1101
|
+
// literal value (Linux/Windows)
|
|
1102
|
+
// - whether to also include the license env line (only if
|
|
1103
|
+
// license is configured AND we can avoid baking the JWT
|
|
1104
|
+
// into the world-readable config file)
|
|
1094
1105
|
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
+
// This eliminates the install-type matrix that was the #1
|
|
1107
|
+
// source of customer-onboarding friction.
|
|
1108
|
+
const isDarwin = platform() === 'darwin';
|
|
1109
|
+
|
|
1110
|
+
// process.execPath is the actual Node binary executing this CLI.
|
|
1111
|
+
// For nvm: /Users/<u>/.nvm/versions/node/vX.Y.Z/bin/node
|
|
1112
|
+
// For homebrew: /opt/homebrew/bin/node
|
|
1113
|
+
// For system: /usr/local/bin/node or /usr/bin/node
|
|
1114
|
+
// Always absolute; always the right binary that loaded our code.
|
|
1115
|
+
const nodeBin = process.execPath;
|
|
1116
|
+
|
|
1117
|
+
// The script path: derive from where THIS cli.mjs file lives,
|
|
1118
|
+
// then walk to the bin/ directory. cli.mjs is at the package
|
|
1119
|
+
// root; bin/nsauditor-ai-mcp.mjs is its sibling.
|
|
1120
|
+
// import.meta.url gives the file:// URL of THIS cli.mjs.
|
|
1121
|
+
const cliUrl = new URL(import.meta.url);
|
|
1122
|
+
const cliPath = fileURLToPath(cliUrl);
|
|
1123
|
+
const pkgRoot = path.dirname(cliPath);
|
|
1124
|
+
const mcpScriptPath = path.join(pkgRoot, 'bin', 'nsauditor-ai-mcp.mjs');
|
|
1125
|
+
|
|
1126
|
+
// MCP auth: keychain: indirection on macOS (no plaintext in
|
|
1127
|
+
// config file). Literal value on Linux/Windows where there's
|
|
1128
|
+
// no system secret store equivalent.
|
|
1106
1129
|
const onKeychain = typeof persistedLocation === 'string' && persistedLocation.includes('Keychain');
|
|
1107
|
-
const
|
|
1130
|
+
const authEnvValue = onKeychain ? `keychain:${MCP_AUTH_ENV_VAR}` : key;
|
|
1131
|
+
|
|
1132
|
+
// License: detect whether configured and where it lives. On
|
|
1133
|
+
// macOS, prefer the keychain: indirection — same no-plaintext
|
|
1134
|
+
// pattern as auth. If license is in file (~/.nsauditor/.env)
|
|
1135
|
+
// but NOT in Keychain, we'll prompt the operator to migrate
|
|
1136
|
+
// (handled by the caller; this fn just emits the snippet).
|
|
1137
|
+
const { loadLicense } = await import('./utils/license.mjs');
|
|
1138
|
+
const licenseStatus = await loadLicense();
|
|
1139
|
+
const licenseConfigured = licenseStatus.valid;
|
|
1140
|
+
|
|
1141
|
+
// Build the env block as a JSON-serializable object so the
|
|
1142
|
+
// snippet output is valid JSON the operator can paste verbatim.
|
|
1143
|
+
const envBlock = {};
|
|
1144
|
+
envBlock[MCP_AUTH_ENV_VAR] = authEnvValue;
|
|
1145
|
+
if (licenseConfigured) {
|
|
1146
|
+
if (isDarwin) {
|
|
1147
|
+
// Indirection — secret stays in Keychain. Requires that
|
|
1148
|
+
// the license actually IS in Keychain (or in a place
|
|
1149
|
+
// resolveSecret can reach). Caller is responsible for
|
|
1150
|
+
// ensuring this is true before printing the snippet.
|
|
1151
|
+
envBlock['NSAUDITOR_LICENSE_KEY'] = 'keychain:NSAUDITOR_LICENSE_KEY';
|
|
1152
|
+
}
|
|
1153
|
+
// On Linux/Windows we deliberately OMIT NSAUDITOR_LICENSE_KEY
|
|
1154
|
+
// from the env block — the MCP server will fall through to
|
|
1155
|
+
// the file fallback (~/.nsauditor/.env). Including a literal
|
|
1156
|
+
// JWT would expose it in the world-readable config file.
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const snippet = {
|
|
1160
|
+
mcpServers: {
|
|
1161
|
+
'nsauditor-ai': {
|
|
1162
|
+
command: nodeBin,
|
|
1163
|
+
args: [mcpScriptPath],
|
|
1164
|
+
env: envBlock,
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
};
|
|
1108
1168
|
|
|
1109
1169
|
console.log('');
|
|
1110
|
-
console.log('
|
|
1111
|
-
console.log('');
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
console.log('
|
|
1170
|
+
console.log('═'.repeat(70));
|
|
1171
|
+
console.log('Claude Desktop config — paste this into:');
|
|
1172
|
+
if (isDarwin) {
|
|
1173
|
+
console.log(' ~/Library/Application Support/Claude/claude_desktop_config.json');
|
|
1174
|
+
} else if (platform() === 'win32') {
|
|
1175
|
+
console.log(' %APPDATA%\\Claude\\claude_desktop_config.json');
|
|
1176
|
+
} else {
|
|
1177
|
+
console.log(' ~/.config/Claude/claude_desktop_config.json');
|
|
1178
|
+
}
|
|
1179
|
+
console.log('═'.repeat(70));
|
|
1180
|
+
// Pretty-print with 2-space indent. If the operator already has
|
|
1181
|
+
// mcpServers, they merge the inner "nsauditor-ai" block.
|
|
1182
|
+
console.log(JSON.stringify(snippet, null, 2));
|
|
1183
|
+
console.log('═'.repeat(70));
|
|
1120
1184
|
console.log('');
|
|
1185
|
+
|
|
1121
1186
|
if (onKeychain) {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1187
|
+
// macOS + Keychain reachable: the snippet uses indirection
|
|
1188
|
+
// for both auth and license. Secret never lands in the
|
|
1189
|
+
// world-readable Claude Desktop config file.
|
|
1190
|
+
console.log('Security notes:');
|
|
1191
|
+
console.log(` • Auth key uses "keychain:${MCP_AUTH_ENV_VAR}" indirection — the actual`);
|
|
1192
|
+
console.log(' secret stays in macOS Keychain. The config file contains only the');
|
|
1193
|
+
console.log(' placeholder string, NOT the secret.');
|
|
1194
|
+
if (licenseConfigured) {
|
|
1195
|
+
console.log(' • License key uses the same indirection — JWT never lands in the config.');
|
|
1196
|
+
} else {
|
|
1197
|
+
console.log(' • No license configured. To activate Pro/Enterprise features:');
|
|
1198
|
+
console.log(' nsauditor-ai license install <YOUR-KEY>');
|
|
1199
|
+
console.log(' Then re-run `nsauditor-ai mcp install-key` to get a snippet that');
|
|
1200
|
+
console.log(' includes the license line.');
|
|
1201
|
+
}
|
|
1125
1202
|
console.log('');
|
|
1126
|
-
console.log('On a
|
|
1127
|
-
console.log(
|
|
1128
|
-
console.log(`print-key --confirm
|
|
1203
|
+
console.log(' • On a HEADLESS macOS / SSH-only CI runner where Keychain GUI prompts');
|
|
1204
|
+
console.log(" won't reach you, replace the placeholder values with the literal");
|
|
1205
|
+
console.log(' secrets (run `nsauditor-ai mcp print-key --confirm` for the auth');
|
|
1206
|
+
console.log(' key). Move the config file to mode 0600 in that case.');
|
|
1129
1207
|
} else {
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
console.log(
|
|
1133
|
-
console.log(`
|
|
1208
|
+
// Linux/Windows OR macOS-with-Keychain-unavailable: snippet
|
|
1209
|
+
// contains literal secret. chmod warning required.
|
|
1210
|
+
console.log('Security notes:');
|
|
1211
|
+
console.log(` • Auth key value is the LITERAL secret in the config file.`);
|
|
1212
|
+
console.log(' chmod 600 your Claude Desktop config file to keep other local users');
|
|
1213
|
+
console.log(' from reading it:');
|
|
1214
|
+
if (platform() === 'win32') {
|
|
1215
|
+
console.log(' icacls "%APPDATA%\\Claude\\claude_desktop_config.json" /inheritance:r /grant:r "%USERNAME%:F"');
|
|
1216
|
+
} else if (isDarwin) {
|
|
1217
|
+
console.log(' chmod 600 ~/Library/Application\\ Support/Claude/claude_desktop_config.json');
|
|
1218
|
+
} else {
|
|
1219
|
+
console.log(' chmod 600 ~/.config/Claude/claude_desktop_config.json');
|
|
1220
|
+
}
|
|
1221
|
+
if (licenseConfigured) {
|
|
1222
|
+
console.log(' • License key is NOT in the env block — the MCP server reads it from');
|
|
1223
|
+
console.log(' ~/.nsauditor/.env (mode 0600) at startup. No JWT in the config.');
|
|
1224
|
+
} else {
|
|
1225
|
+
console.log(' • No license configured. To activate Pro/Enterprise features:');
|
|
1226
|
+
console.log(' nsauditor-ai license install <YOUR-KEY>');
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
console.log('');
|
|
1231
|
+
console.log('After pasting:');
|
|
1232
|
+
console.log(' 1. Save the config file');
|
|
1233
|
+
console.log(' 2. Cmd+Q Claude Desktop (full quit) and re-launch');
|
|
1234
|
+
if (isDarwin) {
|
|
1235
|
+
console.log(' 3. macOS will prompt for Keychain access on first launch — click "Always Allow"');
|
|
1236
|
+
console.log(' for both NSA_MCP_AUTH_KEY and NSAUDITOR_LICENSE_KEY entries.');
|
|
1134
1237
|
}
|
|
1238
|
+
console.log(' 4. Verify in Claude: ask "list nsauditor plugins"');
|
|
1239
|
+
console.log(` Tier should report as "${licenseConfigured ? licenseStatus.tier : 'ce'}"`);
|
|
1135
1240
|
console.log('');
|
|
1136
|
-
console.log(
|
|
1137
|
-
console.log('
|
|
1138
|
-
|
|
1139
|
-
|
|
1241
|
+
console.log('Diagnostic if it doesn\'t work:');
|
|
1242
|
+
console.log(' nsauditor-ai mcp status # confirm storage source');
|
|
1243
|
+
if (licenseConfigured) {
|
|
1244
|
+
console.log(' nsauditor-ai license --status # confirm license still verified');
|
|
1245
|
+
}
|
|
1140
1246
|
}
|
|
1141
1247
|
|
|
1142
1248
|
if (subCmd === 'install-key') {
|
|
@@ -1172,7 +1278,43 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
1172
1278
|
}
|
|
1173
1279
|
console.log(`✓ MCP auth key ${generated ? 'generated and ' : ''}installed`);
|
|
1174
1280
|
console.log(` Stored at: ${persisted.location}`);
|
|
1175
|
-
|
|
1281
|
+
|
|
1282
|
+
// Thread K: if license is configured but NOT in Keychain (e.g.,
|
|
1283
|
+
// operator has it in ~/.nsauditor/.env from a pre-0.1.30 install
|
|
1284
|
+
// or from manual editing), back-fill to Keychain on macOS so the
|
|
1285
|
+
// `keychain:NSAUDITOR_LICENSE_KEY` indirection in the printed
|
|
1286
|
+
// snippet actually resolves. Without this, the snippet would
|
|
1287
|
+
// include the indirection but the MCP server would fail to find
|
|
1288
|
+
// the license and report CE — exactly the customer-onboarding
|
|
1289
|
+
// friction Thread K eliminates.
|
|
1290
|
+
if (platform() === 'darwin') {
|
|
1291
|
+
try {
|
|
1292
|
+
const { resolveLicenseKey } = await import('./utils/license.mjs');
|
|
1293
|
+
const { keychainGetDetailed, keychainSet } = await import('./utils/keychain.mjs');
|
|
1294
|
+
const licenseKey = await resolveLicenseKey();
|
|
1295
|
+
if (licenseKey) {
|
|
1296
|
+
const keychainState = await keychainGetDetailed('NSAUDITOR_LICENSE_KEY');
|
|
1297
|
+
if (keychainState.state !== 'ok') {
|
|
1298
|
+
// License is reachable via env or file but not Keychain.
|
|
1299
|
+
// Mirror it into Keychain so the indirection works.
|
|
1300
|
+
await keychainSet('NSAUDITOR_LICENSE_KEY', licenseKey);
|
|
1301
|
+
console.log('');
|
|
1302
|
+
console.log(' ✓ License key mirrored from file/env to macOS Keychain');
|
|
1303
|
+
console.log(' so the snippet below can use keychain: indirection.');
|
|
1304
|
+
console.log(' Original storage location is preserved unchanged.');
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
// Best-effort: if the mirror fails, the snippet's keychain:
|
|
1309
|
+
// indirection won't resolve, but the operator can fall back
|
|
1310
|
+
// to literal-key configuration. Don't block install-key.
|
|
1311
|
+
console.warn(` ⚠ Could not mirror license to Keychain (${err.message}).`);
|
|
1312
|
+
console.warn(` The snippet below uses keychain: indirection — if Claude Desktop`);
|
|
1313
|
+
console.warn(` reports CE tier, replace the indirection with the literal license JWT.`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
await printConfigSnippet(key, persisted.location);
|
|
1176
1318
|
} else if (subCmd === 'rotate-key') {
|
|
1177
1319
|
// Generate a fresh key and persist over the old one. Old key is
|
|
1178
1320
|
// immediately invalid — operator must update Claude Desktop
|
|
@@ -1202,7 +1344,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
1202
1344
|
console.log(` Stored at: ${persisted.location}`);
|
|
1203
1345
|
console.log('');
|
|
1204
1346
|
console.log(' ⚠ The OLD key is now invalid. Update your Claude Desktop config NOW.');
|
|
1205
|
-
printConfigSnippet(key, persisted.location);
|
|
1347
|
+
await printConfigSnippet(key, persisted.location);
|
|
1206
1348
|
} else if (subCmd === 'print-key') {
|
|
1207
1349
|
// Reveal the stored key — gated behind --confirm to defend
|
|
1208
1350
|
// against accidental shell-history capture. Reviewer 2 CRITICAL #1
|
|
@@ -1242,10 +1384,62 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
1242
1384
|
// visible terminal (otherwise we'd refuse). Operators copy from
|
|
1243
1385
|
// the visible terminal output, not from a redirected stdout.
|
|
1244
1386
|
process.stderr.write(`${key}\n`);
|
|
1387
|
+
} else if (subCmd === 'tier') {
|
|
1388
|
+
// Thread K (CE 0.1.32): customer-side ground-truth check for the
|
|
1389
|
+
// tier the MCP server WOULD resolve to at startup. Customers
|
|
1390
|
+
// (including the maintainer 2026-05-10) reported "Claude Desktop
|
|
1391
|
+
// shows tier=CE" — but on investigation, Claude (the AI) was
|
|
1392
|
+
// synthesizing the tier text from training data + context
|
|
1393
|
+
// without actually calling list_plugins via MCP. The real MCP
|
|
1394
|
+
// server's _tier was correct (enterprise), only Claude's
|
|
1395
|
+
// narration was wrong. `mcp tier` runs the same loadLicense()
|
|
1396
|
+
// path the MCP server uses and prints the unambiguous result —
|
|
1397
|
+
// customers can paste this output into a support ticket and
|
|
1398
|
+
// distinguish "MCP genuinely broken" from "Claude misreading".
|
|
1399
|
+
const { loadLicense, getTierFromEnv } = await import('./utils/license.mjs');
|
|
1400
|
+
const result = await loadLicense();
|
|
1401
|
+
const tier = getTierFromEnv();
|
|
1402
|
+
const tierLabels = {
|
|
1403
|
+
ce: 'Community Edition (CE)',
|
|
1404
|
+
pro: 'Pro',
|
|
1405
|
+
enterprise: 'Enterprise',
|
|
1406
|
+
};
|
|
1407
|
+
const symbol = tier === 'ce' ? '✗' : '✓';
|
|
1408
|
+
console.log(`${symbol} MCP server tier: ${tier} — ${tierLabels[tier] ?? tier}`);
|
|
1409
|
+
if (result.valid) {
|
|
1410
|
+
console.log(` Org: ${result.org}`);
|
|
1411
|
+
console.log(` Seats: ${result.seats}`);
|
|
1412
|
+
console.log(` License ID: ${result.licenseId}`);
|
|
1413
|
+
console.log(` Expires: ${result.expiresAt}`);
|
|
1414
|
+
if (result.daysUntilExpiry !== undefined) {
|
|
1415
|
+
console.log(` Renews in: ${result.daysUntilExpiry} days`);
|
|
1416
|
+
}
|
|
1417
|
+
if (result.expiryWarning) {
|
|
1418
|
+
console.log(` ⚠ ${result.expiryWarning}`);
|
|
1419
|
+
}
|
|
1420
|
+
} else {
|
|
1421
|
+
console.log(` Reason: ${result.reason}`);
|
|
1422
|
+
console.log('');
|
|
1423
|
+
console.log(' Diagnose with: nsauditor-ai license --status');
|
|
1424
|
+
console.log(' Install with: nsauditor-ai license install <KEY>');
|
|
1425
|
+
}
|
|
1426
|
+
console.log('');
|
|
1427
|
+
console.log('This is the EXACT tier the spawned MCP server resolves to. If Claude');
|
|
1428
|
+
console.log("Desktop reports a different tier, Claude isn't calling list_plugins —");
|
|
1429
|
+
console.log('it\'s synthesizing from context. Force a real call by asking:');
|
|
1430
|
+
console.log(' "Use the list_plugins MCP tool right now and show the raw response."');
|
|
1431
|
+
// Exit 0 if any tier resolved (success), 1 if CE/no key (operator action needed).
|
|
1432
|
+
process.exit(tier === 'ce' ? 1 : 0);
|
|
1245
1433
|
} else if (subCmd === 'status') {
|
|
1246
1434
|
// Report which storage source the resolver currently honors,
|
|
1247
1435
|
// WITHOUT printing the key value. Safe to run in screen-share,
|
|
1248
|
-
// logs, etc.
|
|
1436
|
+
// logs, etc. EE-SEC.1.1 (Thread I): also surfaces key age + the
|
|
1437
|
+
// keychain-locked state distinction (so headless macOS / SSH
|
|
1438
|
+
// sessions get an actionable error rather than a generic
|
|
1439
|
+
// "unconfigured" fallthrough). MEDIUM #4 fold: rotation
|
|
1440
|
+
// threshold respects NSA_MCP_AUTH_KEY_ROTATION_DAYS env override.
|
|
1441
|
+
const { getRotationWarningDays: _grwd } = await import('./utils/mcp_auth.mjs');
|
|
1442
|
+
const _rwd = _grwd();
|
|
1249
1443
|
const result = await reportMcpAuthSource();
|
|
1250
1444
|
if (result.source === 'unconfigured') {
|
|
1251
1445
|
console.log(`✗ MCP authentication is not configured.`);
|
|
@@ -1255,9 +1449,57 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
1255
1449
|
console.log(` ⚠ ${MCP_AUTH_DISABLE_ENV_VAR}=1 is set — server will start without auth.`);
|
|
1256
1450
|
}
|
|
1257
1451
|
process.exit(1);
|
|
1452
|
+
} else if (result.source === 'keychain-locked') {
|
|
1453
|
+
// EE-SEC.1.1 (Reviewer 2 MEDIUM #3 from EE-SEC.1): Keychain
|
|
1454
|
+
// entry exists but the security daemon refused to unlock —
|
|
1455
|
+
// GUI prompt unavailable. Common on SSH sessions, headless
|
|
1456
|
+
// CI runners, and login-keychain-not-unlocked-yet scenarios.
|
|
1457
|
+
// MEDIUM #3 fold (post-EE-SEC.1.1): operators triggering this
|
|
1458
|
+
// branch are by construction in a no-GUI context. Reorder
|
|
1459
|
+
// workarounds to put the GUI-FREE options first; "approve
|
|
1460
|
+
// GUI prompt" demoted to the fallback for operators who DO
|
|
1461
|
+
// have GUI access (rare given they hit this branch).
|
|
1462
|
+
console.log(`⚠ MCP auth key is configured in macOS Keychain, but Keychain access`);
|
|
1463
|
+
console.log(` is currently locked (security daemon refused without GUI prompt).`);
|
|
1464
|
+
console.log(` This is normal on SSH sessions and headless CI runners.`);
|
|
1465
|
+
console.log('');
|
|
1466
|
+
console.log(` Detail: ${result.detail}`);
|
|
1467
|
+
console.log('');
|
|
1468
|
+
console.log(` Workarounds (GUI-free paths first):`);
|
|
1469
|
+
console.log(` 1. Replace the keychain: indirection in Claude Desktop config`);
|
|
1470
|
+
console.log(` with the literal key value (run \`mcp print-key --confirm\`).`);
|
|
1471
|
+
console.log(` 2. Move auth to the file fallback by setting NSA_MCP_AUTH_KEY`);
|
|
1472
|
+
console.log(` in ~/.nsauditor/.env directly (mode 0600).`);
|
|
1473
|
+
console.log(` 3. If you have GUI access: approve a Keychain prompt in the`);
|
|
1474
|
+
console.log(` macOS GUI session.`);
|
|
1475
|
+
process.exit(1);
|
|
1258
1476
|
} else {
|
|
1259
1477
|
console.log(`✓ MCP auth key configured`);
|
|
1260
1478
|
console.log(` Source: ${result.source}${result.detail ? ` (${result.detail})` : ''}`);
|
|
1479
|
+
// EE-SEC.1.1: surface key age when known. Older installs
|
|
1480
|
+
// (predating the timestamp companion) get null and the
|
|
1481
|
+
// CRITICAL #1 hint below instead.
|
|
1482
|
+
if (typeof result.ageDays === 'number') {
|
|
1483
|
+
const ageStr = result.ageDays === 0 ? 'today' : `${result.ageDays} day${result.ageDays === 1 ? '' : 's'} ago`;
|
|
1484
|
+
if (result.ageDays > _rwd) {
|
|
1485
|
+
console.log(` ⚠ Created: ${result.createdAt} (${ageStr}) — > ${_rwd}d threshold`);
|
|
1486
|
+
console.log(` Consider: nsauditor-ai mcp rotate-key --confirm`);
|
|
1487
|
+
console.log(` SOC 2 CC6.1 / CC6.7 reviewers flag unrotated shared secrets.`);
|
|
1488
|
+
} else {
|
|
1489
|
+
console.log(` Created: ${result.createdAt} (${ageStr})`);
|
|
1490
|
+
}
|
|
1491
|
+
} else if (result.legacyTimestampMissing) {
|
|
1492
|
+
// CRITICAL #1 fold (post-review): existing CE 0.1.31
|
|
1493
|
+
// operators upgrading have a key but no timestamp →
|
|
1494
|
+
// ageDays is null → silent. Distinct hint pointing at
|
|
1495
|
+
// backfill so SOC 2 evidence isn't dark for the installed base.
|
|
1496
|
+
console.log(` ⚠ Created: unknown (pre-0.1.32 install — no rotation timestamp)`);
|
|
1497
|
+
console.log(` Backfill the timestamp without invalidating the key:`);
|
|
1498
|
+
console.log(` nsauditor-ai mcp print-key --confirm # retrieve current key`);
|
|
1499
|
+
console.log(` nsauditor-ai mcp install-key <KEY> # re-install with timestamp`);
|
|
1500
|
+
console.log(` Or rotate to a fresh key:`);
|
|
1501
|
+
console.log(` nsauditor-ai mcp rotate-key --confirm`);
|
|
1502
|
+
}
|
|
1261
1503
|
if (process.env[MCP_AUTH_DISABLE_ENV_VAR] === '1') {
|
|
1262
1504
|
console.log('');
|
|
1263
1505
|
console.log(` ⚠ ${MCP_AUTH_DISABLE_ENV_VAR}=1 is set — server will start without auth.`);
|
|
@@ -1270,6 +1512,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
|
|
|
1270
1512
|
console.log(' nsauditor-ai mcp print-key --confirm Reveal the stored key (use with care)');
|
|
1271
1513
|
console.log(' nsauditor-ai mcp rotate-key Replace the stored key with a fresh one');
|
|
1272
1514
|
console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
|
|
1515
|
+
console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
|
|
1273
1516
|
console.log('');
|
|
1274
1517
|
console.log('Environment variables:');
|
|
1275
1518
|
console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
|
package/mcp_server.mjs
CHANGED
|
@@ -22,7 +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
|
+
import { authorizeMcpServerStartup, getMcpAuthKeyAge, getRotationWarningDays, reportMcpAuthSource } from './utils/mcp_auth.mjs';
|
|
26
26
|
|
|
27
27
|
const _require = createRequire(import.meta.url);
|
|
28
28
|
const { version: TOOL_VERSION } = _require('./package.json');
|
|
@@ -436,6 +436,47 @@ if (isMainModule) {
|
|
|
436
436
|
}
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
// EE-SEC.1.1 (Thread I): rotation-cadence soft warning. SOC 2 CC6.1 /
|
|
440
|
+
// CC6.7 reviewers expect a credential-rotation cadence; an unrotated
|
|
441
|
+
// shared secret is treated the same way as an unrotated IAM access
|
|
442
|
+
// key.
|
|
443
|
+
//
|
|
444
|
+
// Design note (LOW #1 from Reviewer 2): soft warning, not hard
|
|
445
|
+
// refusal. Hard-refuse would break Claude Desktop integration mid-
|
|
446
|
+
// session for operators who haven't seen 30 days of warnings (Claude
|
|
447
|
+
// Desktop buries MCP server stderr in a log file most operators
|
|
448
|
+
// never check). Soft warning + `mcp status` operator-runnable
|
|
449
|
+
// evidence path is the right SOC 2 posture — auditor sees the warning
|
|
450
|
+
// fires AND that the operator chose to defer rotation.
|
|
451
|
+
//
|
|
452
|
+
// Reviewer 2 CRITICAL #1 fold: when a key is configured but the
|
|
453
|
+
// NSA_MCP_AUTH_KEY_CREATED companion is missing (operator upgraded
|
|
454
|
+
// from CE 0.1.31 without re-installing), we surface a DIFFERENT
|
|
455
|
+
// stderr message pointing at `mcp install-key <existing-key>` to
|
|
456
|
+
// backfill the timestamp. Without this hint, EE-SEC.1.1 ships dark
|
|
457
|
+
// for the entire installed base of CE 0.1.31 deployments.
|
|
458
|
+
try {
|
|
459
|
+
const status = await reportMcpAuthSource();
|
|
460
|
+
const threshold = getRotationWarningDays();
|
|
461
|
+
if (status.legacyTimestampMissing) {
|
|
462
|
+
process.stderr.write(
|
|
463
|
+
`⚠ MCP auth key is configured but the rotation-cadence timestamp ` +
|
|
464
|
+
`is missing (likely a pre-0.1.32 install). Rotation warnings will ` +
|
|
465
|
+
`not fire until you backfill the timestamp. Either:\n` +
|
|
466
|
+
` (1) re-run \`nsauditor-ai mcp install-key <KEY>\` with your existing key ` +
|
|
467
|
+
`(use \`mcp print-key --confirm\` to retrieve it), OR\n` +
|
|
468
|
+
` (2) rotate to a fresh key with \`nsauditor-ai mcp rotate-key --confirm\` ` +
|
|
469
|
+
`and update Claude Desktop config.\n`,
|
|
470
|
+
);
|
|
471
|
+
} else if (typeof status.ageDays === 'number' && status.ageDays > threshold) {
|
|
472
|
+
process.stderr.write(
|
|
473
|
+
`⚠ MCP auth key is ${status.ageDays} days old (> ${threshold}d threshold). ` +
|
|
474
|
+
`Consider \`nsauditor-ai mcp rotate-key --confirm\` and update Claude Desktop config. ` +
|
|
475
|
+
`SOC 2 CC6.1 / CC6.7 reviewers flag unrotated shared secrets.\n`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
} catch { /* age check is non-fatal — never block startup on it */ }
|
|
479
|
+
|
|
439
480
|
// Verify license JWT before accepting MCP requests — upgrades _tier from
|
|
440
481
|
// prefix-based to cryptographically verified.
|
|
441
482
|
await loadLicense();
|
package/package.json
CHANGED
package/utils/keychain.mjs
CHANGED
|
@@ -33,6 +33,61 @@ export async function keychainGet(account) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Diagnostic variant of keychainGet that distinguishes failure modes.
|
|
38
|
+
* Used by EE-SEC.1.1 (Thread I) to surface a different error message
|
|
39
|
+
* for "Keychain locked / GUI prompt needed" (headless macOS / SSH-only
|
|
40
|
+
* CI) vs "no entry exists" (operator never ran install-key).
|
|
41
|
+
*
|
|
42
|
+
* Returns { value, state, raw }:
|
|
43
|
+
* - state='ok' — value is the secret
|
|
44
|
+
* - state='not-found' — Keychain has no entry for this account
|
|
45
|
+
* - state='locked' — Keychain entry exists but GUI approval needed
|
|
46
|
+
* - state='unavailable' — security daemon error (non-mac platform,
|
|
47
|
+
* timeout, etc.)
|
|
48
|
+
*
|
|
49
|
+
* On non-macOS platforms, returns { value: null, state: 'unavailable' }.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} account
|
|
52
|
+
* @returns {Promise<{ value: string|null, state: 'ok'|'not-found'|'locked'|'unavailable', raw?: string }>}
|
|
53
|
+
*/
|
|
54
|
+
export async function keychainGetDetailed(account) {
|
|
55
|
+
if (!isMac) {
|
|
56
|
+
return { value: null, state: 'unavailable', raw: 'not-macos' };
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const value = await exec('security', [
|
|
60
|
+
'find-generic-password', '-s', SERVICE, '-a', account, '-w'
|
|
61
|
+
]);
|
|
62
|
+
return { value, state: 'ok' };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const raw = (err && err.message) ? err.message : String(err);
|
|
65
|
+
// `security` returns specific stderr per failure mode. We pattern-
|
|
66
|
+
// match on the stable English text — the underlying SecKeychain
|
|
67
|
+
// API error codes (errSecInteractionNotAllowed=-25308,
|
|
68
|
+
// errSecItemNotFound=-25300) are also embedded but the strings
|
|
69
|
+
// are more reliable across macOS versions.
|
|
70
|
+
// EE-SEC.1.1 LOW #2 fold (post-review): also pattern-match numeric
|
|
71
|
+
// SecKeychain error codes (-25300 errSecItemNotFound, -25308
|
|
72
|
+
// errSecInteractionNotAllowed). The English-text patterns are
|
|
73
|
+
// stable on US-locale macOS but break on localized installs
|
|
74
|
+
// (French/Japanese/etc.); the numeric codes appear in the security
|
|
75
|
+
// command's stderr regardless of locale.
|
|
76
|
+
if (/could not be found in the keychain/i.test(raw)
|
|
77
|
+
|| /SecKeychainSearchCopyNext: The specified item could not be found/i.test(raw)
|
|
78
|
+
|| /errSecItemNotFound/i.test(raw)
|
|
79
|
+
|| /-25300\b/.test(raw)) {
|
|
80
|
+
return { value: null, state: 'not-found', raw };
|
|
81
|
+
}
|
|
82
|
+
if (/User interaction is not allowed/i.test(raw)
|
|
83
|
+
|| /errSecInteractionNotAllowed/i.test(raw)
|
|
84
|
+
|| /-25308\b/.test(raw)) {
|
|
85
|
+
return { value: null, state: 'locked', raw };
|
|
86
|
+
}
|
|
87
|
+
return { value: null, state: 'unavailable', raw };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
36
91
|
/**
|
|
37
92
|
* Store a secret in the macOS Keychain.
|
|
38
93
|
* Updates existing entry if present.
|
package/utils/license.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { promises as fsp } from 'node:fs';
|
|
|
11
11
|
import { homedir, platform } from 'node:os';
|
|
12
12
|
import { dirname, join } from 'node:path';
|
|
13
13
|
import dotenv from 'dotenv';
|
|
14
|
-
import { keychainGet, keychainSet } from './keychain.mjs';
|
|
14
|
+
import { keychainGet, keychainSet, resolveSecret } from './keychain.mjs';
|
|
15
15
|
|
|
16
16
|
// ES256 public key — embedded directly so it works in npm package (no file read).
|
|
17
17
|
// Corresponding private key is in the license-manager service (NEVER shipped here).
|
|
@@ -286,9 +286,24 @@ export async function loadLicense(keyStr) {
|
|
|
286
286
|
// Explicit keyStr argument wins (preserves the existing behavior for
|
|
287
287
|
// callers like the `license --status` subcommand which passes the env
|
|
288
288
|
// var directly). When omitted, run the multi-source resolution chain.
|
|
289
|
-
|
|
289
|
+
let raw = keyStr ?? (await resolveLicenseKey());
|
|
290
290
|
if (!raw) return { valid: false, tier: 'ce', reason: 'no key provided' };
|
|
291
291
|
|
|
292
|
+
// Thread K (CE 0.1.32): support `keychain:LABEL` indirection on the
|
|
293
|
+
// resolved value. Mirrors the EE-SEC.1 MCP-auth pattern — operators
|
|
294
|
+
// can put `"NSAUDITOR_LICENSE_KEY": "keychain:NSAUDITOR_LICENSE_KEY"`
|
|
295
|
+
// in their Claude Desktop config env block; the literal JWT never
|
|
296
|
+
// lands in the world-readable config file. resolveSecret is a no-op
|
|
297
|
+
// (returns input unchanged) for non-`keychain:` strings, so literal
|
|
298
|
+
// JWT keys continue to work for backward compat.
|
|
299
|
+
if (typeof raw === 'string' && raw.startsWith('keychain:')) {
|
|
300
|
+
const resolved = await resolveSecret(raw);
|
|
301
|
+
if (!resolved) {
|
|
302
|
+
return { valid: false, tier: 'ce', reason: 'license keychain: indirection could not be resolved (entry missing or Keychain locked)' };
|
|
303
|
+
}
|
|
304
|
+
raw = resolved;
|
|
305
|
+
}
|
|
306
|
+
|
|
292
307
|
// Strip tier prefix
|
|
293
308
|
let token = raw;
|
|
294
309
|
let prefixTier = null;
|
package/utils/mcp_auth.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import { dirname, join } from 'node:path';
|
|
|
51
51
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
52
52
|
import dotenv from 'dotenv';
|
|
53
53
|
|
|
54
|
-
import { keychainGet, keychainSet, resolveSecret } from './keychain.mjs';
|
|
54
|
+
import { keychainGet, keychainSet, keychainGetDetailed, resolveSecret } from './keychain.mjs';
|
|
55
55
|
|
|
56
56
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
57
57
|
|
|
@@ -71,6 +71,43 @@ export const MCP_AUTH_DISABLE_ENV_VAR = 'NSA_MCP_AUTH_DISABLE';
|
|
|
71
71
|
// named so operators don't have to remember two strings.
|
|
72
72
|
const MCP_AUTH_KEYCHAIN_ACCOUNT = MCP_AUTH_ENV_VAR;
|
|
73
73
|
|
|
74
|
+
// EE-SEC.1.1: created-at timestamp companion. Stored alongside the key
|
|
75
|
+
// so `mcp status` and the server-startup stderr can emit a soft warning
|
|
76
|
+
// when the key has been in use for more than ROTATION_WARNING_DAYS.
|
|
77
|
+
// SOC 2 CC6.1 / CC6.7 reviewers expect a credential rotation cadence;
|
|
78
|
+
// an unrotated MCP auth key is a finding the same way an unrotated
|
|
79
|
+
// IAM access key is a finding.
|
|
80
|
+
export const MCP_AUTH_CREATED_ENV_VAR = 'NSA_MCP_AUTH_KEY_CREATED';
|
|
81
|
+
const MCP_AUTH_CREATED_KEYCHAIN_ACCOUNT = MCP_AUTH_CREATED_ENV_VAR;
|
|
82
|
+
export const ROTATION_WARNING_DAYS = 90;
|
|
83
|
+
// EE-SEC.1.1 MEDIUM #4 fold (post-review): operator override env var.
|
|
84
|
+
// SOC 2 doesn't fix a number — common interpretations span 30/60/90/180.
|
|
85
|
+
// PCI-DSS pushes for 90d hard; NIST SP 800-63B prefers event-based rotation.
|
|
86
|
+
// An operator whose customer auditor demands 60 days needs a knob; clamping
|
|
87
|
+
// to [7, 365] prevents pathological values (sub-week = constant-warn-noise;
|
|
88
|
+
// > 1 year = effectively no rotation).
|
|
89
|
+
export const ROTATION_WARNING_DAYS_ENV_VAR = 'NSA_MCP_AUTH_KEY_ROTATION_DAYS';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the effective rotation-warning threshold. Reads
|
|
93
|
+
* NSA_MCP_AUTH_KEY_ROTATION_DAYS from the env (or `opts._env`),
|
|
94
|
+
* clamps to [7, 365], falls back to ROTATION_WARNING_DAYS (90).
|
|
95
|
+
*
|
|
96
|
+
* @param {object} [opts]
|
|
97
|
+
* @param {Record<string, string|undefined>} [opts._env]
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
export function getRotationWarningDays(opts = {}) {
|
|
101
|
+
const env = opts._env ?? process.env;
|
|
102
|
+
const raw = env[ROTATION_WARNING_DAYS_ENV_VAR];
|
|
103
|
+
if (!raw) return ROTATION_WARNING_DAYS;
|
|
104
|
+
const parsed = Number.parseInt(raw, 10);
|
|
105
|
+
if (!Number.isFinite(parsed)) return ROTATION_WARNING_DAYS;
|
|
106
|
+
return Math.max(7, Math.min(365, parsed));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
110
|
+
|
|
74
111
|
// Key prefix. Lets operators distinguish from license keys (`pro_eyJ...`,
|
|
75
112
|
// `enterprise_eyJ...`) at a glance. 32 bytes of entropy → 43 chars
|
|
76
113
|
// base64url after the prefix.
|
|
@@ -196,29 +233,153 @@ export async function resolveMcpAuthKey(opts = {}) {
|
|
|
196
233
|
* verify their setup without exposing the secret to shell history /
|
|
197
234
|
* tmux scrollback / screen-share.
|
|
198
235
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
236
|
+
* EE-SEC.1.1: extended with `ageDays` (when a created-at timestamp is
|
|
237
|
+
* available) so `mcp status` and the server-startup stderr can emit a
|
|
238
|
+
* soft warning when the key has been in use for more than
|
|
239
|
+
* ROTATION_WARNING_DAYS. Also distinguishes Keychain-locked from
|
|
240
|
+
* Keychain-empty so headless macOS / SSH-only CI runners get an
|
|
241
|
+
* actionable error rather than a generic "unconfigured" fallthrough.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} [opts] — same test seams as resolveMcpAuthKey, plus
|
|
244
|
+
* `_keychainGetDetailed` for the lock-state distinction and `_now`
|
|
245
|
+
* for hermetic age computation.
|
|
246
|
+
* @returns {Promise<{
|
|
247
|
+
* source: 'env'|'keychain'|'file'|'unconfigured'|'keychain-locked',
|
|
248
|
+
* detail?: string,
|
|
249
|
+
* ageDays?: number|null,
|
|
250
|
+
* createdAt?: string|null,
|
|
251
|
+
* }>}
|
|
201
252
|
*/
|
|
202
253
|
export async function reportMcpAuthSource(opts = {}) {
|
|
254
|
+
const now = opts._now ? new Date(opts._now).getTime() : Date.now();
|
|
255
|
+
|
|
203
256
|
if (process.env[MCP_AUTH_ENV_VAR]) {
|
|
204
|
-
|
|
257
|
+
// No createdAt available for env-supplied keys — operator-managed,
|
|
258
|
+
// we don't know the rotation history. EE-SEC.1.1 CRITICAL #1 fold:
|
|
259
|
+
// surface a `legacyTimestampMissing: false` here so callers don't
|
|
260
|
+
// mis-interpret env-supplied as "old install missing timestamp".
|
|
261
|
+
return {
|
|
262
|
+
source: 'env',
|
|
263
|
+
detail: MCP_AUTH_ENV_VAR,
|
|
264
|
+
ageDays: null,
|
|
265
|
+
createdAt: null,
|
|
266
|
+
legacyTimestampMissing: false,
|
|
267
|
+
};
|
|
205
268
|
}
|
|
206
|
-
|
|
269
|
+
|
|
270
|
+
// EE-SEC.1.1: use detailed Keychain lookup so lock-state is preserved.
|
|
271
|
+
// Backward-compat: legacy callers (and EE-SEC.1 tests) supply
|
|
272
|
+
// _keychainGet which returns just `string|null`. Wrap it into the
|
|
273
|
+
// detailed shape so the resolver works for both APIs.
|
|
274
|
+
let kgetDetailed;
|
|
275
|
+
if (opts._keychainGetDetailed) {
|
|
276
|
+
kgetDetailed = opts._keychainGetDetailed;
|
|
277
|
+
} else if (opts._keychainGet) {
|
|
278
|
+
const legacyKget = opts._keychainGet;
|
|
279
|
+
kgetDetailed = async (account) => {
|
|
280
|
+
try {
|
|
281
|
+
const value = await legacyKget(account);
|
|
282
|
+
return value
|
|
283
|
+
? { value, state: 'ok' }
|
|
284
|
+
: { value: null, state: 'not-found' };
|
|
285
|
+
} catch (err) {
|
|
286
|
+
return { value: null, state: 'unavailable', raw: err && err.message ? err.message : String(err) };
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
} else {
|
|
290
|
+
kgetDetailed = keychainGetDetailed;
|
|
291
|
+
}
|
|
292
|
+
let detailedResult = null;
|
|
207
293
|
try {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
294
|
+
detailedResult = await kgetDetailed(MCP_AUTH_KEYCHAIN_ACCOUNT);
|
|
295
|
+
} catch { /* defensive — should never throw, but tolerate test seams that do */ }
|
|
296
|
+
if (detailedResult && detailedResult.state === 'ok' && detailedResult.value) {
|
|
297
|
+
// Read the companion timestamp (best-effort; old installs may not
|
|
298
|
+
// have it). EE-SEC.1.1: skip the timestamp lookup when the caller
|
|
299
|
+
// only supplied the legacy `_keychainGet` seam — that seam returns
|
|
300
|
+
// the same value for any account name (it's a single-account stub),
|
|
301
|
+
// so calling it for the timestamp contaminates the result with the
|
|
302
|
+
// key value. Real `keychainGetDetailed` properly distinguishes by
|
|
303
|
+
// account.
|
|
304
|
+
let createdAt = null;
|
|
305
|
+
const usedLegacySeam = !opts._keychainGetDetailed && opts._keychainGet;
|
|
306
|
+
if (!usedLegacySeam) {
|
|
307
|
+
try {
|
|
308
|
+
const ts = await kgetDetailed(MCP_AUTH_CREATED_KEYCHAIN_ACCOUNT);
|
|
309
|
+
if (ts && ts.state === 'ok' && ts.value) createdAt = ts.value;
|
|
310
|
+
} catch { /* fall through */ }
|
|
211
311
|
}
|
|
212
|
-
|
|
312
|
+
return {
|
|
313
|
+
source: 'keychain',
|
|
314
|
+
detail: 'macOS Keychain (service=nsauditor-ai)',
|
|
315
|
+
createdAt,
|
|
316
|
+
ageDays: _ageDaysFromIso(createdAt, now),
|
|
317
|
+
// EE-SEC.1.1 CRITICAL #1 fold: existing CE 0.1.31 operators
|
|
318
|
+
// upgrading to 0.1.32 have a key but no NSA_MCP_AUTH_KEY_CREATED
|
|
319
|
+
// companion — `getMcpAuthKeyAge` returns null and the rotation
|
|
320
|
+
// warning silently never fires. The auditor evidence story is
|
|
321
|
+
// broken until the operator happens to rotate. Distinguish
|
|
322
|
+
// "legacy install missing timestamp" from "env-supplied (no
|
|
323
|
+
// history known)" so cli.mjs `mcp status` and the server-startup
|
|
324
|
+
// stderr can prompt operators to backfill via `mcp install-key
|
|
325
|
+
// <existing-key>` (which preserves the key but writes the
|
|
326
|
+
// timestamp companion).
|
|
327
|
+
legacyTimestampMissing: createdAt === null,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (detailedResult && detailedResult.state === 'locked') {
|
|
331
|
+
// Keychain has the entry but the security daemon refused to
|
|
332
|
+
// unlock — operator's GUI is unavailable (SSH / headless CI /
|
|
333
|
+
// login-keychain not unlocked yet).
|
|
334
|
+
return {
|
|
335
|
+
source: 'keychain-locked',
|
|
336
|
+
detail: 'macOS Keychain (service=nsauditor-ai) — entry exists but interaction not allowed',
|
|
337
|
+
ageDays: null,
|
|
338
|
+
createdAt: null,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
213
342
|
const filePath = opts._homeFileOverride ?? defaultMcpAuthFilePath();
|
|
214
343
|
try {
|
|
215
344
|
const buf = await fsp.readFile(filePath, 'utf8');
|
|
216
345
|
const parsed = dotenv.parse(buf);
|
|
217
346
|
if (parsed[MCP_AUTH_ENV_VAR]) {
|
|
218
|
-
|
|
347
|
+
const createdAt = parsed[MCP_AUTH_CREATED_ENV_VAR] || null;
|
|
348
|
+
return {
|
|
349
|
+
source: 'file',
|
|
350
|
+
detail: filePath,
|
|
351
|
+
createdAt,
|
|
352
|
+
ageDays: _ageDaysFromIso(createdAt, now),
|
|
353
|
+
// CRITICAL #1 fold: same legacy-install signal as the keychain
|
|
354
|
+
// branch above.
|
|
355
|
+
legacyTimestampMissing: createdAt === null,
|
|
356
|
+
};
|
|
219
357
|
}
|
|
220
358
|
} catch { /* fall through */ }
|
|
221
|
-
return { source: 'unconfigured' };
|
|
359
|
+
return { source: 'unconfigured', ageDays: null, createdAt: null };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get the age of the configured MCP auth key, in days. Returns null
|
|
364
|
+
* when no key is configured or the timestamp is missing/unparseable
|
|
365
|
+
* (older installs predating EE-SEC.1.1 won't have the timestamp).
|
|
366
|
+
*
|
|
367
|
+
* @param {object} [opts] — same test seams as reportMcpAuthSource.
|
|
368
|
+
* @returns {Promise<number|null>}
|
|
369
|
+
*/
|
|
370
|
+
export async function getMcpAuthKeyAge(opts = {}) {
|
|
371
|
+
const status = await reportMcpAuthSource(opts);
|
|
372
|
+
return status.ageDays ?? null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Internal: parse an ISO-8601 timestamp and return age in days from
|
|
376
|
+
// the reference time. Returns null on parse failure or future dates.
|
|
377
|
+
function _ageDaysFromIso(isoString, nowMs) {
|
|
378
|
+
if (typeof isoString !== 'string' || isoString.length === 0) return null;
|
|
379
|
+
const ms = Date.parse(isoString);
|
|
380
|
+
if (Number.isNaN(ms)) return null;
|
|
381
|
+
if (ms > nowMs) return null; // clock skew — don't emit a misleading negative
|
|
382
|
+
return Math.floor((nowMs - ms) / MS_PER_DAY);
|
|
222
383
|
}
|
|
223
384
|
|
|
224
385
|
function defaultMcpAuthFilePath() {
|
|
@@ -251,13 +412,34 @@ export async function persistMcpAuthKey(key, opts = {}) {
|
|
|
251
412
|
|
|
252
413
|
const plat = opts._platform ?? platform();
|
|
253
414
|
const kset = opts._keychainSet ?? keychainSet;
|
|
415
|
+
// EE-SEC.1.1: timestamp the key for rotation cadence tracking.
|
|
416
|
+
// ISO-8601 UTC for unambiguous parsing across timezones / locales.
|
|
417
|
+
// Test seam allows hermetic injection of "now".
|
|
418
|
+
// Reviewer 1 MEDIUM #1 fold (post-EE-SEC.1.1): normalize _now via
|
|
419
|
+
// `new Date(...).toISOString()` so callers passing an epoch-ms
|
|
420
|
+
// number (valid in reportMcpAuthSource via `new Date(opts._now).getTime()`)
|
|
421
|
+
// don't write an unparseable literal. Production callers always
|
|
422
|
+
// omit _now; this guards the hermetic-test path symmetrically.
|
|
423
|
+
const createdAt = opts._now
|
|
424
|
+
? new Date(opts._now).toISOString()
|
|
425
|
+
: new Date().toISOString();
|
|
254
426
|
|
|
255
427
|
// 1. macOS: try Keychain first.
|
|
256
428
|
let keychainFallbackReason = null;
|
|
257
429
|
if (plat === 'darwin') {
|
|
258
430
|
try {
|
|
431
|
+
// Reviewer 2 MEDIUM #2 fold (post-EE-SEC.1.1): write timestamp
|
|
432
|
+
// FIRST, then key. A half-write where the timestamp succeeds
|
|
433
|
+
// but the key fails leaves no usable key — auth check refuses
|
|
434
|
+
// the next startup → fail-CLOSED. Pre-fold the order was
|
|
435
|
+
// reversed: a half-write left a usable key with no timestamp,
|
|
436
|
+
// silently disabling rotation-cadence warnings forever.
|
|
437
|
+
// Companion writes use the same kset; if the FIRST one fails
|
|
438
|
+
// we fall through to file storage (full reset) rather than
|
|
439
|
+
// leaving partial state.
|
|
440
|
+
await kset(MCP_AUTH_CREATED_KEYCHAIN_ACCOUNT, createdAt);
|
|
259
441
|
await kset(MCP_AUTH_KEYCHAIN_ACCOUNT, key);
|
|
260
|
-
return { ok: true, location: 'macOS Keychain (service=nsauditor-ai)' };
|
|
442
|
+
return { ok: true, location: 'macOS Keychain (service=nsauditor-ai)', createdAt };
|
|
261
443
|
} catch (err) {
|
|
262
444
|
keychainFallbackReason = err && err.message ? err.message : String(err);
|
|
263
445
|
}
|
|
@@ -284,13 +466,35 @@ export async function persistMcpAuthKey(key, opts = {}) {
|
|
|
284
466
|
existingContent = await fsp.readFile(filePath, 'utf8');
|
|
285
467
|
} catch { /* missing file — create one */ }
|
|
286
468
|
|
|
287
|
-
|
|
288
|
-
|
|
469
|
+
// EE-SEC.1.1: write BOTH the key and the created-at timestamp.
|
|
470
|
+
// Same merge semantics — preserves other env-vars, replaces
|
|
471
|
+
// existing values, drops duplicates. The two writes are atomic
|
|
472
|
+
// at the file level (single writeFile call after merging both
|
|
473
|
+
// into the new content).
|
|
474
|
+
//
|
|
475
|
+
// Reviewer 2 MEDIUM #1 fold (post-EE-SEC.1.1): write to a
|
|
476
|
+
// sibling .tmp file first, then rename() — POSIX rename is
|
|
477
|
+
// atomic, so concurrent readers see either the OLD file or the
|
|
478
|
+
// NEW file, never a truncated mid-write state. Fixes the race
|
|
479
|
+
// where `mcp status` running simultaneously with `mcp install-key`
|
|
480
|
+
// could observe an empty file → "unconfigured" → operator
|
|
481
|
+
// confusion. Also defends against host-crash mid-write losing
|
|
482
|
+
// both the key AND the timestamp.
|
|
483
|
+
let newContent = mergeMcpAuthIntoEnvFile(existingContent, key);
|
|
484
|
+
newContent = mergeMcpAuthCreatedIntoEnvFile(newContent, createdAt);
|
|
485
|
+
const tmpPath = `${filePath}.tmp`;
|
|
486
|
+
await fsp.writeFile(tmpPath, newContent, { mode: 0o600 });
|
|
487
|
+
if (plat !== 'win32') {
|
|
488
|
+
await fsp.chmod(tmpPath, 0o600);
|
|
489
|
+
}
|
|
490
|
+
await fsp.rename(tmpPath, filePath);
|
|
289
491
|
if (plat !== 'win32') {
|
|
492
|
+
// rename preserves source mode on POSIX, but a chmod after is
|
|
493
|
+
// belt-and-suspenders if the source mode was somehow changed.
|
|
290
494
|
await fsp.chmod(filePath, 0o600);
|
|
291
495
|
}
|
|
292
496
|
|
|
293
|
-
const result = { ok: true, location: filePath };
|
|
497
|
+
const result = { ok: true, location: filePath, createdAt };
|
|
294
498
|
if (keychainFallbackReason !== null) {
|
|
295
499
|
result.warning =
|
|
296
500
|
`macOS Keychain unavailable (${keychainFallbackReason}); fell back to file storage. ` +
|
|
@@ -316,34 +520,86 @@ export async function persistMcpAuthKey(key, opts = {}) {
|
|
|
316
520
|
* @internal
|
|
317
521
|
*/
|
|
318
522
|
export function mergeMcpAuthIntoEnvFile(existingContent, key) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
523
|
+
return mergeKeyValueIntoEnvFile(
|
|
524
|
+
existingContent,
|
|
525
|
+
MCP_AUTH_ENV_VAR,
|
|
526
|
+
key,
|
|
527
|
+
`# NSAuditor AI MCP auth key — set via \`nsauditor-ai mcp install-key\``,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Merge a created-at timestamp companion line. EE-SEC.1.1 — used by
|
|
533
|
+
* persistMcpAuthKey to write `NSA_MCP_AUTH_KEY_CREATED=<ISO-8601>`
|
|
534
|
+
* alongside the auth key for rotation-cadence tracking.
|
|
535
|
+
*
|
|
536
|
+
* Exported for test coverage.
|
|
537
|
+
* @internal
|
|
538
|
+
*/
|
|
539
|
+
export function mergeMcpAuthCreatedIntoEnvFile(existingContent, createdAt) {
|
|
540
|
+
return mergeKeyValueIntoEnvFile(
|
|
541
|
+
existingContent,
|
|
542
|
+
MCP_AUTH_CREATED_ENV_VAR,
|
|
543
|
+
createdAt,
|
|
544
|
+
null, // no header — already present from the key line
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Generic dotenv-format merge: replace `${name}=...` line if present
|
|
550
|
+
* (collapsing duplicates per the corrupted-file defense), append at
|
|
551
|
+
* end if absent, write a header for empty input.
|
|
552
|
+
*
|
|
553
|
+
* EE-SEC.1.1 (Thread I refactor): the original merge logic was
|
|
554
|
+
* copy-pasted between the auth-key and (now) created-at lines; this
|
|
555
|
+
* factor-out also addresses Reviewer 2 LOW #2 from EE-SEC.1 (the
|
|
556
|
+
* universal-exclusion + sentinel-string duplication concern — both
|
|
557
|
+
* are now contained in a single helper).
|
|
558
|
+
*
|
|
559
|
+
* Reviewer 1 MEDIUM #5 fold (preserved): regex metacharacters in
|
|
560
|
+
* `name` are escaped before interpolation. Today the names are
|
|
561
|
+
* `NSA_MCP_AUTH_KEY` and `NSA_MCP_AUTH_KEY_CREATED` (no specials),
|
|
562
|
+
* so this is defense-in-depth — but a future rename containing
|
|
563
|
+
* `.+?^${}()|[]\\` would silently break the merge without this guard.
|
|
564
|
+
*
|
|
565
|
+
* @param {string} existingContent — current dotenv file content.
|
|
566
|
+
* @param {string} name — env-var name to write/replace.
|
|
567
|
+
* @param {string} value — value to assign.
|
|
568
|
+
* @param {string|null} headerComment — optional header to write when
|
|
569
|
+
* the file is empty (e.g., `# NSAuditor AI MCP auth key — ...`).
|
|
570
|
+
* @internal
|
|
571
|
+
*/
|
|
572
|
+
export function mergeKeyValueIntoEnvFile(existingContent, name, value, headerComment = null) {
|
|
573
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
327
574
|
const KEY_LINE_RE = new RegExp(
|
|
328
575
|
`^[ \\t]*${escaped}[ \\t]*=[^\\r\\n]*$`,
|
|
329
576
|
'gm',
|
|
330
577
|
);
|
|
331
|
-
const newLine = `${
|
|
578
|
+
const newLine = `${name}=${value}`;
|
|
579
|
+
// Sentinel includes the env-var name to avoid the (astronomical
|
|
580
|
+
// but possible) collision concern Reviewer 2 LOW #2 raised — the
|
|
581
|
+
// sentinel is now per-key rather than global.
|
|
582
|
+
const PURGE_SENTINEL = `__NSAUDITOR_PURGE_${name}__`;
|
|
332
583
|
|
|
333
584
|
const matches = existingContent.match(KEY_LINE_RE);
|
|
334
585
|
if (matches && matches.length > 0) {
|
|
335
586
|
let firstReplaced = false;
|
|
336
587
|
let merged = existingContent.replace(KEY_LINE_RE, () => {
|
|
337
|
-
if (firstReplaced) return
|
|
588
|
+
if (firstReplaced) return PURGE_SENTINEL;
|
|
338
589
|
firstReplaced = true;
|
|
339
590
|
return newLine;
|
|
340
591
|
});
|
|
341
|
-
|
|
592
|
+
const sentinelRe = new RegExp(
|
|
593
|
+
PURGE_SENTINEL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\r?\\n?',
|
|
594
|
+
'g',
|
|
595
|
+
);
|
|
596
|
+
merged = merged.replace(sentinelRe, '');
|
|
342
597
|
return merged;
|
|
343
598
|
}
|
|
344
599
|
|
|
345
600
|
if (existingContent.trim().length === 0) {
|
|
346
|
-
|
|
601
|
+
const header = headerComment ? `${headerComment}\n` : '';
|
|
602
|
+
return `${header}${newLine}\n`;
|
|
347
603
|
}
|
|
348
604
|
|
|
349
605
|
const sep = existingContent.endsWith('\n') ? '' : '\n';
|