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,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class History
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 history <package|cve> [options]"
20
+ opts.separator ""
21
+ opts.separator "Show vulnerability timeline for a specific package or CVE."
22
+ opts.separator ""
23
+ opts.separator "Arguments:"
24
+ opts.separator " package|cve Package name or CVE/GHSA ID"
25
+ opts.separator ""
26
+ opts.separator "Examples:"
27
+ opts.separator " git pkgs vulns history lodash"
28
+ opts.separator " git pkgs vulns history CVE-2024-1234"
29
+ opts.separator " git pkgs vulns history GHSA-xxxx-yyyy"
30
+ opts.separator ""
31
+ opts.separator "Options:"
32
+
33
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
34
+ options[:ecosystem] = v
35
+ end
36
+
37
+ opts.on("--since=DATE", "Show events after date") do |v|
38
+ options[:since] = v
39
+ end
40
+
41
+ opts.on("--until=DATE", "Show events before date") do |v|
42
+ options[:until] = v
43
+ end
44
+
45
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
46
+ options[:format] = v
47
+ end
48
+
49
+ opts.on("-h", "--help", "Show this help") do
50
+ puts opts
51
+ exit
52
+ end
53
+ end
54
+
55
+ parser.parse!(@args)
56
+ options[:target] = @args.shift
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. History requires commit history."
65
+ end
66
+
67
+ Database.connect(repo.git_dir)
68
+
69
+ target = @options[:target]
70
+ error "Usage: git pkgs vulns history <package|cve>" unless target
71
+
72
+ if target.match?(/^(CVE-|GHSA-)/i)
73
+ run_cve_history(target.upcase, repo)
74
+ else
75
+ run_package_history(target, repo)
76
+ end
77
+ end
78
+
79
+ def run_cve_history(cve_id, repo)
80
+ ensure_vulns_synced
81
+
82
+ vuln = Models::Vulnerability.first(id: cve_id)
83
+ unless vuln
84
+ error "Vulnerability #{cve_id} not found. Run 'git pkgs vulns sync' first."
85
+ end
86
+
87
+ vuln_pkgs = Models::VulnerabilityPackage.where(vulnerability_id: cve_id).all
88
+
89
+ if vuln_pkgs.empty?
90
+ puts "No affected packages found for #{cve_id}"
91
+ return
92
+ end
93
+
94
+ timeline = []
95
+
96
+ if vuln.published_at
97
+ timeline << {
98
+ date: vuln.published_at,
99
+ event_type: :cve_published,
100
+ description: "#{cve_id} published",
101
+ severity: vuln.severity
102
+ }
103
+ end
104
+
105
+ vuln_pkgs.each do |vp|
106
+ ecosystem = Ecosystems.from_osv(vp.ecosystem) || vp.ecosystem.downcase
107
+ changes = Models::DependencyChange
108
+ .join(:commits, id: :commit_id)
109
+ .where(ecosystem: ecosystem, name: vp.package_name)
110
+ .order(Sequel[:commits][:committed_at])
111
+ .eager(:commit)
112
+ .all
113
+
114
+ changes.each do |change|
115
+ current_affected = change.requirement && vp.affects_version?(change.requirement)
116
+ previous_affected = change.previous_requirement && vp.affects_version?(change.previous_requirement)
117
+
118
+ event = nil
119
+ case change.change_type
120
+ when "added"
121
+ if current_affected
122
+ event = {
123
+ date: change.commit.committed_at,
124
+ event_type: :vulnerable_added,
125
+ description: "#{vp.package_name} #{change.requirement} added (vulnerable)",
126
+ commit: format_commit_info(change.commit)
127
+ }
128
+ end
129
+ when "modified"
130
+ if current_affected && !previous_affected
131
+ event = {
132
+ date: change.commit.committed_at,
133
+ event_type: :became_vulnerable,
134
+ description: "#{vp.package_name} updated to #{change.requirement} (vulnerable)",
135
+ commit: format_commit_info(change.commit)
136
+ }
137
+ elsif !current_affected && previous_affected
138
+ event = {
139
+ date: change.commit.committed_at,
140
+ event_type: :fixed,
141
+ description: "#{vp.package_name} updated to #{change.requirement} (fixed)",
142
+ commit: format_commit_info(change.commit)
143
+ }
144
+ end
145
+ when "removed"
146
+ if previous_affected
147
+ event = {
148
+ date: change.commit.committed_at,
149
+ event_type: :removed,
150
+ description: "#{vp.package_name} removed",
151
+ commit: format_commit_info(change.commit)
152
+ }
153
+ end
154
+ end
155
+
156
+ timeline << event if event
157
+ end
158
+ end
159
+
160
+ timeline = filter_timeline_by_date(timeline)
161
+ timeline.sort_by! { |e| e[:date] }
162
+
163
+ if timeline.empty?
164
+ puts "No history found for #{cve_id}"
165
+ return
166
+ end
167
+
168
+ if @options[:format] == "json"
169
+ require "json"
170
+ puts JSON.pretty_generate({
171
+ cve: cve_id,
172
+ severity: vuln.severity,
173
+ summary: vuln.summary,
174
+ published_at: vuln.published_at&.strftime("%Y-%m-%d"),
175
+ timeline: timeline.map { |e| e.merge(date: e[:date].strftime("%Y-%m-%d")) }
176
+ })
177
+ else
178
+ output_cve_timeline(cve_id, vuln, timeline)
179
+ end
180
+ end
181
+
182
+ def run_package_history(package_name, repo)
183
+ ensure_vulns_synced
184
+
185
+ ecosystem = @options[:ecosystem]
186
+
187
+ changes_query = Models::DependencyChange
188
+ .join(:commits, id: :commit_id)
189
+ .where(Sequel.ilike(:name, package_name))
190
+ .order(Sequel[:commits][:committed_at])
191
+ .eager(:commit, :manifest)
192
+
193
+ changes_query = changes_query.where(ecosystem: ecosystem) if ecosystem
194
+
195
+ changes = changes_query.all
196
+
197
+ if changes.empty?
198
+ puts "No history found for package '#{package_name}'"
199
+ return
200
+ end
201
+
202
+ osv_ecosystem = ecosystem ? Ecosystems.to_osv(ecosystem) : nil
203
+ vuln_query = Models::VulnerabilityPackage.where(Sequel.ilike(:package_name, package_name))
204
+ vuln_query = vuln_query.where(ecosystem: osv_ecosystem) if osv_ecosystem
205
+
206
+ vuln_pkgs = vuln_query.eager(:vulnerability).all
207
+
208
+ timeline = []
209
+
210
+ changes.each do |change|
211
+ affected_vulns = vuln_pkgs.select do |vp|
212
+ change.requirement && vp.affects_version?(change.requirement)
213
+ end
214
+
215
+ active_vulns = affected_vulns.reject { |vp| vp.vulnerability&.withdrawn? }
216
+ withdrawn_vulns = affected_vulns.select { |vp| vp.vulnerability&.withdrawn? }
217
+
218
+ vuln_parts = []
219
+ vuln_parts << "vulnerable to #{active_vulns.map(&:vulnerability_id).join(", ")}" if active_vulns.any?
220
+ vuln_parts << "#{withdrawn_vulns.map(&:vulnerability_id).join(", ")} withdrawn" if withdrawn_vulns.any?
221
+ vuln_info = vuln_parts.any? ? "(#{vuln_parts.join("; ")})" : ""
222
+
223
+ event = {
224
+ date: change.commit.committed_at,
225
+ event_type: change.change_type.to_sym,
226
+ description: "#{change.change_type.capitalize} #{package_name} #{change.requirement} #{vuln_info}".strip,
227
+ version: change.requirement,
228
+ commit: format_commit_info(change.commit),
229
+ affected_vulns: active_vulns.map(&:vulnerability_id),
230
+ withdrawn_vulns: withdrawn_vulns.map(&:vulnerability_id)
231
+ }
232
+
233
+ timeline << event
234
+ end
235
+
236
+ vuln_pkgs.each do |vp|
237
+ vuln = vp.vulnerability
238
+ next unless vuln&.published_at
239
+
240
+ withdrawn_note = vuln.withdrawn? ? " [withdrawn]" : ""
241
+ timeline << {
242
+ date: vuln.published_at,
243
+ event_type: :cve_published,
244
+ description: "#{vp.vulnerability_id} published (#{vuln.severity || "unknown"} severity)#{withdrawn_note}"
245
+ }
246
+
247
+ if vuln.withdrawn? && vuln.withdrawn_at
248
+ timeline << {
249
+ date: vuln.withdrawn_at,
250
+ event_type: :cve_withdrawn,
251
+ description: "#{vp.vulnerability_id} withdrawn"
252
+ }
253
+ end
254
+ end
255
+
256
+ timeline = filter_timeline_by_date(timeline)
257
+ timeline.sort_by! { |e| e[:date] }
258
+
259
+ if timeline.empty?
260
+ puts "No history found for package '#{package_name}'"
261
+ return
262
+ end
263
+
264
+ if @options[:format] == "json"
265
+ require "json"
266
+ puts JSON.pretty_generate({
267
+ package: package_name,
268
+ timeline: timeline.map { |e| e.merge(date: e[:date].strftime("%Y-%m-%d")) }
269
+ })
270
+ else
271
+ output_package_timeline(package_name, timeline)
272
+ end
273
+ end
274
+
275
+ def filter_timeline_by_date(timeline)
276
+ if @options[:since]
277
+ since_time = parse_date(@options[:since])
278
+ timeline = timeline.select { |e| e[:date] >= since_time } if since_time
279
+ end
280
+
281
+ if @options[:until]
282
+ until_time = parse_date(@options[:until])
283
+ timeline = timeline.select { |e| e[:date] <= until_time } if until_time
284
+ end
285
+
286
+ timeline
287
+ end
288
+
289
+ def output_cve_timeline(cve_id, vuln, timeline)
290
+ puts "#{cve_id} (#{vuln.severity || "unknown"} severity)"
291
+ puts vuln.summary if vuln.summary
292
+ puts ""
293
+
294
+ timeline.each do |event|
295
+ date = event[:date].strftime("%Y-%m-%d")
296
+ desc = event[:description]
297
+
298
+ line = if event[:commit]
299
+ "#{date} #{desc} #{event[:commit][:sha]} #{event[:commit][:author]}"
300
+ else
301
+ "#{date} #{desc}"
302
+ end
303
+
304
+ colored_line = case event[:event_type]
305
+ when :cve_published then Color.yellow(line)
306
+ when :vulnerable_added, :became_vulnerable then Color.red(line)
307
+ when :fixed, :removed then Color.green(line)
308
+ else line
309
+ end
310
+ puts colored_line
311
+ end
312
+ end
313
+
314
+ def output_package_timeline(package_name, timeline)
315
+ puts "History for #{package_name}"
316
+ puts ""
317
+
318
+ timeline.each do |event|
319
+ date = event[:date].strftime("%Y-%m-%d")
320
+ desc = event[:description]
321
+
322
+ line = if event[:commit]
323
+ "#{date} #{desc} #{event[:commit][:sha]} #{event[:commit][:author]}"
324
+ else
325
+ "#{date} #{desc}"
326
+ end
327
+
328
+ colored_line = case event[:event_type]
329
+ when :cve_published then Color.yellow(line)
330
+ when :cve_withdrawn then Color.cyan(line)
331
+ when :added
332
+ event[:affected_vulns]&.any? ? Color.red(line) : line
333
+ when :modified
334
+ event[:affected_vulns]&.any? ? Color.red(line) : Color.green(line)
335
+ when :removed then Color.cyan(line)
336
+ else line
337
+ end
338
+ puts colored_line
339
+ end
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class Log
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 log [options]"
20
+ opts.separator ""
21
+ opts.separator "Show commits that introduced or fixed vulnerabilities."
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("--since=DATE", "Show commits after date") do |v|
38
+ options[:since] = v
39
+ end
40
+
41
+ opts.on("--until=DATE", "Show commits before date") do |v|
42
+ options[:until] = v
43
+ end
44
+
45
+ opts.on("--author=NAME", "Filter by author") do |v|
46
+ options[:author] = v
47
+ end
48
+
49
+ opts.on("--introduced", "Show only commits that introduced vulnerabilities") do
50
+ options[:introduced] = true
51
+ end
52
+
53
+ opts.on("--fixed", "Show only commits that fixed vulnerabilities") do
54
+ options[:fixed] = true
55
+ end
56
+
57
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
58
+ options[:format] = v
59
+ end
60
+
61
+ opts.on("-h", "--help", "Show this help") do
62
+ puts opts
63
+ exit
64
+ end
65
+ end
66
+
67
+ parser.parse!(@args)
68
+ options
69
+ end
70
+
71
+ def run
72
+ repo = Repository.new
73
+
74
+ unless Database.exists?(repo.git_dir)
75
+ error "No database found. Run 'git pkgs init' first. Log requires commit history."
76
+ end
77
+
78
+ Database.connect(repo.git_dir)
79
+
80
+ commits_with_vulns = find_commits_with_vuln_changes(repo)
81
+
82
+ if commits_with_vulns.empty?
83
+ puts "No commits with vulnerability changes found"
84
+ return
85
+ end
86
+
87
+ if @options[:format] == "json"
88
+ require "json"
89
+ puts JSON.pretty_generate(commits_with_vulns)
90
+ else
91
+ output_vuln_log(commits_with_vulns)
92
+ end
93
+ end
94
+
95
+ def find_commits_with_vuln_changes(repo)
96
+ branch_name = @options[:branch] || repo.default_branch
97
+ branch = Models::Branch.first(name: branch_name)
98
+ return [] unless branch
99
+
100
+ commits_query = Models::Commit
101
+ .join(:branch_commits, commit_id: :id)
102
+ .where(Sequel[:branch_commits][:branch_id] => branch.id)
103
+ .where(has_dependency_changes: true)
104
+ .order(Sequel.desc(Sequel[:commits][:committed_at]))
105
+
106
+ if @options[:since]
107
+ since_time = parse_date(@options[:since])
108
+ commits_query = commits_query.where { Sequel[:commits][:committed_at] >= since_time }
109
+ end
110
+
111
+ if @options[:until]
112
+ until_time = parse_date(@options[:until])
113
+ commits_query = commits_query.where { Sequel[:commits][:committed_at] <= until_time }
114
+ end
115
+
116
+ if @options[:author]
117
+ commits_query = commits_query.where(Sequel.ilike(:author_name, "%#{@options[:author]}%"))
118
+ end
119
+
120
+ commits = commits_query.all
121
+ results = []
122
+
123
+ ensure_vulns_synced
124
+
125
+ commits.each do |commit|
126
+ changes = commit.dependency_changes.to_a
127
+ vuln_changes = []
128
+
129
+ changes.each do |change|
130
+ next unless Ecosystems.supported?(change.ecosystem)
131
+
132
+ osv_ecosystem = Ecosystems.to_osv(change.ecosystem)
133
+ next unless osv_ecosystem
134
+
135
+ vuln_pkgs = Models::VulnerabilityPackage
136
+ .where(ecosystem: osv_ecosystem, package_name: change.name)
137
+ .eager(:vulnerability)
138
+ .all
139
+
140
+ vuln_pkgs.each do |vp|
141
+ next if vp.vulnerability&.withdrawn?
142
+
143
+ current_affected = change.requirement && vp.affects_version?(change.requirement)
144
+ previous_affected = change.previous_requirement && vp.affects_version?(change.previous_requirement)
145
+
146
+ case change.change_type
147
+ when "added"
148
+ if current_affected
149
+ vuln_changes << { type: :introduced, vuln_id: vp.vulnerability_id, severity: vp.vulnerability&.severity }
150
+ end
151
+ when "modified"
152
+ if current_affected && !previous_affected
153
+ vuln_changes << { type: :introduced, vuln_id: vp.vulnerability_id, severity: vp.vulnerability&.severity }
154
+ elsif !current_affected && previous_affected
155
+ vuln_changes << { type: :fixed, vuln_id: vp.vulnerability_id, severity: vp.vulnerability&.severity }
156
+ end
157
+ when "removed"
158
+ if previous_affected
159
+ vuln_changes << { type: :fixed, vuln_id: vp.vulnerability_id, severity: vp.vulnerability&.severity }
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ next if vuln_changes.empty?
166
+
167
+ if @options[:introduced]
168
+ vuln_changes = vuln_changes.select { |vc| vc[:type] == :introduced }
169
+ elsif @options[:fixed]
170
+ vuln_changes = vuln_changes.select { |vc| vc[:type] == :fixed }
171
+ end
172
+
173
+ next if vuln_changes.empty?
174
+
175
+ results << {
176
+ sha: commit.sha[0, 7],
177
+ full_sha: commit.sha,
178
+ date: commit.committed_at&.strftime("%Y-%m-%d"),
179
+ author: commit.author_name,
180
+ message: commit.message&.lines&.first&.strip&.slice(0, 40),
181
+ vuln_changes: vuln_changes
182
+ }
183
+ end
184
+
185
+ results
186
+ end
187
+
188
+ def output_vuln_log(results)
189
+ results.each do |result|
190
+ sha = result[:sha]
191
+ date = result[:date]
192
+ author = result[:author]
193
+ message = result[:message]
194
+
195
+ vuln_summary = result[:vuln_changes].map do |vc|
196
+ prefix = vc[:type] == :introduced ? "+" : "-"
197
+ "#{prefix}#{vc[:vuln_id]}"
198
+ end.join(" ")
199
+
200
+ introduced_count = result[:vuln_changes].count { |vc| vc[:type] == :introduced }
201
+ fixed_count = result[:vuln_changes].count { |vc| vc[:type] == :fixed }
202
+
203
+ line = "#{sha} #{date} #{author.to_s.ljust(15)[0, 15]} \"#{message}\" #{vuln_summary}"
204
+ colored_line = if introduced_count > fixed_count
205
+ Color.red(line)
206
+ elsif fixed_count > introduced_count
207
+ Color.green(line)
208
+ else
209
+ line
210
+ end
211
+ puts colored_line
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end