Bài 3 — A03:2025 Software Supply Chain Failures: lockfile, install script, registry, digest, CI token và SBOM

Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 2 — A02 Security Misconfiguration · Bài 3 — A03 Software Supply Chain Failures (bài này) · Tiếp: Bài 4 — A04 Cryptographic Failures →

Software Supply Chain Failures (hỏng hóc trong chuỗi cung ứng phần mềm) là mục mở rộng từ "Vulnerable and Outdated Components" (A06:2021) và nhảy lên hạng 3. Điểm khác căn bản so với các mục khác của Top 10: phần lớn code chạy trong production của một app Laravel hay NestJS không do team viết. Một app Laravel bình thường kéo về vài chục đến hơn trăm package Composer, một app NestJS kéo về hàng trăm package npm (con số minh hoạ, tuỳ project) — mỗi package có maintainer riêng, tài khoản publish riêng, quy trình build riêng. Câu hỏi interviewer muốn nghe ở mức Senior/Tech Lead không phải "chạy npm audit", mà là: ta tin những ai, tin tới mức nào, và làm sao để khi một mắt xích ta tin bị chiếm thì thiệt hại nhỏ và ta biết ngay.

Vì sao bài này dùng khung riêng, và vì sao không có lab. Bài về một nhóm rủi ro, không phải một công nghệ, nên dùng khung riêng của series (10 mục cố định) thay cho 9 câu hỏi của CLAUDE.md. Bài này không có lab (quyết định của series): các nhận định về hành vi dựa trên tài liệu chính thức (OWASP, docs Composer, npm, GitLab, SLSA, Renovate) và được đánh dấu nguồn; chỗ nào cần lab mới khẳng định được thì ghi "chưa verify" — danh sách tổng hợp ở cuối bài. Trọng tâm là phòng thủ: cơ chế được giải thích ở mức "giả định nào trong quy trình bị sai và hệ quả là gì", không có hướng dẫn tấn công.

1. Mục này là gì trong 2025

1.1 Định nghĩa của OWASP và các dấu hiệu

OWASP định nghĩa: supply chain failures là "breakdowns or other compromises in the process of building, distributing, or updating software" — sự cố hoặc bị xâm nhập trong quá trình build, phân phối, hoặc cập nhật phần mềm, thường do lỗ hổng hoặc thay đổi độc hại trong code, công cụ, hay dependency của bên thứ ba mà hệ thống dựa vào. Ba động từ đó quan trọng: lỗi không nằm trong logic nghiệp vụ, mà nằm ở đường đi từ mã nguồn của người khác tới process đang chạy trên server của mình.

Trang A03:2025 liệt kê các dấu hiệu "bạn có khả năng bị ảnh hưởng nếu". Gom lại thành năm nhóm để dễ nhớ khi trả lời:

  1. Không biết mình đang dùng gì: không theo dõi phiên bản của mọi component, cả client-side lẫn server-side, cả dependency trực tiếp lẫn dependency bắc cầu (transitive dependency — package mà package của mình lại kéo về).
  2. Dùng thứ đã cũ hoặc có lỗ hổng: phần mềm có lỗ hổng, hết hỗ trợ, lỗi thời — từ hệ điều hành, web server, DBMS, runtime cho tới thư viện; không quét lỗ hổng định kỳ, không đăng ký bản tin bảo mật; vá theo lịch tháng/quý nên phơi nhiễm "hàng ngày tới hàng tháng không cần thiết"; không test tương thích khi nâng thư viện.
  3. Không quản lý thay đổi trong chuỗi: không theo dõi thay đổi của IDE và extension, repo code, sandbox, image và library repository, cách artifact được tạo và lưu.
  4. Không hardening chuỗi và không tách nhiệm vụ: không áp dụng least privilege cho các hệ thống trong chuỗi; một người có thể viết code và đẩy thẳng lên production mà không ai khác xem; "your CI/CD pipeline has weaker security than the systems it builds and deploys" — pipeline yếu hơn chính hệ thống nó build.
  5. Nhận component từ nguồn không tin cậy ở bất kỳ tầng nào của stack, và cấu hình các mắt xích không an toàn (chỗ này OWASP trỏ sang A02).

1.2 Thay đổi so với 2021

Gốc của mục này là "A9 – Using Components with Known Vulnerabilities" năm 2013, thành "A06:2021 Vulnerable and Outdated Components", và năm 2025 được đổi tên, mở rộng phạm vi thành Software Supply Chain Failures, lên hạng 3. Ba điểm cần nói được khi phỏng vấn:

  1. Phạm vi rộng ra từ "có CVE" thành "mọi sự cố trong chuỗi". Năm 2021, câu hỏi chính là "thư viện này có lỗ hổng đã công bố không". Năm 2025, câu hỏi gồm cả: package có bị cài mã độc không (chưa hề có CVE), tài khoản maintainer có bị chiếm không, pipeline build có bị can thiệp không, máy dev có bị nhiễm không. Đây là thay đổi về loại rủi ro, không chỉ về số lượng.
  2. Vào danh sách nhờ community survey. OWASP ghi mục này đứng đầu survey cộng đồng, đúng 50% người trả lời xếp nó hạng 1. Lý do: dữ liệu test tự động không bắt được phần lớn loại rủi ro này (một package độc hại không để lại dấu vết trong kết quả DAST), nên nếu chỉ dựa vào dữ liệu thì mục này sẽ bị đánh giá thấp. Bài 0 đã giải thích cơ chế "2 suất từ survey" — xem Bài 0.
  3. Ít CVE nhưng điểm exploit/impact cao nhất bảng. Chỉ 11 CVE gắn với các CWE của mục này, vì "package bị cài mã độc" thường được xử lý bằng advisory gỡ package chứ không cấp CVE. Nhưng trung bình Exploit 8,17 và Impact 5,23 là cao nhất trong 10 mục (Bài 0 có bảng so sánh).

1.3 Bảng điểm chính thức

Chỉ sốGiá trịĐọc thế nào
CWE được map6Ít nhất bảng (cùng A09 thuộc nhóm thấp nhất) — rủi ro này khó mô tả bằng CWE kiểu "lỗi code".
Max / Avg Incidence Rate9,56% / 5,72%Trong các app được test có kiểm mục này, trung bình 5,72% có ít nhất một phát hiện. Phần mô tả của OWASP lại ghi "highest average incidence rate at 5.19%" — hai con số lệch nhau ngay trên cùng trang (ghi nhận, không tự sửa).
Max / Avg Coverage65,42% / 27,47%Chỉ khoảng một phần tư dữ liệu đóng góp có test mục này — độ phủ thấp, nên số liệu incidence là ước lượng dưới.
Avg Weighted Exploit / Impact8,17 / 5,23Cao nhất bảng: khi xảy ra thì dễ khai thác và hậu quả lớn (code độc chạy với quyền của app).
Total Occurrences215.248
Total CVEs11Thấp vì malware trong package thường không được cấp CVE.
Cách nói khi phỏng vấn: "Mục này ít CVE, độ phủ test thấp, nhưng điểm khai thác và tác động cao nhất cả danh sách. Nghĩa là nó hiếm khi lộ ra trong báo cáo quét tự động, nhưng khi xảy ra thì code lạ chạy với đúng quyền của ứng dụng. Vì vậy OWASP phải dựa vào khảo sát cộng đồng để đưa nó lên hạng ba, và vì vậy em không coi một báo cáo audit sạch là bằng chứng an toàn."

1.4 Các CWE: hai nhóm cơ chế

Phần Background của trang A03 nêu tên các CWE chính; phần "List of Mapped CWEs" trên bản em trích được chỉ hiện CWE-1035, nên CWE thứ sáu chưa đối chiếu được (ghi ở Chưa verify). Gom theo cơ chế:

NhómCWEGiả định sai
Component có lỗ hổng đã biếtCWE-1035 (2017 Top 10 A9: Using Components with Known Vulnerabilities), CWE-1395 Dependency on Vulnerable Third-Party Component"Thư viện phổ biến thì an toàn" — trong khi lỗ hổng được công bố sau khi mình đã cài.
Component không còn được bảo trì / không cập nhật đượcCWE-1104 Use of Unmaintained Third Party Components, CWE-1329 Reliance on Component That is Not Updateable, CWE-477 Use of Obsolete Function"Đang chạy ổn thì cứ để" — khi có lỗ hổng sẽ không có bản vá, hoặc có mà không lắp vào được.

Để ý: không CWE nào mô tả "package bị cài mã độc" hay "pipeline bị chiếm". Đó là lý do câu trả lời tốt cho A03 phải vượt ra ngoài danh sách CWE — danh sách CWE mô tả lỗ hổng, còn rủi ro lớn nhất của 2025 là niềm tin bị lạm dụng.

1.5 Ranh giới với A08, A02 và A06

flowchart TD
  Q["Sự cố liên quan tới code / artifact bên ngoài"] --> C1{"Ta có tự kiểm<br/>toàn vẹn ở chỗ nhận vào không?"}
  C1 -- "Không kiểm chữ ký, digest,<br/>deserialize dữ liệu không tin cậy" --> A08["A08 Software or Data<br/>Integrity Failures"]
  C1 -- "Có kiểm, nhưng thứ ta tin<br/>(maintainer, registry, CI) bị chiếm,<br/>hoặc component cũ có CVE" --> A03["A03 Software Supply<br/>Chain Failures"]
  Q --> C2{"Mắt xích bị cấu hình<br/>sai (token quá rộng, registry public)?"}
  C2 -- "Có" --> A02["A02 Security Misconfiguration<br/>(thường là nguyên nhân phụ)"]
  Q --> C3{"Quy trình thiếu<br/>tách nhiệm vụ từ thiết kế?"}
  C3 -- "Có" --> A06["A06 Insecure Design"]

OWASP tự nói A08 là "at a lower level than Software Supply Chain Failures": A08 tập trung vào điểm kiểm tra toàn vẹn (ta nhận một artifact, một bản cập nhật, một object serialize mà không kiểm chữ ký/nguồn gốc), còn A03 là bức tranh rộng của toàn chuỗi (ai được tin, phiên bản nào, pipeline nào). Trong phỏng vấn, phân loại đúng ô không quan trọng bằng nói được: ở mỗi mắt xích, kiểm soát nào đáp ứng cho cái gì. Bài này giữ ký số và provenance ở mức "dùng để làm gì"; cơ chế xác minh chữ ký và deserialize để lại cho Bài 8 — A08.

Cách nói khi phỏng vấn: "Em phân biệt thế này: nếu ứng dụng nhận một thứ từ bên ngoài mà không tự kiểm tra toàn vẹn của nó, đó là A08. Nếu ta có kiểm, nhưng chính bên ta tin bị chiếm, hoặc ta đang dùng thư viện cũ có lỗ hổng, đó là A03. Hai mục bổ sung cho nhau: A03 hỏi ta tin ai, A08 hỏi ta có kiểm chứng niềm tin đó không."

2. Bài toán gốc — vì sao chuỗi cung ứng luôn là điểm yếu

Lỗi chuỗi cung ứng không phải do một dòng code sai. Nó đến từ cách phần mềm hiện đại được lắp ráp. Bảy nguyên nhân gốc, mỗi nguyên nhân là một giả định mà quy trình mặc định đang đặt sẵn:

  1. Niềm tin bắc cầu, không ai ký vào. Khi team thêm một dòng "nestjs/config" vào package.json, team quyết định tin maintainer của package đó. Nhưng package đó kéo theo các package khác mà team không chọn và thường không biết tên. Ví dụ minh hoạ: 25 dependency trực tiếp có thể thành 600 package trong node_modules. Nếu mỗi package có một hai người có quyền publish, số người team đang ngầm tin là hàng trăm — không ai trong số đó ký hợp đồng gì với team. Một quyết định "thêm một thư viện nhỏ cho tiện" là quyết định mở rộng tập người được chạy code trên server.
  2. Code của dependency chạy với đúng quyền của app. OWASP scenario #4: "Components typically run with the same privileges as the application itself". Không có ranh giới quyền nào giữa code của team và code trong vendor/ hay node_modules/ — cùng process, cùng biến môi trường (có cả DB_PASSWORD, APP_KEY), cùng quyền mạng. Một hàm tiện ích định dạng ngày tháng có thể đọc mọi biến môi trường.
  3. Code chạy ngay lúc cài, trước cả khi ai dùng nó. npm có lifecycle script (preinstall, install, postinstall), Composer có plugin và script. Một package độc hại không cần được import trong code; chỉ cần được cài là đã chạy — trên máy dev và trên CI runner, nơi có nhiều bí mật nhất (token publish, token deploy, khoá registry). Đây chính là cơ chế của worm Shai-Hulud năm 2025 mà OWASP dùng làm scenario #3.
  4. Tham chiếu có thể đổi nội dung (mutable reference). Version range ^2.0, tag latest của Docker image, tag v4 của một CI action, URL một script trên CDN — tất cả là tên, không phải nội dung. Hôm nay tên đó trỏ tới nội dung A, ngày mai người kiểm soát tên có thể trỏ nó sang nội dung B, và quy trình không hề biết. Sự cố tj-actions/changed-files năm 2025 (mục 8) là ví dụ thẳng: các tag cũ bị trỏ sang một commit độc hại.
  5. Mắt xích yếu nhất là con người, không phải code. Tài khoản maintainer bị phishing, maintainer mệt mỏi trao quyền cho người lạ "nhiệt tình" (event-stream 2018, xz-utils 2024). Không công cụ quét code nào bắt được một thay đổi về quyền sở hữu.
  6. CI/CD có nhiều quyền hơn production nhưng được bảo vệ kém hơn. CI runner giữ token đẩy image, token deploy, quyền đọc mọi repo — nhưng lại chạy code từ mọi merge request, mọi action/template bên thứ ba, thường không có giám sát. OWASP gọi thẳng tên: pipeline yếu hơn hệ thống nó build.
  7. Vá chậm vì vá đắt. Nâng một thư viện có thể kéo theo nâng framework, nâng PHP/Node. Khi chi phí vá cao, team gom lại vá theo quý — đúng điều OWASP cảnh báo là phơi nhiễm "days or months". Bài Vá và nâng cấp PHP (PHP2Shell) là một ví dụ đầy đủ về chi phí này.
flowchart TD
  T["Team viết ~5–10% code"] --> APP["Process ứng dụng<br/>(cùng quyền, cùng env)"]
  D["Dependency trực tiếp<br/>(team chọn)"] --> APP
  TR["Dependency bắc cầu<br/>(team không chọn)"] --> APP
  B["Base image, runtime, OS package"] --> APP
  CI["CI runner + action/template<br/>bên thứ ba"] -->|"build ra"| APP
  M["Maintainer, tài khoản publish,<br/>registry, CDN"] --> D
  M --> TR
  M --> B
Cách nói khi phỏng vấn: "Gốc của rủi ro chuỗi cung ứng là niềm tin bắc cầu. Khi thêm một thư viện, team không chỉ tin một người, mà tin tất cả những người có quyền publish các package mà thư viện đó kéo theo. Code của họ chạy cùng quyền với ứng dụng, có khi chạy ngay lúc cài đặt, và thường được tham chiếu bằng tên có thể đổi nội dung. Vì vậy phòng thủ phải xoay quanh ba việc: biết mình đang tin gì, khoá chặt nội dung đã kiểm, và giới hạn thiệt hại khi một mắt xích bị chiếm."

3. Cơ chế — mỗi dạng sự cố sai ở giả định nào

Mỗi tiểu mục dưới đây mô tả một dạng sự cố ở mức khái niệm: giả định nào của quy trình bị sai, hệ quả là gì, và một ví dụ có nguồn. Không có hướng dẫn tái hiện — mục 4 là mẫu code phòng thủ.

3.1 Component có lỗ hổng đã biết, và component bị bỏ rơi

  1. Giả định sai: "phiên bản ta cài hôm nay an toàn thì mãi an toàn". Thực tế, lỗ hổng được công bố sau khi ta cài. Một package không đổi một byte nào nhưng hôm qua "sạch", hôm nay có advisory mức critical.
  2. Hệ quả: lỗ hổng trong thư viện có tác động ngang lỗ hổng trong code của team, vì cùng quyền. OWASP nêu CVE-2017-5638 (Struts 2, thực thi code từ xa) và CVE-2021-44228 (Log4Shell) — cả hai nằm trong thư viện mà nhiều team không biết mình đang dùng, vì nó là dependency bắc cầu.
  3. Biến thể bị bỏ rơi (CWE-1104, CWE-1329): package không còn ai bảo trì thì khi có lỗ hổng sẽ không có bản vá. Composer đánh dấu được package "abandoned" (maintainer tự khai báo), nhưng đa số package chết không ai khai báo — chỉ thấy qua dấu hiệu gián tiếp: lâu không release, issue bảo mật không ai trả lời.
  4. Ví dụ số (minh hoạ): advisory công bố ngày D. Nếu team chỉ quét khi có merge request và tháng đó không ai đụng vào service, lỗ hổng tồn tại tới khi có commit tiếp theo — có thể là D+30. Nếu có pipeline quét định kỳ hằng ngày trên nhánh main, team biết từ D+1. Khoảng 29 ngày chênh lệch đó là thứ OWASP gọi là "unnecessary exposure".
flowchart TD
  I["Ngày 0: cài lib X 2.3.1<br/>audit sạch"] --> P["Ngày D: advisory cho X ≤ 2.3.4"]
  P --> Q1{"Có quét định kỳ<br/>trên lockfile đang chạy?"}
  Q1 -- "Có" --> F["D+1: cảnh báo, MR nâng lên 2.3.5"]
  Q1 -- "Không, chỉ quét khi có MR" --> L["Tới commit kế tiếp mới biết<br/>(có thể D+30)"]
Cách nói khi phỏng vấn: "Một thư viện không đổi gì vẫn có thể từ an toàn thành nguy hiểm chỉ sau một đêm, vì lỗ hổng được công bố sau khi mình cài. Nên quét chỉ khi có merge request là chưa đủ: service ít người đụng sẽ mang lỗ hổng hàng tháng mà không ai biết. Em cho chạy quét định kỳ hằng ngày trên lockfile của nhánh chính, độc lập với việc có ai commit hay không."

3.2 Tài khoản maintainer bị chiếm — phiên bản độc hại mới

  1. Giả định sai: "phiên bản mới của một package uy tín cũng uy tín". Thực tế, uy tín gắn với người, còn phiên bản gắn với tài khoản. Khi tài khoản publish bị chiếm (phishing, token lộ), kẻ chiếm có thể publish phiên bản mới mang tên package uy tín.
  2. Ví dụ có nguồn: ngày 22/10/2021, tài khoản của maintainer ua-parser-js bị chiếm; ba phiên bản 0.7.29, 0.8.0, 1.0.0 chứa mã độc (cryptominer và trojan đánh cắp mật khẩu theo CERT-EU), bản sạch là 0.7.30, 0.8.1, 1.0.1 (CISA, CERT-EU). Package này được dùng rộng rãi qua dependency bắc cầu.
  3. Vì sao version range làm hại: nếu package.json ghi ^0.7.28 và team chạy npm install (không phải npm ci) trong khoảng thời gian phiên bản độc còn trên registry, resolver sẽ chọn 0.7.29 vì nó thoả range và mới nhất. Không ai "quyết định" nâng cấp — nó tự xảy ra.
  4. Cửa sổ thời gian là yếu tố then chốt: các phiên bản độc kiểu này thường bị phát hiện và gỡ trong vài giờ tới vài ngày. Đây là cơ sở cho kiểm soát "cooldown" (chờ một phiên bản đủ tuổi mới nhận) ở mục 4.8: nếu team chỉ nhận phiên bản đã được phát hành ít nhất N ngày, phần lớn phiên bản độc đã bị gỡ trước khi tới lượt team.
flowchart TD
  A["Tài khoản publish bị chiếm"] --> V["Phiên bản mới X 1.0.0<br/>mang tên package uy tín"]
  V --> R{"Project cài thế nào?"}
  R -- "install theo range ^, không lockfile" --> BAD["Resolver tự chọn bản độc"]
  R -- "ci theo lockfile đã review" --> OK["Vẫn dùng bản cũ đã khoá"]
  R -- "Bot cập nhật có cooldown N ngày" --> WAIT["Chưa đề xuất; bản độc<br/>thường bị gỡ trong thời gian chờ"]
Cách nói khi phỏng vấn: "Uy tín thuộc về con người, nhưng phiên bản thuộc về tài khoản. Khi tài khoản publish bị chiếm, phiên bản độc mang đúng tên package uy tín. Nếu môi trường cài theo khoảng phiên bản mà không có lockfile, nó sẽ tự nhận bản độc. Em chặn bằng hai lớp: build chỉ cài đúng những gì lockfile đã khoá, và bot cập nhật chỉ đề xuất phiên bản đã phát hành đủ vài ngày."

3.3 Code chạy lúc cài đặt — install script và plugin

  1. Giả định sai: "cài một package chỉ là chép file". Với npm, một dependency có thể khai báo lifecycle script chạy tự động khi cài. Với Composer, một package kiểu plugin có thể chạy code trong mọi lệnh Composer.
  2. Hệ quả: code chạy trên máy dev và CI runner, không phải trên production. Đây là chỗ có nhiều bí mật nhất: token npm, token GitLab, SSH key, file .env của nhiều project. OWASP scenario #3 (Shai-Hulud, 2025) mô tả đúng chuỗi: phiên bản độc dùng post-install script thu thập dữ liệu nhạy cảm, tìm npm token trong môi trường nạn nhân và dùng nó để publish phiên bản độc của mọi package token đó có quyền — tự lan, hơn 500 phiên bản trước khi npm chặn (theo OWASP và cảnh báo của CISA ngày 23/09/2025). OWASP kết luận: "developers themselves are now prime targets".
  3. Thay đổi mặc định đáng chú ý: theo docs npm CLI v12, install script của dependency bị chặn mặc định; team phải ghi rõ package nào được chạy script trong trường allowScripts của package.json (lệnh npm approve-scripts quản lý trường này, mặc định ghim theo phiên bản đã review). Composer từ lâu yêu cầu khai báo allow-plugins cho plugin. Ở các bản npm cũ hơn, cần chủ động dùng --ignore-scripts. Chi tiết ở mục 4.2.
  4. Điều không được che: chặn install script không chặn code độc nằm trong thân thư viện — code đó vẫn chạy khi app import thư viện. Chặn script thu hẹp bề mặt ở máy dev/CI, không thay được review dependency.
flowchart TD
  I["npm install / composer install"] --> S{"Dependency có<br/>install script / plugin?"}
  S -- "Không" --> C["Chỉ chép file"]
  S -- "Có, và được phép chạy" --> E["Chạy trên máy dev / CI runner<br/>thấy token, SSH key, .env"]
  E --> W["Lộ bí mật, có thể dùng token<br/>publish tiếp (kiểu worm)"]
  S -- "Có, nhưng không có trong<br/>allowScripts / allow-plugins" --> K["Bị bỏ qua, in danh sách để review"]
Cách nói khi phỏng vấn: "Cài một package không chỉ là chép file: npm có script chạy lúc cài, Composer có plugin. Script đó chạy trên máy dev và CI, nơi giữ nhiều token nhất, nên một package độc không cần ai import cũng đã gây hại — worm npm năm 2025 lan đúng theo đường này. Em để mặc định là không chạy script nào, chỉ cho phép từng package đã review và có lý do, và nhớ rằng việc này không thay được review dependency vì code độc trong thân thư viện vẫn chạy."

3.4 Nhầm nguồn package — dependency confusion và typosquatting

  1. Giả định sai: "tên package xác định duy nhất một package". Thực tế, tên chỉ duy nhất trong một registry. Khi project cấu hình nhiều nguồn (registry nội bộ + registry public), câu hỏi "lấy acme/billing từ đâu" có thể có hai câu trả lời.
  2. Dependency confusion (nhầm lẫn nguồn): docs Composer mô tả đúng tình huống: project có package riêng foo/bar ^2.0 trên repo nội bộ, bản mới nhất 2.4.3. Nếu repo nội bộ không canonical và ai đó publish foo/bar 2.999 lên packagist.org, Composer sẽ chọn bản 2.999 vì cao hơn. Composer 2 coi mọi repository là canonical mặc định: package tìm thấy ở repo trên thì không xét repo dưới — đó là thiết kế an toàn. Với npm, giải pháp tương đương là scope: mọi package nội bộ mang scope riêng (@acme/…), và scope đó được gán registry nội bộ trong .npmrc (mục 4.3).
  3. Typosquatting (đặt tên gần giống): package độc đặt tên giống package thật sai một ký tự. Lỗi xảy ra ở khoảnh khắc dev gõ lệnh cài. Kiểm soát nằm ở review: lockfile diff có package mới thì phải có người nhìn tên và nguồn.
flowchart TD
  N["Cần package acme/billing ^2.0"] --> R1["Repo nội bộ: có 2.4.3"]
  N --> R2["Registry public: ai đó publish 2.999"]
  R1 --> Q{"Repo nội bộ có canonical?<br/>(npm: scope gán registry riêng?)"}
  Q -- "Có (mặc định Composer 2)" --> OK["Chỉ xét repo nội bộ → 2.4.3"]
  Q -- "Không" --> BAD["So version trên mọi nguồn → 2.999"]
Cách nói khi phỏng vấn: "Tên package chỉ duy nhất trong một registry. Khi có cả registry nội bộ và public, nếu cấu hình để trình quản lý package so phiên bản trên mọi nguồn, ai đó chỉ cần publish lên public một package trùng tên với số phiên bản cao hơn. Composer 2 mặc định coi repo được khai báo là canonical nên tránh được; với npm em đặt mọi package nội bộ dưới một scope riêng và gán scope đó cho registry nội bộ."

3.5 Tham chiếu có thể đổi nội dung — tag, latest, script trên CDN

  1. Giả định sai: "cùng tên thì cùng nội dung". Docker tag, git tag, tag của CI action/template, và URL của một script trên CDN đều là con trỏ có thể bị trỏ lại.
  2. tj-actions/changed-files (03/2025, CVE-2025-30066): các tag từ v1 tới v45.0.7 bị kẻ tấn công sửa để trỏ tới một commit độc hại; workflow nào chạy action này trong khoảng 14–15/03/2025 in bí mật của runner ra log (với repo public, log ai cũng đọc được). GitHub advisory ước tính hơn 23.000 repository dùng action này; CISA ghi nhận nguyên nhân có thể là một action khác (reviewdog/action-setup, CVE-2025-30154) bị chiếm trước — một chuỗi cung ứng lồng trong chuỗi cung ứng. Repo nào ghim action theo commit SHA đầy đủ thay vì tag thì không bị trỏ lại.
  3. polyfill.io (2024): theo Sansec, đầu năm 2024 domain và tài khoản GitHub của dự án được bán cho một công ty mới; từ đó script phục vụ từ cdn.polyfill.io bị chèn mã chuyển hướng người dùng di động, ảnh hưởng hơn 100.000 site nhúng script này. Không site nào đổi một dòng code — chỉ chủ sở hữu của URL đổi.
  4. Docker FROM php:8.4-fpm: tag này được build lại thường xuyên (đó là điều tốt để nhận bản vá OS), nhưng nghĩa là cùng một Dockerfile build hai lần có thể ra hai image khác nhau. Ghim theo digest (@sha256:…) cho tính tái lập, và dùng bot cập nhật digest để vẫn nhận bản vá (mục 4.5).
flowchart TD
  P["Pipeline tham chiếu action@v4 / image:latest / CDN URL"] --> T["Hôm nay: tên → nội dung A (đã review)"]
  T --> X["Người kiểm soát tên trỏ lại → nội dung B"]
  X --> Q{"Tham chiếu theo gì?"}
  Q -- "Tên (tag, URL)" --> BAD["Lần chạy sau dùng B, không ai biết"]
  Q -- "Nội dung (SHA commit, digest,<br/>integrity hash)" --> OK["Vẫn là A; muốn sang B phải qua MR"]
Cách nói khi phỏng vấn: "Tag và URL là tên, không phải nội dung; người kiểm soát tên có thể trỏ nó sang nội dung khác bất cứ lúc nào. Sự cố tj-actions năm 2025 là các tag cũ bị trỏ sang commit độc, còn polyfill.io là domain đổi chủ. Em ghim theo thứ mô tả nội dung — commit SHA, digest của image, hash của file — và để bot mở merge request khi có nội dung mới, nên mọi thay đổi vẫn đi qua review."

3.6 Hệ thống build bị can thiệp — artifact khác mã nguồn

  1. Giả định sai: "artifact build ra đúng bằng mã nguồn đã review". Thực tế, giữa repo và artifact có hệ thống build, script build, file release do người đóng gói tạo — chỗ nào cũng có thể chèn thêm.
  2. SolarWinds (2019–2020): OWASP scenario #1 — một vendor tin cậy bị xâm nhập, bản cập nhật chứa mã độc, khoảng 18.000 tổ chức bị ảnh hưởng khi họ cập nhật như bình thường. Code độc được chèn trong quá trình build của vendor, nên bản cập nhật vẫn được ký hợp lệ — ký số chứng minh "đến từ vendor", không chứng minh "vendor không bị chiếm".
  3. xz-utils (2024, CVE-2024-3094): backdoor nằm trong file tarball release 5.6.0 và 5.6.1; một phần quan trọng (macro build đã sửa) không có trong repo git, chỉ có trong tarball do maintainer tải lên. Ai review repo git không thấy. Phát hiện nhờ một kỹ sư (Andres Freund) điều tra đăng nhập SSH chậm thêm khoảng nửa giây trên bản Debian unstable — không nhờ công cụ quét nào.
  4. Bài học thiết kế: đây là lý do của SLSA (Supply-chain Levels for Software Artifacts): Build L1 yêu cầu có provenance (bản khai: ai build, từ commit nào, bằng quy trình nào); L2 yêu cầu provenance được ký và sinh bởi build platform được host; L3 yêu cầu build platform hardened để các lần build không ảnh hưởng nhau và khoá ký provenance không lộ cho bước build do người dùng định nghĩa. Đi kèm là "build một lần, promote artifact" — OWASP ghi rõ "promote artifacts rather than rebuilding for each environment".
flowchart TD
  SRC["Mã nguồn đã review (git)"] --> BLD["Hệ thống build<br/>(runner, script, tarball)"]
  INJ["Chèn thêm ở đây:<br/>SolarWinds, tarball xz"] -.-> BLD
  BLD --> ART["Artifact (image, package)"]
  ART --> V{"Có provenance ký bởi<br/>build platform tin cậy?"}
  V -- "Có (SLSA L2/L3)" --> CHK["So commit + builder với kỳ vọng"]
  V -- "Không" --> TRUST["Chỉ tin vào tên artifact"]
Cách nói khi phỏng vấn: "Review mã nguồn không đủ nếu artifact có thể khác mã nguồn. SolarWinds bị chèn trong lúc build của vendor nên bản cập nhật vẫn được ký hợp lệ; xz giấu phần quan trọng trong file release không có trên git. Khung SLSA trả lời bằng provenance: một bản khai được ký, ghi ai build, từ commit nào, bằng quy trình nào. Trong team, em áp dụng phần dễ nhất là build image đúng một lần trên CI, promote cùng một digest qua các môi trường, và giữ khoá ký ngoài tầm của các bước build."

3.7 Chiếm quyền bảo trì bằng xã hội — event-stream và xz

  1. Giả định sai: "người được maintainer trao quyền thì đáng tin". Cả hai vụ lớn đều bắt đầu bằng việc một người lạ đóng góp nhiệt tình, được trao quyền, rồi mới chèn mã độc.
  2. event-stream (2018): theo npm blog và postmortem của Snyk, maintainer gốc trao quyền publish cho một người đề nghị giúp; người này thêm dependency flatmap-stream, sau đó publish bản 0.1.1 của nó chứa mã độc. Mã độc nhắm đúng một ứng dụng ví Bitcoin (Copay): chỉ giải mã payload khi mô tả package gốc khớp, và đã lọt vào các bản Copay 5.0.2–5.1.0. Có điều kiện là điểm nguy hiểm: chạy thử ở môi trường khác thì không thấy gì — giống OWASP scenario #2 (vụ Bybit 2025, mã độc chỉ chạy khi đúng ví mục tiêu được dùng).
  3. xz-utils: chiến dịch kéo dài khoảng hai năm (từ cuối 2021), kèm nhiều tài khoản phụ gây áp lực lên maintainer duy nhất để trao quyền đồng bảo trì.
  4. Hệ quả cho team: không team nào kiểm soát được quyền sở hữu của package bên ngoài. Kiểm soát khả thi là tín hiệu: thay đổi maintainer, thêm dependency mới trong một bản patch, phiên bản có install script mới — đều là lý do để một bản cập nhật bị giữ lại chờ người xem thay vì tự merge.
flowchart TD
  H["Người lạ đóng góp, được tin"] --> G["Được trao quyền publish / release"]
  G --> D["Thêm dependency hoặc file release mới"]
  D --> C["Mã độc có điều kiện:<br/>chỉ chạy ở mục tiêu cụ thể"]
  C --> S{"Team phát hiện bằng gì?"}
  S -- "Chạy test thường" --> MISS["Không thấy (điều kiện không khớp)"]
  S -- "Tín hiệu trong diff lockfile:<br/>dependency mới, maintainer đổi" --> HOLD["Giữ lại, cần người review"]
Cách nói khi phỏng vấn: "Hai vụ lớn nhất, event-stream và xz, đều bắt đầu bằng xã hội chứ không phải kỹ thuật: người lạ đóng góp nhiệt tình rồi được trao quyền. Mã độc còn có điều kiện, chỉ chạy ở đúng mục tiêu, nên test bình thường không thấy. Team không kiểm soát được quyền sở hữu của package bên ngoài, nên em dựa vào tín hiệu: bản vá nhỏ mà thêm dependency mới, có script cài đặt mới, hay đổi maintainer thì không cho tự merge."

3.8 Hình động: một bản cập nhật dependency đi qua năm cổng

Gom các kiểm soát ở trên thành một luồng: một bản cập nhật phải qua năm cổng — cooldown của bot cập nhật, review lockfile trong merge request, cài đặt có kiểm soát ở CI, build có ghim và SBOM, rồi rollout từng phần cộng quét định kỳ. Hình dưới cho ba bản cập nhật đi qua năm cổng đó.

Ba bản cập nhật dependency đi qua năm cổng kiểm soát chuỗi cung ứng Năm cột là năm cổng: cooldown của bot cập nhật, review lockfile trong merge request, cài đặt có kiểm soát ở CI, build có ghim digest và sinh SBOM, rollout từng phần cùng quét định kỳ. Bản A là bản vá framework bình thường, đi qua cả năm cổng và lên production. Bản B là phiên bản mới của một package npm phát hành ba giờ trước từ tài khoản bị chiếm, dừng ở cổng cooldown vì chưa đủ tuổi, bot không mở merge request. Bản C là package đang chạy production, hôm nay có advisory mới; nó không đi qua pipeline mà bị phát hiện ở cổng quét định kỳ trên SBOM, sinh cảnh báo và merge request vá theo SLA. Bản cập nhật 1. Cooldown 2. Review lockfile 3. Cài ở CI 4. Build + SBOM 5. Rollout + quét Kết quả chờ N ngày tuổi package mới, nguồn lock, script, audit digest, provenance canary, quét lịch đã chạy production từ trước, không đi lại pipeline A: framework 13.xbản vá thường B: lib npm mớiphát hành 3 giờ trước C: lib đang chạyhôm nay có advisory ! Lên productioncanary rồi toàn bộ Chưa đề xuấtbị gỡ trong lúc chờ Cảnh báo + MR vátrong SLA theo mức độ Cổng 1 và cổng 5 (viền xanh) làm việc với THỜI GIAN: cổng 1 đợi thế giới phát hiện bản độc trước mình, cổng 5 bắt lỗ hổng công bố SAU khi đã deploy. Cổng 2–4 chỉ thấy những gì đi qua merge request.
Hình 1 — Năm cổng trên một bản cập nhật dependency. Chấm xanh lá (A): bản vá framework bình thường, đủ tuổi, lockfile diff chỉ đổi phiên bản, CI cài từ lock với script bị chặn và audit sạch, build ra image có digest và SBOM, rollout qua canary. Chấm cam (B): phiên bản mới của một package npm do tài khoản bị chiếm phát hành 3 giờ trước — bot cập nhật có cooldown 7 ngày không mở merge request; trong thời gian chờ, registry đã gỡ bản độc. Chấm tím (C): package đã chạy production từ lâu, hôm nay có advisory mới — không có commit nào để pipeline chạy, chỉ quét định kỳ trên SBOM của image đang chạy mới bắt được.
Cách nói khi phỏng vấn: "Em nghĩ về cập nhật dependency như một luồng có năm cổng. Cổng đầu là thời gian: bot chỉ đề xuất phiên bản đã phát hành vài ngày, để bản độc bị cộng đồng phát hiện trước. Ba cổng giữa là review lockfile, cài đặt có kiểm soát ở CI và build có ghim kèm SBOM. Cổng cuối lại là thời gian theo chiều ngược: quét định kỳ những gì đang chạy, vì lỗ hổng có thể được công bố sau khi mình đã deploy."

4. Mẫu code sai và mẫu code đúng (Laravel + NestJS)

Mỗi tiểu mục có: mẫu sai (ngắn, để nhận ra khi review), mẫu đúng (giải thích kỹ), và một diagram cho luồng phòng thủ. Với A03, "code" phần lớn là composer.json, package.json, .npmrc, Dockerfile và .gitlab-ci.yml — đó chính là bề mặt của chuỗi cung ứng. Cú pháp lấy theo docs Composer (nhánh main, Context7 bản 2.10.x), docs npm CLI v12, docs GitLab CI, docs Renovate; liệt kê ở Nguồn. Tên package nội bộ, registry, digest là minh hoạ.

4.1 Cài đặt tái lập: lockfile được commit, CI cài đúng lockfile

## ===== SAI: Dockerfile / CI giải lại phiên bản mỗi lần build =====
COPY composer.json ./
RUN composer update --no-dev          # bỏ qua lock: mỗi lần build có thể ra bộ package khác
COPY package.json ./
RUN npm install                       # có thể ghi lại package-lock.json, nhận bản mới thoả range

## ===== ĐÚNG: Laravel — Dockerfile stage vendor =====
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer validate --strict --no-check-publish \
 && composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader

## ===== ĐÚNG: NestJS — Dockerfile stage build =====
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                            # lock bắt buộc; lệch với package.json là lỗi, không tự sửa lock
COPY . .
RUN npm run build && npm prune --omit=dev

Giải thích mẫu đúng, từng điểm:

  1. Lockfile là danh sách nội dung đã được review. composer.json/package.json ghi ý định ("Laravel 13 bản nào cũng được"), lockfile ghi quyết định (đúng phiên bản nào, tải từ URL nào, và với npm là hash integrity của từng tarball). Commit lockfile biến mỗi lần thay đổi dependency thành một diff nhìn thấy được trong merge request.
  2. composer install đọc lock, composer update giải lại. Trên CI và trong Dockerfile chỉ dùng install. composer validate --strict trả lỗi nếu có cảnh báo, trong đó có trường hợp lock không còn khớp composer.json (ai đó sửa composer.json mà quên cập nhật lock) — dừng build sớm thay vì cài một bộ không ai review.
  3. npm ci thay cho npm install. Theo docs npm: npm ci yêu cầu có sẵn package-lock.json, báo lỗi và thoát nếu dependency trong lock không khớp package.json, xoá node_modules cũ, và không bao giờ ghi vào package.json hay lock. Tức là CI không thể "tự nâng" một package.
  4. --no-scripts ở stage vendor của Laravel: script trong composer.json của Laravel (package:discover…) cần code app; chạy chúng ở stage sau, khi đã copy code — và ở stage vendor không chạy gì ngoài cài đặt. Bài Laravel + GitLab CI/CD đã có Dockerfile multi-stage đầy đủ.
  5. Không che được: lockfile khoá phiên bản đang có; nếu phiên bản trong lock đã độc từ lúc được thêm vào, lockfile khoá luôn cái độc. Lockfile chỉ có giá trị khi diff của nó được review (mục 4.3, 6.1) và có quét (mục 4.4).
flowchart TD
  DEV["Dev: composer require / npm install<br/>(máy dev, có review)"] --> LOCK["composer.lock / package-lock.json<br/>được commit"]
  LOCK --> MR["Merge request: diff lockfile"]
  MR --> CI["CI: composer validate --strict<br/>+ composer install / npm ci"]
  CI --> Q{"Lock khớp manifest?"}
  Q -- "Không" --> STOP["Dừng build"]
  Q -- "Có" --> IMG["Image chứa đúng bộ đã review"]
Cách nói khi phỏng vấn: "File khai báo dependency ghi ý định, lockfile ghi quyết định: đúng phiên bản nào, tải từ đâu, kèm hash. Em commit lockfile để mọi thay đổi dependency hiện ra trong merge request, và trên CI chỉ dùng lệnh cài theo lock — composer install, npm ci — chứ không dùng lệnh giải lại phiên bản. Nếu lock lệch với file khai báo thì build dừng. Nhưng lockfile chỉ khoá cái đang có, nên nó phải đi cùng review diff và quét lỗ hổng."

4.2 Code chạy lúc cài: chỉ cho phép package đã review

// ===== SAI: Laravel — cho mọi plugin chạy để "khỏi bị hỏi" =====
"config": {
    "allow-plugins": true
}

// ===== SAI: NestJS — bản npm cũ, cài trên CI không chặn script =====
// npm install   (mọi preinstall/postinstall của mọi dependency chạy với token CI trong env)

// ===== ĐÚNG: Laravel — composer.json =====
"config": {
    "allow-plugins": {
        "pestphp/pest-plugin": true,
        "php-http/discovery": false
    },
    "policy": {
        "abandoned": { "block": true }
    },
    "secure-http": true
}

// ===== ĐÚNG: NestJS — package.json (npm CLI v12: script của dependency bị chặn mặc định) =====
{
  "name": "lms-api",
  "allowScripts": {
    "bcrypt@6.0.0": true
  }
}
// Quản lý bằng lệnh, không sửa tay:
//   npm approve-scripts --allow-scripts-pending   (xem package nào đang bị bỏ qua script)
//   npm approve-scripts bcrypt                    (ghim theo phiên bản đang cài)
//   npm deny-scripts some-legacy-lib              (ghi false: từ chối rõ ràng)

// ===== ĐÚNG: NestJS — bản npm cũ hơn v12: chặn toàn bộ, chạy lại có chọn lọc =====
// npm ci --ignore-scripts
// npm rebuild bcrypt          (chỉ package cần build native)
  1. Composer allow-plugins: theo docs Composer, key là pattern tên package, true cho phép plugin chạy code, false từ chối và tắt lời nhắc. Đặt allow-plugins: true cho mọi plugin là mở cửa cho bất kỳ package nào tự khai báo là plugin — kể cả package bắc cầu mới xuất hiện trong lần cập nhật sau. Danh sách tường minh biến "package mới muốn chạy code lúc cài" thành một thay đổi phải sửa composer.json, tức là phải qua review.
  2. Composer config.policy: docs Composer hiện hành mô tả policy là cấu hình thống nhất cho dependency có advisory, bị gắn cờ malware, bị bỏ rơi và policy tự định nghĩa; policy.advisories.block mặc định true — phiên bản có advisory bảo mật bị chặn khi update/require/remove (thay cho key cũ audit.block-insecure, đã deprecated). policy.abandoned.block mặc định false; bật lên để không cài thêm package đã bị đánh dấu bỏ rơi. Policy malware còn có thể chặn cả lúc composer install qua block-scope. Đây là tính năng mới, Composer bản cũ chỉ có config.audit — kiểm bản Composer của image trước khi dựa vào nó (Chưa verify).
  3. npm allowScripts: theo docs npm CLI v12, install script (preinstall, install, postinstall, và prepare với nguồn không phải registry) của dependency bị chặn mặc định; lệnh cài bỏ qua script của package không có trong allowScripts và in danh sách cuối cùng để review. npm approve-scripts mặc định ghi entry ghim phiên bản (pkg@1.2.3), nên khi package lên bản mới có script mới, nó lại phải được review. --ignore-scripts luôn thắng mọi cấu hình. Docs cũng có cờ --dangerously-allow-all-scripts — tên đã nói lên tất cả; không đưa vào CI.
  4. Trade-off: package cần build native (bcrypt, sharp…) cần script. Cách chọn: ưu tiên package có prebuilt binary hoặc thay bằng bản thuần JS (ví dụ bcryptjs, hoặc scrypt có sẵn trong module crypto của Node nếu phù hợp — chọn theo yêu cầu bảo mật ở Bài 4), nếu không thì cho phép đúng package đó và ghi lý do.
  5. Không che được: code độc trong thân thư viện chạy khi app import nó; script được phép (bcrypt) nếu bị chiếm vẫn chạy. Kiểm soát này giảm bề mặt trên máy dev/CI chứ không thay review.
flowchart TD
  I["Cài dependency"] --> L{"Package có script/plugin?"}
  L -- "Không" --> F["Chép file"]
  L -- "Có" --> A{"Có trong allowScripts /<br/>allow-plugins (đúng phiên bản)?"}
  A -- "Có" --> RUN["Chạy (đã review, có lý do)"]
  A -- "Không" --> SKIP["Bỏ qua + liệt kê để review"]
  SKIP --> MR["Muốn cho phép → sửa manifest → qua MR"]
Cách nói khi phỏng vấn: "Em để mặc định là không package nào được chạy code lúc cài. Composer có danh sách plugin được phép, npm bản mới chặn script của dependency mặc định và giữ danh sách cho phép ghim theo phiên bản trong package.json; bản npm cũ thì cài với cờ bỏ qua script rồi build lại riêng package cần. Như vậy một package muốn chạy code lúc cài phải đi qua một thay đổi có review. Điều này không bảo vệ khỏi code độc trong thân thư viện, nên nó chỉ là một lớp."

4.3 Nguồn package: registry nội bộ, canonical, scope

// ===== SAI: Laravel — repo nội bộ tắt canonical (Composer so phiên bản trên mọi nguồn) =====
"repositories": [
    { "type": "composer", "url": "https://packages.acme.internal", "canonical": false }
]

# ===== SAI: NestJS — .npmrc trỏ cả registry mặc định về một proxy, package nội bộ không có scope =====
registry=https://npm.acme.internal/
# package.json: "billing-client": "^2.0.0"    ← tên trần, có thể trùng tên trên registry public

// ===== ĐÚNG: Laravel — composer.json =====
"repositories": [
    { "type": "composer", "url": "https://packages.acme.internal", "only": ["acme/*"] }
],
"require": {
    "acme/billing-client": "^2.4"
}

# ===== ĐÚNG: NestJS — .npmrc (commit vào repo, không chứa token) =====
@acme:registry=https://npm.acme.internal/
//npm.acme.internal/:_authToken=${NPM_INTERNAL_TOKEN}
# package.json: "@acme/billing-client": "^2.4.0"
  1. Composer canonical: theo docs "Repository priorities", Composer 2 coi mọi repository là canonical mặc định: tìm package từ repo trên xuống, gặp ở đâu thì dừng ở đó; packagist.org luôn được thêm ngầm ở cuối. Ví dụ chính thức của docs: nếu repo riêng không canonical, một foo/bar 2.999 publish lên packagist.org sẽ thắng bản 2.4.3 nội bộ. Vì vậy "canonical": false là cờ đỏ khi review.
  2. Giới hạn phạm vi repo: filter only (và exclude) trong docs cùng trang cho phép một repo chỉ cung cấp các package khớp pattern — repo nội bộ chỉ phục vụ acme/*, không vô tình "che" package public cùng tên (Chưa verify cú pháp chính xác của filter trong bản Composer đang dùng).
  3. npm scope: mọi package nội bộ mang scope @acme, và dòng @acme:registry=… gán scope đó cho registry nội bộ. Package không có scope đi registry mặc định. Kết quả: không có package nào có thể "vừa ở nội bộ vừa ở public" — tên đã chứa nguồn. Nên giữ chỗ (reserve) scope @acme trên registry public để không ai khác đăng ký.
  4. Token không nằm trong repo: .npmrc commit dòng tham chiếu biến môi trường; giá trị thật là biến CI protected (mục 4.6). Token chỉ cần quyền đọc registry.
  5. Proxy/mirror nội bộ (Nexus, Artifactory, GitLab Package Registry…) cho thêm một chốt: chỉ package đã được mirror mới cài được, và có chỗ để chặn một phiên bản đang bị điều tra. Đổi lại là một hệ thống phải vận hành và vá — nếu không có người trực thì proxy trở thành điểm hỏng mới.
flowchart TD
  N["Cần @acme/billing-client"] --> S{"Tên có scope gán registry?"}
  S -- "Có: @acme → registry nội bộ" --> INT["Chỉ hỏi registry nội bộ"]
  S -- "Không, tên trần" --> ANY["Hỏi registry mặc định;<br/>có thể trùng tên public"]
  P["Composer: acme/billing-client"] --> C{"Repo nội bộ canonical<br/>(mặc định Composer 2)?"}
  C -- "Có" --> INT2["Dừng ở repo nội bộ"]
  C -- "canonical: false" --> CMP["So phiên bản mọi nguồn → rủi ro"]
Cách nói khi phỏng vấn: "Để tránh nhầm nguồn, em làm sao cho tên package tự chứa nguồn của nó. Với npm, mọi package nội bộ mang một scope riêng, scope đó gán cho registry nội bộ, và em giữ chỗ scope đó trên registry public. Với Composer, repo nội bộ để canonical như mặc định và chỉ phục vụ namespace của công ty. Khi review, thấy ai tắt canonical hay thêm package nội bộ không có scope là em hỏi ngay."

4.4 Quét lỗ hổng và chữ ký: trong MR và theo lịch

# ===== SAI: chỉ quét khi có merge request, và cho phép lỗi =====
audit:
  script: [ "composer audit || true" ]
  rules: [ { if: '$CI_PIPELINE_SOURCE == "merge_request_event"' } ]

# ===== ĐÚNG: .gitlab-ci.yml (stage quality) =====
composer-audit:
  stage: quality
  image: composer:2
  script:
    - composer audit --locked --no-dev --format=plain
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_PIPELINE_SOURCE == "schedule"'        # pipeline lịch hằng ngày trên main

npm-audit:
  stage: quality
  image: node:22-alpine
  script:
    - npm ci --ignore-scripts
    - npm audit --omit=dev --audit-level=high
    - npm audit signatures                            # kiểm chữ ký registry + provenance attestation
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
  1. Hai thời điểm quét, hai mục đích. Quét trong MR chặn thêm mới một phiên bản có lỗ hổng. Quét theo lịch (GitLab pipeline schedule) bắt lỗ hổng công bố sau trên những gì đã deploy — đúng kịch bản 3.1. Thiếu cái thứ hai là thiếu nửa giá trị.
  2. composer audit đọc advisory từ nguồn được cấu hình (mặc định Packagist, tổng hợp từ GitHub Advisory Database và FriendsOfPHP); --locked quét theo lockfile mà không cần cài. Exit code khác 0 khi có advisory. Viết || true là biến kiểm soát thành trang trí.
  3. npm audit --audit-level=high chỉ fail khi có lỗ hổng từ mức high trở lên — một cách thoả thuận ngưỡng để pipeline không đỏ vì mọi advisory low. --omit=dev tập trung vào thứ chạy production, nhưng đừng quên devDependency vẫn chạy trên máy dev và CI (chỗ có token) — nên quét đủ ở pipeline lịch.
  4. npm audit signatures (npm ≥ 8.15.0, cần cài bằng npm install/npm ci trước): kiểm chữ ký registry của mọi package đã cài và kiểm cả provenance attestation nếu package có. Docs npm nhắc phải dùng npm CLI mới, thường mới hơn bản đi kèm Node.
  5. Giới hạn quan trọng nhất: audit chỉ biết những gì đã có advisory. Một phiên bản độc mới publish 3 giờ trước thường chưa có advisory — audit sạch. Chữ ký registry chứng minh package đến từ registry, không chứng minh người publish là người tốt. Đó là lý do cần cooldown (4.8) và review lockfile (6.1). OSV (osv.dev) là cơ sở dữ liệu mở OWASP gợi ý theo dõi thêm, dùng được qua osv-scanner nếu muốn một công cụ chung cho nhiều ngôn ngữ.
  6. Thoả thuận trước khi bật chặn: advisory mới có thể làm main đỏ dù không ai đổi code (bài Pint + static analysis đã nhắc). Cần quy ước: mức nào chặn merge, mức nào chỉ cảnh báo, SLA vá theo mức (ví dụ minh hoạ: critical 2 ngày, high 7 ngày), và cách ghi ngoại lệ có hạn (audit.ignore của Composer kèm lý do).
flowchart TD
  MR["Merge request"] --> A1["audit + signatures"]
  SCH["Pipeline lịch hằng ngày<br/>(main, không cần commit)"] --> A2["audit trên lockfile đang deploy"]
  A1 --> G{"Có advisory ≥ ngưỡng?"}
  A2 --> G
  G -- "Có, trong MR" --> BLK["Chặn merge"]
  G -- "Có, từ lịch" --> ISS["Mở issue/MR vá + đếm SLA"]
  G -- "Không" --> PASS["Qua — nhưng chưa có advisory<br/>không có nghĩa là sạch"]
Cách nói khi phỏng vấn: "Em quét ở hai thời điểm. Trong merge request để không thêm mới một phiên bản có lỗ hổng, và theo lịch hằng ngày trên nhánh chính để bắt lỗ hổng công bố sau khi đã deploy. Với npm em kiểm thêm chữ ký registry. Nhưng em luôn nói rõ giới hạn: công cụ quét chỉ biết những gì đã có advisory, một bản độc mới phát hành vài giờ thì quét vẫn sạch, nên phải có thêm thời gian chờ và review lockfile."

4.5 Ghim base image và template CI theo nội dung

## ===== SAI =====
FROM php:fpm-alpine                     # tag trôi: hôm nay 8.4, mai có thể 8.5
FROM node:latest
# .gitlab-ci.yml
include:
  - project: 'platform/ci-templates'
    ref: main                           # ai push lên main của repo template là đổi pipeline của mọi project
    file: '/php.yml'

## ===== ĐÚNG: Dockerfile =====
FROM php:8.4-fpm-alpine@sha256:<digest-đã-review>
FROM node:22-alpine@sha256:<digest-đã-review>

## ===== ĐÚNG: .gitlab-ci.yml =====
include:
  - project: 'platform/ci-templates'
    ref: 9f1c2ab4e7d0c6b5a3f28e1d4c7b6a5f0e9d8c7b   # commit SHA đầy đủ, đổi qua MR
    file: '/php.yml'

default:
  image: composer:2@sha256:<digest-đã-review>
  1. Digest là nội dung, tag là tên. php:8.4-fpm-alpine@sha256:… — khi có digest, Docker kéo đúng image có digest đó; tag chỉ để người đọc biết đó là bản gì. Hai lần build cùng Dockerfile ra cùng base. Đây cũng là điều kiện để "build một lần, promote digest" (bài Git workflow) có nghĩa trọn vẹn.
  2. Ghim không có nghĩa là đóng băng. Base image được vá OS thường xuyên; ghim digest mà không ai cập nhật là đổi rủi ro "nhận thay đổi không review" lấy rủi ro "không bao giờ nhận bản vá" (CWE-1329). Phải đi kèm bot cập nhật digest: Renovate có preset ghim digest Docker và mở MR khi digest mới xuất hiện (mục 4.8).
  3. Template CI theo SHA: GitLab include: project nhận ref là nhánh, tag hoặc commit SHA. Ghim SHA biến mọi thay đổi template thành MR ở từng project dùng nó. Đổi lại, sửa một lỗi trong template phải lan qua nhiều MR — chấp nhận được nếu có bot; nếu không, tối thiểu ghim theo protected tag của repo template và giới hạn ai được tạo tag.
  4. Bài học tj-actions áp dụng cho GitLab: mọi thứ bên ngoài pipeline kéo vào lúc chạy — template, image của job, script curl … | sh — đều cần ghim theo nội dung. Script tải về lúc chạy rồi pipe vào shell là dạng khó ghim nhất; thay bằng image đã build sẵn có digest.
flowchart TD
  T["Tham chiếu ngoài: base image, template CI, image của job"] --> G{"Ghim theo gì?"}
  G -- "Tag / nhánh" --> D["Nội dung đổi không qua review"]
  G -- "Digest / commit SHA" --> R["Nội dung cố định"]
  R --> BOT["Bot thấy digest/SHA mới → mở MR"]
  BOT --> RV["Review + CI → merge"]
  R -. "không có bot" .-> OLD["Đóng băng, không nhận bản vá"]
Cách nói khi phỏng vấn: "Base image, template CI và image của từng job em đều ghim theo digest hoặc commit SHA, vì đó là nội dung chứ không phải tên. Nhưng ghim mà không cập nhật thì thành đóng băng, không nhận bản vá hệ điều hành, nên luôn đi kèm một bot mở merge request khi có digest mới. Như vậy mọi thay đổi từ bên ngoài vào pipeline đều đi qua review, kể cả bản vá."

4.6 CI token: quyền tối thiểu, bí mật theo môi trường

# ===== SAI: một token cá nhân quyền rộng dùng cho mọi job, biến không protected =====
variables:
  DEPLOY_TOKEN: $MAINTAINER_PAT          # PAT của một người, quyền api trên cả group
test:
  script: [ "npm ci", "npm test" ]       # job chạy code từ MR vẫn thấy DEPLOY_TOKEN

# ===== ĐÚNG: .gitlab-ci.yml =====
test:
  stage: test
  script:
    - npm ci
    - npm test
  # không biến bí mật nào được khai báo ở đây; job MR chỉ có CI_JOB_TOKEN của chính project

publish-image:
  stage: build
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_JOB_TOKEN" "$CI_REGISTRY"
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

deploy-production:
  stage: deploy
  environment: production                # biến PROD_* chỉ có scope environment này, là protected
  id_tokens:
    CLOUD_ID_TOKEN:
      aud: https://deploy.acme.internal  # OIDC token ngắn hạn thay cho khoá dài hạn
  script:
    - ./deploy.sh "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
      when: manual
  1. Job chạy code của MR không được thấy bí mật deploy. Biến protected trong GitLab chỉ được đưa vào pipeline chạy trên nhánh/tag protected; biến có environment scope chỉ vào job khai báo đúng environment. Job test chạy npm ci — tức là chạy code của mọi dependency (nếu script được phép) — nên phải là job "nghèo" nhất.
  2. CI_JOB_TOKEN thay cho token cá nhân: theo docs GitLab, token sinh riêng cho mỗi job, chỉ có hiệu lực khi job chạy, có quyền của người kích hoạt pipeline nhưng truy cập ít tài nguyên hơn personal access token. Allowlist "Limit access to this project" (bật mặc định ở project mới) quyết định project nào được dùng job token của họ để truy cập project này; docs ghi rõ tắt nó là rủi ro bảo mật.
  3. OIDC id_tokens thay cho khoá dài hạn: job nhận một JWT ngắn hạn có aud riêng, bên nhận (cloud, Vault, dịch vụ deploy) đổi nó lấy quyền tạm theo claim (project, nhánh, environment). Không còn khoá deploy nằm im trong biến CI chờ bị đọc. Chính docs npm cũng dùng cơ chế này (SIGSTORE_ID_TOKEN) để publish có provenance.
  4. Tách nhiệm vụ: deploy production chạy từ tag protected và cần bấm tay — đáp ứng ý OWASP "không ai một mình đưa code từ viết tới production". Bài Git workflow có cấu hình promote bằng tag đầy đủ.
  5. Không che được: runner dùng chung (shared runner) mà job trước để lại file, cache, hay Docker socket mở cho job sau là đường đi giữa các job — cần runner riêng cho job có bí mật deploy (SLSA L3 đòi hỏi đúng tính cô lập này).
flowchart TD
  MRJ["Job MR: cài + test<br/>(chạy code dependency)"] --> NO["Không có biến bí mật<br/>chỉ CI_JOB_TOKEN của project"]
  MAIN["Job main: build + push image"] --> JT["CI_JOB_TOKEN → registry của project"]
  TAG["Tag protected + bấm tay"] --> DEP["Job deploy: environment production"]
  DEP --> OIDC["id_tokens: JWT ngắn hạn<br/>đổi lấy quyền tạm"]
  DEP --> PV["Biến protected, scope production"]
Cách nói khi phỏng vấn: "Em chia job theo mức tin cậy. Job chạy code của merge request, tức là chạy cả code của dependency, không có bí mật nào ngoài token tạm của chính project. Job build dùng token tạm của job để đẩy image. Chỉ job deploy chạy từ tag được bảo vệ, có bấm tay, mới thấy biến của môi trường production, và tốt nhất là nhận token OIDC ngắn hạn thay vì một khoá dài hạn nằm sẵn trong biến CI."

4.7 SBOM: biết mình đang chạy gì, theo từng image

# ===== SAI: không có danh sách component; khi có CVE lớn thì grep thủ công từng server =====

# ===== ĐÚNG: Laravel — sinh SBOM CycloneDX trong job build =====
# composer.json: "allow-plugins": { "cyclonedx/cyclonedx-php-composer": true }
composer CycloneDX:make-sbom --omit=dev --output-format=JSON --output-file=sbom-php.cdx.json

# ===== ĐÚNG: NestJS — npm có sẵn lệnh sbom =====
npm sbom --sbom-format cyclonedx --omit dev > sbom-node.cdx.json

# ===== Gắn SBOM với đúng image đã build (artifact của job, lưu cùng digest) =====
sbom-php:
  stage: build
  script:
    - composer CycloneDX:make-sbom --omit=dev --output-format=JSON --output-file=sbom-php.cdx.json
  artifacts:
    paths: [ sbom-php.cdx.json ]
    expire_in: 1 year
  1. SBOM (Software Bill of Materials — danh mục thành phần phần mềm) là danh sách máy đọc được: component nào, phiên bản nào, định danh chuẩn (purl), quan hệ phụ thuộc. CycloneDX là chuẩn do OWASP phát triển; SPDX là chuẩn còn lại phổ biến. npm sbom hỗ trợ cả hai (--sbom-format cyclonedx|spdx); với Composer dùng plugin cyclonedx/cyclonedx-php-composer (vì là plugin nên phải có trong allow-plugins — liên hệ 4.2).
  2. Giá trị thật nằm ở câu hỏi ngược. Khi Log4Shell hay xz được công bố, câu hỏi đầu tiên là "chúng ta có đang chạy nó không, ở service nào, môi trường nào". Không có SBOM thì trả lời bằng cách đăng nhập từng server. Có SBOM gắn với digest của image, nạp vào một công cụ như OWASP Dependency-Track (OWASP gợi ý), câu trả lời là một truy vấn — và Dependency-Track tự đối chiếu với nguồn lỗ hổng liên tục, thay cho cổng 5 trong Hình 1.
  3. Sinh SBOM từ cái gì: từ lockfile (nhanh, chính xác cho dependency của ngôn ngữ) hay từ image đã build (bắt được cả package OS của base image — các công cụ quét image làm việc này). Hai cách bổ sung nhau; lockfile không biết openssl của Alpine, quét image không biết dev dependency đã chạy trên CI.
  4. Không che được: SBOM là danh sách, không phải kiểm soát. Nó không chặn gì; giá trị phụ thuộc vào việc có ai/công cụ nào đọc nó liên tục. SBOM cũng chỉ đúng nếu sinh từ đúng artifact được deploy — sinh từ nhánh main trong khi production chạy tag cũ là danh sách sai.
flowchart TD
  B["Build image (digest D)"] --> S1["SBOM từ lockfile<br/>(composer / npm)"]
  B --> S2["SBOM/quét từ image<br/>(package OS)"]
  S1 --> DT["Kho SBOM (vd Dependency-Track)<br/>gắn với digest D, môi trường"]
  S2 --> DT
  ADV["Advisory mới (CVE, OSV, GHSA)"] --> DT
  DT --> Q["Trả lời: service nào, môi trường nào<br/>đang chạy component bị ảnh hưởng"]
Cách nói khi phỏng vấn: "SBOM là danh mục máy đọc được của mọi thành phần trong một artifact. Em sinh nó ở job build, theo chuẩn CycloneDX, gắn với đúng digest của image đã deploy, rồi nạp vào một kho đối chiếu liên tục với nguồn lỗ hổng. Giá trị thật là khi một lỗ hổng lớn được công bố, câu hỏi chúng ta có bị ảnh hưởng không được trả lời bằng một truy vấn thay vì đăng nhập từng server. Bản thân SBOM không chặn gì cả, nó chỉ có ích khi có người hoặc công cụ đọc nó."

4.8 Chính sách cập nhật: bot, cooldown, gom nhóm

// ===== SAI: tự merge mọi bản cập nhật ngay khi phát hành =====
{
  "extends": ["config:recommended"],
  "automerge": true
}

// ===== ĐÚNG: renovate.json =====
{
  "extends": ["config:recommended", "docker:pinDigests"],
  "minimumReleaseAge": "7 days",
  "schedule": ["before 6am on monday"],
  "packageRules": [
    {
      "matchUpdateTypes": ["patch", "digest"],
      "matchCurrentVersion": "!/^0/",
      "automerge": true
    },
    {
      "matchUpdateTypes": ["major"],
      "automerge": false,
      "labels": ["dependency-major", "needs-review"]
    },
    {
      "matchPackageNames": ["laravel/**", "@nestjs/**"],
      "groupName": "framework"
    }
  ]
}
  1. minimumReleaseAge (cooldown — thời gian chờ): theo docs Renovate, bot chỉ đề xuất một phiên bản khi nó đã được phát hành đủ thời gian (tên cũ là stabilityDays); mục đích docs ghi rõ là giảm rủi ro chuỗi cung ứng, cho cộng đồng và công cụ bảo mật thời gian phát hiện phiên bản độc. Thời gian chờ tính riêng cho từng phiên bản. Docs còn khuyên đặt cả trong cấu hình của package manager, vì khi bot gọi package manager cập nhật lockfile, một dependency bắc cầu mới có thể lọt vào mà bot không kiểm tuổi.
  2. Chọn N bao nhiêu: đây là trade-off giữa hai rủi ro ngược chiều. N lớn: bản độc có nhiều thời gian bị phát hiện trước khi tới lượt mình, nhưng bản vá bảo mật cũng tới chậm N ngày. Cách giải: cooldown cho cập nhật thường, và một đường riêng cho cập nhật bảo mật (pipeline audit ở 4.4 phát hiện → người quyết định nâng ngay, có kiểm tra bản sửa đã được phát hành bởi đúng maintainer). Con số 7 ngày ở trên là ví dụ minh hoạ, không phải chuẩn.
  3. Automerge có điều kiện: chỉ patch và digest, chỉ package đã ≥ 1.0 (0.x thì patch có thể phá), chỉ khi CI xanh. Major luôn cần người. Gom nhóm framework để không phải review 15 MR rời cho một lần nâng Laravel.
  4. Dependabot (GitHub) có các tuỳ chọn tương đương (lịch, gom nhóm, và gần đây là thời gian chờ); team dùng GitLab thì Renovate là lựa chọn phổ biến vì chạy được như một job CI.
  5. Vì sao không "cứ để yên": OWASP vừa nói "upgrade only when there is need" vừa cảnh báo vá theo quý gây phơi nhiễm hàng tháng. Hai ý không mâu thuẫn: không chạy theo mọi phiên bản mới cho vui, nhưng có một nhịp cập nhật đều đặn để khi cần vá gấp, khoảng cách từ bản đang chạy tới bản vá là một bước nhỏ chứ không phải ba major.
flowchart TD
  NEW["Phiên bản mới được phát hành"] --> AGE{"Đủ minimumReleaseAge?"}
  AGE -- "Chưa" --> PEND["Chờ (status pending)"]
  AGE -- "Đủ" --> TYPE{"Loại cập nhật?"}
  TYPE -- "patch / digest, ≥ 1.0" --> AUTO["MR + automerge nếu CI xanh"]
  TYPE -- "minor" --> REV["MR, 1 người review"]
  TYPE -- "major" --> MAJ["MR, review + kế hoạch nâng cấp"]
  SEC["Audit báo lỗ hổng"] --> FAST["Đường nhanh: người quyết định,<br/>bỏ qua cooldown có kiểm soát"]
Cách nói khi phỏng vấn: "Em dùng bot cập nhật với thời gian chờ: một phiên bản mới chỉ được đề xuất sau vài ngày, để nếu nó là bản độc thì cộng đồng đã kịp phát hiện. Bản vá nhỏ và cập nhật digest được tự merge khi CI xanh, bản lớn luôn cần người. Thời gian chờ làm bản vá bảo mật đến chậm, nên em tách một đường nhanh riêng: khi quét báo lỗ hổng thì người quyết định nâng ngay. Cập nhật đều đặn còn giúp khi cần vá gấp thì chỉ cách một bước nhỏ."

4.9 Script bên thứ ba ở front-end: tự host, hoặc khoá bằng hash

<!-- ===== SAI: Blade / Next.js layout nhúng script trực tiếp từ CDN, không kiểm nội dung ===== -->
<script src="https://cdn.example-polyfill.io/v3/polyfill.min.js"></script>

<!-- ===== ĐÚNG (ưu tiên): cài qua npm, bundle bằng Vite/Next — đi qua lockfile, audit, review ===== -->
@vite(['resources/js/app.js'])

<!-- ===== ĐÚNG (khi bắt buộc dùng CDN cho file tĩnh, có phiên bản cố định): Subresource Integrity ===== -->
<script src="https://cdn.jsdelivr.net/npm/some-lib@1.2.3/dist/some-lib.min.js"
        integrity="sha384-<hash-của-đúng-file-đã-review>"
        crossorigin="anonymous"></script>
  1. Bài học polyfill.io: script tải từ domain của bên thứ ba lúc runtime nằm ngoài mọi kiểm soát ở mục 4.1–4.8: không có trong lockfile, không qua audit, không qua review. Tự host qua bundler đưa nó trở lại trong chuỗi có kiểm soát.
  2. SRI (Subresource Integrity): trình duyệt tính hash của file tải về và từ chối chạy nếu khác giá trị integrity. Chỉ dùng được với file tĩnh, có phiên bản cố định — polyfill.io sinh nội dung động theo header, nên không thể khoá bằng SRI; đó cũng là một lý do để không dùng dạng dịch vụ đó.
  3. CSP giới hạn domain được tải script là lớp bổ sung (Bài 2 đã nói về header). Cơ chế kiểm toàn vẹn chi tiết thuộc Bài 8 — A08.
flowchart TD
  S["Cần thư viện JS ở trình duyệt"] --> Q{"Lấy thế nào?"}
  Q -- "npm + bundler (tự host)" --> IN["Trong chuỗi: lockfile, audit, review"]
  Q -- "CDN, file tĩnh có phiên bản" --> SRI["Thêm integrity hash + CSP"]
  Q -- "Dịch vụ sinh nội dung động" --> OUT["Ngoài mọi kiểm soát → tránh"]
Cách nói khi phỏng vấn: "Một thẻ script trỏ tới CDN của bên thứ ba là code chạy trên trình duyệt người dùng mà không đi qua lockfile, quét hay review nào. Em ưu tiên cài qua npm và tự đóng gói. Nếu bắt buộc dùng CDN thì chỉ với file tĩnh có phiên bản cố định, kèm hash toàn vẹn để trình duyệt từ chối nếu nội dung bị đổi. Những dịch vụ sinh nội dung động như polyfill.io thì không khoá được bằng hash, nên tránh hẳn."

5. Phòng thủ nhiều lớp — mỗi lớp chặn gì, không chặn gì

Không có kiểm soát nào che được cả chuỗi. Câu trả lời tốt là ma trận: mỗi lớp nhắm vào một giả định sai ở mục 3, và nói rõ chỗ nó không với tới.

5.1 Bảng tổng hợp

LớpChặn / giảmKhông chặnChi phí, đánh đổi
Lockfile + cài theo lock (4.1)Tự nhận phiên bản mới không review (3.2)Phiên bản đã độc từ lúc thêm vào lockGần như miễn phí; cần kỷ luật không chạy update trên CI
Chặn install script / plugin (4.2)Code chạy lúc cài trên máy dev/CI (3.3, kiểu worm)Code độc trong thân thư viện; package được cho phép bị chiếmPackage native cần ngoại lệ; npm cũ cần cờ thủ công
Scope + canonical + mirror (4.3)Nhầm nguồn, dependency confusion (3.4)Typosquatting do dev tự gõ sai; package public bị chiếmMirror là hệ thống phải vận hành
Audit trong MR + theo lịch (4.4)Lỗ hổng đã có advisory (3.1)Bản độc mới, chưa có advisory; zero-dayMain có thể đỏ không do ai đổi code — cần SLA và quy trình ngoại lệ
Ghim digest / SHA (4.5)Tag/URL bị trỏ lại (3.5)Nội dung đã độc lúc ghimPhải có bot, nếu không thành đóng băng
Token tối thiểu, OIDC, tách job (4.6)Giảm thiệt hại khi CI chạy code độc (3.3, 3.6)Bản thân việc code độc chạyThiết kế pipeline phức tạp hơn; runner riêng tốn tiền
SBOM + kho đối chiếu (4.7)Thời gian trả lời "có bị ảnh hưởng không"Không chặn gì — chỉ là thông tinMột hệ thống nữa (Dependency-Track) cần người đọc cảnh báo
Cooldown + chính sách cập nhật (4.8)Bản độc mới (3.2, 3.3) trong cửa sổ trước khi bị gỡMã độc nằm im lâu (event-stream ~2 tháng, xz)Bản vá bảo mật tới chậm — cần đường nhanh
Provenance, ký, build một lần (3.6)Artifact khác mã nguồn, can thiệp sau buildBuild platform bị chiếm ở mức đủ sâu; maintainer xấuHạ tầng ký; với package nội bộ có thể vượt nhu cầu của team nhỏ
Rollout từng phần, canaryThu nhỏ phạm vi khi bản cập nhật của vendor tin cậy bị chiếm (OWASP khuyến nghị)Mã độc có điều kiện, chậm kích hoạtHạ tầng deploy phức tạp hơn — xem Canary

5.2 Hai trường phái về cập nhật — và vì sao cả hai đúng

  1. "Cập nhật nhanh, liên tục": lập luận rằng phần lớn sự cố thực tế là lỗ hổng đã biết mà không ai vá (Log4Shell kéo dài hàng năm ở các hệ thống không cập nhật). Bot tự merge, khoảng cách với bản mới nhất luôn nhỏ.
  2. "Chậm lại có chủ ý": lập luận rằng các vụ 2021–2025 (ua-parser-js, chuỗi worm npm 2025) là bản mới mang mã độc, và người nhận nhanh nhất là nạn nhân đầu tiên. Cooldown, pin chặt, chỉ nâng khi cần.
  3. Tổng hợp của em: hai trường phái bảo vệ khỏi hai mối đe doạ khác nhau, nên ghép theo loại cập nhật: cập nhật thường đi chậm có chủ ý (cooldown vài ngày, gom nhóm, lịch cố định); cập nhật bảo mật đi đường nhanh có người quyết định. Bối cảnh quyết định độ nghiêng: thư viện chạy ở biên (parser input, auth) nên đi nhanh hơn; thư viện build-time ít phơi nhiễm runtime thì có thể chậm hơn — nhưng nhớ rằng chính thư viện build-time chạy trên CI có token.

5.3 Ít dependency hơn là kiểm soát rẻ nhất

OWASP ghi "Reduce attack surface by removing unused dependencies". Mỗi dependency bị gỡ là bớt một nhóm người phải tin, bớt một nguồn advisory, bớt một mục trong SBOM. Câu hỏi ở review khi thêm một package: (1) tự viết mất bao lâu — một hàm 20 dòng thì tự viết; (2) package có bao nhiêu dependency bắc cầu; (3) có ai bảo trì, release gần nhất khi nào; (4) có install script không; (5) có lựa chọn nằm sẵn trong framework/runtime không (Laravel và Node hiện đại đã có sẵn nhiều thứ từng cần package riêng). Đánh đổi: tự viết thì team phải tự bảo trì và có thể viết sai — với crypto, parser phức tạp, luôn dùng thư viện đã kiểm chứng.

5.4 Nếu chỉ có một sprint: thứ tự ưu tiên

  1. Commit lockfile, CI chỉ dùng composer install/npm ci — một buổi chiều.
  2. Audit trong MR + pipeline lịch hằng ngày, có SLA theo mức độ — một ngày.
  3. Tách bí mật: job MR không có biến bí mật, biến deploy protected + environment scope — một ngày, giảm thiệt hại lớn nhất.
  4. Chặn install script (npm v12 mặc định; bản cũ --ignore-scripts), allow-plugins tường minh — nửa ngày.
  5. Ghim digest base image + template CI, bật bot cập nhật có cooldown — một hai ngày.
  6. SBOM theo image + kho đối chiếu — khi đã có người đọc cảnh báo.
  7. Provenance/ký artifact nội bộ — khi team publish package cho bên khác dùng, hoặc yêu cầu tuân thủ đòi hỏi.
Cách nói khi phỏng vấn: "Nếu chỉ có một sprint, em làm theo thứ tự chi phí trên giá trị: khoá lockfile và cài đúng theo lock, quét trong merge request và theo lịch, rồi tách bí mật để job chạy code của dependency không thấy token deploy. Sau đó mới tới chặn script cài đặt, ghim digest kèm bot cập nhật có thời gian chờ. SBOM và ký artifact đến sau, khi đã có người thực sự đọc cảnh báo và có nhu cầu tuân thủ."

6. Phát hiện & vận hành

6.1 Code review: đọc diff lockfile

Lockfile diff thường bị bỏ qua vì dài. Không cần đọc từng dòng; cần soi đúng các tín hiệu:

Tín hiệu trong diffVì sao đáng hỏi
Package mới xuất hiện (trực tiếp hoặc bắc cầu) trong một MR chỉ "nâng patch"Bản patch thêm dependency mới là mẫu của event-stream
URL resolved (npm) / dist.url (Composer) đổi sang registry/host khácNhầm nguồn, hoặc mirror bị cấu hình sai
integrity đổi mà version không đổiCùng phiên bản không được phép có nội dung khác — dừng và điều tra
Package có hasInstallScript: true mới (npm lock)Đòi chạy code lúc cài — cần lý do
Tên gần giống package phổ biếnTyposquatting
composer.json thêm repository, tắt canonical, allow-plugins: true, secure-http: falseNới chốt chặn — cần người có trách nhiệm duyệt (CODEOWNERS cho file manifest)

Đặt composer.json, composer.lock, package.json, lockfile npm, .npmrc, Dockerfile, .gitlab-ci.yml, renovate.json dưới CODEOWNERS (một nhóm nhỏ phải approve) — đây là "separation of duty" của OWASP áp dụng vào đúng những file định nghĩa chuỗi cung ứng. Bài 0 có checklist review theo từng mục.

6.2 Kiểm tra tự động trong CI

6.3 SAST, DAST, SCA: phủ được gì

SCA (Software Composition Analysis — phân tích thành phần) là nhóm công cụ của A03: đọc manifest/lockfile/image, đối chiếu với cơ sở dữ liệu lỗ hổng. SAST (phân tích code tĩnh) gần như không thấy gì của A03 vì nó phân tích code của team. DAST (quét từ ngoài vào app đang chạy) chỉ thấy lỗ hổng của component khi nó lộ ra qua HTTP (ví dụ banner phiên bản). Không công cụ nào trong ba nhóm phát hiện được một package độc chưa được báo cáo — đó là việc của cooldown, review và giám sát hành vi.

6.4 Giám sát runtime và CI, metric vận hành

  1. Egress của CI runner: job cài + test không cần gọi ra Internet ngoài registry. Giới hạn egress của runner (hoặc ít nhất log kết nối ra) biến hành vi "gửi bí mật ra ngoài" thành thứ thấy được. Tương tự với container production: app gọi ra ngoài tới những đích đã biết.
  2. Theo dõi package mình publish: nếu team publish package nội bộ hoặc public, cảnh báo khi có phiên bản mới không do pipeline của mình tạo ra (dấu hiệu token bị lấy — đúng mẫu worm 2025). Publish có provenance làm việc phân biệt này dễ hơn.
  3. Metric đáng theo dõi (ví dụ minh hoạ cách đo, không phải chuẩn):
    • Thời gian từ advisory tới deploy bản vá, theo mức độ (p50, p90) — đo trực tiếp "unnecessary exposure" OWASP nói tới.
    • Số dependency có advisory mức high/critical đang mở, và tuổi của advisory lâu nhất.
    • Số major version mà mỗi framework đang chậm so với bản mới nhất được hỗ trợ.
    • Tỉ lệ image production có SBOM và digest base đã ghim.
  4. Runbook khi có sự cố chuỗi cung ứng công bố: (1) truy vấn SBOM: có dùng không, phiên bản nào, ở đâu; (2) nếu dùng bản bị ảnh hưởng, xác định nó đã chạy ở đâu — chỉ production, hay cả CI (CI có token thì phải giả định token lộ); (3) xoay vòng mọi bí mật nơi nó đã chạy — CISA khuyến nghị đúng việc này trong vụ tj-actions; (4) ghim về bản sạch, build lại, deploy; (5) đọc log egress quanh khoảng thời gian bị ảnh hưởng.
flowchart TD
  NEWS["Sự cố chuỗi cung ứng được công bố"] --> SB["Truy vấn SBOM: có dùng không, ở đâu"]
  SB -- "Không" --> DONE["Ghi nhận, đóng"]
  SB -- "Có" --> WHERE{"Đã chạy ở đâu?"}
  WHERE -- "Cả CI / máy dev" --> ROT["Giả định token lộ → xoay vòng bí mật"]
  WHERE -- "Chỉ production" --> PROD["Đánh giá dữ liệu bị đọc, log egress"]
  ROT --> FIX["Ghim bản sạch → build lại → deploy"]
  PROD --> FIX
Cách nói khi phỏng vấn: "Khi một sự cố chuỗi cung ứng được công bố, em đi theo bốn bước. Đầu tiên truy vấn SBOM để biết có dùng không và ở service nào. Nếu có, xác định nó đã chạy ở đâu: nếu đã chạy trên CI hay máy dev thì phải giả định token đã lộ và xoay vòng toàn bộ. Sau đó ghim về bản sạch, build lại và deploy, rồi đọc log kết nối ra ngoài quanh thời điểm đó để đánh giá dữ liệu có bị gửi đi không."

7. Áp dụng vào hệ thống trong CV

Dựa trên cv-v3.md. Chỗ nào đánh dấu * là giả định, CV không nêu — cần anh xác nhận trước khi dùng trong phỏng vấn.

7.1 LMS Uni — Moodle, plugin bên thứ ba, student portal NestJS

  1. Plugin Moodle là chuỗi cung ứng lớn nhất. Moodle cho phép cài plugin (activity, block, auth, theme…) từ Moodle plugins directory hoặc từ zip. Plugin chạy cùng process PHP với core, cùng quyền đọc config.php (có mật khẩu MySQL) và moodledata trên NFS. Một plugin bên thứ ba bị bỏ rơi (CWE-1104) trên một LMS 100.000 sinh viên là rủi ro thật: nó có thể chặn việc nâng Moodle core (CWE-1329) — đúng điều CV đã kể ở dự án nâng Moodle 2.7 → 3.5 ("rà soát và sửa các plugin tự phát triển theo thay đổi API của core").
    Câu trả lời: danh mục plugin có chủ sở hữu, phiên bản, nguồn, ngày review; plugin cài từ repo git nội bộ đã ghim theo commit thay vì tải zip trực tiếp trên server; cài/nâng plugin chỉ qua pipeline build (không qua giao diện admin trên production)*; trước mỗi lần nâng core, kiểm tra plugin nào chưa hỗ trợ bản mới.
  2. Thư viện bên thứ ba trong Moodle core: Moodle đóng gói sẵn nhiều thư viện PHP/JS trong source và liệt kê chúng trong các file thirdpartylibs.xml — đây gần như là một SBOM thô có sẵn (Chưa verify trên bản Moodle anh đang chạy). Nghĩa là vá lỗ hổng của các thư viện này đi theo nhịp release bảo mật của Moodle — nhịp nâng bản Moodle minor chính là "patch management" của A03.
  3. 3 app node, cùng một artifact. CV ghi Nginx LB trước 3 app node. Nếu mỗi node tự git pull hoặc tự cài plugin*, ba node có thể lệch nhau (Bài 2 nói về drift). Theo tinh thần A03: build một artifact (image hoặc gói release) một lần, kèm SBOM, deploy cùng artifact lên cả 3 node — khớp với bullet "build một lần, deploy cho cả 3 môi trường" đã có trong CV.
  4. Student portal NestJS + Next.js: hai cây node_modules lớn, dùng chung database với Moodle. Một package npm độc trong portal có quyền đọc chính database Moodle. Áp dụng 4.1–4.4 và 4.6: npm ci, chặn install script, audit theo lịch, và user database của portal chỉ có quyền trên các bảng nó cần* (giảm thiệt hại khi một dependency bị chiếm — chỗ này giao với A01).
  5. Worker trên nhiều VPS: mỗi VPS là một nơi chạy cùng code và giữ cùng bí mật. Image có digest giúp biết chắc VPS nào đang chạy gì khi có advisory.

7.2 LMS Micro-learning — Laravel + Livewire + NestJS + Next.js, GitLab CI, Docker

  1. Pipeline đã có nền tảng đúng: CV ghi pipeline GitLab CI (Pint, Larastan, test, build Docker image một lần dùng cho 3 môi trường) và promote bằng tag. Đó chính là "promote artifacts rather than rebuilding" của OWASP. Bổ sung theo A03 là các bước nhỏ: composer audit + npm audit trong stage quality và pipeline lịch; SBOM gắn với digest image; ghim digest base image và template CI; bot cập nhật có cooldown.
  2. Bốn hệ sinh thái package: Composer (Laravel, Livewire), npm (NestJS, Next.js, asset Vite của Laravel), cộng image nền (PHP, Node, PostgreSQL, Redis, MongoDB, MinIO*). Mỗi hệ sinh thái có công cụ audit riêng — SBOM chuẩn CycloneDX là cách gom về một chỗ.
  3. Bí mật trong GitLab CI: pipeline build cho 3 môi trường, nghĩa là có token deploy cho production. Áp dụng 4.6: job MR không có biến bí mật; biến production protected + environment scope; deploy từ tag protected, bấm tay — khớp với quy trình promote bằng tag đã có.
  4. Blue-green giúp rollback nhanh khi bản cập nhật dependency gây sự cố (rollback dưới 1 phút theo CV) — nhưng nhớ: nếu sự cố là package độc đã chạy, rollback image không thu hồi được bí mật đã lộ. Bước xoay vòng bí mật ở 6.4 vẫn phải làm.

7.3 Trả lời mẫu

Cách nói khi phỏng vấn (câu hỏi "anh quản lý rủi ro dependency thế nào ở LMS?"): "Với hệ Moodle, rủi ro lớn nhất là plugin bên thứ ba: chúng chạy cùng quyền với core và có thể chặn việc nâng phiên bản, nên tôi giữ danh mục plugin có người chịu trách nhiệm và kiểm tra tương thích trước mỗi lần nâng core. Với hệ Laravel và NestJS, pipeline GitLab đã build image một lần rồi promote cùng image đó qua các môi trường; trên nền đó tôi khoá lockfile, quét lỗ hổng trong merge request và theo lịch hằng ngày, và tách bí mật để job chạy code của dependency không thấy token deploy. Bước tiếp theo tôi muốn làm là sinh SBOM theo từng image và ghim digest base image kèm bot cập nhật có thời gian chờ."

Lưu ý: câu trên chỉ nên nói những phần anh đã thực sự làm. CV hiện không ghi việc quét dependency hay SBOM — nếu chưa làm, nói ở thì "đề xuất", đừng nói như đã làm.

8. Anti-patterns & bài học thực tế

8.1 Sự cố công khai: điều gì sai và kiểm soát nào giúp

Sự cốĐiều gì sai (giả định nào)Kiểm soát nào giúp
event-stream (2018, npm blog, Snyk)Quyền publish trao cho người lạ; bản mới thêm dependency có mã độc nhắm đúng ví Copay, chỉ chạy khi khớp điều kiệnReview lockfile (dependency mới trong bản nhỏ), cooldown không đủ (mã độc nằm ~2 tháng) — cần tín hiệu "đổi maintainer"
ua-parser-js (10/2021, CISA, CERT-EU)Tài khoản maintainer bị chiếm; 3 phiên bản độc thoả version range của rất nhiều projectLockfile + npm ci; cooldown; chặn install script
Log4Shell (CVE-2021-44228), Struts (CVE-2017-5638)Lỗ hổng trong dependency bắc cầu mà team không biết mình dùngSBOM + quét theo lịch; khả năng nâng nhanh (cập nhật đều đặn)
SolarWinds (2019–2020, OWASP scenario #1)Vendor bị chiếm trong lúc build; bản cập nhật ký hợp lệ tới ~18.000 tổ chứcProvenance/build hardened ở phía vendor (SLSA L3); phía khách hàng: rollout từng phần, giám sát egress
xz-utils (2024, CVE-2024-3094)Chiếm quyền bảo trì qua nhiều năm; phần độc chỉ có trong tarball, không có trên gitBuild từ nguồn git thay vì tarball; provenance; distro stable không bị ảnh hưởng vì nhịp chậm hơn (một dạng cooldown)
polyfill.io (2024, Sansec)Script nhúng từ domain bên thứ ba; domain đổi chủTự host qua bundler; SRI với file tĩnh; CSP
tj-actions/changed-files (03/2025, CISA, CVE-2025-30066)Tham chiếu action theo tag; tag bị trỏ lại; bí mật runner in ra log publicGhim theo commit SHA; bí mật tối thiểu cho job; xoay vòng bí mật sau sự cố
Shai-Hulud (09/2025, OWASP scenario #3, CISA)Post-install script lấy token npm trên máy nạn nhân để tự publish tiếp — wormChặn install script mặc định; token publish ngắn hạn/OIDC thay cho token dài hạn trên máy dev; cooldown
Bybit (2025, OWASP scenario #2, Sygnia)Phần mềm ví của vendor bị chiếm, mã độc chỉ chạy với đúng ví mục tiêuMã độc có điều kiện vượt qua test thường — cần tách quyền ở quy trình nghiệp vụ nhạy cảm (A06), không chỉ kiểm soát kỹ thuật

8.2 Anti-pattern thường gặp khi review

  1. composer update / npm install trên CI hoặc trong Dockerfile — mỗi lần build ra bộ package khác nhau, không ai review.
  2. Không commit lockfile "vì hay conflict" — đổi một phiền toái nhỏ lấy mất toàn bộ khả năng biết mình đang chạy gì.
  3. audit || true, hoặc audit chỉ chạy trong MR — kiểm soát có tên mà không có tác dụng.
  4. allow-plugins: true, --dangerously-allow-all-scripts để "hết bị hỏi".
  5. FROM …:latest, include … ref: main, curl | sh trong pipeline.
  6. Ghim digest nhưng không có bot — đóng băng, base image mang lỗ hổng OS hàng năm.
  7. Một PAT quyền rộng dùng cho mọi job, biến bí mật không protected — job MR đọc được token deploy.
  8. Tự merge mọi cập nhật ngay khi phát hành — trở thành nạn nhân đầu tiên của bản độc.
  9. Giữ plugin/thư viện bị bỏ rơi vì "đang chạy ổn" — tới lúc có lỗ hổng thì không có bản vá và chặn luôn việc nâng framework.
  10. Coi "audit sạch" là "an toàn" — audit chỉ biết advisory đã công bố.

9. Câu hỏi phụ interviewer hay hỏi tiếp

Câu hỏiTrả lời ngắn
A03:2025 khác A06:2021 thế nào?Từ "component có lỗ hổng đã biết" thành mọi sự cố trong build, phân phối, cập nhật: package bị cài mã độc, maintainer bị chiếm, CI bị chiếm. Vào top 3 nhờ survey (50% xếp hạng 1); ít CVE nhưng exploit/impact cao nhất.
A03 và A08 khác nhau ở đâu?A08 là điểm kiểm toàn vẹn cụ thể (không kiểm chữ ký, deserialize dữ liệu không tin cậy); A03 là bức tranh toàn chuỗi (tin ai, phiên bản nào, pipeline nào). OWASP gọi A08 là "lower level".
npm ci khác npm install?npm ci bắt buộc có lock, lỗi nếu lock lệch package.json, xoá node_modules, không bao giờ ghi lock. Dùng trên CI.
Audit sạch có nghĩa là an toàn?Không. Audit chỉ biết advisory đã công bố; bản độc mới phát hành vài giờ thường chưa có advisory. Cần cooldown và review lockfile.
Dependency confusion là gì, chặn thế nào?Cùng tên ở registry nội bộ và public, resolver chọn bản public có phiên bản cao hơn. Composer 2 coi repo là canonical mặc định; npm dùng scope gán registry nội bộ và giữ chỗ scope trên public.
Ghim digest có làm mất bản vá không?Có, nếu không có bot. Ghim + bot mở MR khi có digest mới = vừa tái lập vừa nhận bản vá có review.
Cooldown 7 ngày có làm vá chậm?Có, với cập nhật thường — đó là chủ ý. Cập nhật bảo mật đi đường nhanh có người quyết định. Hai trường phái bảo vệ khỏi hai mối đe doạ khác nhau.
SBOM để làm gì nếu nó không chặn gì?Trả lời trong vài phút câu "có bị ảnh hưởng không, ở đâu" khi có Log4Shell/xz kế tiếp; nạp vào kho đối chiếu (Dependency-Track) để quét liên tục.
SLSA level nào là đủ?Tuỳ bạn là ai: team publish package/artifact cho bên khác nên hướng tới L2–L3 (provenance ký, build platform hardened). Team nội bộ nhỏ: build một lần trên CI được host + promote digest đã cho phần lớn giá trị của L1–L2.
Một package trong CI bị phát hiện độc, việc đầu tiên?Giả định mọi bí mật mà job đó thấy đã lộ → xoay vòng ngay; sau đó mới truy SBOM, ghim bản sạch, build lại, đọc log egress.

10. Chưa verify và Nguồn

Chưa verify

Bài này không có lab. Ngoài nguyên tắc chung đó, các điểm sau đặc biệt cần lab hoặc đối chiếu thêm mới khẳng định được:

  1. npm CLI v12 chặn install script của dependency mặc định, trường allowScripts (3.3, 4.2): theo docs npm v12 (npm approve-scripts, allow-scripts, dangerously-allow-all-scripts). Chưa chạy; dạng entry "pkg@1.2.3": true suy ra từ mô tả "pinned entries (pkg@1.2.3)" và "false entries"; chưa kiểm bản Node nào đi kèm npm 12.
  2. Composer config.policy (advisories.block mặc định true, abandoned.block, malware.block-scope): theo docs nhánh main (Context7 ghi bản 2.10.x). Chưa xác định chính xác bản Composer nào bắt đầu có, chưa chạy.
  3. composer validate --strict fail khi lock lệch composer.json, và hành vi composer install với lock lệch (cảnh báo, vẫn cài theo lock): theo hiểu biết về Composer 2, chưa chạy lại trong lab.
  4. Filter only cho repository Composer (4.3): cú pháp theo docs repository priorities, chưa chạy.
  5. Lệnh plugin CycloneDX cho Composer (CycloneDX:make-sbom và tên option) và output của npm sbom (4.7): chưa chạy; tên option có thể khác giữa các bản plugin.
  6. Thiết lập GitLab (job token allowlist bật mặc định, protected variables, id_tokens, include: ref nhận SHA): theo docs GitLab 17.x, chưa kiểm trên bản GitLab self-managed cụ thể.
  7. Renovate minimumReleaseAge, preset docker:pinDigests, matchCurrentVersion với regex phủ định: theo docs Renovate, chưa chạy bot thật; Dependabot "cooldown" chưa đối chiếu docs.
  8. Số liệu OWASP: trang A03 ghi Avg Incidence 5,72% trong bảng nhưng "5.19%" trong phần mô tả; danh sách "Mapped CWEs" có 6 mục nhưng bản trích em đọc chỉ hiện CWE-1035 — CWE thứ sáu chưa đối chiếu được.
  9. Số liệu sự cố từ nguồn thứ cấp: "hơn 100.000 site" (Sansec, polyfill.io), "hơn 23.000 repository" (advisory GitHub/StepSecurity qua cơ sở dữ liệu CVE), "~18.000 tổ chức" (OWASP trích NPR), "hơn 500 phiên bản" (OWASP) — dẫn lại, không tự kiểm.
  10. Moodle thirdpartylibs.xml (7.1): theo cấu trúc source Moodle đã biết, chưa đọc lại trên nhánh anh đang chạy trong lần viết này.
  11. Các giả định * ở mục 7 về cách cài plugin, quyền database của portal, các image nền — CV không nêu.

Nguồn

Phụ lục lab: không áp dụng — series A01–A10 không có lab (quyết định của series).

Series OWASP Top 10:2025: ← Bài 0 — tổng quan · ← Trước: Bài 2 — A02 Security Misconfiguration · Bài 3 — A03 Software Supply Chain Failures (bài này) · Tiếp: Bài 4 — A04 Cryptographic Failures →