Skip to content

npm vendored refuses a registry package with vendor_workspace_member whenever a local file: directory (or workspace member) has the same name@version, and the hosted→vendored takeover then un-hosts it, leaving it unpatched #688

Description

[agent] Found by the scheduled npm bug-hunt routine (ledger #302).

Summary

scan_lock_matches in the npm vendored backend returns LockScan::WorkspaceMember as soon as it sees any packages key outside node_modules/ whose name@version matches the patch, before it looks at the other entries. A project that has both:

  • a normal registry install, node_modules/left-pad → https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz, and
  • an unrelated local directory dependency whose package.json happens to say left-pad@1.3.0 (for example a fork under third_party/, consumed as "lp-local": "file:./third_party/left-pad", which gives the lock key third_party/left-pad)

gets the whole package refused: vendor_workspace_member: third_party/left-pad is a workspace member of this project; patch the source directly instead of vendoring it. But the patch target is the registry copy, which is fully rewritable. Hosted mode handles the same project correctly: it pins node_modules/left-pad, leaves the file: link alone, npm ci installs the patched bytes, and vex attests.

The refusal also hits the hosted→vendored takeover, which is the #659 ordering problem on a v2/v3 lock. scan --mode vendored and get <uuid> --mode vendored on the hosted project first restore the hosted pin to upstream (vendor_takeover_reverted_redirect) and delete .npmrc. Then they hit the refusal and exit 1 without restoring the pin. The package ends up patched in neither mode, and the next npm ci installs the upstream bytes. (vendor eject runs the same refusal but rolls back correctly to hosted.)

Impact

  • Vendored mode can't patch a registry package at all when the repo also carries a local copy, fork or workspace member with the same name@version. The error tells the user to "patch the source directly", which doesn't apply to a registry copy.
  • A hosted→vendored switch through scan/get silently drops a working hosted patch: exit 1, but the project is left unpatched with .npmrc removed and no ledger.

Repro (main 045d7ec, Linux; local mock patch API serving left-pad@1.3.0)

mkdir -p p/third_party/left-pad && cd p
printf '{"name":"left-pad","version":"1.3.0","main":"index.js"}\n' > third_party/left-pad/package.json
printf 'module.exports = () => "first-party fork";\n' > third_party/left-pad/index.js
echo '{"name":"p","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0","lp-local":"file:./third_party/left-pad"}}' > package.json
npm i         # lock: "node_modules/left-pad" (registry), "node_modules/lp-local" (link), "third_party/left-pad" {name: left-pad, version: 1.3.0}
git init -q && git add -A && git commit -qm i

# 1. plain vendored: refused
socket-patch scan --mode vendored --json --yes <api flags>; echo $?    # 1, vendor_workspace_member

# 2. hosted works
socket-patch scan --mode hosted --json --yes <api flags>               # 0; npm ci → node_modules/left-pad patched; vex attests
git add -A && git commit -qm hosted

# 3. hosted → vendored takeover
socket-patch scan --mode vendored --json --yes <api flags>; echo $?    # 1: vendor_takeover_reverted_redirect, then vendor_workspace_member
ls .npmrc; grep '"resolved"' package-lock.json                         # .npmrc gone; left-pad back on registry.npmjs.org; no .socket/vendor/state.json
rm -rf node_modules && npm ci && head -c 20 node_modules/left-pad/index.js   # upstream bytes
socket-patch vex --output o.vex --json                                 # exit 2, nothing to attest

Expected vs actual

Matrix (Linux, main 045d7ec)

npm lockfileVersion plain vendored hosted (npm ci patched + vex) takeover via scan takeover via get <uuid> vendor eject
8.19.4 (Node 22) 2 refused, exit 1 pass un-hosted, then refused; fresh npm ci unpatched un-hosted, then refused refused, rolled back to hosted
10.9.4 (Node 22) 3 refused, exit 1 (x3) pass same (x2) same same
12.2.0 (Node 24) 3 refused, exit 1 pass same same same

macOS / Windows: not run, because the decision is made purely on lock JSON. v4.0.0 refuses the plain vendored scan the same way, so it isn't a regression. The takeover half has the same shape as #659 (the v1 gate), but it's reached through a different gate on v2/v3 locks.

Suspect code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions