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