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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/{main.yml → ci.yml} +3 -21
  3. data/.rubocop.yml +12 -0
  4. data/CHANGELOG.md +25 -1
  5. data/CODE_OF_CONDUCT.md +1 -1
  6. data/Gemfile +0 -1
  7. data/README.md +397 -49
  8. data/Rakefile +27 -27
  9. data/bin/console +2 -2
  10. data/gem-guardian.gemspec +11 -9
  11. data/lib/gem/guardian/artifact_store.rb +13 -2
  12. data/lib/gem/guardian/checksum_provider.rb +181 -0
  13. data/lib/gem/guardian/cli.rb +99 -7
  14. data/lib/gem/guardian/configuration.rb +88 -0
  15. data/lib/gem/guardian/dependency.rb +5 -1
  16. data/lib/gem/guardian/github_release_verifier.rb +2 -2
  17. data/lib/gem/guardian/lockfile_parser.rb +32 -6
  18. data/lib/gem/guardian/progress.rb +66 -0
  19. data/lib/gem/guardian/provenance_verifier.rb +1 -3
  20. data/lib/gem/guardian/registry.rb +83 -0
  21. data/lib/gem/guardian/registry_audit.rb +81 -0
  22. data/lib/gem/guardian/report_builder.rb +3 -4
  23. data/lib/gem/guardian/result_printer.rb +35 -5
  24. data/lib/gem/guardian/rubygems_client.rb +366 -21
  25. data/lib/gem/guardian/verifier.rb +119 -12
  26. data/lib/gem/guardian/version.rb +1 -1
  27. data/lib/gem/guardian.rb +4 -0
  28. data/script/registry_provenance_audit.rb +41 -0
  29. metadata +16 -19
  30. data/sig/gem/guardian/artifact_store.rbs +0 -22
  31. data/sig/gem/guardian/checksum.rbs +0 -14
  32. data/sig/gem/guardian/cli.rbs +0 -60
  33. data/sig/gem/guardian/dependency.rbs +0 -18
  34. data/sig/gem/guardian/error.rbs +0 -26
  35. data/sig/gem/guardian/lockfile_parser.rbs +0 -55
  36. data/sig/gem/guardian/rubygems_client.rbs +0 -46
  37. data/sig/gem/guardian/verifier.rbs +0 -40
  38. data/sig/gem/guardian/version.rbs +0 -10
  39. 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
- # Reads checksum metadata from RubyGems.org and downloads gem artifacts.
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
- def initialize(host: DEFAULT_HOST, http: Net::HTTP)
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
- raise ChecksumNotFound,
47
- "No SHA256 found for #{dependency.name} #{dependency.version} #{dependency.platform}"
48
- end
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
- sha.downcase
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
- body = get("/downloads/#{dependency.gem_filename}")
64
- File.binwrite(destination, body)
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/MethodLength, Metrics/CyclomaticComplexity
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/MethodLength, Metrics/CyclomaticComplexity
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/MethodLength, Metrics/CyclomaticComplexity
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/MethodLength, Metrics/CyclomaticComplexity
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("#{@host}#{path}")
307
- response = @http.get_response(uri)
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) do
8
- # Returns true when the verification succeeded.
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 an expected checksum source.
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 and returns a VerificationResult.
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
- expected, checksum_source = expected_sha256_for(dependency)
25
- build_verification_result(dependency, expected, checksum_source)
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: secure_compare(expected, actual) ? :ok : :mismatch, error: nil,
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
- # Uses lockfile checksums first and falls back to RubyGems metadata.
71
- def expected_sha256_for(dependency)
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
- [@client.expected_sha256(dependency), :rubygems]
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
@@ -3,6 +3,6 @@
3
3
  module Gem
4
4
  module Guardian
5
5
  # gem-guardian version.
6
- VERSION = "0.3.0"
6
+ VERSION = "0.4.0"
7
7
  end
8
8
  end