返回 2026-05-02
⚙️ 工程

当上游项目失联时:包管理器的补丁与分叉策略Patching and forking in package managers

nesbitt.io·2026-05-01

文章探讨了当开源项目的维护者停止响应(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-fixed

Bundler 在任何 gem 上接受 :git 或 :path:

gem 'httpclient', git: 'https://github.com/yourfork/httpclient.git', branch: 'fix-ssl'

其余字段:

  • npm、pnpm、Yarn 和 Bun 在 package.json 中接受 git URL 和 npm: 别名协议。
  • pip、Poetry 和 uv 在其各自的 Python 配置格式中支持 VCS URL。
  • Mix 和 Pub 在依赖项条目中接受 git 和 path 选项。
  • Composer 添加一个屏蔽 Packagist 版本的 VCS 仓库。
  • Swift PM 通过 CLI 配置实现依赖镜像。
  • Gradle 有复合构建。Maven 使用本地仓库。NuGet 支持本地包源。
  • Stack 和 Cabal 在其项目配置文件中使用 git 依赖。
  • 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: true

    Bundler 不支持此功能。变通方案是将每个阻塞依赖指向带有放宽约束的 git 仓库,这意味着要为修复真正的版本边界问题而维护多个 fork。

    Cargo 的 [patch] 能处理这种情况,因为补丁项会在整个依赖树(无论是直接还是传递性)中替换该依赖。被修补的版本必须与原始版本在语义化版本控制上兼容,因此如果你需要跨越主版本边界,它将无济于事。Go 的 replace 工作方式相同,但没有任何此类限制。Go 还有 exclude 功能,可以阻止特定版本,并强制解析器选择下一个有效版本。

    npm 在 package.json 中有 overrides:

    {
      "overrides": {
        "lodash": "4.17.21"
      }
    }

    嵌套对象将覆盖范围限定到依赖树中的特定路径。其他 JavaScript 包管理器也具有相同的功能:

  • pnpm 有 pnpm.overrides,采用 parent>child 的作用域语法,并支持在 overrides 内部使用 npm: 协议来用一个包替换另一个包作为覆盖的一部分。
  • Yarn 从 v1 开始就支持 resolutions。
  • Bun 同时读取 overrides 和 resolutions。
  • 其他内置覆盖机制的生态系统:

  • Mix 在依赖项上使用 override: true 来强制顶层版本约束。
  • Pub 的 dependency_overrides 会替换整个依赖树中对某个包的所有引用。
  • Gradle 有严格约束和 resolutionStrategy.force。
  • Maven 的 dependencyManagement 控制直接和传递依赖的版本。
  • NuGet 的中央包管理配合传递性固定版本也实现了相同效果。
  • 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 也支持此功能。其他具备替换机制的语言生态系统包括:

  • Composer 的 replace 声明表示你的包提供了与另一个包相同的功能,从而阻止两者同时被安装。
  • Gradle 的 dependencySubstitution 规则可以在解析时将任意模块替换为另一个模块。
  • ManageIQ 的 bundler-inject 插件添加了一个 override_gem 命令,它通过 bundler.d/*.rb 文件将 gems 重定向到分叉版本或本地路径,而无需修改 Gemfile 本身,这是 Ruby 生态中最接近内置替换机制的方案。
  • byroot 更广泛的观点在于控制权:应用开发者的清单文件是其管辖范围,他们应有权覆盖其中任何上游约束,尤其是当制定该约束的人已不再参与时。若缺乏覆盖和替换能力,唯一的替代方案是为一个只需两行代码修复的问题维护分叉版本,并且如果上游项目永不回归,则需无限期地维护这些分叉。

    两种方法都会带来各自的维护负担。一个分叉版本必须与上游中与漏洞无关的更新保持同步。GitHub 分叉默认禁用了 Issues、Actions、Dependabot 和安全功能,因此需要手动配置才能作为一个真正被维护的项目运行。对于一个仅需两行安全修复的问题来说,这需要承担大量基础设施成本。

    补丁更轻量,但它改变了锁文件中条目的含义:你运行的是与锁中记录版本不匹配的代码,而那些依赖审计工具基于锁文件进行检查时不会看到补丁。基于锁文件生成的 SBOM 会列出未打补丁的版本。根据欧盟《网络韧性法案》,该法案要求数字组件产品中依赖清单必须准确,这就导致你所运行的代码与工具报告之间存在差距,从而构成合规问题。

    将源代码直接嵌入仓库可以完全绕过包管理器,但这只是用另一组问题替代了当前问题:现在你需要对整个包负责,而不仅仅是补丁。

    系统软件包管理器在设计时就假设打补丁是其职责之一,而语言级包管理器则假设不应如此。随着 AI 工具加速漏洞发现,已知存在 CVE 的死链包数量将会增长。

    需要完整排版与评论请前往来源站点阅读。