Skip to content

npm install-strategy=linked: transitive packages under node_modules/.store are "not installed", and scan --apply exits 0 leaving them unpatched #359

Description

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

Summary

npm (9.x+, still present in 10 and 12) has an isolated layout, install-strategy=linked (set with --install-strategy=linked or install-strategy=linked in .npmrc). Every package's real directory lives at node_modules/.store/<name>@<version>-<hash>/node_modules/<name>. node_modules/<direct-dep> and each store entry's dependency edges are symlinks into .store. A transitive dependency is only ever a real directory inside .store. This is the same property as pnpm's .pnpm virtual store, which the crawler explicitly walks.

The npm crawler skips .store as just another hidden directory. As a result, any transitive package installed with the linked strategy is invisible to every command that looks for installed copies:

  • scan counts it as lockfileOnlyPackages (in the lock, but not installed).
  • scan --apply reports skipped / package_not_installed, yet the envelope status is success and the exit code is 0. The vulnerable code stays installed and is what require('is-odd') loads, and CI sees a green run.
  • apply exits 1 ("The targeted manifest patch matched no installed package").
  • vendor returns partialFailure / package_not_installed, because it can't build the tarball from the installed copy.

Direct dependencies work, because node_modules/<name> is a symlink the resolver probes and patches through. Only transitive packages are missed, and those are the majority of real vulnerable packages.

Repro (Linux, main f6b7fb9, npm 12.1.0 and 10.9.7; reproduced on 3 fresh projects)

mkdir app && cd app
echo '{"name":"app","version":"1.0.0","private":true,"dependencies":{"is-odd":"3.0.1","left-pad":"1.3.0"}}' > package.json
echo 'install-strategy=linked' > .npmrc
npm install
ls -la node_modules/.store/is-odd@3.0.1-*/node_modules/   # is-number -> ../../is-number@6.0.0-<hash>/node_modules/is-number
P=$(ls -d node_modules/.store/is-number@6.0.0-*/node_modules/is-number)

# agent apply with a hand-staged manifest for pkg:npm/is-number@6.0.0 (marker prepended to
# package/index.js; same shape as tests/e2e_vendor_npm_build.rs stage_patch_with_vuln)
socket-patch apply --offline
#   Error: The targeted manifest patch matched no installed package: pkg:npm/is-number@6.0.0   (exit 1)

# scan --apply against a mock patch API offering a free patch for is-number@6.0.0
# (the batch / by-package / view endpoints from tests/e2e_redirect_npm_build.rs)
socket-patch scan --apply --yes --json --api-url $MOCK --org test-org --api-token fake
#   exit 0, status "success", scannedPackages 3, lockfileOnlyPackages 1,
#   apply.patches: [{purl: pkg:npm/is-number@6.0.0, action: skipped, errorCode: package_not_installed}]
head -c 20 $P/index.js                     # unpatched

With the default hoisted strategy the same project reports lockfileOnlyPackages: 0, and all three commands patch node_modules/is-number.

Expected vs actual

Expected: CLI_CONTRACT.md ("Monorepo / multi-project discovery model") says "Deeply nested transitive dependencies are fully supported… apply is path-agnostic — it patches a package by PURL… regardless of how deep in the dependency tree it was installed". docs/ecosystems.md ("npm: which node_modules trees are crawled") lists the directories the walk skips, and npm's own .store is not among the documented exclusions. The crawler already recognizes the analogous stores (.pnpm, pnpm ≤3 .<registry-host>, vlt's .vlt) precisely because "it is the ONLY physical home of transitive dependencies".

Actual: .store falls through to the generic hidden-entry skip, so transitive linked installs are "not installed". scan --apply also treats that as a clean success with exit 0, even though the package is installed and unpatched.

OS × version

npm 10.9.7 npm 12.1.0
Linux, main f6b7fb9 — apply fails (exit 1, not found) fails (exit 1, not found) ×2
Linux, main — scan --apply exit 0 / success, package left unpatched
Linux, main — vendor partialFailure package_not_installed
Linux, main — direct dep (symlink into .store) patched ✓
Linux, releases 4.0.0 and 3.3.0 — apply fails the same way (not a regression)
macOS / Windows not probed (crawler logic is OS-independent; Windows uses junctions for these links)

Suspect code

  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:1362-1392 (gather_node_modules, the scan side): .pnpm, the legacy pnpm stores and VLT_STORE_NAME get a deferred store walk. npm's .store reaches the generic name_str.starts_with('.') skip at :1392.
  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:1045-1070 (the resolver side used by find_by_purls): the same nested-dir policy. Only the vlt and pnpm stores are special-cased before the hidden-entry skip at :1069.
  • npm's linked store entries use <name>@<version>-<hash>/node_modules/<name>, the same shape as pnpm's .pnpm entries, so the existing store-entry policy (real dirs only, symlinks are dependency edges) should apply unchanged.
  • The scan --apply exit 0 comes from package_not_installed being treated as a benign skip in the scan-apply summary. It's reasonable for a package that really isn't installed, but here the lock and the store both say it is installed.

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