[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
Agent-mode scan / apply follows a node_modules/<name> symlink into first-party source — an npm workspace member, a file: directory dependency, or an npm link target — whenever that local package's name@version matches a patched registry package. The local index.js doesn't match the patch's beforeHash, so the default mismatch policy overwrites the user's own source with the upstream patched file (only a warning), vex then attests not_affected, and rollback "restores" the upstream original file instead of the user's code. The user's source is gone in every case.
Vendored mode already recognizes this case and refuses (vendor_workspace_member: "is a workspace member of this project; patch the source directly instead of vendoring it"), and hosted mode correctly leaves the link: true lock entry alone (redirect_npm_entry_not_found). Only agent mode writes through the link.
Impact
- Data loss in committed first-party code: a monorepo that keeps a fork of a dependency as a workspace (same
name@version as upstream — a common way to carry a local fix) has the fork's files silently replaced by upstream content on socket-patch scan. rollback doesn't undo it.
- Writes outside the project: with
npm link left-pad, the link target is the developer's checkout of left-pad in another directory, and agent mode rewrites files there. docs/ecosystems.md uses the same reasoning to skip pnpm's global virtual store ("other projects on the machine load the same files, so patching it in place would patch them as well").
- Misleading VEX: the attestation covers code that isn't the registry package the advisory is about.
Repro (real npm, local mock patch API)
# mock API: a patch for left-pad@1.3.0 that prepends a marker to index.js
mkdir -p ws/packages/left-pad && cd ws && git init -q .
echo '{"name":"left-pad","version":"1.3.0","main":"index.js"}' > packages/left-pad/package.json
echo 'module.exports = "first-party fork";' > packages/left-pad/index.js
echo '{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"]}' > package.json
# or: "dependencies":{"left-pad":"file:packages/left-pad"} (same result)
npm install && git add -A && git commit -qm init
ls -l node_modules/left-pad # -> ../packages/left-pad (link: true in package-lock.json)
socket-patch scan --mode agent --api-url $MOCK --patch-server-url $MOCK --org o --api-token x
# Warning: pkg:npm/left-pad@1.3.0 package/index.js did not match the patch's expected original
# content; applied the full verified patched content instead (pass --strict to fail on mismatches)
# Summary: 1 of 1 targeted patch applied (exit 0)
git diff --stat -- packages # packages/left-pad/index.js | 54 +++++- (fork replaced by upstream)
socket-patch vex ... # exit 0, not_affected
socket-patch rollback ... # exit 0
head -c 60 packages/left-pad/index.js # "/* This program is free software…" — upstream original, not the fork
npm link variant: cd ~/dev/left-pad && npm link; cd proj && npm link left-pad; socket-patch scan --mode agent → ~/dev/left-pad/index.js is rewritten.
Expected vs actual
- Expected: a
node_modules entry that is a link to a workspace member / file: directory / npm link target (the lock marks it "link": true) isn't an installed copy of the registry package, so agent mode skips it with a diagnostic, as vendored mode already does (vendor_workspace_member), and never writes through it. The default mismatch overwrite (crates/socket-patch-cli/CLI_CONTRACT.md, "mismatch-policy note"; apply.rs:45: "What tolerance can do is discard local modifications to the dependency file") is justified for an installed dependency that npm ci can restore. It isn't justified for first-party source that no reinstall brings back. docs/ecosystems.md also refuses to patch a store outside the project in place, which is the same hazard as an npm link target.
- Actual: agent mode patches the link target (overwriting it on a hash mismatch), reports success, VEX attests it, and rollback writes the upstream original over the user's file.
Matrix (main 045d7ec, Node 22 / 24)
| OS |
npm |
workspace member |
file: dir dep |
npm link |
| Linux |
6.14.18 |
– (npm 6 has no workspaces) |
repro |
– |
| Linux |
8.19.4 |
repro |
repro |
– |
| Linux |
10.9.4 |
repro (2/2) |
repro |
repro |
| Linux |
12.2.0 |
repro |
repro |
– |
| macOS (macos-latest, arm64) |
10.9.7 |
repro (2/2, second run) |
repro |
– |
| Windows (latest) |
8.19.4 / 10.9.7 / 12.2.0 |
repro (junction) |
repro |
– |
| Ubuntu (Actions) |
8.19.4 / 10.9.7 / 12.2.0 |
repro |
repro |
– |
--strict blocks it (exit 1, nothing written). Hosted: no rewrite, loud redirect_npm_entry_not_found. Vendored: refused, vendor_workspace_member.
First bad version: not a regression. Released v4.0.0 (apply against the same manifest) overwrites the fork the same way.
Suspect code
crates/socket-patch-core/src/crawlers/npm_crawler.rs:1933 acceptable_package_entry: importer trees accept file_type.is_symlink() for any entry (meant for pnpm/vlt store links, see the doc comment at :1772, which also lists "npm link targets"), with no check of where the link resolves to: into a store under node_modules, or into first-party source / outside the project.
- Vendored's equivalent guard:
crates/socket-patch-core/src/vendor/npm_lock.rs:459 (vendor_workspace_member).
- Combined with the default
MismatchPolicy::Warn (crates/socket-patch-core/src/patch/apply.rs:51), which overwrites on a mismatch.
Probe runs: https://github.com/SocketDev/socket-patch/actions/runs/37081541007 (ubuntu / macOS / Windows × npm 8 / 10 / 12; the first macOS jobs lost a startup race with the mock server, so scan exited 1 on a connect timeout) and https://github.com/SocketDev/socket-patch/actions/runs/37082844719 (macOS rerun with the server warmed: reproduces).
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
Agent-mode
scan/applyfollows anode_modules/<name>symlink into first-party source — an npm workspace member, afile:directory dependency, or annpm linktarget — whenever that local package'sname@versionmatches a patched registry package. The localindex.jsdoesn't match the patch'sbeforeHash, so the default mismatch policy overwrites the user's own source with the upstream patched file (only a warning),vexthen attestsnot_affected, androllback"restores" the upstream original file instead of the user's code. The user's source is gone in every case.Vendored mode already recognizes this case and refuses (
vendor_workspace_member: "is a workspace member of this project; patch the source directly instead of vendoring it"), and hosted mode correctly leaves thelink: truelock entry alone (redirect_npm_entry_not_found). Only agent mode writes through the link.Impact
name@versionas upstream — a common way to carry a local fix) has the fork's files silently replaced by upstream content onsocket-patch scan.rollbackdoesn't undo it.npm link left-pad, the link target is the developer's checkout of left-pad in another directory, and agent mode rewrites files there. docs/ecosystems.md uses the same reasoning to skip pnpm's global virtual store ("other projects on the machine load the same files, so patching it in place would patch them as well").Repro (real npm, local mock patch API)
npm linkvariant:cd ~/dev/left-pad && npm link; cd proj && npm link left-pad; socket-patch scan --mode agent→~/dev/left-pad/index.jsis rewritten.Expected vs actual
node_modulesentry that is a link to a workspace member /file:directory /npm linktarget (the lock marks it"link": true) isn't an installed copy of the registry package, so agent mode skips it with a diagnostic, as vendored mode already does (vendor_workspace_member), and never writes through it. The default mismatch overwrite (crates/socket-patch-cli/CLI_CONTRACT.md, "mismatch-policy note";apply.rs:45: "What tolerance can do is discard local modifications to the dependency file") is justified for an installed dependency thatnpm cican restore. It isn't justified for first-party source that no reinstall brings back. docs/ecosystems.md also refuses to patch a store outside the project in place, which is the same hazard as annpm linktarget.Matrix (main
045d7ec, Node 22 / 24)file:dir depnpm link--strictblocks it (exit 1, nothing written). Hosted: no rewrite, loudredirect_npm_entry_not_found. Vendored: refused,vendor_workspace_member.First bad version: not a regression. Released v4.0.0 (
applyagainst the same manifest) overwrites the fork the same way.Suspect code
crates/socket-patch-core/src/crawlers/npm_crawler.rs:1933acceptable_package_entry: importer trees acceptfile_type.is_symlink()for any entry (meant for pnpm/vlt store links, see the doc comment at:1772, which also lists "npm linktargets"), with no check of where the link resolves to: into a store undernode_modules, or into first-party source / outside the project.crates/socket-patch-core/src/vendor/npm_lock.rs:459(vendor_workspace_member).MismatchPolicy::Warn(crates/socket-patch-core/src/patch/apply.rs:51), which overwrites on a mismatch.Probe runs: https://github.com/SocketDev/socket-patch/actions/runs/37081541007 (ubuntu / macOS / Windows × npm 8 / 10 / 12; the first macOS jobs lost a startup race with the mock server, so scan exited 1 on a connect timeout) and https://github.com/SocketDev/socket-patch/actions/runs/37082844719 (macOS rerun with the server warmed: reproduces).