git-pkgs 0.6.1 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +28 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +23 -0
  5. data/Dockerfile +18 -0
  6. data/Formula/git-pkgs.rb +28 -0
  7. data/README.md +69 -5
  8. data/lib/git/pkgs/analyzer.rb +140 -8
  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 +181 -7
  12. data/lib/git/pkgs/commands/diff_driver.rb +25 -5
  13. data/lib/git/pkgs/commands/init.rb +5 -0
  14. data/lib/git/pkgs/commands/list.rb +68 -15
  15. data/lib/git/pkgs/commands/show.rb +126 -3
  16. data/lib/git/pkgs/commands/stale.rb +38 -4
  17. data/lib/git/pkgs/commands/tree.rb +44 -2
  18. data/lib/git/pkgs/commands/update.rb +3 -0
  19. data/lib/git/pkgs/commands/vulns/base.rb +354 -0
  20. data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
  21. data/lib/git/pkgs/commands/vulns/diff.rb +172 -0
  22. data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
  23. data/lib/git/pkgs/commands/vulns/history.rb +345 -0
  24. data/lib/git/pkgs/commands/vulns/log.rb +218 -0
  25. data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
  26. data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
  27. data/lib/git/pkgs/commands/vulns/show.rb +216 -0
  28. data/lib/git/pkgs/commands/vulns/sync.rb +108 -0
  29. data/lib/git/pkgs/commands/vulns.rb +50 -0
  30. data/lib/git/pkgs/commands/why.rb +40 -1
  31. data/lib/git/pkgs/config.rb +10 -2
  32. data/lib/git/pkgs/database.rb +135 -5
  33. data/lib/git/pkgs/ecosystems.rb +83 -0
  34. data/lib/git/pkgs/models/package.rb +54 -0
  35. data/lib/git/pkgs/models/vulnerability.rb +300 -0
  36. data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
  37. data/lib/git/pkgs/osv_client.rb +151 -0
  38. data/lib/git/pkgs/output.rb +22 -0
  39. data/lib/git/pkgs/version.rb +1 -1
  40. data/lib/git/pkgs.rb +77 -0
  41. metadata +66 -4
@@ -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
@@ -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