npm · pnpm
pnpm: Repository-controlled configDependencies can select a pacquet native install engine
This report is ready to review with the shared patch branch. Start with the PR and the expected fixed behavior, then use the detailed exploit narrative below only if you want to replay the original path.
CAND-PNPM-097 / GHSA-gj8w-mvpf-x27xsecurity/ghsa-batch-2026-06-09a93449314f398cf4bdf2e28d033c02d37395ad22origin/main 55a4035abf1ae3fe7208ba1f5ef43c5eff58ccecstart-herepnpm configDependencies / pacquet delegationnpm:pnpm, npm:@pnpm/config.reader, npm:@pnpm/installing.commandsCWE-829, CWE-78, CWE-4947.5 / CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:Hconfig-dependency pacquet install engines are not selected unless the trusted allowlist is set outside the repository; the marker file is not created.
config/reader/src/Config.tsconfig/reader/src/types.tsconfig/reader/src/configFileKey.tsconfig/reader/src/index.tsconfig/reader/test/index.tsinstalling/commands/src/installDeps.tsinstalling/commands/test/runPacquet.tspnpm/test/install/pacquet.ts.changeset/lucky-config-plugin-pnpmfiles.mdRun 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.
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.
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.
Standalone PoC and verification script:
Repository fixture:
packages:
- .
configDependencies:
pacquet: 0.2.2
Registry package shape:
{
"name": "pacquet",
"version": "0.2.2",
"optionalDependencies": {
"@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:
pnpm install in the repository..pnpm-config.installDeps() treats the presence of configDependencies.pacquet or configDependencies["@pnpm/pacquet"] as authorization to delegate install materialization.runPacquet() resolves the platform binary from the installed config dependency tree and spawns it in the lockfile directory.Observed PoC output:
{
"primitive": "repository-selected pacquet config dependency reaches native process execution when selected",
"patchedWithoutAllowlist": "blocked",
"trustedAllowlist": "allows explicit opt-in"
}
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:
getPacquetConfigDependencyName() returns undefined without a trusted allowlist.getPacquetConfigDependencyName() allows exact pacquet, exact @pnpm/pacquet, and wildcard * trusted opt-in.configDependencyInstallEngineAllowlist, while pnpm-workspace.yaml cannot grant this permission to itself.@pnpm/config.reader, @pnpm/installing.commands, and pnpm.installing/commands/test/runPacquet.ts: 3 passed.config/reader/test/index.ts: 2 passed, 132 skipped under the focused pattern.config/reader/test/index.ts and pnpm/test/install/pacquet.ts.git diff --check: passed.A malicious repository can cause pnpm to execute a registry-selected native binary while handling dependency-management commands. The binary runs with the
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.