可重現建置到底能證明什麼?
checksum 只能證明檔案未在傳輸中損毀;可重現建置則能確認 binary 對應到可檢視的特定 source,但無法證明原始碼、相依套件或工具鏈安全。
可重現建置能證明什麼
可重現建置只能證明一項明確事實:你取得的二進位檔,正是這份特定原始碼所產生的二進位檔。任何人都能使用相同的原始碼再次建置,並比較每個位元組。驗證不再只有發布者能夠執行。
Reproducible Builds 專案如此定義:「如果使用相同的原始碼、建置環境與建置指示,任何一方都能逐位元重建所有指定成品的完全相同副本,則該建置具備可重現性。」比較本身使用雜湊值。真正困難之處,在於將環境與指示精確固定,讓兩台不同的機器也能產生一致結果。
為什麼 checksum 無法回答這個問題
已發布的 checksum 可證明檔案在傳輸過程中未損毀。對該 checksum 產生的簽章,可證明檔案來自持有該金鑰的人員。但這兩者都無法說明 artifact 產生之前發生了什麼。如果發布者的建置機器遭到入侵,惡意 binary 會和乾淨的 binary 一樣取得 checksum 並完成簽章,因此下游的所有驗證都會通過。如果維護者從從未推送的 working tree 進行建置,結果也一樣。
這就是缺口。你可以閱讀 source、驗證簽章、驗證 checksum,卻仍可能執行從未出現在 repository 中的程式碼。因此,可重現性和依據已發布的 checksum 驗證下載檔案是不同的主題。checksum 保護傳輸過程。重新建置則保護傳輸前發生的一切。
這種攻擊並非理論情境。2020 年的 SolarWinds Orion 入侵事件正處於這個位置:建置系統產生了已簽章的 artifact,但其內容並不對應任何人曾審查過的 source。每次簽章驗證都會通過,因為簽章驗證是從 artifact 開始的。
可重現建置無法證明的事項
這部分最容易被過度宣稱,因此必須清楚說明其限制。
- 這不表示原始碼安全。 直接提交至公開儲存庫的後門仍可被可重現地建置,所有重新建置者也會確認結果一致,因為他們建置的是相同的惡意原始碼。可重現性會將攻擊目標移至原始碼樹。仍然需要有人檢閱該原始碼樹,因此包括 開放原始碼專案中 AI 輔助程式碼的政策 在內的審查政策,仍是獨立的控制措施。
- 這不表示輸入內容安全。 相依套件也是建置內容的一部分。建置時解析到的惡意套件會被編譯進成品,所有解析到相同相依套件的重新建置者都會得到與你相同的結果。npm 供應鏈攻擊如何入侵伺服器 就是透過這種方式發生,而可重現建置也會忠實地重現該攻擊。
- 這不表示工具鏈可信。 如果編譯器已遭入侵,所有使用該編譯器的重新建置者都會產生相同的受污染輸出,判定結果也會全部一致。可重現性會提高發動此類攻擊的成本,但無法偵測攻擊。
- 這與漏洞無關。 逐位元重現的舊版函式庫,仍然是含有已公開缺陷的舊版函式庫,因此請依照獨立的排程,持續 檢查伺服器是否存在已知的 CVE。
可重現性排除的是攻擊者的一個特定立足點:建置機器,以及從原始碼到二進位檔的整條路徑。在套件尚未具備可重現性之前,發布者以外的任何人都無法檢查這條路徑。
為何相同原始碼會產生不同位元組
大多數軟體預設不具備可重現性,原因通常很單純。編譯器與封存格式會記錄執行建置之機器的資訊。
- 時間戳記。
tar、ar與zip格式會儲存檔案的修改時間,因此在不同秒數建置會改變輸出檔案。 - 路徑。除錯資訊會記錄絕對建置目錄,因此即使程式碼相同,在
/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 中加入此功能,而文件記載的形式會將結果推送到 registry:
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 -- nullreprotest 回報的任何問題,重建者日後都會公開回報,並列出你的專案名稱。
Rebuilder 判定的意義
Rebuilder 是不屬於發佈者的機器。它會使用已發佈的原始碼與記錄下來的建置環境重新建置套件,再將產出結果與 archive 中的 artifact 比對。這項判定之所以有意義,只因為該機器是獨立的。
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.buildinfodebrebuild 會讀取 buildinfo,並從 snapshot.debian.org 取得其中指定的確切相依套件版本。如此一來,今天重新建置時可以使用原始建置當日存在的套件版本。mmdebstrap builder 不需要設定 chroot,也不需要 superuser 權限。請使用 diffoscope,將它產出的 artifact 與 archive 中的副本比對。
Arch Linux 執行 rebuilderd,持續進行這項工作並發佈判定結果:
rebuildctl -H https://reproducible.archlinux.org pkgs ls --name rebuilderd狀態包括 GOOD、BAD 與 UNKWN。直接解讀這些狀態,會分別在不同方向上產生錯誤理解。GOOD 表示獨立的一方取得相同的位元組內容。這是對建置結果的有力說明,但完全不能說明原始碼。BAD 幾乎從來不是攻擊。最常見的原因是封裝未固定時間戳記或路徑,因此 rebuilderd 可以在失敗結果中附上 diffoscope 報告。UNKWN 表示尚未有人測試。未測試的套件不等於通過測試的套件。
因此,實務規則很簡單。出現 BAD 判定時,應閱讀報告。如果報告顯示時間戳記、建置路徑或成員排序不同,請提交封裝錯誤回報。如果報告顯示可執行程式碼不同,且沒有上述解釋,請停止部署該建置結果並升級處理。
Debian 目前的可重現程度如何?
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 個套件;這符合尚未完成修正的套件通常會有的情況。
這些數字來自 tests.reproducible-builds.org 上的 Debian 頁面。該頁面於 2026-08-18 查閱,當時顯示「Last update: 2026-08-18 16:02 UTC」。數字會變動。請改看追蹤器,不要在 6 個月後直接引用這段內容。
有一項注意事項比百分比更重要。這個框架會在自己的硬體上建置每個套件 2 次,並在 2 次建置之間變更環境,再比較這 2 次的結果。它測量的是套件是否能夠以可重現方式建置。它不會檢查存放在套件庫中的 .deb 是否相符;這是 rebuilder 的另一項工作,由 rebuilder 將結果與已發布的成品比較。這 2 個數字都很有用,但回答的是不同問題。人們常引用第 1 個數字,卻把它當成第 2 個數字。
在自己的伺服器上該做什麼
你不需要重建整個發行版。能套用到一般伺服器的做法較少,成本也低。
- 固定工具鏈版本。以標籤參照的基礎映像可能在未告知的情況下變更。改用 digest 參照,並將 digest 與版本一併記錄。
- 記錄輸入內容。將 lockfile、映像 digest 與編譯器版本和 artifact 放在一起。若無法重建建置環境,就無法重新建置,因此也無法驗證。
- 在 CI 中建置兩次,輸出不同時就讓工作失敗。這只會增加一次建置成本,並能在有人引入非確定性行為的當天發現問題,而不是等到一年後發生事故時才發現。
- 移除編譯器嵌入的路徑。對 Go 而言,
go build -trimpath -buildvcs=false會移除建置目錄與版本控制標記,go version -m ./app會顯示最後實際寫入 binary 的內容。 - 保留已部署內容的 hash。需要確認執行中的 binary 是否對應某個原始碼修訂版本時,只有這筆記錄能提供答案。
CI 檢查只有 4 行:
set -eu
./build.sh && mv dist/app app.1
./build.sh && mv dist/app app.2
diffoscope --text - app.1 app.2當兩個 artifact 不同時,diffoscope 會以非零狀態結束,因此工作會自動失敗,並在 log 中留下可讀的說明。這就是完整概念,縮小到單一 repository:binary 來自某個 source tree 的主張,應該能由另一台機器加以驗證。
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 報告,目的就是協助確認這類問題。若差異來自時間戳記、建置路徑或檔案排序,這是值得提交問題的封裝錯誤。若差異是可執行程式碼,且無法由上述原因解釋,請停止部署該建置,保留相關成品,並將問題升級給發布者。