Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions scripts/regression-scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)}`);
Expand Down
46 changes: 28 additions & 18 deletions src/scanner/rules/indirect-prompt-injection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<string>> = 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.
Expand All @@ -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;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions test-fixtures/safe/mcp_untrusted_tool_source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
}
Expand Down
3 changes: 2 additions & 1 deletion test/regression-baseline.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down