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",