git-pkgs 0.7.0 → 0.9.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/CHANGELOG.md +25 -0
- data/Formula/git-pkgs.rb +2 -2
- data/README.md +84 -5
- data/lib/git/pkgs/analyzer.rb +19 -10
- data/lib/git/pkgs/cli.rb +6 -2
- data/lib/git/pkgs/commands/branch.rb +5 -2
- data/lib/git/pkgs/commands/diff_driver.rb +6 -0
- data/lib/git/pkgs/commands/init.rb +5 -2
- data/lib/git/pkgs/commands/integrity.rb +288 -0
- data/lib/git/pkgs/commands/licenses.rb +416 -0
- data/lib/git/pkgs/commands/outdated.rb +312 -0
- data/lib/git/pkgs/commands/sbom.rb +325 -0
- data/lib/git/pkgs/commands/update.rb +3 -1
- data/lib/git/pkgs/commands/vulns/base.rb +16 -12
- data/lib/git/pkgs/commands/vulns/diff.rb +3 -2
- data/lib/git/pkgs/commands/vulns/praise.rb +2 -0
- data/lib/git/pkgs/commands/vulns/sync.rb +30 -28
- data/lib/git/pkgs/config.rb +1 -2
- data/lib/git/pkgs/database.rb +23 -13
- data/lib/git/pkgs/ecosystems_client.rb +142 -0
- data/lib/git/pkgs/models/dependency_change.rb +8 -0
- data/lib/git/pkgs/models/dependency_snapshot.rb +8 -0
- data/lib/git/pkgs/models/package.rb +61 -0
- data/lib/git/pkgs/models/version.rb +56 -0
- data/lib/git/pkgs/purl_helper.rb +56 -0
- data/lib/git/pkgs/spinner.rb +46 -0
- data/lib/git/pkgs/version.rb +1 -1
- data/lib/git/pkgs.rb +9 -0
- metadata +45 -3
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Git
|
|
6
|
+
module Pkgs
|
|
7
|
+
module Commands
|
|
8
|
+
class Outdated
|
|
9
|
+
include Output
|
|
10
|
+
|
|
11
|
+
def self.description
|
|
12
|
+
"Show packages with newer versions available"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(args)
|
|
16
|
+
@args = args.dup
|
|
17
|
+
@options = parse_options
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parse_options
|
|
21
|
+
options = {}
|
|
22
|
+
|
|
23
|
+
parser = OptionParser.new do |opts|
|
|
24
|
+
opts.banner = "Usage: git pkgs outdated [options]"
|
|
25
|
+
opts.separator ""
|
|
26
|
+
opts.separator "Show packages that have newer versions available in their registries."
|
|
27
|
+
opts.separator ""
|
|
28
|
+
opts.separator "Options:"
|
|
29
|
+
|
|
30
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
31
|
+
options[:ecosystem] = v
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
opts.on("-r", "--ref=REF", "Git ref to check (default: HEAD)") do |v|
|
|
35
|
+
options[:ref] = v
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
|
|
39
|
+
options[:format] = v
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
opts.on("--major", "Show only major version updates") do
|
|
43
|
+
options[:major_only] = true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
opts.on("--minor", "Show only minor or major updates (skip patch)") do
|
|
47
|
+
options[:minor_only] = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
opts.on("--stateless", "Parse manifests directly without database") do
|
|
51
|
+
options[:stateless] = true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
opts.on("-h", "--help", "Show this help") do
|
|
55
|
+
puts opts
|
|
56
|
+
exit
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
parser.parse!(@args)
|
|
61
|
+
options
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run
|
|
65
|
+
repo = Repository.new
|
|
66
|
+
use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
|
|
67
|
+
|
|
68
|
+
if use_stateless
|
|
69
|
+
Database.connect_memory
|
|
70
|
+
deps = get_dependencies_stateless(repo)
|
|
71
|
+
else
|
|
72
|
+
Database.connect(repo.git_dir)
|
|
73
|
+
deps = get_dependencies_with_database(repo)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if deps.empty?
|
|
77
|
+
empty_result "No dependencies found"
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if @options[:ecosystem]
|
|
82
|
+
deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
deps_with_versions = Analyzer.lockfile_dependencies(deps).select do |dep|
|
|
86
|
+
dep[:requirement] && !dep[:requirement].match?(/[<>=~^]/)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if deps_with_versions.empty?
|
|
90
|
+
empty_result "No dependencies with pinned versions found"
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
packages_to_check = deps_with_versions.map do |dep|
|
|
95
|
+
purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name]).to_s
|
|
96
|
+
{
|
|
97
|
+
purl: purl,
|
|
98
|
+
name: dep[:name],
|
|
99
|
+
ecosystem: dep[:ecosystem],
|
|
100
|
+
current_version: dep[:requirement],
|
|
101
|
+
manifest_path: dep[:manifest_path]
|
|
102
|
+
}
|
|
103
|
+
end.uniq { |p| p[:purl] }
|
|
104
|
+
|
|
105
|
+
enrich_packages(packages_to_check.map { |p| p[:purl] })
|
|
106
|
+
|
|
107
|
+
outdated = []
|
|
108
|
+
packages_to_check.each do |pkg|
|
|
109
|
+
db_pkg = Models::Package.first(purl: pkg[:purl])
|
|
110
|
+
next unless db_pkg&.latest_version
|
|
111
|
+
|
|
112
|
+
latest = db_pkg.latest_version
|
|
113
|
+
current = pkg[:current_version]
|
|
114
|
+
|
|
115
|
+
next if current == latest
|
|
116
|
+
|
|
117
|
+
update_type = classify_update(current, latest)
|
|
118
|
+
next if @options[:major_only] && update_type != :major
|
|
119
|
+
next if @options[:minor_only] && update_type == :patch
|
|
120
|
+
|
|
121
|
+
outdated << pkg.merge(
|
|
122
|
+
latest_version: latest,
|
|
123
|
+
update_type: update_type
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if outdated.empty?
|
|
128
|
+
puts "All packages are up to date"
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
type_order = { major: 0, minor: 1, patch: 2, unknown: 3 }
|
|
133
|
+
outdated.sort_by! { |o| [type_order[o[:update_type]], o[:name]] }
|
|
134
|
+
|
|
135
|
+
if @options[:format] == "json"
|
|
136
|
+
require "json"
|
|
137
|
+
puts JSON.pretty_generate(outdated)
|
|
138
|
+
else
|
|
139
|
+
output_text(outdated)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def enrich_packages(purls)
|
|
144
|
+
packages_by_purl = {}
|
|
145
|
+
purls.each do |purl|
|
|
146
|
+
parsed = Purl::PackageURL.parse(purl)
|
|
147
|
+
ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
|
|
148
|
+
pkg = Models::Package.find_or_create_by_purl(
|
|
149
|
+
purl: purl,
|
|
150
|
+
ecosystem: ecosystem,
|
|
151
|
+
name: parsed.name
|
|
152
|
+
)
|
|
153
|
+
packages_by_purl[purl] = pkg
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
stale_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
|
|
157
|
+
return if stale_purls.empty?
|
|
158
|
+
|
|
159
|
+
client = EcosystemsClient.new
|
|
160
|
+
begin
|
|
161
|
+
results = Spinner.with_spinner("Fetching package metadata...") do
|
|
162
|
+
client.bulk_lookup(stale_purls)
|
|
163
|
+
end
|
|
164
|
+
results.each do |purl, data|
|
|
165
|
+
packages_by_purl[purl]&.enrich_from_api(data)
|
|
166
|
+
end
|
|
167
|
+
rescue EcosystemsClient::ApiError => e
|
|
168
|
+
$stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def classify_update(current, latest)
|
|
173
|
+
current_parts = parse_version(current)
|
|
174
|
+
latest_parts = parse_version(latest)
|
|
175
|
+
|
|
176
|
+
return :unknown if current_parts.nil? || latest_parts.nil?
|
|
177
|
+
|
|
178
|
+
if latest_parts[0] > current_parts[0]
|
|
179
|
+
:major
|
|
180
|
+
elsif latest_parts[1] > current_parts[1]
|
|
181
|
+
:minor
|
|
182
|
+
elsif latest_parts[2] > current_parts[2]
|
|
183
|
+
:patch
|
|
184
|
+
else
|
|
185
|
+
:unknown
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_version(version)
|
|
190
|
+
cleaned = version.to_s.sub(/^v/i, "")
|
|
191
|
+
parts = cleaned.split(".").first(3).map { |p| p.to_i }
|
|
192
|
+
return nil if parts.empty?
|
|
193
|
+
|
|
194
|
+
parts + [0] * (3 - parts.length)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def output_text(outdated)
|
|
198
|
+
max_name = outdated.map { |o| o[:name].length }.max || 20
|
|
199
|
+
max_current = outdated.map { |o| o[:current_version].length }.max || 10
|
|
200
|
+
max_latest = outdated.map { |o| o[:latest_version].length }.max || 10
|
|
201
|
+
|
|
202
|
+
outdated.each do |pkg|
|
|
203
|
+
name = pkg[:name].ljust(max_name)
|
|
204
|
+
current = pkg[:current_version].ljust(max_current)
|
|
205
|
+
latest = pkg[:latest_version].ljust(max_latest)
|
|
206
|
+
update = pkg[:update_type].to_s
|
|
207
|
+
|
|
208
|
+
line = "#{name} #{current} -> #{latest} (#{update})"
|
|
209
|
+
|
|
210
|
+
colored = case pkg[:update_type]
|
|
211
|
+
when :major then Color.red(line)
|
|
212
|
+
when :minor then Color.yellow(line)
|
|
213
|
+
when :patch then Color.cyan(line)
|
|
214
|
+
else line
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
puts colored
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
puts ""
|
|
221
|
+
summary = "#{outdated.size} outdated package#{"s" if outdated.size != 1}"
|
|
222
|
+
by_type = outdated.group_by { |o| o[:update_type] }
|
|
223
|
+
parts = []
|
|
224
|
+
parts << "#{by_type[:major].size} major" if by_type[:major]&.any?
|
|
225
|
+
parts << "#{by_type[:minor].size} minor" if by_type[:minor]&.any?
|
|
226
|
+
parts << "#{by_type[:patch].size} patch" if by_type[:patch]&.any?
|
|
227
|
+
puts "#{summary}: #{parts.join(", ")}" if parts.any?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def get_dependencies_stateless(repo)
|
|
231
|
+
ref = @options[:ref] || "HEAD"
|
|
232
|
+
commit_sha = repo.rev_parse(ref)
|
|
233
|
+
rugged_commit = repo.lookup(commit_sha)
|
|
234
|
+
|
|
235
|
+
error "Could not resolve '#{ref}'" unless rugged_commit
|
|
236
|
+
|
|
237
|
+
analyzer = Analyzer.new(repo)
|
|
238
|
+
analyzer.dependencies_at_commit(rugged_commit)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def get_dependencies_with_database(repo)
|
|
242
|
+
ref = @options[:ref] || "HEAD"
|
|
243
|
+
commit_sha = repo.rev_parse(ref)
|
|
244
|
+
target_commit = Models::Commit.first(sha: commit_sha)
|
|
245
|
+
|
|
246
|
+
return get_dependencies_stateless(repo) unless target_commit
|
|
247
|
+
|
|
248
|
+
branch_name = repo.default_branch
|
|
249
|
+
branch = Models::Branch.first(name: branch_name)
|
|
250
|
+
return [] unless branch
|
|
251
|
+
|
|
252
|
+
compute_dependencies_at_commit(target_commit, branch)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def compute_dependencies_at_commit(target_commit, branch)
|
|
256
|
+
snapshot_commit = branch.commits_dataset
|
|
257
|
+
.join(:dependency_snapshots, commit_id: :id)
|
|
258
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
259
|
+
.order(Sequel.desc(Sequel[:commits][:committed_at]))
|
|
260
|
+
.distinct
|
|
261
|
+
.first
|
|
262
|
+
|
|
263
|
+
deps = {}
|
|
264
|
+
if snapshot_commit
|
|
265
|
+
snapshot_commit.dependency_snapshots.each do |s|
|
|
266
|
+
key = [s.manifest.path, s.name]
|
|
267
|
+
deps[key] = {
|
|
268
|
+
manifest_path: s.manifest.path,
|
|
269
|
+
manifest_kind: s.manifest.kind,
|
|
270
|
+
name: s.name,
|
|
271
|
+
ecosystem: s.ecosystem,
|
|
272
|
+
requirement: s.requirement,
|
|
273
|
+
dependency_type: s.dependency_type
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if snapshot_commit && snapshot_commit.id != target_commit.id
|
|
279
|
+
commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id])
|
|
280
|
+
changes = Models::DependencyChange
|
|
281
|
+
.join(:commits, id: :commit_id)
|
|
282
|
+
.where(Sequel[:commits][:id] => commit_ids)
|
|
283
|
+
.where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at }
|
|
284
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
285
|
+
.order(Sequel[:commits][:committed_at])
|
|
286
|
+
.eager(:manifest)
|
|
287
|
+
.all
|
|
288
|
+
|
|
289
|
+
changes.each do |change|
|
|
290
|
+
key = [change.manifest.path, change.name]
|
|
291
|
+
case change.change_type
|
|
292
|
+
when "added", "modified"
|
|
293
|
+
deps[key] = {
|
|
294
|
+
manifest_path: change.manifest.path,
|
|
295
|
+
manifest_kind: change.manifest.kind,
|
|
296
|
+
name: change.name,
|
|
297
|
+
ecosystem: change.ecosystem,
|
|
298
|
+
requirement: change.requirement,
|
|
299
|
+
dependency_type: change.dependency_type
|
|
300
|
+
}
|
|
301
|
+
when "removed"
|
|
302
|
+
deps.delete(key)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
deps.values
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "sbom"
|
|
5
|
+
|
|
6
|
+
module Git
|
|
7
|
+
module Pkgs
|
|
8
|
+
module Commands
|
|
9
|
+
class Sbom
|
|
10
|
+
include Output
|
|
11
|
+
|
|
12
|
+
def self.description
|
|
13
|
+
"Export dependencies as SBOM (SPDX or CycloneDX)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(args)
|
|
17
|
+
@args = args.dup
|
|
18
|
+
@options = parse_options
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
repo = Repository.new
|
|
23
|
+
use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
|
|
24
|
+
|
|
25
|
+
if use_stateless
|
|
26
|
+
Database.connect_memory
|
|
27
|
+
deps = get_dependencies_stateless(repo)
|
|
28
|
+
else
|
|
29
|
+
Database.connect(repo.git_dir)
|
|
30
|
+
deps = get_dependencies_with_database(repo)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if deps.empty?
|
|
34
|
+
empty_result "No dependencies found"
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if @options[:ecosystem]
|
|
39
|
+
deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
deps = Analyzer.pair_manifests_with_lockfiles(deps)
|
|
43
|
+
|
|
44
|
+
if deps.empty?
|
|
45
|
+
empty_result "No dependencies found"
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
packages = build_packages(deps)
|
|
50
|
+
enrich_packages(packages) unless @options[:skip_enrichment]
|
|
51
|
+
|
|
52
|
+
output_sbom(repo, packages)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_packages(deps)
|
|
56
|
+
deps.map do |dep|
|
|
57
|
+
purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:requirement])
|
|
58
|
+
{
|
|
59
|
+
purl: purl.to_s,
|
|
60
|
+
name: dep[:name],
|
|
61
|
+
ecosystem: dep[:ecosystem],
|
|
62
|
+
version: dep[:requirement],
|
|
63
|
+
integrity: dep[:integrity]
|
|
64
|
+
}
|
|
65
|
+
end.uniq { |p| p[:purl] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def enrich_packages(packages)
|
|
69
|
+
client = EcosystemsClient.new
|
|
70
|
+
|
|
71
|
+
# Enrich package-level data (license, latest version)
|
|
72
|
+
base_purls = packages.map { |p| PurlHelper.build_purl(ecosystem: p[:ecosystem], name: p[:name]).to_s }
|
|
73
|
+
|
|
74
|
+
packages_by_purl = {}
|
|
75
|
+
base_purls.each do |purl|
|
|
76
|
+
parsed = Purl::PackageURL.parse(purl)
|
|
77
|
+
ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
|
|
78
|
+
pkg = Models::Package.find_or_create_by_purl(
|
|
79
|
+
purl: purl,
|
|
80
|
+
ecosystem: ecosystem,
|
|
81
|
+
name: parsed.name
|
|
82
|
+
)
|
|
83
|
+
packages_by_purl[purl] = pkg
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
stale_pkg_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
|
|
87
|
+
|
|
88
|
+
if stale_pkg_purls.any?
|
|
89
|
+
begin
|
|
90
|
+
results = Spinner.with_spinner("Fetching package metadata...") do
|
|
91
|
+
client.bulk_lookup(stale_pkg_purls)
|
|
92
|
+
end
|
|
93
|
+
results.each do |purl, data|
|
|
94
|
+
packages_by_purl[purl]&.enrich_from_api(data)
|
|
95
|
+
end
|
|
96
|
+
rescue EcosystemsClient::ApiError => e
|
|
97
|
+
$stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Enrich version-level data (integrity, published_at)
|
|
102
|
+
versions_by_purl = {}
|
|
103
|
+
packages.each do |pkg|
|
|
104
|
+
base_purl = PurlHelper.build_purl(ecosystem: pkg[:ecosystem], name: pkg[:name]).to_s
|
|
105
|
+
version = Models::Version.find_or_create_by_purl(
|
|
106
|
+
purl: pkg[:purl],
|
|
107
|
+
package_purl: base_purl
|
|
108
|
+
)
|
|
109
|
+
versions_by_purl[pkg[:purl]] = version
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
stale_version_purls = versions_by_purl.select { |_, v| v.needs_enrichment? }.keys
|
|
113
|
+
|
|
114
|
+
if stale_version_purls.any?
|
|
115
|
+
begin
|
|
116
|
+
Spinner.with_spinner("Fetching version metadata...") do
|
|
117
|
+
stale_version_purls.each do |purl|
|
|
118
|
+
data = client.lookup_version(purl)
|
|
119
|
+
versions_by_purl[purl]&.enrich_from_api(data) if data
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
rescue EcosystemsClient::ApiError => e
|
|
123
|
+
$stderr.puts "Warning: Could not fetch version data: #{e.message}" unless Git::Pkgs.quiet
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Apply enriched data to packages
|
|
128
|
+
packages.each do |pkg|
|
|
129
|
+
base_purl = PurlHelper.build_purl(ecosystem: pkg[:ecosystem], name: pkg[:name]).to_s
|
|
130
|
+
db_pkg = packages_by_purl[base_purl]
|
|
131
|
+
db_version = versions_by_purl[pkg[:purl]]
|
|
132
|
+
|
|
133
|
+
pkg[:license] ||= db_version&.license || db_pkg&.license
|
|
134
|
+
pkg[:integrity] ||= db_version&.integrity
|
|
135
|
+
pkg[:supplier_name] ||= db_pkg&.supplier_name
|
|
136
|
+
pkg[:supplier_type] ||= db_pkg&.supplier_type
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def output_sbom(repo, packages)
|
|
141
|
+
sbom_type = @options[:type]&.to_sym || :cyclonedx
|
|
142
|
+
format = @options[:format]&.to_sym || :json
|
|
143
|
+
|
|
144
|
+
generator = ::Sbom::Generator.new(sbom_type: sbom_type, format: format)
|
|
145
|
+
|
|
146
|
+
sbom_packages = packages.map do |pkg|
|
|
147
|
+
sbom_pkg = ::Sbom::Data::Package.new
|
|
148
|
+
sbom_pkg.name = pkg[:name]
|
|
149
|
+
sbom_pkg.version = pkg[:version]
|
|
150
|
+
sbom_pkg.purl = pkg[:purl]
|
|
151
|
+
sbom_pkg.license_concluded = pkg[:license] if pkg[:license]
|
|
152
|
+
|
|
153
|
+
if pkg[:supplier_name]
|
|
154
|
+
sbom_pkg.set_supplier(pkg[:supplier_type] || "organization", pkg[:supplier_name])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if pkg[:integrity]
|
|
158
|
+
algorithm, hash = parse_integrity(pkg[:integrity])
|
|
159
|
+
sbom_pkg.add_checksum(algorithm, hash) if algorithm && hash
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
sbom_pkg
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
project_name = @options[:name] || File.basename(repo.path)
|
|
166
|
+
generator.generate(project_name, { packages: sbom_packages })
|
|
167
|
+
puts generator.output
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_integrity(integrity)
|
|
171
|
+
return nil unless integrity
|
|
172
|
+
|
|
173
|
+
case integrity
|
|
174
|
+
when /^sha256[-:=](.+)$/i
|
|
175
|
+
["SHA256", $1]
|
|
176
|
+
when /^sha512[-:=](.+)$/i
|
|
177
|
+
["SHA512", $1]
|
|
178
|
+
when /^sha1[-:=](.+)$/i
|
|
179
|
+
["SHA1", $1]
|
|
180
|
+
when /^md5[-:=](.+)$/i
|
|
181
|
+
["MD5", $1]
|
|
182
|
+
when /^h1:(.+)$/
|
|
183
|
+
# Go modules use base64-encoded SHA256 in go.sum
|
|
184
|
+
# SPDX/CycloneDX require hex, so convert
|
|
185
|
+
require "base64"
|
|
186
|
+
hex = Base64.decode64($1).unpack1("H*")
|
|
187
|
+
["SHA256", hex]
|
|
188
|
+
else
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def parse_options
|
|
194
|
+
options = {}
|
|
195
|
+
|
|
196
|
+
parser = OptionParser.new do |opts|
|
|
197
|
+
opts.banner = "Usage: git pkgs sbom [options]"
|
|
198
|
+
opts.separator ""
|
|
199
|
+
opts.separator "Export dependencies as SBOM (Software Bill of Materials)."
|
|
200
|
+
opts.separator ""
|
|
201
|
+
opts.separator "Options:"
|
|
202
|
+
|
|
203
|
+
opts.on("-t", "--type=TYPE", "SBOM type: cyclonedx (default) or spdx") do |v|
|
|
204
|
+
options[:type] = v.downcase
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
opts.on("-f", "--format=FORMAT", "Output format: json (default) or xml") do |v|
|
|
208
|
+
options[:format] = v.downcase
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
opts.on("-n", "--name=NAME", "Project name (default: repository directory name)") do |v|
|
|
212
|
+
options[:name] = v
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
216
|
+
options[:ecosystem] = v
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
opts.on("-r", "--ref=REF", "Git ref to export (default: HEAD)") do |v|
|
|
220
|
+
options[:ref] = v
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
opts.on("--skip-enrichment", "Skip fetching license data from registries") do
|
|
224
|
+
options[:skip_enrichment] = true
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
opts.on("--stateless", "Parse manifests directly without database") do
|
|
228
|
+
options[:stateless] = true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
opts.on("-h", "--help", "Show this help") do
|
|
232
|
+
puts opts
|
|
233
|
+
exit
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
parser.parse!(@args)
|
|
238
|
+
options
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def get_dependencies_stateless(repo)
|
|
242
|
+
ref = @options[:ref] || "HEAD"
|
|
243
|
+
commit_sha = repo.rev_parse(ref)
|
|
244
|
+
rugged_commit = repo.lookup(commit_sha)
|
|
245
|
+
|
|
246
|
+
error "Could not resolve '#{ref}'" unless rugged_commit
|
|
247
|
+
|
|
248
|
+
analyzer = Analyzer.new(repo)
|
|
249
|
+
analyzer.dependencies_at_commit(rugged_commit)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def get_dependencies_with_database(repo)
|
|
253
|
+
ref = @options[:ref] || "HEAD"
|
|
254
|
+
commit_sha = repo.rev_parse(ref)
|
|
255
|
+
target_commit = Models::Commit.first(sha: commit_sha)
|
|
256
|
+
|
|
257
|
+
return get_dependencies_stateless(repo) unless target_commit
|
|
258
|
+
|
|
259
|
+
branch_name = repo.default_branch
|
|
260
|
+
branch = Models::Branch.first(name: branch_name)
|
|
261
|
+
return [] unless branch
|
|
262
|
+
|
|
263
|
+
compute_dependencies_at_commit(target_commit, branch)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def compute_dependencies_at_commit(target_commit, branch)
|
|
267
|
+
snapshot_commit = branch.commits_dataset
|
|
268
|
+
.join(:dependency_snapshots, commit_id: :id)
|
|
269
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
270
|
+
.order(Sequel.desc(Sequel[:commits][:committed_at]))
|
|
271
|
+
.distinct
|
|
272
|
+
.first
|
|
273
|
+
|
|
274
|
+
deps = {}
|
|
275
|
+
if snapshot_commit
|
|
276
|
+
snapshot_commit.dependency_snapshots.each do |s|
|
|
277
|
+
key = [s.manifest.path, s.name]
|
|
278
|
+
deps[key] = {
|
|
279
|
+
manifest_path: s.manifest.path,
|
|
280
|
+
manifest_kind: s.manifest.kind,
|
|
281
|
+
name: s.name,
|
|
282
|
+
ecosystem: s.ecosystem,
|
|
283
|
+
requirement: s.requirement,
|
|
284
|
+
dependency_type: s.dependency_type,
|
|
285
|
+
integrity: s.integrity
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
if snapshot_commit && snapshot_commit.id != target_commit.id
|
|
291
|
+
commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id])
|
|
292
|
+
changes = Models::DependencyChange
|
|
293
|
+
.join(:commits, id: :commit_id)
|
|
294
|
+
.where(Sequel[:commits][:id] => commit_ids)
|
|
295
|
+
.where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at }
|
|
296
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
297
|
+
.order(Sequel[:commits][:committed_at])
|
|
298
|
+
.eager(:manifest)
|
|
299
|
+
.all
|
|
300
|
+
|
|
301
|
+
changes.each do |change|
|
|
302
|
+
key = [change.manifest.path, change.name]
|
|
303
|
+
case change.change_type
|
|
304
|
+
when "added", "modified"
|
|
305
|
+
deps[key] = {
|
|
306
|
+
manifest_path: change.manifest.path,
|
|
307
|
+
manifest_kind: change.manifest.kind,
|
|
308
|
+
name: change.name,
|
|
309
|
+
ecosystem: change.ecosystem,
|
|
310
|
+
requirement: change.requirement,
|
|
311
|
+
dependency_type: change.dependency_type,
|
|
312
|
+
integrity: nil
|
|
313
|
+
}
|
|
314
|
+
when "removed"
|
|
315
|
+
deps.delete(key)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
deps.values
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -43,7 +43,8 @@ module Git
|
|
|
43
43
|
ecosystem: s.ecosystem,
|
|
44
44
|
purl: s.purl,
|
|
45
45
|
requirement: s.requirement,
|
|
46
|
-
dependency_type: s.dependency_type
|
|
46
|
+
dependency_type: s.dependency_type,
|
|
47
|
+
integrity: s.integrity
|
|
47
48
|
}
|
|
48
49
|
end
|
|
49
50
|
end
|
|
@@ -110,6 +111,7 @@ module Git
|
|
|
110
111
|
s.purl = dep_info[:purl]
|
|
111
112
|
s.requirement = dep_info[:requirement]
|
|
112
113
|
s.dependency_type = dep_info[:dependency_type]
|
|
114
|
+
s.integrity = dep_info[:integrity]
|
|
113
115
|
end
|
|
114
116
|
end
|
|
115
117
|
end
|
|
@@ -143,7 +143,9 @@ module Git
|
|
|
143
143
|
|
|
144
144
|
client = OsvClient.new
|
|
145
145
|
results = begin
|
|
146
|
-
|
|
146
|
+
Spinner.with_spinner("Checking vulnerabilities...") do
|
|
147
|
+
client.query_batch(packages.map { |p| p.slice(:ecosystem, :name, :version) })
|
|
148
|
+
end
|
|
147
149
|
rescue OsvClient::ApiError => e
|
|
148
150
|
error "Failed to query OSV API: #{e.message}"
|
|
149
151
|
end
|
|
@@ -176,20 +178,22 @@ module Git
|
|
|
176
178
|
return if packages_to_sync.empty?
|
|
177
179
|
|
|
178
180
|
client = OsvClient.new
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
Spinner.with_spinner("Syncing vulnerability data...") do
|
|
182
|
+
packages_to_sync.each_slice(100) do |batch|
|
|
183
|
+
queries = batch.map do |pkg|
|
|
184
|
+
osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
|
|
185
|
+
next unless osv_ecosystem
|
|
183
186
|
|
|
184
|
-
|
|
185
|
-
|
|
187
|
+
{ ecosystem: osv_ecosystem, name: pkg.name }
|
|
188
|
+
end.compact
|
|
186
189
|
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
results = client.query_batch(queries)
|
|
191
|
+
fetch_vulnerability_details(client, results)
|
|
189
192
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
batch.each do |pkg|
|
|
194
|
+
purl = Ecosystems.generate_purl(pkg.ecosystem, pkg.name)
|
|
195
|
+
mark_package_synced(purl, pkg.ecosystem, pkg.name) if purl
|
|
196
|
+
end
|
|
193
197
|
end
|
|
194
198
|
end
|
|
195
199
|
end
|