bundler-trivy-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+
6
+ module Bundler
7
+ module Trivy
8
+ # Configuration management for the Bundler Trivy plugin.
9
+ #
10
+ # Config handles loading and merging configuration from multiple sources:
11
+ # - YAML configuration files (.bundler-trivy.yml)
12
+ # - Environment variables (BUNDLER_TRIVY_*)
13
+ # - Global configuration (~/.bundle/trivy.yml)
14
+ # - CI environment detection and smart defaults
15
+ #
16
+ # Environment variables always take precedence over file configuration,
17
+ # allowing for easy overrides in CI/CD environments.
18
+ #
19
+ # @example Basic usage
20
+ # config = Config.new
21
+ # config.skip_scan? # => false
22
+ # config.fail_on_critical? # => true (in CI), false (locally)
23
+ #
24
+ # @example With environment variable override
25
+ # ENV["BUNDLER_TRIVY_FAIL_ON_CRITICAL"] = "true"
26
+ # config = Config.new
27
+ # config.fail_on_critical? # => true
28
+ class Config
29
+ # Initializes a new Config instance.
30
+ #
31
+ # Loads configuration from file and validates it. Configuration is loaded
32
+ # from multiple sources and merged with the following precedence:
33
+ # 1. Environment variables (highest priority)
34
+ # 2. Project config file (.bundler-trivy.yml)
35
+ # 3. Global config file (~/.bundle/trivy.yml)
36
+ # 4. Built-in defaults (lowest priority)
37
+ #
38
+ # @raise [ConfigError] If configuration validation fails
39
+ #
40
+ # @example Create with default settings
41
+ # config = Config.new
42
+ def initialize
43
+ @file_config = load_config_file
44
+ validate! if @file_config.any?
45
+ end
46
+
47
+ # Determines if scanning should be skipped entirely.
48
+ #
49
+ # @return [Boolean] true if scanning is disabled, false otherwise
50
+ #
51
+ # @example Check if scanning is enabled
52
+ # config.skip_scan? # => false
53
+ #
54
+ # @example Disable via environment variable
55
+ # ENV["BUNDLER_TRIVY_SKIP"] = "true"
56
+ # config.skip_scan? # => true
57
+ def skip_scan?
58
+ env_bool("BUNDLER_TRIVY_SKIP", file_value("enabled", true) == false)
59
+ end
60
+
61
+ # Determines if the plugin should exit with error on CRITICAL vulnerabilities.
62
+ #
63
+ # Defaults to true in CI environments, false in local development.
64
+ # This allows strict enforcement in CI while keeping local development flexible.
65
+ #
66
+ # @return [Boolean] true if should fail on critical vulnerabilities
67
+ #
68
+ # @example In CI environment
69
+ # ENV["CI"] = "true"
70
+ # config.fail_on_critical? # => true
71
+ #
72
+ # @example Force enable locally
73
+ # ENV["BUNDLER_TRIVY_FAIL_ON_CRITICAL"] = "true"
74
+ # config.fail_on_critical? # => true
75
+ def fail_on_critical?
76
+ env_bool("BUNDLER_TRIVY_FAIL_ON_CRITICAL",
77
+ file_value(%w[fail_on critical], ci_environment?))
78
+ end
79
+
80
+ # Determines if the plugin should exit with error on HIGH severity vulnerabilities.
81
+ #
82
+ # @return [Boolean] true if should fail on high severity vulnerabilities
83
+ #
84
+ # @example Enable strict mode
85
+ # ENV["BUNDLER_TRIVY_FAIL_ON_HIGH"] = "true"
86
+ # config.fail_on_high? # => true
87
+ def fail_on_high?
88
+ env_bool("BUNDLER_TRIVY_FAIL_ON_HIGH",
89
+ file_value(%w[fail_on high], false))
90
+ end
91
+
92
+ # Determines if the plugin should exit with error on ANY vulnerabilities.
93
+ #
94
+ # This is the most strict setting, causing failure on vulnerabilities
95
+ # of any severity level (including LOW and MEDIUM).
96
+ #
97
+ # @return [Boolean] true if should fail on any vulnerabilities
98
+ #
99
+ # @example Ultra-strict mode
100
+ # ENV["BUNDLER_TRIVY_FAIL_ON_ANY"] = "true"
101
+ # config.fail_on_any? # => true
102
+ def fail_on_any?
103
+ env_bool("BUNDLER_TRIVY_FAIL_ON_ANY", false)
104
+ end
105
+
106
+ # Determines if output should be in compact format.
107
+ #
108
+ # Compact format is more suitable for CI logs with less visual formatting.
109
+ # Defaults to true in CI environments, false in local development.
110
+ #
111
+ # @return [Boolean] true if compact output is enabled
112
+ def compact_output?
113
+ env_bool("BUNDLER_TRIVY_COMPACT",
114
+ file_value(%w[output compact], ci_environment?))
115
+ end
116
+
117
+ # Determines if output should be in JSON format.
118
+ #
119
+ # JSON output is useful for machine parsing and integration with other tools.
120
+ #
121
+ # @return [Boolean] true if JSON output is enabled
122
+ #
123
+ # @example Enable JSON output
124
+ # ENV["BUNDLER_TRIVY_FORMAT"] = "json"
125
+ # config.json_output? # => true
126
+ def json_output?
127
+ format = ENV["BUNDLER_TRIVY_FORMAT"] || file_value(%w[output format], "terminal")
128
+ format == "json"
129
+ end
130
+
131
+ # Returns the minimum severity threshold for reporting.
132
+ #
133
+ # @return [String] Severity threshold (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN)
134
+ def severity_threshold
135
+ ENV["BUNDLER_TRIVY_SEVERITY"] || "CRITICAL"
136
+ end
137
+
138
+ # Returns the list of severity levels to filter for during scanning.
139
+ #
140
+ # @return [Array<String>] Array of severity levels (e.g., ["CRITICAL", "HIGH"])
141
+ #
142
+ # @example Get configured severities
143
+ # config.severity_filter # => ["CRITICAL", "HIGH"]
144
+ def severity_filter
145
+ filter = file_value(%w[scanning severity_filter], [])
146
+ return filter if filter.is_a?(Array)
147
+
148
+ []
149
+ end
150
+
151
+ # Returns the configured timeout for Trivy scans in seconds.
152
+ #
153
+ # @return [Integer] Timeout in seconds (minimum 10, default 120)
154
+ #
155
+ # @example Get default timeout
156
+ # config.trivy_timeout # => 120
157
+ #
158
+ # @example Override via environment
159
+ # ENV["BUNDLER_TRIVY_TIMEOUT"] = "300"
160
+ # config.trivy_timeout # => 300
161
+ def trivy_timeout
162
+ ENV.fetch("BUNDLER_TRIVY_TIMEOUT",
163
+ file_value(%w[scanning timeout], 120)).to_i
164
+ end
165
+
166
+ # Returns the list of ignored CVEs from configuration.
167
+ #
168
+ # Each ignore entry should include an id, reason, and optionally an expires date.
169
+ #
170
+ # @return [Array<Hash>] Array of ignore entries
171
+ #
172
+ # @example Get ignored CVEs
173
+ # config.ignored_cves
174
+ # # => [{"id" => "CVE-2023-12345", "reason" => "False positive", "expires" => "2025-12-31"}]
175
+ def ignored_cves
176
+ file_value("ignores", [])
177
+ end
178
+
179
+ # Checks if a specific CVE is currently ignored.
180
+ #
181
+ # A CVE is considered ignored if it appears in the ignores list
182
+ # and either has no expiration date or the expiration date is in the future.
183
+ #
184
+ # @param cve_id [String] The CVE identifier (e.g., "CVE-2023-12345")
185
+ # @return [Boolean] true if the CVE should be ignored
186
+ #
187
+ # @example Check if CVE is ignored
188
+ # config.cve_ignored?("CVE-2023-12345") # => true/false
189
+ def cve_ignored?(cve_id)
190
+ ignored_cves.any? do |ignore_entry|
191
+ ignore_entry["id"] == cve_id && !expired?(ignore_entry)
192
+ end
193
+ end
194
+
195
+ # Detects if running in a CI/CD environment.
196
+ #
197
+ # Checks for common CI environment variables from popular CI platforms:
198
+ # - Generic CI
199
+ # - Travis CI
200
+ # - GitLab CI
201
+ # - GitHub Actions
202
+ # - Jenkins
203
+ #
204
+ # @return [Boolean] true if running in CI environment
205
+ #
206
+ # @example Check CI status
207
+ # config.ci_environment? # => false (locally), true (in CI)
208
+ def ci_environment?
209
+ ENV["CI"] == "true" ||
210
+ ENV["TRAVIS"] == "true" ||
211
+ ENV["GITLAB_CI"] == "true" ||
212
+ ENV["GITHUB_ACTIONS"] == "true" ||
213
+ !ENV["JENKINS_URL"].nil?
214
+ end
215
+
216
+ # Validates the configuration for correctness.
217
+ #
218
+ # Checks for:
219
+ # - Valid severity levels
220
+ # - Minimum timeout values
221
+ # - Valid expiration date formats
222
+ # - Required fields in ignore entries
223
+ #
224
+ # @raise [ConfigError] If validation fails
225
+ # @return [void]
226
+ def validate!
227
+ errors = []
228
+
229
+ # Validate severity filter
230
+ valid_severities = %w[CRITICAL HIGH MEDIUM LOW UNKNOWN]
231
+ invalid = severity_filter - valid_severities
232
+ errors << "Invalid severity levels: #{invalid.join(", ")}" unless invalid.empty?
233
+
234
+ # Validate timeout
235
+ errors << "Timeout must be at least 10 seconds" if trivy_timeout < 10
236
+
237
+ # Validate ignore expiration dates and required fields
238
+ ignored_cves.each do |ignore|
239
+ if ignore["expires"]
240
+ begin
241
+ Date.parse(ignore["expires"])
242
+ rescue ArgumentError
243
+ errors << "Invalid expiration date for #{ignore["id"]}: #{ignore["expires"]}"
244
+ end
245
+ end
246
+
247
+ errors << "Ignore entry for #{ignore["id"]} missing required 'reason' field" unless ignore["reason"]
248
+ end
249
+
250
+ return if errors.empty?
251
+
252
+ raise ConfigError, "Configuration errors:\n #{errors.join("\n ")}"
253
+ end
254
+
255
+ private
256
+
257
+ def env_bool(key, default)
258
+ value = ENV.fetch(key, nil)
259
+ return default if value.nil?
260
+
261
+ %w[true 1].include?(value)
262
+ end
263
+
264
+ def file_value(key_path, default)
265
+ keys = key_path.is_a?(Array) ? key_path : [key_path]
266
+ value = keys.reduce(@file_config) do |config, key|
267
+ config.is_a?(Hash) ? config[key] : nil
268
+ end
269
+ value.nil? ? default : value
270
+ end
271
+
272
+ def load_config_file
273
+ config_path = config_file_path
274
+
275
+ return {} unless File.exist?(config_path)
276
+
277
+ config = YAML.load_file(config_path) || {}
278
+
279
+ # Load global config and merge
280
+ global_config_path = File.expand_path("~/.bundle/trivy.yml")
281
+ if File.exist?(global_config_path)
282
+ global = YAML.load_file(global_config_path) || {}
283
+ config = deep_merge(global, config)
284
+ end
285
+
286
+ config
287
+ rescue StandardError => e
288
+ Bundler.ui.warn "Failed to load config: #{e.message}"
289
+ {}
290
+ end
291
+
292
+ def config_file_path
293
+ env = ENV.fetch("BUNDLER_TRIVY_ENV", nil)
294
+
295
+ if env && !env.empty?
296
+ env_config = File.join(project_root, ".bundler-trivy.#{env}.yml")
297
+ return env_config if File.exist?(env_config)
298
+ end
299
+
300
+ File.join(project_root, ".bundler-trivy.yml")
301
+ end
302
+
303
+ def project_root
304
+ Bundler.default_gemfile.dirname.to_s
305
+ end
306
+
307
+ def deep_merge(hash1, hash2)
308
+ hash1.merge(hash2) do |_, v1, v2|
309
+ v1.is_a?(Hash) && v2.is_a?(Hash) ? deep_merge(v1, v2) : v2
310
+ end
311
+ end
312
+
313
+ def expired?(ignore_entry)
314
+ return false unless ignore_entry["expires"]
315
+
316
+ expiration_date = Date.parse(ignore_entry["expires"])
317
+ Date.today > expiration_date
318
+ rescue ArgumentError
319
+ # Handle invalid date format, treat as not expired to be safe
320
+ false
321
+ end
322
+ end
323
+
324
+ class ConfigError < StandardError; end
325
+ end
326
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require_relative "scanner"
5
+ require_relative "reporter"
6
+ require_relative "config"
7
+
8
+ module Bundler
9
+ module Trivy
10
+ # Main plugin class that integrates Trivy security scanning into Bundler's workflow.
11
+ #
12
+ # This plugin hooks into Bundler's after-install-all event to automatically scan
13
+ # Ruby dependencies for known security vulnerabilities using Aqua Security's Trivy scanner.
14
+ #
15
+ # @example Running a scan after bundle install
16
+ # # Automatically triggered after: bundle install
17
+ # Bundler::Trivy::Plugin.scan_after_install
18
+ class Plugin
19
+ # Executes a Trivy security scan after bundle install completes.
20
+ #
21
+ # This method is called automatically by Bundler's plugin system after all gems
22
+ # have been installed. It performs the following steps:
23
+ # 1. Loads configuration from file and environment variables
24
+ # 2. Checks if scanning is enabled and Trivy is available
25
+ # 3. Runs Trivy scan on the Gemfile.lock
26
+ # 4. Displays results to the user via the Reporter
27
+ # 5. Exits with non-zero status if critical vulnerabilities found (based on config)
28
+ #
29
+ # @return [void]
30
+ #
31
+ # @example Manually triggering a scan
32
+ # Bundler::Trivy::Plugin.scan_after_install
33
+ def self.scan_after_install
34
+ config = Config.new
35
+
36
+ # Skip if explicitly disabled via config or environment variable
37
+ return if config.skip_scan?
38
+
39
+ # Verify Gemfile.lock exists before attempting to scan
40
+ unless File.exist?(Bundler.default_lockfile)
41
+ Bundler.ui.warn "Gemfile.lock not found, skipping scan"
42
+ return
43
+ end
44
+
45
+ lockfile_path = Bundler.default_lockfile.to_s
46
+ project_root = File.dirname(lockfile_path)
47
+
48
+ scanner = Scanner.new(project_root, config)
49
+
50
+ # Check if Trivy binary is available in PATH
51
+ unless scanner.trivy_available?
52
+ Bundler.ui.warn "Trivy not found, skipping scan"
53
+ Bundler.ui.info "Install: https://trivy.dev/docs/getting-started/installation/"
54
+ return
55
+ end
56
+
57
+ begin
58
+ # Execute the security scan
59
+ results = scanner.scan
60
+
61
+ # Display formatted results to the user
62
+ reporter = Reporter.new(results, config)
63
+ reporter.display
64
+
65
+ # Exit with error code if vulnerabilities exceed configured thresholds
66
+ handle_exit_code(results, config)
67
+ rescue StandardError => e
68
+ Bundler.ui.warn "Trivy scan failed: #{e.message}"
69
+ Bundler.ui.debug e.backtrace.join("\n") if ENV["DEBUG"]
70
+ end
71
+ end
72
+
73
+ # Handles exit code based on vulnerability severity and configuration.
74
+ #
75
+ # This method determines whether to exit with a non-zero status based on
76
+ # the scan results and configured failure thresholds. This is particularly
77
+ # useful in CI/CD pipelines where builds should fail on security issues.
78
+ #
79
+ # @param results [ScanResult] The scan results containing vulnerability information
80
+ # @param config [Config] Configuration object with fail_on settings
81
+ # @return [void]
82
+ #
83
+ # @example Failing on critical vulnerabilities
84
+ # config = Config.new
85
+ # # With config.fail_on_critical? == true and critical vulns found
86
+ # handle_exit_code(results, config)
87
+ # # => exits with status 1
88
+ def self.handle_exit_code(results, config)
89
+ if results.has_critical_vulnerabilities? && config.fail_on_critical?
90
+ Bundler.ui.error "CRITICAL vulnerabilities found. Install blocked."
91
+ exit 1
92
+ elsif results.has_vulnerabilities? && config.fail_on_any?
93
+ Bundler.ui.error "Vulnerabilities found. Install blocked."
94
+ exit 1
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Bundler
6
+ module Trivy
7
+ # Formats and displays security scan results to the terminal or JSON.
8
+ #
9
+ # Reporter handles presentation of scan results with multiple output modes:
10
+ # - Terminal output with color-coded severity levels
11
+ # - Compact mode for CI environments
12
+ # - JSON output for machine parsing
13
+ #
14
+ # Terminal output respects the NO_COLOR environment variable and TTY detection.
15
+ #
16
+ # @example Basic usage
17
+ # reporter = Reporter.new(scan_results, config)
18
+ # reporter.display
19
+ #
20
+ # @example JSON output
21
+ # config.json_output = true
22
+ # reporter = Reporter.new(scan_results, config)
23
+ # reporter.display # Outputs JSON to stdout
24
+ class Reporter
25
+ # Initializes a new Reporter instance.
26
+ #
27
+ # @param scan_result [ScanResult] The scan results to report
28
+ # @param config [Config, nil] Configuration object for output formatting
29
+ #
30
+ # @example Create reporter
31
+ # reporter = Reporter.new(scan_results, config)
32
+ def initialize(scan_result, config = nil)
33
+ @result = scan_result
34
+ @config = config || Config.new
35
+ end
36
+
37
+ # Displays the scan results according to configuration.
38
+ #
39
+ # Output format depends on configuration:
40
+ # - JSON mode: Outputs machine-readable JSON
41
+ # - No vulnerabilities: Displays success message
42
+ # - Vulnerabilities found: Displays formatted report
43
+ #
44
+ # @return [void]
45
+ #
46
+ # @example Display results
47
+ # reporter.display
48
+ def display
49
+ if @config.json_output?
50
+ display_json
51
+ return
52
+ end
53
+
54
+ if @result.vulnerabilities.empty?
55
+ display_clean_result
56
+ return
57
+ end
58
+
59
+ display_summary
60
+ display_vulnerabilities_by_severity
61
+ display_remediation_advice
62
+ end
63
+
64
+ private
65
+
66
+ def display_clean_result
67
+ ui.confirm "No vulnerabilities found by Trivy"
68
+ end
69
+
70
+ def display_summary
71
+ counts = @result.severity_counts
72
+
73
+ ui.warn "Trivy found #{@result.vulnerability_count} vulnerabilities"
74
+ puts
75
+
76
+ %w[CRITICAL HIGH MEDIUM LOW UNKNOWN].each do |severity|
77
+ count = counts[severity]
78
+ next if count.nil? || count.zero?
79
+
80
+ color = color_for_severity(severity)
81
+ puts " #{send(color, severity)}: #{count}"
82
+ end
83
+
84
+ puts
85
+ end
86
+
87
+ def display_vulnerabilities_by_severity
88
+ @result.by_severity.sort_by { |sev, _| severity_order(sev) }.each do |severity, vulns|
89
+ next if vulns.empty?
90
+ next if @config.compact_output? && !%w[CRITICAL HIGH].include?(severity)
91
+
92
+ puts "#{send(color_for_severity(severity), severity)} Vulnerabilities:"
93
+ puts
94
+
95
+ vulns.sort.each do |vuln|
96
+ display_vulnerability(vuln)
97
+ end
98
+
99
+ puts
100
+ end
101
+ end
102
+
103
+ def display_vulnerability(vuln)
104
+ puts " #{bold(vuln.package_name)} (#{vuln.installed_version})"
105
+ puts " #{vuln.id}: #{vuln.title}"
106
+
107
+ if vuln.fixable?
108
+ fixed_version = vuln.applicable_fixed_version || vuln.fixed_version
109
+ puts " Fixed in: #{green(fixed_version)}"
110
+ else
111
+ puts " #{yellow("No fix available yet")}"
112
+ end
113
+
114
+ puts " #{vuln.primary_url}" if vuln.primary_url
115
+ puts
116
+ end
117
+
118
+ def display_remediation_advice
119
+ fixable = @result.vulnerabilities.select(&:fixable?)
120
+
121
+ return if fixable.empty?
122
+
123
+ puts bold("Recommended Actions:")
124
+ puts
125
+
126
+ fixable.group_by(&:package_name).each do |pkg, vulns|
127
+ vulns.map(&:fixed_version).compact.max_by { |v| Gem::Version.new(v.split(",").first) }
128
+ puts " Update #{pkg}: bundle update #{pkg}"
129
+ end
130
+
131
+ puts
132
+ end
133
+
134
+ def display_json
135
+ output = {
136
+ vulnerabilities: @result.vulnerabilities.map do |vuln|
137
+ {
138
+ id: vuln.id,
139
+ package: vuln.package_name,
140
+ installed_version: vuln.installed_version,
141
+ fixed_version: vuln.fixed_version,
142
+ severity: vuln.severity,
143
+ title: vuln.title,
144
+ url: vuln.primary_url
145
+ }
146
+ end,
147
+ summary: {
148
+ total: @result.vulnerability_count,
149
+ by_severity: @result.severity_counts
150
+ }
151
+ }
152
+
153
+ puts JSON.pretty_generate(output)
154
+ end
155
+
156
+ def color_for_severity(severity)
157
+ case severity
158
+ when "CRITICAL" then :red
159
+ when "HIGH" then :red
160
+ when "MEDIUM" then :yellow
161
+ when "LOW" then :blue
162
+ else :default
163
+ end
164
+ end
165
+
166
+ def severity_order(severity)
167
+ { "CRITICAL" => 0, "HIGH" => 1, "MEDIUM" => 2, "LOW" => 3, "UNKNOWN" => 4 }[severity] || 99
168
+ end
169
+
170
+ # Color helpers
171
+ def colorize(text, color_code)
172
+ return text unless color_enabled?
173
+
174
+ "\e[#{color_code}m#{text}\e[0m"
175
+ end
176
+
177
+ def red(text)
178
+ colorize(text, 31)
179
+ end
180
+
181
+ def green(text)
182
+ colorize(text, 32)
183
+ end
184
+
185
+ def yellow(text)
186
+ colorize(text, 33)
187
+ end
188
+
189
+ def blue(text)
190
+ colorize(text, 34)
191
+ end
192
+
193
+ def bold(text)
194
+ colorize(text, 1)
195
+ end
196
+
197
+ def default(text)
198
+ text
199
+ end
200
+
201
+ def color_enabled?
202
+ return false if ENV["NO_COLOR"]
203
+ return false unless $stdout.tty?
204
+
205
+ true
206
+ end
207
+
208
+ def ui
209
+ Bundler.ui
210
+ end
211
+ end
212
+ end
213
+ end