/** * Analysis for research report EFF-RR-2026-003: vulnerabilities in AI application and agent tooling. * * node research/ai-agents-2026/analyse.mjs * * holds nvd-2023.json.gz ... nvd-2026.json.gz (NVD CVE 2.0 yearly feeds) and kev.json * (CISA KEV catalogue), the same inputs as research/epss-2026. See README.md. * * Outputs: results.json (every number in the report) and ai-tooling-cves.csv (one row per CVE). */ import { createReadStream, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { createGunzip } from 'node:zlib'; import { join } from 'node:path'; const [dir, out] = process.argv.slice(2); if (!out) { console.error('Usage: node analyse.mjs '); process.exit(1); } const YEARS = [2023, 2024, 2025, 2026]; /* ---------- Product rules ---------- */ // Matched against "vendor | product" pairs from the CNA's affected-product data and NVD's CPE data, // lower-cased. Order matters: the first rule that matches any pair in a CVE assigns its product. const GROUPS = { assistant: 'AI coding assistants and copilots', mcp: 'MCP servers and tooling', framework: 'Agent frameworks and SDKs', builder: 'AI app builders and chat platforms', serving: 'Model serving and gateways', ml: 'ML platforms and libraries', }; const PRODUCTS = [ [/^anthropics? \| claude[-_ ]code/, 'Claude Code', 'assistant'], [/^(cursor|anysphere) \| cursor/, 'Cursor', 'assistant'], [/^roo ?code(inc)? \| roo[-_ ]code/, 'Roo Code', 'assistant'], [/^cline(bot)? \| cline/, 'Cline', 'assistant'], [/^google \| gemini[-_ ]cli/, 'Gemini CLI', 'assistant'], [/^openai \| codex/, 'OpenAI Codex CLI', 'assistant'], [/^(microsoft|github) \| .*copilot/, 'Microsoft and GitHub Copilot', 'assistant'], [/^(?!.*wordpress)(?!.*\bwp).*(\| (.*[-_ /])?mcp([-_ ]|$)|mcphub|model[-_ ]context[-_ ]protocol)/, 'MCP servers and tooling', 'mcp'], [/langchain|langgraph/, 'LangChain and LangGraph', 'framework'], [/llama[-_ ]?index|run-llama/, 'LlamaIndex', 'framework'], [/\| (microsoft\/)?autogen/, 'AutoGen', 'framework'], [/crewai/, 'CrewAI', 'framework'], [/semantic[-_ ]kernel/, 'Semantic Kernel', 'framework'], [/significant-gravitas|^agpt \||\| autogpt/, 'AutoGPT', 'framework'], [/superagi/, 'SuperAGI', 'framework'], [/metagpt/, 'MetaGPT', 'framework'], [/agentscope/, 'AgentScope', 'framework'], [/praisonai/, 'PraisonAI', 'framework'], [/hermes-agent/, 'Hermes Agent', 'framework'], [/cowagent/, 'CowAgent', 'framework'], [/smolagents/, 'smolagents', 'framework'], [/langflow/, 'Langflow', 'builder'], [/flowise/, 'Flowise', 'builder'], [/^(langgenius|dify) \|/, 'Dify', 'builder'], [/^n8n(-io)? \|/, 'n8n', 'builder'], [/fastgpt/, 'FastGPT', 'builder'], [/anything-?llm/, 'AnythingLLM', 'builder'], [/open[-_ ]?webui/, 'Open WebUI', 'builder'], [/librechat/, 'LibreChat', 'builder'], [/lobe-?chat|^lobehub \|/, 'LobeChat', 'builder'], [/lollms/, 'LoLLMs', 'builder'], [/chuanhuchatgpt/, 'ChuanhuChatGPT', 'builder'], [/gpt_academic/, 'GPT Academic', 'builder'], [/nextchat|chatgptnextweb/, 'NextChat', 'builder'], [/db-gpt/, 'DB-GPT', 'builder'], [/privategpt/, 'PrivateGPT', 'builder'], [/text-generation-webui/, 'Text Generation WebUI', 'builder'], [/vllm/, 'vLLM', 'serving'], [/sglang/, 'SGLang', 'serving'], [/llama\.cpp/, 'llama.cpp', 'serving'], [/^ollama \|/, 'Ollama', 'serving'], [/litellm/, 'LiteLLM', 'serving'], [/localai/, 'LocalAI', 'serving'], [/lightllm/, 'LightLLM', 'serving'], [/tensorrt-llm/, 'TensorRT-LLM', 'serving'], [/triton[_ ]inference[_ ]server/, 'Triton Inference Server', 'serving'], [/xinference/, 'Xinference', 'serving'], [/gpustack/, 'GPUStack', 'serving'], [/bentoml/, 'BentoML', 'serving'], [/mlflow/, 'MLflow', 'ml'], [/^h2o(ai)? \| (h2oai\/)?h2o(-3)?$/, 'H2O-3', 'ml'], [/^(ray-project|anyscale) \|/, 'Ray', 'ml'], [/kubeflow/, 'Kubeflow', 'ml'], [/^(huggingface|hugging face) \| (huggingface\/)?transformers/, 'Hugging Face Transformers', 'ml'], [/gradio/, 'Gradio', 'ml'], [/clearml/, 'ClearML', 'ml'], ]; // Weakness families, the same as report EFF-RR-2026-001, plus prompt injection (CWE-1427). const FAMILIES = { prompt: ['Prompt injection', [1427]], memory: ['Memory safety', [119, 120, 121, 122, 124, 125, 126, 129, 131, 190, 191, 401, 415, 416, 476, 680, 704, 763, 787, 822, 823, 824, 843]], auth: ['Authentication and access control', [250, 266, 267, 269, 276, 284, 285, 287, 288, 290, 294, 302, 303, 304, 305, 306, 307, 345, 346, 347, 425, 521, 522, 639, 640, 648, 732, 798, 862, 863, 1188, 1390]], injection: ['Injection and code execution', [74, 77, 78, 88, 89, 91, 93, 94, 95, 96, 611, 917, 1236, 1321, 1336]], xss: ['Cross-site scripting', [79, 80]], file: ['Path traversal and file handling', [22, 23, 24, 29, 35, 36, 59, 61, 73, 434, 552]], deser: ['Unsafe deserialisation', [502]], forgery: ['Request forgery (SSRF, CSRF)', [352, 918]], resource: ['Resource exhaustion', [400, 770, 674, 1333, 834, 835]], exposure: ['Information exposure', [200, 201, 209, 532, 538]], }; const familyOf = new Map(Object.entries(FAMILIES).flatMap(([id, [, c]]) => c.map((n) => [`CWE-${n}`, id]))); const FAMILY_LABEL = { ...Object.fromEntries(Object.entries(FAMILIES).map(([id, [l]]) => [id, l])), other: 'Other specified weakness', none: 'No weakness recorded' }; /* ---------- Reading ---------- */ async function* vulnerabilities(file) { const stream = createReadStream(file).pipe(createGunzip()); stream.setEncoding('utf8'); let buf = '', started = false, depth = 0, inStr = false, esc = false, start = 0, pos = 0; for await (const chunk of stream) { buf += chunk; if (!started) { const i = buf.indexOf('"vulnerabilities"'); const j = i < 0 ? -1 : buf.indexOf('[', i); if (j < 0) continue; buf = buf.slice(j + 1); started = true; pos = 0; } for (; pos < buf.length; pos++) { const c = buf[pos]; if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; } if (c === '"') inStr = true; else if (c === '{') { if (depth++ === 0) start = pos; } else if (c === '}' && --depth === 0) { yield JSON.parse(buf.slice(start, pos + 1)).cve; buf = buf.slice(pos + 1); pos = -1; } else if (c === ']' && depth === 0) return; } if (depth === 0) { buf = ''; pos = 0; } } } /** * The product a CVE is about: the first product in the CNA's affected list, or, if the CNA gave none, * NVD's CPE products. Using only the first CNA product stops multi-product incidents (for example a * compromised build tool listing every downstream package) being counted as AI tooling flaws. */ function primaryPairs(c) { const d = (c.affected || []).flatMap((a) => a.affectedData || [])[0]; if (d && (d.vendor || d.product)) return [`${(d.vendor || '').toLowerCase().trim()} | ${(d.product || '').toLowerCase().trim()}`]; const set = new Set(); const walk = (n) => { for (const m of n.cpeMatch || []) { const p = m.criteria.split(':'); set.add(`${p[3]} | ${p[4]}`); } for (const k of n.children || []) walk(k); }; for (const cfg of c.configurations || []) for (const n of cfg.nodes || []) walk(n); return [...set]; } function product(ps) { for (const [re, name, group] of PRODUCTS) if (ps.some((p) => re.test(p))) return { name, group }; return null; } function cvss3(metrics = {}) { const all = [...(metrics.cvssMetricV31 || []), ...(metrics.cvssMetricV30 || [])]; const cna = all.find((x) => x.type === 'Secondary')?.cvssData.baseScore ?? null; const nvd = all.find((x) => x.type === 'Primary')?.cvssData.baseScore ?? null; return cna ?? nvd; } const band = (s) => (s === null ? 'none' : s >= 9 ? 'critical' : s >= 7 ? 'high' : s >= 4 ? 'medium' : s > 0 ? 'low' : 'none'); function cwes(c) { const ids = []; for (const w of c.weaknesses || []) for (const d of w.description || []) if (/^CWE-\d+$/.test(d.value) && !ids.includes(d.value)) ids.push(d.value); return ids; } function family(ids) { for (const id of ids) if (familyOf.has(id)) return familyOf.get(id); return ids.length ? 'other' : 'none'; } /* ---------- Run ---------- */ const kevDoc = JSON.parse(readFileSync(join(dir, 'kev.json'), 'utf8')); const kev = new Map(kevDoc.vulnerabilities.map((v) => [v.cveID, v.dateAdded])); const rows = []; let allCves = 0, promptMentionsAll = 0, cwe1427All = 0; const allByYear = {}; for (const y of YEARS) { for await (const c of vulnerabilities(join(dir, `nvd-${y}.json.gz`))) { if (c.vulnStatus === 'Rejected') continue; allCves++; const py = c.published.slice(0, 4); allByYear[py] = (allByYear[py] || 0) + 1; const desc = (c.descriptions || []).find((d) => d.lang === 'en')?.value || ''; const mentionsPrompt = /prompt[- ]injection/i.test(desc); if (mentionsPrompt) promptMentionsAll++; if ((c.weaknesses || []).some((w) => w.description.some((d) => d.value === 'CWE-1427'))) cwe1427All++; const p = product(primaryPairs(c)); if (!p) continue; const ids = cwes(c); const score = cvss3(c.metrics); rows.push({ id: c.id, published: c.published.slice(0, 10), year: py, product: p.name, group: p.group, score, band: band(score), family: family(ids), cwes: ids.join(' '), promptInDescription: mentionsPrompt, kevAdded: kev.get(c.id) || null }); } } /* ---------- Results ---------- */ const pct = (n, d) => (d ? Math.round((n / d) * 1000) / 10 : 0); const tally = (xs, f) => xs.reduce((m, x) => ((m[f(x)] = (m[f(x)] || 0) + 1), m), {}); const sorted = (o) => Object.entries(o).sort((a, b) => b[1] - a[1]); const pubYears = ['2023', '2024', '2025', '2026']; const results = { sources: { kevCatalogVersion: kevDoc.catalogVersion, nvdYears: YEARS }, scope: 'Non-rejected CVEs with 2023-2026 identifiers whose affected-product or CPE data matches a product rule.', allCves, aiCves: rows.length, aiPct: Math.round((rows.length / allCves) * 10000) / 100, byPublishedYear: Object.fromEntries(pubYears.map((y) => [y, rows.filter((r) => r.year === y).length])), allByPublishedYear: Object.fromEntries(pubYears.map((y) => [y, allByYear[y] || 0])), sharePerMilleByYear: Object.fromEntries(pubYears.map((y) => [y, Math.round((rows.filter((r) => r.year === y).length / (allByYear[y] || 1)) * 10000) / 10])), byGroup: Object.fromEntries(Object.keys(GROUPS).map((g) => { const s = rows.filter((r) => r.group === g); return [g, { label: GROUPS[g], n: s.length, byYear: Object.fromEntries(pubYears.map((y) => [y, s.filter((r) => r.year === y).length])) }]; })), products: sorted(tally(rows, (r) => r.product)).map(([name, n]) => ({ name, n, group: rows.find((r) => r.product === name).group })), bands: Object.fromEntries(['critical', 'high', 'medium', 'low', 'none'].map((b) => { const n = rows.filter((r) => r.band === b).length; return [b, { n, pct: pct(n, rows.length) }]; })), families: Object.fromEntries(sorted(tally(rows, (r) => r.family)).map(([k, n]) => [k, { label: FAMILY_LABEL[k], n, pct: pct(n, rows.length) }])), familiesByGroup: Object.fromEntries(Object.keys(GROUPS).map((g) => { const s = rows.filter((r) => r.group === g); return [g, Object.fromEntries(sorted(tally(s, (r) => r.family)).map(([k, n]) => [k, { n, pct: pct(n, s.length) }]))]; })), topCwes: sorted(tally(rows.flatMap((r) => r.cwes.split(' ').filter(Boolean).map((c) => ({ c }))), (x) => x.c)).slice(0, 12), promptInjection: { cwe1427All, descriptionMentionsAll: promptMentionsAll, descriptionMentionsAi: rows.filter((r) => r.promptInDescription).length, aiMentionFamilies: Object.fromEntries(sorted(tally(rows.filter((r) => r.promptInDescription), (r) => r.family))), aiMentionProducts: sorted(tally(rows.filter((r) => r.promptInDescription), (r) => r.product)).slice(0, 10), }, kev: rows.filter((r) => r.kevAdded).map((r) => ({ id: r.id, product: r.product, published: r.published, kevAdded: r.kevAdded, band: r.band, family: r.family })), }; mkdirSync(out, { recursive: true }); writeFileSync(join(out, 'results.json'), JSON.stringify(results, null, 2) + '\n'); const cols = ['id', 'published', 'product', 'group', 'score', 'band', 'family', 'cwes', 'promptInDescription', 'kevAdded']; const cell = (v) => { const s = v === null ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; writeFileSync(join(out, 'ai-tooling-cves.csv'), [cols.join(','), ...rows.map((r) => cols.map((k) => cell(r[k])).join(','))].join('\n') + '\n'); console.log(`Done: ${rows.length} AI tooling CVEs out of ${allCves}.`);