git-pkgs 0.6.2 → 0.7.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +28 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +15 -0
  5. data/Dockerfile +18 -0
  6. data/Formula/git-pkgs.rb +28 -0
  7. data/README.md +36 -4
  8. data/lib/git/pkgs/analyzer.rb +141 -9
  9. data/lib/git/pkgs/cli.rb +16 -6
  10. data/lib/git/pkgs/commands/blame.rb +0 -18
  11. data/lib/git/pkgs/commands/diff.rb +122 -5
  12. data/lib/git/pkgs/commands/diff_driver.rb +24 -4
  13. data/lib/git/pkgs/commands/init.rb +5 -0
  14. data/lib/git/pkgs/commands/list.rb +60 -15
  15. data/lib/git/pkgs/commands/show.rb +126 -3
  16. data/lib/git/pkgs/commands/stale.rb +6 -2
  17. data/lib/git/pkgs/commands/update.rb +3 -0
  18. data/lib/git/pkgs/commands/vulns/base.rb +354 -0
  19. data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
  20. data/lib/git/pkgs/commands/vulns/diff.rb +172 -0
  21. data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
  22. data/lib/git/pkgs/commands/vulns/history.rb +345 -0
  23. data/lib/git/pkgs/commands/vulns/log.rb +218 -0
  24. data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
  25. data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
  26. data/lib/git/pkgs/commands/vulns/show.rb +216 -0
  27. data/lib/git/pkgs/commands/vulns/sync.rb +108 -0
  28. data/lib/git/pkgs/commands/vulns.rb +50 -0
  29. data/lib/git/pkgs/config.rb +8 -1
  30. data/lib/git/pkgs/database.rb +135 -5
  31. data/lib/git/pkgs/ecosystems.rb +83 -0
  32. data/lib/git/pkgs/models/package.rb +54 -0
  33. data/lib/git/pkgs/models/vulnerability.rb +300 -0
  34. data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
  35. data/lib/git/pkgs/osv_client.rb +151 -0
  36. data/lib/git/pkgs/output.rb +22 -0
  37. data/lib/git/pkgs/version.rb +1 -1
  38. data/lib/git/pkgs.rb +6 -0
  39. metadata +66 -4
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ module Base
8
+ include Output
9
+
10
+ SEVERITY_ORDER = { "critical" => 0, "high" => 1, "medium" => 2, "low" => 3, nil => 4 }.freeze
11
+
12
+ def compute_dependencies_at_commit(target_commit, repo)
13
+ branch_name = @options[:branch] || repo.default_branch
14
+ branch = Models::Branch.first(name: branch_name)
15
+ return [] unless branch
16
+
17
+ snapshot_commit = branch.commits_dataset
18
+ .join(:dependency_snapshots, commit_id: :id)
19
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
20
+ .order(Sequel.desc(Sequel[:commits][:committed_at]))
21
+ .distinct
22
+ .first
23
+
24
+ deps = {}
25
+ if snapshot_commit
26
+ snapshot_commit.dependency_snapshots.each do |s|
27
+ key = [s.manifest.path, s.name]
28
+ deps[key] = {
29
+ manifest_path: s.manifest.path,
30
+ manifest_kind: s.manifest.kind,
31
+ name: s.name,
32
+ ecosystem: s.ecosystem,
33
+ requirement: s.requirement,
34
+ dependency_type: s.dependency_type
35
+ }
36
+ end
37
+ end
38
+
39
+ if snapshot_commit && snapshot_commit.id != target_commit.id
40
+ commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id])
41
+ changes = Models::DependencyChange
42
+ .join(:commits, id: :commit_id)
43
+ .where(Sequel[:commits][:id] => commit_ids)
44
+ .where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at }
45
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
46
+ .order(Sequel[:commits][:committed_at])
47
+ .eager(:manifest)
48
+ .all
49
+
50
+ changes.each do |change|
51
+ key = [change.manifest.path, change.name]
52
+ case change.change_type
53
+ when "added", "modified"
54
+ deps[key] = {
55
+ manifest_path: change.manifest.path,
56
+ manifest_kind: change.manifest.kind,
57
+ name: change.name,
58
+ ecosystem: change.ecosystem,
59
+ requirement: change.requirement,
60
+ dependency_type: change.dependency_type
61
+ }
62
+ when "removed"
63
+ deps.delete(key)
64
+ end
65
+ end
66
+ end
67
+
68
+ deps.values
69
+ end
70
+
71
+ def scan_for_vulnerabilities(deps)
72
+ vulns = []
73
+
74
+ # Pair manifests with lockfiles by directory and ecosystem
75
+ # Prefer lockfile versions over manifest constraints
76
+ paired = Analyzer.pair_manifests_with_lockfiles(deps)
77
+
78
+ # Deduplicate across directories by ecosystem+name
79
+ deduped = {}
80
+ paired.each do |dep|
81
+ osv_ecosystem = Ecosystems.to_osv(dep[:ecosystem])
82
+ next unless osv_ecosystem
83
+
84
+ key = [osv_ecosystem, dep[:name]]
85
+ existing = deduped[key]
86
+
87
+ # Prefer more specific versions: actual version > constraint
88
+ if existing.nil? || more_specific_version?(dep[:requirement], existing[:version])
89
+ deduped[key] = {
90
+ ecosystem: osv_ecosystem,
91
+ name: dep[:name],
92
+ version: dep[:requirement],
93
+ original: dep
94
+ }
95
+ end
96
+ end
97
+
98
+ packages = deduped.values
99
+
100
+ packages_needing_sync = packages.reject do |pkg|
101
+ package_synced?(pkg[:ecosystem], pkg[:name])
102
+ end
103
+
104
+ sync_packages(packages_needing_sync) if packages_needing_sync.any?
105
+
106
+ packages.each do |pkg|
107
+ vuln_pkgs = Models::VulnerabilityPackage
108
+ .for_package(pkg[:ecosystem], pkg[:name])
109
+ .eager(:vulnerability)
110
+ .all
111
+
112
+ vuln_pkgs.each do |vp|
113
+ next unless vp.affects_version?(pkg[:version])
114
+ next if vp.vulnerability&.withdrawn?
115
+
116
+ vulns << {
117
+ id: vp.vulnerability_id,
118
+ severity: vp.vulnerability&.severity,
119
+ cvss_score: vp.vulnerability&.cvss_score,
120
+ package_name: pkg[:name],
121
+ package_version: pkg[:version],
122
+ ecosystem: pkg[:original][:ecosystem],
123
+ manifest_path: pkg[:original][:manifest_path],
124
+ summary: vp.vulnerability&.summary,
125
+ fixed_versions: vp.fixed_versions_list.first
126
+ }
127
+ end
128
+ end
129
+
130
+ vulns
131
+ end
132
+
133
+ def package_synced?(ecosystem, name)
134
+ purl = Ecosystems.generate_purl(Ecosystems.from_osv(ecosystem), name)
135
+ return false unless purl
136
+
137
+ pkg = Models::Package.first(purl: purl)
138
+ pkg && !pkg.needs_vuln_sync?
139
+ end
140
+
141
+ def sync_packages(packages)
142
+ return if packages.empty?
143
+
144
+ client = OsvClient.new
145
+ results = begin
146
+ client.query_batch(packages.map { |p| p.slice(:ecosystem, :name, :version) })
147
+ rescue OsvClient::ApiError => e
148
+ error "Failed to query OSV API: #{e.message}"
149
+ end
150
+
151
+ fetch_vulnerability_details(client, results)
152
+
153
+ packages.each do |pkg|
154
+ bib_ecosystem = Ecosystems.from_osv(pkg[:ecosystem])
155
+ purl = Ecosystems.generate_purl(bib_ecosystem, pkg[:name])
156
+ mark_package_synced(purl, bib_ecosystem, pkg[:name]) if purl
157
+ end
158
+ end
159
+
160
+ def ensure_vulns_synced
161
+ packages = Models::DependencyChange
162
+ .select(:ecosystem, :name)
163
+ .select_group(:ecosystem, :name)
164
+ .all
165
+
166
+ packages_to_sync = packages.select do |pkg|
167
+ next false unless Ecosystems.supported?(pkg.ecosystem)
168
+
169
+ purl = Ecosystems.generate_purl(pkg.ecosystem, pkg.name)
170
+ next false unless purl
171
+
172
+ db_pkg = Models::Package.first(purl: purl)
173
+ !db_pkg || db_pkg.needs_vuln_sync?
174
+ end
175
+
176
+ return if packages_to_sync.empty?
177
+
178
+ client = OsvClient.new
179
+ packages_to_sync.each_slice(100) do |batch|
180
+ queries = batch.map do |pkg|
181
+ osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
182
+ next unless osv_ecosystem
183
+
184
+ { ecosystem: osv_ecosystem, name: pkg.name }
185
+ end.compact
186
+
187
+ results = client.query_batch(queries)
188
+ fetch_vulnerability_details(client, results)
189
+
190
+ batch.each do |pkg|
191
+ purl = Ecosystems.generate_purl(pkg.ecosystem, pkg.name)
192
+ mark_package_synced(purl, pkg.ecosystem, pkg.name) if purl
193
+ end
194
+ end
195
+ end
196
+
197
+ def fetch_vulnerability_details(client, results)
198
+ vuln_ids = results.flatten.map { |v| v["id"] }.uniq
199
+ vuln_ids.each do |vuln_id|
200
+ next if Models::Vulnerability.first(id: vuln_id)&.vulnerability_packages&.any?
201
+
202
+ begin
203
+ full_vuln = client.get_vulnerability(vuln_id)
204
+ Models::Vulnerability.from_osv(full_vuln)
205
+ rescue OsvClient::ApiError => e
206
+ $stderr.puts "Warning: Failed to fetch vulnerability #{vuln_id}: #{e.message}" unless Git::Pkgs.quiet
207
+ end
208
+ end
209
+ end
210
+
211
+ def mark_package_synced(purl, ecosystem, name)
212
+ Models::Package.update_or_create(
213
+ { purl: purl },
214
+ { ecosystem: ecosystem, name: name, vulns_synced_at: Time.now }
215
+ )
216
+ end
217
+
218
+ def format_commit_info(commit)
219
+ return nil unless commit
220
+
221
+ {
222
+ sha: commit.sha[0, 7],
223
+ full_sha: commit.sha,
224
+ date: commit.committed_at&.strftime("%Y-%m-%d"),
225
+ author: best_author(commit),
226
+ message: commit.message&.lines&.first&.strip&.slice(0, 50)
227
+ }
228
+ end
229
+
230
+ def parse_date(date_str)
231
+ Time.parse(date_str)
232
+ rescue ArgumentError
233
+ error "Invalid date format: #{date_str}"
234
+ end
235
+
236
+ def find_introducing_change(ecosystem, package_name, vuln_pkg, up_to_commit)
237
+ changes = Models::DependencyChange
238
+ .join(:commits, id: :commit_id)
239
+ .where(ecosystem: ecosystem, name: package_name)
240
+ .where(change_type: %w[added modified])
241
+ .where { Sequel[:commits][:committed_at] <= up_to_commit.committed_at }
242
+ .order(Sequel[:commits][:committed_at])
243
+ .eager(:commit)
244
+ .all
245
+
246
+ changes.each do |change|
247
+ next unless vuln_pkg.affects_version?(change.requirement)
248
+ return change
249
+ end
250
+
251
+ nil
252
+ end
253
+
254
+ def find_fixing_change(ecosystem, package_name, vuln_pkg, up_to_commit, after_time)
255
+ return nil unless after_time
256
+
257
+ changes = Models::DependencyChange
258
+ .join(:commits, id: :commit_id)
259
+ .where(ecosystem: ecosystem, name: package_name)
260
+ .where(change_type: %w[modified removed])
261
+ .where { Sequel[:commits][:committed_at] > after_time }
262
+ .where { Sequel[:commits][:committed_at] <= up_to_commit.committed_at }
263
+ .order(Sequel[:commits][:committed_at])
264
+ .eager(:commit)
265
+ .all
266
+
267
+ find_first_fixing_change(changes, vuln_pkg)
268
+ end
269
+
270
+ def find_first_fixing_change(changes, vuln_pkg)
271
+ changes.each do |change|
272
+ if change.change_type == "removed"
273
+ return change
274
+ elsif !vuln_pkg.affects_version?(change.requirement)
275
+ return change
276
+ end
277
+ end
278
+ nil
279
+ end
280
+
281
+ def find_vulnerability_window(ecosystem, package_name, vuln_pkg)
282
+ introducing_changes = Models::DependencyChange
283
+ .join(:commits, id: :commit_id)
284
+ .where(ecosystem: ecosystem, name: package_name)
285
+ .where(change_type: %w[added modified])
286
+ .order(Sequel[:commits][:committed_at])
287
+ .eager(:commit)
288
+ .all
289
+
290
+ introducing_change = introducing_changes.find { |c| vuln_pkg.affects_version?(c.requirement) }
291
+ return nil unless introducing_change
292
+
293
+ introduced_at = introducing_change.commit.committed_at
294
+
295
+ fix_changes = Models::DependencyChange
296
+ .join(:commits, id: :commit_id)
297
+ .where(ecosystem: ecosystem, name: package_name)
298
+ .where(change_type: %w[modified removed])
299
+ .where { Sequel[:commits][:committed_at] > introduced_at }
300
+ .order(Sequel[:commits][:committed_at])
301
+ .eager(:commit)
302
+ .all
303
+
304
+ fixing_change = find_first_fixing_change(fix_changes, vuln_pkg)
305
+
306
+ {
307
+ introducing: introducing_change,
308
+ fixing: fixing_change,
309
+ status: fixing_change ? "fixed" : "ongoing"
310
+ }
311
+ end
312
+
313
+ def get_dependencies_stateless(repo)
314
+ ref = @options[:ref] || "HEAD"
315
+ commit_sha = repo.rev_parse(ref)
316
+ rugged_commit = repo.lookup(commit_sha)
317
+
318
+ error "Could not resolve '#{ref}'. Check that the ref exists." unless rugged_commit
319
+
320
+ analyzer = Analyzer.new(repo)
321
+ analyzer.dependencies_at_commit(rugged_commit)
322
+ end
323
+
324
+ def get_dependencies_with_database(repo)
325
+ ref = @options[:ref] || "HEAD"
326
+ commit_sha = repo.rev_parse(ref)
327
+ target_commit = Models::Commit.first(sha: commit_sha)
328
+
329
+ # Fall back to stateless mode if commit not tracked
330
+ return get_dependencies_stateless(repo) unless target_commit
331
+
332
+ compute_dependencies_at_commit(target_commit, repo)
333
+ end
334
+
335
+ # Returns true if `new_version` is more specific than `old_version`.
336
+ # Actual version numbers are preferred over loose constraints like ">= 0".
337
+ def more_specific_version?(new_version, old_version)
338
+ return false if new_version.nil? || new_version.empty?
339
+ return true if old_version.nil? || old_version.empty?
340
+
341
+ new_is_constraint = new_version.match?(/[<>=~^]/)
342
+ old_is_constraint = old_version.match?(/[<>=~^]/)
343
+
344
+ # Prefer actual versions over constraints
345
+ return true if !new_is_constraint && old_is_constraint
346
+
347
+ # If both are versions or both are constraints, prefer neither
348
+ false
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class Blame
8
+ include Base
9
+
10
+ def initialize(args)
11
+ @args = args.dup
12
+ @options = parse_options
13
+ end
14
+
15
+ def parse_options
16
+ options = {}
17
+
18
+ parser = OptionParser.new do |opts|
19
+ opts.banner = "Usage: git pkgs vulns blame [ref] [options]"
20
+ opts.separator ""
21
+ opts.separator "Show who introduced each vulnerability."
22
+ opts.separator ""
23
+ opts.separator "Arguments:"
24
+ opts.separator " ref Git ref to analyze (default: HEAD)"
25
+ opts.separator ""
26
+ opts.separator "Options:"
27
+
28
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
29
+ options[:ecosystem] = v
30
+ end
31
+
32
+ opts.on("-s", "--severity=LEVEL", "Minimum severity (critical, high, medium, low)") do |v|
33
+ options[:severity] = v
34
+ end
35
+
36
+ opts.on("-r", "--ref=REF", "Git ref to analyze (default: HEAD)") do |v|
37
+ options[:ref] = v
38
+ end
39
+
40
+ opts.on("-b", "--branch=NAME", "Branch context for finding commits") do |v|
41
+ options[:branch] = v
42
+ end
43
+
44
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
45
+ options[:format] = v
46
+ end
47
+
48
+ opts.on("--all-time", "Show blame for all historical vulnerabilities") do
49
+ options[:all_time] = true
50
+ end
51
+
52
+ opts.on("-h", "--help", "Show this help") do
53
+ puts opts
54
+ exit
55
+ end
56
+ end
57
+
58
+ parser.parse!(@args)
59
+ options[:ref] ||= @args.shift unless @args.empty?
60
+ options
61
+ end
62
+
63
+ def run
64
+ repo = Repository.new
65
+
66
+ unless Database.exists?(repo.git_dir)
67
+ error "No database found. Run 'git pkgs init' first. Blame requires commit history."
68
+ end
69
+
70
+ Database.connect(repo.git_dir)
71
+
72
+ if @options[:all_time]
73
+ run_all_time(repo)
74
+ else
75
+ run_at_ref(repo)
76
+ end
77
+ end
78
+
79
+ def run_at_ref(repo)
80
+ ref = @options[:ref] || "HEAD"
81
+ commit_sha = repo.rev_parse(ref)
82
+ target_commit = Models::Commit.first(sha: commit_sha)
83
+
84
+ unless target_commit
85
+ error "Commit #{commit_sha[0, 7]} not in database. Run 'git pkgs update' first."
86
+ end
87
+
88
+ deps = compute_dependencies_at_commit(target_commit, repo)
89
+
90
+ if deps.empty?
91
+ empty_result "No dependencies found"
92
+ return
93
+ end
94
+
95
+ supported_deps = deps.select { |d| Ecosystems.supported?(d[:ecosystem]) }
96
+ vulns = scan_for_vulnerabilities(supported_deps)
97
+
98
+ if @options[:severity]
99
+ min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
100
+ vulns = vulns.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
101
+ end
102
+
103
+ if vulns.empty?
104
+ puts "No known vulnerabilities found"
105
+ return
106
+ end
107
+
108
+ blame_results = vulns.map do |vuln|
109
+ introducing = find_introducing_commit(
110
+ vuln[:ecosystem],
111
+ vuln[:package_name],
112
+ vuln[:id],
113
+ target_commit
114
+ )
115
+
116
+ vuln.merge(introducing_commit: introducing)
117
+ end
118
+
119
+ output_results(blame_results)
120
+ end
121
+
122
+ def run_all_time(repo)
123
+ branch_name = @options[:branch] || repo.default_branch
124
+ branch = Models::Branch.first(name: branch_name)
125
+
126
+ unless branch&.last_analyzed_sha
127
+ error "No analysis found for branch '#{branch_name}'. Run 'git pkgs init' first."
128
+ end
129
+
130
+ # Get all unique packages from dependency changes
131
+ packages = Models::DependencyChange
132
+ .select(:ecosystem, :name)
133
+ .select_group(:ecosystem, :name)
134
+ .all
135
+
136
+ blame_results = []
137
+
138
+ packages.each do |pkg|
139
+ next unless Ecosystems.supported?(pkg.ecosystem)
140
+
141
+ osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
142
+ next unless osv_ecosystem
143
+
144
+ vuln_pkgs = Models::VulnerabilityPackage
145
+ .for_package(osv_ecosystem, pkg.name)
146
+ .eager(:vulnerability)
147
+ .all
148
+
149
+ vuln_pkgs.each do |vp|
150
+ next if vp.vulnerability&.withdrawn?
151
+
152
+ introducing = find_historical_introducing_commit(pkg.ecosystem, pkg.name, vp)
153
+ next unless introducing
154
+
155
+ severity = vp.vulnerability&.severity
156
+
157
+ if @options[:severity]
158
+ min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
159
+ next unless (SEVERITY_ORDER[severity&.downcase] || 4) <= min_level
160
+ end
161
+
162
+ blame_results << {
163
+ id: vp.vulnerability_id,
164
+ severity: severity,
165
+ package_name: pkg.name,
166
+ package_version: introducing[:version],
167
+ summary: vp.vulnerability&.summary,
168
+ introducing_commit: introducing[:commit_info],
169
+ status: introducing[:status]
170
+ }
171
+ end
172
+ end
173
+
174
+ if blame_results.empty?
175
+ puts "No historical vulnerabilities found"
176
+ return
177
+ end
178
+
179
+ output_results(blame_results)
180
+ end
181
+
182
+ def find_historical_introducing_commit(ecosystem, package_name, vuln_pkg)
183
+ window = find_vulnerability_window(ecosystem, package_name, vuln_pkg)
184
+ return nil unless window
185
+
186
+ {
187
+ commit_info: format_commit_info(window[:introducing].commit),
188
+ version: window[:introducing].requirement,
189
+ status: window[:status]
190
+ }
191
+ end
192
+
193
+ def output_results(blame_results)
194
+ blame_results.sort_by! do |v|
195
+ [SEVERITY_ORDER[v[:severity]&.downcase] || 4, v[:package_name]]
196
+ end
197
+
198
+ if @options[:format] == "json"
199
+ require "json"
200
+ puts JSON.pretty_generate(blame_results)
201
+ else
202
+ output_blame_text(blame_results)
203
+ end
204
+ end
205
+
206
+ def find_introducing_commit(ecosystem, package_name, vuln_id, up_to_commit)
207
+ osv_ecosystem = Ecosystems.to_osv(ecosystem)
208
+ vuln_pkg = Models::VulnerabilityPackage.first(
209
+ vulnerability_id: vuln_id,
210
+ ecosystem: osv_ecosystem,
211
+ package_name: package_name
212
+ )
213
+
214
+ return nil unless vuln_pkg
215
+
216
+ changes = Models::DependencyChange
217
+ .join(:commits, id: :commit_id)
218
+ .where(ecosystem: ecosystem, name: package_name)
219
+ .where(change_type: %w[added modified])
220
+ .where { Sequel[:commits][:committed_at] <= up_to_commit.committed_at }
221
+ .order(Sequel.desc(Sequel[:commits][:committed_at]))
222
+ .eager(:commit)
223
+ .all
224
+
225
+ changes.each do |change|
226
+ next unless vuln_pkg.affects_version?(change.requirement)
227
+ return format_commit_info(change.commit)
228
+ end
229
+
230
+ first_add = Models::DependencyChange
231
+ .join(:commits, id: :commit_id)
232
+ .where(ecosystem: ecosystem, name: package_name)
233
+ .where(change_type: "added")
234
+ .order(Sequel[:commits][:committed_at])
235
+ .eager(:commit)
236
+ .first
237
+
238
+ return format_commit_info(first_add.commit) if first_add && vuln_pkg.affects_version?(first_add.requirement)
239
+
240
+ nil
241
+ end
242
+
243
+ def output_blame_text(results)
244
+ has_status = results.any? { |r| r[:status] }
245
+ max_severity = results.map { |v| (v[:severity] || "").length }.max || 8
246
+ max_id = results.map { |v| v[:id].length }.max || 15
247
+ max_pkg = results.map { |v| "#{v[:package_name]} #{v[:package_version]}".length }.max || 20
248
+
249
+ results.each do |result|
250
+ severity = (result[:severity] || "unknown").upcase.ljust(max_severity)
251
+ id = result[:id].ljust(max_id)
252
+ pkg = "#{result[:package_name]} #{result[:package_version]}".ljust(max_pkg)
253
+
254
+ intro = result[:introducing_commit]
255
+ commit_info = if intro
256
+ "#{intro[:sha]} #{intro[:date]} #{intro[:author]} \"#{intro[:message]}\""
257
+ else
258
+ "(unknown origin)"
259
+ end
260
+
261
+ status_str = has_status ? " [#{result[:status]}]" : ""
262
+ line = "#{severity} #{id} #{pkg} #{commit_info}#{status_str}"
263
+ colored_line = case result[:severity]&.downcase
264
+ when "critical", "high" then Color.red(line)
265
+ when "medium" then Color.yellow(line)
266
+ when "low" then Color.cyan(line)
267
+ else line
268
+ end
269
+ puts colored_line
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end