BlogBlog · security

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ố.

Part of Cybersecurity Alerts series
July 16, 2025
27 min read
By Leo Pham
securitynpmsupply-chainjavascriptdevops
Share this post:

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ùng onlyBuiltDependencies, pnpm 11 trở lên dùng allowBuilds.
  • Bật release age (npm min-release-age, pnpm minimumReleaseAge): 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, permissions tối thiểu, không để secret trong job chạy npm 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-Hulud trê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:

  1. Pin action theo commit SHA đầy đủ. Tag như v4 có 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.
  2. permissions tối thiểu. Đặt permissions: contents: read ở đầu workflow, chỉ cấp thêm ở job cần.
  3. Không để secret trong job cài dependency nếu không cần. Job test chạy npm ci không cần token deploy.
  4. Không chạy code của PR từ fork với quyền cao. pull_request_target cộ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ặc pnpm 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ặc ignore-scripts=true cộng npm rebuild có 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 signatures và osv-scanner chạ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

Last updated: July 16, 2025

Comments