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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class Diff
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 diff [ref1] [ref2] [options]"
20
+ opts.separator ""
21
+ opts.separator "Compare vulnerability state between two commits."
22
+ opts.separator ""
23
+ opts.separator "Arguments:"
24
+ opts.separator " ref1 First git ref (default: HEAD~1)"
25
+ opts.separator " ref2 Second git ref (default: HEAD)"
26
+ opts.separator ""
27
+ opts.separator "Examples:"
28
+ opts.separator " git pkgs vulns diff main feature-branch"
29
+ opts.separator " git pkgs vulns diff v1.0.0 v2.0.0"
30
+ opts.separator " git pkgs vulns diff HEAD~10"
31
+ opts.separator ""
32
+ opts.separator "Options:"
33
+
34
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
35
+ options[:ecosystem] = v
36
+ end
37
+
38
+ opts.on("-s", "--severity=LEVEL", "Minimum severity (critical, high, medium, low)") do |v|
39
+ options[:severity] = v
40
+ end
41
+
42
+ opts.on("-b", "--branch=NAME", "Branch context for finding commits") do |v|
43
+ options[:branch] = v
44
+ end
45
+
46
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
47
+ options[:format] = v
48
+ end
49
+
50
+ opts.on("-h", "--help", "Show this help") do
51
+ puts opts
52
+ exit
53
+ end
54
+ end
55
+
56
+ parser.parse!(@args)
57
+ options
58
+ end
59
+
60
+ def run
61
+ repo = Repository.new
62
+
63
+ unless Database.exists?(repo.git_dir)
64
+ error "No database found. Run 'git pkgs init' first. Diff requires commit history."
65
+ end
66
+
67
+ Database.connect(repo.git_dir)
68
+
69
+ ref1, ref2 = parse_diff_refs(repo)
70
+ commit1_sha = repo.rev_parse(ref1)
71
+ commit2_sha = repo.rev_parse(ref2)
72
+
73
+ commit1 = Models::Commit.first(sha: commit1_sha)
74
+ commit2 = Models::Commit.first(sha: commit2_sha)
75
+
76
+ error "Commit #{commit1_sha[0, 7]} not in database. Run 'git pkgs update' first." unless commit1
77
+ error "Commit #{commit2_sha[0, 7]} not in database. Run 'git pkgs update' first." unless commit2
78
+
79
+ deps1 = compute_dependencies_at_commit(commit1, repo)
80
+ deps2 = compute_dependencies_at_commit(commit2, repo)
81
+
82
+ supported_deps1 = deps1.select { |d| Ecosystems.supported?(d[:ecosystem]) }
83
+ supported_deps2 = deps2.select { |d| Ecosystems.supported?(d[:ecosystem]) }
84
+
85
+ vulns1 = scan_for_vulnerabilities(supported_deps1)
86
+ vulns2 = scan_for_vulnerabilities(supported_deps2)
87
+
88
+ if @options[:severity]
89
+ min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
90
+ vulns1 = vulns1.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
91
+ vulns2 = vulns2.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
92
+ end
93
+
94
+ vulns1_ids = vulns1.map { |v| v[:id] }.to_set
95
+ vulns2_ids = vulns2.map { |v| v[:id] }.to_set
96
+
97
+ added = vulns2.reject { |v| vulns1_ids.include?(v[:id]) }
98
+ removed = vulns1.reject { |v| vulns2_ids.include?(v[:id]) }
99
+
100
+ if added.empty? && removed.empty?
101
+ puts "No vulnerability changes between #{ref1} and #{ref2}"
102
+ return
103
+ end
104
+
105
+ if @options[:format] == "json"
106
+ require "json"
107
+ puts JSON.pretty_generate({
108
+ from: ref1,
109
+ to: ref2,
110
+ added: added,
111
+ removed: removed
112
+ })
113
+ else
114
+ output_diff_text(added, removed, ref1, ref2)
115
+ end
116
+ end
117
+
118
+ def parse_diff_refs(repo)
119
+ args = @args.dup
120
+ ref1 = args.shift
121
+ ref2 = args.shift
122
+
123
+ if ref1.nil?
124
+ ref1 = "HEAD~1"
125
+ ref2 = "HEAD"
126
+ elsif ref2.nil?
127
+ ref2 = ref1
128
+ ref1 = "HEAD"
129
+ end
130
+
131
+ if ref1.include?("...")
132
+ parts = ref1.split("...")
133
+ ref1 = parts[0]
134
+ ref2 = parts[1]
135
+ elsif ref1.include?("..")
136
+ parts = ref1.split("..")
137
+ ref1 = parts[0]
138
+ ref2 = parts[1]
139
+ end
140
+
141
+ [ref1, ref2]
142
+ end
143
+
144
+ def output_diff_text(added, removed, ref1, ref2)
145
+ all_vulns = added.map { |v| v.merge(diff_type: :added) } +
146
+ removed.map { |v| v.merge(diff_type: :removed) }
147
+
148
+ all_vulns.sort_by! do |v|
149
+ [SEVERITY_ORDER[v[:severity]&.downcase] || 4, v[:package_name]]
150
+ end
151
+
152
+ max_severity = all_vulns.map { |v| (v[:severity] || "").length }.max || 8
153
+ max_id = all_vulns.map { |v| v[:id].length }.max || 15
154
+ max_pkg = all_vulns.map { |v| "#{v[:package_name]} #{v[:package_version]}".length }.max || 20
155
+
156
+ all_vulns.each do |vuln|
157
+ prefix = vuln[:diff_type] == :added ? "+" : "-"
158
+ severity = (vuln[:severity] || "unknown").upcase.ljust(max_severity)
159
+ id = vuln[:id].ljust(max_id)
160
+ pkg = "#{vuln[:package_name]} #{vuln[:package_version]}".ljust(max_pkg)
161
+ note = vuln[:diff_type] == :added ? "(introduced in #{ref2})" : "(fixed in #{ref2})"
162
+
163
+ color = vuln[:diff_type] == :added ? :red : :green
164
+ line = "#{prefix}#{severity} #{id} #{pkg} #{note}"
165
+ puts Color.send(color, line)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class Exposure
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 exposure [ref] [options]"
20
+ opts.separator ""
21
+ opts.separator "Calculate exposure windows and remediation metrics."
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("--summary", "Show aggregate metrics only") do
49
+ options[:summary] = true
50
+ end
51
+
52
+ opts.on("--all-time", "Show stats for all historical vulnerabilities") do
53
+ options[:all_time] = true
54
+ end
55
+
56
+ opts.on("-h", "--help", "Show this help") do
57
+ puts opts
58
+ exit
59
+ end
60
+ end
61
+
62
+ parser.parse!(@args)
63
+ options[:ref] ||= @args.shift unless @args.empty?
64
+ options
65
+ end
66
+
67
+ def run
68
+ repo = Repository.new
69
+
70
+ unless Database.exists?(repo.git_dir)
71
+ error "No database found. Run 'git pkgs init' first. Exposure analysis requires commit history."
72
+ end
73
+
74
+ Database.connect(repo.git_dir)
75
+
76
+ if @options[:all_time]
77
+ run_all_time(repo)
78
+ else
79
+ run_at_ref(repo)
80
+ end
81
+ end
82
+
83
+ def run_at_ref(repo)
84
+ ref = @options[:ref] || "HEAD"
85
+ commit_sha = repo.rev_parse(ref)
86
+ target_commit = Models::Commit.first(sha: commit_sha)
87
+
88
+ unless target_commit
89
+ error "Commit #{commit_sha[0, 7]} not in database. Run 'git pkgs update' first."
90
+ end
91
+
92
+ deps = compute_dependencies_at_commit(target_commit, repo)
93
+
94
+ if deps.empty?
95
+ empty_result "No dependencies found"
96
+ return
97
+ end
98
+
99
+ supported_deps = deps.select { |d| Ecosystems.supported?(d[:ecosystem]) }
100
+ vulns = scan_for_vulnerabilities(supported_deps)
101
+
102
+ if @options[:severity]
103
+ min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
104
+ vulns = vulns.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
105
+ end
106
+
107
+ if vulns.empty?
108
+ puts "No known vulnerabilities found"
109
+ return
110
+ end
111
+
112
+ exposure_data = vulns.map do |vuln|
113
+ calculate_exposure(vuln, target_commit)
114
+ end.compact
115
+
116
+ output_results(exposure_data)
117
+ end
118
+
119
+ def run_all_time(repo)
120
+ branch_name = @options[:branch] || repo.default_branch
121
+ branch = Models::Branch.first(name: branch_name)
122
+
123
+ unless branch&.last_analyzed_sha
124
+ error "No analysis found for branch '#{branch_name}'. Run 'git pkgs init' first."
125
+ end
126
+
127
+ last_commit = Models::Commit.first(sha: branch.last_analyzed_sha)
128
+
129
+ # Get all unique packages from dependency changes
130
+ packages = Models::DependencyChange
131
+ .select(:ecosystem, :name)
132
+ .select_group(:ecosystem, :name)
133
+ .all
134
+
135
+ exposure_data = []
136
+
137
+ packages.each do |pkg|
138
+ next unless Ecosystems.supported?(pkg.ecosystem)
139
+
140
+ osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
141
+ next unless osv_ecosystem
142
+
143
+ vuln_pkgs = Models::VulnerabilityPackage
144
+ .for_package(osv_ecosystem, pkg.name)
145
+ .eager(:vulnerability)
146
+ .all
147
+
148
+ vuln_pkgs.each do |vp|
149
+ next if vp.vulnerability&.withdrawn?
150
+
151
+ exposure = calculate_historical_exposure(pkg.ecosystem, pkg.name, vp, last_commit)
152
+ next unless exposure
153
+
154
+ if @options[:severity]
155
+ min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
156
+ next unless (SEVERITY_ORDER[exposure[:severity]&.downcase] || 4) <= min_level
157
+ end
158
+
159
+ exposure_data << exposure
160
+ end
161
+ end
162
+
163
+ if exposure_data.empty?
164
+ puts "No historical vulnerabilities found"
165
+ return
166
+ end
167
+
168
+ output_results(exposure_data)
169
+ end
170
+
171
+ def calculate_historical_exposure(ecosystem, package_name, vuln_pkg, last_commit)
172
+ window = find_vulnerability_window(ecosystem, package_name, vuln_pkg)
173
+ return nil unless window
174
+
175
+ introducing_change = window[:introducing]
176
+ fixing_change = window[:fixing]
177
+
178
+ introduced_at = introducing_change.commit.committed_at
179
+ fixed_at = fixing_change&.commit&.committed_at
180
+ published_at = vuln_pkg.vulnerability&.published_at
181
+ now = Time.now
182
+
183
+ total_exposure_days = if introduced_at
184
+ end_time = fixed_at || now
185
+ ((end_time - introduced_at) / 86400).round
186
+ end
187
+
188
+ post_disclosure_days = if published_at
189
+ start_time = [introduced_at, published_at].compact.max
190
+ end_time = fixed_at || now
191
+ if start_time && end_time > start_time
192
+ ((end_time - start_time) / 86400).round
193
+ else
194
+ 0
195
+ end
196
+ end
197
+
198
+ {
199
+ id: vuln_pkg.vulnerability_id,
200
+ severity: vuln_pkg.vulnerability&.severity,
201
+ package_name: package_name,
202
+ package_version: introducing_change.requirement,
203
+ published_at: published_at&.strftime("%Y-%m-%d"),
204
+ introduced_at: introduced_at&.strftime("%Y-%m-%d"),
205
+ introduced_by: format_commit_info(introducing_change.commit),
206
+ fixed_at: fixed_at&.strftime("%Y-%m-%d"),
207
+ fixed_by: fixing_change ? format_commit_info(fixing_change.commit) : nil,
208
+ status: window[:status],
209
+ total_exposure_days: total_exposure_days,
210
+ post_disclosure_days: post_disclosure_days
211
+ }
212
+ end
213
+
214
+ def output_results(exposure_data)
215
+ if @options[:format] == "json"
216
+ require "json"
217
+ puts JSON.pretty_generate({
218
+ vulnerabilities: exposure_data,
219
+ summary: compute_exposure_summary(exposure_data)
220
+ })
221
+ elsif @options[:summary]
222
+ output_exposure_summary(exposure_data)
223
+ else
224
+ output_exposure_table(exposure_data)
225
+ end
226
+ end
227
+
228
+ def calculate_exposure(vuln, up_to_commit)
229
+ osv_ecosystem = Ecosystems.to_osv(vuln[:ecosystem])
230
+ vuln_pkg = Models::VulnerabilityPackage.first(
231
+ vulnerability_id: vuln[:id],
232
+ ecosystem: osv_ecosystem,
233
+ package_name: vuln[:package_name]
234
+ )
235
+
236
+ return nil unless vuln_pkg
237
+
238
+ vulnerability = vuln_pkg.vulnerability
239
+ published_at = vulnerability&.published_at
240
+
241
+ introduced_change = find_introducing_change(
242
+ vuln[:ecosystem],
243
+ vuln[:package_name],
244
+ vuln_pkg,
245
+ up_to_commit
246
+ )
247
+
248
+ introduced_at = introduced_change&.commit&.committed_at
249
+
250
+ fixed_change = find_fixing_change(
251
+ vuln[:ecosystem],
252
+ vuln[:package_name],
253
+ vuln_pkg,
254
+ up_to_commit,
255
+ introduced_at
256
+ )
257
+
258
+ fixed_at = fixed_change&.commit&.committed_at
259
+ now = Time.now
260
+
261
+ total_exposure_days = if introduced_at
262
+ end_time = fixed_at || now
263
+ ((end_time - introduced_at) / 86400).round
264
+ end
265
+
266
+ post_disclosure_days = if published_at
267
+ start_time = [introduced_at, published_at].compact.max
268
+ end_time = fixed_at || now
269
+ if start_time && end_time > start_time
270
+ ((end_time - start_time) / 86400).round
271
+ else
272
+ 0
273
+ end
274
+ end
275
+
276
+ {
277
+ id: vuln[:id],
278
+ severity: vuln[:severity],
279
+ package_name: vuln[:package_name],
280
+ package_version: vuln[:package_version],
281
+ published_at: published_at&.strftime("%Y-%m-%d"),
282
+ introduced_at: introduced_at&.strftime("%Y-%m-%d"),
283
+ introduced_by: introduced_change ? format_commit_info(introduced_change.commit) : nil,
284
+ fixed_at: fixed_at&.strftime("%Y-%m-%d"),
285
+ fixed_by: fixed_change ? format_commit_info(fixed_change.commit) : nil,
286
+ status: fixed_at ? "fixed" : "ongoing",
287
+ total_exposure_days: total_exposure_days,
288
+ post_disclosure_days: post_disclosure_days
289
+ }
290
+ end
291
+
292
+ def compute_exposure_summary(data)
293
+ return {} if data.empty?
294
+
295
+ fixed = data.select { |d| d[:status] == "fixed" }
296
+ ongoing = data.select { |d| d[:status] == "ongoing" }
297
+
298
+ post_disclosure_times = fixed.map { |d| d[:post_disclosure_days] }.compact
299
+ mean_remediation = post_disclosure_times.empty? ? nil : (post_disclosure_times.sum.to_f / post_disclosure_times.size).round(1)
300
+ median_remediation = median(post_disclosure_times)
301
+
302
+ oldest_ongoing = ongoing.map { |d| d[:post_disclosure_days] }.compact.max
303
+
304
+ by_severity = {}
305
+ %w[critical high medium low].each do |sev|
306
+ sev_fixed = fixed.select { |d| d[:severity]&.downcase == sev }
307
+ sev_times = sev_fixed.map { |d| d[:post_disclosure_days] }.compact
308
+ next if sev_times.empty?
309
+
310
+ by_severity[sev] = (sev_times.sum.to_f / sev_times.size).round(1)
311
+ end
312
+
313
+ {
314
+ total_vulnerabilities: data.size,
315
+ fixed_count: fixed.size,
316
+ ongoing_count: ongoing.size,
317
+ mean_remediation_days: mean_remediation,
318
+ median_remediation_days: median_remediation,
319
+ oldest_ongoing_days: oldest_ongoing,
320
+ by_severity: by_severity
321
+ }
322
+ end
323
+
324
+ def output_exposure_summary(data)
325
+ summary = compute_exposure_summary(data)
326
+
327
+ # Build stats rows
328
+ rows = []
329
+ rows << ["Total vulnerabilities", summary[:total_vulnerabilities].to_s]
330
+ rows << ["Fixed", summary[:fixed_count].to_s]
331
+ rows << ["Ongoing", summary[:ongoing_count].to_s]
332
+
333
+ if summary[:fixed_count].positive?
334
+ rows << ["Median remediation", "#{summary[:median_remediation_days] || 'N/A'} days"]
335
+ rows << ["Mean remediation", "#{summary[:mean_remediation_days] || 'N/A'} days"]
336
+ end
337
+
338
+ if summary[:oldest_ongoing_days]
339
+ rows << ["Oldest unpatched", "#{summary[:oldest_ongoing_days]} days"]
340
+ end
341
+
342
+ # Add severity breakdown
343
+ summary[:by_severity].each do |sev, avg|
344
+ rows << ["#{sev.capitalize} (avg)", "#{avg} days"]
345
+ end
346
+
347
+ output_stats_table(rows)
348
+ end
349
+
350
+ def output_stats_table(rows)
351
+ return if rows.empty?
352
+
353
+ max_label = rows.map { |r| r[0].length }.max || 20
354
+ max_value = rows.map { |r| r[1].length }.max || 10
355
+
356
+ width = max_label + max_value + 7
357
+ border = "+" + ("-" * (width - 2)) + "+"
358
+
359
+ puts border
360
+ rows.each do |label, value|
361
+ puts "| #{label.ljust(max_label)} | #{value.rjust(max_value)} |"
362
+ end
363
+ puts border
364
+ end
365
+
366
+ def output_exposure_table(data)
367
+ max_pkg = data.map { |d| d[:package_name].length }.max || 10
368
+ max_id = data.map { |d| d[:id].length }.max || 15
369
+
370
+ header = "#{"Package".ljust(max_pkg)} #{"CVE".ljust(max_id)} Introduced Fixed Exposed Post-Disclosure"
371
+ puts header
372
+ puts "-" * header.length
373
+
374
+ data.sort_by { |d| [SEVERITY_ORDER[d[:severity]&.downcase] || 4, d[:package_name]] }.each do |row|
375
+ pkg = row[:package_name].ljust(max_pkg)
376
+ id = row[:id].ljust(max_id)
377
+ introduced = (row[:introduced_at] || "unknown").ljust(10)
378
+ fixed = row[:status] == "fixed" ? row[:fixed_at].ljust(10) : "-".ljust(10)
379
+ exposed = row[:total_exposure_days] ? "#{row[:total_exposure_days]}d".ljust(7) : "?".ljust(7)
380
+
381
+ post = if row[:status] == "ongoing" && row[:post_disclosure_days]
382
+ "#{row[:post_disclosure_days]}d (ongoing)"
383
+ elsif row[:post_disclosure_days]
384
+ "#{row[:post_disclosure_days]}d"
385
+ else
386
+ "?"
387
+ end
388
+
389
+ line = "#{pkg} #{id} #{introduced} #{fixed} #{exposed} #{post}"
390
+ colored_line = case row[:severity]&.downcase
391
+ when "critical", "high" then Color.red(line)
392
+ when "medium" then Color.yellow(line)
393
+ when "low" then Color.cyan(line)
394
+ else line
395
+ end
396
+ puts colored_line
397
+ end
398
+
399
+ puts ""
400
+ output_exposure_summary(data)
401
+ end
402
+
403
+ def median(values)
404
+ return nil if values.empty?
405
+
406
+ sorted = values.sort
407
+ mid = sorted.size / 2
408
+ if sorted.size.odd?
409
+ sorted[mid]
410
+ else
411
+ ((sorted[mid - 1] + sorted[mid]) / 2.0).round(1)
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end