Skip to content

Global agent mode on pnpm 12 (and 11 without the global virtual store) patches only one of the per-install copies of a package, reports success, and VEX attests not_affected #435

Description

[agent] Found by the scheduled pnpm bug-hunt routine (ledger #303).

Summary

pnpm 11+ isolates global installs. Each pnpm add -g … command gets its own install directory, $PNPM_HOME/global/v11/<hash>/, with its own node_modules and, on pnpm 12 (or pnpm 11 with enableGlobalVirtualStore: false), its own node_modules/.pnpm virtual store. pnpm root -g returns the parent global/v11, and socket-patch uses that one directory as the global root.

When two global install groups both contain [email protected] (a direct pnpm add -g [email protected], plus a global tool that depends on left-pad), get -g / apply -g / scan -g --mode agent patch only one of the two physical copies. The tool keeps loading the unpatched copy. The run exits 0 with status: success, applied: 1, a re-run of apply -g says already_patched, and vex -g attests not_affected.

Which copy is missed depends on directory-listing order. When the group holding the direct left-pad link is listed first, the other group's .pnpm/[email protected] store entry is never probed. On ext4 that's about half of fresh setups; on tmpfs it's deterministic (see the repro).

Impact

A globally installed tool (any CLI installed with pnpm add -g) keeps running a vulnerable dependency while socket-patch reports it patched and emits a VEX statement saying the vulnerability isn't exploitable. Nothing in the JSON or on stderr shows that a copy was skipped.

Repro (Linux, pnpm 12.8.2, Node 22)

Uses /dev/shm so the listing order is deterministic. A local mock of the patch API serves a patch for pkg:npm/[email protected] that prepends /*SOCKET_PATCHED*/ to index.js (batch / by-package / view routes with inline blob content, as in crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs).

# a global "tool" that depends on left-pad 1.3.0
mkdir wrap && cd wrap
echo '{"name":"lp-wrapper","version":"1.0.0","bin":{"lpw":"cli.js"},"dependencies":{"left-pad":"1.3.0"}}' > package.json
printf '#!/usr/bin/env node\nconsole.log(require("fs").readFileSync(require.resolve("left-pad"),"utf8").slice(0,18))\n' > cli.js
npm pack && cd ..

base=/dev/shm/g; mkdir -p $base/home/bin
export PNPM_HOME=$base/home HOME=$base PATH=$base/home/bin:$PATH
pnpm add -g ./wrap/lp-wrapper-1.0.0.tgz     # group 1: left-pad is transitive (only in its .pnpm)
pnpm add -g [email protected]                 # group 2: left-pad is a direct global package
pnpm root -g                               # $base/home/global/v11
find $base/home -path '*left-pad/index.js' # two copies, one per global/v11/<hash>/node_modules/.pnpm

mkdir w && cd w
socket-patch get pkg:npm/[email protected] -g --yes --json --api-url $MOCK --org test-org --api-token fake
#   exit 0, "status": "success", "applied": 1
lpw                                        # "/* This program is"   <- the tool still loads the original bytes
socket-patch apply -g --json --offline     # success, left-pad: skipped / already_patched
socket-patch vex -g --offline --product pkg:npm/[email protected] --output v.json
#   exit 0, one statement: not_affected, subcomponent pkg:npm/[email protected]

If you install the two groups in the opposite order, both copies are patched.

Expected vs actual

  • Expected: agent mode patches every physical copy of a name@version. find_by_purls documents this ("Returns every physical copy … patching only one leaves a live, vulnerable copy while reporting success (a silent partial)"), as do docs/ecosystems.md (npm row: "any install layout … every store copy") and the agent-mode notes ("apply and rollback patch every store copy of a name@version"). VEX should not attest a patch that a loaded copy doesn't carry.
  • Actual: one copy is patched, the other stays original, and every command reports success.

OS × version (Linux, Node 22, main 2463257)

pnpm global layout copies of [email protected] result
10.34.5 single global/5, shared .pnpm 1 pass
11.0.0 / 11.28.3 (default) per-install dirs, global virtual store (store/v11/links) 1 shared copy pass here (the shared-store write is #361's problem)
11.28.3, enableGlobalVirtualStore: false per-install .pnpm 2 fail (2 of 3 ext4 runs)
12.4.2 per-install .pnpm 2 fail
12.8.1 per-install .pnpm 2 fail (1 of 3 ext4 runs; order-dependent)
12.8.2 per-install .pnpm 2 fail: tmpfs 4/4 in the order above; on a fixed ext4 layout, 3/3 re-runs fail
12.8.2, groups installed in the opposite order per-install .pnpm 2 pass

macOS and Windows weren't probed (no probe branch this run; see the ledger). The walk order there is filesystem-dependent too, so the miss should be possible there as well.

First bad commit

This is a regression. On the same ext4 layout, release 4.0.0 patched both copies in 3/3 clean runs, and main missed one in 3/3. Bisected on the deterministic tmpfs layout:

Suspect code

  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:922: in resolve_pending_targets, unmatched_names is computed walk-wide. Once any importer tree has matched left-pad (group 2's direct link), every later pnpm store's entries for that name are filtered out, including a different install's own .pnpm that holds a separate physical copy.
  • npm_crawler.rs:972: since b32711f, inside a store entry only a real directory matches (is_real_package_dir_sync). Before that, group 1's .pnpm/lp-wrapper…/node_modules/left-pad link probably matched, which is how 4.0.0 reached the second copy.
  • find_store_peer_variant_copies (npm_crawler.rs:1913) only fans out within the found copy's own virtual store, so it can't recover the other install's copy.
  • get_global_node_modules_paths (npm_crawler.rs:1135, :1148) passes pnpm 11+'s global/v11 as one root. Treating each global/v11/<hash>/node_modules as its own root (as for separate projects) would also avoid the cross-install filter. The analogous project-mode layout (a sharedWorkspaceLockfile: false workspace, where each package has its own .pnpm) patches both copies correctly on pnpm 10.34.5 and 12.8.2, because those are separate roots.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions