gem-guardian 0.3.0 → 0.4.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/.github/workflows/{main.yml → ci.yml} +3 -21
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +25 -1
- data/CODE_OF_CONDUCT.md +1 -1
- data/Gemfile +0 -1
- data/README.md +397 -49
- data/Rakefile +27 -27
- data/bin/console +2 -2
- data/gem-guardian.gemspec +11 -9
- data/lib/gem/guardian/artifact_store.rb +13 -2
- data/lib/gem/guardian/checksum_provider.rb +181 -0
- data/lib/gem/guardian/cli.rb +99 -7
- data/lib/gem/guardian/configuration.rb +88 -0
- data/lib/gem/guardian/dependency.rb +5 -1
- data/lib/gem/guardian/github_release_verifier.rb +2 -2
- data/lib/gem/guardian/lockfile_parser.rb +32 -6
- data/lib/gem/guardian/progress.rb +66 -0
- data/lib/gem/guardian/provenance_verifier.rb +1 -3
- data/lib/gem/guardian/registry.rb +83 -0
- data/lib/gem/guardian/registry_audit.rb +81 -0
- data/lib/gem/guardian/report_builder.rb +3 -4
- data/lib/gem/guardian/result_printer.rb +35 -5
- data/lib/gem/guardian/rubygems_client.rb +366 -21
- data/lib/gem/guardian/verifier.rb +119 -12
- data/lib/gem/guardian/version.rb +1 -1
- data/lib/gem/guardian.rb +4 -0
- data/script/registry_provenance_audit.rb +41 -0
- metadata +16 -19
- data/sig/gem/guardian/artifact_store.rbs +0 -22
- data/sig/gem/guardian/checksum.rbs +0 -14
- data/sig/gem/guardian/cli.rbs +0 -60
- data/sig/gem/guardian/dependency.rbs +0 -18
- data/sig/gem/guardian/error.rbs +0 -26
- data/sig/gem/guardian/lockfile_parser.rbs +0 -55
- data/sig/gem/guardian/rubygems_client.rbs +0 -46
- data/sig/gem/guardian/verifier.rbs +0 -40
- data/sig/gem/guardian/version.rbs +0 -10
- data/sig/gem/guardian.rbs +0 -4
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "openssl"
|
|
5
|
+
require "bundler"
|
|
6
|
+
require "gem/guardian/progress"
|
|
7
|
+
require "gem/guardian/checksum_provider"
|
|
8
|
+
require "fileutils"
|
|
5
9
|
require "net/http"
|
|
10
|
+
require "rubygems/dependency"
|
|
11
|
+
require "rubygems/spec_fetcher"
|
|
6
12
|
require "uri"
|
|
7
13
|
|
|
8
14
|
module Gem
|
|
9
15
|
module Guardian
|
|
10
|
-
#
|
|
16
|
+
# Resolves gem sources, reads registry metadata, and downloads gem artifacts.
|
|
17
|
+
#
|
|
18
|
+
# The client deliberately separates source discovery, checksum-provider lookup,
|
|
19
|
+
# provenance lookup, and artifact download. This lets gem-guardian support
|
|
20
|
+
# RubyGems.org, RubyGems-compatible private registries, and publisher-provided
|
|
21
|
+
# checksum URLs without coupling verification to one registry API.
|
|
11
22
|
# rubocop:disable Metrics/ClassLength
|
|
12
23
|
class RubygemsClient
|
|
13
24
|
# Trusted Publishing provenance metadata extracted from RubyGems version data.
|
|
@@ -32,26 +43,151 @@ module Gem
|
|
|
32
43
|
|
|
33
44
|
# Default RubyGems.org endpoint used by the client.
|
|
34
45
|
DEFAULT_HOST = "https://rubygems.org"
|
|
46
|
+
# Maximum number of HTTP redirects followed for API and artifact requests.
|
|
47
|
+
MAX_REDIRECTS = 5
|
|
48
|
+
# Connection timeout, in seconds, for direct HTTP requests.
|
|
49
|
+
OPEN_TIMEOUT = 10
|
|
50
|
+
# Read timeout, in seconds, for direct HTTP requests.
|
|
51
|
+
READ_TIMEOUT = 30
|
|
52
|
+
|
|
53
|
+
# Built-in checksum providers used when no project configuration overrides
|
|
54
|
+
# provider order.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<#checksum_for>] RubyGems.org API and compact-index providers
|
|
57
|
+
def self.default_checksum_providers
|
|
58
|
+
[ChecksumProvider::RubyGemsApi.new, ChecksumProvider::CompactIndex.new]
|
|
59
|
+
end
|
|
35
60
|
|
|
36
|
-
|
|
61
|
+
# @param host [String] default RubyGems host used for API requests when a dependency has no source
|
|
62
|
+
# @param http [#get_response] HTTP client used for metadata and artifact requests
|
|
63
|
+
# @param credentials [Object] Bundler settings-like object used to resolve source credentials
|
|
64
|
+
# @param spec_fetcher [Gem::SpecFetcher] RubyGems spec fetcher used for source discovery
|
|
65
|
+
# @param sources [Gem::SourceList, Array<Gem::Source>] configured RubyGems sources
|
|
66
|
+
# @param checksum_providers [Array<#checksum_for>, nil] ordered checksum providers.
|
|
67
|
+
# Defaults to RubyGems API and compact index providers.
|
|
68
|
+
def initialize(host: DEFAULT_HOST, http: Net::HTTP, credentials: Bundler.settings,
|
|
69
|
+
spec_fetcher: Gem::SpecFetcher.fetcher, sources: Gem.sources,
|
|
70
|
+
checksum_providers: nil)
|
|
37
71
|
@host = host.delete_suffix("/")
|
|
38
72
|
@http = http
|
|
73
|
+
@credentials = credentials
|
|
74
|
+
@spec_fetcher = spec_fetcher
|
|
75
|
+
@sources = sources
|
|
76
|
+
@checksum_providers = checksum_providers || default_checksum_providers
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns +dependency+ with its source populated from the configured RubyGems sources.
|
|
80
|
+
#
|
|
81
|
+
# @param dependency [Dependency] dependency that may not include a source URI
|
|
82
|
+
# @return [Dependency] dependency with a sanitized source URI when resolution succeeds
|
|
83
|
+
#
|
|
84
|
+
# Explicit verification starts with a source-less dependency, unlike Bundler lockfile
|
|
85
|
+
# verification where Bundler has already recorded the remote. Resolving through
|
|
86
|
+
# RubyGems keeps gem-guardian aligned with `gem install` behavior for private
|
|
87
|
+
# registries such as GitHub Packages, Gemfury, CodeArtifact, or self-hosted
|
|
88
|
+
# RubyGems-compatible servers.
|
|
89
|
+
def resolve_dependency(dependency)
|
|
90
|
+
return dependency unless blank?(dependency.source)
|
|
91
|
+
|
|
92
|
+
_spec, source = resolve_spec_and_source(dependency)
|
|
93
|
+
Dependency.new(name: dependency.name, version: dependency.version, platform: dependency.platform,
|
|
94
|
+
source: sanitized_source_uri(source))
|
|
95
|
+
rescue StandardError
|
|
96
|
+
dependency
|
|
39
97
|
end
|
|
40
98
|
|
|
41
99
|
# Returns the expected SHA256 checksum for +dependency+.
|
|
100
|
+
#
|
|
101
|
+
# @param dependency [Dependency] dependency to look up
|
|
102
|
+
# @return [String] SHA256 digest from the first checksum provider that can answer
|
|
103
|
+
# @raise [ChecksumNotFound] when no provider exposes a checksum
|
|
104
|
+
#
|
|
105
|
+
# This compatibility method returns only the digest. Prefer
|
|
106
|
+
# {#registry_checksum} when callers need provider metadata such as the
|
|
107
|
+
# verification URI or provider name.
|
|
42
108
|
def expected_sha256(dependency)
|
|
109
|
+
checksum = registry_checksum(dependency)
|
|
110
|
+
return checksum.sha256 if checksum
|
|
111
|
+
|
|
112
|
+
raise ChecksumNotFound,
|
|
113
|
+
"No SHA256 found for #{dependency.name} #{dependency.version} #{dependency.platform}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns registry or publisher supplied checksum metadata for +dependency+.
|
|
117
|
+
#
|
|
118
|
+
# @param dependency [Dependency] dependency to look up
|
|
119
|
+
# @return [ChecksumProvider::Result, nil] provider result with SHA256, provider name, and verification URI
|
|
120
|
+
#
|
|
121
|
+
# Providers are tried in order. The first provider that returns a checksum
|
|
122
|
+
# becomes the independent checksum source. This allows RubyGems.org, compact
|
|
123
|
+
# index registries, and publisher-controlled checksum URLs to participate in
|
|
124
|
+
# the same verification flow.
|
|
125
|
+
def registry_checksum(dependency)
|
|
126
|
+
@checksum_providers.each do |provider|
|
|
127
|
+
checksum = provider.checksum_for(dependency, client: self)
|
|
128
|
+
return checksum if checksum
|
|
129
|
+
rescue StandardError
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Returns a sanitized URI string suitable for reports.
|
|
137
|
+
#
|
|
138
|
+
# @param uri [URI, String] URI that may contain credentials
|
|
139
|
+
# @return [String] URI with password/token material redacted
|
|
140
|
+
def sanitize_uri(uri)
|
|
141
|
+
sanitized_uri(uri)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns checksum metadata from the RubyGems.org-style versions API.
|
|
145
|
+
#
|
|
146
|
+
# @param dependency [Dependency] dependency to look up
|
|
147
|
+
# @return [ChecksumProvider::Result, nil] checksum metadata when the versions API exposes SHA256
|
|
148
|
+
def rubygems_api_checksum(dependency)
|
|
43
149
|
version = matching_version(dependency)
|
|
44
150
|
sha = version && version_checksum(version)
|
|
45
|
-
if blank?(sha)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
151
|
+
return if blank?(sha)
|
|
152
|
+
|
|
153
|
+
ChecksumProvider::Result.new(
|
|
154
|
+
sha256: sha.downcase,
|
|
155
|
+
source: :registry,
|
|
156
|
+
provider: "rubygems-api",
|
|
157
|
+
verification_uri: "#{host_for(dependency).delete_suffix("/")}/api/v1/versions/#{dependency.name}.json"
|
|
158
|
+
)
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
49
162
|
|
|
50
|
-
|
|
163
|
+
# Returns checksum metadata from the RubyGems/Bundler compact index.
|
|
164
|
+
#
|
|
165
|
+
# @param dependency [Dependency] dependency to look up
|
|
166
|
+
# @return [ChecksumProvider::Result, nil] checksum metadata when the compact index exposes SHA256
|
|
167
|
+
def compact_index_registry_checksum(dependency)
|
|
168
|
+
host = host_for(dependency)
|
|
169
|
+
info_path = "/info/#{dependency.name}"
|
|
170
|
+
info = get(info_path, host:, progress: false)
|
|
171
|
+
sha = compact_index_checksum_for(info, dependency)
|
|
172
|
+
return if blank?(sha)
|
|
173
|
+
|
|
174
|
+
ChecksumProvider::Result.new(
|
|
175
|
+
sha256: sha.downcase,
|
|
176
|
+
source: :registry,
|
|
177
|
+
provider: "compact-index",
|
|
178
|
+
verification_uri: "#{host.delete_suffix("/")}#{info_path}"
|
|
179
|
+
)
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil
|
|
51
182
|
end
|
|
52
183
|
|
|
53
184
|
# Returns trusted publishing provenance data for +dependency+ when RubyGems exposes it.
|
|
185
|
+
#
|
|
186
|
+
# @param dependency [Dependency] dependency to inspect
|
|
187
|
+
# @return [TrustedPublishingProvenance, nil] provenance metadata when available
|
|
54
188
|
def trusted_publishing_provenance(dependency)
|
|
189
|
+
return nil unless ruby_gems_org_source?(dependency)
|
|
190
|
+
|
|
55
191
|
version = matching_version(dependency)
|
|
56
192
|
version && provenance_for(version) ||
|
|
57
193
|
attestation_api_provenance(dependency) ||
|
|
@@ -59,18 +195,142 @@ module Gem
|
|
|
59
195
|
end
|
|
60
196
|
|
|
61
197
|
# Downloads the .gem file for +dependency+ into +destination+.
|
|
198
|
+
#
|
|
199
|
+
# @param dependency [Dependency] dependency to resolve and download
|
|
200
|
+
# @param destination [String] path where the downloaded artifact should be written
|
|
201
|
+
# @return [String] destination path
|
|
202
|
+
# @raise [ArtifactFetchError] when source resolution or artifact download fails
|
|
203
|
+
#
|
|
204
|
+
# RubyGems is used for source/spec resolution, but gem-guardian performs the
|
|
205
|
+
# artifact download itself. This keeps verification deterministic, applies
|
|
206
|
+
# explicit HTTP timeouts, avoids RubyGems installer-side behavior, and prevents
|
|
207
|
+
# `Gem::Source#download` from emitting progress output or hanging in internal
|
|
208
|
+
# fetch paths.
|
|
62
209
|
def download_gem(dependency, destination)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
destination
|
|
210
|
+
spec, source = resolve_spec_and_source(dependency)
|
|
211
|
+
download_gem_uri(gem_uri(source, spec), destination)
|
|
66
212
|
rescue StandardError => e
|
|
67
213
|
raise ArtifactFetchError, "Could not fetch #{dependency.gem_filename}: #{e.message}"
|
|
68
214
|
end
|
|
69
215
|
|
|
70
216
|
private
|
|
71
217
|
|
|
218
|
+
def default_checksum_providers
|
|
219
|
+
self.class.default_checksum_providers
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def download_gem_uri(uri, destination)
|
|
223
|
+
Gem::Guardian::Progress.update("Downloading #{File.basename(uri.path)}...")
|
|
224
|
+
response = get_response(uri)
|
|
225
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
226
|
+
raise Error, "GET #{sanitized_uri(uri)} failed with #{response.code} #{response.message}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
230
|
+
File.binwrite(destination, response.body)
|
|
231
|
+
Gem::Guardian::Progress.finish("Downloaded #{File.basename(uri.path)}")
|
|
232
|
+
destination
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def gem_uri(source, spec)
|
|
236
|
+
base_uri = URI.parse(source.respond_to?(:uri) ? source.uri.to_s : source.to_s)
|
|
237
|
+
URI.join(base_uri.to_s.end_with?("/") ? base_uri.to_s : "#{base_uri}/", "gems/#{spec_full_name(spec)}.gem")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def spec_full_name(spec)
|
|
241
|
+
return spec.full_name if spec.respond_to?(:full_name)
|
|
242
|
+
|
|
243
|
+
platform = spec.platform.to_s
|
|
244
|
+
segments = [spec.name, spec.version.to_s]
|
|
245
|
+
segments << platform unless platform.empty? || platform == "ruby"
|
|
246
|
+
segments.join("-")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def resolve_spec_and_source(dependency)
|
|
250
|
+
matches = matching_specs(dependency)
|
|
251
|
+
if matches.empty?
|
|
252
|
+
raise ArtifactFetchError,
|
|
253
|
+
"No source found for #{dependency.name} #{dependency.version} #{dependency.platform}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
warn_ambiguous_sources(dependency, matches) if matches.size > 1
|
|
257
|
+
matches.first
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def matching_specs(dependency)
|
|
261
|
+
remote_dependency = Gem::Dependency.new(dependency.name, "= #{dependency.version}")
|
|
262
|
+
specs, = @spec_fetcher.spec_for_dependency(remote_dependency, false)
|
|
263
|
+
# rubocop:disable Style/MultilineBlockChain
|
|
264
|
+
specs.select do |spec, source|
|
|
265
|
+
platform_matches?(spec.platform, dependency.platform) && source_matches?(source, dependency.source)
|
|
266
|
+
end.sort_by { |_spec, source| source_order(source) }
|
|
267
|
+
# rubocop:enable Style/MultilineBlockChain
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def warn_ambiguous_sources(dependency, matches)
|
|
271
|
+
sources = matches.map { |_spec, source| sanitized_source_uri(source) }.uniq
|
|
272
|
+
return if sources.size <= 1
|
|
273
|
+
|
|
274
|
+
warn "gem-guardian: #{dependency.name} #{dependency.version} #{dependency.platform} found in multiple sources; " \
|
|
275
|
+
"using #{sources.first}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def source_order(source)
|
|
279
|
+
source_uri = comparable_source_uri(source)
|
|
280
|
+
configured_sources.index { |configured| comparable_source_uri(configured) == source_uri } || configured_sources.size
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def configured_sources
|
|
284
|
+
@configured_sources ||= @sources.respond_to?(:to_a) ? @sources.to_a : Array(@sources)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def source_matches?(source, wanted_source)
|
|
288
|
+
return true if blank?(wanted_source)
|
|
289
|
+
|
|
290
|
+
comparable_source_uri(source) == comparable_source_uri(wanted_source)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def comparable_source_uri(source)
|
|
294
|
+
uri = URI.parse(source.respond_to?(:uri) ? source.uri.to_s : source.to_s)
|
|
295
|
+
uri.user = nil
|
|
296
|
+
uri.password = nil
|
|
297
|
+
uri.to_s.delete_suffix("/")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def sanitized_source_uri(source)
|
|
301
|
+
comparable_source_uri(source)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def compact_index_checksum_for(info, dependency)
|
|
305
|
+
after_header = false
|
|
306
|
+
|
|
307
|
+
info.each_line do |line|
|
|
308
|
+
line = line.strip
|
|
309
|
+
if line == "---"
|
|
310
|
+
after_header = true
|
|
311
|
+
next
|
|
312
|
+
end
|
|
313
|
+
next unless after_header
|
|
314
|
+
next if line.empty?
|
|
315
|
+
|
|
316
|
+
version_platform, _metadata = line.split(" ", 2)
|
|
317
|
+
version, platform = compact_version_and_platform(version_platform)
|
|
318
|
+
next unless version == dependency.version && platform_matches?(platform, dependency.platform)
|
|
319
|
+
|
|
320
|
+
checksum = line[/[|,]checksum:([a-fA-F0-9]{64})(?:,|$)/, 1]
|
|
321
|
+
return checksum.downcase unless blank?(checksum)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def compact_version_and_platform(version_platform)
|
|
328
|
+
version, platform = version_platform.to_s.split("-", 2)
|
|
329
|
+
[version, blank?(platform) ? "ruby" : platform]
|
|
330
|
+
end
|
|
331
|
+
|
|
72
332
|
def matching_version(dependency)
|
|
73
|
-
versions = JSON.parse(get("/api/v1/versions/#{dependency.name}.json"))
|
|
333
|
+
versions = JSON.parse(get("/api/v1/versions/#{dependency.name}.json", host: host_for(dependency), progress: false))
|
|
74
334
|
versions.find do |item|
|
|
75
335
|
item["number"] == dependency.version && platform_matches?(item["platform"], dependency.platform)
|
|
76
336
|
end
|
|
@@ -90,7 +350,7 @@ module Gem
|
|
|
90
350
|
|
|
91
351
|
# Reads provenance details from the RubyGems version page HTML.
|
|
92
352
|
def version_page_provenance(dependency)
|
|
93
|
-
html = get("/gems/#{dependency.name}/versions/#{dependency.version}")
|
|
353
|
+
html = get("/gems/#{dependency.name}/versions/#{dependency.version}", host: host_for(dependency), progress: false)
|
|
94
354
|
provenance = html_provenance_payload(html)
|
|
95
355
|
return unless provenance
|
|
96
356
|
|
|
@@ -102,7 +362,7 @@ module Gem
|
|
|
102
362
|
# Reads provenance details from the RubyGems attestations API.
|
|
103
363
|
def attestation_api_provenance(dependency)
|
|
104
364
|
attestation_id = dependency.gem_filename.delete_suffix(".gem")
|
|
105
|
-
attestations = JSON.parse(get("/api/v1/attestations/#{attestation_id}.json"))
|
|
365
|
+
attestations = JSON.parse(get("/api/v1/attestations/#{attestation_id}.json", host: host_for(dependency), progress: false))
|
|
106
366
|
attestations.each do |attestation|
|
|
107
367
|
provenance = attestation_bundle_provenance(attestation)
|
|
108
368
|
return TrustedPublishingProvenance.new(**provenance.merge(trusted_publishing: true)) if provenance
|
|
@@ -204,7 +464,7 @@ module Gem
|
|
|
204
464
|
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
205
465
|
|
|
206
466
|
# Finds the first certificate-like payload in a nested attestation bundle.
|
|
207
|
-
# rubocop:disable Metrics/
|
|
467
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
208
468
|
def find_certificate(value)
|
|
209
469
|
case value
|
|
210
470
|
when String
|
|
@@ -222,7 +482,7 @@ module Gem
|
|
|
222
482
|
end
|
|
223
483
|
nil
|
|
224
484
|
end
|
|
225
|
-
# rubocop:enable Metrics/
|
|
485
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
226
486
|
|
|
227
487
|
# Extracts the workflow file path from the SAN extension.
|
|
228
488
|
def build_file_from_subject_alt_name(san, repo, ref)
|
|
@@ -266,7 +526,7 @@ module Gem
|
|
|
266
526
|
end
|
|
267
527
|
|
|
268
528
|
# Finds a nested provenance hash inside a RubyGems version payload.
|
|
269
|
-
# rubocop:disable Metrics/
|
|
529
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
270
530
|
def deep_find_provenance_hash(value)
|
|
271
531
|
case value
|
|
272
532
|
when Hash
|
|
@@ -284,7 +544,7 @@ module Gem
|
|
|
284
544
|
end
|
|
285
545
|
nil
|
|
286
546
|
end
|
|
287
|
-
# rubocop:enable Metrics/
|
|
547
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
288
548
|
|
|
289
549
|
# Returns true when +value+ looks like a provenance record.
|
|
290
550
|
def provenance_hash?(value)
|
|
@@ -301,15 +561,100 @@ module Gem
|
|
|
301
561
|
value == true || value.to_s.casecmp("true").zero?
|
|
302
562
|
end
|
|
303
563
|
|
|
304
|
-
# GETs +path+ from the configured host and returns the response body.
|
|
305
|
-
def get(path)
|
|
306
|
-
uri = URI("#{
|
|
307
|
-
|
|
564
|
+
# GETs +path+ from the configured or dependency source host and returns the response body.
|
|
565
|
+
def get(path, host: @host, progress: true)
|
|
566
|
+
uri = URI("#{host.delete_suffix("/")}#{path}")
|
|
567
|
+
Gem::Guardian::Progress.update("Downloading #{File.basename(uri.path)}...") if progress
|
|
568
|
+
response = get_response(uri)
|
|
308
569
|
return response.body if response.is_a?(Net::HTTPSuccess)
|
|
309
570
|
|
|
310
571
|
raise Error, "GET #{uri} failed with #{response.code} #{response.message}"
|
|
311
572
|
end
|
|
312
573
|
|
|
574
|
+
def get_response(uri, limit: MAX_REDIRECTS)
|
|
575
|
+
raise Error, "Too many redirects for #{uri}" if limit.negative?
|
|
576
|
+
|
|
577
|
+
headers = authorization_headers(uri)
|
|
578
|
+
response = if headers.empty?
|
|
579
|
+
plain_response(uri)
|
|
580
|
+
else
|
|
581
|
+
authenticated_response(uri, headers)
|
|
582
|
+
end
|
|
583
|
+
return redirect_response(response, uri, limit) if response.is_a?(Net::HTTPRedirection)
|
|
584
|
+
|
|
585
|
+
response
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def plain_response(uri)
|
|
589
|
+
return @http.get_response(uri) unless @http == Net::HTTP
|
|
590
|
+
|
|
591
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
592
|
+
open_timeout: OPEN_TIMEOUT,
|
|
593
|
+
read_timeout: READ_TIMEOUT) do |http|
|
|
594
|
+
http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def authenticated_response(uri, headers)
|
|
599
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
600
|
+
request.instance_variable_set(:@uri, uri)
|
|
601
|
+
headers.each { |key, value| request[key] = value }
|
|
602
|
+
return @http.request(request) if @http.respond_to?(:request)
|
|
603
|
+
|
|
604
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
605
|
+
open_timeout: OPEN_TIMEOUT,
|
|
606
|
+
read_timeout: READ_TIMEOUT) do |http|
|
|
607
|
+
http.request(request)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def redirect_response(response, uri, limit)
|
|
612
|
+
location = response["location"]
|
|
613
|
+
raise Error, "Redirect missing location for #{uri}" if blank?(location)
|
|
614
|
+
|
|
615
|
+
redirect_uri = URI.parse(URI.join(uri.to_s, location.to_s).to_s)
|
|
616
|
+
get_response(redirect_uri, limit: limit - 1)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def authorization_headers(uri)
|
|
620
|
+
return {} unless github_packages_host?(uri.host)
|
|
621
|
+
|
|
622
|
+
token = bearer_token_for(uri)
|
|
623
|
+
token ? { "Authorization" => "Bearer #{token}" } : {}
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def bearer_token_for(uri)
|
|
627
|
+
embedded_token = uri.password || uri.user
|
|
628
|
+
return embedded_token unless blank?(embedded_token)
|
|
629
|
+
|
|
630
|
+
credentials = @credentials.credentials_for(sanitized_uri(uri)) || @credentials.credentials_for(uri)
|
|
631
|
+
return if blank?(credentials)
|
|
632
|
+
|
|
633
|
+
credentials.to_s.split(":", 2).last
|
|
634
|
+
rescue StandardError
|
|
635
|
+
nil
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def sanitized_uri(uri)
|
|
639
|
+
sanitized = URI.parse(uri.to_s)
|
|
640
|
+
sanitized.user = nil
|
|
641
|
+
sanitized.password = nil
|
|
642
|
+
sanitized.to_s
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def github_packages_host?(host)
|
|
646
|
+
host.to_s.casecmp("rubygems.pkg.github.com").zero?
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def host_for(dependency)
|
|
650
|
+
source = dependency.respond_to?(:source) && dependency.source
|
|
651
|
+
blank?(source) ? @host : source
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def ruby_gems_org_source?(dependency)
|
|
655
|
+
comparable_source_uri(host_for(dependency)) == comparable_source_uri(DEFAULT_HOST)
|
|
656
|
+
end
|
|
657
|
+
|
|
313
658
|
# Compares a RubyGems platform string with the requested platform.
|
|
314
659
|
def platform_matches?(remote_platform, wanted_platform)
|
|
315
660
|
normalized_remote = remote_platform.to_s.empty? ? "ruby" : remote_platform.to_s
|
|
@@ -3,15 +3,48 @@
|
|
|
3
3
|
module Gem
|
|
4
4
|
module Guardian
|
|
5
5
|
# Result object for a single verification attempt.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] dependency
|
|
8
|
+
# @return [Dependency] dependency being verified
|
|
9
|
+
# @!attribute [r] expected_sha256
|
|
10
|
+
# @return [String, nil] independent checksum used as the primary expected digest,
|
|
11
|
+
# or +nil+ when the artifact was only recorded
|
|
12
|
+
# @!attribute [r] actual_sha256
|
|
13
|
+
# @return [String, nil] SHA256 computed from the downloaded `.gem` artifact
|
|
14
|
+
# @!attribute [r] artifact_path
|
|
15
|
+
# @return [String, nil] local path to the downloaded artifact
|
|
16
|
+
# @!attribute [r] status
|
|
17
|
+
# @return [Symbol] +:ok+, +:mismatch+, or +:error+
|
|
18
|
+
# @!attribute [r] error
|
|
19
|
+
# @return [Exception, nil] verification error when +status+ is +:error+
|
|
20
|
+
# @!attribute [r] checksum_source
|
|
21
|
+
# @return [Symbol, nil] +:lockfile+, +:registry+, or +:artifact+
|
|
22
|
+
# @!attribute [r] registry_sha256
|
|
23
|
+
# @return [String, nil] registry or publisher checksum used as an optional cross-check
|
|
24
|
+
# @!attribute [r] registry_checksum_provider
|
|
25
|
+
# @return [String, nil] checksum provider name, such as +rubygems-api+, +compact-index+, or +url+
|
|
26
|
+
# @!attribute [r] registry_checksum_uri
|
|
27
|
+
# @return [String, nil] sanitized URI where the registry or publisher checksum can be inspected
|
|
6
28
|
VerificationResult = Data.define(:dependency, :expected_sha256, :actual_sha256, :artifact_path, :status, :error,
|
|
7
|
-
:checksum_source
|
|
8
|
-
|
|
29
|
+
:checksum_source, :registry_sha256, :registry_checksum_provider,
|
|
30
|
+
:registry_checksum_uri) do
|
|
31
|
+
# Indicates whether the verification result is successful.
|
|
32
|
+
#
|
|
33
|
+
# For +:artifact+ results, success means the artifact digest was recorded,
|
|
34
|
+
# not that an independent checksum comparison occurred.
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean] +true+ when +status+ is +:ok+
|
|
9
37
|
def ok?
|
|
10
38
|
status == :ok
|
|
11
39
|
end
|
|
12
40
|
end
|
|
13
41
|
|
|
14
|
-
# Verifies gem artifacts against
|
|
42
|
+
# Verifies gem artifacts against lockfile, registry, or artifact checksum sources.
|
|
43
|
+
#
|
|
44
|
+
# Verification follows the trust-source priority documented in the README:
|
|
45
|
+
# lockfile checksums are preferred, registry or publisher checksums are used
|
|
46
|
+
# for ad-hoc verification when available, and artifact-only digests are
|
|
47
|
+
# recorded when no independent checksum exists.
|
|
15
48
|
class Verifier
|
|
16
49
|
def initialize(client: RubygemsClient.new, artifact_store: nil, expected_checksums: {})
|
|
17
50
|
@client = client
|
|
@@ -19,15 +52,22 @@ module Gem
|
|
|
19
52
|
@expected_checksums = expected_checksums
|
|
20
53
|
end
|
|
21
54
|
|
|
22
|
-
# Verifies one dependency
|
|
55
|
+
# Verifies one dependency.
|
|
56
|
+
#
|
|
57
|
+
# @param dependency [Dependency] dependency to resolve, download, hash, and verify
|
|
58
|
+
# @return [VerificationResult] verification result for the dependency
|
|
23
59
|
def verify(dependency)
|
|
24
|
-
|
|
25
|
-
|
|
60
|
+
resolved_dependency = resolve_dependency(dependency)
|
|
61
|
+
expected, checksum_source = expected_sha256_for(dependency, resolved_dependency)
|
|
62
|
+
build_verification_result(resolved_dependency, expected, checksum_source)
|
|
26
63
|
rescue StandardError => e
|
|
27
64
|
build_error_result(dependency, e)
|
|
28
65
|
end
|
|
29
66
|
|
|
30
67
|
# Verifies each dependency in +dependencies+.
|
|
68
|
+
#
|
|
69
|
+
# @param dependencies [Enumerable<Dependency>] dependencies to verify
|
|
70
|
+
# @return [Array<VerificationResult>] verification results in dependency order
|
|
31
71
|
def verify_all(dependencies)
|
|
32
72
|
dependencies.map { |dependency| verify(dependency) }
|
|
33
73
|
end
|
|
@@ -46,19 +86,33 @@ module Gem
|
|
|
46
86
|
artifact_path: nil,
|
|
47
87
|
status: :error,
|
|
48
88
|
error:,
|
|
49
|
-
checksum_source: nil
|
|
89
|
+
checksum_source: nil,
|
|
90
|
+
registry_sha256: nil,
|
|
91
|
+
registry_checksum_provider: nil,
|
|
92
|
+
registry_checksum_uri: nil
|
|
50
93
|
)
|
|
51
94
|
end
|
|
52
95
|
|
|
53
96
|
def verification_attributes(dependency, expected, checksum_source)
|
|
54
97
|
artifact_path = @artifact_store.path_for(dependency)
|
|
55
98
|
actual = Checksum.sha256_file(artifact_path)
|
|
99
|
+
registry_checksum = registry_checksum_for(dependency, checksum_source, expected)
|
|
100
|
+
checksum_source = :artifact if checksum_source == :registry && registry_checksum.nil?
|
|
101
|
+
registry_sha256 = registry_checksum&.sha256
|
|
102
|
+
expected = registry_sha256 if checksum_source == :registry
|
|
103
|
+
|
|
56
104
|
{ dependency:, expected_sha256: expected, actual_sha256: actual, artifact_path:,
|
|
57
|
-
status:
|
|
58
|
-
checksum_source
|
|
105
|
+
status: checksum_status(expected, actual, registry_sha256, checksum_source), error: nil,
|
|
106
|
+
checksum_source:, registry_sha256:,
|
|
107
|
+
registry_checksum_provider: registry_checksum&.provider,
|
|
108
|
+
registry_checksum_uri: registry_checksum&.verification_uri }
|
|
59
109
|
end
|
|
60
110
|
|
|
61
111
|
# Constant-time comparison for checksum strings.
|
|
112
|
+
#
|
|
113
|
+
# @param left [String, nil] first checksum value
|
|
114
|
+
# @param right [String, nil] second checksum value
|
|
115
|
+
# @return [Boolean] +true+ when both checksum strings are byte-identical
|
|
62
116
|
def secure_compare(left, right)
|
|
63
117
|
left = left.to_s
|
|
64
118
|
right = right.to_s
|
|
@@ -67,13 +121,66 @@ module Gem
|
|
|
67
121
|
left.bytes.zip(right.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b) }.zero?
|
|
68
122
|
end
|
|
69
123
|
|
|
70
|
-
#
|
|
71
|
-
|
|
124
|
+
# Selects the primary expected checksum source.
|
|
125
|
+
#
|
|
126
|
+
# @param dependency [Dependency] original dependency requested by the caller
|
|
127
|
+
# @param _resolved_dependency [Dependency] dependency after registry source resolution
|
|
128
|
+
# @return [(String, Symbol)] expected checksum and source label
|
|
129
|
+
def expected_sha256_for(dependency, _resolved_dependency)
|
|
72
130
|
if @expected_checksums.key?(dependency)
|
|
73
131
|
[@expected_checksums.fetch(dependency), :lockfile]
|
|
74
132
|
else
|
|
75
|
-
[
|
|
133
|
+
[nil, :registry]
|
|
76
134
|
end
|
|
135
|
+
rescue ChecksumNotFound
|
|
136
|
+
[nil, :artifact]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Returns the registry or publisher checksum when available.
|
|
140
|
+
#
|
|
141
|
+
# @param dependency [Dependency] dependency being verified
|
|
142
|
+
# @param checksum_source [Symbol] primary checksum source selected for the result
|
|
143
|
+
# @param _expected [String, nil] primary expected checksum, usually from the lockfile
|
|
144
|
+
# @return [ChecksumProvider::Result, nil] independent registry/publisher checksum metadata when available
|
|
145
|
+
#
|
|
146
|
+
# Lockfile verification can be stronger when the registry also exposes a
|
|
147
|
+
# checksum. In that case gem-guardian verifies the three-way relationship:
|
|
148
|
+
#
|
|
149
|
+
# lockfile SHA256 == registry SHA256 == artifact SHA256
|
|
150
|
+
#
|
|
151
|
+
# Registries are not required to expose checksum metadata. Missing registry
|
|
152
|
+
# metadata does not weaken the lockfile comparison; it only means the
|
|
153
|
+
# optional registry cross-check cannot be performed.
|
|
154
|
+
def registry_checksum_for(dependency, checksum_source, _expected = nil)
|
|
155
|
+
if checksum_source == :registry
|
|
156
|
+
return @client.registry_checksum(dependency) if @client.respond_to?(:registry_checksum)
|
|
157
|
+
|
|
158
|
+
sha = @client.expected_sha256(dependency)
|
|
159
|
+
return ChecksumProvider::Result.new(sha256: sha, source: :registry, provider: "legacy", verification_uri: nil)
|
|
160
|
+
end
|
|
161
|
+
return nil if checksum_source == :artifact
|
|
162
|
+
|
|
163
|
+
checksum = @client.registry_checksum(dependency) if @client.respond_to?(:registry_checksum)
|
|
164
|
+
return checksum if checksum
|
|
165
|
+
|
|
166
|
+
sha = @client.expected_sha256(dependency)
|
|
167
|
+
ChecksumProvider::Result.new(sha256: sha, source: :registry, provider: "legacy", verification_uri: nil)
|
|
168
|
+
rescue ChecksumNotFound
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def checksum_status(expected, actual, registry_sha256, checksum_source)
|
|
173
|
+
return :ok if checksum_source == :artifact
|
|
174
|
+
return :mismatch unless expected && secure_compare(expected, actual)
|
|
175
|
+
return :mismatch if registry_sha256 && !secure_compare(registry_sha256, actual)
|
|
176
|
+
|
|
177
|
+
:ok
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def resolve_dependency(dependency)
|
|
181
|
+
return dependency unless @client.respond_to?(:resolve_dependency)
|
|
182
|
+
|
|
183
|
+
@client.resolve_dependency(dependency)
|
|
77
184
|
end
|
|
78
185
|
end
|
|
79
186
|
end
|