Safeguard
Open Source Security

Bundler dependency resolution and safe Gemfile upgrade strategies

In May 2026 RubyGems suspended new signups after attackers mass-created accounts to flood the registry with malicious gems. Here's how Bundler actually resolves risk.

Safeguard Research Team
Research
6 min read

On May 16, 2026, RubyGems restored new account registrations after several days of suspending signups entirely — the registry's response to an attacker who used thousands of bot accounts to flood the registry with hundreds of malicious packages, some carrying working exploits, according to reporting from The Hacker News. It was the second major Ruby supply-chain scare in a year: in May 2025, gems impersonating Fastlane plugins and published under aliases including "Bùi nam" exfiltrated data to attacker-controlled infrastructure, timed to land days after Vietnam's Telegram ban, per CSO Online. By August 2025, researchers had catalogued roughly 60 malicious gems posing as social-media automation tools — many shipping real, working functionality alongside credential-stealing code aimed at Korean marketing operators. None of these campaigns exploited a Bundler bug. They exploited the gap between what a Gemfile requests and what actually gets installed — the same gap that a disciplined lockfile and audit workflow is built to close. This post walks through how Bundler's resolver works, what Gemfile.lock actually protects you from, and how to upgrade dependencies without reopening the door to exactly this kind of drift.

How does Bundler actually resolve which gem versions to install?

Bundler resolves your Gemfile using Molinillo, a general-purpose backtracking dependency resolution algorithm that CocoaPods also adopted for iOS package management. Molinillo treats resolution as a constraint-satisfaction search: it walks every gem's declared requirements plus every dependency's own gemspec constraints, and backtracks whenever a version choice creates a conflict further down the graph, until it finds one consistent set of versions — or reports that no such set exists. This matters because a Gemfile alone never fully determines what installs; a top-level gem "rails", "~> 7.1" plus a transitive dependency requiring an incompatible version of activesupport can only be reconciled by walking the whole graph at once. Bundler runs this resolution once, when you run bundle install without a matching lockfile or bundle update, and writes the exact result to Gemfile.lock — turning a search problem you'd otherwise re-solve (and potentially resolve differently) on every machine into a single fixed answer.

What does Gemfile.lock actually protect against?

Gemfile.lock pins the exact resolved version of every gem in your dependency graph, direct and transitive, along with a source checksum. Once it exists, bundle install reads the lockfile and installs precisely those versions — it does not re-run resolution or silently pull newer releases, even if a dependency's maintainer ships a new patch version five minutes before your CI runs. That is the core defense against supply-chain drift: without a committed lockfile, two developers running bundle install a day apart, or a CI pipeline running weeks after a local install, can end up with different transitive gem versions entirely, none of them reviewed. Only an explicit bundle update (optionally scoped to one gem, e.g. bundle update nokogiri) tells Bundler to recalculate. This is precisely why the August 2025 social-media-automation gems worked as an attack vector at all — code that ships "real" functionality alongside exfiltration logic relies on projects pulling it in without a human ever reviewing the diff, which a committed, reviewed lockfile update is designed to force.

Does the pessimistic operator (~>) stop malicious updates?

No — ~>, Ruby's pessimistic version constraint, limits how far a version can drift, but it does not vet who is publishing that version. gem "faraday", "~> 2.7" allows Bundler to accept 2.7.1 through anything below 2.8.0, blocking a jump to a new major release but permitting any patch or minor bump the maintainer pushes within that range. If an attacker compromises a maintainer's RubyGems account — the exact mechanism behind the May 2026 mass-account-creation incident, where attackers controlled the publishing identity rather than exploiting Bundler's resolver — a malicious 2.7.2 satisfies ~> 2.7 just as legitimately as a benign one would. The pessimistic operator is a blast-radius control for accidental breaking changes, not an integrity control. It reduces how much version space an update can move through; it says nothing about whether the content at that version is trustworthy, which is why version pinning has to be paired with checksum verification and audit tooling rather than treated as a substitute for either.

How does bundle audit catch known-vulnerable gems?

bundle audit, a community-maintained gem, checks every version recorded in your Gemfile.lock against the ruby-advisory-db, a curated feed of known RubyGems vulnerabilities that is also mirrored into the GitHub Advisory Database (GHSA) for the RubyGems ecosystem. Running bundle audit check --update refreshes the local advisory database and then flags any installed gem version matching a known CVE or GHSA record, reporting the advisory ID, affected version range, and patched version. This closes a real gap: bundle install and bundle update will happily install a version with a known, disclosed vulnerability, because neither command consults an advisory feed by default — resolution only cares about declared version constraints, not security history. Running an audit pass as a required CI step, separate from and in addition to bundle install, is what turns "the lockfile resolves" into "the lockfile resolves to something known to be safe," and it's the step most Rails projects skip until an incident forces the habit.

What's a safe way to upgrade dependencies without reopening drift risk?

The safer alternative to a blanket bundle update is bundle outdated, which lists every gem with a newer version available without installing anything, followed by scoped updates via bundle update <gem> or bundle lock --update <gem> — the latter recalculates the lockfile for one dependency's subtree without touching the rest of your graph. This turns an upgrade into a reviewable, single-gem diff in Gemfile.lock rather than an unbounded resolver run that can shift dozens of transitive versions at once, several of which nobody reads before merging. It also directly limits exposure to compromised-maintainer scenarios: a scoped update only pulls in the specific gem and its dependents you intended to touch, on a schedule you control, rather than absorbing every transitive change that has accumulated since the lockfile was last regenerated. Pair scoped updates with bundle audit on every CI run and treat any bundle update output touching a gem you didn't request as a signal worth investigating before merge — not a default to accept.

How Safeguard helps

Safeguard's software composition analysis treats Ruby Bundler as a first-class ecosystem: it resolves your full Gemfile and Gemfile.lock dependency graph, direct and transitive, and matches every resolved gem version against known CVE and GHSA advisories with EPSS and CISA KEV context layered on top — the same continuously updated intelligence that would have flagged the ruby-advisory-db entries a manual bundle audit run depends on, but running automatically on every scan instead of when someone remembers to invoke it. Malicious-package detection surfaces typosquats and known-malicious gems — the exact pattern behind the 2025 Fastlane-impersonation and social-media-automation campaigns — and those findings are never suppressed by reachability scoring, unlike routine CVE noise. Each finding carries the full transitive path back to your project root and a safe upgrade target, so a scoped bundle update is an informed decision rather than a guess.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.