Creating a Custom Bundler Plugin: Part 1 - Understanding Bundler Plugins and Trivy Integration
Learn the fundamentals of Bundler's plugin system and Trivy vulnerability scanning. This first part covers architecture, basic concepts, and initial plugin scaffolding to set the foundation for building a security scanner.
For many projects, managing dependencies was – and even is – a unfortunately manual affair. Developers would carefully track libraries, ensuring compatibility and addressing conflicts by hand. This difficulty has led to the creation of an ever-increasing list of tools like Bundler to automate dependency management. Bundler, in essence, provided a systematic solution to a chaotic problem for Ruby programming – and, unlike, say, Python, its been generally well accepted, and hasn’t spawned a huge group of compeitors.
However, the very power and ubiquity of Bundler and other centralized package systems creates its own critical challenge: the threat of security vulnerabilities, either accidental or intentionally, hidden within packages. We install gems, trust their transitive dependencies, and perhaps hope a security scanner in our CI pipeline will catch problems before they reach production. This reactive approach, though widely adopted, creates a significant gap between a vulnerability’s introduction and its discovery.
Bundler, of course, capably manages our dependencies, and there are innumerable vendors claiming to solve this problem in an ever-increasing list of ways. Of course, for-profit tools aren’t the only way; there are open source solutions, including tools like https://trivy.dev/latest/, which scan for vulnerabilities. (Unsurprisngly, the company behind Trivy does sell commercial services 1, but the core tool is free.)
Of course, using a vendors methodology often involves entering their ecosystem – even if its a reasonably benign one like that of Trivy.
It does not, of course, need to be that way.
Bundler’s plugin system is a way to extend Bundler’s own functionality. We can use this to incorporate Trivy into our workflow smoothly. Of course, this is PracticalRubyGems.com, so we’ll be using this as an excuse to discuss Bundlers plugin system – and you can create plugins which perform many different things, not just security-related or Trivy-specific
Unlike gems, which add functionality to applications and are managed by Bundler, plugins actually modify or enhance Bundler itself. They operate at a deeper level within its core processes, allowing them to hook into the dependency resolution process, add custom commands, and integrate external tools directly into the workflow developers already use. This provides a powerful extension mechanism that many developers never encounter, but which offers powerful capabilities for customizing Bundler’s behavior.
As noted, this article explores Bundler’s plugin architecture by building a practical integration — a Trivy scanner plugin that checks for vulnerabilities immediately after bundle install completes. We will examine the plugin API, understand Bundler’s lifecycle hooks, and create a tool that brings security scanning into the development workflow without requiring separate commands or extensive CI configuration. This approach demonstrates how Bundler plugins can extend the dependency manager with domain-specific functionality, offering a proactive layer of security within the dependency management process.
If you’d like to follow along – or skip ahead – the complete source code for this plugin is available at github.com/practicalrubygems/bundler-trivy-plugin.
Bundler Plugin Architecture
In the early days of computing, extending a system often meant modifying its core. This approach, while sometimes necessary, introduced significant challenges: maintaining custom forks, dealing with upstream changes, and ensuring stability. The ideal, of course, is to extend functionality without altering the foundational code, allowing for greater flexibility and easier upgrades.
Similarly, Bundler, at its core, is designed to manage Ruby gem dependencies. However, the diverse needs of developers and organizations often require additional capabilities — security scanning, custom reporting, or alternative gem sources — that go beyond Bundler’s default scope. Modifying Bundler’s core for each such need would be unsustainable.
This is where Bundler plugins become essential.2 They exist to extend Bundler’s functionality without requiring modifications to its core codebase. The plugin system provides three primary extension points: hooks, commands, and sources.3 Each serves a distinct purpose in the dependency management lifecycle, allowing us to tailor Bundler’s behavior to specific requirements while maintaining its integrity.
Discovery and Installation
Plugins install like gems; however, their registration process differs. While a standard gem install places a gem into your Ruby environment, a Bundler plugin requires explicit registration with Bundler itself. The bundle plugin install command handles this dual task: it installs the gem and then registers it as a plugin.4
For example, to install the bundler-trivy-plugin, we would run:
bundle plugin install bundler-trivy-plugin
This command not only installs the bundler-trivy-plugin gem but also records its presence in a special file, typically located at ~/.bundle/plugins.rb. This file acts as Bundler’s manifest for active plugins, containing entries like this:
# ~/.bundle/plugins.rb
Plugin.register("bundler-trivy-plugin", "0.1.0")
When Bundler starts up, it first consults ~/.bundle/plugins.rb. It loads all registered plugins before it even begins to process your application’s Gemfile. This early loading mechanism is crucial, as it allows plugins to intercept and extend Bundler’s behavior from the outset of its execution cycle.
The Plugin API Surface
At its most fundamental, a Bundler plugin requires a single file named plugins.rb located at the root of its gem.5 This file serves as the plugin’s manifest and entry point, where Bundler discovers and loads the plugin’s capabilities. Within this file, we define the core Plugin class that extends Bundler::Plugin::API, signaling to Bundler how it intends to extend functionality.
# plugins.rb
module BundlerTrivyPlugin
class Plugin < Bundler::Plugin::API
# Plugin implementation goes here
end
end
The Bundler::Plugin::API base class is the gateway to extending Bundler. It provides access to three distinct, yet interconnected, extension mechanisms, each designed to solve a specific category of problems within Bundler’s lifecycle:
Hooks allow plugins to respond to lifecycle events within Bundler’s execution. Think of them as notification points: Bundler emits hooks at specific, predefined moments — such as before and after dependency resolution, after gem installation, or when loading the runtime environment. Plugins register handlers to execute custom code at these precise junctures. For example, to run code immediately after all gems have been installed, we might use:
class Plugin < Bundler::Plugin::API
hook "after-install-all" do
# Execute custom logic after bundle install completes for all gems
end
end
Commands enable plugins to add new subcommands to the bundle CLI. This allows us to integrate custom tools directly into Bundler’s command-line interface, providing a seamless user experience. For instance, a plugin could register bundle trivy scan or bundle security check, making these operations feel like native Bundler commands. This is achieved by implementing a command class and defining an exec method to handle the command’s logic, effectively extending Bundler’s own command set:
class Plugin < Bundler::Plugin::API
command "trivy"
def exec(command, args)
# Handle the 'bundle trivy <args>' command execution
end
end
Sources provide the most powerful, and consequently, the most complex extension point: they allow plugins to integrate alternative gem sources beyond RubyGems.org.6 A source plugin can implement custom logic for authenticating with private gem servers, fetching gems from cloud storage solutions, or routing requests through corporate proxy infrastructure. Implementing a source requires a deep understanding of the source protocol that Bundler uses internally, making it suitable for advanced use cases involving custom dependency fetching.
While a full implementation is beyond the scope of a simple example, a source plugin would typically register itself and define methods to handle gem fetching. Here is a simplified illustration of how a source might be declared:
class Plugin < Bundler::Plugin::API
source "my-custom-source" do
# This block would contain logic to fetch gems from a custom source.
# For example, it might interact with a private gem server API.
# The actual implementation involves complex network interactions and parsing.
# This is a placeholder to illustrate the declaration.
end
end
Hooks and Lifecycle Events
Bundler’s lifecycle is punctuated by various hooks, which are emitted at strategic points during its execution.5 Understanding these events is crucial for deciding where a plugin’s custom logic should intervene, allowing for precise control over the dependency management workflow:
after-install: This hook is triggered after each individual gem completes its installation. This is useful for actions that need to be performed on a per-gem basis, such as logging individual installation events or performing a quick check on a newly installed gem.after-install-all: This hook fires after all gems in the bundle have successfully completed their installation. This is a common and powerful point for post-installation tasks that require the entire dependency graph to be present and stable, such as comprehensive security scans or generating reports.before-install-all: Conversely, this hook is triggered before any gem installation begins. It is useful for preparatory steps, environment checks, or pre-flight validations that need to occur before Bundler modifies the system or installs any gems.after-require: This hook fires when runtime dependency loading completes, typically afterBundler.setuporBundler.requirehas finished its work. This allows plugins to react to the final state of the application’s loaded dependencies.
To illustrate, consider how a plugin might use the after-install-all hook:
# Example: A plugin using the 'after-install-all' hook
class MyPlugin < Bundler::Plugin::API
hook "after-install-all" do |installer|
# The 'installer' object provides access to installation details.
# For instance, we could log that all gems are now installed.
Bundler.ui.info "All gems have been installed. Ready for post-installation tasks!"
end
end
For a task like security scanning, after-install-all often provides the most appropriate timing. At this point, Bundler has resolved all dependencies, installed every required gem, and finalized the Gemfile.lock. This means the complete and stable dependency graph is now present on disk, ready to be thoroughly scanned for vulnerabilities.
Plugin vs. Gem: Architectural Differences
Understanding the fundamental architectural differences between a Bundler plugin and a standard gem is crucial for making informed development decisions. While both extend functionality within the Ruby ecosystem, they operate at distinct layers and serve fundamentally different purposes. Recognizing these distinctions helps us leverage each tool effectively and choose the right abstraction for our needs.
A gem, in its traditional sense, is designed to add functionality to applications. It provides reusable classes, modules, and methods that your application code requires and directly uses. When you require a gem, its code becomes part of your application’s runtime environment, and the gem itself is an integral part of your application’s dependency tree. Its purpose is to enhance the application’s capabilities.
A plugin, by contrast, is designed to add functionality to Bundler itself. It executes during Bundler’s operational lifecycle, not during your application’s runtime. A plugin can interact with Bundler’s internal state — reading the Gemfile.lock, inspecting installed gems, and even modifying Bundler’s behavior through its defined extension points. A plugin does not typically introduce code that your application directly loads or executes at runtime, though; its purpose is to enhance Bundler’s capabilities.
This distinction, then, guides our decision when choosing to build a plugin versus a gem. If the functionality we envision directly relates to dependency management — such as auditing, security reporting, integrating alternative gem sources, or implementing custom Bundler workflows — then a plugin is the appropriate choice. Conversely, if the functionality provides features that your applications will directly use — like logging utilities, authentication mechanisms, data processing libraries, or UI components — then a standard gem is what we need.
Consider the Trivy scanner integration: its purpose is to examine gems after installation, report vulnerabilities, and potentially block deployment based on security policies. This process directly extends Bundler’s dependency management workflow, rather than adding features to the application itself. Therefore, it fits well within the plugin model, as it enhances what Bundler does, not what the application does.
Accessing Bundler Internals
One of the key strengths of Bundler plugins is their ability to directly access Bundler’s internal state and data structures through the Bundler module. This provides a powerful, programmatic interface to information that would otherwise require manual file parsing or complex introspection, allowing plugins to make informed decisions based on the project’s dependency landscape. This direct access is crucial for tasks that require deep insight into the resolved dependencies and their metadata.
Here are some common ways plugins interact with Bundler’s internals, demonstrating the rich data available:
# We can read the resolved dependency graph from the lockfile
# This provides a snapshot of the exact gems and versions Bundler has selected.
lockfile = Bundler.default_lockfile<sup class="footnote-ref" id="footnote-ref-7"><a href="#footnote-7" class="footnote-link">7</a></sup>
specs = lockfile.specs # This gives us an array of Gem::Specification objects,
# each representing a resolved gem with its full metadata.
# We can also access the specifications of all currently installed gems.
# This is particularly useful for verifying the actual state of the environment.
Bundler.load.specs.each do |spec|<sup class="footnote-ref" id="footnote-ref-7"><a href="#footnote-7" class="footnote-link">7</a></sup>
puts "#{spec.name} #{spec.version}" # Output: faker 2.20.0, rails 7.1.2, etc.
end
# We can easily retrieve the absolute path to the application's Gemfile.
# This is essential for plugins that need to read or analyze the Gemfile itself.
gemfile_path = Bundler.default_gemfile
These APIs are invaluable because they allow plugins to inspect the complete dependency graph, read specific version constraints, and access rich gem metadata — all without the need to manually parse Gemfile, Gemfile.lock, or individual .gemspec files. Bundler meticulously maintains these data structures throughout its execution, and plugins can reliably query them through these stable, well-defined interfaces. Of course, this level of programmatic access greatly simplifies the development of sophisticated plugin functionalities.
Plugin Isolation and Limitations
While Bundler plugins offer significant power, it is crucial to understand their inherent isolation and limitations. Grasping these boundaries helps us avoid common pitfalls and ensures that our plugins enhance, rather than destabilize, Bundler’s core operations. These limitations are not arbitrary; they are deliberate design choices that safeguard Bundler’s stability and reliability.
First, plugins run directly within Bundler’s own Ruby process, sharing its environment. This means that if a plugin introduces a dependency that conflicts with Bundler’s internal gems or versions, it can lead to significant problems. Such conflicts might cause the plugin to fail to load, or, in more severe cases, could even cause Bundler itself to malfunction. This shared environment necessitates careful dependency management within plugins to avoid unexpected side effects and maintain Bundler’s operational integrity.
Second, and arguably more importantly, plugins cannot modify core Bundler behavior beyond the explicitly provided extension points. This is a deliberate design choice. We cannot, for instance, change how Bundler resolves dependencies, rewrite version constraints directly within Gemfiles, or alter the fundamental Gemfile.lock format. The plugin API is intentionally constrained to prevent plugins from undermining Bundler’s core guarantees of consistent and reliable dependency management. This ensures that Bundler remains a stable foundation, even with various plugins extending its capabilities.
For our Trivy integration, these limitations do not pose a problem. The plugin’s role is to read existing data from Bundler’s state and then execute an external security scanning tool. It does not attempt to modify Bundler’s core functionality or introduce complex, potentially conflicting, dependencies into Bundler’s own environment.
Why Plugins Remain Underutilized
Given the power and flexibility that Bundler plugins offer, one might reasonably ask: why are they not more widely adopted?8 The plugin system, of course, exists and is robust, yet it sees relatively limited utilization across the Ruby ecosystem. Several factors contribute to this underutilization, which we will explore here:
- Minimal Documentation and Awareness: A significant hurdle is the lack of comprehensive documentation and readily available examples. While the official Bundler documentation mentions plugins, it provides few in-depth tutorials or practical use cases. Consequently, many developers remain unaware that such a powerful extension mechanism even exists, or how to effectively leverage it.
- Niche Use Cases: The scenarios that genuinely require a Bundler plugin are inherently less common than those that call for a standard gem. Most functionality developers need to add is typically application-specific, belonging within their application’s codebase rather than extending the dependency manager itself. This means the need for a plugin arises in specialized contexts.
- Added Installation Complexity: Plugins introduce an additional, explicit step in project setup. Users must explicitly run
bundle plugin installin addition to the familiarbundle install. This can be perceived as an extra layer of complexity in an already streamlined workflow, especially for projects with many contributors or automated setup processes.
Despite these challenges and their limited adoption, the Bundler plugin system provides real and undeniable value for specific, critical use cases. Functionalities such as automated security scanning, custom dependency reporting, enforcing corporate policy on gem usage, and integrating alternative gem sources all benefit immensely from a plugin-based implementation. These specialized use cases more than justify the effort to understand how plugins work and when to strategically leverage them in our projects.
Trivy Fundamentals
Our applications, in their modern form, rarely exist in isolation. Instead, we build upon a foundation of open-source libraries and frameworks. While these components offer immense functionality, they can also introduce security vulnerabilities. Effectively managing these dependencies and ensuring their security presents a continuous challenge. This is where tools like Trivy prove invaluable.
Trivy, a comprehensive vulnerability scanner maintained by Aqua Security, is designed to identify known security vulnerabilities across various targets.9 These targets include container images, filesystems, and dependency manifests. Trivy offers a unified scanning experience across multiple programming language ecosystems, such as Ruby, Python, JavaScript, and Go. This allows us to use a single tool to assess the security posture of diverse projects, rather than relying on a patchwork of application-specific scanners.
What Trivy Scans
For Ruby projects, Trivy focuses its scanning efforts on the Gemfile.lock file.10 This file is essential because it precisely records the exact versions of all gems and their dependencies used in your project. Trivy reads this lockfile to identify these specific gem versions, and then cross-references them against its extensive vulnerability database. This database aggregates data from multiple sources:
- Ruby Advisory Database (rubysec/ruby-advisory-db)
- National Vulnerability Database (NVD)
- GitHub Security Advisories
- Language-specific security feeds
Trivy downloads and caches this database locally. This local caching allows subsequent scans to execute offline, which contributes to faster performance for repeated checks and makes it ideal for environments with limited or no internet access after the initial download.
Command-Line Interface
The basic scan command accepts a directory path, which can be your project’s root directory:
$ trivy fs --scanners vuln /path/to/project<sup class="footnote-ref" id="footnote-ref-11"><a href="#footnote-11" class="footnote-link">11</a></sup>
The --scanners vuln flag explicitly limits the scan to vulnerabilities,12 allowing us to focus on critical security issues without being distracted by other scan types, such as license checks or configuration issues, which Trivy also supports.
For Ruby projects, Trivy is designed to automatically detect and scan the Gemfile.lock file. This means you do not need to provide explicit configuration to tell Trivy how to understand your Ruby dependencies, streamlining its integration into your workflow.
Output Formats
Beyond merely identifying vulnerabilities, effective security scanning requires presenting findings in formats useful for diverse audiences and workflows. Trivy supports multiple output formats, enabling us to tailor the presentation of scan results to specific requirements. The default table format provides human-readable output, which is particularly useful for quick visual inspection and for developers who prefer to review scan results directly in their terminal. We can explicitly request this format, though, of course, it is the default:
$ trivy fs --scanners vuln --format table /path/to/project
Note that the exact vulnerabilities, versions, and severity levels in your output will vary based on your project’s dependencies and the latest vulnerability database:
Gemfile.lock (bundler)
======================
Total: 2 (CRITICAL: 1, HIGH: 1)
┌─────────────┬──────────────────┬──────────┬───────────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │
├─────────────┼──────────────────┼──────────┼───────────────────┼───────────────┤
│ rails │ CVE-2023-XXXXX │ CRITICAL │ 6.1.0 │ 6.1.7.4 │
│ nokogiri │ CVE-2023-YYYYY │ HIGH │ 1.13.8 │ 1.14.0 │
└─────────────┴──────────────────┴──────────┴───────────────────┴───────────────┘
One may wonder: what if we need to process these results programmatically? For automated workflows and integration with other tools, Trivy offers a machine-readable JSON format:
$ trivy fs --scanners vuln --format json /path/to/project
This structured output proves valuable for integrating Trivy into automated workflows, allowing other tools or scripts to parse and act upon the vulnerability data programmatically. The JSON output, while varying in exact content based on your project’s dependencies and the latest vulnerability database, consistently reveals several key fields. These fields provide detailed information about each identified vulnerability:
VulnerabilityID: A unique identifier for the vulnerability (e.g., CVE-2023-XXXXX).PkgName: The name of the affected package (e.g.,rails).InstalledVersion: The version of the package currently in your project.FixedVersion: The version of the package where the vulnerability is resolved.Severity: The severity level of the vulnerability (e.g.,CRITICAL,HIGH).TitleandDescription: Human-readable summaries of the vulnerability.PrimaryURL: A link to further details about the CVE.
These fields are crucial for automated parsing, allowing us to build custom reports, trigger alerts, or even automatically create issues in project management systems.
{
"SchemaVersion": 2,
"ArtifactName": "/path/to/project",
"ArtifactType": "filesystem",
"Results": [
{
"Target": "Gemfile.lock",
"Class": "lang-pkgs",
"Type": "bundler",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2023-XXXXX",
"PkgName": "rails",
"InstalledVersion": "6.1.0",
"FixedVersion": "6.1.7.4",
"Severity": "CRITICAL",
"Title": "Rails SQL Injection Vulnerability",
"Description": "Rails before 6.1.7.4 allows SQL injection...",
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-xxxxx"
}
]
}
]
}
To inspect the JSON output, especially for specific fields, we can use command-line tools like jq. For example, to extract the VulnerabilityID and Severity for each vulnerability, we could pipe the Trivy output to jq like this:
$ trivy fs --scanners vuln --format json /path/to/project | jq '.Results[].Vulnerabilities[] | {VulnerabilityID, Severity}'
This allows us to quickly parse and focus on the most critical information, demonstrating the power of combining Trivy with other Unix-like tools for efficient security analysis.
Database Management
Trivy maintains a local vulnerability database, typically located at ~/.cache/trivy/db.13 The scanner downloads this database automatically on its first run and updates it periodically to ensure you have the latest vulnerability information.14
While automatic updates are convenient, there are times when we might need to trigger a manual database update. We can do this with the following command:
$ trivy image --download-db-only
Of course, the database size varies, but it typically ranges from 200-300MB compressed. These database updates usually complete in seconds over reasonable network connections.
For environments with restricted internet access, often referred to as air-gapped environments, Trivy supports offline database distribution. You can download the database on an internet-connected machine, transfer it to your isolated environment, and then configure Trivy to use that local copy. This ensures that even in highly secure or disconnected setups, you can maintain up-to-date vulnerability scanning capabilities.
Exit Codes
Trivy uses exit codes to signal the outcome of a scan, which is a critical feature for integrating it into automated workflows like CI/CD pipelines:15
0: The scan completed, and no vulnerabilities were found.1: The scan completed, but vulnerabilities were found.- Other non-zero codes: These typically indicate errors during the scan, such as database issues or a file not being found.
This exit code behavior is particularly useful for CI systems. By default, if Trivy finds vulnerabilities, it will exit with a non-zero code (typically 1), which can be configured to fail a build. This ensures that our CI pipeline flags security issues early in the development cycle:
$ trivy fs --scanners vuln --exit-code 1 .
$ echo $?
1 # Build fails if vulnerabilities found
The --exit-code flag provides fine-grained control over this behavior. For instance, setting --exit-code 0 prevents Trivy from failing builds regardless of any findings. This can be useful when you are treating scans as purely informational, perhaps in a monitoring pipeline where you want to collect data without halting deployments.
False Positives and Noise
It is a common occurrence for vulnerability databases to contain false positives. A CVE (Common Vulnerabilities and Exposures) might, for example, apply to specific usage patterns that do not actually affect your application. Trivy, by its nature, reports the vulnerability because it cannot definitively determine whether your code exercises the vulnerable code path.
To manage these situations, Trivy provides an ignore file mechanism, typically named .trivyignore,16 which allows us to suppress specific findings:
# .trivyignore
CVE-2023-XXXXX # Rails SQL injection doesn't affect read-only queries
It is crucial to understand that this suppression requires clear justification. We must document why each ignored vulnerability does not apply to our application. Undocumented ignores can quickly become a form of technical debt, as team members may later forget the rationale behind dismissing a particular vulnerability, potentially leading to security blind spots or wasted effort in re-evaluating old findings. Prioritizing this documentation contributes to the long-term maintainability and security posture of our projects.
Integration Patterns
Trivy can be integrated into our development workflows through several primary patterns, each presenting its own set of trade-offs and benefits. Understanding these approaches helps us choose the most suitable strategy for our projects.
-
Direct execution: This involves running Trivy as a standalone tool, either manually by developers or through custom scripts. This is the most straightforward method. This approach offers significant flexibility, allowing us to scan specific targets on demand. However, it requires consistent discipline to ensure scans are performed regularly across all projects.
-
CI integration: Here, Trivy is added to the continuous integration (CI) pipeline. It acts as an automated security gate. The scanner runs automatically on every commit or pull request. This pattern is effective for catching vulnerabilities before code is merged into the main branch, though it typically occurs later in the development cycle, meaning feedback on security issues might not be immediate.
-
Hook-based integration: This pattern embeds Trivy directly into existing development tools and workflows. Examples include Git hooks, editor plugins, or — most relevant to our discussion in this article — Bundler plugins. Hook-based integration provides the earliest possible feedback on security vulnerabilities, as scans execute during normal development activities. Nevertheless, it requires careful implementation to avoid disrupting developer productivity.
The Bundler plugin, which we will explore in detail, represents an effective form of hook-based integration. It runs Trivy automatically after dependency installation, providing immediate feedback on newly introduced vulnerabilities without requiring separate commands or waiting for CI builds to complete. Therefore, this proactive approach helps us address security concerns as soon as they arise.
Plugin Scaffolding: Laying the Foundation for Bundler Integration
To integrate Trivy, we must first understand the architecture of Bundler plugins. A Bundler plugin, at its core, is a RubyGem designed to extend Bundler’s functionality. Building such a plugin requires a clear understanding of two primary components: the standard gem structure that distributes the plugin’s code, and the specific entry point Bundler uses for loading. While a plugin gem largely adheres to conventional RubyGems practices, it introduces one critical distinction: the presence of a plugins.rb file at the gem’s root directory, which serves as Bundler’s direct interface.
Directory Structure: The Blueprint of a Bundler Plugin
For a Bundler plugin to function correctly, a well-defined directory structure is essential. This structure ensures that Bundler can reliably locate and load your plugin’s components. While largely consistent with standard RubyGems, a key distinction lies in the placement of the plugins.rb file. Let’s examine a typical layout for our bundler-trivy-plugin:
bundler-trivy-plugin/
├── plugins.rb # The primary entry point for Bundler to load the plugin.
├── bundler-trivy-plugin.gemspec # Defines the gem's metadata, dependencies, and files.
├── lib/ # Contains the core Ruby code for the plugin.
│ └── bundler/
│ └── trivy/
│ ├── plugin.rb # Coordinates the plugin's main logic and interactions.
│ ├── scanner.rb # Encapsulates the logic for executing Trivy and parsing its output.
│ └── reporter.rb # Handles the formatting and display of scan results to the user.
├── spec/ # Houses the test suite for the plugin.
│ ├── spec_helper.rb # Configuration for RSpec tests.
│ └── plugin_spec.rb # Tests for the core plugin functionality.
└── README.md # Provides an overview and usage instructions for the gem.
The plugins.rb file is unique in its placement: it resides directly at the gem’s root, not within the lib/ directory. This is a deliberate design choice by Bundler, which loads this file directly when it activates the plugin. All other Ruby code, following standard Ruby gem conventions, belongs within the lib/ directory. This separation ensures that Bundler can quickly locate and initialize the plugin, while the rest of the implementation remains organized and follows established best practices for Ruby libraries.
The Gemspec: Declaring Plugin Identity and Dependencies
The .gemspec file is fundamental for any RubyGems project, and our Bundler plugin is no exception. It serves as the manifest for our gem, declaring its metadata, files, and dependencies. Let’s examine the bundler-trivy-plugin.gemspec:
# bundler-trivy-plugin.gemspec
Gem::Specification.new do |spec|
spec.name = "bundler-trivy-plugin" # The name of our gem, used for installation and identification.
spec.version = "0.1.0" # The current version of the gem.
spec.authors = ["Your Name"] # The author(s) of the gem.
spec.email = ["you@example.com"] # Contact email for the author(s).
spec.summary = "Trivy security scanner integration for Bundler" # A concise, one-line summary of the gem's purpose.
spec.description = "Automatically scans dependencies for vulnerabilities using Trivy after bundle install" # A more detailed explanation of what the gem does.
spec.homepage = "https://github.com/yourusername/bundler-trivy-plugin" # The URL for the gem's project page.
spec.license = "MIT" # The license under which the gem is distributed.
spec.required_ruby_version = ">= 2.7.0" # Specifies the minimum Ruby version required to run this gem.
# These files will be included in the distributed gem package.
spec.files = Dir["lib/**/*", "plugins.rb", "README.md", "LICENSE"]
spec.require_paths = ["lib"] # Specifies directories that should be added to Ruby's $LOAD_PATH when the gem is activated.
# A crucial design decision for this plugin: we explicitly avoid runtime dependencies on other gems.
# This is because the plugin shells out to the `trivy` binary directly.
# Avoiding gem dependencies helps prevent potential conflicts with Bundler's own internal dependencies
# or with the dependencies of the project being scanned. This pragmatic approach prioritizes stability
# and minimizes the risk of unexpected behavior during critical Bundler operations.
spec.add_development_dependency "bundler", "~> 2.0" # Dependencies needed only for development and testing.
spec.add_development_dependency "rspec", "~> 3.12"
end
A notable aspect of this gemspec is the absence of runtime gem dependencies. This is a deliberate and pragmatic choice. Our plugin executes Trivy as an external command, which means it does not need to rely on other RubyGems to function. This approach offers a distinct advantage: it helps us avoid potential dependency conflicts that might arise with Bundler’s own internal dependencies or with the dependencies of the projects being scanned. By minimizing its own RubyGems footprint, the plugin is designed to be more stable and less intrusive, contributing to a more maintainable and predictable development environment, especially during critical bundle install operations.
The Plugin Entry Point: plugins.rb and Bundler Hooks
Unlike standard RubyGems, which typically load their functionality through require statements within the lib/ directory, Bundler plugins require a specific entry point: the plugins.rb file. This file, located at the gem’s root, serves as the primary interface for Bundler to discover and activate your plugin. This deliberate design ensures efficient loading and integration with Bundler’s internal systems, minimizing overhead.
The plugins.rb file’s responsibilities are intentionally constrained, focusing on three primary tasks:
- Load the Plugin Implementation: It loads the main plugin logic from
lib/bundler/trivy/plugin.rb. This keeps the entry point clean and delegates complex tasks to the appropriate classes, adhering to the principle of separation of concerns. - Register Hooks with Bundler: The
Bundler::Plugin.add_hookmethod registers lifecycle hooks that execute at specific points in Bundler’s execution. Bundler offers various lifecycle hooks (e.g.,after-install-all,before-install-all,after-update-all) that correspond to specific events. The Ruby block passed toadd_hookthen defines the code that will execute when that particular event occurs. In our case, we instruct Bundler to callBundler::Trivy::Plugin.scan_after_installimmediately after all gems have been installed, ensuring our security scan runs at an opportune moment.
One may wonder: why is plugins.rb so minimal? This design choice is deliberate and reflects a core principle of separation of concerns. While plugins.rb efficiently handles the initial registration and hook declaration, the main, more complex logic for our Bundler Trivy plugin resides in lib/bundler/trivy/plugin.rb. This clear division ensures that the entry point remains lightweight and focused, while the core functionality is organized and maintainable within its dedicated module.
Let’s look at the contents of plugins.rb:
# plugins.rb
require_relative "lib/bundler/trivy/plugin" # Loads the core plugin implementation.
# Register the 'after-install-all' hook
# This hook ensures our scanning logic runs immediately after all gems have been installed.
Bundler::Plugin.add_hook("after-install-all") do
Bundler::Trivy::Plugin.scan_after_install
end
Core Plugin Implementation: Orchestrating the Scan Workflow
While plugins.rb handles the initial registration and hook declaration, the main logic for our Bundler Trivy plugin resides in lib/bundler/trivy/plugin.rb. This class acts as the orchestrator, coordinating the various steps involved in performing a security scan after bundle install completes.
Let’s examine the core implementation:
# lib/bundler/trivy/plugin.rb
require "bundler"
require_relative "scanner"
require_relative "reporter"
require_relative "config"
module Bundler
module Trivy
class Plugin
# This class method is called by the Bundler hook after all gems are installed.
def self.scan_after_install
# Load configuration from file and environment variables
config = Config.new
# Skip if explicitly disabled via config or environment variable
return if config.skip_scan?
# Determine the project's lockfile path and root directory.
lockfile_path = Bundler.default_lockfile.to_s
project_root = File.dirname(lockfile_path)
# Initialize the scanner and perform the Trivy scan.
scanner = Scanner.new(project_root, config)
results = scanner.scan
# Initialize the reporter and display the scan results to the user.
reporter = Reporter.new(results, config)
reporter.display
# Exit with error code if vulnerabilities exceed configured thresholds
handle_exit_code(results, config)
end
# Handles exit code based on vulnerability severity and configuration.
def self.handle_exit_code(results, config)
if results.has_critical_vulnerabilities? && config.fail_on_critical?
Bundler.ui.error "CRITICAL vulnerabilities found. Install blocked."
exit 1
elsif results.has_vulnerabilities? && config.fail_on_any?
Bundler.ui.error "Vulnerabilities found. Install blocked."
exit 1
end
end
end
end
end
This Plugin implementation employs a distinct separation of responsibilities, delegating specific tasks to specialized classes:
Scanner: This class is responsible for handling the execution of the external Trivy binary and parsing its raw output into a structured format.Reporter: This class takes the structured scan results and formats them for clear and concise display to the user in the terminal.Plugin(this class): Its role is to coordinate the overall workflow, retrieve necessary context (like the project root), and determine the final exit behavior based on scan results and user configuration. For example, theexit 1statement is a deliberate choice to allow continuous integration (CI) systems to fail a build if critical vulnerabilities are detected, enforcing security policies. Thefail_on_vulnerabilities?method provides a mechanism for users to control this behavior via an environment variable, offering flexibility without altering the code.
Scanner Skeleton: Executing Trivy and Parsing Results
The Scanner class is where the interaction with the external Trivy security scanner takes place. Its primary responsibility is to execute the trivy binary with the correct arguments, capture its output, and then parse that output into a structured format that our plugin can easily work with.
Let’s look at the lib/bundler/trivy/scanner.rb skeleton:
# lib/bundler/trivy/scanner.rb
require "json" # For parsing Trivy's JSON output.
require "open3" # For executing external commands and capturing stdout, stderr, and exit status.
require "timeout" # For enforcing scan timeouts.
require_relative "scan_result"
module Bundler
module Trivy
class Scanner
def initialize(project_root, config = nil)
@project_root = project_root # The root directory of the project being scanned.
@config = config || Config.new
end
# Executes the Trivy scan command.
def scan
args = build_trivy_args
timeout = @config.trivy_timeout
# Execute Trivy with timeout for robust command execution
stdout, stderr, status = Timeout.timeout(timeout) do
Open3.capture3(*args)
end
# Handle Trivy exit codes:
# 0 = success, no vulnerabilities
# 1 = success, vulnerabilities found
# >1 = error condition
raise ScanError, build_error_message(status.exitstatus, stderr) if status.exitstatus > 1
# Parse JSON output into structured data
data = parse_json(stdout)
ScanResult.new(data, @config)
rescue JSON::ParserError => e
raise ScanError, "Invalid JSON from Trivy: #{e.message}"
rescue Timeout::Error
raise ScanError, "Trivy scan timed out after #{timeout} seconds"
end
# Checks if the Trivy binary is available in the system's PATH.
# This is an important defensive check to prevent errors if Trivy is not installed.
def trivy_available?
system("which trivy > /dev/null 2>&1")
end
private
def build_trivy_args
args = [
"trivy", "fs",
"--scanners", "vuln",
"--format", "json",
"--quiet"
]
# Add severity filtering if configured
severity_filter = @config.severity_filter
args += ["--severity", severity_filter.join(",")] if severity_filter&.any?
args << @project_root
args
end
def parse_json(json_string)
return {} if json_string.empty?
JSON.parse(json_string)
end
def build_error_message(exit_code, stderr)
"Trivy scan failed with exit code #{exit_code}: #{stderr}"
end
end
class ScanError < StandardError; end
end
end
The Open3.capture3 method offers a robust mechanism for executing external commands, standing in contrast to simpler, less controlled methods like system() or backticks. While system() returns only a boolean indicating success or failure, and backticks capture only stdout, Open3.capture3 provides a more comprehensive and controlled way to interact with system binaries. By separately capturing stdout, stderr, and the exit status, we gain fine-grained control over error handling and can distinguish between a scan that found no vulnerabilities and a scan that failed to run at all.
The trivy_available? method, on the other hand, serves as a proactive check to ensure the Trivy binary is even present on the system before attempting a scan, preventing unnecessary failures.
Finally, the ScanResult class acts as a lightweight wrapper around Trivy’s raw JSON output, making it easier and more idiomatic to access and process the vulnerability data within our Ruby code.
Reporter Skeleton: Formatting Scan Results for User Feedback
The Reporter class is dedicated to presenting the Trivy scan results in a user-friendly format within the terminal. While our initial implementation is foundational, its purpose is clear: to translate the raw vulnerability data into actionable feedback for the developer.
Let’s examine the lib/bundler/trivy/reporter.rb skeleton:
# lib/bundler/trivy/reporter.rb
module Bundler
module Trivy
class Reporter
def initialize(results)
@results = results # The structured ScanResult object containing vulnerability data.
end
# Displays the scan results to the console.
def display
vulns = @results.vulnerabilities
if vulns.empty?
puts "[✓] No vulnerabilities found" # A positive message when no issues are detected.
return
end
# Informs the user about the total number of vulnerabilities found.
puts "\n⚠ Found #{vulns.size} vulnerabilities:\n\n"
# Groups vulnerabilities by severity level and displays a count for each.
vulns.group_by { |v| v["Severity"] }.each do |severity, sevs|
puts "#{severity}: #{sevs.size}"
end
end
end
end
end
This basic implementation currently groups vulnerabilities by their severity level and displays a count for each, providing an immediate overview of the security posture. In later sections, we will expand upon this Reporter class to provide a more comprehensive security report, including detailed vulnerability information such as specific package names, Common Vulnerabilities and Exposures (CVE) identifiers, and recommended remediation steps.
Next: Continue to Part 2: Core Scanner Implementation to build the complete scanning engine, parse Trivy output, and create comprehensive vulnerability reports.
References
- You can find out more about their platform at [https://www.aquasec.com/aqua-cloud-native-security-platform/]. ↩
- Arko, André. "Towards a Bundler plugin system." André Arko (blog). July 23, 2012. https://andre.arko.net/2012/07/23/towards-a-bundler-plugin-system/ ↩
- Class: Bundler::Plugin::API (Ruby 3.1.5). Ruby-Doc.org. Accessed November 4, 2025. https://ruby-doc.org/3.1.5/stdlibs/bundler/Bundler/Plugin/API.html ↩
- bundle plugin. Bundler.io. Accessed November 4, 2025. https://bundler.io/man/bundle-plugin.1.html ↩
- How to write a Bundler plugin. Bundler.io. Accessed November 4, 2025. https://bundler.io/guides/bundler_plugins.html ↩
- suhastech/bundler-custom-source repository. GitHub. Accessed November 4, 2025. https://github.com/suhastech/bundler-custom-source ↩
- Module: Bundler (Ruby 3.1.5). Ruby-Doc.org. Accessed November 4, 2025. https://ruby-doc.org/3.1.5/stdlibs/bundler/Bundler.html ↩
- Time to rethink Rubygems and Bundler? Reddit. 2024. https://www.reddit.com/r/ruby/comments/1obeqm7/time_to_rethink_rubygems_and_bundler_aka_story_of/ ↩
- Trivy Documentation. Trivy 0.55 Documentation. Accessed November 4, 2025. https://trivy.dev/v0.55/ ↩
- Language-specific Packages. Trivy 0.35 Documentation. Accessed November 4, 2025. http://trivy.dev/v0.35/docs/vulnerability/detection/language/ ↩
- Continuous Container Vulnerability Testing with Trivy. Semaphore (blog). May 25, 2021. https://semaphore.io/blog/continuous-container-vulnerability-testing-with-trivy ↩
- Tutorial: Working with Scanners. Chainguard Academy. Accessed November 4, 2025. https://edu.chainguard.dev/chainguard/chainguard-images/staying-secure/working-with-scanners/trivy-tutorial/ ↩
- Trivy vulnerability database location. GitHub Issue. March 10, 2020. https://github.com/aquasecurity/trivy/issues/423 ↩
- Vulnerability Database. Trivy 0.40 Documentation. Accessed November 4, 2025. http://trivy.dev/v0.40/docs/vulnerability/db/ ↩
- Trivy. 0x1.gitlab.io. Accessed November 4, 2025. https://0x1.gitlab.io/security/Trivy/ ↩
- Trivy Vulnerability Scanning. Trivy Operator 0.12.1 Documentation. Accessed November 4, 2025. https://aquasecurity.github.io/trivy-operator/v0.12.1/docs/vulnerability-scanning/trivy/ ↩