# JavaScript 混淆最佳实践

默认发布经过混淆的 JavaScript,用指令标记真正重要的区域,并持续轮换构建,让攻击者始终追不上。

Source: https://www.afterpack.dev/zh/docs/best-practices

## 发布 Web 应用时,混淆应当是默认选项

你发布的 JavaScript 中,一切都是可读的。密钥、业务逻辑、你所依赖的算法:任何人都能打开这个 bundle,前沿 LLM 也会在被要求时逐行解释它。[安全扫描器](https://www.afterpack.dev/zh/security-scanner)会展示真实生产环境 bundle 中究竟暴露了什么。在你自己的站点上运行一次,看看你已经泄露了什么。

混淆这个 bundle 能换来具体、可核实的收益:

- **针对你应用编写的补丁,撑不过下一次发布。** 用来定制或自动化你应用的 userscript、Tampermonkey 脚本或扩展,都必须锚定在某些稳定的东西上:一个函数名、一段字面量字符串、一种可识别的结构。重命名和字符串编码会移除这些锚点,而[新种子](https://www.afterpack.dev/zh/docs/config#seed)会在每次构建时重塑输出。作者针对某一个版本写好补丁,下一个版本就不再匹配,他们分发出去的用户只能守着一个失效的脚本,直到有人重写它。
- **针对你代码的查找替换,找不到匹配对象。** 简单的文本替换是自动化或篡改 Web 应用最便宜的方式,它能对 `isSubscribed` 和 `"Upgrade to continue"` 生效。但你发布的文件里,这两个字符串都不存在。
- **你的字符串不再是可读文本。** 字符串字面量会被编码,因此对某个密钥、某个接口地址或付费墙文案的 `grep` 搜索什么都找不到,大规模读取 bundle 的扫描器和爬虫同样一无所获。在 `light` 下数字仍然可读;从 `medium` 起,大多数整数也会被编码。
- **输出结果是一个被重塑的程序。** AfterPack 会把逻辑重写为现成工具无法还原回去的[等价](https://www.afterpack.dev/zh/docs/semantic-contract)形式。读者逐步分析这份产物,可以恢复出一个能运行的程序,但要付出真正的功夫,而且每次新构建之后,针对上一次构建编写的工具就不再匹配。
- **代价会反复出现。**每次构建的形态都不同，所以针对上一次发布的模式编写的工具或补丁，到这一次就会失效。已经有人从代码里读到的值，依然是读到了。

以上都不是保证,也不能替代服务端的权限控制。任何客户端代码都能在某种程度上被还原,因为运行时终究要执行它。真正改变的是修改它的代价:针对某次构建的模式编写的补丁或工具,到下一次构建就会失效。这个页面接下来要讲的,是如何把这份保护用在真正重要的地方。

## 持续轮换构建,领先攻击者一步

单个已混淆的版本是一个固定目标,只要投入足够的精力,验证码破解工具、作弊扩展或爬虫都能适应它。默认情况下[每次构建都会使用新种子](https://www.afterpack.dev/zh/docs/config#seed),因此每次构建的形态都不同,针对某次构建的模式编写的工具到下一次就会失效。运行你代码的人仍然能读到它用到的值。

如果你发布得不频繁,这层防护在版本之间就会失去效果。可以增加一个与代码改动无关的定时重建,让已部署的 bundle 持续轮换;[构建与 CI](https://www.afterpack.dev/zh/docs/builds#rotate-on-a-schedule) 介绍了如何把它接入流水线。

## 先选预设,再标记重要区域

默认预设是 [`light`](https://www.afterpack.dev/zh/docs/config#preset)([复杂度目标](https://www.afterpack.dev/zh/docs/config#complexity)为 5):它会编码字符串字面量、重命名标识符并重写语法,不添加结构层。它是适合整个应用 bundle 的预设。全局调高它会使页面加载的 JavaScript 和启动时间成倍增加([加载代价](https://www.afterpack.dev/zh/docs/presets#load-cost)),因此在全局发布 `medium` 或更高的预设之前,请先实测你自己的页面:

```bash
npx afterpack@latest dist/ --preset=hard
```

这个梯度是 `minify`(0)、`light`(5)、`medium`(7)、`hard`(12)、`extreme`(25);详见[预设](https://www.afterpack.dev/zh/docs/presets)。[`--complexity=<number>`](https://www.afterpack.dev/zh/docs/config#complexity) 会覆盖预设携带的目标值:`--preset=hard --complexity=40` 就是目标值为 40 的 `hard`。以这种方式设置的值会作用于每一个词元,即使在 `light` 上也是如此。

然后在 [Pro](https://www.afterpack.dev/zh/docs/tiers) 上标记出一旦出错代价最高的区域,让更重的处理只落在真正值得付出加载和体积代价的地方。

### 用指令标记一个区域

```ts
/* @afterpack preset=hard */
function checkLicense(token: string): boolean {
  return verifySignature(token, PUBLIC_KEY);
}
/* @afterpack end */

const apiKey = /* @afterpack preset=extreme */ "YOUR_SECRET";
// only the rest of this line is covered
```

[指令](https://www.afterpack.dev/zh/docs/directives)是完整参考:语法、指令可以设置的每一个键、[该标记什么以及为什么](https://www.afterpack.dev/zh/docs/directives#what-to-mark),以及当构建无法应用某条指令时会发生什么。区域级覆盖只在 [Pro](https://www.afterpack.dev/zh/docs/tiers) 构建中生效。

## 两种反篡改变换

反篡改不属于 `preset=hard` 的一部分。它是两个显式的 Pro 配置开关,默认都是关闭的,且不会应用在 Free 上:

- **[`transforms.comparisonHardening.enabled`](https://www.afterpack.dev/zh/docs/config#transforms-kind-enabled)** 会把针对构建期字符串字面量的严格相等判断(`x === "LIT"`)重写成单向摘要校验,让该字面量完全不出现在产物中。任何你不想被 grep 出来的常量比较,都可以用它。
- **[`transforms.selfIntegrity.enabled`](https://www.afterpack.dev/zh/docs/config#transforms-kind-enabled)** 对它自身的校验表加一道检查。未被改动时，它对行为没有任何影响。它只检测对这张表的修改；对你代码其他地方的修改不会被检测到。它需要附着在 `medium` 或更高级别的代码上，因此 `light` 构建只在被提高到这一级别的区域内带有它。从引擎 0.2.3 起，没有这类代码的构建会以 [`DIAG_SELF_INTEGRITY_INERT`](https://www.afterpack.dev/zh/docs/diagnostics#complete-reference) 失败，而不是不带检查就发布。

为整个程序开启这两项。详见[配置](https://www.afterpack.dev/zh/docs/config#transforms-kind-enabled)。

## 保护绑定域名的许可证或试用

AfterPack 没有内置的域名锁定或到期变换。你需要自己写出检查逻辑,AfterPack 会保护你写的这段 JavaScript。下面是一个带试用到期时间的、按域名生效的许可证检查:

```js
/* @afterpack preset=hard */
if (location.hostname !== "customer-domain.example.com") return;
if (Date.now() > TRIAL_EXPIRES_AT) return;
/* @afterpack end */
```

打开 `transforms.comparisonHardening.enabled` 后,`location.hostname` 的比较会变成摘要校验,允许的域名不会出现在产物中,对域名的 `grep` 搜索什么都找不到。[`preset=hard`](https://www.afterpack.dev/zh/docs/config#preset) 会让周围的逻辑处在复杂度目标 12、并开启常量下限的状态,新种子又会在每个版本重塑它。

> **Note**
>
> 这会提高绕过检查的成本和频率要求,但不会把检查从攻击面上移除。一个有决心、能运行代码的攻击者仍然能找到并修改这处比较,只是每个版本都要重新做一遍。把它当作提高试用门槛的手段,任何关系到真实收入的检查,都应该再配上一次服务端的权限校验。

## 不要把保护浪费在第三方代码上

像 React、Lodash 或某个 CSS-in-JS 运行时这样的第三方 bundle,值得投入的保护很少。它们本来就是公开的,还会让你的 bundle 体积膨胀,并且往往使用需要额外确认的运行时反射。

> **`paths.exclude` 是文件级别的豁免**
>
> [`paths.exclude`](https://www.afterpack.dev/zh/docs/config#paths-exclude) 会在解析之前完全跳过匹配的文件,原样按字节返回。被排除的文件会被报告为 `DIAG_PATH_EXCLUDED`。它只在文件级别生效:当某个库构建把你的代码和一个依赖拼接进同一个输出文件时,就没有单独的文件可以排除了。这种情况下改用 [`identifiers.reserved`](https://www.afterpack.dev/zh/docs/config#identifiers-reserved)。

有四种方式可以控制 AfterPack 能看到什么:

- **排除文件**,用 [`paths.exclude`](https://www.afterpack.dev/zh/docs/config#paths-exclude),例如 `["**/*.min.js", "dist/vendor/**"]`。`*` 和 `?` 在一个路径片段内匹配,整段 `**` 可以匹配零个或多个片段,匹配区分大小写。
- **把 CLI 指向你想保护的目录。** 它会递归遍历这个构建目录,处理找到的每一个 `.js`/`.mjs`/`.cjs` 文件,跳过嵌套的 `node_modules/`(可以用 [`paths.include`](https://www.afterpack.dev/zh/docs/config#paths-include) 把它纳入)以及它自己生成的 `.backup.<hash>` 副本。这个目录之外的第三方 chunk 完全不会被触及。
- **在打包工具里把第三方代码拆分成独立的 chunk**,然后让 AfterPack 处理一个不包含它的目录。如果你愿意承担这个代价,第三方 chunk 本身照样可以正常混淆。
- **用 [`skip`](https://www.afterpack.dev/zh/docs/config#skip) 标记区域**,用于某个被打包进同一文件的依赖代码,且需要保持可识别的情况。

你的 import 依然能正常解析。

AfterPack 完整支持 ES2022 私有类字段和方法:`#field` 和 `#method()` 能被正确解析,并和其他一切一样被重命名和保护。

## 保留必须保持可读的部分

有些代码必须保留原始名称,运行时才能正常工作。[框架预设](https://www.afterpack.dev/zh/docs/config#reflection-allow)覆盖了常见情形。除此之外,应尽量小范围地做保留,永远不要全局关闭重命名,否则你会在整个 bundle 上丢掉成本最低的一层保护。

| 场景 | 该怎么做 |
| --- | --- |
| 调用方按名称导入的公开导出 | 在导出周围使用 `/* @afterpack skip */ … /* @afterpack end */`,或者用 [`identifiers.reserved`](https://www.afterpack.dev/zh/docs/config#identifiers-reserved) 固定名称 |
| 你实际需要调试的堆栈跟踪 | 用你保留在部署目录之外的 source map 做符号还原。[`identifiers.rename=false`](https://www.afterpack.dev/zh/docs/config#identifiers-rename) 会让整个 bundle 中的每个标识符保持原样 |
| 基于反射的依赖注入([Angular](https://www.afterpack.dev/zh/docs/frameworks/angular)、NestJS、Inversify) | 在配置中用 [`reflection.allow`](https://www.afterpack.dev/zh/docs/config#reflection-allow) 确认某种自定义模式;未被确认的模式会导致构建失败 |
| `instanceof` / 按类名做分支 | 用 `identifiers.reserved` 列出这些类名,或在声明周围用 [`/* @afterpack skip */`](https://www.afterpack.dev/zh/docs/config#skip) |
| 第三方 API 符号被打包进和你自己代码相同的文件(例如某个内联依赖的 Rollup/Vite 库构建) | 用 [`identifiers.reserved`](https://www.afterpack.dev/zh/docs/config#identifiers-reserved),整个构建用一个普通名称,或者用 `{ glob, names }` 条目匹配特定文件 |
| 没有其他代码调用的脚本 | [`identifiers.globals.rename: true`](https://www.afterpack.dev/zh/docs/config#identifiers-globals-rename)。选择让引擎原本会保留的全局作用域也被重命名。默认关闭,只有在没有任何外部代码读取这些名称时才安全 |

`identifiers.reserved` 只保留你列出的名称,无论它们出现在哪里,匹配的是重命名发生之前、源码里编写的名称:精确匹配、整词匹配、区分大小写。Free 版本即可使用。当你没法标记整个区域——因为你的代码和某个第三方依赖共享同一个文件——时,就用它。

如果只需要在一处保留名称,用 `identifiers.reserved` 或 [`/* @afterpack skip */`](https://www.afterpack.dev/zh/docs/config#skip);详见[你可以设置什么](https://www.afterpack.dev/zh/docs/directives#what-you-can-set)。

每一处保留都会降低那些代码行上的保护强度。只在运行时确实需要的地方使用它。

## 种子策略

[种子](https://www.afterpack.dev/zh/docs/config#seed)决定了已混淆输出的形态。默认情况下每次构建都会使用一个随机种子。在生产环境中应保持这个默认值:每个版本都会变化的构建,是最主要的防线。

只在一个地方固定种子:CI,当你需要可复现的输出来命中缓存或做基准快照测试时。

```bash
npx afterpack@latest dist/ --seed=git
```

[`--seed=git`](https://www.afterpack.dev/zh/docs/config#seed) 会从 `git rev-parse HEAD` 派生出种子。如果没有仓库,它会回退到一个新的随机种子并打印提示,而不是让构建失败。这样输出在每次提交下都是确定的,同时每个版本仍会轮换。

种子也有对应的环境变量 [`AFTERPACK_seed`](https://www.afterpack.dev/zh/docs/config#seed),取值和 `--seed` 相同。多 bundle 构建会通过这个变量自动共享同一个种子,无论是在同一个进程内还是跨进程。只有在需要复现某个特定构建时才手动设置它;显式传入的 `--seed` 或 `seed` 选项仍然优先于它。

生产构建只做一次,让同一份字节内容依次晋级 qa → preprod → prod。开发构建不做混淆,因此日常开发不受影响。

不要给生产环境使用固定不变的种子。这会让输出在各个版本之间不再改变形态。

## 让这些产物远离部署目录

AfterPack 会在本地写出三类产物,每一类都比它所描述的 bundle 更敏感。

- **Protection Map**(`protectionMap.html`)内嵌了你的原始源码。它开启时(默认仅在构建产出了 source map 且不在 CI 中运行时开启,见 [Protection Map](https://www.afterpack.dev/zh/docs/protection-map)),会被写入项目根目录下一个被 gitignore 排除的 [`.afterpack/`](https://www.afterpack.dev/zh/docs/cli#files) 目录,绝不会出现在对外提供的构建旁边,[生产构建](https://www.afterpack.dev/zh/docs/config#build-mode)会对它的开启发出警告。用 [`--protectionMap.enabled=false`](https://www.afterpack.dev/zh/docs/config#protectionMap-enabled) 关闭它。
- **你原始文件的备份。** CLI 在原地改写一个文件之前,会把原始文件复制到 `.afterpack/backups/`,并附带一份记录每个文件被混淆成了什么的清单。这个目录一字不差地保存着你的源码,只留在构建机器上,`npx afterpack@latest restore` 能把原始文件放回去。可以用 [`--build.backup=false`](https://www.afterpack.dev/zh/docs/config#build-backup) 关闭这个行为。框架插件默认不写这份副本;把 [`build.backup`](https://www.afterpack.dev/zh/docs/config#build-backup) 设为 `true` 后,它会改成在输出旁边写一个 `.backup.<hash>` 同级文件,而这个文件会随构建一起发布出去。
- **Source map** 会把已混淆的输出直接链回原始源码。[`.map` 同级文件](https://www.afterpack.dev/zh/docs/config#sourceMap-enabled)在生产环境默认关闭,[`//# sourceMappingURL=` 注释](https://www.afterpack.dev/zh/docs/config#sourceMap-emitUrl)在生产环境也默认关闭。

插件和 CLI 通过 `.afterpack/` 内的一个 `.gitignore` 让该目录不进入 git,从不修改你的 `.gitignore`,并在某个产物落在对外提供的路径下时发出警告。构建旁边的 `.map` 或 `.backup.<hash>` 文件需要你自己把它挡在 git 之外。

> **绝不要部署 source map 或备份**
>
> Source map 和备份都包含你的完整源码,两者都绝不能部署。

生产构建还会删除打包工具写出的 source map：`.map` 文件、hidden map 和内联 map、`sourceMappingURL` 注释，以及 CSS map。CLI 和每个插件都会这样做，无法删除的 map 会让构建失败。

## 不发布 map 也能调试生产环境的堆栈

生产构建不会写出 `.map`,也不会写 `//# sourceMappingURL=` 注释,这是正确的默认行为,代价是你得手动完成堆栈的符号还原。如果你的团队确实需要生产环境的真实堆栈,应该构建出 map 并把它保密,而不是干脆跳过它。

为这次构建打开 map,同时保持指针关闭,让发布出去的 JavaScript 里没有任何东西暗示 map 的存在:

```json
{
  "sourceMap": { "enabled": true, "emitUrl": false, "sourcesContent": true }
}
```

然后让 `.map` 文件不出现在部署目录中:把它们上传到你的错误追踪服务,或者作为 CI 产物保留,在那里完成符号还原。如果你的部署会复制整个构建目录,那就在边缘节点也拒绝这些文件,让针对它们的请求永远得不到响应。

```nginx
location ~ \.map$ {
  return 404;
}
```

这条规则在任何环境下都有对应的写法:匹配 `*.map` 的 CDN 或 WAF 规则、代理层的 `deny`,或者一个压根不接收这些文件的存储桶。可以在你的网络之外用 `curl -I https://yoursite.example.com/assets/app.js.map` 来检查,预期得到 404;也可以用 [`npx afterpack@latest audit`](https://www.afterpack.dev/zh/docs/audit),它会把可访问的 map 作为一项发现报告出来。

> **拒绝规则是第二道防线,不是第一道**
>
> 边缘规则保护的是已经在服务器上的文件。任何绕过它的情况,比如配置错误的路由、直接的存储桶 URL、或是预览部署,都会把内含你原始源码的 map 提供出去。压根不部署这些 map,才是不可能被配置错的那种做法。

## 如果你不小心发布了一个密钥

一个进入了你 bundle 的 API 密钥、令牌或密码,是一起正在发生的事件。AfterPack 能改变读者在发布文件里能找到什么,但不能改变这个凭证已经泄露出去的事实。

**AfterPack 对它做了什么。** 在[默认预设](https://www.afterpack.dev/zh/docs/presets)下,字符串字面量会被编码,因此存放在字符串字面量里的密钥在你部署的文件里是不可读的。少数始终保持可读的字面量列在[预设](https://www.afterpack.dev/zh/docs/presets#the-default-is-light)中。在 bundle 里搜索这个密钥,或者搜索它周围的文本,都什么都找不到。自动化的密钥扫描器和 bundle 爬虫同样一无所获,而这两者正是大多数泄露密钥被发现的途径。

**AfterPack 没有做什么。** 这个凭证仍然在构建产物里,你的代码仍然在使用它。任何在自己控制的浏览器里运行你应用的人,都能在代码使用这个值的那一刻拿到它。编码为你争取了响应的时间,但不会让一个客户端密钥变得安全。

**该做什么,按顺序:**

1. **轮换这个密钥。** 从构建发布出去的那一刻起,就把它当作已经泄露处理。这一步才能真正结束这起事件。
2. **把权限判断移到服务端。** 让你自己的后端持有这个凭证,并决定调用方是否可以拿到结果。一个浏览器从未收到过的密钥,不可能泄露。
3. **检查现在线上的情况。** 对你部署的站点运行 [`npx afterpack@latest audit`](https://www.afterpack.dev/zh/docs/audit)。它会报告暴露的密钥、泄露的 source map,以及站点实际提供的 JavaScript 的保护级别,让你知道此刻一个读者能从生产环境中取出什么。
4. **重新构建并重新部署**,让携带旧值的那个 bundle 不再被提供。

> **混淆不能替代轮换**
>
> 一个已经发布出去的凭证必须被轮换,无论它是用哪个预设构建出来的。

## 测试已混淆的构建

始终针对已混淆的构建运行你的集成测试。测试混淆之前的构建,不会告诉你任何关于实际发布内容的信息。

如果一个测试在未混淆的构建上通过,却在已混淆的构建上失败,通常指向两类问题之一:保留设置有缺口(一个运行时期望存在的名称被重命名了),或者存在一种未被识别的反射模式。诊断信息通常会直接指出能修复它的配置键。用 [`--diagnostics.level=all`](https://www.afterpack.dev/zh/docs/config#diagnostics-level) 运行,可以看到完整的每一条[诊断](https://www.afterpack.dev/zh/docs/diagnostics),而不是汇总摘要。

> **构建通过就是唯一的信号**
>
> 任何会导致发布出比你配置的更弱保护的情况,都会让构建直接失败,包括引擎无法混淆的文件,以及无法连上云端的 [Pro](https://www.afterpack.dev/zh/docs/tiers) 构建。[诊断](https://www.afterpack.dev/zh/docs/diagnostics#exit-codes)记录了完整的退出约定,以及背后的每一个代码。

## 下一步

- [构建与 CI](https://www.afterpack.dev/zh/docs/builds):同一份字节逐级晋级的生命周期、产物卫生,以及定期重建。
- [威胁模型](https://www.afterpack.dev/zh/docs/threat-model):这些指令所对准校准的攻击者梯度。
- [配置](https://www.afterpack.dev/zh/docs/config):所有配置键汇总在一处。
- [指令](https://www.afterpack.dev/zh/docs/directives):上面那张表背后完整的区域标记语法。
- [预设](https://www.afterpack.dev/zh/docs/presets):这些指令按区域覆盖的五级梯度。
- [Protection Map](https://www.afterpack.dev/zh/docs/protection-map):核实你标记的区域实际命中了哪个类别。
- [控制台中的构建](https://www.afterpack.dev/zh/docs/dashboard/builds):针对你的 Pro Cloud 构建,逐次提供同样的数据。
