1. 一次 npm 发布事故,为什么值得每个 TypeScript 团队复盘
Claude Code 源代码泄露这件事,表面看是 Anthropic 的发布流程翻车,实际暴露的是 TypeScript 工程里一个非常普遍的盲区:source map 到底该不该进生产包。安全研究员在 npm 注册表上发现 Claude Code 2.1.88 版本附带了一个 59.8MB 的.map文件,这个文件能把压缩混淆后的产物完整映射回原始 TypeScript 源码,1900 多个源文件、51.2 万行代码连同手写注释一起暴露在公共互联网上。还原出来的内容包含长达 4.6 万行的 QueryEngine.ts、约 2.9 万行的工具系统定义、40 多个独立工具模块,以及一套六级权限验证系统。
这件事的技术原因并不复杂:Claude Code 使用 Bun 作为运行时和打包工具,Bun 的构建器默认生成 source map 文件,正常发布流程中这类文件应该通过.npmignore或构建配置被排除,但这一次它随正式包一同上传到了 npm 公共注册表。更值得注意的是,2025 年 2 月 Claude Code 的一个早期版本就因为同样的问题被曝光过,一年之后同样的问题再次出现。
我写这篇不是来嘲笑谁的。代码永远会有 bug,流程永远会有疏漏,那个忘记删.map文件的工程师可能连续加班了一周。真正有价值的是:把 source map 的生成、发布、泄露链路拆开看一遍,然后给你一套可以直接复制到项目里的 npm 发布配置、关闭/校验命令,以及 CI 中自动检测 source map 泄露的验证动作。适合谁看?正在维护 TypeScript 库或 CLI 工具、需要往 npm 发版的团队,尤其是用 Bun、esbuild、tsup、vite 这类默认带 source map 的构建工具的人。
2. 先把 source map 的泄露链路讲清楚
2.1 source map 是什么,为什么它会泄露源码
source map 本质是一个 JSON 文件,里面记录了「压缩后代码的某个位置」对应「原始 TypeScript 文件的某一行某一列」的映射关系,同时还带一个sourcesContent字段,直接把原始源码内容嵌进去了。浏览器 DevTools 能还原源码,靠的就是它。
问题就出在sourcesContent。很多人以为 source map 只是「位置映射」,删掉.map文件就没事了,其实只要.map被发布出去,攻击者用source-map这个 npm 包几行代码就能把原始.ts文件全部还原出来:
const { SourceMapConsumer } = require('source-map'); const fs = require('fs'); const raw = JSON.parse(fs.readFileSync('./dist/index.js.map', 'utf8')); SourceMapConsumer.with(raw, null, consumer => { consumer.sources.forEach(src => { const content = consumer.sourceContentFor(src); fs.writeFileSync(src.replace(/^.*\//, ''), content); }); });这段代码跑完,你的src/目录基本就回来了。
2.2 泄露链路:从构建到 npm 注册表
把链路拆成四步,每一步都是一个可能出事的环节:
| 环节 | 常见默认行为 | 风险 |
|---|---|---|
| 构建工具 | Bun/esbuild/tsup 默认sourcemap: true或linked | 产物目录里出现.map |
| 打包配置 | files字段写["dist"]或干脆不写 | .map被一起打进 tarball |
| 忽略规则 | 没有.npmignore,或.npmignore没写*.map | npm 按默认规则打包 |
| 发布校验 | npm publish前没有npm pack --dry-run检查 | 泄露文件直接上传 |
Claude Code 这次就是卡在第三步和第四步:Bun 生成了.map,.npmignore没排除,发布前也没做 dry-run 校验。
2.3 为什么「删了 .map 就安全」是错觉
还有一种更隐蔽的情况:构建时用了sourcemap: 'inline',source map 被 base64 编码后塞进.js文件末尾的//# sourceMappingURL=data:application/json;base64,...注释里。这种情况下你删.map文件根本没用,源码已经在.js里了。校验时必须同时检查这两种形态。
3. TaoToken 前置:把模型接入和发布校验串起来
讲发布配置之前,先说一个实际场景。很多团队在做 CI 自动检测时,想让模型帮忙判断「这个 diff 里有没有引入 source map 相关风险」,或者用 Claude Code 这类编码 Agent 直接改构建配置。这时候你需要一个稳定的模型接入点。
TaoToken 是一个大模型 API 聚合平台,兼容 OpenAI 和 Anthropic 的接口格式,你可以用它来调用 Claude、GPT 等模型。对于本文场景,它的用处有两个:一是给 CI 里的自动化脚本提供模型调用能力,二是给 Claude Code 这类编码工具提供后端。
注册和拿 Key 的入口在这里:
- 官网:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- 控制台(创建 API Key):https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
API 基础地址是https://taotoken.net/api,注意这个地址不带 UTM 参数,直接用于代码里的baseURL。
如果你只是想在 CI 里跑一个「检查 source map 泄露」的脚本,其实不需要模型,纯 Node 脚本就够了。但如果你想用 Claude Code 帮你自动修构建配置,或者用模型做代码审查,那就需要先配好 Key。下面给一个最小验证:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "回复 ok"}] }'返回里有choices[0].message.content就说明 Key 可用。这一步只是确认接入正常,真正的重点在下面的发布配置。
4. 可复制的 npm 发布配置与 source map 关闭命令
4.1 构建阶段:按环境关闭 source map
不同构建工具的关闭方式不一样,这里给几个常用的。
tsup(tsup.config.ts):
import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], format: ['esm', 'cjs'], dts: true, sourcemap: process.env.NODE_ENV !== 'production', clean: true, });esbuild(脚本里):
require('esbuild').build({ entryPoints: ['src/index.ts'], bundle: true, outfile: 'dist/index.js', sourcemap: process.env.NODE_ENV !== 'production', });Bun(bun build):
bun build ./src/index.ts --outdir ./dist --target node --sourcemap=none注意 Bun 的--sourcemap参数,none才是完全关闭,external会生成独立.map文件,inline会塞进.js。
vite(库模式vite.config.ts):
export default defineConfig({ build: { lib: { entry: 'src/index.ts', formats: ['es', 'cjs'] }, sourcemap: false, }, });4.2 打包阶段:用 files 字段白名单 + .npmignore 双保险
package.json里的files字段是白名单机制,写进去的才会被打包。推荐显式列出:
{ "name": "your-package", "version": "1.0.0", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist/**/*.js", "dist/**/*.cjs", "dist/**/*.d.ts", "README.md", "LICENSE" ] }注意这里没有dist/**/*.map,也没有dist整个目录。如果你写"files": ["dist"],那dist下所有文件包括.map都会被包含。
再加一层.npmignore:
# 源码 src/ test/ tests/ *.ts !*.d.ts # source map *.map *.js.map *.css.map # 配置 tsconfig.json tsup.config.ts vite.config.ts .eslintrc* .prettierrc* # CI .github/ .gitlab-ci.yml注意:
files字段和.npmignore同时存在时,files优先级更高。所以最稳的做法是files只写需要的,.npmignore作为兜底。
4.3 发布前:npm pack --dry-run 校验
这是最关键的一步,也是 Claude Code 这次事故里缺失的一步。在npm publish之前先跑:
npm pack --dry-run输出会列出即将打包的所有文件。你要检查两件事:有没有.map文件,有没有src/目录。如果输出里出现dist/index.js.map或者src/xxx.ts,立刻停下来修配置。
更严格的写法,直接让脚本在发现.map时退出码非零:
#!/usr/bin/env bash set -euo pipefail OUTPUT=$(npm pack --dry-run 2>&1) if echo "$OUTPUT" | grep -qE '\.map$|\.map\s'; then echo "检测到 source map 文件将被发布,已阻止:" echo "$OUTPUT" | grep -E '\.map' exit 1 fi if echo "$OUTPUT" | grep -qE '^\s*src/'; then echo "检测到 src/ 目录将被发布,已阻止" exit 1 fi echo "打包校验通过"把这个脚本存成scripts/check-pack.sh,在package.json里加:
{ "scripts": { "prepublishOnly": "bash scripts/check-pack.sh && npm run build" } }prepublishOnly会在npm publish前自动执行,校验不过就发不出去。
5. 验证请求与成功结果:CI 中自动检测 source map 泄露
5.1 一个独立的 Node 检测脚本
上面的 bash 脚本依赖npm pack,在 CI 里够用。但如果你想在 PR 阶段就检测,可以写一个不依赖打包的 Node 脚本,直接扫描dist/目录:
// scripts/scan-sourcemap.mjs import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, extname } from 'node:path'; const DIST = 'dist'; const problems = []; function walk(dir) { for (const name of readdirSync(dir)) { const full = join(dir, name); const st = statSync(full); if (st.isDirectory()) { walk(full); continue; } if (extname(name) === '.map') { problems.push(`发现独立 source map 文件: ${full}`); continue; } if (/\.(js|cjs|mjs)$/.test(name)) { const content = readFileSync(full, 'utf8'); if (content.includes('sourceMappingURL=data:')) { problems.push(`发现 inline source map: ${full}`); } if (/sourceMappingURL=.*\.map/.test(content)) { problems.push(`发现外部 source map 引用: ${full}`); } } } } walk(DIST); if (problems.length) { console.error('source map 泄露检测未通过:'); problems.forEach(p => console.error(' - ' + p)); process.exit(1); } console.log('source map 泄露检测通过');这个脚本同时覆盖了三种形态:独立.map文件、inline base64、外部引用。
5.2 接入 GitHub Actions
name: publish-check on: pull_request: push: branches: [main] jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run build - name: 扫描 source map 泄露 run: node scripts/scan-sourcemap.mjs - name: 校验 npm 打包内容 run: bash scripts/check-pack.sh5.3 成功结果长什么样
本地跑一遍:
npm run build node scripts/scan-sourcemap.mjs输出:
source map 泄露检测通过再跑打包校验:
bash scripts/check-pack.sh输出:
打包校验通过如果故意把sourcemap打开,脚本会输出:
source map 泄露检测未通过: - 发现独立 source map 文件: dist/index.js.map - 发现外部 source map 引用: dist/index.js退出码为 1,CI 直接红掉,PR 合不进去。这就是你要的效果。
6. 本篇常见错排查
6.1 关了 sourcemap 但 dist 里还有 .map
大概率是构建缓存没清。tsup 加clean: true,vite 加emptyOutDir: true,或者手动rm -rf dist再构建。另一个可能是你改了tsup.config.ts但实际跑的是package.json里的build脚本,脚本里硬编码了--sourcemap,配置文件的改动没生效。
6.2 npm pack --dry-run 输出里没有 .map,但发布后 npm 上还有
检查是不是用了npm publish --access public之外的其他发布方式,比如pnpm publish或yarn npm publish,它们的打包规则和 npm 略有差异。另外确认你发布的是当前目录,不是某个子目录。还有一种情况:你本地删了.map,但 CI 里构建时NODE_ENV没设成production,导致 CI 构建产物带.map然后被发布。
6.3 inline source map 检测漏了
上面的脚本用content.includes('sourceMappingURL=data:')检测 inline。如果你的构建工具用了//# sourceMappingURL=data:application/json;charset=utf-8;base64,这种带 charset 的格式,includes依然能匹配到sourceMappingURL=data:。但如果被压缩工具改成了//# sourceMappingURL=data:application/json;base64,且中间有换行,就可能漏。更稳的写法是用正则:
if (/sourceMappingURL=data:application\/json/.test(content)) { problems.push(`发现 inline source map: ${full}`); }6.4 .npmignore 写了 *.map 但没生效
.npmignore和.gitignore同时存在时,npm 优先用.npmignore。如果你没有.npmignore但有.gitignore,npm 会拿.gitignore当忽略规则。所以如果你在.gitignore里写了dist/,npm 打包时也会忽略dist/,导致你的包发出去是空的。这种情况要么加.npmignore覆盖,要么用files字段白名单。推荐后者,更明确。
6.5 用 Claude Code 改配置时改错了文件
如果你用 Claude Code 这类编码 Agent 帮你改tsup.config.ts或package.json,改完一定要跑一遍npm run build && node scripts/scan-sourcemap.mjs验证。Agent 可能会把sourcemap: false改成sourcemap: true来「方便调试」,或者把files字段改成["dist"]。这类改动在 diff 里不显眼,但后果严重。建议在 CI 里把scan-sourcemap.mjs设成必过项,Agent 改错了也发不出去。
7. 把校验动作固化下来,比事后复盘有用
Claude Code 这次泄露,还原出来的代码里有一套六级权限验证系统,还有一个专门在公共代码仓库操作时自动清除 AI 使用痕迹的 Undercover Mode。一个专门防止内部信息泄露的系统,最终没能防住整个源码库的裸奔。这件事的讽刺之处在于:安全设计做得再精细,发布流程的最后一公里没守住,前面全白搭。
对你来说,不需要搞六级权限,也不需要 Undercover Mode。你只需要三件事:构建时按环境关 source map,打包时用files白名单加.npmignore双保险,发布前跑npm pack --dry-run和scan-sourcemap.mjs。把这三个动作写进prepublishOnly和 CI,下次谁改错了配置,流水线会拦住他。
如果你在 CI 里想加一层模型审查,比如让模型读 diff 判断有没有引入 source map 风险,可以用 TaoToken 的 API 接入:
- 模型对话(测试模型可用性):https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- Coding Plan(长期编码/Agent 场景):https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=
但说实话,source map 泄露这件事,纯脚本检测比模型审查更可靠。模型可能漏判,正则不会。先把scan-sourcemap.mjs跑起来,再考虑要不要加模型层。
最后提醒一句:下次你发布代码时,跑一遍npm pack --dry-run。至少,别让全世界看到你的注释。