Back openDesk Edu for a sovereign, open-source education β every vote counts.
Vote nowSave products you love by clicking the heart icon.
How an attacker chained pull_request_target cache poisoning with GitHub Actions memory extraction to publish 84 malicious npm packages β and what you must audit in your own CI/CD pipeline.
npm 12.0.0 shipped on July 8, 2026, and was promoted to the latest tag on the same day. It is the most consequential npm major release since npm 7 overhauled the lockfile format. If you have not read the changelog yet, here is what you need to know: three npm install behaviors that have been automatic for a decade now require explicit approval. Several other breaking changes will silently break CI scripts and lockfile-driven build pipelines.
npm 12 is already bundled with Node.js 24.16.0 (released July 9) and Node.js 26.3.0 (released June 1), and the npm team has indicated it may be backported to both active LTS lines under the Node security policy. That means the new defaults reach the majority of Node users within weeks, not months.
The preview warnings have been available since npm 11.16.0 (May 27). If you did not run them yet, you are already behind.
npm 12 turns three install-time behaviors from automatic into opt-in. Each one closes a code-execution path that real supply-chain attacks have used in the past two years.
npm install no longer runs preinstall, install, or postinstall scripts from dependency packages unless the package is on an explicit allowlist. This includes the implicit node-gyp rebuild that npm triggers for any package shipping a binding.gyp file, even when no explicit install script exists. prepare scripts from git, file, and link dependencies are blocked the same way.
Your own project scripts in the root package.json still run. If your postinstall runs patch-package or your prepare installs husky hooks, nothing changes for you. The block targets code arriving from the registry.
The allowlist lives in package.json under a new allowScripts field, managed by two new commands:
npm approve-scripts --allow-scripts-pending
# Approve specific packages
npm approve-scripts sharp esbuild
# Deny specific packages
npm deny-scripts node-sass
The resulting package.json entry looks like this:
{
"allowScripts": {
"sharp@0.33.5": true,
"esbuild@0.24.0": true
}
}
Approvals are pinned to specific versions by default. When you approve sharp@0.33.5, the approval does not automatically extend to sharp@0.34.0. You re-approve after reviewing the update. The --no-allow-scripts-pin flag bypasses this per-version pinning; use it only when you understand the trade-off.
The v12 default is a soft skip, not a hard failure. An unapproved script is skipped, npm prints a warning, and the install still succeeds with exit code 0. There is a separate opt-in setting, strict-allow-scripts, that turns the skip into a hard error for CI.
npm install will not resolve git dependencies, direct or transitive, unless you set --allow-git. This closes a code-execution path where a transitive git dependency's .npmrc could override the git executable that npm invokes -- a path that remained open even under --ignore-scripts.
# Before (v11, still works but shows warning)
npm install github:some-org/private-fork#feature-branch
# After (v12, blocked)
# Error: git dependencies are not allowed by this project
# Fix: pass the flag
npm install github:some-org/private-fork#feature-branch --allow-git
# Or configure in .npmrc
echo "allow-git=true" >> .npmrc
The npm team explicitly advises against long-term reliance on the --allow-git escape hatch. For cross-package references within a monorepo, the workspace protocol (workspace:*) is the correct replacement. For external forks, publish a prerelease to your registry instead of pinning to a git SHA.
Dependencies pointing at HTTPS tarball URLs no longer resolve without --allow-remote:
{
"dependencies": {
"my-package": "https://artifactory.internal/example-pkg-1.0.0.tgz"
}
}
This produces a resolve error in v12 unless --allow-remote is set. The --allow-file and --allow-directory flags exist but their defaults are not changing in v12.
The primary migration path for these dependencies is to serve them through a proper npm registry (the public registry, a private npm registry, or a registry proxy like Verdaccio). npm's staged publishing feature, introduced in May 2026, adds a pre-publication review window for registry packages but does not cover ad-hoc tarballs.
The three security defaults dominate the conversation, but the v12 changelog also includes several breaking changes that will cause silent failures in CI pipelines.
npm shrinkwrap is removed. npm-shrinkwrap.json is no longer loaded or honored at the project root or from inside dependency tarballs. The shrinkwrap config alias is also removed.
If you rely on npm-shrinkwrap.json for deterministic installs of published packages, you need to rename it:
mv npm-shrinkwrap.json package-lock.json
If you ship a locked dependency tree inside a published package (the original use case for shrinkwrap), migrate to bundleDependencies in package.json instead. The danger here is silent: a published package that ships only npm-shrinkwrap.json and no package-lock.json will have its pinned dependencies ignored by v12 consumers, who will resolve fresh against the registry.
In v11, npm view --json returned a single object for single-version packages and an array for multi-version packages. This inconsistency has been a footgun since it was introduced. In v12, the output is always an array:
# v11 behavior (single version)
npm view express@4.18.2 --json
# => { "name": "express", "version": "4.18.2", ... }
# v12 behavior (always array)
npm view express@4.18.2 --json
# => [{ "name": "express", "version": "4.18.2", ... }]
Any script that does JSON.parse(output).version without checking for an array will break on every call, not just the multi-version case. The fix is to always treat the result as an array:
// Before (breaks in v12)
const pkg = JSON.parse(output);
console.log(pkg.version);
// After (works in both)
const [pkg] = JSON.parse(output);
console.log(pkg.version);
In v11, npm pkg get version returned a JSON-wrapped value: {"version":"1.2.3"}. In v12, single-value queries return a plain string:
# v11
npm pkg get version # => {"version":"1.2.3"}
# v12
npm pkg get version # => "1.2.3"
Scripts that pipe npm pkg through JSON.parse() and access .version will throw a TypeError. Unlike the npm view array change where you can add a destructuring wrapper, this one requires restructuring how the consuming code reads the output.
The --json output for both commands has been restructured in v12 to share a consistent schema. The changelog does not document the exact field-level diff, but the stated goal is unified output between the two commands. Any CI step that parses these outputs to extract tarball filenames, integrity hashes, or version numbers should be tested against v12 before deploying.
The adduser command is gone. Create and manage user accounts on the npm website, and use npm login to authenticate on the command line. For CI pipelines, this is unlikely to affect you if you already use token-based authentication via .npmrc entries:
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
Legacy provisioning scripts that call npm adduser will fail with a "command not found" error.
Three commands for bookmarking packages on the registry are removed with no CLI replacement. If any CI script calls them, those calls will fail.
man npm-install and similar system man page lookups stop working. npm help install continues to work because it reads from npm's bundled documentation.
npm sbom --sbom-format=cyclonedx now reports the name field from each package's package.json instead of the on-disk directory name. The name, bom-ref, and purl of the root component and of aliased dependencies may change. In monorepo setups where the directory is packages/logger/ but the package name is @scope/logger, every SBOM identifier will change. Compliance systems that key off bom-ref to track vulnerabilities across releases will see the same package appear as a new entry.
npm init now defaults to an empty license string instead of "ISC". If not set, the license field is omitted from new packages.
npm no longer resolves the Node binary path via PATH lookup. It relies solely on process.execPath. This is fine in most setups but can break when npm is invoked through a version manager or wrapper script that symlinks Node into the PATH.
The root package.json preinstall script now runs before dependencies are installed, not after. If your root preinstall depends on any dependency being present in node_modules, it will fail.
The three security changes affect CI pipelines differently depending on how your builds are configured.
If your Dockerfile or CI configuration pins npm to a specific major version:
RUN npm install -g npm@11
You control when v12 arrives. Schedule the upgrade like any other dependency bump: audit, test on a staging branch, then update the pin. This is the safest approach.
If your Dockerfile does npm install -g npm@latest or you use a node:latest style tag, v12 arrives on its own schedule. On July 8, images rebuilt after the release will produce different install behavior with no code changes. This is how teams end up debugging a production incident on release day.
Lockfile resolution changes. If your CI runs npm ci and relies on npm-shrinkwrap.json, the entire install step will produce different dependency trees in v12. Rename to package-lock.json before upgrading.
JSON output parsing. Any CI step that captures output from npm view --json, npm pack --json, npm publish --json, or npm pkg get and parses it will produce wrong results until updated. This includes release automation, version bump scripts, and artifact publishing workflows.
Native addon compilation. Packages that compile C++ at install time (bcrypt, sharp in some configurations, older database drivers) will silently produce missing binary errors at runtime unless their scripts are on the allowlist. The install succeeds, the app crashes.
Git dependencies. Any monorepo or internal toolchain that references packages via git+https:// URLs will fail to resolve. This includes transitive dependencies: your top-level deps may be clean, but a dependency's own dependency could come from a git URL.
Downloaders. Packages whose postinstall fetches browser binaries (Puppeteer, Playwright, Cypress) will install the npm package but leave the binary behind. The failure mode is a runtime error about a missing executable, not an install error.
The entire v12 breaking change surface is available as warnings in npm 11.16.0 and later. You can audit and prepare without switching to v12.
npm install -g npm@11
npm --version # should be 11.16.0 or later
If you are on Node.js 24.16.0 or Node.js 26.3.0, this version is already bundled.
npm approve-scripts --allow-scripts-pending
This lists every package with lifecycle scripts that would be blocked under v12 defaults. The output includes the command each script runs.
Ignore prepare scripts from registry packages on the first pass. prepare only executes when you install from git, file, or link -- never from a normal registry install. The audit lists them because it cannot know you will not switch a dependency to a git URL tomorrow, but for triage they are noise.
Focus on install and postinstall scripts. These are the ones that run on every clean install.
For each real script entry, decide:
npm approve-scripts <package>.postinstall (sharp, esbuild) already distribute prebuilt binaries through optionalDependencies -- the install script is a fallback for unsupported platforms. If your platform has a prebuilt binary, the script is unnecessary.# Test whether a package works without its install script
npm deny-scripts sharp
npm ci
node -e "require('sharp')" # if this works, deny it permanently
The allowScripts field is written to your package.json. Commit it. From now on, every new dependency that wants script execution shows up as a diff in code review instead of executing silently during install.
# Find all git dependencies in your tree
grep -r 'git+' package.json package-lock.json
# Find remote tarball dependencies
grep -r 'https://.*\.tgz' package.json package-lock.json
For each one, decide:
workspace:* protocol.--allow-git or --allow-remote in tightly scoped CI steps, and set a deadline to migrate.Check every CI pipeline that parses npm output:
# Find scripts that parse npm JSON output
grep -r 'npm.*--json' .github/ gitlab-ci.yml Jenkinsfile
Update parsing logic for the new output shapes. Test npm view --json and npm pkg get against v12 pre-release to confirm your parsers still work.
# Check if you have one
find . -name "npm-shrinkwrap.json"
# If found, rename
mv npm-shrinkwrap.json package-lock.json
Add a specific npm version to your CI configuration:
# Before (floats)
RUN npm install -g npm@latest
# After (pinned)
RUN npm install -g npm@12
If you are not ready for v12, pin to npm@11 and schedule the v12 upgrade separately.
# Run with v12 defaults (on npm 11.16+, warnings only)
npm ci
# Verify native modules load correctly
node -e "require('sharp'); require('esbuild'); console.log('OK')"
# Run the full test suite
npm test
Run this on a staging branch before touching your production CI configuration.
Monorepos have an additional layer of complexity in v12.
Workspaces are independent install scopes. Approving sharp at the monorepo root does not approve it in packages/api. You must run npm approve-scripts in each workspace that depends on a package with lifecycle scripts.
Git dependencies are common in monorepo setups for pre-release cross-references. The workspace protocol (workspace:*) is the correct replacement -- it resolves locally without fetching from git and is not affected by the --allow-git default.
Root .npmrc configuration does not cascade to workspaces. If you set allow-git=true in the root .npmrc, it affects root-level operations only. Each workspace resolves its own effective config. Either set the config in each workspace's .npmrc, or pass the flag at install time: npm ci --workspaces --allow-git.
npm 7 (October 2020) was the last major that changed how installs fundamentally worked. It introduced the v2 lockfile format, automatic peer dependency installation, and workspace support. The transition took months -- teams that pinned npm version survived without disruption, teams that floated npm bumped into CI failures and spent days updating lockfiles.
npm 9 (February 2023) was relatively quiet: it removed npm audit --audit-level, changed npm pack output behavior, and tightened config validation. Most teams upgraded without incident.
npm 10 (June 2023) removed npm link step behavior changes and the --global style flag, again low-impact for most.
npm 12 is closer to npm 7 in blast radius. The security defaults affect every install, and the JSON output changes affect every CI pipeline that extracts metadata from npm commands. The preview period (warnings available since npm 11.16.0 on May 27) is short compared to the npm 7 transition, where the v2 lockfile warnings appeared months before the breaking switch. The compressed timeline reflects the urgency of the supply-chain attack wave: Miasma, the TanStack breach, and the Axios hijack all exploited the exact entry points that npm 12 closes.
pnpm blocked lifecycle scripts by default in version 10. Bun and Deno do not run lifecycle scripts at all for dependencies. npm 12 brings the default behavior in line with the rest of the ecosystem -- a decade late but finally here.
The community discussion on the Node.js release issue and the GitHub blog post covers the predictable range: security teams applauding, maintainers of native modules scrambling to update guidance, and platform teams asking about the transition story for monorepos.
One concrete concern that came up during the prerelease period: the announcement originally included a change that turned unknown configuration keys in .npmrc into hard EUNKNOWNCONFIG errors. This was reverted in PR #9729 -- unknown file configs now warn by default, with an opt-in strict-npmrc setting for anyone who wants the stricter check. Unknown CLI flags still error. The revert removed a real barrier for teams carrying legacy or tool-specific keys in their config, which was exactly the kind of friction that stalls a security upgrade.
Another gap the community has identified: npm approve-scripts --allow-scripts-pending lists which packages have scripts and the command each one runs, but not what those scripts do once they execute -- what files they pull in, whether they make network calls, or write outside the package directory. An RFC is under discussion proposing a review-report mode that surfaces those signals.
| Date | Event |
|---|---|
| 18 February 2026 | --allow-git warning available in npm 11.10.0 |
| 20 May 2026 | npm 12.0.0-pre.0 (first prerelease) |
| 27 May 2026 | npm 11.16.0 (full warning set for all v12 breaking changes) |
| 1 June 2026 | Node.js 26.3.0 (bundles npm 11) |
| 9 June 2026 | GitHub blog post announcing v12 breaking changes |
| 19 June 2026 | npm 12.0.0-pre.1 (allow-git/allow-remote defaults harden) |
| 8 July 2026 | npm 12.0.0 final -- promoted to latest |
| 9 July 2026 | Node.js 24.16.0 (bundles npm 12) |
| Q3 2026 | 2FA-bypass token deprecation Phase 1 (block sensitive operations) |
| ~January 2027 | 2FA-bypass token deprecation Phase 2 (block direct publishing) |
npm is also beginning to deprecate granular access tokens configured to bypass two-factor authentication. Phase 1, expected in August 2026, blocks these tokens from performing sensitive account and publishing operations. Phase 2, around January 2027, removes their direct publishing capability. GitHub's guidance is to move automated publishing to trusted publishing (OIDC) or staged publishing with a human approval step.
npm approve-scripts --allow-scripts-pending in each repo. Ignore prepare entries on the first pass.allowScripts block in package.json.package.json and package-lock.json. Plan migration to workspace protocol or registry versions.npm view --json and npm pkg output shapes.npm@11 (defer) or npm@12 (upgrade now).The whole audit takes under an hour for a typical repository. Doing it now means July is a controlled migration instead of an incident investigation.
allowScripts default now closes. The Phantom Gyp technique that drove the worm's propagation relied on binding.gyp files running implicit node-gyp rebuild during install -- the primary attack vector that npm 12 blocks by default.