Exactly what every rule checks and how. Each rule ships with a fixture that triggers it and a benign fixture that must not, in the open-source test suite. Severity and confidence are separate on purpose.
How it checks: Reports any discovered config file that cannot be read or parsed as JSON/JSONC, and any server entry that is not an object or has neither a command nor a url.
Why it matters: A config file or server entry that cannot be parsed is not scanned, so anything risky inside it is invisible to this report. Clients may also load it differently than you expect.
Fix: Fix the JSON syntax or the server entry (each server needs a command or a url), then re-run the scan.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Groups servers by name across all discovered files and reports names whose command, arguments, or url differ between files.
Why it matters: When two configs define the same server name with different launch details, which one runs depends on the client and its precedence rules. A project config can silently override the server you reviewed.
Fix: Keep one definition per server name, or rename one so you know which server the assistant is talking to.
How it checks: Checks env values, arguments, headers, and URLs of every server against specific credential formats (Anthropic, OpenAI, AWS, GitHub, Slack, Stripe, Google, private keys, database URLs with passwords and more), and flags literal non-placeholder values under credential-like names (API_KEY, TOKEN, SECRET, PASSWORD). Placeholders like ${VAR}, <your-key>, and env:/op:// references are ignored. Severity is High for project-scoped files (likely committed) and Medium for files in your home directory.
Why it matters: Anything that can read this file, including other tools, backups, sync folders, and anyone the file is shared or committed with, gets a working credential. Assistants also read project files, so a key in a config can end up in a prompt or a log.
Fix: Rotate the credential first (removing it does not un-leak it), then reference it from the environment or a secret store instead of the literal value. Claude Code and Claude Desktop expand "${VAR}" in .mcp.json, Cursor expands "${env:VAR}", and VS Code supports "${input:...}" prompts or an "envFile".
Lab: lab-01-secrets · Pattern: secret-handling
How it checks: Flags servers whose command is a shell (sh, bash, zsh, cmd, PowerShell) given an inline script containing pipes, command chaining, substitution, or redirection, and any argument that pipes a download into an interpreter (curl ... | sh). The plain Windows wrapper `cmd /c npx ...` is not flagged.
Why it matters: A launch line that runs a shell script, pipes a download into a shell, or chains commands runs more than an MCP server. Every time the client starts, that script runs with your user's full permissions, and it is hard to review what it actually does.
Fix: Launch the server binary or package directly with a pinned version (for example npx -y package@1.2.3) and pass configuration through env, not a shell one-liner.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Extracts the package from npx, bunx, pnpm/yarn dlx, uvx, uv tool run, pipx run, and docker/podman run launch lines. npm specs are pinned only with an exact semver (not ranges or @latest); Python specs need ==version; images need a non-latest tag or a digest.
Why it matters: Without a pinned version, every client start can download and run whatever the publisher (or anyone who takes over the package) released last. A server you reviewed last month is not the server you are running today.
Fix: Pin an exact version you have reviewed (npx -y package@1.2.3, uvx package==1.2.3, image:tag or image@sha256:...), and re-scan after each deliberate upgrade.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Flags package specs that start with git+, git:, github:, gitlab:, bitbucket:, http(s)://, or file:, or that point at a .tgz/.tar.gz/.whl/.zip archive. A git spec pinned to a full 40-character commit SHA is reported at Low severity.
Why it matters: Installing from a URL or a git branch skips the registry's publisher identity and version history. Whoever controls that URL or branch can change the code you run at any time, with no version number to notice.
Fix: Install a published, pinned release from the package registry instead. If you must use git, pin a full commit SHA, not a branch or tag.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Compares each launched package name with a published list of widely used MCP server packages. Flags names within an edit distance of 2 of a known package (but not identical), and known package names published under a look-alike scope.
Why it matters: Attackers publish packages one or two characters away from popular ones and wait for a typo. A look-alike name is the single most common way malicious packages reach developer machines.
Fix: Compare the name character by character with the official package. If it is not the one you meant, remove it from the config and check what it may have accessed while it ran.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Flags remote servers whose url uses http:// with a host that is not loopback (localhost, 127.0.0.0/8, ::1, *.localhost). Malformed URLs are reported at Medium.
Why it matters: Over plain HTTP, anyone on the network path (coffee-shop Wi-Fi, a proxy, a compromised router) can read every prompt, tool result, and token, and can inject their own tool descriptions into what the model sees.
Fix: Use the server's https:// endpoint. If it has none, do not send it anything sensitive, or run it locally over stdio instead.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: For servers whose name or package mentions filesystem/fs/files, flags path arguments equal to /, ~, $HOME, %USERPROFILE%, the home directory itself, or a drive root (C:\). Also flags docker volume mounts of / or the home directory.
Why it matters: A filesystem server rooted at / or your home directory lets the model read SSH keys, cloud credentials, browser data, and every other project. One prompt injection from a web page or issue is enough to ask for them.
Fix: Grant only the project directories the assistant needs (for example the current repo), never / or ~.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: For docker/podman run launch lines, flags --privileged, --network/--net host, --pid host, --cap-add ALL or SYS_ADMIN, and volume mounts of /var/run/docker.sock.
Why it matters: Running a server in Docker only isolates it if the container is unprivileged. --privileged, host networking, host PID, or a mounted Docker socket give the server effective control of your machine.
Fix: Remove --privileged, --network host, --pid host, broad --cap-add, and Docker socket mounts. Pass only the env and the narrow volume the server needs.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Classifies servers by name, package, and (when probed live) tool names into readers of private data (filesystem, git, databases, mail, notes, chat) and senders to the network (fetch, browsers, mail, chat, webhooks). Reports once when at least one of each is enabled. This is a combination warning, not proof of misuse, so confidence is Low.
Why it matters: Each server may be fine alone. Together they form an exfiltration path: a prompt injection in a web page, issue, or document can ask the model to read a private file with one tool and send it out with another, all in one session.
Fix: Enable the sending server only in sessions that need it, scope the reading server to the minimum paths or repos, and require approval for tool calls on both.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Connects to each approved server, lists tools, prompts, and resources, and checks every description (including parameter descriptions) for instructions aimed at the model: ignore previous instructions, hide something from the user, read credential or MCP config files, send data to an outside address, or steer the model away from another tool. One strong signal gives Critical/High confidence; two medium signals (hidden-instruction tags, urgent override wording, forced tool calls, concealment) give High/Medium.
Why it matters: The model reads every tool description as trusted context, even for tools it never calls. A description that tells it to read secrets, hide actions from you, or override other tools can hijack the whole session without you seeing anything in the chat.
Fix: Remove this server from your config now. If you used it, check what other tools it could have steered (file reads, network sends) and rotate any credentials those tools could reach. Report the server to its registry listing.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Scans tool names, descriptions, parameter descriptions, prompts, and resources for Unicode tag characters (U+E0000 to U+E007F), zero-width characters, bidirectional overrides and isolates, word joiners, BOM, and soft hyphens. Evidence shows the code points, not the hidden text.
Why it matters: Unicode tag characters, zero-width characters, and direction overrides are invisible in most UIs but still read by the model. There is no legitimate reason for them in a tool description, and they are a known way to smuggle instructions past human review.
Fix: Treat the server as untrusted: remove it, or pin a version whose metadata is clean and report the issue to the publisher.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Compares tool names across every live-probed server (case-insensitive) and reports each name exposed by more than one server.
Why it matters: When two servers offer the same tool name, the model can call the wrong one. A malicious server can register the name of a trusted tool (send_email, read_file) to intercept calls and data meant for the real one.
Fix: Disable one of the servers, or confirm both are trusted and that your client namespaces tool names per server.
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: On the first approved live probe, stores a local fingerprint (SHA-256 of each tool's name, description, and input schema, plus prompts and resources) in ~/.mcpvetter/fingerprints.json. Later probes compare against it and classify drift: tool added, description changed, schema changed (High); prompts/resources changed (Medium); tool removed (Low). A changed description that also trips MCP-POISON-001 is Critical. Fingerprints never leave your machine.
Why it matters: A server can look clean when you install it and change its tool descriptions or permissions later. That silent change (a rug pull) is exactly what no one re-reviews, and the model trusts the new text immediately.
Fix: Review the before/after below. If you did not expect it, pin the server to the last version you trusted. If the change is fine, accept the new fingerprint with: mcpvetter accept <server> --reason "..."
Lab: lab-10-mcp-install · Pattern: mcp-config
How it checks: Flags string literals that match the format of a known provider credential (Anthropic, OpenAI, AWS, GitHub, Slack, Stripe, Google, Hugging Face, private key blocks, database URLs with passwords) at high confidence. Also flags a literal of 12+ characters mixing letters and digits that is assigned to a name containing key, secret, token, password or credential, at medium confidence. Placeholders (<...>, ${VAR}, your-key, changeme, xxx), environment lookups, and values that are public by design (publishable keys, analytics beacons, client IDs) are ignored; in test and fixture files only known provider formats are flagged.
Why it matters: A credential written into source code ends up in git history, build artifacts, logs and every copy of the repository. Anyone who can read the code can use the key, and deleting the line later does not remove it from history.
Fix: Rotate the credential first, because it should be treated as exposed. Then load it from the environment or a secret manager (process.env.NAME, os.environ["NAME"]) and keep the value in an untracked .env file or your platform's secret store.
Lab: lab-01-secrets · Pattern: secret-handling
How it checks: Finds string literals that contain SQL (SELECT ... FROM, INSERT INTO, UPDATE ... SET, DELETE FROM, WHERE) and are built with an untagged template literal, f-string, concatenation, .format() or % formatting. Reports when that string is passed to a query/execute/raw call on the same line or through a variable. Confidence is high when an interpolated value comes from request input in the same snippet, low when the query also uses bound placeholders (interpolation is then usually an identifier list) or the file is a test, and medium otherwise. Tagged templates (sql`...`, Prisma.sql) and placeholder arguments are not flagged.
Why it matters: When values are pasted into the SQL text, the database cannot tell data from commands. Input containing quotes or SQL keywords can change what the query does, including reading or deleting other users' data.
Fix: Pass values as bound parameters instead of building the string: db.query("SELECT * FROM users WHERE id = $1", [id]) in Node, cursor.execute("SELECT * FROM users WHERE id = %s", (id,)) in Python, or use your ORM's query builder. Tagged templates such as sql`...${id}` that parameterize automatically are also safe.
Lab: lab-02-sqli · Pattern: parameterized-queries
How it checks: Flags a string that interpolates request-controlled input (request fields, route params, or variables named like user_input / userMessage) when the string is a system or developer prompt (role: "system", system=, systemPrompt, instructions, SystemMessage), at high confidence. Also flags a prompt string that contains instruction phrasing ("You are a", "Your task is", "Instructions:") and interpolates such input without delimiters, at medium confidence. User input sent as its own user-role message, or wrapped in XML-style tags, triple quotes, or JSON.stringify, is not flagged.
Why it matters: When text from a user is pasted into the system prompt or into the instructions themselves, the model cannot tell your rules apart from the user's words. A user can write text that reads like new instructions and override what the feature is supposed to do, leak the prompt, or misuse any tools the model can call.
Fix: Keep the system prompt static. Send user text as a separate user message, wrap it in clear delimiters (for example <user_input>...</user_input>), tell the model that content inside the delimiters is data and never instructions, and give the model only the tools and data it needs for this request.
Lab: lab-03-prompt-injection · Pattern: llm-input-handling
How it checks: Tracks variables assigned from model responses (choices[0].message.content, content[0].text, output_text, generateText, .invoke(...).content, or variables named like llm_output / completion / aiResponse) and flags them when they reach an HTML sink (innerHTML, insertAdjacentHTML, document.write, dangerouslySetInnerHTML, v-html, Markup), eval / exec / new Function, a shell call (exec, execSync, spawn, os.system, subprocess with shell=True), a raw SQL call, or a file write path. Lines that pass the value through DOMPurify, sanitize-html, bleach, html.escape or shlex.quote are not flagged.
Why it matters: A model's output is shaped by whatever it read, including user text, web pages and documents that may contain injected instructions. Rendering it as HTML, running it as SQL or code, or passing it to a shell gives anyone who can influence the model a path to script injection, data loss or command execution.
Fix: Treat model output as untrusted user input. Render it as text (textContent, or a markdown renderer with raw HTML disabled followed by DOMPurify), never pass it to eval, exec or a shell, and if the model chooses an action, have it return structured data that you validate against an allowlist before running a fixed, parameterized operation.
Lab: lab-04-llm-output · Pattern: llm-output-handling
How it checks: Flags: jwt.decode() in JavaScript when the snippet never calls jwt.verify(); PyJWT decode with verify_signature False; the "none" algorithm; jwt.sign() / jwt.encode() without an expiry nearby; equality comparison between two password values (no hash or compare call on the line); MD5/SHA-1/SHA-256 next to the word password; authorization flags read from localStorage; session cookies with httpOnly or secure set to false.
Why it matters: Login and session code that looks finished can still let anyone in: tokens that are never verified or never expire, passwords stored in plain text or with fast hashes, and access decisions made in the browser. These flaws turn one leaked token or database dump into full account takeover.
Fix: Verify every token's signature with a fixed algorithm, give tokens a short expiry, hash passwords with bcrypt or argon2, set session cookies HttpOnly and Secure, and make every authorization decision on the server.
Lab: lab-05-auth · Pattern: auth-sessions
How it checks: Flags a record lookup (findById, findUnique, findFirst, findOne, get_object_or_404, query.get, filter_by, or SQL WHERE id = ?) whose ID argument comes from request input or a route parameter, when no ownership or authorization signal (req.user, current_user, request.user, userId, ownerId, tenantId, an authorize/permission call, or a 403) appears within the surrounding handler (8 lines before, 12 after). Only snippets that look like web request handlers are analysed, and test files are skipped. Confidence is medium because ownership can be enforced elsewhere, such as in middleware or row-level security.
Why it matters: The endpoint loads a record using an ID taken straight from the request and returns or changes it without checking that it belongs to the caller. Any signed-in user can change the ID in the URL or body and read or modify someone else's data.
Fix: Scope the lookup to the authenticated user, for example findFirst({ where: { id, userId: req.user.id } }) or Order.query.filter_by(id=order_id, user_id=current_user.id).first_or_404(), and return 404 when nothing matches. For shared resources, check an explicit permission before returning the record.
Lab: lab-06-idor · Pattern: auth-sessions
How it checks: Flags values written to raw-HTML sinks (innerHTML, outerHTML, insertAdjacentHTML, document.write, dangerouslySetInnerHTML, v-html, Jinja |safe, Handlebars {{{ }}}, EJS <%- %>, Markup / mark_safe) unless the value is a plain string literal or passes through DOMPurify, sanitize-html, bleach, html.escape or an escape helper on the same line. HTML strings built with request input and returned from res.send / HttpResponse / render_template_string are flagged only when the input is traceable to the request. Confidence is high for request-derived values; values of unknown origin are medium for template opt-outs (dangerouslySetInnerHTML, v-html, |safe, Markup) and low for DOM sinks. Model output reaching these sinks is reported by CODE-LLMOUT-001 instead.
Why it matters: Inserting a value into the page as raw HTML lets any markup inside it run in your users' browsers. A comment, profile field or URL parameter containing a script can steal sessions, act as the user, or rewrite the page.
Fix: Render values as text: textContent in the DOM, normal {value} interpolation in React, {{ value }} without |safe in Jinja, <%= %> in EJS. If you really need HTML (for example rendered markdown), sanitize it with DOMPurify.sanitize() or bleach.clean() using an allowlist right before inserting it.
Lab: lab-07-xss · Pattern: output-encoding
How it checks: Flags outbound HTTP calls (fetch, axios, got, http.get, requests, httpx, urllib urlopen, client.get) whose URL argument is request input or model output in the same snippet. Calls whose URL starts with a fixed scheme and host (only the path varies) are not flagged, and no finding is raised when the snippet contains a destination allowlist or private-address check.
Why it matters: If the server requests whatever URL a user provides, the user can point it at internal addresses such as cloud metadata endpoints, admin panels or databases that are not reachable from the internet. The response, or even its timing, can leak credentials and internal data.
Fix: Only fetch destinations you expect: parse the URL, require https, check the hostname against an allowlist, resolve DNS and reject private, loopback and link-local addresses, and disable redirects (or re-check every redirect target). For webhooks, validate the URL when it is saved and again when it is called.
Lab: lab-08-ssrf · Pattern: url-fetching
How it checks: Flags multer configured without limits or without fileFilter; Flask/Werkzeug code that reads request.files with no MAX_CONTENT_LENGTH and no extension allowlist; and any path built from the client-supplied filename (originalname, file.filename) without secure_filename, a generated UUID name, or a sanitizer.
Why it matters: Upload handlers that accept any file of any size let users fill the disk, upload HTML or scripts that are later served from your domain, or overwrite files when the client-supplied filename contains ../ path segments.
Fix: Set a maximum size (multer limits.fileSize, Flask MAX_CONTENT_LENGTH), allow only the types you need by checking the extension and the file's content, generate the stored filename yourself (a UUID), keep uploads outside the web root or in object storage, and serve them with a safe Content-Type and Content-Disposition: attachment.
Lab: lab-09-upload · Pattern: file-uploads
How it checks: Parses MCP configuration JSON found in the snippet (mcpServers, servers, mcp.servers) and flags: npx/bunx/uvx/pipx packages without an exact version (including @latest); secret-looking values in env, headers or args (known key formats, or long mixed values under a key/token/secret name) that are not ${VAR} references; remote url values over http:// to a non-loopback host; and commands run through a shell (sh/bash/cmd/powershell with -c, or a pipe to sh).
Why it matters: An MCP server runs with your user's permissions and its tool descriptions go straight into the model's context. An unpinned package can change under you on the next launch, a key pasted into the config is readable by anything that reads the file, and a plain-HTTP or shell-wrapped server is an easy place for someone else's code to run.
Fix: Pin every package to an exact version (npx -y @scope/server@1.2.3, uvx package==1.2.3), reference secrets as ${ENV_VAR} instead of pasting values, use https:// for remote servers, launch the server binary directly instead of through sh -c, and re-scan after every update.
Lab: lab-10-mcp-install · Pattern: mcp-config