Skip to content

Yarn classic VEX attests not_affected while a file: directory copy of the patched package@version installs unpatched, and hosted scan gives no warning for that copy #921

Description

[agent] Found by the scheduled Yarn classic (1.x) bug-hunt routine (ledger #304).

Summary

In a yarn classic workspace where one member uses the registry left-pad@^1.3.0 and another uses a file: directory copy of the same left-pad@1.3.0 (for example a local fork or an in-repo copy), yarn.lock has two blocks:

left-pad@^1.3.0:
  version "1.3.0"
  resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a…"
  integrity sha512-…

"left-pad@file:forks/left-pad":
  version "1.3.0"

yarn 1 copies the file: directory into b/node_modules/left-pad, so that's a second installed copy of left-pad@1.3.0. socket-patch's own crawler counts it: agent mode patches b/node_modules/left-pad alongside the root copy.

Hosted and vendored modes rewire only the registry block. The file: block has no resolved line, so:

  • Hosted / vendored scan exit 0 with status: success, redirected: 1 and no warning that a copy stays unpatched.
  • Lock-only vex (no node_modules) attests not_affected (exit 0) in both modes. After the next yarn install --frozen-lockfile, b/node_modules/left-pad/index.js is the unpatched upstream file.
  • Vendored post-install vex still attests not_affected. Its warning tells the user to "re-run your package manager's install to resync it", but a reinstall never patches the file: copy.
  • Hosted post-install vex and agent mode behave correctly: the first omits the package (not_applied), the second patches both copies.

Impact

A VEX document claims the product isn't affected by the CVE while it ships an unpatched copy of the vulnerable package@version. Nothing in the scan output says that copy was left behind.

This is the same false claim the git-copy handling (#363 / #710) was built to prevent. It's also distinct from #758: there the lock can't show the bundled copy, but here yarn.lock names the file: block, with its version, explicitly.

Expected vs actual

docs/ecosystems.md ("yarn classic git dependencies") sets the rule for a copy the lock rewrite can't reach: the entry is left untouched with a warning naming it (redirect_yarn_classic_git_skipped), and "vex never attests the package from that lock while the git copy is there". The npm-alias rule in the same section works the same way: an unrewritable consumer gets redirect_yarn_classic_alias_skipped, because "that copy keeps the unpatched artifact". CLI_CONTRACT.md (hosted) says that "A dep counts as redirected only when its hosted-artifact URL … actually landed in a project file".

  • Expected: the file: block of the patched name@version is reported (a redirect_yarn_classic_* / vendor_* warning naming the entry), and vex doesn't attest left-pad@1.3.0 from this lock while that copy exists. This is what extract_classic already does for git_copies.
  • Actual: no warning is emitted, and lock-only vex (both modes) and post-install vex (vendored) attest not_affected.

Related, same root: when the file: block is the only left-pad@1.3.0 block ("left-pad": "file:./forks/left-pad", or yarn merging left-pad@^1.3.0, "left-pad@file:forks/left-pad": into one block with no resolved), hosted scan --json returns status: success, redirected: 0, warnings: [], skipped: []. The rewriter sets matched_any = true before finding there's no resolved line to rewrite, so it suppresses redirect_yarn_classic_entry_not_found. Only the human stderr line "no lockfile entry pinning it could be rewritten" says anything, and --json has no equivalent of it. Vendored refuses that shape with the misleading vendor_lock_entry_not_found ("make sure the package is installed and locked (yarn install)"), the same shape as #857.

Repro

This uses a local mock patch API serving a left-pad@1.3.0 patch: the self-contained run-18 mock described in the ledger, also embedded in the probe workflow on bughunt/yarn-classic/20261006-gh-shorthand.

# python3 mock.py 8787 left-pad-1.3.0.tgz &   (mock from the ledger)
BIN=target/release/socket-patch; Y="yarn"   # yarn 1.x
API="--api-url http://127.0.0.1:8787 --org o --api-token x"
export SOCKET_PATCH_SERVER_URL=http://127.0.0.1:8787
mkdir -p w/forks w/a w/b && cd w
curl -sL https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz | tar xz -C forks && mv forks/package forks/left-pad
echo '{"name":"root","version":"1.0.0","private":true,"workspaces":["a","b"]}' > package.json
echo '{"name":"a","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}' > a/package.json
echo '{"name":"b","version":"1.0.0"}' > b/package.json
$Y install
echo '{"name":"b","version":"1.0.0","dependencies":{"left-pad":"file:../forks/left-pad"}}' > b/package.json
$Y install                                  # yarn.lock now has the registry block + the file: block
$BIN scan --mode hosted --json --yes $API   # status success, redirected 1, warnings []
rm -rf node_modules */node_modules
$BIN vex --output v.json --product pkg:npm/root@1.0.0 $API   # exit 0, not_affected
$Y install --frozen-lockfile
grep -c SOCKET-PATCHED node_modules/left-pad/index.js b/node_modules/left-pad/index.js
# node_modules/left-pad/index.js:1
# b/node_modules/left-pad/index.js:0      <- unpatched copy, already attested

For vendored, use --mode vendored. Lock-only and post-install vex both attest.

OS × version

Linux, main 9c43dfc, Node 22. Each cell was run twice on 1.22.22 and once on the others.

yarn hosted scan warns hosted lock-only vex hosted post-install vex vendored scan warns vendored lock-only vex vendored post-install vex
1.0.2 no (bug) attests (bug) omits (ok) n/a (yarn ≤1.6 can't install file: tarballs) n/a n/a
1.7.0 no (bug) attests (bug) omits (ok) no (bug) attests (bug) attests (bug)
1.10.1 no (bug) attests (bug) omits (ok) no (bug) attests (bug) attests (bug)
1.22.22 no (bug) attests (bug) omits (ok) no (bug) attests (bug) attests (bug)

macOS and Windows weren't probed. The code path is platform-independent lock parsing.

Release v4.0.0 (1.22.22): hosted scan is silent the same way, lock-only vex omits the package (package_not_found), and post-install vex attests not_affected with the file: copy unpatched. So the defect predates v5, and v5 moved it from post-install to lock-only.

Suspect code

  • crates/socket-patch-core/src/vex/discover/yarn.rs:216: let Some(resolved) = resolved else { return; } drops a file: / no-resolved block of the same name@version without recording it as an unpatched copy. Compare the git_copies path at :211, which calls resolved_elsewhere and blocks attestation.
  • crates/socket-patch-core/src/patch/redirect/mod.rs:3342: matched_any = true is set before the rewrite. A block with no resolved line yields rewritten == *block, so there's no edit, and no redirect_yarn_classic_* warning names the copy left unpatched. The vendored yarn classic wirer has the same gap.

Found by real-yarn runs, no probe branch (Linux only).

No activity

Activity on this issue will appear here.

Activity

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