Hãy nghĩ về container của bạn như một thùng hàng cập cảng. Phần lớn thứ bên trong không phải do bạn sản xuất — base image, thư viện hệ thống, thư viện ngôn ngữ đều là hàng người khác chất vào. Ở cảng có ba kiểu kiểm tra, và cái hay là mỗi cái bắt một loại rủi ro khác hẳn: hải quan dò tờ khai theo danh sách hàng bị thu hồi (bắt món xấu đã biết, nhưng không mở hộp ra tự phán xét); chó nghiệp vụ đánh hơi hàng cấm (bắt thứ có "mùi" đặc trưng, bỏ sót thứ không mùi); và cái niêm phong trên cửa thùng (chứng minh thùng không bị tráo giữa đường, nhưng chẳng nói gì về việc hàng bên trong vốn đã có vấn đề hay chưa). Và không một kiểu nào soi cái bạn tự tay đóng gói. Chuỗi cung ứng của một container đúng là ba thứ bạn không viết như vậy. Bài này đo xem mỗi lớp phòng thủ thật sự bắt được cái gì.

Chọn base image là quyết định bảo mật lớn nhất

Quét bằng Trivy, chỉ đếm CRITICAL và HIGH:

Base image CRITICAL HIGH Số gói dính Dung lượng
python:3.12-alpine 0 2 2 79,5 MB
python:3.9-alpine3.16 (từ 2022) 0 11 8 73 MB
python:3.12-slim 3 16 11 205 MB
python:3.12 56 362 78 1,62 GB

Bản đầy đủ có 56 lỗ hổng nghiêm trọng và 362 lỗ hổng cao. Bản alpine cùng phiên bản Python có 0 và 2.

Và dòng thứ hai đáng suy nghĩ hơn cả: một image alpine từ năm 2022, đã bốn năm không cập nhật, vẫn ít lỗ hổng hơn image Debian đầy đủ mới nhất. Bề mặt tấn công quan trọng hơn tuổi.

Lý do đơn giản: python:3.12 chứa trình biên dịch, perl, công cụ mạng, thư viện đồ hoạ — hàng trăm gói mà ứng dụng của bạn không dùng nhưng vẫn phải chịu trách nhiệm. Đây là lập luận bảo mật của phần 10, giờ có số kèm theo.

Cách rẻ nhất để giảm 56 lỗ hổng nghiêm trọng xuống 0 là đổi một dòng FROM.

Máy quét bắt được gì

Tôi dựng một image chứa mã của chính mình: hai lỗi lập trình cố ý (ghép chuỗi thẳng vào shell, không kiểm tra đường dẫn) và ba bí mật ghi cứng.

Quét lỗ hổng (--scanners vuln) tìm ra CVE của libssl3 và các gói hệ thống. Nó không nói một chữ nào về mã của tôi hay ba khoá kia — đúng như thiết kế: nó so danh sách gói với cơ sở dữ liệu CVE, không đọc mã.

Quét bí mật (--scanners secret) thì tìm được:

/app.py (secrets)
Total: 2 (CRITICAL: 2)

CRITICAL: AWS (aws-access-key-id)
  /app.py:2 (added by 'COPY app.py /app.py # buildkit')
CRITICAL: GitHub (github-pat)

Hai trong ba. Khoá bí mật AWS 40 ký tự lọt lưới — nó chỉ là một chuỗi ngẫu nhiên, không có tiền tố nhận dạng như AKIA hay ghp_.

Chi tiết (added by 'COPY app.py /app.py') rất hữu ích: nó chỉ thẳng lớp nào đưa bí mật vào, kể cả khi lớp sau đã xoá tệp đi — đúng chuyện phần 21 đã đo.

Phép thử đầu tiên của tôi cho kết quả sai

Lần đầu tôi dùng AKIAIOSFODNN7EXAMPLE — khoá mẫu quen thuộc trong tài liệu AWS. Máy quét báo 0 bí mật, và tôi suýt kết luận rằng nó không nhìn vào mã ứng dụng.

Đó là khoá ví dụ được đưa vào danh sách bỏ qua. Đổi sang một chuỗi có dạng thật thì nó tìm ra ngay. Nếu bạn kiểm tra máy quét bằng khoá mẫu, bạn sẽ tin là nó hỏng — hoặc tệ hơn, nếu bạn kiểm tra thứ khác bằng khoá mẫu và nó "sạch", bạn sẽ tin là mình an toàn.

Phép thử phải thất bại được thì kết quả thành công mới có nghĩa.

Ghim digest ngăn được chuyện gì

Nhãn có thể bị đẩy đè. Tôi dựng một registry riêng để đo:

digest ban dau: sha256:0791b5c86634...
noi dung: phien ban LANH

(day len mot image KHAC voi CUNG mot nhan 1.0)

keo lai bang NHAN   -> phien ban BI THAY DOI
keo lai bang DIGEST -> phien ban LANH

Cùng một chuỗi ung-dung:1.0, hai nội dung khác nhau, không có cảnh báo nào. Chỉ digest là bất biến — đúng như phần 4 đã đo, giờ nhìn từ góc bảo mật.

FROM python:3.12-alpine@sha256:0791b5c86634a27ca2c8123bb305c1a8150afac05ffa96cce3527ab2e792d853

Đổi lại là bạn phải tự cập nhật digest khi muốn lên bản mới. Đó là chi phí đáng trả cho image nền của môi trường thật; với môi trường phát triển thì nhãn vẫn tiện hơn.

Ba lớp, ba loại rủi ro khác nhau

Lớp Bắt được Không bắt được
Quét lỗ hổng CVE của gói hệ thống và thư viện bí mật, lỗi trong mã bạn viết
Quét bí mật khoá có tiền tố nhận dạng, kèm tên lớp khoá không có dạng đặc trưng, khoá ví dụ
Ghim digest nhãn bị đẩy đè, registry bị chiếm image gốc vốn đã có lỗ hổng

Không lớp nào thay được lớp nào, và không lớp nào đọc mã của bạn. Lỗi ghép chuỗi vào shell trong ví dụ của tôi lọt qua cả ba.

Đưa vào quy trình

# .github/workflows/quet.yml
- name: Quet image
  run: |
    docker build -t ung-dung:${{ github.sha }} .
    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
      aquasec/trivy:latest image \
      --scanners vuln,secret \
      --severity CRITICAL,HIGH \
      --exit-code 1 \
      ung-dung:${{ github.sha }}

Cờ --exit-code 1 là thứ biến phép quét thành hàng rào thật: build hỏng thay vì in ra một bảng mà không ai đọc.

Ba lưu ý khi bật:

  • Bật ở nhánh mới trước. Quét lần đầu trên một dự án cũ thường ra hàng trăm dòng, và nếu nó chặn ngay thì đội của bạn sẽ tắt nó đi trong tuần đầu.
  • docker scout cần tài khoản Docker, Trivy thì không. Tôi chuyển sang Trivy giữa chừng vì scout yêu cầu đăng nhập, và trong CI thì đó là thêm một bí mật phải quản lý.
  • Số lỗ hổng không phải điểm số. Một CVE trong thư viện bạn thật sự gọi tới nguy hiểm hơn hai mươi CVE trong gói chỉ nằm đó. --severity giúp lọc, nhưng đọc vẫn phải đọc.

Muốn thấy ngay bức tranh của chính image mình, quét nó không cần cài gì:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy:latest image --scanners vuln,secret \
  --severity CRITICAL,HIGH --quiet <image-cua-ban>

Rồi đổi một dòng FROM sang bản -alpine hoặc -slim và quét lại. Trong phép đo của tôi, đúng một dòng đó đưa 56 lỗ hổng nghiêm trọng về 0.

Mẫu số chung

Bài học lớn nhất: phần lớn bề mặt tấn công của bạn nằm ở thứ bạn không viết, nên đòn bẩy mạnh nhất không phải vá cái đang có mà là không mang vào cái không cần. Đổi python:3.12 sang bản alpine đưa 56 lỗ hổng nghiêm trọng về 0 — không phải bằng cách sửa lỗ hổng nào, mà bằng cách bỏ đi hàng trăm gói (trình biên dịch, perl, thư viện đồ hoạ) mà ứng dụng chẳng dùng nhưng vẫn phải chịu trách nhiệm. Cùng nguyên lý ấy ở khắp nơi trong bảo mật và cả kỹ thuật nói chung: cây phụ thuộc càng gọn càng ít chỗ hổng, nguyên tắc đặc quyền tối thiểu, cổng đóng theo mặc định, mã chết là nợ chứ không phải tài sản. Nguyên tắc: thứ an toàn nhất là thứ không tồn tại trong ảnh — trước khi hỏi "vá lỗ hổng này thế nào", hỏi "mình có cần thứ mang lỗ hổng này vào không".

Điều thứ hai, một cái bẫy về niềm tin sai: một phép thử không thể thất bại thì kết quả "đạt" của nó vô nghĩa. Tôi suýt tin máy quét mù vì thử bằng AKIAIOSFODNN7EXAMPLE — đúng cái khoá nằm trong danh sách bỏ qua, nên nó không thể báo dương tính. Đây là dạng tự-trấn-an nguy hiểm nhất: một bảng "0 lỗi" từ một công cụ chưa bao giờ được chứng minh là biết kêu. Cùng cái bẫy ở bài test luôn xanh vì thiếu assert, ở cảnh báo được cấu hình sai nên không bao giờ bắn, ở bản sao lưu chưa ai thử phục hồi bao giờ. Và ba lớp phòng thủ ở đây nhắc thêm một vế: mỗi lớp chỉ bắt một loại rủi ro và không lớp nào đọc mã bạn viết — nên "quét sạch" không bao giờ đồng nghĩa "an toàn". Nguyên tắc: trước khi tin một lớp bảo vệ, bắt nó thất bại một lần cho thấy — và biết rõ nó không bảo vệ điều gì cũng quan trọng ngang biết nó bảo vệ điều gì.

Phần cuối của sê-ri là danh sách kiểm trước khi lên sản xuất, gom lại mọi con số đã đo.