Get listed

Malicious code: detect it in the lockfile and the review

A teal gift box with a coral spring popping out.

Malicious code in a dependency is a maintainer account or a package name you did not intend to install.

The npm ecosystem keeps seeing waves where a popular package, or a lookalike, ships a payload. Your lockfile and a review of new transitive names are the controls. Stars are not.

The usual mistake is npm audit for known CVEs and no process for a brand-new malicious version that has no CVE yet.

This page is how those campaigns land, and the install-time habits that stop the next one from looking like a routine bump.

CISA dated Widespread Supply Chain Compromise Impacting npm Ecosystem on 23 September 2025. The alert said a self-replicating worm, publicly known as Shai-Hulud, had compromised over 500 packages. Two weeks earlier, chalk@5.6.1 appeared on npm on 8 September 2025. chalk issue 656, created at 13:55 UTC that day. A scanner that only matches public CVEs had nothing to match while those tarballs were live.

That is not a control a web team can ship. Keep the secure coding checklist next to this page for the rest of the request surface. Read injection when the code you already trust starts concatenating untrusted input. Read input validation when the value is still a request field. This page stays in the tree you install and the PR you merge.

Hostile code is a publish or a merge

Intentional harm is the useful half of the old definition. A bug that drops a table is still a bug. A tarball that exfiltrates a token, or a pull request that adds eval(req.body), is hostile code. The clock is minutes, not the week it takes a CVE to land. OWASP Top 10:2025 added A03 Software Supply Chain Failures for that reason. The category has the fewest occurrences in the contributed data and the highest average exploit and impact scores from CVEs. Testing lags. Your lockfile does not have to.

A CVE scanner matches a known id. A fresh publish and a hatch in a PR have no id yet.
PUBLISH registry grows a new tarball
 no advisory in the first hour
 npm ci from last week's lockfile refuses it

MERGE PR adds eval, exec, or postinstall
 review_hatches fails the check
 CODEOWNERS requires a named reviewer

FIX restore the last agreed lockfile
 rebuild with ignore-scripts
 rotate tokens the agent could read

Those are not two severities of the same ticket. One is registry trust. The other is a reviewer who approved a hatch. Mixing them is how a team spends Friday on lodash while a two-hour publish walks into the build, or how a nightly Bandit job pages after the line already shipped.

SignalWhat it isWhat you do
New lockfile numberinstall-time eventdiff, then npm ci
New postinstalllifecycle hatchfail the PR unless it is on ALLOW_REBUILD
New eval / execCWE-94 / CWE-78fail the PR, rewrite as a parser or execFile
Known CVE on a pinvulnerable dependencybump the pin on the sibling page

Two September 2025 windows

I am dating both windows from first-party pages, not from secondary blogs.

Window one is 8 September 2025. chalk#656 opened at 13:55 UTC. The title is “Version 5.6.1 published to npm is compromised (RESOLVED).” sindresorhus wrote that the bad versions “were available for approximately 2 hours.” 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, and more. Help Net Security dated a writeup 9 September 2025. That is the next-day article, not a second day of live tarballs.

Window two is the CISA alert of 23 September 2025. CISA said the actor scanned for GitHub PATs and cloud API keys, uploaded stolen credentials to a public repository named Shai-Hulud, and published further compromised versions. CISA’s first detect step is a dependency review of every lockfile. CISA’s pin line is: known safe releases produced prior to 16 September 2025. CISA also said to rotate developer credentials and to mandate phishing-resistant MFA on GitHub and npm. I will not reconstruct the worm, name a C2, or walk a publish recipe.

Compromised coordinates the chalk issue named, so you can grep a lockfile:

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

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. A later incident will have its own list. Use that list, not a vibe.

Fail the PR on eval, exec, and postinstall

A nightly scan that pages after deploy is late. The line already shipped. The gate that matches this page is a required check on the PR, plus a required reviewer on the files that can spawn a child, evaluate a string, or run an install script. Style lint does not see those hatches.

CWE-94 is code injection. CWE-78 is a shell string. A new postinstall in a dependency is the install-time cousin of both. Treat all three as merge blockers. A # nosec without a ticket id is a reject.

// scripts/review_hatches.js
const fs = require("node:fs");
const path = require("node:path");

const APP_DENY = [
 /\beval\s*\(/,
 /\bnew\s+Function\s*\(/,
 /\bchild_process\.exec\s*\(/,
 /\bchild_process\.execSync\s*\(/,
];
const LOCK_POSTINSTALL = /"postinstall"\s*:/;
const ALLOW_REBUILD = new Set(["esbuild", "sharp"]);
const SKIP_DIR = new Set([".git", "dist", "coverage"]);
const ROOT = process.cwd();
const ROOT_PKG = path.join(ROOT, "package.json");

function walk(dir, hits, deny, { skipNodeModules, skipRootPkg }) {
 if (!fs.existsSync(dir)) return;
 for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
 if (SKIP_DIR.has(ent.name)) continue;
 if (skipNodeModules && ent.name === "node_modules") continue;
 const full = path.join(dir, ent.name);
 if (ent.isDirectory()) {
 walk(full, hits, deny, { skipNodeModules, skipRootPkg });
 continue;
 }
 if (skipRootPkg && full === ROOT_PKG) continue;
 if (!/\.(js|mjs|cjs|json)$/.test(ent.name)) continue;
 const text = fs.readFileSync(full, "utf8");
 for (const re of deny) {
 if (re.test(text)) hits.push(full + " " + re);
 }
 }
}

if (!fs.existsSync(path.join(ROOT, "package-lock.json"))
 && !fs.existsSync(path.join(ROOT, "npm-shrinkwrap.json"))) {
 console.error("missing lockfile");
 process.exit(1);
}

const appHits = [];
walk(ROOT, appHits, APP_DENY, { skipNodeModules: true, skipRootPkg: true });

const nm = path.join(ROOT, "node_modules");
if (!fs.existsSync(nm)) {
 console.error("node_modules missing; run npm ci --ignore-scripts first");
 process.exit(1);
}
const lockHits = [];
walk(nm, lockHits, [LOCK_POSTINSTALL], { skipNodeModules: false, skipRootPkg: false });
const allowed = lockHits.filter((h) => {
 const pkg = h.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/);
 return pkg && ALLOW_REBUILD.has(pkg[1]);
});
const bad = appHits.concat(lockHits.filter((h) => !allowed.includes(h)));
if (bad.length) {
 console.error(bad.join("\n"));
 process.exit(1);
}

Identifiers stay review_hatches, APP_DENY, LOCK_POSTINSTALL, and ALLOW_REBUILD. The first walk skips node_modules and the root package.json, so your own rebuild script does not fail the hook. After npm ci --ignore-scripts, the second walk reads node_modules for a new postinstall only. Soften that list when a native module you named must compile. Fail closed if the lockfile or node_modules is missing.

#.github/CODEOWNERS
/package.json @app-sec
/package-lock.json @app-sec
/scripts/review_hatches.js @app-sec
**/*child_process* @app-sec

Pin the lockfile, then decide who may install

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
node scripts/review_hatches.js

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 rejects // comments.
ignore-scripts=true
{
 "scripts": {
 "rebuild:native": "node./scripts/build_native.js",
 "review": "node./scripts/review_hatches.js"
 }
}
// scripts/build_native.js
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 review_hatches consults. A new native module is a review, not an auto-merge. 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.

Three blind spots sit next to a quiet CVE scanner:

  • 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.json but not the lockfile will fetch whatever the range allows today.
  • Developer laptops. npm update on a Monday morning is an install-time event. The lockfile on main does not protect a laptop that rewrote it.

After a bad tree lands

Assume a version you did not mean is in a lockfile, a cache, or an image. The work is containment, then a clean tree, then a review of what that tree could reach. CISA’s September 2025 alert already listed the same shape: review the lockfile, pin to a known-safe line, rotate developer credentials, turn on phishing-resistant MFA.

  1. Freeze publishes. Stop npm install on developer laptops until the pin is known.
  2. Search every lockfile and image for the coordinates the maintainer or CISA named. Use that list.
  3. Restore the last lockfile that predates the window. Rebuild with ci and scripts off. Run review_hatches.
  4. Rotate tokens that a build script could have read: npm, cloud, signing. Rotate anyway if the install ran on an agent that holds them.
  5. Read the diff between the bad version and the last good version. You are looking for new files and new postinstall entries, not for a CVE blurb.
# lockfile grep for the 8 Sep 2025 family
rg -n "debug-4\\.4\\.2|ansi-styles-6\\.2\\.2|supports-color-10\\.2\\.1" package-lock.json

Do not “clean” by deleting node_modules and running npm install again. That is how you re-resolve. Delete node_modules, keep the restored lockfile, run npm ci --ignore-scripts.

The sibling page is the CVE-versus-hijack split and the planned bump. When the bump is yours, read the changelog there. When the bump was not yours, this section is the runbook. If the agent that ran the bad install also held cloud keys, rotate those keys before you declare the tree clean. A restored lockfile does not revoke a token that already left the box.

Prove the hook and the tree

You are not proving a package is kind. You are proving the build cannot silently move, that a new postinstall cannot run unnoticed, and that review_hatches fails a PR that adds eval.

# 1. ci must be clean against the committed lockfile
npm ci --ignore-scripts
node scripts/review_hatches.js

# 2. a broken lockfile must fail
# copy package.json, break one version, expect npm ci nonzero

# 3. a planted eval must fail the hook
# echo 'eval("1")' >> tmp_hatch.js && node scripts/review_hatches.js
# expect nonzero, then delete tmp_hatch.js

That third step is a file you own in a repo you own. You are not sending it anywhere. Soften review_hatches only by checking hits against ALLOW_REBUILD. A new name is a review.

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. The 8 September chalk publish had none while it was live. CISA’s 23 September worm alert was a human notice, not a CVE your scanner already had. Use the lockfile diff and review_hatches. 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, and it does not see eval in a PR. Pair the pin with npm ci, ignore-scripts, and the hook.

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 ALLOW_REBUILD list.