agentaudit 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.mjs ADDED
@@ -0,0 +1,693 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AgentAudit CLI — Beautiful terminal output for security audits
4
+ *
5
+ * Usage:
6
+ * agentaudit setup Interactive setup (register + API key)
7
+ * agentaudit scan <repo-url> [repo-url...] Scan repositories
8
+ * agentaudit check <package-name> Look up in registry
9
+ *
10
+ * Examples:
11
+ * agentaudit setup
12
+ * agentaudit scan https://github.com/owner/repo
13
+ * agentaudit scan repo1 repo2 repo3
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { execSync } from 'child_process';
19
+ import { createInterface } from 'readline';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const SKILL_DIR = path.resolve(__dirname);
24
+ const REGISTRY_URL = 'https://agentaudit.dev';
25
+
26
+ // ── ANSI Colors ──────────────────────────────────────────
27
+
28
+ const c = {
29
+ reset: '\x1b[0m',
30
+ bold: '\x1b[1m',
31
+ dim: '\x1b[2m',
32
+ red: '\x1b[31m',
33
+ green: '\x1b[32m',
34
+ yellow: '\x1b[33m',
35
+ blue: '\x1b[34m',
36
+ magenta: '\x1b[35m',
37
+ cyan: '\x1b[36m',
38
+ white: '\x1b[37m',
39
+ gray: '\x1b[90m',
40
+ bgRed: '\x1b[41m',
41
+ bgGreen: '\x1b[42m',
42
+ bgYellow: '\x1b[43m',
43
+ };
44
+
45
+ const icons = {
46
+ safe: `${c.green}✔${c.reset}`,
47
+ caution: `${c.yellow}⚠${c.reset}`,
48
+ unsafe: `${c.red}✖${c.reset}`,
49
+ info: `${c.blue}ℹ${c.reset}`,
50
+ scan: `${c.cyan}◉${c.reset}`,
51
+ tree: `${c.gray}├──${c.reset}`,
52
+ treeLast: `${c.gray}└──${c.reset}`,
53
+ pipe: `${c.gray}│${c.reset}`,
54
+ bullet: `${c.gray}•${c.reset}`,
55
+ };
56
+
57
+ // ── Credentials ─────────────────────────────────────────
58
+
59
+ const home = process.env.HOME || process.env.USERPROFILE || '';
60
+ const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
61
+ const USER_CRED_DIR = path.join(xdgConfig, 'agentaudit');
62
+ const USER_CRED_FILE = path.join(USER_CRED_DIR, 'credentials.json');
63
+ const SKILL_CRED_FILE = path.join(SKILL_DIR, 'config', 'credentials.json');
64
+
65
+ function loadCredentials() {
66
+ for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
67
+ if (fs.existsSync(f)) {
68
+ try {
69
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
70
+ if (data.api_key) return data;
71
+ } catch {}
72
+ }
73
+ }
74
+ if (process.env.AGENTAUDIT_API_KEY) {
75
+ return { api_key: process.env.AGENTAUDIT_API_KEY, agent_name: 'env' };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function saveCredentials(data) {
81
+ const json = JSON.stringify(data, null, 2);
82
+ fs.mkdirSync(USER_CRED_DIR, { recursive: true });
83
+ fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
84
+ try {
85
+ fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
86
+ fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
87
+ } catch {}
88
+ }
89
+
90
+ function askQuestion(question) {
91
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
92
+ return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim()); }));
93
+ }
94
+
95
+ async function registerAgent(agentName) {
96
+ const res = await fetch(`${REGISTRY_URL}/api/register`, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ agent_name: agentName }),
100
+ signal: AbortSignal.timeout(15_000),
101
+ });
102
+ if (!res.ok) throw new Error(`Registration failed (HTTP ${res.status}): ${await res.text()}`);
103
+ return res.json();
104
+ }
105
+
106
+ async function setupCommand() {
107
+ console.log(` ${c.bold}Setup${c.reset}`);
108
+ console.log();
109
+
110
+ const existing = loadCredentials();
111
+ if (existing) {
112
+ console.log(` ${icons.safe} Already configured as ${c.bold}${existing.agent_name}${c.reset}`);
113
+ console.log(` ${c.dim}Key: ${existing.api_key.slice(0, 8)}...${c.reset}`);
114
+ console.log();
115
+ const answer = await askQuestion(` Reconfigure? ${c.dim}(y/N)${c.reset} `);
116
+ if (answer.toLowerCase() !== 'y') {
117
+ console.log(` ${c.dim}Keeping existing config.${c.reset}`);
118
+ return;
119
+ }
120
+ console.log();
121
+ }
122
+
123
+ console.log(` ${c.bold}1)${c.reset} Register new agent ${c.dim}(free, creates API key automatically)${c.reset}`);
124
+ console.log(` ${c.bold}2)${c.reset} Enter existing API key`);
125
+ console.log();
126
+ const choice = await askQuestion(` Choice ${c.dim}(1/2)${c.reset}: `);
127
+ console.log();
128
+
129
+ if (choice === '2') {
130
+ const key = await askQuestion(` API Key: `);
131
+ if (!key) { console.log(` ${c.red}No key entered.${c.reset}`); return; }
132
+ const name = await askQuestion(` Agent name ${c.dim}(optional)${c.reset}: `);
133
+ saveCredentials({ api_key: key, agent_name: name || 'custom' });
134
+ console.log();
135
+ console.log(` ${icons.safe} Saved! Key stored in ${c.dim}${USER_CRED_FILE}${c.reset}`);
136
+ } else {
137
+ const name = await askQuestion(` Agent name ${c.dim}(e.g. my-scanner, claude-desktop)${c.reset}: `);
138
+ if (!name || !/^[a-zA-Z0-9._-]{2,64}$/.test(name)) {
139
+ console.log(` ${c.red}Invalid name. Use 2-64 chars: letters, numbers, dash, underscore, dot.${c.reset}`);
140
+ return;
141
+ }
142
+ process.stdout.write(` Registering ${c.bold}${name}${c.reset}...`);
143
+ try {
144
+ const data = await registerAgent(name);
145
+ saveCredentials({ api_key: data.api_key, agent_name: data.agent_name });
146
+ console.log(` ${c.green}done!${c.reset}`);
147
+ console.log();
148
+ console.log(` ${icons.safe} Registered as ${c.bold}${data.agent_name}${c.reset}`);
149
+ console.log(` ${c.dim}Key: ${data.api_key.slice(0, 12)}...${c.reset}`);
150
+ console.log(` ${c.dim}Saved to: ${USER_CRED_FILE}${c.reset}`);
151
+ } catch (err) {
152
+ console.log(` ${c.red}failed${c.reset}`);
153
+ console.log(` ${c.red}${err.message}${c.reset}`);
154
+ return;
155
+ }
156
+ }
157
+
158
+ console.log();
159
+ console.log(` ${c.bold}Ready!${c.reset} You can now:`);
160
+ console.log(` ${c.dim}•${c.reset} Scan packages: ${c.cyan}agentaudit scan <repo-url>${c.reset}`);
161
+ console.log(` ${c.dim}•${c.reset} Check registry: ${c.cyan}agentaudit check <name>${c.reset}`);
162
+ console.log(` ${c.dim}•${c.reset} Submit reports via MCP in Claude/Cursor/Windsurf`);
163
+ console.log();
164
+ }
165
+
166
+ // ── Helpers ──────────────────────────────────────────────
167
+
168
+ function banner() {
169
+ console.log();
170
+ console.log(` ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v1.0.0${c.reset}`);
171
+ console.log(` ${c.dim}Security scanner for AI packages${c.reset}`);
172
+ console.log();
173
+ }
174
+
175
+ function slugFromUrl(url) {
176
+ const match = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
177
+ if (match) return match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
178
+ return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
179
+ }
180
+
181
+ function elapsed(startMs) {
182
+ const ms = Date.now() - startMs;
183
+ if (ms < 1000) return `${ms}ms`;
184
+ return `${(ms / 1000).toFixed(1)}s`;
185
+ }
186
+
187
+ function riskBadge(score) {
188
+ if (score === 0) return `${c.bgGreen}${c.bold}${c.white} SAFE ${c.reset}`;
189
+ if (score <= 10) return `${c.bgGreen}${c.white} LOW ${c.reset}`;
190
+ if (score <= 30) return `${c.bgYellow}${c.bold} CAUTION ${c.reset}`;
191
+ return `${c.bgRed}${c.bold}${c.white} UNSAFE ${c.reset}`;
192
+ }
193
+
194
+ function severityColor(sev) {
195
+ switch (sev) {
196
+ case 'critical': return c.red;
197
+ case 'high': return c.red;
198
+ case 'medium': return c.yellow;
199
+ case 'low': return c.blue;
200
+ default: return c.gray;
201
+ }
202
+ }
203
+
204
+ function severityIcon(sev) {
205
+ switch (sev) {
206
+ case 'critical': return `${c.red}●${c.reset}`;
207
+ case 'high': return `${c.red}●${c.reset}`;
208
+ case 'medium': return `${c.yellow}●${c.reset}`;
209
+ case 'low': return `${c.blue}●${c.reset}`;
210
+ default: return `${c.green}●${c.reset}`;
211
+ }
212
+ }
213
+
214
+ // ── File Collection (same logic as MCP server) ──────────
215
+
216
+ const MAX_FILE_SIZE = 50_000;
217
+ const MAX_TOTAL_SIZE = 300_000;
218
+ const SKIP_DIRS = new Set([
219
+ 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
220
+ '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
221
+ 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
222
+ 'examples', 'example', 'fixtures', '.github', '.vscode', '.idea',
223
+ 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
224
+ ]);
225
+ const SKIP_EXTENSIONS = new Set([
226
+ '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
227
+ '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
228
+ '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
229
+ '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
230
+ ]);
231
+
232
+ function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
233
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
234
+ let entries;
235
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
236
+ catch { return collected; }
237
+ entries.sort((a, b) => a.name.localeCompare(b.name));
238
+ for (const entry of entries) {
239
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
240
+ const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
241
+ const fullPath = path.join(dir, entry.name);
242
+ if (entry.isDirectory()) {
243
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
244
+ collectFiles(fullPath, relPath, collected, totalSize);
245
+ } else {
246
+ const ext = path.extname(entry.name).toLowerCase();
247
+ if (SKIP_EXTENSIONS.has(ext)) continue;
248
+ try {
249
+ const stat = fs.statSync(fullPath);
250
+ if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
251
+ const content = fs.readFileSync(fullPath, 'utf8');
252
+ totalSize.bytes += content.length;
253
+ collected.push({ path: relPath, content, size: stat.size });
254
+ } catch {}
255
+ }
256
+ }
257
+ return collected;
258
+ }
259
+
260
+ // ── Detect package properties ───────────────────────────
261
+
262
+ function detectPackageInfo(repoPath, files) {
263
+ const info = { type: 'unknown', tools: [], prompts: [], language: 'unknown', entrypoint: null };
264
+
265
+ // Detect language
266
+ const exts = files.map(f => path.extname(f.path).toLowerCase());
267
+ const extCounts = {};
268
+ exts.forEach(e => { extCounts[e] = (extCounts[e] || 0) + 1; });
269
+ const topExt = Object.entries(extCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
270
+
271
+ const langMap = { '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript', '.mjs': 'JavaScript', '.rs': 'Rust', '.go': 'Go', '.java': 'Java', '.rb': 'Ruby' };
272
+ info.language = langMap[topExt] || topExt || 'unknown';
273
+
274
+ // Detect package type
275
+ const allContent = files.map(f => f.content).join('\n');
276
+ if (allContent.includes('@modelcontextprotocol') || allContent.includes('FastMCP') || allContent.includes('mcp.server') || allContent.includes('mcp_server')) {
277
+ info.type = 'mcp-server';
278
+ } else if (files.some(f => f.path.toLowerCase() === 'skill.md')) {
279
+ info.type = 'agent-skill';
280
+ } else if (allContent.includes('#!/usr/bin/env') || allContent.includes('argparse') || allContent.includes('commander')) {
281
+ info.type = 'cli-tool';
282
+ } else {
283
+ info.type = 'library';
284
+ }
285
+
286
+ // Extract MCP tools (look for tool definitions)
287
+ const toolPatterns = [
288
+ // JS/TS: name: 'tool_name' or "tool_name" in tool definitions
289
+ /(?:name|tool_name)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
290
+ // Python: @mcp.tool() def func_name or Tool(name="...")
291
+ /(?:@(?:mcp|server)\.tool\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*))|(?:Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"])/gi,
292
+ // Direct: tool names in ListTools handlers
293
+ /['"]name['"]\s*:\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
294
+ ];
295
+
296
+ const toolSet = new Set();
297
+ for (const file of files) {
298
+ for (const pattern of toolPatterns) {
299
+ pattern.lastIndex = 0;
300
+ let m;
301
+ while ((m = pattern.exec(file.content)) !== null) {
302
+ const name = m[1] || m[2];
303
+ if (name && name.length > 2 && name.length < 50 && !['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none'].includes(name)) {
304
+ toolSet.add(name);
305
+ }
306
+ }
307
+ }
308
+ }
309
+ info.tools = [...toolSet];
310
+
311
+ // Extract prompts (look for prompt definitions)
312
+ const promptPatterns = [
313
+ /(?:prompt|PROMPT)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
314
+ /@(?:mcp|server)\.prompt\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
315
+ ];
316
+ const promptSet = new Set();
317
+ for (const file of files) {
318
+ for (const pattern of promptPatterns) {
319
+ pattern.lastIndex = 0;
320
+ let m;
321
+ while ((m = pattern.exec(file.content)) !== null) {
322
+ if (m[1] && m[1].length > 2) promptSet.add(m[1]);
323
+ }
324
+ }
325
+ }
326
+ info.prompts = [...promptSet];
327
+
328
+ // Detect entrypoint
329
+ const entryFiles = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
330
+ for (const ef of entryFiles) {
331
+ if (files.some(f => f.path === ef)) { info.entrypoint = ef; break; }
332
+ }
333
+
334
+ return info;
335
+ }
336
+
337
+ // ── Quick static checks ─────────────────────────────────
338
+
339
+ function quickChecks(files) {
340
+ const findings = [];
341
+
342
+ const checks = [
343
+ {
344
+ id: 'EXEC_INJECTION',
345
+ title: 'Command injection risk',
346
+ severity: 'high',
347
+ pattern: /(?:exec(?:Sync)?|spawn|child_process|subprocess|os\.system|os\.popen|Popen)\s*\([^)]*(?:\$\{|`|\+\s*(?:req|input|args|param|user|query))/i,
348
+ category: 'injection',
349
+ },
350
+ {
351
+ id: 'EVAL_USAGE',
352
+ title: 'Dynamic code evaluation',
353
+ severity: 'high',
354
+ pattern: /(?:^|[^a-z])eval\s*\([^)]*(?:input|req|user|param|arg|query)/im,
355
+ category: 'injection',
356
+ },
357
+ {
358
+ id: 'HARDCODED_SECRET',
359
+ title: 'Potential hardcoded secret',
360
+ severity: 'medium',
361
+ pattern: /(?:api[_-]?key|password|secret|token)\s*[:=]\s*['"][A-Za-z0-9+/=_-]{16,}['"]/i,
362
+ category: 'secrets',
363
+ },
364
+ {
365
+ id: 'SSL_DISABLED',
366
+ title: 'SSL/TLS verification disabled',
367
+ severity: 'medium',
368
+ pattern: /(?:rejectUnauthorized\s*:\s*false|verify\s*=\s*False|VERIFY_SSL\s*=\s*false|NODE_TLS_REJECT_UNAUTHORIZED|InsecureRequestWarning)/i,
369
+ category: 'crypto',
370
+ },
371
+ {
372
+ id: 'PATH_TRAVERSAL',
373
+ title: 'Potential path traversal',
374
+ severity: 'medium',
375
+ pattern: /(?:\.\.\/|\.\.\\|path\.join|os\.path\.join)\s*\([^)]*(?:input|req|user|param|arg|query)/i,
376
+ category: 'filesystem',
377
+ },
378
+ {
379
+ id: 'CORS_WILDCARD',
380
+ title: 'Wildcard CORS origin',
381
+ severity: 'low',
382
+ pattern: /(?:Access-Control-Allow-Origin|cors)\s*[:({]\s*['"]\*/i,
383
+ category: 'network',
384
+ },
385
+ {
386
+ id: 'TELEMETRY',
387
+ title: 'Undisclosed telemetry',
388
+ severity: 'low',
389
+ pattern: /(?:posthog|mixpanel|analytics|telemetry|tracking|sentry).*(?:init|setup|track|capture)/i,
390
+ category: 'privacy',
391
+ },
392
+ {
393
+ id: 'SHELL_EXEC',
394
+ title: 'Shell command execution',
395
+ severity: 'high',
396
+ pattern: /(?:subprocess\.(?:run|call|Popen)|os\.system|os\.popen|execSync|child_process\.exec)\s*\(/i,
397
+ category: 'injection',
398
+ },
399
+ {
400
+ id: 'SQL_INJECTION',
401
+ title: 'Potential SQL injection',
402
+ severity: 'high',
403
+ pattern: /(?:execute|query|raw)\s*\(\s*(?:f['"]|['"].*?%s|['"].*?\{|['"].*?\+)/i,
404
+ category: 'injection',
405
+ },
406
+ {
407
+ id: 'YAML_UNSAFE',
408
+ title: 'Unsafe YAML loading',
409
+ severity: 'medium',
410
+ pattern: /yaml\.(?:load|unsafe_load)\s*\(/i,
411
+ category: 'deserialization',
412
+ },
413
+ {
414
+ id: 'PICKLE_LOAD',
415
+ title: 'Unsafe deserialization (pickle)',
416
+ severity: 'high',
417
+ pattern: /pickle\.loads?\s*\(/i,
418
+ category: 'deserialization',
419
+ },
420
+ {
421
+ id: 'PROMPT_INJECTION',
422
+ title: 'Prompt injection vector',
423
+ severity: 'high',
424
+ pattern: /(?:<IMPORTANT>|<SYSTEM>|ignore previous|you are now|new instructions)/i,
425
+ category: 'prompt-injection',
426
+ },
427
+ ];
428
+
429
+ for (const file of files) {
430
+ for (const check of checks) {
431
+ const match = check.pattern.exec(file.content);
432
+ if (match) {
433
+ // Find line number
434
+ const lines = file.content.slice(0, match.index).split('\n');
435
+ findings.push({
436
+ ...check,
437
+ file: file.path,
438
+ line: lines.length,
439
+ snippet: match[0].trim().slice(0, 80),
440
+ confidence: 'medium',
441
+ });
442
+ }
443
+ }
444
+ }
445
+
446
+ return findings;
447
+ }
448
+
449
+ // ── Registry check ──────────────────────────────────────
450
+
451
+ async function checkRegistry(slug) {
452
+ try {
453
+ const res = await fetch(`${REGISTRY_URL}/api/skills/${encodeURIComponent(slug)}`, {
454
+ signal: AbortSignal.timeout(5000),
455
+ });
456
+ if (res.ok) return await res.json();
457
+ } catch {}
458
+ return null;
459
+ }
460
+
461
+ // ── Print results ───────────────────────────────────────
462
+
463
+ function printScanResult(url, info, files, findings, registryData, duration) {
464
+ const slug = slugFromUrl(url);
465
+
466
+ // Header
467
+ console.log(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.dim}${url}${c.reset}`);
468
+ console.log(`${icons.pipe} ${c.dim}${info.language} ${info.type}${c.reset} ${c.dim}${files.length} files scanned in ${duration}${c.reset}`);
469
+
470
+ // Tools & prompts tree
471
+ const items = [
472
+ ...info.tools.map(t => ({ kind: 'tool', name: t })),
473
+ ...info.prompts.map(p => ({ kind: 'prompt', name: p })),
474
+ ];
475
+
476
+ if (items.length > 0) {
477
+ console.log(`${icons.pipe}`);
478
+ for (let i = 0; i < items.length; i++) {
479
+ const isLast = i === items.length - 1 && findings.length === 0;
480
+ const branch = isLast ? icons.treeLast : icons.tree;
481
+ const item = items[i];
482
+ const kindLabel = item.kind === 'tool' ? `${c.dim}tool${c.reset} ` : `${c.dim}prompt${c.reset}`;
483
+ const padName = item.name.padEnd(28);
484
+
485
+ // Check if this tool has a finding associated
486
+ const toolFinding = findings.find(f =>
487
+ f.snippet && f.snippet.toLowerCase().includes(item.name.toLowerCase())
488
+ );
489
+
490
+ if (toolFinding) {
491
+ const sc = severityColor(toolFinding.severity);
492
+ console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${sc}⚠ flagged${c.reset} — ${toolFinding.title}`);
493
+ } else {
494
+ console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${c.green}✔ ok${c.reset}`);
495
+ }
496
+ }
497
+ } else {
498
+ console.log(`${icons.pipe} ${c.dim}(no tools or prompts detected)${c.reset}`);
499
+ }
500
+
501
+ // Findings
502
+ if (findings.length > 0) {
503
+ console.log(`${icons.pipe}`);
504
+ console.log(`${icons.pipe} ${c.bold}Findings (${findings.length})${c.reset} ${c.dim}static analysis — may include false positives${c.reset}`);
505
+ for (let i = 0; i < findings.length; i++) {
506
+ const f = findings[i];
507
+ const isLast = i === findings.length - 1;
508
+ const branch = isLast ? icons.treeLast : icons.tree;
509
+ const pipeOrSpace = isLast ? ' ' : `${icons.pipe} `;
510
+ const sc = severityColor(f.severity);
511
+ console.log(`${branch} ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
512
+ console.log(`${pipeOrSpace} ${c.dim}${f.file}:${f.line}${c.reset} ${c.dim}${f.snippet || ''}${c.reset}`);
513
+ }
514
+ }
515
+
516
+ // Registry status
517
+ console.log(`${icons.pipe}`);
518
+ if (registryData) {
519
+ const rd = registryData;
520
+ const riskScore = rd.risk_score ?? rd.latest_risk_score ?? 0;
521
+ console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${riskBadge(riskScore)} Risk ${riskScore} ${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
522
+ } else {
523
+ console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${c.dim}not audited yet${c.reset}`);
524
+ }
525
+
526
+ console.log();
527
+ }
528
+
529
+ function printSummary(results) {
530
+ const total = results.length;
531
+ const safe = results.filter(r => r.findings.length === 0).length;
532
+ const withFindings = total - safe;
533
+ const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
534
+
535
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
536
+ console.log(` ${c.bold}Summary${c.reset} ${total} packages scanned`);
537
+ console.log();
538
+ if (safe > 0) console.log(` ${icons.safe} ${c.green}${safe} clean${c.reset}`);
539
+ if (withFindings > 0) console.log(` ${icons.caution} ${c.yellow}${withFindings} with findings${c.reset} (${totalFindings} total)`);
540
+
541
+ // Breakdown by severity
542
+ const bySev = {};
543
+ results.forEach(r => r.findings.forEach(f => {
544
+ bySev[f.severity] = (bySev[f.severity] || 0) + 1;
545
+ }));
546
+ if (Object.keys(bySev).length > 0) {
547
+ console.log();
548
+ for (const sev of ['critical', 'high', 'medium', 'low']) {
549
+ if (bySev[sev]) {
550
+ console.log(` ${severityIcon(sev)} ${bySev[sev]}× ${severityColor(sev)}${sev}${c.reset}`);
551
+ }
552
+ }
553
+ }
554
+
555
+ console.log();
556
+ }
557
+
558
+ // ── Clone & Scan ────────────────────────────────────────
559
+
560
+ async function scanRepo(url) {
561
+ const start = Date.now();
562
+ const slug = slugFromUrl(url);
563
+
564
+ process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}...${c.reset}`);
565
+
566
+ // Clone
567
+ const tmpDir = fs.mkdtempSync('/tmp/agentaudit-');
568
+ const repoPath = path.join(tmpDir, 'repo');
569
+ try {
570
+ execSync(`git clone --depth 1 "${url}" "${repoPath}" 2>/dev/null`, {
571
+ timeout: 30_000,
572
+ stdio: 'pipe',
573
+ });
574
+ } catch (err) {
575
+ process.stdout.write(` ${c.red}✖ clone failed${c.reset}\n`);
576
+ return null;
577
+ }
578
+
579
+ // Collect files
580
+ const files = collectFiles(repoPath);
581
+
582
+ // Detect info
583
+ const info = detectPackageInfo(repoPath, files);
584
+
585
+ // Quick checks
586
+ const findings = quickChecks(files);
587
+
588
+ // Registry lookup
589
+ const registryData = await checkRegistry(slug);
590
+
591
+ // Cleanup
592
+ try { execSync(`rm -rf "${tmpDir}"`, { stdio: 'pipe' }); } catch {}
593
+
594
+ const duration = elapsed(start);
595
+
596
+ // Clear the "Scanning..." line
597
+ process.stdout.write('\r\x1b[K');
598
+
599
+ // Print result
600
+ printScanResult(url, info, files, findings, registryData, duration);
601
+
602
+ return { slug, url, info, files: files.length, findings, registryData, duration };
603
+ }
604
+
605
+ // ── Check command ───────────────────────────────────────
606
+
607
+ async function checkPackage(name) {
608
+ console.log(`${icons.info} Looking up ${c.bold}${name}${c.reset} in registry...`);
609
+ console.log();
610
+
611
+ const data = await checkRegistry(name);
612
+ if (!data) {
613
+ console.log(` ${c.yellow}Not found${c.reset} — package "${name}" hasn't been audited yet.`);
614
+ console.log(` ${c.dim}Run: agentaudit scan <repo-url> to audit it${c.reset}`);
615
+ return;
616
+ }
617
+
618
+ const riskScore = data.risk_score ?? data.latest_risk_score ?? 0;
619
+ console.log(` ${c.bold}${name}${c.reset} ${riskBadge(riskScore)}`);
620
+ console.log(` ${c.dim}Risk Score: ${riskScore}/100${c.reset}`);
621
+ if (data.source_url) console.log(` ${c.dim}Source: ${data.source_url}${c.reset}`);
622
+ console.log(` ${c.dim}Registry: ${REGISTRY_URL}/skills/${name}${c.reset}`);
623
+ if (data.has_official_audit) console.log(` ${c.green}✔ Officially audited${c.reset}`);
624
+ console.log();
625
+ }
626
+
627
+ // ── Main ────────────────────────────────────────────────
628
+
629
+ async function main() {
630
+ const args = process.argv.slice(2);
631
+
632
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
633
+ banner();
634
+ console.log(` ${c.bold}Usage:${c.reset}`);
635
+ console.log(` agentaudit setup Register + configure API key`);
636
+ console.log(` agentaudit scan <repo-url> [repo-url...] Scan repositories`);
637
+ console.log(` agentaudit check <package-name> Look up in registry`);
638
+ console.log();
639
+ console.log(` ${c.bold}Examples:${c.reset}`);
640
+ console.log(` agentaudit setup`);
641
+ console.log(` agentaudit scan https://github.com/owner/repo`);
642
+ console.log(` agentaudit scan repo1.git repo2.git repo3.git`);
643
+ console.log(` agentaudit check fastmcp`);
644
+ console.log();
645
+ process.exit(0);
646
+ }
647
+
648
+ const command = args[0];
649
+ const targets = args.slice(1);
650
+
651
+ banner();
652
+
653
+ if (command === 'setup') {
654
+ await setupCommand();
655
+ return;
656
+ }
657
+
658
+ if (command === 'check') {
659
+ if (targets.length === 0) {
660
+ console.log(` ${c.red}Error: package name required${c.reset}`);
661
+ process.exit(1);
662
+ }
663
+ for (const t of targets) await checkPackage(t);
664
+ return;
665
+ }
666
+
667
+ if (command === 'scan') {
668
+ if (targets.length === 0) {
669
+ console.log(` ${c.red}Error: at least one repository URL required${c.reset}`);
670
+ process.exit(1);
671
+ }
672
+
673
+ const results = [];
674
+ for (const url of targets) {
675
+ const result = await scanRepo(url);
676
+ if (result) results.push(result);
677
+ }
678
+
679
+ if (results.length > 1) {
680
+ printSummary(results);
681
+ }
682
+ return;
683
+ }
684
+
685
+ console.log(` ${c.red}Unknown command: ${command}${c.reset}`);
686
+ console.log(` ${c.dim}Run agentaudit --help for usage${c.reset}`);
687
+ process.exit(1);
688
+ }
689
+
690
+ main().catch(err => {
691
+ console.error(`${c.red}Error: ${err.message}${c.reset}`);
692
+ process.exit(1);
693
+ });