Skip to main content

A definitive guide to Ruby gems dependency management

著者:
wordpress-sync/blog-feature-ruby-security

2022年8月5日

0 分で読めます

Ruby, much like other programming languages, has an entire ecosystem of third-party open source libraries which it refers to as gems, or sometimes Ruby gems. These gems are authored by the community, and are available from RubyGems.org which is the official registry for Ruby libraries. Similarly to other open source ecosystems, threat actors may publish deliberate malicious code or such which includes backdoors or credentials harvesting. Hence, attention to detail for how you manage and audit your open source Ruby gems is crucial.

In this article, I'll run through the concepts and tooling that make up the Ruby dependencies ecosystem, and answer some of the common questions Ruby developers have.

The Ruby programming language

You might have heard about Ruby on Rails (RoR), the popular web development framework that contributed a lot to Ruby’s success and popularity. RoR is based on Ruby which is is a dynamic, cross-platform and interpreted based language. Its source code files are easily recognizable using the .rb file extension.

What are Ruby gems?

Gems are packaged source code libraries that are modular, independent and are easily reusable across projects.

What is RubyGems and how is it different from Bundler?

RubyGems is the central package registry where third-party, open source Ruby gems are shared as well as the official Ruby package manager, known as gem when interacted with via the command line interface.

wordpress-sync/blog-ruby-gems

RubyGems is a wonderful way to allow developers to tap into an ecosystem of hundreds of thousands of open source libraries and utilize them in their projects.

RubyGem’s own gems CLI comes pre-installed with a working Ruby environment so developers can get right away with installing their favorite open source dependencies. Such as:

1sh
2gem install rails

The gems CLI, however, manages dependencies each on their own by fetching them from a registry and installing them on the filesystem so that they are available and easily imported to Ruby code projects.

The Bundler project, which is technically a Ruby gem itself and is installed as one, eases the burden of installing gems one-by-one, and allows for a deterministic and consistent dependency tree using a manifest file known as Gemfile and its counterpart lockfile Gemfile.lock. The latter pins the dependencies across the tree and ensures consistency across installations over time.

Since Bundler is a gem, getting started with Bundler is as easy as:

1sh
2gem install bundler

This commands makes the bundler command line tool available to developers

The Ruby Gemfile

The file named Gemfile is a manifest text file that describes all dependencies that should be used by the project, and other metadata instructions that the bundler tool exposes.

A simple Gemfile is as follows:

1ruby
2source 'https://rubygems.org'
3
4gem ‘rails’, ‘4.2.35gem ‘sqlite3’

The above Gemfile defines the source entry which is the remote repository from which Bundler will download all the gems from, and each gem line defines an open source library, which version to fetch, as well as the source to find it at.

The following is a more complex example showing how Ruby Gems are sourced from different locations:

1ruby
2
3gem 'nokogiri'
4gem 'nokogiri', git: 'https://github.com/sparklemotion/nokogiri.git', branch: main'
5gem 'nokogiri', github: ‘sparklemotion/nokogiri’
6gem 'nokogiri', path: '/path/to/local/nokogiri’

More about these sources are described to length in the official Bundler git documentation.

What makes up a Ruby gem?

A Ruby gem is a packaged and modular Ruby source code that can be re-used by other Ruby gems, by Ruby applications, or generally by users interacting with it via the command line, like we’ve learned about Bundler earlier in this article.

Ruby gems usually follow a convention that includes the Ruby source code, tests, potential executable files made available to the user’s file path, and most importantly Ruby gems specification file, known as the Gemspec.

1sh 
2base64ness/
3├─ lib/
4│  ├─ base64.rb
5├─ bin/
6│  ├─ base64ness
7│  ├─ index.html
8│  ├─ robots.txt
9├─ test/
10│  ├─ base64
11├─ base64ness.gemspec
12├─ Rakefile
13├─ Gemfile
14├─ Gemfile.lock
15├─ README.md

Some of the directories like test/ and bin/ should be fairly straightforward as they represent the testing code with this dependency and the executable file to be shipped and installed with it. Other files we can call out are the Gemfile and Gemfile.lock files which we learned about when introducing Bundler to manage Ruby gems and its lockfile to make sure installs are deterministic and safely repeatable.

The Rakefile is a file that is attributed to the open source Rake project, which is a make-like build utility for Ruby projects. Thus, the name is similar to Makefile. Here’s an example to its contents:

1ruby
2require "rake/testtask"
3
4Rake::TestTask.new do |t|
5  t.libs << "test"
6  t.verbose = true
7  t.test_files = FileList["test/**/*.rb"]
8end
9
10desc "Run tests"
11task default: :test

The Rakefile imports the needed classes from the Rake gem and defines a test task. When the rake test command is executed in this directory it signals the Rake program to match using a glob pattern all the test files in the test/ folder and then run them. For more details on the format of the Rakefile refer to the Rake project repository’s documentation.

Another new file that you may have noticed in the file tree above is the gemspec file, which is often named the same as the package, such as base64ness.gemspec. The gemspec describes the Ruby gem and provides metadata for this dependency and is helpful to the RubyGems registry to track, analyze and make available this information to users browsing for Ruby gems.

Here’s an example of the above referenced base64 ruby gem base64ness.gemspec:

1ruby
2Gem::Specification.new do |s|
3  s.name        = "base64ness"
4  s.version     = "1.0.0"
5  s.summary     = "It does magic with base64!"
6  s.description = "This Ruby gem adds extended utilities for base64 encoding and decoding tasks such as making them URL compliant."
7  s.authors     = ["Liran Tal"]
8  s.email       = "liran@snyk.io"
9  s.files       = ["lib/base64.rb"]
10  s.homepage    =
11    "https://rubygems.org/gems/base64ness"
12  s.license       = "MIT"
13end
14

The RubyGems website provides a complete documentation for the Specification class we reference above, such as further metadata attached to a Ruby gem, extended capabilities allowing Ruby maintainers to define executables, bundling C extensions to Ruby gems which through cross-compilation provides native modules capabilities, and more.

Tip: if you’re curious about what’s inside a particular Ruby gem, you can unpack it to a directory and examine the source code as follows:

1sh
2gem unpack your-ruby-gem.gem --target /path/to/gem-directory

Controlling Ruby gem dependency installation

Dependencies can be logically grouped into specific categories in which they relate. For example, you can have production Ruby gem dependencies, as well as development Ruby gems that you’d install only when developing locally.

To differentiate between them you can define the following Gemfile manifest:

1ruby
2group :production do
3  gem ‘rails’,
4end
5
6group :development do
7  gem ‘rspec-rails’
8end

You can then install dependencies by category with Bundler. For example, the below command would only install production dependencies:

1sh
2bundle install --without development

Why do we need a Gemfile.lock?

If you were solely restricted to defining your dependencies using bundler’s Gemfile package manifest, then you’d be subject to the following constraints:

  1. Only direct Ruby gems dependencies are documented.

  2. These direct Ruby gem dependencies use a sparse and loosely defined version for the package. It could be fetching latest, or just the latest in a semantic version range.

  3. Even if you pin these direct Ruby gem dependencies to hard-coded versions, they could still resolve an unexpected dependent gem version with every new install.

The above leads to the fact that without a lockfile to pin down the entire nested dependency of Ruby gems installed for your project, you’d be introducing indeterministic versions of installed gems. That’s probably the last thing you want happening in an automated CI or build environment, or for the other Ruby developers who collaborate on the project with you.

Therefore, I give you the Gemfile.lock:

1ruby
2GEM
3  remote: https://rubygems.org/
4  specs:
5    actioncable (7.0.3.1)
6      actionpack (= 7.0.3.1)
7      activesupport (= 7.0.3.1)
8      nio4r (~> 2.0)
9      websocket-driver (>= 0.6.1)
10    actionmailbox (7.0.3.1)
11      actionpack (= 7.0.3.1)
12      activejob (= 7.0.3.1)
13      activerecord (= 7.0.3.1)
14      activestorage (= 7.0.3.1)
15      activesupport (= 7.0.3.1)
16      mail (>= 2.7.1)
17      net-imap
18      net-pop
19      net-smtp
20
21PLATFORMS
22  aarch64-linux
23
24DEPENDENCIES
25  bootsnap
26  capybara
27  debug
28  importmap-rails
29  jbuilder
30  puma (~> 5.0)
31  rails (~> 7.0.3, >= 7.0.3.1)
32  rails_admin!
33  selenium-webdriver
34  sprockets-rails
35  sqlite3 (~> 1.4)
36  stimulus-rails
37  turbo-rails
38  tzinfo-data
39  web-console
40  webdrivers
41
42RUBY VERSION
43   ruby 3.1.2p20
44
45BUNDLED WITH
46   2.3.18

Let’s review the specification and format of this gem dependency lockfile:

  • The GEM directive starts the block for listing out all the Ruby gem dependencies in a nested tree format which shows direct and transitive dependencies and their versions. These are identified under the specs directive. The remote directive instructs the bundler tool where is the source to fetch these Ruby gems for installation.

  • The PLATFORMS directive is an open list of target platforms for which building native Ruby gems is required, due to the cross-compilation chain needed for Ruby extensions written in C, for example.

  • The RUBY VERSION directive is optional and specifies the Ruby runtime version that was used when creating this Gemfile.lock lockfile.

  • The BUNDLED WITH directive specifies the version of Bundler which was used to create the lockfile. Developers which are using older versions should consider upgrading to stay up to date and maintain consistent and coherent Ruby gem install results.

bundler-audit and the case for improved Ruby gems security

With a great ecosystem, comes great responsibility. Indeed. This is where the community-powered project called bundler-audit comes in. It’s a Ruby gem which scans through the dependencies specified with the project’s lockfile (Gemfile.lock) and compares that with a database of vulnerable Ruby gems and will report any findings of versions that found matching one or more vulnerabilities.

Getting started with bundler-audit for Ruby gems vulnerability scanning

To begin scanning for vulnerabilities with bundler-audit, we first install it, just like any other Ruby gem:

1sh
2gem install bundler-audit

Once installed, bundler-audit needs to download security advisories from resources such as the community-maintained ruby-adivsory-db, which itself sources data from GitHub Advisory and Google’s maintained Open Source Vulnerability database:

1sh
2bundler-audit update

If successful, the bundler-audit command should output something similar to the following, indicating that everything is up to date:

1sh
2Updating ruby-advisory-db ...
3hint: Pulling without specifying how to reconcile divergent branches is
4hint: discouraged. You can squelch this message by running one of the following
5hint: commands sometime before your next pull:
6hint:
7hint:   git config pull.rebase false  # merge (the default strategy)
8hint:   git config pull.rebase true   # rebase
9hint:   git config pull.ff only       # fast-forward only
10hint:
11hint: You can replace "git config" with "git config --global" to set a default
12hint: preference for all repositories. You can also pass --rebase, --no-rebase,
13hint: or --ff-only on the command line to override the configured default per
14hint: invocation.
15From https://github.com/rubysec/ruby-advisory-db
16 * branch            master     -> FETCH_HEAD
17Already up to date.
18Updated ruby-advisory-db
19ruby-advisory-db:
20  advisories:596 advisories
21  last updated:2022-07-18 13:57:38 -0700
22  commit:66f047bdcb4bbda76857f9ba668b7d71b641b28b

Then we can run bundle-audit without any extra arguments and test for security vulnerabilities. Currently, for my local Ruby on Rails project, it printed the following:

1No vulnerabilities found

This looks good, but can be misleading and expose you to unnecessary risk. In fact, this is a false negative because security vulnerabilities do exist for some of the dependency versions that I have installed.

With all the respect attributed to the work done by the community to build security tooling, they’d probably be understaffed and not resourced well enough to have proper vulnerability coverage. This is where Snyk Open Source’s security scanner comes in.

Snyk has a (free) CLI tool, backed by a developer-first security company with a highly rich database of Ruby gems vulnerability reports, that you can install and scan your dependencies.

Securing Ruby gems (for free) with Snyk

Let’s run Snyk for a test drive and see what security vulnerabilities it comes up with for our Rails project? If you’re on an M1 MacBook Pro like myself, run the following:

1sh
2curl https://static.snyk.io/cli/latest/snyk-linux-arm64 -o snyk
3chmod +x snyk
4mv snyk /usr/local/bin

This will download the standalone Snyk binary, make it executable, and make it available in your path. Note, if you require other installation methods (such as brew for macOS, scoop for Windows, or otherwise, consult the getting started with the Snyk CLI docs.

Once we have the Snyk CLI installed, we can run it as follows:

1sh
2snyk test

If this is your first time you’ll be greeted with the following message:

1sh
2`snyk` requires an authenticated account. Please run `snyk auth` and try again.

Upon which you should type-in snyk auth and follow the instructions to copy an authentication URL on to the browser, login or sign-up action to receive your API key, and you’ll be back in your command line prompt in no-time to continue on with scanning:

1sh
2snyk test
3
4Testing /home/app/myapp...
5
6Tested 80 dependencies for known issues, found 2 issues, 59 vulnerable paths.
7
8Issues with no direct upgrade or patch:
9  ✗ Information Exposure [Medium Severity][https://security.snyk.io/vuln/SNYK-RUBY-ACTIONCABLE-20338] in actioncable@7.0.3.1
10    introduced by rails@7.0.3.1 > actioncable@7.0.3.1 and 1 other path(s)
11  No upgrade or patch available
12  ✗ Web Cache Poisoning [Medium Severity][https://security.snyk.io/vuln/SNYK-RUBY-RACK-1061917] in rack@2.2.4
13    introduced by capybara@3.37.1 > rack@2.2.4 and 56 other path(s)
14  No upgrade or patch available
15
16Organization:      snyk-demo-567
17Package manager:   rubygems
18Target file:       Gemfile
19Project name:      myapp
20Open source:       no
21Project path:      /home/app/myapp
22Licenses:          enabled

Well then, 2 issues and 59 vulnerable paths… not as safe as we thought we were. At the moment it looks like there are no newer versions to upgrade these libraries to across the nested dependency tree. So, to make sure Snyk monitors our project’s Gemfile continually to search for new version fixes to be applied we can do one of the following:

  1. Go to https://apps.snyk.io and connect Snyk to the source code repository on GitHub or elsewhere. This will allow Snyk to monitor this project in the background.

  2. Run the command snyk monitor which will take a snapshot of the current Gemfile and Gemfile.lock files and monitor them continuously. Be advised that you’ll need to run this from a CI or automated build so that every time those Ruby package manifest files are updated, you are sending a new snapshot for Snyk to monitor and keep track of.

Should I use bundle-audit or Snyk?

Use both. This isn’t a diplomatic statement, but rather they truly complement each other in the following ways:

  1. Snyk provides a rich database of security vulnerabilities in the Ruby ecosystem, manually curated and kept up to date, often even ahead of time of bundle-audit and others.

  2. Bundle-audit runs other checks aside from vulnerability scanning, such as verifying that your Ruby gems are not being fetched from insecure sources of the likes of http:// and git:// that would allow a man-in-the-middle attack, or lockfile injection attack, to tamper with the source code you receive and run.

Summary

We learned about managing Ruby gems as project dependencies, why using a lockfile is important, and outlined other aspects and recommended best practices, such as securely using Ruby dependencies by scanning them with tools like Snyk and bundler-audit.

Other Ruby resources:

For those of you who are also practicing JavaScript development, I highly recommend reading what is package lock json and how a lockfile works for yarn and npm packages.