Skip to content

npm VEX attests not_affected while a bundled (inBundle) copy of the same package@version stays unpatched #325

Description

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

Summary

A project can install the same name@version twice: once as a normal lock entry and once bundled inside another package's tarball (inBundle: true). The vendored and hosted rewriters handle this correctly. They rewire the normal entry and skip the bundled one with a loud warning that it "stays UNPATCHED" (vendor_bundled_instance_skipped / redirect_npm_bundled_instance_skipped). vex then attests the whole purl not_affected anyway:

  • vendored: always, both in-run (scan --mode vendored --vex) and post-install (socket-patch vex). The installed bundled copy is unpatched and there is no vendored_tree_out_of_sync warning.
  • hosted: in-run (scan --mode hosted --vex) and pre-install (lockfile-pin basis). Only the post-install hosted vex catches it: it hash-checks every installed copy and omits the purl as not_applied.

So the attestation contradicts the same run's own warning, and the verdict depends on the mode and on whether node_modules exists.

Impact

A false VEX statement: the shipped product contains an unpatched copy of the vulnerable name@version, and the OpenVEX document says not_affected / inline_mitigations_already_exist for it. Downstream scanners suppress the finding.

#189 fixed the bundled-only case (nothing gets rewired, so nothing is attested). The mixed case, one patched copy plus one bundled unpatched copy, is still open.

Repro (npm 12.1.0, Linux, main f6b7fb9e, reproduced twice)

# a parent package that bundles left-pad@1.3.0
mkdir bsrc && cd bsrc
echo '{"name":"bund","version":"1.0.0","dependencies":{"left-pad":"1.3.0"},"bundleDependencies":["left-pad"]}' > package.json
npm install && npm pack && cd ..
mkdir app && mv bsrc/bund-1.0.0.tgz app/ && cd app
echo '{"name":"app","version":"1.0.0","private":true,"dependencies":{"bund":"file:bund-1.0.0.tgz","left-pad":"1.3.0"}}' > package.json
npm install
# lock: node_modules/left-pad (registry) + node_modules/bund/node_modules/left-pad {inBundle:true}

socket-patch scan --mode vendored --yes --json --vex inrun.vex.json --api-url $MOCK --org test-org --api-token fake
#   events: vendor_bundled_instance_skipped ("that copy stays UNPATCHED")
#   inrun.vex.json: not_affected pkg:npm/left-pad@1.3.0
# fresh checkout (no node_modules) -> npm ci
head -c 20 node_modules/left-pad/index.js                 # /* SOCKET-PATCHED */
head -c 20 node_modules/bund/node_modules/left-pad/index.js  # original, unpatched
socket-patch vex --output v.json --json
#   status success, statement not_affected "(vendored)", warnings: []

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, serving a tarball with a marker prepended to index.js). For the hosted pre-install cell, pass --patch-server-url for the mock origin so the lockfile reference is recognized.

Expected vs actual

Expected: a patch is attested only when the build consumes patched bytes. The CLI contract drops a ref when "another lock resolves the same name@version from a non-Socket source" (patched_ref_unattributable, CLI_CONTRACT.md "Contested locks"). A bundled instance in the same lock is the same situation: the build consumes unpatched bytes of that name@version from a non-Socket source, the parent's tarball. At minimum a warning is expected, since the vendored contract says a present installed tree with different bytes warns vendored_tree_out_of_sync.

Actual:

cell vendored hosted
in-run --vex not_affected ✗ not_affected ✗
vex, no node_modules (lock basis) not_affected ✗ not_affected ✗
vex after npm ci not_affected, no warning ✗ omitted not_applied ✓

OS × version

npm 10.9.9 npm 12.1.0
Linux, main f6b7fb9e reproduces (vendored) reproduces (vendored ×2, hosted)
Linux, release 4.0.0 reproduces (vendored)
macOS / Windows not run (lock/VEX logic is OS-independent)

Not a regression: 4.0.0 behaves the same.

Suspect code

  • crates/socket-patch-core/src/vex/discover/mod.rs:574-595: contest_across_locks only contests refs from a different file (e.file != r.source_file). inBundle / bundled entries are skipped outright by the npm extractor (vex/discover/npm.rs:17-22) instead of being recorded as a same-name@version "resolved elsewhere" instance.
  • crates/socket-patch-core/src/vex/verify.rs:185-193: the vendored out-of-sync probe checks one package_paths.get(purl) copy (the hoisted, patched one), not every installed copy. The hosted path uses verify_hosted_copies, which checks them all.

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