Creating a Custom Bundler Plugin: Part 3 - Configuration, Commands, and Performance

Enhance your Bundler security plugin with configuration options, custom CLI commands, and performance optimizations. Learn how to make the plugin flexible, user-friendly, and production-ready.

In Part 2, we implemented the core scanning functionality: executing Trivy, parsing its output, and presenting vulnerability findings. We established patterns for defensive execution and thoughtful presentation. However, a production-ready plugin requires more than just core functionality — it needs flexible configuration to adapt to diverse workflows and environments.

This installment explores configuration architecture for Bundler plugins. We will examine how to support multiple configuration sources with clear precedence rules, implement environment-specific overrides, and provide validation that catches errors early. The goal is to create a configuration system that balances organizational policy enforcement with developer flexibility, ensuring the plugin adapts to different contexts without requiring code changes.

The complete source code for this plugin is available at github.com/practicalrubygems/bundler-trivy-plugin.

Configuration Options

Just as a skilled artisan tunes their tools to the specific demands of each project, so too must software be adaptable to its environment. Effective security scanning, in particular, requires flexible configuration to align with diverse workflows, environments, and security policies. The bundler-trivy plugin is designed precisely for this purpose: to adapt, allowing for both global defaults and per-project overrides. This approach empowers organizations to enforce consistent security policies while providing teams with the necessary flexibility to manage their specific needs without constant manual intervention.

Configuration Sources

How does the plugin determine which settings to apply when multiple configurations are present? The bundler-trivy plugin employs a clear, tiered system for reading configuration from various sources, applying them in a specific order of precedence. This ensures that the most specific and immediate settings take priority, while still allowing for broader defaults. This well-defined hierarchy ensures that CI environments can override project settings, projects can establish team-wide defaults, and you can configure personal preferences without disrupting broader policies.

Here is the order, from highest to lowest precedence:

  1. Environment variables (highest precedence) — These are ideal for immediate, runtime overrides, especially within dynamic environments like CI/CD pipelines, where temporary adjustments are often necessary.
  2. Project-local configuration file (.bundler-trivy.yml) — This file is designed for version-controlled, project-specific defaults, ensuring consistency across a team’s development environments.
  3. Global configuration file (~/.bundle/trivy.yml) — This serves as a location for user-specific or system-wide defaults, allowing individual developers to set personal preferences.
  4. Built-in defaults (lowest precedence) — These are the plugin’s sensible fallback settings, providing a baseline configuration if no other sources are specified.

Environment Variable Configuration

Given the flexibility of file-based configuration, one might ask: why are environment variables necessary? Environment variables provide a direct and powerful mechanism for configuring the plugin, particularly useful for immediate, runtime overrides or within automated environments like CI/CD pipelines. They offer the highest precedence, ensuring that runtime settings can temporarily supersede any file-based configurations. This is especially valuable for injecting sensitive values or dynamic parameters without modifying committed files, and for making quick, temporary adjustments without altering version-controlled files.

Let’s examine how the plugin’s Config class reads these environment variables:

# lib/bundler/trivy/config.rb
module Bundler
  module Trivy
    class Config
      def skip_scan?
        env_bool("BUNDLER_TRIVY_SKIP", file_value("enabled", true) == false)
      end

      def fail_on_critical?
        env_bool("BUNDLER_TRIVY_FAIL_ON_CRITICAL", file_value(%w[fail_on critical], ci_environment?))
      end

      def fail_on_high?
        env_bool("BUNDLER_TRIVY_FAIL_ON_HIGH", file_value(%w[fail_on high], false))
      end

      def fail_on_any?
        env_bool("BUNDLER_TRIVY_FAIL_ON_ANY", false)
      end

      def compact_output?
        env_bool("BUNDLER_TRIVY_COMPACT", file_value(%w[output compact], ci_environment?))
      end

      def json_output?
        ENV["BUNDLER_TRIVY_FORMAT"] == "json"
      end

      def severity_threshold
        ENV["BUNDLER_TRIVY_SEVERITY"] || "CRITICAL"
      end

      def severity_filter
        filter = file_value(%w[scanning severity_filter], [])
        return filter if filter.is_a?(Array)

        []
      end

      def trivy_timeout
        ENV.fetch("BUNDLER_TRIVY_TIMEOUT",
          file_value(%w[scanning timeout], 120)).to_i
      end

      def ignored_cves
        file_value("ignores", [])
      end

      private

      def env_bool(key, default)
        value = ENV.fetch(key, nil)
        return default if value.nil?
        %w[true 1].include?(value)
      end

      def file_value(key_path, default)
        keys = key_path.is_a?(Array) ? key_path : [key_path]
        value = keys.reduce(@file_config) do |config, key|
          config.is_a?(Hash) ? config[key] : nil
        end
        value.nil? ? default : value
      end
    end
  end
end

These environment variables allow us fine-grained control over the plugin’s behavior, enabling various practical use cases:

  • BUNDLER_TRIVY_SKIP=true: Temporarily disables the security scan. This is useful for local development when you want to bypass scans for faster iteration, or in specific CI jobs where scanning might be redundant.
  • BUNDLER_TRIVY_FAIL_ON_CRITICAL=true: Configures the plugin to cause bundle install to fail if any CRITICAL vulnerabilities are detected. This is a common setting in CI/CD pipelines to enforce strict security gates.
  • BUNDLER_TRIVY_FAIL_ON_HIGH=true: Similar to FAIL_ON_CRITICAL, but extends the failure condition to include HIGH severity vulnerabilities. You can combine this with other fail_on settings to create a comprehensive security policy.
  • BUNDLER_TRIVY_FAIL_ON_ANY=true: Causes bundle install to fail if any vulnerability, regardless of severity, is found. This is suitable for projects with extremely strict security requirements.
  • BUNDLER_TRIVY_COMPACT=true: Enables a compact output format, often preferred in CI logs to reduce verbosity and highlight only the most important findings (e.g., CRITICAL and HIGH vulnerabilities).
  • BUNDLER_TRIVY_FORMAT=json: Changes the output format to JSON, which is ideal for programmatic consumption by other tools or for detailed analysis.
  • BUNDLER_TRIVY_SEVERITY=HIGH: Sets the minimum severity level for reported vulnerabilities. Only vulnerabilities at or above the specified level (e.g., HIGH, CRITICAL) will be displayed. This helps focus on the most relevant issues.
  • BUNDLER_TRIVY_TIMEOUT=300: Adjusts the maximum duration (in seconds) for the Trivy scan. This is crucial in environments with slow network connections or for very large projects where scans might otherwise time out prematurely.

Here are some examples of how you might use these environment variables in practice:

# Skip scan for local development
$ BUNDLER_TRIVY_SKIP=true bundle install

# Fail CI build on critical vulnerabilities
$ BUNDLER_TRIVY_FAIL_ON_CRITICAL=true bundle install

# Output JSON for programmatic parsing
$ BUNDLER_TRIVY_FORMAT=json bundle install > trivy_results.json

# Set a longer timeout for large projects
$ BUNDLER_TRIVY_TIMEOUT=300 bundle install

File-Based Configuration

If environment variables offer immediate control, why then do we need file-based configuration? While environment variables are excellent for immediate, high-precedence overrides, they are less suitable for managing complex, persistent settings that define a project’s default behavior. For these scenarios, a YAML configuration file provides a more ergonomic and maintainable approach. This file allows us to define a comprehensive set of defaults that are version-controlled with the project, ensuring consistency across development environments and team members.

Tip: It is a best practice to commit your .bundler-trivy.yml file to version control. This ensures that your security scanning policies are consistent across all team members and CI/CD environments, and that changes to these policies are tracked and reviewed.

Here is an example of a .bundler-trivy.yml file, illustrating the range of configurable options we can use:

# .bundler-trivy.yml
enabled: true
fail_on:
  critical: true
  high: false
  medium: false
  low: false
output:
  format: terminal # terminal, json, or compact
  compact: false
scanning:
  timeout: 120
  severity_filter: ['CRITICAL', 'HIGH']
ignores:
  - id: CVE-2023-12345
    reason: 'Does not affect our usage pattern'
    expires: 2025-12-31
  - id: CVE-2023-67890
    reason: 'No fix available, mitigated by WAF'
    expires: 2025-06-30

The plugin’s configuration loader intelligently merges settings from various sources. Environment variables always take the highest precedence, followed by project-local and then global configuration files.

This merging process is not a simple overwrite; instead, it employs a deep merge strategy. This means that nested configuration structures are combined, allowing specific keys to be overridden without losing other settings within the same section. For example, you can override a single fail_on severity level in an environment variable or a project-local file without needing to redefine all other fail_on settings. This approach provides maximum flexibility and reduces redundancy in your configuration.

Let’s look at the Config class, which is responsible for loading and interpreting these settings:

# lib/bundler/trivy/config.rb
module Bundler
  module Trivy
    class Config
      # ... (other methods for config access, not shown for brevity) ...

      private

      def load_config_file
        config_path = config_file_path

        return {} unless File.exist?(config_path)

        config = YAML.safe_load_file(config_path, permitted_classes: [Date]) || {}

        # Load global config and merge
        global_config_path = File.expand_path("~/.bundle/trivy.yml")
        if File.exist?(global_config_path)
          global = YAML.safe_load_file(global_config_path, permitted_classes: [Date]) || {}
          config = deep_merge(global, config)
        end

        config
      rescue => e
        Bundler.ui.warn "Failed to load config (#{config_path}): #{e.message}"
        {}
      end

      def project_root
        Bundler.default_gemfile.dirname.to_s
      end

      def deep_merge(hash1, hash2)
        hash1.merge(hash2) do |_, v1, v2|
          v1.is_a?(Hash) && v2.is_a?(Hash) ? deep_merge(v1, v2) : v2
        end
      end
    end
  end
end

Ignore List with Expiration

In the real world, not every security finding can be immediately remediated, and some vulnerabilities may be deemed acceptable risks under specific conditions. So, why do we need an ignore list? Security findings sometimes require suppression with proper justification. The plugin’s ignore list supports this crucial capability, allowing teams to temporarily or permanently suppress specific vulnerabilities. A key feature of this ignore mechanism is the support for expiration dates.

If an ignore entry includes an expires field, the vulnerability will only be suppressed until that date. Once the expiration date passes, the vulnerability will automatically reappear in scan results, prompting a necessary re-evaluation. This ensures that ignored vulnerabilities are not forgotten and are periodically reviewed for continued relevance and mitigation status. Each ignore entry also requires a reason field, enforcing documentation for every suppression.

Here’s how the cve_ignored? method checks if a given CVE ID is present in the configured ignore list and respects expiration dates:

# lib/bundler/trivy/config.rb (simplified for illustration)
class Config
  def cve_ignored?(cve_id)
    ignored_cves.any? do |ignore_entry|
      ignore_entry["id"] == cve_id && !expired?(ignore_entry)
    end
  end

  private

  def expired?(ignore_entry)
    return false unless ignore_entry["expires"]

    # Handle both string and Date objects
    expires = ignore_entry["expires"]
    expiration_date = expires.is_a?(Date) ? expires : Date.parse(expires.to_s)
    Date.today > expiration_date
  rescue ArgumentError
    # Handle invalid date format, treat as not expired to be safe
    false
  end
end

CI Environment Detection and Defaults

How does the bundler-trivy plugin adapt its behavior for automated pipelines? CI environments often have distinct operational requirements compared to local development setups. For instance, in CI/CD pipelines, you typically want stricter security enforcement and less verbose output. The bundler-trivy plugin is designed to automatically detect if it’s running within a common CI environment and apply sensible defaults tailored for automation.

This automatic detection and default application helps us to:

  • Enforce Security Gates: Automatically fail builds on critical vulnerabilities, ensuring that insecure code does not reach production.
  • Reduce Noise: Provide compact output, focusing on actionable findings rather than extensive details.
  • Optimize Performance: Adjust timeouts for potentially slower CI infrastructure.

Here’s how the plugin identifies a CI environment:

# lib/bundler/trivy/config.rb
module Bundler
  module Trivy
    class Config
      # ... other methods ...

      def ci_environment?
        # Check for common CI environment variables
        ENV["CI"] == "true" ||
          ENV["TRAVIS"] == "true" ||
          ENV["GITLAB_CI"] == "true" ||
          ENV["GITHUB_ACTIONS"] == "true" ||
          !ENV["JENKINS_URL"].nil?
      end

      # ... rest of the class ...
    end
  end

end

When ci_environment? returns true, the plugin automatically adjusts its behavior. For instance, it defaults to fail_on_critical: true and compact_output: true to align with typical CI/CD security policies and logging preferences. This automatic adaptation reduces boilerplate configuration and ensures consistent security enforcement in automated workflows.

Severity Filter Configuration

Many projects prioritize certain vulnerability severities over others. For instance, you might only be concerned with CRITICAL and HIGH vulnerabilities in your automated scans, choosing to address MEDIUM or LOW findings through other processes. The bundler-trivy plugin allows you to configure a specific severity filter, ensuring that only the most relevant issues are reported.

This filtering capability is not just about reducing noise; it also offers practical benefits. Filtering at the scanner level reduces output size and processing time, which is particularly beneficial in CI/CD environments:

  • Focused Reporting: Concentrates attention on the most impactful security risks.
  • Reduced Output Size: Decreases the volume of scan results, making logs easier to parse and review.
  • Faster Processing: By filtering at the scanner level, you can reduce the overall processing time, especially for large projects with many dependencies.

Here’s how you can configure the severity_filter in your .bundler-trivy.yml file:

# .bundler-trivy.yml
scanning:
  severity_filter:
    - CRITICAL
    - HIGH

When a severity_filter is specified, the plugin passes these criteria directly to Trivy, ensuring that only vulnerabilities matching the specified levels are included in the scan results:

def scan
  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

  stdout, stderr, status = Timeout.timeout(@config.trivy_timeout) do
    Open3.capture3(*args)
  end
  # ... parse results
end

Filtering at the scanner level reduces output size and processing time, which is particularly beneficial in CI/CD environments.

Timeout Configuration

Trivy security scans, especially for large projects or over slow network connections, can sometimes take an extended period or even hang indefinitely. To prevent automated pipelines from stalling and to ensure predictable execution times, the bundler-trivy plugin includes a configurable timeout mechanism.

This timeout ensures that we can:

  • CI/CD Pipelines Remain Responsive: Prevents individual scan jobs from consuming excessive resources or blocking subsequent stages.
  • Resource Management: Helps manage the computational resources allocated to security scanning.
  • Early Feedback: Provides prompt notification if a scan is taking unusually long, indicating a potential issue with the environment or the scan target.

The default timeout is set to 120 seconds, which accommodates most projects. However, for very large dependency trees or environments with constrained network access, you may need to increase this value. The timeout is applied directly to the Open3.capture3 call that executes Trivy. If the Trivy scan exceeds the configured timeout, a ScanError is raised, providing actionable feedback about the cause of the failure:

def scan
  args = build_trivy_args
  timeout = @config.trivy_timeout

  # Use Timeout.timeout wrapper, not Open3's timeout parameter
  stdout, stderr, status = Timeout.timeout(timeout) do
    Open3.capture3(*args)
  end

  # ... handle results
rescue Timeout::Error
  raise ScanError, "Trivy scan timed out after #{timeout} seconds"
end

If the Trivy scan exceeds the configured timeout, a ScanError is raised, providing actionable feedback about the cause of the failure.

Validation and Error Handling

What happens if our configuration is invalid or malformed? Configuration files, like any manual input, are prone to errors. Incorrectly formatted values, invalid severity levels, or missing required fields can lead to unexpected behavior or silent failures. To ensure the reliability and robustness of the bundler-trivy plugin, a comprehensive validation and error handling mechanism is in place.

This validation process is designed to help us, ensuring that configuration issues are identified and addressed early in the development or CI/CD pipeline, preventing downstream problems:

  • Prevent Misconfiguration: Catches common errors before they can impact scan results.
  • Provide Clear Feedback: Offers actionable error messages, guiding users to correct their configuration.
  • Ensure Data Integrity: Verifies that all necessary fields, such as a reason for ignored CVEs, are present and correctly formatted.

The validate! method performs these checks when the configuration is loaded, failing fast with actionable error messages if any issues are found:

def validate!
  errors = []

  # Validate severity filter
  valid_severities = %w[CRITICAL HIGH MEDIUM LOW UNKNOWN]
  invalid = severity_filter - valid_severities
  errors << "Invalid severity levels: #{invalid.join(", ")}" unless invalid.empty?

  # Validate timeout
  errors << "Timeout must be at least 10 seconds" if trivy_timeout < 10

  # Validate ignore expiration dates and required fields
  ignored_cves.each do |ignore|
    if ignore["expires"]
      begin
        # Handle both string and Date objects
        expires = ignore["expires"]
        expires.is_a?(Date) ? expires : Date.parse(expires.to_s)
      rescue ArgumentError
        errors << "Invalid expiration date for #{ignore["id"]}: #{ignore["expires"]}"
      end
    end

    errors << "Ignore entry for #{ignore["id"]} missing required 'reason' field" unless ignore["reason"]
  end

  return if errors.empty?

  raise ConfigError, "Configuration errors:\n  #{errors.join("\n  ")}"
end

This proactive validation ensures that configuration issues are identified and addressed early in the development or CI/CD pipeline, preventing downstream problems.

Configuration Documentation

Effective configuration is only possible if you understand the available options and their purpose. To facilitate this, the bundler-trivy plugin emphasizes documenting configuration directly within the YAML file itself. This approach ensures that the documentation is always co-located with the configuration, making it easily discoverable and accessible to anyone working with the project.

By embedding comments directly in the .bundler-trivy.yml file, we achieve several benefits, ensuring that the configuration documentation is always current and readily available to developers.

  • Discoverability: Users can see available options and their explanations without needing to consult external documentation.
  • Contextual Understanding: Explanations are provided precisely where the configuration is defined, aiding immediate comprehension.
  • Version Control: Configuration documentation is version-controlled alongside the code, ensuring it remains up-to-date with changes to the plugin.

Here’s an example of a well-documented .bundler-trivy.yml file that we can use:

# .bundler-trivy.yml
# Configuration for bundler-trivy-plugin
# See: https://github.com/your-org/bundler-trivy-plugin#configuration

# Enable or disable scanning entirely
enabled: true

# Determine when to fail bundle install based on findings
fail_on:
  critical: true # Fail on CRITICAL vulnerabilities
  high: false # Fail on HIGH vulnerabilities
  medium: false # Fail on MEDIUM vulnerabilities
  low: false # Fail on LOW vulnerabilities

# Output formatting options
output:
  format: terminal # terminal, json, or compact
  compact: false # Show only CRITICAL and HIGH

# Scanning behavior
scanning:
  timeout: 120 # Maximum seconds for Trivy scan
  # Filter to specific severity levels (empty = all)
  severity_filter:
    - CRITICAL
    - HIGH

# Suppress specific vulnerabilities with justification
# All ignores require a 'reason' and should include 'expires'
ignores:
  - id: CVE-2023-12345
    reason: "Vulnerability in code path we don't execute"
    expires: 2025-12-31

This approach ensures that the configuration documentation is always current and readily available to developers.

Environment-Specific Overrides

How can we manage different security scanning configurations for various environments, such as development, staging, and production, or specific CI/CD pipelines? In complex deployment scenarios, projects often require distinct security scanning configurations for these different contexts. To address this, the bundler-trivy plugin supports environment-specific configuration files, allowing you to maintain distinct settings without duplicating your entire configuration.

This capability is particularly useful for us to achieve, providing a flexible and maintainable way to manage diverse security scanning requirements across different operational contexts:

  • Tailored Policies: Applying stricter fail_on rules in production or staging environments compared to development.
  • Reduced Noise: Using more compact output formats in CI logs while retaining detailed output locally.
  • Dynamic Behavior: Adjusting timeouts or other scanning parameters based on the characteristics of a specific environment.

Projects can maintain multiple configuration files, following a clear naming convention:

.bundler-trivy.yml              # Base configuration, applied by default
.bundler-trivy.ci.yml           # Overrides for CI environments
.bundler-trivy.production.yml   # Overrides for production deployments

The plugin’s config_file_path method dynamically selects the appropriate configuration file based on the BUNDLER_TRIVY_ENV environment variable:

def config_file_path
  env = ENV.fetch("BUNDLER_TRIVY_ENV", nil)

  if env && !env.empty?
    env_config = File.join(project_root, ".bundler-trivy.#{env}.yml")
    return env_config if File.exist?(env_config)
  end

  File.join(project_root, ".bundler-trivy.yml")
end

For example, to load .bundler-trivy.ci.yml instead of the base configuration, you would set the BUNDLER_TRIVY_ENV environment variable:

$ BUNDLER_TRIVY_ENV=ci bundle install

This mechanism provides a flexible and maintainable way to manage diverse security scanning requirements across different operational contexts.

Custom Bundler Commands

Throughout history, the most effective tools have often been those that can be extended and customized to meet specific needs. From the artisan’s specialized tools to the engineer’s bespoke instruments, the ability to add on-demand functionality is crucial. In the realm of Ruby development, Bundler provides a powerful foundation for managing dependencies and automating tasks through its plugin system. While its hooks enable automated actions triggered by events like bundle install, there are times when we require immediate, user-initiated functionality — a direct command to perform a specific task. This is the purpose of custom Bundler commands. They allow us to integrate new capabilities directly into Bundler’s command-line interface, offering immediate actions that feel like native Bundler features. For a security scanning plugin, this means we can provide instant vulnerability checks, complementing automatic scans without relying solely on event-driven triggers.

Of course, you might ask why not create a standalone Ruby script or a Rake task for such functionality. While those approaches are certainly viable, integrating commands directly into Bundler offers several advantages. It leverages Bundler’s existing infrastructure for plugin management, ensures a consistent CLI experience for users who are already familiar with bundle <command>, and allows the command to operate within the context of Bundler’s dependency resolution and environment setup. This tight integration makes the custom command feel like a natural extension of Bundler itself, rather than an external utility.

Command Registration

Plugins register commands through the plugin API. The plugin.command method takes a string argument, which becomes the name of the new subcommand available under bundle. Though the registration itself is straightforward, the real work lies in implementing the command’s behavior:

# plugins.rb
require_relative "lib/bundler/trivy/plugin"

Bundler::Plugin.register("bundler-trivy-plugin") do |plugin|
  # Hook-based automatic scanning
  plugin.hook("after-install-all") do
    Bundler::Trivy::Plugin.scan_after_install if Bundler::Trivy::Plugin.auto_scan_enabled?
  end

  # Custom command for manual scanning
  plugin.command "trivy"
end

The command name becomes a Bundler subcommand, which we can then invoke:

Usage: bundle trivy [COMMAND] [args...]

For example:

$ bundle trivy
$ bundle trivy scan
$ bundle trivy ignore CVE-2023-12345

Command Handler Implementation

Once a custom command is registered, the plugin must provide a command handler to define its behavior. When a user invokes bundle trivy, Bundler calls the exec method within the plugin’s Bundler::Trivy::Plugin class. This method receives the command name — for example, “trivy” — and any subsequent arguments provided by the user.

One may wonder: how does a single exec method handle multiple subcommands like scan, ignore, and update-db? The answer lies in parsing the args array — typically using a case statement — to dispatch to specific handler methods based on the first argument:

# lib/bundler/trivy/plugin.rb
module Bundler
  module Trivy
    class Plugin < Bundler::Plugin::API
      command "trivy"

      def exec(command, args)
        case args.first
        when "scan", nil
          cmd_scan(args[1..-1])
        when "ignore"
          cmd_ignore(args[1..-1])
        when "update-db"
          cmd_update_db
        when "version"
          cmd_version
        else
          Bundler.ui.error "Unknown trivy command: #{args.first}"
          cmd_help
          exit 1
        end
      end

      private

      def cmd_scan(args)
        Bundler.ui.info "Running Trivy vulnerability scan..."

        scanner = Scanner.new(project_root)
        result = scanner.scan

        Reporter.new(result).display

        exit 1 if should_fail?(result)
      end

      def cmd_help
        Bundler.ui.info <<~HELP
          Usage: bundle trivy COMMAND

          Commands:
            scan              Run vulnerability scan (default)
            ignore CVE-ID     Add CVE to ignore list
            update-db         Update Trivy vulnerability database
            version           Show plugin and Trivy versions
            help              Show this help message
        HELP
      end

      def project_root
        Bundler.default_gemfile.dirname.to_s
      end

      def config
        @config ||= Config.new
      end

      def should_fail?(result)
        (result.has_critical_vulnerabilities? && config.fail_on_critical?) ||
        (result.has_vulnerabilities? && config.fail_on_any?)
      end
    end
  end
end

The Scan Command

The scan command provides on-demand vulnerability checking. This allows developers to quickly assess their project’s security posture at any time during development or before committing changes.

Usage: bundle trivy scan [options]

In its simplest form, we can run a scan without any additional options:

$ bundle trivy scan
Running Trivy vulnerability scan...

Trivy found 3 vulnerabilities:

  CRITICAL: 1
  HIGH: 2

Of course, this command leverages the same underlying scanner and reporter as the automatic hook, ensuring consistent and reliable vulnerability assessment across all workflows. The exact number and severity of vulnerabilities will, naturally, vary depending on your project’s dependencies and the current Trivy database.

Additional scan options could accept arguments:

def cmd_scan(args)
  options = parse_scan_options(args)

  Bundler.ui.info "Running Trivy vulnerability scan..."

  scanner = Scanner.new(project_root)
  result = scanner.scan

  reporter = Reporter.new(result, options)
  reporter.display

  exit 1 if should_fail?(result)
end

def parse_scan_options(args)
  options = {
    format: :terminal,
    severity: nil
  }

  args.each_with_index do |arg, i|
    case arg
    when "--json"
      options[:format] = :json
    when "--severity"
      options[:severity] = args[i + 1]&.split(",")
    end
  end

  options
end

This approach enables standard command-line interface patterns, such as:

$ bundle trivy scan --json
$ bundle trivy scan --severity CRITICAL,HIGH

The Ignore Command

The ignore command allows us to manage the project’s vulnerability ignore list. This provides a mechanism to suppress alerts for known false positives or accepted risks. The command guides us through an interactive process to add specific CVE IDs, along with a reason and an optional expiration date, directly to the project’s configuration. Since bundle trivy ignore modifies the .bundler-trivy.yml configuration file, it is wise to ensure that the latest “known good” version of your project’s configuration files are committed to source control before making changes. This allows for easy rollback if unintended modifications occur.

Usage: bundle trivy ignore CVE-ID

We can add entries to the ignore list interactively using the ignore command:

def cmd_ignore(args)
  cve_id = args.first

  unless cve_id
    Bundler.ui.error "CVE ID required"
    Bundler.ui.info "Usage: bundle trivy ignore CVE-2023-12345"
    exit 1
  end

  unless cve_id.match?(/^CVE-\d{4}-\d+$/)
    Bundler.ui.error "Invalid CVE ID format: #{cve_id}"
    exit 1
  end

  # Prompt for reason
  Bundler.ui.info "Reason for ignoring #{cve_id}:"
  reason = $stdin.gets.chomp

  if reason.empty?
    Bundler.ui.error "Reason required"
    exit 1
  end

  # Prompt for expiration
  Bundler.ui.info "Expiration date (YYYY-MM-DD, or leave empty for no expiration):"
  expires_input = $stdin.gets.chomp
  expires = expires_input.empty? ? nil : expires_input

  if expires
    begin
      Date.parse(expires)
    rescue ArgumentError
      Bundler.ui.error "Invalid date format: #{expires}"
      exit 1
    end
  end

  add_ignore(cve_id, reason, expires)
  Bundler.ui.confirm "Added #{cve_id} to ignore list"
end

def add_ignore(cve_id, reason, expires)
  config_path = File.join(project_root, ".bundler-trivy.yml")

  config = if File.exist?(config_path)
    YAML.load_file(config_path) || {}
  else
    {}
  end

  config["ignores"] ||= []

  ignore_entry = {
    "id" => cve_id,
    "reason" => reason
  }
  ignore_entry["expires"] = expires if expires

  config["ignores"] << ignore_entry

  File.write(config_path, YAML.dump(config))
end

Usage:

$ bundle trivy ignore CVE-2023-38545
Reason for ignoring CVE-2023-38545:
> Vulnerability in code path not executed by our application
Expiration date (YYYY-MM-DD, or leave empty for no expiration):
> 2025-12-31
Added CVE-2023-38545 to ignore list

This creates or updates .bundler-trivy.yml:

ignores:
  - id: CVE-2023-38545
    reason:
      Vulnerability in code path not executed by our application
    expires: 2025-12-31

The Update-DB Command

The update-db command provides a way to manually refresh Trivy’s vulnerability database. This is crucial for ensuring that our security scans are always performed against the most current threat intelligence, especially in environments where automatic updates might be restricted or delayed. Usage: bundle trivy update-db

Let’s run the command to update the database:

$ bundle trivy update-db
Updating Trivy vulnerability database...
Database updated successfully

This command is particularly useful in continuous integration (CI) environments — where we might want to explicitly update the database before triggering a scan — to ensure the automatic scan uses a fresh database:

$ bundle trivy update-db
$ bundle install  # Triggers automatic scan with fresh database

The Version Command

The version command provides a quick way to display the versions of both the bundler-trivy-plugin and the underlying Trivy scanner. This information is invaluable for debugging, ensuring compatibility, and verifying that we are using the expected tool versions — in our development and CI environments. Usage: bundle trivy version

def cmd_version
  plugin_version = VERSION  # Defined in lib/bundler/trivy/version.rb

  stdout, _, status = Open3.capture3("trivy", "--version")
  trivy_version = if status.success?
    stdout.match(/Version: ([\d.]+)/)[1] rescue "unknown"
  else
    "not installed"
  end

  Bundler.ui.info "bundler-trivy-plugin version #{plugin_version}"
  Bundler.ui.info "trivy version #{trivy_version}"
end

When we run this command, we will see output similar to the following. Of course, the exact version numbers you observe will depend on your installed plugin and Trivy versions:

$ bundle trivy version
bundler-trivy-plugin version 0.1.0
trivy version 0.48.0

Argument Parsing

As commands grow in complexity, handling various options and flags becomes crucial for usability. While simple argument parsing can be done manually, Ruby’s OptionParser library provides a robust and standardized way to define and process command-line arguments. This ensures a familiar experience for users. This approach allows us to define expected options — their types and help messages — making our custom commands more user-friendly and maintainable.

For more complex commands, proper argument parsing improves usability:

Tip: While manual argument parsing with case statements is feasible for simple commands, OptionParser is the idiomatic Ruby choice for building robust and user-friendly command-line interfaces. It handles common CLI patterns (flags, options with arguments, help messages) and ensures a consistent experience for users. For more advanced scenarios, consider libraries like Thor or Commander which build upon OptionParser to provide even richer CLI development features.

require "optparse"

def parse_scan_options(args)
  options = {
    format: :terminal,
    severity: [],
    exit_code: 1
  }

  OptionParser.new do |opts|
    opts.banner = "Usage: bundle trivy scan [options]"

    opts.on("--json", "Output in JSON format") do
      options[:format] = :json
    end

    opts.on("--severity SEVERITIES", Array, "Filter by severity (CRITICAL,HIGH,MEDIUM,LOW)") do |s|
      options[:severity] = s
    end

    opts.on("--[no-]exit-code", "Exit with non-zero code on findings (default: true)") do |e|
      options[:exit_code] = e ? 1 : 0
    end

    opts.on("-h", "--help", "Show help") do
      Bundler.ui.info opts
      exit 0
    end
  end.parse!(args)

  options
end

This integration with OptionParser enables standard command-line interface patterns, such as:

$ bundle trivy scan --help
$ bundle trivy scan --json --severity CRITICAL,HIGH
$ bundle trivy scan --no-exit-code

Integration with Bundler’s UI

When developing custom Bundler commands, it is crucial to integrate seamlessly with Bundler’s existing user interface. By utilizing Bundler.ui methods, we ensure that our plugin’s output — whether informational messages, warnings, or errors — adheres to Bundler’s established style and formatting. This consistency not only provides a familiar experience for users but also respects their configured preferences for output verbosity and colorization. Furthermore, Bundler.ui methods often integrate with Bundler’s internal logging mechanisms, making debugging and auditing easier.

Commands should use Bundler.ui methods for consistency:

def cmd_scan(args)
  Bundler.ui.info "Running scan..."        # Blue info message
  Bundler.ui.confirm "Scan complete"       # Green success message
  Bundler.ui.warn "2 vulnerabilities"      # Yellow warning
  Bundler.ui.error "Scan failed"           # Red error message
end

Command Discoverability

Bundler lists available plugin commands:

$ bundle plugin list
bundler-trivy-plugin (0.1.0)
  Commands: trivy

Users discover the command through:

$ bundle trivy help
Usage: bundle trivy COMMAND

Commands:
  scan              Run vulnerability scan (default)
  ignore CVE-ID     Add CVE to ignore list
  update-db         Update Trivy vulnerability database
  version           Show plugin and Trivy versions
  help              Show this help message

The help command, for example, should be comprehensive and include examples.

Error Handling in Commands

Robust error handling is a critical aspect of any well-designed command-line tool. For custom Bundler commands — which often run interactively — providing clear and actionable error messages is paramount. This approach guides users toward resolving issues without cryptic messages or overwhelming stack traces, enhancing the overall user experience.

Of course, commands run interactively and should provide clear error messages:

def cmd_scan(args)
  unless scanner_available?
    Bundler.ui.error "Trivy not found"
    Bundler.ui.info "Install Trivy: https://trivy.dev/docs/getting-started/installation/"
    exit 1
  end

  unless lockfile_exists?
    Bundler.ui.error "Gemfile.lock not found"
    Bundler.ui.info "Run 'bundle install' first"
    exit 1
  end

  begin
    scanner = Scanner.new(project_root)
    result = scanner.scan
    Reporter.new(result).display
  rescue Scanner::Error => e
    Bundler.ui.error "Scan failed: #{e.message}"
    exit 1
  end
end

Performance Considerations

Integrating security scanning into our dependency management is crucial for identifying vulnerabilities. Of course, this integration inevitably introduces a significant trade-off: performance. Just as a thorough security check at a physical checkpoint adds time, scanning our RubyGems for potential issues presents a similar challenge. In this section, we will explore where this latency originates and how we can strategically minimize its impact, ensuring the Bundler Trivy plugin enhances our development workflow without undue disruption. Our goal is to strike a pragmatic balance between robust security and efficient developer velocity.

Performance Breakdown

To effectively optimize the security scanning process, we must first understand its constituent parts. A scan through the Bundler Trivy plugin involves several distinct steps, each contributing to the overall latency:

  1. Plugin loading: Bundler loads the plugin when it starts (10-50ms)
  2. Hook execution: The after-install-all hook fires (<1ms)
  3. Trivy database check: Trivy verifies its vulnerability database is current (100-500ms)
  4. Lockfile scanning: Trivy reads and parses the Gemfile.lock (50-200ms)
  5. Vulnerability matching: Cross-referencing gems against the database (200-1000ms)
  6. Result formatting: The plugin parses JSON output and displays results (10-100ms)

For a typical project with 100-200 gems, the total scan time usually ranges from 500ms to 2 seconds when the database is current. Of course, the very first scan after installing Trivy or performing a database update will take significantly longer, as it involves a full database download.

Database Updates

One may wonder: how do we manage the vulnerability database updates efficiently, given their potential for latency? The Trivy vulnerability database requires periodic updates to remain effective, ensuring we scan against the latest known threats. By default, Trivy checks for updates automatically and downloads a new version if its local database is older than six hours.

Of course, these database downloads can take a significant amount of time — typically 10-60 seconds, depending on network speed, as the compressed database size is approximately 200MB. This one-time cost per day, in typical usage, creates noticeable latency that can disrupt development workflows if not managed thoughtfully.

To minimize this impact and maintain a predictable development experience, we can employ several strategies:

Cache the database in CI: In continuous integration (CI) environments, repeated database downloads can significantly inflate build times. To mitigate this, we can cache the ~/.cache/trivy directory between builds. This pragmatic approach prevents redundant downloads on every CI run, reducing pipeline execution times and improving overall efficiency.

# .github/workflows/ci.yml
- name: Cache Trivy database
  uses: actions/cache@v3
  with:
    path: ~/.cache/trivy
    key: trivy-db-${{ hashFiles('Gemfile.lock') }}
    restore-keys: trivy-db-

Update database separately: To isolate the potentially long database download from the core bundle install process, we can run bundle trivy update-db as a distinct step in our workflow. This separation makes the performance characteristics of our dependency installation more predictable; the bundle install command itself will not be blocked by a database download.

- name: Update Trivy database
  run: bundle trivy update-db

- name: Install dependencies with scanning
  run: bundle install

Skip auto-updates in controlled environments: For environments where database updates are managed through other mechanisms (such as scheduled jobs or pre-built Docker image layers), automatic updates during bundle install are often redundant and can introduce unnecessary delays. In such cases, we can set the TRIVY_SKIP_DB_UPDATE=true environment variable to prevent these automatic updates, ensuring the scan uses the pre-existing database without attempting a fresh download.

$ TRIVY_SKIP_DB_UPDATE=true bundle install

Conditional Scanning

One may wonder: if our dependencies have not changed, why should we perform a security scan every time we run bundle install? Performing a full security scan when the Gemfile.lock remains identical to the last scan is a waste of time and resources. To address this, the plugin can implement conditional scanning, checking whether a scan is truly necessary before proceeding.

The core philosophy here is to avoid redundant work. If the inputs to our security scan — primarily the Gemfile.lock — have not changed, then the outputs of the scan are highly unlikely to change. Therefore, we can introduce a caching layer that tracks the state of the Gemfile.lock and only triggers a scan when necessary, thereby optimizing our feedback loop.

To implement this, we can introduce a should_scan? mechanism within the plugin. This intelligent mechanism determines if a scan is truly necessary, relying on several helper methods. Each method serves a distinct purpose in assessing scan necessity and managing cache state, structured as follows:

  • should_scan?: The primary entry point, determining if a scan is required based on environmental variables and lockfile changes.
  • lockfile_changed?: Compares the Gemfile.lock’s modification time against a cached timestamp to detect any alterations.
  • cache_path_for: Generates a unique and consistent cache file path based on the Gemfile.lock’s absolute path, using a SHA256 digest to ensure uniqueness and avoid naming conflicts.
  • record_scan: Updates the cache file’s timestamp after a successful scan, marking the Gemfile.lock’s state as “scanned.”
require 'digest' # For SHA256 hashing

def self.scan_after_install
  return unless should_scan?

  # Perform scan...
end

def self.should_scan?
  # Skip if explicitly disabled via environment variable
  return false if ENV["BUNDLER_TRIVY_SKIP"] == "true"

  # Skip if lockfile unchanged since last scan
  return false unless lockfile_changed?

  true
end

def self.lockfile_changed?
  lockfile_path = Bundler.default_lockfile
  cache_file = cache_path_for(lockfile_path)

  # If no cache file exists, a scan is always required
  return true unless File.exist?(cache_file)

  lockfile_mtime = File.mtime(lockfile_path)
  cache_mtime = File.mtime(cache_file)

  # A scan is required if the lockfile has been modified more recently than the cache file
  lockfile_mtime > cache_mtime
end

def self.cache_path_for(lockfile_path)
  # Using a SHA256 digest of the lockfile path ensures a unique and stable cache file name
  digest = Digest::SHA256.hexdigest(lockfile_path.to_s)
  File.join(ENV["HOME"], ".cache", "bundler-trivy", "#{digest}.scan")
end

def self.record_scan
  lockfile_path = Bundler.default_lockfile
  cache_file = cache_path_for(lockfile_path)

  # Ensure the cache directory exists before creating the cache file
  FileUtils.mkdir_p(File.dirname(cache_file))
  FileUtils.touch(cache_file) # Update the timestamp of the cache file
end

This caching mechanism effectively prevents redundant scans, thereby saving valuable time in development and CI environments. Observe its behavior through these examples:

  • The first bundle install command triggers a scan, as either the Gemfile.lock has changed since the last recorded scan, or no cache file exists. This demonstrates the initial scan.
  • The second bundle install command, run immediately after, skips the scan because the Gemfile.lock remains unchanged. You will notice the absence of “Scanning with Trivy…”, which confirms the caching mechanism is active.
  • The bundle update rails command, which modifies the Gemfile.lock, correctly triggers a new scan. This illustrates that any dependency updates will invalidate the cache and ensure a fresh security check.
$ bundle install    # Runs scan, dependencies changed or no cache
Scanning with Trivy...

$ bundle install    # Skips scan, lockfile unchanged
$

$ bundle update rails   # Runs scan, lockfile changed
Scanning with Trivy...

The cache is invalidated by comparing the Gemfile.lock’s modification timestamp with the cache file’s timestamp. This ensures that a scan always runs after any dependency updates, providing both efficiency and accuracy without unnecessary overhead.

Parallel Execution

While Trivy itself supports scanning multiple files concurrently, this capability offers little direct benefit for single-project scans, which typically involve a single Gemfile.lock. However, to further reduce perceived latency during bundle install, the plugin could potentially execute the security scan in the background, allowing the dependency installation to complete more quickly.

Consider an implementation that leverages asynchronous execution, such as this:

def self.scan_after_install
  return unless should_scan?

  if async_scan_enabled?
    scan_async
  else
    scan_sync
  end
end

def self.scan_async
  pid = fork do
    scanner = Scanner.new(project_root)
    result = scanner.scan
    Reporter.new(result).display
  end

  Process.detach(pid)

  Bundler.ui.info "Vulnerability scan running in background (PID: #{pid})"
end

This approach allows bundle install to return immediately while the security scan continues as a separate process. The trade-off, though, is significant: in interactive development workflows, developers might easily miss critical scan results if they do not actively monitor the background process’s output. The immediate feedback loop, which is crucial for developer productivity, is broken.

Conversely, asynchronous scanning makes considerable sense for CI environments. In CI, logs reliably capture all output for later review, and the primary goal is often to maximize throughput and minimize the total pipeline execution time. Here, the delayed feedback is acceptable because the system is designed to aggregate and present results comprehensively. However, for local development, where immediate and visible results are typically expected, this approach can easily confuse and frustrate developers.

Trivy Performance Options

Beyond the plugin’s internal optimizations, Trivy itself offers a range of command-line options that can significantly affect scan speed and resource usage. Understanding these allows us to fine-tune the scanning process for our specific needs, striking a better balance between thoroughness and performance.

Here are some key Trivy options that influence performance:

Skip database update (--skip-db-update): As we have discussed, database updates can be a major source of latency. This option prevents Trivy from attempting to update its vulnerability database during a scan, which is particularly useful in environments where database updates are managed externally or are known to be current.

$ trivy fs --skip-db-update /path/to/project

Limit scanners (--scanners): Trivy can scan for various types of vulnerabilities, including OS packages, application dependencies, and configuration issues. By explicitly limiting the scanners to only those relevant to our immediate needs — for example, vuln for known vulnerabilities — we can significantly reduce the processing time.

$ trivy fs --scanners vuln /path/to/project  # Skips license scanning, config checks

Reduce output (--quiet): While detailed output is valuable for debugging, suppressing progress indicators and verbose logging can slightly reduce the overhead associated with output formatting, especially in automated environments where only the final results matter.

$ trivy fs --quiet /path/to/project  # Suppresses progress output

Of course, the Bundler Trivy plugin is designed to leverage some of these options by default to provide a sensible out-of-the-box experience. However, a robust configuration system could expose additional options, allowing developers to customize Trivy’s behavior directly and achieve even finer-grained control over performance characteristics:

def trivy_args
  args = [
    "trivy", "fs",
    "--scanners", "vuln",
    "--format", "json",
    "--quiet"
  ]

  args << "--skip-db-update" if config.skip_db_update?
  args << "--timeout" << config.trivy_timeout.to_s if config.trivy_timeout

  args << @project_root
  args
end

Measuring Impact

To make truly informed decisions about performance optimizations, we must first understand the actual, quantifiable cost. Developers and teams should have a clear understanding of the performance overhead introduced by security scanning. To facilitate this, the plugin can be designed to report precise timing information for each scan:

def self.scan_after_install
  return unless should_scan?

  start_time = Time.now

  scanner = Scanner.new(project_root)
  result = scanner.scan

  scan_duration = Time.now - start_time

  Reporter.new(result).display

  if ENV["BUNDLER_TRIVY_SHOW_TIMING"] == "true"
    Bundler.ui.info "Scan completed in #{scan_duration.round(2)}s"
  end
end

By making the scan duration visible, we empower teams to objectively assess whether the plugin’s security value justifies its incurred latency, allowing for data-driven optimization choices.

CI-Specific Optimizations

Continuous Integration (CI) environments often present different performance considerations compared to local development setups. We can leverage these differences to optimize our security scanning workflow, focusing on maximizing pipeline efficiency and maintaining a rapid feedback loop. Here are several strategies to consider:

Pre-built images with Trivy: One highly effective approach is to use Docker images that come pre-built with Trivy and a recently updated vulnerability database. This significantly reduces setup time within CI jobs, as the tools and their data are already present, eliminating the need for repeated installations and database downloads.

Separate scan job: Instead of integrating the security scan directly into the bundle install step of every job, which can block subsequent tasks, we can run scanning as a dedicated, parallel CI job. This allows the testing and security analysis to proceed concurrently, thereby reducing the total pipeline execution time and providing a clearer separation of concerns.

jobs:
  test:
    steps:
      - run: bundle install --without development
      - run: bundle exec rspec

  security:
    steps:
      - run: bundle install
      - run: bundle trivy scan --exit-code 1

Scheduled scans: For a less intrusive approach that avoids adding latency to every single build, we can implement daily scheduled scans. This allows us to catch new vulnerabilities that emerge over time without impacting the immediate feedback loop of development, ensuring continuous security oversight with minimal disruption.

on:
  schedule:
    - cron: '0 9 * * *' # 9 AM daily

By strategically employing these CI-specific optimizations, we can parallelize scanning with other pipeline tasks, significantly reducing total pipeline time and ensuring security checks are both thorough and efficient.

Memory Considerations

When we discuss performance, memory usage is often a critical factor, particularly in resource-constrained environments where every megabyte counts. Trivy’s vulnerability database, during active scans, typically requires approximately 300-400MB of RAM. For systems with limited available memory, this can indeed become a significant concern, potentially leading to resource contention.

It is important to distinguish that the Bundler Trivy plugin itself runs within Bundler’s process, which usually consumes a modest 50-100MB of RAM. Trivy, however, executes as a separate process. This means its substantial memory footprint does not directly impact Bundler’s own memory usage, but rather adds to the overall system load.

Nevertheless, in highly constrained environments — such as Docker containers provisioned with less than 1GB of RAM — running Trivy during bundle install can lead to considerable memory pressure, potentially causing performance degradation or even out-of-memory errors. In such scenarios, it becomes prudent to either run security scans as separate, dedicated processes or to entirely skip automatic scanning during dependency installation, thereby preserving system stability.

Optimization Summary

To effectively manage the performance overhead of security scanning, we can summarize the key strategies discussed throughout this section:

  1. Cache the vulnerability database in both CI and local development environments to avoid redundant downloads.
  2. Skip scans when the Gemfile.lock is unchanged by implementing intelligent caching mechanisms.
  3. Update the Trivy database separately from the main dependency installation process to isolate latency.
  4. Utilize parallel CI jobs for security scanning and testing, reducing overall pipeline execution time.
  5. Measure and report scan timing to provide transparency and enable data-driven optimization decisions.
  6. Provide explicit skip mechanisms for scenarios where automatic scanning is not appropriate or necessary.

Ultimately, performance optimization in this context is a balancing act. We must weigh the speed of our feedback loop against the thoroughness of our security scans. A scan that takes a couple of seconds but reliably identifies critical vulnerabilities before they reach production, of course, represents excellent value. Conversely, a scan that adds a significant half-minute to every bundle install without yielding actionable insights quickly becomes a counterproductive burden. Our goal is to find that pragmatic balance that ensures both security and developer velocity.


Previous: Part 2: Core Scanner Implementation

Next: Continue to Part 4: Testing and Deployment to explore comprehensive testing strategies, deployment approaches, and operational considerations for production use.