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.
- checksums.yaml +4 -4
- data/.gitattributes +28 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +15 -0
- data/Dockerfile +18 -0
- data/Formula/git-pkgs.rb +28 -0
- data/README.md +36 -4
- data/lib/git/pkgs/analyzer.rb +141 -9
- data/lib/git/pkgs/cli.rb +16 -6
- data/lib/git/pkgs/commands/blame.rb +0 -18
- data/lib/git/pkgs/commands/diff.rb +122 -5
- data/lib/git/pkgs/commands/diff_driver.rb +24 -4
- data/lib/git/pkgs/commands/init.rb +5 -0
- data/lib/git/pkgs/commands/list.rb +60 -15
- data/lib/git/pkgs/commands/show.rb +126 -3
- data/lib/git/pkgs/commands/stale.rb +6 -2
- data/lib/git/pkgs/commands/update.rb +3 -0
- data/lib/git/pkgs/commands/vulns/base.rb +354 -0
- data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
- data/lib/git/pkgs/commands/vulns/diff.rb +172 -0
- data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
- data/lib/git/pkgs/commands/vulns/history.rb +345 -0
- data/lib/git/pkgs/commands/vulns/log.rb +218 -0
- data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
- data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
- data/lib/git/pkgs/commands/vulns/show.rb +216 -0
- data/lib/git/pkgs/commands/vulns/sync.rb +108 -0
- data/lib/git/pkgs/commands/vulns.rb +50 -0
- data/lib/git/pkgs/config.rb +8 -1
- data/lib/git/pkgs/database.rb +135 -5
- data/lib/git/pkgs/ecosystems.rb +83 -0
- data/lib/git/pkgs/models/package.rb +54 -0
- data/lib/git/pkgs/models/vulnerability.rb +300 -0
- data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
- data/lib/git/pkgs/osv_client.rb +151 -0
- data/lib/git/pkgs/output.rb +22 -0
- data/lib/git/pkgs/version.rb +1 -1
- data/lib/git/pkgs.rb +6 -0
- 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
|