依赖管理反思:为什么应该直接从VCS获取依赖

几乎所有 npm/RubyGems 供应链攻击都依赖"发布包"这一中间步骤。文章认为,Go 直接从 VCS 获取依赖的做法从根本上消除了这一攻击面,让依赖审计变得简单透明。AI 扫描工具无法替代模型层面的安全设计。
一位从 Go 转向 Ruby 的开发者在 Hacker News 上引发了热烈讨论。他的核心观点是:几乎所有"侧信道攻击"更准确地说是"包发布攻击"——它们依赖在"发布包"步骤注入恶意代码。而 Go 的做法从根本上消除了这一攻击面。
Go 的做法:直接从 VCS 获取
在 Go 中,依赖通过 URL 标识,如 github.com/user/pkg。Go 工具链识别使用的 VCS(通常是 git),获取 go.mod 中指定的 tag 或 commit。go.mod 同时充当依赖规范和锁文件——列出精确版本(没有 ~>1.1 这种范围),包含直接和间接依赖的完整树。
所有文件的哈希在 sum.golang.org 上核对,防止 tag 被替换。还使用代理防止仓库被"left-pad"式删除。
审计依赖更新非常简单:git log -p old..new,阅读所有提交,更新 go.mod。不需要深度审查,只需寻找可疑的东西——比如在 globbing library 中出现 exec.Command(..) 或 http.Post(..) 会非常明显。

RubyGems/npm 的做法:发布包
Ruby 需要创建 .gem 归档并上传到 rubygems.org。包内容与源码仓库之间没有保证一致。审计一次更新需要:
curl -s https://rubygems.org/downloads/example-2.7.5.gem > old.gem
curl -s https://rubygems.org/downloads/example-2.8.2.gem > new.gem
mkdir old new
tar xf old.gem -C old; tar xf new.gem -C new
(cd old && tar xf data.tar.gz); (cd new && tar xf data.tar.gz)
diff -urN old new
这能工作,但远非简单。单个提交的信息丢失了,整体更难审计。在 diff 很大的情况下,没有提交历史是个大麻烦。
供应链攻击的常见模式
几乎所有的"侧信道攻击"更准确地说是"包发布攻击":
- npm 攻击:获取 npm 账户权限,在发布包中注入恶意代码。源码仓库完全干净。
- xz 后门:exploit 代码在源码仓库中存在但处于休眠状态(隐藏在二进制测试文件中),只在修改后的
.tar.gz发布包中被激活。源码审计无法发现。 - event-stream 事件(2018):添加了对
flatmap-stream的依赖,恶意代码仅存在于 npm 发布包的index.min.js中,源码仓库中完全没有。
关键洞察:源码仓库被攻破的情况很罕见,因为太显眼了。攻击者需要至少稍微隐藏 exploit 才能有效。而"发布包"步骤提供了完美的隐藏机会——尤其是包含编译产物(minified files、二进制文件)的包。

第二重问题:编译产物
JavaScript 从 TypeScript 生成后,虽然不是完全不可读,但远不如原始 TypeScript 可审计。更不用说 minified 文件或二进制 blob 了。这在 Ruby 中问题较小,但在 npm 生态中是更大的问题——恶意代码可以藏在 .min.js 中,源码仓库中完全不存在。
AI 扫描不是解决方案
RubyGems 最近添加了"冷却"选项和"AI 辅助漏洞扫描"。作者认为这不是根本解决方案:
"AI 工具不会神奇地修复发布包模型缺乏透明度的问题。"
更合适的方案是重新审视整个"发布包"模型。Go 的做法虽然不是完美无缺,但就目前所知,这是最好的方案。
实践建议
Bundler 已经部分支持从 git 获取依赖:gem 'rails', git: 'https://github.com/rails/rails.git', tag: 'v8.1.2'。但间接依赖仍从 rubygems.org 获取。作者提出了一个概念性的改进方案——允许 Gemfile 声明"必须使用 git 获取所有依赖",并将依赖标识改为 URL 而非 gem 名称。
对于 Go 开发者来说,这些都不成问题。每次依赖更新只需 git log -p old..new——简单、透明、可审计。也许其他语言生态应该认真考虑这种做法。
[关联推荐]
- Zig 架构进化 -- 包管理功能从编译器迁移到构建系统
- Claude Code 会话隔离漏洞 -- 软件安全实践
评论 (0)
加载评论中…