Supply chain attack trên npm: bài học từ các vụ lớn gần đây và cách tự bảo vệ dự án JavaScript
Mổ xẻ các vụ event-stream, ua-parser-js, colors, polyfill.io, xz-utils, tj-actions, chalk/debug và Shai-Hulud, rồi dựng phòng thủ cụ thể: lockfile, chặn install script, release age, provenance, scanning, hardening CI và quy trình xử lý sự cố.
Sáng 8/9/2025 theo giờ UTC, chalk@5.6.1 và debug@4.4.2 xuất hiện trên npm. Không ai trong team bạn sửa dòng code nào, nhưng pipeline nào chạy npm install không có lockfile, hoặc có bot tự nâng dependency, trong mấy tiếng đó đều có thể kéo về một đoạn code chuyên tráo địa chỉ ví crypto trong trình duyệt. Nếu đoạn đó được bundle vào frontend, người dùng của bạn là nạn nhân, còn bạn là người phát tán.
Đó là bản chất của supply chain attack: thay vì tấn công code của bạn, kẻ tấn công tấn công code bạn tin dùng. Một dự án Next.js bình thường có hàng trăm đến hàng nghìn package trong node_modules, và mỗi package có thể chạy code ở ba thời điểm: lúc cài (lifecycle script, chạy với quyền của máy dev hoặc CI, có quyền đọc mọi token trong biến môi trường), lúc build (được bundle vào JavaScript gửi cho người dùng), và lúc runtime (thư viện server, hoặc script tải từ CDN bên thứ ba).
Bài này đi qua tám vụ có tài liệu công khai đầy đủ, mỗi vụ rút ra một bài học cụ thể, rồi dựng từng lớp phòng thủ bằng config và lệnh chạy được: lockfile, chặn install script, trì hoãn version mới, provenance, scanning, hardening GitHub Actions, SRI, và checklist xử lý khi lỡ cài phải bản độc.
TL;DR:
- Commit lockfile, CI chỉ dùng
npm ci, và review diff lockfile như review code. Script ở dưới giúp lọc ra package mới có install script. - Chặn lifecycle script của dependency: npm 12 làm mặc định (
allowScripts), pnpm 10 dùngonlyBuiltDependencies, pnpm 11 trở lên dùngallowBuilds. - Bật release age (npm
min-release-age, pnpmminimumReleaseAge): phần lớn version độc bị phát hiện và gỡ trong vài giờ đến vài ngày. - Pin GitHub Actions theo commit SHA,
permissionstối thiểu, không để secret trong job chạynpm install. - Lỡ cài bản độc: rotate mọi secret mà môi trường đó đọc được trước, dọn dẹp sau.
Tám vụ lớn và bài học từ mỗi vụ
event-stream và flatmap-stream (2018): trao quyền cho người lạ
event-stream là package stream phổ biến mà tác giả gốc không còn thời gian duy trì. Một người lạ xin giúp, được trao quyền publish, và ngày 9/9/2018 phát hành event-stream@3.3.6 có thêm dependency mới là flatmap-stream@0.1.1. Đoạn code độc nằm trong flatmap-stream, được mã hóa và chỉ kích hoạt khi chạy trong build của đúng một mục tiêu: ví Bitcoin Copay (các bản 5.0.2 đến 5.1.0). Với tài khoản có hơn 100 BTC hoặc 1000 BCH, nó gửi dữ liệu tài khoản và private key về server của kẻ tấn công. Ở mọi môi trường khác, nó không làm gì, nên test và review thông thường không thấy gì bất thường.
npm nhận báo cáo ngày 26/11/2018, gần ba tháng sau khi bản độc được phát hành.
Bài học: thay đổi maintainer và dependency mới xuất hiện trong một bản patch là tín hiệu cần review. Payload nhắm mục tiêu hẹp nghĩa là "chạy thử thấy không sao" chẳng chứng minh được gì.
ua-parser-js (10/2021): chiếm tài khoản maintainer
Ngày 22/10/2021, tài khoản npm của maintainer ua-parser-js (package có hàng triệu lượt tải mỗi tuần) bị chiếm, và ba bản 0.7.29, 0.8.0, 1.0.0 được phát hành. Script preinstall kiểm tra hệ điều hành rồi chạy shell script (Linux) hoặc batch file (Windows) để cài máy đào Monero; trên Windows còn kèm một DLL đánh cắp mật khẩu lưu trên máy. Các bản độc tồn tại khoảng bốn tiếng trước khi bị gỡ, bản sạch là 0.7.30, 0.8.1, 1.0.1. CISA ra cảnh báo ngay trong ngày.
Bài học: payload chạy ngay lúc npm install, trước khi bạn kịp import package. Chặn install script và trì hoãn version mới vài ngày sẽ chặn được vụ này dù package chưa bị ai phát hiện.
colors và faker (1/2022): maintainer tự phá package của mình
Đầu tháng 1/2022, chính tác giả của colors và faker cố ý phát hành bản phá hoại. colors@1.4.44-liberty-2 chứa vòng lặp vô hạn in ra "LIBERTY LIBERTY LIBERTY" và ký tự rác, làm treo các CLI và service phụ thuộc vào nó. faker@6.6.6 bị xóa sạch code. Không có gì bị đánh cắp, nhưng ứng dụng hỏng hàng loạt.
Bài học: niềm tin vào maintainer không phải vĩnh viễn, kể cả với người viết package từ đầu. Dự án có lockfile và dùng npm ci không bị ảnh hưởng ngay; người bị dính là những ai cài mới với range ^1.4.0, chạy npx, hoặc cài global, vì các trường hợp đó không dùng lockfile của dự án.
polyfill.io (2024): domain đổi chủ
cdn.polyfill.io từng là cách phổ biến để tải polyfill theo trình duyệt. Tháng 2/2024, một công ty mua lại domain và tài khoản GitHub của dự án. Ngày 25/6/2024, Sansec công bố rằng CDN này đang chèn code chuyển hướng người dùng mobile sang trang cá cược, qua một domain giả Google Analytics. Code chỉ kích hoạt vào một số giờ, theo xác suất ngẫu nhiên, bỏ qua desktop và người dùng admin, để chủ site khó tự thấy. Sansec ghi nhận hơn 100.000 site nhúng domain này. Namecheap sau đó đặt domain vào trạng thái hold, Cloudflare tự động viết lại các link trỏ tới nó, và tác giả gốc khuyên bỏ hẳn polyfill.io vì trình duyệt hiện đại không còn cần.
Bài học: thẻ <script src> trỏ tới domain bên thứ ba là trao cho chủ domain đó quyền chạy code trên origin của bạn, và chủ domain có thể đổi. SRI không cứu được trường hợp này vì polyfill.io trả nội dung khác nhau theo User-Agent; cách đúng là self-host.
xz-utils (3/2024): backdoor được trồng trong nhiều năm
Không thuộc npm, nhưng cùng một loại tấn công và là vụ đáng học nhất. Ngày 29/3/2024, Andres Freund công bố trên oss-security rằng xz 5.6.0 và 5.6.1 chứa backdoor (CVE-2024-3094). Anh phát hiện vì đăng nhập SSH trên Debian sid tốn CPU bất thường: ssh vào localhost mất khoảng 0,8 giây thay vì 0,3 giây, kèm lỗi từ Valgrind.
Cơ chế: một phần backdoor chỉ có trong tarball phát hành, không có trong git. File build-to-host.m4 trong tarball chạy ở cuối bước configure, lấy payload giấu trong các file test nhị phân đã commit vào repo, rồi chèn vào quá trình build liblzma. Nó chỉ kích hoạt trên Linux x86-64, build bằng GCC và GNU linker, khi đóng gói .deb hoặc .rpm. OpenSSH không link liblzma trực tiếp, nhưng một số distro patch sshd để hỗ trợ systemd notification, và libsystemd lại phụ thuộc liblzma, nên backdoor chui được vào tiến trình sshd. Người đứng sau dùng danh tính "Jia Tan", mất khoảng hai năm đóng góp để trở thành co-maintainer.
Bài học: tarball hay package đã publish không nhất thiết khớp với source trên git. Với npm, điều này nghĩa là thứ bạn review trên GitHub có thể khác thứ nằm trong registry; provenance (phần dưới) sinh ra để thu hẹp khoảng cách này.
tj-actions/changed-files (3/2025): GitHub Action bị chiếm
tj-actions/changed-files là GitHub Action phổ biến để lấy danh sách file thay đổi. Trong khoảng 12 đến 15/3/2025, kẻ tấn công sửa action và trỏ các tag version về một commit độc. Commit này dump bộ nhớ của runner và in secret ra log của workflow, mã hóa base64 hai lần (CVE-2025-30066). Với repo public, log workflow ai cũng đọc được, nên secret coi như công khai. Điều tra sau đó chỉ ra điểm khởi đầu có thể là vụ chiếm reviewdog/action-setup@v1 (CVE-2025-30154) vài ngày trước, qua đó lấy được personal access token dùng cho tj-actions. Bản sửa là 46.0.1.
Bài học: uses: tj-actions/changed-files@v45 là tham chiếu tới một tag có thể bị di chuyển, không phải một phiên bản bất biến. Chỉ commit SHA đầy đủ mới bất biến.
chalk, debug và 16 package khác (8/9/2025): phishing maintainer
Maintainer của một loạt package nền tảng nhận email từ support@npmjs.help, domain đăng ký vài ngày trước đó, yêu cầu "cập nhật 2FA" nếu không muốn bị khóa tài khoản. Trang phishing đứng giữa trang đăng nhập thật và nạn nhân, nhận cả mật khẩu lẫn mã TOTP. Từ khoảng 13:16 UTC, 18 package bị phát hành bản độc, trong đó có chalk@5.6.1, debug@4.4.2, ansi-styles@6.2.2, strip-ansi@7.1.1, supports-color@10.2.1, wrap-ansi@9.0.1, ansi-regex@6.2.1 và color-convert@3.1.1. Gộp lại, các package này có hơn 2 tỷ lượt tải mỗi tuần.
Payload chỉ hoạt động trong trình duyệt. Nó hook fetch, XMLHttpRequest và các method của window.ethereum, tìm chuỗi trông giống địa chỉ ví (Ethereum, Bitcoin, Solana, Tron...) và thay bằng địa chỉ của kẻ tấn công trông gần giống nhất, đồng thời sửa người nhận trong giao dịch trước khi ví ký. Advisory của debug xác nhận môi trường server và CLI không bị ảnh hưởng, và hướng dẫn: nâng lên 4.4.3, xóa node_modules, xóa cache của package manager, build lại bundle frontend từ đầu.
Bài học: 2FA dạng TOTP không chống được phishing kiểu proxy. Và payload nhắm vào bundle frontend nghĩa là quy trình xử lý sự cố phải gồm cả việc build lại và purge cache CDN, không chỉ cài lại dependency.
Shai-Hulud (9/2025): con worm tự nhân bản
Một tuần sau, từ khoảng 15/9/2025, xuất hiện các bản độc có script postinstall hoạt động như worm, trong đó có @ctrl/tinycolor (khoảng 2 triệu lượt tải mỗi tuần). Khi chạy, nó:
- Dùng công cụ quét secret TruffleHog để tìm token trên máy, đọc biến môi trường và credential của cloud qua metadata service nếu có.
- Tạo repo public tên
Shai-Huludtrên GitHub của nạn nhân, chứa dump secret thu được. - Đẩy một GitHub Actions workflow vào các repo nạn nhân có quyền, workflow này gửi secret của repo ra ngoài.
- Chuyển repo private của tổ chức thành public dưới tài khoản kẻ tấn công, với hậu tố
-migration. - Dùng npm token tìm được để phát hành bản nhiễm của các package khác mà nạn nhân có quyền publish. Đây là bước tự nhân bản.
Số package bị nhiễm tăng lên hàng trăm, kể cả một số package do CrowdStrike duy trì. CISA ra cảnh báo ngày 23/9/2025, khuyến nghị kiểm tra lockfile với các bản phát hành sau 16/9, rotate credential, thu hồi GitHub PAT và dùng MFA chống phishing. Cuối tháng 11/2025, một làn sóng thứ hai (Shai-Hulud 2.0) quay lại với cùng ý tưởng.
Bài học: một token npm có quyền publish nằm trong ~/.npmrc hoặc biến môi trường CI là một vector lây lan. Install script bị chặn và token sống ngắn sẽ cắt đứt chuỗi lây.
Registry đã thay đổi gì sau các vụ này
Sau chuỗi sự cố tháng 9/2025, GitHub công bố kế hoạch siết npm và triển khai dần: từ tháng 10/2025, granular token có quyền ghi mặc định hết hạn sau 7 ngày và tối đa 90 ngày; ngày 9/12/2025, toàn bộ classic token bị thu hồi vĩnh viễn, và npm login chuyển sang session token hai giờ. Trusted publishing qua OIDC đã GA từ tháng 7/2025. npm 12 (ra mắt giữa năm 2026) chặn install script của dependency nếu chưa được duyệt, và mặc định không cài dependency từ git hay URL tùy ý.
Cùng một mục tiêu (chạy code trên máy developer để lấy token và ví) còn đi theo đường social engineering trực tiếp: mình từng viết về một "job fix code" trả 15 triệu thực chất là mồi để dev giải nén và chạy malware. Phòng thủ ở dưới giúp cả hai trường hợp, vì chúng đều dựa vào việc bạn chạy code chưa review trên máy có secret.
Lớp 1: lockfile, npm ci và review diff lockfile
Lockfile ghi lại chính xác version, URL tải và hash integrity của từng package. Không có lockfile, mỗi lần cài là một lần resolve lại range ^, và bạn có thể nhận version mới phát hành cách đây năm phút.
# .npmrc của dự án
save-exact=true # npm install <pkg> ghi "1.2.3" thay vì "^1.2.3"
engine-strict=true
# CI và Docker build: chỉ dùng npm ci
npm ci
# npm ci cài đúng theo lockfile và báo lỗi nếu package.json lệch lockfile,
# thay vì âm thầm cập nhật lockfile như npm install
Pin exact trong package.json chỉ có tác dụng với dependency trực tiếp; dependency bắc cầu vẫn do lockfile quyết định. Nếu cần ép một dependency bắc cầu về bản an toàn, dùng overrides:
{
"overrides": {
"debug": "4.4.3"
}
}
Lockfile hàng nghìn dòng thì không ai review bằng mắt. Mình dùng script nhỏ so lockfile của PR với nhánh chính, chỉ in ra package mới hoặc đổi version, và đánh dấu package có install script hoặc tải từ nguồn không phải registry npm:
// scripts/lockfile-diff.mjs
// Cách chạy: node scripts/lockfile-diff.mjs origin/main
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
const baseRef = process.argv[2] ?? 'origin/main'
const base = JSON.parse(
execFileSync('git', ['show', `${baseRef}:package-lock.json`], {
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
}),
)
const head = JSON.parse(readFileSync('package-lock.json', 'utf8'))
const basePkgs = base.packages ?? {}
const rows = []
for (const [path, meta] of Object.entries(head.packages ?? {})) {
if (path === '' || meta.link) continue // bỏ qua project gốc và workspace link
const before = basePkgs[path]
if (before && before.version === meta.version && before.integrity === meta.integrity) continue
const name = meta.name ?? path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length)
const fromRegistry = meta.resolved?.startsWith('https://registry.npmjs.org/') ?? false
rows.push({
name,
from: before?.version ?? '(mới)',
to: meta.version,
installScript: meta.hasInstallScript ? 'CÓ' : '',
source: fromRegistry ? '' : (meta.resolved ?? '?'),
})
}
console.table(rows)
const risky = rows.filter((r) => r.installScript || r.source)
if (risky.length > 0) {
console.error(`\n${risky.length} package có install script hoặc nguồn lạ: review trước khi merge.`)
process.exitCode = 1
}
Lockfile version 2 trở lên có field hasInstallScript cho từng package, nên script không cần tải gì từ mạng. Khi một package quen thuộc bỗng có install script ở bản patch, hoặc một dependency mới xuất hiện trong bản nâng version nhỏ (đúng kịch bản event-stream), đó là lúc mở mã nguồn đã publish ra đọc. npm diff --diff=debug@4.4.1 --diff=debug@4.4.2 cho bạn diff của chính tarball trên registry, không phải diff trên GitHub. Như vụ xz cho thấy, hai thứ đó có thể khác nhau.
Lớp 2: chặn lifecycle script và trì hoãn version mới
preinstall, install, postinstall của dependency là con đường của ua-parser-js và Shai-Hulud. Phần lớn package không cần chúng; số ít cần thật thường là package có binary native hoặc tải binary (esbuild, sharp, @swc/core, better-sqlite3, bcrypt).
npm
Từ npm 12, install script của dependency bị chặn nếu không nằm trong field allowScripts của package.json. Từ npm 11.16.0, các thay đổi này đã có ở dạng cảnh báo, nên bạn chạy thử trước khi nâng cấp:
npm -v # nhiều bản Node vẫn đi kèm npm 11
npm approve-scripts --allow-scripts-pending # xem package nào đang bị chặn, không sửa gì
npm approve-scripts esbuild sharp # duyệt, mặc định pin theo version đang cài
npm deny-scripts --all # từ chối phần còn lại
npm ci --strict-allow-scripts # trong CI: biến cảnh báo thành lỗi
approve-scripts ghi vào allowScripts trong package.json, và file này phải được commit để cả team cùng một chính sách. Entry mặc định được pin theo version, nên khi sharp lên bản mới, bạn phải duyệt lại; đó chính là điểm kiểm soát bạn muốn có. Hãy để lệnh tự ghi thay vì tự gõ JSON. npm 12 cũng đổi mặc định allow-git và allow-remote thành none, tức dependency trỏ tới git hay URL tarball bị từ chối trừ khi bạn mở.
Nếu còn ở npm 11 hoặc thấp hơn, cách cũ vẫn dùng được:
# .npmrc
ignore-scripts=true
npm ci
npm rebuild esbuild sharp --ignore-scripts=false # chạy script cho đúng package bạn đã duyệt
Lưu ý ignore-scripts=true cũng chặn script pre*/post* trong chính package.json của bạn khi chạy npm run, nên thường mình chỉ đặt nó trong CI và Dockerfile thay vì máy dev.
pnpm
pnpm 10 đã không chạy build script của dependency trừ khi được cho phép bằng onlyBuiltDependencies (pnpm approve-builds giúp chọn). Từ pnpm 11, các setting cũ này bị bỏ và thay bằng allowBuilds trong pnpm-workspace.yaml. pnpm 12 (bản viết lại bằng Rust) giữ nguyên setting của pnpm 11.
# pnpm-workspace.yaml (pnpm 11 trở lên)
allowBuilds:
esbuild: true
sharp: true
core-js: false # có postinstall nhưng không cần, từ chối tường minh
strictDepBuilds: true # mặc định true từ pnpm 11: package chưa duyệt có script thì install fail
minimumReleaseAge: 4320 # phút; pnpm 11 mặc định 1440 (1 ngày), ở đây đặt 3 ngày
minimumReleaseAgeExclude:
- '@acme/*' # package nội bộ của bạn, cần dùng ngay khi publish
trustPolicy: no-downgrade
trustPolicy: no-downgrade làm install fail khi mức tin cậy của một package giảm so với các bản trước, ví dụ bản trước có provenance từ CI mà bản mới thì không. Đây đúng là dấu hiệu của vụ chalk/debug: bản độc được publish bằng tài khoản bị chiếm, không đi qua pipeline thường lệ.
Trì hoãn version mới
Các vụ ở trên có chung một điểm: bản độc bị phát hiện và gỡ trong vài giờ đến vài ngày. Nếu package manager chỉ cài version đã tồn tại đủ lâu, bạn tránh được phần lớn chúng mà không cần biết trước:
| Công cụ | Setting | Đơn vị | Ghi chú |
|---|---|---|---|
| npm 11.10+ | min-release-age trong .npmrc |
ngày | min-release-age-exclude để miễn trừ package |
| pnpm | minimumReleaseAge |
phút | pnpm 11 mặc định 1440; có minimumReleaseAgeExclude |
| Yarn 4 | npmMinimalAgeGate trong .yarnrc.yml |
duration (vd. 7d) |
kiểm tra docs Yarn cho đúng bản bạn dùng |
| Bun 1.3+ | minimumReleaseAge trong bunfig.toml |
giây | có minimumReleaseAgeExcludes |
Cái giá là bản vá bảo mật khẩn cũng bị trì hoãn. Quy tắc của mình: đặt 3 đến 7 ngày cho mọi thứ, miễn trừ package nội bộ, và khi có advisory nghiêm trọng (như vụ React Server Components cuối 2025) thì nâng thủ công package đó bằng exclude tạm thời, review xong thì gỡ exclude.
Lớp 3: provenance, trusted publishing, chữ ký và 2FA
Phía người dùng package
npm audit signatures
Lệnh này kiểm tra chữ ký của registry cho mọi package đã cài, và kiểm tra provenance attestation với package có. Kết quả minh họa:
audited 1184 packages in 4s
1184 packages have verified registry signatures
213 packages have verified attestations
Provenance là bằng chứng có chữ ký rằng tarball được build từ commit nào, repo nào, workflow nào. Nó không nói code an toàn, nhưng nói được code đến từ đâu. Một package lâu nay luôn có provenance mà bản mới nhất lại không có, là lý do để dừng lại và kiểm tra.
Phía maintainer: trusted publishing thay cho token
Nếu bạn publish package (kể cả package nội bộ lên npm public), trusted publishing loại bỏ hoàn toàn npm token khỏi CI. GitHub Actions xin token OIDC ngắn hạn, npm kiểm tra token đó khớp repo và workflow bạn đã khai báo trên npmjs.com. Yêu cầu npm CLI 11.5.1 trở lên và Node.js 22.14.0 trở lên; với repo và package public, npm tự đính kèm provenance mà không cần cờ --provenance.
# .github/workflows/publish.yml
name: Publish
on:
release:
types: [published]
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
environment: npm-publish # environment có reviewer bắt buộc
permissions:
contents: read
id-token: write # cho phép xin token OIDC, chỉ ở job này
steps:
- uses: actions/checkout@v5 # pinact sẽ đổi thành commit SHA (xem phần CI)
- uses: actions/setup-node@v5
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm install -g npm@latest
- run: npm ci
- run: npm test
- run: npm publish
Sau khi publish thành công qua trusted publishing, vào phần Publishing access của package trên npmjs.com và chọn "Require two-factor authentication and disallow tokens". Từ đó không còn token nào có thể publish package của bạn, kể cả token bị worm lấy được.
Với tài khoản maintainer, dùng security key hoặc passkey (WebAuthn) cho 2FA. Vụ chalk/debug cho thấy TOTP bị lấy dễ dàng qua trang proxy, còn WebAuthn gắn với domain thật nên trang npmjs.help không dùng được.
Lớp 4: scanning với npm audit, OSV-Scanner, Socket và bot cập nhật
Các công cụ dưới đây bổ sung cho nhau, không thay thế nhau:
# Lỗ hổng đã biết trong dependency production, fail CI từ mức high
npm audit --omit=dev --audit-level=high
# OSV-Scanner v2: dùng cơ sở dữ liệu OSV, quét lockfile hoặc cả thư mục
osv-scanner scan -L package-lock.json
osv-scanner scan -r .
# Socket Firewall: chặn package độc đã biết ngay lúc cài
npm i -g sfw
sfw npm install
npm audit và OSV-Scanner trả lời câu hỏi "package này có CVE hay advisory nào không". Chúng hữu ích nhất cho lỗ hổng vô ý, và chậm với package độc mới vì phải có người báo cáo trước. Các công cụ phân tích hành vi như Socket nhìn vào chính code: package mới thêm install script, gọi mạng, đọc biến môi trường, chứa code obfuscate. Cài GitHub app của Socket, mỗi PR đổi dependency sẽ có comment liệt kê các tín hiệu đó.
Bot cập nhật dependency là con dao hai lưỡi: nó giúp bạn vá nhanh, nhưng cũng có thể mở PR kéo bản độc vào đúng ngày bản đó được phát hành. Cấu hình cooldown để bot chờ:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
semver-major-days: 30
groups:
dev-dependencies:
dependency-type: "development"
update-types: ["minor", "patch"]
open-pull-requests-limit: 10
- package-ecosystem: "github-actions" # cập nhật cả SHA của action đã pin
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
Với Renovate, setting tương đương là "minimumReleaseAge": "5 days". Chính sách merge của mình: tự động merge chỉ áp dụng cho bản patch/minor của devDependency, sau khi CI xanh và hết cooldown. Dependency mới, bản major, hay bất cứ thứ gì có install script đều cần người review.
Lớp 5: hardening CI
CI là nơi có nhiều secret nhất và chạy nhiều code của người khác nhất. Bốn nguyên tắc:
- Pin action theo commit SHA đầy đủ. Tag như
v4có thể bị trỏ sang commit khác, như vụ tj-actions. Công cụpinactđổi toàn bộ workflow sang SHA và giữ tag trong comment. Từ tháng 8/2025, policy Actions của GitHub cho phép admin bắt buộc pin SHA và chặn hẳn một action theo tên. permissionstối thiểu. Đặtpermissions: contents: readở đầu workflow, chỉ cấp thêm ở job cần.- Không để secret trong job cài dependency nếu không cần. Job test chạy
npm cikhông cần token deploy. - Không chạy code của PR từ fork với quyền cao.
pull_request_targetcộng với checkout code của PR là cấu hình nguy hiểm kinh điển.
# Pin toàn bộ action trong .github/workflows sang commit SHA
pinact run
# Kết quả trong file workflow (SHA thật do pinact điền):
# uses: actions/checkout@<40 ký tự SHA> # v5.0.0
# .github/workflows/ci.yml
name: CI
on:
pull_request:
permissions:
contents: read # mặc định cho mọi job
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5 # đã pin SHA bằng pinact trong repo thật
with:
persist-credentials: false # không để GITHUB_TOKEN nằm lại trong .git/config
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci --ignore-scripts
- run: npm rebuild esbuild --ignore-scripts=false
- run: node scripts/lockfile-diff.mjs origin/${{ github.base_ref }}
- run: npm audit signatures
- run: npm test
Để script diff lockfile thấy được nhánh gốc, bước checkout cần fetch-depth: 0 hoặc một lệnh git fetch origin <nhánh gốc> trước đó. Với deploy lên cloud, dùng OIDC (AWS, GCP, Azure đều hỗ trợ GitHub Actions OIDC) thay cho access key sống lâu trong secret. Công cụ như zizmor quét workflow để tìm các cấu hình nguy hiểm ở trên.
SRI và script bên thứ ba
Nếu bắt buộc tải script từ CDN, dùng Subresource Integrity: trình duyệt tính hash của file tải về và từ chối chạy nếu không khớp.
# Tính hash cho một file đã pin version cụ thể
curl -s https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js \
| openssl dgst -sha384 -binary | openssl base64 -A
<script
src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"
integrity="sha384-HASH_TINH_TU_LENH_TREN"
crossorigin="anonymous"
></script>
SRI chỉ dùng được với file bất biến: URL có version cố định và nội dung không đổi theo trình duyệt. Script kiểu polyfill.io (sinh nội dung theo User-Agent), Google Tag Manager hay widget chat tự cập nhật đều không dùng SRI được. Với chúng, lựa chọn là self-host qua npm (để chúng đi qua lockfile và review như mọi dependency khác), hoặc giới hạn bằng CSP và chấp nhận rủi ro một cách có ý thức. Trong Next.js, mình đã viết cách đặt CSP có nonce ở bài bảo mật Next.js theo OWASP Top 10.
Lỡ cài phải bản bị compromise: quy trình xử lý
Khi có tin một package bạn dùng bị chiếm, làm theo thứ tự sau. Thứ tự này quan trọng: dọn dẹp mà chưa rotate secret thì kẻ tấn công vẫn giữ quyền truy cập.
1. Xác định có bị ảnh hưởng không.
# Version nào của package đang có trong cây dependency?
npm ls chalk debug --all
# Lockfile có chứa đúng bản bị compromise không?
jq -r '.packages | to_entries[]
| select(.key | test("node_modules/(chalk|debug)$"))
| "\(.key) \(.value.version)"' package-lock.json
# pnpm
pnpm why debug
Kiểm tra cả lịch sử: log CI trong khoảng thời gian bản độc còn trên registry, image Docker được build trong khoảng đó, và máy dev nào đã chạy npm install khi ấy.
2. Ngăn lan rộng. Tạm dừng deploy. Nếu payload chạy lúc cài (ua-parser-js, Shai-Hulud), cô lập máy dev và runner self-hosted đã cài.
3. Rotate mọi secret mà môi trường đó đọc được. npm token, GitHub PAT và SSH key, credential cloud, secret trong .env và trong CI. Với kiểu worm như Shai-Hulud, kiểm tra thêm: repo lạ tên Shai-Hulud trong tài khoản/tổ chức, workflow mới được đẩy vào .github/workflows, repo private bị chuyển thành public, và version mới của package bạn sở hữu mà bạn không phát hành.
4. Dọn sạch và cài lại. Xóa node_modules, xóa cache (npm cache clean --force, pnpm store prune), pin về bản sạch bằng lockfile hoặc overrides, cài lại bằng npm ci. Nếu payload nằm trong bundle frontend (chalk/debug), build lại toàn bộ bundle, deploy, và purge cache CDN để người dùng không còn nhận file cũ.
5. Kiểm tra dấu vết. Audit log của npm, của tổ chức GitHub, của cloud (CloudTrail hoặc tương đương) từ thời điểm cài bản độc đến lúc rotate xong. Tìm token được dùng từ IP lạ, package được publish, repo được tạo.
6. Ghi lại và cải thiện. Lớp nào trong bài này lẽ ra đã chặn được sự cố? Thêm lớp đó vào CI để lần sau không phụ thuộc vào việc ai đó đọc tin kịp.
Bảng tổng hợp: kiểu tấn công, ví dụ, phòng thủ
| Kiểu tấn công | Ví dụ | Phòng thủ hiệu quả nhất |
|---|---|---|
| Chiếm tài khoản maintainer (phishing, lộ mật khẩu) | ua-parser-js 2021, chalk/debug 2025 | Release age, chặn install script, WebAuthn cho maintainer, trustPolicy: no-downgrade |
| Maintainer mới có ý đồ xấu | event-stream 2018 | Review dependency mới xuất hiện trong lockfile diff, cảnh báo hành vi (Socket) |
| Maintainer tự phá package (protestware) | colors/faker 2022 | Lockfile + npm ci, pin exact, không cài global/npx không cần thiết |
| Domain CDN đổi chủ | polyfill.io 2024 | Self-host qua npm, SRI cho file bất biến, CSP |
| Backdoor trồng lâu năm trong upstream | xz-utils 2024 | Diff tarball đã publish thay vì chỉ đọc GitHub, provenance, build tái lập được |
| GitHub Action bị chiếm | tj-actions/changed-files 2025 | Pin commit SHA, permissions tối thiểu, không in secret ra log |
| Worm dùng token bị đánh cắp | Shai-Hulud 2025 | Chặn install script, trusted publishing, token ngắn hạn, secret scanning |
| Package giả tên (typosquatting) | crossenv giả cross-env (2017) |
Review dependency mới, copy tên từ docs chính thức, scanner hành vi |
| Lừa dev chạy code trực tiếp | "Job fix code" gửi file zip | Không chạy code lạ trên máy có secret; dùng VM hoặc container dùng một lần |
Những lỗi thường gặp
1. Dùng npm install trong CI.
# SAI: có thể cập nhật lockfile và kéo version mới ngay trong pipeline
npm install && npm run build
# ĐÚNG: cài đúng theo lockfile đã review, lỗi nếu lệch
npm ci && npm run build
2. Không commit lockfile, hoặc thêm nó vào .gitignore vì "hay bị conflict". Conflict lockfile giải quyết bằng cách merge package.json rồi chạy lại npm install để sinh lockfile mới, sau đó review diff. Bỏ lockfile là bỏ lớp phòng thủ rẻ nhất.
3. Tham chiếu action theo tag.
# SAI: tag có thể bị trỏ sang commit độc
- uses: tj-actions/changed-files@v45
# ĐÚNG: commit SHA đầy đủ, tag ghi trong comment để bot cập nhật
- uses: tj-actions/changed-files@<SHA đầy đủ do pinact điền> # v46.0.1
4. Token npm sống lâu trong CI. Một NPM_TOKEN quyền publish, không hết hạn, dùng chung cho mọi repo là thứ Shai-Hulud tìm kiếm. Chuyển sang trusted publishing; nếu bắt buộc dùng token thì dùng granular token, giới hạn package và IP, thời hạn ngắn.
5. Auto-merge mọi PR của Dependabot. Bot không phân biệt được bản vá với bản độc phát hành sáng nay. Cooldown cộng với chỉ auto-merge patch của devDependency là mức hợp lý.
6. Chỉ cài lại dependency sau sự cố, quên build lại frontend. Bundle đã deploy và cache CDN vẫn chứa code độc. Rebuild, redeploy, purge cache.
7. Tin vào số lượt tải và số sao GitHub. Mọi package trong bài đều rất phổ biến. Độ phổ biến làm chúng thành mục tiêu, không làm chúng an toàn hơn.
Checklist
- Lockfile được commit; CI và Dockerfile chỉ dùng
npm ci(hoặcpnpm install --frozen-lockfile). - PR đổi lockfile chạy script diff, package mới có install script phải được review tay.
- Install script của dependency bị chặn mặc định:
allowScripts(npm 12),allowBuilds(pnpm 11+), hoặcignore-scripts=truecộngnpm rebuildcó chọn lọc. - Release age đã bật (3 đến 7 ngày) với danh sách miễn trừ cho package nội bộ.
-
npm audit signaturesvàosv-scannerchạy trong CI; Socket hoặc công cụ tương tự comment trên PR đổi dependency. - Dependabot/Renovate có cooldown; không auto-merge dependency mới hay bản major.
- Mọi GitHub Action được pin theo commit SHA; workflow mặc định
permissions: contents: read. - Package bạn publish dùng trusted publishing, đã tắt publish bằng token; tài khoản maintainer dùng WebAuthn.
- Không còn script tải từ CDN bên thứ ba mà không có SRI, trừ các trường hợp đã cân nhắc và có CSP.
- Team có runbook xử lý sự cố: xác định, ngăn lan, rotate secret, cài lại, rebuild frontend, kiểm tra audit log.
Đọc thêm
- npm: Details about the event-stream incident
- Sansec: Polyfill supply chain attack
- Andres Freund: backdoor in upstream xz/liblzma (oss-security)
- CISA: Supply chain compromise of tj-actions/changed-files (CVE-2025-30066)
- GitHub Advisory: debug 4.4.2 malware (GHSA-4x49-vf9v-38px)
- GitHub: Our plan for a more secure npm supply chain
- npm Docs: Trusted publishing
- pnpm: Settings
- Bài liên quan: Bảo mật web app Next.js theo OWASP Top 10, CI/CD với GitHub Actions cho Next.js