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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vulnerability"
4
+
5
+ module Bundler
6
+ module Trivy
7
+ # Represents the results of a Trivy security scan.
8
+ #
9
+ # ScanResult wraps the raw JSON output from Trivy and provides convenient
10
+ # methods for accessing vulnerability information, filtering by severity,
11
+ # grouping vulnerabilities, and generating summaries. It automatically
12
+ # filters out ignored CVEs based on configuration.
13
+ #
14
+ # @example Basic usage
15
+ # results = scanner.scan
16
+ # puts "Found #{results.vulnerability_count} vulnerabilities"
17
+ # puts "Critical: #{results.critical_vulnerabilities.count}"
18
+ #
19
+ # @example Filtering by severity
20
+ # critical_vulns = results.critical_vulnerabilities
21
+ # critical_vulns.each do |vuln|
22
+ # puts "#{vuln.id}: #{vuln.package_name}"
23
+ # end
24
+ #
25
+ # @example Grouping vulnerabilities
26
+ # results.by_severity.each do |severity, vulns|
27
+ # puts "#{severity}: #{vulns.count} vulnerabilities"
28
+ # end
29
+ class ScanResult
30
+ # @return [Hash] Raw Trivy scan data
31
+ attr_reader :data
32
+
33
+ # Initializes a new ScanResult instance.
34
+ #
35
+ # @param data [Hash] Raw JSON data from Trivy scan
36
+ # @param config [Config, nil] Configuration object for filtering ignored CVEs
37
+ #
38
+ # @example Create from scan data
39
+ # data = JSON.parse(trivy_output)
40
+ # results = ScanResult.new(data, config)
41
+ def initialize(data, config = nil)
42
+ @data = data || {}
43
+ @config = config
44
+ end
45
+
46
+ # Returns all vulnerabilities found in the scan.
47
+ #
48
+ # Vulnerabilities are wrapped in Vulnerability objects for convenient access.
49
+ # CVEs marked as ignored in the configuration are automatically filtered out.
50
+ #
51
+ # @return [Array<Vulnerability>] Array of vulnerability objects
52
+ #
53
+ # @example Get all vulnerabilities
54
+ # results.vulnerabilities.each do |vuln|
55
+ # puts "#{vuln.severity}: #{vuln.package_name} - #{vuln.id}"
56
+ # end
57
+ def vulnerabilities
58
+ results = @data["Results"] || []
59
+
60
+ results.flat_map do |result|
61
+ vulns = result["Vulnerabilities"]
62
+ next [] if vulns.nil? || vulns.empty?
63
+
64
+ vulns.map { |v| Vulnerability.new(v, result["Target"]) }
65
+ end.compact.reject { |v| ignored?(v) }
66
+ end
67
+
68
+ # Groups vulnerabilities by severity level.
69
+ #
70
+ # @return [Hash<String, Array<Vulnerability>>] Hash with severity levels as keys
71
+ #
72
+ # @example Group by severity
73
+ # results.by_severity.each do |severity, vulns|
74
+ # puts "#{severity}: #{vulns.count}"
75
+ # end
76
+ def by_severity
77
+ vulnerabilities.group_by(&:severity)
78
+ end
79
+
80
+ # Returns only CRITICAL severity vulnerabilities.
81
+ #
82
+ # @return [Array<Vulnerability>] Array of critical vulnerabilities
83
+ #
84
+ # @example Get critical vulnerabilities
85
+ # critical = results.critical_vulnerabilities
86
+ # puts "#{critical.count} critical issues found"
87
+ def critical_vulnerabilities
88
+ vulnerabilities.select(&:critical?)
89
+ end
90
+
91
+ # Returns only HIGH severity vulnerabilities.
92
+ #
93
+ # @return [Array<Vulnerability>] Array of high severity vulnerabilities
94
+ def high_vulnerabilities
95
+ vulnerabilities.select(&:high?)
96
+ end
97
+
98
+ # Checks if any vulnerabilities were found.
99
+ #
100
+ # @return [Boolean] true if vulnerabilities exist
101
+ #
102
+ # @example Check for vulnerabilities
103
+ # if results.has_vulnerabilities?
104
+ # puts "Security issues detected!"
105
+ # end
106
+ def has_vulnerabilities?
107
+ !vulnerabilities.empty?
108
+ end
109
+
110
+ # Checks if any CRITICAL severity vulnerabilities were found.
111
+ #
112
+ # This is useful for implementing fail-on-critical policies.
113
+ #
114
+ # @return [Boolean] true if critical vulnerabilities exist
115
+ #
116
+ # @example Fail on critical
117
+ # exit 1 if results.has_critical_vulnerabilities?
118
+ def has_critical_vulnerabilities?
119
+ !critical_vulnerabilities.empty?
120
+ end
121
+
122
+ # Returns the total count of all vulnerabilities.
123
+ #
124
+ # @return [Integer] Total vulnerability count
125
+ #
126
+ # @example Get total count
127
+ # puts "Total: #{results.vulnerability_count}"
128
+ def vulnerability_count
129
+ vulnerabilities.size
130
+ end
131
+
132
+ # Returns vulnerability counts grouped by severity level.
133
+ #
134
+ # @return [Hash<String, Integer>] Hash with severity levels as keys and counts as values
135
+ #
136
+ # @example Get severity breakdown
137
+ # counts = results.severity_counts
138
+ # # => {"CRITICAL" => 2, "HIGH" => 5, "MEDIUM" => 10}
139
+ def severity_counts
140
+ by_severity.transform_values(&:size)
141
+ end
142
+
143
+ private
144
+
145
+ # Checks if a vulnerability should be ignored based on configuration.
146
+ #
147
+ # @param vulnerability [Vulnerability] The vulnerability to check
148
+ # @return [Boolean] true if the vulnerability should be ignored
149
+ def ignored?(vulnerability)
150
+ return false unless @config
151
+
152
+ @config.cve_ignored?(vulnerability.id)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "timeout"
6
+ require_relative "scan_result"
7
+
8
+ module Bundler
9
+ module Trivy
10
+ # Executes Trivy security scans on Ruby projects.
11
+ #
12
+ # The Scanner class is responsible for invoking the Trivy command-line tool,
13
+ # parsing its JSON output, and returning structured results. It handles
14
+ # timeout management, error conditions, and severity filtering.
15
+ #
16
+ # @example Basic usage
17
+ # config = Config.new
18
+ # scanner = Scanner.new("/path/to/project", config)
19
+ # if scanner.trivy_available?
20
+ # results = scanner.scan
21
+ # puts "Found #{results.vulnerability_count} vulnerabilities"
22
+ # end
23
+ class Scanner
24
+ # @return [String] the root directory of the project being scanned
25
+ attr_reader :project_root
26
+
27
+ # @return [Config] the configuration object for the scanner
28
+ attr_reader :config
29
+
30
+ # Initializes a new Scanner instance.
31
+ #
32
+ # @param project_root [String] The root directory of the project to scan
33
+ # @param config [Config, nil] Configuration object. If nil, creates a new Config with defaults
34
+ #
35
+ # @example Create scanner with default config
36
+ # scanner = Scanner.new("/path/to/project")
37
+ #
38
+ # @example Create scanner with custom config
39
+ # config = Config.new
40
+ # scanner = Scanner.new("/path/to/project", config)
41
+ def initialize(project_root, config = nil)
42
+ @project_root = project_root
43
+ @config = config || Config.new
44
+ end
45
+
46
+ # Executes a Trivy security scan on the project.
47
+ #
48
+ # This method runs the Trivy CLI tool with appropriate arguments, captures
49
+ # its JSON output, and parses it into a ScanResult object. The scan checks
50
+ # for known vulnerabilities in Ruby dependencies listed in Gemfile.lock.
51
+ #
52
+ # @return [ScanResult] Parsed scan results containing vulnerability information
53
+ #
54
+ # @raise [ScanError] If Trivy execution fails with exit code > 1
55
+ # @raise [ScanError] If Trivy output is not valid JSON
56
+ # @raise [ScanError] If the scan exceeds the configured timeout
57
+ #
58
+ # @example Scanning a project
59
+ # scanner = Scanner.new("/path/to/project")
60
+ # begin
61
+ # results = scanner.scan
62
+ # puts "Scan completed: #{results.vulnerability_count} issues found"
63
+ # rescue ScanError => e
64
+ # puts "Scan failed: #{e.message}"
65
+ # end
66
+ def scan
67
+ args = build_trivy_args
68
+ timeout = @config.trivy_timeout
69
+
70
+ # Execute Trivy with Open3 for robust command execution
71
+ stdout, stderr, status = Open3.capture3(
72
+ *args,
73
+ timeout: timeout
74
+ )
75
+
76
+ # Handle Trivy exit codes:
77
+ # 0 = success, no vulnerabilities
78
+ # 1 = success, vulnerabilities found
79
+ # >1 = error condition
80
+ raise ScanError, build_error_message(status.exitstatus, stderr) if status.exitstatus > 1
81
+
82
+ # Parse JSON output into structured data
83
+ data = parse_json(stdout)
84
+ ScanResult.new(data, @config)
85
+ rescue JSON::ParserError => e
86
+ raise ScanError, build_json_error_message(e, stdout)
87
+ rescue Timeout::Error
88
+ raise ScanError, build_timeout_error_message(timeout)
89
+ end
90
+
91
+ # Checks if the Trivy binary is available in the system PATH.
92
+ #
93
+ # This method performs a defensive check to verify that Trivy is installed
94
+ # and accessible before attempting to run a scan. It prevents cryptic errors
95
+ # by failing early with a clear message.
96
+ #
97
+ # @return [Boolean] true if Trivy is available, false otherwise
98
+ #
99
+ # @example Check availability before scanning
100
+ # scanner = Scanner.new("/path/to/project")
101
+ # if scanner.trivy_available?
102
+ # results = scanner.scan
103
+ # else
104
+ # puts "Please install Trivy first"
105
+ # end
106
+ def trivy_available?
107
+ system("which trivy > /dev/null 2>&1")
108
+ end
109
+
110
+ private
111
+
112
+ # Builds the command-line arguments for the Trivy invocation.
113
+ #
114
+ # @return [Array<String>] Array of command arguments
115
+ def build_trivy_args
116
+ args = [
117
+ "trivy", "fs",
118
+ "--scanners", "vuln",
119
+ "--format", "json",
120
+ "--quiet"
121
+ ]
122
+
123
+ # Add severity filtering if configured
124
+ severity_filter = @config.severity_filter
125
+ args += ["--severity", severity_filter.join(",")] if severity_filter && severity_filter.any?
126
+
127
+ args << @project_root
128
+ args
129
+ end
130
+
131
+ # Parses Trivy JSON output into a Ruby hash.
132
+ #
133
+ # @param json_string [String] JSON string from Trivy
134
+ # @return [Hash] Parsed JSON data
135
+ def parse_json(json_string)
136
+ return {} if json_string.empty?
137
+
138
+ JSON.parse(json_string)
139
+ end
140
+
141
+ # Builds a detailed error message for Trivy execution failures.
142
+ #
143
+ # @param exit_code [Integer] The exit code from Trivy
144
+ # @param stderr [String] Standard error output from Trivy
145
+ # @return [String] Formatted error message with troubleshooting guidance
146
+ def build_error_message(exit_code, stderr)
147
+ <<~ERROR
148
+ Trivy scan failed with exit code #{exit_code}
149
+
150
+ Error output:
151
+ #{stderr}
152
+
153
+ Possible causes:
154
+ - Trivy database is outdated or corrupted
155
+ - Network connectivity issues during database update
156
+ - Invalid or corrupted Gemfile.lock
157
+ - Insufficient disk space
158
+
159
+ Troubleshooting steps:
160
+ 1. Update Trivy database: trivy image --download-db-only
161
+ 2. Check network connectivity
162
+ 3. Verify Gemfile.lock is valid: bundle check
163
+ 4. Check disk space: df -h
164
+
165
+ For more information, visit: https://trivy.dev/docs/
166
+ ERROR
167
+ end
168
+
169
+ # Builds an error message for JSON parsing failures.
170
+ #
171
+ # @param error [JSON::ParserError] The JSON parsing error
172
+ # @param output [String] The output that failed to parse
173
+ # @return [String] Formatted error message
174
+ def build_json_error_message(error, output)
175
+ <<~ERROR
176
+ Invalid JSON output from Trivy: #{error.message}
177
+
178
+ This may indicate:
179
+ - Trivy version incompatibility (requires Trivy v0.40.0 or later)
180
+ - Corrupted output due to interrupted execution
181
+ - System error messages mixed with JSON output
182
+
183
+ Output received:
184
+ #{output.slice(0, 500)}#{"...\n[truncated]" if output.length > 500}
185
+
186
+ Troubleshooting:
187
+ 1. Check Trivy version: trivy --version
188
+ 2. Update Trivy to latest version
189
+ 3. Run Trivy manually: trivy fs --format json #{@project_root}
190
+ ERROR
191
+ end
192
+
193
+ # Builds an error message for timeout conditions.
194
+ #
195
+ # @param timeout [Integer] The timeout value that was exceeded
196
+ # @return [String] Formatted error message
197
+ def build_timeout_error_message(timeout)
198
+ <<~ERROR
199
+ Trivy scan timed out after #{timeout} seconds
200
+
201
+ This may occur when:
202
+ - Scanning a very large project with many dependencies
203
+ - Slow network connection during database updates
204
+ - System resource constraints
205
+
206
+ Solutions:
207
+ 1. Increase timeout in config: scanning.timeout: #{timeout * 2}
208
+ 2. Update Trivy database before scanning: trivy image --download-db-only
209
+ 3. Check system resources: top or htop
210
+
211
+ Current timeout: #{timeout} seconds
212
+ Suggested timeout for large projects: 300+ seconds
213
+ ERROR
214
+ end
215
+ end
216
+
217
+ # Exception raised when a Trivy scan fails.
218
+ #
219
+ # This error is raised for various scan failures including:
220
+ # - Trivy execution errors (exit code > 1)
221
+ # - Invalid JSON output
222
+ # - Timeout conditions
223
+ # - Database update failures
224
+ class ScanError < StandardError; end
225
+ end
226
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Trivy
5
+ # Version constant for bundler-trivy-plugin
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Trivy
5
+ # Represents a single security vulnerability found in a Ruby gem.
6
+ #
7
+ # Vulnerability wraps vulnerability data from Trivy and provides convenient
8
+ # accessors for all vulnerability attributes. It includes helper methods for
9
+ # severity checking, version comparison, and determining if a fix is available.
10
+ #
11
+ # Vulnerabilities are sortable by severity (critical first) and then by package name.
12
+ #
13
+ # @example Basic usage
14
+ # vuln = Vulnerability.new(trivy_data, "Gemfile.lock")
15
+ # puts "#{vuln.id}: #{vuln.package_name} (#{vuln.severity})"
16
+ # puts "Fixed in: #{vuln.fixed_version}" if vuln.fixable?
17
+ #
18
+ # @example Checking severity
19
+ # if vuln.critical?
20
+ # puts "CRITICAL: Immediate action required!"
21
+ # end
22
+ #
23
+ # @example Sorting vulnerabilities
24
+ # vulns.sort.each { |v| puts v.package_name }
25
+ class Vulnerability
26
+ # Severity ranking for sorting (lower number = higher priority)
27
+ SEVERITY_ORDER = {
28
+ "CRITICAL" => 0,
29
+ "HIGH" => 1,
30
+ "MEDIUM" => 2,
31
+ "LOW" => 3,
32
+ "UNKNOWN" => 4
33
+ }.freeze
34
+
35
+ # @return [Hash] Raw vulnerability data from Trivy
36
+ attr_reader :data
37
+
38
+ # @return [String] Target file where vulnerability was found (e.g., "Gemfile.lock")
39
+ attr_reader :target
40
+
41
+ # Initializes a new Vulnerability instance.
42
+ #
43
+ # @param data [Hash] Raw vulnerability data from Trivy JSON output
44
+ # @param target [String] Target file path where the vulnerability was found
45
+ #
46
+ # @example Create from Trivy data
47
+ # vuln = Vulnerability.new(data, "Gemfile.lock")
48
+ def initialize(data, target)
49
+ @data = data
50
+ @target = target
51
+ end
52
+
53
+ # Returns the CVE or vulnerability identifier.
54
+ #
55
+ # @return [String] CVE ID (e.g., "CVE-2023-12345") or other vulnerability ID
56
+ #
57
+ # @example Get vulnerability ID
58
+ # vuln.id # => "CVE-2023-12345"
59
+ def id
60
+ @data["VulnerabilityID"]
61
+ end
62
+
63
+ # Returns the name of the vulnerable package.
64
+ #
65
+ # @return [String] Package/gem name
66
+ #
67
+ # @example Get package name
68
+ # vuln.package_name # => "rails"
69
+ def package_name
70
+ @data["PkgName"]
71
+ end
72
+
73
+ # Returns the currently installed version of the vulnerable package.
74
+ #
75
+ # @return [String] Installed version number
76
+ def installed_version
77
+ @data["InstalledVersion"]
78
+ end
79
+
80
+ # Returns the version(s) that fix this vulnerability.
81
+ #
82
+ # May contain multiple versions separated by commas (e.g., "2.1.4, 3.0.1").
83
+ #
84
+ # @return [String, nil] Fixed version string, or nil if no fix available
85
+ def fixed_version
86
+ @data["FixedVersion"]
87
+ end
88
+
89
+ # Returns an array of all available fixed versions.
90
+ #
91
+ # Parses the fixed_version string and splits on commas.
92
+ #
93
+ # @return [Array<String>] Array of fixed version strings, empty if no fix available
94
+ #
95
+ # @example Get all fixed versions
96
+ # vuln.fixed_versions # => ["2.1.4", "3.0.1", "4.0.0"]
97
+ def fixed_versions
98
+ return [] unless fixable?
99
+
100
+ fixed_version.split(",").map(&:strip)
101
+ end
102
+
103
+ # Returns the most applicable fixed version for the installed version.
104
+ #
105
+ # Attempts to find a fixed version in the same major.minor series as the
106
+ # installed version. For example, if version 2.1.0 is installed and fixes
107
+ # exist for 2.1.4 and 3.0.1, this returns 2.1.4.
108
+ #
109
+ # @return [String, nil] Most applicable fixed version, or nil if no fix available
110
+ #
111
+ # @example Get applicable fix
112
+ # # If installed: 2.1.0, fixes: ["2.1.4", "3.0.1"]
113
+ # vuln.applicable_fixed_version # => "2.1.4"
114
+ def applicable_fixed_version
115
+ return nil unless fixable?
116
+
117
+ begin
118
+ installed = Gem::Version.new(installed_version)
119
+
120
+ fixed_versions.find do |v|
121
+ fixed = Gem::Version.new(v)
122
+ fixed.segments[0..1] == installed.segments[0..1]
123
+ end
124
+ rescue ArgumentError
125
+ # If version parsing fails, return the first fixed version
126
+ fixed_versions.first
127
+ end
128
+ end
129
+
130
+ # Returns the severity level of this vulnerability.
131
+ #
132
+ # @return [String] Severity level: "CRITICAL", "HIGH", "MEDIUM", "LOW", or "UNKNOWN"
133
+ #
134
+ # @example Get severity
135
+ # vuln.severity # => "CRITICAL"
136
+ def severity
137
+ sev = @data["Severity"]
138
+ return "UNKNOWN" if sev.nil? || sev.empty?
139
+
140
+ sev
141
+ end
142
+
143
+ # Returns a human-readable title for the vulnerability.
144
+ #
145
+ # @return [String] Vulnerability title or default message
146
+ def title
147
+ @data["Title"] || "No title available"
148
+ end
149
+
150
+ # Returns a detailed description of the vulnerability.
151
+ #
152
+ # @return [String] Vulnerability description or default message
153
+ def description
154
+ @data["Description"] || "No description available"
155
+ end
156
+
157
+ # Returns the primary URL for more information about the vulnerability.
158
+ #
159
+ # @return [String, nil] URL to vulnerability details (often NVD or GitHub Advisory)
160
+ def primary_url
161
+ @data["PrimaryURL"]
162
+ end
163
+
164
+ # Returns additional reference URLs for this vulnerability.
165
+ #
166
+ # @return [Array<String>] Array of reference URLs
167
+ def references
168
+ @data["References"] || []
169
+ end
170
+
171
+ # Returns the date this vulnerability was published.
172
+ #
173
+ # @return [String, nil] Publication date in ISO 8601 format
174
+ def published_date
175
+ @data["PublishedDate"]
176
+ end
177
+
178
+ # Checks if this is a CRITICAL severity vulnerability.
179
+ #
180
+ # @return [Boolean] true if severity is CRITICAL
181
+ def critical?
182
+ severity == "CRITICAL"
183
+ end
184
+
185
+ # Checks if this is a HIGH severity vulnerability.
186
+ #
187
+ # @return [Boolean] true if severity is HIGH
188
+ def high?
189
+ severity == "HIGH"
190
+ end
191
+
192
+ # Checks if this is a MEDIUM severity vulnerability.
193
+ #
194
+ # @return [Boolean] true if severity is MEDIUM
195
+ def medium?
196
+ severity == "MEDIUM"
197
+ end
198
+
199
+ # Checks if this is a LOW severity vulnerability.
200
+ #
201
+ # @return [Boolean] true if severity is LOW
202
+ def low?
203
+ severity == "LOW"
204
+ end
205
+
206
+ # Returns the numeric rank for this severity level.
207
+ #
208
+ # Lower numbers indicate higher severity. Used for sorting.
209
+ #
210
+ # @return [Integer] Severity rank (0-4, or 999 for unknown)
211
+ def severity_rank
212
+ SEVERITY_ORDER[severity] || 999
213
+ end
214
+
215
+ # Compares this vulnerability with another for sorting.
216
+ #
217
+ # Vulnerabilities are sorted by severity (critical first), then by package name.
218
+ #
219
+ # @param other [Vulnerability] Another vulnerability to compare with
220
+ # @return [Integer] -1, 0, or 1 for sorting
221
+ #
222
+ # @example Sort vulnerabilities
223
+ # vulnerabilities.sort # Critical vulnerabilities first
224
+ def <=>(other)
225
+ # Sort by severity first (critical first), then by package name
226
+ comparison = severity_rank <=> other.severity_rank
227
+ comparison.zero? ? package_name <=> other.package_name : comparison
228
+ end
229
+
230
+ # Checks if a fix is available for this vulnerability.
231
+ #
232
+ # @return [Boolean] true if a fixed version exists
233
+ #
234
+ # @example Check if fixable
235
+ # if vuln.fixable?
236
+ # puts "Update to #{vuln.fixed_version}"
237
+ # else
238
+ # puts "No fix available yet"
239
+ # end
240
+ def fixable?
241
+ !fixed_version.nil? && !fixed_version.empty?
242
+ end
243
+ end
244
+ end
245
+ end
data/plugins.rb ADDED
@@ -0,0 +1,11 @@
1
+ # plugins.rb
2
+ require_relative "lib/bundler/trivy/plugin"
3
+
4
+ Bundler::Plugin.register("bundler-trivy-plugin") do |plugin|
5
+ # Registers the plugin with Bundler and declares a hook.
6
+ # The 'after-install-all' hook ensures our scanning logic runs
7
+ # immediately after all gems have been installed.
8
+ plugin.hook("after-install-all") do
9
+ Bundler::Trivy::Plugin.scan_after_install
10
+ end
11
+ end