GeezerVison is showing github.com — npm install-strategy=linked: transitive packages under node_modules/.store are "not installed", and scan --apply exits 0 leaving them unpatched · Issue #359 · SocketDev/socket-patch
npm install-strategy=linked: transitive packages under node_modules/.store are "not installed", and scan --apply exits 0 leaving them unpatched #359Description[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/[email protected]*/node_modules/ # is-number -> ../../[email protected]<hash>/node_modules/is-number
P=$(ls -d node_modules/.store/[email protected]*/node_modules/is-number)
# agent apply with a hand-staged manifest for pkg:npm/[email protected] (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/[email protected] (exit 1)
# scan --apply against a mock patch API offering a free patch for [email protected]
# (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/[email protected], 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.
Activitymikolalysenko commented on Sep 30, 2026 CollaboratorAuthor More actions [agent] Cross-OS probe (main f6b7fb9 built on each runner, Node 22, install-strategy=linked): apply fails with "The targeted manifest patch matched no installed package: pkg:npm/[email protected]", even though node_modules/.store/[email protected]/ is present.
| OS |
npm 10.9.4 |
npm 12.1.0 |
| ubuntu-latest |
reproduces |
reproduces |
| macos-latest |
reproduces |
reproduces |
| windows-latest |
(still running at time of writing) |
reproduces (npm writes absolute symlinks into .store here) |
Run: https://github.com/SocketDev/socket-patch/actions/runs/36758644937
Generated by Claude Code mikolalysenko commented on Sep 30, 2026 CollaboratorAuthor More actions [agent] Shares root cause with #362: npm_crawler.rs finds a dependency store only by its hard-coded directory name (.pnpm, pnpm ≤3 .<host>, .vlt) in both gather_node_modules (scan, :1362) and the find_by_purls resolver (:1033). Any other store location (npm's .store, or a pnpm virtualStoreDir recorded in node_modules/.modules.yaml) falls through to the hidden-entry skip. Will be fixed together. Triaged priority:p1 (npm).
Generated by Claude Code mikolalysenko commented on Sep 30, 2026 CollaboratorAuthor More actions [agent] Claiming this issue (with #362; shared root cause: npm crawler recognizes dependency stores only by hard-coded names, so npm's .store and a pnpm virtualStoreDir are skipped). Branch: agent/fix-npm-crawler-store-discovery. Claim-ID: 2026-09-30T19:21:12Z-aa553b
Generated by Claude Code mikolalysenko commented on Sep 30, 2026 CollaboratorAuthor More actions
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|