Safeguard
Open Source Security

Bundler dependency pinning and Gemfile.lock as a supply c...

A step-by-step guide to Gemfile.lock security: pin gems deliberately, enforce ruby lockfile integrity with checksums, and lock down bundle install in CI/CD.

Priya Mehta
DevSecOps Engineer
8 min read

If your Ruby application ships to production without a locked, verified dependency tree, you are trusting every gem author, every mirror, and every network hop between bundle install and your deploy pipeline. Gemfile.lock security is the practical answer to that problem: it pins exact gem versions and their cryptographic checksums so the code you tested is the code you ship. Attackers have already exploited loose Ruby dependency resolution — typosquatted gems, compromised RubyGems accounts, and floating version constraints have all been used to slip malicious code into production Rails apps.

This guide walks through hardening your Bundler workflow end to end: pinning versions deliberately, generating a trustworthy Gemfile.lock, enforcing lockfile integrity in CI, and auditing for known vulnerabilities before every release. By the end, you'll have a repeatable process for bundle install security that stands up to a real supply chain audit, plus the verification steps to prove it's working.

Why Gemfile.lock Security Matters for Ruby Supply Chains

Bundler's Gemfile.lock isn't just a convenience file that keeps your team on the same gem versions — it's a security control. Every entry in the lockfile records the exact version and, when checksum verification is enabled, a hash of the gem's contents. Without it, Bundler resolves your Gemfile's loose constraints (gem "rails", "~> 7.1") fresh every time, which means a newly published — and potentially compromised — patch release can silently enter your dependency tree the moment bundle install runs on a machine without a committed lockfile.

The RubyGems ecosystem has seen real incidents: hijacked maintainer accounts pushing backdoored versions of popular gems, and dependency confusion attacks where a malicious gem with a similar name to an internal package gets pulled from the public index instead. A disciplined Gemfile.lock security practice — combined with bundler dependency pinning and ruby lockfile integrity checks — closes most of these gaps before code ever reaches CI.

Step 1: Pin Direct Dependencies Deliberately in the Gemfile

Bundler dependency pinning starts in the Gemfile itself, not the lockfile. Avoid unbounded or overly permissive version constraints for anything security-sensitive (auth libraries, serializers, HTTP clients, YAML/JSON parsers).

# Avoid this — allows any 7.x release, including same-day compromises
gem "rails", ">= 7.0"

# Prefer this — pessimistic constraint scoped to a minor line
gem "rails", "~> 7.1.3"

# For high-risk gems, pin exact versions
gem "devise", "4.9.4"
gem "nokogiri", "1.16.5"

Pinning exact versions for your most security-critical gems means a new release — malicious or otherwise — never enters your build without a deliberate bundle update <gem> and a code review of the diff.

Step 2: Generate and Commit a Deterministic Gemfile.lock

Run bundle install locally (or bundle lock if you only want to resolve without installing) and commit the resulting Gemfile.lock to version control. Never add it to .gitignore for an application repository — that's a common mistake that defeats the entire purpose of pinning.

bundle lock
git add Gemfile.lock
git commit -m "Pin dependency versions in Gemfile.lock"

Set Bundler to fail loudly if the lockfile and Gemfile ever drift out of sync, which is exactly the behavior you want in CI:

bundle config set --local deployment true
bundle config set --local frozen true

With frozen set, bundle install will refuse to modify Gemfile.lock and will error out instead — forcing a human to explicitly run bundle update and review the change rather than letting a build silently re-resolve dependencies.

Step 3: Enforce Ruby Lockfile Integrity with Checksums

Modern Bundler (2.5+) supports a CHECKSUMS section embedded in Gemfile.lock, capturing SHA-256 digests of each gem package. This is the core of ruby lockfile integrity: even if an attacker republishes a gem under the same version number with different contents, the checksum mismatch will cause bundle install to fail rather than silently install tampered code.

bundle config set --local checksums true
bundle lock --update

Verify checksums are present:

grep -A2 "CHECKSUMS" Gemfile.lock

You should see entries like:

CHECKSUMS
  actionpack (7.1.3.4) sha256=3a1f...
  nokogiri (1.16.5) sha256=9c72...

If your Bundler version predates checksum support, upgrade it first — this single feature meaningfully raises the cost of a lockfile substitution attack.

Step 4: Harden bundle install Security in CI/CD

Bundle install security depends heavily on how your CI pipeline invokes it. The goal is to make CI behave identically to a strict, offline-verified install rather than a permissive developer workstation.

# .github/workflows/ci.yml (excerpt)
- name: Install dependencies (frozen, verified)
  run: |
    bundle config set --local deployment true
    bundle config set --local frozen true
    bundle config set --local without 'development test'
    bundle install --jobs 4 --retry 2

Deployment mode enforces that Gemfile.lock exists and matches the Gemfile exactly, installs gems into vendor/bundle rather than shared system paths, and refuses network resolution outside what's already locked. Pair this with pinning your Bundler and Ruby versions themselves in the Gemfile:

ruby "3.3.4"

# Gemfile.lock footer
# BUNDLED WITH
#    2.5.11

An outdated or unpinned Bundler binary in CI can behave differently than what your team tested locally, which undermines every other control in this list.

Step 5: Audit Locked Dependencies for Known Vulnerabilities

Pinning protects against drift, but a pinned gem can still have a disclosed CVE. Run bundler-audit (or an equivalent SCA tool) against the lockfile as a required CI gate, not an optional check.

gem install bundler-audit
bundle audit check --update

This cross-references every entry in Gemfile.lock against the Ruby Advisory Database and fails the build on matches. Wire it into CI immediately after the install step:

- name: Audit dependencies
  run: |
    gem install bundler-audit --no-document
    bundle audit check --update

Treat a failing audit the same as a failing test suite — block the merge, don't just log a warning.

Step 6: Restrict Gem Sources and Verify Provenance

Confirm every Gemfile has exactly one source declaration pointing to the official RubyGems index (or your vetted internal mirror), and that no gem entry overrides it with an untrusted URL.

source "https://rubygems.org"

# Flag and review anything like this before merging:
gem "internal-tool", git: "https://github.com/some-fork/internal-tool.git"

For internal or forked gems pulled via git:, pin to a commit SHA rather than a branch name — branches can be force-pushed, commit SHAs cannot be silently swapped:

gem "internal-tool", git: "https://github.com/org/internal-tool.git", ref: "a1b2c3d4"

If you run a private gem mirror (Gemfury, Artifactory, or a self-hosted RubyGems instance), make sure it's the only configured source and that credentials are scoped read-only for CI runners.

Troubleshooting and Verification

Use these checks to confirm your controls are actually enforced, not just configured:

  • Lockfile drift check: run bundle check — it should report "The Gemfile's dependencies are satisfied" with no modifications. If it wants to write to Gemfile.lock, your Gemfile and lockfile have diverged.
  • Frozen mode enforcement: temporarily loosen a version in the Gemfile without updating Gemfile.lock, then run bundle install --frozen. It must fail with a clear error — if it silently resolves, frozen mode isn't actually active in that environment.
  • Checksum coverage: run bundle lock --print and confirm every gem has a corresponding CHECKSUMS entry; gems installed from git: or path: sources won't have one, which is expected but worth tracking explicitly.
  • Audit freshness: bundle audit check --update pulls the latest advisory database before checking — running it without --update in CI can give you false confidence from a stale local cache.
  • CI parity: diff the Ruby and Bundler versions reported by your CI logs against what's pinned in the Gemfile and lockfile footer; mismatches here are a common source of "works locally, breaks in prod" supply chain surprises.

How Safeguard Helps

Manually policing bundler dependency pinning, checksum coverage, and audit results across dozens of Ruby services doesn't scale with spot checks and tribal knowledge. Safeguard continuously monitors your repositories for Gemfile.lock security regressions — unpinned version constraints, missing or incomplete checksums, drifted lockfiles, and newly disclosed CVEs in gems you've already shipped — and surfaces them before they reach production.

Rather than waiting for a quarterly audit, Safeguard evaluates every pull request against your dependency policy, flags git-sourced gems pinned to mutable branches instead of commit SHAs, and tracks bundle install security posture across your entire fleet of services from a single dashboard. When a maintainer account gets compromised or a typosquatted gem shows up on RubyGems, Safeguard correlates that intelligence against your actual lockfiles in real time — so you find out from your security tooling, not from an incident report.

Never miss an update

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