Bản build có thể tái lập chứng minh điều gì?
Checksum chỉ cho biết file tải về không bị hỏng. Bản build có thể tái lập còn chứng minh binary khớp từng bit với source code công khai.
Bản build có thể tái lập chứng minh điều gì
Bản build có thể tái lập chỉ chứng minh một điều cụ thể: binary bạn nhận được chính là binary được tạo ra từ đúng source code này. Bất kỳ ai cũng có thể dùng cùng source code để build lại rồi so sánh từng byte. Việc xác minh không còn là thao tác chỉ publisher mới thực hiện được.
Dự án Reproducible Builds định nghĩa khái niệm này như sau: "Một bản build có tính tái lập nếu, khi có cùng source code, môi trường build và hướng dẫn build, bất kỳ bên nào cũng có thể tạo lại các bản sao giống hệt nhau theo từng bit của toàn bộ artifact được chỉ định." Bản thân việc so sánh sử dụng hash. Khó khăn nằm ở việc cố định môi trường và hướng dẫn đủ chặt để hai máy khác nhau cho ra kết quả giống nhau.
Vì sao checksum không trả lời được câu hỏi này
Checksum được công bố chứng minh file bạn đã tải xuống khớp với giá trị tham chiếu, nghĩa là file không bị hỏng trong quá trình truyền. Nó không cho biết ai đã tạo file. Hướng dẫn xác minh của Apache nói rất rõ về hash: “Hash không đảm bảo file là xác thực.” Checksum tham chiếu phải đến từ một nguồn mà bạn tin cậy; nếu không, việc khớp giá trị không chứng minh được gì. Chữ ký trên checksum chứng minh checksum đó đến từ người đang giữ key, sau khi bạn đã xác minh key thực sự thuộc về nhà phát hành. Cả checksum lẫn chữ ký đều không cho biết điều gì đã xảy ra trước khi artifact được tạo. Nếu build machine của nhà phát hành bị compromise, binary độc hại vẫn được tạo checksum và ký giống hệt binary sạch, nên mọi bước kiểm tra downstream đều thành công. Điều tương tự cũng xảy ra nếu maintainer build từ một working tree chưa từng được push.
Đó là khoảng trống cần lưu ý. Bạn có thể đọc source, xác minh chữ ký, xác minh checksum, nhưng vẫn đang chạy code chưa từng xuất hiện trong repository. Vì vậy, khả năng tái lập là một vấn đề khác với xác minh file tải xuống bằng checksum đã được công bố. Checksum bảo vệ quá trình truyền file. Việc rebuild bảo vệ mọi thứ xảy ra trước quá trình truyền.
Đây không phải là một cuộc tấn công mang tính giả thuyết. Vụ compromise SolarWinds Orion năm 2020 nằm đúng ở vị trí này: build system tạo ra các artifact đã được ký nhưng không tương ứng với source mà bất kỳ ai đã review. Mọi bước kiểm tra chữ ký đều thành công, vì chữ ký chỉ bắt đầu từ artifact.
Bản build có tính tái lập không chứng minh được điều gì
Đây là phần thường bị quảng bá quá mức, vì vậy cần nêu rõ các giới hạn.
- Bản build không chứng minh source an toàn. Một backdoor được commit công khai vẫn build tái lập được, và mọi bên build lại đều xác nhận kết quả đó vì tất cả đã build cùng một source độc hại. Tính tái lập chuyển mục tiêu kiểm tra sang source tree. Vẫn cần có người đọc source tree đó. Vì vậy, chính sách review, bao gồm các chính sách về code có AI hỗ trợ trong các dự án open source, vẫn là một biện pháp kiểm soát riêng.
- Bản build không chứng minh input của bạn an toàn. Dependencies là một phần của nội dung được build. Một package độc hại được resolve tại thời điểm build sẽ được compile vào artifact, và mọi bên build lại resolve cùng dependency đó sẽ cho cùng kết quả với bạn. Đó là cách một cuộc tấn công supply chain của npm xâm nhập server, và bản build tái lập sẽ tái tạo cuộc tấn công đó một cách chính xác.
- Bản build không chứng minh toolchain trung thực. Nếu compiler bị compromise, mọi bên build lại dùng compiler đó sẽ tạo ra cùng output đã bị compromise, và mọi kết quả kiểm tra đều khớp nhau. Tính tái lập làm tăng chi phí thực hiện kiểu tấn công này. Nó không phát hiện được cuộc tấn công.
- Bản build không cho biết gì về các lỗ hổng. Một library cũ được tái tạo giống từng bit vẫn là library cũ với các lỗi đã được công bố. Vì vậy, hãy tiếp tục kiểm tra server để tìm các CVE đã biết theo lịch riêng.
Tính tái lập loại bỏ một vị trí tấn công cụ thể: build machine và toàn bộ đường đi từ source đến binary. Khi một package chưa có tính tái lập, không ai bên ngoài publisher có thể kiểm tra đường đi đó.
Vì sao cùng một source lại tạo ra các byte khác nhau
Hầu hết phần mềm không có khả năng build reproducible theo mặc định. Nguyên nhân thường rất đơn giản. Compiler và định dạng archive ghi lại các thông tin về máy đã chạy chúng.
- Một timestamp. Các định dạng
tar,arvàziplưu thời gian sửa đổi của file, nên build vào giây khác sẽ làm file output thay đổi. - Một path. Thông tin debug lưu thư mục build tuyệt đối, nên build trong
/home/alice/srcvà build trong/build/pkgsẽ khác nhau dù code giống hệt nhau. - Một thứ tự. Khi đọc một directory, hệ thống trả về các entry theo thứ tự của filesystem, nên dòng lệnh link hoặc thứ tự member trong archive có thể thay đổi giữa các máy.
- Một danh tính. Build script có thể nhúng username, hostname hoặc locale của người thực hiện build.
- Một quyết định tại thời điểm build. Việc phát hiện CPU feature hoặc seed ngẫu nhiên khiến output phụ thuộc vào máy thay vì source.
Bạn có thể quan sát nguyên nhân đầu tiên xảy ra trong khoảng 10 giây:
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.tarHai hash khác nhau vì tar header lưu thời gian sửa đổi của a.txt, và việc ghi lại file đã đẩy thời gian đó lên 2 giây. Nội dung giống nhau từng byte. Cố định metadata sẽ khắc phục vấn đề:
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.tarBây giờ hai hash giống nhau vì không có field nào trong archive header lấy từ trạng thái hiện tại của máy. --sort=name cố định thứ tự, --mtime cố định clock, còn các ownership flag ngăn user id của bạn được ghi vào archive.
Đọc khác biệt bằng diffoscope
Khi hai bản build khác nhau, sha256sum chỉ cho biết chúng khác nhau, không cho biết thêm điều gì. diffoscope được dùng để giải thích nguyên nhân theo cách con người có thể đọc. Công cụ này giải nén đệ quy cả hai phía, chuyển các định dạng nhị phân thành văn bản rồi so sánh phần văn bản đó. Công cụ hỗ trợ các package Debian, binary ELF, archive tar và ZIP, PDF, database SQLite cùng hàng chục định dạng khác.
sudo apt install -y diffoscope
diffoscope one.tar two.tarVới cặp tar ở trên, report khá ngắn. Sau khi rút gọn, report trông như sau:
--- 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Đó là toàn bộ chẩn đoán: kích thước giống nhau, path giống nhau, quyền giống nhau nhưng thời gian sửa đổi khác nhau. Một package thực tế sẽ tạo report dài hơn nhiều, vì vậy hãy ghi report ra file rồi mở bằng trình duyệt:
diffoscope --html report.html build1.changes build2.changesdiffoscope thoát với mã 0 khi các đầu vào giống hệt nhau, mã 1 khi chúng khác nhau và mã 2 khi gặp lỗi. Vì vậy, công cụ này có thể đưa thẳng vào một CI job mà không cần wrapper script. Trên một VPS nhỏ, hãy cài diffoscope-minimal thay vì diffoscope: package đầy đủ kéo theo một tập lớn các helper xử lý định dạng mà bạn có thể sẽ không bao giờ gọi.
SOURCE_DATE_EPOCH khắc phục điều gì và giới hạn ở đâu
SOURCE_DATE_EPOCH là một biến môi trường chứa một số duy nhất: thời điểm sửa đổi cuối cùng của source, tính bằng số giây kể từ ngày 1 tháng 1 năm 1970 theo UTC. Build tool hỗ trợ biến này sẽ dùng số đó ở mọi nơi mà nếu không sẽ phải lấy thời gian hiện tại từ hệ điều hành. Hãy lấy giá trị từ version control để giá trị này đi theo source thay vì đi theo lần build:
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)Trong Debian package, debhelper tự export biến này từ changelog. Cách đặt thủ công trong debian/rules như sau:
export SOURCE_DATE_EPOCH ?= $(shell dpkg-parsechangelog -STimestamp)Mỗi tool hỗ trợ theo cách riêng, không phải hỗ trợ toàn hệ thống. cmake 3.8 trở lên, gcc 7 trở lên, rpm trên 4.13 và Docker buildx 0.10 trở lên đều đọc biến này. Script của bạn thì không, trừ khi bạn tự viết để hỗ trợ nó. Nếu một script gọi date, hãy truyền biến này cho nó:
BUILD_DATE="$(date --utc --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y-%m-%d)"Khi triển khai, có một quy tắc quan trọng. Nếu biến đã được đặt, giá trị đó chính là thời gian hiện tại đối với build của bạn. Vì vậy, hãy dùng nó ở mọi nơi mà bạn lẽ ra sẽ đọc clock, đồng thời giữ nguyên biến cho mọi process mà build khởi chạy. Đặc tả nêu rõ: "Build processes MUST NOT unset this variable for child processes if it is already present."
Container image cũng gặp vấn đề tương tự, nhưng thông qua một lớp wrapper khác. Docker buildx 0.10 trở lên truyền SOURCE_DATE_EPOCH từ shell của bạn vào build dưới dạng build argument. Timestamp của các file bên trong layer cần exporter viết lại. BuildKit đã bổ sung tính năng này trong 0.13, và dạng được tài liệu hóa sẽ đẩy kết quả lên 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 .Kiểm thử bản build của bạn bằng reprotest
reprotest build cùng một source tree 2 lần và cố ý thay đổi môi trường giữa 2 lần build, sau đó so sánh kết quả. Các biến thể này là mục đích chính của công cụ. Mặc định, công cụ thay đổi các biến môi trường, đường dẫn build, phiên bản kernel, cơ chế ngẫu nhiên hóa không gian địa chỉ, số CPU, thời gian, user và group, thứ tự file, domain và hostname, thư mục home, locale, đường dẫn executable, timezone và umask.
sudo apt install -y reprotest
reprotest . -- nullMọi nội dung sau -- đều dùng để chọn backend cho môi trường build, còn null có nghĩa là hệ thống bạn đang sử dụng. Thêm -vv -d để giữ lại các thư mục tạm nhằm kiểm tra, như trong reprotest . -vv -- null -d. Dùng reprotest auto -- null để reprotest tự xác định loại source tree đang được kiểm tra.
Một số biến thể cần privilege hoặc package bổ sung. Chúng sẽ báo lỗi rõ ràng khi không thể chạy. Hãy tắt các biến thể đó thay vì chạy toàn bộ dưới quyền root:
reprotest --vary=-user_group,-domain_host,-fileordering auto -- nullMọi vấn đề reprotest báo cáo đều là những gì một rebuilder sẽ phát hiện sau đó và công khai, kèm theo tên project của bạn.
Ý nghĩa của kết luận từ rebuilder
Rebuilder là một máy không thuộc về bên phát hành. Máy này lấy source đã được phát hành và build environment đã được ghi lại, build lại package, rồi so sánh output của chính nó với artifact trong archive. Kết luận chỉ có giá trị vì máy đó độc lập.
Debian ghi lại environment trong file .buildinfo do dpkg-buildpackage ghi cạnh .deb. Các field mới là phần đáng chú ý. Installed-Build-Depends liệt kê mọi package đã cài có thể ảnh hưởng đến quá trình build, kèm version chính xác. Build-Path ghi lại nơi quá trình build đã chạy. Environment ghi lại các biến môi trường được biết là có ảnh hưởng. Checksums-Sha256 bao quát các output. File này là công thức cho lần thử thứ hai:
sudo apt install -y devscripts mmdebstrap
debrebuild --buildresult=./artifacts --builder=mmdebstrap hello_2.10-2_amd64.buildinfodebrebuild đọc buildinfo và lấy đúng các version dependency được nêu trong đó từ snapshot.debian.org, nên lần rebuild hôm nay có thể dùng các version package từng tồn tại vào ngày build ban đầu. Builder mmdebstrap không cần thiết lập chroot và không cần quyền superuser. Dùng diffoscope để so sánh các artifact mà nó tạo ra với bản copy trong archive.
Arch Linux chạy rebuilderd liên tục và công bố các kết luận:
rebuildctl -H https://reproducible.archlinux.org pkgs ls --name rebuilderdCác status là GOOD, BAD và UNKWN, nhưng cách hiểu đơn giản về từng status đều sai theo một hướng khác nhau. GOOD có nghĩa là một bên độc lập tạo ra đúng cùng một byte, đây là khẳng định mạnh về quá trình build nhưng hoàn toàn không khẳng định gì về source. BAD hầu như không bao giờ là một cuộc tấn công: nguyên nhân thường gặp là timestamp hoặc path mà quá trình đóng gói chưa cố định, vì vậy rebuilderd có thể đính kèm báo cáo diffoscope vào lỗi. UNKWN có nghĩa là chưa có ai kiểm thử package đó, và package chưa được kiểm thử không phải là package đạt.
Vì vậy, quy tắc vận hành rất ngắn gọn. Kết luận BAD là lý do để đọc báo cáo. Nếu báo cáo cho thấy timestamp, build path hoặc thứ tự member, hãy mở packaging bug. Nếu báo cáo cho thấy executable code khác nhau mà không có lời giải thích như trên, hãy dừng triển khai bản build đó và chuyển việc xử lý lên cấp cao hơn.
Debian hiện có khả năng tái lập đến mức nào?
Dữ liệu đằng sau biểu đồ này
[
{
"label": "unstable",
"percent_reproducible": 94.8,
"tested_count": "41,490"
},
{
"label": "forky",
"percent_reproducible": 95.7,
"tested_count": "39,529"
},
{
"label": "experimental",
"percent_reproducible": 72.5,
"tested_count": "619"
}
]Vào 2026-10-15, nhánh unstable trên amd64 đạt 94.8% gói có thể tái lập, trên tổng số 41,490 gói đã được kiểm thử. Experimental đạt 72.5%, trên một mẫu nhỏ hơn nhiều và mới hơn nhiều gồm 619 gói. Đây là điều có thể dự đoán ở các gói mà chưa ai sửa xong khả năng tái lập.
Các số liệu này lấy từ trang Debian trên tests.reproducible-builds.org, được đọc vào 2026-10-15, khi trang hiển thị dòng "Last update: 2026-10-15 13:02 UTC". Các số liệu sẽ thay đổi. Hãy đọc tracker thay vì trích nguyên đoạn này sau sáu tháng nữa.
Có một điểm cần lưu ý quan trọng hơn phần trăm. Framework này build mỗi gói 2 lần trên phần cứng riêng, thay đổi môi trường giữa 2 lần build, rồi so sánh 2 kết quả của chính nó. Nó đo xem một gói có thể build theo cách tái lập hay không. Đây không phải là kiểm tra xem .deb đang nằm trong archive có khớp hay không. Việc đó do một rebuilder thực hiện bằng cách so sánh với artifact đã được publish. Cả 2 số liệu đều hữu ích. Chúng trả lời 2 câu hỏi khác nhau, nhưng nhiều người lại trích số liệu đầu tiên như thể đó là số liệu thứ hai.
Việc cần làm trên server của bạn
Bạn không cần rebuild cả một distribution. Những phần có thể áp dụng cho một server thông thường ít hơn và không tốn nhiều chi phí.
- Pin toolchain. Base image được tham chiếu bằng tag có thể thay đổi mà không cảnh báo. Hãy tham chiếu image bằng digest và ghi lại digest cùng với release.
- Ghi lại các input. Lưu lockfile, image digest và phiên bản compiler cùng với artifact. Một build có môi trường không thể tái tạo thì không thể rebuild, nên cũng không thể kiểm tra.
- Build hai lần trong CI và fail job khi output khác nhau. Việc này chỉ tốn thêm một lần build, đồng thời phát hiện nondeterminism ngay khi có người đưa nó vào, thay vì một năm sau trong lúc xử lý incident.
- Loại bỏ các path mà compiler nhúng vào. Với Go,
go build -trimpath -buildvcs=falseloại bỏ build directory và version control stamp, còngo version -m ./appin ra chính xác những gì đã được đưa vào binary. - Lưu hash của artifact đã deploy. Khi cần biết binary đang chạy có tương ứng với một source revision hay không, record này là thứ duy nhất có thể trả lời.
CI check gồm bốn dòng:
set -eu
./build.sh && mv dist/app app.1
./build.sh && mv dist/app app.2
diffoscope --text - app.1 app.2diffoscope trả về mã thoát khác 0 khi hai artifact khác nhau, nên job tự fail và để lại phần giải thích dễ đọc trong log. Đó là toàn bộ ý tưởng, được thu gọn cho một repository: tuyên bố rằng một binary được tạo từ một source tree phải là điều mà một máy thứ hai có thể kiểm tra.
FAQ
Một bản build có thể tái lập có nghĩa là phần mềm an toàn không?
Không. Nó chỉ chứng minh binary tương ứng với source, không chứng minh điều gì khác. Một backdoor được commit vào source tree công khai vẫn build tái lập được, và mọi bên rebuild đều xác nhận điều đó vì họ đã build cùng source độc hại. Một package có CVE đã biết vẫn tái lập hoàn toàn và vẫn dễ bị tấn công. Tính tái lập loại bỏ một vị trí mà attacker có thể tấn công: build machine và đường đi từ source đến binary. Đọc source và theo dõi lỗ hổng là các công việc riêng mà tính tái lập không thể làm thay bạn.
Vì sao 2 bản build của tôi khác nhau khi source không thay đổi?
Gần như luôn là do timestamp, path hoặc thứ tự. Các format archive như tar và zip lưu thời gian sửa đổi của file, nên checkout được tạo ở một giây khác sẽ tạo ra các byte khác nhau. Debug information ghi lại build directory tuyệt đối, nên /home/alice/src và /build/pkg tạo ra các binary khác nhau từ cùng một code. Việc đọc directory trả về các entry theo thứ tự của filesystem, nên các object file trên link line có thể được sắp xếp khác nhau trên máy khác. Chạy diffoscope build1 build2 để report cho biết nguyên nhân là gì, thay vì để bạn phải đoán.
SOURCE_DATE_EPOCH là gì và tôi có phải đặt nó không?
Đây là một environment variable tiêu chuẩn chứa 1 số: thời gian sửa đổi cuối cùng của source, tính bằng giây kể từ 1 January 1970 UTC. Các tool hỗ trợ biến này sẽ dùng giá trị đó ở mọi nơi mà nếu không chúng sẽ đọc system clock. Đặt giá trị này từ version control bằng export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct). Biến này không tự động được đặt và không phải cách khắc phục chung. Chỉ các tool có implement biến này mới đọc nó. Các build script của bạn phải tự đọc biến này, nên script gọi date vẫn tiếp tục ghi timestamp hiện tại cho đến khi bạn sửa script.
Tôi nên làm gì khi rebuilder báo BAD?
Đọc report trước khi làm bất kỳ việc gì khác. Kết luận BAD có nghĩa là một lần rebuild độc lập không tạo ra cùng các byte, và nguyên nhân thông thường là tính không xác định trong packaging chứ không phải một cuộc tấn công. rebuilderd có thể tạo report diffoscope chính vì lý do này. Nếu khác biệt nằm ở timestamp, build path hoặc thứ tự file, đó là packaging bug đáng để tạo issue. Nếu khác biệt nằm trong executable code mà không có cách giải thích như trên, hãy dừng deploy build đó, giữ lại các artifact và chuyển vụ việc cho publisher.