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,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class Praise
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 praise [options]"
20
+ opts.separator ""
21
+ opts.separator "Show who fixed vulnerabilities (opposite of blame)."
22
+ opts.separator ""
23
+ opts.separator "Options:"
24
+
25
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
26
+ options[:ecosystem] = v
27
+ end
28
+
29
+ opts.on("-s", "--severity=LEVEL", "Minimum severity (critical, high, medium, low)") do |v|
30
+ options[:severity] = v
31
+ end
32
+
33
+ opts.on("-b", "--branch=NAME", "Branch to analyze") do |v|
34
+ options[:branch] = v
35
+ end
36
+
37
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
38
+ options[:format] = v
39
+ end
40
+
41
+ opts.on("--summary", "Show author leaderboard") do
42
+ options[:summary] = true
43
+ end
44
+
45
+ opts.on("-h", "--help", "Show this help") do
46
+ puts opts
47
+ exit
48
+ end
49
+ end
50
+
51
+ parser.parse!(@args)
52
+ options
53
+ end
54
+
55
+ def run
56
+ repo = Repository.new
57
+
58
+ unless Database.exists?(repo.git_dir)
59
+ error "No database found. Run 'git pkgs init' first. Praise requires commit history."
60
+ end
61
+
62
+ Database.connect(repo.git_dir)
63
+
64
+ branch_name = @options[:branch] || repo.default_branch
65
+ branch = Models::Branch.first(name: branch_name)
66
+
67
+ unless branch&.last_analyzed_sha
68
+ error "No analysis found for branch '#{branch_name}'. Run 'git pkgs init' first."
69
+ end
70
+
71
+ # Get all unique packages from dependency changes
72
+ packages = Models::DependencyChange
73
+ .select(:ecosystem, :name)
74
+ .select_group(:ecosystem, :name)
75
+ .all
76
+
77
+ praise_results = []
78
+
79
+ packages.each do |pkg|
80
+ next unless Ecosystems.supported?(pkg.ecosystem)
81
+
82
+ osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
83
+ next unless osv_ecosystem
84
+
85
+ vuln_pkgs = Models::VulnerabilityPackage
86
+ .for_package(osv_ecosystem, pkg.name)
87
+ .eager(:vulnerability)
88
+ .all
89
+
90
+ vuln_pkgs.each do |vp|
91
+ next if vp.vulnerability&.withdrawn?
92
+
93
+ fix_info = find_fixing_commit_info(pkg.ecosystem, pkg.name, vp)
94
+ next unless fix_info
95
+
96
+ severity = vp.vulnerability&.severity
97
+
98
+ if @options[:severity]
99
+ min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
100
+ next unless (SEVERITY_ORDER[severity&.downcase] || 4) <= min_level
101
+ end
102
+
103
+ praise_results << {
104
+ id: vp.vulnerability_id,
105
+ severity: severity,
106
+ package_name: pkg.name,
107
+ from_version: fix_info[:from_version],
108
+ to_version: fix_info[:to_version],
109
+ summary: vp.vulnerability&.summary,
110
+ fixing_commit: fix_info[:commit_info],
111
+ days_exposed: fix_info[:days_exposed],
112
+ days_after_disclosure: fix_info[:days_after_disclosure]
113
+ }
114
+ end
115
+ end
116
+
117
+ if praise_results.empty?
118
+ puts "No fixed vulnerabilities found"
119
+ return
120
+ end
121
+
122
+ praise_results.sort_by! do |v|
123
+ [SEVERITY_ORDER[v[:severity]&.downcase] || 4, v[:package_name]]
124
+ end
125
+
126
+ if @options[:format] == "json"
127
+ require "json"
128
+ if @options[:summary]
129
+ puts JSON.pretty_generate(compute_author_summary(praise_results))
130
+ else
131
+ puts JSON.pretty_generate(praise_results)
132
+ end
133
+ elsif @options[:summary]
134
+ output_author_summary(praise_results)
135
+ else
136
+ output_praise_text(praise_results)
137
+ end
138
+ end
139
+
140
+ def compute_author_summary(results)
141
+ by_author = results.group_by { |r| r[:fixing_commit][:author] }
142
+
143
+ summaries = by_author.map do |author, fixes|
144
+ times = fixes.map { |f| f[:days_after_disclosure] }.compact
145
+ avg_time = times.empty? ? nil : (times.sum.to_f / times.size).round(1)
146
+
147
+ by_sev = {}
148
+ %w[critical high medium low].each do |sev|
149
+ count = fixes.count { |f| f[:severity]&.downcase == sev }
150
+ by_sev[sev] = count if count > 0
151
+ end
152
+
153
+ {
154
+ author: author,
155
+ total_fixes: fixes.size,
156
+ avg_days_to_fix: avg_time,
157
+ by_severity: by_sev
158
+ }
159
+ end
160
+
161
+ summaries.sort_by { |s| -s[:total_fixes] }
162
+ end
163
+
164
+ def output_author_summary(results)
165
+ summaries = compute_author_summary(results)
166
+
167
+ max_author = summaries.map { |s| s[:author].length }.max || 20
168
+ max_fixes = summaries.map { |s| s[:total_fixes].to_s.length }.max || 3
169
+
170
+ puts "Author".ljust(max_author) + " Fixes Avg Days Critical High Medium Low"
171
+ puts "-" * (max_author + 50)
172
+
173
+ summaries.each do |s|
174
+ author = s[:author].ljust(max_author)
175
+ fixes = s[:total_fixes].to_s.rjust(max_fixes)
176
+ avg = s[:avg_days_to_fix] ? "#{s[:avg_days_to_fix]}d".rjust(8) : "N/A".rjust(8)
177
+ crit = (s[:by_severity]["critical"] || 0).to_s.rjust(8)
178
+ high = (s[:by_severity]["high"] || 0).to_s.rjust(4)
179
+ med = (s[:by_severity]["medium"] || 0).to_s.rjust(6)
180
+ low = (s[:by_severity]["low"] || 0).to_s.rjust(4)
181
+
182
+ puts "#{author} #{fixes} #{avg} #{crit} #{high} #{med} #{low}"
183
+ end
184
+ end
185
+
186
+ def find_fixing_commit_info(ecosystem, package_name, vuln_pkg)
187
+ window = find_vulnerability_window(ecosystem, package_name, vuln_pkg)
188
+ return nil unless window && window[:fixing]
189
+
190
+ introducing_change = window[:introducing]
191
+ fixing_change = window[:fixing]
192
+
193
+ introduced_at = introducing_change.commit.committed_at
194
+ fixed_at = fixing_change.commit.committed_at
195
+ published_at = vuln_pkg.vulnerability&.published_at
196
+
197
+ days_exposed = ((fixed_at - introduced_at) / 86400).round
198
+ days_after_disclosure = if published_at && fixed_at > published_at
199
+ ((fixed_at - published_at) / 86400).round
200
+ end
201
+
202
+ {
203
+ commit_info: format_commit_info(fixing_change.commit),
204
+ from_version: introducing_change.requirement,
205
+ to_version: fixing_change.change_type == "removed" ? "(removed)" : fixing_change.requirement,
206
+ days_exposed: days_exposed,
207
+ days_after_disclosure: days_after_disclosure
208
+ }
209
+ end
210
+
211
+ def output_praise_text(results)
212
+ max_severity = results.map { |v| (v[:severity] || "").length }.max || 8
213
+ max_id = results.map { |v| v[:id].length }.max || 15
214
+ max_pkg = results.map { |v| v[:package_name].length }.max || 20
215
+
216
+ results.each do |result|
217
+ severity = (result[:severity] || "unknown").upcase.ljust(max_severity)
218
+ id = result[:id].ljust(max_id)
219
+ pkg = result[:package_name].ljust(max_pkg)
220
+
221
+ fix = result[:fixing_commit]
222
+ commit_info = "#{fix[:sha]} #{fix[:date]} #{fix[:author]} \"#{fix[:message]}\""
223
+
224
+ days_info = if result[:days_after_disclosure]
225
+ "(#{result[:days_after_disclosure]}d after disclosure)"
226
+ else
227
+ "(#{result[:days_exposed]}d total)"
228
+ end
229
+
230
+ line = "#{severity} #{id} #{pkg} #{commit_info} #{days_info}"
231
+ puts Color.green(line)
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class Scan
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 [ref] [options]"
20
+ opts.separator ""
21
+ opts.separator "Scan dependencies for known vulnerabilities."
22
+ opts.separator ""
23
+ opts.separator "Arguments:"
24
+ opts.separator " ref Git ref to scan (default: HEAD)"
25
+ opts.separator ""
26
+ opts.separator "Subcommands:"
27
+ opts.separator " sync Sync vulnerability data from OSV"
28
+ opts.separator " blame Show who introduced each vulnerability"
29
+ opts.separator " praise Show who fixed vulnerabilities"
30
+ opts.separator " exposure Calculate exposure windows and remediation metrics"
31
+ opts.separator " diff Compare vulnerability state between commits"
32
+ opts.separator " log Show commits that introduced or fixed vulns"
33
+ opts.separator " history Show vulnerability timeline for a package or CVE"
34
+ opts.separator " show Show details about a specific CVE"
35
+ opts.separator ""
36
+ opts.separator "Options:"
37
+
38
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
39
+ options[:ecosystem] = v
40
+ end
41
+
42
+ opts.on("-s", "--severity=LEVEL", "Minimum severity (critical, high, medium, low)") do |v|
43
+ options[:severity] = v
44
+ end
45
+
46
+ opts.on("-r", "--ref=REF", "Git ref to scan (default: HEAD)") do |v|
47
+ options[:ref] = v
48
+ end
49
+
50
+ opts.on("-b", "--branch=NAME", "Branch context for finding snapshots") do |v|
51
+ options[:branch] = v
52
+ end
53
+
54
+ opts.on("-f", "--format=FORMAT", "Output format (text, json, sarif)") do |v|
55
+ options[:format] = v
56
+ end
57
+
58
+ opts.on("--no-pager", "Do not pipe output into a pager") do
59
+ options[:no_pager] = true
60
+ end
61
+
62
+ opts.on("--stateless", "Parse manifests directly without database") do
63
+ options[:stateless] = true
64
+ end
65
+
66
+ opts.on("-h", "--help", "Show this help") do
67
+ puts opts
68
+ exit
69
+ end
70
+ end
71
+
72
+ parser.parse!(@args)
73
+ options[:ref] ||= @args.shift unless @args.empty?
74
+ options
75
+ end
76
+
77
+ def run
78
+ repo = Repository.new
79
+ use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
80
+
81
+ if use_stateless
82
+ # Use in-memory database for vuln caching in stateless mode
83
+ Database.connect_memory
84
+ deps = get_dependencies_stateless(repo)
85
+ else
86
+ Database.connect(repo.git_dir)
87
+ deps = get_dependencies_with_database(repo)
88
+ end
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
+
97
+ if supported_deps.empty?
98
+ empty_result "No dependencies from supported ecosystems (#{Ecosystems.supported_ecosystems.join(", ")})"
99
+ return
100
+ end
101
+
102
+ vulns = scan_for_vulnerabilities(supported_deps)
103
+
104
+ if @options[:severity]
105
+ min_level = SEVERITY_ORDER[@options[:severity].downcase] || 4
106
+ vulns = vulns.select { |v| (SEVERITY_ORDER[v[:severity]&.downcase] || 4) <= min_level }
107
+ end
108
+
109
+ if vulns.empty?
110
+ puts "No known vulnerabilities found"
111
+ return
112
+ end
113
+
114
+ vulns.sort_by! { |v| [SEVERITY_ORDER[v[:severity]&.downcase] || 4, v[:package_name]] }
115
+
116
+ case @options[:format]
117
+ when "json"
118
+ require "json"
119
+ puts JSON.pretty_generate(vulns)
120
+ when "sarif"
121
+ output_sarif(vulns, deps)
122
+ else
123
+ output_text(vulns)
124
+ end
125
+ end
126
+
127
+ def output_sarif(vulns, deps)
128
+ require "sarif"
129
+
130
+ rules = vulns.map do |vuln|
131
+ Sarif::ReportingDescriptor.new(
132
+ id: vuln[:id],
133
+ name: vuln[:id],
134
+ short_description: Sarif::MultiformatMessageString.new(text: vuln[:summary] || vuln[:id]),
135
+ help_uri: "https://osv.dev/vulnerability/#{vuln[:id]}",
136
+ properties: {
137
+ security_severity: severity_score(vuln[:cvss_score], vuln[:severity])
138
+ }.compact
139
+ )
140
+ end.uniq(&:id)
141
+
142
+ results = vulns.map do |vuln|
143
+ locations = deps
144
+ .select { |d| d[:name].downcase == vuln[:package_name].downcase && d[:ecosystem] == vuln[:ecosystem] }
145
+ .map do |dep|
146
+ Sarif::Location.new(
147
+ physical_location: Sarif::PhysicalLocation.new(
148
+ artifact_location: Sarif::ArtifactLocation.new(uri: dep[:manifest_path])
149
+ ),
150
+ message: Sarif::Message.new(text: "#{dep[:name]} #{dep[:requirement]}")
151
+ )
152
+ end
153
+
154
+ Sarif::Result.new(
155
+ rule_id: vuln[:id],
156
+ level: severity_to_sarif_level(vuln[:severity]),
157
+ message: Sarif::Message.new(
158
+ text: "#{vuln[:package_name]} #{vuln[:package_version]} has a known vulnerability: #{vuln[:summary] || vuln[:id]}"
159
+ ),
160
+ locations: locations.empty? ? nil : locations
161
+ )
162
+ end
163
+
164
+ log = Sarif::Log.new(
165
+ version: "2.1.0",
166
+ runs: [
167
+ Sarif::Run.new(
168
+ tool: Sarif::Tool.new(
169
+ driver: Sarif::ToolComponent.new(
170
+ name: "git-pkgs",
171
+ version: Git::Pkgs::VERSION,
172
+ information_uri: "https://github.com/andrew/git-pkgs",
173
+ rules: rules
174
+ )
175
+ ),
176
+ results: results
177
+ )
178
+ ]
179
+ )
180
+
181
+ puts log.to_json
182
+ end
183
+
184
+ def severity_to_sarif_level(severity)
185
+ case severity&.downcase
186
+ when "critical", "high" then "error"
187
+ when "medium" then "warning"
188
+ when "low" then "note"
189
+ else "warning"
190
+ end
191
+ end
192
+
193
+ def severity_score(cvss_score, severity)
194
+ return cvss_score.to_s if cvss_score
195
+
196
+ case severity&.downcase
197
+ when "critical" then "9.0"
198
+ when "high" then "7.0"
199
+ when "medium" then "4.0"
200
+ when "low" then "1.0"
201
+ end
202
+ end
203
+
204
+ def output_text(vulns)
205
+ max_severity = vulns.map { |v| (v[:severity] || "").length }.max || 8
206
+ max_id = vulns.map { |v| v[:id].length }.max || 15
207
+ max_pkg = vulns.map { |v| v[:package_name].length }.max || 20
208
+
209
+ vulns.each do |vuln|
210
+ severity = (vuln[:severity] || "unknown").upcase.ljust(max_severity)
211
+ id = vuln[:id].ljust(max_id)
212
+ pkg = "#{vuln[:package_name]} #{vuln[:package_version]}".ljust(max_pkg + 10)
213
+ fixed = vuln[:fixed_versions] ? "(fixed in #{vuln[:fixed_versions]})" : ""
214
+
215
+ line = "#{severity} #{id} #{pkg} #{fixed}"
216
+
217
+ colored_line = case vuln[:severity]&.downcase
218
+ when "critical", "high" then Color.red(line)
219
+ when "medium" then Color.yellow(line)
220
+ when "low" then Color.cyan(line)
221
+ else line
222
+ end
223
+
224
+ puts colored_line
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class Show
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 show <cve> [options]"
20
+ opts.separator ""
21
+ opts.separator "Show details about a specific CVE."
22
+ opts.separator ""
23
+ opts.separator "Arguments:"
24
+ opts.separator " cve CVE or GHSA ID (e.g., CVE-2024-1234)"
25
+ opts.separator ""
26
+ opts.separator "Options:"
27
+
28
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
29
+ options[:format] = v
30
+ end
31
+
32
+ opts.on("-r", "--ref=REF", "Git ref for exposure analysis (default: HEAD)") do |v|
33
+ options[:ref] = v
34
+ end
35
+
36
+ opts.on("-b", "--branch=NAME", "Branch context for finding snapshots") do |v|
37
+ options[:branch] = v
38
+ end
39
+
40
+ opts.on("-h", "--help", "Show this help") do
41
+ puts opts
42
+ exit
43
+ end
44
+ end
45
+
46
+ parser.parse!(@args)
47
+ options[:target] = @args.shift
48
+ options
49
+ end
50
+
51
+ def run
52
+ repo = Repository.new
53
+
54
+ cve_id = @options[:target]
55
+ error "Usage: git pkgs vulns show <cve>" unless cve_id
56
+ cve_id = cve_id.upcase
57
+
58
+ has_db = Database.exists?(repo.git_dir)
59
+ Database.connect(repo.git_dir) if has_db
60
+
61
+ ensure_vulns_synced if has_db
62
+
63
+ vuln = Models::Vulnerability.first(id: cve_id)
64
+ unless vuln
65
+ error "Vulnerability #{cve_id} not found. Try 'git pkgs vulns sync' first."
66
+ end
67
+
68
+ vuln_pkgs = Models::VulnerabilityPackage.where(vulnerability_id: cve_id).eager(:vulnerability).all
69
+
70
+ if @options[:format] == "json"
71
+ require "json"
72
+ output = build_show_json(vuln, vuln_pkgs, repo, has_db)
73
+ puts JSON.pretty_generate(output)
74
+ else
75
+ output_show_text(vuln, vuln_pkgs, repo, has_db)
76
+ end
77
+ end
78
+
79
+ def build_show_json(vuln, vuln_pkgs, repo, has_db)
80
+ output = {
81
+ id: vuln.id,
82
+ severity: vuln.severity,
83
+ summary: vuln.summary,
84
+ details: vuln.details,
85
+ published_at: vuln.published_at&.strftime("%Y-%m-%d"),
86
+ affected_packages: vuln_pkgs.map do |vp|
87
+ {
88
+ ecosystem: vp.ecosystem,
89
+ package: vp.package_name,
90
+ affected_versions: vp.affected_versions,
91
+ fixed_versions: vp.fixed_versions
92
+ }
93
+ end
94
+ }
95
+
96
+ if has_db
97
+ output[:your_exposure] = find_exposure_for_vuln(vuln, vuln_pkgs, repo)
98
+ end
99
+
100
+ output
101
+ end
102
+
103
+ def output_show_text(vuln, vuln_pkgs, repo, has_db)
104
+ header = "#{vuln.id} (#{vuln.severity || "unknown"} severity)"
105
+ colored_header = case vuln.severity&.downcase
106
+ when "critical", "high" then Color.red(header)
107
+ when "medium" then Color.yellow(header)
108
+ when "low" then Color.cyan(header)
109
+ else header
110
+ end
111
+ puts colored_header
112
+ puts vuln.summary if vuln.summary
113
+ puts ""
114
+
115
+ puts "Affected packages:"
116
+ vuln_pkgs.each do |vp|
117
+ fixed_info = vp.fixed_versions.to_s.empty? ? "" : " (fixed in #{vp.fixed_versions})"
118
+ puts " #{vp.ecosystem}/#{vp.package_name}: #{vp.affected_versions}#{fixed_info}"
119
+ end
120
+
121
+ puts ""
122
+ puts "Published: #{vuln.published_at&.strftime("%Y-%m-%d") || "unknown"}"
123
+
124
+ if vuln.references && !vuln.references.empty?
125
+ puts ""
126
+ puts "References:"
127
+ refs = begin
128
+ JSON.parse(vuln.references)
129
+ rescue JSON::ParserError => e
130
+ $stderr.puts "Warning: Could not parse references for #{vuln.id}: #{e.message}" unless Git::Pkgs.quiet
131
+ []
132
+ end
133
+ refs.each do |ref|
134
+ puts " #{ref["url"]}" if ref["url"]
135
+ end
136
+ end
137
+
138
+ return unless has_db
139
+
140
+ exposures = find_exposure_for_vuln(vuln, vuln_pkgs, repo)
141
+ return if exposures.empty?
142
+
143
+ puts ""
144
+ puts "Your exposure:"
145
+ exposures.each do |exposure|
146
+ pkg_line = " #{exposure[:package]} #{exposure[:version]} in #{exposure[:manifest_path]}"
147
+ puts Color.send(:red, pkg_line)
148
+
149
+ if exposure[:introduced_by]
150
+ intro = exposure[:introduced_by]
151
+ puts " Added: #{intro[:sha]} #{intro[:date]} #{intro[:author]} \"#{intro[:message]}\""
152
+ end
153
+
154
+ if exposure[:fixed_by]
155
+ fix = exposure[:fixed_by]
156
+ puts Color.send(:green, " Fixed: #{fix[:sha]} #{fix[:date]} #{fix[:author]} \"#{fix[:message]}\"")
157
+ elsif exposure[:status] == "ongoing"
158
+ puts Color.send(:yellow, " Status: Still vulnerable")
159
+ end
160
+ end
161
+ end
162
+
163
+ def find_exposure_for_vuln(vuln, vuln_pkgs, repo)
164
+ exposures = []
165
+ ref = @options[:ref] || "HEAD"
166
+
167
+ begin
168
+ commit_sha = repo.rev_parse(ref)
169
+ target_commit = Models::Commit.first(sha: commit_sha)
170
+ rescue Rugged::ReferenceError
171
+ return exposures
172
+ end
173
+
174
+ return exposures unless target_commit
175
+
176
+ deps = compute_dependencies_at_commit(target_commit, repo)
177
+
178
+ vuln_pkgs.each do |vp|
179
+ ecosystem = Ecosystems.from_osv(vp.ecosystem) || vp.ecosystem.downcase
180
+
181
+ matching_deps = deps.select do |dep|
182
+ dep[:ecosystem] == ecosystem &&
183
+ dep[:name].downcase == vp.package_name.downcase &&
184
+ vp.affects_version?(dep[:requirement])
185
+ end
186
+
187
+ matching_deps.each do |dep|
188
+ exposure = {
189
+ package: dep[:name],
190
+ version: dep[:requirement],
191
+ ecosystem: dep[:ecosystem],
192
+ manifest_path: dep[:manifest_path]
193
+ }
194
+
195
+ intro_change = find_introducing_change(dep[:ecosystem], dep[:name], vp, target_commit)
196
+ exposure[:introduced_by] = format_commit_info(intro_change&.commit) if intro_change
197
+
198
+ fix_change = find_fixing_change(dep[:ecosystem], dep[:name], vp, target_commit, intro_change&.commit&.committed_at)
199
+ if fix_change
200
+ exposure[:fixed_by] = format_commit_info(fix_change.commit)
201
+ exposure[:status] = "fixed"
202
+ else
203
+ exposure[:status] = "ongoing"
204
+ end
205
+
206
+ exposures << exposure
207
+ end
208
+ end
209
+
210
+ exposures
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end