[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.
[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=linkedorinstall-strategy=linkedin.npmrc). Every package's real directory lives atnode_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.pnpmvirtual store, which the crawler explicitly walks.The npm crawler skips
.storeas 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)With the default hoisted strategy the same project reports
lockfileOnlyPackages: 0, and all three commands patchnode_modules/is-number.Expected vs actual
Expected: CLI_CONTRACT.md ("Monorepo / multi-project discovery model") says "Deeply nested transitive dependencies are fully supported…
applyis path-agnostic — it patches a package by PURL… regardless of how deep in the dependency tree it was installed". docs/ecosystems.md ("npm: whichnode_modulestrees are crawled") lists the directories the walk skips, and npm's own.storeis 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:
.storefalls through to the generic hidden-entry skip, so transitive linked installs are "not installed".scan --applyalso treats that as a cleansuccesswith exit 0, even though the package is installed and unpatched.OS × version
f6b7fb9—applyscan --applysuccess, package left unpatchedvendorpartialFailurepackage_not_installed.store)applySuspect 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.<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.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.