Skip to content

Vendored yarn classic replaces a symlinked yarn.lock with a regular file (hosted refuses the same lock), leaving the link's target unpatched; rollback never restores the link #627

Description

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

Summary

When yarn.lock is a symbolic link (a shared lock in a monorepo or Docker build context, e.g. yarn.lock -> ../shared/yarn.lock), scan --mode hosted refuses it with redirect_symlinked_file_unsupported ("…an atomic rename, which would replace the link…; nothing was written", exit 1). scan --mode vendored on the same project has no such gate. It exits 0 with status: "success", and its group commit renames the rewritten lock over the link:

  • yarn.lock becomes a regular file (git shows a typechange), and the link is gone.
  • The link's target, the lock the other consumers actually use, keeps the upstream registry entry, so they install unpatched bytes.
  • --dry-run predicts no refusal or warning (exit 0).
  • rollback restores the right bytes but writes them as a regular file, so the link is never restored.

Yarn itself writes through the link: after adding a dependency, yarn install (1.22.22) leaves yarn.lock a symlink and updates the target. The same happens with npm's package-lock.json in vendored mode (exit 0, link replaced), so this looks like a shared npm-family vendored gap rather than something specific to the yarn rewriter.

Impact

This is a refusal that should fire and doesn't. The project silently stops sharing its lock, and every other checkout or build that reads the link's target keeps the vulnerable package while this project's vex attests the patch. Undoing it with rollback / vendor --revert doesn't repair the link either.

Repro (Linux, any yarn 1.x; a local mock patch API at :8765)

mkdir -p shared p && cd p
echo '{"name":"a","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0"}}' > package.json
yarn install && mv yarn.lock ../shared/ && ln -s ../shared/yarn.lock yarn.lock
cp ../shared/yarn.lock ../orig
API="--api-url http://127.0.0.1:8765 --org org --api-token fake"
socket-patch scan --mode hosted   $API --json --yes; echo $?   # 1, redirect_symlinked_file_unsupported, nothing written
socket-patch scan --mode vendored $API --json --yes; echo $?   # 0, status success
ls -l yarn.lock                       # -rw-r--r-- regular file, link gone
cmp ../shared/yarn.lock ../orig && echo "shared lock still unpatched"
socket-patch rollback $API --json     # exit 0; yarn.lock is still a regular file

Expected vs actual

  • Expected: the vendored arm refuses a symlinked write target before writing anything, as hosted does (CLI_CONTRACT "Takeover symlink pre-check" and the redirect_symlinked_file_unsupported refusal; the bun binary lock "A symlinked binary write target is redirect_symlinked_file_unsupported (exit 1, including dry-run)"; uv vendored has pypi_uv_symlink_unsupported). The group commit's own crash recovery already refuses to "write through a symbolic link" (CLI_CONTRACT "Vendored group commit"). The other option is to write through the link like yarn does. Either way it shouldn't silently replace the link.
  • Actual: vendored exits 0 and replaces the link with a regular file, the target stays unpatched, the dry-run gives no signal, and rollback doesn't restore the link.

Matrix (main 045d7ec, Linux, Node 22; each cell run twice)

yarn hosted vendored dry-run vendored target lock rollback
1.7.0 refuses (exit 1) exit 0, no warning exit 0, link → regular file unpatched bytes restored, link not restored
1.10.1 refuses exit 0 same unpatched same
1.22.22 refuses exit 0 same unpatched same
npm 10 (package-lock.json, for comparison) — — exit 0, link → regular file — —

This is OS-independent filesystem logic, so it wasn't probed on macOS or Windows. The code is unchanged since v5.0.

Suspect code

  • crates/socket-patch-core/src/utils/group_commit.rs:676 (apply_durably) and apply_deferred just below it: atomic_write_bytes* renames over the path without checking symlink_metadata. Only recovery (crosses_symlink, group_commit.rs:909) checks.
  • Hosted has the gate: crates/socket-patch-core/src/hosted/engine.rs:1532 (view.is_symlink → symlink_refusal). The vendored yarn/npm arms have no equivalent.

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