agentaudit 3.9.7 → 3.9.9

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.
Files changed (6) hide show
  1. package/LICENSE +34 -34
  2. package/README.md +504 -504
  3. package/cli.mjs +1716 -1680
  4. package/index.mjs +605 -603
  5. package/package.json +45 -45
  6. package/prompts/audit-prompt.md +663 -663
package/cli.mjs CHANGED
@@ -1,1680 +1,1716 @@
1
- #!/usr/bin/env node
2
- /**
3
- * AgentAudit CLI — Security scanner for AI packages
4
- *
5
- * Usage:
6
- * agentaudit Discover local MCP servers
7
- * agentaudit discover [--quick|--deep] Find MCP servers in AI editors
8
- * agentaudit scan <repo-url> [--deep] Quick scan (or deep audit with --deep)
9
- * agentaudit audit <repo-url> Deep LLM-powered security audit
10
- * agentaudit lookup <name> Look up package in registry
11
- * agentaudit setup Register + configure API key
12
- *
13
- * Global flags: --json, --quiet, --no-color
14
- */
15
-
16
- import fs from 'fs';
17
- import os from 'os';
18
- import path from 'path';
19
- import { execSync } from 'child_process';
20
- import { createInterface } from 'readline';
21
- import { fileURLToPath } from 'url';
22
-
23
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
- const SKILL_DIR = path.resolve(__dirname);
25
- const REGISTRY_URL = 'https://agentaudit.dev';
26
-
27
- // ── Global flags (set in main before command routing) ────
28
- let jsonMode = false;
29
- let quietMode = false;
30
-
31
- // ── ANSI Colors (respects NO_COLOR and --no-color) ───────
32
-
33
- const noColor = !!(process.env.NO_COLOR || process.argv.includes('--no-color'));
34
-
35
- const c = noColor ? {
36
- reset: '', bold: '', dim: '', red: '', green: '', yellow: '',
37
- blue: '', magenta: '', cyan: '', white: '', gray: '',
38
- bgRed: '', bgGreen: '', bgYellow: '',
39
- } : {
40
- reset: '\x1b[0m',
41
- bold: '\x1b[1m',
42
- dim: '\x1b[2m',
43
- red: '\x1b[31m',
44
- green: '\x1b[32m',
45
- yellow: '\x1b[33m',
46
- blue: '\x1b[34m',
47
- magenta: '\x1b[35m',
48
- cyan: '\x1b[36m',
49
- white: '\x1b[37m',
50
- gray: '\x1b[90m',
51
- bgRed: '\x1b[41m',
52
- bgGreen: '\x1b[42m',
53
- bgYellow: '\x1b[43m',
54
- };
55
-
56
- const icons = {
57
- safe: `${c.green}✔${c.reset}`,
58
- caution: `${c.yellow}⚠${c.reset}`,
59
- unsafe: `${c.red}✖${c.reset}`,
60
- info: `${c.blue}ℹ${c.reset}`,
61
- scan: `${c.cyan}◉${c.reset}`,
62
- tree: `${c.gray}├──${c.reset}`,
63
- treeLast: `${c.gray}└──${c.reset}`,
64
- pipe: `${c.gray}│${c.reset}`,
65
- bullet: `${c.gray}•${c.reset}`,
66
- };
67
-
68
- // ── Credentials ─────────────────────────────────────────
69
-
70
- const home = process.env.HOME || process.env.USERPROFILE || '';
71
- const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
72
- const USER_CRED_DIR = path.join(xdgConfig, 'agentaudit');
73
- const USER_CRED_FILE = path.join(USER_CRED_DIR, 'credentials.json');
74
- const SKILL_CRED_FILE = path.join(SKILL_DIR, 'config', 'credentials.json');
75
-
76
- function loadCredentials() {
77
- for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
78
- if (fs.existsSync(f)) {
79
- try {
80
- const data = JSON.parse(fs.readFileSync(f, 'utf8'));
81
- if (data.api_key) return data;
82
- } catch {}
83
- }
84
- }
85
- if (process.env.AGENTAUDIT_API_KEY) {
86
- return { api_key: process.env.AGENTAUDIT_API_KEY, agent_name: 'env' };
87
- }
88
- return null;
89
- }
90
-
91
- function saveCredentials(data) {
92
- const json = JSON.stringify(data, null, 2);
93
- fs.mkdirSync(USER_CRED_DIR, { recursive: true });
94
- fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
95
- try {
96
- fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
97
- fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
98
- } catch {}
99
- }
100
-
101
- function askQuestion(question) {
102
- const rl = createInterface({ input: process.stdin, output: process.stdout });
103
- return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim()); }));
104
- }
105
-
106
- /**
107
- * Interactive multi-select in terminal. No dependencies.
108
- * items: [{ label, sublabel?, value, checked? }]
109
- * Returns: array of selected values
110
- */
111
- function multiSelect(items, { title = 'Select items', hint = 'Space=toggle ↑↓=move a=all n=none Enter=confirm' } = {}) {
112
- return new Promise((resolve) => {
113
- if (!process.stdin.isTTY) {
114
- // Non-interactive: return all items
115
- resolve(items.map(i => i.value));
116
- return;
117
- }
118
-
119
- const selected = new Set(items.filter(i => i.checked).map((_, idx) => idx));
120
- let cursor = 0;
121
-
122
- const render = () => {
123
- // Move cursor up to overwrite previous render
124
- process.stdout.write(`\x1b[${items.length + 3}A\x1b[J`);
125
- draw();
126
- };
127
-
128
- const draw = () => {
129
- console.log(` ${c.bold}${title}${c.reset} ${c.dim}(${selected.size}/${items.length} selected)${c.reset}`);
130
- console.log(` ${c.dim}${hint}${c.reset}`);
131
- console.log();
132
- for (let i = 0; i < items.length; i++) {
133
- const item = items[i];
134
- const isCursor = i === cursor;
135
- const isSelected = selected.has(i);
136
- const pointer = isCursor ? `${c.cyan}❯${c.reset}` : ' ';
137
- const checkbox = isSelected ? `${c.green}◉${c.reset}` : `${c.dim}○${c.reset}`;
138
- const label = isCursor ? `${c.bold}${item.label}${c.reset}` : item.label;
139
- const sub = item.sublabel ? ` ${c.dim}${item.sublabel}${c.reset}` : '';
140
- console.log(` ${pointer} ${checkbox} ${label}${sub}`);
141
- }
142
- };
143
-
144
- // Initial draw
145
- draw();
146
-
147
- process.stdin.setRawMode(true);
148
- process.stdin.resume();
149
- process.stdin.setEncoding('utf8');
150
-
151
- const onData = (key) => {
152
- // Ctrl+C
153
- if (key === '\x03') {
154
- process.stdin.setRawMode(false);
155
- process.stdin.pause();
156
- process.stdin.removeListener('data', onData);
157
- console.log();
158
- process.exitCode = 0; return;
159
- }
160
-
161
- // Enter
162
- if (key === '\r' || key === '\n') {
163
- process.stdin.setRawMode(false);
164
- process.stdin.pause();
165
- process.stdin.removeListener('data', onData);
166
- resolve(items.filter((_, i) => selected.has(i)).map(i => i.value));
167
- return;
168
- }
169
-
170
- // Space — toggle
171
- if (key === ' ') {
172
- if (selected.has(cursor)) selected.delete(cursor);
173
- else selected.add(cursor);
174
- render();
175
- return;
176
- }
177
-
178
- // a — select all
179
- if (key === 'a') {
180
- for (let i = 0; i < items.length; i++) selected.add(i);
181
- render();
182
- return;
183
- }
184
-
185
- // n — select none
186
- if (key === 'n') {
187
- selected.clear();
188
- render();
189
- return;
190
- }
191
-
192
- // Arrow up / k
193
- if (key === '\x1b[A' || key === 'k') {
194
- cursor = (cursor - 1 + items.length) % items.length;
195
- render();
196
- return;
197
- }
198
-
199
- // Arrow down / j
200
- if (key === '\x1b[B' || key === 'j') {
201
- cursor = (cursor + 1) % items.length;
202
- render();
203
- return;
204
- }
205
- };
206
-
207
- process.stdin.on('data', onData);
208
- });
209
- }
210
-
211
- async function registerAgent(agentName) {
212
- const res = await fetch(`${REGISTRY_URL}/api/register`, {
213
- method: 'POST',
214
- headers: { 'Content-Type': 'application/json' },
215
- body: JSON.stringify({ agent_name: agentName }),
216
- signal: AbortSignal.timeout(15_000),
217
- });
218
- if (!res.ok) throw new Error(`Registration failed (HTTP ${res.status}): ${await res.text()}`);
219
- return res.json();
220
- }
221
-
222
- async function setupCommand() {
223
- console.log(` ${c.bold}Setup${c.reset}`);
224
- console.log();
225
-
226
- const existing = loadCredentials();
227
- if (existing) {
228
- console.log(` ${icons.safe} Already configured as ${c.bold}${existing.agent_name}${c.reset}`);
229
- console.log(` ${c.dim}Key: ${existing.api_key.slice(0, 8)}...${c.reset}`);
230
- console.log();
231
- const answer = await askQuestion(` Reconfigure? ${c.dim}(y/N)${c.reset} `);
232
- if (answer.toLowerCase() !== 'y') {
233
- console.log(` ${c.dim}Keeping existing config.${c.reset}`);
234
- return;
235
- }
236
- console.log();
237
- }
238
-
239
- console.log(` ${c.bold}1)${c.reset} Register new agent ${c.dim}(free, creates API key automatically)${c.reset}`);
240
- console.log(` ${c.bold}2)${c.reset} Enter existing API key`);
241
- console.log();
242
- const choice = await askQuestion(` Choice ${c.dim}(1/2)${c.reset}: `);
243
- console.log();
244
-
245
- if (choice === '2') {
246
- const key = await askQuestion(` API Key: `);
247
- if (!key) { console.log(` ${c.red}No key entered.${c.reset}`); return; }
248
- const name = await askQuestion(` Agent name ${c.dim}(optional)${c.reset}: `);
249
- saveCredentials({ api_key: key, agent_name: name || 'custom' });
250
- console.log();
251
- console.log(` ${icons.safe} Saved! Key stored in ${c.dim}${USER_CRED_FILE}${c.reset}`);
252
- } else {
253
- const name = await askQuestion(` Agent name ${c.dim}(e.g. my-scanner, claude-desktop)${c.reset}: `);
254
- if (!name || !/^[a-zA-Z0-9._-]{2,64}$/.test(name)) {
255
- console.log(` ${c.red}Invalid name. Use 2-64 chars: letters, numbers, dash, underscore, dot.${c.reset}`);
256
- return;
257
- }
258
- process.stdout.write(` Registering ${c.bold}${name}${c.reset}...`);
259
- try {
260
- const data = await registerAgent(name);
261
- saveCredentials({ api_key: data.api_key, agent_name: data.agent_name });
262
- console.log(` ${c.green}done!${c.reset}`);
263
- console.log();
264
- console.log(` ${icons.safe} Registered as ${c.bold}${data.agent_name}${c.reset}`);
265
- console.log(` ${c.dim}Key: ${data.api_key.slice(0, 12)}...${c.reset}`);
266
- console.log(` ${c.dim}Saved to: ${USER_CRED_FILE}${c.reset}`);
267
- } catch (err) {
268
- console.log(` ${c.red}failed${c.reset}`);
269
- console.log(` ${c.red}${err.message}${c.reset}`);
270
- return;
271
- }
272
- }
273
-
274
- console.log();
275
- console.log(` ${c.bold}Ready!${c.reset} You can now:`);
276
- console.log(` ${c.dim}•${c.reset} Discover servers: ${c.cyan}agentaudit discover${c.reset}`);
277
- console.log(` ${c.dim}•${c.reset} Audit packages: ${c.cyan}agentaudit audit <repo-url>${c.reset} ${c.dim}(deep LLM analysis)${c.reset}`);
278
- console.log(` ${c.dim}•${c.reset} Quick scan: ${c.cyan}agentaudit scan <repo-url>${c.reset} ${c.dim}(regex-based)${c.reset}`);
279
- console.log(` ${c.dim}•${c.reset} Check registry: ${c.cyan}agentaudit check <name>${c.reset}`);
280
- console.log(` ${c.dim}•${c.reset} Submit reports via MCP in Claude/Cursor/Windsurf`);
281
- console.log();
282
- }
283
-
284
- // ── Helpers ──────────────────────────────────────────────
285
-
286
- function getVersion() {
287
- try {
288
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
289
- return pkg.version || '0.0.0';
290
- } catch { return '0.0.0'; }
291
- }
292
-
293
- function banner() {
294
- if (quietMode || jsonMode) return;
295
- console.log();
296
- console.log(` ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset}`);
297
- console.log(` ${c.dim}Security scanner for AI packages${c.reset}`);
298
- console.log();
299
- }
300
-
301
- function slugFromUrl(url) {
302
- const match = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
303
- if (match) return match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
304
- return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
305
- }
306
-
307
- function elapsed(startMs) {
308
- const ms = Date.now() - startMs;
309
- if (ms < 1000) return `${ms}ms`;
310
- return `${(ms / 1000).toFixed(1)}s`;
311
- }
312
-
313
- function riskBadge(score) {
314
- if (score === 0) return `${c.bgGreen}${c.bold}${c.white} SAFE ${c.reset}`;
315
- if (score <= 10) return `${c.bgGreen}${c.white} LOW ${c.reset}`;
316
- if (score <= 30) return `${c.bgYellow}${c.bold} CAUTION ${c.reset}`;
317
- return `${c.bgRed}${c.bold}${c.white} UNSAFE ${c.reset}`;
318
- }
319
-
320
- function severityColor(sev) {
321
- switch (sev) {
322
- case 'critical': return c.red;
323
- case 'high': return c.red;
324
- case 'medium': return c.yellow;
325
- case 'low': return c.blue;
326
- default: return c.gray;
327
- }
328
- }
329
-
330
- function severityIcon(sev) {
331
- switch (sev) {
332
- case 'critical': return `${c.red}●${c.reset}`;
333
- case 'high': return `${c.red}●${c.reset}`;
334
- case 'medium': return `${c.yellow}●${c.reset}`;
335
- case 'low': return `${c.blue}●${c.reset}`;
336
- default: return `${c.green}●${c.reset}`;
337
- }
338
- }
339
-
340
- // ── File Collection (same logic as MCP server) ──────────
341
-
342
- const MAX_FILE_SIZE = 50_000;
343
- const MAX_TOTAL_SIZE = 300_000;
344
- const SKIP_DIRS = new Set([
345
- 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
346
- '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
347
- 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
348
- 'examples', 'example', 'fixtures', '.github', '.vscode', '.idea',
349
- 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
350
- ]);
351
- const SKIP_EXTENSIONS = new Set([
352
- '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
353
- '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
354
- '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
355
- '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
356
- ]);
357
-
358
- function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
359
- if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
360
- let entries;
361
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
362
- catch { return collected; }
363
- entries.sort((a, b) => a.name.localeCompare(b.name));
364
- for (const entry of entries) {
365
- if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
366
- const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
367
- const fullPath = path.join(dir, entry.name);
368
- if (entry.isDirectory()) {
369
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
370
- collectFiles(fullPath, relPath, collected, totalSize);
371
- } else {
372
- const ext = path.extname(entry.name).toLowerCase();
373
- if (SKIP_EXTENSIONS.has(ext)) continue;
374
- try {
375
- const stat = fs.statSync(fullPath);
376
- if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
377
- const content = fs.readFileSync(fullPath, 'utf8');
378
- totalSize.bytes += content.length;
379
- collected.push({ path: relPath, content, size: stat.size });
380
- } catch {}
381
- }
382
- }
383
- return collected;
384
- }
385
-
386
- // ── Detect package properties ───────────────────────────
387
-
388
- function detectPackageInfo(repoPath, files) {
389
- const info = { type: 'unknown', tools: [], prompts: [], language: 'unknown', entrypoint: null };
390
-
391
- // Detect language
392
- const exts = files.map(f => path.extname(f.path).toLowerCase());
393
- const extCounts = {};
394
- exts.forEach(e => { extCounts[e] = (extCounts[e] || 0) + 1; });
395
- const topExt = Object.entries(extCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
396
-
397
- const langMap = { '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript', '.mjs': 'JavaScript', '.rs': 'Rust', '.go': 'Go', '.java': 'Java', '.rb': 'Ruby' };
398
- info.language = langMap[topExt] || topExt || 'unknown';
399
-
400
- // Detect package type
401
- const allContent = files.map(f => f.content).join('\n');
402
- if (allContent.includes('@modelcontextprotocol') || allContent.includes('FastMCP') || allContent.includes('mcp.server') || allContent.includes('mcp_server')) {
403
- info.type = 'mcp-server';
404
- } else if (files.some(f => f.path.toLowerCase() === 'skill.md')) {
405
- info.type = 'agent-skill';
406
- } else if (allContent.includes('#!/usr/bin/env') || allContent.includes('argparse') || allContent.includes('commander')) {
407
- info.type = 'cli-tool';
408
- } else {
409
- info.type = 'library';
410
- }
411
-
412
- // Extract MCP tools (look for tool definitions)
413
- const toolPatterns = [
414
- // JS/TS: name: 'tool_name' or "tool_name" in tool definitions
415
- /(?:name|tool_name)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
416
- // Python: @mcp.tool() def func_name or Tool(name="...")
417
- /(?:@(?:mcp|server)\.tool\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*))|(?:Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"])/gi,
418
- // Direct: tool names in ListTools handlers
419
- /['"]name['"]\s*:\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
420
- ];
421
-
422
- const toolSet = new Set();
423
- for (const file of files) {
424
- for (const pattern of toolPatterns) {
425
- pattern.lastIndex = 0;
426
- let m;
427
- while ((m = pattern.exec(file.content)) !== null) {
428
- const name = m[1] || m[2];
429
- if (name && name.length > 2 && name.length < 50 && !['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none'].includes(name)) {
430
- toolSet.add(name);
431
- }
432
- }
433
- }
434
- }
435
- info.tools = [...toolSet];
436
-
437
- // Extract prompts (look for prompt definitions)
438
- const promptPatterns = [
439
- /(?:prompt|PROMPT)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
440
- /@(?:mcp|server)\.prompt\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
441
- ];
442
- const promptSet = new Set();
443
- for (const file of files) {
444
- for (const pattern of promptPatterns) {
445
- pattern.lastIndex = 0;
446
- let m;
447
- while ((m = pattern.exec(file.content)) !== null) {
448
- if (m[1] && m[1].length > 2) promptSet.add(m[1]);
449
- }
450
- }
451
- }
452
- info.prompts = [...promptSet];
453
-
454
- // Detect entrypoint
455
- const entryFiles = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
456
- for (const ef of entryFiles) {
457
- if (files.some(f => f.path === ef)) { info.entrypoint = ef; break; }
458
- }
459
-
460
- return info;
461
- }
462
-
463
- // ── Quick static checks ─────────────────────────────────
464
-
465
- function quickChecks(files) {
466
- const findings = [];
467
-
468
- const checks = [
469
- {
470
- id: 'EXEC_INJECTION',
471
- title: 'Command injection risk',
472
- severity: 'high',
473
- pattern: /(?:exec(?:Sync)?|spawn|child_process|subprocess|os\.system|os\.popen|Popen)\s*\([^)]*(?:\$\{|`|\+\s*(?:req|input|args|param|user|query))/i,
474
- category: 'injection',
475
- },
476
- {
477
- id: 'EVAL_USAGE',
478
- title: 'Dynamic code evaluation',
479
- severity: 'high',
480
- pattern: /(?:^|[^a-z])eval\s*\([^)]*(?:input|req|user|param|arg|query)/im,
481
- category: 'injection',
482
- },
483
- {
484
- id: 'HARDCODED_SECRET',
485
- title: 'Potential hardcoded secret',
486
- severity: 'medium',
487
- pattern: /(?:api[_-]?key|password|secret|token)\s*[:=]\s*['"][A-Za-z0-9+/=_-]{16,}['"]/i,
488
- category: 'secrets',
489
- },
490
- {
491
- id: 'SSL_DISABLED',
492
- title: 'SSL/TLS verification disabled',
493
- severity: 'medium',
494
- pattern: /(?:rejectUnauthorized\s*:\s*false|verify\s*=\s*False|VERIFY_SSL\s*=\s*false|NODE_TLS_REJECT_UNAUTHORIZED|InsecureRequestWarning)/i,
495
- category: 'crypto',
496
- },
497
- {
498
- id: 'PATH_TRAVERSAL',
499
- title: 'Potential path traversal',
500
- severity: 'medium',
501
- pattern: /(?:\.\.\/|\.\.\\|path\.join|os\.path\.join)\s*\([^)]*(?:input|req|user|param|arg|query)/i,
502
- category: 'filesystem',
503
- },
504
- {
505
- id: 'CORS_WILDCARD',
506
- title: 'Wildcard CORS origin',
507
- severity: 'low',
508
- pattern: /(?:Access-Control-Allow-Origin|cors)\s*[:({]\s*['"]\*/i,
509
- category: 'network',
510
- },
511
- {
512
- id: 'TELEMETRY',
513
- title: 'Undisclosed telemetry',
514
- severity: 'low',
515
- pattern: /(?:posthog|mixpanel|analytics|telemetry|tracking|sentry).*(?:init|setup|track|capture)/i,
516
- category: 'privacy',
517
- },
518
- {
519
- id: 'SHELL_EXEC',
520
- title: 'Shell command execution',
521
- severity: 'high',
522
- pattern: /(?:subprocess\.(?:run|call|Popen)|os\.system|os\.popen|execSync|child_process\.exec)\s*\(/i,
523
- category: 'injection',
524
- },
525
- {
526
- id: 'SQL_INJECTION',
527
- title: 'Potential SQL injection',
528
- severity: 'high',
529
- pattern: /(?:execute|query|raw)\s*\(\s*(?:f['"]|['"].*?%s|['"].*?\{|['"].*?\+)/i,
530
- category: 'injection',
531
- },
532
- {
533
- id: 'YAML_UNSAFE',
534
- title: 'Unsafe YAML loading',
535
- severity: 'medium',
536
- pattern: /yaml\.(?:load|unsafe_load)\s*\(/i,
537
- category: 'deserialization',
538
- },
539
- {
540
- id: 'PICKLE_LOAD',
541
- title: 'Unsafe deserialization (pickle)',
542
- severity: 'high',
543
- pattern: /pickle\.loads?\s*\(/i,
544
- category: 'deserialization',
545
- },
546
- {
547
- id: 'PROMPT_INJECTION',
548
- title: 'Prompt injection vector',
549
- severity: 'high',
550
- pattern: /(?:<IMPORTANT>|<SYSTEM>|ignore previous|you are now|new instructions)/i,
551
- category: 'prompt-injection',
552
- },
553
- ];
554
-
555
- for (const file of files) {
556
- for (const check of checks) {
557
- const match = check.pattern.exec(file.content);
558
- if (match) {
559
- // Find line number
560
- const lines = file.content.slice(0, match.index).split('\n');
561
- findings.push({
562
- ...check,
563
- file: file.path,
564
- line: lines.length,
565
- snippet: match[0].trim().slice(0, 80),
566
- confidence: 'medium',
567
- });
568
- }
569
- }
570
- }
571
-
572
- return findings;
573
- }
574
-
575
- // ── Registry check ──────────────────────────────────────
576
-
577
- async function checkRegistry(slug) {
578
- try {
579
- const res = await fetch(`${REGISTRY_URL}/api/skills/${encodeURIComponent(slug)}`, {
580
- signal: AbortSignal.timeout(5000),
581
- });
582
- if (res.ok) return await res.json();
583
- } catch {}
584
- return null;
585
- }
586
-
587
- // ── Print results ───────────────────────────────────────
588
-
589
- function printScanResult(url, info, files, findings, registryData, duration) {
590
- if (jsonMode) return; // JSON mode handles output separately
591
-
592
- const slug = slugFromUrl(url);
593
-
594
- // Quiet mode: compact one-line-per-package output
595
- if (quietMode) {
596
- if (findings.length > 0) {
597
- const bySev = {};
598
- for (const f of findings) { bySev[f.severity] = (bySev[f.severity] || 0) + 1; }
599
- const sevStr = Object.entries(bySev).map(([s, n]) => {
600
- const sc = severityColor(s);
601
- return `${sc}${n} ${s}${c.reset}`;
602
- }).join(', ');
603
- console.log(`${icons.caution} ${c.bold}${slug}${c.reset} ${findings.length} findings (${sevStr}) ${c.dim}${duration}${c.reset}`);
604
- for (const f of findings) {
605
- const sc = severityColor(f.severity);
606
- console.log(` ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title} ${c.dim}${f.file}:${f.line}${c.reset}`);
607
- }
608
- } else {
609
- console.log(`${icons.safe} ${c.bold}${slug}${c.reset} ${c.green}clean${c.reset} ${c.dim}${files.length} files, ${duration}${c.reset}`);
610
- }
611
- return;
612
- }
613
-
614
- // Header
615
- console.log(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.dim}${url}${c.reset}`);
616
- console.log(`${icons.pipe} ${c.dim}${info.language} ${info.type}${c.reset} ${c.dim}${files.length} files scanned in ${duration}${c.reset}`);
617
-
618
- // Tools & prompts tree
619
- const items = [
620
- ...info.tools.map(t => ({ kind: 'tool', name: t })),
621
- ...info.prompts.map(p => ({ kind: 'prompt', name: p })),
622
- ];
623
-
624
- if (items.length > 0) {
625
- console.log(`${icons.pipe}`);
626
- for (let i = 0; i < items.length; i++) {
627
- const isLast = i === items.length - 1 && findings.length === 0;
628
- const branch = isLast ? icons.treeLast : icons.tree;
629
- const item = items[i];
630
- const kindLabel = item.kind === 'tool' ? `${c.dim}tool${c.reset} ` : `${c.dim}prompt${c.reset}`;
631
- const padName = item.name.padEnd(28);
632
-
633
- // Check if this tool has a finding associated
634
- const toolFinding = findings.find(f =>
635
- f.snippet && f.snippet.toLowerCase().includes(item.name.toLowerCase())
636
- );
637
-
638
- if (toolFinding) {
639
- const sc = severityColor(toolFinding.severity);
640
- console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${sc}⚠ flagged${c.reset} — ${toolFinding.title}`);
641
- } else {
642
- console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${c.green}✔ ok${c.reset}`);
643
- }
644
- }
645
- } else {
646
- console.log(`${icons.pipe} ${c.dim}(no tools or prompts detected)${c.reset}`);
647
- }
648
-
649
- // Findings
650
- if (findings.length > 0) {
651
- console.log(`${icons.pipe}`);
652
- console.log(`${icons.pipe} ${c.bold}Findings (${findings.length})${c.reset} ${c.dim}static analysis — may include false positives${c.reset}`);
653
- for (let i = 0; i < findings.length; i++) {
654
- const f = findings[i];
655
- const isLast = i === findings.length - 1;
656
- const branch = isLast ? icons.treeLast : icons.tree;
657
- const pipeOrSpace = isLast ? ' ' : `${icons.pipe} `;
658
- const sc = severityColor(f.severity);
659
- console.log(`${branch} ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
660
- console.log(`${pipeOrSpace} ${c.dim}${f.file}:${f.line}${c.reset} ${c.dim}${f.snippet || ''}${c.reset}`);
661
- }
662
- }
663
-
664
- // Registry status
665
- console.log(`${icons.pipe}`);
666
- if (registryData) {
667
- const rd = registryData;
668
- const riskScore = rd.risk_score ?? rd.latest_risk_score ?? 0;
669
- console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${riskBadge(riskScore)} Risk ${riskScore} ${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
670
- } else {
671
- console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${c.dim}not audited yet${c.reset}`);
672
- }
673
-
674
- console.log();
675
- }
676
-
677
- function printSummary(results) {
678
- const total = results.length;
679
- const safe = results.filter(r => r.findings.length === 0).length;
680
- const withFindings = total - safe;
681
- const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
682
-
683
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
684
- console.log(` ${c.bold}Summary${c.reset} ${total} packages scanned`);
685
- console.log();
686
- if (safe > 0) console.log(` ${icons.safe} ${c.green}${safe} clean${c.reset}`);
687
- if (withFindings > 0) console.log(` ${icons.caution} ${c.yellow}${withFindings} with findings${c.reset} (${totalFindings} total)`);
688
-
689
- // Breakdown by severity
690
- const bySev = {};
691
- results.forEach(r => r.findings.forEach(f => {
692
- bySev[f.severity] = (bySev[f.severity] || 0) + 1;
693
- }));
694
- if (Object.keys(bySev).length > 0) {
695
- console.log();
696
- for (const sev of ['critical', 'high', 'medium', 'low']) {
697
- if (bySev[sev]) {
698
- console.log(` ${severityIcon(sev)} ${bySev[sev]}× ${severityColor(sev)}${sev}${c.reset}`);
699
- }
700
- }
701
- }
702
-
703
- console.log();
704
- }
705
-
706
- // ── Clone & Scan ────────────────────────────────────────
707
-
708
- async function scanRepo(url) {
709
- const start = Date.now();
710
- const slug = slugFromUrl(url);
711
-
712
- if (!jsonMode) process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}...${c.reset}`);
713
-
714
- // Clone
715
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
716
- const repoPath = path.join(tmpDir, 'repo');
717
- try {
718
- execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
719
- timeout: 30_000,
720
- stdio: 'pipe',
721
- });
722
- } catch (err) {
723
- if (!jsonMode) {
724
- process.stdout.write(` ${c.red}✖ clone failed${c.reset}\n`);
725
- const msg = err.stderr?.toString().trim() || err.message?.split('\n')[0] || '';
726
- if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
727
- console.log(` ${c.dim}Make sure git is installed and the URL is accessible.${c.reset}`);
728
- }
729
- return null;
730
- }
731
-
732
- // Collect files
733
- const files = collectFiles(repoPath);
734
-
735
- // Detect info
736
- const info = detectPackageInfo(repoPath, files);
737
-
738
- // Quick checks
739
- const findings = quickChecks(files);
740
-
741
- // Registry lookup
742
- const registryData = await checkRegistry(slug);
743
-
744
- // Cleanup
745
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
746
-
747
- const duration = elapsed(start);
748
-
749
- if (!jsonMode) {
750
- // Clear the "Scanning..." line
751
- process.stdout.write('\r\x1b[K');
752
-
753
- // Print result
754
- printScanResult(url, info, files, findings, registryData, duration);
755
- }
756
-
757
- return { slug, url, info, files: files.length, findings, registryData, duration };
758
- }
759
-
760
- // ── Discover local MCP configs ──────────────────────────
761
-
762
- function findMcpConfigs() {
763
- const home = process.env.HOME || process.env.USERPROFILE || '';
764
- const platform = process.platform;
765
-
766
- // All known MCP config locations
767
- const candidates = [
768
- // Claude Desktop
769
- { name: 'Claude Desktop', path: path.join(home, '.claude', 'mcp.json') },
770
- { name: 'Claude Desktop', path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') },
771
- { name: 'Claude Desktop', path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') },
772
- { name: 'Claude Desktop', path: path.join(home, '.config', 'claude', 'claude_desktop_config.json') },
773
- // Cursor
774
- { name: 'Cursor', path: path.join(home, '.cursor', 'mcp.json') },
775
- // Windsurf / Codeium
776
- { name: 'Windsurf', path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
777
- // VS Code
778
- { name: 'VS Code', path: path.join(home, '.vscode', 'mcp.json') },
779
- // Continue.dev
780
- { name: 'Continue', path: path.join(home, '.continue', 'config.json') },
781
- ];
782
-
783
- // Also check AGENTAUDIT_TEST_CONFIG env for testing
784
- if (process.env.AGENTAUDIT_TEST_CONFIG) {
785
- candidates.push({ name: 'Test Config', path: process.env.AGENTAUDIT_TEST_CONFIG });
786
- }
787
-
788
- // Also scan workspace .cursor/mcp.json, .vscode/mcp.json in cwd
789
- const cwd = process.cwd();
790
- candidates.push(
791
- { name: 'Cursor (project)', path: path.join(cwd, '.cursor', 'mcp.json') },
792
- { name: 'VS Code (project)', path: path.join(cwd, '.vscode', 'mcp.json') },
793
- );
794
-
795
- const found = [];
796
- for (const c of candidates) {
797
- if (fs.existsSync(c.path)) {
798
- try {
799
- const content = JSON.parse(fs.readFileSync(c.path, 'utf8'));
800
- found.push({ ...c, content });
801
- } catch {}
802
- }
803
- }
804
- return found;
805
- }
806
-
807
- function extractServersFromConfig(config) {
808
- // Handle both { mcpServers: {...} } and { servers: {...} } formats
809
- const servers = config.mcpServers || config.servers || {};
810
- const result = [];
811
-
812
- for (const [name, serverConfig] of Object.entries(servers)) {
813
- const info = {
814
- name,
815
- command: serverConfig.command || null,
816
- args: serverConfig.args || [],
817
- url: serverConfig.url || null,
818
- sourceUrl: null,
819
- };
820
-
821
- // Try to extract source URL from args (common patterns)
822
- const allArgs = [info.command, ...info.args].filter(Boolean).join(' ');
823
-
824
- // npx package-name npm package
825
- const npxMatch = allArgs.match(/npx\s+(?:-y\s+)?(@?[a-z0-9][\w./-]*)/i);
826
- if (npxMatch) info.npmPackage = npxMatch[1];
827
-
828
- // node /path/to/something → try to find package.json
829
- const nodePathMatch = allArgs.match(/node\s+["']?([^"'\s]+)/);
830
- if (nodePathMatch) {
831
- const scriptPath = nodePathMatch[1];
832
- // Walk up to find package.json with repository
833
- let dir = path.dirname(path.resolve(scriptPath));
834
- for (let i = 0; i < 5; i++) {
835
- const pkgPath = path.join(dir, 'package.json');
836
- if (fs.existsSync(pkgPath)) {
837
- try {
838
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
839
- if (pkg.repository?.url) {
840
- info.sourceUrl = pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
841
- }
842
- if (pkg.name) info.npmPackage = pkg.name;
843
- } catch {}
844
- break;
845
- }
846
- const parent = path.dirname(dir);
847
- if (parent === dir) break;
848
- dir = parent;
849
- }
850
- }
851
-
852
- // python/uvx with package name
853
- const pyMatch = allArgs.match(/(?:uvx|pip run|python -m)\s+(@?[a-z0-9][\w./-]*)/i);
854
- if (pyMatch) info.pyPackage = pyMatch[1];
855
-
856
- // URL-based MCP server (remote HTTP)
857
- if (info.url && !info.npmPackage && !info.pyPackage) {
858
- try {
859
- const parsed = new URL(info.url);
860
- // Extract service name from hostname: mcp.supabase.com → supabase
861
- const hostParts = parsed.hostname.split('.');
862
- if (hostParts.length >= 2) {
863
- const serviceName = hostParts.length === 3 ? hostParts[1] : hostParts[0];
864
- info.remoteService = serviceName;
865
- }
866
- } catch {}
867
- }
868
-
869
- result.push(info);
870
- }
871
- return result;
872
- }
873
-
874
- function serverSlug(server) {
875
- // Try to derive a slug for registry lookup
876
- if (server.npmPackage) return server.npmPackage.replace(/^@/, '').replace(/\//g, '-');
877
- if (server.pyPackage) return server.pyPackage.replace(/[^a-z0-9-]/gi, '-');
878
- return server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
879
- }
880
-
881
- async function searchGitHub(query) {
882
- try {
883
- const res = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=1`, {
884
- signal: AbortSignal.timeout(5000),
885
- headers: { 'Accept': 'application/vnd.github+json' },
886
- });
887
- if (res.ok) {
888
- const data = await res.json();
889
- if (data.items?.length > 0) {
890
- return data.items[0].html_url;
891
- }
892
- }
893
- } catch {}
894
- return null;
895
- }
896
-
897
- async function resolveSourceUrl(server) {
898
- // Already have it
899
- if (server.sourceUrl) return server.sourceUrl;
900
-
901
- // Try npm registry
902
- if (server.npmPackage) {
903
- try {
904
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(server.npmPackage)}`, {
905
- signal: AbortSignal.timeout(5000),
906
- });
907
- if (res.ok) {
908
- const data = await res.json();
909
- let repoUrl = data.repository?.url;
910
- if (repoUrl) {
911
- repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
912
- if (repoUrl.startsWith('http')) return repoUrl;
913
- }
914
- }
915
- } catch {}
916
- // Fallback: try GitHub search for the package name
917
- const ghUrl = await searchGitHub(server.npmPackage);
918
- if (ghUrl) return ghUrl;
919
- return `https://www.npmjs.com/package/${server.npmPackage}`;
920
- }
921
-
922
- // Try PyPI
923
- if (server.pyPackage) {
924
- try {
925
- const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(server.pyPackage)}/json`, {
926
- signal: AbortSignal.timeout(5000),
927
- });
928
- if (res.ok) {
929
- const data = await res.json();
930
- const urls = data.info?.project_urls || {};
931
- const source = urls.Source || urls.Repository || urls.Homepage || urls['Source Code'] || data.info?.home_page;
932
- if (source && source.startsWith('http')) return source;
933
- }
934
- } catch {}
935
- // Fallback: GitHub search
936
- const ghUrl = await searchGitHub(server.pyPackage);
937
- if (ghUrl) return ghUrl;
938
- return `https://pypi.org/project/${server.pyPackage}/`;
939
- }
940
-
941
- // URL-based remote MCP server — try GitHub search by service name
942
- if (server.remoteService) {
943
- // Try npm registry with common MCP naming patterns
944
- for (const tryName of [
945
- `@${server.remoteService}/mcp-server-${server.remoteService}`,
946
- `${server.remoteService}-mcp`,
947
- `mcp-server-${server.remoteService}`,
948
- server.remoteService,
949
- ]) {
950
- try {
951
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(tryName)}`, {
952
- signal: AbortSignal.timeout(3000),
953
- });
954
- if (res.ok) {
955
- const data = await res.json();
956
- let repoUrl = data.repository?.url;
957
- if (repoUrl) {
958
- repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
959
- if (repoUrl.startsWith('http')) return repoUrl;
960
- }
961
- }
962
- } catch {}
963
- }
964
- }
965
-
966
- // Last resort: if server has a url, show it as context
967
- if (server.url) {
968
- try {
969
- const parsed = new URL(server.url);
970
- return `https://github.com/search?q=${encodeURIComponent(parsed.hostname + ' MCP')}&type=repositories`;
971
- } catch {}
972
- }
973
-
974
- return null;
975
- }
976
-
977
- async function discoverCommand(options = {}) {
978
- const autoScan = options.scan || false;
979
- const interactiveAudit = options.audit || false;
980
-
981
- if (!jsonMode) {
982
- console.log(` ${c.bold}Discovering MCP servers in your AI editors...${c.reset}`);
983
- console.log();
984
- }
985
-
986
- const configs = findMcpConfigs();
987
-
988
- if (configs.length === 0) {
989
- console.log(` ${c.yellow}No MCP configurations found.${c.reset}`);
990
- console.log(` ${c.dim}Searched: Claude Desktop, Cursor, Windsurf, VS Code${c.reset}`);
991
- console.log();
992
- console.log(` ${c.dim}MCP config locations:${c.reset}`);
993
- console.log(` ${c.dim} Claude: ~/.claude/mcp.json${c.reset}`);
994
- console.log(` ${c.dim} Cursor: ~/.cursor/mcp.json${c.reset}`);
995
- console.log(` ${c.dim} Windsurf: ~/.codeium/windsurf/mcp_config.json${c.reset}`);
996
- console.log(` ${c.dim} VS Code: ~/.vscode/mcp.json${c.reset}`);
997
- console.log();
998
- return;
999
- }
1000
-
1001
- let totalServers = 0;
1002
- let checkedServers = 0;
1003
- let auditedServers = 0;
1004
- let unauditedServers = 0;
1005
- const unauditedWithUrls = [];
1006
- const allServersWithUrls = []; // For --scan: all servers we can scan
1007
-
1008
- for (const config of configs) {
1009
- const servers = extractServersFromConfig(config.content);
1010
- const serverCount = servers.length;
1011
- totalServers += serverCount;
1012
-
1013
- const countLabel = serverCount === 0
1014
- ? `${c.dim}no servers${c.reset}`
1015
- : `found ${c.bold}${serverCount}${c.reset} server${serverCount > 1 ? 's' : ''}`;
1016
-
1017
- console.log(`${icons.bullet} Scanning ${c.bold}${config.name}${c.reset} ${c.dim}${config.path}${c.reset} ${countLabel}`);
1018
-
1019
- if (serverCount === 0) {
1020
- console.log();
1021
- continue;
1022
- }
1023
-
1024
- console.log();
1025
-
1026
- for (let i = 0; i < servers.length; i++) {
1027
- const server = servers[i];
1028
- const isLast = i === servers.length - 1;
1029
- const branch = isLast ? icons.treeLast : icons.tree;
1030
- const pipe = isLast ? ' ' : `${icons.pipe} `;
1031
-
1032
- const slug = serverSlug(server);
1033
- checkedServers++;
1034
-
1035
- // Registry lookup
1036
- const registryData = await checkRegistry(slug);
1037
-
1038
- // Also try with server name directly
1039
- let regData = registryData;
1040
- if (!regData && slug !== server.name.toLowerCase()) {
1041
- regData = await checkRegistry(server.name.toLowerCase());
1042
- }
1043
-
1044
- // Determine source display
1045
- let sourceLabel = '';
1046
- if (server.npmPackage) sourceLabel = `${c.dim}npm:${server.npmPackage}${c.reset}`;
1047
- else if (server.pyPackage) sourceLabel = `${c.dim}pip:${server.pyPackage}${c.reset}`;
1048
- else if (server.url) sourceLabel = `${c.dim}${server.url.length > 60 ? server.url.slice(0, 57) + '...' : server.url}${c.reset}`;
1049
- else if (server.command) sourceLabel = `${c.dim}${[server.command, ...server.args.slice(0, 2)].join(' ')}${c.reset}`;
1050
-
1051
- // Always resolve source URL (needed for --scan)
1052
- const resolvedUrl = await resolveSourceUrl(server);
1053
-
1054
- if (regData) {
1055
- auditedServers++;
1056
- const riskScore = regData.risk_score ?? regData.latest_risk_score ?? 0;
1057
- const hasOfficial = regData.has_official_audit;
1058
- console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1059
- console.log(`${pipe} ${riskBadge(riskScore)} Risk ${riskScore} ${hasOfficial ? `${c.green}✔ official${c.reset} ` : ''}${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
1060
- if (resolvedUrl) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: true, regData });
1061
- } else {
1062
- unauditedServers++;
1063
- console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1064
- if (resolvedUrl) {
1065
- console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Run: ${c.cyan}agentaudit audit ${resolvedUrl}${c.reset}`);
1066
- unauditedWithUrls.push({ name: server.name, sourceUrl: resolvedUrl });
1067
- allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: false });
1068
- } else {
1069
- console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Source URL unknown check the package's GitHub/npm page${c.reset}`);
1070
- }
1071
- }
1072
-
1073
- if (server.sourceUrl && !server.sourceUrl.includes('npmjs.com')) {
1074
- console.log(`${pipe} ${c.dim}source: ${server.sourceUrl}${c.reset}`);
1075
- }
1076
- }
1077
-
1078
- console.log();
1079
- }
1080
-
1081
- // Summary
1082
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1083
- console.log(` ${c.bold}Summary${c.reset} ${totalServers} server${totalServers !== 1 ? 's' : ''} across ${configs.length} config${configs.length !== 1 ? 's' : ''}`);
1084
- console.log();
1085
- if (auditedServers > 0) console.log(` ${icons.safe} ${c.green}${auditedServers} audited${c.reset}`);
1086
- if (unauditedServers > 0) console.log(` ${icons.caution} ${c.yellow}${unauditedServers} not audited${c.reset}`);
1087
- console.log();
1088
-
1089
- // --scan: automatically scan all servers with resolved source URLs (git-cloneable only)
1090
- if (autoScan) {
1091
- const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1092
- const scanTargets = allServersWithUrls.filter(s => s.sourceUrl && isCloneable(s.sourceUrl));
1093
- // Deduplicate by sourceUrl
1094
- const seen = new Set();
1095
- const dedupedTargets = scanTargets.filter(s => {
1096
- if (seen.has(s.sourceUrl)) return false;
1097
- seen.add(s.sourceUrl);
1098
- return true;
1099
- });
1100
- const skipped = allServersWithUrls.filter(s => s.sourceUrl && !isCloneable(s.sourceUrl));
1101
- if (dedupedTargets.length > 0) {
1102
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1103
- console.log(` ${c.bold}${icons.scan} Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}...${c.reset}`);
1104
- if (skipped.length > 0) {
1105
- console.log(` ${c.dim}(${skipped.length} skipped — no cloneable source URL)${c.reset}`);
1106
- }
1107
- console.log();
1108
-
1109
- const scanResults = [];
1110
- for (const target of dedupedTargets) {
1111
- const result = await scanRepo(target.sourceUrl);
1112
- if (result) scanResults.push({ ...result, serverName: target.name });
1113
- }
1114
-
1115
- if (scanResults.length > 1) {
1116
- // Print combined scan summary
1117
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1118
- console.log(` ${c.bold}Scan Summary${c.reset} ${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} scanned`);
1119
- console.log();
1120
-
1121
- let totalFindings = 0;
1122
- let serversWithFindings = 0;
1123
-
1124
- for (const r of scanResults) {
1125
- const findingCount = r.findings ? r.findings.length : 0;
1126
- totalFindings += findingCount;
1127
- if (findingCount > 0) serversWithFindings++;
1128
-
1129
- const status = findingCount === 0
1130
- ? `${icons.safe} ${c.green}clean${c.reset}`
1131
- : `${icons.caution} ${c.yellow}${findingCount} finding${findingCount !== 1 ? 's' : ''}${c.reset}`;
1132
- console.log(` ${status} ${c.bold}${r.serverName || r.slug}${c.reset} ${c.dim}(${r.duration})${c.reset}`);
1133
- }
1134
-
1135
- console.log();
1136
- if (serversWithFindings > 0) {
1137
- console.log(` ${c.yellow}${serversWithFindings}/${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} with findings (${totalFindings} total)${c.reset}`);
1138
- console.log(` ${c.dim}Run ${c.cyan}agentaudit scan <url> --deep${c.dim} for deep LLM analysis on flagged servers${c.reset}`);
1139
- } else {
1140
- console.log(` ${c.green}All servers passed quick scan${c.reset}`);
1141
- console.log(` ${c.dim}Run ${c.cyan}agentaudit scan <url> --deep${c.dim} for thorough LLM-powered analysis${c.reset}`);
1142
- }
1143
- console.log();
1144
- }
1145
- } else {
1146
- console.log(` ${c.dim}No scannable source URLs found.${c.reset}`);
1147
- console.log();
1148
- }
1149
- } else if (interactiveAudit && allServersWithUrls.length > 0) {
1150
- // Interactive multi-select for audit
1151
- const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1152
- const auditCandidates = [];
1153
- const seen = new Set();
1154
- for (const s of allServersWithUrls) {
1155
- if (!s.sourceUrl || !isCloneable(s.sourceUrl)) continue;
1156
- if (seen.has(s.sourceUrl)) continue;
1157
- seen.add(s.sourceUrl);
1158
- auditCandidates.push(s);
1159
- }
1160
-
1161
- if (auditCandidates.length > 0) {
1162
- console.log();
1163
- const items = auditCandidates.map(s => ({
1164
- label: s.name,
1165
- sublabel: s.hasAudit ? `${c.green}✔ audited${c.reset} ${s.sourceUrl}` : s.sourceUrl,
1166
- value: s,
1167
- checked: !s.hasAudit, // Pre-select unaudited
1168
- }));
1169
-
1170
- const selected = await multiSelect(items, {
1171
- title: 'Select servers to audit',
1172
- hint: 'Space=toggle ↑↓=move a=all n=none Enter=confirm',
1173
- });
1174
-
1175
- if (selected.length > 0) {
1176
- console.log();
1177
- console.log(` ${c.bold}Auditing ${selected.length} server${selected.length !== 1 ? 's' : ''}...${c.reset}`);
1178
- console.log();
1179
- for (const s of selected) {
1180
- await auditRepo(s.sourceUrl);
1181
- console.log();
1182
- }
1183
- } else {
1184
- console.log();
1185
- console.log(` ${c.dim}No servers selected.${c.reset}`);
1186
- }
1187
- }
1188
- } else if (unauditedServers > 0) {
1189
- if (unauditedWithUrls.length > 0) {
1190
- console.log(` ${c.dim}To audit unaudited servers:${c.reset}`);
1191
- for (const { name, sourceUrl } of unauditedWithUrls) {
1192
- console.log(` ${c.cyan}agentaudit audit ${sourceUrl}${c.reset} ${c.dim}(${name})${c.reset}`);
1193
- }
1194
- } else {
1195
- console.log(` ${c.dim}To audit unaudited servers, run:${c.reset}`);
1196
- console.log(` ${c.cyan}agentaudit audit <source-url>${c.reset}`);
1197
- }
1198
- console.log();
1199
- console.log(` ${c.dim}Or run ${c.cyan}agentaudit discover --quick${c.dim} to quick-scan all servers${c.reset}`);
1200
- console.log(` ${c.dim}Or run ${c.cyan}agentaudit discover --deep${c.dim} to select & deep-audit interactively${c.reset}`);
1201
- console.log();
1202
- }
1203
-
1204
- if (!autoScan && !interactiveAudit && !jsonMode) {
1205
- console.log(` ${c.dim}Looking for general package scanning? Try ${c.cyan}pip audit${c.dim} or ${c.cyan}npm audit${c.dim}.${c.reset}`);
1206
- console.log();
1207
- }
1208
- }
1209
-
1210
- // ── Audit command (deep LLM-powered) ────────────────────
1211
-
1212
- function loadAuditPrompt() {
1213
- const promptPath = path.join(SKILL_DIR, 'prompts', 'audit-prompt.md');
1214
- if (fs.existsSync(promptPath)) return fs.readFileSync(promptPath, 'utf8');
1215
- return null;
1216
- }
1217
-
1218
- async function auditRepo(url) {
1219
- const start = Date.now();
1220
- const slug = slugFromUrl(url);
1221
-
1222
- console.log(`${icons.scan} ${c.bold}Auditing ${slug}${c.reset} ${c.dim}${url}${c.reset}`);
1223
- console.log(`${icons.pipe} ${c.dim}Deep LLM-powered analysis (3-pass: UNDERSTAND → DETECT → CLASSIFY)${c.reset}`);
1224
- console.log();
1225
-
1226
- // Step 1: Clone
1227
- process.stdout.write(` ${c.dim}[1/4]${c.reset} Cloning repository...`);
1228
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
1229
- const repoPath = path.join(tmpDir, 'repo');
1230
- try {
1231
- execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
1232
- timeout: 30_000, stdio: 'pipe',
1233
- });
1234
- console.log(` ${c.green}done${c.reset}`);
1235
- } catch (err) {
1236
- console.log(` ${c.red}failed${c.reset}`);
1237
- const msg = err.stderr?.toString().trim() || err.message?.split('\n')[0] || '';
1238
- if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
1239
- console.log(` ${c.dim}Make sure git is installed and the URL is accessible.${c.reset}`);
1240
- return null;
1241
- }
1242
-
1243
- // Step 2: Collect files
1244
- process.stdout.write(` ${c.dim}[2/4]${c.reset} Collecting source files...`);
1245
- const files = collectFiles(repoPath);
1246
- console.log(` ${c.green}${files.length} files${c.reset}`);
1247
-
1248
- // Step 3: Build audit payload
1249
- process.stdout.write(` ${c.dim}[3/4]${c.reset} Preparing audit payload...`);
1250
- const auditPrompt = loadAuditPrompt();
1251
-
1252
- let codeBlock = '';
1253
- for (const file of files) {
1254
- codeBlock += `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
1255
- }
1256
- console.log(` ${c.green}done${c.reset}`);
1257
-
1258
- // Step 4: LLM Analysis
1259
- // Check for API keys to determine which LLM to use
1260
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
1261
- const openaiKey = process.env.OPENAI_API_KEY;
1262
-
1263
- if (!anthropicKey && !openaiKey) {
1264
- // No LLM API key clear explanation
1265
- console.log();
1266
- console.log(` ${c.yellow}No LLM API key found.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
1267
- console.log();
1268
- console.log(` ${c.bold}Option 1: Set an API key${c.reset}`);
1269
- console.log(` Supported keys: ${c.cyan}ANTHROPIC_API_KEY${c.reset} or ${c.cyan}OPENAI_API_KEY${c.reset}`);
1270
- console.log();
1271
- console.log(` ${c.dim}# Linux / macOS:${c.reset}`);
1272
- console.log(` ${c.dim}export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1273
- console.log(` ${c.dim}export OPENAI_API_KEY=sk-...${c.reset}`);
1274
- console.log();
1275
- console.log(` ${c.dim}# Windows (PowerShell):${c.reset}`);
1276
- console.log(` ${c.dim}$env:ANTHROPIC_API_KEY = "sk-ant-..."${c.reset}`);
1277
- console.log(` ${c.dim}$env:OPENAI_API_KEY = "sk-..."${c.reset}`);
1278
- console.log();
1279
- console.log(` ${c.dim}# Windows (CMD):${c.reset}`);
1280
- console.log(` ${c.dim}set ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1281
- console.log(` ${c.dim}set OPENAI_API_KEY=sk-...${c.reset}`);
1282
- console.log();
1283
- console.log(` ${c.bold}Option 2: Export for manual review${c.reset}`);
1284
- console.log(` ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
1285
- console.log(` ${c.dim}Creates a markdown file you can paste into any LLM (Claude, ChatGPT, etc.)${c.reset}`);
1286
- console.log();
1287
- console.log(` ${c.bold}Option 3: Use MCP in Claude/Cursor/Windsurf (no API key needed)${c.reset}`);
1288
- console.log(` ${c.dim}Add AgentAudit as MCP server — your editor's agent runs the audit using its own LLM.${c.reset}`);
1289
- console.log(` ${c.dim}Config: { "mcpServers": { "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } } }${c.reset}`);
1290
- console.log();
1291
-
1292
- // Check if --export flag
1293
- if (process.argv.includes('--export')) {
1294
- const exportPath = path.join(process.cwd(), `audit-${slug}.md`);
1295
- const exportContent = [
1296
- `# Security Audit: ${slug}`,
1297
- `**Source:** ${url}`,
1298
- `**Files:** ${files.length}`,
1299
- ``,
1300
- `## Audit Instructions`,
1301
- ``,
1302
- auditPrompt || '(audit prompt not found)',
1303
- ``,
1304
- `## Report Format`,
1305
- ``,
1306
- `After analysis, produce a JSON report:`,
1307
- '```json',
1308
- `{ "skill_slug": "${slug}", "source_url": "${url}", "risk_score": 0, "result": "safe", "findings": [] }`,
1309
- '```',
1310
- ``,
1311
- `## Source Code`,
1312
- ``,
1313
- codeBlock,
1314
- ].join('\n');
1315
- fs.writeFileSync(exportPath, exportContent);
1316
- console.log(` ${icons.safe} Exported to ${c.bold}${exportPath}${c.reset}`);
1317
- console.log(` ${c.dim}Paste this into any LLM (Claude, ChatGPT, etc.) for analysis${c.reset}`);
1318
- }
1319
-
1320
- // Cleanup
1321
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1322
- return null;
1323
- }
1324
-
1325
- // We have an API key — run LLM audit
1326
- process.stdout.write(` ${c.dim}[4/4]${c.reset} Running LLM analysis...`);
1327
-
1328
- const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
1329
- const userMessage = [
1330
- `Audit this package: **${slug}** (${url})`,
1331
- ``,
1332
- `After analysis, respond with ONLY a JSON object (no markdown, no explanation):`,
1333
- '```',
1334
- `{ "skill_slug": "${slug}", "source_url": "${url}", "package_type": "<mcp-server|agent-skill|library|cli-tool>",`,
1335
- ` "risk_score": <0-100>, "result": "<safe|caution|unsafe>", "max_severity": "<none|low|medium|high|critical>",`,
1336
- ` "findings_count": <n>, "findings": [{ "id": "...", "title": "...", "severity": "...", "category": "...",`,
1337
- ` "description": "...", "file": "...", "line": <n>, "remediation": "...", "confidence": "...", "is_by_design": false }] }`,
1338
- '```',
1339
- ``,
1340
- `## Source Code`,
1341
- codeBlock,
1342
- ].join('\n');
1343
-
1344
- let report = null;
1345
-
1346
- try {
1347
- if (anthropicKey) {
1348
- const res = await fetch('https://api.anthropic.com/v1/messages', {
1349
- method: 'POST',
1350
- headers: {
1351
- 'x-api-key': anthropicKey,
1352
- 'anthropic-version': '2023-06-01',
1353
- 'content-type': 'application/json',
1354
- },
1355
- body: JSON.stringify({
1356
- model: 'claude-sonnet-4-20250514',
1357
- max_tokens: 8192,
1358
- system: systemPrompt,
1359
- messages: [{ role: 'user', content: userMessage }],
1360
- }),
1361
- signal: AbortSignal.timeout(120_000),
1362
- });
1363
- const data = await res.json();
1364
- const text = data.content?.[0]?.text || '';
1365
- // Extract JSON from response
1366
- const jsonMatch = text.match(/\{[\s\S]*\}/);
1367
- if (jsonMatch) report = JSON.parse(jsonMatch[0]);
1368
- } else if (openaiKey) {
1369
- const res = await fetch('https://api.openai.com/v1/chat/completions', {
1370
- method: 'POST',
1371
- headers: {
1372
- 'Authorization': `Bearer ${openaiKey}`,
1373
- 'Content-Type': 'application/json',
1374
- },
1375
- body: JSON.stringify({
1376
- model: 'gpt-4o',
1377
- max_tokens: 8192,
1378
- messages: [
1379
- { role: 'system', content: systemPrompt },
1380
- { role: 'user', content: userMessage },
1381
- ],
1382
- }),
1383
- signal: AbortSignal.timeout(120_000),
1384
- });
1385
- const data = await res.json();
1386
- const text = data.choices?.[0]?.message?.content || '';
1387
- const jsonMatch = text.match(/\{[\s\S]*\}/);
1388
- if (jsonMatch) report = JSON.parse(jsonMatch[0]);
1389
- }
1390
-
1391
- console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
1392
- } catch (err) {
1393
- console.log(` ${c.red}failed${c.reset}`);
1394
- console.log(` ${c.red}${err.message}${c.reset}`);
1395
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1396
- return null;
1397
- }
1398
-
1399
- // Cleanup repo
1400
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1401
-
1402
- if (!report) {
1403
- console.log(` ${c.red}Could not parse LLM response as JSON${c.reset}`);
1404
- return null;
1405
- }
1406
-
1407
- // Display results
1408
- console.log();
1409
- const riskScore = report.risk_score || 0;
1410
- console.log(` ${riskBadge(riskScore)} Risk ${riskScore}/100 ${c.bold}${report.result || 'unknown'}${c.reset}`);
1411
- console.log();
1412
-
1413
- if (report.findings && report.findings.length > 0) {
1414
- console.log(` ${c.bold}Findings (${report.findings.length})${c.reset}`);
1415
- console.log();
1416
- for (const f of report.findings) {
1417
- const sc = severityColor(f.severity);
1418
- console.log(` ${severityIcon(f.severity)} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
1419
- if (f.file) console.log(` ${c.dim}${f.file}${f.line ? ':' + f.line : ''}${c.reset}`);
1420
- if (f.description) console.log(` ${c.dim}${f.description.slice(0, 120)}${c.reset}`);
1421
- console.log();
1422
- }
1423
- } else {
1424
- console.log(` ${c.green}No findings — package looks clean.${c.reset}`);
1425
- console.log();
1426
- }
1427
-
1428
- // Upload to registry
1429
- const creds = loadCredentials();
1430
- if (creds) {
1431
- process.stdout.write(` Uploading report to registry...`);
1432
- try {
1433
- const res = await fetch(`${REGISTRY_URL}/api/reports`, {
1434
- method: 'POST',
1435
- headers: {
1436
- 'Authorization': `Bearer ${creds.api_key}`,
1437
- 'Content-Type': 'application/json',
1438
- },
1439
- body: JSON.stringify(report),
1440
- signal: AbortSignal.timeout(15_000),
1441
- });
1442
- if (res.ok) {
1443
- const data = await res.json();
1444
- console.log(` ${c.green}done${c.reset}`);
1445
- console.log(` ${c.dim}Report: ${REGISTRY_URL}/skills/${slug}${c.reset}`);
1446
- } else {
1447
- console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
1448
- }
1449
- } catch (err) {
1450
- console.log(` ${c.yellow}failed${c.reset}`);
1451
- }
1452
- } else {
1453
- console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to upload reports to the registry${c.reset}`);
1454
- }
1455
-
1456
- console.log();
1457
- return report;
1458
- }
1459
-
1460
- // ── Check command ───────────────────────────────────────
1461
-
1462
- async function checkPackage(name) {
1463
- if (!jsonMode) {
1464
- console.log(`${icons.info} Looking up ${c.bold}${name}${c.reset} in registry...`);
1465
- console.log();
1466
- }
1467
-
1468
- const data = await checkRegistry(name);
1469
- if (!data) {
1470
- if (!jsonMode) {
1471
- console.log(` ${c.yellow}Not found${c.reset} — package "${name}" hasn't been audited yet.`);
1472
- console.log(` ${c.dim}Run: agentaudit audit <repo-url> for a deep LLM audit${c.reset}`);
1473
- }
1474
- return null;
1475
- }
1476
-
1477
- if (!jsonMode) {
1478
- const riskScore = data.risk_score ?? data.latest_risk_score ?? 0;
1479
- console.log(` ${c.bold}${name}${c.reset} ${riskBadge(riskScore)}`);
1480
- console.log(` ${c.dim}Risk Score: ${riskScore}/100${c.reset}`);
1481
- if (data.source_url) console.log(` ${c.dim}Source: ${data.source_url}${c.reset}`);
1482
- console.log(` ${c.dim}Registry: ${REGISTRY_URL}/skills/${name}${c.reset}`);
1483
- if (data.has_official_audit) console.log(` ${c.green} Officially audited${c.reset}`);
1484
- console.log();
1485
- }
1486
- return data;
1487
- }
1488
-
1489
- // ── Main ────────────────────────────────────────────────
1490
-
1491
- async function main() {
1492
- const rawArgs = process.argv.slice(2);
1493
-
1494
- // MCP server mode: launched by an editor (no TTY + no args) or explicit --stdio flag
1495
- if (rawArgs.includes('--stdio') || (!process.stdin.isTTY && rawArgs.length === 0)) {
1496
- await import('./index.mjs');
1497
- return;
1498
- }
1499
-
1500
- // Parse global flags early
1501
- jsonMode = rawArgs.includes('--json');
1502
- quietMode = rawArgs.includes('--quiet') || rawArgs.includes('-q');
1503
- // --no-color already handled at top level for `c` object
1504
-
1505
- // Strip global flags from args
1506
- const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color']);
1507
- const args = rawArgs.filter(a => !globalFlags.has(a));
1508
-
1509
- if (args[0] === '-v' || args[0] === '--version') {
1510
- console.log(`agentaudit ${getVersion()}`);
1511
- process.exitCode = 0; return;
1512
- }
1513
-
1514
- if (args[0] === '--help' || args[0] === '-h') {
1515
- banner();
1516
- console.log(` ${c.bold}Commands:${c.reset}`);
1517
- console.log();
1518
- console.log(` ${c.cyan}agentaudit${c.reset} Discover MCP servers (same as discover)`);
1519
- console.log(` ${c.cyan}agentaudit discover${c.reset} Find MCP servers in your AI editors (Cursor, Claude, VS Code, Windsurf)`);
1520
- console.log(` ${c.cyan}agentaudit discover --quick${c.reset} Discover + auto-scan all servers`);
1521
- console.log(` ${c.cyan}agentaudit discover --deep${c.reset} Discover + select servers to deep-audit`);
1522
- console.log(` ${c.cyan}agentaudit scan${c.reset} <url> [url...] Quick static scan (regex, local)`);
1523
- console.log(` ${c.cyan}agentaudit scan${c.reset} <url> ${c.dim}--deep${c.reset} Deep audit (same as audit)`);
1524
- console.log(` ${c.cyan}agentaudit audit${c.reset} <url> [url...] Deep LLM-powered security audit`);
1525
- console.log(` ${c.cyan}agentaudit lookup${c.reset} <name> Look up package in registry`);
1526
- console.log(` ${c.cyan}agentaudit setup${c.reset} Register + configure API key`);
1527
- console.log();
1528
- console.log(` ${c.bold}Global flags:${c.reset}`);
1529
- console.log(` ${c.dim}--json Output JSON to stdout (machine-readable)${c.reset}`);
1530
- console.log(` ${c.dim}--quiet Suppress banner and tree visualization${c.reset}`);
1531
- console.log(` ${c.dim}--no-color Disable ANSI colors (also: NO_COLOR env)${c.reset}`);
1532
- console.log();
1533
- console.log(` ${c.bold}Quick Scan${c.reset} vs ${c.bold}Deep Audit${c.reset}:`);
1534
- console.log(` ${c.dim}scan = fast regex-based static analysis (~2s)${c.reset}`);
1535
- console.log(` ${c.dim}audit = deep LLM analysis with 3-pass methodology (~30s)${c.reset}`);
1536
- console.log();
1537
- console.log(` ${c.bold}Exit codes:${c.reset}`);
1538
- console.log(` ${c.dim}0 = clean / success 1 = findings detected 2 = error${c.reset}`);
1539
- console.log();
1540
- console.log(` ${c.bold}Examples:${c.reset}`);
1541
- console.log(` agentaudit`);
1542
- console.log(` agentaudit discover --quick`);
1543
- console.log(` agentaudit scan https://github.com/owner/repo`);
1544
- console.log(` agentaudit audit https://github.com/owner/repo`);
1545
- console.log(` agentaudit lookup fastmcp --json`);
1546
- console.log();
1547
- console.log(` ${c.bold}For deep audits,${c.reset} set an LLM API key:`);
1548
- if (process.platform === 'win32') {
1549
- console.log(` ${c.dim}PowerShell: $env:ANTHROPIC_API_KEY = "sk-ant-..."${c.reset}`);
1550
- console.log(` ${c.dim}CMD: set ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1551
- console.log(` ${c.dim}(or use OPENAI_API_KEY instead)${c.reset}`);
1552
- } else {
1553
- console.log(` ${c.dim}export ANTHROPIC_API_KEY=sk-ant-...${c.reset} ${c.dim}(or OPENAI_API_KEY)${c.reset}`);
1554
- }
1555
- console.log();
1556
- console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed):${c.reset}`);
1557
- console.log(` ${c.dim}Add to your MCP config:${c.reset}`);
1558
- console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
1559
- console.log();
1560
- process.exitCode = 0; return;
1561
- }
1562
-
1563
- // Default no-arg → discover
1564
- const command = args.length === 0 ? 'discover' : args[0];
1565
- const targets = args.slice(1);
1566
-
1567
- banner();
1568
-
1569
- if (command === 'setup') {
1570
- await setupCommand();
1571
- return;
1572
- }
1573
-
1574
- if (command === 'discover') {
1575
- const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
1576
- const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
1577
- await discoverCommand({ scan: scanFlag, audit: auditFlag });
1578
- return;
1579
- }
1580
-
1581
- if (command === 'lookup' || command === 'check') {
1582
- const names = targets.filter(t => !t.startsWith('--'));
1583
- if (names.length === 0) {
1584
- console.log(` ${c.red}Error: package name required${c.reset}`);
1585
- process.exitCode = 2;
1586
- return;
1587
- }
1588
- const results = [];
1589
- for (const t of names) {
1590
- const data = await checkPackage(t);
1591
- results.push(data);
1592
- }
1593
- if (jsonMode) {
1594
- console.log(JSON.stringify(results.length === 1 ? (results[0] || { error: 'not_found' }) : results, null, 2));
1595
- }
1596
- process.exitCode = 0; return;
1597
- return;
1598
- }
1599
-
1600
- if (command === 'scan') {
1601
- const deepFlag = targets.includes('--deep');
1602
- const urls = targets.filter(t => !t.startsWith('--'));
1603
- if (urls.length === 0) {
1604
- console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
1605
- console.log(` ${c.dim}Tip: use ${c.cyan}agentaudit discover${c.dim} to find & check locally installed MCP servers${c.reset}`);
1606
- console.log(` ${c.dim}Tip: use ${c.cyan}agentaudit audit <url>${c.dim} for a deep LLM-powered audit${c.reset}`);
1607
- process.exitCode = 2;
1608
- return;
1609
- }
1610
-
1611
- // --deep redirects to audit flow
1612
- if (deepFlag) {
1613
- let hasFindings = false;
1614
- for (const url of urls) {
1615
- const report = await auditRepo(url);
1616
- if (report?.findings?.length > 0) hasFindings = true;
1617
- }
1618
- process.exitCode = hasFindings ? 1 : 0;
1619
- return;
1620
- }
1621
-
1622
- const results = [];
1623
- let hadErrors = false;
1624
- for (const url of urls) {
1625
- const result = await scanRepo(url);
1626
- if (result) results.push(result);
1627
- else hadErrors = true;
1628
- }
1629
-
1630
- if (jsonMode) {
1631
- const jsonOut = results.map(r => ({
1632
- slug: r.slug,
1633
- url: r.url,
1634
- findings: r.findings.map(f => ({
1635
- severity: f.severity,
1636
- title: f.title,
1637
- file: f.file,
1638
- line: f.line,
1639
- snippet: f.snippet,
1640
- })),
1641
- fileCount: r.files,
1642
- duration: r.duration,
1643
- }));
1644
- console.log(JSON.stringify(jsonOut.length === 1 ? jsonOut[0] : jsonOut, null, 2));
1645
- } else if (results.length > 1) {
1646
- printSummary(results);
1647
- }
1648
-
1649
- if (hadErrors && results.length === 0) { process.exitCode = 2; return; }
1650
- const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
1651
- process.exitCode = totalFindings > 0 ? 1 : 0;
1652
- return;
1653
- }
1654
-
1655
- if (command === 'audit') {
1656
- const urls = targets.filter(t => !t.startsWith('--'));
1657
- if (urls.length === 0) {
1658
- console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
1659
- process.exitCode = 2;
1660
- return;
1661
- }
1662
-
1663
- let hasFindings = false;
1664
- for (const url of urls) {
1665
- const report = await auditRepo(url);
1666
- if (report?.findings?.length > 0) hasFindings = true;
1667
- }
1668
- process.exitCode = hasFindings ? 1 : 0;
1669
- return;
1670
- }
1671
-
1672
- console.log(` ${c.red}Unknown command: ${command}${c.reset}`);
1673
- console.log(` ${c.dim}Run agentaudit --help for usage${c.reset}`);
1674
- process.exitCode = 2;
1675
- }
1676
-
1677
- main().catch(err => {
1678
- console.error(`${c.red}Error: ${err.message}${c.reset}`);
1679
- process.exitCode = 2;
1680
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AgentAudit CLI — Security scanner for AI packages
4
+ *
5
+ * Usage:
6
+ * agentaudit Discover local MCP servers
7
+ * agentaudit discover [--quick|--deep] Find MCP servers in AI editors
8
+ * agentaudit scan <repo-url> [--deep] Quick scan (or deep audit with --deep)
9
+ * agentaudit audit <repo-url> Deep LLM-powered security audit
10
+ * agentaudit lookup <name> Look up package in registry
11
+ * agentaudit setup Register + configure API key
12
+ *
13
+ * Global flags: --json, --quiet, --no-color
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import os from 'os';
18
+ import path from 'path';
19
+ import { execSync } from 'child_process';
20
+ import { createInterface } from 'readline';
21
+ import { fileURLToPath } from 'url';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const SKILL_DIR = path.resolve(__dirname);
25
+ const REGISTRY_URL = 'https://agentaudit.dev';
26
+
27
+ // ── Global flags (set in main before command routing) ────
28
+ let jsonMode = false;
29
+ let quietMode = false;
30
+
31
+ // ── ANSI Colors (respects NO_COLOR and --no-color) ───────
32
+
33
+ const noColor = !!(process.env.NO_COLOR || process.argv.includes('--no-color'));
34
+
35
+ const c = noColor ? {
36
+ reset: '', bold: '', dim: '', red: '', green: '', yellow: '',
37
+ blue: '', magenta: '', cyan: '', white: '', gray: '',
38
+ bgRed: '', bgGreen: '', bgYellow: '',
39
+ } : {
40
+ reset: '\x1b[0m',
41
+ bold: '\x1b[1m',
42
+ dim: '\x1b[2m',
43
+ red: '\x1b[31m',
44
+ green: '\x1b[32m',
45
+ yellow: '\x1b[33m',
46
+ blue: '\x1b[34m',
47
+ magenta: '\x1b[35m',
48
+ cyan: '\x1b[36m',
49
+ white: '\x1b[37m',
50
+ gray: '\x1b[90m',
51
+ bgRed: '\x1b[41m',
52
+ bgGreen: '\x1b[42m',
53
+ bgYellow: '\x1b[43m',
54
+ };
55
+
56
+ const icons = {
57
+ safe: `${c.green}✔${c.reset}`,
58
+ caution: `${c.yellow}⚠${c.reset}`,
59
+ unsafe: `${c.red}✖${c.reset}`,
60
+ info: `${c.blue}ℹ${c.reset}`,
61
+ scan: `${c.cyan}◉${c.reset}`,
62
+ tree: `${c.gray}├──${c.reset}`,
63
+ treeLast: `${c.gray}└──${c.reset}`,
64
+ pipe: `${c.gray}│${c.reset}`,
65
+ bullet: `${c.gray}•${c.reset}`,
66
+ };
67
+
68
+ // ── Credentials ─────────────────────────────────────────
69
+
70
+ const home = process.env.HOME || process.env.USERPROFILE || '';
71
+ const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
72
+ const USER_CRED_DIR = path.join(xdgConfig, 'agentaudit');
73
+ const USER_CRED_FILE = path.join(USER_CRED_DIR, 'credentials.json');
74
+ const SKILL_CRED_FILE = path.join(SKILL_DIR, 'config', 'credentials.json');
75
+
76
+ function loadCredentials() {
77
+ for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
78
+ if (fs.existsSync(f)) {
79
+ try {
80
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
81
+ if (data.api_key) return data;
82
+ } catch {}
83
+ }
84
+ }
85
+ if (process.env.AGENTAUDIT_API_KEY) {
86
+ return { api_key: process.env.AGENTAUDIT_API_KEY, agent_name: 'env' };
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function saveCredentials(data) {
92
+ const json = JSON.stringify(data, null, 2);
93
+ fs.mkdirSync(USER_CRED_DIR, { recursive: true });
94
+ fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
95
+ try {
96
+ fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
97
+ fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
98
+ } catch {}
99
+ }
100
+
101
+ function askQuestion(question) {
102
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
103
+ return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim()); }));
104
+ }
105
+
106
+ /**
107
+ * Interactive multi-select in terminal. No dependencies.
108
+ * items: [{ label, sublabel?, value, checked? }]
109
+ * Returns: array of selected values
110
+ */
111
+ function multiSelect(items, { title = 'Select items', hint = 'Space=toggle ↑↓=move a=all n=none Enter=confirm' } = {}) {
112
+ return new Promise((resolve) => {
113
+ if (!process.stdin.isTTY) {
114
+ // Non-interactive: return all items
115
+ resolve(items.map(i => i.value));
116
+ return;
117
+ }
118
+
119
+ const selected = new Set(items.filter(i => i.checked).map((_, idx) => idx));
120
+ let cursor = 0;
121
+
122
+ const render = () => {
123
+ // Move cursor up to overwrite previous render
124
+ process.stdout.write(`\x1b[${items.length + 3}A\x1b[J`);
125
+ draw();
126
+ };
127
+
128
+ const draw = () => {
129
+ console.log(` ${c.bold}${title}${c.reset} ${c.dim}(${selected.size}/${items.length} selected)${c.reset}`);
130
+ console.log(` ${c.dim}${hint}${c.reset}`);
131
+ console.log();
132
+ for (let i = 0; i < items.length; i++) {
133
+ const item = items[i];
134
+ const isCursor = i === cursor;
135
+ const isSelected = selected.has(i);
136
+ const pointer = isCursor ? `${c.cyan}❯${c.reset}` : ' ';
137
+ const checkbox = isSelected ? `${c.green}◉${c.reset}` : `${c.dim}○${c.reset}`;
138
+ const label = isCursor ? `${c.bold}${item.label}${c.reset}` : item.label;
139
+ const sub = item.sublabel ? ` ${c.dim}${item.sublabel}${c.reset}` : '';
140
+ console.log(` ${pointer} ${checkbox} ${label}${sub}`);
141
+ }
142
+ };
143
+
144
+ // Initial draw
145
+ draw();
146
+
147
+ process.stdin.setRawMode(true);
148
+ process.stdin.resume();
149
+ process.stdin.setEncoding('utf8');
150
+
151
+ const onData = (key) => {
152
+ // Ctrl+C
153
+ if (key === '\x03') {
154
+ process.stdin.setRawMode(false);
155
+ process.stdin.pause();
156
+ process.stdin.removeListener('data', onData);
157
+ console.log();
158
+ process.exitCode = 0; return;
159
+ }
160
+
161
+ // Enter
162
+ if (key === '\r' || key === '\n') {
163
+ process.stdin.setRawMode(false);
164
+ process.stdin.pause();
165
+ process.stdin.removeListener('data', onData);
166
+ resolve(items.filter((_, i) => selected.has(i)).map(i => i.value));
167
+ return;
168
+ }
169
+
170
+ // Space — toggle
171
+ if (key === ' ') {
172
+ if (selected.has(cursor)) selected.delete(cursor);
173
+ else selected.add(cursor);
174
+ render();
175
+ return;
176
+ }
177
+
178
+ // a — select all
179
+ if (key === 'a') {
180
+ for (let i = 0; i < items.length; i++) selected.add(i);
181
+ render();
182
+ return;
183
+ }
184
+
185
+ // n — select none
186
+ if (key === 'n') {
187
+ selected.clear();
188
+ render();
189
+ return;
190
+ }
191
+
192
+ // Arrow up / k
193
+ if (key === '\x1b[A' || key === 'k') {
194
+ cursor = (cursor - 1 + items.length) % items.length;
195
+ render();
196
+ return;
197
+ }
198
+
199
+ // Arrow down / j
200
+ if (key === '\x1b[B' || key === 'j') {
201
+ cursor = (cursor + 1) % items.length;
202
+ render();
203
+ return;
204
+ }
205
+ };
206
+
207
+ process.stdin.on('data', onData);
208
+ });
209
+ }
210
+
211
+ async function registerAgent(agentName) {
212
+ const res = await fetch(`${REGISTRY_URL}/api/register`, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json' },
215
+ body: JSON.stringify({ agent_name: agentName }),
216
+ signal: AbortSignal.timeout(15_000),
217
+ });
218
+ if (!res.ok) throw new Error(`Registration failed (HTTP ${res.status}): ${await res.text()}`);
219
+ return res.json();
220
+ }
221
+
222
+ async function setupCommand() {
223
+ console.log(` ${c.bold}Setup${c.reset}`);
224
+ console.log();
225
+
226
+ const existing = loadCredentials();
227
+ if (existing) {
228
+ console.log(` ${icons.safe} Already configured as ${c.bold}${existing.agent_name}${c.reset}`);
229
+ console.log(` ${c.dim}Key: ${existing.api_key.slice(0, 8)}...${c.reset}`);
230
+ console.log();
231
+ const answer = await askQuestion(` Reconfigure? ${c.dim}(y/N)${c.reset} `);
232
+ if (answer.toLowerCase() !== 'y') {
233
+ console.log(` ${c.dim}Keeping existing config.${c.reset}`);
234
+ return;
235
+ }
236
+ console.log();
237
+ }
238
+
239
+ console.log(` ${c.bold}1)${c.reset} Register new agent ${c.dim}(free, creates API key automatically)${c.reset}`);
240
+ console.log(` ${c.bold}2)${c.reset} Enter existing API key`);
241
+ console.log();
242
+ const choice = await askQuestion(` Choice ${c.dim}(1/2)${c.reset}: `);
243
+ console.log();
244
+
245
+ if (choice === '2') {
246
+ const key = await askQuestion(` API Key: `);
247
+ if (!key) { console.log(` ${c.red}No key entered.${c.reset}`); return; }
248
+ const name = await askQuestion(` Agent name ${c.dim}(optional)${c.reset}: `);
249
+ saveCredentials({ api_key: key, agent_name: name || 'custom' });
250
+ console.log();
251
+ console.log(` ${icons.safe} Saved! Key stored in ${c.dim}${USER_CRED_FILE}${c.reset}`);
252
+ } else {
253
+ const name = await askQuestion(` Agent name ${c.dim}(e.g. my-scanner, claude-desktop)${c.reset}: `);
254
+ if (!name || !/^[a-zA-Z0-9._-]{2,64}$/.test(name)) {
255
+ console.log(` ${c.red}Invalid name. Use 2-64 chars: letters, numbers, dash, underscore, dot.${c.reset}`);
256
+ return;
257
+ }
258
+ process.stdout.write(` Registering ${c.bold}${name}${c.reset}...`);
259
+ try {
260
+ const data = await registerAgent(name);
261
+ saveCredentials({ api_key: data.api_key, agent_name: data.agent_name });
262
+ console.log(` ${c.green}done!${c.reset}`);
263
+ console.log();
264
+ console.log(` ${icons.safe} Registered as ${c.bold}${data.agent_name}${c.reset}`);
265
+ console.log(` ${c.dim}Key: ${data.api_key.slice(0, 12)}...${c.reset}`);
266
+ console.log(` ${c.dim}Saved to: ${USER_CRED_FILE}${c.reset}`);
267
+ } catch (err) {
268
+ console.log(` ${c.red}failed${c.reset}`);
269
+ console.log(` ${c.red}${err.message}${c.reset}`);
270
+ return;
271
+ }
272
+ }
273
+
274
+ console.log();
275
+ console.log(` ${c.bold}Ready!${c.reset} You can now:`);
276
+ console.log(` ${c.dim}•${c.reset} Discover servers: ${c.cyan}agentaudit discover${c.reset}`);
277
+ console.log(` ${c.dim}•${c.reset} Audit packages: ${c.cyan}agentaudit audit <repo-url>${c.reset} ${c.dim}(deep LLM analysis)${c.reset}`);
278
+ console.log(` ${c.dim}•${c.reset} Quick scan: ${c.cyan}agentaudit scan <repo-url>${c.reset} ${c.dim}(regex-based)${c.reset}`);
279
+ console.log(` ${c.dim}•${c.reset} Check registry: ${c.cyan}agentaudit check <name>${c.reset}`);
280
+ console.log(` ${c.dim}•${c.reset} Submit reports via MCP in Claude/Cursor/Windsurf`);
281
+ console.log();
282
+ }
283
+
284
+ // ── Helpers ──────────────────────────────────────────────
285
+
286
+ function getVersion() {
287
+ try {
288
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
289
+ return pkg.version || '0.0.0';
290
+ } catch { return '0.0.0'; }
291
+ }
292
+
293
+ function banner() {
294
+ if (quietMode || jsonMode) return;
295
+ console.log();
296
+ console.log(` ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset}`);
297
+ console.log(` ${c.dim}Security scanner for AI packages${c.reset}`);
298
+ console.log();
299
+ }
300
+
301
+ function slugFromUrl(url) {
302
+ const match = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
303
+ if (match) return match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
304
+ return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
305
+ }
306
+
307
+ function elapsed(startMs) {
308
+ const ms = Date.now() - startMs;
309
+ if (ms < 1000) return `${ms}ms`;
310
+ return `${(ms / 1000).toFixed(1)}s`;
311
+ }
312
+
313
+ function riskBadge(score) {
314
+ if (score === 0) return `${c.bgGreen}${c.bold}${c.white} SAFE ${c.reset}`;
315
+ if (score <= 10) return `${c.bgGreen}${c.white} LOW ${c.reset}`;
316
+ if (score <= 30) return `${c.bgYellow}${c.bold} CAUTION ${c.reset}`;
317
+ return `${c.bgRed}${c.bold}${c.white} UNSAFE ${c.reset}`;
318
+ }
319
+
320
+ function severityColor(sev) {
321
+ switch (sev) {
322
+ case 'critical': return c.red;
323
+ case 'high': return c.red;
324
+ case 'medium': return c.yellow;
325
+ case 'low': return c.blue;
326
+ default: return c.gray;
327
+ }
328
+ }
329
+
330
+ function severityIcon(sev) {
331
+ switch (sev) {
332
+ case 'critical': return `${c.red}●${c.reset}`;
333
+ case 'high': return `${c.red}●${c.reset}`;
334
+ case 'medium': return `${c.yellow}●${c.reset}`;
335
+ case 'low': return `${c.blue}●${c.reset}`;
336
+ default: return `${c.green}●${c.reset}`;
337
+ }
338
+ }
339
+
340
+ // ── File Collection (same logic as MCP server) ──────────
341
+
342
+ function extractJSON(text) {
343
+ // 1. Try parsing the entire text as JSON directly
344
+ try { return JSON.parse(text.trim()); } catch {}
345
+
346
+ // 2. Strip markdown code fences (```json ... ``` or ``` ... ```)
347
+ const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
348
+ if (fenceMatch) {
349
+ try { return JSON.parse(fenceMatch[1].trim()); } catch {}
350
+ }
351
+
352
+ // 3. Find the first balanced top-level { ... } block
353
+ const start = text.indexOf('{');
354
+ if (start === -1) return null;
355
+ let depth = 0;
356
+ let inString = false;
357
+ let escape = false;
358
+ for (let i = start; i < text.length; i++) {
359
+ const ch = text[i];
360
+ if (escape) { escape = false; continue; }
361
+ if (ch === '\\' && inString) { escape = true; continue; }
362
+ if (ch === '"') { inString = !inString; continue; }
363
+ if (inString) continue;
364
+ if (ch === '{') depth++;
365
+ else if (ch === '}') {
366
+ depth--;
367
+ if (depth === 0) {
368
+ try { return JSON.parse(text.slice(start, i + 1)); } catch {}
369
+ break;
370
+ }
371
+ }
372
+ }
373
+
374
+ // 4. Last resort: greedy match
375
+ const greedy = text.match(/\{[\s\S]*\}/);
376
+ if (greedy) {
377
+ try { return JSON.parse(greedy[0]); } catch {}
378
+ }
379
+
380
+ return null;
381
+ }
382
+
383
+ const MAX_FILE_SIZE = 50_000;
384
+ const MAX_TOTAL_SIZE = 300_000;
385
+ const SKIP_DIRS = new Set([
386
+ 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
387
+ '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
388
+ 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
389
+ 'examples', 'example', 'fixtures', '.github', '.vscode', '.idea',
390
+ 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
391
+ ]);
392
+ const SKIP_EXTENSIONS = new Set([
393
+ '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
394
+ '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
395
+ '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
396
+ '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
397
+ ]);
398
+
399
+ function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
400
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
401
+ let entries;
402
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
403
+ catch { return collected; }
404
+ entries.sort((a, b) => a.name.localeCompare(b.name));
405
+ for (const entry of entries) {
406
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
407
+ const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
408
+ const fullPath = path.join(dir, entry.name);
409
+ if (entry.isDirectory()) {
410
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
411
+ collectFiles(fullPath, relPath, collected, totalSize);
412
+ } else {
413
+ const ext = path.extname(entry.name).toLowerCase();
414
+ if (SKIP_EXTENSIONS.has(ext)) continue;
415
+ try {
416
+ const stat = fs.statSync(fullPath);
417
+ if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
418
+ const content = fs.readFileSync(fullPath, 'utf8');
419
+ totalSize.bytes += content.length;
420
+ collected.push({ path: relPath, content, size: stat.size });
421
+ } catch {}
422
+ }
423
+ }
424
+ return collected;
425
+ }
426
+
427
+ // ── Detect package properties ───────────────────────────
428
+
429
+ function detectPackageInfo(repoPath, files) {
430
+ const info = { type: 'unknown', tools: [], prompts: [], language: 'unknown', entrypoint: null };
431
+
432
+ // Detect language
433
+ const exts = files.map(f => path.extname(f.path).toLowerCase());
434
+ const extCounts = {};
435
+ exts.forEach(e => { extCounts[e] = (extCounts[e] || 0) + 1; });
436
+ const topExt = Object.entries(extCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
437
+
438
+ const langMap = { '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript', '.mjs': 'JavaScript', '.rs': 'Rust', '.go': 'Go', '.java': 'Java', '.rb': 'Ruby' };
439
+ info.language = langMap[topExt] || topExt || 'unknown';
440
+
441
+ // Detect package type
442
+ const allContent = files.map(f => f.content).join('\n');
443
+ if (allContent.includes('@modelcontextprotocol') || allContent.includes('FastMCP') || allContent.includes('mcp.server') || allContent.includes('mcp_server')) {
444
+ info.type = 'mcp-server';
445
+ } else if (files.some(f => f.path.toLowerCase() === 'skill.md')) {
446
+ info.type = 'agent-skill';
447
+ } else if (allContent.includes('#!/usr/bin/env') || allContent.includes('argparse') || allContent.includes('commander')) {
448
+ info.type = 'cli-tool';
449
+ } else {
450
+ info.type = 'library';
451
+ }
452
+
453
+ // Extract MCP tools (look for tool definitions)
454
+ const toolPatterns = [
455
+ // JS/TS: name: 'tool_name' or "tool_name" in tool definitions
456
+ /(?:name|tool_name)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
457
+ // Python: @mcp.tool() def func_name or Tool(name="...")
458
+ /(?:@(?:mcp|server)\.tool\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*))|(?:Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"])/gi,
459
+ // Direct: tool names in ListTools handlers
460
+ /['"]name['"]\s*:\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
461
+ ];
462
+
463
+ const toolSet = new Set();
464
+ for (const file of files) {
465
+ for (const pattern of toolPatterns) {
466
+ pattern.lastIndex = 0;
467
+ let m;
468
+ while ((m = pattern.exec(file.content)) !== null) {
469
+ const name = m[1] || m[2];
470
+ if (name && name.length > 2 && name.length < 50 && !['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none'].includes(name)) {
471
+ toolSet.add(name);
472
+ }
473
+ }
474
+ }
475
+ }
476
+ info.tools = [...toolSet];
477
+
478
+ // Extract prompts (look for prompt definitions)
479
+ const promptPatterns = [
480
+ /(?:prompt|PROMPT)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
481
+ /@(?:mcp|server)\.prompt\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
482
+ ];
483
+ const promptSet = new Set();
484
+ for (const file of files) {
485
+ for (const pattern of promptPatterns) {
486
+ pattern.lastIndex = 0;
487
+ let m;
488
+ while ((m = pattern.exec(file.content)) !== null) {
489
+ if (m[1] && m[1].length > 2) promptSet.add(m[1]);
490
+ }
491
+ }
492
+ }
493
+ info.prompts = [...promptSet];
494
+
495
+ // Detect entrypoint
496
+ const entryFiles = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
497
+ for (const ef of entryFiles) {
498
+ if (files.some(f => f.path === ef)) { info.entrypoint = ef; break; }
499
+ }
500
+
501
+ return info;
502
+ }
503
+
504
+ // ── Quick static checks ─────────────────────────────────
505
+
506
+ function quickChecks(files) {
507
+ const findings = [];
508
+
509
+ const checks = [
510
+ {
511
+ id: 'EXEC_INJECTION',
512
+ title: 'Command injection risk',
513
+ severity: 'high',
514
+ pattern: /(?:exec(?:Sync)?|spawn|child_process|subprocess|os\.system|os\.popen|Popen)\s*\([^)]*(?:\$\{|`|\+\s*(?:req|input|args|param|user|query))/i,
515
+ category: 'injection',
516
+ },
517
+ {
518
+ id: 'EVAL_USAGE',
519
+ title: 'Dynamic code evaluation',
520
+ severity: 'high',
521
+ pattern: /(?:^|[^a-z])eval\s*\([^)]*(?:input|req|user|param|arg|query)/im,
522
+ category: 'injection',
523
+ },
524
+ {
525
+ id: 'HARDCODED_SECRET',
526
+ title: 'Potential hardcoded secret',
527
+ severity: 'medium',
528
+ pattern: /(?:api[_-]?key|password|secret|token)\s*[:=]\s*['"][A-Za-z0-9+/=_-]{16,}['"]/i,
529
+ category: 'secrets',
530
+ },
531
+ {
532
+ id: 'SSL_DISABLED',
533
+ title: 'SSL/TLS verification disabled',
534
+ severity: 'medium',
535
+ pattern: /(?:rejectUnauthorized\s*:\s*false|verify\s*=\s*False|VERIFY_SSL\s*=\s*false|NODE_TLS_REJECT_UNAUTHORIZED|InsecureRequestWarning)/i,
536
+ category: 'crypto',
537
+ },
538
+ {
539
+ id: 'PATH_TRAVERSAL',
540
+ title: 'Potential path traversal',
541
+ severity: 'medium',
542
+ pattern: /(?:\.\.\/|\.\.\\|path\.join|os\.path\.join)\s*\([^)]*(?:input|req|user|param|arg|query)/i,
543
+ category: 'filesystem',
544
+ },
545
+ {
546
+ id: 'CORS_WILDCARD',
547
+ title: 'Wildcard CORS origin',
548
+ severity: 'low',
549
+ pattern: /(?:Access-Control-Allow-Origin|cors)\s*[:({]\s*['"]\*/i,
550
+ category: 'network',
551
+ },
552
+ {
553
+ id: 'TELEMETRY',
554
+ title: 'Undisclosed telemetry',
555
+ severity: 'low',
556
+ pattern: /(?:posthog|mixpanel|analytics|telemetry|tracking|sentry).*(?:init|setup|track|capture)/i,
557
+ category: 'privacy',
558
+ },
559
+ {
560
+ id: 'SHELL_EXEC',
561
+ title: 'Shell command execution',
562
+ severity: 'high',
563
+ pattern: /(?:subprocess\.(?:run|call|Popen)|os\.system|os\.popen|execSync|child_process\.exec)\s*\(/i,
564
+ category: 'injection',
565
+ },
566
+ {
567
+ id: 'SQL_INJECTION',
568
+ title: 'Potential SQL injection',
569
+ severity: 'high',
570
+ pattern: /(?:execute|query|raw)\s*\(\s*(?:f['"]|['"].*?%s|['"].*?\{|['"].*?\+)/i,
571
+ category: 'injection',
572
+ },
573
+ {
574
+ id: 'YAML_UNSAFE',
575
+ title: 'Unsafe YAML loading',
576
+ severity: 'medium',
577
+ pattern: /yaml\.(?:load|unsafe_load)\s*\(/i,
578
+ category: 'deserialization',
579
+ },
580
+ {
581
+ id: 'PICKLE_LOAD',
582
+ title: 'Unsafe deserialization (pickle)',
583
+ severity: 'high',
584
+ pattern: /pickle\.loads?\s*\(/i,
585
+ category: 'deserialization',
586
+ },
587
+ {
588
+ id: 'PROMPT_INJECTION',
589
+ title: 'Prompt injection vector',
590
+ severity: 'high',
591
+ pattern: /(?:<IMPORTANT>|<SYSTEM>|ignore previous|you are now|new instructions)/i,
592
+ category: 'prompt-injection',
593
+ },
594
+ ];
595
+
596
+ for (const file of files) {
597
+ for (const check of checks) {
598
+ const match = check.pattern.exec(file.content);
599
+ if (match) {
600
+ // Find line number
601
+ const lines = file.content.slice(0, match.index).split('\n');
602
+ findings.push({
603
+ ...check,
604
+ file: file.path,
605
+ line: lines.length,
606
+ snippet: match[0].trim().slice(0, 80),
607
+ confidence: 'medium',
608
+ });
609
+ }
610
+ }
611
+ }
612
+
613
+ return findings;
614
+ }
615
+
616
+ // ── Registry check ──────────────────────────────────────
617
+
618
+ async function checkRegistry(slug) {
619
+ try {
620
+ const res = await fetch(`${REGISTRY_URL}/api/skills/${encodeURIComponent(slug)}`, {
621
+ signal: AbortSignal.timeout(5000),
622
+ });
623
+ if (res.ok) return await res.json();
624
+ } catch {}
625
+ return null;
626
+ }
627
+
628
+ // ── Print results ───────────────────────────────────────
629
+
630
+ function printScanResult(url, info, files, findings, registryData, duration) {
631
+ if (jsonMode) return; // JSON mode handles output separately
632
+
633
+ const slug = slugFromUrl(url);
634
+
635
+ // Quiet mode: compact one-line-per-package output
636
+ if (quietMode) {
637
+ if (findings.length > 0) {
638
+ const bySev = {};
639
+ for (const f of findings) { bySev[f.severity] = (bySev[f.severity] || 0) + 1; }
640
+ const sevStr = Object.entries(bySev).map(([s, n]) => {
641
+ const sc = severityColor(s);
642
+ return `${sc}${n} ${s}${c.reset}`;
643
+ }).join(', ');
644
+ console.log(`${icons.caution} ${c.bold}${slug}${c.reset} ${findings.length} findings (${sevStr}) ${c.dim}${duration}${c.reset}`);
645
+ for (const f of findings) {
646
+ const sc = severityColor(f.severity);
647
+ console.log(` ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title} ${c.dim}${f.file}:${f.line}${c.reset}`);
648
+ }
649
+ } else {
650
+ console.log(`${icons.safe} ${c.bold}${slug}${c.reset} ${c.green}clean${c.reset} ${c.dim}${files.length} files, ${duration}${c.reset}`);
651
+ }
652
+ return;
653
+ }
654
+
655
+ // Header
656
+ console.log(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.dim}${url}${c.reset}`);
657
+ console.log(`${icons.pipe} ${c.dim}${info.language} ${info.type}${c.reset} ${c.dim}${files.length} files scanned in ${duration}${c.reset}`);
658
+
659
+ // Tools & prompts tree
660
+ const items = [
661
+ ...info.tools.map(t => ({ kind: 'tool', name: t })),
662
+ ...info.prompts.map(p => ({ kind: 'prompt', name: p })),
663
+ ];
664
+
665
+ if (items.length > 0) {
666
+ console.log(`${icons.pipe}`);
667
+ for (let i = 0; i < items.length; i++) {
668
+ const isLast = i === items.length - 1 && findings.length === 0;
669
+ const branch = isLast ? icons.treeLast : icons.tree;
670
+ const item = items[i];
671
+ const kindLabel = item.kind === 'tool' ? `${c.dim}tool${c.reset} ` : `${c.dim}prompt${c.reset}`;
672
+ const padName = item.name.padEnd(28);
673
+
674
+ // Check if this tool has a finding associated
675
+ const toolFinding = findings.find(f =>
676
+ f.snippet && f.snippet.toLowerCase().includes(item.name.toLowerCase())
677
+ );
678
+
679
+ if (toolFinding) {
680
+ const sc = severityColor(toolFinding.severity);
681
+ console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${sc}⚠ flagged${c.reset} ${toolFinding.title}`);
682
+ } else {
683
+ console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${c.green}✔ ok${c.reset}`);
684
+ }
685
+ }
686
+ } else {
687
+ console.log(`${icons.pipe} ${c.dim}(no tools or prompts detected)${c.reset}`);
688
+ }
689
+
690
+ // Findings
691
+ if (findings.length > 0) {
692
+ console.log(`${icons.pipe}`);
693
+ console.log(`${icons.pipe} ${c.bold}Findings (${findings.length})${c.reset} ${c.dim}static analysis — may include false positives${c.reset}`);
694
+ for (let i = 0; i < findings.length; i++) {
695
+ const f = findings[i];
696
+ const isLast = i === findings.length - 1;
697
+ const branch = isLast ? icons.treeLast : icons.tree;
698
+ const pipeOrSpace = isLast ? ' ' : `${icons.pipe} `;
699
+ const sc = severityColor(f.severity);
700
+ console.log(`${branch} ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
701
+ console.log(`${pipeOrSpace} ${c.dim}${f.file}:${f.line}${c.reset} ${c.dim}${f.snippet || ''}${c.reset}`);
702
+ }
703
+ }
704
+
705
+ // Registry status
706
+ console.log(`${icons.pipe}`);
707
+ if (registryData) {
708
+ const rd = registryData;
709
+ const riskScore = rd.risk_score ?? rd.latest_risk_score ?? 0;
710
+ console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${riskBadge(riskScore)} Risk ${riskScore} ${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
711
+ } else {
712
+ console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${c.dim}not audited yet${c.reset}`);
713
+ }
714
+
715
+ console.log();
716
+ }
717
+
718
+ function printSummary(results) {
719
+ const total = results.length;
720
+ const safe = results.filter(r => r.findings.length === 0).length;
721
+ const withFindings = total - safe;
722
+ const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
723
+
724
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
725
+ console.log(` ${c.bold}Summary${c.reset} ${total} packages scanned`);
726
+ console.log();
727
+ if (safe > 0) console.log(` ${icons.safe} ${c.green}${safe} clean${c.reset}`);
728
+ if (withFindings > 0) console.log(` ${icons.caution} ${c.yellow}${withFindings} with findings${c.reset} (${totalFindings} total)`);
729
+
730
+ // Breakdown by severity
731
+ const bySev = {};
732
+ results.forEach(r => r.findings.forEach(f => {
733
+ bySev[f.severity] = (bySev[f.severity] || 0) + 1;
734
+ }));
735
+ if (Object.keys(bySev).length > 0) {
736
+ console.log();
737
+ for (const sev of ['critical', 'high', 'medium', 'low']) {
738
+ if (bySev[sev]) {
739
+ console.log(` ${severityIcon(sev)} ${bySev[sev]}× ${severityColor(sev)}${sev}${c.reset}`);
740
+ }
741
+ }
742
+ }
743
+
744
+ console.log();
745
+ }
746
+
747
+ // ── Clone & Scan ────────────────────────────────────────
748
+
749
+ async function scanRepo(url) {
750
+ const start = Date.now();
751
+ const slug = slugFromUrl(url);
752
+
753
+ if (!jsonMode) process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}...${c.reset}`);
754
+
755
+ // Clone
756
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
757
+ const repoPath = path.join(tmpDir, 'repo');
758
+ try {
759
+ execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
760
+ timeout: 30_000,
761
+ stdio: 'pipe',
762
+ });
763
+ } catch (err) {
764
+ if (!jsonMode) {
765
+ process.stdout.write(` ${c.red}✖ clone failed${c.reset}\n`);
766
+ const msg = err.stderr?.toString().trim() || err.message?.split('\n')[0] || '';
767
+ if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
768
+ console.log(` ${c.dim}Make sure git is installed and the URL is accessible.${c.reset}`);
769
+ }
770
+ return null;
771
+ }
772
+
773
+ // Collect files
774
+ const files = collectFiles(repoPath);
775
+
776
+ // Detect info
777
+ const info = detectPackageInfo(repoPath, files);
778
+
779
+ // Quick checks
780
+ const findings = quickChecks(files);
781
+
782
+ // Registry lookup
783
+ const registryData = await checkRegistry(slug);
784
+
785
+ // Cleanup
786
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
787
+
788
+ const duration = elapsed(start);
789
+
790
+ if (!jsonMode) {
791
+ // Clear the "Scanning..." line
792
+ process.stdout.write('\r\x1b[K');
793
+
794
+ // Print result
795
+ printScanResult(url, info, files, findings, registryData, duration);
796
+ }
797
+
798
+ return { slug, url, info, files: files.length, findings, registryData, duration };
799
+ }
800
+
801
+ // ── Discover local MCP configs ──────────────────────────
802
+
803
+ function findMcpConfigs() {
804
+ const home = process.env.HOME || process.env.USERPROFILE || '';
805
+ const platform = process.platform;
806
+
807
+ // All known MCP config locations
808
+ const candidates = [
809
+ // Claude Desktop
810
+ { name: 'Claude Desktop', path: path.join(home, '.claude', 'mcp.json') },
811
+ { name: 'Claude Desktop', path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') },
812
+ { name: 'Claude Desktop', path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') },
813
+ { name: 'Claude Desktop', path: path.join(home, '.config', 'claude', 'claude_desktop_config.json') },
814
+ // Cursor
815
+ { name: 'Cursor', path: path.join(home, '.cursor', 'mcp.json') },
816
+ // Windsurf / Codeium
817
+ { name: 'Windsurf', path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
818
+ // VS Code
819
+ { name: 'VS Code', path: path.join(home, '.vscode', 'mcp.json') },
820
+ // Continue.dev
821
+ { name: 'Continue', path: path.join(home, '.continue', 'config.json') },
822
+ ];
823
+
824
+ // Also check AGENTAUDIT_TEST_CONFIG env for testing
825
+ if (process.env.AGENTAUDIT_TEST_CONFIG) {
826
+ candidates.push({ name: 'Test Config', path: process.env.AGENTAUDIT_TEST_CONFIG });
827
+ }
828
+
829
+ // Also scan workspace .cursor/mcp.json, .vscode/mcp.json in cwd
830
+ const cwd = process.cwd();
831
+ candidates.push(
832
+ { name: 'Cursor (project)', path: path.join(cwd, '.cursor', 'mcp.json') },
833
+ { name: 'VS Code (project)', path: path.join(cwd, '.vscode', 'mcp.json') },
834
+ );
835
+
836
+ const found = [];
837
+ for (const c of candidates) {
838
+ if (fs.existsSync(c.path)) {
839
+ try {
840
+ const content = JSON.parse(fs.readFileSync(c.path, 'utf8'));
841
+ found.push({ ...c, content });
842
+ } catch {}
843
+ }
844
+ }
845
+ return found;
846
+ }
847
+
848
+ function extractServersFromConfig(config) {
849
+ // Handle both { mcpServers: {...} } and { servers: {...} } formats
850
+ const servers = config.mcpServers || config.servers || {};
851
+ const result = [];
852
+
853
+ for (const [name, serverConfig] of Object.entries(servers)) {
854
+ const info = {
855
+ name,
856
+ command: serverConfig.command || null,
857
+ args: serverConfig.args || [],
858
+ url: serverConfig.url || null,
859
+ sourceUrl: null,
860
+ };
861
+
862
+ // Try to extract source URL from args (common patterns)
863
+ const allArgs = [info.command, ...info.args].filter(Boolean).join(' ');
864
+
865
+ // npx package-name → npm package
866
+ const npxMatch = allArgs.match(/npx\s+(?:-y\s+)?(@?[a-z0-9][\w./-]*)/i);
867
+ if (npxMatch) info.npmPackage = npxMatch[1];
868
+
869
+ // node /path/to/something → try to find package.json
870
+ const nodePathMatch = allArgs.match(/node\s+["']?([^"'\s]+)/);
871
+ if (nodePathMatch) {
872
+ const scriptPath = nodePathMatch[1];
873
+ // Walk up to find package.json with repository
874
+ let dir = path.dirname(path.resolve(scriptPath));
875
+ for (let i = 0; i < 5; i++) {
876
+ const pkgPath = path.join(dir, 'package.json');
877
+ if (fs.existsSync(pkgPath)) {
878
+ try {
879
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
880
+ if (pkg.repository?.url) {
881
+ info.sourceUrl = pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
882
+ }
883
+ if (pkg.name) info.npmPackage = pkg.name;
884
+ } catch {}
885
+ break;
886
+ }
887
+ const parent = path.dirname(dir);
888
+ if (parent === dir) break;
889
+ dir = parent;
890
+ }
891
+ }
892
+
893
+ // python/uvx with package name
894
+ const pyMatch = allArgs.match(/(?:uvx|pip run|python -m)\s+(@?[a-z0-9][\w./-]*)/i);
895
+ if (pyMatch) info.pyPackage = pyMatch[1];
896
+
897
+ // URL-based MCP server (remote HTTP)
898
+ if (info.url && !info.npmPackage && !info.pyPackage) {
899
+ try {
900
+ const parsed = new URL(info.url);
901
+ // Extract service name from hostname: mcp.supabase.com → supabase
902
+ const hostParts = parsed.hostname.split('.');
903
+ if (hostParts.length >= 2) {
904
+ const serviceName = hostParts.length === 3 ? hostParts[1] : hostParts[0];
905
+ info.remoteService = serviceName;
906
+ }
907
+ } catch {}
908
+ }
909
+
910
+ result.push(info);
911
+ }
912
+ return result;
913
+ }
914
+
915
+ function serverSlug(server) {
916
+ // Try to derive a slug for registry lookup
917
+ if (server.npmPackage) return server.npmPackage.replace(/^@/, '').replace(/\//g, '-');
918
+ if (server.pyPackage) return server.pyPackage.replace(/[^a-z0-9-]/gi, '-');
919
+ return server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
920
+ }
921
+
922
+ async function searchGitHub(query) {
923
+ try {
924
+ const res = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=1`, {
925
+ signal: AbortSignal.timeout(5000),
926
+ headers: { 'Accept': 'application/vnd.github+json' },
927
+ });
928
+ if (res.ok) {
929
+ const data = await res.json();
930
+ if (data.items?.length > 0) {
931
+ return data.items[0].html_url;
932
+ }
933
+ }
934
+ } catch {}
935
+ return null;
936
+ }
937
+
938
+ async function resolveSourceUrl(server) {
939
+ // Already have it
940
+ if (server.sourceUrl) return server.sourceUrl;
941
+
942
+ // Try npm registry
943
+ if (server.npmPackage) {
944
+ try {
945
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(server.npmPackage)}`, {
946
+ signal: AbortSignal.timeout(5000),
947
+ });
948
+ if (res.ok) {
949
+ const data = await res.json();
950
+ let repoUrl = data.repository?.url;
951
+ if (repoUrl) {
952
+ repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
953
+ if (repoUrl.startsWith('http')) return repoUrl;
954
+ }
955
+ }
956
+ } catch {}
957
+ // Fallback: try GitHub search for the package name
958
+ const ghUrl = await searchGitHub(server.npmPackage);
959
+ if (ghUrl) return ghUrl;
960
+ return `https://www.npmjs.com/package/${server.npmPackage}`;
961
+ }
962
+
963
+ // Try PyPI
964
+ if (server.pyPackage) {
965
+ try {
966
+ const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(server.pyPackage)}/json`, {
967
+ signal: AbortSignal.timeout(5000),
968
+ });
969
+ if (res.ok) {
970
+ const data = await res.json();
971
+ const urls = data.info?.project_urls || {};
972
+ const source = urls.Source || urls.Repository || urls.Homepage || urls['Source Code'] || data.info?.home_page;
973
+ if (source && source.startsWith('http')) return source;
974
+ }
975
+ } catch {}
976
+ // Fallback: GitHub search
977
+ const ghUrl = await searchGitHub(server.pyPackage);
978
+ if (ghUrl) return ghUrl;
979
+ return `https://pypi.org/project/${server.pyPackage}/`;
980
+ }
981
+
982
+ // URL-based remote MCP server try GitHub search by service name
983
+ if (server.remoteService) {
984
+ // Try npm registry with common MCP naming patterns
985
+ for (const tryName of [
986
+ `@${server.remoteService}/mcp-server-${server.remoteService}`,
987
+ `${server.remoteService}-mcp`,
988
+ `mcp-server-${server.remoteService}`,
989
+ server.remoteService,
990
+ ]) {
991
+ try {
992
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(tryName)}`, {
993
+ signal: AbortSignal.timeout(3000),
994
+ });
995
+ if (res.ok) {
996
+ const data = await res.json();
997
+ let repoUrl = data.repository?.url;
998
+ if (repoUrl) {
999
+ repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
1000
+ if (repoUrl.startsWith('http')) return repoUrl;
1001
+ }
1002
+ }
1003
+ } catch {}
1004
+ }
1005
+ }
1006
+
1007
+ // Last resort: if server has a url, show it as context
1008
+ if (server.url) {
1009
+ try {
1010
+ const parsed = new URL(server.url);
1011
+ return `https://github.com/search?q=${encodeURIComponent(parsed.hostname + ' MCP')}&type=repositories`;
1012
+ } catch {}
1013
+ }
1014
+
1015
+ return null;
1016
+ }
1017
+
1018
+ async function discoverCommand(options = {}) {
1019
+ const autoScan = options.scan || false;
1020
+ const interactiveAudit = options.audit || false;
1021
+
1022
+ if (!jsonMode) {
1023
+ console.log(` ${c.bold}Discovering MCP servers in your AI editors...${c.reset}`);
1024
+ console.log();
1025
+ }
1026
+
1027
+ const configs = findMcpConfigs();
1028
+
1029
+ if (configs.length === 0) {
1030
+ console.log(` ${c.yellow}No MCP configurations found.${c.reset}`);
1031
+ console.log(` ${c.dim}Searched: Claude Desktop, Cursor, Windsurf, VS Code${c.reset}`);
1032
+ console.log();
1033
+ console.log(` ${c.dim}MCP config locations:${c.reset}`);
1034
+ console.log(` ${c.dim} Claude: ~/.claude/mcp.json${c.reset}`);
1035
+ console.log(` ${c.dim} Cursor: ~/.cursor/mcp.json${c.reset}`);
1036
+ console.log(` ${c.dim} Windsurf: ~/.codeium/windsurf/mcp_config.json${c.reset}`);
1037
+ console.log(` ${c.dim} VS Code: ~/.vscode/mcp.json${c.reset}`);
1038
+ console.log();
1039
+ return;
1040
+ }
1041
+
1042
+ let totalServers = 0;
1043
+ let checkedServers = 0;
1044
+ let auditedServers = 0;
1045
+ let unauditedServers = 0;
1046
+ const unauditedWithUrls = [];
1047
+ const allServersWithUrls = []; // For --scan: all servers we can scan
1048
+
1049
+ for (const config of configs) {
1050
+ const servers = extractServersFromConfig(config.content);
1051
+ const serverCount = servers.length;
1052
+ totalServers += serverCount;
1053
+
1054
+ const countLabel = serverCount === 0
1055
+ ? `${c.dim}no servers${c.reset}`
1056
+ : `found ${c.bold}${serverCount}${c.reset} server${serverCount > 1 ? 's' : ''}`;
1057
+
1058
+ console.log(`${icons.bullet} Scanning ${c.bold}${config.name}${c.reset} ${c.dim}${config.path}${c.reset} ${countLabel}`);
1059
+
1060
+ if (serverCount === 0) {
1061
+ console.log();
1062
+ continue;
1063
+ }
1064
+
1065
+ console.log();
1066
+
1067
+ for (let i = 0; i < servers.length; i++) {
1068
+ const server = servers[i];
1069
+ const isLast = i === servers.length - 1;
1070
+ const branch = isLast ? icons.treeLast : icons.tree;
1071
+ const pipe = isLast ? ' ' : `${icons.pipe} `;
1072
+
1073
+ const slug = serverSlug(server);
1074
+ checkedServers++;
1075
+
1076
+ // Registry lookup
1077
+ const registryData = await checkRegistry(slug);
1078
+
1079
+ // Also try with server name directly
1080
+ let regData = registryData;
1081
+ if (!regData && slug !== server.name.toLowerCase()) {
1082
+ regData = await checkRegistry(server.name.toLowerCase());
1083
+ }
1084
+
1085
+ // Determine source display
1086
+ let sourceLabel = '';
1087
+ if (server.npmPackage) sourceLabel = `${c.dim}npm:${server.npmPackage}${c.reset}`;
1088
+ else if (server.pyPackage) sourceLabel = `${c.dim}pip:${server.pyPackage}${c.reset}`;
1089
+ else if (server.url) sourceLabel = `${c.dim}${server.url.length > 60 ? server.url.slice(0, 57) + '...' : server.url}${c.reset}`;
1090
+ else if (server.command) sourceLabel = `${c.dim}${[server.command, ...server.args.slice(0, 2)].join(' ')}${c.reset}`;
1091
+
1092
+ // Always resolve source URL (needed for --scan)
1093
+ const resolvedUrl = await resolveSourceUrl(server);
1094
+
1095
+ if (regData) {
1096
+ auditedServers++;
1097
+ const riskScore = regData.risk_score ?? regData.latest_risk_score ?? 0;
1098
+ const hasOfficial = regData.has_official_audit;
1099
+ console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1100
+ console.log(`${pipe} ${riskBadge(riskScore)} Risk ${riskScore} ${hasOfficial ? `${c.green}✔ official${c.reset} ` : ''}${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
1101
+ if (resolvedUrl) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: true, regData });
1102
+ } else {
1103
+ unauditedServers++;
1104
+ console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1105
+ if (resolvedUrl) {
1106
+ console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Run: ${c.cyan}agentaudit audit ${resolvedUrl}${c.reset}`);
1107
+ unauditedWithUrls.push({ name: server.name, sourceUrl: resolvedUrl });
1108
+ allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: false });
1109
+ } else {
1110
+ console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Source URL unknown — check the package's GitHub/npm page${c.reset}`);
1111
+ }
1112
+ }
1113
+
1114
+ if (server.sourceUrl && !server.sourceUrl.includes('npmjs.com')) {
1115
+ console.log(`${pipe} ${c.dim}source: ${server.sourceUrl}${c.reset}`);
1116
+ }
1117
+ }
1118
+
1119
+ console.log();
1120
+ }
1121
+
1122
+ // Summary
1123
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1124
+ console.log(` ${c.bold}Summary${c.reset} ${totalServers} server${totalServers !== 1 ? 's' : ''} across ${configs.length} config${configs.length !== 1 ? 's' : ''}`);
1125
+ console.log();
1126
+ if (auditedServers > 0) console.log(` ${icons.safe} ${c.green}${auditedServers} audited${c.reset}`);
1127
+ if (unauditedServers > 0) console.log(` ${icons.caution} ${c.yellow}${unauditedServers} not audited${c.reset}`);
1128
+ console.log();
1129
+
1130
+ // --scan: automatically scan all servers with resolved source URLs (git-cloneable only)
1131
+ if (autoScan) {
1132
+ const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1133
+ const scanTargets = allServersWithUrls.filter(s => s.sourceUrl && isCloneable(s.sourceUrl));
1134
+ // Deduplicate by sourceUrl
1135
+ const seen = new Set();
1136
+ const dedupedTargets = scanTargets.filter(s => {
1137
+ if (seen.has(s.sourceUrl)) return false;
1138
+ seen.add(s.sourceUrl);
1139
+ return true;
1140
+ });
1141
+ const skipped = allServersWithUrls.filter(s => s.sourceUrl && !isCloneable(s.sourceUrl));
1142
+ if (dedupedTargets.length > 0) {
1143
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1144
+ console.log(` ${c.bold}${icons.scan} Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}...${c.reset}`);
1145
+ if (skipped.length > 0) {
1146
+ console.log(` ${c.dim}(${skipped.length} skipped — no cloneable source URL)${c.reset}`);
1147
+ }
1148
+ console.log();
1149
+
1150
+ const scanResults = [];
1151
+ for (const target of dedupedTargets) {
1152
+ const result = await scanRepo(target.sourceUrl);
1153
+ if (result) scanResults.push({ ...result, serverName: target.name });
1154
+ }
1155
+
1156
+ if (scanResults.length > 1) {
1157
+ // Print combined scan summary
1158
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1159
+ console.log(` ${c.bold}Scan Summary${c.reset} ${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} scanned`);
1160
+ console.log();
1161
+
1162
+ let totalFindings = 0;
1163
+ let serversWithFindings = 0;
1164
+
1165
+ for (const r of scanResults) {
1166
+ const findingCount = r.findings ? r.findings.length : 0;
1167
+ totalFindings += findingCount;
1168
+ if (findingCount > 0) serversWithFindings++;
1169
+
1170
+ const status = findingCount === 0
1171
+ ? `${icons.safe} ${c.green}clean${c.reset}`
1172
+ : `${icons.caution} ${c.yellow}${findingCount} finding${findingCount !== 1 ? 's' : ''}${c.reset}`;
1173
+ console.log(` ${status} ${c.bold}${r.serverName || r.slug}${c.reset} ${c.dim}(${r.duration})${c.reset}`);
1174
+ }
1175
+
1176
+ console.log();
1177
+ if (serversWithFindings > 0) {
1178
+ console.log(` ${c.yellow}${serversWithFindings}/${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} with findings (${totalFindings} total)${c.reset}`);
1179
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit scan <url> --deep${c.dim} for deep LLM analysis on flagged servers${c.reset}`);
1180
+ } else {
1181
+ console.log(` ${c.green}All servers passed quick scan${c.reset}`);
1182
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit scan <url> --deep${c.dim} for thorough LLM-powered analysis${c.reset}`);
1183
+ }
1184
+ console.log();
1185
+ }
1186
+ } else {
1187
+ console.log(` ${c.dim}No scannable source URLs found.${c.reset}`);
1188
+ console.log();
1189
+ }
1190
+ } else if (interactiveAudit && allServersWithUrls.length > 0) {
1191
+ // Interactive multi-select for audit
1192
+ const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1193
+ const auditCandidates = [];
1194
+ const seen = new Set();
1195
+ for (const s of allServersWithUrls) {
1196
+ if (!s.sourceUrl || !isCloneable(s.sourceUrl)) continue;
1197
+ if (seen.has(s.sourceUrl)) continue;
1198
+ seen.add(s.sourceUrl);
1199
+ auditCandidates.push(s);
1200
+ }
1201
+
1202
+ if (auditCandidates.length > 0) {
1203
+ console.log();
1204
+ const items = auditCandidates.map(s => ({
1205
+ label: s.name,
1206
+ sublabel: s.hasAudit ? `${c.green}✔ audited${c.reset} ${s.sourceUrl}` : s.sourceUrl,
1207
+ value: s,
1208
+ checked: !s.hasAudit, // Pre-select unaudited
1209
+ }));
1210
+
1211
+ const selected = await multiSelect(items, {
1212
+ title: 'Select servers to audit',
1213
+ hint: 'Space=toggle ↑↓=move a=all n=none Enter=confirm',
1214
+ });
1215
+
1216
+ if (selected.length > 0) {
1217
+ console.log();
1218
+ console.log(` ${c.bold}Auditing ${selected.length} server${selected.length !== 1 ? 's' : ''}...${c.reset}`);
1219
+ console.log();
1220
+ for (const s of selected) {
1221
+ await auditRepo(s.sourceUrl);
1222
+ console.log();
1223
+ }
1224
+ } else {
1225
+ console.log();
1226
+ console.log(` ${c.dim}No servers selected.${c.reset}`);
1227
+ }
1228
+ }
1229
+ } else if (unauditedServers > 0) {
1230
+ if (unauditedWithUrls.length > 0) {
1231
+ console.log(` ${c.dim}To audit unaudited servers:${c.reset}`);
1232
+ for (const { name, sourceUrl } of unauditedWithUrls) {
1233
+ console.log(` ${c.cyan}agentaudit audit ${sourceUrl}${c.reset} ${c.dim}(${name})${c.reset}`);
1234
+ }
1235
+ } else {
1236
+ console.log(` ${c.dim}To audit unaudited servers, run:${c.reset}`);
1237
+ console.log(` ${c.cyan}agentaudit audit <source-url>${c.reset}`);
1238
+ }
1239
+ console.log();
1240
+ console.log(` ${c.dim}Or run ${c.cyan}agentaudit discover --quick${c.dim} to quick-scan all servers${c.reset}`);
1241
+ console.log(` ${c.dim}Or run ${c.cyan}agentaudit discover --deep${c.dim} to select & deep-audit interactively${c.reset}`);
1242
+ console.log();
1243
+ }
1244
+
1245
+ if (!autoScan && !interactiveAudit && !jsonMode) {
1246
+ console.log(` ${c.dim}Looking for general package scanning? Try ${c.cyan}pip audit${c.dim} or ${c.cyan}npm audit${c.dim}.${c.reset}`);
1247
+ console.log();
1248
+ }
1249
+ }
1250
+
1251
+ // ── Audit command (deep LLM-powered) ────────────────────
1252
+
1253
+ function loadAuditPrompt() {
1254
+ const promptPath = path.join(SKILL_DIR, 'prompts', 'audit-prompt.md');
1255
+ if (fs.existsSync(promptPath)) return fs.readFileSync(promptPath, 'utf8');
1256
+ return null;
1257
+ }
1258
+
1259
+ async function auditRepo(url) {
1260
+ const start = Date.now();
1261
+ const slug = slugFromUrl(url);
1262
+
1263
+ console.log(`${icons.scan} ${c.bold}Auditing ${slug}${c.reset} ${c.dim}${url}${c.reset}`);
1264
+ console.log(`${icons.pipe} ${c.dim}Deep LLM-powered analysis (3-pass: UNDERSTAND DETECT → CLASSIFY)${c.reset}`);
1265
+ console.log();
1266
+
1267
+ // Step 1: Clone
1268
+ process.stdout.write(` ${c.dim}[1/4]${c.reset} Cloning repository...`);
1269
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
1270
+ const repoPath = path.join(tmpDir, 'repo');
1271
+ try {
1272
+ execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
1273
+ timeout: 30_000, stdio: 'pipe',
1274
+ });
1275
+ console.log(` ${c.green}done${c.reset}`);
1276
+ } catch (err) {
1277
+ console.log(` ${c.red}failed${c.reset}`);
1278
+ const msg = err.stderr?.toString().trim() || err.message?.split('\n')[0] || '';
1279
+ if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
1280
+ console.log(` ${c.dim}Make sure git is installed and the URL is accessible.${c.reset}`);
1281
+ return null;
1282
+ }
1283
+
1284
+ // Step 2: Collect files
1285
+ process.stdout.write(` ${c.dim}[2/4]${c.reset} Collecting source files...`);
1286
+ const files = collectFiles(repoPath);
1287
+ console.log(` ${c.green}${files.length} files${c.reset}`);
1288
+
1289
+ // Step 3: Build audit payload
1290
+ process.stdout.write(` ${c.dim}[3/4]${c.reset} Preparing audit payload...`);
1291
+ const auditPrompt = loadAuditPrompt();
1292
+
1293
+ let codeBlock = '';
1294
+ for (const file of files) {
1295
+ codeBlock += `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
1296
+ }
1297
+ console.log(` ${c.green}done${c.reset}`);
1298
+
1299
+ // Step 4: LLM Analysis
1300
+ // Check for API keys to determine which LLM to use
1301
+ const anthropicKey = process.env.ANTHROPIC_API_KEY;
1302
+ const openaiKey = process.env.OPENAI_API_KEY;
1303
+
1304
+ if (!anthropicKey && !openaiKey) {
1305
+ // No LLM API key — clear explanation
1306
+ console.log();
1307
+ console.log(` ${c.yellow}No LLM API key found.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
1308
+ console.log();
1309
+ console.log(` ${c.bold}Option 1: Set an API key${c.reset}`);
1310
+ console.log(` Supported keys: ${c.cyan}ANTHROPIC_API_KEY${c.reset} or ${c.cyan}OPENAI_API_KEY${c.reset}`);
1311
+ console.log();
1312
+ console.log(` ${c.dim}# Linux / macOS:${c.reset}`);
1313
+ console.log(` ${c.dim}export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1314
+ console.log(` ${c.dim}export OPENAI_API_KEY=sk-...${c.reset}`);
1315
+ console.log();
1316
+ console.log(` ${c.dim}# Windows (PowerShell):${c.reset}`);
1317
+ console.log(` ${c.dim}$env:ANTHROPIC_API_KEY = "sk-ant-..."${c.reset}`);
1318
+ console.log(` ${c.dim}$env:OPENAI_API_KEY = "sk-..."${c.reset}`);
1319
+ console.log();
1320
+ console.log(` ${c.dim}# Windows (CMD):${c.reset}`);
1321
+ console.log(` ${c.dim}set ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1322
+ console.log(` ${c.dim}set OPENAI_API_KEY=sk-...${c.reset}`);
1323
+ console.log();
1324
+ console.log(` ${c.bold}Option 2: Export for manual review${c.reset}`);
1325
+ console.log(` ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
1326
+ console.log(` ${c.dim}Creates a markdown file you can paste into any LLM (Claude, ChatGPT, etc.)${c.reset}`);
1327
+ console.log();
1328
+ console.log(` ${c.bold}Option 3: Use MCP in Claude/Cursor/Windsurf (no API key needed)${c.reset}`);
1329
+ console.log(` ${c.dim}Add AgentAudit as MCP server — your editor's agent runs the audit using its own LLM.${c.reset}`);
1330
+ console.log(` ${c.dim}Config: { "mcpServers": { "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } } }${c.reset}`);
1331
+ console.log();
1332
+
1333
+ // Check if --export flag
1334
+ if (process.argv.includes('--export')) {
1335
+ const exportPath = path.join(process.cwd(), `audit-${slug}.md`);
1336
+ const exportContent = [
1337
+ `# Security Audit: ${slug}`,
1338
+ `**Source:** ${url}`,
1339
+ `**Files:** ${files.length}`,
1340
+ ``,
1341
+ `## Audit Instructions`,
1342
+ ``,
1343
+ auditPrompt || '(audit prompt not found)',
1344
+ ``,
1345
+ `## Report Format`,
1346
+ ``,
1347
+ `After analysis, produce a JSON report:`,
1348
+ '```json',
1349
+ `{ "skill_slug": "${slug}", "source_url": "${url}", "risk_score": 0, "result": "safe", "findings": [] }`,
1350
+ '```',
1351
+ ``,
1352
+ `## Source Code`,
1353
+ ``,
1354
+ codeBlock,
1355
+ ].join('\n');
1356
+ fs.writeFileSync(exportPath, exportContent);
1357
+ console.log(` ${icons.safe} Exported to ${c.bold}${exportPath}${c.reset}`);
1358
+ console.log(` ${c.dim}Paste this into any LLM (Claude, ChatGPT, etc.) for analysis${c.reset}`);
1359
+ }
1360
+
1361
+ // Cleanup
1362
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1363
+ return null;
1364
+ }
1365
+
1366
+ // We have an API key — run LLM audit
1367
+ process.stdout.write(` ${c.dim}[4/4]${c.reset} Running LLM analysis...`);
1368
+
1369
+ const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
1370
+ const userMessage = [
1371
+ `Audit this package: **${slug}** (${url})`,
1372
+ ``,
1373
+ `After analysis, respond with ONLY a valid JSON object. No markdown fences, no explanation, no text before or after. Just the raw JSON:`,
1374
+ `{ "skill_slug": "${slug}", "source_url": "${url}", "package_type": "<mcp-server|agent-skill|library|cli-tool>",`,
1375
+ ` "risk_score": <0-100>, "result": "<safe|caution|unsafe>", "max_severity": "<none|low|medium|high|critical>",`,
1376
+ ` "findings_count": <n>, "findings": [{ "id": "...", "title": "...", "severity": "...", "category": "...",`,
1377
+ ` "description": "...", "file": "...", "line": <n>, "remediation": "...", "confidence": "...", "is_by_design": false }] }`,
1378
+ ``,
1379
+ `## Source Code`,
1380
+ codeBlock,
1381
+ ].join('\n');
1382
+
1383
+ let report = null;
1384
+
1385
+ try {
1386
+ if (anthropicKey) {
1387
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
1388
+ method: 'POST',
1389
+ headers: {
1390
+ 'x-api-key': anthropicKey,
1391
+ 'anthropic-version': '2023-06-01',
1392
+ 'content-type': 'application/json',
1393
+ },
1394
+ body: JSON.stringify({
1395
+ model: 'claude-sonnet-4-20250514',
1396
+ max_tokens: 8192,
1397
+ system: systemPrompt,
1398
+ messages: [{ role: 'user', content: userMessage }],
1399
+ }),
1400
+ signal: AbortSignal.timeout(120_000),
1401
+ });
1402
+ const data = await res.json();
1403
+ const text = data.content?.[0]?.text || '';
1404
+ report = extractJSON(text);
1405
+ } else if (openaiKey) {
1406
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
1407
+ method: 'POST',
1408
+ headers: {
1409
+ 'Authorization': `Bearer ${openaiKey}`,
1410
+ 'Content-Type': 'application/json',
1411
+ },
1412
+ body: JSON.stringify({
1413
+ model: 'gpt-4o',
1414
+ max_tokens: 8192,
1415
+ messages: [
1416
+ { role: 'system', content: systemPrompt },
1417
+ { role: 'user', content: userMessage },
1418
+ ],
1419
+ }),
1420
+ signal: AbortSignal.timeout(120_000),
1421
+ });
1422
+ const data = await res.json();
1423
+ const text = data.choices?.[0]?.message?.content || '';
1424
+ report = extractJSON(text);
1425
+ }
1426
+
1427
+ console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
1428
+ } catch (err) {
1429
+ console.log(` ${c.red}failed${c.reset}`);
1430
+ console.log(` ${c.red}${err.message}${c.reset}`);
1431
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1432
+ return null;
1433
+ }
1434
+
1435
+ // Cleanup repo
1436
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1437
+
1438
+ if (!report) {
1439
+ console.log(` ${c.red}Could not parse LLM response as JSON${c.reset}`);
1440
+ return null;
1441
+ }
1442
+
1443
+ // Display results
1444
+ console.log();
1445
+ const riskScore = report.risk_score || 0;
1446
+ console.log(` ${riskBadge(riskScore)} Risk ${riskScore}/100 ${c.bold}${report.result || 'unknown'}${c.reset}`);
1447
+ console.log();
1448
+
1449
+ if (report.findings && report.findings.length > 0) {
1450
+ console.log(` ${c.bold}Findings (${report.findings.length})${c.reset}`);
1451
+ console.log();
1452
+ for (const f of report.findings) {
1453
+ const sc = severityColor(f.severity);
1454
+ console.log(` ${severityIcon(f.severity)} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
1455
+ if (f.file) console.log(` ${c.dim}${f.file}${f.line ? ':' + f.line : ''}${c.reset}`);
1456
+ if (f.description) console.log(` ${c.dim}${f.description.slice(0, 120)}${c.reset}`);
1457
+ console.log();
1458
+ }
1459
+ } else {
1460
+ console.log(` ${c.green}No findings package looks clean.${c.reset}`);
1461
+ console.log();
1462
+ }
1463
+
1464
+ // Upload to registry
1465
+ const creds = loadCredentials();
1466
+ if (creds) {
1467
+ process.stdout.write(` Uploading report to registry...`);
1468
+ try {
1469
+ const res = await fetch(`${REGISTRY_URL}/api/reports`, {
1470
+ method: 'POST',
1471
+ headers: {
1472
+ 'Authorization': `Bearer ${creds.api_key}`,
1473
+ 'Content-Type': 'application/json',
1474
+ },
1475
+ body: JSON.stringify(report),
1476
+ signal: AbortSignal.timeout(15_000),
1477
+ });
1478
+ if (res.ok) {
1479
+ const data = await res.json();
1480
+ console.log(` ${c.green}done${c.reset}`);
1481
+ console.log(` ${c.dim}Report: ${REGISTRY_URL}/skills/${slug}${c.reset}`);
1482
+ } else {
1483
+ console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
1484
+ }
1485
+ } catch (err) {
1486
+ console.log(` ${c.yellow}failed${c.reset}`);
1487
+ }
1488
+ } else {
1489
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to upload reports to the registry${c.reset}`);
1490
+ }
1491
+
1492
+ console.log();
1493
+ return report;
1494
+ }
1495
+
1496
+ // ── Check command ───────────────────────────────────────
1497
+
1498
+ async function checkPackage(name) {
1499
+ if (!jsonMode) {
1500
+ console.log(`${icons.info} Looking up ${c.bold}${name}${c.reset} in registry...`);
1501
+ console.log();
1502
+ }
1503
+
1504
+ const data = await checkRegistry(name);
1505
+ if (!data) {
1506
+ if (!jsonMode) {
1507
+ console.log(` ${c.yellow}Not found${c.reset} package "${name}" hasn't been audited yet.`);
1508
+ console.log(` ${c.dim}Run: agentaudit audit <repo-url> for a deep LLM audit${c.reset}`);
1509
+ }
1510
+ return null;
1511
+ }
1512
+
1513
+ if (!jsonMode) {
1514
+ const riskScore = data.risk_score ?? data.latest_risk_score ?? 0;
1515
+ console.log(` ${c.bold}${name}${c.reset} ${riskBadge(riskScore)}`);
1516
+ console.log(` ${c.dim}Risk Score: ${riskScore}/100${c.reset}`);
1517
+ if (data.source_url) console.log(` ${c.dim}Source: ${data.source_url}${c.reset}`);
1518
+ console.log(` ${c.dim}Registry: ${REGISTRY_URL}/skills/${name}${c.reset}`);
1519
+ if (data.has_official_audit) console.log(` ${c.green} Officially audited${c.reset}`);
1520
+ console.log();
1521
+ }
1522
+ return data;
1523
+ }
1524
+
1525
+ // ── Main ────────────────────────────────────────────────
1526
+
1527
+ async function main() {
1528
+ const rawArgs = process.argv.slice(2);
1529
+
1530
+ // MCP server mode: launched by an editor (no TTY + no args) or explicit --stdio flag
1531
+ if (rawArgs.includes('--stdio') || (!process.stdin.isTTY && rawArgs.length === 0)) {
1532
+ await import('./index.mjs');
1533
+ return;
1534
+ }
1535
+
1536
+ // Parse global flags early
1537
+ jsonMode = rawArgs.includes('--json');
1538
+ quietMode = rawArgs.includes('--quiet') || rawArgs.includes('-q');
1539
+ // --no-color already handled at top level for `c` object
1540
+
1541
+ // Strip global flags from args
1542
+ const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color']);
1543
+ const args = rawArgs.filter(a => !globalFlags.has(a));
1544
+
1545
+ if (args[0] === '-v' || args[0] === '--version') {
1546
+ console.log(`agentaudit ${getVersion()}`);
1547
+ process.exitCode = 0; return;
1548
+ }
1549
+
1550
+ if (args[0] === '--help' || args[0] === '-h') {
1551
+ banner();
1552
+ console.log(` ${c.bold}Commands:${c.reset}`);
1553
+ console.log();
1554
+ console.log(` ${c.cyan}agentaudit${c.reset} Discover MCP servers (same as discover)`);
1555
+ console.log(` ${c.cyan}agentaudit discover${c.reset} Find MCP servers in your AI editors (Cursor, Claude, VS Code, Windsurf)`);
1556
+ console.log(` ${c.cyan}agentaudit discover --quick${c.reset} Discover + auto-scan all servers`);
1557
+ console.log(` ${c.cyan}agentaudit discover --deep${c.reset} Discover + select servers to deep-audit`);
1558
+ console.log(` ${c.cyan}agentaudit scan${c.reset} <url> [url...] Quick static scan (regex, local)`);
1559
+ console.log(` ${c.cyan}agentaudit scan${c.reset} <url> ${c.dim}--deep${c.reset} Deep audit (same as audit)`);
1560
+ console.log(` ${c.cyan}agentaudit audit${c.reset} <url> [url...] Deep LLM-powered security audit`);
1561
+ console.log(` ${c.cyan}agentaudit lookup${c.reset} <name> Look up package in registry`);
1562
+ console.log(` ${c.cyan}agentaudit setup${c.reset} Register + configure API key`);
1563
+ console.log();
1564
+ console.log(` ${c.bold}Global flags:${c.reset}`);
1565
+ console.log(` ${c.dim}--json Output JSON to stdout (machine-readable)${c.reset}`);
1566
+ console.log(` ${c.dim}--quiet Suppress banner and tree visualization${c.reset}`);
1567
+ console.log(` ${c.dim}--no-color Disable ANSI colors (also: NO_COLOR env)${c.reset}`);
1568
+ console.log();
1569
+ console.log(` ${c.bold}Quick Scan${c.reset} vs ${c.bold}Deep Audit${c.reset}:`);
1570
+ console.log(` ${c.dim}scan = fast regex-based static analysis (~2s)${c.reset}`);
1571
+ console.log(` ${c.dim}audit = deep LLM analysis with 3-pass methodology (~30s)${c.reset}`);
1572
+ console.log();
1573
+ console.log(` ${c.bold}Exit codes:${c.reset}`);
1574
+ console.log(` ${c.dim}0 = clean / success 1 = findings detected 2 = error${c.reset}`);
1575
+ console.log();
1576
+ console.log(` ${c.bold}Examples:${c.reset}`);
1577
+ console.log(` agentaudit`);
1578
+ console.log(` agentaudit discover --quick`);
1579
+ console.log(` agentaudit scan https://github.com/owner/repo`);
1580
+ console.log(` agentaudit audit https://github.com/owner/repo`);
1581
+ console.log(` agentaudit lookup fastmcp --json`);
1582
+ console.log();
1583
+ console.log(` ${c.bold}For deep audits,${c.reset} set an LLM API key:`);
1584
+ if (process.platform === 'win32') {
1585
+ console.log(` ${c.dim}PowerShell: $env:ANTHROPIC_API_KEY = "sk-ant-..."${c.reset}`);
1586
+ console.log(` ${c.dim}CMD: set ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1587
+ console.log(` ${c.dim}(or use OPENAI_API_KEY instead)${c.reset}`);
1588
+ } else {
1589
+ console.log(` ${c.dim}export ANTHROPIC_API_KEY=sk-ant-...${c.reset} ${c.dim}(or OPENAI_API_KEY)${c.reset}`);
1590
+ }
1591
+ console.log();
1592
+ console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed):${c.reset}`);
1593
+ console.log(` ${c.dim}Add to your MCP config:${c.reset}`);
1594
+ console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
1595
+ console.log();
1596
+ process.exitCode = 0; return;
1597
+ }
1598
+
1599
+ // Default no-arg → discover
1600
+ const command = args.length === 0 ? 'discover' : args[0];
1601
+ const targets = args.slice(1);
1602
+
1603
+ banner();
1604
+
1605
+ if (command === 'setup') {
1606
+ await setupCommand();
1607
+ return;
1608
+ }
1609
+
1610
+ if (command === 'discover') {
1611
+ const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
1612
+ const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
1613
+ await discoverCommand({ scan: scanFlag, audit: auditFlag });
1614
+ return;
1615
+ }
1616
+
1617
+ if (command === 'lookup' || command === 'check') {
1618
+ const names = targets.filter(t => !t.startsWith('--'));
1619
+ if (names.length === 0) {
1620
+ console.log(` ${c.red}Error: package name required${c.reset}`);
1621
+ process.exitCode = 2;
1622
+ return;
1623
+ }
1624
+ const results = [];
1625
+ for (const t of names) {
1626
+ const data = await checkPackage(t);
1627
+ results.push(data);
1628
+ }
1629
+ if (jsonMode) {
1630
+ console.log(JSON.stringify(results.length === 1 ? (results[0] || { error: 'not_found' }) : results, null, 2));
1631
+ }
1632
+ process.exitCode = 0; return;
1633
+ return;
1634
+ }
1635
+
1636
+ if (command === 'scan') {
1637
+ const deepFlag = targets.includes('--deep');
1638
+ const urls = targets.filter(t => !t.startsWith('--'));
1639
+ if (urls.length === 0) {
1640
+ console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
1641
+ console.log(` ${c.dim}Tip: use ${c.cyan}agentaudit discover${c.dim} to find & check locally installed MCP servers${c.reset}`);
1642
+ console.log(` ${c.dim}Tip: use ${c.cyan}agentaudit audit <url>${c.dim} for a deep LLM-powered audit${c.reset}`);
1643
+ process.exitCode = 2;
1644
+ return;
1645
+ }
1646
+
1647
+ // --deep redirects to audit flow
1648
+ if (deepFlag) {
1649
+ let hasFindings = false;
1650
+ for (const url of urls) {
1651
+ const report = await auditRepo(url);
1652
+ if (report?.findings?.length > 0) hasFindings = true;
1653
+ }
1654
+ process.exitCode = hasFindings ? 1 : 0;
1655
+ return;
1656
+ }
1657
+
1658
+ const results = [];
1659
+ let hadErrors = false;
1660
+ for (const url of urls) {
1661
+ const result = await scanRepo(url);
1662
+ if (result) results.push(result);
1663
+ else hadErrors = true;
1664
+ }
1665
+
1666
+ if (jsonMode) {
1667
+ const jsonOut = results.map(r => ({
1668
+ slug: r.slug,
1669
+ url: r.url,
1670
+ findings: r.findings.map(f => ({
1671
+ severity: f.severity,
1672
+ title: f.title,
1673
+ file: f.file,
1674
+ line: f.line,
1675
+ snippet: f.snippet,
1676
+ })),
1677
+ fileCount: r.files,
1678
+ duration: r.duration,
1679
+ }));
1680
+ console.log(JSON.stringify(jsonOut.length === 1 ? jsonOut[0] : jsonOut, null, 2));
1681
+ } else if (results.length > 1) {
1682
+ printSummary(results);
1683
+ }
1684
+
1685
+ if (hadErrors && results.length === 0) { process.exitCode = 2; return; }
1686
+ const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
1687
+ process.exitCode = totalFindings > 0 ? 1 : 0;
1688
+ return;
1689
+ }
1690
+
1691
+ if (command === 'audit') {
1692
+ const urls = targets.filter(t => !t.startsWith('--'));
1693
+ if (urls.length === 0) {
1694
+ console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
1695
+ process.exitCode = 2;
1696
+ return;
1697
+ }
1698
+
1699
+ let hasFindings = false;
1700
+ for (const url of urls) {
1701
+ const report = await auditRepo(url);
1702
+ if (report?.findings?.length > 0) hasFindings = true;
1703
+ }
1704
+ process.exitCode = hasFindings ? 1 : 0;
1705
+ return;
1706
+ }
1707
+
1708
+ console.log(` ${c.red}Unknown command: ${command}${c.reset}`);
1709
+ console.log(` ${c.dim}Run agentaudit --help for usage${c.reset}`);
1710
+ process.exitCode = 2;
1711
+ }
1712
+
1713
+ main().catch(err => {
1714
+ console.error(`${c.red}Error: ${err.message}${c.reset}`);
1715
+ process.exitCode = 2;
1716
+ });