What's New in RubyGems/Bundler 4.1, Part 2: Defenses Against Supply Chain Attacks
I'm Hiroshi Shibata (hsbt), a Ruby committer and the lead maintainer of RubyGems and Bundler. TL;DR RubyGems and Bundler 4.1.0.beta3 shipped on October 28, 2026. This is Part 2 of a three-part series on what'
I'm Hiroshi Shibata (hsbt), a Ruby committer and the lead maintainer of RubyGems and Bundler.
TL;DR
RubyGems and Bundler 4.1.0.beta3 shipped on October 28, 2026. This is Part 2 of a three-part series on what's new in 4.1. Part 1 covered override and prune. This part covers defenses against supply chain attacks, in the order a developer meets them, from fetching and installing gems to publishing your own. Cooldown keeps freshly published versions out of resolution, two install options stop code from running at install time, a credential store moves your API keys into the OS keychain, and gem push attaches an attestation by itself on GitHub Actions. At the end there is a short note on the ML-DSA signature standard. Everything here is opt-in or leaves the default behavior alone.
Cooldown keeps fresh versions out
I wrote about the idea behind cooldown and what other ecosystems do in Should RubyGems/Bundler Have a Cooldown Feature?. Bundler got cooldown in 4.0.13. In 4.1 the gem command supports it too, so here is the whole picture, Bundler included.
Cooldown in Bundler
bundle install, update, add, cache, lock, and outdated accept --cooldown DAYS. Versions published fewer than that many days ago drop out of the candidates for resolution. They only drop out, so the newest version that still qualifies is chosen.
Versions already in the lockfile are not subject to cooldown. Cooldown decides whether to take in a new version. It never takes back a version that is already locked.
It is off by default. Instead of passing the flag every time, you can turn it on with bundle config set cooldown 7 or the BUNDLE_COOLDOWN environment variable. In the Gemfile you can set the days per source. To exempt a private registry, for example:
source "https://rubygems.org", cooldown: 7
source "https://internal.example.com", cooldown: 0 do
gem "our-internal-gem"
end
There are three layers, and the higher one wins.
-
--cooldown Non the command line -
bundle config set cooldown NorBUNDLE_COOLDOWN -
cooldown:per source in the Gemfile
The first two apply to every source and override the third. If you want different days per source as in the Gemfile above, leave the second unset and write them in the Gemfile.
Cooldown in the gem command
In 4.1, gem install, gem update, and gem outdated accept --cooldown DAYS too. Put :cooldown: 7 in your gemrc and it applies without the flag.
Cooldown reads the publication time from the created_at that the compact index returns. To use it in the gem command, the command's index had to move to the compact index. Because of that switch, cooldown in the gem command is not backported to 4.0. Part 3 covers the move to the compact index.
This is gem install bundler --cooldown 7 run on October 1, the day after bundler 4.0.22 came out. Bundler prints the same list at the end.
Successfully installed bundler-4.0.21
1 gem installed
The following gem versions were skipped by the cooldown setting:
* bundler 4.0.22 (available in 7 days), resolved 4.0.21 instead
Neither Bundler nor the gem command excludes a version whose publication time is unknown. Stopping on a missing timestamp would break setups that use a private registry. The gem command warns once when a source returns no publication times.
Bundler's setting reaches gem
If you already set cooldown for Bundler, the 4.1 gem command reads it. gem install, gem update, and gem outdated look at BUNDLE_COOLDOWN and Bundler's config files, so the same days apply without any setting on the gem side. The gem command doesn't read the Gemfile, though, so per-source values don't reach it.
It works the other way too. The gemrc value doesn't sit beside Bundler's three layers. It raises whatever those layers decide, and when both have a value the longer one wins. Even the cooldown: 0 for the private registry above becomes 3 days if your gemrc says 3.
A 0 in a config file counts as zero days, not as off. That's why bundle config set cooldown 0 can't cancel the gemrc value. Only --cooldown 0 on the command line can, and it always wins regardless of the other tool's settings.
A config value that isn't a non-negative integer prints a warning and is ignored, so a typo in your gemrc won't break every command. Only --cooldown on the command line fails on the spot.
Lifting it with --cooldown 0
When you need an update right now:
bundle update rails --conservative --cooldown 0
--cooldown 0 applies to every gem in the dependency tree. I didn't make it lift only the named gem because resolution would fail. rails 8.0.2, for example, pins its components with activesupport = 8.0.2. Lifting only rails would leave the new activesupport excluded by cooldown. To narrow what gets updated, combine it with existing flags such as --conservative.
Two options that stop code from running at install time
gem install runs code shipped inside a gem in two ways. One is building native extensions, which runs the extconf.rb or Rakefile listed in the gemspec's extensions. The other is RubyGems plugins. A gem with a rubygems_plugin.rb gets it copied into the plugins directory and loaded during installation.
4.1 lets you stop each of them.
gem install foo --no-build-extension --no-install-plugin
Both stay on by default and stop only when you ask. With a gem that has both an extension and a plugin, you get these warnings.
WARNING: demo-1.0.0 contains native extensions that were not built.
To build extensions, run: gem pristine demo --extensions
WARNING: demo-1.0.0 contains plugins that were not installed.
To install plugins, run: gem pristine demo --only-plugins
As the warnings say, gem pristine can build the extension or install the plugin later. Put the options in your gemrc to avoid typing them each time.
install: --no-build-extension --no-install-plugin
update: --no-build-extension --no-install-plugin
In real projects bundle install is the main path, so Bundler has the same settings, bundle config set no_build_extension true and bundle config set no_install_plugin true. gem pristine can't reach gems that Bundler installed under BUNDLE_PATH, so Bundler's warning tells you to unset the setting and run bundle pristine.
Both apply to every gem and can't be switched per gem. A gem with a native extension won't work if its build is skipped. These options are for environments built only from gems without extensions, or ones that ship prebuilt artifacts some other way.
A credential store for your keys
The API key that gem push uses lives in plain text in ~/.gem/credentials. RubyGems warns when the file's permissions aren't 0600, but the contents stay plain text. The same goes for .bundle/config, where Bundler writes per-host credentials.
In 4.1 you can keep them in the OS credential store instead. On macOS it uses the Keychain through the security command. On Linux it uses the Secret Service API (GNOME Keyring or KWallet) through secret-tool. On Windows it uses Credential Manager through PowerShell.
It is off by default. For RubyGems, turn it on with :credential_store: in your gemrc or the RUBYGEMS_CREDENTIAL_STORE environment variable. For Bundler, use bundle config set credential_store true or BUNDLE_CREDENTIAL_STORE.
Credentials already written to files don't move on their own. After turning it on, run gem signin or bundle config set <host> <user:pass> again. That writes them into the store and removes them from the file.
Bundler can use a different store per host.
bundle config set --global credential_store.gems.example.com 1password
A name instead of true selects the backend with that name. External tools such as 1Password or pass can be selected by name when a gem provides rubygems/credential_store/backends/<name>. This shape came from a request to keep separate stores for CodeArtifact across AWS accounts.
When the store isn't available, it falls back to the original file. A Keychain locked over SSH or a Linux machine without a keyring daemon doesn't raise. You get a warning, and reads and writes go to the file as before. RubyGems and Bundler also use different service names in the store, so gem signout doesn't remove Bundler's credentials.
The bundle-config man page still marks this feature as experimental.
Automatic attestations record where a gem came from
From here on, it's about publishing gems.
Until now, attaching an attestation to a gem meant using the rubygems/release-gem GitHub Action, or building a Sigstore bundle yourself and passing it to gem push --attestation FILE. In 4.1, gem push does this itself.
It happens only when three conditions hold. The push goes to rubygems.org, it runs on GitHub Actions, and the Ruby isn't JRuby. GitHub Actions is detected through the GITHUB_ACTIONS environment variable. Pushes to private registries or from your own machine are left alone. When you pass --attestation, that always takes precedence.
Under the hood it calls gem exec --conservative sigstore-cli sign. The signature uses the ID token that GitHub Actions issues, and the certificate records the workflow's repository, file, and ref. The resulting bundle goes up with the gem in a multipart request. If signing fails, you get a warning and the gem is pushed without an attestation, so a failed attestation never blocks the release itself.
This lets anyone check afterwards which workflow published a gem on rubygems.org. It gives you something to notice when a gem was released from the wrong repository by mistake, or pushed from someone's machine with a stolen API key.
FIPS 204 support
In August 2024, NIST standardized ML-DSA, a post-quantum signature algorithm, as FIPS 204. RubyGems 4.1 supports it.
The -A option of gem cert selects one of the three parameter sets FIPS 204 defines.
gem cert --build [email protected] -A ML-DSA-65
ML-DSA-44, ML-DSA-65, and ML-DSA-87 match NIST security strength categories 2, 3, and 5. Their signatures are 2420, 3309, and 4627 bytes. The default is still RSA, so nothing changes unless you ask for it.
Trying it locally has two requirements. You need OpenSSL 3.5 or later, since older versions fail when generating the key. You also need ruby/openssl 3.3.0 or later to sign the certificate. ML-DSA hashes the message inside the algorithm, so signing passes no digest, and OpenSSL::X509::Certificate#sign accepts a nil digest starting with ruby/openssl 3.3.0.
Jun Aruga implemented this. Kazuki Yamaguchi, a ruby/openssl maintainer, and I joined in reviewing and fixing it.
Try It
Everything in this part is either off by default or leaves the default behavior alone. As I wrote in March, cooldown isn't a complete answer, and some gems won't work if you skip their extension build. The point was to have the options in place first, so you can pick what fits how you work.
4.1.0.beta3 is on rubygems.org, so you can try it today.
gem update --system 4.1.0.beta3
gem install bundler -v 4.1.0.beta3
If anything behaves oddly, please report it to ruby/rubygems. Part 3 will cover how RubyGems and Bundler are becoming one, and the speedups that came with it.
Notes
- Written in November 2026 against 4.1.0.beta3. Details may change before the final release.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.