[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
When the patched name@version is installed inside a registry dependency that publishes its own npm-shrinkwrap.json (the lock marks that dependency "hasShrinkwrap": true, e.g. firebase-tools@11, netlify-cli@17), hosted and vendored modes rewrite the nested lock entry node_modules/<dep>/node_modules/<pkg> and report success. npm 7–11 ignore that root-lock entry at reify time and install the copy from the dependency's own shrinkwrap, so:
- hosted:
npm ci silently installs the unpatched registry bytes (the hosted URL is never requested). On a lockfile-only checkout, vex attests the package not_affected from the pin (exit 0). After install, vex refuses with not_applied, which is correct. npm install silently rewrites the entry back to the registry. On npm 6 the next npm ci fails with EINTEGRITY.
- vendored:
npm ci installs the unpatched bytes, but vex still exits 0 and attests not_affected. It only warns vendored_tree_out_of_sync ("re-run your package manager's install to resync it"), and reinstalling never resyncs. vendor --check reports "committed artifact and wiring verified".
npm 12.2.0 honors the root-lock entry and installs the patched bytes, so it passes.
This is the same class of problem the code already refuses for inBundle entries (redirect_npm_bundled_instance_skipped, vendor_bundled_instance_skipped) and for non-registry entries: "a rewrite here would confirm (and VEX-attest) a patch that never installs". Descendants of a hasShrinkwrap entry aren't covered by either check.
Impact
A VEX document claims not_affected (vendored always; hosted when generated before install) for a vulnerable copy that npm 7–11 keep installing. Scan and vendor --check both report success.
Repro (Linux, npm 10.9.4 / Node 22; also npm 7.24.2, 8.19.4, 9.9.4, 11.6.2)
A tiny scoped registry serves @bh/[email protected]. It depends on [email protected] and ships an npm-shrinkwrap.json pinning it, so it has the same shape as firebase-tools@11. A local mock of the patch API (--patch-server-url) serves one free patch for pkg:npm/[email protected].
# 1. registry for @bh/sw (any static server works; packument has "_hasShrinkwrap": true)
mkdir -p reg/stage/package && cd reg/stage/package
echo '{"name":"@bh/sw","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}' > package.json
echo 'module.exports=require("left-pad")' > index.js
cat > npm-shrinkwrap.json <<'J'
{"name":"@bh/sw","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"@bh/sw","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}},"node_modules/left-pad":{"version":"1.3.0","resolved":"``https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz","integrity":"sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==","license":"WTFPL"}}}``
J
cd .. && tar czf ../sw-1.0.0.tgz package && cd ..
# serve /@bh%2fsw (packument with dist.tarball/integrity + "_hasShrinkwrap": true) and the tarball on :18802
# 2. project
mkdir p && cd p
printf '@bh:registry=http://127.0.0.1:18802/\n' > .npmrc
echo '{"name":"p","version":"1.0.0","dependencies":{"@bh/sw":"1.0.0","left-pad":"1.1.3"}}' > package.json
npm install && git init -q && git add -A . ':!node_modules' && git commit -qm init
# lock: node_modules/@bh/sw has "hasShrinkwrap": true; node_modules/@bh/sw/node_modules/left-pad is 1.3.0
# 3a. hosted
socket-patch scan --mode hosted --patch-server-url http://127.0.0.1:18801
# Switched 1 package to hosted patches; rewrote 2 files. (exit 0)
# lock: node_modules/@bh/sw/node_modules/left-pad resolved -> http://127.0.0.1:18801/patch/npm/left-pad/1.3.0/…
mv node_modules /tmp/nm; socket-patch vex --output v.json # exit 0: not_affected for [email protected]
mv /tmp/nm node_modules
rm -rf node_modules && npm ci --cache "$(mktemp -d)"
head -c 14 node_modules/@bh/sw/node_modules/left-pad/index.js # "/* This progra" = UNPATCHED; mock never hit
socket-patch vex --output v.json # exit 1, not_applied (correct)
# 3b. vendored (fresh copy of step 2)
socket-patch scan --mode vendored ... # Vendored 1 package. (exit 0)
rm -rf node_modules && npm ci --cache "$(mktemp -d)" # nested copy UNPATCHED
socket-patch vex --output v.json # exit 0, not_affected + vendored_tree_out_of_sync warning
socket-patch vendor --check # "committed artifact and wiring verified", exit 0
Patch API mock used (Python, hosted + vendored routes)
It serves POST /patch/batch, GET /patch/by-package/<purl>, GET /patch/view/<uuid> (beforeHash/afterHash), GET /patch/blob/<hash>, POST /patch/package (granted, one tarball artifact with the patched tarball's sha512) and the tarball URL itself. The patched tarball is the installed [email protected] with index.js prefixed by /*PATCHED-LP*/, packed with only regular-file package/… members. It has the same shape as the wiremock routes in crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs:380-490.
Expected vs actual
- Expected: per the existing refusals for copies npm installs "from elsewhere" (
patch/redirect/mod.rs:964-980, vendor/npm_lock.rs:880-900, vex/discover/npm.rs:15-30), a lock entry beneath a hasShrinkwrap: true package should be skipped loudly (e.g. redirect_npm_shrinkwrapped_instance_skipped / vendor_shrinkwrapped_instance_skipped, telling the user that copy stays UNPATCHED on npm < 12), or gated on npm ≥ 12. It should also contest the ref in VEX the way an inBundle copy does, so vex never attests it. docs/testing/npm-compatibility.md says hosted and vendored installs of a rewired lock are "patched" on npm 7–11.
- Actual: the entry is rewritten, scan exits 0, and npm 7–11 install the unpatched bytes. Vendored
vex attests not_affected after install, and hosted vex does so on a lockfile-only checkout.
OS × version
| OS |
npm |
lock |
hosted npm ci |
hosted vex (no node_modules) |
vendored npm ci |
vendored vex after install |
| Linux |
6.14.18 |
v1 |
EINTEGRITY (install fails) |
— |
v1 refused (documented) |
— |
| Linux |
7.24.2 |
v2 |
unpatched |
— |
— |
— |
| Linux |
8.19.4 |
v2 |
unpatched |
— |
unpatched |
not_affected (exit 0) |
| Linux |
9.9.4 |
v3 |
unpatched |
— |
— |
— |
| Linux |
10.9.4 |
v3 |
unpatched (×3) |
— |
unpatched |
not_affected (exit 0) |
| Linux |
11.6.2 |
v3 |
unpatched |
not_affected (exit 0) |
unpatched |
not_affected (exit 0) |
| Linux |
12.2.0 (Node 24) |
v3 |
patched |
— |
patched |
pass |
macOS / Windows weren't probed: this run couldn't push probe branches. The behaviour is decided by npm's reify and the platform-independent lock rewriters.
First bad version
It isn't a regression. v4.0.0 (scan --mode hosted) rewrites the same entry, and npm ci installs it unpatched. Tested on main 045d7ec.
Suspect code
crates/socket-patch-core/src/patch/redirect/mod.rs:945-1000: the npm packages loop skips link, inBundle and non-registry entries but has no hasShrinkwrap-ancestor check (and the v1 dependencies walk near :1107-1120, which only checks bundled).
crates/socket-patch-core/src/vendor/npm_lock.rs:880-900: the same for vendored.
crates/socket-patch-core/src/vex/discover/npm.rs:15-30: such entries aren't treated as installed-from-elsewhere, so the pin attests.
crates/socket-patch-cli/src/commands/vex.rs:681: vendored_tree_out_of_sync advises a reinstall that can never resync this copy.
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
When the patched
name@versionis installed inside a registry dependency that publishes its ownnpm-shrinkwrap.json(the lock marks that dependency"hasShrinkwrap": true, e.g.firebase-tools@11,netlify-cli@17), hosted and vendored modes rewrite the nested lock entrynode_modules/<dep>/node_modules/<pkg>and report success. npm 7–11 ignore that root-lock entry at reify time and install the copy from the dependency's own shrinkwrap, so:npm cisilently installs the unpatched registry bytes (the hosted URL is never requested). On a lockfile-only checkout,vexattests the packagenot_affectedfrom the pin (exit 0). After install,vexrefuses withnot_applied, which is correct.npm installsilently rewrites the entry back to the registry. On npm 6 the nextnpm cifails withEINTEGRITY.npm ciinstalls the unpatched bytes, butvexstill exits 0 and attestsnot_affected. It only warnsvendored_tree_out_of_sync("re-run your package manager's install to resync it"), and reinstalling never resyncs.vendor --checkreports "committed artifact and wiring verified".npm 12.2.0 honors the root-lock entry and installs the patched bytes, so it passes.
This is the same class of problem the code already refuses for
inBundleentries (redirect_npm_bundled_instance_skipped,vendor_bundled_instance_skipped) and for non-registry entries: "a rewrite here would confirm (and VEX-attest) a patch that never installs". Descendants of ahasShrinkwrapentry aren't covered by either check.Impact
A VEX document claims
not_affected(vendored always; hosted when generated before install) for a vulnerable copy that npm 7–11 keep installing. Scan andvendor --checkboth report success.Repro (Linux, npm 10.9.4 / Node 22; also npm 7.24.2, 8.19.4, 9.9.4, 11.6.2)
A tiny scoped registry serves
@bh/[email protected]. It depends on[email protected]and ships annpm-shrinkwrap.jsonpinning it, so it has the same shape asfirebase-tools@11. A local mock of the patch API (--patch-server-url) serves one free patch forpkg:npm/[email protected].Patch API mock used (Python, hosted + vendored routes)
It serves
POST /patch/batch,GET /patch/by-package/<purl>,GET /patch/view/<uuid>(beforeHash/afterHash),GET /patch/blob/<hash>,POST /patch/package(granted, onetarballartifact with the patched tarball's sha512) and the tarball URL itself. The patched tarball is the installed[email protected]withindex.jsprefixed by/*PATCHED-LP*/, packed with only regular-filepackage/…members. It has the same shape as the wiremock routes incrates/socket-patch-cli/tests/e2e_redirect_npm_build.rs:380-490.Expected vs actual
patch/redirect/mod.rs:964-980,vendor/npm_lock.rs:880-900,vex/discover/npm.rs:15-30), a lock entry beneath ahasShrinkwrap: truepackage should be skipped loudly (e.g.redirect_npm_shrinkwrapped_instance_skipped/vendor_shrinkwrapped_instance_skipped, telling the user that copy stays UNPATCHED on npm < 12), or gated on npm ≥ 12. It should also contest the ref in VEX the way aninBundlecopy does, sovexnever attests it. docs/testing/npm-compatibility.md says hosted and vendored installs of a rewired lock are "patched" on npm 7–11.vexattestsnot_affectedafter install, and hostedvexdoes so on a lockfile-only checkout.OS × version
npm civex(no node_modules)npm civexafter installmacOS / Windows weren't probed: this run couldn't push probe branches. The behaviour is decided by npm's reify and the platform-independent lock rewriters.
First bad version
It isn't a regression. v4.0.0 (
scan --mode hosted) rewrites the same entry, andnpm ciinstalls it unpatched. Tested on main045d7ec.Suspect code
crates/socket-patch-core/src/patch/redirect/mod.rs:945-1000: the npmpackagesloop skipslink,inBundleand non-registry entries but has nohasShrinkwrap-ancestor check (and the v1dependencieswalk near:1107-1120, which only checksbundled).crates/socket-patch-core/src/vendor/npm_lock.rs:880-900: the same for vendored.crates/socket-patch-core/src/vex/discover/npm.rs:15-30: such entries aren't treated as installed-from-elsewhere, so the pin attests.crates/socket-patch-cli/src/commands/vex.rs:681:vendored_tree_out_of_syncadvises a reinstall that can never resync this copy.