
SOURCE_DATE_EPOCH 固定时间戳和规范化文件路径。在软件开发中,将源代码转化为功能性程序的过程——即“构建”——似乎应该是一个确定性的、机器般的过程。遵循同样的配方每次都应产生相同的结果。这一理想状态被称为可复现构建,即任何人都可以从其源代码重新创建出比特级完全相同的软件。然而,在这种简单的期望与现实之间存在着巨大的鸿沟。构建过程经常受到隐藏变异来源的困扰,导致相同的输入产生不同的输出,从而在软件供应链中造成了关键的漏洞。
本文旨在通过对可复现构建进行全面概述来应对这一挑战。首先,文章将深入探讨原理与机制,揭示那些导致非确定性的“机器中的幽灵”——即微妙的环境因素和随机性——并探索为驾驭它们而开发的强大技术。随后,文章将探讨其深远的应用与跨学科联系,展示可复现性不仅是一个技术细节,而且是现代安全的基石,是抵御深层次威胁的防线,也是确保科学发现完整性的革命性力量。我们将从剖析构建的内部结构开始,以理解其理想状态与现实世界中挑战它的复杂性。
想象你有一个制作蛋糕的食谱。如果你遵循指示——相同的配料、相同的份量、相同的烤箱温度、相同的烘焙时间——你期望每次都能得到相同的蛋糕。在软件世界里,将人类可读的源代码转化为可执行程序的过程被称为构建,而食谱就是构建脚本。乍一看,这个过程似乎应该是一台完美的、确定性的机器。
我们可以将编译器或构建系统想象成一个数学函数,一个我们放入某些东西就能得到特定产出的黑匣子。我们将这个函数称为 ,代表“转换”(translation)。最明显的输入是源代码,我们称之为 ,以及配置,我们称之为 ,它包括我们提供给编译器的所有设置和标志,如优化级别或目标计算机架构。在这个理想世界中,构建是一个简单的转换:,其中 是最终的产物,即我们的二进制程序。
如果这个模型是真实的,我们的生活就会很简单。任何人在任何地方,只要使用相同的源代码 和相同的配置 ,都会生成一个与我们比特级完全相同的二进制文件 。我们只需通过重新构建并比较结果的加密哈希(一种数字指纹)就可以验证任何软件的完整性。如果哈希值匹配,程序就是相同的。这就是可复现构建的核心承诺,一个优美而简单的原则。但当我们仔细观察时,会发现现实世界并非如此井然有序。这个钟表宇宙的齿轮中存在着幽灵。
实际上,我们简单的函数 是一种因省略而产生的谎言。构建过程并非与世隔绝;它对一系列不属于源代码或显式配置的隐藏输入非常敏感。为了更真实地描绘情况,我们必须扩展我们的模型以包含这些幽灵:。在这里, 代表构建环境, 代表随机性。这些幽灵困扰着我们的构建过程,在我们期望一致的地方制造出令人抓狂的、细微的差异。
环境 () 作为隐藏变量
环境是构建过程中可以“看到”、“听到”或“感觉到”的一切周边事物。
首先,是时间的暴政。构建发生在何时?许多工具默认情况下会贴心地将时间戳嵌入它们创建的二进制文件中。编译器可能会定义像 __DATE__ 和 __TIME__ 这样的特殊宏,它们会在编译时刻被解析。甚至文件系统也参与其中。如果在一个软件容器内运行的构建过程创建了一个新文件,该文件的修改时间(mtime)将被设置为容器的当前时钟。如果你运行两个完全相同的构建,但为每个构建设置不同的容器时钟,那么输出文件上的 mtime 时间戳就会不同,从而破坏可复现性。
其次,是空间的混乱。构建发生在哪里?编译器需要知道源文件的位置以生成调试信息。它通常会将完整的绝对路径——例如 /home/user/project/src/main.c——直接嵌入到二进制文件中。如果另一个人在不同的目录(比如 /var/build/project/src/main.c)中运行相同的构建,路径就会不同,最终的二进制文件也将不完全相同。
第三,是人群的低语。构建过程对你可能从未想过的环境设置很敏感。系统的语言和字符排序规则(区域设置,或 LC_ALL)、时区(TZ),甚至当前的用户和组 ID 都可能微妙地影响工具的输出,在这里或那里改变一个字节或一个字符串。
最后,是无序事物的骚动。当构建脚本需要编译一个文件列表时,它可能会从文件系统中收集这些文件。但文件系统能保证每次都以相同的顺序列出文件吗?不一定。这种非确定性排序会传播开来,改变文件链接到库或可执行文件中的顺序。两个构建过程可能会以不同的顺序处理同一组文件,从而产生不同的最终产物,即使每个文件的内容都完全相同。
随机性 () 与内部混乱 ()
有时,变异并非偶然,而是故意的。为了找到复杂问题的更优解,一些编译器采用随机化算法。像寄存器分配这样的阶段,它决定如何使用 CPU 宝贵的少量寄存器,可能会使用随机种子来探索不同的策略。编译器内部的数据结构,如哈希表,可能会使用随机化种子来防御拒绝服务攻击。如果这些种子不受控制,编译器的每次运行都会不同。
最糟糕的是内部非确定性,我们可以称之为 。当构建工具本身存在缺陷或设计缺陷时,就会发生这种情况,例如仅在并行构建(make -j8)时出现的竞态条件。在这种情况下,即使在完全相同的环境和受控的随机性下,该工具也像一台有故障的机器,从相同的输入产生不同的输出。这不是一个隐藏的输入;而是机器本身确定性本质的崩溃。
面对这一系列五花八门的变异,要实现一个真正可复现的构建似乎希望渺茫。但多年来,一个由数字工匠组成的社区已经开发出了一套强大的技术——一种现代驱魔术——来驾驭这些幽灵并恢复构建过程的秩序。
策略很简单:识别每一个非确定性的来源,并系统地消除它或将其置于控制之下。
为了对抗时间的暴政,我们可以为构建设置一个通用时钟。一个名为 SOURCE_DATE_EPOCH 的环境变量已成为一个标准,它指示所有兼容的工具假装构建发生在一个特定的、固定的时间戳——例如,最后一次源代码提交的时间。这冻结了时间,确保所有嵌入的时间戳在任何地方的所有构建中都是相同的。
为了解决空间的混乱,我们指示编译器重写路径。我们为它提供一个前缀映射,告诉它,例如,“无论你在哪里看到路径 /home/user/project/,都将其替换为一个通用标记,如 /src/。” 这确保了没有特定于机器的目录结构会泄露到最终的二进制文件中。在像容器这样提供一致文件系统布局的受控环境中运行构建,进一步帮助标准化“空间”。
为了平息人群的低语并驾驭无序事物的骚动,我们强制执行一个规范化上下文。我们固定区域设置、时区和用户 ID。我们在将任何文件列表提供给编译器或归档器之前,都明确地对它进行排序。我们配置工具以使用其确定性模式,例如,告诉归档器 ar 对库中的文件使用稳定的排序。我们设计编译器遍(pass)管理器,使其使用带有确定性决胜规则(如按字母顺序)的稳定拓扑排序,而不是依赖哈希表的任意迭代顺序。
最后,我们掌控随机性。对于工具链中任何使用随机种子的部分,我们提供一个从构建输入派生出来的固定的、确定性的种子。这使得“随机”选择变得可预测和可重复。
通过应用这些技术,我们将构建过程从一个混乱、不可预测的事件转变为一个纯粹的、确定性的函数。幽灵被驯服了。当我们拥有一个可复现的构建时,我们就可以满怀信心地为输出生成一个加密哈希。这个哈希成为软件的通用标识符,使得内容寻址缓存等强大应用成为可能,如果完全相同的产物已经存在,就可以避免重新构建。但最重要的应用不是关于效率,而是关于信任。
为什么要费这么多周折?答案是,可复现构建是现代软件供应链安全的基石。
想象一下你从互联网上下载了一个浏览器。供应商也提供了源代码。你如何知道你下载的可执行文件确实是从那个源代码构建的?如果攻击者在供应商的构建服务器上植入了后门,并在浏览器二进制文件发布前将其插入呢?源代码保持原始状态,而供应商在不知情的情况下,用他们的官方密钥签署了恶意的二进制文件。你计算机的安全机制,如安全启动(Secure Boot),会检查签名,发现它有效,然后毫无怨言地运行该程序。最终内核二进制文件的度量值也将与供应商(被篡改的)清单相匹配,甚至能挫败基本的可信启动证明(Measured Boot attestations)。
这就是构建器受损攻击,它非常有效,因为它攻击的是我们对软件来源的信任。这时,可复现构建就成了我们的试金石。如果一个构建是可复现的,你——或任何独立的第三方——可以下载源代码,按照确定性的配方执行构建,并计算结果的哈希值。然后你将你的哈希值与供应商发布的哈希值进行比较。
如果哈希值匹配,你就有了强有力的加密证明,证明你拥有的二进制文件与你检查过的源代码相对应。如果哈希值不匹配,警报就会响起。有些东西不一样了。二进制文件被篡改了。可复现构建使我们能够独立验证供应链的完整性,并检测到这类攻击。
这个原则延伸到了编译器安全中最深层、最著名的问题:“信任之信”攻击,由 Ken Thompson 在他 1984 年的图灵奖演讲中首次描述。如果你用来构建新程序的编译器本身就是恶意的,该怎么办?它可以被编程为检测到自己正在编译一个登录程序时插入一个后门,并且在检测到自己正在编译一个新版本的自身时,将这套同样的恶意逻辑注入其中。这种攻击会永远自我延续,而不会在任何源代码中留下证据。
抵御这种深层次攻击的防御方法是一种称为多样化双重编译(Diverse Double-Compiling,DDC)的技术。你拿来新编译器的源代码,使用两个完全独立的、多样化的现有编译器(比如 GCC 和 Clang)进行两次构建。如果其中一个被感染了,它会产生一个恶意的新编译器,而干净的那个会产生一个干净的新编译器。由于两个结果二进制文件会不同,比特级的比较将揭露这次攻击。只有当构建过程是可复现的时,这种比较才可能实现。
将整个构建(可能包含数千个文件)的状态总结为单个可验证的哈希,这是一个强大的概念。先进的系统使用默克尔树(Merkle trees)——即哈希之树——来创建一个单一的根哈希,代表所有构建产物的集体状态。这既允许高效的整体验证,又能快速定位任何被篡改的单个文件,为我们提供了一种可扩展的方式来确保即使是最复杂的软件系统的完整性。
最终,对可复现构建的追求是从一个优雅但有缺陷的理想走向一个混乱复杂现实的旅程。它要求我们成为数字侦探,追捕隐藏的变异来源。但通过掌握确定性的技艺,我们不仅能构建出更好、更可靠的软件,还能锻造出在数字世界中建立可验证信任基础所必需的工具。
在我们经历了可复现构建的原理与机制之旅后,我们可能会留下这样的印象:这是一个相当深奥的问题,是专家们为软件构建的细枝末节而烦恼的事情。事实远非如此。对可复现性的追求并非一个偏门的技术细节;它是我们在数字世界中建立信任的基石。其影响从计算机科学的核心涟漪般扩散开来,触及从我们基础设施的安全、我们天空的安全,到科学发现本身的完整性等方方面面。这是一个关于我们如何征服混乱、从无到有地构建,并最终学会如何信任我们自己创造物的故事。
让我们从问题最初出现的地方开始:在编译程序这个看似简单的行为中。为什么这个过程不是天然可复现的?答案在于构建环境微妙而普遍的影响。计算机不是一个抽象的图灵机;它是一个真实的、沉浸在上下文中的系统。本地时区(TZ)可以改变文件中嵌入的时间戳,语言和地区设置(locale)可以改变源文件的排序和处理顺序,而系统的搜索路径(PATH)可能导致构建过程拾取不同版本的基础工具,如编译器和链接器。即使这些环境条件中最微小的差异,也可能级联成完全不同的二进制输出,即便源代码完全相同。现代构建系统通过创建“密封”环境来对抗这种混乱,通常使用容器技术来精确控制这些变量——固定工具版本,将时钟设置为协调世界时(UTC),并使用固定的、按字节排序的顺序来创建一个规范的、可预测的过程。
这场对抗环境混乱的战斗只是第一步。一个更深层的问题迫在眉睫:我们到底如何信任我们的工具?第一个编译器从何而来?这是数字时代的“先有鸡还是先有蛋”的问题,一个称为自举(bootstrapping)的过程。想象你有一台全新的计算机架构,一块没有任何软件的白板。你的目标是创建一个完整的软件生态系统,从一个基本的汇编器开始。解决方案是一个分阶段构建的杰作,一个可复现供应链的完美例证。你首先用汇编语言为某个语言的子集编写一个微小、简单的编译器。这个初始编译器是你信任的种子,小到可以手工审计。你用这个种子编译一个稍微更强大的编译器,然后用它来编译一个更强大的编译器,以此类推。每个阶段都建立在前一阶段经过验证的输出之上,从一个最小的、可审计的基础(称为可信计算基 TCB)逐步构建一个复杂的、自托管的工具链。这不仅仅是一个历史趣闻;新的 Linux 发行版就是这样诞生的,我们也是这样从最简单的起源建立起信任链的。
然而,这条信任链是脆弱的。在他 1984 年的图灵奖演讲中,Unix 的创造者之一 Ken Thompson 描述了一种极其巧妙的攻击。他想象修改一个 C 编译器,使其不仅能编译源代码,还能识别出它正在编译 C 编译器本身。当它这样做时,它会把同样的自我复制修改注入到新的编译器二进制文件中。然后他可以从编译器的源代码中移除恶意代码。结果呢?一个在源代码中看起来干净,但会永远产生受感染后代的编译器,一个代代相传的特洛伊木马。这就是“信任之信”攻击,它是终极的软件供应链噩梦。
我们如何防御这样的内部敌人?可复现构建提供了一个强大的武器。通过将它们与一种称为多样化双重编译的技术相结合,我们可以对我们的工具进行测试。策略既简单又深刻:我们取一个关键程序(比如编译器)的源代码,然后使用两个完全独立的工具链——例如 GNU 编译器集合(GCC)和 Clang——来编译它。如果原始源代码是干净的,并且两个编译器都是可信的,那么尽管它们的内部工作方式不同,在可复现的构建过程中,它们应该从相同的源代码产生比特级完全相同的二进制文件。如果二进制文件不同,这要么标志着一个缺陷,要么更不祥地,意味着其中一个工具链不可信。
可复现性的保证超出了开发阶段,延伸到线上系统的运行安全。现代服务器通常配备一种称为可信平台模块(TPM)的特殊安全芯片。通过一个称为可信启动(Measured Boot)的过程,TPM 在每段软件加载时对其进行加密度量(哈希),从固件到操作系统内核,创建一个无可否认的、关于实际运行内容的记录。通过将这个度量出的哈希值与一个合法内核的、已知的可复现构建哈希值进行比较,远程验证者可以证明服务器的完整性。但如果哈希值不匹配怎么办?一个简单的策略是拒绝该机器,但现实更为复杂。先进的系统使用一种更细致的方法,采用“规范化”哈希,忽略二进制文件中良性的、非确定性的部分(如嵌入的构建时间戳)。这使得系统能够区分无害的变异、已知的构建系统缺陷和真正的恶意修改,从而实现一种复杂的、基于风险的响应。
没有什么地方的风险比天空中更高。对于安全关键的航空电子软件,故障可能是灾难性的。像 DO-178C Level A 这样的认证标准要求极高的保证水平。这需要一个“合格的”编译器工具链,其中每个优化都经过形式化证明,以保证程序的语义不变,并且其对执行时间的影响是有界的和已知的。这些编译器会拒绝模糊或未定义的语言特性,并生成大量的验证产物,包括从每一行目标代码回溯到原始源代码及其实现的高级需求的可追溯性。这是可信、可复现过程的终极体现——以尽可能高的置信度确保驾驶飞机的代码正是所设计、构建和测试的代码,没有任何隐藏的意外。
保障我们软件供应链的原则现在正在革新科学。多年来,计算科学一直面临着“可复现性危机”,研究人员发现很难或不可能复制同行的已发表结果。原因通常与困扰软件构建的环境混乱相同:不同的库版本、未记录的参数,或对宿主机的微妙依赖。事实证明,解决方案是将科学分析视为一个构建过程。
在基因组学等领域,从原始测序数据组装基因组的复杂工作流正被形式化为内容寻址的有向无环图(DAGs)。每个输入数据集、每个软件工具(封装在容器中)以及每个参数都被赋予一个唯一的加密哈希。这些哈希在工作流中传播,为整个分析生成最终的“可复现性证书”。这使得世界任何地方的另一位科学家能够重新运行完全相同的计算,并验证他们得到完全相同的结果,这是科学验证的基石。
然而,有时完美的比特级一致性是一个过于严格的标准。在高通量材料科学中,研究人员运行数千次模拟来筛选具有理想特性的新材料。在这里,目标不一定是复现每一个比特,而是确保计算“工厂”正在产生统计上稳定的结果。从制造业中汲取灵感,这些工作流可以用统计过程控制图来监控。建立一个“正常”结果的基线,如果一个新的计算产生的结果偏离了预定义的统计阈值(例如,超过三个标准差,或 ),就会标记一个“漂移”。这可能表明代码中存在一个微妙的缺陷或底层计算环境发生了变化,从而在数千个 CPU 小时被浪费在有缺陷的计算上之前促使调查。
这种对计算完整性的追求贯穿所有科学领域,从我们数据的内部运作到宇宙的遥远边界。构建日益塑造我们世界的人工智能模型的数据科学流水线,可以建立在用于编译器的相同自举原则之上。通过从一个小的、经过审计的核心开始,并以可验证的阶段逐步增加复杂性,我们可以在我们的人工智能系统中获得信任并确保可复现性。在高能物理学中,巨大而复杂的模拟被用来预测寻找像暗物质这样的难以捉摸的粒子的实验的灵敏度。这些模拟是物理学家的“眼睛”,指导着数十亿美元探测器的设计。一个微小的、未被发现的、改变了预测结果的缺陷可能是灾难性的。通过强制实施可复现构建,追踪每个结果的来源,并使用确定性的“Asimov 数据集”进行回归测试,物理学家确保他们观察宇宙的计算镜头保持清晰和真实。
从一个程序员希望每次得到相同答案的简单愿望中,一个普适的原则得以展现。对可复现构建的探索揭示了软件安全、工程纪律和科学方法之间的深刻联系。它是一个简单而深刻理念的实践体现:“我确切地知道我构建了什么,而且我能证明它。” 在一个数字产物可以无限修改的时代,建立一个基本事实的这种能力不仅仅是一个特性——它是信任的必要基础。