git-pkgs 0.6.2 → 0.8.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 +25 -0
- data/Dockerfile +18 -0
- data/Formula/git-pkgs.rb +28 -0
- data/README.md +90 -6
- data/lib/git/pkgs/analyzer.rb +142 -10
- data/lib/git/pkgs/cli.rb +20 -8
- 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 +30 -4
- data/lib/git/pkgs/commands/init.rb +5 -0
- data/lib/git/pkgs/commands/licenses.rb +378 -0
- data/lib/git/pkgs/commands/list.rb +60 -15
- data/lib/git/pkgs/commands/outdated.rb +312 -0
- 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 +358 -0
- data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
- data/lib/git/pkgs/commands/vulns/diff.rb +173 -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 +110 -0
- data/lib/git/pkgs/commands/vulns.rb +50 -0
- data/lib/git/pkgs/config.rb +8 -1
- data/lib/git/pkgs/database.rb +151 -5
- data/lib/git/pkgs/ecosystems.rb +83 -0
- data/lib/git/pkgs/ecosystems_client.rb +96 -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 +92 -0
- data/lib/git/pkgs/models/version.rb +27 -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/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 +12 -0
- metadata +72 -4
|
@@ -13,9 +13,7 @@ module Git
|
|
|
13
13
|
|
|
14
14
|
def run
|
|
15
15
|
repo = Repository.new
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
Database.connect(repo.git_dir)
|
|
16
|
+
use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
|
|
19
17
|
|
|
20
18
|
from_ref, to_ref = parse_range_argument
|
|
21
19
|
from_ref ||= @options[:from]
|
|
@@ -30,6 +28,46 @@ module Git
|
|
|
30
28
|
error "Could not resolve '#{from_ref}'. Check that the ref exists." unless from_sha
|
|
31
29
|
error "Could not resolve '#{to_ref}'. Check that the ref exists." unless to_sha
|
|
32
30
|
|
|
31
|
+
if use_stateless
|
|
32
|
+
run_stateless(repo, from_sha, to_sha)
|
|
33
|
+
else
|
|
34
|
+
run_with_database(repo, from_sha, to_sha)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_stateless(repo, from_sha, to_sha)
|
|
39
|
+
from_commit = repo.lookup(from_sha)
|
|
40
|
+
to_commit = repo.lookup(to_sha)
|
|
41
|
+
|
|
42
|
+
analyzer = Analyzer.new(repo)
|
|
43
|
+
diff = analyzer.diff_commits(from_commit, to_commit)
|
|
44
|
+
|
|
45
|
+
if @options[:ecosystem]
|
|
46
|
+
diff[:added] = diff[:added].select { |d| d[:ecosystem] == @options[:ecosystem] }
|
|
47
|
+
diff[:modified] = diff[:modified].select { |d| d[:ecosystem] == @options[:ecosystem] }
|
|
48
|
+
diff[:removed] = diff[:removed].select { |d| d[:ecosystem] == @options[:ecosystem] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if diff[:added].empty? && diff[:modified].empty? && diff[:removed].empty?
|
|
52
|
+
if @options[:format] == "json"
|
|
53
|
+
require "json"
|
|
54
|
+
puts JSON.pretty_generate({ from: from_sha[0..7], to: to_sha[0..7], added: [], modified: [], removed: [] })
|
|
55
|
+
else
|
|
56
|
+
empty_result "No dependency changes between #{from_sha[0..7]} and #{to_sha[0..7]}"
|
|
57
|
+
end
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if @options[:format] == "json"
|
|
62
|
+
output_json_stateless(from_sha, to_sha, diff)
|
|
63
|
+
else
|
|
64
|
+
paginate { output_text_stateless(from_sha, to_sha, diff) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def run_with_database(repo, from_sha, to_sha)
|
|
69
|
+
Database.connect(repo.git_dir)
|
|
70
|
+
|
|
33
71
|
from_commit = Models::Commit.find_or_create_from_repo(repo, from_sha)
|
|
34
72
|
to_commit = Models::Commit.find_or_create_from_repo(repo, to_sha)
|
|
35
73
|
|
|
@@ -154,6 +192,81 @@ module Git
|
|
|
154
192
|
puts JSON.pretty_generate(data)
|
|
155
193
|
end
|
|
156
194
|
|
|
195
|
+
def output_text_stateless(from_sha, to_sha, diff)
|
|
196
|
+
puts "Dependency changes from #{from_sha[0..7]} to #{to_sha[0..7]}:"
|
|
197
|
+
puts
|
|
198
|
+
|
|
199
|
+
if diff[:added].any?
|
|
200
|
+
puts Color.green("Added:")
|
|
201
|
+
diff[:added].group_by { |d| d[:name] }.each do |name, pkg_changes|
|
|
202
|
+
latest = pkg_changes.last
|
|
203
|
+
puts Color.green(" + #{name} #{latest[:requirement]} (#{latest[:manifest_path]})")
|
|
204
|
+
end
|
|
205
|
+
puts
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if diff[:modified].any?
|
|
209
|
+
puts Color.yellow("Modified:")
|
|
210
|
+
diff[:modified].group_by { |d| d[:name] }.each do |name, pkg_changes|
|
|
211
|
+
latest = pkg_changes.last
|
|
212
|
+
puts Color.yellow(" ~ #{name} #{latest[:previous_requirement]} -> #{latest[:requirement]}")
|
|
213
|
+
end
|
|
214
|
+
puts
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if diff[:removed].any?
|
|
218
|
+
puts Color.red("Removed:")
|
|
219
|
+
diff[:removed].group_by { |d| d[:name] }.each do |name, pkg_changes|
|
|
220
|
+
latest = pkg_changes.last
|
|
221
|
+
puts Color.red(" - #{name} (was #{latest[:requirement]})")
|
|
222
|
+
end
|
|
223
|
+
puts
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
added_count = Color.green("+#{diff[:added].map { |d| d[:name] }.uniq.count}")
|
|
227
|
+
removed_count = Color.red("-#{diff[:removed].map { |d| d[:name] }.uniq.count}")
|
|
228
|
+
modified_count = Color.yellow("~#{diff[:modified].map { |d| d[:name] }.uniq.count}")
|
|
229
|
+
puts "Summary: #{added_count} #{removed_count} #{modified_count}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def output_json_stateless(from_sha, to_sha, diff)
|
|
233
|
+
require "json"
|
|
234
|
+
|
|
235
|
+
format_change = lambda do |change|
|
|
236
|
+
{
|
|
237
|
+
name: change[:name],
|
|
238
|
+
ecosystem: change[:ecosystem],
|
|
239
|
+
requirement: change[:requirement],
|
|
240
|
+
manifest: change[:manifest_path]
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
format_modified = lambda do |change|
|
|
245
|
+
{
|
|
246
|
+
name: change[:name],
|
|
247
|
+
ecosystem: change[:ecosystem],
|
|
248
|
+
previous_requirement: change[:previous_requirement],
|
|
249
|
+
requirement: change[:requirement],
|
|
250
|
+
manifest: change[:manifest_path]
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
data = {
|
|
255
|
+
from: from_sha[0..7],
|
|
256
|
+
to: to_sha[0..7],
|
|
257
|
+
added: diff[:added].map { |c| format_change.call(c) },
|
|
258
|
+
modified: diff[:modified].map { |c| format_modified.call(c) },
|
|
259
|
+
removed: diff[:removed].map { |c| format_change.call(c) },
|
|
260
|
+
summary: {
|
|
261
|
+
added: diff[:added].map { |d| d[:name] }.uniq.count,
|
|
262
|
+
modified: diff[:modified].map { |d| d[:name] }.uniq.count,
|
|
263
|
+
removed: diff[:removed].map { |d| d[:name] }.uniq.count
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
puts JSON.pretty_generate(data)
|
|
268
|
+
end
|
|
269
|
+
|
|
157
270
|
def parse_range_argument
|
|
158
271
|
return [nil, nil] if @args.empty?
|
|
159
272
|
|
|
@@ -183,11 +296,11 @@ module Git
|
|
|
183
296
|
opts.separator " git pkgs diff --from=v1.0 --to=v2.0"
|
|
184
297
|
opts.separator ""
|
|
185
298
|
|
|
186
|
-
opts.on("
|
|
299
|
+
opts.on("--from=REF", "Start commit") do |v|
|
|
187
300
|
options[:from] = v
|
|
188
301
|
end
|
|
189
302
|
|
|
190
|
-
opts.on("
|
|
303
|
+
opts.on("--to=REF", "End commit (default: HEAD)") do |v|
|
|
191
304
|
options[:to] = v
|
|
192
305
|
end
|
|
193
306
|
|
|
@@ -203,6 +316,10 @@ module Git
|
|
|
203
316
|
options[:no_pager] = true
|
|
204
317
|
end
|
|
205
318
|
|
|
319
|
+
opts.on("--stateless", "Parse manifests directly without database") do
|
|
320
|
+
options[:stateless] = true
|
|
321
|
+
end
|
|
322
|
+
|
|
206
323
|
opts.on("-h", "--help", "Show this help") do
|
|
207
324
|
puts opts
|
|
208
325
|
exit
|
|
@@ -24,18 +24,24 @@ module Git
|
|
|
24
24
|
gems.locked
|
|
25
25
|
glide.lock
|
|
26
26
|
go.mod
|
|
27
|
+
go.sum
|
|
28
|
+
gradle.lockfile
|
|
27
29
|
mix.lock
|
|
28
30
|
npm-shrinkwrap.json
|
|
29
31
|
package-lock.json
|
|
30
32
|
packages.lock.json
|
|
31
33
|
paket.lock
|
|
34
|
+
pdm.lock
|
|
32
35
|
pnpm-lock.yaml
|
|
33
36
|
poetry.lock
|
|
34
37
|
project.assets.json
|
|
35
38
|
pubspec.lock
|
|
36
39
|
pylock.toml
|
|
40
|
+
renv.lock
|
|
37
41
|
shard.lock
|
|
42
|
+
stack.yaml.lock
|
|
38
43
|
uv.lock
|
|
44
|
+
verification-metadata.xml
|
|
39
45
|
yarn.lock
|
|
40
46
|
].freeze
|
|
41
47
|
|
|
@@ -80,10 +86,10 @@ module Git
|
|
|
80
86
|
|
|
81
87
|
def install_driver
|
|
82
88
|
# Set up git config for textconv
|
|
83
|
-
|
|
89
|
+
git_config("diff.pkgs.textconv", "git-pkgs diff-driver")
|
|
84
90
|
|
|
85
91
|
# Add to .gitattributes
|
|
86
|
-
gitattributes_path = File.join(
|
|
92
|
+
gitattributes_path = File.join(work_tree, ".gitattributes")
|
|
87
93
|
existing = File.exist?(gitattributes_path) ? File.read(gitattributes_path) : ""
|
|
88
94
|
|
|
89
95
|
new_entries = []
|
|
@@ -109,9 +115,9 @@ module Git
|
|
|
109
115
|
end
|
|
110
116
|
|
|
111
117
|
def uninstall_driver
|
|
112
|
-
|
|
118
|
+
git_config_unset("diff.pkgs.textconv")
|
|
113
119
|
|
|
114
|
-
gitattributes_path = File.join(
|
|
120
|
+
gitattributes_path = File.join(work_tree, ".gitattributes")
|
|
115
121
|
if File.exist?(gitattributes_path)
|
|
116
122
|
lines = File.readlines(gitattributes_path)
|
|
117
123
|
lines.reject! { |line| line.include?("diff=pkgs") || line.include?("# git-pkgs") }
|
|
@@ -140,6 +146,26 @@ module Git
|
|
|
140
146
|
{}
|
|
141
147
|
end
|
|
142
148
|
|
|
149
|
+
def work_tree
|
|
150
|
+
Git::Pkgs.work_tree || Dir.pwd
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def git_cmd
|
|
154
|
+
if Git::Pkgs.git_dir
|
|
155
|
+
["git", "-C", work_tree]
|
|
156
|
+
else
|
|
157
|
+
["git"]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def git_config(key, value)
|
|
162
|
+
system(*git_cmd, "config", key, value)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def git_config_unset(key)
|
|
166
|
+
system(*git_cmd, "config", "--unset", key)
|
|
167
|
+
end
|
|
168
|
+
|
|
143
169
|
def parse_options
|
|
144
170
|
options = {}
|
|
145
171
|
|
|
@@ -102,6 +102,7 @@ module Git
|
|
|
102
102
|
manifest_id: manifest_ids[c[:manifest_path]],
|
|
103
103
|
name: c[:name],
|
|
104
104
|
ecosystem: c[:ecosystem],
|
|
105
|
+
purl: c[:purl],
|
|
105
106
|
change_type: c[:change_type],
|
|
106
107
|
requirement: c[:requirement],
|
|
107
108
|
previous_requirement: c[:previous_requirement],
|
|
@@ -121,6 +122,7 @@ module Git
|
|
|
121
122
|
manifest_id: manifest_ids[s[:manifest_path]],
|
|
122
123
|
name: s[:name],
|
|
123
124
|
ecosystem: s[:ecosystem],
|
|
125
|
+
purl: s[:purl],
|
|
124
126
|
requirement: s[:requirement],
|
|
125
127
|
dependency_type: s[:dependency_type],
|
|
126
128
|
created_at: now,
|
|
@@ -185,6 +187,7 @@ module Git
|
|
|
185
187
|
manifest_path: manifest_key,
|
|
186
188
|
name: change[:name],
|
|
187
189
|
ecosystem: change[:ecosystem],
|
|
190
|
+
purl: change[:purl],
|
|
188
191
|
change_type: change[:change_type],
|
|
189
192
|
requirement: change[:requirement],
|
|
190
193
|
previous_requirement: change[:previous_requirement],
|
|
@@ -202,6 +205,7 @@ module Git
|
|
|
202
205
|
manifest_path: manifest_path,
|
|
203
206
|
name: name,
|
|
204
207
|
ecosystem: dep_info[:ecosystem],
|
|
208
|
+
purl: dep_info[:purl],
|
|
205
209
|
requirement: dep_info[:requirement],
|
|
206
210
|
dependency_type: dep_info[:dependency_type]
|
|
207
211
|
}
|
|
@@ -222,6 +226,7 @@ module Git
|
|
|
222
226
|
manifest_path: manifest_path,
|
|
223
227
|
name: name,
|
|
224
228
|
ecosystem: dep_info[:ecosystem],
|
|
229
|
+
purl: dep_info[:purl],
|
|
225
230
|
requirement: dep_info[:requirement],
|
|
226
231
|
dependency_type: dep_info[:dependency_type]
|
|
227
232
|
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Git
|
|
6
|
+
module Pkgs
|
|
7
|
+
module Commands
|
|
8
|
+
class Licenses
|
|
9
|
+
include Output
|
|
10
|
+
|
|
11
|
+
PERMISSIVE = %w[
|
|
12
|
+
MIT Apache-2.0 BSD-2-Clause BSD-3-Clause ISC Unlicense CC0-1.0
|
|
13
|
+
0BSD WTFPL Zlib BSL-1.0
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
COPYLEFT = %w[
|
|
17
|
+
GPL-2.0 GPL-3.0 LGPL-2.1 LGPL-3.0 AGPL-3.0 MPL-2.0
|
|
18
|
+
GPL-2.0-only GPL-2.0-or-later GPL-3.0-only GPL-3.0-or-later
|
|
19
|
+
LGPL-2.1-only LGPL-2.1-or-later LGPL-3.0-only LGPL-3.0-or-later
|
|
20
|
+
AGPL-3.0-only AGPL-3.0-or-later
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def self.description
|
|
24
|
+
"Show licenses for dependencies"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(args)
|
|
28
|
+
@args = args.dup
|
|
29
|
+
@options = parse_options
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse_options
|
|
33
|
+
options = { allow: [], deny: [] }
|
|
34
|
+
|
|
35
|
+
parser = OptionParser.new do |opts|
|
|
36
|
+
opts.banner = "Usage: git pkgs licenses [options]"
|
|
37
|
+
opts.separator ""
|
|
38
|
+
opts.separator "Show licenses for dependencies with optional compliance checks."
|
|
39
|
+
opts.separator ""
|
|
40
|
+
opts.separator "Options:"
|
|
41
|
+
|
|
42
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
43
|
+
options[:ecosystem] = v
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
opts.on("-r", "--ref=REF", "Git ref to check (default: HEAD)") do |v|
|
|
47
|
+
options[:ref] = v
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
opts.on("-f", "--format=FORMAT", "Output format (text, json, csv)") do |v|
|
|
51
|
+
options[:format] = v
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
opts.on("--allow=LICENSES", "Comma-separated list of allowed licenses") do |v|
|
|
55
|
+
options[:allow] = v.split(",").map(&:strip)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
opts.on("--deny=LICENSES", "Comma-separated list of denied licenses") do |v|
|
|
59
|
+
options[:deny] = v.split(",").map(&:strip)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
opts.on("--permissive", "Only allow permissive licenses (MIT, Apache, BSD, etc.)") do
|
|
63
|
+
options[:permissive] = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on("--copyleft", "Flag copyleft licenses (GPL, AGPL, etc.)") do
|
|
67
|
+
options[:copyleft] = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
opts.on("--unknown", "Flag packages with unknown/missing licenses") do
|
|
71
|
+
options[:unknown] = true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
opts.on("--group", "Group output by license") do
|
|
75
|
+
options[:group] = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
opts.on("--stateless", "Parse manifests directly without database") do
|
|
79
|
+
options[:stateless] = true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
opts.on("-h", "--help", "Show this help") do
|
|
83
|
+
puts opts
|
|
84
|
+
exit
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
parser.parse!(@args)
|
|
89
|
+
options
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def run
|
|
93
|
+
repo = Repository.new
|
|
94
|
+
use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
|
|
95
|
+
|
|
96
|
+
if use_stateless
|
|
97
|
+
Database.connect_memory
|
|
98
|
+
deps = get_dependencies_stateless(repo)
|
|
99
|
+
else
|
|
100
|
+
Database.connect(repo.git_dir)
|
|
101
|
+
deps = get_dependencies_with_database(repo)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if deps.empty?
|
|
105
|
+
empty_result "No dependencies found"
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if @options[:ecosystem]
|
|
110
|
+
deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
deps = Analyzer.pair_manifests_with_lockfiles(deps)
|
|
114
|
+
|
|
115
|
+
if deps.empty?
|
|
116
|
+
empty_result "No dependencies found"
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
packages = deps.map do |dep|
|
|
121
|
+
purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name]).to_s
|
|
122
|
+
{
|
|
123
|
+
purl: purl,
|
|
124
|
+
name: dep[:name],
|
|
125
|
+
ecosystem: dep[:ecosystem],
|
|
126
|
+
version: dep[:requirement],
|
|
127
|
+
manifest_path: dep[:manifest_path]
|
|
128
|
+
}
|
|
129
|
+
end.uniq { |p| p[:purl] }
|
|
130
|
+
|
|
131
|
+
enrich_packages(packages.map { |p| p[:purl] })
|
|
132
|
+
|
|
133
|
+
packages.each do |pkg|
|
|
134
|
+
db_pkg = Models::Package.first(purl: pkg[:purl])
|
|
135
|
+
pkg[:license] = db_pkg&.license
|
|
136
|
+
pkg[:violation] = check_violation(pkg[:license])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
violations = packages.select { |p| p[:violation] }
|
|
140
|
+
|
|
141
|
+
case @options[:format]
|
|
142
|
+
when "json"
|
|
143
|
+
output_json(packages, violations)
|
|
144
|
+
when "csv"
|
|
145
|
+
output_csv(packages)
|
|
146
|
+
else
|
|
147
|
+
if @options[:group]
|
|
148
|
+
output_grouped(packages, violations)
|
|
149
|
+
else
|
|
150
|
+
output_text(packages, violations)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
exit 1 if violations.any?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def check_violation(license)
|
|
158
|
+
return "unknown" if @options[:unknown] && (license.nil? || license.empty?)
|
|
159
|
+
|
|
160
|
+
return nil if license.nil? || license.empty?
|
|
161
|
+
|
|
162
|
+
if @options[:permissive]
|
|
163
|
+
return "copyleft" if COPYLEFT.any? { |l| license_matches?(license, l) }
|
|
164
|
+
return "not-permissive" unless PERMISSIVE.any? { |l| license_matches?(license, l) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if @options[:copyleft]
|
|
168
|
+
return "copyleft" if COPYLEFT.any? { |l| license_matches?(license, l) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if @options[:allow].any?
|
|
172
|
+
return "not-allowed" unless @options[:allow].any? { |l| license_matches?(license, l) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
if @options[:deny].any?
|
|
176
|
+
return "denied" if @options[:deny].any? { |l| license_matches?(license, l) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def license_matches?(license, pattern)
|
|
183
|
+
license.downcase.include?(pattern.downcase)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def enrich_packages(purls)
|
|
187
|
+
packages_by_purl = {}
|
|
188
|
+
purls.each do |purl|
|
|
189
|
+
parsed = Purl::PackageURL.parse(purl)
|
|
190
|
+
ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
|
|
191
|
+
pkg = Models::Package.find_or_create_by_purl(
|
|
192
|
+
purl: purl,
|
|
193
|
+
ecosystem: ecosystem,
|
|
194
|
+
name: parsed.name
|
|
195
|
+
)
|
|
196
|
+
packages_by_purl[purl] = pkg
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
stale_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
|
|
200
|
+
return if stale_purls.empty?
|
|
201
|
+
|
|
202
|
+
client = EcosystemsClient.new
|
|
203
|
+
begin
|
|
204
|
+
results = Spinner.with_spinner("Fetching package metadata...") do
|
|
205
|
+
client.bulk_lookup(stale_purls)
|
|
206
|
+
end
|
|
207
|
+
results.each do |purl, data|
|
|
208
|
+
packages_by_purl[purl]&.enrich_from_api(data)
|
|
209
|
+
end
|
|
210
|
+
rescue EcosystemsClient::ApiError => e
|
|
211
|
+
$stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def output_text(packages, violations)
|
|
216
|
+
max_name = packages.map { |p| p[:name].length }.max || 20
|
|
217
|
+
max_license = packages.map { |p| (p[:license] || "").length }.max || 10
|
|
218
|
+
max_license = [max_license, 20].min
|
|
219
|
+
|
|
220
|
+
packages.sort_by { |p| [p[:license] || "zzz", p[:name]] }.each do |pkg|
|
|
221
|
+
name = pkg[:name].ljust(max_name)
|
|
222
|
+
license = (pkg[:license] || "unknown").ljust(max_license)[0, max_license]
|
|
223
|
+
ecosystem = pkg[:ecosystem]
|
|
224
|
+
|
|
225
|
+
line = "#{name} #{license} (#{ecosystem})"
|
|
226
|
+
|
|
227
|
+
colored = if pkg[:violation]
|
|
228
|
+
Color.red("#{line} [#{pkg[:violation]}]")
|
|
229
|
+
else
|
|
230
|
+
line
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
puts colored
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
output_summary(packages, violations)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def output_grouped(packages, violations)
|
|
240
|
+
by_license = packages.group_by { |p| p[:license] || "unknown" }
|
|
241
|
+
|
|
242
|
+
by_license.sort_by { |license, _| license.downcase }.each do |license, pkgs|
|
|
243
|
+
has_violation = pkgs.any? { |p| p[:violation] }
|
|
244
|
+
header = "#{license} (#{pkgs.size})"
|
|
245
|
+
puts has_violation ? Color.red(header) : Color.bold(header)
|
|
246
|
+
|
|
247
|
+
pkgs.sort_by { |p| p[:name] }.each do |pkg|
|
|
248
|
+
puts " #{pkg[:name]}"
|
|
249
|
+
end
|
|
250
|
+
puts ""
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
output_summary(packages, violations)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def output_summary(packages, violations)
|
|
257
|
+
return unless violations.any?
|
|
258
|
+
|
|
259
|
+
puts ""
|
|
260
|
+
puts Color.red("#{violations.size} license violation#{"s" if violations.size != 1} found")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def output_json(packages, violations)
|
|
264
|
+
require "json"
|
|
265
|
+
puts JSON.pretty_generate({
|
|
266
|
+
packages: packages,
|
|
267
|
+
summary: {
|
|
268
|
+
total: packages.size,
|
|
269
|
+
violations: violations.size,
|
|
270
|
+
by_license: packages.group_by { |p| p[:license] || "unknown" }.transform_values(&:size)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def output_csv(packages)
|
|
276
|
+
puts "name,ecosystem,version,license,violation"
|
|
277
|
+
packages.sort_by { |p| p[:name] }.each do |pkg|
|
|
278
|
+
puts [
|
|
279
|
+
pkg[:name],
|
|
280
|
+
pkg[:ecosystem],
|
|
281
|
+
pkg[:version],
|
|
282
|
+
pkg[:license] || "",
|
|
283
|
+
pkg[:violation] || ""
|
|
284
|
+
].map { |v| csv_escape(v) }.join(",")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def csv_escape(value)
|
|
289
|
+
if value.to_s.include?(",") || value.to_s.include?('"')
|
|
290
|
+
"\"#{value.to_s.gsub('"', '""')}\""
|
|
291
|
+
else
|
|
292
|
+
value.to_s
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def get_dependencies_stateless(repo)
|
|
297
|
+
ref = @options[:ref] || "HEAD"
|
|
298
|
+
commit_sha = repo.rev_parse(ref)
|
|
299
|
+
rugged_commit = repo.lookup(commit_sha)
|
|
300
|
+
|
|
301
|
+
error "Could not resolve '#{ref}'" unless rugged_commit
|
|
302
|
+
|
|
303
|
+
analyzer = Analyzer.new(repo)
|
|
304
|
+
analyzer.dependencies_at_commit(rugged_commit)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def get_dependencies_with_database(repo)
|
|
308
|
+
ref = @options[:ref] || "HEAD"
|
|
309
|
+
commit_sha = repo.rev_parse(ref)
|
|
310
|
+
target_commit = Models::Commit.first(sha: commit_sha)
|
|
311
|
+
|
|
312
|
+
return get_dependencies_stateless(repo) unless target_commit
|
|
313
|
+
|
|
314
|
+
branch_name = repo.default_branch
|
|
315
|
+
branch = Models::Branch.first(name: branch_name)
|
|
316
|
+
return [] unless branch
|
|
317
|
+
|
|
318
|
+
compute_dependencies_at_commit(target_commit, branch)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def compute_dependencies_at_commit(target_commit, branch)
|
|
322
|
+
snapshot_commit = branch.commits_dataset
|
|
323
|
+
.join(:dependency_snapshots, commit_id: :id)
|
|
324
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
325
|
+
.order(Sequel.desc(Sequel[:commits][:committed_at]))
|
|
326
|
+
.distinct
|
|
327
|
+
.first
|
|
328
|
+
|
|
329
|
+
deps = {}
|
|
330
|
+
if snapshot_commit
|
|
331
|
+
snapshot_commit.dependency_snapshots.each do |s|
|
|
332
|
+
key = [s.manifest.path, s.name]
|
|
333
|
+
deps[key] = {
|
|
334
|
+
manifest_path: s.manifest.path,
|
|
335
|
+
manifest_kind: s.manifest.kind,
|
|
336
|
+
name: s.name,
|
|
337
|
+
ecosystem: s.ecosystem,
|
|
338
|
+
requirement: s.requirement,
|
|
339
|
+
dependency_type: s.dependency_type
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
if snapshot_commit && snapshot_commit.id != target_commit.id
|
|
345
|
+
commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id])
|
|
346
|
+
changes = Models::DependencyChange
|
|
347
|
+
.join(:commits, id: :commit_id)
|
|
348
|
+
.where(Sequel[:commits][:id] => commit_ids)
|
|
349
|
+
.where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at }
|
|
350
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
351
|
+
.order(Sequel[:commits][:committed_at])
|
|
352
|
+
.eager(:manifest)
|
|
353
|
+
.all
|
|
354
|
+
|
|
355
|
+
changes.each do |change|
|
|
356
|
+
key = [change.manifest.path, change.name]
|
|
357
|
+
case change.change_type
|
|
358
|
+
when "added", "modified"
|
|
359
|
+
deps[key] = {
|
|
360
|
+
manifest_path: change.manifest.path,
|
|
361
|
+
manifest_kind: change.manifest.kind,
|
|
362
|
+
name: change.name,
|
|
363
|
+
ecosystem: change.ecosystem,
|
|
364
|
+
requirement: change.requirement,
|
|
365
|
+
dependency_type: change.dependency_type
|
|
366
|
+
}
|
|
367
|
+
when "removed"
|
|
368
|
+
deps.delete(key)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
deps.values
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|