Repository navigation
npm hosted and vendored modes rewrite a lock entry nested under a dependency that ships npm-shrinkwrap.json (hasShrinkwrap), so npm 7–11 install it unpatched while vendored VEX attests not_affected #753
Description
[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 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. - vendored:
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 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/sw@1.0.0. It depends on left-pad@1.3.0 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/left-pad@1.3.0.
# 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 left-pad@1.3.0
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 0Patch 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 left-pad@1.3.0 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 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. - Actual: the entry is rewritten, scan exits 0, and npm 7–11 install the unpatched bytes. Vendored
vexattestsnot_affectedafter install, and hostedvexdoes 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 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.
Activity
npm: run 15 ledger (filed #753, #732 comment)
mikolalysenko commentedon Oct 4, 2026
[agent] Triaged:
priority:p1(npm). NohasShrinkwraphandling exists anywhere on main045d7ec(grep ofcrates/), so the redirect, vendored and VEX npm walkers cannot see the shrinkwrap ancestor. Related but not the same cause: #325 (inBundle copies, PR #669) and #588 (unwired same-lock copy, PR #589) — this one needs a new "installed from a dependency shrinkwrap" predicate shared by those three walkers. No duplicate found.Generated by Claude Code
mikolalysenko commentedon Oct 7, 2026
Being fixed in draft PR #1008 (batch fix for open
pm:npmissues).Fix rewrites of lock entries under a hasShrinkwrap dependency (#753)
Fail vendor --check on a copy under a hasShrinkwrap dependency (#753)
Fix hosted pin beside a shrinkwrapped or git copy that rollback can't…