Repository navigation
Agent-mode npm apply, apply --check and vex skip a declared workspace member in a hidden directory (.github/actions/x, .config/tool), so its nested copy stays unpatched while VEX attests not_affected #1827
Description
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
#1627 made the agent-mode node_modules walk crawl a declared workspace member whose path crosses a SKIP_DIRS name (packages/build, vendor/lib). A declared member inside a hidden directory is still pruned. A common example is a JavaScript GitHub Action kept as an npm workspace at .github/actions/<name>, matched by "workspaces": [".github/actions/*"]. Another is an explicit ".config/tool" entry. npm 7+ treats these as workspace members and installs each member's unhoistable dependencies into <member>/node_modules from the root lock, so the copy there is live.
skipped_members_of (crates/socket-patch-core/src/crawlers/npm_crawler.rs:109) walks the patterns with a walkable filter that rejects every name starting with . (line 121). It also only adds a member when its path crosses a SKIP_DIRS name (line 183). The main walk's workspace_children drops hidden dirs too (line 3476). The result:
scan --mode agentpatches every other copy of the samename@version, skips the hidden member's copy, and exits 0.apply --checkreports "Patches are in sync".vexwrites anot_affectedstatement for the vulnerability, though npm runs the unpatched bytes from.github/actions/x/node_modules/left-pad.
If the hidden member holds the only copy, scan --mode agent prints [skip] pkg:npm/left-pad@1.2.0 (not installed; run your package manager's install first …) and exits 0. The package is installed.
Impact
A false VEX attestation, the same class as #1627: a scanner consuming the VEX suppresses a vulnerability in code that is installed and executed (for example by the Action at runtime). Nothing in the output warns about it.
Repro (npm 10.9.4, Node 22.22.0, Linux)
The patch API is a local mock. Any patch for left-pad@1.2.0 shows it; a hand-staged .socket/manifest.json works the same way.
mkdir -p proj/.github/actions/x proj/packages/y && cd proj
echo '{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*",".github/actions/*"],"dependencies":{"left-pad":"1.3.0"}}' > package.json
echo '{"name":"x","version":"1.0.0","dependencies":{"left-pad":"1.2.0"}}' > .github/actions/x/package.json
echo '{"name":"y","version":"1.0.0","dependencies":{"left-pad":"1.2.0"}}' > packages/y/package.json
npm install
# lock: node_modules/left-pad (1.3.0), packages/y/node_modules/left-pad and
# .github/actions/x/node_modules/left-pad (both 1.2.0)
socket-patch scan --mode agent --yes --api-url <mock> --org test-org --api-token fake
head -c 20 packages/y/node_modules/left-pad/index.js # /* SOCKET-PATCHED */
head -c 20 .github/actions/x/node_modules/left-pad/index.js # /* This program is f <- unpatched
socket-patch apply --check … # "Patches are in sync (1 patch checked)", exit 0
socket-patch vex … -O v.json # 1 statement, status not_affected, exit 0Expected vs actual
docs/ecosystems.md, "npm: which node_modules trees are crawled": the walk skips hidden directories and build dirs, "except that a workspace member the root package.json workspaces field declares … still has its own node_modules crawled. npm, yarn and Bun install a member's unhoistable dependencies there from the root lock." The usage docs say agent VEX attests "Installed patched files, checked against the manifest's hashes".
- Expected: the declared member
.github/actions/xhas itsnode_modulescrawled and patched. At the least,apply --check/vexmust not report the patch as in sync or attestnot_affectedwhile that copy is unpatched. - Actual: the copy is never seen. apply,
--checkandvexall succeed.
Matrix (Linux, Node 22.22.0)
| socket-patch | npm | member path | result |
|---|---|---|---|
main ecd4924 |
8.19.4 | .github/actions/x (glob) |
fail |
main ecd4924 |
10.9.4 | .github/actions/x (glob) |
fail (2/2) |
main ecd4924 |
10.9.4 | .config/tool (explicit pattern) |
fail |
main ecd4924 |
12.2.1 | .github/actions/x (glob) |
fail |
main ecd4924 |
10.9.4 | tools/build/x (control, #1627 shape) |
pass |
| v5.0.1 | 10.9.4 | .github/actions/x |
fail |
This isn't a regression: v5.0.1 does the same. macOS and Windows are untested; the cause is platform-independent. Hosted and vendored modes are unaffected, because the pin is lock-driven and npm ci installs the patched tarball into the hidden member too. The walk is shared, so yarn and Bun workspaces probably behave the same (not verified).
Suspect code
crates/socket-patch-core/src/crawlers/npm_crawler.rs:121:walkableinskipped_members_ofrejects hidden names, including the literal prefix of a pattern such as.github/actions/*.crates/socket-patch-core/src/crawlers/npm_crawler.rs:183: members are only added when the path crosses aSKIP_DIRSname, so a hidden segment would need the same treatment.crates/socket-patch-core/src/crawlers/npm_crawler.rs:3476:workspace_childrenprunes hidden dirs.
No probe runs: probe branches are blocked for this routine (see the ledger).
Activity
mikolalysenko commentedon Oct 29, 2026
[agent] A correction to "hosted and vendored modes are unaffected": that holds for the install, but not for hosted
vex's installed-copy check. Same project, npm 10.9.4, mainecd4924:scan --mode hostedpins bothleft-pad@1.2.0lock entries, including.github/actions/x/node_modules/left-pad, and a freshnpm ciinstalls the patched bytes into both members. That part passes.packages/y/node_modules/left-pad/index.js(what a stale tree looks like).vexthen warnsomitting pkg:npm/left-pad@1.2.0 from VEX: the patched files still hold the original content (not_applied)and exits 1. That's correct..github/actions/x/node_modules/left-pad/index.jsonly.vexwrites 1 statement,not_affected, and exits 0. The hidden member's copy is never crawled, so hosted mode's "installed copies are verified when available" check silently skips it.Same root cause as above (the hidden-dir prune in the walk).
Generated by Claude Code
npm bughunt run 55: #1827 hidden workspace member
mikolalysenko commentedon Oct 29, 2026
[agent] Triage: priority:p1 (npm). Not a duplicate: #1627 fixed members under a
SKIP_DIRSname; this is the hidden-dir half of the same prune. Confirmed on mainb291caa:skipped_members_ofincrates/socket-patch-core/src/crawlers/npm_crawler.rsfilters every dot-named dir out of the member walk (walkable) and only keeps members whose path crosses aSKIP_DIRSname, so a declared.github/actions/xmember is never added back after the main walk prunes it. No open PR covers it.Generated by Claude Code
mikolalysenko commentedon Oct 29, 2026
[agent] Claiming this issue (shared root cause: the workspace-member walk prunes hidden dirs, so a declared member under
.github/or.config/is never crawled). Branch: agent/fix-npm-hidden-workspace-members. Claim-ID: 2026-10-29T14:41:28Z-1a790cGenerated by Claude Code
mikolalysenko commentedon Oct 29, 2026
[agent] Draft PR: #1829
Generated by Claude Code
Crawl npm workspace members in hidden dirs
Count hidden members only where a dot is spelled
Expand workspace braces before the member walk
mikolalysenko commentedon Oct 29, 2026
[agent] npm bug-hunt run 56 (ledger #302), main
c07af61: the vendored cell for this issue.I used the same layout on npm 10.9.4:
workspaces: [".github/actions/*", "packages/*"], rootleft-pad@1.2.0, andleft-pad@1.3.0nested in both.github/actions/xandpackages/b.scan --mode vendoredwires the lock entries for both members.npm ciinstalls the patched bytes in both, andvex/vendor --checkpass.packages/b),vexwarns: "the installed tree does not match its vendored artifact … re-run your package manager's install". It exits 0 and attests from the committed artifact, which is the documented behaviour..github/actions/x),vexwrites the samenot_affectedstatement with no drift warning (2/2 runs).vendor --checkalso passes.So the vendored installed-tree drift check misses the hidden member too. It appears to share the walk prune described above, so the fix for this issue should cover it.
Generated by Claude Code