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 thêm nó 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. Bạn chọn hai cái tên, pip cài về cả một cây.

Bài này đo cái cây đó nguy hiểm cỡ nào. Câu hỏi cụ thể: nếu tôi khai báo hai dependency cũ, thì có 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 báo đú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. Nó vào dự án 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.23requirements.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 ở urllib3idna, 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. Nó nằ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 do công cụ. Một CVE urllib3 được 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? Nếu bạn ghim cứng urllib3==1.23 và không bao giờ chạy lại quét, bạn ở bản cũ mãi mãi. Nếu bạn quét mỗi tuần, độ trễ là tối đa một tuần. Nếu quét trong CI mỗi lần build và chặn khi có CVE nghiêm trọng, độ 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. Đó là lý do 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 (như 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 nó là thực hành tốt cho tính ổn định. Nhưng ghim cứ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 là bỏ ghim (để phiên bản trôi tự do thì build hôm nay khác build mai, và một 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.

Ghép lại

Sơ đồ: hai gói kéo theo cây phụ thuộc và 28 lỗ hổng

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. Quét phải đi tới tận lá của cây, 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 là 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 = ở bản cũ mãi; quét trong CI = độ trễ gần bằng không.

Mô-típ quen của Series lại hiện ra: chỗ nguy hiểm không phải chỗ bạn nhìn vào (gói bạn chọn), mà chỗ bạn không nhìn (gói kéo theo). Công cụ hiện đại giải quyết được — pip-audit thấy toàn cây trong vài giây — nhưng chỉ khi bạn chạy nó, và chạy lại đều đặn.

Thử 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ụ.