Skip to content

Hosted and vendored scans run from an npm workspace member that has a stray package-lock.json of its own rewrite that lock, which npm ignores, and report success while npm installs the unpatched package and VEX attests not_affected #1094

Description

[agent] Found by the scheduled npm bug-hunt routine (ledger #302).

Summary

npm 7+ installs every workspace member from the root package-lock.json. It never reads a package-lock.json inside a member directory, and running npm install / npm ci inside the member walks up to the root and uses the root lock too. Stray member locks are common, for example in a package that was moved into a monorepo with its old lock still committed.

The #884 / #901 workspace-member refusal (redirect_workspace_lockfile_elsewhere) is skipped whenever the member directory holds any npm-family lock (has_own_npm_family_lock). So a hosted scan / get from such a member:

  • rewrites the member's ignored lock and writes a member .npmrc,
  • reports status: success, redirected: 1, exit 0, with no warning,
  • leaves the root lock untouched, so npm ci (at the root or in the member) installs the unpatched package,
  • and socket-patch vex from the member then attests not_affected.

Vendored mode behaves the same way. Without the stray lock, the member is correctly refused (exit 1). With it, scan --mode vendored vendors into the ignored member lock, exits 0, and vex attests.

Impact

The patch looks applied, and a signed-off VEX statement says not_affected, but every install ships the vulnerable bytes. This is the same false-success shape as #884 (member with no lock) and #899 (a lock npm 12 ignores), reached through a different layout.

Repro

A local mock of the patch API serves one free patch for left-pad@1.3.0 (it prepends /* SOCKET-PATCHED */ to index.js). sp is socket-patch … --api-url <mock> --org o --api-token fake --patch-server-url <mock>.

# a package with its own lock, later moved into a workspace
mkdir -p stray ws/packages/a && cd stray
echo '{"name":"a","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}' > package.json
npm install
cd ../ws
echo '{"name":"root","private":true,"workspaces":["packages/*"]}' > package.json
cp ../stray/package.json packages/a/
npm install                                # root package-lock.json governs the member
cp ../stray/package-lock.json packages/a/  # the stray member lock npm ignores
git init -q && git add -A && git commit -qm init

cd packages/a
sp scan --mode hosted --json   # exit 0, status success, redirected 1, rewrittenFiles [.npmrc, package-lock.json] (the member's)
cd ../.. && git status --short #  M packages/a/package-lock.json   ?? packages/a/.npmrc   (root lock untouched)
rm -rf node_modules && npm ci --cache "$(mktemp -d)"
head -c 21 node_modules/left-pad/index.js     # upstream bytes, no marker
cd packages/a && sp vex -O v.json             # exit 0, one statement: not_affected

get <uuid> --mode hosted and scan --mode vendored from the member give the same result.

Expected vs actual

  • Expected: CLI_CONTRACT (redirect_workspace_lockfile_elsewhere) says a hosted run from a workspace member "whose lock lives in another directory" is refused, because "the rewriters, which read only the project directory, would pin nothing". For npm the governing lock is always the root's, whatever else sits in the member. The refusal (or at least a loud warning) should fire. Vendored should keep refusing the member (vendor_lockfile_missing) as it does without the stray file. vex must not attest a pin that npm never reads.
  • Actual: the refusal is skipped because the member has "a lock of its own". Exit 0, success, and a not_affected attestation, while npm installs unpatched.

The own-lock shortcut is right for pnpm (sharedWorkspaceLockfile: false, #492) but wrong for npm, whose workspaces never read a member lock. yarn classic / berry and Bun probably behave the same way, but that wasn't tested here.

Matrix (Linux, main 05ecc6e, each cell run twice)

npm (Node) root lock hosted scan hosted get <uuid> vendored scan root npm ci installs vex from member
7.24.2 (22.22) v2 exit 0, member lock pinned — — unpatched not_affected
8.19.4 (22.22) v2 exit 0, member lock pinned — — unpatched not_affected
10.9.4 (22.22) v3 exit 0, member lock pinned exit 0, same exit 0, member lock vendored unpatched not_affected
12.2.0 (24.21) v3 exit 0, member lock pinned — — unpatched not_affected

Control: the same workspace without packages/a/package-lock.json is refused (redirect_workspace_lockfile_elsewhere / vendored exit 1), which passes. A hosted scan from the root pins the root lock, and npm ci installs patched, which also passes.

First bad version: none. v4.0.0 (npm @socketsecurity/socket-patch) also pins the stray member lock (exit 0) on npm 10.9.4, so this predates the #884 fix and isn't a regression. The #901 refusal just doesn't cover it.

Suspect code

  • crates/socket-patch-core/src/hosted/governing_root.rs:90: let workspace = if has_own_npm_family_lock(root) { None } else { package_json_workspace_refusal(root).await }. A member package-lock.json / npm-shrinkwrap.json skips the npm workspace check. has_own_npm_family_lock is at :242 and OWN_LOCKS / NPM_LOCKS at :56.
  • The vendored lock inventory (crates/socket-patch-core/src/vendor/lock_inventory/view.rs, vendor_lockfile_missing) likewise treats the member lock as the project's own.
  • Open PR Fix workspace-member refusal for vlt and brace/class globs (#1071, #942) #1073 keeps the same early exit, so it doesn't cover this.

Probe runs: none (Linux sandbox only; macOS / Windows untested).

Activity

  1. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (npm). Confirmed on main 05ecc6e: governing_root::refusal skips package_json_workspace_refusal whenever has_own_npm_family_lock(root) is true, so a member package-lock.json / npm-shrinkwrap.json suppresses the #884 refusal even though npm installs every workspace member from the root lock. Not a duplicate of #884/#901 (those cover a member with no lock); it touches the same function as open PR #1073 but is a separate cause (the own-lock shortcut, not the glob grammar).

    [agent] Claiming this issue (shared root cause: npm workspace-member refusal is skipped when the member holds an npm lock that npm never reads). Branch: agent/fix-npm-member-stray-lock. Claim-ID: 2026-10-08T00:20:33Z-7c3e91


    Generated by Claude Code

  2. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft fix PR: #1095


    Generated by Claude Code

  3. added a commit that references this issue on Oct 8, 2026
    4e520c3
  4. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] #1095 fixes the hosted (scan / get) and vendored legs. A member that holds only npm locks, under a package.json workspace whose root has an npm lock, is now refused (redirect_workspace_lockfile_elsewhere / vendor_lockfile_missing) and nothing is written.

    The PR uses Refs #1094 rather than Fixes, and I'm leaving this issue open for one remaining piece. vex still attests a member-lock pin that an earlier socket-patch version wrote. Gating it needs a new VEX omission code and note in CLI_CONTRACT, because the existing unattested gate's note is Gradle-specific. I also kept the yarn classic and Bun variants out of scope, since the report didn't test them.


    Generated by Claude Code

  5. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] npm bug-hunt follow-up (ledger #302), Linux, main ea09714 (after #1044 and #1073).

    • Still reproduces on ea09714. I re-ran the issue's repro on npm 10.9.4: a hosted scan from the member exits 0 with success and redirected: 1, the member's stray package-lock.json is pinned, and the root package-lock.json is untouched.
    • A stray member npm-shrinkwrap.json does the same. It goes through the same has_own_npm_family_lock early exit. I set up the member as above, but renamed its own lock to npm-shrinkwrap.json (the root depends on left-pad@1.2.0, so the member keeps a nested left-pad@1.3.0). A hosted scan from packages/a pins the member shrinkwrap, exits 0 with success / redirected: 1, and leaves the root lock unpinned. npm 7+ ignores a workspace member's shrinkwrap just as it ignores a member's package-lock, so root npm ci installs the member's copy unpatched. Reproduced twice each on npm 10.9.4 (Node 22) and npm 12.2.0 (Node 24.21).

    A fix should therefore treat any npm-family lock in a member (package-lock.json or npm-shrinkwrap.json) as not governing when an ancestor root lists the member under workspaces.


    Generated by Claude Code

  6. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-triage from the npm bug-hunt routine (ledger #302): fixed on main 16106b1 by #1095.

    Linux, npm 10.9.4 / Node 22.22, local mock patch API. Workspace root workspaces: ["packages/*"], member packages/a depends on ms@2.1.2 and has its own stray lock (npm install --package-lock-only --workspaces=false):

    • scan --mode hosted from packages/a → exit 1, Error (redirect_workspace_lockfile_elsewhere): . is a member of the npm workspace rooted at …: npm installs it from …/package-lock.json and ignores its own ./package-lock.json …; nothing was written.
    • scan --mode vendored --cwd packages/a → "Cannot vendor pkg:npm/ms@2.1.2: packages/a is a member of the npm workspace …", 0 vendored.
    • The same stray lock renamed to npm-shrinkwrap.json → the same redirect_workspace_lockfile_elsewhere refusal.
    • vex from the member → exit 2 (no attestation).
    • git status is clean after every command (nothing written).

    Closing as fixed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions