[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
Since #1058 (4d06019), the rollout's recorded view is built from HostedPin::all(discovery). npm lockfile discovery drops a pin as contested when another entry of the same name@version still resolves elsewhere: another entry of the same lock (for example an npm alias), or the twin lock of a npm-shrinkwrap.json + package-lock.json pair. So a patch that is already pinned in the project is classified NEW rather than ALREADY. Under --max-new-patches 0, SOCKET_MAX_NEW_PATCHES=0 or socket.yml patches.maxNewPatches: 0, the scan defers it (rollout_deferred), and the unpinned copy is never rewired.
vex then refuses with "another entry of the same lock … still resolves that version elsewhere … re-run socket-patch vendor / scan --mode hosted to rewire every copy". That re-run is exactly what defers it, so in an "upgrades only" CI job the remedy loops forever and npm ci keeps installing the unpatched copy.
Before #1058 (ef48495, its parent), the same re-scan classifies the row ALREADY, rewires the second entry, and npm ci installs both copies patched.
Impact
- A project with a hosted pin adds a second copy of the same version, which is a natural thing to do (
npm install mz@npm:ms@2.1.2 resolves the new alias entry to the registry). From then on, a capped CI scan never repairs it, and the human output says 1 new patch deferred … Next up: ms@2.1.2 for a patch that is already applied.
- The same happens with a dual lock where only
package-lock.json carries the pin. npm ≤ 11 reads npm-shrinkwrap.json, so it installs unpatched.
- It fails closed (
vex exit 2, no attestation), but nothing the docs point to can fix it short of removing the cap.
Repro (Linux, npm 10.9.4, local mock patch API)
echo '{"name":"proj","version":"1.0.0","dependencies":{"ms":"2.1.2"}}' > package.json
npm install && git init -q && git add -A && git commit -qm init
socket-patch scan --mode hosted --yes # pins node_modules/ms
git add -A && git commit -qm hosted
npm install mz@npm:ms@2.1.2 # new alias entry -> registry.npmjs.org/ms/-/ms-2.1.2.tgz
socket-patch scan --mode hosted --yes --max-new-patches 0 --json | jq '.rollout.counts, .redirect.skipped'
# {"new":0,"deferred":1,"upgrade":0,"already":0}
# [{"purl":"pkg:npm/ms@2.1.2","uuid":"…","reason":"rollout_deferred","detail":"rank 1 in the rollout queue; a later scan adds it"}]
socket-patch vex -O v.json; echo $? # 2: "…another entry of the same lock, \"node_modules/mz\", still resolves that version elsewhere … re-run … scan --mode hosted…"
rm -rf node_modules && npm ci # node_modules/mz is unpatched
Dual-lock variant: cp package-lock.json npm-shrinkwrap.json before the first scan, then restore only the shrinkwrap's pre-scan version (git checkout HEAD~1 -- npm-shrinkwrap.json) and re-scan with --max-new-patches 0. The result is the same: deferred: 1, nothing rewritten.
Expected vs actual
CLI_CONTRACT.md, "Per-run limit on new patches": NEW = "nothing recorded for the base purl in this project", and "When the lockfiles of a project pin a purl to several uuids, the recorded uuid is the selected one if it is among them". The --max-new-patches flag row says "Upgrades and already-applied patches are never capped", and docs/configuration.md says --max-new-patches 0 # upgrade existing patches only and "Updates of already patched packages do not consume the cap". The lock pins pkg:npm/ms@2.1.2 to the selected uuid, so the row should be ALREADY, uncapped, with every copy rewired, as it was before #1058.
Actual: NEW, deferred, nothing rewritten, 0 already applied.
Matrix (main 16106b1)
| OS |
npm |
Shape |
--max-new-patches 0 re-scan |
Pre-#1058 ef48495 |
| Linux |
8.19.4 (lockfileVersion 2) |
alias added after pin |
deferred (fail) |
— |
| Linux |
10.9.4 (×2) |
alias added after pin |
deferred (fail) |
ALREADY, rewired, npm ci patched both |
| Linux |
12.2.0 / Node 24.21 (×2) |
alias added after pin |
deferred (fail) |
— |
| Linux |
10.9.4 |
dual lock, shrinkwrap unpinned |
deferred (fail) |
ALREADY, rewired shrinkwrap |
| Linux |
10.9.4 |
socket.yml maxNewPatches: 0 / SOCKET_MAX_NEW_PATCHES=0 |
deferred (fail) |
— |
| Linux |
10.9.4 |
no cap |
pass (rewires) |
pass |
| Linux |
10.9.4 |
fully pinned project, cap 0 |
pass (ALREADY) |
pass |
macOS and Windows weren't probed (no probe branches this run); the code path is OS-independent.
First bad commit
4d06019 "Decide whether a hosted patch is pinned through lockfile discovery alone (#1058)". Its parent ef48495 passes. #1058 deleted rollout::mark_pinned / mentioned_uuids, which previously marked these rows ALREADY.
Suspect code
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
Since #1058 (
4d06019), the rollout's recorded view is built fromHostedPin::all(discovery). npm lockfile discovery drops a pin as contested when another entry of the samename@versionstill resolves elsewhere: another entry of the same lock (for example an npm alias), or the twin lock of anpm-shrinkwrap.json+package-lock.jsonpair. So a patch that is already pinned in the project is classified NEW rather than ALREADY. Under--max-new-patches 0,SOCKET_MAX_NEW_PATCHES=0or socket.ymlpatches.maxNewPatches: 0, the scan defers it (rollout_deferred), and the unpinned copy is never rewired.vexthen refuses with "another entry of the same lock … still resolves that version elsewhere … re-runsocket-patch vendor/scan --mode hostedto rewire every copy". That re-run is exactly what defers it, so in an "upgrades only" CI job the remedy loops forever andnpm cikeeps installing the unpatched copy.Before #1058 (
ef48495, its parent), the same re-scan classifies the row ALREADY, rewires the second entry, andnpm ciinstalls both copies patched.Impact
npm install mz@npm:ms@2.1.2resolves the new alias entry to the registry). From then on, a capped CI scan never repairs it, and the human output says1 new patch deferred … Next up: ms@2.1.2for a patch that is already applied.package-lock.jsoncarries the pin. npm ≤ 11 readsnpm-shrinkwrap.json, so it installs unpatched.vexexit 2, no attestation), but nothing the docs point to can fix it short of removing the cap.Repro (Linux, npm 10.9.4, local mock patch API)
Dual-lock variant:
cp package-lock.json npm-shrinkwrap.jsonbefore the first scan, then restore only the shrinkwrap's pre-scan version (git checkout HEAD~1 -- npm-shrinkwrap.json) and re-scan with--max-new-patches 0. The result is the same:deferred: 1, nothing rewritten.Expected vs actual
CLI_CONTRACT.md, "Per-run limit on new patches": NEW = "nothing recorded for the base purl in this project", and "When the lockfiles of a project pin a purl to several uuids, the recorded uuid is the selected one if it is among them". The
--max-new-patchesflag row says "Upgrades and already-applied patches are never capped", and docs/configuration.md says--max-new-patches 0 # upgrade existing patches onlyand "Updates of already patched packages do not consume the cap". The lock pinspkg:npm/ms@2.1.2to the selected uuid, so the row should be ALREADY, uncapped, with every copy rewired, as it was before #1058.Actual: NEW, deferred, nothing rewritten,
0 already applied.Matrix (main
16106b1)--max-new-patches 0re-scanef48495npm cipatched bothmaxNewPatches: 0/SOCKET_MAX_NEW_PATCHES=0macOS and Windows weren't probed (no probe branches this run); the code path is OS-independent.
First bad commit
4d06019"Decide whether a hosted patch is pinned through lockfile discovery alone (#1058)". Its parentef48495passes. #1058 deletedrollout::mark_pinned/mentioned_uuids, which previously marked these rows ALREADY.Suspect code
crates/socket-patch-cli/src/commands/scan/mod.rs:1896: the recorded view takesHostedPin::all(ctx.discovery()), i.e. only attributable refs.crates/socket-patch-core/src/vex/discover/npm.rs:158(and the cross-lockcontested_byarm below it):push_uncontesteddrops a ref whose purl has an unwired twin entry. That's right for attestation, but the rollout's "is anything recorded for this purl" question needs the contested pins too. The bundled-copy arm atnpm.rs:143(the npm hosted pin next to a bundled copy can't be unwound: rollback/remove refuse it, and the vendored takeover skips the restore, so vendor --revert lands back on hosted and allow-remote=all stays #828 shape) presumably drops the same way.