DEV Community

Gaberial Sofie
Gaberial Sofie

Posted on

A Typosquatted Gem Almost Shipped, Caught by Luck: A Ruby Supply Chain Security Playbook

The exposure

A teammate opened a PR adding a small utility gem. The name looked right at a glance — one transposed character from a popular package. It passed our tests. On a normal Friday it would have merged. Someone caught the typo in review by luck, not process, and the gem turned out to be a typosquat whose extconf.rb ran code at install time that had no business running.

"By luck, not process" is the phrase that reframes this from an anecdote into a security finding. Ruby supply chain security is the discipline of not depending on luck — or on someone else's controls — for the integrity of code you ship. We were trusting gem install blindly and leaning entirely on RubyGems.org to catch everything upstream. It usually does, but "usually" is a probability, not a guarantee, and a plan that works right up until it doesn't is not a control. RubyGems.org's own account of how it protects the ecosystem puts a number on it: its automated tooling catches roughly 70-80% of malicious packages before anyone reports them. That upstream layer is real and valuable, but the residual 20-30% is precisely the population your build has to assume it will meet. For the fuller hardening notes I worked from a thorough third-party writeup of a Ruby dependency-security playbook → alongside the registry's post.

Threat model

  • Typosquatting and look-alike names. A one-character-off gem name is a social-engineering primitive aimed at review and autocomplete. It is caught, if at all, by attention — an unreliable control at 2pm on a Friday.
  • Arbitrary code execution at install time. extconf.rb, native extension builds, and install hooks run on developer laptops and CI runners with whatever privileges those hosts hold. A malicious gem does not need to reach production to do damage; installing it is the exploit.
  • Lockfile injection and source substitution. A version number can be re-pointed at different bytes upstream, and a machine-generated Gemfile.lock that nobody reads during review can quietly point a dependency at a different set of bytes than the manifest implies. Mixed gem sources open a substitution path where a gem is pulled from an unexpected origin.
  • The post-disclosure window. A gem that is clean today gets a CVE tomorrow while sitting in your lockfile. Detection that only runs when code changes never revisits already-shipped dependencies.
  • Over-reliance on upstream controls. Delegating your production security entirely to the registry's 70-80% means the ecosystem's residual miss rate is your residual risk, unmediated.

The organizing principle is defense in depth: the registry is the first line, and the build must be a deliberate second line behind it, because malicious packages are not rare events — they are background noise of the modern software supply chain.

Controls we added

Control 1 — shrink the attack surface

  • Pin versions and platforms in Gemfile.lock and commit it. No floating ranges into production.
  • No git dependencies without a commit SHA. A branch can be rewritten; a SHA cannot.
  • Narrow ranges (~>) for critical gems, and prune ruthlessly — every unused and transitive gem is surface area, reviewed on a quarterly dependency-hygiene day.

Control 2 — checksum verification, the highest-leverage single addition

Pinning a version number is necessary but not sufficient, because the number can be re-pointed at different bytes. Bundler 2.6 makes checksum verification first-class (--add-checksums is a documented bundle lock flag):

bundle lock --add-checksums
Enter fullscreen mode Exit fullscreen mode

This writes a CHECKSUMS section into Gemfile.lock, and from then on Bundler refuses to install a gem whose contents do not match the exact bytes you locked. It closes the nastiest gap in a pin-the-version-only strategy: a checksum cannot be quietly swapped the way a version can be re-pointed. If you adopt one new control this quarter, make it this one. Alongside it, a few Bundler settings harden source handling — disable_multisource true (the underrated one, blocking the source-substitution class), cache_all true, and clean 'true'.

Control 3 — make the human review a process, not a hero act

The near-miss was caught by a person, so we turned the person into a checklist. Adding or bumping a gem now requires a short "why this gem?" note and a CHANGELOG link in the PR, documented in the PR template where the work happens rather than a wiki nobody reads. Dependabot and GitHub alerts are enabled so advisories are not manually watched. The cultural shift — that a dependency is code we are adopting, not a freebie we are grabbing — mattered more than any single tool.

Control 4 — gate it in CI

A checklist people can forget is a suggestion; a CI gate is a rule. Two independent scanners catch different things, and layering them is cheap: bundler-audit (checking Gemfile.lock against the ruby-advisory-db) and Google's OSV-Scanner (fed by osv.dev). It runs on every dependency-touching PR and weekly on a schedule:

on:
  pull_request:
    paths: ['Gemfile', 'Gemfile.lock']
  schedule:
    - cron: '0 6 * * 1'   # weekly on Mondays

jobs:
  bundler-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with: { ruby-version: '3.3', bundler-cache: true }
      - run: gem install bundler-audit
      - run: bundle audit check --update
  osv-scanner:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: google/osv-scanner-action@v1
        with: { scan-args: '-L -r .' }
Enter fullscreen mode Exit fullscreen mode

The weekly cron addresses the post-disclosure window directly: a vulnerability disclosed on Wednesday fails the build even if nobody touches the code. For teams that want typosquat detection and SBOM generation on top, a Ruby-specific scanner like GemGuard — pulling from OSV.dev and the ruby-advisory-db — slots in beside bundler-audit rather than replacing it.

Verifying the control, not just shipping it

The fastest way to get a security process ignored is to drop a wall of red CI checks on a team mid-sprint, so we staged it and verified adoption as deliberately as coverage. Week one, the scanners ran in warn-only mode — reporting findings without failing the build. That surfaced the existing backlog of advisories, gave us an honest baseline instead of a nasty surprise, and let us triage and either fix or explicitly accept each item. Only then did we flip the gate to blocking, by which point there was nothing left for it to fail on. The second thing that kept adoption alive was making the checks fast and legible: bundler-audit and OSV both run in well under a minute with a warm cache, and their output points straight at the offending gem and advisory, so a failure has an obvious fix and nobody routes around it. A gate that takes ten minutes or emits inscrutable output gets bypassed, which is how a control becomes theater.

Residual risk / what we're still watching

The playbook meaningfully raised the cost of a supply-chain compromise, but it does not reduce the residual to zero, and the edges are where the honesty has to be.

  • Scanners detect known vulnerabilities only. A brand-new typosquat or an undisclosed backdoor has no advisory yet and passes clean. That is exactly why the human "why this gem?" review still carries load the automation cannot — the checklist is a control against the unknown-unknown, not redundant with the scanners.
  • Signatures are not a force field. Gem signing is one control among pinning, checksums, review, and CI gating — treating it as a silver bullet would be a false-confidence failure. We weight it accordingly.
  • The lockfile diff still needs human eyes. Checksum verification defuses most lockfile-injection risk, but a reviewer who reads only the Gemfile and skims the machine-generated Gemfile.lock can still miss a dependency quietly re-pointed. We review the lockfile diff on any dependency PR.
  • Patch-bump noise vs. coverage. A blanket manual-review policy drowns the team; letting CI and scans gate patch bumps while humans review major/minor keeps signal high, but it does accept that a malicious patch release is caught by scanners rather than eyes. We watch that trade.
  • We still depend on the registry and the advisory databases. Our second line assumes RubyGems.org's first line and assumes OSV.dev and the ruby-advisory-db are current; a gap or delay in any of them is a gap in us. Two independent scanners mitigate the advisory dependency, and — because that shared line of defense runs partly on a small group's effort — we treat a recurring sponsorship of the ecosystem as risk management, not charity: the CI gate protects our repo, sponsorship protects the line in front of it.

If a team ships Ruby, the near-miss described here is a matter of when, not if. The residual we keep watching is the undisclosed package and the lockfile change nobody reads closely — the two places where luck, not process, is still doing quiet work.

Sources & further reading

Top comments (0)