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.
Files changed (2) hide show
  1. package/cli.mjs +157 -67
  2. 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 (!p) return null;
52
- return p;
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
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
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 files = collectFiles(repoPath);
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: Build audit payload
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
- // Build code chunks for multi-pass analysis.
1468
- // Budget ~45k tokens (~180k chars) per chunk for code, leaving room for prompt + output.
1469
- // ~15k tokens per chunk for code fits comfortably in 32k+ context models
1470
- // with room for system prompt (~2k tokens) + output (4k tokens)
1471
- const MAX_CHUNK_CHARS = 60_000;
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 files) {
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
- // Determine actual model name for display
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentaudit",
3
- "version": "3.9.41",
3
+ "version": "3.9.43",
4
4
  "description": "Security scanner for AI packages — MCP server + CLI",
5
5
  "type": "module",
6
6
  "bin": {