gem-guardian 0.1.0 → 0.2.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 +24 -3
- data/.github/workflows/pages.yml +68 -0
- data/.github/workflows/release.yml +31 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +12 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +18 -1
- data/Gemfile +3 -3
- data/README.md +47 -15
- data/Rakefile +2 -1
- data/gem-guardian.gemspec +3 -1
- data/lib/gem/guardian/artifact_store.rb +4 -0
- data/lib/gem/guardian/checksum.rb +2 -0
- data/lib/gem/guardian/cli.rb +129 -61
- data/lib/gem/guardian/dependency.rb +2 -0
- data/lib/gem/guardian/error.rb +4 -0
- data/lib/gem/guardian/lockfile_parser.rb +95 -13
- data/lib/gem/guardian/provenance_verifier.rb +88 -0
- data/lib/gem/guardian/report_builder.rb +99 -0
- data/lib/gem/guardian/result_printer.rb +150 -0
- data/lib/gem/guardian/rubygems_client.rb +270 -6
- data/lib/gem/guardian/verifier.rb +43 -20
- data/lib/gem/guardian/version.rb +2 -1
- data/lib/gem/guardian.rb +7 -0
- data/sig/gem/guardian/artifact_store.rbs +9 -0
- data/sig/gem/guardian/checksum.rbs +7 -0
- data/sig/gem/guardian/cli.rbs +31 -0
- data/sig/gem/guardian/dependency.rbs +13 -0
- data/sig/gem/guardian/error.rbs +15 -0
- data/sig/gem/guardian/lockfile_parser.rbs +36 -0
- data/sig/gem/guardian/rubygems_client.rbs +21 -0
- data/sig/gem/guardian/verifier.rbs +19 -0
- data/sig/gem/guardian/version.rbs +5 -0
- data/sig/gem/guardian.rbs +4 -0
- metadata +13 -8
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "openssl"
|
|
4
5
|
require "net/http"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
7
8
|
module Gem
|
|
8
9
|
module Guardian
|
|
10
|
+
# Reads checksum metadata from RubyGems.org and downloads gem artifacts.
|
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
12
|
class RubygemsClient
|
|
13
|
+
# Trusted Publishing provenance metadata extracted from RubyGems version data.
|
|
14
|
+
TrustedPublishingProvenance = Data.define(
|
|
15
|
+
:trusted_publishing, :repository, :ref, :workflow, :issuer, :subject, :sha256, :attestation_url
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
SOURCE_COMMIT_PATTERN = %r{Source Commit\s+([A-Za-z0-9._/-]+@[A-Za-z0-9._-]+)}i
|
|
19
|
+
BUILD_FILE_PATTERN = /Build File\s+([^\s]+)/i
|
|
20
|
+
LOG_ENTRY_PATTERN = %r{transparency log entry\s*(https?://[^\s]+)}i
|
|
21
|
+
SHA256_PATTERN = /SHA 256 checksum\s*([a-f0-9]{64})/i
|
|
22
|
+
WORKFLOW_PATTERN = /
|
|
23
|
+
Built and signed on\s+
|
|
24
|
+
([A-Za-z0-9 ._-]+?)
|
|
25
|
+
(?:\s+Build summary|\s+Source Commit|\z)
|
|
26
|
+
/ix
|
|
27
|
+
|
|
28
|
+
# Default RubyGems.org endpoint used by the client.
|
|
10
29
|
DEFAULT_HOST = "https://rubygems.org"
|
|
11
30
|
|
|
12
31
|
def initialize(host: DEFAULT_HOST, http: Net::HTTP)
|
|
@@ -14,18 +33,27 @@ module Gem
|
|
|
14
33
|
@http = http
|
|
15
34
|
end
|
|
16
35
|
|
|
36
|
+
# Returns the expected SHA256 checksum for +dependency+.
|
|
17
37
|
def expected_sha256(dependency)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
38
|
+
version = matching_version(dependency)
|
|
39
|
+
sha = version && version_checksum(version)
|
|
40
|
+
if blank?(sha)
|
|
41
|
+
raise ChecksumNotFound,
|
|
42
|
+
"No SHA256 found for #{dependency.name} #{dependency.version} #{dependency.platform}"
|
|
21
43
|
end
|
|
22
44
|
|
|
23
|
-
sha = version && (version["sha"] || version["sha256"] || version["checksum"])
|
|
24
|
-
raise ChecksumNotFound, "No SHA256 found for #{dependency.name} #{dependency.version} #{dependency.platform}" if blank?(sha)
|
|
25
|
-
|
|
26
45
|
sha.downcase
|
|
27
46
|
end
|
|
28
47
|
|
|
48
|
+
# Returns trusted publishing provenance data for +dependency+ when RubyGems exposes it.
|
|
49
|
+
def trusted_publishing_provenance(dependency)
|
|
50
|
+
version = matching_version(dependency)
|
|
51
|
+
version && provenance_for(version) ||
|
|
52
|
+
attestation_api_provenance(dependency) ||
|
|
53
|
+
version_page_provenance(dependency)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Downloads the .gem file for +dependency+ into +destination+.
|
|
29
57
|
def download_gem(dependency, destination)
|
|
30
58
|
body = get("/downloads/#{dependency.gem_filename}")
|
|
31
59
|
File.binwrite(destination, body)
|
|
@@ -36,6 +64,239 @@ module Gem
|
|
|
36
64
|
|
|
37
65
|
private
|
|
38
66
|
|
|
67
|
+
def matching_version(dependency)
|
|
68
|
+
versions = JSON.parse(get("/api/v1/versions/#{dependency.name}.json"))
|
|
69
|
+
versions.find do |item|
|
|
70
|
+
item["number"] == dependency.version && platform_matches?(item["platform"], dependency.platform)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def version_checksum(version)
|
|
75
|
+
version["sha"] || version["sha256"] || version["checksum"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Extracts trusted publishing provenance data from a RubyGems version payload.
|
|
79
|
+
def provenance_for(version)
|
|
80
|
+
provenance = provenance_payload(version)
|
|
81
|
+
return unless provenance.any?
|
|
82
|
+
|
|
83
|
+
TrustedPublishingProvenance.new(**provenance_attributes(provenance).merge(trusted_publishing: true))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Reads provenance details from the RubyGems version page HTML.
|
|
87
|
+
def version_page_provenance(dependency)
|
|
88
|
+
html = get("/gems/#{dependency.name}/versions/#{dependency.version}")
|
|
89
|
+
provenance = html_provenance_payload(html)
|
|
90
|
+
return unless provenance
|
|
91
|
+
|
|
92
|
+
TrustedPublishingProvenance.new(**provenance.merge(trusted_publishing: true))
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Reads provenance details from the RubyGems attestations API.
|
|
98
|
+
def attestation_api_provenance(dependency)
|
|
99
|
+
attestation_id = dependency.gem_filename.delete_suffix(".gem")
|
|
100
|
+
attestations = JSON.parse(get("/api/v1/attestations/#{attestation_id}.json"))
|
|
101
|
+
attestations.each do |attestation|
|
|
102
|
+
provenance = attestation_bundle_provenance(attestation)
|
|
103
|
+
return TrustedPublishingProvenance.new(**provenance.merge(trusted_publishing: true)) if provenance
|
|
104
|
+
end
|
|
105
|
+
nil
|
|
106
|
+
rescue StandardError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns the provenance payload from a version hash.
|
|
111
|
+
def provenance_payload(version)
|
|
112
|
+
payload = version["provenance"] || version["trusted_publishing"] || version["attestation"]
|
|
113
|
+
payload = deep_find_provenance_hash(version) unless payload.is_a?(Hash)
|
|
114
|
+
payload = version if payload.nil? && trusted_publishing_flag?(version)
|
|
115
|
+
payload.is_a?(Hash) ? payload : {}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Returns the first non-empty provenance string value for the provided keys.
|
|
119
|
+
def provenance_string(provenance, *keys)
|
|
120
|
+
keys.map { |key| provenance[key] }.find { |value| !blank?(value) }&.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns the extracted provenance attributes.
|
|
124
|
+
def provenance_attributes(provenance)
|
|
125
|
+
{
|
|
126
|
+
repository: provenance_string(provenance, "repository", "repository_url", "source_repository"),
|
|
127
|
+
ref: provenance_string(provenance, "ref", "source_ref", "git_ref", "tag", "source_commit"),
|
|
128
|
+
workflow: provenance_string(provenance, "workflow", "workflow_name", "build_file"),
|
|
129
|
+
issuer: provenance_string(provenance, "issuer"),
|
|
130
|
+
subject: provenance_string(provenance, "subject"),
|
|
131
|
+
sha256: provenance_string(provenance, "sha256", "checksum", "digest"),
|
|
132
|
+
attestation_url: provenance_string(provenance, "attestation_url", "provenance_url", "url",
|
|
133
|
+
"transparency_log_entry")
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Extracts provenance metadata from the visible RubyGems HTML page text.
|
|
138
|
+
# rubocop:disable Metrics/MethodLength
|
|
139
|
+
def html_provenance_payload(html)
|
|
140
|
+
text = normalized_text(html)
|
|
141
|
+
source_commit = capture_text(text, SOURCE_COMMIT_PATTERN)
|
|
142
|
+
build_file = capture_text(text, BUILD_FILE_PATTERN)
|
|
143
|
+
log_entry = capture_text(text, LOG_ENTRY_PATTERN)
|
|
144
|
+
sha256 = capture_text(text, SHA256_PATTERN)
|
|
145
|
+
workflow = capture_text(text, WORKFLOW_PATTERN)
|
|
146
|
+
return unless source_commit || build_file || log_entry || sha256 || workflow
|
|
147
|
+
|
|
148
|
+
repository, ref = parse_source_commit(source_commit)
|
|
149
|
+
{
|
|
150
|
+
repository:,
|
|
151
|
+
ref:,
|
|
152
|
+
workflow: workflow || "GitHub Actions",
|
|
153
|
+
issuer: "GitHub Actions",
|
|
154
|
+
subject: source_commit,
|
|
155
|
+
sha256: sha256,
|
|
156
|
+
attestation_url: log_entry
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
# rubocop:enable Metrics/MethodLength
|
|
160
|
+
|
|
161
|
+
# Extracts provenance metadata from a Sigstore attestation bundle.
|
|
162
|
+
def attestation_bundle_provenance(attestation)
|
|
163
|
+
bundle = attestation.is_a?(Hash) ? attestation : JSON.parse(attestation.to_s)
|
|
164
|
+
certificate = find_certificate(bundle)
|
|
165
|
+
return unless certificate
|
|
166
|
+
|
|
167
|
+
parse_attestation_certificate(certificate)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Returns a provenance hash extracted from a leaf certificate.
|
|
171
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
172
|
+
def parse_attestation_certificate(certificate)
|
|
173
|
+
cert = certificate.is_a?(OpenSSL::X509::Certificate) ? certificate : OpenSSL::X509::Certificate.new(certificate)
|
|
174
|
+
extensions = cert.extensions.each_with_object({}) do |ext, memo|
|
|
175
|
+
memo[ext.oid] = ext.value
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
repo = extensions["1.3.6.1.4.1.57264.1.5"]
|
|
179
|
+
commit = extensions["1.3.6.1.4.1.57264.1.3"]
|
|
180
|
+
ref = extensions["1.3.6.1.4.1.57264.1.14"]
|
|
181
|
+
build_summary_url = extensions["1.3.6.1.4.1.57264.1.21"]
|
|
182
|
+
san = extensions["subjectAltName"]
|
|
183
|
+
build_file = build_file_from_subject_alt_name(san, repo, ref)
|
|
184
|
+
|
|
185
|
+
return unless repo || commit || ref || build_summary_url || build_file
|
|
186
|
+
|
|
187
|
+
{
|
|
188
|
+
repository: normalize_repository(repo),
|
|
189
|
+
ref: commit || ref,
|
|
190
|
+
workflow: build_file || build_summary_url,
|
|
191
|
+
issuer: "https://token.actions.githubusercontent.com",
|
|
192
|
+
subject: [repo, commit].compact.join("@"),
|
|
193
|
+
sha256: nil,
|
|
194
|
+
attestation_url: build_summary_url
|
|
195
|
+
}
|
|
196
|
+
rescue OpenSSL::X509::CertificateError, OpenSSL::ASN1::ASN1Error
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
200
|
+
|
|
201
|
+
# Finds the first certificate-like payload in a nested attestation bundle.
|
|
202
|
+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
203
|
+
def find_certificate(value)
|
|
204
|
+
case value
|
|
205
|
+
when String
|
|
206
|
+
return value if value.include?("BEGIN CERTIFICATE")
|
|
207
|
+
when Hash
|
|
208
|
+
value.each_value do |child|
|
|
209
|
+
found = find_certificate(child)
|
|
210
|
+
return found if found
|
|
211
|
+
end
|
|
212
|
+
when Array
|
|
213
|
+
value.each do |child|
|
|
214
|
+
found = find_certificate(child)
|
|
215
|
+
return found if found
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
221
|
+
|
|
222
|
+
# Extracts the workflow file path from the SAN extension.
|
|
223
|
+
def build_file_from_subject_alt_name(san, repo, ref)
|
|
224
|
+
return unless san && repo && ref
|
|
225
|
+
|
|
226
|
+
match = san.match(%r{\AURI:https://github\.com/#{Regexp.escape(repo)}/(.+)@#{Regexp.escape(ref)}\z})
|
|
227
|
+
match && match[1]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Normalizes the repository value to a full GitHub URL.
|
|
231
|
+
def normalize_repository(repository)
|
|
232
|
+
return if blank?(repository)
|
|
233
|
+
|
|
234
|
+
repository = repository.to_s
|
|
235
|
+
repository.start_with?("http") ? repository : "https://github.com/#{repository}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Returns a text-only version of the RubyGems HTML page.
|
|
239
|
+
def normalized_text(html)
|
|
240
|
+
html.to_s
|
|
241
|
+
.gsub(%r{<script.*?</script>}m, " ")
|
|
242
|
+
.gsub(%r{<style.*?</style>}m, " ")
|
|
243
|
+
.gsub(/<[^>]+>/, " ")
|
|
244
|
+
.gsub(/ /, " ")
|
|
245
|
+
.gsub(/&/, "&")
|
|
246
|
+
.gsub(/\s+/, " ")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Captures the first matching string from +text+ for +pattern+.
|
|
250
|
+
def capture_text(text, pattern)
|
|
251
|
+
match = text.match(pattern)
|
|
252
|
+
match && match[1].to_s.strip
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Returns repository and ref values from a source commit string.
|
|
256
|
+
def parse_source_commit(source_commit)
|
|
257
|
+
return [nil, nil] if blank?(source_commit)
|
|
258
|
+
|
|
259
|
+
repository, ref = source_commit.split("@", 2)
|
|
260
|
+
[repository.start_with?("http") ? repository : "https://github.com/#{repository}", ref]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Finds a nested provenance hash inside a RubyGems version payload.
|
|
264
|
+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
265
|
+
def deep_find_provenance_hash(value)
|
|
266
|
+
case value
|
|
267
|
+
when Hash
|
|
268
|
+
return value if provenance_hash?(value)
|
|
269
|
+
|
|
270
|
+
value.each_value do |child|
|
|
271
|
+
found = deep_find_provenance_hash(child)
|
|
272
|
+
return found if found
|
|
273
|
+
end
|
|
274
|
+
when Array
|
|
275
|
+
value.each do |child|
|
|
276
|
+
found = deep_find_provenance_hash(child)
|
|
277
|
+
return found if found
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
283
|
+
|
|
284
|
+
# Returns true when +value+ looks like a provenance record.
|
|
285
|
+
def provenance_hash?(value)
|
|
286
|
+
(value.keys & %w[
|
|
287
|
+
repository repository_url source_repository ref source_ref git_ref tag source_commit workflow
|
|
288
|
+
workflow_name build_file issuer subject sha256 checksum digest attestation_url provenance_url url
|
|
289
|
+
transparency_log_entry
|
|
290
|
+
]).any?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Returns true when the version payload advertises Trusted Publishing.
|
|
294
|
+
def trusted_publishing_flag?(version)
|
|
295
|
+
value = version["trusted_publishing"]
|
|
296
|
+
value == true || value.to_s.casecmp("true").zero?
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# GETs +path+ from the configured host and returns the response body.
|
|
39
300
|
def get(path)
|
|
40
301
|
uri = URI("#{@host}#{path}")
|
|
41
302
|
response = @http.get_response(uri)
|
|
@@ -44,15 +305,18 @@ module Gem
|
|
|
44
305
|
raise Error, "GET #{uri} failed with #{response.code} #{response.message}"
|
|
45
306
|
end
|
|
46
307
|
|
|
308
|
+
# Compares a RubyGems platform string with the requested platform.
|
|
47
309
|
def platform_matches?(remote_platform, wanted_platform)
|
|
48
310
|
normalized_remote = remote_platform.to_s.empty? ? "ruby" : remote_platform.to_s
|
|
49
311
|
normalized_wanted = wanted_platform.to_s.empty? ? "ruby" : wanted_platform.to_s
|
|
50
312
|
normalized_remote == normalized_wanted
|
|
51
313
|
end
|
|
52
314
|
|
|
315
|
+
# Returns true when +value+ is nil or empty.
|
|
53
316
|
def blank?(value)
|
|
54
317
|
value.nil? || value.to_s.empty?
|
|
55
318
|
end
|
|
56
319
|
end
|
|
320
|
+
# rubocop:enable Metrics/ClassLength
|
|
57
321
|
end
|
|
58
322
|
end
|
|
@@ -2,49 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
module Gem
|
|
4
4
|
module Guardian
|
|
5
|
-
|
|
5
|
+
# Result object for a single verification attempt.
|
|
6
|
+
VerificationResult = Data.define(:dependency, :expected_sha256, :actual_sha256, :artifact_path, :status, :error,
|
|
7
|
+
:checksum_source) do
|
|
8
|
+
# Returns true when the verification succeeded.
|
|
6
9
|
def ok?
|
|
7
10
|
status == :ok
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
|
|
14
|
+
# Verifies gem artifacts against an expected checksum source.
|
|
11
15
|
class Verifier
|
|
12
|
-
def initialize(client: RubygemsClient.new, artifact_store: nil)
|
|
16
|
+
def initialize(client: RubygemsClient.new, artifact_store: nil, expected_checksums: {})
|
|
13
17
|
@client = client
|
|
14
18
|
@artifact_store = artifact_store || ArtifactStore.new(client: @client)
|
|
19
|
+
@expected_checksums = expected_checksums
|
|
15
20
|
end
|
|
16
21
|
|
|
22
|
+
# Verifies one dependency and returns a VerificationResult.
|
|
17
23
|
def verify(dependency)
|
|
18
|
-
expected =
|
|
19
|
-
|
|
20
|
-
actual = Checksum.sha256_file(artifact_path)
|
|
21
|
-
status = secure_compare(expected, actual) ? :ok : :mismatch
|
|
22
|
-
|
|
23
|
-
VerificationResult.new(
|
|
24
|
-
dependency:,
|
|
25
|
-
expected_sha256: expected,
|
|
26
|
-
actual_sha256: actual,
|
|
27
|
-
artifact_path:,
|
|
28
|
-
status:,
|
|
29
|
-
error: nil
|
|
30
|
-
)
|
|
24
|
+
expected, checksum_source = expected_sha256_for(dependency)
|
|
25
|
+
build_verification_result(dependency, expected, checksum_source)
|
|
31
26
|
rescue StandardError => e
|
|
27
|
+
build_error_result(dependency, e)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Verifies each dependency in +dependencies+.
|
|
31
|
+
def verify_all(dependencies)
|
|
32
|
+
dependencies.map { |dependency| verify(dependency) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_verification_result(dependency, expected, checksum_source)
|
|
38
|
+
VerificationResult.new(**verification_attributes(dependency, expected, checksum_source))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_error_result(dependency, error)
|
|
32
42
|
VerificationResult.new(
|
|
33
43
|
dependency:,
|
|
34
44
|
expected_sha256: nil,
|
|
35
45
|
actual_sha256: nil,
|
|
36
46
|
artifact_path: nil,
|
|
37
47
|
status: :error,
|
|
38
|
-
error
|
|
48
|
+
error:,
|
|
49
|
+
checksum_source: nil
|
|
39
50
|
)
|
|
40
51
|
end
|
|
41
52
|
|
|
42
|
-
def
|
|
43
|
-
|
|
53
|
+
def verification_attributes(dependency, expected, checksum_source)
|
|
54
|
+
artifact_path = @artifact_store.path_for(dependency)
|
|
55
|
+
actual = Checksum.sha256_file(artifact_path)
|
|
56
|
+
{ dependency:, expected_sha256: expected, actual_sha256: actual, artifact_path:,
|
|
57
|
+
status: secure_compare(expected, actual) ? :ok : :mismatch, error: nil,
|
|
58
|
+
checksum_source: }
|
|
44
59
|
end
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
# Constant-time comparison for checksum strings.
|
|
48
62
|
def secure_compare(left, right)
|
|
49
63
|
left = left.to_s
|
|
50
64
|
right = right.to_s
|
|
@@ -52,6 +66,15 @@ module Gem
|
|
|
52
66
|
|
|
53
67
|
left.bytes.zip(right.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b) }.zero?
|
|
54
68
|
end
|
|
69
|
+
|
|
70
|
+
# Uses lockfile checksums first and falls back to RubyGems metadata.
|
|
71
|
+
def expected_sha256_for(dependency)
|
|
72
|
+
if @expected_checksums.key?(dependency)
|
|
73
|
+
[@expected_checksums.fetch(dependency), :lockfile]
|
|
74
|
+
else
|
|
75
|
+
[@client.expected_sha256(dependency), :rubygems]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
55
78
|
end
|
|
56
79
|
end
|
|
57
80
|
end
|
data/lib/gem/guardian/version.rb
CHANGED
data/lib/gem/guardian.rb
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# Gem Guardian provides small, explicit verification and audit helpers for Ruby gems.
|
|
2
|
+
#
|
|
3
|
+
# The library is intentionally organized as a set of focused objects rather than a
|
|
4
|
+
# framework so the CLI, tests, and signatures stay easy to reason about.
|
|
1
5
|
# frozen_string_literal: true
|
|
2
6
|
|
|
3
7
|
require_relative "guardian/version"
|
|
@@ -8,4 +12,7 @@ require_relative "guardian/lockfile_parser"
|
|
|
8
12
|
require_relative "guardian/rubygems_client"
|
|
9
13
|
require_relative "guardian/artifact_store"
|
|
10
14
|
require_relative "guardian/verifier"
|
|
15
|
+
require_relative "guardian/provenance_verifier"
|
|
16
|
+
require_relative "guardian/report_builder"
|
|
17
|
+
require_relative "guardian/result_printer"
|
|
11
18
|
require_relative "guardian/cli"
|
data/sig/gem/guardian/cli.rbs
CHANGED
|
@@ -27,3 +27,34 @@ module Gem
|
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
|
+
module Gem
|
|
31
|
+
module Guardian
|
|
32
|
+
class CLI
|
|
33
|
+
@argv: untyped
|
|
34
|
+
|
|
35
|
+
@stdout: untyped
|
|
36
|
+
|
|
37
|
+
@stderr: untyped
|
|
38
|
+
|
|
39
|
+
def self.start: (untyped argv) -> untyped
|
|
40
|
+
|
|
41
|
+
def initialize: (untyped argv, ?stdout: untyped, ?stderr: untyped, ?verifier_class: untyped, ?lockfile_parser_class: untyped) -> void
|
|
42
|
+
|
|
43
|
+
def run: () -> untyped
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def verify: () -> untyped
|
|
48
|
+
|
|
49
|
+
def parse_gem_spec: (String spec) -> Dependency
|
|
50
|
+
|
|
51
|
+
def option_value: (String name) -> (String?)
|
|
52
|
+
|
|
53
|
+
def print_results: (untyped results, lockfile_mode: bool) -> void
|
|
54
|
+
|
|
55
|
+
def print_lockfile_coverage: (untyped lockfile_data) -> void
|
|
56
|
+
|
|
57
|
+
def usage: (?untyped io) -> void
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/sig/gem/guardian/error.rbs
CHANGED
|
@@ -9,3 +9,18 @@ module Gem
|
|
|
9
9
|
LockfileError: untyped
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
|
+
module Gem
|
|
13
|
+
module Guardian
|
|
14
|
+
class Error < StandardError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class ChecksumNotFound < Error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class ArtifactFetchError < Error
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class LockfileError < Error
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -17,3 +17,39 @@ module Gem
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
|
+
module Gem
|
|
21
|
+
module Guardian
|
|
22
|
+
class LockfileParser
|
|
23
|
+
GEM_LINE: ::Regexp
|
|
24
|
+
CHECKSUM_LINE: ::Regexp
|
|
25
|
+
|
|
26
|
+
class LockfileData
|
|
27
|
+
attr_reader dependencies: ::Array[Dependency]
|
|
28
|
+
|
|
29
|
+
attr_reader checksums: ::Hash[Dependency, ::Hash[String, String]]
|
|
30
|
+
|
|
31
|
+
attr_reader checksums_section_present: bool
|
|
32
|
+
|
|
33
|
+
def checksum_for: (Dependency dependency, ?String algorithm) -> String?
|
|
34
|
+
|
|
35
|
+
def sha256_checksums: () -> ::Hash[Dependency, String]
|
|
36
|
+
|
|
37
|
+
def missing_checksum_dependencies: () -> ::Array[Dependency]
|
|
38
|
+
|
|
39
|
+
def checksums_present?: () -> bool
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize: (?String path) -> void
|
|
43
|
+
|
|
44
|
+
def parse: () -> LockfileData
|
|
45
|
+
|
|
46
|
+
def dependencies: () -> ::Array[Dependency]
|
|
47
|
+
|
|
48
|
+
def checksums: () -> ::Hash[Dependency, ::Hash[String, String]]
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def split_version_and_platform: (String value) -> ::Array[String]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -23,3 +23,24 @@ module Gem
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
|
+
module Gem
|
|
27
|
+
module Guardian
|
|
28
|
+
class RubygemsClient
|
|
29
|
+
DEFAULT_HOST: String
|
|
30
|
+
|
|
31
|
+
def initialize: (?host: String, ?http: untyped) -> void
|
|
32
|
+
|
|
33
|
+
def expected_sha256: (Dependency dependency) -> String
|
|
34
|
+
|
|
35
|
+
def download_gem: (Dependency dependency, String destination) -> String
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def get: (String path) -> String
|
|
40
|
+
|
|
41
|
+
def platform_matches?: (untyped remote_platform, untyped wanted_platform) -> bool
|
|
42
|
+
|
|
43
|
+
def blank?: (untyped value) -> bool
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -19,3 +19,22 @@ module Gem
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
|
+
module Gem
|
|
23
|
+
module Guardian
|
|
24
|
+
VerificationResult: untyped
|
|
25
|
+
|
|
26
|
+
class Verifier
|
|
27
|
+
def initialize: (?client: RubygemsClient, ?artifact_store: ArtifactStore?, ?expected_checksums: ::Hash[Dependency, String]) -> void
|
|
28
|
+
|
|
29
|
+
def verify: (Dependency dependency) -> untyped
|
|
30
|
+
|
|
31
|
+
def verify_all: (::Array[Dependency] dependencies) -> ::Array[untyped]
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def secure_compare: (String left, String right) -> bool
|
|
36
|
+
|
|
37
|
+
def expected_sha256_for: (Dependency dependency) -> ::Array[untyped]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/sig/gem/guardian.rbs
CHANGED