Hồi mình còn làm ở một công ty outsource, có hôm cả team ngồi nhìn CI đỏ lòm mà không hiểu vì sao. Không ai sửa dòng code nào. Thủ phạm là một package phụ thuộc bậc ba vừa ra bản mới, và pipeline của mình tự động kéo nó về vì trong package.json ghi ^1.2.0.

Chuyện đó còn nhẹ. Cái đáng sợ hơn là mỗi lần CI chạy npm install, mình đang cho hàng trăm package lạ — nhiều cái mình chưa từng đọc tên — quyền chạy script trên máy build, nơi có sẵn token, biến môi trường và quyền push. Nói thẳng: CI tin package quá dễ dàng.

Rủi ro nằm ở đâu, không phải ở npm audit

Mọi người hay nghĩ "có npm audit là ổn". Nhưng npm audit chỉ soi lỗ hổng đã biết (CVE). Nó im lặng với:

  • Package mới bị chiếm quyền maintainer rồi thêm code lạ vào bản patch.
  • Script postinstall đọc biến môi trường và gửi dữ liệu ra ngoài.
  • Package giả tên gần giống package thật (typosquatting) mà bạn gõ nhầm.
  • Dependency bậc 5-6-7 — node_modules của một app Next.js bình thường có thể lên hàng nghìn package.

Vụ event-stream năm 2018 là ví dụ kinh điển: đúng một package nhỏ trong cây phụ thuộc, và tiền của người dùng bị nhắm tới. Sau này còn có những đợt worm lây lan qua chính token npm của maintainer bị đánh cắp. Không có bản vá nào cho "mình đã tin nhầm".

6 việc mình làm cho mọi pipeline JS

1. Commit lockfile và chỉ dùng lệnh cài theo lockfile. npm ci (hoặc pnpm install --frozen-lockfile) sẽ fail nếu lockfile lệch package.json, thay vì âm thầm cài bản khác. CI không bao giờ được tự "giải quyết" phụ thuộc.

2. Tắt script cài đặt. npm ci --ignore-scripts chặn toàn bộ preinstall/postinstall/prepare. Đây là chốt quan trọng nhất vì phần lớn mã độc npm sống trong postinstall.

3. Pin version, đừng thả ^. Với app chạy production mình dùng version chính xác. Chỗ nào buộc phải có range thì gom lại bằng overrides (npm) hoặc resolutions/pnpm.overrides để chỉ còn một bản duy nhất cho mỗi package.

4. Cooldown version mới. Mã độc thường bị phát hiện và gỡ trong 24-72 giờ đầu. Nếu bạn không nhận version mới trong 7 ngày đầu, phần lớn đợt tấn công tự trôi qua. Mình viết một script TypeScript nhỏ chạy ngay sau bước cài để chặn version quá mới:

import { readFile } from 'node:fs/promises';

type Packument = { time: Record<string, string> };
type Violation = { name: string; version: string; ageDays: number };

const MIN_AGE_DAYS = 7;

async function checkCooldown(pkgPath = 'package.json'): Promise<Violation[]> {
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as {
    dependencies?: Record<string, string>;
  };
  const deps = Object.entries(pkg.dependencies ?? {});
  const bad: Violation[] = [];

  await Promise.all(
    deps.map(async ([name, range]) => {
      const version = range.replace(/^[\^~]/, '');
      const res = await fetch(
        `https://registry.npmjs.org/${name.replace('/', '%2F')}`,
        { headers: { accept: 'application/vnd.npm.install-v1+json' } },
      );
      if (!res.ok) throw new Error(`registry ${name}: HTTP ${res.status}`);
      const data = (await res.json()) as Packument;
      const publishedAt = data.time[version];
      if (!publishedAt) return;
      const ageDays = (Date.now() - Date.parse(publishedAt)) / 86_400_000;
      if (ageDays < MIN_AGE_DAYS) bad.push({ name, version, ageDays });
    }),
  );

  return bad.sort((a, b) => a.ageDays - b.ageDays);
}

const violations = await checkCooldown();
if (violations.length > 0) {
  console.error('Version qua moi, cho them vai ngay:', violations);
  process.exit(1);
}

Chạy script này trong CI (không cần token) là bạn có "cửa ủ" 7 ngày do mình kiểm soát, thay vì tin vào may mắn.

5. Token và quyền tối thiểu trong workflow. Job CI chỉ cần đọc code:

permissions:
  contents: read

Và tuyệt đối không dùng pull_request_target rồi checkout code của PR kèm secret — đó là kịch bản pwn request kinh điển. Action bên thứ ba thì pin theo commit SHA, không pin theo tag v4 vì tag có thể bị trỏ lại.

6. Chỉ fail ở mức nghiêm trọng. npm audit --audit-level=high --omit=dev. Repo nào bật audit ở mức low cũng sẽ đỏ liên tục, rồi tới lúc mọi người quen với việc bỏ qua nó — hỏng cả văn hoá.

Điểm khó khi áp dụng thật

Tắt script cài thì mấy package có native binary sẽ hỏng ngay: esbuild, sharp, prisma, bcrypt. Cách mình xử lý là bật lại có danh sách:

npm ci --ignore-scripts
npm rebuild esbuild sharp @prisma/client   # chỉ những cái đã nằm trong allowlist

Script cooldown cũng không chạy với monorepo nhiều package.json, lúc đó mình gọi checkCooldown('apps/web/package.json') cho từng workspace rồi gom kết quả.

Và một điều dễ bị bỏ qua: lockfile phải được review như code. Một diff lockfile thêm vài chục dòng từ package bạn không biết tên là tín hiệu cần hỏi, không phải thứ để merge cho nhanh.

Kết lại

Supply chain npm không phải chuyện của riêng team security. Nó là vài dòng cấu hình trong pipeline mà bất kỳ ai cũng thêm được: cài theo lockfile, tắt script, pin version, ủ 7 ngày, và cho CI đúng quyền nó cần. Làm xong bạn sẽ thấy CI chậm hơn đúng vài giây, nhưng đổi lại là mỗi lần merge không còn phải cầu nguyện.

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
Supply chain attack Tấn công qua phụ thuộc bên thứ ba thay vì đánh trực tiếp vào code của bạn
Lockfile package-lock.json / pnpm-lock.yaml — ghi chính xác version của cả cây phụ thuộc
npm ci Cài đúng theo lockfile, fail nếu lệch package.json
postinstall Script tự chạy sau khi cài package — cũng là chỗ mã độc hay nấp
Typosquatting Đặt tên package gần giống package nổi tiếng để bạn gõ nhầm
Cooldown / minimum age Chỉ nhận version đã publish đủ lâu (ví dụ 7 ngày) để né đợt tấn công sớm
Provenance Bằng chứng package được build từ repo và commit công khai, xác minh được
pwn request Lỗi workflow: chạy code của PR kèm secret — thường qua pull_request_target

Xem các bài viết khác