SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-21

可复现构建究竟能证明什么?

校验和只能证明下载文件未被篡改,可复现构建则验证二进制是否对应可审查的源代码。本文说明两者的区别,以及它无法证明代码、依赖和工具链安全的原因。

可复现构建能证明什么

可复现构建只能证明一个明确事实:你拿到的二进制文件,确实由这份特定源代码生成。任何人都可以使用相同源代码再次构建,并比较字节内容。验证不再只能由发布者完成。

Reproducible Builds 项目这样定义:“如果使用相同的源代码、构建环境和构建说明,任何一方都能逐位重新创建所有指定构建产物的相同副本,则该构建是可复现的。”比较本身使用哈希值完成。真正困难的是严格固定环境和构建说明,使两台不同的计算机能够生成一致结果。

为什么校验和无法回答这个问题

已发布的校验和可以证明文件在传输过程中未损坏。针对该校验和的签名可以证明文件来自持有相应密钥的人员。但二者都无法说明构件生成之前发生了什么。如果发布者的构建机器已被攻破,恶意二进制文件会像干净的二进制文件一样通过校验和计算和签名,因此下游的所有检查都会通过。如果维护者从一个从未推送到仓库的工作树构建,结果也一样。

这就是缺口。您可以阅读源代码、验证签名、验证校验和,但运行的代码仍可能从未出现在仓库中。因此,可复现构建不同于根据已发布的校验和验证下载文件。校验和保护传输过程。重新构建则保护传输之前发生的一切。

这种攻击并非理论上的假设。2020 年的 SolarWinds Orion 攻破事件正处于这个位置:构建系统生成了与任何人审查过的源代码都不对应、但带有有效签名的构件。每次签名验证都通过了,因为签名验证从构件才开始。

可复现构建无法证明什么

这一部分最容易被过度宣传,因此必须明确其限制。

  • 它不能证明源代码是安全的。 以明文形式提交的后门同样可以进行可复现构建,所有重新构建者都会确认这一点,因为他们构建的是同一份恶意源代码。可复现性将检查目标转移到了源代码树。仍然需要有人审查该代码树,因此审查策略(包括 开源项目中的 AI 辅助代码策略)仍是独立的控制措施。
  • 它不能证明输入是安全的。 依赖项属于构建内容的一部分。构建时解析到的恶意软件包会被编译进构件,所有解析到同一依赖项的重新构建者都会得出相同结果。这正是 npm 供应链攻击如何到达服务器 的方式,而可复现构建会如实复现该攻击。
  • 它不能证明工具链是可信的。 如果编译器已被入侵,所有使用该编译器的重新构建者都会生成相同的受感染输出,验证结果也会全部一致。可复现性会提高发动此类攻击的成本,但无法检测此类攻击。
  • 它无法说明漏洞情况。 按位完全复现的旧版库仍然是带有已公开缺陷的旧版库,因此仍应按独立计划 检查服务器是否存在已知 CVE。

可复现性消除的是攻击者的一个特定立足点:构建机器,以及从源代码到二进制文件的整个路径。在软件包实现可复现之前,发布者之外的任何人都无法检查这条路径。

相同源代码为何会产生不同的字节

大多数软件默认无法实现可复现构建,原因通常很普通。编译器和归档格式会记录运行它们的机器信息。

  • 时间戳。tararzip 格式会存储文件修改时间,因此在不同的秒数执行构建会改变输出文件。
  • 路径。调试信息会记录绝对构建目录,因此即使代码完全相同,在 /home/alice/src/build/pkg 中构建也会产生差异。
  • 排序顺序。读取目录时,返回的条目按文件系统顺序排列,因此链接命令行或归档成员的顺序会因机器而异。
  • 身份信息。构建脚本会嵌入执行构建的用户的用户名、主机名或区域设置。
  • 构建时做出的决策。检测 CPU 特性或随机初始化某个值,会使输出依赖机器,而不是依赖源代码。

您可以在大约十秒内观察到第一个原因:

mkdir -p /tmp/rb && cd /tmp/rb
echo hello > a.txt && tar cf one.tar a.txt
sleep 2
echo hello > a.txt && tar cf two.tar a.txt
sha256sum one.tar two.tar

两个哈希值不同,是因为 tar 头部存储了 a.txt 的修改时间,而重写该文件使这个时间向前移动了两秒。文件内容逐字节完全相同。固定元数据即可解决这个问题:

export SOURCE_DATE_EPOCH=1700000000
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf three.tar a.txt
touch a.txt
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf four.tar a.txt
sha256sum three.tar four.tar

现在两个哈希值一致,因为归档头部中的所有字段都不再来自机器的当前状态。--sort=name 固定排序顺序,--mtime 固定时钟,所有权标志则阻止记录您的用户 ID。

使用 diffoscope 读取差异

当两个构建结果不一致时,sha256sum只能告诉您它们不同,无法提供其他信息。diffoscope 用人可以阅读的形式说明差异原因。它会递归解包两边的内容,将二进制格式转换为文本,然后比较这些文本。它支持 Debian 软件包、ELF 二进制文件、tar 和 ZIP 归档、PDF、SQLite 数据库,以及一百多种其他格式。

sudo apt install -y diffoscope
diffoscope one.tar two.tar

对于上面的 tar 文件对,报告很短。截取后如下:

--- one.tar
+++ two.tar
├── file list
│ @@ -1 +1 @@
│ -rw-r--r-- 0/0  6 2026-08-18 10:14:02.000000 a.txt
│ +rw-r--r-- 0/0  6 2026-08-18 10:14:04.000000 a.txt

这就是完整诊断结果:大小相同、路径相同、权限相同,但修改时间不同。实际软件包生成的报告会长得多,因此请将报告写入文件,然后在浏览器中打开:

diffoscope --html report.html build1.changes build2.changes

输入内容完全相同时,diffoscope 退出状态为 0;内容不同时为 1;遇到问题时为 2。因此无需包装脚本,即可直接用于 CI 作业。在小型 VPS 上,请安装 diffoscope-minimal,不要安装 diffoscope:完整软件包会拉取大量格式辅助工具,而您可能永远不会调用其中的大多数。

SOURCE_DATE_EPOCH 可修复什么,以及它的局限

SOURCE_DATE_EPOCH 是一个只包含单个数字的环境变量:源代码的最后修改时间,以自 1970 年 1 月 1 日 UTC 起经过的秒数表示。支持该变量的构建工具会在原本需要向操作系统获取当前时间的地方使用这个数值。应从版本控制系统中设置它,使该值跟随源代码,而不是跟随构建过程:

export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)

在 Debian 软件包中,debhelper 会根据变更日志自动导出该变量。手动在 debian/rules 中设置它的方式如下:

export SOURCE_DATE_EPOCH ?= $(shell dpkg-parsechangelog -STimestamp)

各工具分别支持该变量,不是全局支持。cmake 3.8 及更高版本、gcc 7 及更高版本、4.13 以上的 rpm,以及 Docker buildx 0.10 及更高版本都会读取它。您自己的脚本不会读取它,除非您明确编写支持逻辑。如果脚本调用 date,请将该变量传递给它:

BUILD_DATE="$(date --utc --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y-%m-%d)"

实现时有一条规则很重要。如果该变量已经设置,那么对于构建过程而言,该值就是当前时间,因此绝不能覆盖调用方提供的值。

容器镜像也存在相同问题,只是位于不同的封装层中。Docker buildx 0.10 及更高版本会将 shell 中的 SOURCE_DATE_EPOCH 作为构建参数传入构建过程。层内文件的时间戳需要由导出器重写;BuildKit 在 0.13 中加入了此功能。文档规定的形式会将结果推送到注册表:

export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
docker buildx build --output type=image,name=registry.example.com/app:1.0,push=true,rewrite-timestamp=true .

使用 reprotest 测试自己的构建

reprotest 对同一份源代码执行两次构建,并在两次构建之间有意改变环境,然后比较构建结果。环境变化是测试的核心。默认情况下,它会改变构建路径、时间、时区、区域设置、umask、主机名、用户和组、CPU 数量、主目录以及文件排序方式。

sudo apt install -y reprotest
reprotest . -- null

--之后的所有内容都会选择构建环境后端,null表示当前所在的系统。添加 -vv -d 可保留临时目录,便于检查,如 reprotest . -vv -- null -d 所示。使用 reprotest auto -- null 可让 reprotest 自动判断当前源代码树的类型。

某些变化需要特权或额外软件包。如果无法执行,reprotest 会直接报告失败。不要以 root 身份运行整个测试,而应关闭这些变化:

reprotest --vary=-user_group,-domain_host,-fileordering auto -- null

reprotest 报告的任何问题,重建者之后都可能发现,并在公开环境中报告,同时注明您的项目名称。

重新构建者判定的含义

重新构建者使用的机器不属于发布者。它获取已发布的源代码和记录的构建环境,再次构建软件包,然后将生成结果与归档中的构件进行比较。只有因为这台机器是独立的,该判定才有意义。

Debian 将环境记录在一个 .buildinfo 文件中,该文件由 dpkg-buildpackage 写入,并放在 .deb 旁边。字段内容最为关键。Installed-Build-Depends 列出所有可能影响构建的已安装软件包及其准确版本。Build-Path 记录构建运行的位置。Environment 记录已知会产生影响的环境变量。Checksums-Sha256 记录输出内容。该文件就是再次构建时使用的配方:

sudo apt install -y devscripts mmdebstrap
debrebuild --buildresult=./artifacts --builder=mmdebstrap hello_2.10-2_amd64.buildinfo

debrebuild 读取 buildinfo,并从 snapshot.debian.org 获取其中指定的准确依赖版本。因此,今天重新构建时,可以使用原始构建当天存在的软件包版本。mmdebstrap 构建器无需设置 chroot,也不需要 superuser 权限。使用 diffoscope 将其生成的构件与归档中的副本进行比较。

Arch Linux 运行 rebuilderd,持续执行此过程并发布判定结果:

rebuildctl -H https://reproducible.archlinux.org pkgs ls --name rebuilderd

状态包括 GOODBADUNKWN。这些状态的实际含义分别存在不同限制。GOOD 表示独立方生成了相同的字节内容。这充分说明构建结果一致,但完全不能说明源代码没有问题。BAD 几乎从来不是攻击导致的。通常原因是软件包构建没有固定时间戳或路径。因此,rebuilderd 可以为失败结果附加 diffoscope 报告。UNKWN 表示尚未有人测试该软件包。未测试的软件包不能视为通过。

因此,操作规则很简单。出现 BAD 判定时,应查看报告。如果报告显示存在时间戳、构建路径或成员排序差异,应提交软件包错误报告。如果报告显示可执行代码不同,且无法用这些原因解释,应停止部署该构建,并升级处理。

Debian 目前的可复现性如何?

ChartDebian packages reproducible in the test framework, amd64
The data behind this chart
[
  {
    "label": "unstable",
    "percent_reproducible": 94.2,
    "tested_count": "41,163"
  },
  {
    "label": "forky",
    "percent_reproducible": 93.4,
    "tested_count": "39,059"
  },
  {
    "label": "experimental",
    "percent_reproducible": 67.0,
    "tested_count": "588"
  }
]

在本文撰写当天,amd64 上的 unstable 在已测试的 41,163 个软件包中,有 94.2% 可复现构建。Experimental 的比例为 67.0%,但样本小得多、版本也新得多,仅包含 588 个软件包。这符合预期,因为这些软件包尚未完成修复。

这些数据来自 Debian 在 tests.reproducible-builds.org 上的页面。页面读取时间为 2026-08-18,当时页面显示的时间戳为“Last update: 2026-08-18 16:02 UTC”。数据会变化。请查看跟踪器,不要在 6 个月后继续引用本段内容。

有一个注意事项比百分比更重要。该框架在自己的硬件上对每个软件包构建两次,并在两次构建之间改变环境,然后比较这两次构建的结果。它衡量的是软件包是否能够进行可复现构建。它并不检查归档中的 .deb 是否匹配,因为后者由 rebuilder 负责与已发布的软件构件进行比较。这两个数字都很有用,但回答的是不同问题。人们经常引用第一个数字,却把它当成第二个数字。

在自己的服务器上怎么做

您不需要重建一个发行版。适用于普通服务器的做法更少,成本也更低。

  • 固定工具链。按标签引用的基础镜像可能在您不知情的情况下发生变化。请按摘要引用,并将该摘要与发行版本一起记录。
  • 记录输入。将 lockfile、镜像摘要和编译器版本与构建产物放在一起。无法重建其环境的构建就无法重建,因此也无法验证。
  • 在 CI 中构建两次,并在输出不同的情况下让任务失败。这样只增加一次构建成本,还能在引入非确定性行为当天发现问题,而不是等到一年后的故障处理中才发现。
  • 移除编译器嵌入的路径。对于 Go,go build -trimpath -buildvcs=false 会移除构建目录和版本控制标记,go version -m ./app 则会显示最终实际写入二进制文件的内容。
  • 保留已部署内容的哈希值。需要确认运行中的二进制文件是否对应某个源代码版本时,只有这条记录能够给出答案。

CI 检查只有4行:

set -eu
./build.sh && mv dist/app app.1
./build.sh && mv dist/app app.2
diffoscope --text - app.1 app.2

当两个构建产物不同时,diffoscope 会以非零状态退出,因此任务会自动失败,并在日志中留下易于阅读的说明。这就是完整思路,只是缩小到一个代码仓库:二进制文件来自某个源代码树这一结论,应当能够由另一台机器进行验证。

FAQ

可复现构建是否意味着软件安全?

不能。它只能证明二进制文件与源代码相对应,除此之外不能证明任何事情。提交到公开源代码树中的后门同样可以被可复现地构建,所有重新构建者都会确认这一点,因为他们构建的是同一份恶意源代码。包含已知 CVE 的软件包也能完美复现,但仍然存在漏洞。可复现构建只能排除攻击者控制构建机器以及从源代码到二进制文件这一过程的一种可能性。阅读源代码和跟踪漏洞是独立的工作,不能由可复现构建代替。

为什么源代码没有变化,但我的两次构建结果不同?

几乎总是因为时间戳、路径或排序不同。tar 和 zip 等归档格式会存储文件修改时间,因此在不同秒数执行 checkout 会生成不同的字节。调试信息会记录绝对构建目录,因此 /home/alice/src/build/pkg 会使用相同代码生成不同的二进制文件。目录读取会按文件系统顺序返回条目,因此另一台机器上的目标文件可能以不同顺序出现在链接命令中。运行 diffoscope build1 build2,报告会指出具体原因,不必靠猜测排查。

SOURCE_DATE_EPOCH 是什么?我必须设置它吗?

它是一个标准环境变量,保存一个数字:源代码的最后修改时间,即自 1970 年 1 月 1 日 UTC 起经过的秒数。支持该变量的工具会在原本读取系统时钟的地方使用这个值。使用 export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) 从版本控制系统中设置它。这个变量不会自动生效,也不是通用修复方法。只有实现了该功能的工具才会读取它;您自己的构建脚本也必须自行读取它。因此,只要脚本调用 date,它就会继续写入当前时间,直到您修改脚本。

重新构建者报告 BAD 时,我应该怎么做?

首先阅读报告,再进行其他操作。BAD 判定表示一次独立构建没有生成完全相同的字节;通常原因是打包过程存在非确定性,而不是遭到攻击。rebuilderd 可以生成 diffoscope 报告,正是为了帮助定位此类问题。如果差异来自时间戳、构建路径或文件排序,这是一个应当提交报告的打包错误。如果差异出现在可执行代码中,且无法用这些因素解释,请停止部署该构建,保留相关构件,并将问题升级给发布者。

#reproducible-builds#supply-chain#diffoscope#debian#verification