nsauditor-ai 0.1.4 → 0.1.6
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 +65 -3
- 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
|
@@ -3,6 +3,10 @@ import 'dotenv/config';
|
|
|
3
3
|
import PluginManager from './plugin_manager.mjs';
|
|
4
4
|
import { buildHtmlReport } from './utils/report_html.mjs';
|
|
5
5
|
import fsp from 'node:fs/promises';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
10
|
import path from 'node:path';
|
|
7
11
|
import { openaiSimplePrompt, openaiPrompt as openaiProPrompt, openaiPromptOptimized } from './utils/prompts.mjs';
|
|
8
12
|
import { parseHostArg, parseHostFile } from './utils/host_iterator.mjs';
|
|
@@ -92,11 +96,12 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
|
|
|
92
96
|
: aiProvider === 'ollama'
|
|
93
97
|
? toCleanPath(process.env.OLLAMA_MODEL || 'llama3')
|
|
94
98
|
: toCleanPath(process.env.OPENAI_MODEL || 'gpt-4o-mini');
|
|
99
|
+
const { resolveSecret } = await import('./utils/keychain.mjs');
|
|
95
100
|
const keyRaw = aiProvider === 'claude'
|
|
96
|
-
? process.env.ANTHROPIC_API_KEY
|
|
101
|
+
? await resolveSecret(process.env.ANTHROPIC_API_KEY)
|
|
97
102
|
: aiProvider === 'ollama'
|
|
98
103
|
? 'ollama' // Ollama needs no real key; OpenAI SDK requires a non-empty string
|
|
99
|
-
: process.env.OPENAI_API_KEY;
|
|
104
|
+
: await resolveSecret(process.env.OPENAI_API_KEY);
|
|
100
105
|
const key = keyRaw ? String(keyRaw).trim() : null;
|
|
101
106
|
|
|
102
107
|
// Base output folder (directory ONLY; if a file path is given, take its dir)
|
|
@@ -650,6 +655,27 @@ const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
|
650
655
|
* @param {object} conclusion
|
|
651
656
|
* @returns {number} highest severity rank found (0-4)
|
|
652
657
|
*/
|
|
658
|
+
async function readSecretFromStdin(keyName) {
|
|
659
|
+
if (!process.stdin.isTTY) {
|
|
660
|
+
// Piped input
|
|
661
|
+
return new Promise((resolve) => {
|
|
662
|
+
let data = '';
|
|
663
|
+
process.stdin.setEncoding('utf8');
|
|
664
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
665
|
+
process.stdin.on('end', () => resolve(data.trim() || null));
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
// Interactive prompt
|
|
669
|
+
const { createInterface } = await import('node:readline');
|
|
670
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
671
|
+
return new Promise((resolve) => {
|
|
672
|
+
rl.question(`Enter value for ${keyName}: `, (answer) => {
|
|
673
|
+
rl.close();
|
|
674
|
+
resolve(answer.trim() || null);
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
653
679
|
function maxSeverityInConclusion(conclusion) {
|
|
654
680
|
const services = conclusion?.result?.services || [];
|
|
655
681
|
let max = 0;
|
|
@@ -709,6 +735,42 @@ async function main() {
|
|
|
709
735
|
process.exit(0);
|
|
710
736
|
}
|
|
711
737
|
|
|
738
|
+
if (cmd === 'security') {
|
|
739
|
+
const { keychainSet, keychainDelete, keychainList, keychainGet } = await import('./utils/keychain.mjs');
|
|
740
|
+
const rawArgs = process.argv.slice(2);
|
|
741
|
+
const subCmd = rawArgs[1]; // set | delete | list | get
|
|
742
|
+
const keyName = rawArgs[2];
|
|
743
|
+
|
|
744
|
+
if (subCmd === 'set' && keyName) {
|
|
745
|
+
// Read secret from stdin (piped) or prompt
|
|
746
|
+
const secret = await readSecretFromStdin(keyName);
|
|
747
|
+
if (!secret) { console.error('No secret provided.'); process.exit(1); }
|
|
748
|
+
await keychainSet(keyName, secret);
|
|
749
|
+
console.log(`Stored "${keyName}" in macOS Keychain (service: nsauditor-ai)`);
|
|
750
|
+
} else if (subCmd === 'delete' && keyName) {
|
|
751
|
+
const ok = await keychainDelete(keyName);
|
|
752
|
+
console.log(ok ? `Deleted "${keyName}" from Keychain` : `"${keyName}" not found in Keychain`);
|
|
753
|
+
} else if (subCmd === 'list') {
|
|
754
|
+
const entries = await keychainList();
|
|
755
|
+
if (entries.length === 0) {
|
|
756
|
+
console.log('No nsauditor-ai keys stored in Keychain.');
|
|
757
|
+
} else {
|
|
758
|
+
console.log('Stored keys (service: nsauditor-ai):\n');
|
|
759
|
+
for (const name of entries) {
|
|
760
|
+
const val = await keychainGet(name);
|
|
761
|
+
const masked = val ? `${val.slice(0, 8)}...(${val.length} chars)` : '(empty)';
|
|
762
|
+
console.log(` ${name} = ${masked}`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
console.log(`Usage:
|
|
767
|
+
nsauditor-ai security set <KEY_NAME> Store a secret in macOS Keychain
|
|
768
|
+
nsauditor-ai security delete <KEY_NAME> Remove a secret from Keychain
|
|
769
|
+
nsauditor-ai security list List stored secrets (masked)`);
|
|
770
|
+
}
|
|
771
|
+
process.exit(0);
|
|
772
|
+
}
|
|
773
|
+
|
|
712
774
|
if (cmd !== 'scan') {
|
|
713
775
|
console.error(`Unknown command: ${cmd}`);
|
|
714
776
|
process.exit(2);
|
|
@@ -732,7 +794,7 @@ async function main() {
|
|
|
732
794
|
|
|
733
795
|
const opts = { insecureHttps };
|
|
734
796
|
if (ports) opts.ports = ports;
|
|
735
|
-
const pm = await PluginManager.create(
|
|
797
|
+
const pm = await PluginManager.create(`${__dirname}/plugins`);
|
|
736
798
|
const promptMode = String(process.env.OPENAI_PROMPT_MODE || 'basic').toLowerCase().trim();
|
|
737
799
|
|
|
738
800
|
// --- CTEM: continuous watch mode ---
|
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
|
+
}
|