
A vulnerable dependency has a known bug. A malicious dependency was written to hurt you. Both arrive through the same install.
Audit tools find many of the first. They will not always find the second on day one, because there is no CVE yet. A lockfile review and a sudden maintainer change are the signals for the second.
The usual mistake is treating npm audit as coverage for both.
This page is how to tell the two apart in an incident, and the install habits that help with each.
chalk@5.6.1 appeared on npm on 8 September 2025. chalk issue 656, created at 13:55 UTC that day, and the BleepingComputer story dated 8 September 2025. sindresorhus wrote that the bad versions “were available for approximately 2 hours.” A scanner that only matches public CVEs in a lockfile had nothing to match while those tarballs were live.
That split is still the right split. The 2025 chalk, debug, and strip-ansi window is the current proof. Keep the dependency-update guide next to this page when the work is a planned bump. Read injection when the code you already trust starts concatenating untrusted input. This page is the difference between those two jobs.
Two failures, two clocks
A vulnerable pin is a version you already chose. A researcher files a CVE. GitHub, NVD, or npm advisory grows a record. Your scanner lights up because the lockfile names a coordinate it knows. You read the fix version, you bump, you ship. The clock is days to weeks. The code was honest when you installed it.
Put the two clocks next to each other. A CVE on a pin you already chose is homework. An install-time hatch is a surprise publish, and the lockfile is the first place it shows.
SecureCoding
A malicious install is a version nobody intended you to run. The tarball on the registry changed because an account did. There is no CVE in the first hour. The lockfile, if you regenerate it in that hour, will happily record the new number. The clock is minutes. The code was hostile when you installed it.
Those are not two severities of the same ticket. One is patch management. The other is registry trust. Mixing them is how a team spends Friday on lodash while a two-hour publish walks into the build.
What 8 September 2025 actually was
I am dating the window from first-party and BleepingComputer pages, not from secondary blogs.
- chalk#656 opened 8 September 2025 at 13:55 UTC. The title is “Version 5.6.1 published to npm is compromised (RESOLVED).”
- sindresorhus listed the republished clean lines (chalk@5.6.2, strip-ansi@7.1.2, and the rest of that family) and wrote they “were available for approximately 2 hours.” A later note on the same comment, “08 Sep 2025 21:59 CEST”, says npm took the infected versions down.
- BleepingComputer, by Sergiu Gatlan, 8 September 2025, said a phishing mail from support at npmjs.help led to the maintainer account, and that the set included chalk, debug, strip-ansi, ansi-styles, and more, with weekly downloads in the billions.
Help Net Security dated a writeup 9 September 2025. That is the next-day article, not a second day of live tarballs. The first-party issue and BleepingComputer put the publish and the takedown on 8 September.
The payload, as BleepingComputer and the maintainer thread described it, ran in the browser and rewrote cryptocurrency destinations. This page will not reconstruct it. The defensive fact is enough: a package you already trusted grew new code at publish time, and the clients that installed during those two hours received it. The public thread is HN 45196235.
Complete 8 September coordinates from chalk#656, so a grep of this list is not a partial pass:
chalk 5.6.1
debug 4.4.2
ansi-styles 6.2.2
supports-color 10.2.1
ansi-regex 6.2.1
wrap-ansi 9.0.1
has-ansi 6.0.1
chalk-template 1.1.1
supports-hyperlinks 4.1.1
strip-ansi 7.1.1
slice-ansi 7.1.1
color-convert 3.1.1
color-name 2.0.1
color-string 2.1.1
color 5.0.1
simple-swizzle 0.2.3
is-arrayish 0.3.3
error-ex 1.3.3
backslash 0.2.1
If any of those strings still appear in a lockfile in 2026, that tree was written during the window or copied from one that was. Replace each pin with the clean line the maintainer published the same day.
Why a CVE scanner stays quiet
Software composition analysis matches a coordinate to an advisory. No advisory, no alert. A brand-new version of a beloved package looks like hygiene. Many pipelines even auto-merge a minor bump when the score is clean.
That is the right behavior for a vulnerable pin. lodash@4.17.20 with a public CVE is a known quantity. The fix is a higher number that the same advisory already names. You can schedule it. You can explain it to a ticket.
It is the wrong behavior for a malicious install. The 5.6.1 chalk tarball had no CVE when it landed. npm audit had nothing to print. Dependabot had nothing to open. The only signal was “this number was not in our last lockfile” or “this tarball grew a file we did not review.”
Three other blind spots sit next to that one:
- Transitive installs. You may never have typed
chalk. The lockfile still records it. A floating range one level up can pull the new number without a human seeing the name. - Build caches. A CI cache that keys on
package.jsonbut not the lockfile will fetch whatever the range allows today. - Developer laptops.
npm updateon a Monday morning is an install-time event. The lockfile on main does not protect a laptop that rewrote it.
The secure coding checklist belongs here for the same reason it belongs next to injection: the control is a boundary you enforce, not a scanner you hope finishes first.
Pin the lockfile, then decide who may run install scripts
Commit package-lock.json or npm-shrinkwrap.json. Build with npm ci. npm ci refuses to proceed when the lockfile and package.json disagree. That is the feature. npm install will resolve, rewrite, and keep going.
# CI: install exactly what main already agreed
npm ci --ignore-scripts
ignoreScripts is the named fallback when a package still needs a compile step. Keep the default off, then allow one package at a time:
;.npmrc in the app repo. npm's ini parser treats // as a key.
ignore-scripts=true
// package.json scripts you own, not a dependency's postinstall
{
"scripts": {
"postinstall": "node./scripts/build-native.js"
}
}
// scripts/build-native.js
// Only the packages you listed may compile.
const { execFileSync } = require("node:child_process");
const ALLOW_REBUILD = new Set(["esbuild", "sharp"]);
for (const name of ALLOW_REBUILD) {
execFileSync("npm", ["rebuild", name], { stdio: "inherit" });
}
ALLOW_REBUILD is the same set the malicious-code review hook consults. A dependency that adds postinstall without going through that list does not run on the build agent.
Pin exact versions in the lockfile. Prefer save-exact=true for direct dependencies so a reopen of the PR does not silently move. Ranges in package.json are a product choice. The lockfile is the security choice. Never regenerate the lockfile on the release agent.
Integrity hashes in a lockfile v2 or v3 file are the cheap tamper check for a tarball you have already accepted. They do not decide whether the accepted tarball was honest. They decide whether the bits changed after you accepted them.
After a bad publish lands
This page is the CVE-lane versus hijack-lane split. Containment, the review hook, and the rebuild steps live on malicious-code. Use that runbook when a coordinate you did not mean is already in a lockfile. This page only needs you to recognize that a quiet CVE scanner is the wrong clock.
To grep a lockfile v2 or v3 for the 8 September family, match the node_modules/name key and the version field, not the tarball filename:
rg -n '"node_modules/(chalk|debug|ansi-styles|supports-color|strip-ansi|slice-ansi|color-convert|color-name|color-string|color|simple-swizzle|is-arrayish|error-ex|backslash|ansi-regex|wrap-ansi|has-ansi|chalk-template|supports-hyperlinks)": \{' -A2 package-lock.json | rg '"version": "(5\.6\.1|4\.4\.2|6\.2\.2|10\.2\.1|7\.1\.1|3\.1\.1|2\.0\.1|2\.1\.1|5\.0\.1|0\.2\.3|0\.3\.3|1\.3\.3|0\.2\.1|6\.2\.1|9\.0\.1|6\.0\.1|1\.1\.1|4\.1\.1)"'
Or ask npm: npm ls chalk debug strip-ansi color-convert error-ex. Planned upgrades stay on updating open source dependencies.
Prove the tree you ship
You are not proving a package is kind. You are proving the build cannot silently move, and that a new postinstall cannot run unnoticed.
# 1. ci must be clean against the committed lockfile
npm ci --ignore-scripts
# 2. a second ci with a broken pin must fail
cp package.json /tmp/p.json
sed -i 's/"chalk": "[^"]*"/"chalk": "9.9.9"/' package.json
(npm ci --ignore-scripts && echo FAIL) || echo PASS
mv /tmp/p.json package.json
# 3. walk node_modules package.json files. npm ls --json has no scripts field.
node scripts/review_lifecycle.js
// scripts/review_lifecycle.js
const fs = require("node:fs");
const path = require("node:path");
const ALLOW_REBUILD = new Set(["esbuild", "sharp"]);
const hits = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
if (!ent.isDirectory()) continue;
const full = path.join(dir, ent.name);
if (ent.name.startsWith("@")) { walk(full); continue; }
const pj = path.join(full, "package.json");
if (fs.existsSync(pj)) {
const s = JSON.parse(fs.readFileSync(pj, "utf8")).scripts || {};
for (const k of ["preinstall", "install", "postinstall"]) {
if (s[k] && !ALLOW_REBUILD.has(ent.name)) hits.push(full + " " + k);
}
}
const nested = path.join(full, "node_modules");
if (fs.existsSync(nested)) walk(nested);
}
}
walk("node_modules");
if (hits.length) { console.error(hits.join("\n")); process.exit(1); }
That walker fails closed on a lifecycle script that is not in ALLOW_REBUILD. npm ls --json cannot see scripts. Read each package.json.
Also fail the build when the lockfile diff adds a package whose publisher you have not seen. npm’s --package-lock-only output in a dry-run PR is enough to read. You do not need a vendor platform for that first cut.
Questions we keep getting
Does npm audit catch a hijacked release?
Not in the first hour. Audit matches known advisories. That September publish had none while it was live. Use the lockfile diff and the install-script grep. Treat audit as the CVE lane.
Is pinning enough?
Pinning stops a floating range from moving on the next npm install. It does not stop a human from regenerating the lockfile onto a bad number. Pair the pin with npm ci and a review of unexpected bumps.
Should we ban install scripts forever?
Ban them by default. Allow a named native module when you can say why it compiles. A blanket ban you then override with ignore-scripts=false on the agent is worse than a short allowlist.



