Skip to content

npm hosted and vendored scans pin a dependency that overrides forces to a remote tarball URL; npm ci installs it unpatched, and vendored vex / vendor --check still pass #1965

Open

Description

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

Summary

Take a project whose dependent declares a registry range ("left-pad": "^1.3.0") and whose root package.json overrides it with a remote tarball URL:

"overrides": { "left-pad": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz" }

npm records that copy in the lock with a resolved that looks exactly like a registry entry. scan --mode hosted and scan --mode vendored both rewire it and report success. But npm installs an overridden dependency from the override spec, not from the lock's resolved. So a fresh npm ci downloads the upstream URL and installs the unpatched bytes, with no error.

The guard for this case already exists. When a dependent declares the URL spec directly, hosted skips the copy with redirect_npm_non_registry_entry_skipped ("CANNOT be redirected") and vendored refuses it (verified in ledger run 62). Behind an override, that refusal doesn't fire.

Impact

  • Vendored: after a fresh npm ci, the tree is unpatched, yet vex exits 0 and writes an OpenVEX attestation for the patch. It warns vendored_tree_out_of_sync and tells you to "re-run your package manager's install to resync it", and that remedy loops: npm install and npm ci both install the upstream bytes again, and npm install also reverts the lock's resolved to the registry. vendor --check exits 0 ("committed artifact and wiring verified").
  • Hosted: the scan says "Switched 1 package to hosted patches", and the in-run scan --vex attests not_affected. A fresh npm ci then installs unpatched bytes. A standalone vex afterwards fails closed (not_applied, exit 1), so only the in-run attestation is wrong.
  • Both modes: the fix is silently never installed, while the scan exits 0 with status: success.

Repro (Linux, Node 22.23.4, main cbbbdb9)

This needs a mock of the org patch API on 127.0.0.1:8899 serving one free patch for pkg:npm/left-pad@1.3.0, whose tarball prepends /* SOCKET-PATCHED */ to index.js. It uses the same contract as crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs: patches/batch, by-package, package (granted tarball + sha512), view/<uuid> and the tarball route.

#!/usr/bin/env bash
# repro.sh <npm-bin> <socket-patch-bin> <workdir> <hosted|vendored>
set -u; NPM=$1; SP=$2; W=$3; MODE=$4
rm -rf "$W"; mkdir -p "$W/foosrc" "$W/proj"
# a local tarball dependency `foo` that depends on left-pad ^1.3.0 (any transitive dependent works)
cd "$W/foosrc"
echo '{"name":"foo","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}' > package.json
echo 'module.exports=require("left-pad")' > index.js
$NPM pack --pack-destination "$W/proj" >/dev/null 2>&1
cd "$W/proj"
cat > package.json <<'EOF'
{"name":"app","version":"1.0.0","private":true,
 "dependencies":{"foo":"file:foo-1.0.0.tgz"},
 "overrides":{"left-pad":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}}
EOF
# npm 12 only: echo 'allow-remote=all' > .npmrc   (else npm refuses the URL override itself)
$NPM install >/dev/null 2>&1
API="--api-url http://127.0.0.1:8899 --org test-org --api-token fake --patch-server-url http://127.0.0.1:8899"
$SP scan --mode $MODE --yes $API; echo "scan exit=$?"
rm -rf node_modules; $NPM ci >/dev/null 2>&1; echo "npm ci exit=$?"
head -c 22 node_modules/left-pad/index.js | grep -q SOCKET && echo PATCHED || echo UNPATCHED
$SP vex $API >/dev/null; echo "vex exit=$?"
[ $MODE = vendored ] && { $SP vendor --check $API; echo "vendor --check exit=$?"; }

Output (npm 10.9.9, vendored):

Vendored 1 package.
scan exit=0
npm ci exit=0
UNPATCHED
Warning: pkg:npm/left-pad@1.3.0: the installed tree does not match its vendored artifact; the attestation is based on the committed .socket/vendor artifact (the lockfile consumes it), but the live tree carries different bytes — re-run your package manager's install to resync it.
vex exit=0
pkg:npm/left-pad@1.3.0: committed artifact and wiring verified
vendor --check exit=0

Output (npm 10.9.9, hosted, with --json --vex in.vex.json):

scan: status success, warnings ['redirect_npm_allow_remote']   # no redirect_npm_non_registry_entry_skipped
in-run vex: not_affected
npm ci exit=0 → UNPATCHED
standalone vex exit=1 {'code': 'no_applicable_patches'} (not_applied)

The lock after the scan pins the copy as expected (resolved is the hosted URL in hosted mode, file:.socket/vendor/npm/<uuid>/left-pad-1.3.0.tgz in vendored mode). npm ignores that pin because the override spec wins.

Control: with the override set to a plain registry version ("left-pad": "1.3.0"), the same script gives PATCHED, and vex and vendor --check pass in both modes.

Expected vs actual

  • Expected: the copy is treated like any other non-registry install. Hosted skips it with redirect_npm_non_registry_entry_skipped, vendored refuses it, and no VEX attests it. That is the documented contract: docs/ecosystems.md (npm hosted notes) says a non-registry copy "keeps installing the user's own bytes … vex attests nothing for the package while the copy is in the lock", and the in-code comment at vendor/npm_origin.rs/patch/redirect/mod.rs:1571 says "npm installs a git / url / file: dependency from the dependent's spec and ignores resolved, so a rewrite here would confirm (and VEX-attest) a patch that never installs".
  • Actual: both modes rewire the copy and exit 0. After npm ci the tree is unpatched. Vendored vex and vendor --check pass anyway, and the hosted in-run --vex attests not_affected.

OS × version

OS npm lockfileVersion hosted vendored
Linux 8.19.4 2 repro (unpatched, standalone vex exit 1) repro (unpatched, vex 0, vendor --check 0)
Linux 10.9.9 3 repro (×3) repro (×2)
Linux 12.2.1 (allow-remote=all) 3 repro repro
Linux 12.2.1 (default allow-remote) n/a n/a: npm itself refuses the URL override (EALLOWREMOTE) n/a

macOS and Windows weren't tested, because probe branches are blocked in this routine. The logic is platform-independent.

First bad version

This isn't a regression: v5.0.1 behaves the same (hosted and vendored, npm 10.9.9).

Suspect code

crates/socket-patch-core/src/vendor/npm_origin.rs:385, in npm_non_registry_entries. It skips every edge whose declared spec is a registry spec (if npm_spec_is_registry(spec) { continue; }) before it consults overrides. It only handles the opposite direction (#490: a non-registry spec that an override turns into a registry one, lines 390–394). A registry spec that an override replaces with a URL, git or file: spec falls through. Git and file: overrides are still caught by resolved_is_non_registry, because their resolved isn't a tarball URL. A remote-tarball URL override isn't caught, because its resolved is an ordinary https://…tgz. The same function feeds both the hosted rewriter (patch/redirect/mod.rs:1466) and the vendored writer (vendor/npm_lock.rs:1047), which is why both modes are affected. The probable fix is to also check overrides.replacement(...) for registry specs and treat a non-registry replacement as non-registry.

Related, but not duplicates: #490 (the opposite override direction), #1481 (file: copies), #326 (direct git / URL / file: specs).

Activity

  1. mikolalysenko commented on Nov 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (npm; a patch reported as installed and VEX-attested while npm ci installs the upstream bytes). Confirmed on main @ cbbbdb9: npm_non_registry_entries (crates/socket-patch-core/src/vendor/npm_origin.rs) skips every registry-spec edge before consulting overrides, so a registry range overridden to a remote tarball URL is never marked non-registry. No duplicate or open PR found; related but distinct: #490 (opposite override direction).


    Generated by Claude Code

  2. mikolalysenko commented on Nov 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (shared root cause: npm_non_registry_entries ignores overrides that turn a registry spec into a URL/git/file spec). Branch: agent/fix-npm-override-to-url. Claim-ID: 2026-11-02T02:21:26Z-b6ded9


    Generated by Claude Code

  3. mikolalysenko commented on Nov 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1966


    Generated by Claude Code

  4. mikolalysenko commented on Nov 2, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] #1966 is blocked on a maintainer decision. An override scoped to a logical ancestor that is not on the copy's physical node_modules/ path (e.g. {"a": {"left-pad": <url>}} with b and left-pad hoisted) is still treated as a registry install, so that copy is still rewired and attested. The options are listed in #1966 (comment):

    1. Conservative: any scoped non-registry rule for the package counts.
    2. Exact: logical ancestry from the lock's dependency edges.
    3. Accept the gap with a follow-up issue.

    Reply on the PR with the choice and the next run will implement it.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions