
A hostile Ruby gem runs with the same rights as your deploy user. The install is the incident.
If your app bundles gems from RubyGems.org, a new version that looks like a typo-fix or a popular name can execute code the moment CI or a laptop runs bundle install. You do not have to require the gem in a route for that to happen.
The usual mistake is trusting stars, download counts, or a familiar maintainer name. Those are not a review. A lockfile you actually pin, and a registry change you notice, are the controls.
This page is how Ruby malware reaches a Gemfile, what to refuse at install time, and the checks that catch the next wave before it ships.
On 11 May 2026 Mend Defender flagged more than 120 newly published gems. The same Mend post, dated 14 May 2026 and written by Maciej Mensfeld of the RubyGems security team, said the next day grew into thousands of accounts and tens of thousands of uploads, and that RubyGems suspended new registrations while cleanup ran. A CVE scanner had nothing to match. The hatch was a publish, not a numbered advisory.
That is not a control a Rails team can ship. Keep the secure coding checklist next to this page for the rest of the request surface. Read injection when a string you already trust reaches SQL or a shell. Read input validation when the value is still a params key. This page stays in the lockfile you commit and the evaluator you refuse.
Two clocks a CVE scanner never sees
CWE-94 is code injection. A gem that evaluates a paste, or a route that calls eval on params[:expr], is that weakness. A yanked tarball that never received a CVE is still a hostile install. OWASP Top 10:2025 added A03 Software Supply Chain Failures for the second clock. Testing lags. Your lockfile does not have to.
I am dating the first clock from a first-party issue, not from a blog recap. rest-client issue 713 says attackers published 1.6.10 through 1.6.13 on 14 August 2019 using a maintainer account. The versions were downloaded a small number of times, about 1000. On 19 August 2019 the reporter opened the issue and RubyGems yanked the line. CVE-2019-15224 is the later id. The issue says the 1.6.13 payload activated when Rails.env started with p, then fetched remote Ruby and ran it. I will not reconstruct that fetch. The grep the maintainers published is the control:
grep --include='Gemfile.lock' -r. -e 'rest-client (1\.6\.1[0123])'
Unaffected pins, in their words, are <= 1.6.9 or >= 1.6.14. Most apps were already on 1.7 or 2.x. The people who got hurt were the ones who floated an abandoned series and then resolved. That is still how the incident looks in 2026: a quiet lockfile move, not a new language feature.
The second clock is a flood of new names. Mensfeld’s May post is the citation for the 11 May 2026 batch of more than 120 gems and for the later account-creation wave. I did not independently count those uploads. A name that did not exist on Sunday is not in your bundler-audit database on Monday. bundle install --frozen against last week’s lockfile refuses it. bundle update on a laptop records it.
PUBLISH RubyGems grows a new.gem no advisory in the first hour frozen Gemfile.lock refuses the name MERGE PR adds eval or Marshal.load review_hatches fails the check CODEOWNERS requires a named reviewer FIX restore the last agreed lockfile bundle install --frozen rotate tokens the process could read
| Signal | What it is | What you do |
|---|---|---|
| New lockfile line | install-time event | diff, then frozen install |
| Checksum mismatch | bits changed after accept | refuse, restore cache |
New eval | CWE-94 | block the merge, write parse_config |
| Known CVE on a pin | vulnerable dependency | bump on the sibling page |
Freeze the lockfile, then store checksums
Commit Gemfile.lock for every application. A gem library is a different contract. An app that ships without a lockfile re-resolves on every agent. That is how a floating ~> becomes a yank you never reviewed.
The Bundler 2.6 announcement is dated 19 December 2024. David Rodrรญguez wrote that a lockfile already pinned versions, and that checksums close the remaining hole: a .gem whose bits changed after you accepted the version. Enable them on a lockfile you already trust:
bundle lock --add-checksums
# persist checksums for later lockfile writes
bundle config lockfile_checksums true
# fail if Gemfile.lock would change
bundle install --frozen
Bundler then keeps a CHECKSUMS section and verifies the file it is about to unpack. A mismatch aborts. The same post prints the recovery: delete the bad cache copy, run install again against the recorded digest, or, if you insist on ignoring the warning, set disable_checksum_validation locally. Leave that override out of CI. It is the named fallback that turns the feature off. If a platform gem variant is not in the lockfile, 2.6 tells you to normalize platforms so the digest you store is the digest you install.
#.github/workflows/ruby.yml
- name: Install gems the lockfile already named
run: |
bundle config set deployment true
BUNDLE_FROZEN=true bundle install --jobs 4
ruby scripts/review_hatches.rb
deployment sets frozen and a local path. The point is the same: the agent cannot rewrite the lockfile. Do not regenerate it on the release box. Do not cache a vendor/bundle keyed only on Gemfile. Key the cache on the lockfile digest.
Ruby 4.0.6, dated 14 July 2026. The downloads page still lists 4.0.6 as the current stable line when I checked, with 3.4.10 and 3.3.12 as the older stables. A 3.2 box that is past its own patch cadence is a language risk, not a gem risk. Patch the interpreter on its own schedule. The lockfile still decides which .gem you unpack.
Three blinds sit next to a quiet advisory scan:
- Transitive names. You may never have typed
rest-client. The lockfile still records it. A parent range can move the child without a human seeing the name in the PR title. - Laptop resolve.
bundle updateon Monday is an install-time event. Main’s lockfile does not protect a checkout that rewrote it. - Git and path sources. The Bundler 2.6 note says git and path entries have no packaged file to digest. Treat a git branch that is not a pinned commit as an open resolve. Pin the commit sha.
eval is CWE-94. Write a parser
$SAFE is gone. It was never a sandbox you could sell, and Ruby 3 removed the remaining theater. eval, instance_eval, class_eval, module_eval, and binding.eval compile a string in your process. A request body is not a program. If the client sent JSON, parse JSON. If the client sent a formula, write a grammar. Do not compile the string.
# lib/parse_config.rb
# frozen_string_literal: true
require "json"
ALLOWED_KEYS = %w[name theme page_size].freeze
ALLOWED_THEMES = %w[light dark].freeze
def parse_config(text)
data = JSON.parse(text)
unless data.is_a?(Hash)
raise ArgumentError, "config must be an object"
end
extra = data.keys - ALLOWED_KEYS
raise ArgumentError, "unknown keys" unless extra.empty?
theme = data.fetch("theme")
raise ArgumentError, "theme" unless ALLOWED_THEMES.include?(theme)
page_size = Integer(data.fetch("page_size"))
raise ArgumentError, "page_size" unless page_size.between?(1, 200)
{ "name" => String(data.fetch("name")), "theme" => theme, "page_size" => page_size }
end
Identifiers stay parse_config, ALLOWED_KEYS, and ALLOWED_THEMES. A route that still wants “a little Ruby” is asking for CWE-94. Issue 713 described remote Ruby reaching eval. Your app should not offer the same hatch on a params key just because the string came from your own form.
The same rule covers deserialize. Marshal.load on a cookie or a cache blob is an object graph the attacker designed. Prefer JSON plus an allowlist. YAML.unsafe_load is the Psych hatch. Reject both unsafe_load and Marshal.load in review. If you must parse YAML, call YAML.safe_load with permitted_classes you listed.
# BAD: do not compile or deserialize request text
# eval(params[:expr])
# instance_eval(params[:rule])
# Marshal.load(cookies[:pref])
# YAML.unsafe_load(request.raw_post)
# FIX: parse_config on a JSON body you already size-capped
config = parse_config(request.body.read)
Shell strings are the cousin hatch. Prefer Open3.capture2 with an argv list, not backticks and not system(user). That is CWE-78 and belongs on the injection page. Grep it in the same hook so a PR cannot sneak a second interpreter past a gem review.
Block the merge on eval and a surprise gem
A nightly bundler-audit job that pages after deploy is late. The line already shipped. The gate that matches this page is a required check on the files the PR touched, plus a second pass on the resolved lockfile. Style lint does not see these hatches.
# review_hatches.rb
# frozen_string_literal: true
require "find"
require "pathname"
DENY = [
/\beval\s*\(/,
/\binstance_eval\s*\(/,
/\bclass_eval\s*\(/,
/\bmodule_eval\s*\(/,
/\bbinding\.eval\s*\(/,
/\bMarshal\.load\b/,
/\bYAML\.unsafe_load\b/,
].freeze
ALLOW_NATIVE = %w[nokogiri puma].freeze
ROOT = Pathname.pwd
SKIP = %w[.git vendor tmp log node_modules].freeze
hits = []
Find.find(ROOT) do |path|
path = Pathname(path)
Find.prune if path.directory? && SKIP.include?(path.basename.to_s)
next unless path.file? && path.extname == ".rb"
text = path.read
DENY.each { |re| hits << "#{path} #{re.source}" if re.match?(text) }
end
lock = ROOT.join("Gemfile.lock")
abort "missing Gemfile.lock" unless lock.file?
hits.reject! { |h| ALLOW_NATIVE.any? { |n| h.include?("/#{n}/") } }
abort hits.join("\n") unless hits.empty?
warn "review_hatches ok"
The first pass prunes vendor, so vendor/bundle is not scanned as app code. Identifiers stay review_hatches, DENY, and ALLOW_NATIVE. Soften only when a native gem you listed must compile. After a frozen install, run the same DENY list over bundle show --paths for a hatch that arrived only inside a gem. Put CODEOWNERS on Gemfile, the lockfile, and every file that calls Open3 or Kernel.open.
#.github/CODEOWNERS
/Gemfile @app-sec
/Gemfile.lock @app-sec
/scripts/ @app-sec
Run the hook on the app tree, then once more after a frozen install. A hatch that arrived only inside a gem will not show up in your app/ diff. The cheap second pass is bundle show --paths piped into the same DENY list, skipping ALLOW_NATIVE. Fail closed if the lockfile is missing. A skipped check is an open merge.
After a yanked gem lands
Assume a version you did not mean is in a lockfile, a cache, or an image. The work is containment, then a clean tree, then a review of what that process could read. The rest-client maintainers already published the grep shape. Later incidents publish their own coordinates. Use that list, not a vibe.
- Freeze publishes. Stop
bundle updateand ad-hocgem installon laptops until the pin is known. - Search every lockfile and image for the coordinates the maintainer or the registry named.
- Restore the last lockfile that predates the window. Reinstall frozen. Run
review_hatches. - Rotate tokens a build script could have read: RubyGems, cloud, signing. Rotate anyway if the install ran on an agent that holds them.
- Read the diff between the bad version and the last good version. You are looking for new files and a new
eval, not for a CVE blurb.
# coordinates the rest-client issue named, so you can recognize the shape
rg -n "rest-client \(1\.6\.1[0123]\)" Gemfile.lock
Aikido SleeperGem writeup dated 19 July 2026. Grep the names it published. Do not “clean” by deleting vendor/bundle and resolving free. Delete the bundle, keep the restored lockfile, run frozen install.
If the agent that ran the bad install also held cloud keys, rotate those keys before you declare the tree clean. A restored lockfile does not revoke a token that already left the box.
Prove frozen install and the hook
You are not proving a gem is kind. You are proving the agent cannot silently move, that a checksum mismatch aborts, and that review_hatches fails a PR that adds eval.
# 1. frozen install must be clean against the committed lockfile
bundle install --frozen
ruby scripts/review_hatches.rb
# 2. a broken lockfile must fail
# copy Gemfile.lock, change one version, expect bundle install nonzero
# 3. a planted eval must fail the hook
# printf '%s\n' 'eval("1")' >> tmp_hatch.rb
# expect the hook nonzero, then delete tmp_hatch.rb
That third step is a file you own in a repo you own. You are not sending it anywhere. Soften review_hatches only by checking hits against ALLOW_NATIVE. A new name is a review. Also fail the build when the lockfile diff adds a publisher you have not seen. bundle lock --print in a dry-run PR is enough to read. You do not need a vendor platform for that first cut.
Questions we keep getting
Does bundler-audit catch a hijacked release?
Not in the first hour. Audit matches known advisories. CVE-2019-15224 landed after the yank. The May 2026 flood, on Mensfeld’s telling, had no CVE while the names were live. Use the lockfile diff and review_hatches. Treat audit as the CVE lane.
Is pinning enough?
Pinning stops a floating range from moving on the next free resolve. It does not stop a human from regenerating the lockfile onto a bad number, and it does not see eval in a PR. Pair the pin with frozen install, checksums, and the hook.
Should we ban native extensions forever?
Ban surprise compile steps. Allow a named native gem when you can say why it compiles. Put that name in ALLOW_NATIVE. A blanket ban you then override with an unfrozen agent is worse than a short list.



