Creating a Custom Bundler Plugin: Part 2 - Building the Core Security Scanner
Build the core functionality of a Bundler security plugin. This part covers advanced scaffolding, hooking into bundle install, parsing Trivy output, and presenting scan results to users.
In Part 1, we explored Bundler’s plugin architecture and laid the foundation for integrating Trivy security scanning. We examined how plugins hook into Bundler’s lifecycle, created the basic scaffolding, and established error handling patterns. Now, we move beyond the fundamentals to build the core scanning engine.
This installment focuses on the practical implementation of security scanning: executing Trivy, parsing its output, and presenting results to developers. We will examine how to hook into bundle install, transform raw JSON into actionable vulnerability reports, and display findings in a way that respects developer attention. The patterns we establish here — defensive execution, structured parsing, and thoughtful presentation — form the operational heart of our plugin.
The complete source code for this plugin is available at github.com/practicalrubygems/bundler-trivy-plugin.
Local Development Workflow: Testing Your Plugin Iteratively
Developing a Bundler plugin effectively requires an efficient local development workflow. This involves building your gem, installing it as a plugin from a local source, and verifying its functionality without needing to publish it to RubyGems.org. Let’s walk through the essential steps:
-
Build the Gem: First, we need to package our plugin’s code into a
.gemfile. This command reads ourgemspecand creates the distributable gem.$ gem build bundler-trivy-plugin.gemspec -
Install as a Plugin: Next, we install the newly built gem as a Bundler plugin. The
--sourceflag instructs Bundler to install the plugin from a local path rather than attempting to fetch it from a remote gem source like RubyGems.org. This is important for testing changes during development.$ bundle plugin install bundler-trivy-plugin --source /path/to/bundler-trivy-pluginNote: Replace
/path/to/bundler-trivy-pluginwith the actual absolute path to your plugin’s root directory. -
Verify Installation: After installation, we can confirm that our plugin is recognized by Bundler using the
bundle plugin listcommand.$ bundle plugin list bundler-trivy-plugin (0.1.0)You should see your plugin listed with its version, indicating successful installation.
-
Uninstall the Plugin: When you need to remove the plugin, perhaps to test a clean installation or to switch to a different version, use the
bundle plugin uninstallcommand.$ bundle plugin uninstall bundler-trivy-plugin -
Rapid Iteration: For a more efficient development cycle, especially when making frequent code changes, you can combine these steps into a single command. This sequence rebuilds the gem, uninstalls any existing version, and then reinstalls the updated plugin from the current directory.
$ gem build bundler-trivy-plugin.gemspec && \ bundle plugin uninstall bundler-trivy-plugin && \ bundle plugin install bundler-trivy-plugin --source .This chained command ensures that your latest changes are quickly reflected in your local Bundler environment, allowing for rapid testing and debugging.
Error Handling Strategy: Building Resilient Bundler Plugins
Bundler plugins operate within a critical part of a developer’s workflow. A plugin failure can disrupt bundle install, effectively preventing a project from functioning. Therefore, a robust error handling strategy is essential for maintaining the stability and usability of the development environment. The core principle here is that plugins should enhance the workflow, not break fundamental operations. A vulnerability scanner that prevents bundle install from completing creates more problems than it solves.
Our plugin is designed to gracefully handle three primary failure modes, ensuring that even when issues arise, Bundler can still complete its core tasks:
-
Trivy Not Installed: Before attempting any scan, the plugin should verify that the
trivybinary is available on the system. If Trivy is missing, we should inform the user with a clear warning but not halt thebundle installprocess. This allows developers to proceed with their work while being aware of the missing security tool.# In lib/bundler/trivy/plugin.rb, within scan_after_install unless scanner.trivy_available? Bundler.ui.warn "Trivy not found. Install: https://trivy.dev/docs/getting-started/installation/" return # Exit the plugin's execution gracefully. endThis proactive check, utilizing the
trivy_available?method we introduced earlier, is crucial for ensuring the plugin fails gracefully, providing a clear warning to the user without disrupting the fundamentalbundle installprocess. -
Trivy Execution Failure: Even if Trivy is installed, the execution of the scan itself might fail due to various reasons (e.g., incorrect permissions, corrupted binary, unexpected system state). In such cases, the plugin should catch the error, log the details as a warning, and allow
bundle installto complete. The goal is to provide diagnostic information without blocking the primary dependency resolution.# In lib/bundler/trivy/plugin.rb, within scan_after_install begin results = scanner.scan rescue => e Bundler.ui.warn "Trivy scan failed: #{e.message}" # Log the error message as a warning. Bundler.ui.debug e.backtrace.join("\n") if ENV["DEBUG"] return # Exit the plugin's execution gracefully. endBy wrapping the
scanner.scancall in abegin...rescueblock, we ensure that any exceptions raised during Trivy’s execution are caught and handled, thereby preventing them from propagating and crashing Bundler, which would otherwise halt the entire dependency installation process. -
Configuration Errors: Plugins often rely on configuration, such as environment variables, to customize their behavior. Invalid or unexpected configuration values should be handled defensively. Instead of crashing, the plugin should issue a warning, revert to a sensible default, and continue execution. This prevents user errors in configuration from breaking the entire
bundle installprocess.# Example: In lib/bundler/trivy/plugin.rb or a configuration module severity_threshold = ENV["BUNDLER_TRIVY_SEVERITY"] || "CRITICAL" unless %w[CRITICAL HIGH MEDIUM LOW].include?(severity_threshold) Bundler.ui.warn "Invalid severity threshold: #{severity_threshold}, using CRITICAL" # Warn about invalid input. severity_threshold = "CRITICAL" # Revert to a safe default. endThis pattern ensures that the plugin remains robust even when faced with malformed or unsupported configuration, providing a better user experience by failing softly and offering clear guidance on how to correct the issue, rather than abruptly halting the
bundle installprocess.
Separation of Concerns: An Architectural Foundation for Maintainability
The architectural decision to separate responsibilities across distinct classes is fundamental to building maintainable and extensible software. In our Bundler Trivy plugin, this principle is applied by distributing the plugin’s functionality among three specialized classes:
Plugin: This class is solely responsible for the integration with Bundler’s plugin system, managing the lifecycle hooks, and coordinating the overall scan workflow. It acts as the high-level controller.Scanner: This class encapsulates all logic related to executing the external Trivy binary, handling its command-line arguments, and parsing its raw output into a structuredScanResultobject.Reporter: This class focuses exclusively on the presentation layer, taking the structured scan results and formatting them for clear and concise display to the user in the terminal.
This clear separation offers several distinct advantages:
- Independent Testing: Each component can be tested in isolation. For example, we can verify Trivy’s output parsing logic in the
Scannerwithout needing to execute actual Bundler hooks or run the Trivy binary. Similarly,Reportertests can validate output formatting without requiring a live scan. This makes our test suite faster, more reliable, and easier to write. - Enhanced Flexibility: The architecture allows for alternative implementations without affecting other parts of the system. We could, for instance, swap out the
Scannerto support a different vulnerability scanning tool (e.g.,SnykorOWASP Dependency-Check) or modify theReporterto generate output in a different format (e.g., HTML, CSV, or a custom JSON structure) without altering thePlugin’s core coordination logic. - Improved Maintainability: By limiting each class to a single, well-defined responsibility, the codebase becomes easier to understand, debug, and modify. Changes in one area are less likely to introduce regressions in another, reducing the overall cost of maintenance over the long term.
Ultimately, this design ensures that our Bundler plugin is not only functional but also robust, adaptable, and easy to evolve as requirements or external tools change.
Integrating Security Scans: The after-install-all Hook
Dependencies in software development offer both accelerated functionality and potential vulnerabilities. A software project’s security, much like any complex supply chain, is only as strong as its weakest link. Integrating security scanning into the dependency management workflow is a critical practice for maintaining a robust application.
Bundler, the standard Ruby gem manager, facilitates this integration through its plugin system. The after-install-all hook provides a precise point for security analysis. This hook runs after Bundler has successfully installed all project dependencies, but before control returns to the user. At this stage, the Gemfile.lock accurately reflects the resolved dependency graph. All required gems are available on disk, making this an ideal moment for a security scanner — like Trivy — to operate.
Hook Timing and Execution Context
To understand when hooks execute, we must examine Bundler’s installation flow:
- Parse
Gemfileand resolve version constraints - Download gems that need installation
- Install each gem (compiling native extensions if needed)
- Write or update
Gemfile.lock - Fire
after-install-allhook - Return control to the user
This sequence is significant because it means the after-install-all hook executes within Bundler’s own process. This grants the plugin direct access to Bundler’s internal APIs. It allows the plugin to query the lockfile, enumerate all installed gems, and inspect the project’s structure with full context. This deep integration is what enables robust security scanning.
Accessing Bundler State
To perform its security scan effectively, a Bundler plugin typically requires access to specific pieces of information about the project’s state. For our Trivy integration, we primarily need three key details:
Project root: The directory containing Gemfile. Trivy scans this directory to find Gemfile.lock:
gemfile_path = Bundler.default_gemfile<sup class="footnote-ref" id="footnote-ref-1"><a href="#footnote-1" class="footnote-link">1</a></sup>
project_root = File.dirname(gemfile_path)
Lockfile path: The specific path to Gemfile.lock. Trivy, though, is designed to discover this automatically when scanning the project root; strictly speaking, explicitly passing it is often not necessary:
lockfile_path = Bundler.default_lockfile.to_s<sup class="footnote-ref" id="footnote-ref-1"><a href="#footnote-1" class="footnote-link">1</a></sup>
Installed gems: The complete list of gems and versions in the resolved bundle. This information exists in the lockfile, but Bundler also provides it through its API:
specs = Bundler.load.specs<sup class="footnote-ref" id="footnote-ref-1"><a href="#footnote-1" class="footnote-link">1</a></sup>
specs.each do |spec|
puts "#{spec.name} #{spec.version}"
end
For Trivy integration, only the project root is necessary. Trivy reads Gemfile.lock directly to identify dependencies.
Hook Registration Patterns
The fundamental approach to registering a Bundler plugin hook is straightforward. In the plugins.rb file, we use Bundler::Plugin.add_hook to register for specific lifecycle events:
# plugins.rb
require_relative "lib/bundler/trivy/plugin"
Bundler::Plugin.add_hook("after-install-all") do
Bundler::Trivy::Plugin.scan_after_install
end
For more robust error handling, we can wrap the hook execution in a begin-rescue block. However, in our implementation, we handle errors within the scan_after_install method itself, allowing the hook registration to remain clean and focused.
The Bundler.ui object is crucial for consistent output. It provides methods that respect Bundler’s verbosity settings, ensuring warnings display appropriately across contexts — from interactive terminals to CI logs. This idiomatic approach integrates gracefully into the Bundler ecosystem.
Conditional Execution
Not every bundle install operation should automatically trigger a security scan. Thoughtful conditional execution is crucial for a plugin to integrate seamlessly into diverse development and deployment workflows. We can consider distinct scenarios where different scanning behaviors might be desirable:
-
Initial project setup: When cloning a repository and running
bundle installfor the first time, the developer expects gem installation. A vulnerability scan at this moment provides useful information, but it shouldn’t block setup. -
CI environments: Continuous integration systems run
bundle installon every build. Scanning in CI makes sense, but the plugin might want different behavior — for example, failing builds versus merely reporting findings. -
Development vs. production: Development environments might treat vulnerabilities as warnings. Production deployments, however, should treat CRITICAL vulnerabilities as blockers.
The plugin can detect execution context through environment variables:
def self.should_scan?
# Skip if explicitly disabled
return false if ENV["BUNDLER_TRIVY_SKIP"] == "true"
# Skip in CI if configured
return false if ci_environment? && ENV["BUNDLER_TRIVY_CI_SKIP"] == "true"
true
end
def self.ci_environment?
ENV["CI"] == "true" || ENV["GITHUB_ACTIONS"] == "true" || ENV["GITLAB_CI"] == "true"
end
This allows users to control plugin behavior without modifying code:
# Skip scanning for this installation
$ BUNDLER_TRIVY_SKIP=true bundle install
# Skip in CI but scan locally
$ export BUNDLER_TRIVY_CI_SKIP=true
Performance Impact
Integrating a hook into bundle install introduces latency. A security scanner — like Trivy — performs several operations: downloading its vulnerability database (typically on the first run), parsing Gemfile.lock, cross-referencing dependencies against known vulnerabilities, and displaying results. Each step consumes time.
The initial database download can take 30-60 seconds. Subsequent scans with a cached database complete in 1-5 seconds for typical projects. A project with hundreds of dependencies might take longer.
This latency is acceptable in some contexts and problematic in others. During active development, developers run bundle install infrequently — only when adding or updating dependencies. A few seconds of scanning in exchange for immediate vulnerability feedback represents a reasonable trade-off.
In automated environments that run bundle install repeatedly (like CI systems that don’t cache dependencies), the plugin adds latency to every build. Organizations running hundreds or thousands of builds daily will notice this cost.
This latency presents a trade-off. While acceptable in some contexts, it can become problematic in others. Therefore, the plugin should clearly document this trade-off and provide robust options to control precisely when scanning occurs.
Failed Scans and Exit Codes
When vulnerabilities are detected, a security scanning plugin must decide: should CRITICAL vulnerabilities cause bundle install to fail? This choice significantly impacts security posture and development velocity.
Failing the installation prevents developers from proceeding with vulnerable dependencies, forcing immediate attention to security issues. However, it can block legitimate work if a vulnerability affects a transitive dependency not directly used by the project, leaving developers stuck until an upstream fix appears.
Warning without failing allows installation to complete while highlighting security issues. Developers see warnings, but they can proceed. The downside: warnings often get ignored in high-velocity environments, as developers facing deadlines may skip past them to ship features.
The plugin handles this through configuration:
def self.scan_after_install
config = Config.new
# Skip if explicitly disabled via config or environment variable
return if config.skip_scan?
lockfile_path = Bundler.default_lockfile.to_s
project_root = File.dirname(lockfile_path)
scanner = Scanner.new(project_root, config)
results = scanner.scan
reporter = Reporter.new(results, config)
reporter.display
# Exit with error code if vulnerabilities exceed configured thresholds
handle_exit_code(results, config)
end
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
This allows per-project or per-environment configuration:
# Fail only on CRITICAL in production
$ BUNDLER_TRIVY_FAIL_ON_CRITICAL=true bundle install
# Fail on any vulnerability in security-sensitive projects
$ BUNDLER_TRIVY_FAIL_ON_ANY=true bundle install
Integration with Bundler’s Output
A Bundler plugin’s output should integrate gracefully with Bundler’s structured messages to ensure a coherent user experience and avoid confusion. During a typical installation, Bundler produces output similar to the following:
Fetching gem metadata from https://rubygems.org/...
Resolving dependencies...
Using bundler 2.4.10
Fetching nokogiri 1.15.4
Installing nokogiri 1.15.4 with native extensions
Bundle complete! 12 Gemfile dependencies, 45 gems now installed.
Therefore, the plugin’s output should integrate naturally with this flow while clearly delineating itself as a distinct, post-installation security check. Careful use of spacing, formatting, and messaging signals that vulnerability scanning is a separate — yet integral — step following Bundler’s core dependency management:
Bundle complete! 12 Gemfile dependencies, 45 gems now installed.
Running Trivy vulnerability scan...
Found 2 vulnerabilities:
CRITICAL: 1
HIGH: 1
See details below.
Multiple Hooks
Bundler’s plugin system offers considerable flexibility, allowing plugins to register multiple hooks to respond to different events within the dependency management lifecycle. For instance, a plugin could register for both after-install-all and after-install:
Bundler::Plugin.register("bundler-trivy-plugin") do |plugin|
plugin.hook("after-install-all") do
Bundler::Trivy::Plugin.scan_after_install
end
plugin.hook("after-install") do |spec|
# Called after each individual gem installs
# Could be used for progressive scanning
end
end
For our security scanning plugin, after-install-all provides optimal timing. While after-install is called after each individual gem installs, triggering a full security scan at that granular level would produce redundant output. It would also significantly impede the installation process. Scanning once after all gems are installed achieves complete results efficiently and without unnecessary overhead.
Hook Context and Isolation
While Bundler hooks execute within Bundler’s process, plugins must behave defensively. We cannot assume anything about the project environment. Consider these potential conditions:
- The project might have
Gemfile.lockcorrupted or missing - Trivy might not be installed
- Network connectivity for database updates might be unavailable
- The project directory might not be writable
Each of these conditions requires graceful handling:
def self.scan_after_install
config = Config.new
# Skip if explicitly disabled via config or environment variable
return if config.skip_scan?
# Verify Gemfile.lock exists before attempting to scan
unless File.exist?(Bundler.default_lockfile)
Bundler.ui.warn "Gemfile.lock not found, skipping scan"
return
end
lockfile_path = Bundler.default_lockfile.to_s
project_root = File.dirname(lockfile_path)
scanner = Scanner.new(project_root, config)
# Check if Trivy binary is available in PATH
unless scanner.trivy_available?
begin
Bundler.ui.warn "Trivy not found, skipping scan"
Bundler.ui.info "Install: https://trivy.dev/docs/getting-started/installation/"
rescue
# Ignore UI errors
end
return
end
begin
# Execute the security scan
results = scanner.scan
# Display formatted 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)
rescue => e
Bundler.ui.warn "Trivy scan failed: #{e.message}"
Bundler.ui.debug e.backtrace.join("\n") if ENV["DEBUG"]
end
end
This robust, defensive approach is critical. It ensures the plugin enhances the workflow by providing valuable security insights when conditions allow. This is done without inadvertently breaking the fundamental operation of installing dependencies — the primary purpose of bundle install.
Transforming Trivy Output: From Raw JSON to Actionable Vulnerability Data
When we integrate a security scanner like Trivy into a Bundler plugin, a crucial step is transforming its raw JSON output into a structured, actionable format. This process is not merely about reading data; it is about making sense of it, identifying key security findings, and presenting them in a way that is useful for developers. Trivy’s JSON schema provides a comprehensive, nested representation of scan results — detailing targets and individual vulnerabilities. Understanding this structure is the first step in building a robust parsing mechanism.
Understanding Trivy’s JSON Structure
A typical Trivy JSON response for a Ruby project presents a clear hierarchy of information. This structure guides how our plugin navigates the data to extract relevant security findings. While the exact version numbers and specific vulnerabilities in your output will likely vary, the overall structure remains consistent.
Let’s examine a simplified example of Trivy’s JSON output:
{
"SchemaVersion": 2,
"ArtifactName": "/path/to/project",
"ArtifactType": "filesystem",
"Metadata": {
"ImageID": "",
"DiffIDs": null,
"RepoTags": [],
"RepoDigests": [],
"ImageConfig": {}
},
"Results": [
{
"Target": "Gemfile.lock",
"Class": "lang-pkgs",
"Type": "bundler",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2023-38545",
"PkgName": "rails",
"InstalledVersion": "6.1.0",
"FixedVersion": "6.1.7.6, 7.0.8",
"Severity": "CRITICAL",
"Title": "Rails ActiveRecord SQL Injection",
"Description": "ActiveRecord in Rails 6.1.0 through 6.1.7.5 allows SQL injection...",
"References": [
"https://nvd.nist.gov/vuln/detail/CVE-2023-38545",
"https://github.com/rails/rails/security/advisories/GHSA-..."
],
"PrimaryURL": "https://avd.aquasec.com/nvd/cve-2023-38545",
"PublishedDate": "2023-10-18T16:15:00Z",
"LastModifiedDate": "2023-10-25T18:30:00Z"
}
]
}
]
}
We can observe several key elements. The SchemaVersion indicates the format of the JSON output, which is useful for ensuring compatibility with different Trivy versions. The ArtifactName specifies the scanned path — in this case, our project root. The Results array contains entries for each scanned target, typically one for Gemfile.lock in Ruby projects. Each result, in turn, contains a Vulnerabilities array with detailed finding information.
This hierarchical design, though comprehensive, necessitates a structured approach to parsing. To access vulnerability details, we first navigate to the Results array, then to a specific target’s entry, and finally to its Vulnerabilities list. This multi-layered structure is crucial for our parsing logic.
Scanner Implementation
The core of our plugin’s interaction with Trivy lies in the scanner implementation. This component is responsible for executing Trivy as an external process and robustly parsing its output. Before we dive into the Ruby code, let’s consider how we would invoke Trivy from the command line to get JSON output:
$ trivy fs --scanners vuln --format json --quiet /path/to/project
This command instructs trivy to:
fs: Scan the filesystem (as opposed to an image or repository).--scanners vuln: Focus only on vulnerability scanning.--format json: Output the results in JSON format.--quiet: Suppress non-essential output, making the JSON easier to parse./path/to/project: The target directory to scan.
In Ruby, we can execute external commands and capture their output using the Open3.capture3 method.2 This method, part of Ruby’s open3 library, provides a robust way to run external commands and manage their standard input, standard output, and standard error streams. It returns three values — the standard output (stdout), standard error (stderr), and the status object, which contains the exit code of the executed command. This allows us to programmatically interact with command-line tools like Trivy — and handle their responses, including error conditions — effectively.
Here is the Scanner class, which encapsulates this interaction:
# lib/bundler/trivy/scanner.rb
require "json"
require "open3"
module Bundler
module Trivy
class Scanner
def initialize(project_root)
@project_root = project_root
end
def scan
stdout, stderr, status = Open3.capture3(
"trivy", "fs",
"--scanners", "vuln",
"--format", "json",
"--quiet",
@project_root
)
if status.exitstatus > 1
# Exit code 1 means vulnerabilities found (expected)
# Exit codes > 1 indicate errors
raise ScanError, "Trivy failed: #{stderr}"
end
data = parse_json(stdout)
ScanResult.new(data)
rescue JSON::ParserError => e
raise ScanError, "Invalid JSON from Trivy: #{e.message}"
end
def trivy_available?
# Checks if `trivy` is available in the system's PATH by silencing `which trivy` output
# and inspecting the exit status.
system("which trivy > /dev/null 2>&1")
end
private
def parse_json(json_string)
return {} if json_string.empty?
JSON.parse(json_string)
end
end
class ScanError < StandardError; end
end
end
One may wonder: what do Trivy’s exit codes signify? Trivy uses distinct exit codes to signal different outcomes. It exits with 1 when vulnerabilities are found; this indicates a successful execution with findings, not an error. Conversely, exit codes greater than 1 signify actual errors, such as Trivy not being installed, database issues, or filesystem problems. Our scanner is designed to handle this distinction, raising a ScanError only for genuine operational failures.
Result Wrapper: Abstracting Trivy’s Output
One may wonder: why do we need a wrapper class for Trivy’s output? While the raw JSON provides all the necessary data, directly manipulating nested hashes and arrays can become cumbersome and error-prone. To effectively work with Trivy’s output, we introduce the ScanResult class. This class serves as a crucial abstraction layer, wrapping the raw JSON data and providing a more Ruby-idiomatic interface for querying vulnerability information. This approach offers several benefits:
- Abstraction: It hides the underlying JSON structure, allowing us to interact with vulnerability data using clear, semantic methods.
- Maintainability: Should
Trivychange its output format in the future, only theScanResultclass (and potentiallyVulnerability) would require updates, minimizing impact on the rest of our codebase. - Usability: It provides convenient methods for common queries, such as
vulnerabilities,by_severity, andhas_critical_vulnerabilities?, making the data easier to consume.
Here is the ScanResult class:
# lib/bundler/trivy/scan_result.rb
require_relative "vulnerability"
module Bundler
module Trivy
class ScanResult
attr_reader :data
def initialize(data, config = nil)
@data = data || {}
@config = config || Config.new
end
def vulnerabilities
results = @data["Results"] || []
results.flat_map do |result|
vulns = result["Vulnerabilities"]
next [] if vulns.nil? || vulns.empty?
vulns.map { |v| Vulnerability.new(v, result["Target"]) }
end.compact.reject { |v| ignored?(v) }
end
private
def ignored?(vulnerability)
@config.cve_ignored?(vulnerability.id)
end
def by_severity
vulnerabilities.group_by(&:severity)
end
def critical_vulnerabilities
vulnerabilities.select(&:critical?)
end
def high_vulnerabilities
vulnerabilities.select(&:high?)
end
def has_vulnerabilities?
!vulnerabilities.empty?
end
def has_critical_vulnerabilities?
!critical_vulnerabilities.empty?
end
def vulnerability_count
vulnerabilities.size
end
def severity_counts
by_severity.transform_values(&:size)
end
end
end
end
Vulnerability Model: Encapsulating Individual Findings
While ScanResult handles the overall collection of findings, individual vulnerabilities warrant their own dedicated abstraction. The Vulnerability class encapsulates the details of a single security issue, providing semantic methods that make working with vulnerability data more intuitive and less error-prone. This dedicated model offers several advantages:
- Encapsulation: It centralizes all logic related to a single vulnerability, preventing scattered data access and manipulation.
- Semantic Methods: Methods like
critical?,fixable?, andseverity_rankprovide a clear, readable interface, abstracting away the raw JSON keys. - Comparability: The inclusion of the
<=>method enables straightforward sorting of vulnerabilities by severity and then by package name, enhancing the usability of the collected data.
Here is the Vulnerability class:
# lib/bundler/trivy/vulnerability.rb
module Bundler
module Trivy
class Vulnerability
SEVERITY_ORDER = {
"CRITICAL" => 0,
"HIGH" => 1,
"MEDIUM" => 2,
"LOW" => 3,
"UNKNOWN" => 4
}.freeze
attr_reader :data, :target
def initialize(data, target)
@data = data
@target = target
end
def id
@data["VulnerabilityID"]
end
def package_name
@data["PkgName"]
end
def installed_version
@data["InstalledVersion"]
end
def fixed_version
@data["FixedVersion"]
end
def severity
sev = @data["Severity"]
return "UNKNOWN" if sev.nil? || sev.empty?
sev
end
def title
@data["Title"] || "No title available"
end
def description
@data["Description"] || "No description available"
end
def primary_url
@data["PrimaryURL"]
end
def references
@data["References"] || []
end
def published_date
@data["PublishedDate"]
end
def critical?
severity == "CRITICAL"
end
def high?
severity == "HIGH"
end
def medium?
severity == "MEDIUM"
end
def low?
severity == "LOW"
end
def severity_rank
SEVERITY_ORDER[severity] || 999
end
def <=>(other)
# Sort by severity first (critical first), then by package name
comparison = severity_rank <=> other.severity_rank
comparison.zero? ? package_name <=> other.package_name : comparison
end
def fixable?
!fixed_version.nil? && !fixed_version.empty?
end
end
end
end
This vulnerability model provides semantic methods like critical? and fixable?, which allow callers to query vulnerability attributes without needing to inspect the raw data structure directly. Furthermore, the inclusion of the <=> method enables straightforward sorting of vulnerabilities by severity and then by package name, enhancing the usability of the collected data.
Handling Edge Cases
Real-world data is rarely perfectly clean, and Trivy’s output is no exception. Our parsing logic must robustly handle several common edge cases to ensure accurate and reliable vulnerability reporting.
Missing Fixed Versions: Not all vulnerabilities have an immediate fix available. In such cases, Trivy’s output for the FixedVersion field may be null or an empty string. Our Vulnerability class is designed to gracefully handle this scenario:
The fixed_version method, as defined in our Vulnerability class, ensures that if a fixed version is absent, it returns nil, and fixable? correctly reports false. This Ruby-idiomatic approach prevents errors when querying for non-existent fix information.
Multiple Fixed Versions: The FixedVersion field sometimes contains multiple versions separated by commas (e.g., “6.1.7.6, 7.0.8”). This indicates fixes exist in multiple release branches:
def fixed_versions
return [] unless fixable?
fixed_version.split(",").map(&:strip).filter_map do |constraint|
# Parse as a requirement (e.g., "~> 7.1.5" or ">= 7.1.5.2")
Gem::Requirement.new(constraint)
# Extract the version number from the requirement
# For "~> 7.1.5" -> "7.1.5", ">= 7.1.5.2" -> "7.1.5.2"
constraint.match(/\d+(?:\.\d+)*/)&.to_s
rescue ArgumentError
# If it's not a valid requirement, try to extract version directly
constraint.match(/\d+(?:\.\d+)*/)&.to_s
end.compact.reject(&:empty?)
end
def applicable_fixed_version
return nil unless fixable?
begin
installed = Gem::Version.new(installed_version)
# Find versions in same major.minor series
same_series = fixed_versions.select do |v|
fixed = Gem::Version.new(v)
fixed.segments[0..1] == installed.segments[0..1]
rescue ArgumentError
false
end
# Return the minimum version in the same series, or the overall minimum
target_versions = same_series.empty? ? fixed_versions : same_series
target_versions.min_by do |v|
Gem::Version.new(v)
rescue ArgumentError
# If version parsing fails, use a very high version to sort it last
Gem::Version.new("999.999.999")
end
rescue ArgumentError
# If version parsing fails, return the first fixed version
fixed_versions.first
end
end
To address this, the fixed_versions method parses the comma-separated string and extracts version numbers from requirement constraints (like ”~> 7.1.5” or ”>= 7.1.5.2”). The applicable_fixed_version method then intelligently selects the fixed version that matches the installed gem’s major and minor version series, ensuring we recommend the most relevant upgrade path.
Unknown Severity: Vulnerabilities occasionally lack severity scores. These appear with "Severity": "UNKNOWN" or "Severity": null:
def severity
sev = @data["Severity"]
return "UNKNOWN" if sev.nil? || sev.empty?
sev
end
The severity method handles this by checking if the Severity field is nil or empty. If so, it defaults to "UNKNOWN", ensuring that every vulnerability has a defined severity level for consistent processing and reporting.
Empty Results: Projects with no vulnerabilities return empty structures:
The initialize method of ScanResult ensures that @data is always a hash, even if nil is passed, preventing NoMethodError when accessing keys. Similarly, the vulnerabilities method defensively checks for nil or empty Results and Vulnerabilities arrays, returning an empty array if no findings are present. This robust handling ensures that our plugin operates smoothly even when no vulnerabilities are detected.
Testing the Parser: Ensuring Robustness
Given that our parsing logic directly interacts with the output of an external tool, thorough testing is not merely good practice — it is essential. Trivy’s output format, while generally stable, could evolve, and robust tests ensure our plugin remains resilient to such changes. We achieve this by creating fixtures with sample Trivy output. These fixtures are crucial, for they allow us to simulate various Trivy responses without actually invoking the external command during our test suite, making tests faster and more reliable.
Here is an example of a Trivy output fixture:
# spec/fixtures/trivy_output.json
{
"SchemaVersion": 2,
"Results": [
{
"Target": "Gemfile.lock",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-2023-TEST",
"PkgName": "test-gem",
"InstalledVersion": "1.0.0",
"FixedVersion": "1.0.1",
"Severity": "CRITICAL",
"Title": "Test vulnerability",
"Description": "This is a test"
}
]
}
]
}
These fixtures allow us to write focused tests that verify our parsing behavior under various conditions:
# spec/scan_result_spec.rb
RSpec.describe Bundler::Trivy::ScanResult do
let(:json_data) do
JSON.parse(File.read("spec/fixtures/trivy_output.json"))
end
let(:result) { described_class.new(json_data) }
it "extracts vulnerabilities" do
expect(result.vulnerabilities.size).to eq(1)
expect(result.vulnerabilities.first.package_name).to eq("test-gem")
end
it "identifies critical vulnerabilities" do
expect(result.has_critical_vulnerabilities?).to be true
expect(result.critical_vulnerabilities.size).to eq(1)
end
it "handles empty results" do
empty_result = described_class.new({"Results" => []})
expect(empty_result.has_vulnerabilities?).to be false
end
end
Performance Considerations
Regarding performance, parsing JSON and creating Ruby objects typically has a minimal impact for most projects. For instance, a project with 200 dependencies might yield around 50 vulnerabilities, resulting in the creation of approximately 50 Vulnerability objects. This object creation process usually takes only milliseconds.
Therefore, the primary performance bottleneck lies not in the parsing and object instantiation, but in the execution of Trivy itself. Optimizations focused on parsing will provide negligible benefit compared to strategies that reduce the number of Trivy invocations.
With this in mind, we should always strive to parse Trivy’s JSON output only once. The recommended approach is to parse the output, wrap it in a ScanResult object, and then query that wrapper for all subsequent data access:
# Good: Parse once
result = scanner.scan
puts result.vulnerability_count
puts result.critical_vulnerabilities.size
# Bad: Re-executes Trivy for each call if scanner doesn\'t cache
puts scanner.scan.vulnerability_count
puts scanner.scan.critical_vulnerabilities.size
It is important to note that our Scanner is designed to execute Trivy and parse its output once per scan invocation. This means that calling scan multiple times will, by design, re-execute Trivy, rather than simply re-parsing cached output. This design reinforces the principle of parsing Trivy’s output only once per scan operation to avoid unnecessary external process calls.
Presenting Scan Results
After a security scan completes, the raw data — often a lengthy list of findings — often presents a challenge. The reporter component transforms these structured scan results into digestible terminal output, enabling developers to efficiently grasp and resolve security issues without being distracted by extraneous information. This transformation of raw vulnerability data into actionable insights is what makes the presentation truly valuable.
Design Principles for Output
Consider the raw output of a security scanner — much like an unedited satellite image, it contains a vast amount of data. However, not all of this data is immediately actionable or relevant to a developer’s immediate task. Just as a cartographer decides what details to include on a map to make it useful — highlighting major roads while omitting every pebble — a security reporter curates its output. For instance, a project with 100 dependencies might report 30 vulnerabilities, yet only a handful may be genuinely critical. Therefore, effective output highlights what genuinely matters, providing sufficient context for developers to make informed decisions.
To achieve this, effective security output adheres to several core principles:
Severity-first organization: Vulnerabilities are grouped and sorted by severity, with CRITICAL issues appearing first, followed by HIGH, MEDIUM, and LOW. This approach prioritizes the most impactful findings for immediate attention.
Progressive disclosure: Summary information is presented first, followed by detailed findings. This allows developers to quickly ascertain if critical issues exist before delving into specific CVEs.
Actionable information: Output includes the necessary details for remediation: the package name, installed version, fixed version, and clear upgrade instructions.
Terminal context awareness: Color and formatting are used to enhance readability in color terminals, while also supporting plain text output for environments such as CI logs, where ANSI escape codes are undesirable.
Initial Reporter Implementation
With the design principles for effective output firmly established, we can now turn our attention to the initial implementation of our Reporter class. This class is central to transforming the raw ScanResult object into a human-readable format, ensuring that the output adheres to the principles of severity-first organization and progressive disclosure we have just discussed. It is here that the abstract principles meet concrete code, allowing us to present security findings in a way that is both informative and actionable.
# lib/bundler/trivy/reporter.rb
require "json"
module Bundler
module Trivy
class Reporter
def initialize(scan_result, config = nil)
@result = scan_result
@config = config || Config.new
end
def display
if @result.vulnerabilities.empty?
display_clean_result
return
end
display_summary
display_vulnerabilities_by_severity
display_remediation_advice
end
private
def display_clean_result
ui.confirm "No vulnerabilities found by Trivy"
end
def display_summary
counts = @result.severity_counts
ui.warn "Trivy found #{@result.vulnerability_count} vulnerabilities:"
puts
["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"].each do |severity|
count = counts[severity]
next if count.nil? || count.zero?
color = color_for_severity(severity)
puts " #{send(color, severity)}: #{count}"
end
puts
end
def display_vulnerabilities_by_severity
@result.by_severity.sort_by { |sev, _| severity_order(sev) }.each do |severity, vulns|
next if vulns.empty?
# Skip if compact mode is enabled and this severity is not CRITICAL or HIGH
next if @config.compact_output? && !%w[CRITICAL HIGH].include?(severity)
puts "#{send(color_for_severity(severity), severity)} Vulnerabilities:"
puts
vulns.sort.each do |vuln|
display_vulnerability(vuln)
end
puts
end
end
def display_vulnerability(vuln)
puts " #{bold(vuln.package_name)} (#{vuln.installed_version})"
puts " #{vuln.id}: #{vuln.title}"
if vuln.fixable?
fixed_version = vuln.applicable_fixed_version || vuln.fixed_version
puts " Fixed in: #{green(fixed_version)}"
else
puts " #{yellow("No fix available yet")}"
end
puts " #{vuln.primary_url}" if vuln.primary_url
puts
end
def display_remediation_advice
fixable = @result.vulnerabilities.select(&:fixable?)
return if fixable.empty?
puts bold("Recommended Actions:")
puts
fixable.group_by(&:package_name).each do |pkg, vulns|
# Get all fixed versions from all vulnerabilities for this package
all_versions = vulns.flat_map(&:fixed_versions).compact.uniq
# Find the maximum version that fixes all vulns (safest upgrade path)
recommended_version = all_versions.max_by { |v| Gem::Version.new(v) } if all_versions.any?
if recommended_version
puts " Update #{pkg} to #{recommended_version}: bundle update #{pkg}"
else
puts " Update #{pkg}: bundle update #{pkg}"
end
end
puts
end
def color_for_severity(severity)
case severity
when "CRITICAL" then :red
when "HIGH" then :red
when "MEDIUM" then :yellow
when "LOW" then :blue
else :default
end
end
def severity_order(severity)
{ "CRITICAL" => 0, "HIGH" => 1, "MEDIUM" => 2, "LOW" => 3, "UNKNOWN" => 4 }[severity] || 99
end
# Color helpers
def colorize(text, color_code)
return text unless color_enabled?
"\e[#{color_code}m#{text}\e[0m"
end
def red(text); colorize(text, 31); end
def green(text); colorize(text, 32); end
def yellow(text); colorize(text, 33); end
def blue(text); colorize(text, 34); end
def bold(text); colorize(text, 1); end
def color_enabled?
return false if ENV["NO_COLOR"]
return false unless $stdout.tty?
true
end
def ui
Bundler.ui
end
end
end
end
The Reporter class, as presented above, serves as the primary mechanism for rendering security scan results to the terminal. Its core responsibility is to take a ScanResult object — which encapsulates all the vulnerability data — and transform it into a user-friendly, actionable display.
The display method is the public interface of the Reporter. It orchestrates the entire output process, first checking if any vulnerabilities were found. If the ScanResult is empty, it calls display_clean_result to indicate a clean scan. Otherwise, it proceeds through a series of private methods to present a summary, detail individual vulnerabilities by severity, and offer remediation advice. This progressive disclosure ensures that readers can quickly grasp the overall security posture before delving into specifics.
Let’s examine each of these private methods in turn, to understand how they contribute to the overall presentation.
display_clean_result
This straightforward method is invoked when no vulnerabilities are detected. It prints a confirmation message, prefixed with a green checkmark, to clearly indicate a successful and clean scan.
display_summary
The display_summary method provides a high-level overview of the scan results. It counts the total number of vulnerabilities and breaks them down by severity. This adheres to the principle of severity-first organization, immediately highlighting the most critical issues. The output is color-coded to visually distinguish between different severity levels, making it easier for developers to quickly assess the situation.
display_vulnerabilities_by_severity
Following the summary, display_vulnerabilities_by_severity iterates through the detected vulnerabilities, grouping and sorting them by severity. This ensures that CRITICAL issues are presented before HIGH, MEDIUM, and LOW, maintaining the severity-first prioritization. For each severity group, it then calls display_vulnerability for every individual finding.
display_vulnerability
This method is responsible for presenting the details of a single vulnerability. It includes essential information such as the package name, installed version, CVE ID, title, and a link to the primary URL for more information. It also indicates whether a fix is available and, if so, specifies the fixed version. If no fix is yet available, it clearly communicates this to the user, preventing frustration.
display_remediation_advice
After detailing all vulnerabilities, display_remediation_advice offers concrete, actionable steps for remediation. It identifies all fixable vulnerabilities and groups them by package name, providing bundle update commands for each. This directly addresses the “Actionable information” design principle, guiding developers toward immediate solutions.
color_for_severity and severity_order
These two helper methods are crucial for implementing the severity-first organization and visual cues. color_for_severity maps each severity level (CRITICAL, HIGH, MEDIUM, LOW) to a specific color, while severity_order assigns a numerical priority, ensuring that vulnerabilities are consistently sorted from most to least critical.
Color Helper Methods (colorize, red, green, yellow, blue, bold)
The colorize method is the core of our terminal formatting. It wraps text with ANSI escape codes to apply colors and bold styling. The convenience methods (red, green, yellow, blue, bold) simplify its usage, allowing us to apply specific styles without directly manipulating escape codes.
color_enabled?
This method intelligently determines whether color output is appropriate for the current terminal environment. It respects the NO_COLOR environment variable, a standard convention for disabling color in CLI tools. Additionally, it checks if $stdout is a TTY (teletypewriter), which is false when output is piped or redirected. This prevents unsightly ANSI escape codes from appearing in log files or when output is consumed by other tools.
Example Output
To illustrate the Reporter’s behavior and the practical application of our design principles, let us examine some example output. For a project with multiple vulnerabilities, we might see output similar to the following:
⚠ Trivy found 5 vulnerabilities:
CRITICAL: 2
HIGH: 2
MEDIUM: 1
CRITICAL Vulnerabilities:
rails (6.1.0)
CVE-2023-38545: Rails ActiveRecord SQL Injection
Fixed in: 6.1.7.6, 7.0.8
https://avd.aquasec.com/nvd/cve-2023-38545
nokogiri (1.13.8)
CVE-2023-28752: Nokogiri XML Entity Expansion
Fixed in: 1.14.3
https://avd.aquasec.com/nvd/cve-2023-28752
HIGH Vulnerabilities:
rack (2.2.3)
CVE-2023-27539: Rack Header Injection
Fixed in: 2.2.6.4, 3.0.4.2
https://avd.aquasec.com/nvd/cve-2023-27539
Recommended Actions:
Update rails: bundle update rails
Update nokogiri: bundle update nokogiri
Update rack: bundle update rack
Additionally, note that the exact version numbers and CVEs in your output will likely vary, as vulnerability databases are constantly updated.
For a project with no detected vulnerabilities, the output is much simpler:
✓ No vulnerabilities found by Trivy
Handling Color and Terminal Detection
Effective terminal output, of course, extends beyond merely displaying information; it also involves intelligently adapting to the environment. The color_enabled? method, which we examined earlier, is designed to ensure that our output is readable and appropriate across various contexts. It considers several factors to determine if color output is suitable:
-
NO_COLORenvironment variable: This widely adopted standard convention allows users to explicitly disable color in command-line tools. SettingNO_COLOR=1forces plain text output, respecting user preference and accessibility needs. -
TTY detection: The
$stdout.tty?method returnsfalsewhen the output is piped to another command or redirected to a file. This is a crucial check, as it prevents unsightly ANSI escape codes from appearing in log files or when the output is consumed by other automated systems, such as those in Continuous Integration (CI) environments. CI environments, though, typically detect as non-TTY automatically, ensuring that color codes do not appear in CI logs without additional, explicit configuration.
Compact vs. Detailed Modes
The initial reporter, as we have seen, provides a comprehensive list of all detected vulnerabilities. For larger projects with numerous dependencies, this can lead to extensive output, making it challenging for developers to efficiently identify and prioritize critical issues. To manage this complexity and optimize developer focus, a common approach is to offer different reporting modes: a compact mode and a detailed mode.
Compact Mode: This mode prioritizes brevity and immediate action. It might, for example, display only CRITICAL and HIGH severity vulnerabilities, or perhaps a summary count of all issues without individual details. This approach helps developers focus on pressing concerns without being inundated with less urgent information.
Detailed Mode: Conversely, the detailed mode provides a full overview, including all severity levels and comprehensive information for each vulnerability. This is useful for thorough audits or when a complete picture of all security findings is required.
Consider a scenario where a project has many low and medium severity findings. A compact output might appear as follows:
⚠ Trivy found 5 vulnerabilities (2 CRITICAL, 2 HIGH, 1 MEDIUM):
CRITICAL Vulnerabilities:
rails (6.1.0)
CVE-2023-38545: Rails ActiveRecord SQL Injection
Fixed in: 6.1.7.6, 7.0.8
https://avd.aquasec.com/nvd/cve-2023-38545
HIGH Vulnerabilities:
rack (2.2.3)
CVE-2023-27539: Rack Header Injection
Fixed in: 2.2.6.4, 3.0.4.2
https://avd.aquasec.com/nvd/cve-2023-27539
This compact view ensures that critical information remains visible, allowing developers to quickly assess the security posture and prioritize remediation efforts. The trade-off, though, is that less severe vulnerabilities are initially hidden, requiring a switch to a detailed mode for a full overview. This design choice optimizes developer focus by filtering extraneous information, ensuring the security scanner delivers actionable insights rather than mere noise.
Integration with Bundler UI
While our Reporter class currently uses raw puts for output, a more robust approach for a Bundler plugin involves integrating with Bundler’s own UI object. Bundler provides a Bundler.ui object specifically designed for output, which respects Bundler’s global verbosity settings and ensures a consistent user experience across all Bundler commands and plugins. This integration is crucial for maintaining a cohesive user experience within the Bundler ecosystem, making our plugin feel like a native extension rather than an external tool.
Using Bundler.ui offers several distinct advantages:
- Consistency: Output aligns seamlessly with Bundler’s established conventions, making the plugin feel like a native part of Bundler. This, of course, reduces cognitive load for users, as they encounter a familiar and predictable interface.
- Verbosity Control: Users can control the level of detail through Bundler’s
--verboseor--quietflags. This centralizes output management, simplifying plugin development by offloading the responsibility of verbosity handling to Bundler itself. - Contextual Messaging: Semantic methods (
confirm,warn,error,info,debug) enable appropriate messaging for different situations.3 These methods often come with built-in color coding that respects terminal capabilities, further enhancing clarity and user experience.
Here’s how we might adapt our display method to leverage Bundler.ui:
def display
ui = Bundler.ui
if @result.vulnerabilities.empty?
ui.confirm "No vulnerabilities found by Trivy"
return
end
ui.warn "Trivy found #{@result.vulnerability_count} vulnerabilities"
# ... rest of output
end
The Bundler.ui object provides a range of methods for different message types, each designed for a specific communicative purpose:
confirm(message): For green success messages, indicating a positive outcome.warn(message): For yellow warning messages, drawing attention to potential issues.error(message): For red error messages, signaling a critical problem.info(message): For normal informational output, providing general updates.debug(message): Only shown when Bundler’s--verboseflag is active, useful for detailed diagnostics.
By using Bundler’s UI, we ensure the plugin’s output is not only informative but also respects user preferences for verbosity and formatting, contributing to a more integrated and polished experience. This approach, though, requires us to adapt our existing puts statements to the appropriate Bundler.ui methods.
Structured Output for CI Integration
While human-readable terminal output is essential for developers, Continuous Integration (CI) environments often require structured, machine-readable output. This is because CI/CD pipelines need to programmatically parse scan results, extract key metrics, and enforce policies — for example, failing a build if critical vulnerabilities are detected. To address this distinct need, our reporter offers a JSON output mode as an alternative to the default terminal display.
This JSON output provides a standardized, easily parsable format for CI tools. It encapsulates all relevant vulnerability data and summary statistics, enabling automated analysis and informed decision-making within the pipeline. This approach, of course, allows for a clear separation of concerns: the reporter focuses on generating the data, while the CI system consumes and acts upon it.
Here’s an example of how we might implement a JSON output mode:
def display
if json_output?
display_json
else
display_terminal
end
end
def json_output?
ENV["BUNDLER_TRIVY_FORMAT"] == "json"
end
def display_json
output = {
vulnerabilities: @result.vulnerabilities.map do |vuln|
{
id: vuln.id,
package: vuln.package_name,
installed_version: vuln.installed_version,
fixed_version: vuln.fixed_version,
severity: vuln.severity,
title: vuln.title,
url: vuln.primary_url
}
end,
summary: {
total: @result.vulnerability_count,
by_severity: @result.severity_counts
}
}
puts JSON.pretty_generate(output)
end
With this structured output, CI tools can readily parse the JSON, extract severity counts, and fail builds based on predefined thresholds or other criteria, fully automating the security gate in the development workflow. This read-only operation ensures that the CI system can analyze results without modifying the project’s state.
Exit Code Strategy
While the reporter component is responsible for presenting scan results in various formats, it is crucial to separate this concern from the policy decision of when a scan should cause a build or process to fail. This separation of concerns ensures that the reporter focuses purely on output, while the plugin coordinator handles the complex logic of interpreting scan results and determining the appropriate exit code based on configured policies. This approach, of course, allows for greater flexibility and maintainability, as presentation logic is decoupled from business rules.
# In lib/bundler/trivy/plugin.rb
def self.scan_after_install
scanner = Scanner.new(project_root)
result = scanner.scan
reporter = Reporter.new(result)
reporter.display
# Plugin determines exit code based on configuration
handle_exit_code(result)
end
def self.handle_exit_code(result)
if result.has_critical_vulnerabilities? && fail_on_critical?
exit 1
elsif result.has_vulnerabilities? && fail_on_any?
exit 1
end
end
This clear separation allows the reporter to focus on presentation while the plugin handles policy decisions about when to fail, ensuring that our tool is both informative and robust.
Respecting User Attention
In the crowded landscape of development tools, security scanners compete for developer attention. Excessive warnings or verbose, unactionable output can quickly lead to alert fatigue, training developers to ignore even critical information. Therefore, a well-designed reporter should prioritize respecting user attention by:
- Using color sparingly and meaningfully: Color should highlight, not distract. It serves as a visual cue for severity, guiding the eye to the most important findings.
- Prioritizing actionable information over comprehensive details: While all details are available in a detailed mode or structured output, the default view should focus on what developers can immediately act upon.
- Providing clear next steps: As we saw in the remediation advice, offering concrete
bundle updatecommands empowers developers to resolve issues efficiently. - Respecting configuration for verbosity and failure modes: Allowing users to control the level of detail and define failure thresholds ensures the tool adapts to their workflow, rather than imposing a rigid one.
A vulnerability scanner that consistently reports numerous unfixable findings can quickly become mere noise, undermining its own value. Conversely, one that highlights a few critical, fixable issues with clear upgrade paths offers genuine, immediate value, fostering a proactive security posture without overwhelming the user.
Previous: Part 1: Fundamentals and Scaffolding
Next: Continue to Part 3: Configuration and Customization to build a flexible configuration system supporting multiple sources, environment-specific overrides, and thorough validation.
References
- Module: Bundler (Ruby 3.1.5). Ruby-Doc.org. Accessed November 4, 2025. https://ruby-doc.org/3.1.5/stdlibs/bundler/Bundler.html ↩
- Module: Open3 (Ruby 3.4.1). Ruby-Doc.org. Accessed November 4, 2025. https://docs.ruby-lang.org/en/master/Open3.html ↩
- Class: Bundler::UI::Shell (Ruby 3.3.6). Ruby-Doc.org. Accessed November 4, 2025. https://ruby-doc.org/3.3.6/stdlibs/bundler/Bundler/UI/Shell.html ↩