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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Pkgs
|
|
5
|
+
module Models
|
|
6
|
+
class Version < Sequel::Model
|
|
7
|
+
many_to_one :package, key: :package_purl, primary_key: :purl
|
|
8
|
+
|
|
9
|
+
def parsed_purl
|
|
10
|
+
@parsed_purl ||= Purl.parse(purl)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def version_string
|
|
14
|
+
parsed_purl.version
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def registry_url
|
|
18
|
+
parsed_purl.registry_url
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enriched?
|
|
22
|
+
!enriched_at.nil?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Git
|
|
6
|
+
module Pkgs
|
|
7
|
+
module Models
|
|
8
|
+
class Vulnerability < Sequel::Model
|
|
9
|
+
one_to_many :vulnerability_packages, key: :vulnerability_id
|
|
10
|
+
|
|
11
|
+
dataset_module do
|
|
12
|
+
def by_severity(severity)
|
|
13
|
+
where(severity: severity)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def critical
|
|
17
|
+
by_severity("critical")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def high
|
|
21
|
+
by_severity("high")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def medium
|
|
25
|
+
by_severity("medium")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def low
|
|
29
|
+
by_severity("low")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def not_withdrawn
|
|
33
|
+
where(withdrawn_at: nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stale(max_age_seconds = 86400)
|
|
37
|
+
threshold = Time.now - max_age_seconds
|
|
38
|
+
where { fetched_at < threshold }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fresh(max_age_seconds = 86400)
|
|
42
|
+
threshold = Time.now - max_age_seconds
|
|
43
|
+
where { fetched_at >= threshold }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def severity_level
|
|
48
|
+
case severity&.downcase
|
|
49
|
+
when "critical" then 4
|
|
50
|
+
when "high" then 3
|
|
51
|
+
when "medium" then 2
|
|
52
|
+
when "low" then 1
|
|
53
|
+
else 0
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def severity_display
|
|
58
|
+
severity&.upcase || "UNKNOWN"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def withdrawn?
|
|
62
|
+
!withdrawn_at.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def aliases_list
|
|
66
|
+
return [] if aliases.nil? || aliases.empty?
|
|
67
|
+
|
|
68
|
+
aliases.split(",").map(&:strip)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Create or update from OSV API response data.
|
|
72
|
+
# Creates both the Vulnerability record and VulnerabilityPackage records
|
|
73
|
+
# for each affected package.
|
|
74
|
+
def self.from_osv(osv_data)
|
|
75
|
+
vuln_id = osv_data["id"]
|
|
76
|
+
severity_info = extract_severity(osv_data)
|
|
77
|
+
|
|
78
|
+
vuln = update_or_create(
|
|
79
|
+
{ id: vuln_id },
|
|
80
|
+
{
|
|
81
|
+
aliases: extract_aliases(osv_data),
|
|
82
|
+
severity: severity_info[:severity],
|
|
83
|
+
cvss_score: severity_info[:score],
|
|
84
|
+
cvss_vector: severity_info[:vector],
|
|
85
|
+
summary: osv_data["summary"],
|
|
86
|
+
details: osv_data["details"],
|
|
87
|
+
published_at: parse_timestamp(osv_data["published"]),
|
|
88
|
+
modified_at: parse_timestamp(osv_data["modified"]),
|
|
89
|
+
withdrawn_at: parse_timestamp(osv_data["withdrawn"]),
|
|
90
|
+
fetched_at: Time.now
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Create VulnerabilityPackage records for each affected package
|
|
95
|
+
(osv_data["affected"] || []).each do |affected|
|
|
96
|
+
pkg = affected["package"]
|
|
97
|
+
next unless pkg
|
|
98
|
+
|
|
99
|
+
ecosystem = pkg["ecosystem"]
|
|
100
|
+
name = pkg["name"]
|
|
101
|
+
|
|
102
|
+
affected_range = build_affected_range(affected)
|
|
103
|
+
fixed = extract_fixed_versions(affected)
|
|
104
|
+
|
|
105
|
+
VulnerabilityPackage.update_or_create(
|
|
106
|
+
{ vulnerability_id: vuln_id, ecosystem: ecosystem, package_name: name },
|
|
107
|
+
{
|
|
108
|
+
affected_versions: affected_range,
|
|
109
|
+
fixed_versions: fixed&.join(",")
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
vuln
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.extract_aliases(osv_data)
|
|
118
|
+
aliases = osv_data["aliases"] || []
|
|
119
|
+
aliases.any? ? aliases.join(",") : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.extract_severity(osv_data)
|
|
123
|
+
result = { severity: nil, score: nil, vector: nil }
|
|
124
|
+
|
|
125
|
+
if osv_data["severity"]&.any?
|
|
126
|
+
sev = osv_data["severity"].first
|
|
127
|
+
result[:vector] = sev["score"]
|
|
128
|
+
|
|
129
|
+
if sev["score"]&.include?("CVSS")
|
|
130
|
+
result[:score] = parse_cvss_score(sev["score"])
|
|
131
|
+
result[:severity] = score_to_severity(result[:score])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check root-level database_specific (GHSA format)
|
|
136
|
+
if osv_data["database_specific"]&.dig("severity")
|
|
137
|
+
result[:severity] ||= normalize_severity(osv_data["database_specific"]["severity"])
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Check affected entries for database_specific severity
|
|
141
|
+
osv_data["affected"]&.each do |affected|
|
|
142
|
+
db_specific = affected["database_specific"]
|
|
143
|
+
if db_specific && db_specific["severity"]
|
|
144
|
+
result[:severity] ||= normalize_severity(db_specific["severity"])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.normalize_severity(severity)
|
|
152
|
+
return nil unless severity
|
|
153
|
+
|
|
154
|
+
case severity.downcase
|
|
155
|
+
when "critical" then "critical"
|
|
156
|
+
when "high" then "high"
|
|
157
|
+
when "moderate", "medium" then "medium"
|
|
158
|
+
when "low" then "low"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.parse_cvss_score(vector)
|
|
163
|
+
return nil unless vector
|
|
164
|
+
|
|
165
|
+
if vector.match?(/\A\d+(\.\d+)?\z/)
|
|
166
|
+
return vector.to_f
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
return nil unless vector.include?("CVSS:")
|
|
170
|
+
|
|
171
|
+
metrics = parse_cvss_metrics(vector)
|
|
172
|
+
return nil if metrics.empty?
|
|
173
|
+
|
|
174
|
+
estimate_cvss_score(metrics)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.parse_cvss_metrics(vector)
|
|
178
|
+
metrics = {}
|
|
179
|
+
vector.split("/").each do |part|
|
|
180
|
+
key, value = part.split(":")
|
|
181
|
+
metrics[key] = value if key && value
|
|
182
|
+
end
|
|
183
|
+
metrics
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def self.estimate_cvss_score(metrics)
|
|
187
|
+
impact_values = { "N" => 0, "L" => 1, "H" => 2 }
|
|
188
|
+
c = impact_values[metrics["C"]] || 0
|
|
189
|
+
i = impact_values[metrics["I"]] || 0
|
|
190
|
+
a = impact_values[metrics["A"]] || 0
|
|
191
|
+
max_impact = [c, i, a].max
|
|
192
|
+
|
|
193
|
+
ac_easy = metrics["AC"] == "L"
|
|
194
|
+
av_network = metrics["AV"] == "N"
|
|
195
|
+
pr_none = metrics["PR"] == "N"
|
|
196
|
+
ui_none = metrics["UI"] == "N"
|
|
197
|
+
|
|
198
|
+
if max_impact == 2 && av_network && ac_easy && pr_none && ui_none
|
|
199
|
+
9.8
|
|
200
|
+
elsif max_impact == 2 && av_network && ac_easy
|
|
201
|
+
8.1
|
|
202
|
+
elsif max_impact == 2
|
|
203
|
+
7.0
|
|
204
|
+
elsif max_impact == 1 && av_network
|
|
205
|
+
5.3
|
|
206
|
+
elsif max_impact == 1
|
|
207
|
+
4.0
|
|
208
|
+
elsif max_impact == 0
|
|
209
|
+
0.0
|
|
210
|
+
else
|
|
211
|
+
5.0
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def self.score_to_severity(score)
|
|
216
|
+
return nil unless score
|
|
217
|
+
|
|
218
|
+
case score
|
|
219
|
+
when 9.0..10.0 then "critical"
|
|
220
|
+
when 7.0...9.0 then "high"
|
|
221
|
+
when 4.0...7.0 then "medium"
|
|
222
|
+
when 0.0...4.0 then "low"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def self.build_affected_range(affected)
|
|
227
|
+
return nil unless affected
|
|
228
|
+
|
|
229
|
+
ranges = affected["ranges"] || []
|
|
230
|
+
versions = affected["versions"] || []
|
|
231
|
+
|
|
232
|
+
return versions.join(",") if versions.any? && ranges.empty?
|
|
233
|
+
|
|
234
|
+
range_parts = ranges.flat_map do |range|
|
|
235
|
+
events = range["events"] || []
|
|
236
|
+
build_range_from_events(events)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
range_parts.compact.join(" || ")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.build_range_from_events(events)
|
|
243
|
+
ranges = []
|
|
244
|
+
current_introduced = nil
|
|
245
|
+
|
|
246
|
+
events.each do |event|
|
|
247
|
+
if event["introduced"]
|
|
248
|
+
current_introduced = event["introduced"]
|
|
249
|
+
elsif event["fixed"] && current_introduced
|
|
250
|
+
if current_introduced == "0"
|
|
251
|
+
ranges << "<#{event["fixed"]}"
|
|
252
|
+
else
|
|
253
|
+
ranges << ">=#{current_introduced} <#{event["fixed"]}"
|
|
254
|
+
end
|
|
255
|
+
current_introduced = nil
|
|
256
|
+
elsif event["last_affected"] && current_introduced
|
|
257
|
+
if current_introduced == "0"
|
|
258
|
+
ranges << "<=#{event["last_affected"]}"
|
|
259
|
+
else
|
|
260
|
+
ranges << ">=#{current_introduced} <=#{event["last_affected"]}"
|
|
261
|
+
end
|
|
262
|
+
current_introduced = nil
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
if current_introduced
|
|
267
|
+
ranges << if current_introduced == "0"
|
|
268
|
+
">=0"
|
|
269
|
+
else
|
|
270
|
+
">=#{current_introduced}"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
ranges
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.extract_fixed_versions(affected)
|
|
278
|
+
return nil unless affected
|
|
279
|
+
|
|
280
|
+
fixed = []
|
|
281
|
+
(affected["ranges"] || []).each do |range|
|
|
282
|
+
(range["events"] || []).each do |event|
|
|
283
|
+
fixed << event["fixed"] if event["fixed"]
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
fixed.uniq.empty? ? nil : fixed.uniq
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def self.parse_timestamp(str)
|
|
291
|
+
return nil unless str
|
|
292
|
+
|
|
293
|
+
Time.parse(str)
|
|
294
|
+
rescue ArgumentError
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "vers"
|
|
4
|
+
|
|
5
|
+
module Git
|
|
6
|
+
module Pkgs
|
|
7
|
+
module Models
|
|
8
|
+
class VulnerabilityPackage < Sequel::Model
|
|
9
|
+
many_to_one :vulnerability, key: :vulnerability_id
|
|
10
|
+
|
|
11
|
+
dataset_module do
|
|
12
|
+
def for_package(ecosystem, name)
|
|
13
|
+
where(ecosystem: ecosystem, package_name: name)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def affects_version?(version)
|
|
18
|
+
return false if affected_versions.nil? || affected_versions.empty?
|
|
19
|
+
return false if version.nil? || version.empty?
|
|
20
|
+
|
|
21
|
+
# Convert OSV ecosystem to purl type for Vers
|
|
22
|
+
bib_ecosystem = Ecosystems.from_osv(ecosystem) || ecosystem.downcase
|
|
23
|
+
purl_type = Ecosystems.to_purl(bib_ecosystem) || bib_ecosystem
|
|
24
|
+
|
|
25
|
+
# Handle || separator (OR conditions between different ranges)
|
|
26
|
+
# Each part separated by || is an independent range (OR)
|
|
27
|
+
# Within each part, space-separated constraints are AND conditions
|
|
28
|
+
affected_versions.split(" || ").any? do |range_part|
|
|
29
|
+
range_matches?(version, range_part, purl_type)
|
|
30
|
+
end
|
|
31
|
+
rescue ArgumentError, Vers::Error
|
|
32
|
+
# If we can't parse the version or range, be conservative and assume affected
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def range_matches?(version, range_part, purl_type)
|
|
37
|
+
# Extract individual constraints (e.g., ">=7.1.0 <7.1.3.1" -> [">=7.1.0", "<7.1.3.1"])
|
|
38
|
+
constraints = range_part.scan(/[<>=!~^]+[^\s]+/)
|
|
39
|
+
return false if constraints.empty?
|
|
40
|
+
|
|
41
|
+
# All constraints must be satisfied (AND logic)
|
|
42
|
+
constraints.all? do |constraint|
|
|
43
|
+
Vers.satisfies?(version, constraint, purl_type)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fixed_versions_list
|
|
48
|
+
return [] if fixed_versions.nil? || fixed_versions.empty?
|
|
49
|
+
|
|
50
|
+
fixed_versions.split(",").map(&:strip)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def purl
|
|
54
|
+
Models::Package.generate_purl(ecosystem, package_name)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Git
|
|
8
|
+
module Pkgs
|
|
9
|
+
# Client for the OSV (Open Source Vulnerabilities) API.
|
|
10
|
+
# https://google.github.io/osv.dev/api/
|
|
11
|
+
class OsvClient
|
|
12
|
+
API_BASE = "https://api.osv.dev/v1"
|
|
13
|
+
BATCH_SIZE = 1000 # Max queries per batch request
|
|
14
|
+
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
class ApiError < Error; end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@http_clients = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Query vulnerabilities for a single package version.
|
|
23
|
+
#
|
|
24
|
+
# @param ecosystem [String] OSV ecosystem name (e.g., "RubyGems")
|
|
25
|
+
# @param name [String] package name
|
|
26
|
+
# @param version [String] package version
|
|
27
|
+
# @return [Array<Hash>] array of vulnerability hashes
|
|
28
|
+
def query(ecosystem:, name:, version:)
|
|
29
|
+
payload = {
|
|
30
|
+
package: {
|
|
31
|
+
name: name,
|
|
32
|
+
ecosystem: ecosystem
|
|
33
|
+
},
|
|
34
|
+
version: version
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
response = post("/query", payload)
|
|
38
|
+
fetch_all_pages(response, payload)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Batch query vulnerabilities for multiple packages.
|
|
42
|
+
# More efficient than individual queries for large dependency sets.
|
|
43
|
+
#
|
|
44
|
+
# @param packages [Array<Hash>] array of {ecosystem:, name:, version:} hashes
|
|
45
|
+
# @return [Array<Array<Hash>>] array of vulnerability arrays, one per input package
|
|
46
|
+
def query_batch(packages)
|
|
47
|
+
return [] if packages.empty?
|
|
48
|
+
|
|
49
|
+
results = Array.new(packages.size) { [] }
|
|
50
|
+
|
|
51
|
+
packages.each_slice(BATCH_SIZE).with_index do |batch, batch_idx|
|
|
52
|
+
queries = batch.map do |pkg|
|
|
53
|
+
{
|
|
54
|
+
package: {
|
|
55
|
+
name: pkg[:name],
|
|
56
|
+
ecosystem: pkg[:ecosystem]
|
|
57
|
+
},
|
|
58
|
+
version: pkg[:version]
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
response = post("/querybatch", { queries: queries })
|
|
63
|
+
batch_results = response["results"] || []
|
|
64
|
+
|
|
65
|
+
batch_results.each_with_index do |result, idx|
|
|
66
|
+
global_idx = batch_idx * BATCH_SIZE + idx
|
|
67
|
+
results[global_idx] = result["vulns"] || []
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
results
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Fetch full details for a specific vulnerability by ID.
|
|
75
|
+
#
|
|
76
|
+
# @param vuln_id [String] vulnerability ID (e.g., "CVE-2024-1234", "GHSA-xxxx")
|
|
77
|
+
# @return [Hash] full vulnerability data
|
|
78
|
+
def get_vulnerability(vuln_id)
|
|
79
|
+
get("/vulns/#{URI.encode_uri_component(vuln_id)}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def post(path, payload)
|
|
85
|
+
uri = URI("#{API_BASE}#{path}")
|
|
86
|
+
request = Net::HTTP::Post.new(uri)
|
|
87
|
+
request["Content-Type"] = "application/json"
|
|
88
|
+
request.body = JSON.generate(payload)
|
|
89
|
+
|
|
90
|
+
execute_request(uri, request)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def get(path)
|
|
94
|
+
uri = URI("#{API_BASE}#{path}")
|
|
95
|
+
request = Net::HTTP::Get.new(uri)
|
|
96
|
+
request["Content-Type"] = "application/json"
|
|
97
|
+
|
|
98
|
+
execute_request(uri, request)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def execute_request(uri, request)
|
|
102
|
+
http = http_client(uri)
|
|
103
|
+
response = http.request(request)
|
|
104
|
+
|
|
105
|
+
case response
|
|
106
|
+
when Net::HTTPSuccess
|
|
107
|
+
JSON.parse(response.body)
|
|
108
|
+
else
|
|
109
|
+
raise ApiError, "OSV API error: #{response.code} #{response.message}"
|
|
110
|
+
end
|
|
111
|
+
rescue JSON::ParserError => e
|
|
112
|
+
raise ApiError, "Invalid JSON response from OSV API: #{e.message}"
|
|
113
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
114
|
+
raise ApiError, "OSV API timeout: #{e.message}"
|
|
115
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
|
116
|
+
raise ApiError, "OSV API connection error: #{e.message}"
|
|
117
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
118
|
+
raise ApiError, "OSV API SSL error: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def http_client(uri)
|
|
122
|
+
key = "#{uri.host}:#{uri.port}"
|
|
123
|
+
@http_clients[key] ||= begin
|
|
124
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
125
|
+
http.use_ssl = uri.scheme == "https"
|
|
126
|
+
http.open_timeout = 10
|
|
127
|
+
http.read_timeout = 30
|
|
128
|
+
http
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
MAX_PAGES = 100
|
|
133
|
+
|
|
134
|
+
def fetch_all_pages(response, original_payload)
|
|
135
|
+
vulns = response["vulns"] || []
|
|
136
|
+
page_token = response["next_page_token"]
|
|
137
|
+
pages_fetched = 0
|
|
138
|
+
|
|
139
|
+
while page_token && pages_fetched < MAX_PAGES
|
|
140
|
+
payload = original_payload.merge(page_token: page_token)
|
|
141
|
+
response = post("/query", payload)
|
|
142
|
+
vulns.concat(response["vulns"] || [])
|
|
143
|
+
page_token = response["next_page_token"]
|
|
144
|
+
pages_fetched += 1
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
vulns
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
data/lib/git/pkgs/output.rb
CHANGED
|
@@ -52,6 +52,28 @@ module Git
|
|
|
52
52
|
|
|
53
53
|
error "Database not initialized. Run 'git pkgs init' first."
|
|
54
54
|
end
|
|
55
|
+
|
|
56
|
+
# Pick best author from commit, preferring humans over bots
|
|
57
|
+
def best_author(commit)
|
|
58
|
+
author_name = commit.respond_to?(:author_name) ? commit.author_name : commit[:author_name]
|
|
59
|
+
message = commit.respond_to?(:message) ? commit.message : commit[:message]
|
|
60
|
+
|
|
61
|
+
authors = [author_name] + parse_coauthors(message)
|
|
62
|
+
|
|
63
|
+
# Prefer human authors over bots
|
|
64
|
+
human = authors.find { |a| !bot_author?(a) }
|
|
65
|
+
human || authors.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_coauthors(message)
|
|
69
|
+
return [] unless message
|
|
70
|
+
|
|
71
|
+
message.scan(/^Co-authored-by:([^<]+)<[^>]+>/i).flatten.map(&:strip)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def bot_author?(name)
|
|
75
|
+
name =~ /\[bot\]$|^dependabot|^renovate|^github-actions/i
|
|
76
|
+
end
|
|
55
77
|
end
|
|
56
78
|
end
|
|
57
79
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "purl"
|
|
4
|
+
|
|
5
|
+
module Git
|
|
6
|
+
module Pkgs
|
|
7
|
+
module PurlHelper
|
|
8
|
+
# Mapping from Bibliothecary/ecosyste.ms ecosystem names to PURL types
|
|
9
|
+
# Source: https://packages.ecosyste.ms/api/v1/registries/
|
|
10
|
+
ECOSYSTEM_TO_PURL_TYPE = {
|
|
11
|
+
"npm" => "npm",
|
|
12
|
+
"go" => "golang",
|
|
13
|
+
"docker" => "docker",
|
|
14
|
+
"pypi" => "pypi",
|
|
15
|
+
"nuget" => "nuget",
|
|
16
|
+
"maven" => "maven",
|
|
17
|
+
"packagist" => "composer",
|
|
18
|
+
"cargo" => "cargo",
|
|
19
|
+
"rubygems" => "gem",
|
|
20
|
+
"cocoapods" => "cocoapods",
|
|
21
|
+
"pub" => "pub",
|
|
22
|
+
"bower" => "bower",
|
|
23
|
+
"cpan" => "cpan",
|
|
24
|
+
"alpine" => "alpine",
|
|
25
|
+
"actions" => "githubactions",
|
|
26
|
+
"cran" => "cran",
|
|
27
|
+
"clojars" => "clojars",
|
|
28
|
+
"conda" => "conda",
|
|
29
|
+
"hex" => "hex",
|
|
30
|
+
"hackage" => "hackage",
|
|
31
|
+
"julia" => "julia",
|
|
32
|
+
"swiftpm" => "swift",
|
|
33
|
+
"openvsx" => "openvsx",
|
|
34
|
+
"spack" => "spack",
|
|
35
|
+
"homebrew" => "brew",
|
|
36
|
+
"puppet" => "puppet",
|
|
37
|
+
"deno" => "deno",
|
|
38
|
+
"elm" => "elm",
|
|
39
|
+
"vcpkg" => "vcpkg",
|
|
40
|
+
"racket" => "racket",
|
|
41
|
+
"bioconductor" => "bioconductor",
|
|
42
|
+
"carthage" => "carthage",
|
|
43
|
+
"elpa" => "melpa"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
def self.purl_type_for(ecosystem)
|
|
47
|
+
ECOSYSTEM_TO_PURL_TYPE.fetch(ecosystem, ecosystem)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.build_purl(ecosystem:, name:, version: nil)
|
|
51
|
+
type = purl_type_for(ecosystem)
|
|
52
|
+
Purl::PackageURL.new(type: type, name: name, version: version)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Pkgs
|
|
5
|
+
class Spinner
|
|
6
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
7
|
+
INTERVAL = 0.08
|
|
8
|
+
|
|
9
|
+
def initialize(message)
|
|
10
|
+
@message = message
|
|
11
|
+
@running = false
|
|
12
|
+
@thread = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def start
|
|
16
|
+
return unless $stdout.tty? && !Git::Pkgs.quiet
|
|
17
|
+
|
|
18
|
+
@running = true
|
|
19
|
+
@frame_index = 0
|
|
20
|
+
@thread = Thread.new do
|
|
21
|
+
while @running
|
|
22
|
+
print "\r#{FRAMES[@frame_index]} #{@message}"
|
|
23
|
+
@frame_index = (@frame_index + 1) % FRAMES.length
|
|
24
|
+
sleep INTERVAL
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stop
|
|
30
|
+
return unless @thread
|
|
31
|
+
|
|
32
|
+
@running = false
|
|
33
|
+
@thread.join
|
|
34
|
+
print "\r#{" " * (@message.length + 3)}\r"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.with_spinner(message)
|
|
38
|
+
spinner = new(message)
|
|
39
|
+
spinner.start
|
|
40
|
+
yield
|
|
41
|
+
ensure
|
|
42
|
+
spinner.stop
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|