Skip to content

npm agent-mode apply never patches an npm-aliased install (lp@npm:left-pad), yet VEX attests the package not_affected #356

Description

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

Summary

npm aliases ("lp": "npm:left-pad@1.3.0") install the real left-pad@1.3.0 bytes under node_modules/lp/, and its package.json says "name": "left-pad". Agent-mode apply never finds that copy:

  • Alias-only project: apply reports skipped / package_not_installed ("No installed package matches this PURL"), exits partialFailure, and the installed copy stays unpatched.
  • Mixed project (a plain left-pad@1.3.0 plus an alias of the same name@version): apply patches node_modules/left-pad, reports success with no warning, and leaves node_modules/lp unpatched. Both apply --vex and a later socket-patch vex then attest pkg:npm/left-pad@1.3.0 as not_affected / inline_mitigations_already_exist, even though require('lp') loads the unpatched file.

Vendored mode handles the same alias correctly: vendor rewires the node_modules/lp lock entry ("name": "left-pad") to the vendored tarball. Only agent mode (the find_by_purls resolver) misses it.

Impact

  • The vulnerable code stays in the build for every consumer that imports the package through the alias. Aliases are common for side-by-side majors, for example "lodash4": "npm:lodash@4.17.20".
  • The OpenVEX document claims the vulnerability is mitigated when the shipped node_modules still holds an unpatched copy of the same name@version, so downstream scanners suppress a real finding.
  • Alias-only projects get a misleading package_not_installed error for a package that is installed.

Repro (Linux, main f6b7fb9, npm 12.1.0; reproduced twice)

mkdir app && cd app
echo '{"name":"app","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0","lp":"npm:left-pad@1.3.0"}}' > package.json
npm install
# node_modules/left-pad and node_modules/lp both hold left-pad@1.3.0 (lp/package.json: "name": "left-pad")

# hand-stage .socket/manifest.json + blobs for pkg:npm/left-pad@1.3.0 (a marker prepended to
# package/index.js, same shape as crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs
# stage_patch_with_vuln), plus "setup": {"manual": ["npm"]}
socket-patch apply --json --offline --vex inrun.json
#   status success, events [applied pkg:npm/left-pad@1.3.0], no warnings
head -c 20 node_modules/left-pad/index.js   # /* SOCKET-PATCHED */
head -c 20 node_modules/lp/index.js         # /* This program is f   <- unpatched
node -e "console.log(require('fs').readFileSync(require.resolve('lp'),'utf8').slice(0,20))"  # unpatched
socket-patch vex --output v.json --json --offline
#   inrun.json and v.json: not_affected / inline_mitigations_already_exist for GHSA-xxxx-yyyy-zzzz

Alias-only variant: use {"dependencies":{"lp":"npm:left-pad@1.3.0"}} and socket-patch apply returns partialFailure, skipped package_not_installed. A scoped alias ("@x/pad": "npm:left-pad@1.3.0") behaves the same.

Expected vs actual

Expected: CLI_CONTRACT.md ("Monorepo / multi-project discovery model") says apply "patches a package by PURL against the manifest regardless of how deep in the dependency tree it was installed", and describes npm's multi-copy fan-out, where every copy of a name@version is patched. The purl is pkg:npm/left-pad@1.3.0 and the installed package's own package.json identity is left-pad@1.3.0, so the aliased directory is an installed copy of that purl. A VEX not_affected statement should also only come out when the build consumes patched bytes (Setup property 7: VEX reflects "on-disk verification").

Actual: aliased copies are never probed. The alias-only case reports package_not_installed, and the mixed case leaves the alias unpatched with a clean success and a not_affected attestation.

OS × version

npm 6.14.18 (lock v1) npm 8.19.4 (v2) npm 10.9.7 (v3) npm 12.1.0 (v3)
Linux, main f6b7fb9 reproduces reproduces reproduces (workspace root + alias, VEX not_affected) reproduces ×2 (alias-only, mixed + VEX)
Linux, release 4.0.0 reproduces (alias-only)
Linux, release 3.3.0 reproduces (alias-only)
macOS / Windows not probed (resolver logic is OS-independent)

Not a regression: 3.3.0 and 4.0.0 behave the same.

Suspect code

  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:797 (find_by_purls): the resolver target's dir_key is built from the purl name, so only node_modules/<purl-name> is ever probed.
  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:978-985 (visit_resolver_dir): it also requires found_name == target.dir_key. That correctly rejects a different package living under the purl's directory name, but it means a copy whose directory name differs from its package.json name (an alias) can never match. The vendored lock rewriter already resolves alias edges by the entry's name (vendor/npm_lock.rs:882), so the two modes disagree.
  • VEX then verifies only the resolved (hoisted, patched) copy, so the unpatched alias copy doesn't block the attestation. This is the same verification gap as npm VEX attests not_affected while a bundled (inBundle) copy of the same package@version stays unpatched #325 (bundled copies), but a different instance source.

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