Package event-stream — 2 triệu download/tuần — bị gắn mã độc sau khi maintainer gốc chuyển quyền cho người lạ. ua-parser-js bị chiếm tài khoản npm. colors.js bị chính tác giả phá hoại. Log4Shell biến một lỗ hổng logging thành RCE trên hàng triệu server. Mỗi sự cố khác nhau về vector, nhưng cùng một bài học: dependency là code của người khác chạy trong hệ thống của bạn, với quyền tương đương code của bạn.
Bài này đi qua các vector tấn công phổ biến trong supply chain, công cụ phòng thủ từ lockfile đến SBOM, và quy trình giảm rủi ro mà không biến việc quản lý dependency thành gánh nặng.
Bài toán dependency hiện đại
Một ứng dụng Node.js trung bình có vài chục dependency trực tiếp trong package.json. Nhưng node_modules sau khi cài thường chứa hàng trăm — đôi khi hàng nghìn — package. Đó là transitive dependency: package A phụ thuộc package B, B phụ thuộc C và D, mỗi cái lại kéo theo thêm vài package nữa. Cây dependency phình ra theo cấp số nhân.
Go module hạn chế vấn đề này tốt hơn nhờ triết lý “ít dependency” trong cộng đồng, nhưng một service Go trung bình vẫn dễ dàng có 50-100 module gián tiếp. Python với pip cũng tương tự — pip install django kéo theo hàng chục package phụ thuộc. Java/Maven thì nổi tiếng với dependency tree sâu hàng chục tầng.
Vấn đề không chỉ là số lượng mà là khả năng kiểm soát. Developer đọc code của dependency trực tiếp — có thể. Đọc code của dependency cấp hai, cấp ba — gần như không ai làm. Mỗi package trong cây dependency là một bề mặt tấn công tiềm ẩn: maintainer có thể bị chiếm tài khoản, package có thể bị inject mã độc, hoặc đơn giản là chứa lỗ hổng bảo mật chưa được phát hiện. Khi bạn chạy npm install, bạn đang tin tưởng hàng trăm maintainer mà bạn chưa bao giờ gặp.
Các vector tấn công supply chain
Typosquatting
Kẻ tấn công đăng ký package có tên gần giống package phổ biến — lodash thành lodahs, express thành expresss, cross-env thành crossenv. Developer gõ nhầm một ký tự khi cài package, hoặc copy lệnh từ blog post có lỗi chính tả, và vô tình cài package độc hại. Package giả thường chứa code gốc của package thật (để chức năng hoạt động bình thường) cộng thêm payload — thường là đánh cắp biến môi trường, SSH key, hoặc token từ máy developer.
Npm registry đã có một số biện pháp chống typosquatting — cấm đăng ký tên quá giống package phổ biến — nhưng không thể bắt hết mọi biến thể. PyPI và RubyGems cũng gặp vấn đề tương tự. Biện pháp phòng thủ tốt nhất vẫn là kiểm tra kỹ tên package trước khi cài, verify trên trang chính thức của thư viện thay vì tin vào kết quả search đầu tiên.
Chiếm tài khoản maintainer
Đây là vector nguy hiểm nhất vì package hợp pháp bị biến thành vũ khí. Kẻ tấn công chiếm tài khoản npm/PyPI của maintainer qua credential stuffing, phishing, hoặc social engineering. Sau đó publish phiên bản mới chứa mã độc — và mọi project có dependency range (^1.2.3) tự động nhận bản mới khi cài lại. Sự cố ua-parser-js là ví dụ điển hình: tài khoản maintainer bị chiếm, ba phiên bản độc hại được publish, hàng triệu project bị ảnh hưởng trong vài giờ.
Bật 2FA cho tài khoản registry là biện pháp bắt buộc cho maintainer. npm đã yêu cầu 2FA cho top-100 package từ 2022 và đang mở rộng dần. Nhưng với tư cách consumer, bạn không kiểm soát được maintainer có bật 2FA hay không — nên cần lockfile và audit để phát hiện sớm.
Malicious install scripts
Khi chạy npm install, npm không chỉ tải package về mà còn chạy các script được định nghĩa trong package.json: preinstall, install, postinstall. Đây là cơ chế hợp lệ — nhiều package cần compile native addon hoặc download binary. Nhưng nó cũng là cửa ngõ để mã độc chạy trên máy developer hoặc CI server ngay lúc cài đặt, trước khi bất kỳ dòng code nào của project được thực thi.
Install script có quyền truy cập mọi thứ mà user chạy npm install có quyền: đọc biến môi trường (chứa API key, token), đọc file trên disk (SSH key, AWS credentials), gửi dữ liệu ra ngoài qua HTTP. Một số cuộc tấn công tinh vi chỉ chạy payload khi detect môi trường CI (kiểm tra biến CI=true) để tránh bị phát hiện trên máy developer.
Dependency confusion
Khi tổ chức dùng private registry (Artifactory, Verdaccio, GitHub Packages) cho internal package, có thể xảy ra xung đột tên với public registry. Ví dụ: team có package private @company/auth-utils trên internal registry. Kẻ tấn công đăng ký auth-utils (không có scope) trên npm public với version cao hơn. Tuỳ cấu hình package manager, bản public có thể được ưu tiên vì version cao hơn — và code độc hại được cài thay vì package internal.
Biện pháp phòng thủ: luôn dùng scoped package (@company/package-name) cho internal package, cấu hình registry mapping rõ ràng trong .npmrc — scope @company trỏ về private registry, mọi thứ khác trỏ về npm public. Không để package manager tự quyết định nơi lấy package khi có nhiều registry.
Lockfile — tuyến phòng thủ đầu tiên
package-lock.json (npm), yarn.lock (Yarn), pnpm-lock.yaml (pnpm), go.sum (Go), Gemfile.lock (Ruby), poetry.lock (Python) — tất cả đều phục vụ cùng mục đích: ghi lại chính xác phiên bản và integrity hash của mọi dependency (kể cả transitive) tại thời điểm cài đặt.
Khi không có lockfile, npm install trên máy developer và trên CI server có thể cho kết quả khác nhau. package.json ghi "lodash": "^4.17.0" — trên máy developer cài tuần trước được 4.17.20, trên CI cài hôm nay được 4.17.21. Thường thì không sao, nhưng nếu 4.17.21 chứa bug hoặc mã độc thì production bị ảnh hưởng trong khi máy developer vẫn chạy tốt — debug cực khó.
Lockfile đảm bảo mọi lần cài đặt đều cho cùng kết quả — deterministic install. Quan trọng hơn, lockfile chứa integrity hash (SHA-512) của từng package tarball. Nếu ai đó thay đổi nội dung package mà giữ nguyên version number (hiếm nhưng đã xảy ra), hash sẽ không khớp và package manager sẽ từ chối cài.
Commit lockfile vào git là bắt buộc — đây không phải chuyện tranh luận nữa. Cả npm, Yarn, pnpm, Go module đều khuyến nghị commit lockfile. Trên CI, dùng npm ci thay vì npm install — lệnh này đọc lockfile và cài chính xác những gì lockfile ghi, không resolve lại version range. Nếu lockfile không khớp package.json, npm ci fail ngay thay vì âm thầm update — đây là hành vi mong muốn.
Một sai lầm phổ biến: có lockfile nhưng CI vẫn chạy npm install. Lệnh này có thể update lockfile nếu có version mới thoả mãn range, nghĩa là bạn không thực sự enforce deterministic install. Luôn dùng lệnh “frozen install” tương ứng: npm ci, yarn install --frozen-lockfile, pnpm install --frozen-lockfile.
Audit — quét lỗ hổng tự động
npm audit và tương đương
npm audit kiểm tra dependency tree của project so với database lỗ hổng đã biết (GitHub Advisory Database). Output liệt kê package bị ảnh hưởng, severity (critical, high, moderate, low), và phiên bản đã fix nếu có.
npm audit
# hoặc chỉ xem lỗi nghiêm trọng
npm audit --audit-level=high
Go có govulncheck — thông minh hơn npm audit vì nó phân tích xem code của bạn có thực sự gọi function bị lỗ hổng hay không, thay vì chỉ kiểm tra version. Python có pip-audit. Ruby có bundler-audit. Rust có cargo audit.
Chạy audit trong CI pipeline là tối thiểu. Cấu hình CI fail khi có lỗ hổng severity high hoặc critical. Lỗ hổng moderate và low nên review thủ công định kỳ — không phải ignore vĩnh viễn nhưng cũng không cần block deploy.
Thực tế thì npm audit hay cho ra nhiều false positive và lỗ hổng trong devDependency (chỉ chạy lúc build, không ảnh hưởng production). Mình thấy nhiều team bắt đầu dùng rồi bỏ vì “toàn cảnh báo không liên quan”. Cách tiếp cận thực dụng: dùng npm audit --omit=dev để chỉ kiểm tra production dependency, và maintain file .nsprc hoặc tương đương để ignore advisory cụ thể đã review và xác nhận không ảnh hưởng — kèm comment giải thích lý do ignore.
Socket.dev và phân tích hành vi
Khác với audit truyền thống (kiểm tra CVE đã biết), Socket.dev phân tích hành vi thực tế của package: có install script không, có truy cập network không, có đọc environment variable không, có obfuscated code không. Đây là layer phát hiện sớm cho zero-day supply chain attack — loại tấn công mà CVE database chưa biết.
GitHub tích hợp Socket.dev vào Dependabot alert. Nếu project dùng GitHub, bật GitHub security advisories là được layer bảo vệ này miễn phí cho public repo.
Dependabot và Renovate — cập nhật tự động
Dependency không cập nhật là dependency tích luỹ lỗ hổng. Nhưng cập nhật thủ công hàng trăm dependency mỗi tháng là không khả thi. Đây là lúc cần bot tự động.
Dependabot
GitHub Dependabot tạo PR tự động khi dependency có phiên bản mới hoặc có security advisory. Cấu hình qua .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
dev-dependencies:
dependency-type: "development"
production-dependencies:
dependency-type: "production"
Grouping là tính năng quan trọng — thay vì 30 PR riêng lẻ cho 30 package, gom thành 2-3 PR theo nhóm (dev, production, framework). Giảm review fatigue đáng kể.
Renovate
Renovate (Mend) linh hoạt hơn Dependabot nhiều: automerge cho patch update nếu CI xanh, custom regex manager cho dependency trong Dockerfile hoặc Terraform, schedule update theo timezone, và nhiều chiến lược grouping phức tạp.
{
"extends": ["config:recommended"],
"automerge": true,
"automergeType": "pr",
"major": { "automerge": false },
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true,
"groupName": "patch updates"
}
]
}
Automerge cho patch update là pattern mình khuyến khích: nếu CI pass (lint, test, build đều xanh), merge tự động. Patch update theo semver cam kết không breaking change — và nếu test suite đủ tốt, risk rất thấp. Minor update nên review nhanh. Major update cần review kỹ hơn, đặc biệt đọc changelog xem có breaking change nào ảnh hưởng.
Cả Dependabot và Renovate đều chỉ hiệu quả khi CI pipeline đủ tin cậy. Nếu test suite yếu (coverage thấp, flaky test), automerge sẽ để lọt bug — và bạn đổ lỗi cho tool thay vì cho test. Đầu tư vào test quality trước khi bật automerge.
Semantic versioning và sự tin tưởng ngầm
Khi bạn viết "express": "^4.18.0" trong package.json, bạn đang nói: “tôi chấp nhận bất kỳ phiên bản nào từ 4.18.0 đến dưới 5.0.0”. Đây là trust statement — bạn tin rằng maintainer của Express sẽ tuân thủ semver: patch chỉ fix bug, minor chỉ thêm feature tương thích ngược, major mới có breaking change.
Thực tế thì semver là convention, không phải contract bắt buộc. Maintainer có thể vô tình giới thiệu breaking change trong minor update — hoặc cố ý nếu tài khoản bị chiếm. Lockfile bảo vệ bạn khỏi thay đổi bất ngờ giữa các lần cài đặt, nhưng khi bạn chủ động update (qua Dependabot PR hoặc npm update), bạn nhận version mới và phải verify nó hoạt động đúng.
Pinning chính xác ("express": "4.18.2" thay vì "^4.18.2") loại bỏ hoàn toàn yếu tố bất ngờ — mỗi lần update phải sửa version number rõ ràng, đi qua PR review. Nhưng trade-off là maintenance burden tăng: mỗi security patch phải tạo PR thủ công (hoặc qua bot) thay vì tự động nhận. Với library publish lên registry, pinning dependency gây vấn đề duplicate — hai library cùng dùng lodash nhưng pin version khác nhau sẽ có hai bản lodash trong bundle.
Cách tiếp cận cân bằng mà nhiều team dùng: range (^) cho dependency có test coverage tốt trong project, pin cho dependency “nhạy cảm” (crypto, auth, payment) hoặc dependency có lịch sử breaking change trong minor/patch. Lockfile đảm bảo deterministic install bất kể strategy nào.
Install scripts và cách hạn chế
npm install script là cơ chế hợp lệ nhưng cũng là vector tấn công phổ biến nhất. Hạn chế install script không khó nhưng cần chủ động.
Chạy npm install --ignore-scripts rồi kiểm tra package nào thật sự cần script. Phần lớn package JavaScript thuần không cần install script — chỉ package có native addon (node-gyp, sharp, bcrypt) mới cần. Từ npm 9+, có thể allowlist package được phép chạy script:
# .npmrc
ignore-scripts=true
[install-scripts]
allow[]=sharp
allow[]=bcrypt
Cách này đảm bảo chỉ package đã review mới được chạy code lúc install. Package mới thêm vào project phải qua bước “có cần install script không, script làm gì” trước khi được allow.
Trên CI, --ignore-scripts nên là mặc định, bật lại cho package cụ thể qua cấu hình. CI server thường có biến môi trường nhạy cảm (deploy key, API token) — install script có quyền đọc hết nếu không bị chặn.
Private registry và dependency confusion prevention
Nếu tổ chức có internal package, cấu hình registry mapping rõ ràng là bắt buộc:
# .npmrc
@company:registry=https://npm.company.internal/
registry=https://registry.npmjs.org/
Mọi package có scope @company sẽ chỉ được tải từ internal registry. Package không có scope tải từ npm public. Kẻ tấn công không thể đăng ký @company/anything trên npm public vì scope đã được claim.
Nếu internal package không dùng scope (legacy), cần cấu hình proxy registry (Artifactory, Nexus) với policy: package có tên trùng với internal package chỉ được tải từ internal source, không fallback về public. Một số proxy hỗ trợ “exclude pattern” để block lookup public cho tên package internal.
Thêm biện pháp phòng thủ: đăng ký tên internal package trên public registry với version 0.0.1 chứa README cảnh báo “đây là placeholder, package thật nằm trên internal registry”. Cách này ngăn kẻ tấn công chiếm tên trên public registry — đơn giản nhưng hiệu quả.
SBOM — biết mình đang chạy gì
SBOM (Software Bill of Materials) là danh sách chi tiết mọi component trong phần mềm — dependency, version, license, origin. Tương tự nhãn thành phần trên bao bì thực phẩm, SBOM cho bạn biết phần mềm “chứa gì bên trong”.
Hai format phổ biến là SPDX (Linux Foundation) và CycloneDX (OWASP). Cả hai đều output JSON hoặc XML, liệt kê package name, version, license, hash, và relationship (dependency tree).
# Tạo SBOM CycloneDX cho project Node.js
npx @cyclonedx/cyclonedx-npm --output-file sbom.json
# Tạo SBOM cho Go module
cyclonedx-gomod mod -json -output sbom.json
SBOM có giá trị khi incident xảy ra: Log4Shell công bố ngày 9/12/2021, mọi tổ chức phải trả lời “service nào của chúng ta dùng Log4j?”. Team có SBOM cho mỗi service trả lời trong vài phút — grep SBOM, liệt kê service bị ảnh hưởng, ưu tiên patch. Team không có SBOM phải scan từng repo, từng Docker image — mất hàng ngày.
Một số ngành (y tế, tài chính, chính phủ Mỹ theo Executive Order 14028) đã bắt đầu yêu cầu SBOM từ vendor phần mềm. Dù chưa phải bắt buộc phổ quát, tạo SBOM trong CI pipeline là đầu tư nhỏ với giá trị phòng thủ lớn. Lưu SBOM artifact cùng mỗi bản release — khi cần kiểm tra “bản deploy ngày X có chứa package Y version Z không?”, câu trả lời có sẵn.
License compliance
Dependency không chỉ mang theo code mà còn mang theo license. MIT và Apache-2.0 rất permissive — dùng thoải mái trong commercial software, chỉ cần giữ copyright notice. BSD tương tự.
GPL (v2, v3) có tính “lây lan” — nếu bạn link GPL library vào phần mềm, toàn bộ phần mềm phải distribute dưới GPL, nghĩa là phải mở source code. Với SaaS (chạy trên server, không distribute binary cho user), GPL thường không áp dụng — nhưng AGPL thì có. AGPL yêu cầu mở source ngay cả khi phần mềm chạy trên server mà user truy cập qua network. MongoDB chuyển sang SSPL (Server Side Public License) vì lý do tương tự — và nhiều tổ chức cấm dùng SSPL dependency.
Kiểm tra license nên tự động hoá trong CI:
npx license-checker --production --failOn "GPL-2.0;GPL-3.0;AGPL-3.0"
Lệnh trên fail build nếu bất kỳ production dependency nào dùng GPL/AGPL. Danh sách license cho phép và cấm nên được legal team hoặc tech lead chốt, document rõ ràng, và enforce qua CI — không phải để developer phải nhớ.
Giảm dependency count
Mỗi dependency thêm vào là thêm bề mặt tấn công, thêm gánh nặng maintenance, thêm rủi ro license. Câu hỏi “có thật sự cần package này không?” nên được hỏi mỗi lần thêm dependency mới.
Package is-odd (kiểm tra số lẻ) có 500,000 download/tuần trên npm. Package is-number có hàng triệu download. Những utility package một function này tồn tại vì JavaScript không có standard library phong phú, nhưng mỗi package là một node trong dependency tree cần maintain, audit, và trust.
Trước khi thêm dependency, kiểm tra: function đó có thể implement trong 10-20 dòng code không? Nếu có, viết tay và giữ trong project. Đó là code bạn kiểm soát hoàn toàn — không ai inject mã độc, không có transitive dependency, không cần audit. left-pad là bài học kinh điển: 11 dòng code, hàng nghìn project phụ thuộc, và khi maintainer unpublish thì nửa internet bị ảnh hưởng.
Với dependency lớn hơn (framework, ORM, HTTP client), đánh giá dựa trên: bao nhiêu maintainer active, commit gần nhất bao lâu, có security policy không, có FUNDING.yml không (dấu hiệu project được hỗ trợ tài chính). Package một maintainer, commit cuối 2 năm trước, không có security disclosure process — đó là rủi ro cần cân nhắc nghiêm túc.
CI pipeline checks
Audit, license check, và SBOM nên chạy tự động trong CI, không phụ thuộc vào developer nhớ chạy thủ công.
# GitHub Actions example
- name: Install with frozen lockfile
run: npm ci
- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=high
- name: License compliance
run: npx license-checker --production --failOn "GPL-2.0;GPL-3.0;AGPL-3.0"
- name: Generate SBOM
run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.json
Policy quyết định CI fail hay warn nên rõ ràng: critical/high vulnerability → fail. Moderate → warn, review trong sprint. Low → log, review quarterly. License violation → fail luôn vì đây là rủi ro pháp lý, không phải kỹ thuật.
Lockfile diff cũng nên được review kỹ trong PR. Khi package-lock.json thay đổi, kiểm tra: package mới nào được thêm vào, version nào thay đổi, có integrity hash thay đổi bất thường không (cùng version nhưng hash khác là dấu hiệu package bị tamper). GitHub hiển thị lockfile diff readable hơn raw JSON — nhưng với thay đổi lớn, dùng tool so sánh chuyên dụng.
Monitoring liên tục
Security không phải “setup xong rồi quên”. Lỗ hổng mới được phát hiện hàng ngày, package bị compromised bất kỳ lúc nào.
GitHub security advisories là layer miễn phí cho mọi repo trên GitHub — bật Dependabot alerts để nhận notification khi dependency có CVE mới. Snyk cung cấp scanning sâu hơn, hỗ trợ nhiều ecosystem, và có tính năng fix PR tự động. Socket.dev phân tích hành vi package real-time — phát hiện supply chain attack trước khi có CVE.
Đặt SLA cho việc xử lý security alert: critical trong 24 giờ, high trong 1 tuần, moderate trong 1 sprint. Alert không có SLA thì sẽ bị ignore — đã thấy quá nhiều lần. Assign owner rõ ràng cho security alert — “team nào own service này thì own security alert của service đó” là quy tắc đơn giản nhất.
Sai lầm phổ biến
Ignore audit warning là thói quen nguy hiểm nhất. Nhiều team chạy npm audit, thấy 47 vulnerability, overwhelm rồi ignore hết. Tháng sau thấy 53, vẫn ignore. Đến khi bị exploit thì mới cuống — nhưng lúc đó đã muộn. Cách đúng là triage: fix critical/high ngay, acknowledge moderate với lý do (không ảnh hưởng vì là devDependency, hoặc function bị lỗ hổng không được gọi), và schedule review định kỳ.
Không update dependency trong nhiều tháng tạo “update cliff” — khi cuối cùng phải update (vì security, vì feature mới cần), bạn phải nhảy nhiều major version cùng lúc. Breaking change chồng breaking change, migration guide dài hàng trang, risk cao hơn nhiều so với update incremental. Renovate/Dependabot với cadence weekly giữ dependency luôn gần latest — mỗi PR nhỏ, dễ review, dễ rollback.
Blindly accept Dependabot PR cũng là vấn đề — đặc biệt major update. Automerge cho patch OK nếu CI tốt. Nhưng major update cần đọc changelog, kiểm tra breaking change, verify integration. PR xanh CI không có nghĩa là không có regression — test coverage 60 % nghĩa là 40 % hành vi không được kiểm tra.
Copy-paste dependency từ tutorial hoặc Stack Overflow mà không verify cũng phổ biến. Tutorial viết năm 2019 recommend package version 2.x — bạn cài năm 2026 được version 5.x với API hoàn toàn khác, hoặc package đã bị deprecated và replace bằng package khác. Luôn kiểm tra npm page hoặc GitHub repo trước khi thêm dependency.
Tóm tắt
Supply chain attack nhắm vào điểm yếu nhất trong chuỗi: dependency mà bạn tin tưởng nhưng không kiểm soát. Lockfile commit vào git và dùng frozen install (npm ci) trên CI là tuyến phòng thủ đầu tiên — đảm bảo deterministic install và phát hiện tampering qua integrity hash.
Audit tự động trong CI (npm audit, govulncheck, pip-audit) bắt lỗ hổng đã biết. Dependabot hoặc Renovate giữ dependency luôn gần latest, automerge patch khi CI xanh, review minor và major thủ công. Install script hạn chế qua --ignore-scripts với allowlist cho package cần thiết. Private registry cấu hình scope mapping rõ ràng để ngăn dependency confusion.
SBOM tạo trong CI giúp trả lời nhanh “service nào bị ảnh hưởng” khi zero-day xuất hiện. License check tự động ngăn rủi ro pháp lý từ GPL/AGPL lọt vào production. Và cuối cùng, giảm dependency count bằng cách hỏi “có thật sự cần package này không” — mỗi dependency bớt đi là một bề mặt tấn công bớt đi, một thứ bớt phải maintain, và một layer trust bớt phải đặt vào người lạ.