Khi bạn viết import requests, bạn nghĩ mình vừa thêm một thư viện. Thực ra bạn vừa mời cả một đoàn: requests và tất cả những gì nó cần — urllib3, idna, certifi, charset-normalizer — mỗi gói đó lại có thể kéo theo gói khác. Giống như mời một người bạn tới nhà, và họ dẫn theo cả nhóm bạn của họ mà bạn chưa gặp bao giờ; bạn chịu trách nhiệm cho mọi người trong phòng khách, kể cả những người bạn không hề mời. Bài này đo cái đoàn tuỳ tùng đó nguy hiểm cỡ nào: nếu tôi khai hai dependency cũ, thì bao nhiêu lỗ hổng đã biết chui vào dự án, và chúng nằm ở đâu — ở gói tôi chọn, hay ở gói tôi chưa từng nghe tên?

Tôi dựng trong container dùng một lần, quét bằng pip-audit (công cụ đối chiếu với cơ sở dữ liệu CVE của Python), đo 3 lần rồi tự dọn. Không nhắm vào hệ thống của ai khác. Và lần đo đầu tiên của tôi hỏng — tôi kể luôn ở cuối vì nó là một bài học riêng.

Đo 1: hai gói kéo theo bao nhiêu?

Tôi khai đúng hai dependency, cố tình chọn bản cũ có lỗ hổng đã biết:

jinja2==2.11.1
requests==2.19.1

Rồi để pip-audit quét toàn bộ những gì thực sự được cài. Kết quả, giống hệt qua cả 3 lần:

so goi pip-audit thuc su kiem (ke ca bac cau): 7 goi
   jinja2    2.11.1   5 CVE   [TRUC TIEP]
   requests  2.19.1   6 CVE   [TRUC TIEP]
   idna      2.7      4 CVE   [BAC CAU]
   urllib3   1.23    13 CVE   [BAC CAU]
   TONG lo hong ban CU: 28

Hai con số đáng dừng lại. Thứ nhất: tôi khai 2 gói, môi trường thật có 7. Thứ hai, và đây là điểm ngược trực giác: trong 28 lỗ hổng, chỉ 11 nằm ở hai gói tôi chọn (jinja2, requests); 17 nằm ở gói bắc cầu tôi chưa bao giờ chọn. Riêng urllib3 — cái tên nhiều lập trình viên Python còn chẳng gõ bao giờ — mang 13 lỗ hổng, nhiều nhất cả bảng, và nó vào dự án chỉ vì requests cần nó, chấm hết.

Đây là bản chất rủi ro chuỗi cung ứng: bề mặt tấn công của bạn phần lớn nằm ở những gói bạn không trực tiếp quyết định. Bạn đọc kỹ tài liệu của requests, chọn phiên bản cẩn thận — nhưng urllib3 đi kèm thì bạn thường không nhìn tới. Kẻ tấn công thì có nhìn: một CVE trong một gói bắc cầu phổ biến mở cửa vào hàng vạn dự án cùng lúc, vì tất cả đều kéo nó về mà không ai để mắt. Và đó chính là lý do quét phải đi tới tận lá của cây phụ thuộc, không dừng ở danh sách bạn tự viết trong requirements.txt: pip-audit (hay npm audit, osv-scanner cho ngôn ngữ khác) đọc cây đã giải quyết xong — đúng bộ phiên bản thật sự được cài — nên nó thấy cả urllib3 1.23 mà requirements.txt của tôi không hề nhắc tên.

Đo 2: vá là gì, và "độ trễ vá" nằm ở đâu?

"Vá" nghe nặng nề, nhưng với dependency nó thường chỉ là một dòng: nâng phiên bản. Tôi quét lại đúng bộ gói đó sau khi nâng lên bản mới nhất:

phien ban moi: jinja2=3.1.6 requests=2.34.2
TONG lo hong ban MOI: 0

Từ 28 xuống 0. Cái "vá" cho cả 28 lỗ hổng chỉ là bỏ ghim phiên bản cũ và cài bản mới — kể cả những lỗ hổng ở urllib3 và idna, vì nâng requests sẽ kéo theo bản urllib3 mới đã sửa. Về mặt kỹ thuật, vá gần như miễn phí. Vậy "độ trễ vá" nằm ở đâu? Không nằm ở lệnh cài, mà ở khoảng thời gian bạn còn ở bản cũ sau khi bản vá đã có — và khoảng đó do quy trình của bạn quyết định, không phải công cụ. Một CVE urllib3 công bố hôm nay, bản vá thường ra cùng ngày; nhưng dự án của bạn có nâng ngay không? Ghim cứng urllib3==1.23 và không bao giờ quét lại thì bạn ở bản cũ mãi mãi; quét mỗi tuần thì độ trễ tối đa một tuần; quét trong CI mỗi lần build và chặn khi có CVE nghiêm trọng thì độ trễ gần bằng không.

Đây là nghịch lý dễ chịu của chuỗi cung ứng: bản thân việc vá rẻ, nhưng biết mình cần vá mới là phần tốn công — nên các dự án nghiêm túc gắn quét dependency vào CI và dùng bot tự động mở pull request nâng phiên bản (Dependabot, Renovate) để độ trễ vá không phụ thuộc vào việc có ai đó tình cờ nhớ ra. Có một căng thẳng thật ở đây với việc ghim phiên bản: ghim cứng (urllib3==1.23) làm bản build lặp lại được (ai cài cũng ra đúng bộ phiên bản đó) nên tốt cho ổn định, nhưng cũng chính là thứ giữ bạn ở bản có lỗ hổng nếu không có gì nhắc nâng. Lời giải không phải bỏ ghim (để phiên bản trôi tự do thì build hôm nay khác build mai, và bản mới có thể tự mang lỗ hổng riêng) mà là ghim + quét lại thường xuyên: khoá phiên bản trong một lockfile, rồi để CI quét lockfile đó và báo khi một gói đã ghim có CVE. Ổn định và an toàn không mâu thuẫn, nhưng bạn phải chủ động giữ cả hai.

Lần tôi đo sai

Lần chạy đầu tiên, tôi lười: thay vì quét một requirements.txt cô lập, tôi cài gói vào thẳng môi trường rồi cho pip-audit quét cả môi trường. Kết quả vô nghĩa — phần đọc số lỗ hổng báo lỗi phân tích JSON, và tệ hơn, sau khi "nâng lên bản mới nhất" nó vẫn báo 11 lỗ hổng, điều không thể đúng nếu mọi gói đều đã mới. Nguyên nhân: quét cả môi trường thì pip-audit soi luôn chính nó và đống dependency của nó, lẫn vào kết quả của tôi, nên con số chẳng đo đúng cái tôi muốn đo. Tôi bỏ toàn bộ lần đó và làm lại bằng pip-audit -r requirements.txt trên đúng hai gói dự án — lúc đó số mới sạch, ổn định và lặp lại y hệt qua 3 lần. Bài học: một phép đo chuỗi cung ứng phải khoanh đúng cây phụ thuộc của dự án, không phải mọi thứ tình cờ có mặt trong máy; đo nhầm phạm vi thì con số trông có vẻ khoa học mà thật ra là rác.

Ba điều rút ra: (1) bề mặt tấn công phần lớn là bắc cầu — 17/28 lỗ hổng nằm ở gói tôi chưa từng chọn, nên quét phải đi tới tận lá của cây chứ không dừng ở requirements.txt; (2) vá thường chỉ là nâng phiên bản — 28 xuống 0 bằng một lệnh, cái đắt không phải vá mà là biết mình cần vá kịp lúc; (3) độ trễ vá do quy trình quyết định — ghim cứng và không quét lại thì ở bản cũ mãi, quét trong CI thì độ trễ gần bằng không.

Mẫu số chung

Cái phát hiện lõi — bề mặt rủi ro thật của bạn là cây phụ thuộc đã-giải-quyết tới tận lá, không phải danh sách bạn tự khai — không riêng gì Python; nó đúng với mọi hệ có quản lý gói bắc cầu.

Trong thế giới Java, đây đúng là chuyện Maven/Gradle: pom.xml của bạn khai vài dependency, nhưng mvn dependency:tree phơi ra một cây gấp nhiều lần, và OWASP dependency-check quét cả cây đó. Log4Shell là minh hoạ đau nhất — với vô số dự án, log4j-core là một dependency bắc cầu kéo vào qua một framework, không ai chủ động thêm, nên nhiều đội ban đầu còn không biết mình có dính. Cùng bài học: quét cây đã giải quyết, không quét danh sách khai báo.

Trong Go, go.sum khoá băm của toàn bộ module bắc cầu (lockfile đúng nghĩa), và govulncheck đi xa hơn một bước — nó quét cây phụ thuộc và kiểm code bạn có thật sự gọi tới hàm dính CVE không, giảm nhiễu; go mod graph cho bạn thấy đoàn tuỳ tùng đầy đủ. Cùng khuôn "ghim + quét lại", với go get -u là bot nâng phiên bản thủ công.

Còn ở cơ sở dữ liệu, cùng nguyên tắc hiện ra ở phần mở rộng và image: một extension Postgres hay một base image Docker của DB kéo theo cả một chuỗi thư viện hệ thống, và CVE thường nằm ở lớp bạn không chọn (một libssl cũ trong image), nên quét image (trivy, grype) tới tận lớp OS là bản DB/hạ-tầng của "quét tới lá".

Mẫu số chung: bạn chịu trách nhiệm cho mọi thứ chạy trong tiến trình của mình, nhưng bạn chỉ chọn một phần nhỏ trong đó — phần còn lại được kéo vào bắc cầu, và rủi ro tập trung đúng ở phần bạn không nhìn — nên an toàn chuỗi cung ứng là quét cái cây đã giải quyết xong tới tận lá, rồi quét lại đều đặn vì cây đó đổi mỗi khi một mắt xích công bố CVE. Vì thế câu hỏi đúng không phải "tôi đã chọn dependency cẩn thận chưa" mà là "tôi có đang tự động nhìn thấy toàn bộ cây, kể cả những gói tôi chưa từng gõ tên, và có gì báo tôi khi một cái lá mọc lỗ hổng không".

Muốn tự soi trong ba mươi giây, trong một dự án Python của chính bạn:

pip install pip-audit
pip-audit          # quet toan bo cay phu thuoc dang cai

Đọc cột "Fix Versions": mỗi dòng có bản vá sẵn là một lần nâng phiên bản đang chờ bạn. Nếu phần lớn dòng hiện ra là những gói bạn không nhớ đã thêm — xin chúc mừng, bạn vừa gặp cây phụ thuộc bắc cầu của mình, đúng nơi phần lớn rủi ro trú ngụ.