Repository navigation
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:
scancounts it aslockfileOnlyPackages(in the lock, but not installed).scan --applyreportsskipped/package_not_installed, yet the envelopestatusissuccessand the exit code is 0. The vulnerable code stays installed and is whatrequire('is-odd')loads, and CI sees a green run.applyexits 1 ("The targeted manifest patch matched no installed package").vendorreturnspartialFailure/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 # unpatchedWith 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 andVLT_STORE_NAMEget a deferred store walk. npm's.storereaches the genericname_str.starts_with('.')skip at:1392.crates/socket-patch-core/src/crawlers/npm_crawler.rs:1045-1070(the resolver side used byfind_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.pnpmentries, so the existing store-entry policy (real dirs only, symlinks are dependency edges) should apply unchanged. - The
scan --applyexit 0 comes frompackage_not_installedbeing 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
mikolalysenko commentedon Sep 30, 2026
[agent] Cross-OS probe (main
f6b7fb9built on each runner, Node 22,install-strategy=linked):applyfails with "The targeted manifest patch matched no installed package: pkg:npm/is-number@6.0.0", even thoughnode_modules/.store/is-number@6.0.0-Pqc5my552wJdjE6sp0MCIg/is present..storehere)Run: https://github.com/SocketDev/socket-patch/actions/runs/36758644937
Generated by Claude Code
npm: bug-hunt run 1 ledger (#356 #359)
mikolalysenko commentedon Sep 30, 2026
[agent] Shares root cause with #362:
npm_crawler.rsfinds a dependency store only by its hard-coded directory name (.pnpm, pnpm ≤3.<host>,.vlt) in bothgather_node_modules(scan,:1362) and the:1033). Any other store location (npm'sfind_by_purlsresolver (.store, or a pnpmvirtualStoreDirrecorded innode_modules/.modules.yaml) falls through to the hidden-entry skip. Will be fixed together. Triagedpriority:p1(npm).Generated by Claude Code
mikolalysenko commentedon Sep 30, 2026
[agent] Claiming this issue (with #362; shared root cause: npm crawler recognizes dependency stores only by hard-coded names, so npm's
.storeand a pnpmvirtualStoreDirare skipped). Branch: agent/fix-npm-crawler-store-discovery. Claim-ID: 2026-09-30T19:21:12Z-aa553bGenerated by Claude Code
mikolalysenko commentedon Sep 30, 2026
[agent] Draft PR: #365
Generated by Claude Code
Add real npm/pnpm e2e for relocated stores
Fix npm crawler missing relocated dependency stores (#359, #362) (#365)