[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
The project is an npm workspace using install-strategy=linked (npm ≥ 9.4). Each member's dependencies are symlinks into the root node_modules/.store. For a member whose own dependencies are "ms": "2.1.2" and an alias "lp": "npm:left-pad@1.3.0":
scan --mode agent packages/a scans 1 package (ms@2.1.2) and patches it. It never selects left-pad@1.3.0, so packages/a/node_modules/lp (what require('lp') in member a loads) stays unpatched. The status is success, exit 0, with no warning.
rollback packages/a restores ms but not left-pad. packages/a/node_modules/lp keeps the Socket patch, and the status is again success, exit 0.
The unscoped scan --mode agent does find and patch the alias copy (#852 / #356 fixed). Under the default hoisted layout, where the member's alias is a real nested directory, the scoped scan also finds it. Only the link form of a member's alias falls outside the path scope, while the member's plain ms link is inside it.
Impact
- A user who scopes the agent scan to one member believes that member's dependencies were scanned. A vulnerable package it loads through an alias is silently left unpatched.
rollback <member-path> reports success but leaves patched bytes in the member's tree. The run is no longer symmetric with the scan that wrote them.
Repro (Linux, main a80b89e; reproduced on npm 10.9.4 and npm 12.2.0 / Node 24, twice each)
I used a local patch-API mock (public proxy routes, SOCKET_PROXY_URL / SOCKET_API_URL pointing at it) that serves free patches for ms@2.1.2 and left-pad@1.3.0. Each patch appends // SOCKET-PATCHED to index.js.
mkdir -p ws/packages/a && cd ws
echo '{"name":"root","version":"1.0.0","workspaces":["packages/*"]}' > package.json
echo '{"name":"a","version":"1.0.0","dependencies":{"lp":"npm:left-pad@1.3.0","ms":"2.1.2"}}' > packages/a/package.json
printf 'install-strategy=linked\n' > .npmrc
npm install
ls -l packages/a/node_modules
# lp -> ../../../node_modules/.store/left-pad@1.3.0-…/node_modules/left-pad (npm 10: .store/lp@1.3.0-…/node_modules/lp)
# ms -> ../../../node_modules/.store/ms@2.1.2-…/node_modules/ms
socket-patch scan --mode agent packages/a --json
# status success, scannedPackages 1, packages: [pkg:npm/ms@2.1.2]
tail -n1 packages/a/node_modules/ms/index.js # // SOCKET-PATCHED
tail -n1 packages/a/node_modules/lp/index.js # } <- unpatched
socket-patch scan --mode agent --json # unscoped control
# packages: [pkg:npm/left-pad@1.3.0, pkg:npm/ms@2.1.2]; lp now // SOCKET-PATCHED
socket-patch rollback packages/a --json
# status success, warnings: [out_of_scope_copies_restored]
tail -n1 packages/a/node_modules/ms/index.js # } (restored)
tail -n1 packages/a/node_modules/lp/index.js # // SOCKET-PATCHED <- left patched
Hoisted control: with the root depending on "lp": "npm:left-pad@1.1.3", member a's lp@1.3.0 is nested as a real directory. There, scan --mode agent packages/a selects both left-pad@1.3.0 and ms@2.1.2 (npm 10.9.4).
Expected vs actual
CLI_CONTRACT.md, "Path-scoped scans": "a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way rollback's path targets enumerate them: a pnpm workspace member's link into the root .pnpm store and a nested member copy count, not just the one copy the crawl records per purl)". packages/a/node_modules/lp is member a's link into the root .store for pkg:npm/left-pad@1.3.0, just as packages/a/node_modules/ms is for ms. So left-pad@1.3.0 should be in scope for scan packages/a and selected by rollback packages/a.
Actual: the ms link counts, the lp link does not, and both commands exit 0 with success.
OS × version
|
npm 10.9.4 (Node 22) |
npm 12.2.0 (Node 24) |
| Linux, linked + member alias |
reproduces ×2 |
reproduces ×2 |
| Linux, hoisted (nested real alias dir) |
passes |
not run |
| macOS / Windows |
not probed (probe branches are blocked for this routine; the logic is OS-independent) |
|
I didn't bisect it. The rollback half predates #1007 (#778 only added the scan-side re-enumeration via find_all_packages_for_rollback_reusing, which inherits the gap).
Suspect code
crates/socket-patch-core/src/crawlers/npm_crawler.rs:1752 (alias_copies) counts only real package directories ("links are dependency edges into a store … never copies of their own"), so a member's alias link is never returned for the member's node_modules root.
- The direct probe a few lines above (
nm_path.join(&target.dir_key), around line 1694) does follow a link whose name equals the package name. That's why the member's ms link is in scope.
- That rule is right for
apply's physical copies, because the store entry is patched once. But the path-scope enumeration in find_all_packages_for_rollback_reusing (crates/socket-patch-cli/src/ecosystem_dispatch.rs:488) uses the same results to decide which member paths a package is reachable from. It needs the alias link paths too, the same way it gets the plain-name links.
- pnpm members link aliases into
.pnpm the same way. That likely has the same gap, but I haven't tested it.
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
The project is an npm workspace using
install-strategy=linked(npm ≥ 9.4). Each member's dependencies are symlinks into the rootnode_modules/.store. For a member whose own dependencies are"ms": "2.1.2"and an alias"lp": "npm:left-pad@1.3.0":scan --mode agent packages/ascans 1 package (ms@2.1.2) and patches it. It never selectsleft-pad@1.3.0, sopackages/a/node_modules/lp(whatrequire('lp')in member a loads) stays unpatched. The status issuccess, exit 0, with no warning.rollback packages/arestoresmsbut notleft-pad.packages/a/node_modules/lpkeeps the Socket patch, and the status is againsuccess, exit 0.The unscoped
scan --mode agentdoes find and patch the alias copy (#852 / #356 fixed). Under the default hoisted layout, where the member's alias is a real nested directory, the scoped scan also finds it. Only the link form of a member's alias falls outside the path scope, while the member's plainmslink is inside it.Impact
rollback <member-path>reports success but leaves patched bytes in the member's tree. The run is no longer symmetric with the scan that wrote them.Repro (Linux, main
a80b89e; reproduced on npm 10.9.4 and npm 12.2.0 / Node 24, twice each)I used a local patch-API mock (public proxy routes,
SOCKET_PROXY_URL/SOCKET_API_URLpointing at it) that serves free patches forms@2.1.2andleft-pad@1.3.0. Each patch appends// SOCKET-PATCHEDtoindex.js.Hoisted control: with the root depending on
"lp": "npm:left-pad@1.1.3", member a'slp@1.3.0is nested as a real directory. There,scan --mode agent packages/aselects bothleft-pad@1.3.0andms@2.1.2(npm 10.9.4).Expected vs actual
CLI_CONTRACT.md, "Path-scoped scans": "a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way
rollback's path targets enumerate them: a pnpm workspace member's link into the root.pnpmstore and a nested member copy count, not just the one copy the crawl records per purl)".packages/a/node_modules/lpis member a's link into the root.storeforpkg:npm/left-pad@1.3.0, just aspackages/a/node_modules/msis forms. Soleft-pad@1.3.0should be in scope forscan packages/aand selected byrollback packages/a.Actual: the
mslink counts, thelplink does not, and both commands exit 0 withsuccess.OS × version
I didn't bisect it. The
rollbackhalf predates #1007 (#778 only added the scan-side re-enumeration viafind_all_packages_for_rollback_reusing, which inherits the gap).Suspect code
crates/socket-patch-core/src/crawlers/npm_crawler.rs:1752(alias_copies) counts only real package directories ("links are dependency edges into a store … never copies of their own"), so a member's alias link is never returned for the member'snode_modulesroot.nm_path.join(&target.dir_key), around line 1694) does follow a link whose name equals the package name. That's why the member'smslink is in scope.apply's physical copies, because the store entry is patched once. But the path-scope enumeration infind_all_packages_for_rollback_reusing(crates/socket-patch-cli/src/ecosystem_dispatch.rs:488) uses the same results to decide which member paths a package is reachable from. It needs the alias link paths too, the same way it gets the plain-name links..pnpmthe same way. That likely has the same gap, but I haven't tested it.