别让服务带着 dev-secret 上线:用 insecure-defaults 揪出 fail-open 配置
一个 Node.js 服务用 HMAC 签名身份令牌。开发为了方便,把配置写成了:
js
const secret = process.env.JWT_SECRET || "dev-secret";
本地无需配置就能跑,容器也能正常启动。这恰恰是问题:只要部署时漏了 JWT_SECRET,公开源码里的默认值就成了线上签名密钥。攻击者不需要窃取任何凭据,只需用同一个默认值生成管理员令牌。
今天从《AI Skills 每日精选》的 5 个候选中选择 Trail of Bits 的 insecure-defaults 做深入实战。它最值得学的不是几条正则,而是一个安全审计判准:不要在"搜到可疑字符串"时就下结论,必须证明应用在缺配置时仍以不安全状态运行。
本文没有向正式 Codex 环境安装任何第三方 Skill。源码核验来自维护者官方仓库;演示仅在当前项目的
.tmp/ai-skill-2026-07-19/隔离目录运行,只用合成数据与 Node.js 内置模块。
先分清 fail-open 和 fail-secure
insecure-defaults 的官方 Skill 源文件 把重点放在两种失败方式上:
- fail-open:安全配置缺失时,应用仍继续运行,但改用弱密钥、关闭认证或开放访问;
- fail-secure:安全配置缺失时,应用拒绝启动或拒绝请求,不进入降级的安全状态。
Skill 规定了四步工作流:
SEARCH:先识别技术栈与项目约定,搜索密钥回退、默认凭据、默认关闭认证、CORS *、弱密码算法和调试功能;VERIFY:沿真实执行路径追踪,确认代码何时执行、缺配置会发生什么、是否还有强制校验;CONFIRM:核对 Dockerfile、Kubernetes、IaC 或发布配置,判断问题是否可达生产;REPORT:给出位置、缺配置时的实际行为、部署证据和可利用后果。
反过来,tests/里的固定密钥、.example 配置、文档示例、明确只在开发环境使用的工具,以及"配置缺失就崩溃"的代码,都不应被当成此类漏洞报告。
安装或只读接入
Claude Code
维护者 Trail of Bits Skills 官方仓库 给出的市场接入方式是:
text
/plugin marketplace add trailofbits/skills
/plugin menu
然后在菜单中选择 insecure-defaults。如果已经将官方仓库克隆到本地,该插件的 README 也提供了本地路径安装方式:
text
/plugin install ./plugins/insecure-defaults
Codex
官方 Codex 安装说明 使用仓库内的 .codex/skills/ 侧车目录:
bash
git clone https://github.com/trailofbits/skills.git ~/.codex/trailofbits-skills
~/.codex/trailofbits-skills/.codex/scripts/install-for-codex.sh
执行后重启 Codex,再检查 ~/.codex/skills 中的链接。这会写入用户级 Codex 目录,不是"先试试"的无影响操作;应先审查目标 commit、SKILL.md、引用文件与安装脚本。官方脚本是 POSIX Shell,Windows 还需要额外处理 Shell 与符号链接能力,不要盲目提权执行。
只想评估时,更稳妥的做法是把仓库克隆到项目临时目录,固定 commit,只读取 SKILL.md 和 references/examples.md,不运行安装脚本。本文就采用了这种边界。
安装后可以这样触发:
text
使用 insecure-defaults 审计这个 Node.js 服务的生产可达代码。
重点检查 JWT/会话密钥回退、默认凭据、默认关闭认证、CORS * 和对外返回的调试信息。
跳过 tests、docs 和 *.example。每个发现都必须追踪实际执行路径,并核对部署配置。
只输出脱敏报告,不修改文件,不打印 Secret 原文,不连接生产服务。
最小可复现实例
下面的案例只使用 Node.js 内置 node:crypto,不需要安装 npm 依赖。为了把攻击链压缩到一个文件,它实现的是 payload.signature 形式的 JWT 风格 HMAC 令牌,不是完整 JWT 规范实现,也不应直接用于生产。
新建 app.mjs:
js
import { createHmac, timingSafeEqual } from "node:crypto";
const PUBLIC_FALLBACK = "dev-secret";
function loadSecret(mode, env = process.env) {
if (mode === "vulnerable") {
return env.JWT_SECRET || PUBLIC_FALLBACK;
}
const secret = env.JWT_SECRET;
if (!secret || Buffer.byteLength(secret, "utf8") < 32) {
throw new Error("JWT_SECRET must be configured with at least 32 bytes");
}
return secret;
}
function encode(value) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function sign(payload, secret) {
const body = encode(payload);
const signature = createHmac("sha256", secret)
.update(body)
.digest("base64url");
return `${body}.${signature}`;
}
function verify(token, secret) {
const [body, supplied] = token.split(".");
if (!body || !supplied) return false;
const expected = createHmac("sha256", secret).update(body).digest();
const actual = Buffer.from(supplied, "base64url");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
const mode = process.argv[2] ?? "vulnerable";
try {
const serverSecret = loadSecret(mode);
const forgedAdminToken = sign(
{ sub: "attacker", role: "admin" },
PUBLIC_FALLBACK,
);
const accepted = verify(forgedAdminToken, serverSecret);
console.log(`mode=${mode}`);
console.log("server_started=true");
console.log(`forged_admin_token_accepted=${accepted}`);
process.exitCode = accepted ? 2 : 0;
} catch (error) {
console.error(`mode=${mode}`);
console.error("server_started=false");
console.error(`reason=${error.message}`);
process.exitCode = 1;
}
timingSafeEqual 前先比较长度,是因为 Node.js 官方文档 明确说明:两个输入长度不同时会抛错。同时,官方也提醒调用这个函数不代表周边所有代码自动具备常量时间特性。
为了验证生产可达性,再准备一个最小 package.json:
json
{
"name": "isolated-insecure-defaults-demo",
"private": true,
"type": "module",
"scripts": {
"start": "node app.mjs vulnerable"
}
}
以及 Dockerfile:
dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json app.mjs ./
CMD ["npm", "start"]
这个部署入口明确运行 vulnerable 模式,也没有强制声明 JWT_SECRET。本地演示不需要真正构建或启动容器,也不会访问网络。
按 Skill 的四步流程审计
1. SEARCH:搜到候选,但暂不报漏洞
bash
rg -n "JWT_SECRET|secret|createHmac|CMD|start" app.mjs package.json Dockerfile
关键结果是:
text
app.mjs:3:const PUBLIC_FALLBACK = "dev-secret";
app.mjs:7: return env.JWT_SECRET || PUBLIC_FALLBACK;
app.mjs:40: const forgedAdminToken = sign(..., PUBLIC_FALLBACK);
package.json:6: "start": "node app.mjs vulnerable"
Dockerfile:4:CMD ["npm", "start"]
此时只能说"有可疑的密钥回退"。如果这些代码仅在测试目录,或启动前还有另一层校验,那就可能不可利用。
2. VERIFY:证明缺配置时仍能运行且可伪造
PowerShell:
powershell
Remove-Item Env:JWT_SECRET -ErrorAction SilentlyContinue
node app.mjs vulnerable
实测环境为 Node.js v22.20.0,输出:
text
mode=vulnerable
server_started=true
forged_admin_token_accepted=true
演示程序故意返回退出码 2 标记"攻击成功复现"。这里已经证明两件事:服务在缺失密钥时没有失败;公开默认值确实进入了 HMAC-SHA-256 签名和验证路径。crypto.createHmac 官方文档 也说明了该 API 使用传入的 key 生成 HMAC。
3. CONFIRM:核对部署入口
Dockerfile 的 CMD 会进入 npm start,而 npm start 明确选中 vulnerable 模式。部署文件中没有密钥存在性校验。对这个合成案例而言,生产可达性已成立。
真实项目不能只看 Dockerfile,还要查 Kubernetes Secret、Helm values、Terraform、发布平台变量和启动包装脚本。如果无权读取真实生产配置,应写"代码级 fail-open,生产影响待确认",而不是自行猜测已经沦陷。
4. REPORT:用证据描述问题
text
Finding: JWT 风格令牌使用公开 HMAC 密钥回退
Location: app.mjs:3-12,package.json:6,Dockerfile:4
Verification: 未设置 JWT_SECRET 时服务仍启动,且接受由 dev-secret 生成的管理员令牌
Deployment evidence: Dockerfile -> npm start -> node app.mjs vulnerable,未见密钥强制校验
Impact: 知道公开默认值的攻击者可伪造身份令牌
Fix: 启动时强制要求来自密钥管理系统的高熵密钥,缺失或不合格时拒绝启动
报告不需要记录真实密钥或完整令牌。位置、脱敏的配置名、行为和影响已足够支撑修复。
修复不是"换一个更长的默认值"
安全修复的核心是删掉回退路径,并在服务接收流量前完成校验:
js
function loadSecret(env = process.env) {
const secret = env.JWT_SECRET;
if (!secret || Buffer.byteLength(secret, "utf8") < 32) {
throw new Error("JWT_SECRET must be configured with at least 32 bytes");
}
return secret;
}
先验证缺配置的结果:
powershell
Remove-Item Env:JWT_SECRET -ErrorAction SilentlyContinue
node app.mjs secure
text
mode=secure
server_started=false
reason=JWT_SECRET must be configured with at least 32 bytes
退出码为 1,说明服务 fail-secure。再使用一个仅用于本地演示的 32 字节合成值:
powershell
$env:JWT_SECRET = '0123456789abcdef0123456789abcdef'
node app.mjs secure
Remove-Item Env:JWT_SECRET -ErrorAction SilentlyContinue
实测输出:
text
mode=secure
server_started=true
forged_admin_token_accepted=false
退出码为 0。三个场景形成了完整证据链:
| 场景 | 是否启动 | 公开默认值伪造的管理员令牌 | 结果 |
|---|---|---|---|
| 弱回退,缺少密钥 | 是 | 接受 | 攻击成立 |
| 强制校验,缺少密钥 | 否 | 无法进入验证路径 | fail-secure |
| 强制校验,配置独立密钥 | 是 | 拒绝 | 修复有效 |
32 字节只是本例的最低结构校验,不代表任意 32 字节字符串都具备足够熵。生产密钥应由密码学安全的随机源生成,保存在 Secret Manager 中,支持轮换,且不进入代码、镜像、日志和文档。
常见坑
1. 把正则匹配数当漏洞数
rg "MD5|SHA1" 会同时命中密码哈希和非安全用途的缓存键。CORS * 对公开静态资源和带凭据的私有 API 影响也不同。搜索只用于生成候选,不能代替执行路径和安全上下文。
2. "生产会覆盖"没有证据
如果发布配置确实强制注入密钥,当前暴露可能下降;但代码仍保留了在未来新环境中 fail-open 的能力。要核对真实配置,不要把团队惯例当成技术保证。
3. 把 || 换成 ?? 就以为安全了
process.env.JWT_SECRET ?? "dev-secret" 只是改变了空字符串的处理;变量未定义时仍会使用公开回退。安全修复是"缺失就拒绝启动",不是换运算符。
4. 只校验长度,不管来源与轮换
aaaaaaaa... 也可以很长。密钥必须来自安全随机源,使用受控的 Secret Manager 分发,限制读取主体,并设计轮换与旧令牌失效策略。
5. fail-secure 修复变成了可用性事故
如果应用在 Secret Manager 尚未就绪时立即退出,编排系统可能不断重启它。这仍比带着弱密钥接收流量安全,但需要配套启动探针、明确告警、有界重试和发布前配置校验。
6. 报告里复制真实 Secret 和令牌
审计证据应尽量脱敏:记录变量名、文件位置、是否缺失、伪造结果和影响即可。已暴露的真实密钥应走独立的事故响应与轮换流程,不要再把它扩散到 issue、聊天和 CI 日志。
权限与安全边界
insecure-defaults 的官方元数据允许 Read 、Grep、Glob 和 Bash。它是指导 Agent 执行审计的工作流,不是一个只读静态扫描二进制;其中 Shell 能力可以执行仓库脚本、启动服务或发起网络请求,权限边界必须由使用者额外约束。
对真实仓库建议使用以下基线:
- 第一轮只给源码、部署模板和必要历史的只读权限,输出报告而不自动修改;
- 在隔离副本或临时 worktree 中验证,不执行被审仓库的未知脚本;
- 不向 Agent 传入生产
.env、Cookie、云凭据、客户数据或未脱敏日志; - 不允许审计过程连接生产数据库、第三方 API 或内网服务;
- 安装时固定已审查 commit,更新前查看 diff,并注意仓库采用 CC BY-SA 4.0 许可证;
- 若需运行启动验证,只用合成密钥和本地依赖,限制网络与文件系统权限,完成后清理环境变量。
当 Agent 需要读取真实部署平台配置才能确认生产影响时,这已经超出普通代码只读审查。应由有权限的人员在受控环境中核对,或只提供脱敏后的"变量是否必填"证据,不应为了提高结论确定性而扩大 Agent 权限。
适合与不适合的场景
适合
- 生产发布前审计环境变量、认证、会话、加密和调试配置;
- 审查 Docker、Kubernetes、Helm、Terraform 与应用代码之间的配置断层;
- 调查默认管理员账号、默认关闭认证、公开存储、过度宽松的 CORS 和对外暴露的调试信息;
- 作为代码审计的一个专项,生成有执行路径证据的候选清单。
不适合
- 只想做一次"全部安全漏洞"扫描:它不覆盖 SQL 注入、XSS、内存安全、业务授权等所有问题;
- 只有文档、测试夹具或开发示例,且已证明不会进入生产路径;
- 需要证明密码学算法本身正确:这需要专门的密码学审查,不能只靠默认值检查;
- 希望完全自动确定生产影响,但又无法提供任何部署证据;
- 要求 Agent 直接携带生产凭据做攻击性验证。
结论
insecure-defaults 真正改变的是审计的证据标准。看到 dev-secret 只是起点;证明缺少配置时服务仍启动、弱值真正进入认证路径、部署入口可达,才是可执行的安全发现。
最理想的修复也不是把默认密钥藏得更深,而是让安全配置成为启动的必要条件:配置不正确,服务就不接收流量。这就是从"运维大概会配好"到"代码保证不会带病上线"的跨越。
核验记录
- 2026-07-19 逐项核对 Trail of Bits 官方仓库的 Skill 源文件、反例参考、插件 README、Codex 安装说明与许可证;
- 核对 Node.js 官方
crypto.createHmac与crypto.timingSafeEqual文档; - 在项目隔离临时目录使用 Node.js
v22.20.0完成三个场景验证,退出码分别为2 / 1 / 0; - 未安装第三方 Skill,未执行上游安装脚本,未构建容器,未访问生产系统;
- 未读取或记录其他项目的私有业务、协议、Cookie、凭据、客户数据或未公开源码。