From 4f043562a63e5496c3c4a1402771fdf40eb363f7 Mon Sep 17 00:00:00 2001 From: akanthed Date: Thu, 27 Aug 2026 04:07:15 +0530 Subject: [PATCH] fix: two false-positive/crash bugs in fetch-derived taint detection, sparse-checkout the two slow regression repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isFetchLikeCall (shared by AI010 and MCP011) did a raw substring check against the callee's lowercased text, so `extra.sendRequest(...)` — an MCP protocol call to the connected client (sampling/elicitation), not an HTTP fetch — matched the "request(" pattern because "sendRequest" contains "request" as a substring. Found on modelcontextprotocol/servers' trigger-sampling-request*.ts fixtures, which MCP011 flagged as proven/likely untrusted-tool-source findings. Rewrote isFetchLikeCall to match on actual identifier/property names via the AST (bare call name for fetch/got/ky/request, base+method pair for axios/http/https/superagent) instead of substring search. Also switched PROPERTY_FETCH_CALLS from a plain object to a Map: a base identifier literally named `constructor`/`toString`/etc. (found in a minified Next.js bundle under litellm/proxy/_experimental/out) resolved to an Object.prototype value on a plain-object lookup, crashing AI010 with "allowedMethods?.has is not a function" and silently skipping the rule for the whole file (scan.ts isolates per-rule failures, so this went unnoticed rather than failing the scan). New safe fixture: test-fixtures/safe/mcp_untrusted_tool_source.ts (the sendRequest pattern from modelcontextprotocol/servers). Also sparse-checkout litellm (-> litellm/proxy/) and llama_index (-> llama-index-core/ + llama-index-integrations/vector_stores/) in scripts/regression-scan.js instead of full-repo clones: ~1.7k files each instead of ~5.7k/~10k, same rule coverage (proxy is where the LLC config examples and the historical AI003/MCP001/MCP002 false positives were found; core+vector_stores is where every VEC001 baseline finding outside those two paths lives), full scan now finishes in minutes instead of timing out. Verified against all 10 regression repos: servers false positive gone, ai's genuine MCP011 finding baselined after review, litellm's AI010 crash gone with no new findings, llama_index clean. npm test 151/151. Co-Authored-By: Claude Sonnet 5 --- scripts/regression-scan.js | 28 +++++++++-- .../rules/indirect-prompt-injection.ts | 46 +++++++++++-------- .../safe/mcp_untrusted_tool_source.ts | 17 +++++++ test/regression-baseline.json | 3 +- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/scripts/regression-scan.js b/scripts/regression-scan.js index 628794f..df64176 100644 --- a/scripts/regression-scan.js +++ b/scripts/regression-scan.js @@ -53,13 +53,29 @@ const REPOS = [ { name: "typescript-sdk", url: "https://github.com/modelcontextprotocol/typescript-sdk.git" }, { name: "servers", url: "https://github.com/modelcontextprotocol/servers.git" }, { name: "ai", url: "https://github.com/vercel/ai.git" }, - { name: "llama_index", url: "https://github.com/run-llama/llama_index.git" }, { name: "anthropic-skills", url: "https://github.com/anthropics/skills.git" }, { name: "cisco-skill-scanner", url: "https://github.com/cisco-ai-defense/skill-scanner.git" }, // litellm added for LLC001-003 (litellm-config-scanner): the official repo // ships real proxy config.yaml examples under litellm/proxy/example_config_yaml // and docs, the only repo in this set that exercises those rules at all. - { name: "litellm", url: "https://github.com/BerriAI/litellm.git" }, + // Sparse-checked to litellm/proxy/ only (~1.7k files vs. ~5.7k for the full + // monorepo) — the proxy subsystem is both where the LLC config examples live + // and where the historical AI003/MCP001/MCP002/VEC001 false positives were + // found (see CHANGELOG 0.10.0), so this keeps the same coverage that has + // actually produced findings without scanning the unrelated provider + // integrations, docs, and test suites that make up most of the repo. + { name: "litellm", url: "https://github.com/BerriAI/litellm.git", sparsePaths: ["litellm/proxy"] }, + // llama_index sparse-checked to llama-index-core (the shared indexing/query + // engine code, where most VEC001 baseline findings live) plus + // llama-index-integrations/vector_stores (the subpackage VEC001 exists to + // cover) — skips llms/readers/embeddings/graph_stores/indices/retrievers + // integrations and docs, which make up the bulk of the ~10k-file monorepo + // but have never produced a VEC001 finding outside vector_stores/core. + { + name: "llama_index", + url: "https://github.com/run-llama/llama_index.git", + sparsePaths: ["llama-index-core", "llama-index-integrations/vector_stores"], + }, ]; const fresh = process.argv.includes("--fresh"); @@ -94,7 +110,13 @@ for (const repo of targets) { } if (!fs.existsSync(dest)) { console.log(`Cloning ${repo.name}...`); - execSync(`git clone --depth 1 ${repo.url} "${dest}"`, { stdio: "inherit" }); + if (repo.sparsePaths) { + execSync(`git clone --filter=blob:none --no-checkout --depth 1 ${repo.url} "${dest}"`, { stdio: "inherit" }); + execSync(`git sparse-checkout set ${repo.sparsePaths.map((p) => `"${p}"`).join(" ")}`, { cwd: dest, stdio: "inherit" }); + execSync(`git checkout`, { cwd: dest, stdio: "inherit" }); + } else { + execSync(`git clone --depth 1 ${repo.url} "${dest}"`, { stdio: "inherit" }); + } } console.log(`\n${"=".repeat(70)}\nScanning ${repo.name}\n${"=".repeat(70)}`); diff --git a/src/scanner/rules/indirect-prompt-injection.ts b/src/scanner/rules/indirect-prompt-injection.ts index dc0f147..3103793 100644 --- a/src/scanner/rules/indirect-prompt-injection.ts +++ b/src/scanner/rules/indirect-prompt-injection.ts @@ -5,19 +5,19 @@ import { isLikelyLlmCall } from "./llm-rule-utils.js"; import { evidenceConfidence, demoteEvidence, isTestFilePath, hasSanitizationNearby } from "../confidence.js"; import type { Evidence } from "../types.js"; -// HTTP client patterns that fetch external content -const FETCH_PATTERNS = [ - "fetch(", - "axios.get", - "axios.post", - "axios.request", - "http.get", - "https.get", - "got(", - "request(", - "superagent", - "ky(", -]; +// HTTP client functions callable bare: fetch(url), got(url), ky(url), request(url, cb). +const BARE_FETCH_NAMES = new Set(["fetch", "got", "ky", "request"]); + +// HTTP client method calls: base object -> allowed method names. A Map, not +// a plain object — a base identifier literally named `constructor`, +// `toString`, etc. (seen in minified bundled JS) resolves to an +// Object.prototype value on a plain-object lookup instead of undefined. +const PROPERTY_FETCH_CALLS: Map> = new Map([ + ["axios", new Set(["get", "post", "put", "patch", "delete", "request"])], + ["http", new Set(["get", "request"])], + ["https", new Set(["get", "request"])], + ["superagent", new Set(["get", "post", "put", "patch", "delete"])], +]); // Response extraction — property/method names for content pulled off an // HTTP response object. @@ -30,14 +30,24 @@ function unwrapAwait(node: Node): Node { /** * True only for an actual call expression whose callee is a known HTTP - * client function/method — not a substring match against arbitrary - * initializer text (which previously matched things like `{ fetch: true }` - * or a var named `prefetchedIds`). + * client function/method, matched by exact identifier/property name — not + * a substring match against the callee's text. A substring check on + * "request" previously matched `extra.sendRequest(...)` (an MCP + * protocol call to the client, e.g. sampling/elicitation), because + * "sendRequest" contains "request" as a substring. */ function isFetchLikeCall(node: Node): boolean { if (!Node.isCallExpression(node)) return false; - const text = node.getExpression().getText().toLowerCase(); - return FETCH_PATTERNS.some((p) => text.includes(p.replace("(", ""))); + const expr = node.getExpression(); + if (Node.isIdentifier(expr)) { + return BARE_FETCH_NAMES.has(expr.getText()); + } + if (Node.isPropertyAccessExpression(expr)) { + const baseText = expr.getExpression().getText().split(".").pop() ?? ""; + const allowedMethods = PROPERTY_FETCH_CALLS.get(baseText.toLowerCase()); + return allowedMethods?.has(expr.getName().toLowerCase()) ?? false; + } + return false; } /** diff --git a/test-fixtures/safe/mcp_untrusted_tool_source.ts b/test-fixtures/safe/mcp_untrusted_tool_source.ts index 19aa3cf..30ef0ff 100644 --- a/test-fixtures/safe/mcp_untrusted_tool_source.ts +++ b/test-fixtures/safe/mcp_untrusted_tool_source.ts @@ -3,6 +3,23 @@ import { z } from "zod"; const server = new McpServer({ name: "demo", version: "1.0.0" }); +// extra.sendRequest is an MCP protocol call to the connected client +// (sampling/elicitation), not an HTTP fetch to an external/unauthenticated +// source — "sendRequest" merely contains "request" as a substring. +// modelcontextprotocol/servers, src/everything/tools/trigger-sampling-request.ts +server.tool( + "trigger_sampling_request", + "Ask the connected client's LLM to generate a message.", + { prompt: z.string() }, + async ({ prompt }, extra) => { + const result = await extra.sendRequest( + { method: "sampling/createMessage", params: { messages: [{ role: "user", content: { type: "text", text: prompt } }] } }, + z.any(), + ); + return { content: [{ type: "text", text: `LLM sampling result: ${JSON.stringify(result)}` }] }; + }, +); + function sanitizeEventMessage(raw: string): string { return raw.replace(/[^\x20-\x7e]/g, ""); } diff --git a/test/regression-baseline.json b/test/regression-baseline.json index 132c153..f6597be 100644 --- a/test/regression-baseline.json +++ b/test/regression-baseline.json @@ -1,7 +1,8 @@ { "note": "Reviewed proven/likely findings from scripts/regression-scan.js. Only add entries you have read against their source line.", - "updated": "2026-08-19", + "updated": "2026-08-26", "fingerprints": [ + "ai|MCP011|packages/harness-opencode/src/bridge/host-tool-mcp.ts", "cisco-skill-scanner|SKL001|evals/test_skills/malicious/ascii-smuggling/SKILL.md", "cisco-skill-scanner|SKL002|evals/skills/prompt-injection/jailbreak-override/SKILL.md", "cisco-skill-scanner|SKL002|evals/test_skills/malicious/prompt-injection/SKILL.md",