agentaudit 3.9.41 → 3.9.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +157 -67
- package/package.json +1 -1
package/cli.mjs
CHANGED
|
@@ -48,8 +48,8 @@ function resolveProvider(flagOverride, keys) {
|
|
|
48
48
|
if (preferred) {
|
|
49
49
|
const resolved = aliases[preferred] || preferred;
|
|
50
50
|
const p = providers[resolved];
|
|
51
|
-
if (
|
|
52
|
-
|
|
51
|
+
if (p) return p;
|
|
52
|
+
// Preferred provider not available (no API key) — fall through to inference
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
// Smart inference: if model is set, try to match it to a provider
|
|
@@ -558,18 +558,22 @@ const SKIP_FILES = new Set([
|
|
|
558
558
|
'.prettierignore', '.eslintignore',
|
|
559
559
|
]);
|
|
560
560
|
|
|
561
|
-
function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
|
|
562
|
-
if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
|
|
561
|
+
function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0, truncated: false, skippedPaths: [] }) {
|
|
562
|
+
if (totalSize.bytes >= MAX_TOTAL_SIZE) { totalSize.truncated = true; return collected; }
|
|
563
563
|
let entries;
|
|
564
564
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
565
565
|
catch { return collected; }
|
|
566
566
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
567
567
|
for (const entry of entries) {
|
|
568
|
-
if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
|
|
569
568
|
const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
569
|
+
if (totalSize.bytes >= MAX_TOTAL_SIZE) { totalSize.truncated = true; totalSize.skippedPaths.push(relPath); continue; }
|
|
570
570
|
const fullPath = path.join(dir, entry.name);
|
|
571
|
+
// SECURITY: Never follow symlinks — attacker could link to /etc/passwd or ~/.ssh/
|
|
572
|
+
if (entry.isSymbolicLink()) continue;
|
|
571
573
|
if (entry.isDirectory()) {
|
|
572
|
-
|
|
574
|
+
// Allow .github (workflow security), skip other dot-dirs (editor/system config)
|
|
575
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
576
|
+
if (entry.name.startsWith('.') && entry.name !== '.github') continue;
|
|
573
577
|
collectFiles(fullPath, relPath, collected, totalSize);
|
|
574
578
|
} else {
|
|
575
579
|
const ext = path.extname(entry.name).toLowerCase();
|
|
@@ -1457,24 +1461,88 @@ async function auditRepo(url) {
|
|
|
1457
1461
|
|
|
1458
1462
|
// Step 2: Collect files
|
|
1459
1463
|
process.stdout.write(` ${c.dim}[2/4]${c.reset} Collecting source files...`);
|
|
1460
|
-
const
|
|
1464
|
+
const _collectMeta = { bytes: 0, truncated: false, skippedPaths: [] };
|
|
1465
|
+
const files = collectFiles(repoPath, '', [], _collectMeta);
|
|
1461
1466
|
console.log(` ${c.green}${files.length} files${c.reset}`);
|
|
1467
|
+
if (_collectMeta.truncated) {
|
|
1468
|
+
console.log(` ${c.yellow}⚠ Size limit reached (${(MAX_TOTAL_SIZE / 1000).toFixed(0)}KB) — ${_collectMeta.skippedPaths.length} files NOT collected:${c.reset}`);
|
|
1469
|
+
const shown = _collectMeta.skippedPaths.slice(0, 5);
|
|
1470
|
+
for (const p of shown) console.log(` ${c.dim} • ${p}${c.reset}`);
|
|
1471
|
+
if (_collectMeta.skippedPaths.length > 5) console.log(` ${c.dim} ... and ${_collectMeta.skippedPaths.length - 5} more${c.reset}`);
|
|
1472
|
+
}
|
|
1462
1473
|
|
|
1463
|
-
// Step 3:
|
|
1474
|
+
// Step 3: Resolve provider + model FIRST (needed for dynamic chunk sizing)
|
|
1475
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
1476
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
1477
|
+
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
1478
|
+
const openrouterModel = process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
|
|
1479
|
+
const providerFlag = process.argv.find(a => a.startsWith('--provider='))?.split('=')[1]?.toLowerCase()
|
|
1480
|
+
|| (process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1]?.toLowerCase() : null);
|
|
1481
|
+
const resolvedProvider = resolveProvider(providerFlag, { anthropicKey, openaiKey, openrouterKey });
|
|
1482
|
+
// Determine actual model name
|
|
1483
|
+
let actualModel;
|
|
1484
|
+
if (!resolvedProvider) {
|
|
1485
|
+
actualModel = 'unknown';
|
|
1486
|
+
} else if (resolvedProvider.id === 'anthropic') {
|
|
1487
|
+
actualModel = modelOverride || 'claude-sonnet-4-20250514';
|
|
1488
|
+
} else if (resolvedProvider.id === 'openrouter') {
|
|
1489
|
+
actualModel = modelOverride || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
|
|
1490
|
+
} else if (resolvedProvider.id === 'openai') {
|
|
1491
|
+
actualModel = modelOverride || 'gpt-4o';
|
|
1492
|
+
} else if (resolvedProvider.id === 'ollama') {
|
|
1493
|
+
actualModel = modelOverride || resolvedProvider.model;
|
|
1494
|
+
} else {
|
|
1495
|
+
actualModel = modelOverride || resolvedProvider.model || 'unknown';
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Step 3b: Determine model context for dynamic chunk sizing
|
|
1499
|
+
let modelContextTokens = 64_000; // conservative default
|
|
1500
|
+
let outputTokenBudget = 4096;
|
|
1501
|
+
|
|
1502
|
+
if (resolvedProvider) {
|
|
1503
|
+
if (resolvedProvider.id === 'openrouter') {
|
|
1504
|
+
try {
|
|
1505
|
+
const modelInfoRes = await fetch(`https://openrouter.ai/api/v1/models`, {
|
|
1506
|
+
signal: AbortSignal.timeout(5000),
|
|
1507
|
+
headers: { 'HTTP-Referer': 'https://agentaudit.dev' },
|
|
1508
|
+
});
|
|
1509
|
+
if (modelInfoRes.ok) {
|
|
1510
|
+
const modelData = await modelInfoRes.json();
|
|
1511
|
+
const modelInfo = modelData.data?.find(m => m.id === actualModel);
|
|
1512
|
+
if (modelInfo?.context_length) {
|
|
1513
|
+
modelContextTokens = modelInfo.context_length;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
} catch { /* ignore — use default */ }
|
|
1517
|
+
} else if (resolvedProvider.id === 'anthropic') {
|
|
1518
|
+
modelContextTokens = 200_000;
|
|
1519
|
+
} else if (resolvedProvider.id === 'openai') {
|
|
1520
|
+
modelContextTokens = 128_000;
|
|
1521
|
+
} else if (resolvedProvider.id === 'ollama') {
|
|
1522
|
+
modelContextTokens = 32_000;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
outputTokenBudget = modelContextTokens >= 128_000 ? 8192 : modelContextTokens >= 64_000 ? 4096 : modelContextTokens >= 32_000 ? 2048 : 2048;
|
|
1527
|
+
const dynamicChunkChars = Math.floor(modelContextTokens * 0.5 * 4);
|
|
1528
|
+
const MAX_CHUNK_CHARS = Math.max(40_000, Math.min(dynamicChunkChars, 600_000));
|
|
1529
|
+
|
|
1530
|
+
// Step 3c: Build audit payload
|
|
1464
1531
|
process.stdout.write(` ${c.dim}[3/4]${c.reset} Preparing audit payload...`);
|
|
1465
1532
|
const auditPrompt = loadAuditPrompt();
|
|
1466
|
-
|
|
1467
|
-
//
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1533
|
+
// Sort files by directory to keep related files in the same chunk.
|
|
1534
|
+
// This preserves cross-file context (imports, shared modules) within each pass.
|
|
1535
|
+
const sortedFiles = [...files].sort((a, b) => {
|
|
1536
|
+
const dirA = a.path.includes('/') ? a.path.substring(0, a.path.lastIndexOf('/')) : '';
|
|
1537
|
+
const dirB = b.path.includes('/') ? b.path.substring(0, b.path.lastIndexOf('/')) : '';
|
|
1538
|
+
return dirA.localeCompare(dirB) || a.path.localeCompare(b.path);
|
|
1539
|
+
});
|
|
1472
1540
|
const chunks = []; // array of code block strings
|
|
1473
1541
|
const chunkFileNames = []; // track which files are in each chunk for error reporting
|
|
1474
1542
|
let currentChunk = '';
|
|
1475
1543
|
let currentChars = 0;
|
|
1476
1544
|
let currentFiles = [];
|
|
1477
|
-
for (const file of
|
|
1545
|
+
for (const file of sortedFiles) {
|
|
1478
1546
|
const entry = `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
|
|
1479
1547
|
if (currentChars + entry.length > MAX_CHUNK_CHARS && currentChars > 0) {
|
|
1480
1548
|
chunks.push(currentChunk);
|
|
@@ -1506,17 +1574,6 @@ async function auditRepo(url) {
|
|
|
1506
1574
|
const codeBlock = chunks[0] || '';
|
|
1507
1575
|
|
|
1508
1576
|
// Step 4: LLM Analysis
|
|
1509
|
-
// Check for API keys to determine which LLM to use
|
|
1510
|
-
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
1511
|
-
const openaiKey = process.env.OPENAI_API_KEY;
|
|
1512
|
-
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
1513
|
-
const openrouterModel = process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
|
|
1514
|
-
|
|
1515
|
-
// --provider flag overrides auto-detection
|
|
1516
|
-
const providerFlag = process.argv.find(a => a.startsWith('--provider='))?.split('=')[1]?.toLowerCase()
|
|
1517
|
-
|| (process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1]?.toLowerCase() : null);
|
|
1518
|
-
|
|
1519
|
-
const resolvedProvider = resolveProvider(providerFlag, { anthropicKey, openaiKey, openrouterKey });
|
|
1520
1577
|
const activeProvider = resolvedProvider?.label || null;
|
|
1521
1578
|
|
|
1522
1579
|
if (!resolvedProvider) {
|
|
@@ -1585,49 +1642,9 @@ async function auditRepo(url) {
|
|
|
1585
1642
|
return null;
|
|
1586
1643
|
}
|
|
1587
1644
|
|
|
1588
|
-
//
|
|
1589
|
-
let actualModel;
|
|
1590
|
-
if (resolvedProvider.id === 'anthropic') {
|
|
1591
|
-
actualModel = modelOverride || 'claude-sonnet-4-20250514';
|
|
1592
|
-
} else if (resolvedProvider.id === 'openrouter') {
|
|
1593
|
-
actualModel = modelOverride || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
|
|
1594
|
-
} else if (resolvedProvider.id === 'openai') {
|
|
1595
|
-
actualModel = modelOverride || 'gpt-4o';
|
|
1596
|
-
} else if (resolvedProvider.id === 'ollama') {
|
|
1597
|
-
actualModel = modelOverride || resolvedProvider.model;
|
|
1598
|
-
} else {
|
|
1599
|
-
actualModel = modelOverride || resolvedProvider.model || 'unknown';
|
|
1600
|
-
}
|
|
1645
|
+
// actualModel already resolved in Step 3
|
|
1601
1646
|
|
|
1602
1647
|
// ── LLM call helper (reused for multi-pass) ──
|
|
1603
|
-
// Determine optimal max_tokens based on model context size
|
|
1604
|
-
// For large-context models (128k+) we can afford 8192 output tokens
|
|
1605
|
-
// For medium (32k-128k) use 4096, for small (<32k) use 2048
|
|
1606
|
-
let outputTokenBudget = 4096; // safe default
|
|
1607
|
-
if (resolvedProvider.id === 'openrouter') {
|
|
1608
|
-
try {
|
|
1609
|
-
const modelInfoRes = await fetch(`https://openrouter.ai/api/v1/models`, {
|
|
1610
|
-
signal: AbortSignal.timeout(5000),
|
|
1611
|
-
headers: { 'HTTP-Referer': 'https://agentaudit.dev' },
|
|
1612
|
-
});
|
|
1613
|
-
if (modelInfoRes.ok) {
|
|
1614
|
-
const modelData = await modelInfoRes.json();
|
|
1615
|
-
const modelInfo = modelData.data?.find(m => m.id === actualModel);
|
|
1616
|
-
if (modelInfo?.context_length) {
|
|
1617
|
-
const ctx = modelInfo.context_length;
|
|
1618
|
-
outputTokenBudget = ctx >= 128_000 ? 8192 : ctx >= 64_000 ? 4096 : ctx >= 32_000 ? 2048 : 2048;
|
|
1619
|
-
if (process.argv.includes('--debug')) {
|
|
1620
|
-
console.log(` ${c.dim} Model context: ${ctx.toLocaleString()} tokens → max_tokens: ${outputTokenBudget}${c.reset}`);
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
} catch { /* ignore — use default */ }
|
|
1625
|
-
} else if (resolvedProvider.id === 'anthropic') {
|
|
1626
|
-
outputTokenBudget = 8192; // Claude models have 200k context
|
|
1627
|
-
} else if (resolvedProvider.id === 'openai') {
|
|
1628
|
-
outputTokenBudget = 8192; // GPT-4o has 128k context
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
1648
|
async function callLLM(codeContent, passLabel) {
|
|
1632
1649
|
const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
|
|
1633
1650
|
const userMessage = [
|
|
@@ -1816,6 +1833,79 @@ async function auditRepo(url) {
|
|
|
1816
1833
|
providerMeta = { ...lastMeta, input_tokens: totalInput || null, output_tokens: totalOutput || null };
|
|
1817
1834
|
|
|
1818
1835
|
console.log(` ${c.dim} Merged: ${mergedFindings.length} unique findings from ${chunks.length} passes${c.reset}`);
|
|
1836
|
+
|
|
1837
|
+
// ── Cross-file correlation pass ──
|
|
1838
|
+
// Build lightweight import/export map and ask LLM to check for multi-file attack patterns
|
|
1839
|
+
// that individual chunk passes couldn't detect (e.g., credential read in file A + exfil in file B)
|
|
1840
|
+
process.stdout.write(` ${c.dim} Cross-file correlation...${c.reset}`);
|
|
1841
|
+
try {
|
|
1842
|
+
const importMap = sortedFiles.map(f => {
|
|
1843
|
+
const imports = [];
|
|
1844
|
+
const exports = [];
|
|
1845
|
+
// JS/TS imports
|
|
1846
|
+
for (const m of f.content.matchAll(/(?:import|require)\s*\(?['"]([^'"]+)['"]\)?/g)) imports.push(m[1]);
|
|
1847
|
+
for (const m of f.content.matchAll(/(?:from)\s+['"]([^'"]+)['"]/g)) imports.push(m[1]);
|
|
1848
|
+
// Python imports
|
|
1849
|
+
for (const m of f.content.matchAll(/(?:from|import)\s+([\w.]+)/g)) imports.push(m[1]);
|
|
1850
|
+
// Exports
|
|
1851
|
+
for (const m of f.content.matchAll(/(?:module\.exports|export\s+(?:default\s+)?(?:function|class|const|let|var)\s+)(\w+)/g)) exports.push(m[1]);
|
|
1852
|
+
// Dangerous function calls (brief)
|
|
1853
|
+
const dangerousCalls = [];
|
|
1854
|
+
if (/\b(?:exec|spawn|execSync|system|eval|Function)\s*\(/.test(f.content)) dangerousCalls.push('exec/eval');
|
|
1855
|
+
if (/\b(?:fetch|https?\.request|axios|got)\s*\(/.test(f.content)) dangerousCalls.push('network');
|
|
1856
|
+
if (/\b(?:readFile|writeFile|createReadStream|open)\s*\(/.test(f.content)) dangerousCalls.push('fs');
|
|
1857
|
+
if (/process\.env|os\.environ|getenv/.test(f.content)) dangerousCalls.push('env-read');
|
|
1858
|
+
return { path: f.path, imports: [...new Set(imports)].slice(0, 10), exports: exports.slice(0, 10), calls: dangerousCalls };
|
|
1859
|
+
}).filter(f => f.imports.length > 0 || f.exports.length > 0 || f.calls.length > 0);
|
|
1860
|
+
|
|
1861
|
+
if (importMap.length > 2) {
|
|
1862
|
+
const correlationPrompt = [
|
|
1863
|
+
`You previously analyzed ${chunks.length} code chunks from package "${slug}" (${url}).`,
|
|
1864
|
+
`Here is a cross-file map showing imports, exports, and dangerous function calls.`,
|
|
1865
|
+
`Check for MULTI-FILE ATTACK PATTERNS that individual chunk analysis could miss:`,
|
|
1866
|
+
`- File A reads credentials/env → File B sends them to network (credential exfiltration pipeline)`,
|
|
1867
|
+
`- File A defines a function with exec/eval → File B calls it with user input (indirect RCE)`,
|
|
1868
|
+
`- Config file grants broad permissions → Code file exploits them`,
|
|
1869
|
+
`- Install hook in scripts/ triggers code in src/ that exfiltrates data`,
|
|
1870
|
+
``,
|
|
1871
|
+
`Respond with ONLY a JSON object: { "cross_file_findings": [...] } where each finding has:`,
|
|
1872
|
+
`{ "title": "...", "severity": "...", "description": "...", "file": "...", "confidence": "...", "pattern_id": "CORR_001", "remediation": "..." }`,
|
|
1873
|
+
`If no cross-file issues found, respond: { "cross_file_findings": [] }`,
|
|
1874
|
+
``,
|
|
1875
|
+
`## File Map`,
|
|
1876
|
+
JSON.stringify(importMap, null, 2),
|
|
1877
|
+
].join('\n');
|
|
1878
|
+
|
|
1879
|
+
const corrResult = await callLLM(correlationPrompt, 'correlation');
|
|
1880
|
+
if (corrResult.report?.cross_file_findings?.length > 0) {
|
|
1881
|
+
const corrFindings = corrResult.report.cross_file_findings;
|
|
1882
|
+
for (const f of corrFindings) {
|
|
1883
|
+
const key = `${f.title}::${f.file || ''}`;
|
|
1884
|
+
if (!seen.has(key)) {
|
|
1885
|
+
seen.add(key);
|
|
1886
|
+
mergedFindings.push(f);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
console.log(` ${c.yellow}${corrFindings.length} cross-file issues found${c.reset}`);
|
|
1890
|
+
// Re-merge into report
|
|
1891
|
+
const newRisk = Math.min(100, mergedFindings.reduce((s, f) => s + (sevWeights[f.severity] || 0), 0));
|
|
1892
|
+
report.findings = mergedFindings;
|
|
1893
|
+
report.findings_count = mergedFindings.length;
|
|
1894
|
+
report.risk_score = newRisk;
|
|
1895
|
+
report.result = newRisk === 0 ? 'safe' : newRisk <= 20 ? 'caution' : 'unsafe';
|
|
1896
|
+
totalInput += corrResult.meta?.input_tokens || 0;
|
|
1897
|
+
totalOutput += corrResult.meta?.output_tokens || 0;
|
|
1898
|
+
providerMeta = { ...providerMeta, input_tokens: totalInput || null, output_tokens: totalOutput || null };
|
|
1899
|
+
} else {
|
|
1900
|
+
console.log(` ${c.green}clean${c.reset}`);
|
|
1901
|
+
}
|
|
1902
|
+
} else {
|
|
1903
|
+
console.log(` ${c.dim}skipped (too few files with imports)${c.reset}`);
|
|
1904
|
+
}
|
|
1905
|
+
} catch (corrErr) {
|
|
1906
|
+
console.log(` ${c.dim}skipped (${corrErr.message?.slice(0, 40)})${c.reset}`);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1819
1909
|
console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
|
|
1820
1910
|
} else {
|
|
1821
1911
|
// Single-pass (original flow)
|