fix(gem): crawl bundle-path roots in bundler precedence order, patch every coexisting copy, contain config-sourced roots - #222
Conversation
…every coexisting copy, contain config-sourced roots Post-merge audit follow-up on #218 (gem crawler flat-BUNDLE_PATH discovery), mirroring the #216 npm multi-copy precedent (0433bcb). 1. MULTI-COPY (most severe): bundler's scoped `<engine>/<abi>/gems` and flat `gems/` stores coexist under one root, each holding a REAL physical copy of the same gem@version — exactly the state #218's tests create. First-wins merging resolved the purl to ONE path, so apply patched one store and reported success while the other bundler loaded pristine (vulnerable) bytes. Fix mirrors #216: `find_all_packages_for_purls` now routes the release-variant ecosystems through an accumulating `merge_variant_copies` (reusing `push_path`, base-PURL keyed, precedence order kept) and apply's variant branch fans out per copy for gem — per-copy Applied events, `summary.applied` counts each copy, a copy matching no variant fails loudly. Rollback already carried every copy via `merge_qualified` and its per-path grouping; the new in-process suite pins both directions. PyPI/Maven deliberately keep the one-representative contract (the nuget `already_patched` double-patch regression from #216's second commit), as do all collapsing consumers (vendor/vex/setup/get/repair-vendor). scan --sync patches every copy too (it runs the real nested apply); scan's inventory stays purl-level, byte-identical to npm's crawl_all purl-dedup precedent. 2. PRECEDENCE: roots probed local-config > env > default vendor/bundle (bundler's real precedence; the old order was inverted), so first-representative consumers pick the copy bundler actually loads. 3. REGRESSION vs pre-#218: env/config roots no longer trip the `gem env` fallback early-return — only the historic project-local vendor/bundle probe keeps it. Default gems (rexml/json) live only in the DEFAULT/system gem homes, so an env-BUNDLE_PATH project gets those homes appended (deduped) again. 4. SECURITY: a config-sourced BUNDLE_PATH (committed .bundle/config = attacker-authored input, and a scan/apply WRITE-target root) must now, after ~ expansion and lexical normalization, stay contained in the project root — otherwise the root is skipped with a `gem_bundle_config_path_ignored` stderr warning naming the value. Windows rooted forms (`\evil`, `C:evil`) take the strict branch. Env-sourced BUNDLE_PATH stays trusted (user's own environment) but is normalized for dedup. `normalize_lexically` is hoisted from the composer crawler into utils::fs and shared. 5. `~` EXPANSION: a leading `~`/`~/...` in BUNDLE_PATH expands against home (bundler File.expand_path), env-injectable via the _with_env seams for hermetic tests. 6. BUNDLE_PATH__SYSTEM: `"true"` makes bundler ignore the recorded path — the config entry now parses as unset and the fallback finds the system gem homes. 7. TEST HERMETICITY: the six crawler_ruby_e2e tests that read ambient BUNDLE_PATH/BUNDLE_APP_CONFIG now route through the new `get_gem_paths_with_env` seam; a new e2e pins env-root + gem-env fallback coexistence (finding 3). 8. CLI_CONTRACT.md: the stale "gem inspects only <cwd>/vendor/bundle" claim replaced with the real root model, the containment policy, and the multi-copy behavior. TDD evidence (red -> green): dispatch-level `find_all_packages_for_purls_carries_every_gem_store_copy`, crawler `bundle_roots_probe_in_bundler_precedence_order`, and the new `in_process_gem_multicopy.rs` (real binary apply/rollback over a coexisting two-store tree) all failed on base — the flat copy stayed byte-for-byte VULNERABLE while apply reported success — and pass now. Live era-image proof (docker run --rm, socket-patch-test-gem-b1:gemx, bundler 1.17.3): the baked pre-fix binary on a coexist fixture reports status=success/applied=1 while the flat store's copy — loaded via real GEM_HOME resolution in the container — still evaluates VULNERABLE; the fixed binary reports applied=2 and both stores load FIXED. Gates: touched files rustfmt-clean; cargo clippy --workspace --all-features -D warnings clean; core --lib 2413 passed; cli --lib 430 passed; crawler_ruby_e2e 25, crawler_composer_e2e 32, crawlers_empty_paths_e2e 13; e2e_gem hermetic 8; in-process gem+npm multicopy suites; cli_gem_variant_mismatch_policy 6; docker_e2e_gem, docker_e2e_vendor_gem, docker_e2e_pypi, docker_e2e_maven all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Gem apply patches global gem homes
- Changed early-return condition to prevent adding global gem homes when any bundle stores exist, avoiding unwanted patching of machine-wide gem installations.
Or push these changes by commenting:
@cursor push 81f3fac8b4
Preview (81f3fac8b4)
diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
@@ -77,16 +77,16 @@
)
.await;
- // Historic early-return, kept ONLY for the implicit project-local
- // `vendor/bundle` probe: a deployment-style install is the
- // project's one gem source, so the ambient gem homes don't apply.
- // Stores found via an env/config root do NOT suppress the fallback
- // below: default gems (rexml, json, …) never live in a bundle path
- // — they ship with ruby in the DEFAULT/system gem homes — so an
- // env-`BUNDLE_PATH` project still needs the `gem env` homes to see
- // them (the explicit-roots feature briefly suppressed that
- // pre-existing fallback).
- if discovery.default_root_has_stores {
+ // When ANY bundle stores are found (vendor/bundle, env, or config),
+ // return ONLY those stores without appending `gem env` homes. A
+ // deployment-style install or an env/config-rooted store is the
+ // project's gem source, so the ambient gem homes must not become
+ // apply patch targets — patching a machine-wide global gem home
+ // (which `bundle exec` doesn't load) risks permission failures and
+ // the nuget-style global-cache hazard this otherwise avoids.
+ // Default gems (rexml, json, …) never in a bundle path also aren't
+ // in the manifest, so skipping the fallback here is safe.
+ if !discovery.stores.is_empty() {
return Ok(discovery.stores);
}You can send follow-ups to the cloud agent here.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit af59554. Configure here.
| ignoring it as an install root (a committed .bundle/config is \ | ||
| untrusted input — set BUNDLE_PATH in the environment to use an \ | ||
| out-of-tree bundle path)" | ||
| ), |
There was a problem hiding this comment.
Config skip warning missing from JSON
Medium Severity
Skipped out-of-tree config BUNDLE_PATH is reported only via eprintln! with code gem_bundle_config_path_ignored. --json envelopes never get a warnings[] entry, and --silent still prints. Apply/scan can then fall through to vendor/bundle or gem env homes and look successful while the configured store was ignored.
Triggered by learned rule: CLI warnings must be surfaced in both stderr (human) and JSON envelope (machine)
Reviewed by Cursor Bugbot for commit af59554. Configure here.
|
|
||
| // Not a Ruby project — return empty | ||
| Ok(Vec::new()) | ||
| Ok(paths) |
There was a problem hiding this comment.
Gem apply patches global gem homes
High Severity
When an env or config bundle root has stores, get_gem_paths now also appends gem env homes, and apply patches every gem copy. A gem@version that exists in both the bundle store and GEM_HOME is applied in the shared global home (or the run fails on permissions) even though bundle exec loads the bundle copy.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit af59554. Configure here.



Follow-up fix for the verified post-merge audit findings on #218 (gem crawler flat-
BUNDLE_PATHdiscovery, squash 3e51259). Mirrors the multi-copy machinery #216 (squash 0433bcb) built for npm rather than inventing a parallel mechanism.Audit findings and fixes
MULTI-COPY silent false "applied" (most severe). When the same
gem@versionexists in two discovered stores under one root — scopedvendor/bundle/<engine>/<abi>/gemsbeside flatvendor/bundle/gems, a real coexist state fix(gem): discover flat BUNDLE_PATH gem layouts — bundler-1 env-var installs were invisible to scan/get/apply #218's tests create — first-wins merging resolved the purl to ONE path:applypatched that copy and reportedsuccesswhile the bundler major that loads the other store ran pristine (vulnerable) bytes.Fix (the fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216 pattern, reused):
find_all_packages_for_purlsnow routes the release-variant ecosystems through an accumulatingmerge_variant_copies(built on the samepush_paththe npm merge uses; base-PURL keyed; store-precedence order preserved), and apply's variant branch fans out per copy for gem — per-copyappliedevents,summary.appliedcounts each copy (the signal a second copy exists), a failure on any copy fails the run, and a copy that matches no variant fails loudly instead of hiding behind a sibling's success. Rollback already carried every copy post-fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216 (merge_qualifiedaccumulates + per-path grouping); the new in-process suite pins both directions.Scope decisions: PyPI/Maven keep the one-representative contract — their crawlers resolve one install dir per version, and fanning their per-root aliases out is exactly the
already_patcheddouble-patch regression fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216's second commit reverted for nuget. All collapsing consumers (vendor/vex/setup/get/repair vendor) keep the first (highest-precedence) copy. On the audit's scan clause:scan --syncpatches every copy (it runs the real nestedapply::run), and scan's inventory stays purl-level — deliberately byte-identical to the npm precedent, whosecrawl_allkeeps the same purl dedup (check_package); emitting duplicate purl rows would diverge from npm and inflatescannedPackagescounts.PRECEDENCE. Root probe order was
vendor/bundle→ env →.bundle/config, inverting bundler's real precedence. Roots are now probed local config → env → defaultvendor/bundle(Bundler::Settingsorder), so wherever one representative is still chosen, it is the copy bundler actually loads. Pinned bybundle_roots_probe_in_bundler_precedence_order(RED on base).REGRESSION vs pre-fix(gem): discover flat BUNDLE_PATH gem layouts — bundler-1 env-var installs were invisible to scan/get/apply #218 (gem-env fallback suppressed). The new env/config roots tripped the pre-existing early-return in
get_gem_paths, so an env-BUNDLE_PATHproject lost thegem envfallback that surfaces DEFAULT/system gem homes (default gems like rexml/json never live in a bundle path). Restored: only the historic project-localvendor/bundleprobe keeps its early-return; env/config-rooted stores get the gem-env homes appended, deduped. New e2eget_gem_paths_env_root_still_includes_gempath_homesextends the "local includes every gempath home" pin to the env-root case.SECURITY: config-sourced roots contained-or-skipped. A repo-committed
.bundle/configBUNDLE_PATHis attacker-authored input that became a scan/apply WRITE-target root with no guard (absolute values verbatim,..unnormalized — a malicious clone could directapplyinto/usr/localor a sibling checkout). Policy now: after~expansion and lexical normalization, a CONFIG-sourced root must be contained within the project root, else it is skipped with a loud stderr warning naming the config value (gem_bundle_config_path_ignored— the crawler has no structured warning channel, so the established stderr path). Windows rooted forms (\evil,C:evil) take the strict branch —Path::joinwould otherwise substitute them into the base. ENV-sourcedBUNDLE_PATHstays trusted (the user's own environment) but is..-normalized for dedup.normalize_lexicallyis hoisted from the composer crawler'sinstall-pathguard intoutils::fsand shared (same containment posture as thesetup/gemplugin-index cleanup). Tests: absolute-outside skipped,../siblingskipped, contained relative (incl..//..detours) accepted, plus a pure containment contract.~expansion.resolve_bundle_pathtreated~/storeas relative (cwd.join); bundlerFile.expand_paths it against$HOME. A leading bare-~component now expands against home, injected through the_with_envseams so tests stay hermetic (~useris left as before — it needs bundler's passwd lookup).BUNDLE_PATH__SYSTEM.BUNDLE_PATH__SYSTEM: "true"makes bundler ignore the recorded path entirely; the config entry now parses as unset (exact-"true"coercion, either key order), and finding 3's fallback surfaces the system gem homes.Test hermeticity. The six
crawler_ruby_e2e.rstests that laid a Gemfile and asserted exact equality onget_gem_pathswhile reading ambientBUNDLE_PATH/BUNDLE_APP_CONFIGnow route through the new publicget_gem_paths_with_envseam — machines with bundler configured no longer go red. The new in-process suite additionally scrubsBUNDLE_*from the child env.CLI_CONTRACT.md. The stale "gem — the crawler inspects only the project rooted at
--cwd" claim replaced with the truthful model: envBUNDLE_PATHhonored (user-controlled, may point out of tree), config-sourced paths contained-or-skipped per finding 4, bundler-precedence root order, gem-env fallback semantics, and the multi-copy apply/rollback behavior.TDD evidence (red → green)
All three headline tests failed on base with the exact defect (flat copy byte-for-byte VULNERABLE while apply reported success / one path carried):
ecosystem_dispatch::find_all_packages_for_purls_carries_every_gem_store_copy(dispatch carries both copies, scoped first; collapsing wrapper keeps one)ruby_crawler::bundle_roots_probe_in_bundler_precedence_order(config → env → default)tests/in_process_gem_multicopy.rs(real binary: apply patches BOTH copies,summary.applied == 2, twoappliedevents; rollback restores BOTH)Live era-image proof (docker run --rm only)
socket-patch-test-gem-b1:gemx(aarch64, ruby 3.1.7, bundler 1.17.3), coexist fixture, oracle loaded via realGEM_HOMErubygems resolution inside the container:status: success,applied: 1— flat store loadsVULNERABLE, scoped loadsFIXED→ the silent partial, live.applied: 2— flatFIXED, scopedFIXED.Gates (verbatim results)
rustfmt --check: clean (cargo fmtrepo-wide was deliberately not applied — main carries pre-existing drift in unrelated test files)cargo clippy --workspace --all-features -- -D warnings: cleancargo test -p socket-patch-core --lib: 2413 passed; 0 failed; 4 ignoredcargo test -p socket-patch-cli --lib: 430 passed; 0 failed; 1 ignoredcrawler_ruby_e2e: 25 passed ·crawler_composer_e2e: 32 passed ·crawlers_empty_paths_e2e: 13 passede2e_gem(hermetic): 8 passed ·in_process_gem_multicopy: 2 ·in_process_npm_multicopy: 2 ·cli_gem_variant_mismatch_policy: 6 ·apply_invariants: 7 ·apply_network: 9 ·cli_apply_silent: 5 ·cli_rollback_silent: 5 ·e2e_scan: 5docker_e2e_gem: 2 ·docker_e2e_vendor_gem: 2 ·docker_e2e_pypi: 1 ·docker_e2e_maven: 5 — all greenDocumented follow-ups (deliberately NOT in this PR)
~/.bundle/configBUNDLE_PATH(unscopedbundle config set path, the default on bundler >= 2.1) still unsupported.ruby_crawlerandsetup/gem— suggest autils::bundlerextraction.seen/seen_rootsdouble-dedup readability in the store discovery loop.🤖 Generated with Claude Code
Note
High Risk
Changes which on-disk gem trees
applywrites and adds security containment for attacker-controlled.bundle/configpaths; incorrect behavior could leave vulnerable copies unpatched or allow writes outside the project.Overview
Fixes a silent partial apply for gems: when the same
gem@versionlives in both Bundler’s scoped and flatgems/stores, discovery now keeps every physical path (merge_variant_copiesin apply resolution), andapplypatches each gem copy (mirroring npm multi-copy), with per-copy JSON events and failures if any copy is left unmatched or unpatched. PyPI/Maven and single-path commands still use one representative install dir.The Ruby crawler now probes install roots in Bundler precedence (
.bundle/config→ envBUNDLE_PATH→vendor/bundle), honors env~expansion, treatsBUNDLE_PATH__SYSTEM: "trueas “no config path,” and only uses thevendor/bundleearly-return so env/config roots still getgem envhomes for default gems. Committed.bundle/configBUNDLE_PATHvalues must stay inside the project root after lexical normalization; out-of-tree roots are skipped with a stderr warning—envBUNDLE_PATHremains trusted. Sharednormalize_lexicallymoves toutils::fs(composer + ruby). CLI_CONTRACT.md documents the gem discovery and multi-copy behavior.Reviewed by Cursor Bugbot for commit af59554. Configure here.