[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).
Summary
Bundler's config loader drops a trailing # comment from a .bundle/config value. socket-patch's line scraper (bundle_config_setting* / parse_bundle_config_path → unquote_bundle_config_value) keeps it. Take BUNDLE_PATH: .gems # project-local gems. Bundler installs into and loads from .gems/ruby/<abi>/. The crawler resolves the root to a directory literally named .gems # project-local gems, which doesn't exist, and falls back to the gem env homes.
Agent apply therefore patches the system copy of the gem and reports success. vex then attests not_affected while bundle exec loads the unpatched project copy. Nothing warns.
Bundler 4.1 writes .bundle/config values unquoted (BUNDLE_PATH: .gems), so a hand-added trailing comment produces exactly this shape. Bundler 2.4 through 4.1 all honour the commented value (verified below).
Impact
- Silent unpatching plus a false VEX attestation: the patched bytes land in a copy the app doesn't load.
- The same reader backs the other settings, so the divergence probably reaches them too. I traced these in the code but didn't run them end to end:
Repro (Linux, Ruby 3.3.6; no API needed)
mkdir app && cd app && mkdir .bundle
printf 'source "https://rubygems.org"\ngem "colorize", "0.8.1"\n' > Gemfile
printf -- '---\nBUNDLE_PATH: .gems # project-local gems\n' > .bundle/config
gem install colorize -v 0.8.1 # a system copy also exists (common on dev machines / images)
bundle install # installs into ./.gems/ruby/3.3.0
# Hand-written agent manifest: one patch to package/lib/colorize.rb (appends a marker line),
# .socket/manifest.json + .socket/blobs/<afterHash> (git-sha256 hashes).
socket-patch apply --offline # exit 0, "applied"
socket-patch vex --product pkg:gem/app@1.0.0 --output vex.json # exit 0, not_affected
grep -c SOCKET_PATCHED_MARKER .gems/ruby/3.3.0/gems/colorize-0.8.1/lib/colorize.rb # 0 (loaded copy)
grep -c SOCKET_PATCHED_MARKER "$(gem env gemdir)/gems/colorize-0.8.1/lib/colorize.rb" # 1 (unused copy)
bundle exec ruby -e 'require "colorize"; puts $LOADED_FEATURES.grep(/colorize.rb$/)' # → ./.gems/... (unpatched)
Bundler's own view: bundle config get path → ".gems", and Bundler::YAMLSerializer.load("---\nBUNDLE_PATH: .gems # c") → {"BUNDLE_PATH"=>".gems"} (strip_comment in bundler/yaml_serializer.rb, 2.5+; 2.4.22's loader gives the same result).
Expected vs actual
- Expected: install-root discovery follows Bundler's settings (CLI_CONTRACT.md: the bundle path, cache path and
gemfile are resolved "in Bundler::Settings priority", and .bundle/config is read like Bundler reads it). So apply patches .gems/ruby/3.3.0/gems/colorize-0.8.1, and vex attests only if that copy is patched.
- Actual: the comment becomes part of the path. The project copy is never crawled, the
gem env copy is patched instead, and vex exits 0 with not_affected.
OS × version
| OS |
Ruby |
Bundler |
BUNDLE_PATH: .gems # c |
Control BUNDLE_PATH: .gems |
| Linux |
3.3.6 |
4.1.0.beta1 (written by bundle config set --local path .gems, comment appended) |
fail (loaded copy unpatched, VEX not_affected) |
— |
| Linux |
3.3.6 |
4.0.22 |
fail (×2) |
pass |
| Linux |
3.3.6 |
2.6.9 |
fail |
pass |
| Linux |
3.3.6 |
2.4.22 |
fail |
pass |
| macOS / Windows |
— |
— |
untested (the parsing is OS-independent) |
— |
The v4.0.0 release (npm @socketsecurity/socket-patch-linux-x64-gnu@4.0.0) behaves the same, so this isn't a regression. Tested on main 9c43dfc.
Suspect code
No probe runs: the logic is OS-independent string parsing.
[agent] Found by the scheduled Bundler (RubyGems) bug-hunt routine (ledger #316).
Summary
Bundler's config loader drops a trailing
# commentfrom a.bundle/configvalue. socket-patch's line scraper (bundle_config_setting*/parse_bundle_config_path→unquote_bundle_config_value) keeps it. TakeBUNDLE_PATH: .gems # project-local gems. Bundler installs into and loads from.gems/ruby/<abi>/. The crawler resolves the root to a directory literally named.gems # project-local gems, which doesn't exist, and falls back to thegem envhomes.Agent
applytherefore patches the system copy of the gem and reports success.vexthen attestsnot_affectedwhilebundle execloads the unpatched project copy. Nothing warns.Bundler 4.1 writes
.bundle/configvalues unquoted (BUNDLE_PATH: .gems), so a hand-added trailing comment produces exactly this shape. Bundler 2.4 through 4.1 all honour the commented value (verified below).Impact
BUNDLE_CACHE_PATH: vendor/gems # …→ the hosted stale-install guard (CLI_CONTRACT.md "Gem stale-install guard") would look in the wrong cache dir.BUNDLE_PATH__SYSTEM: true # …→ isn't seen astrue(the Gem crawler ignores Bundler'spath.system: truewhen a leftovervendor/bundleexists, so agentapplypatches the unused copy andvexattestsnot_affectedwhile Bundler loads the unpatched system gem #915 shape).BUNDLE_GEMFILE: gems.rb # …→redirect_gem_bundle_gemfile_unsupported, which fails closed.~/.bundle/config/BUNDLE_USER_CONFIG), so a globalcache_pathorgemfilegets no warning or refusal and VEX attests an unpatched install #577) goes through the same reader.Repro (Linux, Ruby 3.3.6; no API needed)
Bundler's own view:
bundle config get path→".gems", andBundler::YAMLSerializer.load("---\nBUNDLE_PATH: .gems # c")→{"BUNDLE_PATH"=>".gems"}(strip_commentinbundler/yaml_serializer.rb, 2.5+; 2.4.22's loader gives the same result).Expected vs actual
gemfileare resolved "inBundler::Settingspriority", and.bundle/configis read like Bundler reads it). Soapplypatches.gems/ruby/3.3.0/gems/colorize-0.8.1, andvexattests only if that copy is patched.gem envcopy is patched instead, andvexexits 0 withnot_affected.OS × version
BUNDLE_PATH: .gems # cBUNDLE_PATH: .gemsbundle config set --local path .gems, comment appended)not_affected)The v4.0.0 release (npm
@socketsecurity/socket-patch-linux-x64-gnu@4.0.0) behaves the same, so this isn't a regression. Tested on main9c43dfc.Suspect code
crates/socket-patch-core/src/crawlers/ruby_crawler.rs:1474(parse_bundle_config_path),:1507(bundle_config_setting_including_empty) and:1520(unquote_bundle_config_value). The value is trimmed and unquoted, but a# …tail isn't stripped the way Bundler'sYAMLSerializer#strip_commentdoes it (val.split("#", 2).first.stripwhen the value contains#and doesn't start with it).No probe runs: the logic is OS-independent string parsing.