八分之一的凭证槽位:公开 MCP 配置文件里藏着的密钥
Hush Security 在 9 月发布了一项研究,分析了约 82,000 个公开的 Model Context Protocol 配置文件,其中最该记住的数字是「八分之一」。这些文件里 12% 的凭证槽位硬编码了密钥——光这一条,不过又是一个「开发者对 API Key 不上心」的老故事。真正值得你本周花一小时看的是第二个发现:这些硬编码密钥里有 55% 没有任何厂商可识别的 token 特征,也就是说,gitleaks 和 GitHub 密钥扫描所依赖的模式匹配根本不会触发。文件是公开的,凭证是活的,而本该抓住它的那些工具,正在找一个并不存在的前缀。
MCP 配置文件本来就是设计成要提交的,这正是问题所在
到底测了什么
数据集覆盖 Claude Code、Cursor、VS Code、Windsurf、Gemini、OpenAI Codex、JetBrains 等编码 Agent 的 MCP 服务配置文件。这个跨度很重要,因为它说明这不是某一家的锅。Hush 对根因的描述很准确:MCP 配置文件告诉 Agent 该连哪些工具、怎么认证;而和 .env 文件不同,它本来就是设计成提交进版本库、方便团队共享 Agent 工具链的。提交这个文件是预期行为,提交密钥不是——但两者在同一个文件里,而这个文件存在的意义就是被共享。Hush 还指出协议本身也没帮上忙:认证在实践中是可选的,对本地服务器不强制,而且对「Agent 连上之后能做什么」保持沉默。
# Variable expansion is the difference between a config file and a credential file.
import json
import os
import re
COMMITTED = { # this is the version that goes into the repository
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "${GH_TOKEN}"},
},
}
}
def resolve(spec, environ):
missing = []
def walk(node):
if isinstance(node, str):
def sub(match):
key = match.group(1)
if key not in environ:
missing.append(key)
return ""
return environ[key]
return re.sub(r"\$\{([A-Z0-9_]+)\}", sub, node)
if isinstance(node, dict):
return {k: walk(v) for k, v in node.items()}
if isinstance(node, list):
return [walk(v) for v in node]
return node
return {"resolved": walk(spec), "missing": missing, "ok": not missing}
print(resolve(COMMITTED, {"GH_TOKEN": "ghp_" + os.urandom(4).hex()}))
print(resolve(COMMITTED, {}))
print("committed file holds no secret:", "ghp_" not in json.dumps(COMMITTED))
# A missing variable should fail loudly at startup, not silently hand the agent an empty credential.为什么扫描器会从 55% 的密钥旁边走过去
密钥扫描靠的是形状。GitHub 个人访问令牌以 ghp_ 开头,Anthropic 和 OpenAI 的密钥各有自己的前缀,正是这些前缀让扫描器又快又低噪。这个设计的代价,是切掉了一切没有可识别前缀的东西。这个数据集里 55% 的硬编码密钥正落在那个缺口里——所以这类泄露被形容为「基本不可见」。数据集里也不都是冷僻凭证:GitHub 个人访问令牌、Anthropic 与 OpenAI 的 API Key、Slack 与 Notion 的工作区令牌、以及内嵌密码的数据库连接串都在其中。有些有前缀,有些没有。如果你的密钥扫描完全基于前缀,那你实际上只扫了一个子集,然后管它叫「策略」。
# The secrets scanners miss have no vendor prefix. Catch them by name, length and entropy.
import math
import re
from collections import Counter
ASSIGNMENT = re.compile(
r"(?i)\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIAL|DSN|CONN)[A-Z0-9_]*)[\"']?"
r"\s*[:=]\s*[\"']?([^\"'\s,}]+)"
)
def shannon_entropy(value):
if not value:
return 0.0
length = len(value)
counts = Counter(value)
return -sum((c / length) * math.log2(c / length) for c in counts.values())
def scan(text, min_entropy=3.2, min_length=16):
findings = []
for name, value in ASSIGNMENT.findall(text):
if value.startswith("${") or value in {"null", "changeme", "REPLACE_ME"}:
continue
if len(value) >= min_length and shannon_entropy(value) >= min_entropy:
findings.append({
"name": name,
"length": len(value),
"entropy": round(shannon_entropy(value), 2),
"preview": value[:4] + "...",
})
return findings
import random
import string
def fake_secret(length, seed=20261015):
rng = random.Random(seed)
alphabet = string.ascii_letters + string.digits
return "".join(rng.choice(alphabet) for _ in range(length))
SAMPLE = (
'"env": {\n'
' "GITHUB_PERSONAL_ACCESS_TOKEN": "${GH_TOKEN}",\n'
' "DB_PASSWORD": "correct-horse-battery-staple",\n'
' "SESSION_SECRET": "' + fake_secret(24) + '",\n'
' "CHAT_BOT_TOKEN": "' + fake_secret(20) + '"\n'
'}\n'
)
for finding in scan(SAMPLE):
print(finding)
# Prefix matching catches ghp_ and sk-. Entropy catches the ones nobody gave a prefix to.大多数扫描器靠前缀匹配。最危险的密钥,恰恰没有前缀
权限范围与有效期,才是伤害被放大的地方
泄露一把沙箱用的只读令牌很糟。泄露一把永不过期、工作区级的令牌,是另一个量级的问题——而这个数据集的分布严重偏向后者。在能判定权限范围的泄露凭证里,53% 是组织级、账号级、工作区级或数据库级。而在有到期策略的凭证里,80% 默认永不过期——这是这些令牌被签发的方式决定的,不是谁做过的决定。两者叠加,数据集里 24% 的硬编码密钥同时具备「权限宽」和「不过期」两个特征。Hush 的 CEO Micha Rave 指出:安全团队被训练出来的那套直觉——扫描、拦住提交、轮换泄露的东西——在这里远远不够,因为这些令牌背后的身份既没有归属人,也没有到期时间。
# Deleting the line does not delete the secret. Audit history, not just the current tree.
import subprocess
def history_hits(path, needle="ghp_"):
commits = subprocess.run(
["git", "log", "--format=%H", "--", path],
capture_output=True, text=True,
)
if commits.returncode != 0:
return {"error": "not a git repository, or path is untracked"}
hits = []
for sha in commits.stdout.split():
blob = subprocess.run(["git", "show", sha + ":" + path], capture_output=True, text=True)
if blob.returncode == 0 and needle in blob.stdout:
hits.append({"commit": sha[:8], "readable_in_history": True})
return {"path": path, "commits_with_secret": hits}
print(history_hits("mcp.json"))
print(history_hits("does-not-exist.json"))
# In Hush's dataset, 7,681 credential-bearing configs were traced through history.
# 1,394 secrets were still live in the current file; 243 more were readable only in old commits.删掉那一行,不叫修复
关于提交历史的那个发现,应该改变你关这类工单的方式。Hush 追溯到 7,681 个含凭证的配置文件的历史,发现 1,394 个密钥在当前文件里依然有效,另有 243 个「已从当前文件删除」的密钥,在更早的提交里仍然完整可读。删掉一行不等于轮换了一把凭证,只是让这把凭证的「说明书」变短了。这是 Git 众所周知的性质,却依然被反复忽略,因为「改个文件、关掉 PR」感觉就像是修复了。只要这个值被提交过,唯一的补救就是轮换;如果仓库是公开的,那就默认它已经被收集了。Hush 的建议很直白:凡是被提交过的密钥,一律轮换。
# Give every agent identity an owner and an expiry. The default is neither.
from datetime import date
IDENTITIES = [
{"name": "github-pat-ci", "owner": "platform-team", "expires": date(2026, 12, 31), "scope": "repo"},
{"name": "notion-workspace", "owner": None, "expires": None, "scope": "workspace"},
{"name": "openai-key-dev", "owner": "eric", "expires": date(2026, 11, 30), "scope": "account"},
]
BROAD_SCOPES = {"organization", "account", "workspace", "database"}
def audit(identities, today=date(2026, 10, 15), warn_days=45):
problems = []
for ident in identities:
if not ident["owner"]:
problems.append((ident["name"], "no owner"))
if not ident["expires"]:
problems.append((ident["name"], "never expires"))
elif (ident["expires"] - today).days <= warn_days:
problems.append((ident["name"], "expires in " + str((ident["expires"] - today).days) + " days"))
if ident["scope"] in BROAD_SCOPES:
problems.append((ident["name"], "broad scope: " + ident["scope"]))
return problems
for row in audit(IDENTITIES):
print(row)
# 24% of the hardcoded secrets in Hush's dataset were both broad-scope and non-expiring. Start there.删掉那一行并不会删掉密钥,只有轮换才可以
四条建议,以及各自要付的成本
Hush 给的四条建议,没有一条需要你上新平台。第一,绝不提交内联密钥,只要配置格式支持变量展开就用变量展开。第二,从静态长命凭证迁移到短期、基于身份访问,并且轮换一切被提交过的东西。第三,把凭证彻底移出 Agent 和 MCP 配置,改成从一个安全位置代理下发,这样在边缘上就没有东西可偷。第四,给每一个 Agent 身份明确的归属人和到期时间,因为两者皆无的身份,从构造上就是不可见的。第一条和第四条是你本周就能做完的配置与流程改动。第三条才真正缩小了攻击面,因为它把价值移走了,而不是搬了个地方。
# Keep the credential out of the agent and out of the config. Broker it when it is needed.
import time
class FakeVault:
def __init__(self):
self.calls = 0
def issue(self, identity, ttl_seconds):
self.calls += 1
return "short-lived-token-" + str(self.calls)
class SecretBroker:
def __init__(self, vault, ttl_seconds=900):
self.vault = vault
self.ttl = ttl_seconds
self.cache = {}
def get(self, identity, now=None):
now = now if now is not None else time.time()
entry = self.cache.get(identity)
if entry and entry["expires_at"] > now:
return {"identity": identity, "secret": entry["secret"], "from_cache": True}
secret = self.vault.issue(identity, ttl_seconds=self.ttl)
self.cache[identity] = {"secret": secret, "expires_at": now + self.ttl}
return {"identity": identity, "secret": secret, "from_cache": False}
broker = SecretBroker(FakeVault())
print(broker.get("github-pat-ci", now=1000))
print(broker.get("github-pat-ci", now=1300))
print(broker.get("github-pat-ci", now=2500))
# Nothing in the config file is worth stealing, because the value is not in the config file.一个合理的处理顺序
先去确认自己有没有暴露,而且要在提交历史上查,而不是只看当前工作树——因为可读的副本在那里。然后按影响半径排序,而不是按文件:一把永不过期的工作区级令牌,优先于一把测试环境的只读令牌,即便后者在扫描报告里看起来更吓人。接着把配置改成变量展开,让下一次提交是干净的;并且在现有的前缀匹配之外,再补一道基于熵值的检查——因为按前缀的那一半,本来就已经被覆盖了。轮换是唯一真正把口子关上的步骤,所以要把它排进日程,而不是当成工单的最后一步。
📌 常见问题 FAQ
Hush Security 发现了什么?
对约 82,000 个公开 MCP 配置文件的分析发现:12% 的凭证槽位硬编码了密钥;这些密钥中 55% 没有厂商可识别的 token 特征;而风险最高的凭证既权限宽泛又极少过期。
为什么密钥扫描器会漏掉这些凭证?
gitleaks、GitHub 密钥扫描这类工具主要匹配厂商可识别的 token 形状与前缀。Hush 发现数据集中 55% 的硬编码密钥没有这种形状,因此这些扫描器没有可匹配的模式。
数据集里都是哪些凭证?
GitHub 个人访问令牌、Anthropic 与 OpenAI 的 API Key、Slack 与 Notion 的工作区令牌、以及内嵌密码的数据库连接串;来源覆盖 Claude Code、Cursor、VS Code、Windsurf、Gemini、OpenAI Codex、JetBrains 等 Agent 的配置。
把密钥从文件里删掉就好了吗?
不行。Hush 追溯了 7,681 个含凭证配置的历史,发现 243 个已从当前文件删除的密钥,在更早的提交中仍然完整可读。只要密钥被提交过,轮换才是唯一真正的补救。
Hush 的建议是什么?
绝不提交内联密钥并改用变量展开;迁移到短期、基于身份的访问,并轮换一切被提交过的密钥;从安全位置代理下发凭证,让 Agent 与 MCP 配置里什么都不留;给每个 Agent 身份明确归属人与到期时间。
🔧 推荐工具
📚 参考资料
- PR Newswire / Hush Security — New research finds 1 in 8 credentials in public MCP config files are hardcoded secrets (official release, Sept 17 2026)
- Hush Security — The State of MCP Configuration: The Identity Security Gaps (official report)
- Help Net Security — Hardcoded MCP credentials found in public GitHub files