cfgaudit

Rules / CFG050

CFG050 hardcoded secret in an MCP server env or headers

Severity: error OWASP: LLM02:2026 – Sensitive Information Disclosure OWASP MCP: MCP01:2025 – Token Mismanagement & Secret Exposure — provisional (MCP Top 10 v0.1)

What cfgaudit checks

CFG007 detects hardcoded secrets in the settings.json env block. An MCP server carries its own credentials in its env map and, for remote (HTTP/SSE/WebSocket) transports, in its request headers (e.g. Authorization: Bearer …). A committed .mcp.json / mcpServers entry with a baked-in token is a direct credential leak — to the repo, and (for headers) to the remote endpoint on every request.

A Cursor remote server's static OAuth auth.CLIENT_SECRET (Cursor's MCP docs, "Static OAuth for remote servers"; #649) is reported as a hardcoded secret unless it is written as ${env:...}, the form the same docs show.

The same header check also reads Gemini CLI's telemetry.otlpHeaders (#646), which Gemini sends on every OpenTelemetry export request: the 0.65 nightly sent a project's literal Authorization header to the collector the same file names.

cfgaudit applies the CFG007 secret heuristics across every MCP source it discovers (settings.json, .mcp.json, other agents' configs):

The same header check runs over the headers of a Copilot type: "http" hook (.github/hooks/*.json), which is a committed request-credential block of exactly the same shape. disableAllHooks: true does not suppress it — a secret committed into a file has leaked whether or not the agent currently runs the hook. The outbound channel itself is CFG088.

It also runs over the headers of an extraKnownMarketplaces entry in settings.json, a third committed request-credential block of the same shape. Those headers are really sent: upstream documents that "if you register the marketplace from a URL source with headers, such as an extraKnownMarketplaces entry, Claude Code sends those headers with archive downloads whose URL shares the marketplace URL's origin", and Claude Code 2.1.231 carries the matching guard, Fetch of … redirected to a different origin; dropped inherited marketplace headers. So the inheritance is real, and bounded to same-origin fetches. Registering the marketplace at all is CFG055; the literal secret in its headers is this rule's. The additionalMarketplaces alias carries the same entry shape and is reported the same way, under the spelling the file used.

It also reports a literal Codex [mcp_servers.<name>.oauth] client_secret (#615), "OAuth client secret used for token exchange with a pre-registered client". Codex accepts it from a project-local .codex/config.toml (measured on 0.160.0) and offers no environment-variable form for it, so the finding points at the user-level ~/.codex/config.toml instead of a ${TOKEN} reference.

It runs over Continue's requestOptions.headers too, wherever that block hangs: models, mcpServers and data entries share one schema. That is where the credentials actually are. Across 108 real .continue/config.yaml files requestOptions appears 44 times (42 of them on models), against 1 for the whole data block that #470 asked about. A data entry's own apiKey is covered as well.

Three exemptions the false-positive run earned

Pointing the detector at Continue's headers exposed three gaps that would have made the new coverage 80% noise. All three are fixed here and apply to every source CFG050 reads, not just this one:

Real value Was Why it is not a credential
${{ secrets.OLLAMA_API_KEY }} flagged CI expression syntax. shellRefRe rejects it over the second brace, so the template branch now accepts ${{…}} alongside {{…}}. 7 of the 15 real header values.
Bearer TU_API_KEY_AQUI flagged An all-caps token naming a credential is an unfilled template. Recognised when it is nothing but capitals, underscores and a credential word, so a real key with mixed case or digits still reports.
'Basic' flagged An auth scheme word, doubly quoted by YAML. One layer of matching literal quotes is stripped, and the scheme pattern now matches a bare scheme word so the remaining credential is empty. 4 findings.

Corpus result: 7 findings before these three, 3 after, and all three survivors are real (sm_live_… twice and a high-entropy X-Auth-Token).

Unlike CFG055, this half is not limited to project settings. A secret written into a user's own settings.json is still a secret in a plaintext file, which is how CFG050 already treats an MCP server's headers.

No "unpinned source" finding is added here. Git-based marketplace sources support ref but not sha, so a pin-it message would ask for a remedy that does not exist at this level. The sha pin belongs to plugin sources inside marketplace.json, which is CFG098.

A reference to an environment variable is the correct pattern and is not flagged: ${TOKEN} / $TOKEN, plus obvious placeholders (<your-token>, changeme, xxxxx, your-…, TODO, example).

Trigger example

{
  "mcpServers": {
    "remote": {
      "url": "https://mcp.example/sse",
      "headers": { "Authorization": "Bearer sk-ant-abcdef1234567890" },
      "env": { "GITHUB_TOKEN": "ghp_abcdefghij0123456789ABCDEF" }
    }
  }
}

→ two error findings. Using "Authorization": "Bearer ${MCP_TOKEN}" and "GITHUB_TOKEN": "${GITHUB_TOKEN}" clears both.

Safe alternative

Reference an environment variable so the secret stays out of the committed config:

{ "mcpServers": { "remote": { "url": "https://mcp.example/sse", "headers": { "Authorization": "Bearer ${MCP_TOKEN}" } } } }

References

Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation