Skip to content

Gem hosted → vendored takeover un-hosts a gem declared inside a group block and then refuses to vendor it (gemfile_declaration_not_editable), so the project silently goes back to unpatched #775

Description

[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).

Summary

Hosted mode accepts a gem declared inside a group … do block. It wraps the declaration in a source "<patch registry>" do … end block inside the group, and the frozen install is patched. Vendored mode refuses an indented declaration with gemfile_declaration_not_editable, which is fail-closed by design.

Taking over such a gem with scan --mode vendored or get <purl> --mode vendored goes wrong in this order:

  1. The takeover restores the hosted pin to upstream rubygems.org (Gemfile + Gemfile.lock, CHECKSUMS back to the upstream sha256) and writes that restore.
  2. The gem vendored backend then reaches plan_gemfile_edit and refuses the indented declaration with gemfile_declaration_not_editable.

The run exits 1 / partial_failure, but the restore has already been written. The gem is now neither hosted nor vendored, and the next frozen bundle install installs and loads the unpatched upstream bytes. The --dry-run of the same command gives no warning: it exits 0 with vendor.patches[].action: "would_vendor", with no vendor_would_revert_redirect and no refusal preview.

socket-patch vendor (the eject path) on the same project does the right thing. It reports eject_rolled_back + gemfile_declaration_not_editable, and the hosted pin stays byte-identical.

Impact

group :development do … end / group :test do … end is one of the most common Gemfile shapes. A user who switches from hosted to vendored with scan --mode vendored loses an active security patch. The working tree now shows a clean upstream Gemfile / Gemfile.lock diff that is easy to commit, and CI's next BUNDLE_FROZEN=true bundle install installs the vulnerable gem with exit 0.

Repro (Linux, Ruby 3.3.6, Bundler 4.0.17, main 045d7ec)

A local mock of the patch API and patch registry (the run-13 mock from the ledger: it serves pkg:gem/colorize@0.8.1 with a rebuilt patched .gem) runs on $MOCK. Real rubygems.org is the upstream.

SPA="--api-url $MOCK --patch-server-url $MOCK --org org --api-token fake"
mkdir proj && cd proj
printf 'source "https://rubygems.org"\n\ngem "rake"\n\ngroup :development do\n  gem "colorize", "~> 0.8.1"\nend\n' > Gemfile
printf 'vendor/bundle/\n' > .gitignore
bundle config set --local path vendor/bundle && bundle lock
git init -q . && git add -A && git commit -qm init
socket-patch scan --mode hosted --json --yes $SPA      # exit 0, redirected 1 (Gemfile, Gemfile.lock)
bundle install && git add -A && git commit -qm hosted   # installs the patched gem

socket-patch scan --mode vendored --json --yes --dry-run $SPA   # exit 0, "would_vendor"
socket-patch scan --mode vendored --json --yes $SPA             # exit 1, partial_failure:
#   skipped  vendor_takeover_reverted_redirect  "pkg:gem/colorize@0.8.1 was hosted; restored its upstream registry entry (Gemfile, Gemfile.lock) before vendoring (mode takeover)"
#   failed   gemfile_declaration_not_editable   "the `gem \"colorize\"` declaration is indented (inside a group/conditional block)"
git diff --stat            # Gemfile and Gemfile.lock: source block, patch-registry GEM section and patched CHECKSUMS gone
grep -c patch-registry Gemfile.lock   # 0
grep -c socket/vendor Gemfile.lock    # 0
# fresh checkout of the committed files:
BUNDLE_FROZEN=true bundle install     # exit 0
bundle exec ruby -e 'puts File.read(Gem.loaded_specs["colorize"].full_gem_path + "/lib/colorize.rb")[-20..]'   # upstream bytes, no patch marker

get pkg:gem/colorize@0.8.1 --mode vendored behaves the same way.

Expected vs actual

  • Expected: a takeover that vendored mode will refuse leaves the hosted wiring in place. CLI_CONTRACT.md, "Takeover reconciliation", describes the Bun preflight in these terms: "a hosted purl on a lock the vendored backend refuses … is reported failed <code> with the hosted wiring … byte-untouched (exit 1 / partial_failure): the package stays hosted-patched instead of being un-hosted and then refused", and --dry-run "previews that same failed code (exit-code parity with the wet run …)". The gem backend's own doc comment on gem_manifest_refusal (crates/socket-patch-core/src/vendor/gem.rs:144-146) says the takeover asks first "so a refused gem keeps its hosted wiring instead of ending up unpatched in both modes". The vendor eject path already rolls back (eject_rolled_back).
  • Actual: the pre-restore gem gate at crates/socket-patch-cli/src/commands/vendor.rs:2398-2410 only checks the manifest/lock pair (gem_manifest_refusal: a gems.rb twin or BUNDLE_GEMFILE). The Gemfile declaration gate (plan_gemfile_edit → gemfile_declaration_not_editable, gem.rs:409-427) only runs inside the backend, after restore_upstream (vendor.rs:2458) has written. Nothing puts the hosted pin back, and the dry run does not preview the refusal.
OS Ruby / Bundler scan --mode vendored get --mode vendored vendor (eject)
Linux 3.3.6 / 4.0.17 (CHECKSUMS) un-hosted + refused, frozen install unpatched (2/2 runs) un-hosted + refused, unpatched rolled back, still patched (correct)
Linux 3.3.6 / 2.6.9 (--add-checksums) un-hosted + refused, unpatched not run not run
Linux 3.3.6 / 2.4.22 (no CHECKSUMS, converged) un-hosted + refused, unpatched not run not run

The logic is OS-independent (the gate is pure Gemfile text, and no bundler process is spawned on this path), so no macOS/Windows probe was run.

First bad version: on this path, the released v4.0.0 stops earlier with no_local_source and leaves the hosted pin intact, so the un-hosting is new in v5 main (045d7ec).

Side note: with a top-level declaration, the same takeover also un-hosts first and then fails vendor_prebuilt_required when the service has no prebuilt artifact (my mock serves none). That's the same "restore written before the vendor step can fail" shape, as in #659 / #688 for npm. The group-block case above is deterministic and gem-specific: a pure Gemfile-text check that can run before the restore, on the Gemfile text the restore would produce.

Suspect code

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions