open atlas
↑ Back to track
Node.js, zero to senior NODE · 07 · 02

Dependencies and supply-chain risk

Installing a package runs its code. Pin and verify the tree with a committed lockfile and `npm ci`, gate known CVEs with `npm audit`, block lifecycle scripts on untrusted installs, validate merge keys, and never treat `vm` as a sandbox.

NODE Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

A team shipped a small CLI on a Friday. Over the weekend a maintainer two levels deep in the dependency tree pushed a patch release whose postinstall script base64-decoded a payload, read process.env, and POSTed every secret it found to a pastebin. The team never ran that code on purpose — it ran the instant their CI did npm install, before a single line of their own application loaded. The audit dashboard was green; there was no CVE yet. The breach wasn’t in their code at all. It was in the act of installing, which on npm is just another way of saying running arbitrary code from strangers.

Installing is running code

The single fact that reframes dependency security: npm install executes arbitrary code. Think about that the next time you reach for a new convenience library — every indirect dependency it pulls in has the same privilege. A package can declare lifecycle scriptspreinstall, install, postinstall — and npm runs them on your machine, with your user’s permissions, the moment the package lands. There is no review gate, no sandbox, and no diff you read first. A postinstall has full access to the filesystem, the network, and process.env, which in CI is exactly where your registry tokens, cloud keys, and deploy credentials live. This is not a theoretical edge: it is the most direct supply-chain vector there is, because it fires before your application code — and any tests or scanners that might catch malicious behavior — ever runs.

The defensive posture is to stop treating install as inert. For untrusted installs and especially in CI, run scripts off by default:

# Install strictly from the lockfile AND skip all lifecycle scripts
npm ci --ignore-scripts

# Or make it the machine-wide default for an untrusted environment
npm config set ignore-scripts true

--ignore-scripts skips every package’s lifecycle hooks. The tradeoff is real: a few legitimate packages (native addons that compile bindings, e.g. node-gyp builds) genuinely need a script to function, and silently blocking them produces a broken install rather than a malicious one. So you vet the small set that needs scripts and allow them deliberately, rather than granting blanket execution to your entire transitive tree. And the deeper lever is count: most of the code that ships in a Node service is third-party, often hundreds of transitive packages from authors you’ll never audit. Every dependency you remove is attack surface you remove.

The lockfile is your integrity boundary

package.json records the versions you want (often ranges like ^4.2.0). package-lock.json records the exact versions you actually got — and, critically, an SRI integrity hash (sha512) for every package’s tarball. That hash is the tamper-evidence: if the bytes fetched from the registry don’t hash to the recorded value, the install fails. This is why the lockfile is not an annoyance to .gitignore — it is your reproducibility and integrity boundary, and it must be committed.

The command that enforces it is npm ci, not npm install:

# CI: deterministic, integrity-verified, lockfile-only
npm ci

npm ci installs strictly from package-lock.json, fails loudly if package.json and the lockfile are out of sync (rather than silently “fixing” the lock the way npm install does), deletes node_modules first for a clean tree, and verifies every integrity hash. npm install, by contrast, may resolve new versions, mutate the lockfile, and pull in a freshly-published malicious patch within your ^ range. The rule is mechanical: npm ci in CI and reproducible builds, npm install only locally when you are deliberately changing dependencies — and review the resulting lockfile diff like code.

ThreatMechanismPrimary mitigation
Lifecycle scriptpostinstall runs at install, reads process.envnpm ci —ignore-scripts; vet the few that need scripts
Lockfile driftnpm install resolves a new malicious patch in rangecommit the lockfile; npm ci; review lock diffs
Known CVEa transitive dep has a published vulnerabilitynpm audit —audit-level=high gate; Dependabot/Renovate
Prototype pollutionproto in input taints Object.prototypeallowlist keys; Object.create(null); —disable-proto
Typosquat / confusionmalicious name shadows a real / private packagescoped packages; pin registry/scope; verify names
Untrusted codevm realm is escapable via prototypesseparate process/worker, container, or isolated-vm

Scanning for known vulnerabilities

A committed lockfile makes builds reproducible, but it also freezes known vulnerabilities in place — pinning is only safe if you keep watching what you pinned. npm audit cross-references your installed tree against the registry’s advisory database and reports CVEs with severity and a fix path:

# Report vulnerabilities; fail CI only on high/critical to cut noise
npm audit --audit-level=high

# Apply safe (semver-compatible) fixes; --force may take breaking majors
npm audit fix

The mechanism is a tree-vs-advisory diff, so its weakness is the same as any signature scanner: it sees only published, known issues, it carries false positives and noise (a “critical” in a dev-only transitive dep you never call), and npm audit fix --force can silently bump a dependency across a major version and break you. So gate CI at --audit-level=high to reduce alert fatigue, automate the upgrade churn with Dependabot or Renovate (which open reviewable PRs), and layer a behavior-aware tool — Snyk or Socket — that inspects install scripts and package behavior, not just version numbers, to catch the brand-new malicious release that has no CVE yet. Audit is a floor, not a ceiling.

Prototype pollution and untrusted input

Not every supply-chain failure is a stolen secret; some are logic corruption. Prototype pollution happens when attacker-controlled input containing the keys __proto__ or constructor.prototype is merged into an object by vulnerable code — a naive recursive deep-merge, a permissive Object.assign loop, or a query-string parser. Because almost every object inherits from Object.prototype, writing through __proto__ mutates that shared prototype, and the pollution leaks into every object in the process: a planted isAdmin: true can flip an authorization check that reads user.isAdmin, and in the worst cases a polluted property feeds a code path that reaches child_process or a template engine and becomes RCE.

// VULNERABLE: a naive deep-merge follows __proto__ straight onto Object.prototype
function badMerge(target, src) {
  for (const k in src) {
    if (typeof src[k] === "object") badMerge(target[k] ??= {}, src[k]);
    else target[k] = src[k];        // k === "__proto__" pollutes everything
  }
}

// DEFENSE: reject dangerous keys and use a null-prototype map
function safeMerge(target, src) {
  for (const k of Object.keys(src)) {
    if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
    target[k] = src[k];
  }
  return target;
}
const lookup = Object.create(null); // no prototype to pollute

Defenses stack: validate and allowlist input keys (a schema validator rejects __proto__ outright), use Object.create(null) for objects used as maps so there is no prototype to corrupt, keep merge/clone libraries patched, and at the process level consider Object.freeze(Object.prototype) or running with the --disable-proto=delete flag to remove the __proto__ accessor entirely.

Why this works

Typosquatting and dependency confusion are the naming attacks. A typosquat is a malicious package named one keystroke off a popular one (cross-env vs crossenv), hoping you mistype. Dependency confusion is sharper: if your internal package @acme/auth isn’t published and your install can reach the public registry, an attacker publishes a public @acme/auth at a higher version, and a misconfigured resolver pulls the impostor over your private one. Defenses: always use scoped packages for internal code, pin each scope to its private registry in .npmrc (@acme:registry=...), verify exact package names before adding them, and rely on the lockfile so an unexpected name can’t slip in unreviewed.

Pick the best fit

Your CI pipeline installs dependencies before running tests, and the repo has a committed lockfile. Which install command should the pipeline use?

You cannot sandbox untrusted code with vm

When you need to run code you don’t trust — a user-submitted formula, a plugin — the seductive wrong answer is Node’s built-in vm module. vm is not a security sandbox. Its own documentation says so. It runs code in a separate V8 context, but that context shares prototypes and references with the host: untrusted code can climb from a passed-in object’s constructor up to the host realm’s Function and reconstruct full access, breaking out of the “sandbox” entirely.

const vm = require("node:vm");
// Escape: reach the host Function constructor through a passed object's chain.
const escape = "this.constructor.constructor('return process')().env";
// vm.runInNewContext(escape, { /* "sandboxed" */ }) // → leaks process.env

Real isolation requires a real boundary. Run untrusted code in a separate process or worker thread with dropped privileges, inside a container or microVM with restricted syscalls and no network, or in a purpose-built isolate such as isolated-vm (a true separate V8 isolate) or a hardened SES/vm2-successor runtime. The mechanism that matters is that the untrusted code must not share an object graph or a process with your secrets — a different V8 context on the same heap is not that boundary.

Quiz

Why should a CI pipeline use `npm ci` rather than `npm install` for a repo with a committed lockfile?

Quiz

Why is Node's `vm` module unsafe for running untrusted user code as a sandbox?

Order the steps

Order the steps to lock down a CI install against supply-chain attacks, from prerequisite to last line of defense:

  1. 1 Commit package-lock.json so exact versions and sha512 integrity hashes are pinned in the repo
  2. 2 Use `npm ci` (not `npm install`) so the build is lockfile-only and fails on drift
  3. 3 Add `--ignore-scripts` for installs you don't fully trust, vetting the few packages that truly need scripts
  4. 4 Gate the pipeline with `npm audit --audit-level=high` and automate upgrades via Dependabot/Renovate
  5. 5 Layer a behavior-aware scanner (Snyk/Socket) to catch malicious releases that have no CVE yet
Recall before you leave
  1. 01
    A maintainer publishes a malicious patch release of a transitive dependency whose postinstall reads process.env. Which two of your defenses block it, and how does each work?
  2. 02
    Why isn't Node's vm module a security sandbox, and what is the correct way to run untrusted user code?
Recap

Dependency security starts from one fact: npm install runs arbitrary code, because packages can declare preinstall/install/postinstall lifecycle scripts that execute with your permissions and can read process.env before your own code or any scanner runs — so block them with npm ci --ignore-scripts (or npm config set ignore-scripts true) for untrusted installs, and shrink the attack surface by minimizing dependency count and transitive depth, since most of your code is third-party. The lockfile is your integrity boundary: package-lock.json pins exact versions and sha512 SRI hashes, and npm ci installs strictly from it, fails on package.json/lock drift, and verifies integrity — so commit it and use npm ci in CI, never npm install, which may mutate the lock and pull an in-range malicious patch. Layer detection on top: npm audit --audit-level=high gates known CVEs (noisy and signature-only), Dependabot/Renovate automate upgrades, and Snyk/Socket inspect behavior to catch releases with no CVE yet. Guard runtime input against prototype pollution — reject __proto__/constructor/prototype keys, use Object.create(null) maps, and consider --disable-proto=delete — and prefer scoped packages pinned to a private registry to defeat typosquatting and dependency confusion. Finally, never treat the vm module as a sandbox: it shares prototypes with the host and is escapable, so run genuinely untrusted code in a separate process, container, or isolated-vm where it can touch neither your object graph nor your secrets. Now when you review a dependency PR or set up a new CI job, you’ll know which three commands (npm ci, --ignore-scripts, npm audit) form the non-negotiable baseline.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.