Một cái niêm phong bảo hành dán lên chính thân hộp, không dán lên cái nhãn tên ngoài — và đó là toàn bộ khác biệt. Bóc hộp ra thay ruột thì niêm phong rách, ai cũng thấy. Nhưng bóc cái nhãn "Hàng loại 1" dán sang một hộp khác thì niêm phong vẫn nguyên trên hộp cũ, chẳng ai biết. Bảo mật ảnh container xoay quanh đúng phân biệt này: chữ ký chứng nhận nội dung, còn cái thẻ (v1, latest) chỉ là cái nhãn tên mà ai có quyền ghi vào registry đều đổi được. Bài này lùi một bước khỏi việc kiểm soát pod làm gì để kiểm soát cái nằm trong pod, và con số bất ngờ nhất là chữ ký biến mất chỉ vì đổi ruột dưới cùng một cái tên.
Phần 34 đo Pod Security Standards. Ta lùi một bước: kiểm soát cái nằm trong pod.
Quét sáu ảnh
| Ảnh | Cỡ | CRIT | HIGH | Tổng |
|---|---|---|---|---|
registry.k8s.io/pause:3.9 |
0 MB | 0 | 0 | 0 |
distroless/static-debian12:nonroot |
1 MB | 0 | 0 | 0 |
python:3.12-alpine |
18 MB | 0 | 2 | 25 |
nginx:1.27-alpine |
21 MB | 2 | 33 | 107 |
nginx:1.21 (cũ) |
53 MB | 30 | 196 | 811 |
python:3.12 (Debian) |
381 MB | 56 | 365 | 2.889 |
Hai dòng cuối cùng đáng nhìn kỹ nhất. python:3.12 và python:3.12-alpine chạy cùng một Python. Số lỗ hổng: 2.889 so với 25 — ít hơn 115 lần, và không phải sửa một dòng mã nào. Chênh lệch không đến từ Python mà từ mọi thứ đi kèm: trình biên dịch, thư viện phát triển, tiện ích dòng lệnh — hàng trăm gói mà ứng dụng không dùng nhưng vẫn có mặt để bị đếm và để bị khai thác.
nginx:1.21 là bản cũ vài năm: 30 lỗ hổng nghiêm trọng so với 2 của bản mới. Ghim phiên bản mà không bao giờ cập nhật cũng là một rủi ro, không chỉ là sự ổn định.
Nhưng cỡ ảnh không giải thích được hết
python:3.12-alpine 18 MB có 25 lỗ hổng. nginx:1.27-alpine 21 MB có 107 — gấp bốn lần, gần như cùng cỡ. Nên "dùng alpine" không phải một bảo đảm. Nó là một chỉ dấu tốt, không phải một câu trả lời. Cách duy nhất biết là quét:
trivy image --scanners vuln python:3.12-alpine
Và trong CI, cái đáng làm không phải là "chặn khi có lỗ hổng" — mọi ảnh đều có. Cái đáng làm là chặn khi số lỗ hổng nghiêm trọng tăng so với lần trước, hoặc chặn theo danh sách CVE có bản vá sẵn:
trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 <anh>
--ignore-unfixed là cờ quan trọng nhất: lỗ hổng chưa có bản vá thì bạn không làm gì được, và để chúng làm đỏ bảng điều khiển khiến người ta bắt đầu bỏ qua cả bảng.
Chữ ký: ai có, ai không
registry.k8s.io/pause:3.9 CÓ chữ ký
distroless/static-debian12:nonroot CÓ chữ ký, kèm cả SBOM ký theo
nginx:1.27-alpine KHÔNG tìm thấy
python:3.12-alpine KHÔNG tìm thấy
Ảnh của Kubernetes và của Google đều ký bằng cosign, kiểm được ngay:
cosign verify --certificate-identity-regexp '.*' \
--certificate-oidc-issuer-regexp '.*' registry.k8s.io/pause:3.9
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted CA certificates
Ảnh chính thức trên Docker Hub thì không — chúng dùng cơ chế khác (Docker Content Trust), không phải cosign. Nên đừng kết luận "không có chữ ký nghĩa là không đáng tin"; nó nghĩa là bạn không kiểm được bằng công cụ này, và trong một quy trình tự động thì hai điều đó dẫn tới cùng một chỗ.
Một chi tiết nhỏ mà thông báo lỗi tự dạy tôi: hỏi distroless về SLSA provenance thì nó trả lời
none of the attestations matched the predicate type: slsaprovenance,
found: https://spdx.dev/Document
Tức là nó có số liệu chứng thực, chỉ là loại khác — một SBOM định dạng SPDX, đã ký. Thông báo lỗi nói luôn nó có gì.
Chữ ký gắn với digest, không gắn với thẻ
Đây là phép đo quan trọng nhất bài. Tôi ký ảnh của chính mình:
thời gian ký: 0,3 giây
Rồi dựng một ảnh khác và đẩy lên cùng thẻ v1:
digest trước sha256:ce59063d25caa7fba09e08dc3ac3a05f86cd34d...
digest sau sha256:20605e12bcbb5f9f724be72ccddb89cb651f648...
cosign verify -> Error: no signatures found
Cùng một thẻ, và chữ ký biến mất hoàn toàn. Đó chính là điều bạn muốn: chữ ký chứng nhận nội dung, không chứng nhận cái tên — niêm phong dán lên thân hộp, không lên nhãn. Thẻ chỉ là con trỏ, và con trỏ thì ai có quyền ghi vào registry đều đổi được.
Hệ quả trực tiếp cho cách viết manifest:
# đổi được dưới chân bạn
image: cong-ty/api:v1.4.2
# không đổi được
image: cong-ty/api@sha256:ce59063d25caa7fba09e08dc3ac...
Dùng digest là cách duy nhất để "cái tôi đã kiểm" và "cái đang chạy" chắc chắn là một. Đổi lại là bạn phải cập nhật digest mỗi lần phát hành — việc mà bộ cập nhật tự động làm giúp được.
Chặn ảnh không ký ở tầng cụm
Kiểm chữ ký trong CI là chưa đủ: ai đó vẫn kubectl apply được một ảnh chưa qua CI. Chốt chặn thật nằm ở webhook nhận vào — Kyverno, Sigstore Policy Controller, hoặc Connaisseur.
# Kyverno, rút gọn
verifyImages:
- imageReferences: ["cong-ty/*"]
attestors:
- entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
...
Hai lời khuyên từ những gì đo được ở trên:
- Bắt đầu bằng chế độ chỉ cảnh báo. Bật cưỡng chế ngay là chặn luôn mọi ảnh bên thứ ba chưa ký — và đo được ở trên,
nginxvàpythonnằm trong nhóm đó. - Chỉ áp cho ảnh của chính bạn trước.
cong-ty/*ký được vì bạn kiểm soát quy trình dựng;docker.io/*thì không.
Bốn việc theo thứ tự đáng làm
- Đổi ảnh nền sang bản gọn. Rẻ nhất, hiệu quả nhất — đo được 115 lần.
- Quét trong CI, chặn theo
--ignore-unfixed. Chặn cái sửa được, đừng chặn cái không sửa được. - Ghim bằng digest. Không cần công cụ gì, chỉ cần đổi cách viết manifest.
- Ký và kiểm chữ ký. Đáng làm khi ba bước trên đã xong, vì nó cần quy trình chứ không chỉ cần một lệnh.
Thứ tự này quan trọng: ký một ảnh 2.889 lỗ hổng chỉ chứng minh rằng chính bạn là người tạo ra nó.
Mẫu số chung: tin nội dung bất biến, đừng tin cái tên đổi được — và mọi thứ bạn đóng gói là bề mặt tấn công
Bài này gói hai nguyên tắc bảo mật chuỗi cung ứng đúng ở mọi tầng. Thứ nhất: xác thực phải gắn với nội dung bất biến, không gắn với một cái tên đổi được. Digest sha256:... là địa chỉ theo nội dung (content-addressing) — đổi một byte là đổi địa chỉ; còn thẻ v1 là con trỏ, ai ghi được registry đều trỏ nó sang chỗ khác, và chữ ký (gắn với digest) không đi theo. Thứ hai: bề mặt tấn công là mọi thứ bạn đóng gói — 2.889 so với 25 lỗ hổng không đến từ mã của bạn mà từ các gói bạn kéo theo mà không dùng.
Trong thế giới Java và mọi hệ quản lý gói, phân biệt tên-vs-nội-dung này chính là Git: một nhánh (main) là con trỏ di động, một commit SHA là bất biến — "đã kiểm trên main" vô nghĩa nếu main đã dịch, đúng như thẻ v1. Và đây là lý do lockfile tồn tại: go.sum, package-lock.json, <dependency> với integrity hash trong Maven/npm ghim theo hash nội dung, không theo dải phiên bản — để cái bạn build hôm nay và cái CI build tuần sau chắc chắn cùng bytes. Subresource Integrity (integrity="sha384-...") cho <script> là đúng ý tưởng ấy ở trình duyệt.
Còn nguyên tắc bề-mặt-tấn-công: một fat JAR kéo cả trăm dependency transitive là cả trăm CVE tiềm tàng, y như ảnh Debian đầy đủ — lời giải song song là cắt phụ thuộc, dùng ảnh distroless / liên kết tĩnh, chỉ đóng gói cái thật sự chạy.
Mẫu số chung: niềm tin phải neo vào nội dung bất biến (hash/digest), không vào một cái tên có thể bị trỏ lại; và mỗi thứ bạn đóng gói kèm là một thứ có thể bị đếm là lỗ hổng và bị khai thác. Hai câu hỏi đúng: "cái đang chạy có đúng bytes tôi đã kiểm không, hay chỉ đúng cái tên tôi đã kiểm" — dùng digest/lockfile để trả lời; và "tôi có đang mang theo gì mình không dùng không" — cắt nó đi. Với Kubernetes, đó là ảnh gọn + ghim digest + ký; với ứng dụng, đó là lockfile + ít phụ thuộc.
Muốn tự xem có bao nhiêu ảnh trong cụm đang dùng thẻ thay vì digest:
kubectl get pods -A -o json | python3 -c '
import sys, json
the = digest = 0
for p in json.load(sys.stdin)["items"]:
for c in (p["status"].get("containerStatuses") or []):
img = c.get("image", "")
if "@sha256:" in img: digest += 1
else:
the += 1
if ":latest" in img or ":" not in img.rsplit("/", 1)[-1]:
print(" the troi noi:", p["metadata"]["namespace"], img)
print(f"-> {digest} ghim bang digest, {the} dung the")
'
Mọi dòng thẻ trôi nổi là một ảnh có thể đổi nội dung mà không đổi tên. Còn tỷ lệ ở dòng cuối cho biết bạn đang ở đâu trên con đường bốn bước phía trên — với phần lớn cụm, con số đầu tiên là 0.
Phần sau: Gateway API — thứ được dựng để thay Ingress, và cái nó làm được mà Ingress không.