Skip to content

Yarn classic renamed-copy detection (#1489) misses a file: copy outside the project root and a tarball whose root isn't package/, so scans succeed and VEX attests while that copy installs unpatched #1516

Closed

Description

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

Summary

#1489 (for #1236 / #939) works out which package a renamed yarn copy holds ("lp2": "file:./lpdir" locks as lp2@file:./lpdir) by reading the copy itself through formats::yarn::copy::CopyRef. Hosted, vendored and vex all use that reader. When the read fails, the copy is treated as "not this package" and ignored. That's fail-open. Two common shapes make the read fail even though yarn installs the copy normally:

  1. A file: path outside the project root, e.g. "lp2": "file:../shared/left-pad" (a directory) or "lp2": "file:../shared/left-pad-1.3.0.tgz" (a tarball). Shared code in a non-workspace monorepo is often declared this way. classic_copy_ref returns None for any path that leaves the root (normalize_rel("", path)?).
  2. A file: tarball whose top-level directory isn't package/, e.g. a tarball made with git archive --prefix=left-pad-1.3.0/ or a GitHub release asset. yarn 1 strips the first path segment whatever it's called. tarball_manifest_name uses the lenient decoder, which strips only a literal package/, so it finds no package.json.

In both shapes, hosted and vendored scans exit 0 success with no copy warning. The in-run scan --vex and the lock-only vex attest not_affected for pkg:npm/left-pad@1.3.0. A fresh yarn install --frozen-lockfile installs node_modules/lp2 (left-pad@1.3.0) unpatched.

The same-name control shows the inconsistency. In a workspace member, "left-pad": "file:../../ext/lpdir" (same outside-root directory) is caught: redirect_yarn_classic_directory_skipped, vex_omitted, exit 1. Only the dependency name differs.

A third shape fails the same way: a renamed non-registry URL tarball ("lp2": "http://host/lp.tgz"), or a renamed GitHub shorthand, which yarn locks as a codeload URL. registry_tarball_name can't name it, so it's ignored. The docs only promise identification from "a registry tarball URL's path", so that one may be intended. Even so, ignoring a copy whose identity can't be read means VEX can attest while an unpatched copy is installed.

Impact

The VEX document says not_affected while the project ships an unpatched copy of the vulnerable name@version, and the scan gives no warning. This is the false attestation #1236 was filed for, reached through a different path spelling or a different tarball layout.

Repro (Linux, Node 22, main 23fa8d4, local mock patch API)

The mock serves a left-pad@1.3.0 patch that prepends a marker to index.js (SOCKET_API_URL / SOCKET_PROXY_URL / SOCKET_PATCH_SERVER_URL → http://127.0.0.1:8787).

mkdir -p ext/lpdir p && curl -sL https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz | tar xz -C ext/lpdir --strip-components=1
cd p
echo '{"name":"p","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0","lp2":"file:../ext/lpdir"}}' > package.json
yarn install                      # yarn.lock: "lp2@file:../ext/lpdir": version "1.3.0"
socket-patch scan --mode hosted --json --yes --vex vin.json    # exit 0, status success, warnings: [redirect_yarn_classic_berry_migration_risk] only
                                  # vin.json: not_affected (left-pad@1.3.0)
rm -rf node_modules
socket-patch vex --output vlock.json --json                     # exit 0, not_affected
yarn install --frozen-lockfile    # exit 0
grep -c SOCKET-PATCHED node_modules/left-pad/index.js   # 1
grep -c SOCKET-PATCHED node_modules/lp2/index.js        # 0  <- unpatched left-pad@1.3.0

# tarball-root shape (inside the project):
mkdir -p s/left-pad-1.3.0 && tar xzf left-pad-1.3.0.tgz -C s/left-pad-1.3.0 --strip-components=1
tar czf lp.tgz -C s left-pad-1.3.0        # root dir "left-pad-1.3.0/", not "package/"
# dependencies: {"left-pad":"1.3.0","lp2":"file:./lp.tgz"}  -> same result as above

Control, same path layout but with a package/ root and inside the project ("lp2": "file:./lp.tgz"): hosted warns redirect_copy_unpatched, vendored skips it with vendor_yarn_classic_non_registry_entry_skipped, vex_omitted, exit 1. That's correct, and it's what #1489 added.

Post-install vex (with node_modules present) refuses correctly in every shape, as in #1236.

Expected vs actual

  • docs/ecosystems.md, "yarn copies under another dependency name": "Hosted mode still pins the registry entry and names the copy (redirect_copy_unpatched …); vendored mode … names it; and vex never attests the package from that lock while the copy is there." The "yarn classic file: directory dependencies" section adds: "This holds whatever dependency name the project gives the copy."
  • Expected: an outside-root file: copy is read the way yarn reads it (the same-name skip already treats it as a copy), and a tarball's manifest is read after stripping its first path segment, as yarn does. If a renamed copy's identity can't be read at all (unreadable path, non-registry URL), the code should fail closed: warn, and don't attest the package from that lock.
  • Actual: the copy is silently ignored. The scan reports success and VEX attests not_affected.

Matrix (Linux, main 23fa8d4; each cell ×1, the 1.22.22 hosted cells ×2)

yarn mode file:../ext/lpdir file:../ext/lp.tgz file:./lp.tgz (root left-pad-1.3.0/) http://127.0.0.1:8799/lp.tgz control file:./lp.tgz (package/) control same-name file:../../ext/lpdir
1.7.0 hosted missed missed missed missed — —
1.10.1 hosted missed missed missed missed — —
1.22.22 hosted missed missed missed missed warned, omitted warned, omitted
1.22.22 vendored missed — missed missed warned, omitted —

"missed" means: scan exits 0 success with no copy warning, in-run and lock-only VEX say not_affected, and node_modules/lp2 installs unpatched. (.tar tarballs aren't a yarn 1 shape: yarn 1.22.22 fails to install file:./lp.tar.) macOS and Windows weren't probed. The logic is path and archive parsing, not OS-specific, though Windows file:..\\x spellings might add more cases.

First bad: not a regression. #1489 (after v5.0.1) introduced the renamed-copy detection, and these shapes were never covered. v5.0.1 misses every renamed copy (#1236).

Suspect code

  • crates/socket-patch-core/src/formats/yarn/copy.rs:107: normalize_rel("", path)? drops any file: path that leaves the root, so classic_copy_ref returns None. The callers vex/discover/yarn.rs:161 and vendor/yarn_copies.rs:64 then skip the block with ?. The same-name path (classic_copy_source → CopySource::Directory) has no such limit.
  • crates/socket-patch-core/src/formats/yarn/copy.rs:183: tarball_manifest_name reads with read_archive_bytes_to_map (lenient: strips only a literal package/). yarn 1, like npm, strips the first segment whatever it is (see the read_archive_bytes_to_map_strict doc comment in patch/package.rs:123).
  • crates/socket-patch-core/src/formats/yarn/copy.rs:69: a non-registry URL returns None (unknown), and every caller treats unknown as "not a copy of this package".
  • berry_copy_path (copy.rs, normalize_rel(&workspace, path)) has the same outside-root limit for yarn berry. I've handed that over to the yarn-berry routine; it's untested here.

Activity

  1. added a commit that references this issue on Oct 14, 2026
  2. mikolalysenko commented on Oct 14, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triage: priority:p1 (yarn classic, npm family). Not a duplicate: #1236 / #939 were the renamed-copy miss as a whole, which #1489 fixed for in-root file: copies with a package/ tarball root. This is a new set of shapes that the same reader drops.

    I confirmed the suspect code on main 23fa8d4:

    • formats/yarn/copy.rs classic_copy_ref: normalize_rel("", path)? returns None for any file: path outside the root, so callers treat the copy as unknown and skip it.
    • tarball_manifest_name reads through the lenient read_archive_bytes_to_map, which only strips a literal package/ prefix. yarn 1 strips the first segment whatever its name.
    • CopyRef::Url falls back to registry_tarball_name, so a non-registry URL gets no name and is also skipped.

    All three share one cause: an unreadable copy identity is treated as "not this package" (fail-open). The fix belongs at that boundary: read outside-root paths the way yarn does, strip the first tarball segment, and when identity still can't be read, warn and stop attesting. No open PR covers it yet.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 14, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (shared root cause: the yarn classic renamed-copy reader treats an unreadable copy identity as "not this package"). Branch: agent/fix-yarn-copy-identity-fail-open. Claim-ID: 2026-10-14T02:20:56Z-5b407f


    Generated by Claude Code

  4. mikolalysenko commented on Oct 14, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft fix PR: #1517


    Generated by Claude Code

  5. mikolalysenko commented on Oct 14, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Yarn Berry evidence from the scheduled Yarn Berry (2+) bug-hunt routine (ledger #305). The berry side shares the same reader (berry_copy_ref → berry_copy_path → normalize_rel, plus tarball_manifest_name), and it fails the same way with a real yarn install.

    Setup: Linux, Node 22, real yarn install (node-modules linker). The repo's wiremock hosted fixture (berry_hosted_project) with a root left-pad@1.3.0, plus a renamed copy lp2 of left-pad@1.3.0. Then scan --mode hosted --vex, a standalone lock-only vex --patch-server-url <mock>, and an in-place yarn install --immutable.

    lp2 descriptor main 23fa8d4 (4.0.2 / 4.18.1) PR #1517 head 6289505 (4.18.1)
    portal:../ext/lpdir (outside the root) scan exit 0, no copy warning; in-run and lock-only VEX attest not_affected for pkg:npm/left-pad@1.3.0; node_modules/lp2 unpatched redirect_copy_unpatched + vex_omitted, exit 1; lock-only vex exit 2
    file:../ext/lp.tgz same fail-open fixed
    file:../ext/lpdir (4.18.1 only) same fail-open fixed
    file:./lp.tgz with a left-pad-1.3.0/ root (berry also strips the first segment, whatever its name) same fail-open fixed
    control: file:./lpdir, file:./lp.tgz with a package/ root warns redirect_copy_unpatched (pass) pass

    Every failing cell reproduced twice. Lock entries look like "lp2@portal:../ext/lpdir::locator=…%40workspace%3A." (version: 0.0.0-use.local) and "lp2@file:./lp.tgz#./lp.tgz::hash=…&locator=…". I didn't test vendored berry (vendor_copy_unpatched) on either SHA. When #1517 merges, the Berry routine will re-check these cells on 4.0.2 and 4.18.1.


    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