Hermetic 与 Reproducible Builder:封住隐藏输入并用双构建取证
一次紧急修复在 CI 中连续成功,发布到第二个区域时却得到不同镜像摘要。团队最初怀疑 Registry 损坏,拆层后才发现构建脚本从浮动 URL 下载代码生成器,主区域命中了旧缓存,灾备区域从网络取得新版本;生成器又把本机时区和绝对工作目录写进产物。两次作业都使用同一 commit 和 lock file,也都显示绿色,但它们执行的构建函数并不相同。
另一个项目为了“提高复现率”把所有构建放进共享容器,并挂载宿主 Docker socket、包管理器缓存和云凭据。攻击者控制的合并请求在缓存里写入同名编译器包装器,后续受保护分支命中缓存后执行它,包装器读取云 token 并把秘密藏进调试符号。容器镜像固定、输出甚至可以重复得到相同摘要,却既不 hermetic,也不 isolated。可复现结果不能替代构建间隔离,更不能证明秘密没有越过边界。
四个属性回答四种不同问题
Hermetic 关注构建是否只受声明和固定输入影响。源码、依赖、工具链、环境变量、平台属性和必要服务都要成为显式输入;未声明的宿主文件、浮动网络响应、用户 home、系统工具、时钟和随机源要被阻断或归一。它减少“同一个构建定义在不同机器上其实做了不同事”的空间。
Deterministic 关注给定相同输入时,构建函数是否稳定产生相同输出。文件遍历顺序、并发调度、随机数、归档成员顺序、时间戳、UID/GID 和绝对路径都可能让 hermetic 构建仍然不确定。固定网络和工具链并不会自动修复一个把当前时间写进二进制的链接器步骤。
Reproducible Builds 项目的正式定义强调:在相同 source、build environment 和 build instructions 下,任何一方能够重建指定制品的逐字节相同副本。这里的“相同环境”必须由制品生产者定义到可重建的程度,比较对象也必须明确。两个 tar 包解压后文件相同但原始字节不同,只能在预先声明并可审计的归一化协议下形成另一种结论,不能直接宣称制品逐字节可复现。
Isolated 关注一个恶意或失败构建能否影响控制平面、其他并发构建、后续构建、共享缓存、签名密钥和输出授权。SLSA v1.2 Build L3 要求强化的构建间隔离,但明确不把 isolated 等同于 hermetic。允许显式网络请求的构建仍可能隔离良好;完全断网的容器若共享可写 cache、宿主 daemon 或签名 secret,仍可能跨构建攻击。四项属性要分别建模、分别验证。
先列出构建函数的全部输入
安装某个 builder 之前,先把构建定义写成输入合同。源码必须是不可变 commit 或内容摘要;依赖和工具链使用 lock、digest 或内容寻址标识;基础镜像不能只写 tag;环境变量采用 allowlist;CPU 架构、内核、文件系统行为、证书、DNS 与代理若会影响输出,也要进入平台约束或兼容矩阵。lock file 只能固定它描述的依赖解析,不能固定宿主编译器、下载脚本、副作用服务或内核行为。
buildContract:
source:
repository: https://scm.example.invalid/payments/app
commit: ${SOURCE_COMMIT}
toolchain:
image: registry.example.invalid/build/toolchain@sha256:${TOOLCHAIN_DIGEST}
dependencies:
lockfiles: [go.sum, package-lock.json]
mirrors: [https://packages.example.invalid]
environment:
allowed: [PATH, HOME, TMPDIR, SOURCE_DATE_EPOCH, TZ, LANG, LC_ALL]
values:
PATH: /opt/toolchain/bin
HOME: /work/home
TMPDIR: /work/tmp
SOURCE_DATE_EPOCH: ${SOURCE_DATE_EPOCH}
TZ: UTC
LANG: C.UTF-8
LC_ALL: C.UTF-8
network:
buildPhase: deny
fetchPhase: allow-listed-and-digest-verified
cache:
namespace: ${PROJECT_CACHE_NAMESPACE}
verifyContent: true
outputs:
- dist/app.tar
- dist/app.sbom.json合同还要区分取件阶段与执行阶段。取件器在受控网络中下载 source、模块和基础镜像,校验摘要后写入只读 content store;执行器只读取这些固定对象并写临时输出。若构建必须访问许可证服务器或远程编译服务,就把服务身份、请求内容、返回值稳定性和失败语义写入合同,不能仍称为完全无外部输入。
构建环境边界可参考 Reproducible Builds 的环境边界说明。哪些输入必须精确相同,哪些允许变化并通过归一化消除,哪些平台差异会导致不同但可接受的目标制品,都应明确。没有边界定义的“可复现”无法被第三方执行,也无法在差异发生时判断责任。
启用沙箱而不是只固定容器镜像
Hermetic 与 reproducible builder 不是一个统一产品开关。可以在 Bazel、Nix、受控容器或 VM 构建平台上实现,但要依据目标版本的官方行为确认沙箱、网络、远端执行和缓存语义。Bazel 的 Hermeticity 指南 把隔离和 source identity 作为核心原则,同时提醒 action 仍可能通过系统工具、绝对路径和非固定环境获得隐藏输入。
最低启用链路是:固定 builder 和工具链版本;为每个 build/action 创建临时执行根;只挂载声明输入为只读;默认不继承宿主环境;执行阶段关闭网络;输出落到独立目录;完成后由控制面计算 digest、生成 provenance,再清理执行环境。容器共享宿主内核,若挂载 Docker socket、/proc、设备、宿主 home 或特权 capability,就不能把它描述成 VM 级隔离。
这里不存在跨 Bazel、Nix、容器和 VM 平台都成立的 builderctl。接入时应给所选平台实现一个仓库内、可版本化的构建入口,并让它接受固定 source、toolchain digest、网络模式、cache 模式、环境 allowlist 与独立输出目录。平台验收记录至少要包含实际命令或作业定义、工具版本、输入 digest、退出码、sandbox 审计事件和输出 digest;只有“sandbox enabled”日志不够。
启用验收要观察真实拒绝:读取未声明文件失败、执行阶段 DNS/连接失败、未知环境变量未传入、输出目录之外的写入被阻断。每个反例还要确认没有副作用,例如网络代理没有请求、共享 cache 没有新对象、其他 workspace 没有新增或变更文件。这样,文档不会用一个并不存在的通用 CLI 掩盖各平台真正不同的控制点。
网络、时钟、locale 与路径都可能改写字节
最稳妥的网络模型是 fetch 与 build 分离。fetcher 只能访问 allowlist mirror,解析浮动名称后验证预期 digest,并把解析结果写入 provenance;build action 无网络。若必须在线构建,至少通过受控代理记录 URI、响应摘要和调用身份,阻止直连与 DNS 旁路。依赖 URL 固定但内容不固定仍然不 hermetic,TLS 成功只证明连接到了持证端点。
时钟治理不能简单伪造系统时间。许多构建工具接受 SOURCE_DATE_EPOCH 作为可重复时间戳约定,但它只解决接受该约定的步骤。归档器、编译器、签名、证书、日志和生成代码可能各读不同时间源。构建合同要规定语义时间来自 source revision、发布元数据还是固定值,并检测产物中当前时间残留。签名时间和透明日志时间属于构建后的证据,不应为了字节一致而伪造成构建内容时间。
TZ、LANG、LC_ALL 会影响日期格式、大小写转换、排序、错误消息和生成文件。路径会进入 debug info、source map、归档成员和编译缓存键;用户名、hostname、UID/GID 与 umask 也可能进入元数据。统一设置只是第一层,更强的验证是主动改变这些变量、工作目录和主机名,确认指定输出仍一致。
并发和文件系统同样重要。未排序的目录遍历、并行链接顺序、哈希表迭代、大小写敏感差异和不同文件时间精度都可能制造漂移。不要一遇到差异就把环境钉得更死;先用逐层摘要和差异工具定位字节来源,再决定修复生成逻辑、归一化元数据,还是把平台拆成不同的可验证构建目标。
用双构建和环境变异建立证据
正向实验不应只是同一 workspace 连续运行两次。至少使用两个 clean workspace,分别关闭和开启缓存,并改变允许变化的 hostname、username、timezone、locale 与绝对路径。更强的结论来自独立初始化、独立运营的第二个 builder;两边都保存 source、工具链、依赖、构建指令、输出摘要和各自 provenance。
先用所选平台的真实入口分别完成 cache off 与 cache on 两轮构建,并把两个候选制品导出到独立目录。比较门禁本身可以保持简单且可执行:
set -eu
test -f out-a/app.tar
test -f out-b/app.tar
sha256sum out-a/app.tar out-b/app.tar | tee artifact-digests.txt
if ! cmp -s out-a/app.tar out-b/app.tar; then
echo "ERROR: cache/workspace variant changed app.tar" >&2
exit 1
fi预期证据是 artifact-digests.txt 中两个 SHA-256 相同、cmp 退出码为零,并且两轮 provenance 分别绑定这个 subject digest。构建日志还要证明两轮使用相同 source、toolchain 与依赖摘要,而 workspace、cache 模式和选定环境变量确实不同;否则“摘要相同”可能只是两次复用了同一个输出。
两次摘要一致只说明当前样本没有暴露差异,不是对所有环境和未来版本的数学证明。持续构建应轮换环境变异,并定期在独立 builder 上重建。要声明 SLSA_BUILD_REPRODUCED,还需由 VSA issuer 确认至少两个受信且独立运营的 Build Platforms 及其 provenance,不能把同一控制面下的两个 runner 当成两个独立运营的平台。
反向实验要故意注入隐藏输入。让脚本读取 /usr/bin 未声明工具、访问浮动 URL、嵌入当前时间、改变 locale、遍历未排序目录,或让一个低信任作业写共享缓存。每个反例都应在靠近根因的位置失败:沙箱拒绝隐藏文件和网络,双构建暴露不确定字节,缓存校验拒绝污染对象。
网络反例必须使用所选平台真实的 sandbox 作业:在执行阶段请求一个测试专用地址,预期进程非零退出,同时保存 DNS/egress 拒绝事件,并确认代理端请求计数没有增加。不能用虚构的 build-release --network=off 代替平台证据。
时间和路径反例可先用下面的自包含 fixture 验证比较门禁确实能发现差异,再把同样的注入方式移入真实构建目标:
set -eu
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/build-a" "$tmp/build-b"
(cd "$tmp/build-a" && printf 'time=%s\npath=%s\n' "$(date +%s)" "$(pwd -P)" > app.bin)
sleep 1
(cd "$tmp/build-b" && printf 'time=%s\npath=%s\n' "$(date +%s)" "$(pwd -P)" > app.bin)
if cmp -s "$tmp/build-a/app.bin" "$tmp/build-b/app.bin"; then
echo "ERROR: negative fixture did not expose variance" >&2
exit 1
fi
sha256sum "$tmp/build-a/app.bin" "$tmp/build-b/app.bin"预期输出是两个不同摘要且脚本退出码为零;如果两个文件意外相同,脚本反而失败。真实构建负例还必须保存差异文件或差异分类,证明变化来自注入的 clock/path,而不是未固定的第三项输入。
负例本身也要防止失效。若测试脚本后来不再嵌入时间,而 CI 仍把“检测到差异”写成固定文本,门禁会产生假阳性。保存实际摘要、差异分类和沙箱拒绝事件,并定期确认 fixture 确实触发目标机制。
缓存必须既可验证又不能跨构建投毒
缓存键至少包含会影响输出的 source、规则、工具链、依赖、平台属性和相关环境;只用分支名、任务名或 lock file hash 会漏掉隐藏输入。内容寻址存储让对象名由内容摘要决定,但索引仍可能把错误输入键指向攻击者内容,因此命中后要验证对象摘要、元数据签名或可信生产者,并限制谁能写入共享命名空间。
隔离策略通常把不可信 PR 设为只读公共缓存并写入自己的临时命名空间;受保护发布可以读取经过验证的公共对象,但只有可信 builder 能发布共享条目。不同租户、项目、安全模式和工具链代际使用独立 namespace 与凭据。缓存服务账号不能同时拥有 provenance 签名权,构建 action 也不能通过缓存 API 覆盖另一个项目的键。
正向验证比较 cache off、冷 cache 与热 cache 的输出摘要;反向验证用低信任身份上传伪条目、修改索引、截断对象和返回错误媒体类型,预期命中被拒绝或退回受控重建,结果仍与无缓存一致。禁止“校验失败后继续使用旧文件”的静默回退。缓存错误指标要区分 miss、corruption、unauthorized write、digest mismatch 和 backend unavailable。
缓存提高吞吐,也放大保留与恢复成本。容量模型包含对象大小、重复消除率、热度、索引、跨区复制、下载带宽、垃圾回收和冷启动重建时间。GC 必须知道哪些发布和回滚窗口仍引用对象;恢复时先恢复信任元数据和索引一致性,再逐步开放写入,避免故障后的大量重建和缓存回填同时压垮依赖镜像。
秘密泄漏与构建逃逸要单独演练
真正 hermetic 的用户构建步骤通常不需要发布密钥、provenance 签名密钥或云管理员凭据。私有依赖 credential 由 fetcher 使用,下载并校验后只把内容交给执行器;发布 credential 由构建完成后的控制面使用;签名操作通过不向 action 暴露 key material 的受限服务完成。不要把一个全能 service account 注入整个 Job,再寄希望于脚本不打印它。
泄漏面包括环境变量、命令行、进程列表、/proc、崩溃转储、编译日志、测试报告、cache、镜像层、调试符号、source map 和 provenance byproducts。Canary secret 实验可以注入只允许控制面看到的无害标记,随后扫描输出、日志、缓存和证据,任何命中都阻止发布。扫描真实 secret 之前先停止外发通道,避免检测过程本身造成复制。
构建逃逸不只指内核漏洞。挂载宿主 Docker socket 等同于把宿主控制交给 action;可写宿主目录、特权容器、设备、host PID/network、远程执行 daemon、共享 home、SSH agent 和云 metadata 都可能越界。隔离强度按威胁模型选择进程、容器、microVM 或 VM,并持续更新宿主内核、运行时和 sandbox。高风险不可信代码与生产签发平面应处于不同节点池、账号和网络故障域。
反向演练让测试 action 尝试读取未授权文件、访问 metadata endpoint、连接外网、写其他 workspace、修改共享 cache 和调用签名 API。预期不仅是命令失败,还应有 sandbox audit event、网络拒绝、权限拒绝和无副作用证明。演练结束后销毁临时身份、节点、磁盘、快照和日志中的 canary,确认没有为调试留下特权 capability 或长期例外。
接入流水线并控制容量、HA 与成本
流水线分成 fetch、build、compare、attest、publish、verify 六个责任域。fetch 输出固定输入清单;build 在隔离执行根中产生候选制品;compare 按风险选择本轮 cache 变异或独立重建;attest 由控制面绑定 subject digest 和 builder identity;publish 按 digest 写入;verify 使用消费策略决定是否晋级。某一步失败时,不得从旧 workspace 或不明缓存捞一个“最近成功”的包继续发布。
pipeline:
fetch:
network: allowlisted
verifyDigests: true
build:
network: denied
ephemeralWorkspace: true
secrets: none
compare:
modes: [cache-off, clean-workspace, independent-builder]
requiredFor: ${RELEASE_RISK_CLASS}
attest:
signerAccessibleFromBuild: false
publish:
identity: artifact-digest
verify:
failClosed: true容量不能只算 CPU 编译分钟。无缓存验证会增加冷启动和依赖读取,双构建接近放大计算、存储和队列占用,microVM/VM 提高隔离但增加启动延迟,差异分析还消耗临时磁盘。按项目风险分层:普通提交持续做沙箱负例与 cache on/off 抽样,候选发布做 clean rebuild,高价值制品再由独立 builder 重建。抽样策略必须可审计,关键发布不能因队列积压静默降级。
HA 覆盖调度器、执行池、固定输入仓库、缓存、签名服务、provenance 存储和独立 rebuilder。多个 runner 连接同一个可写缓存和同一个控制面,不构成独立重建。故障转移后先验证新区域的 toolchain digest、网络封口、KMS 身份、cache namespace 与平台属性,再恢复发布。恢复风暴通过队列上限、优先级、公平调度、退避和预热固定输入控制。
指标至少包括 sandbox 拒绝、网络旁路、未声明输入、cache mismatch、cache on/off 摘要差异、独立重建差异、构建逃逸尝试、secret canary 命中、队列时间、冷启动和单位制品成本。动态价格与配额从所选计算、存储和签名服务官方入口读取,预算同时考虑双构建产生的额外碳与算力成本。
升级、迁移、回滚和退出要保留比较能力
升级 builder 时同时锁定执行镜像、sandbox runtime、规则集、remote execution 协议、缓存 schema 和 provenance builder.id。先在候选池双跑真实但无生产副作用的构建,比较输出摘要、沙箱事件、依赖清单、性能与敏感字段。若安全边界或可复现环境定义变化,发行新的 builder identity,不能让旧身份掩盖能力下降。
迁移采用双轨:旧 builder 继续生产,候选 builder 影子构建并禁止发布;达到预设一致率且所有差异有解释后,让少量项目从候选发布,消费端在限时窗口接受新旧两个明确身份。缓存先只读或使用新 namespace,避免新旧规则互相污染。独立 rebuilder 在迁移期间保留第三方视角,能区分是旧平台、新平台还是源码本身不确定。
回滚不只是切回旧镜像。恢复旧调度器与规则集后,还要切回匹配的 cache namespace、toolchain、签发身份、builder ID 和消费策略;候选平台产生的制品不得冒充旧 builder。若数据库或缓存 schema 已前向迁移,回滚前验证兼容性,必要时从一致性快照恢复索引而不是复用半迁移状态。
退出时先禁止新任务和新 cache 写入,等待或终止在途构建,撤销 fetch、cache、publish 与签名身份;再删除该平台专用的临时执行根、节点磁盘、VM 快照和测试 secret。共享 cache 与 toolchain 镜像必须先做租户、发布和回滚引用核对,只删除该平台拥有且已无引用的 namespace 或副本,不能把平台退出写成全局清库。历史 provenance、构建定义、工具链摘要与必要输入按制品保留期保存,使仍在使用的制品可以继续调查和重建。最后用旧身份提交测试任务,预期调度、签名和发布都被拒绝,并核对账单、存储和网络中没有孤立资源。
长期治理由平台 owner 维护构建合同和隔离基线,项目 owner 修复不确定构建,安全 owner 管理签发边界和逃逸演练,发布 owner 决定独立重建门槛。Hermetic、reproducible 与 isolated 都不是一次性标签;它们是每次工具链升级、缓存变更和平台迁移都要重新建立的证据。
Reproducible Builds 定义。Reproducible Builds 构建环境边界。Reproducible Builds 环境定义策略
Reproducible Builds 变异测试。Bazel Hermeticity。NixOS Reproducible Builds
