agentaudit 3.9.8 → 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.
- package/LICENSE +34 -34
- package/README.md +504 -504
- package/cli.mjs +1716 -1680
- package/index.mjs +605 -605
- package/package.json +45 -45
- 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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}));
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
console.log();
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
});
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
if (
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
console.log();
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
console.log();
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
console.log();
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
console.log(
|
|
1240
|
-
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
const
|
|
1261
|
-
const
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
console.log(`
|
|
1276
|
-
|
|
1277
|
-
console.log(`
|
|
1278
|
-
|
|
1279
|
-
console.log(`
|
|
1280
|
-
console.log(`
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
`
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
const
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
console.log(` ${c.
|
|
1517
|
-
console.log();
|
|
1518
|
-
console.log(`
|
|
1519
|
-
console.log(`
|
|
1520
|
-
console.log(
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
console.log();
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
}
|
|
1555
|
-
console.log();
|
|
1556
|
-
console.log(`
|
|
1557
|
-
console.log(` ${c.
|
|
1558
|
-
console.log(` ${c.
|
|
1559
|
-
console.log();
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
process.exitCode = 0; return;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
+
});
|