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 +33 -1
- package/cli.mjs +60 -2
- package/package.json +1 -1
- package/utils/keychain.mjs +120 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A modular, AI-assisted network security audit platform that scans, understands,
|
|
|
7
7
|
[](https://www.npmjs.com/package/nsauditor-ai)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](https://nodejs.org)
|
|
10
|
-
[](#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
|
@@ -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
|
+
}
|