Skip to content

pnpm security update PRs ship pnpm-lock.yaml specifier rewrites unmatched by package.json changes #15104

@ivnnv

Description

@ivnnv

pnpm security update PRs ship pnpm-lock.yaml specifier: rewrites unmatched by package.json changes

Package ecosystem

pnpm

Package manager version

Reproduced on pnpm 9.0.5 (the version corepack picks inside ghcr.io/dependabot/dependabot-updater-npm:latest when the target repo's packageManager field is set to pnpm@9.0.5) and pnpm 10.16.0 (the version baked into the same image globally).

What you expected to see, versus what you actually saw

For a transitive-dependency security update where the regular pnpm update <dep>@<ver> --lockfile-only --no-save -r and pnpm install --lockfile-only produce no lockfile change, the deep-update fallback (run_pnpm_deep_update_command in npm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb) writes a pnpm-lock.yaml whose importers: block has specifier: lines rewritten to ^<currently-resolved-version> for every direct dependency whose resolved version is newer than its declared caret floor. The matching package.json mutations (which pnpm update --depth Infinity also writes) are discarded by PackageJsonUpdater, so the PR ships lockfile changes without any matching package.json change. CI's frozen-lockfile install then rejects the PR.

Example slice from one of our affected security PRs (Dependabot bumping ws, a purely transitive dep):

 '@apollo/client':
-        specifier: ^3.13.0
+        specifier: ^3.14.0
         version: 3.14.0(graphql@16.12.0)
 '@babel/core':
-        specifier: ^7.21.4
+        specifier: ^7.29.0
         version: 7.29.0
 '@babel/preset-env':
-        specifier: ^7.21.4
+        specifier: ^7.28.5
         version: 7.28.5(@babel/core@7.29.0)

The accompanying package.json is unchanged for all of these.

Reproduction

Minimal public repro: https://github.com/ivnnv/dependabot-pnpm-deep-update-bug

git clone https://github.com/ivnnv/dependabot-pnpm-deep-update-bug
cd dependabot-pnpm-deep-update-bug
./repro.sh

Sample output:

pnpm version: 9.0.5

=== Without --no-save (current dependabot behavior) ===
  command : pnpm update ws --depth Infinity --lockfile-only
  lockfile specifier rewrites : 10
  package.json diff lines     : 18
  package.json delta:
    -        "axios": "^1.0.0",
    -        "lodash": "^4.0.0",
    -        "socket.io": "^4.0.0",
    -        "debug": "^4.0.0",
    -        "dotenv": "^16.0.0"
    +        "axios": "^1.16.1",
    +        "debug": "^4.4.3",
    +        "dotenv": "^16.6.1",
    +        "lodash": "^4.18.1",
    +        "socket.io": "^4.8.3"

=== With --no-save (proposed fix) ===
  command : pnpm update ws --depth Infinity --lockfile-only --no-save
  lockfile specifier rewrites : 0
  package.json diff lines     : 0

Root cause

run_pnpm_deep_update_command in npm_and_yarn/lib/dependabot/npm_and_yarn/native_helpers.rb:72-84:

def self.run_pnpm_deep_update_command(dependency_name, recursive: false)
  # `pnpm update --depth Infinity <dep>` traverses the full dependency
  # graph, allowing transitive dependencies to be updated in the lockfile
  # without modifying any package.json (unlike `pnpm audit --fix`).
  # ...
  Helpers.run_pnpm_command(
    "#{flags}update #{dependency_name} --depth Infinity --lockfile-only",
    ...
  )
end

The comment claims this command does not modify package.json. That claim does not hold for current pnpm. On both 9.0.5 and 10.16.0, pnpm update <dep> --depth Infinity --lockfile-only rewrites caret ranges in package.json and the matching specifier: entries in pnpm-lock.yaml to ^<currently-resolved-version> for every direct dependency whose resolved version is newer than its declared floor.

PnpmLockfileUpdater returns only the lockfile content. PackageJsonUpdater then writes back the final package.json from the original manifest, applying only the target dependency's requirement update (which for a transitive dep is no change). The package.json mutations are dropped; the lockfile mutations survive. The resulting PR has pnpm-lock.yaml specifier: lines for unrelated direct dependencies that don't match the manifest, and CI's frozen-lockfile install rejects it.

Proposed fix

Add --no-save to the deep-update command. --no-save tells pnpm to leave package.json unchanged. Tested locally to:

  • still update the target transitive dependency in pnpm-lock.yaml (verified by running it against a manifest where the target transitive ws@8.18.3 was bumped to ws@8.20.1 only when the parent's range allowed it; --no-save did not block the legitimate bump),
  • produce zero specifier: rewrites in pnpm-lock.yaml,
  • produce zero package.json mutations.

Diff:

-"#{flags}update #{dependency_name} --depth Infinity --lockfile-only",
-fingerprint: "#{flags}update <dependency_name> --depth Infinity --lockfile-only"
+"#{flags}update #{dependency_name} --depth Infinity --lockfile-only --no-save",
+fingerprint: "#{flags}update <dependency_name> --depth Infinity --lockfile-only --no-save"

PR with the fix + the call-site spec update: #15105.

Note on prior art: --no-save is already in use on three of dependabot-core's other pnpm command paths (file_updater/pnpm_lockfile_updater.rb line 192 in run_pnpm_update_packages, and update_checker/subdependency_version_resolver.rb line 256 in pnpm_update_command). This change brings the deep-update fallback in line with the primary path, no new minimum-version requirement is introduced.

Affected since

PR #14589 ("Audit fix fallback", merged 2026-04-27) introduced the deep-update fallback. Every transitive-dep pnpm security PR since that date is affected when enable_audit_fix_fallback is enabled for the user's repository.

Related, not duplicate

Tested image

ghcr.io/dependabot/dependabot-updater-npm:latest digest sha256:6111cd1d49d96c63913ea564ce5c245435f047816cede5ba2d555c776861ebd6.

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

Status

No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions