[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).
Summary
Bundler 4.1 (4.1.0.beta3, released 2026-10-28) lets a Gemfile replace one gem's dependency with another: override "other-dep", with: "vuln-gem", in: "parent". The lock records that in a new OVERRIDES section, but it keeps parent's original dependency edge (parent (1.0.0) → other-dep). So no spec line in the lock names vuln-gem as a dependency.
The hosted redirect treats vuln-gem as transitive (it isn't in DEPENDENCIES) and appends a Socket source … do block, with its blank line. That part is correct. Then rollback / remove decide between "drop the appended block" and "restore an exact pin" with is_subdependency, which looks only for a 6-space spec sub-line naming the gem. No such line exists, so the restore takes the Decl::Direct arm. It writes a brand-new top-level gem "vuln-gem", "1.0.0" into the Gemfile and leaves vuln-gem (= 1.0.0) in DEPENDENCIES.
Impact
- The restored pair isn't the pre-redirect pair. The Gemfile gains a dependency the user never declared, which contradicts the contract.
- The new line pins the vulnerable version exactly. With real Bundler 4.1.0.beta3, after
vuln-gem 1.0.1 is published, bundle lock --update vuln-gem moves the pristine project to 1.0.1 but leaves the rolled-back project on 1.0.0. Unwinding the Socket patch silently blocks the project from taking the upstream fix.
- The pair is self-consistent (a frozen install passes), so nothing flags it.
Expected vs actual
crates/socket-patch-cli/CLI_CONTRACT.md (rollback/remove, gem) says: "A transitive gem (one the manifest never declared) gets an appended block with a blank line before it; the restore removes that block, its blank line and the DEPENDENCIES entry, so the pair comes back byte for byte."
- Expected:
rollback / remove give back the original Gemfile and Gemfile.lock byte for byte, as they do when parent depends on vuln-gem normally (control below).
- Actual: see the run below.
Pristine Gemfile:
source "http://127.0.0.1:40863/upstream"
gem "parent"
override "other-dep", with: "vuln-gem", in: "parent"
Pristine lock (Bundler 4.1.0.beta3):
GEM
remote: http://127.0.0.1:40863/upstream/
specs:
parent (1.0.0)
other-dep
tiny-dep (1.0.0)
vuln-gem (1.0.0)
tiny-dep
...
DEPENDENCIES
parent
OVERRIDES
"other-dep", with: "vuln-gem", in: "parent"
After scan --mode hosted, the Socket block is appended with a blank line (correct). The converging bundle install and a frozen fresh install both pass. Then rollback (or remove pkg:gem/[email protected] --yes) exits 0, and the Gemfile ends:
override "other-dep", with: "vuln-gem", in: "parent"
gem "vuln-gem", "1.0.0"
and the lock's DEPENDENCIES:
DEPENDENCIES
parent
vuln-gem (= 1.0.0)
Repro
This uses a scratch variant of crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs (mock upstream + patch registry, real gem build fixtures, real bundle). Set ZZ_PARENT_MODE=override and redirect_scanned_project adds two upstream gems, other-dep 1.0.0 and parent 1.0.0 (which depends on other-dep), and writes the Gemfile above. With ZZ_PARENT_MODE=plain, parent depends on vuln-gem directly and the Gemfile has only gem "parent". The scratch test zz_parent_unwind then does: hosted scan → bundle install → frozen fresh install → rollback / remove → compare against the pristine pair → frozen fresh install.
gem install bundler:4.1.0.beta3
BUNDLER_VERSION=4.1.0.beta3 SOCKET_PATCH_BUNDLER_E2E_VERSION=4.1.0.beta3 SOCKET_PATCH_BUNDLER_E2E_REQUIRED=1 \
ZZ_PARENT_MODE=override \
cargo test --release -p socket-patch-cli --all-features --test e2e_redirect_gem_build zz_parent_unwind -- --ignored --nocapture
Bundler-only impact check (a local gem generate_index repo with vuln-gem 1.0.0 and 1.0.1 published):
== pristine before: vuln-gem (1.0.0)
== pristine after 'bundle lock --update vuln-gem': vuln-gem (1.0.1)
== restored before: vuln-gem (1.0.0)
== restored after 'bundle lock --update vuln-gem': vuln-gem (1.0.0)
Matrix
| OS |
Ruby |
Bundler |
Shape |
rollback |
remove |
| Linux |
3.3.6 |
4.1.0.beta3 |
override …, with: "vuln-gem", in: "parent" |
fail (×2) |
fail (×2) |
| Linux |
3.3.6 |
4.1.0.beta3 |
control: parent depends on vuln-gem |
pass (byte-exact) |
pass (byte-exact) |
| Linux |
3.3.6 |
≤ 4.0.23 |
n/a: no override DSL |
n/a |
n/a |
| macOS / Windows |
any |
4.1.0.beta3 |
untested (OS-independent text logic) |
— |
— |
4.1.0.beta2 spelled the keys to: / from: and wasn't tested. Bundler 4.1 isn't final yet, so this hits early adopters first, and every 4.1 project once it ships.
First bad version
This isn't a regression in socket-patch. It's a new Bundler lock shape that the restore heuristic (from the #457 fix, #460) doesn't account for. Tested on main cbbbdb9.
Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/gem.rs:1543-1547: the transitive decision requires is_subdependency(&lines, &gem.name).
crates/socket-patch-core/src/patch/redirect/upstream/gem.rs:177-181: is_subdependency reads only 6-space spec sub-lines and ignores the OVERRIDES section, where with: "<name>" is what makes the gem a dependency.
Possible directions (not prescriptive): treat a gem named by an OVERRIDES with: value as a subdependency, or base the decision on the absence of a pre-redirect declaration rather than on the lock's dependency graph.
No probe runs: push-triggered probe workflows aren't currently running for this routine.