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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE +21 -0
- data/README.md +480 -0
- data/lib/bundler/trivy/config.rb +326 -0
- data/lib/bundler/trivy/plugin.rb +99 -0
- data/lib/bundler/trivy/reporter.rb +213 -0
- data/lib/bundler/trivy/scan_result.rb +156 -0
- data/lib/bundler/trivy/scanner.rb +226 -0
- data/lib/bundler/trivy/version.rb +8 -0
- data/lib/bundler/trivy/vulnerability.rb +245 -0
- data/plugins.rb +11 -0
- metadata +115 -0
|
@@ -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
|