当上游项目失联时:包管理器的补丁与分叉策略Patching and forking in package managers
文章探讨了当开源项目的维护者停止响应(ghosting)后,下游开发者如何通过打补丁(patching)或创建分叉(forking)来延续其软件的生命周期。重点分析了 npm、pip 等主流包管理器在此类“上游死亡”场景下的应对实践与最佳做法。
Andrew Nesbitt
当某个依赖存在已知漏洞且没有维护者发布修复版本时,你就必须自己动手修复。克隆源代码,应用补丁,然后将修补后的版本重新纳入你的依赖树中。报告的 CVE 数量将持续上升,其中许多将出现在无人维护的包中。
系统包管理器很久以前就处理过这种情况。Debian 使用 quilt 和 DEP-3 头文件的 debian/patches/;RPM 在 spec 文件中使用 Patch0: 指令;Gentoo 使用 /etc/portage/patches/;Nix 在推导式中使用 patches 属性;Homebrew 在公式中使用 patch do ... end 块。分发版维护者预期会携带上游的增量补丁,工具链也围绕这一假设设计。
语言包管理器则基于不同的假设设计:注册表中的版本是权威的,用户选择他们想要的版本,而不会修改内部内容。当前端无法或不愿发布修复时,用户就会遇到为这种场景设计的工具。
你需要什么取决于具体情况。将依赖重定向到你控制的 fork 是最常见的变通方案。覆盖解析器为树中深层传递依赖所选的内容需要不同的工具。在可用时,就地修补包而不维护 fork 是最轻量的选项。有时你需要完全替换一个包为另一个包。
重定向到 fork
几乎所有语言包管理器都可以将依赖指向 git 仓库或本地路径,而不是注册表。Cargo 在 Cargo.toml 中使用 [patch] 部分:
[patch.crates-io]
serde = { git = "https://github.com/yourfork/serde.git", branch = "fix-cve" }Go modules 有 replace 指令:
replace github.com/original/pkg => github.com/yourfork/pkg v1.4.1-fixedBundler 在任何 gem 上接受 :git 或 :path:
gem 'httpclient', git: 'https://github.com/yourfork/httpclient.git', branch: 'fix-ssl'其余字段:
Go 的 gohack 自动化了手动流程的一部分:它检出模块源码到本地目录,并一键添加 replace 指令到 go.mod,让你无需设置 fork 仓库即可立即开始编辑。
覆盖传递依赖
重定向直接依赖很简单。更复杂的情况是,当有漏洞的包是传递依赖,由依赖的依赖引入,而非你的清单文件中声明的任何内容。
byroot 在最近一篇关于 Bundler 的文章中很好地描述了这一点。他想升级 openssl gem,但 web-push 将其固定为 ~> 2.2。升级 web-push 需要 jwt 的新版本,而另外四个 gem 都将其固定为 ~> 2.0。这些 gem 多年未发布新版本。依赖树被那些已无人维护的维护者编写的悲观版本约束所困。
他提出的解决方案:force: true 选项,告诉 Bundler 覆盖任何上游约束:
gem 'openssl', '>= 3.0', force: trueBundler 不支持此功能。变通方案是将每个阻塞依赖指向带有放宽约束的 git 仓库,这意味着要为修复真正的版本边界问题而维护多个 fork。
Cargo 的 [patch] 能处理这种情况,因为补丁项会在整个依赖树(无论是直接还是传递性)中替换该依赖。被修补的版本必须与原始版本在语义化版本控制上兼容,因此如果你需要跨越主版本边界,它将无济于事。Go 的 replace 工作方式相同,但没有任何此类限制。Go 还有 exclude 功能,可以阻止特定版本,并强制解析器选择下一个有效版本。
npm 在 package.json 中有 overrides:
{
"overrides": {
"lodash": "4.17.21"
}
}嵌套对象将覆盖范围限定到依赖树中的特定路径。其他 JavaScript 包管理器也具有相同的功能:
其他内置覆盖机制的生态系统:
Haskell 采取了不同的方法,使用 allow-newer,它放宽了上界版本限制而不是强制指定特定版本:
allow-newer: my-package:aeson, my-package:text当一个包声明需要 base < 4.19 而你当前是 4.20 时,allow-newer 会让解析器忽略上界并尝试使用它。
uv 在 pyproject.toml 中添加了 override-dependencies:
[tool.uv]
override-dependencies = ["werkzeug==2.3.0"]pip 没有覆盖机制。约束文件只能添加界限,不能与包声明的版本冲突。Poetry 曾在 2022 年提出过覆盖功能的请求,但被关闭为“不计划实现”。
应用补丁
对来自注册表的包应用 diff 是最轻量级的修复方式。你保留原始包、原始版本以及锁文件中记录的版本,并在其之上叠加一个修改。当上游发布正式修复后,你可以移除补丁并按正常流程更新。所有系统包管理器都采用这种方式。在语言包管理器中,有三个内置了该功能。
pnpm:运行 pnpm patch <package>,编辑提取出的源码,然后运行 pnpm patch-commit <path>。一个 .patch 文件会被保存到项目中,并在 package.json 的 patchedDependencies 字段下记录。每次安装时都会重新应用这些补丁。
Yarn Berry:使用 yarn patch 及相同的流程。补丁会与锁文件中的 patch: 协议集成,并通过 Yarn 的校验和验证。
Bun:运行 bun patch <package>,在 node_modules/ 中编辑,然后执行 bun patch --commit <package>。
对于 npm 和 Yarn Classic,第三方库 patch-package 填补了空白。在 node_modules/ 中修改包,运行 npx patch-package <pkg>,并配置 postinstall 脚本来在安装时重新应用补丁。
Composer 有 cweagans/composer-patches,其 2.0 版本增加了 patches.lock.json 以实现可重现性。补丁可以是本地文件或远程 URL,并在 composer install 期间应用。vaimo/composer-patches 是一个替代方案,它允许按包定义补丁(因此库可以为自己的依赖项提供补丁),并对补丁应用顺序和深度提供更精细的控制。
Cargo 有第三方 crate cargo-patch,它会下载 crate 源码,应用 .patch 文件,并将结果写入目录,供指向已修补输出的 [patch.crates-io] 条目使用。patch-crate 则采取更接近 patch-package 的工作流:直接在 crate 源码上进行编辑并自动生成 diff。
Maven 曾经有一个官方的补丁插件,其内部使用 GNU patch 应用差异文件,但现已停用。
除此之外,其余语言生态系统中,Bundler、Go、uv、Poetry、Mix、Swift PM、NuGet、Stack 和 Cabal 都没有内置或第三方支持的补丁文件机制。pip 有 patch-package,Pub 有 patch_package,Gradle 有 brambolt/gradle-patching,但这些方案均未得到广泛采用。
替换包
byroot 对 Bundler 的另一个提议是:能够将一个 gem 作为另一个 gem 的直接替代品安装:
gem "byroot-httpclient", as: "httpclient"背景是 httpclient gem,该版本自 2016 年起停更,直到 2025 年仍未发布,而其内嵌的 SSL 根证书已过期,导致用户无法正常使用。所有需要可用版本的用户都必须维护自己的分叉版本。如果有人以新的名称发布了维护中的分叉版本,你需要确保它能满足其他包对原始依赖所声明的约束条件。
npm: 协议在 JavaScript 生态中实现了这一点:
{
"dependencies": {
"original-name": "npm:@yourorg/forked-name@^2.0.0"
}
}pnpm、Yarn 和 Bun 也支持此功能。其他具备替换机制的语言生态系统包括:
byroot 更广泛的观点在于控制权:应用开发者的清单文件是其管辖范围,他们应有权覆盖其中任何上游约束,尤其是当制定该约束的人已不再参与时。若缺乏覆盖和替换能力,唯一的替代方案是为一个只需两行代码修复的问题维护分叉版本,并且如果上游项目永不回归,则需无限期地维护这些分叉。
两种方法都会带来各自的维护负担。一个分叉版本必须与上游中与漏洞无关的更新保持同步。GitHub 分叉默认禁用了 Issues、Actions、Dependabot 和安全功能,因此需要手动配置才能作为一个真正被维护的项目运行。对于一个仅需两行安全修复的问题来说,这需要承担大量基础设施成本。
补丁更轻量,但它改变了锁文件中条目的含义:你运行的是与锁中记录版本不匹配的代码,而那些依赖审计工具基于锁文件进行检查时不会看到补丁。基于锁文件生成的 SBOM 会列出未打补丁的版本。根据欧盟《网络韧性法案》,该法案要求数字组件产品中依赖清单必须准确,这就导致你所运行的代码与工具报告之间存在差距,从而构成合规问题。
将源代码直接嵌入仓库可以完全绕过包管理器,但这只是用另一组问题替代了当前问题:现在你需要对整个包负责,而不仅仅是补丁。
系统软件包管理器在设计时就假设打补丁是其职责之一,而语言级包管理器则假设不应如此。随着 AI 工具加速漏洞发现,已知存在 CVE 的死链包数量将会增长。
需要完整排版与评论请前往来源站点阅读。