Skip to content

npm lockfileVersion 1: scan/get --mode vendored un-host a hosted patch and then refuse to vendor it, so the project silently goes back to unpatched (vendor eject rolls back correctly) #659

Description

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

Summary

In an npm project whose lock is lockfileVersion 1 (npm 6, package-lock.json or npm-shrinkwrap.json), a hosted patch is lost when you switch it to vendored mode with scan --mode vendored or get <purl> --mode vendored:

  1. The takeover first restores the hosted pin to its upstream registry entry. It rewrites package-lock.json and deletes the .npmrc that hosted mode created.
  2. Then the npm vendored backend refuses the v1 lock (vendor_lockfile_version_unsupported).

The run exits 1 / partial_failure, but the restore has already been written. The project ends up neither hosted nor vendored, and the next npm ci installs the unpatched upstream bytes.

socket-patch vendor (the eject path) on the same project does the right thing: it fails with eject_rolled_back, and the hosted pin and .npmrc stay byte-identical. The scan/get dry run is also wrong: it says Would download and vendor 1 patch and exits 0, with no vendor_would_revert_redirect advisory and no refusal preview, while the wet run exits 1.

Impact

A user on npm 6 (or any project that still commits a v1 lock) who runs scan --mode vendored to move from hosted to vendored loses an active security patch. The working tree now shows the lock "reverted" to the registry, which is easy to commit as-is. A CI job that runs npm ci and vex afterwards just sees an ordinary unpatched project.

Repro (Linux, npm 6.14.18 / Node 22, main 045d7ec)

A local mock of the patch API serves pkg:npm/left-pad@1.3.0 (same routes as e2e_redirect_npm_build.rs / e2e_vendor_npm_build.rs). SOCKET_NPM_REGISTRY points at a local passthrough to registry.npmjs.org.

SPA="--api-url $MOCK --patch-server-url $MOCK --org o --api-token $TOKEN"
mkdir p && cd p && git init -q .
echo '{"name":"v6","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0","is-number":"7.0.0"}}' > package.json
npx -y npm@6 install                     # lockfileVersion 1
socket-patch scan $SPA --yes             # hosted (v5 default): lock pinned + .npmrc allow-remote=all
git add -A && git commit -qm hosted

socket-patch scan --mode vendored $SPA --yes --dry-run   # exit 0: "[dry-run] Would download and vendor 1 patch. No changes made."
socket-patch scan --mode vendored $SPA --yes             # exit 1
#   Note: pkg:npm/left-pad@1.3.0 was hosted; restored its upstream registry entry (.npmrc, package-lock.json) before vendoring (mode takeover)
#   Error: Cannot vendor pkg:npm/left-pad@1.3.0: package-lock.json has lockfileVersion Some(1); only v2/v3 locks (with a `packages` object) are supported — run `npm install` with npm >= 7 to upgrade it
#   Vendored 0 packages; 1 failed.
git diff --stat      # .npmrc | 1 -   package-lock.json | 4 ++--   (hosted pin gone)
grep -c "$MOCK" package-lock.json        # 0
npx -y npm@6 ci && head -c 26 node_modules/left-pad/index.js   # upstream bytes, unpatched

The --json envelope reports partial_failure with both vendor_takeover_reverted_redirect and vendor_lockfile_version_unsupported. get pkg:npm/left-pad@1.3.0 --mode vendored behaves the same way. With npm-shrinkwrap.json (v1) in place of package-lock.json the result is identical.

Control: socket-patch vendor $SPA --yes on the same hosted project gives partialFailure with eject_rolled_back + vendor_lockfile_version_unsupported, and the hosted pin and .npmrc are untouched.

Expected vs actual

  • Expected: a refused takeover leaves the hosted wiring in place. CLI_CONTRACT.md, "Takeover reconciliation", describes the Bun preflight that exists for exactly this case: "a hosted purl on a lock the vendored backend refuses … is reported failed <code> with the hosted wiring and active Bun lock byte-untouched (exit 1 / partial_failure): the package stays hosted-patched instead of being un-hosted and then refused", and vendor --dry-run "previews that same failed code (exit-code parity with the wet run …)". The same rule was applied to yarn berry in Fix berry mode takeover reverting before gates (#468, #369) #470 (Vendored → hosted takeover on yarn berry deletes the vendored patch, then skips the hosted rewrite when the grant has no yarnBerry10c0 checksum, and still exits 0 "fully hosted" #468, "takeover reverting before gates"). The vendor eject path already rolls back (eject_rolled_back). npm's v1 lock gate is a lock-text-only check (npm-compatibility.md: "a v1 lock is refused (vendor_lockfile_version_unsupported)"), so it can run before the restore.
  • Actual: scan/get --mode vendored restore upstream first (commands/vendor.rs:2458), and only then reach the npm backend's version gate (crates/socket-patch-core/src/vendor/npm_lock.rs:420-430). Nothing re-applies the hosted pin. The dry run doesn't preview the refusal at all.
OS npm / lock scan --mode vendored get --mode vendored vendor (eject)
Linux 6.14.18, package-lock.json v1 un-hosted + refused (3/3 runs) un-hosted + refused rolled back (correct)
Linux 6.14.18, npm-shrinkwrap.json v1 un-hosted + refused not run not run
Linux 8.19.4 / 10.9.4 / 12.2.0, v2/v3 lock takeover works (prior ledger cells) works works

The logic is OS-independent (no npm process is spawned on this path), so no macOS/Windows probe was run.

First bad version: not a regression. Released v4.0.0 does the same (scan --mode hosted, then scan --mode vendored → vendor_takeover_reverted_redirect + vendor_lockfile_version_unsupported, hosted pin gone).

Suspect code

  • crates/socket-patch-cli/src/commands/vendor.rs:2458: the per-purl takeover restore_upstream writes immediately, before the backend's lock gates. The Bun preflight at vendor.rs:2161 and the pnpm/yarn "lock-text refusals before the download" (CLI_CONTRACT.md) don't include npm package-lock's version gate.
  • crates/socket-patch-core/src/vendor/npm_lock.rs:420 (the v1 version gate), which is the npm equivalent that needs to run in the preflight.
  • Compare vendor.rs:1399-1402 (the eject snapshot restore, eject_rolled_back).

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