rea-reverse

发布前自查:你的 npm 包和前端产物会不会泄露 source map

基于 REA 6.4.0更新于 约 8 分钟读完
本页目录

source map 本来是调试用的:它把压缩后代码里的位置映射回原始文件。问题在于,多数打包工具默认还会把原始文件的全文塞进 map 的 sourcesContent 字段。只要 .map 进了 npm 包,或者被部署到了 Web 服务器上,任何人下载这一个文件,就能拿回你的整棵源码树,注释、内部地址、TODO、还没发布的功能都在里面。2026 年 3 月,一个用户很多的命令行工具就是这样在 npm 包里带出了 source map,原始源码因此公开。本文只讲防御这一面:发布之前,怎么检查自己的包和自己的站点。

适合谁读:npm 包维护者和前端应用开发者,想在每次发版前跑一遍固定检查。需要 Node.js 和 npm;REA 只在第 3 步用到,可选。

你会做的事:

  1. 弄清 map 是怎样把源码带出构建产物的
  2. 列出 npm pack 实际会发布的文件,找出 .map
  3. 在本地查看器里打开自己的 map,看它暴露了什么
  4. 用 REA 的 analyze-web-bundle 审计已部署的站点
  5. 修改构建配置,并在 CI 里加一道检查

以下检查只针对你自己拥有或已获授权测试的包和站点,参见 REA 的 DISCLAIMER.md。

source map 是怎么把源码带出去的

构建产物里有两样东西要注意。

  • 注释。打包工具会在文件最后一行加上 //# sourceMappingURL=app.js.map。浏览器 DevTools、带 --enable-source-maps 的 Node.js、错误监控服务都会顺着它去找 map。这样链接的 map 很好找,打开 bundle 看最后一行就知道。
  • 内容。map 的 sources 数组列出原始路径,sourcesContent 数组存放每个文件的全文。有了 sourcesContent,单凭一个 .map 就能重建整棵源码树;没有它,map 仍会暴露文件名和位置,但不含代码。

所以每次发版都要回答两个问题:map 有没有被发出去?发出去的 map 里带不带内容?

为了说得具体,下面用一个叫 greet-kit 的小包做例子:Vite 8 的库模式构建,build.sourcemap: true,一共两个源文件。其中 src/internal/flags.ts 里有一个预发环境地址和一条发版 TODO,这些本不该离开代码仓库:

// Internal only: staging switches for the next release.
// TODO(release): drop the beta flag before 2.0.
export const featureFlags = {
  betaGreeting: false,
  stagingApi: 'https://staging.internal.example/api',
};

第 1 步:列出 npm 会发布哪些文件

npm pack --dry-run 会算出 tarball 的文件清单,但既不写文件也不上传。“别人到底会下载到什么”,它给的答案最直接:

npm run build
npm pack --dry-run

下面是 greet-kit 修复之前的真实输出(npm 10.9,只保留文件清单部分):

npm notice Tarball Contents
npm notice 298B dist/greet-kit.js
npm notice 713B dist/greet-kit.js.map
npm notice 139B package.json
npm notice 192B src/index.ts
npm notice 216B src/internal/flags.ts
npm notice 151B vite.config.mjs
npm notice total files: 6

一眼就能看到两个问题:dist/ 里的 map 在清单上;而且 package.json 没写 files 字段,npm 把 src/ 和构建配置也一起发了。写脚本时用 JSON 格式更方便:

npm pack --dry-run --json | jq -r '.[0].files[].path' | grep -E '\.map$'

对 greet-kit 它会打印 dist/greet-kit.js.map。输出为空说明 tarball 里没有 map,但还不能说明 JS 文件里没有 sourceMappingURL 注释,这一点放到第 4 步处理。

为什么用 dry-run,而不是看一眼 dist/:npm 会综合 files、.npmignore、.gitignore 和它自己的默认规则来决定发布内容。dry-run 列出的是这些规则共同作用的结果,也就是你真正会发布的东西。

第 2 步:看看 map 里到底有什么

动手删文件之前,先站在拿到这个 map 的人的角度看一眼。这能告诉你暴露有多严重;接手别人留下的构建时,这也是个好习惯。

打开 JS bundle 与 source map 还原工具,把 dist/greet-kit.js 和 dist/greet-kit.js.map 一起拖进去。所有处理都在浏览器的 Web Worker 里完成,文件不会上传。工具对 greet-kit 给出的报告是:

字段 结果
Map greet-kit.js.map,普通 map,file: greet-kit.js
源文件 2 个,都带 sourcesContent
名称 / 映射段 3 个名称,22 个映射段
bundle 的 sourceMappingURL 外部引用,greet-kit.js.map
原始文件 src/internal/flags.ts(216 字节)、src/index.ts(192 字节)

源码目录树里两个文件都在,预览 src/internal/flags.ts,预发地址和 TODO 注释一字不差。点 全部下载为 .zip,拿到的就是你的 src/ 目录。压缩后的 bundle 已经去掉了注释,可注释在 map 里原样保留着。

换成真实项目,报告里还有几项值得看:

  • node_modules 占比:有多少源文件是第三方代码。map 里大部分是 node_modules,暴露的自有代码就少一些,但你自己的那部分照样全在。
  • 打包工具线索:webpack://、/@fs/ 这类名字能看出是哪种构建产出的 map,有时还能看出你本机的目录结构。
  • 没有内容的源文件:如果所有源文件都列在这里,说明这个 map 只带文件名和位置,也就是第 4 步说的 nosources 情况。

第 3 步:用 REA 审计已部署的站点

npm 包是一个出口,网站部署是另一个。对你自己的站点,REA(morluto 维护的开源项目)可以抓取真实页面加载的脚本,并尝试下载它们声明的 map。需要 REA 6.4.0 或更新版本(npm install --global rea-agents,见 安装与接入),再用回环调试端口和一次性的用户目录启动 Chrome:

# Linux; on macOS use "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
google-chrome --remote-debugging-port=9222 \
  --user-data-dir=/tmp/rea-audit-profile https://your-site.example/
rea list-browser-targets http://127.0.0.1:9222 --json
rea analyze-web-bundle http://127.0.0.1:9222 TARGET_ID --fetch-source-maps --json > bundle-audit.json

analyze-web-bundle 只解析抓到的脚本,不执行。加上 --fetch-source-maps 后,它会自己去请求每个声明的 map 地址:不带浏览器的 cookie,只限页面自己的源(或者你用 --allowed-origins 指定的源)。结果里要看的是 observations.source_maps 这一段:

  • 每一项都有 declared_url 和 status。included 或 partial 表示 map 能下载并且解析成功;fetch_failed(比如 HTTP 404)、invalid、policy_filtered(不在允许的源里)表示没有拿到。
  • 拿到的项会列出 original_sources。某个源文件的 artifact 不为 null,说明它的原文也被带出来了,这就是泄露。
  • 顶层的 status 取值是 not_requested、included、partial 或 unavailable;requested 和 processed 告诉你是不是每个声明的 map 都试过了。

解读时要谨慎。fetch_failed 只说明这一次不带 cookie 的请求没拿到 map,不能证明别人也拿不到。没有 sourceMappingURL 的脚本什么也没声明,自然无从下载。另外,6.4.0 只下载允许的源上的 http(s) 地址,内联的 data: map 不在这里处理。要查内联 map,把 bundle 存下来,放进第 2 步的工具里打开,它能解码内联 map。浏览器的准备工作在 网站 API 调用追踪 一文里讲得更细。

部署之前,也可以直接扫构建目录。rea analyze-javascript-application /absolute/path/dist --json 会读取它找到的每一条本地 sourceMappingURL 和对应的 map,记录每个原始源文件的名字;带 sourcesContent 的,还会记下内容摘要。这一步同样只读文件、不执行。这份 Evidence 怎么看,见 从 bundle 还原 JavaScript 源码。

第 4 步:修改构建配置

按你对 map 的实际需要选方案。

生产环境用不到 map:发版构建直接关掉。

// vite.config.js
export default { build: { sourcemap: false } };
// webpack.config.js (production)
module.exports = { devtool: false };

esbuild 不加 --sourcemap 即可;Bun 用 --sourcemap=none。

想看懂线上报错堆栈:构建 hidden map。.map 照常生成,但 bundle 里没有指向它的注释。把 map 当作构建产物保存,或者上传到错误监控服务,但不要部署、不要发布。Vite 写 build.sourcemap: 'hidden',webpack 写 devtool: 'hidden-source-map',esbuild 用 --sourcemap=external。greet-kit 改成 'hidden' 重新构建后,dist/ 里仍有 greet-kit.js.map,但 greet-kit.js 的最后一行变成了 export { t as greet };,后面没有注释。之后怎么用这些 map,见 用 source map 还原压缩后的报错堆栈。

必须发 map,但不想带出代码:webpack 的 nosources-source-map 和 esbuild 的 --sources-content=false 生成的 map 不含 sourcesContent。文件名和行号依然可见,所以这只算减轻,不算修好。

npm 包要用白名单控制发布内容:加上 files 字段,只发构建产物,并显式排除 map:

{
  "files": ["dist", "!dist/**/*.map"]
}

改完之后,greet-kit 的 dry-run 只剩两个文件:dist/greet-kit.js 和 package.json,map 和 src/ 都不见了。但 bundle 末尾还留着 //# sourceMappingURL=greet-kit.js.map,指向一个已经不存在的文件。它本身没有害处,却说明光靠 files 并没有改掉构建配置,所以要和 hidden map 或关闭 map 一起用。

第 5 步:在 CI 里加一道检查

手动查一次只管这一次发版,写成脚本才能一直管用。下面这个脚本运行 npm pack --dry-run --json,只要 tarball 里有 .map,或者有 JS 文件带着 sourceMappingURL 注释,就让构建失败:

// scripts/check-pack.mjs
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

const [pack] = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--json'], { encoding: 'utf8' }));
const problems = [];
for (const { path } of pack.files) {
  if (path.endsWith('.map')) problems.push(`${path}: source map in tarball`);
  else if (/\.(c|m)?js$/.test(path) && /[#@] sourceMappingURL=/.test(readFileSync(path, 'utf8')))
    problems.push(`${path}: has a sourceMappingURL comment`);
}
if (problems.length) {
  console.error(problems.join('\n'));
  process.exit(1);
}
console.log(`ok: ${pack.files.length} files, no source maps`);

构建完成后在包的根目录运行它,比如挂到 prepublishOnly 脚本上。greet-kit 只加了 files 时,它输出 dist/greet-kit.js: has a sourceMappingURL comment,退出码为 1;改成 sourcemap: 'hidden' 重新构建后,输出 ok: 2 files, no source maps。

Web 应用也可以在部署任务里做同样的事:构建完成后,如果 find dist -name '*.map' 在上传目录里找到了文件就报错,或者部署前先把 map 挪出上传目录。

常见问题和排错

dry-run 里没有 .map,DevTools 却还能看到原始文件。 map 是内联的,bundle 结尾是 sourceMappingURL=data:application/json;base64,...。在构建出的 JS 里搜 sourceMappingURL=data:,把构建配置从 'inline' 改掉。

已经设置了 files,还是有 map 被发出去。 .npmignore 或嵌套的 package.json 会影响结果,map 也可能不在 dist/ 里。以 dry-run 清单为准,不要只看配置。

脚本明明声明了 map,REA 一个都没拿到。 map 多半放在和页面不同源的 CDN 上,所以被过滤了(标为 policy_filtered,或者在抓取时就被排除)。如果两个源都归你管,用 --allowed-origins 把它们都加上。

页面上的第三方脚本也声明了 map。 那不是你的源码。只审计你自己构建和部署的脚本。

常见疑问

生产环境到底该不该用 source map?

该用,但别公开。hidden map 能让错误监控服务还原堆栈,而公开的 bundle 不指向任何 map。公开 map 主要适合代码本来就开源的项目。

只删掉注释够不够?

不够。注释只是告诉工具去哪找。如果 app.js.map 就放在服务器上 app.js 旁边,别人照样能猜到地址。hidden map 不要放进部署目录。

代码压缩过了,还用担心吗?

压缩会去掉 bundle 里的注释、缩短变量名,但带 sourcesContent 的 map 会把这些原样带回来。没有 map 时能恢复什么、不能恢复什么,见 从 bundle 还原 JavaScript 源码。

下一步