Repository navigation
npm hosted and vendored modes rewire git-sourced lock entries, so npm ci silently installs the unpatched git bytes while scan and VEX report success #326
Description
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
The npm lock rewriters match entries on name + version only. A dependency installed from git ("left-pad": "github:stevemao/left-pad#v1.3.0", lock resolved: git+ssh://git@github.com/…#<sha>, version: 1.3.0) therefore matches a pkg:npm/left-pad@1.3.0 patch. scan --mode hosted and scan --mode vendored both replace its resolved / integrity and report the package as patched.
npm, though, installs a git dependency from its package.json git spec and ignores the rewritten resolved. The next npm ci exits 0 and puts the unpatched git checkout in node_modules. A later npm install silently rewrites the lock entry back to git+ssh://….
Impact
- A silent false success: the scan envelope says
redirected: 1/ vendorapplied: 1withstatus: success, and the frozen install ships the vulnerable bytes. - False VEX: the in-run
scan --vexattestsnot_affectedin both modes. After install, vendoredvexstill attestsnot_affectedand only adds avendored_tree_out_of_syncwarning. Only the post-install hostedvexrefuses (no statement). - The vlt backend already refuses this shape ("a git, remote-tarball or local-directory node of the same package",
docs/ecosystems.md:268). npm has no equivalent guard.
Repro (Linux, main f6b7fb9e)
mkdir app && cd app
echo '{"name":"app","version":"1.0.0","private":true,"dependencies":{"left-pad":"github:stevemao/left-pad#v1.3.0"}}' > package.json
npm install # npm 8/10/11; npm 12 needs --allow-git=all
# lock: node_modules/left-pad {version 1.3.0, resolved git+ssh://git@github.com/stevemao/left-pad.git#ff8e7ba…}
socket-patch scan --mode vendored --yes --json --vex inrun.json --api-url $MOCK --org test-org --api-token fake
# status success, vendor applied 1; lock now resolved file:.socket/vendor/npm/<uuid>/left-pad-1.3.0.tgz
# inrun.json: not_affected
# fresh checkout (package.json + lock + .socket, no node_modules):
npm ci # exit 0, "added 1 package" — fetched from git
head -c 20 node_modules/left-pad/index.js # original bytes, no patch marker
socket-patch vex --output v.json # not_affected (+ vendored_tree_out_of_sync)
npm install # lock entry goes back to git+ssh://…The same flow with --mode hosted: the lock gets the hosted tarball URL + sha512, and npm ci again installs the git bytes.
The patch source is a local mock of the patch API modelled on crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs (batch / by-package / patches/package grant / patches/view, tarball with a marker prepended to index.js). vex was run with --patch-server-url for the mock origin.
Expected vs actual
Expected: rewrite only entries that npm actually installs from resolved, i.e. registry-resolved entries. For a git (or remote-tarball / file: directory) instance of the patched name@version, refuse or skip loudly, as vlt does (vendor_lock_entry_unsupported) and as the npm rewriters already do for link / inBundle entries. The contract says a mode "rewrite[s] ONLY the patched dependencies' lockfile … entries" so that they resolve to Socket's patched bytes (CLI_CONTRACT.md scan --mode hosted), and VEX should never attest a patch the build doesn't consume.
Actual:
| npm | mode | scan | npm ci fresh checkout |
in-run VEX | post-install VEX |
|---|---|---|---|---|---|
| 11.20.0 (×2) | hosted | success, redirected 1 | exit 0, unpatched | not_affected ✗ | omitted |
| 11.20.0 (×2) | vendored | success, applied 1 | exit 0, unpatched | not_affected ✗ | not_affected ✗ (+ vendored_tree_out_of_sync) |
| 10.9.9 | hosted / vendored | same | exit 0, unpatched | not_affected ✗ | same as above |
| 8.19.4 | hosted / vendored | same | exit 0, unpatched | not_affected ✗ | same as above |
| 12.1.0 | — | git deps refused by npm's default allow-git=none; with it enabled the lock shape is the same |
The released 4.0.0 behaves identically (npm 11, both modes). This isn't a regression. macOS / Windows weren't run: the matching logic is OS-independent.
Suspect code
crates/socket-patch-core/src/patch/redirect/mod.rs:878:rewrite_one_npm_lockselects entries byentry_nm == fname && version == dep.version. Onlylink/inBundleare excluded, and the entry'sresolvedorigin (git+…, http tarball,file:) is never checked.rewrite_npm_v2_deps(:1001) mirrors this.crates/socket-patch-core/src/vendor/npm_lock.rs:844-848:scan_lock_matcheshas the same name/version-only selection.- VEX:
vex/discover/npm.rstreats the rewired entry as a live reference, with no check that npm will honor it for a non-registrypackage.jsonspec.
Activity
mikolalysenko commentedon Sep 30, 2026
[agent] More data from the same run: remote-tarball (URL) specs behave the same way.
"left-pad": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"gives a lock entry withversion: 1.3.0andresolvedset to that URL. Both modes rewire it and report success. The fresh-checkoutnpm ciexits 0 and installs the original bytes from thepackage.jsonURL. The in-run VEX isnot_affected, and the post-install vendored VEX isnot_affectedwithvendored_tree_out_of_sync.Reproduced on npm 11.20.0 (vendored ×2, hosted) and npm 10.9.9 (hosted), Linux, main
f6b7fb9e. So the guard needs to cover every non-registrypackage.jsonspec (git, URL tarball, and probablyfile:tarball/dir), not just git.Generated by Claude Code
mikolalysenko commentedon Sep 30, 2026
[agent] Triaged:
priority:p1(npm). No duplicate and no existing fix PR. The cause is that the npm rewriters (redirect/mod.rsrewrite_one_npm_lock,vendor/npm_lock.rsscan_lock_matches) select entries by name and version without checking theresolvedorigin. That also covers the remote-tarball variant in the comment above. #325 is separate: it's a VEX-side contest over bundled copies.Generated by Claude Code
mikolalysenko commentedon Sep 30, 2026
[agent] Claiming this issue (shared root cause: the npm lock rewriters pick
packagesentries by name and version only, and never check whether npm installs that entry from the registry). Branch: agent/fix-npm-lock-non-registry-entries. Claim-ID: 2026-09-30T17:20:29Z-d49d87Generated by Claude Code
mikolalysenko commentedon Sep 30, 2026
[agent] Draft PR: #345
Generated by Claude Code
Skip npm lock entries installed from git or URLs
Skip npm lock entries installed from git or URLs
mikolalysenko commentedon Oct 1, 2026
[agent] Confirmed the local
file:tarball variant from the earlier comment. It's also the first cell where npm 12 reproduces with default config, becauseallow-filedefaults toall.f6b7fb9)npm cinpm installfile:left-pad-1.3.0.tgzThe post-install
vexon main still attestsnot_affected.I built PR #345's head (
c46271c). On the same project,vendor --offlinerefuses withvendor_lock_entry_not_rewritable(partialFailure) and leaves the lock untouched, so that branch covers thefile:tarball shape too.Generated by Claude Code
mikolalysenko commentedon Oct 1, 2026
[agent] Re-triaged after the
file:tarball comment: stillpriority:p1. The localfile:tarball variant is in scope for PR #345, whose head already refuses this shape according to the comment. No separate issue is needed.Generated by Claude Code
Merge main into the npm non-registry lock entry fix
Warn on Bun URL and file: tarball copies; stop attesting them (#497)
Fix hosted pin beside a shrinkwrapped or git copy that rollback can't…