You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The patch-remove deletion-scope issue tracked as GHSA-72r4-9c5j-mj57 / CAND-PNPM-030 has been addressed in pnpm.
A crafted patch entry could resolve outside the configured patches directory and cause pnpm patch-remove to delete an arbitrary reachable file. This patch validates the configured directory and every resolved target before unlinking anything, then deletes the final directory entry without following it.
Security boundary
Traversal and absolute paths that resolve outside the configured patches directory are rejected before deletion.
Parent directories are canonicalized before deletion, including the case where a nested symlink points outside and the final outside entry is itself dangling.
The complete batch is validated before any file is removed.
Component-aware predicates accept valid names beginning with .. while still rejecting parent traversal, Windows drive escapes, and UNC escapes.
Valid files and symlinked patch directories whose canonical targets remain below the lockfile directory continue to work.
A final symlink inside a valid patch directory is unlinked without following its target, including when the target is outside or dangling.
Exploit replay
Before the patch, a workspace patchedDependencies path that resolved outside the project caused pnpm patch-remove to delete the external sentinel. A second replay used a nested parent symlink and a dangling outside victim: realpath() returned ENOENT, yet the victim was still removed. With this patch, both paths are rejected and the outside entries remain intact.
patching/commands/src/patchRemove.ts validates the full batch, canonicalizes parents, and unlinks final entries without following them.
patching/commands/test/{isSubdirectory,patchRemove}.test.ts covers traversal, nested symlinks, dangling victims, and valid removals.
Commands run
$ pnpm --filter @​pnpm/patching.commands test test/isSubdirectory.test.ts test/patchRemove.test.ts
PASS: 11 tests across 2 suites
$ pnpm --filter @​pnpm/patching.commands run compile
PASS
$ git diff --check
PASS
Validation
Focused handler and path-predicate suites: 11 passed across 2 suites.
Package-wide ESLint: passed.
Package TypeScript build: passed.
Commit hooks, Commitlint, and git diff --check: passed.
The broader integration harness was environment-blocked because it writes outside the available temporary root; focused handler tests used /private/tmp.
Missing patch files remain no-ops. Valid symlinked patch directories continue to work when their canonical target stays inside the lockfile directory, and final symlinks are removed without touching their targets. patch-remove is not yet in pacquet's command surface, so no Rust-side parity change is required.
Remaining risk
Portable Node APIs do not expose directory-fd-relative unlinkat(). A local attacker who can replace an already validated parent directory before the unlink may still win a time-of-check/time-of-use race. The reproduced repository-controlled traversal and symlink paths do not require that concurrent capability and are blocked by this patch.
The hoisted dependency alias issue tracked as GHSA-fr4h-3cph-29xv / CAND-PNPM-059 has been addressed in both pnpm and pacquet.
A crafted lockfile alias could be joined directly under a hoisted node_modules directory. Traversal aliases could escape that directory, while reserved aliases such as .bin or .pnpm could overwrite pnpm-owned layout. This patch validates package-name semantics and path containment before graph insertion or filesystem work.
Security boundary
The TypeScript hoisted graph uses the shared safe join helper at the actual dep.name sink.
The helper rejects traversal, absolute, platform-specific, and reserved package names.
Pacquet validates the hoister's dep.0.name before adding the graph node or recursing.
Both implementations return ERR_PNPM_INVALID_DEPENDENCY_NAME.
Pacquet uses the same dependency-name containment rule at its hoisted graph sink as it uses for direct dependency aliases.
Exploit replay
Before the patch, a traversal alias in a hoisted lockfile imported package files outside the intended install root. With this patch, both pnpm and pacquet reject the alias before graph insertion or filesystem work, and the escaped file is not created.
Files changed
fs/symlink-dependency/src/safeJoinModulesDir.ts provides the TypeScript containment helper.
installing/deps-restorer/src/lockfileToHoistedDepGraph.ts validates the parsed dependency name at the hoisted graph sink.
pacquet/crates/package-manager/src/{hoisted_dep_graph.rs,safe_join_modules_dir.rs} mirrors that boundary in Rust.
TypeScript and Rust tests cover traversal, reserved aliases, and valid scoped names.
Commands run
$ pnpm --filter @​pnpm/fs.symlink-dependency test
PASS: 24 tests
$ pnpm --filter @​pnpm/installing.deps-restorer test test/index.ts
PASS: exploit regression and positive install control
$ cargo test --locked -p pacquet-package-manager --lib
PASS: 426 tests
$ cargo fmt --all -- --check
PASS
Valid unscoped and scoped package aliases continue to work. The changeset covers @pnpm/fs.symlink-dependency, @pnpm/installing.deps-restorer, and pnpm; pacquet is updated in the same commit for CLI parity.
pnpm accepts package names from the env lockfile configDependencies section and uses those names directly when creating config dependency symlinks under node_modules/.pnpm-config.
A malicious repository can commit a crafted pnpm-lock.yaml whose env-lockfile document contains a traversal-shaped config dependency name such as ../../PWNED_CFGDEP. During pnpm install, pnpm installs the config dependency and creates a symlink at a path derived from that name.
In local testing against pnpm v11.5.1, this caused pnpm to create a symlink outside the intended config dependency directory:
expected root: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config
actual path: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP
This works with --ignore-scripts, so it does not rely on lifecycle script execution.
Vulnerable behavior
The vulnerable behavior appears to be that configDependencies keys from the env lockfile are trusted as package names and used in filesystem paths without rejecting traversal components.
If pkgName is attacker-controlled and contains .., then path.join(configModulesDir, pkgName) can resolve outside node_modules/.pnpm-config.
Impact
A malicious project can cause pnpm to create symlinks outside the intended node_modules/.pnpm-config directory during install.
This gives an attacker a filesystem write primitive in the victim project directory, and potentially outside it with deeper traversal payloads, depending on path permissions and platform behavior.
The issue is especially relevant because:
The malicious input is committed in pnpm-lock.yaml.
The issue is triggered during pnpm install.
It works with --ignore-scripts.
It occurs in the config dependency installation path, before ordinary dependency installation.
The user only needs to install a malicious or compromised repository.
Local proof of concept
The following local-only PoC creates a temporary project, starts a local fake registry on 127.0.0.1, writes a malicious env-lockfile entry, runs pnpm, and checks whether pnpm created a symlink outside node_modules/.pnpm-config.
Installing config dependencies...
Installed config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0
Already up to date
Done in 906ms using pnpm v11.5.1
The PoC then detected the escaped symlink:
[+] Traversal sink status: symlink -> ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP
[VULNERABLE] pnpm created/modified a path derived from a lockfile package name outside node_modules/.pnpm-config
sink = /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP
readlink = ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP
Malicious lockfile structure
The malicious input is an env-lockfile configDependencies key containing traversal components:
Use containment-checked path joining before creating symlinks:
resolve the final destination path,
verify it remains inside node_modules/.pnpm-config,
reject if it escapes.
Apply the same validation to config dependency subdependencies and optional dependency names read from the env lockfile.
Intersect env-lockfile configDependencies with the effective pnpm-workspace.yamlconfigDependencies before installing, so extra lockfile-only entries are rejected.
A safe destination check should enforce behavior equivalent to:
This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.
Next action: review the shared patch branch for this component, set the final affected version range, merge and release the fix, then publish or close the advisory.
Expected Patched Behavior
config-dependency pacquet install engines are not selected unless the trusted allowlist is set outside the repository; the marker file is not created.
Files And Tests To Review
config/reader/src/Config.ts
config/reader/src/types.ts
config/reader/src/configFileKey.ts
config/reader/src/index.ts
config/reader/test/index.ts
installing/commands/src/installDeps.ts
installing/commands/test/runPacquet.ts
pnpm/test/install/pacquet.ts
.changeset/lucky-config-plugin-pnpmfiles.md
Focused Validation
Run these from a checkout of the shared patch branch. They are the useful maintainer commands with machine-local artifact paths removed.
The full patched replay for the shared branch passed with all 20 candidates marked fixed. This candidate's replay evidence is results/CAND-PNPM-097-patched-result.json.
Summary
pnpm can install configDependencies declared in pnpm-workspace.yaml before command dispatch. Before the patch, a repository could declare pacquet or @pnpm/pacquet as a config dependency and pnpm treated that repository-controlled dependency as an install-engine opt-in. During install, pnpm resolved a platform-specific @pacquet/<platform>-<arch>/pacquet binary from node_modules/.pnpm-config/<packageName> and spawned it as the developer or CI user.
Details
The vulnerable source-to-sink path was:
config/reader/src/getOptionsFromRootManifest.ts copies repository pnpm-workspace.yamlconfigDependencies into config.
pnpm/src/getConfig.ts installs config dependencies before command dispatch.
installing/env-installer/src/resolveAndInstallConfigDeps.ts resolves the repository-declared dependency and its optional platform subdependencies.
installing/env-installer/src/installConfigDeps.ts fetches, imports, and symlinks the config dependency tree under node_modules/.pnpm-config.
installing/commands/src/installDeps.ts selected pacquet delegation whenever configDependencies contained pacquet or @pnpm/pacquet.
installing/deps-installer/src/install/index.ts called opts.runPacquet from frozen and materialization paths.
installing/commands/src/runPacquet.ts resolved @pacquet/${process.platform}-${process.arch}/pacquet from the installed config dependency package and executed it with spawn().
Exact-version, integrity, and platform filters only proved which bytes package resolution selected; they did not establish that the repository was trusted to choose a native install engine.
The victim runs a dependency-management command such as pnpm install in the repository.
pnpm installs the repository-declared config dependency and its host-compatible optional platform dependency into .pnpm-config.
installDeps() treats the presence of configDependencies.pacquet or configDependencies["@​pnpm/pacquet"] as authorization to delegate install materialization.
runPacquet() resolves the platform binary from the installed config dependency tree and spawns it in the lockfile directory.
Observed PoC output:
{
"primitive": "repository-selected pacquet config dependency reaches native process execution when selected",
"patchedWithoutAllowlist": "blocked",
"trustedAllowlist": "allows explicit opt-in"
}
Config reader regressions prove user/global config can set configDependencyInstallEngineAllowlist, while pnpm-workspace.yaml cannot grant this permission to itself.
E2E fixtures that intentionally delegate to pacquet now pass the trusted allowlist through environment config.
TypeScript builds passed for @pnpm/config.reader, @pnpm/installing.commands, and pnpm.
Focused config/reader/test/index.ts: 2 passed, 132 skipped under the focused pattern.
ESLint passed with warnings only for existing skipped tests in config/reader/test/index.ts and pnpm/test/install/pacquet.ts.
git diff --check: passed.
Impact
A malicious repository can cause pnpm to execute a registry-selected native binary while handling dependency-management commands. The binary runs with the victim developer or CI user's filesystem, environment, registry credentials, git/SSH credentials, and network access.
Rationale: attacker input is delivered through a repository and registry package, exploitation is low complexity once the victim runs pnpm, no attacker privileges are required, and user interaction is required. Successful exploitation executes a native binary in the victim user's context, with high confidentiality, integrity, and availability impact.
Weaknesses
CWE-829: Inclusion of Functionality from Untrusted Control Sphere
CWE-78: Improper Neutralization of Special Elements used in an OS Command
CWE-494: Download of Code Without Integrity Check
Patch
The patch adds a trusted opt-in gate for config-dependency install-engine delegation:
New setting: configDependencyInstallEngineAllowlist.
The allowlist can be set from trusted user-controlled config such as global config, CLI config, or environment config.
pnpm-workspace.yaml cannot grant this permission to itself; workspace-provided values are discarded after workspace settings are merged.
installDeps() delegates to pacquet only when pacquet, @pnpm/pacquet, or * is present in the trusted allowlist.
Repositories can still install pacquet as a config dependency, but pnpm will not spawn it as an install engine unless trusted config opts in.
Existing tests that intentionally exercise pacquet delegation were updated to pass the trusted allowlist via environment config.
Changed files:
config/reader/src/Config.ts
config/reader/src/types.ts
config/reader/src/configFileKey.ts
config/reader/src/index.ts
config/reader/test/index.ts
installing/commands/src/installDeps.ts
installing/commands/test/runPacquet.ts
pnpm/test/install/pacquet.ts
Changeset:
.changeset/lucky-config-plugin-pnpmfiles.md
Pacquet parity:
No pacquet-side code-execution sink exists for this finding. The Rust port parses and records configDependencies for workspace-state compatibility, but it does not install config dependencies or select/spawn an alternate install engine from them. The user-visible trust setting is TypeScript-side today because it gates pnpm's pacquet delegation path.
Final CVSS after patch: not vulnerable after patch / 0.0. The PoC no longer reaches pacquet install-engine selection or native process execution unless the victim has set a trusted allowlist outside the repository's own workspace settings.
Remaining Risk
Users can explicitly trust pacquet install-engine delegation through the new allowlist. That is intentional behavior; the closed issue is repository self-authorization of a registry-provided native install engine.
Stopped expanding environment variables in repository-controlled registry/proxy request destinations and registry credential values from .npmrc, and in workspace registry URLs from pnpm-workspace.yaml. Move dynamic registry URL and token configuration to trusted user, global, CLI, or environment config.
Resolve package-manager bootstrap dependencies with trusted user or CLI registry and network config, and reject package-manager env-lockfile records that do not use registry package paths with integrity-only resolutions before auto-switch execution.
Avoid writing packageManagerDependencies to pnpm-lock.yaml when package manager policy is set to onFail: ignore or pmOnFail: ignore#12228.
Avoid running dependency-status auto-install when the dependency status is unavailable without a project manifest.
Using the $ version reference syntax in overrides (e.g. "react": "$react") now prints a deprecation warning. The syntax still works, but catalogs are the recommended way to keep an overridden version in sync with the rest of the workspace. Reference a catalog entry with the catalog: protocol instead.
Fixed pnpm config get globalconfig to return the global config.yaml path again pnpm/pnpm#11962.
Fixed bare --color so it does not consume the following CLI flag, allowing command shorthands like --parallel to expand correctly and forms like pnpm --color with current <command> to dispatch the inner command instead of failing with MISSING_WITH_CURRENT_CMD.
Fix pnpm install ignoring enableGlobalVirtualStore toggle by including it in the workspace state settings check #12142.
Security: pnpm now verifies the npm registry signature of a package-manager binary before spawning it, so a cloned repository cannot make pnpm download and execute an arbitrary native binary.
This covers two paths that select an executable from repository-controlled input:
pacquet install engine — declaring pacquet (or @pnpm/pacquet) in configDependencies opts in to pnpm's Rust install engine. pnpm now verifies that the installed pacquet shim and the host's @pacquet/<platform>-<arch> binary carry a valid npm registry signature for their exact name@version, and refuses to run pacquet (failing the command) if the signature does not verify or cannot be checked. The only graceful fallback to pnpm's own engine is when pacquet has no binary for the current platform.
automatic version switch / self-update — the packageManager / devEngines.packageManager field makes pnpm download and run a specific pnpm version. pnpm now verifies the registry signature of pnpm, @pnpm/exe, and the host platform binary before installing/spawning them, and refuses to run an engine whose signature does not match a published, signed release. The check runs only on an actual download (store cache miss), so it does not add a network round trip to every command.
In both cases the signature is verified over the installed integrity, against npm's public signing keys that ship embedded in the pnpm CLI (like corepack), so bytes substituted via a tampered lockfile or a repository-controlled registry fail verification — and a registry the user did not vouch for cannot supply its own signing keys. The signed packument is fetched from the configured registry, so an npm mirror works transparently. Verification fails closed: if it cannot be completed (for example, the registry is unreachable), the command fails rather than running an unverified binary. The embedded keys are kept current by a release-time check against npm's signing-keys endpoint.
Made peer-dependent deduplication deterministic. When a peer-suffixed package variant was a subset of two or more mutually incompatible larger variants, the variant it collapsed into depended on the order importers were resolved in, which varies between machines. This could resolve the same workspace to different lockfiles on different platforms and make pnpm dedupe --check alternate between passing and failing.
Reject invalid package names and versions from staged tarball manifests before deriving filenames for pnpm stage download.
Clarified in CLI help that the pnpm store is trusted shared state and store integrity checks are corruption detection, not a tamper boundary for untrusted store writers.
Reject reserved manifest bin names ("", ".", "..", and scoped forms such as @scope/..) when resolving a package's bins. These names previously passed the bin-name guard and, when joined to the global bin directory during global remove/update/add operations, could resolve to the global bin directory itself or its parent and have it recursively deleted.
Require trusted package identity before package-name allowBuilds entries can approve lifecycle scripts for git, git-hosted tarball, direct tarball, and local directory artifacts. To approve one of those artifacts explicitly, use its peer-suffix-free lockfile depPath as the allowBuilds key. Lockfile verification now rejects lockfiles where a registry-style dependency path (name@semver) is backed by a git, directory, or git-hosted tarball resolution (ERR_PNPM_RESOLUTION_SHAPE_MISMATCH), so the dependency path is a reliable artifact identity by the time scripts can run.
Security: pnpm now verifies the OpenPGP signature of a downloaded Node.js runtime's SHASUMS256.txt before trusting its integrity hashes.
When a repository requests a Node.js runtime (e.g. via devEngines.runtime / useNodeVersion), the download mirror is repository-configurable through node-mirror:<channel>. The integrity of the downloaded binary was only checked against SHASUMS256.txt fetched from that same mirror — a circular check that a malicious mirror could satisfy by serving a tampered binary together with a matching SHASUMS256.txt. pnpm then executes the binary (for example to run lifecycle scripts).
pnpm now fetches SHASUMS256.txt.sig and verifies the detached OpenPGP signature against the Node.js release team's public keys, which ship embedded in the pnpm CLI. A mirror that serves a tampered binary cannot also produce a valid signature, so the download fails to verify. The embedded keys are kept current by a release-time check against the canonical nodejs/release-keys list.
The musl variants from the hardcoded unofficial-builds.nodejs.org mirror are not repository-configurable and are signed by a different key, so they continue to be trusted over TLS.
Peer dependency resolution now reuses the peer contexts already recorded in the lockfile when those providers are still present in the dependency graph and still satisfy the peer ranges. This avoids unnecessary peer-context rewrites during lockfile regeneration. Current manifest choices remain authoritative: a newly added, explicitly updated, or aliased direct provider, a changed nested provider, or a locked version that no longer satisfies the range still takes precedence.
The lockfile verifier now checks that a registry entry pinning an explicit tarball URL points at the artifact the registry's own metadata lists for that name@version. Previously a tampered lockfile could pair a trusted name@version with an attacker-chosen tarball URL (and a matching integrity for those bytes), so the install fetched the attacker's bytes. A mismatch — or any entry that can't be confirmed against the registry — is rejected with ERR_PNPM_TARBALL_URL_MISMATCH. Non-registry resolutions (file:, git-hosted, etc.) and registry entries without an explicit tarball URL (the URL is reconstructed from name+version+registry, so it is inherently bound) are unaffected; non-standard registry tarball URLs (npm Enterprise, GitHub Packages) still pass because they match the metadata.
Fix pnpm update --recursive --lockfile-only <pkg>@​<version> crashing with Invalid Version when the catalog entry for <pkg> is a version range (e.g. ^21.2.10) and catalogMode is strict or prefer. The catalog–version comparison now skips the equality check when either side is a range rather than passing a range to semver.eq(), so range specifiers fall through to the existing mismatch handling instead of throwing #11570.
Avoided a Node.js crash when pnpm exits after network requests on Windows.
Fixed packages being materialized into the virtual store without their root-level files (package.json, LICENSE, README, root entrypoints) when multiple pnpm install processes ran against the same store/workspace concurrently. The fast import path used to destructively empty the shared target directory, so a concurrent importer could wipe files another importer had already written; if the surviving files included the package.json completion marker, every later install treated the broken directory as complete and never repaired it. The fast path now imports directly only when it can create the target directory exclusively, and otherwise builds the package in a private temp directory and atomically renames it into place #12197.
Fix dependency build scripts not running under the global virtual store (enableGlobalVirtualStore).
In a workspace install, dependency build scripts are deferred to a single rebuild pass (buildProjects). That pass resolved each package's location from the classic node_modules/.pnpm/<depPathToFilename> layout, which does not exist under the global virtual store — so native dependencies (e.g. packages using node-gyp / prebuild-install) were never built and failed to load at runtime (Cannot find module .../build/Release/*.node).
buildProjects now resolves the global-virtual-store projection directory (<storeDir>/links/<hash>, computed with the same graph hash the installer uses) when enableGlobalVirtualStore is set, and serializes concurrent builds of the same shared projection so parallel workspace projects don't race on the same directory.
Don't promote a runtime: dependency (such as the Node.js version from devEngines.runtime or pnpm runtime set) into a catalog when catalogMode is strict or prefer. A runtime: dependency round-trips to devEngines.runtime, which only recognizes the runtime: protocol; cataloging it rewrote the manifest entry to catalog:, which broke that round-trip, stranded it in devDependencies, and left devEngines.runtime untouched.
Skip lockfile minimumReleaseAge/trustPolicy verification for non-registry tarball protocols (for example file:), so local tarball dependencies are not incorrectly checked against npm registry metadata.
Improve pnpm audit performance by pruning non-vulnerable lockfile subtrees and stopping path enumeration once vulnerable findings reach the path cap.
Avoid crashing when the workspace state cache is partially written or malformed.
Set npm_config_user_agent for root lifecycle scripts during headless installs.
Preserve the integrity field of a remote (non-registry) tarball dependency when its lockfile entry is rebuilt. Re-resolving such a dependency without re-fetching it (for example via pnpm update, or when another dependency changes) produced a resolution with no integrity — URL/tarball resolvers only learn the integrity after the tarball is downloaded — so the previously recorded integrity was dropped, making later installs fail with ERR_PNPM_MISSING_TARBALL_INTEGRITY#12067.
Normalize a string repository field into the { type, url } object form when creating the publish manifest, matching npm's behavior. Some registries (e.g. Gitea/Codeberg) reject a string repository with a 500 Internal Server Error during pnpm publish#12099.
Preserve compatible optional peer versions already present in the lockfile when resolving dependencies.
Fixed inconsistent resolution of a peer dependency that is shared through a diamond. When a package peer-depends on both another package and one of that package's own peer dependencies (for example @typescript-eslint/eslint-plugin peer-depends on both @typescript-eslint/parser and typescript, and @typescript-eslint/parser peer-depends on typescript), pnpm no longer reuses a hoisted instance of the shared peer that was resolved against a different version #12079.
Added a new hoistingLimits setting for nodeLinker: hoisted installs, mirroring yarn's nmHoistingLimits. It accepts none (the default — hoist as far as possible), workspaces (hoist only as far as each workspace package), or dependencies (hoist only up to each workspace package's direct dependencies). Originally proposed in #6468, closing #6457.
Replaced enquirer with @inquirer/prompts for all interactive prompts. Fixes the update -i scrolling overflow bug where long choice lists were clipped in the terminal #6643.
User-facing changes:
pnpm update -i / pnpm update -i --latest: Scrolling now works correctly when many packages are available; the new library uses visual-line-aware pagination via usePagination
pnpm audit --fix -i: Same scrolling fix for vulnerability selection
pnpm run / pnpm exec (with verifyDepsBeforeRun=prompt): Confirmation prompt updated
Vim-style j/k keys still work for up/down navigation in all interactive prompts.
Internal: The OtpEnquirer and LoginEnquirer DI interfaces changed from { prompt } to { input } / { input, password } respectively. Plugins or custom builds that inject their own enquirer mock will need to update.
Staged publishes are now recognized in the trust scale. When a package version's registry metadata carries an approver field, it is treated as the strongest trust evidence (ranked above trusted publishers and provenance attestations), since staged publishes require 2FA publish approvals. This prevents false-positive trust downgrade errors when moving from a staged publish to a lower trust level #11887.
Patch Changes
Fix pnpm hanging during peer resolution when an aliased install pulls in transitive packages with mutual peer cycles at different depths in the dependency tree (for example, pnpm i nuxt@npm:nuxt-nightly@5x). Cycles whose members hit the findHit cache instead of running their own calculateDepPath are now short-circuited by sibling resolutions at the level where the cycle is detected, so the cached path promises no longer deadlock. #11999.
Fix pnpm dist-tag add and pnpm dist-tag rm against npmjs.org failing without --otp with [ERR_PNPM_UNAUTHORIZED] You must be logged in to set dist-tag … "You must provide a one-time pass. Upgrade your client to npm@latest in order to use 2FA.". pnpm now sends npm-auth-type: web on dist-tag writes and surfaces the resulting OTP challenge through the existing browser-based 2FA flow (the same withOtpHandling helper used by pnpm publish), so the browser opens, the user authenticates, and the dist-tag is set on retry. --otp=<code> continues to work via the classic flow.
Fix minimumReleaseAgeExclude handling in npm resolution fast paths so excluded packages do not get pinned to stale versions. Excludes are honored consistently during publishedBy metadata selection and cache-mtime shortcuts.
Fix the integrity field being dropped from the lockfile entry of a remote (non-registry) https-tarball dependency when an unrelated package is installed afterwards. URL/tarball resolvers do not return an integrity (it is only known after the tarball is downloaded), so when such a dependency was reused from the lockfile without being re-fetched, its integrity was lost. It is now carried over from the existing resolution. With pnpm's lockfile-integrity hardening, the missing integrity made subsequent --frozen-lockfile installs fail with ERR_PNPM_MISSING_TARBALL_INTEGRITY. #12001.
Skip dependency re-resolution when pnpm-lock.yaml is missing but node_modules/.pnpm/lock.yaml exists and still satisfies the manifest. pnpm install now reuses the materialized snapshot to regenerate pnpm-lock.yaml instead of walking the registry to rebuild it from scratch, turning the cache+node_modules variation into a near-no-op for users who deleted the lockfile but kept the install #11993.
--frozen-lockfile still refuses to proceed when pnpm-lock.yaml is absent — the regenerated lockfile must be committed, so failing loudly is the correct behavior for CI.
Treat tarball-integrity mismatches against the lockfile as a hard failure by default. Previously, pnpm install (non-frozen) would log ERR_PNPM_TARBALL_INTEGRITY, silently re-resolve from the registry, and overwrite the locked integrity — which meant a compromised registry, proxy, or republished version could substitute attacker-controlled content on a clean machine even though the project shipped a committed lockfile.
pnpm install now exits with ERR_PNPM_TARBALL_INTEGRITY and a hint pointing at the new opt-in flag.
The only opt-in is pnpm install --update-checksums — narrowly scoped to refreshing the locked integrity values from what the registry currently serves. Mirrors yarn's flag of the same name. A warning still prints when the bypass takes effect so the operation is auditable.
--force and pnpm update deliberately do not bypass the integrity check. They are routine refresh operations; silently overwriting a locked integrity in those flows would erase the protection a committed lockfile is supposed to provide. --frozen-lockfile behavior is unchanged. --fix-lockfile keeps its documented purpose (filling in missing lockfile entries) and is also not a bypass.
pnpm runtime set <name> <version> now saves the runtime to devEngines.runtime by default instead of engines.runtime. Pass --save-prod (or -P) to save it to engines.runtime instead #11948.
Patch Changes
Fix a credential disclosure issue where an unscoped _authToken (or _auth, or username + _password, or tokenHelper) defined in one source — ~/.npmrc, ~/.config/pnpm/auth.ini, a workspace .npmrc, CLI flags, etc. — would be sent as an Authorization header to whichever registry a different (potentially untrusted) source named. The same fix extends to client TLS credentials (cert, key) so they aren't presented to a registry their author didn't choose.
pnpm now rewrites each unscoped per-registry setting (_authToken, _auth, username, _password, tokenHelper, cert, key) to its URL-scoped form at load time, using the registry= value declared in the same source (or the npmjs default registry if the source declares none). A later layer overriding registry= therefore cannot pull an unscoped credential along, because it is already pinned to the URL its author intended. ca/cafile are intentionally not rescoped — they're trust anchors, not credentials, and corporate MITM-proxy setups rely on them applying globally.
Every rescope emits a deprecation warning telling the user where the setting was pinned and how to write it directly. npm has rejected unscoped credentials outright since npm@9, and pnpm intends to remove support in a future major release. To target a specific registry, write the setting URL-scoped (e.g. //registry.example.com/:_authToken=... or //registry.example.com/:cert=...).
@pnpm/network.auth-header: removed the defaultRegistry parameter from createGetAuthHeaderByURI and getAuthHeadersFromCreds. Now that credentials are URL-scoped at load time, the merged configByUri never contains the empty-string "default registry" placeholder slot, so re-keying it onto the merged default registry is no longer needed.
Fix pnpm deploy crashing with ENOENT: ... lstat '<deployDir>/node_modules' when configDependencies declares pacquet (pacquet or @pnpm/pacquet). The deploy directory never installs config dependencies, so the install engine they designate isn't on disk to invoke; the nested install now skips them.
Reject git resolutions whose commit field is not a 40-character hexadecimal SHA before invoking git. A malicious lockfile could otherwise smuggle a value such as --upload-pack=<command> through git fetch / git checkout, which on SSH or local-file transports executes the supplied command.
Limit concurrent project manifest reads while listing large workspaces to avoid EMFILE errors.
Reject patch files whose diff --git headers reference paths outside the patched package directory. Previously a malicious .patch file added via a pull request could write, delete, or rename arbitrary files reachable by the user running pnpm install.
Improve the log message that pnpm prints after auto-adding entries to minimumReleaseAgeExclude when minimumReleaseAge is set without minimumReleaseAgeStrict. The message previously referred to the internal "loose mode" terminology, which wasn't searchable in the docs; it now tells the user to set minimumReleaseAgeStrict to true if they want these updates gated behind a prompt instead #11747.
Reject dependency aliases that contain path-traversal segments (such as @x/../../../../../.git/hooks) when reading them from a package manifest or symlinking them into node_modules. A malicious registry package could otherwise use a transitive dependency key to make pnpm install create symlinks at attacker-chosen paths outside the intended node_modules directory.
Reject pnpm-lock.yaml entries whose remote tarball resolution: block is missing the integrity field. Previously the worker that extracts a downloaded tarball skipped hash verification when no integrity was supplied and minted a fresh one from the unverified bytes, so an attacker who could both alter the lockfile (e.g. via a pull request that strips integrity:) and serve modified content at the referenced tarball URL could install a tampered package without any error — including under --frozen-lockfile. pnpm now fails closed at lockfile-read time with ERR_PNPM_MISSING_TARBALL_INTEGRITY. Git-hosted tarballs (gitHosted: true or a URL on codeload.github.com / bitbucket.org / gitlab.com) and file: tarballs are exempt — the commit SHA in a git-host URL and the user-controlled local path already anchor the bytes.
Validate devEngines.runtime and engines.runtime version ranges for node, deno, and bun when onFail is set to error or warn. Previously these settings only had an effect with onFail: 'download' — the error and warn modes silently did nothing #11818. Violations now throw ERR_PNPM_BAD_RUNTIME_VERSION.
Require provenance before treating trusted publisher metadata as the strongest trust evidence.
Added pnpm stage with publish, list, view, approve, reject, and download subcommands for npm staged publishing.
Added a new setting trustLockfile. When true, pnpm install skips the supply-chain verification pass that re-applies minimumReleaseAge / trustPolicy='no-downgrade' to every entry in the loaded lockfile. The install treats the lockfile as already-trusted — useful for closed-source projects where every commit comes from a trusted author. Defaults to false; verification stays on by default. Set in pnpm-workspace.yaml.
Also cut the memory footprint of the verification pass itself: the per-(registry, name) trust-meta cache previously retained the full packument — dependency graphs, scripts, README, and per-version manifests — for the entire install. On large workspaces (~4k lockfile entries with minimumReleaseAge + trustPolicy: no-downgrade enabled) this could OOM CI runners with a 2GB heap cap. The cache now stores only the fields the trust check actually reads (time, per-version _npmUser.trustedPublisher, dist.attestations.provenance). The abbreviated-metadata cache is similarly projected to just the package-level modified field and the set of currently-listed version names. Fixes #11860.
Implemented pnpm pkg command natively, following npm pkg standards.
Implemented pnpm repo command natively, following npm repo standards.
Implemented pnpm set-script (alias ss) natively. Adds or updates an entry in the scripts field of the project manifest, supporting package.json, package.json5, and package.yaml formats.
Add a skip-manifest-obfuscation option for pnpm pack and pnpm publish. When enabled, the original packageManager field and publish lifecycle scripts are kept in the packed/published manifest instead of being stripped. The pnpm-specific pnpm field continues to be omitted.
Patch Changes
Fixed pnpm dlx failing with ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND when the installed package's CAS slot is missing its package.json. Observed in the wild for pnpm dlx node@runtime:<version> when the GVS slot was populated without the synthesized manifest runtime archives need (they don't ship a package.json of their own, so the synthesized one is the only way it gets there; an existing slot from an earlier code path that skipped the synthesis stays incomplete). The bin link i
✂ Note
PR body was truncated to here.
Configuration
📅 Schedule: (UTC)
Branch creation
At any time (no schedule defined)
Automerge
At any time (no schedule defined)
🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.
♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
If you want to rebase/retry this PR, check this box
Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.
This PR includes no changesets
When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR contains the following updates:
10.34.3+sha512.f2c531b08829d7be7f03c90addc266615f9c5477463e4bf1d275cb263ce9ea57cf0d5599110d94649a9bb3fed9f0b5efcea74a7016b61532a24cb9ad87860c1d→11.5.3pnpm:
patch-removecould delete project-selected files outside the patches directoryCVE-2026-59194 / GHSA-72r4-9c5j-mj57
More information
Details
Summary
The
patch-removedeletion-scope issue tracked as GHSA-72r4-9c5j-mj57 / CAND-PNPM-030 has been addressed in pnpm.A crafted patch entry could resolve outside the configured patches directory and cause
pnpm patch-removeto delete an arbitrary reachable file. This patch validates the configured directory and every resolved target before unlinking anything, then deletes the final directory entry without following it.Security boundary
..while still rejecting parent traversal, Windows drive escapes, and UNC escapes.Exploit replay
Before the patch, a workspace
patchedDependenciespath that resolved outside the project causedpnpm patch-removeto delete the external sentinel. A second replay used a nested parent symlink and a dangling outside victim:realpath()returnedENOENT, yet the victim was still removed. With this patch, both paths are rejected and the outside entries remain intact.Files changed
patching/commands/src/isSubdirectory.tsperforms component-aware containment checks.patching/commands/src/patchRemove.tsvalidates the full batch, canonicalizes parents, and unlinks final entries without following them.patching/commands/test/{isSubdirectory,patchRemove}.test.tscovers traversal, nested symlinks, dangling victims, and valid removals.Commands run
Validation
git diff --check: passed./private/tmp.Patches
10.34.4: pnpm/pnpm@352ae4811.7.0: pnpm/pnpm@612a2e6Compatibility
Missing patch files remain no-ops. Valid symlinked patch directories continue to work when their canonical target stays inside the lockfile directory, and final symlinks are removed without touching their targets.
patch-removeis not yet in pacquet's command surface, so no Rust-side parity change is required.Remaining risk
Portable Node APIs do not expose directory-fd-relative
unlinkat(). A local attacker who can replace an already validated parent directory before the unlink may still win a time-of-check/time-of-use race. The reproduced repository-controlled traversal and symlink paths do not require that concurrent capability and are blocked by this patch.Written by an agent (Codex, GPT-5).
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
pnpm: Hoisted install imports lockfile alias outside node_modules
CVE-2026-59196 / GHSA-fr4h-3cph-29xv
More information
Details
Summary
The hoisted dependency alias issue tracked as GHSA-fr4h-3cph-29xv / CAND-PNPM-059 has been addressed in both pnpm and pacquet.
A crafted lockfile alias could be joined directly under a hoisted
node_modulesdirectory. Traversal aliases could escape that directory, while reserved aliases such as.binor.pnpmcould overwrite pnpm-owned layout. This patch validates package-name semantics and path containment before graph insertion or filesystem work.Security boundary
dep.namesink.dep.0.namebefore adding the graph node or recursing.ERR_PNPM_INVALID_DEPENDENCY_NAME.Exploit replay
Before the patch, a traversal alias in a hoisted lockfile imported package files outside the intended install root. With this patch, both pnpm and pacquet reject the alias before graph insertion or filesystem work, and the escaped file is not created.
Files changed
fs/symlink-dependency/src/safeJoinModulesDir.tsprovides the TypeScript containment helper.installing/deps-restorer/src/lockfileToHoistedDepGraph.tsvalidates the parsed dependency name at the hoisted graph sink.pacquet/crates/package-manager/src/{hoisted_dep_graph.rs,safe_join_modules_dir.rs}mirrors that boundary in Rust.Commands run
Validation
cargo fmt, parsed two-document lockfile validation, andgit diff --check: passed.Patch
Ready-for-review private PR: https://github.com/pnpm/pnpm-ghsa-fr4h-3cph-29xv/pull/1
GitHub reports the branch as mergeable and has requested review from
zkochan. GitHub intentionally does not run status checks on temporary private-fork PRs; the commands and outcomes above are the recorded local validation: https://docs.github.com/code-security/security-advisories/collaborating-in-a-temporary-private-fork-to-resolve-a-security-vulnerabilityCompatibility
Valid unscoped and scoped package aliases continue to work. The changeset covers
@pnpm/fs.symlink-dependency,@pnpm/installing.deps-restorer, andpnpm; pacquet is updated in the same commit for CLI parity.Written by an agent (Codex, GPT-5).
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
pnpm: Path traversal in configDependencies env lockfile allows symlink creation outside node_modules/.pnpm-config
CVE-2026-59195 / GHSA-qrv3-253h-g69c
More information
Details
Summary
pnpmaccepts package names from the env lockfileconfigDependenciessection and uses those names directly when creating config dependency symlinks undernode_modules/.pnpm-config.A malicious repository can commit a crafted
pnpm-lock.yamlwhose env-lockfile document contains a traversal-shaped config dependency name such as../../PWNED_CFGDEP. Duringpnpm install, pnpm installs the config dependency and creates a symlink at a path derived from that name.In local testing against pnpm
v11.5.1, this caused pnpm to create a symlink outside the intended config dependency directory:This works with
--ignore-scripts, so it does not rely on lifecycle script execution.Vulnerable behavior
The vulnerable behavior appears to be that
configDependencieskeys from the env lockfile are trusted as package names and used in filesystem paths without rejecting traversal components.The relevant pattern is:
If
pkgNameis attacker-controlled and contains.., thenpath.join(configModulesDir, pkgName)can resolve outsidenode_modules/.pnpm-config.Impact
A malicious project can cause pnpm to create symlinks outside the intended
node_modules/.pnpm-configdirectory during install.This gives an attacker a filesystem write primitive in the victim project directory, and potentially outside it with deeper traversal payloads, depending on path permissions and platform behavior.
The issue is especially relevant because:
pnpm-lock.yaml.pnpm install.--ignore-scripts.Local proof of concept
The following local-only PoC creates a temporary project, starts a local fake registry on
127.0.0.1, writes a malicious env-lockfile entry, runs pnpm, and checks whether pnpm created a symlink outsidenode_modules/.pnpm-config.Command used:
Observed output:
pnpm output:
The PoC then detected the escaped symlink:
Malicious lockfile structure
The malicious input is an env-lockfile
configDependencieskey containing traversal components:pnpm accepts the traversal-shaped name and reports it as installed:
Security boundary violation
The intended config dependency root was:
But pnpm created:
This demonstrates that a config dependency name from the lockfile can escape the directory where config dependencies should be linked.
Suggested remediation
Validate every
configDependencieskey loaded from the env lockfile before using it as a package name or path component.Recommended fixes:
Reject env-lockfile
configDependenciesnames that are not valid npm package names.Reject names containing absolute paths,
.components,..components, backslashes, or platform-specific path separators.Use containment-checked path joining before creating symlinks:
node_modules/.pnpm-config,Apply the same validation to config dependency subdependencies and optional dependency names read from the env lockfile.
Intersect env-lockfile
configDependencieswith the effectivepnpm-workspace.yamlconfigDependenciesbefore installing, so extra lockfile-only entries are rejected.A safe destination check should enforce behavior equivalent to:
Name validation should happen before this check, not instead of it.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
pnpm: Repository-controlled configDependencies can select a pacquet native install engine
CVE-2026-55697 / GHSA-gj8w-mvpf-x27x
More information
Details
Maintainer Action Plan
This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.
CAND-PNPM-097/GHSA-gj8w-mvpf-x27xsecurity/ghsa-batch-2026-06-09a93449314f398cf4bdf2e28d033c02d37395ad22origin/main55a4035abf1ae3fe7208ba1f5ef43c5eff58ccecstart-herepnpm configDependencies / pacquet delegationnpm:pnpm,npm:@​pnpm/config.reader,npm:@​pnpm/installing.commandsCWE-829,CWE-78,CWE-4947.5/CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:HExpected Patched Behavior
config-dependency pacquet install engines are not selected unless the trusted allowlist is set outside the repository; the marker file is not created.
Files And Tests To Review
config/reader/src/Config.tsconfig/reader/src/types.tsconfig/reader/src/configFileKey.tsconfig/reader/src/index.tsconfig/reader/test/index.tsinstalling/commands/src/installDeps.tsinstalling/commands/test/runPacquet.tspnpm/test/install/pacquet.ts.changeset/lucky-config-plugin-pnpmfiles.mdFocused Validation
Run these from a checkout of the shared patch branch. They are the useful maintainer commands with machine-local artifact paths removed.
The full patched replay for the shared branch passed with all 20 candidates marked fixed. This candidate's replay evidence is
results/CAND-PNPM-097-patched-result.json.Summary
pnpm can install
configDependenciesdeclared inpnpm-workspace.yamlbefore command dispatch. Before the patch, a repository could declarepacquetor@pnpm/pacquetas a config dependency and pnpm treated that repository-controlled dependency as an install-engine opt-in. During install, pnpm resolved a platform-specific@pacquet/<platform>-<arch>/pacquetbinary fromnode_modules/.pnpm-config/<packageName>and spawned it as the developer or CI user.Details
The vulnerable source-to-sink path was:
config/reader/src/getOptionsFromRootManifest.tscopies repositorypnpm-workspace.yamlconfigDependenciesinto config.pnpm/src/getConfig.tsinstalls config dependencies before command dispatch.installing/env-installer/src/resolveAndInstallConfigDeps.tsresolves the repository-declared dependency and its optional platform subdependencies.installing/env-installer/src/installConfigDeps.tsfetches, imports, and symlinks the config dependency tree undernode_modules/.pnpm-config.installing/commands/src/installDeps.tsselected pacquet delegation wheneverconfigDependenciescontainedpacquetor@pnpm/pacquet.installing/deps-installer/src/install/index.tscalledopts.runPacquetfrom frozen and materialization paths.installing/commands/src/runPacquet.tsresolved@pacquet/${process.platform}-${process.arch}/pacquetfrom the installed config dependency package and executed it withspawn().Exact-version, integrity, and platform filters only proved which bytes package resolution selected; they did not establish that the repository was trusted to choose a native install engine.
PoC
Standalone PoC and verification script:
Repository fixture:
Registry package shape:
{ "name": "pacquet", "version": "0.2.2", "optionalDependencies": { "@​pacquet/darwin-arm64": "0.2.2" } }Platform package payload:
Pre-patch exploit model:
pnpm installin the repository..pnpm-config.installDeps()treats the presence ofconfigDependencies.pacquetorconfigDependencies["@​pnpm/pacquet"]as authorization to delegate install materialization.runPacquet()resolves the platform binary from the installed config dependency tree and spawns it in the lockfile directory.Observed PoC output:
{ "primitive": "repository-selected pacquet config dependency reaches native process execution when selected", "patchedWithoutAllowlist": "blocked", "trustedAllowlist": "allows explicit opt-in" }Focused validation commands:
Validation result:
getPacquetConfigDependencyName()returnsundefinedwithout a trusted allowlist.getPacquetConfigDependencyName()allows exactpacquet, exact@pnpm/pacquet, and wildcard*trusted opt-in.configDependencyInstallEngineAllowlist, whilepnpm-workspace.yamlcannot grant this permission to itself.@pnpm/config.reader,@pnpm/installing.commands, andpnpm.installing/commands/test/runPacquet.ts: 3 passed.config/reader/test/index.ts: 2 passed, 132 skipped under the focused pattern.config/reader/test/index.tsandpnpm/test/install/pacquet.ts.git diff --check: passed.Impact
A malicious repository can cause pnpm to execute a registry-selected native binary while handling dependency-management commands. The binary runs with the victim developer or CI user's filesystem, environment, registry credentials, git/SSH credentials, and network access.
Affected products
Ecosystem: npm
Package name:
pnpm,@pnpm/config.reader,@pnpm/installing.commandsAffected versions: current main before this patch, when
configDependenciescontainspacquetor@pnpm/pacquetand install paths delegate to pacquet.Patched versions: 10.34.2, 11.5.3.
Severity
Severity: High
Vector string:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:HBase score: 8.8
Rationale: attacker input is delivered through a repository and registry package, exploitation is low complexity once the victim runs pnpm, no attacker privileges are required, and user interaction is required. Successful exploitation executes a native binary in the victim user's context, with high confidentiality, integrity, and availability impact.
Weaknesses
CWE-829: Inclusion of Functionality from Untrusted Control Sphere
CWE-78: Improper Neutralization of Special Elements used in an OS Command
CWE-494: Download of Code Without Integrity Check
Patch
The patch adds a trusted opt-in gate for config-dependency install-engine delegation:
configDependencyInstallEngineAllowlist.pnpm-workspace.yamlcannot grant this permission to itself; workspace-provided values are discarded after workspace settings are merged.installDeps()delegates to pacquet only whenpacquet,@pnpm/pacquet, or*is present in the trusted allowlist.pacquetas a config dependency, but pnpm will not spawn it as an install engine unless trusted config opts in.Changed files:
config/reader/src/Config.tsconfig/reader/src/types.tsconfig/reader/src/configFileKey.tsconfig/reader/src/index.tsconfig/reader/test/index.tsinstalling/commands/src/installDeps.tsinstalling/commands/test/runPacquet.tspnpm/test/install/pacquet.tsChangeset:
.changeset/lucky-config-plugin-pnpmfiles.mdPacquet parity:
No pacquet-side code-execution sink exists for this finding. The Rust port parses and records
configDependenciesfor workspace-state compatibility, but it does not install config dependencies or select/spawn an alternate install engine from them. The user-visible trust setting is TypeScript-side today because it gates pnpm's pacquet delegation path.CVSS Reassessment
Initial CVSS remains correct for vulnerable versions:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H/ 8.8 High.Final CVSS after patch: not vulnerable after patch / 0.0. The PoC no longer reaches pacquet install-engine selection or native process execution unless the victim has set a trusted allowlist outside the repository's own workspace settings.
Remaining Risk
Users can explicitly trust pacquet install-engine delegation through the new allowlist. That is intentional behavior; the closed issue is repository self-authorization of a registry-provided native install engine.
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Release Notes
pnpm/pnpm (pnpm)
v11.5.3Compare Source
Patch Changes
Stopped expanding environment variables in repository-controlled registry/proxy request destinations and registry credential values from
.npmrc, and in workspace registry URLs frompnpm-workspace.yaml. Move dynamic registry URL and token configuration to trusted user, global, CLI, or environment config.Resolve package-manager bootstrap dependencies with trusted user or CLI registry and network config, and reject package-manager env-lockfile records that do not use registry package paths with integrity-only resolutions before auto-switch execution.
Avoid writing
packageManagerDependenciestopnpm-lock.yamlwhen package manager policy is set toonFail: ignoreorpmOnFail: ignore#12228.Avoid running dependency-status auto-install when the dependency status is unavailable without a project manifest.
Using the
$version reference syntax inoverrides(e.g."react": "$react") now prints a deprecation warning. The syntax still works, but catalogs are the recommended way to keep an overridden version in sync with the rest of the workspace. Reference a catalog entry with thecatalog:protocol instead.Fixed
pnpm config get globalconfigto return the globalconfig.yamlpath again pnpm/pnpm#11962.Fixed bare
--colorso it does not consume the following CLI flag, allowing command shorthands like--parallelto expand correctly and forms likepnpm --color with current <command>to dispatch the inner command instead of failing withMISSING_WITH_CURRENT_CMD.Fix
pnpm installignoringenableGlobalVirtualStoretoggle by including it in the workspace state settings check #12142.Security: pnpm now verifies the npm registry signature of a package-manager binary before spawning it, so a cloned repository cannot make pnpm download and execute an arbitrary native binary.
This covers two paths that select an executable from repository-controlled input:
pacquet(or@pnpm/pacquet) inconfigDependenciesopts in to pnpm's Rust install engine. pnpm now verifies that the installedpacquetshim and the host's@pacquet/<platform>-<arch>binary carry a valid npm registry signature for their exactname@version, and refuses to run pacquet (failing the command) if the signature does not verify or cannot be checked. The only graceful fallback to pnpm's own engine is when pacquet has no binary for the current platform.self-update— thepackageManager/devEngines.packageManagerfield makes pnpm download and run a specific pnpm version. pnpm now verifies the registry signature ofpnpm,@pnpm/exe, and the host platform binary before installing/spawning them, and refuses to run an engine whose signature does not match a published, signed release. The check runs only on an actual download (store cache miss), so it does not add a network round trip to every command.In both cases the signature is verified over the installed integrity, against npm's public signing keys that ship embedded in the pnpm CLI (like corepack), so bytes substituted via a tampered lockfile or a repository-controlled registry fail verification — and a registry the user did not vouch for cannot supply its own signing keys. The signed packument is fetched from the configured registry, so an npm mirror works transparently. Verification fails closed: if it cannot be completed (for example, the registry is unreachable), the command fails rather than running an unverified binary. The embedded keys are kept current by a release-time check against npm's signing-keys endpoint.
Made peer-dependent deduplication deterministic. When a peer-suffixed package variant was a subset of two or more mutually incompatible larger variants, the variant it collapsed into depended on the order importers were resolved in, which varies between machines. This could resolve the same workspace to different lockfiles on different platforms and make
pnpm dedupe --checkalternate between passing and failing.Reject invalid package names and versions from staged tarball manifests before deriving filenames for
pnpm stage download.Clarified in CLI help that the pnpm store is trusted shared state and store integrity checks are corruption detection, not a tamper boundary for untrusted store writers.
Reject reserved manifest
binnames ("",".","..", and scoped forms such as@scope/..) when resolving a package's bins. These names previously passed the bin-name guard and, when joined to the global bin directory during global remove/update/add operations, could resolve to the global bin directory itself or its parent and have it recursively deleted.Require trusted package identity before package-name
allowBuildsentries can approve lifecycle scripts for git, git-hosted tarball, direct tarball, and local directory artifacts. To approve one of those artifacts explicitly, use its peer-suffix-free lockfile depPath as theallowBuildskey. Lockfile verification now rejects lockfiles where a registry-style dependency path (name@semver) is backed by a git, directory, or git-hosted tarball resolution (ERR_PNPM_RESOLUTION_SHAPE_MISMATCH), so the dependency path is a reliable artifact identity by the time scripts can run.Security: pnpm now verifies the OpenPGP signature of a downloaded Node.js runtime's
SHASUMS256.txtbefore trusting its integrity hashes.When a repository requests a Node.js runtime (e.g. via
devEngines.runtime/useNodeVersion), the download mirror is repository-configurable throughnode-mirror:<channel>. The integrity of the downloaded binary was only checked againstSHASUMS256.txtfetched from that same mirror — a circular check that a malicious mirror could satisfy by serving a tampered binary together with a matchingSHASUMS256.txt. pnpm then executes the binary (for example to run lifecycle scripts).pnpm now fetches
SHASUMS256.txt.sigand verifies the detached OpenPGP signature against the Node.js release team's public keys, which ship embedded in the pnpm CLI. A mirror that serves a tampered binary cannot also produce a valid signature, so the download fails to verify. The embedded keys are kept current by a release-time check against the canonicalnodejs/release-keyslist.The musl variants from the hardcoded
unofficial-builds.nodejs.orgmirror are not repository-configurable and are signed by a different key, so they continue to be trusted over TLS.v11.5.2Compare Source
Patch Changes
Peer dependency resolution now reuses the peer contexts already recorded in the lockfile when those providers are still present in the dependency graph and still satisfy the peer ranges. This avoids unnecessary peer-context rewrites during lockfile regeneration. Current manifest choices remain authoritative: a newly added, explicitly updated, or aliased direct provider, a changed nested provider, or a locked version that no longer satisfies the range still takes precedence.
The lockfile verifier now checks that a registry entry pinning an explicit
tarballURL points at the artifact the registry's own metadata lists for thatname@version. Previously a tampered lockfile could pair a trustedname@versionwith an attacker-chosen tarball URL (and a matching integrity for those bytes), so the install fetched the attacker's bytes. A mismatch — or any entry that can't be confirmed against the registry — is rejected withERR_PNPM_TARBALL_URL_MISMATCH. Non-registry resolutions (file:, git-hosted, etc.) and registry entries without an explicit tarball URL (the URL is reconstructed from name+version+registry, so it is inherently bound) are unaffected; non-standard registry tarball URLs (npm Enterprise, GitHub Packages) still pass because they match the metadata.Fix
pnpm update --recursive --lockfile-only <pkg>@​<version>crashing withInvalid Versionwhen the catalog entry for<pkg>is a version range (e.g.^21.2.10) andcatalogModeisstrictorprefer. The catalog–version comparison now skips the equality check when either side is a range rather than passing a range tosemver.eq(), so range specifiers fall through to the existing mismatch handling instead of throwing #11570.Avoided a Node.js crash when pnpm exits after network requests on Windows.
Fixed packages being materialized into the virtual store without their root-level files (
package.json,LICENSE, README, root entrypoints) when multiplepnpm installprocesses ran against the same store/workspace concurrently. The fast import path used to destructively empty the shared target directory, so a concurrent importer could wipe files another importer had already written; if the surviving files included thepackage.jsoncompletion marker, every later install treated the broken directory as complete and never repaired it. The fast path now imports directly only when it can create the target directory exclusively, and otherwise builds the package in a private temp directory and atomically renames it into place #12197.Fix dependency build scripts not running under the global virtual store (
enableGlobalVirtualStore).In a workspace install, dependency build scripts are deferred to a single
rebuildpass (buildProjects). That pass resolved each package's location from the classicnode_modules/.pnpm/<depPathToFilename>layout, which does not exist under the global virtual store — so native dependencies (e.g. packages usingnode-gyp/prebuild-install) were never built and failed to load at runtime (Cannot find module .../build/Release/*.node).buildProjectsnow resolves the global-virtual-store projection directory (<storeDir>/links/<hash>, computed with the same graph hash the installer uses) whenenableGlobalVirtualStoreis set, and serializes concurrent builds of the same shared projection so parallel workspace projects don't race on the same directory.Don't promote a
runtime:dependency (such as the Node.js version fromdevEngines.runtimeorpnpm runtime set) into a catalog whencatalogModeisstrictorprefer. Aruntime:dependency round-trips todevEngines.runtime, which only recognizes theruntime:protocol; cataloging it rewrote the manifest entry tocatalog:, which broke that round-trip, stranded it indevDependencies, and leftdevEngines.runtimeuntouched.Skip lockfile
minimumReleaseAge/trustPolicyverification for non-registry tarball protocols (for examplefile:), so local tarball dependencies are not incorrectly checked against npm registry metadata.v11.5.1Compare Source
Patch Changes
pnpm auditperformance by pruning non-vulnerable lockfile subtrees and stopping path enumeration once vulnerable findings reach the path cap.npm_config_user_agentfor root lifecycle scripts during headless installs.integrityfield of a remote (non-registry) tarball dependency when its lockfile entry is rebuilt. Re-resolving such a dependency without re-fetching it (for example viapnpm update, or when another dependency changes) produced a resolution with no integrity — URL/tarball resolvers only learn the integrity after the tarball is downloaded — so the previously recorded integrity was dropped, making later installs fail withERR_PNPM_MISSING_TARBALL_INTEGRITY#12067.repositoryfield into the{ type, url }object form when creating the publish manifest, matching npm's behavior. Some registries (e.g. Gitea/Codeberg) reject a stringrepositorywith a 500 Internal Server Error duringpnpm publish#12099.@typescript-eslint/eslint-pluginpeer-depends on both@typescript-eslint/parserandtypescript, and@typescript-eslint/parserpeer-depends ontypescript), pnpm no longer reuses a hoisted instance of the shared peer that was resolved against a different version #12079.v11.5.0Compare Source
Minor Changes
Added a new
hoistingLimitssetting fornodeLinker: hoistedinstalls, mirroring yarn'snmHoistingLimits. It acceptsnone(the default — hoist as far as possible),workspaces(hoist only as far as each workspace package), ordependencies(hoist only up to each workspace package's direct dependencies). Originally proposed in #6468, closing #6457.Replaced
enquirerwith@inquirer/promptsfor all interactive prompts. Fixes theupdate -iscrolling overflow bug where long choice lists were clipped in the terminal #6643.User-facing changes:
pnpm update -i/pnpm update -i --latest: Scrolling now works correctly when many packages are available; the new library uses visual-line-aware pagination viausePaginationpnpm audit --fix -i: Same scrolling fix for vulnerability selectionpnpm approve-builds: Interactive build approval prompts updatedpnpm patch: Version selection and "apply to all" prompts updatedpnpm patch-remove: Patch removal selection updatedpnpm publish: Branch confirmation prompt updatedpnpm login: Credential prompts updatedpnpm run/pnpm exec(withverifyDepsBeforeRun=prompt): Confirmation prompt updatedVim-style
j/kkeys still work for up/down navigation in all interactive prompts.Internal: The
OtpEnquirerandLoginEnquirerDI interfaces changed from{ prompt }to{ input }/{ input, password }respectively. Plugins or custom builds that inject their own enquirer mock will need to update.Staged publishes are now recognized in the trust scale. When a package version's registry metadata carries an
approverfield, it is treated as the strongest trust evidence (ranked above trusted publishers and provenance attestations), since staged publishes require 2FA publish approvals. This prevents false-positive trust downgrade errors when moving from a staged publish to a lower trust level #11887.Patch Changes
Fix pnpm hanging during peer resolution when an aliased install pulls in transitive packages with mutual peer cycles at different depths in the dependency tree (for example,
pnpm i nuxt@npm:nuxt-nightly@5x). Cycles whose members hit thefindHitcache instead of running their owncalculateDepPathare now short-circuited by sibling resolutions at the level where the cycle is detected, so the cached path promises no longer deadlock. #11999.Fix
pnpm dist-tag addandpnpm dist-tag rmagainst npmjs.org failing without--otpwith[ERR_PNPM_UNAUTHORIZED] You must be logged in to set dist-tag … "You must provide a one-time pass. Upgrade your client to npm@latest in order to use 2FA.". pnpm now sendsnpm-auth-type: webon dist-tag writes and surfaces the resulting OTP challenge through the existing browser-based 2FA flow (the samewithOtpHandlinghelper used bypnpm publish), so the browser opens, the user authenticates, and the dist-tag is set on retry.--otp=<code>continues to work via the classic flow.Fix
minimumReleaseAgeExcludehandling in npm resolution fast paths so excluded packages do not get pinned to stale versions. Excludes are honored consistently duringpublishedBymetadata selection and cache-mtime shortcuts.Fix the
integrityfield being dropped from the lockfile entry of a remote (non-registry) https-tarball dependency when an unrelated package is installed afterwards. URL/tarball resolvers do not return an integrity (it is only known after the tarball is downloaded), so when such a dependency was reused from the lockfile without being re-fetched, its integrity was lost. It is now carried over from the existing resolution. With pnpm's lockfile-integrity hardening, the missing integrity made subsequent--frozen-lockfileinstalls fail withERR_PNPM_MISSING_TARBALL_INTEGRITY. #12001.Skip dependency re-resolution when
pnpm-lock.yamlis missing butnode_modules/.pnpm/lock.yamlexists and still satisfies the manifest.pnpm installnow reuses the materialized snapshot to regeneratepnpm-lock.yamlinstead of walking the registry to rebuild it from scratch, turning the cache+node_modules variation into a near-no-op for users who deleted the lockfile but kept the install #11993.--frozen-lockfilestill refuses to proceed whenpnpm-lock.yamlis absent — the regenerated lockfile must be committed, so failing loudly is the correct behavior for CI.v11.4.0Compare Source
Minor Changes
Treat tarball-integrity mismatches against the lockfile as a hard failure by default. Previously,
pnpm install(non-frozen) would logERR_PNPM_TARBALL_INTEGRITY, silently re-resolve from the registry, and overwrite the locked integrity — which meant a compromised registry, proxy, or republished version could substitute attacker-controlled content on a clean machine even though the project shipped a committed lockfile.pnpm installnow exits withERR_PNPM_TARBALL_INTEGRITYand a hint pointing at the new opt-in flag.The only opt-in is
pnpm install --update-checksums— narrowly scoped to refreshing the locked integrity values from what the registry currently serves. Mirrors yarn's flag of the same name. A warning still prints when the bypass takes effect so the operation is auditable.--forceandpnpm updatedeliberately do not bypass the integrity check. They are routine refresh operations; silently overwriting a locked integrity in those flows would erase the protection a committed lockfile is supposed to provide.--frozen-lockfilebehavior is unchanged.--fix-lockfilekeeps its documented purpose (filling in missing lockfile entries) and is also not a bypass.pnpm runtime set <name> <version>now saves the runtime todevEngines.runtimeby default instead ofengines.runtime. Pass--save-prod(or-P) to save it toengines.runtimeinstead #11948.Patch Changes
Fix a credential disclosure issue where an unscoped
_authToken(or_auth, orusername+_password, ortokenHelper) defined in one source —~/.npmrc,~/.config/pnpm/auth.ini, a workspace.npmrc, CLI flags, etc. — would be sent as anAuthorizationheader to whichever registry a different (potentially untrusted) source named. The same fix extends to client TLS credentials (cert,key) so they aren't presented to a registry their author didn't choose.pnpm now rewrites each unscoped per-registry setting (
_authToken,_auth,username,_password,tokenHelper,cert,key) to its URL-scoped form at load time, using theregistry=value declared in the same source (or the npmjs default registry if the source declares none). A later layer overridingregistry=therefore cannot pull an unscoped credential along, because it is already pinned to the URL its author intended.ca/cafileare intentionally not rescoped — they're trust anchors, not credentials, and corporate MITM-proxy setups rely on them applying globally.Every rescope emits a deprecation warning telling the user where the setting was pinned and how to write it directly. npm has rejected unscoped credentials outright since
npm@9, and pnpm intends to remove support in a future major release. To target a specific registry, write the setting URL-scoped (e.g.//registry.example.com/:_authToken=...or//registry.example.com/:cert=...).@pnpm/network.auth-header: removed thedefaultRegistryparameter fromcreateGetAuthHeaderByURIandgetAuthHeadersFromCreds. Now that credentials are URL-scoped at load time, the mergedconfigByUrinever contains the empty-string "default registry" placeholder slot, so re-keying it onto the merged default registry is no longer needed.Fix
pnpm deploycrashing withENOENT: ... lstat '<deployDir>/node_modules'whenconfigDependenciesdeclares pacquet (pacquetor@pnpm/pacquet). The deploy directory never installs config dependencies, so the install engine they designate isn't on disk to invoke; the nested install now skips them.Reject git resolutions whose
commitfield is not a 40-character hexadecimal SHA before invokinggit. A malicious lockfile could otherwise smuggle a value such as--upload-pack=<command>throughgit fetch/git checkout, which on SSH or local-file transports executes the supplied command.Limit concurrent project manifest reads while listing large workspaces to avoid
EMFILEerrors.Reject patch files whose
diff --githeaders reference paths outside the patched package directory. Previously a malicious.patchfile added via a pull request could write, delete, or rename arbitrary files reachable by the user runningpnpm install.Improve the log message that pnpm prints after auto-adding entries to
minimumReleaseAgeExcludewhenminimumReleaseAgeis set withoutminimumReleaseAgeStrict. The message previously referred to the internal "loose mode" terminology, which wasn't searchable in the docs; it now tells the user to setminimumReleaseAgeStricttotrueif they want these updates gated behind a prompt instead #11747.Reject dependency aliases that contain path-traversal segments (such as
@x/../../../../../.git/hooks) when reading them from a package manifest or symlinking them intonode_modules. A malicious registry package could otherwise use a transitive dependency key to makepnpm installcreate symlinks at attacker-chosen paths outside the intendednode_modulesdirectory.Reject
pnpm-lock.yamlentries whose remote tarballresolution:block is missing theintegrityfield. Previously the worker that extracts a downloaded tarball skipped hash verification when no integrity was supplied and minted a fresh one from the unverified bytes, so an attacker who could both alter the lockfile (e.g. via a pull request that stripsintegrity:) and serve modified content at the referenced tarball URL could install a tampered package without any error — including under--frozen-lockfile. pnpm now fails closed at lockfile-read time withERR_PNPM_MISSING_TARBALL_INTEGRITY. Git-hosted tarballs (gitHosted: trueor a URL on codeload.github.com / bitbucket.org / gitlab.com) andfile:tarballs are exempt — the commit SHA in a git-host URL and the user-controlled local path already anchor the bytes.Validate
devEngines.runtimeandengines.runtimeversion ranges fornode,deno, andbunwhenonFailis set toerrororwarn. Previously these settings only had an effect withonFail: 'download'— theerrorandwarnmodes silently did nothing #11818. Violations now throwERR_PNPM_BAD_RUNTIME_VERSION.Require provenance before treating trusted publisher metadata as the strongest trust evidence.
v11.3.0Compare Source
Minor Changes
Added
pnpm stagewithpublish,list,view,approve,reject, anddownloadsubcommands for npm staged publishing.Added a new setting
trustLockfile. Whentrue,pnpm installskips the supply-chain verification pass that re-appliesminimumReleaseAge/trustPolicy='no-downgrade'to every entry in the loaded lockfile. The install treats the lockfile as already-trusted — useful for closed-source projects where every commit comes from a trusted author. Defaults tofalse; verification stays on by default. Set inpnpm-workspace.yaml.Also cut the memory footprint of the verification pass itself: the per-(registry, name) trust-meta cache previously retained the full packument — dependency graphs, scripts, README, and per-version manifests — for the entire install. On large workspaces (
~4klockfile entries withminimumReleaseAge+trustPolicy: no-downgradeenabled) this could OOM CI runners with a 2GB heap cap. The cache now stores only the fields the trust check actually reads (time, per-version_npmUser.trustedPublisher,dist.attestations.provenance). The abbreviated-metadata cache is similarly projected to just the package-levelmodifiedfield and the set of currently-listed version names. Fixes #11860.Implemented
pnpm pkgcommand natively, followingnpm pkgstandards.Implemented
pnpm repocommand natively, followingnpm repostandards.Implemented
pnpm set-script(aliasss) natively. Adds or updates an entry in thescriptsfield of the project manifest, supportingpackage.json,package.json5, andpackage.yamlformats.Add a
skip-manifest-obfuscationoption forpnpm packandpnpm publish. When enabled, the originalpackageManagerfield and publish lifecycle scripts are kept in the packed/published manifest instead of being stripped. The pnpm-specificpnpmfield continues to be omitted.Patch Changes
pnpm dlxfailing withERR_PNPM_NO_IMPORTER_MANIFEST_FOUNDwhen the installed package's CAS slot is missing itspackage.json. Observed in the wild forpnpm dlx node@runtime:<version>when the GVS slot was populated without the synthesized manifest runtime archives need (they don't ship apackage.jsonof their own, so the synthesized one is the only way it gets there; an existing slot from an earlier code path that skipped the synthesis stays incomplete). The bin link iConfiguration
📅 Schedule: (UTC)
🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.
♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
This PR was generated by Mend Renovate. View the repository job log.