Lật mặt sau một gói bánh, bạn thấy cái nhãn thành phần: không chỉ "bột, đường" như quảng cáo ngoài mặt, mà cả danh sách dài những phụ gia, chất bảo quản, dẫn xuất mà bạn chẳng chọn nhưng vẫn ăn vào. Khi có đợt thu hồi vì một chất nào đó nhiễm bẩn, người ta không đem từng gói đi xét nghiệm hoá học — họ đọc nhãn rồi lôi đúng những lô có chất ấy xuống kệ. Kèm theo nhãn còn có hồ sơ truy xuất nguồn gốc: lô này làm ở nhà máy nào, ngày nào, từ mẻ nguyên liệu số mấy. SBOM và provenance của một ảnh Docker chính là hai tờ giấy đó — nhãn thành phần và hồ sơ lô hàng — và bài này đo xem chúng đáng giá bao nhiêu.
Có một CVE mới trong urllib3. Câu hỏi đầu tiên là: ảnh nào của chúng ta có nó, và phiên bản nào?
Không có SBOM thì cách trả lời là chạy từng ảnh lên rồi pip list — mà nhiều ảnh không có shell, và ảnh cũ có thể đã không còn tag. Có SBOM thì đó là một lệnh đọc metadata.
Bật lên
docker buildx build --sbom=true --provenance=mode=max --tag registry/app:v1 --push .
Kết quả là một index có thêm một manifest đặc biệt:
linux/arm64 anh manifest 2002 byte
unknown/unknown dinh kem manifest 1112 byte
Mục unknown/unknown là chỗ chứa SBOM và provenance — cùng chỗ mà phần 20 đã thấy khi dựng ảnh đa kiến trúc.
Chi phí
| Dung lượng | |
|---|---|
| Layer của ảnh | 51,97 MB |
| SBOM + provenance | 2 117,9 KB |
Khoảng 3,98%. Với ảnh nhỏ hơn thì tỉ lệ cao hơn, vì SBOM tỉ lệ với số gói chứ không tỉ lệ với dung lượng ảnh.
Về thời gian build, tôi không đo được con số sạch: hai lần build của tôi chia sẻ cache và blob nên so sánh trực tiếp không có nghĩa. Phần thêm vào là một lần quét filesystem của ảnh bằng buildkit-syft-scanner, chạy song song với các bước khác.
Trong SBOM có gì
dinh dang: SPDX-2.3 | 130 thanh phan
goi he dieu hanh (deb) 109
goi Python (pypi) 13
khac 8
Phần Python đầy đủ, kể cả phụ thuộc bắc cầu:
blinker 1.9.0 markupsafe 3.0.3
certifi 2026.7.22 pip 25.0.1
charset-normalizer 3.5.1 requests 2.32.3
click 8.4.2 urllib3 2.7.0
flask 3.0.3 werkzeug 3.1.8
idna 3.19 itsdangerous 2.2.0
jinja2 3.1.6
requirements.txt của tôi chỉ có hai dòng: flask và requests. SBOM liệt kê mười ba — mười một cái còn lại là phụ thuộc bắc cầu mà bạn không viết ra nhưng vẫn phải chịu trách nhiệm khi chúng có lỗ hổng. Đúng như cái nhãn: bạn mua "bột và đường", nhưng ăn vào cả mười một thứ phụ gia đi kèm.
Và trả lời câu hỏi ở đầu bài:
cau hoi: "anh nao dung urllib3?" -> co, phien ban 2.7.0
purl : cpe:2.3:a:python:urllib3:2.7.0:*:*:*:*:*:*:*
Một lệnh, không cần chạy container, không cần đọc Dockerfile, không cần đoán.
Định danh purl/cpe cũng quan trọng: đó là dạng chuẩn để công cụ quét đối chiếu với cơ sở dữ liệu CVE. SBOM không nói cho bạn biết cái gì có lỗ hổng — nó nói cho bạn biết có gì, rồi công cụ khác đối chiếu.
Trong provenance có gì
Provenance theo chuẩn SLSA ghi lại ảnh được dựng như thế nào:
buildType : https://github.com/moby/buildkit/.../slsa-definitions
builderPlatform : linux/arm64
dockerfileVersion : 1.26.0
startedOn : 2026-08-25T07:25:31.116342379Z
finishedOn : 2026-08-25T07:25:47.081647053Z
invocationId : v9lrrad4frp9aafib20gvykvl
Và phần giá trị nhất — nguyên liệu đầu vào kèm digest:
pkg:docker/python@3.12-slim?platform=linux/arm64
sha256:3ecf5ebe01fef4b6e81be345...
pkg:docker/docker/buildkit-syft-scanner@stable-1
sha256:ae4f3b554449e7e25548e7d8...
Đây là câu trả lời cho một vấn đề đã nêu ở phần 4: FROM python:3.12-slim hôm nay và sáu tháng sau là hai nội dung khác nhau, vì tag có thể bị đẩy đè. Provenance ghi lại chính xác digest đã được dùng, nên bạn dựng lại được đúng thứ cũ kể cả khi tag đã trỏ đi nơi khác. Chính là dòng "làm từ mẻ nguyên liệu số mấy" trên hồ sơ lô hàng.
Cùng với externalParameters:
{"configSource": {"path": "Dockerfile"},
"request": {"frontend": "dockerfile.v0", "locals": [{"name": "context"}]}}
Ba mức provenance
| Ghi gì | |
|---|---|
mode=min (mặc định khi push) |
thông tin cơ bản: thời gian, nền tảng, digest nguyên liệu |
mode=max |
thêm nội dung Dockerfile, tham số build, nguồn |
--provenance=false |
tắt hẳn |
mode=max ghi cả nội dung Dockerfile vào attestation. Tiện cho việc truy vết, nhưng cần nhớ: nếu Dockerfile của bạn chứa thứ gì nhạy cảm thì nó đi cùng ảnh. Không phải bí mật — như phần 21 đã đo, --mount=type=secret vẫn sạch — nhưng URL nội bộ, tên máy chủ, cấu trúc hạ tầng thì có.
Khi nào tắt đi
BuildKit bật provenance mặc định khi --push, và một số registry cũ không hiểu định dạng index có attestation. Triệu chứng là lỗi khó hiểu lúc đẩy, hoặc ảnh đẩy lên nhưng công cụ khác không kéo được.
docker buildx build --provenance=false --sbom=false ...
Ngoài ra, nếu bạn dùng --load vào daemon cục bộ thì attestation không có tác dụng gì — nó chỉ có ý nghĩa khi ảnh nằm trong registry.
Dùng thật thì làm gì với nó
SBOM chỉ có giá trị khi có ai đó đọc. Ba việc thực tế:
Trả lời câu hỏi "ai bị ảnh hưởng". Lưu SBOM của mọi ảnh đã phát hành vào một chỗ tìm kiếm được. Khi có CVE, bạn grep thay vì điều tra.
Đối chiếu với cơ sở dữ liệu lỗ hổng. grype và trivy đọc SBOM trực tiếp, nhanh hơn nhiều so với quét lại cả ảnh:
docker buildx imagetools inspect registry/app:v1 --format '{{ json .SBOM }}' > sbom.json
grype sbom:sbom.json
Ghim lại nguyên liệu. Lấy digest image nền từ provenance rồi ghi thẳng vào Dockerfile, để lần build sau cho ra đúng thứ cũ.
Ở phần 10 tôi không quét được CVE vì docker scout đòi đăng nhập tài khoản Docker. Với SBOM thì grype và trivy làm được mà không cần tài khoản nào — đó là lý do thực tế nhất để bật nó.
Tự kiểm ảnh của bạn
Xem ảnh của bạn có SBOM chưa:
docker buildx imagetools inspect <anh> --format '{{ json .SBOM }}' | head -c 200
Rỗng nghĩa là chưa có. Dựng lại với --sbom=true rồi đếm thành phần:
docker buildx imagetools inspect <anh> --format '{{ json .SBOM }}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); s=d.get('SPDX') or list(d.values())[0]['SPDX']; print(len(s['packages']), 'thanh phan')"
Con số đó thường lớn hơn nhiều so với số dòng trong requirements.txt hay package.json của bạn — và chênh lệch chính là phần bạn không biết mình đang chạy.
Mẫu số chung
Bài học đầu tiên, chính là cái nhãn thành phần hai dòng ngoài bao bì mà mười ba dòng bên trong: thứ bạn thật sự phải chịu trách nhiệm không phải danh sách mình khai ra, mà là toàn bộ bao đóng bắc cầu của nó — và đúng cái phần bạn không tự viết ra lại là chỗ bạn không biết mình đang chạy. Hai dòng flask và requests kéo theo mười một gói nữa; CVE nằm ở urllib3 — thứ bạn chưa từng gõ tên. Cùng cái bẫy "phụ thuộc bắc cầu là của bạn" ở khắp nơi: trong Java, một spring-boot-starter đơn lẻ lôi về mấy chục JAR, và Log4Shell dạy cả ngành rằng một thư viện bắc cầu cũng đủ làm thủng hệ thống; trong Go, go.mod bạn viết ngắn nhưng go.sum mới liệt kê trọn đồ thị thật sự được biên dịch vào; trong node_modules, ba dependency khai báo nở ra hàng trăm gói; trong CSDL, một khoá ngoại hay một view kéo theo cả chuỗi bảng mà một lệnh DELETE tưởng gọn lại chạm tới. Nguyên tắc: bề mặt thật của một hệ thống là bao đóng bắc cầu của những gì nó khai báo, không phải bản khai — hãy liệt kê trọn cái bao đóng ấy thành một danh mục tra được, để khi có sự cố bạn đọc nhãn chứ không đi xét nghiệm từng gói.
Điều thứ hai, đọc từ "một lệnh grep thay vì mở từng container": hãy ghi lại câu trả lời ngay lúc nó còn rẻ (lúc build, khi mọi thứ đang bình yên), để câu hỏi đắt về sau (lúc có sự cố, khi đang gấp) tụt xuống thành một phép tra cứu. Không có SBOM, trả lời "ảnh nào dính urllib3" là đi điều tra từng ảnh; có SBOM, nó là một lần grep. Cùng nguyên lý "ghi trước, tra sau" ở khắp nơi: một chỉ mục CSDL biến quét toàn bảng O(n) thành tra cứu tức thì; một nhật ký kiểm toán trả lời "ai đổi dòng này" mà không phải dựng lại lịch sử; git blame thay cho ngồi đoán; provenance ghim digest nguyên liệu để build lặp lại được thay vì mò lại "hồi đó dùng bản nào". Nguyên tắc: mỗi khi bạn đang nắm một dữ kiện mà về sau sẽ rất tốn công tìm lại, hãy trả cái giá nhỏ để lưu nó lại có cấu trúc ngay bây giờ — vì chi phí ghi là cố định và trả lúc rảnh, còn chi phí điều tra lại thì luôn ập đến đúng lúc bạn ít thời gian nhất.
Phần sau đo việc chia tầng để build song song, và chỗ nào tách tầng thật sự có tác dụng.