nsauditor-ai 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ A modular, AI-assisted network security audit platform that scans, understands,
7
7
  [![npm](https://img.shields.io/npm/v/nsauditor-ai.svg)](https://www.npmjs.com/package/nsauditor-ai)
8
8
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node.js 20+](https://img.shields.io/badge/node-20%2B-green.svg)](https://nodejs.org)
10
- [![Tests](https://img.shields.io/badge/tests-487%20passing-brightgreen.svg)](#tests)
10
+ [![Tests](https://img.shields.io/badge/tests-493%20passing-brightgreen.svg)](#tests)
11
11
 
12
12
  ---
13
13
 
@@ -303,6 +303,38 @@ claude mcp add nsauditor-ai -- npx nsauditor-ai-mcp
303
303
 
304
304
  ---
305
305
 
306
+ ## Secure Credential Storage
307
+
308
+ Store API keys in the macOS Keychain instead of plaintext `.env` files:
309
+
310
+ ```bash
311
+ # Store keys
312
+ nsauditor-ai security set ANTHROPIC_API_KEY
313
+ nsauditor-ai security set OPENAI_API_KEY
314
+
315
+ # List stored keys (masked)
316
+ nsauditor-ai security list
317
+
318
+ # Delete a key
319
+ nsauditor-ai security delete OPENAI_API_KEY
320
+ ```
321
+
322
+ Then reference them with the `keychain:` prefix in `.env` or Claude Desktop config:
323
+
324
+ ```env
325
+ ANTHROPIC_API_KEY=keychain:ANTHROPIC_API_KEY
326
+ ```
327
+
328
+ ```json
329
+ "env": {
330
+ "ANTHROPIC_API_KEY": "keychain:ANTHROPIC_API_KEY"
331
+ }
332
+ ```
333
+
334
+ The `keychain:` prefix works anywhere an API key is read — CLI, MCP server, or programmatic API.
335
+
336
+ ---
337
+
306
338
  ## CLI Reference
307
339
 
308
340
  ```
package/cli.mjs CHANGED
@@ -92,11 +92,12 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
92
92
  : aiProvider === 'ollama'
93
93
  ? toCleanPath(process.env.OLLAMA_MODEL || 'llama3')
94
94
  : toCleanPath(process.env.OPENAI_MODEL || 'gpt-4o-mini');
95
+ const { resolveSecret } = await import('./utils/keychain.mjs');
95
96
  const keyRaw = aiProvider === 'claude'
96
- ? process.env.ANTHROPIC_API_KEY
97
+ ? await resolveSecret(process.env.ANTHROPIC_API_KEY)
97
98
  : aiProvider === 'ollama'
98
99
  ? 'ollama' // Ollama needs no real key; OpenAI SDK requires a non-empty string
99
- : process.env.OPENAI_API_KEY;
100
+ : await resolveSecret(process.env.OPENAI_API_KEY);
100
101
  const key = keyRaw ? String(keyRaw).trim() : null;
101
102
 
102
103
  // Base output folder (directory ONLY; if a file path is given, take its dir)
@@ -650,6 +651,27 @@ const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
650
651
  * @param {object} conclusion
651
652
  * @returns {number} highest severity rank found (0-4)
652
653
  */
654
+ async function readSecretFromStdin(keyName) {
655
+ if (!process.stdin.isTTY) {
656
+ // Piped input
657
+ return new Promise((resolve) => {
658
+ let data = '';
659
+ process.stdin.setEncoding('utf8');
660
+ process.stdin.on('data', (chunk) => { data += chunk; });
661
+ process.stdin.on('end', () => resolve(data.trim() || null));
662
+ });
663
+ }
664
+ // Interactive prompt
665
+ const { createInterface } = await import('node:readline');
666
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
667
+ return new Promise((resolve) => {
668
+ rl.question(`Enter value for ${keyName}: `, (answer) => {
669
+ rl.close();
670
+ resolve(answer.trim() || null);
671
+ });
672
+ });
673
+ }
674
+
653
675
  function maxSeverityInConclusion(conclusion) {
654
676
  const services = conclusion?.result?.services || [];
655
677
  let max = 0;
@@ -709,6 +731,42 @@ async function main() {
709
731
  process.exit(0);
710
732
  }
711
733
 
734
+ if (cmd === 'security') {
735
+ const { keychainSet, keychainDelete, keychainList, keychainGet } = await import('./utils/keychain.mjs');
736
+ const rawArgs = process.argv.slice(2);
737
+ const subCmd = rawArgs[1]; // set | delete | list | get
738
+ const keyName = rawArgs[2];
739
+
740
+ if (subCmd === 'set' && keyName) {
741
+ // Read secret from stdin (piped) or prompt
742
+ const secret = await readSecretFromStdin(keyName);
743
+ if (!secret) { console.error('No secret provided.'); process.exit(1); }
744
+ await keychainSet(keyName, secret);
745
+ console.log(`Stored "${keyName}" in macOS Keychain (service: nsauditor-ai)`);
746
+ } else if (subCmd === 'delete' && keyName) {
747
+ const ok = await keychainDelete(keyName);
748
+ console.log(ok ? `Deleted "${keyName}" from Keychain` : `"${keyName}" not found in Keychain`);
749
+ } else if (subCmd === 'list') {
750
+ const entries = await keychainList();
751
+ if (entries.length === 0) {
752
+ console.log('No nsauditor-ai keys stored in Keychain.');
753
+ } else {
754
+ console.log('Stored keys (service: nsauditor-ai):\n');
755
+ for (const name of entries) {
756
+ const val = await keychainGet(name);
757
+ const masked = val ? `${val.slice(0, 8)}...(${val.length} chars)` : '(empty)';
758
+ console.log(` ${name} = ${masked}`);
759
+ }
760
+ }
761
+ } else {
762
+ console.log(`Usage:
763
+ nsauditor-ai security set <KEY_NAME> Store a secret in macOS Keychain
764
+ nsauditor-ai security delete <KEY_NAME> Remove a secret from Keychain
765
+ nsauditor-ai security list List stored secrets (masked)`);
766
+ }
767
+ process.exit(0);
768
+ }
769
+
712
770
  if (cmd !== 'scan') {
713
771
  console.error(`Unknown command: ${cmd}`);
714
772
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,120 @@
1
+ // utils/keychain.mjs
2
+ // macOS Keychain integration for secure credential storage.
3
+ // Falls back gracefully on non-macOS platforms.
4
+
5
+ import { execFile } from 'node:child_process';
6
+ import { platform } from 'node:os';
7
+
8
+ const SERVICE = 'nsauditor-ai';
9
+ const isMac = platform() === 'darwin';
10
+
11
+ function exec(cmd, args) {
12
+ return new Promise((resolve, reject) => {
13
+ execFile(cmd, args, { timeout: 5000 }, (err, stdout, stderr) => {
14
+ if (err) return reject(new Error(stderr?.trim() || err.message));
15
+ resolve(stdout.trim());
16
+ });
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Read a secret from the macOS Keychain.
22
+ * @param {string} account - Key name (e.g., 'ANTHROPIC_API_KEY')
23
+ * @returns {Promise<string|null>} The secret value, or null if not found
24
+ */
25
+ export async function keychainGet(account) {
26
+ if (!isMac) return null;
27
+ try {
28
+ return await exec('security', [
29
+ 'find-generic-password', '-s', SERVICE, '-a', account, '-w'
30
+ ]);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Store a secret in the macOS Keychain.
38
+ * Updates existing entry if present.
39
+ * @param {string} account - Key name
40
+ * @param {string} secret - The secret value
41
+ */
42
+ export async function keychainSet(account, secret) {
43
+ if (!isMac) throw new Error('Keychain storage is only supported on macOS');
44
+ // Delete existing entry first (ignore errors if not found)
45
+ try {
46
+ await exec('security', ['delete-generic-password', '-s', SERVICE, '-a', account]);
47
+ } catch { /* not found — fine */ }
48
+ await exec('security', [
49
+ 'add-generic-password', '-s', SERVICE, '-a', account, '-w', secret
50
+ ]);
51
+ }
52
+
53
+ /**
54
+ * Delete a secret from the macOS Keychain.
55
+ * @param {string} account - Key name
56
+ * @returns {Promise<boolean>} true if deleted, false if not found
57
+ */
58
+ export async function keychainDelete(account) {
59
+ if (!isMac) throw new Error('Keychain storage is only supported on macOS');
60
+ try {
61
+ await exec('security', ['delete-generic-password', '-s', SERVICE, '-a', account]);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * List all nsauditor-ai entries in the Keychain.
70
+ * @returns {Promise<string[]>} Array of account names
71
+ */
72
+ export async function keychainList() {
73
+ if (!isMac) return [];
74
+ try {
75
+ const raw = await exec('security', ['dump-keychain']);
76
+ const entries = [];
77
+ const lines = raw.split('\n');
78
+ let inOurService = false;
79
+ for (const line of lines) {
80
+ const trimmed = line.trim();
81
+ // Match both formats: 0x00000007 <blob>="nsauditor-ai" and "svce"<blob>="nsauditor-ai"
82
+ if (trimmed.includes(`="${SERVICE}"`)) {
83
+ inOurService = true;
84
+ } else if (inOurService && trimmed.includes('"acct"<blob>="')) {
85
+ const m = trimmed.match(/"acct"<blob>="([^"]+)"/);
86
+ if (m) entries.push(m[1]);
87
+ inOurService = false;
88
+ } else if (trimmed.startsWith('keychain:') || trimmed.startsWith('class:')) {
89
+ inOurService = false;
90
+ }
91
+ }
92
+ return [...new Set(entries)];
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Resolve a value that may be a keychain reference.
100
+ * If the value starts with 'keychain:', read from Keychain.
101
+ * Otherwise return the value as-is.
102
+ * @param {string|undefined} value - Raw env var value
103
+ * @returns {Promise<string|null>} Resolved secret
104
+ */
105
+ export async function resolveSecret(value) {
106
+ if (!value) return null;
107
+ const str = String(value).trim();
108
+ if (!str) return null;
109
+ if (str.startsWith('keychain:')) {
110
+ const label = str.slice('keychain:'.length).trim();
111
+ if (!label) return null;
112
+ const secret = await keychainGet(label);
113
+ if (!secret) {
114
+ console.error(`[keychain] No entry found for "${label}" in Keychain (service: ${SERVICE})`);
115
+ console.error(`[keychain] Store it with: nsauditor-ai security set ${label}`);
116
+ }
117
+ return secret;
118
+ }
119
+ return str;
120
+ }