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
data/Rakefile
CHANGED
|
@@ -2,42 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rake/testtask"
|
|
5
|
+
require "rubocop/rake_task"
|
|
6
|
+
require "yard"
|
|
7
|
+
require "yard/rake/yardoc_task"
|
|
5
8
|
|
|
6
9
|
Rake::TestTask.new(:test) do |t|
|
|
7
10
|
t.libs << "test"
|
|
8
|
-
t.
|
|
11
|
+
t.libs << "lib"
|
|
12
|
+
t.warning = false
|
|
13
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
9
14
|
end
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
RuboCop::RakeTask.new(:rubocop) do |task|
|
|
17
|
+
task.options = ["--parallel"]
|
|
18
|
+
end
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
desc "Remove generated RBS prototype files"
|
|
15
|
-
task :clobber do
|
|
16
|
-
sh "rm -rf tmp/sig"
|
|
17
|
-
end
|
|
20
|
+
YARD::Rake::YardocTask.new(:yard)
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
sh "bundle exec rbs prototype rb --out-dir=tmp/sig --base-dir=lib lib"
|
|
22
|
+
namespace :yard do
|
|
23
|
+
desc "Validate YARD documentation coverage"
|
|
24
|
+
task :validate do
|
|
25
|
+
require "open3"
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
end
|
|
27
|
+
stdout, stderr, status = Open3.capture3("bundle", "exec", "yard", "stats")
|
|
28
|
+
text = "#{stdout}\n#{stderr}"
|
|
29
|
+
puts text
|
|
30
|
+
abort("yard stats failed") unless status.success?
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
sh "bundle exec rbs validate sig"
|
|
34
|
-
end
|
|
32
|
+
match = text.match(/([0-9]+(?:\.[0-9]+)?)%\s+documented/)
|
|
33
|
+
abort("Unable to determine YARD coverage") unless match
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
end
|
|
35
|
+
coverage = match[1].to_f
|
|
36
|
+
minimum = 95.0
|
|
37
|
+
abort(format("YARD coverage %<coverage> is below %<minimum>", { coverage:, minimum: })) if coverage < minimum
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
puts format("YARD coverage %.2f%%", coverage)
|
|
40
|
+
end
|
|
43
41
|
end
|
|
42
|
+
|
|
43
|
+
task default: %i[test rubocop yard:validate]
|
data/bin/console
CHANGED
data/gem-guardian.gemspec
CHANGED
|
@@ -8,20 +8,22 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["Kenneth Demanawa"]
|
|
9
9
|
spec.email = ["kenneth.c.demanawa@gmail.com"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "
|
|
11
|
+
spec.summary = "Gem integrity and supply-chain verification for Ruby."
|
|
12
12
|
spec.description = <<~DESC
|
|
13
|
-
|
|
13
|
+
Verifies gem integrity using lockfile, registry, and artifact checksums,
|
|
14
|
+
audits Bundler checksum coverage, and reports supply-chain provenance
|
|
15
|
+
when available.
|
|
14
16
|
DESC
|
|
15
|
-
|
|
17
|
+
|
|
18
|
+
spec.homepage = "https://kanutocd.github.io/gem-guardian"
|
|
16
19
|
spec.license = "MIT"
|
|
17
20
|
spec.required_ruby_version = ">= 3.2"
|
|
18
21
|
|
|
19
|
-
spec.metadata =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
22
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
23
|
+
spec.metadata["documentation_uri"] = spec.homepage
|
|
24
|
+
spec.metadata["source_code_uri"] = "https://github.com/kanutocd/gem-guardian"
|
|
25
|
+
spec.metadata["changelog_uri"] = "#{spec.metadata["source_code_uri"]}/blob/main/CHANGELOG.md"
|
|
26
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
25
27
|
|
|
26
28
|
spec.files = Dir.chdir(__dir__) do
|
|
27
29
|
tracked_files = `git ls-files -z`.split("\x0")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "digest"
|
|
4
5
|
require "tmpdir"
|
|
5
6
|
|
|
6
7
|
module Gem
|
|
@@ -16,12 +17,22 @@ module Gem
|
|
|
16
17
|
|
|
17
18
|
# Returns the local path for +dependency+, downloading it if needed.
|
|
18
19
|
def path_for(dependency)
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
directory = cache_directory_for(dependency)
|
|
21
|
+
FileUtils.mkdir_p(directory)
|
|
22
|
+
path = File.join(directory, dependency.gem_filename)
|
|
21
23
|
return path if File.file?(path)
|
|
22
24
|
|
|
23
25
|
@client.download_gem(dependency, path)
|
|
24
26
|
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def cache_directory_for(dependency)
|
|
31
|
+
source = dependency.respond_to?(:source) && dependency.source
|
|
32
|
+
return @cache_dir if source.to_s.empty?
|
|
33
|
+
|
|
34
|
+
File.join(@cache_dir, Digest::SHA256.hexdigest(source)[0, 16])
|
|
35
|
+
end
|
|
25
36
|
end
|
|
26
37
|
end
|
|
27
38
|
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Gem
|
|
7
|
+
module Guardian
|
|
8
|
+
# Pluggable checksum providers for registry or publisher supplied SHA256 data.
|
|
9
|
+
#
|
|
10
|
+
# A provider answers one question:
|
|
11
|
+
#
|
|
12
|
+
# "Is there an independent SHA256 for this dependency, and where did it come from?"
|
|
13
|
+
#
|
|
14
|
+
# Providers are intentionally separate from artifact hashing. The downloaded
|
|
15
|
+
# `.gem` file is always hashed locally by {Verifier}; provider results are
|
|
16
|
+
# independent trust anchors that can be compared with that artifact digest.
|
|
17
|
+
module ChecksumProvider
|
|
18
|
+
# Independent checksum data returned by a provider.
|
|
19
|
+
#
|
|
20
|
+
# @!attribute [r] sha256
|
|
21
|
+
# @return [String] lowercase SHA256 hex digest
|
|
22
|
+
# @!attribute [r] source
|
|
23
|
+
# @return [Symbol] provider source category, such as +:registry+ or +:publisher+
|
|
24
|
+
# @!attribute [r] provider
|
|
25
|
+
# @return [String] provider implementation name
|
|
26
|
+
# @!attribute [r] verification_uri
|
|
27
|
+
# @return [String, nil] URI a user or tool can inspect to verify the checksum source
|
|
28
|
+
Result = Data.define(:sha256, :source, :provider, :verification_uri) do
|
|
29
|
+
# @return [Hash{Symbol => Object}] JSON-friendly representation of the provider result,
|
|
30
|
+
# including the checksum, provider name, source category, and verification URI
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
sha256: sha256,
|
|
34
|
+
source: source,
|
|
35
|
+
provider: provider,
|
|
36
|
+
verification_uri: verification_uri
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reads checksum metadata from the RubyGems.org-style versions API.
|
|
42
|
+
class RubyGemsApi
|
|
43
|
+
# @param dependency [Dependency] dependency whose checksum should be looked up
|
|
44
|
+
# @param client [RubygemsClient] client used to query the RubyGems.org-style API
|
|
45
|
+
# @return [Result, nil] provider result when checksum metadata is available, otherwise +nil+
|
|
46
|
+
def checksum_for(dependency, client:)
|
|
47
|
+
client.rubygems_api_checksum(dependency)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Reads checksum metadata from a RubyGems/Bundler compact index endpoint.
|
|
52
|
+
class CompactIndex
|
|
53
|
+
# @param dependency [Dependency] dependency whose checksum should be looked up
|
|
54
|
+
# @param client [RubygemsClient] client used to query the compact index endpoint
|
|
55
|
+
# @return [Result, nil] provider result when compact index checksum metadata is available, otherwise +nil+
|
|
56
|
+
def checksum_for(dependency, client:)
|
|
57
|
+
client.compact_index_registry_checksum(dependency)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Restricts another checksum provider to dependencies resolved from a
|
|
62
|
+
# matching gem source.
|
|
63
|
+
#
|
|
64
|
+
# This lets project configuration attach publisher checksum URLs to a
|
|
65
|
+
# private registry without probing that URL for every public gem. Source
|
|
66
|
+
# matching is prefix-based after trailing slashes are normalized, so a
|
|
67
|
+
# configured source such as `https://gems.contribsys.com/` matches locked
|
|
68
|
+
# dependency sources under that registry.
|
|
69
|
+
class SourceScoped
|
|
70
|
+
# @param source [String] source URI prefix this provider applies to
|
|
71
|
+
# @param provider [#checksum_for] checksum provider to delegate to
|
|
72
|
+
def initialize(source:, provider:)
|
|
73
|
+
@source = normalize_source(source)
|
|
74
|
+
@provider = provider
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param dependency [Dependency] dependency whose source should be checked
|
|
78
|
+
# @param client [RubygemsClient] client passed to the delegated provider
|
|
79
|
+
# @return [Result, nil] delegated checksum result when the source matches, otherwise +nil+
|
|
80
|
+
def checksum_for(dependency, client:)
|
|
81
|
+
return unless source_matches?(dependency.source)
|
|
82
|
+
|
|
83
|
+
@provider.checksum_for(dependency, client:)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def source_matches?(source)
|
|
89
|
+
return false if source.to_s.empty?
|
|
90
|
+
|
|
91
|
+
normalize_source(source).start_with?(@source)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def normalize_source(source)
|
|
95
|
+
value = source.to_s
|
|
96
|
+
uri = URI(value)
|
|
97
|
+
uri.user = nil
|
|
98
|
+
uri.password = nil
|
|
99
|
+
uri.to_s.delete_suffix("/")
|
|
100
|
+
rescue URI::InvalidURIError
|
|
101
|
+
value.delete_suffix("/")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Reads checksum metadata from a publisher-controlled checksum URL.
|
|
106
|
+
#
|
|
107
|
+
# This is intentionally generic. Commercial or self-hosted publishers can
|
|
108
|
+
# expose a stable checksum file without implementing RubyGems.org metadata
|
|
109
|
+
# APIs. For example, a publisher could host:
|
|
110
|
+
#
|
|
111
|
+
# https://example.com/checksums/mammoth-pro-1.0.0.gem.sha256
|
|
112
|
+
#
|
|
113
|
+
# The template supports these placeholders:
|
|
114
|
+
#
|
|
115
|
+
# - +{name}+
|
|
116
|
+
# - +{version}+
|
|
117
|
+
# - +{platform}+
|
|
118
|
+
# - +{filename}+
|
|
119
|
+
#
|
|
120
|
+
# The response body may contain either a bare SHA256 or a line such as:
|
|
121
|
+
#
|
|
122
|
+
# <sha256> <filename>
|
|
123
|
+
class Url
|
|
124
|
+
SHA256_PATTERN = /\b([a-fA-F0-9]{64})\b/
|
|
125
|
+
OPEN_TIMEOUT = 10
|
|
126
|
+
READ_TIMEOUT = 30
|
|
127
|
+
|
|
128
|
+
# @param template [String] URL template containing dependency placeholders such as +{filename}+
|
|
129
|
+
# @param http [#get_response] HTTP client, mainly for tests. When omitted, +Net::HTTP+ is used with explicit timeouts.
|
|
130
|
+
# @param provider_name [String] provider label used in reports and JSON output
|
|
131
|
+
def initialize(template:, http: Net::HTTP, provider_name: "url")
|
|
132
|
+
@template = template
|
|
133
|
+
@http = http
|
|
134
|
+
@provider_name = provider_name
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @param dependency [Dependency] dependency whose checksum should be looked up
|
|
138
|
+
# @param client [RubygemsClient] client used to sanitize the verification URI
|
|
139
|
+
# @return [Result, nil] provider result when the configured URL returns a parseable SHA256, otherwise +nil+
|
|
140
|
+
def checksum_for(dependency, client:)
|
|
141
|
+
uri = URI(expand_template(dependency))
|
|
142
|
+
response = http_get(uri)
|
|
143
|
+
return unless response.is_a?(Net::HTTPSuccess)
|
|
144
|
+
|
|
145
|
+
sha256 = response.body.to_s[SHA256_PATTERN, 1]
|
|
146
|
+
return unless sha256
|
|
147
|
+
|
|
148
|
+
Result.new(
|
|
149
|
+
sha256: sha256.downcase,
|
|
150
|
+
source: :publisher,
|
|
151
|
+
provider: @provider_name,
|
|
152
|
+
verification_uri: client.sanitize_uri(uri)
|
|
153
|
+
)
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def expand_template(dependency)
|
|
161
|
+
filename = dependency.gem_filename
|
|
162
|
+
@template
|
|
163
|
+
.gsub("{name}", dependency.name)
|
|
164
|
+
.gsub("{version}", dependency.version)
|
|
165
|
+
.gsub("{platform}", dependency.platform)
|
|
166
|
+
.gsub("{filename}", filename)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def http_get(uri)
|
|
170
|
+
return @http.get_response(uri) unless @http == Net::HTTP
|
|
171
|
+
|
|
172
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
173
|
+
open_timeout: OPEN_TIMEOUT,
|
|
174
|
+
read_timeout: READ_TIMEOUT) do |http|
|
|
175
|
+
http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
data/lib/gem/guardian/cli.rb
CHANGED
|
@@ -7,8 +7,64 @@ module Gem
|
|
|
7
7
|
# Command-line interface and output helpers.
|
|
8
8
|
module Guardian
|
|
9
9
|
# Command-line entry point for gem-guardian.
|
|
10
|
-
# rubocop:disable Metrics/ClassLength
|
|
10
|
+
# rubocop:disable Metrics/ClassLength
|
|
11
11
|
class CLI
|
|
12
|
+
# Lightweight lockfile data adapter used when a user verifies only a subset
|
|
13
|
+
# of gems from a Bundler lockfile.
|
|
14
|
+
#
|
|
15
|
+
# {LockfileParser} returns the full dependency graph and all parsed checksum
|
|
16
|
+
# entries. When the CLI receives both +--lockfile+ and explicit
|
|
17
|
+
# +GEM:VERSION[:PLATFORM]+ arguments, this view narrows that data to the
|
|
18
|
+
# requested dependencies while preserving the same reader methods consumed by
|
|
19
|
+
# {Verifier}, {ReportBuilder}, and {ResultPrinter}.
|
|
20
|
+
#
|
|
21
|
+
# @!attribute [r] dependencies
|
|
22
|
+
# @return [Array<Dependency>] dependencies selected for verification
|
|
23
|
+
# @!attribute [r] checksums
|
|
24
|
+
# @return [Hash{Dependency => Hash{String => String}}] checksum algorithms
|
|
25
|
+
# keyed by dependency
|
|
26
|
+
# @!attribute [r] checksums_section_present
|
|
27
|
+
# @return [Boolean] whether the source lockfile contained a +CHECKSUMS+
|
|
28
|
+
# section
|
|
29
|
+
LockfileDataView = Data.define(:dependencies, :checksums, :checksums_section_present) do
|
|
30
|
+
# Looks up a checksum for a dependency and algorithm.
|
|
31
|
+
#
|
|
32
|
+
# @param dependency [Dependency] dependency to look up
|
|
33
|
+
# @param algorithm [String] checksum algorithm name, currently usually
|
|
34
|
+
# +"sha256"+
|
|
35
|
+
# @return [String, nil] checksum digest when present, otherwise +nil+
|
|
36
|
+
def checksum_for(dependency, algorithm = "sha256")
|
|
37
|
+
checksums.fetch(dependency, {}).fetch(algorithm, nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns only SHA256 checksums from the filtered lockfile data.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash{Dependency => String}] selected dependencies mapped to
|
|
43
|
+
# their SHA256 digest
|
|
44
|
+
def sha256_checksums
|
|
45
|
+
checksums.each_with_object({}) do |(dependency, algorithms), memo|
|
|
46
|
+
digest = algorithms["sha256"]
|
|
47
|
+
memo[dependency] = digest if digest
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Lists selected dependencies that do not have SHA256 lockfile coverage.
|
|
52
|
+
#
|
|
53
|
+
# @return [Array<Dependency>] dependencies missing a SHA256 checksum in
|
|
54
|
+
# the lockfile view
|
|
55
|
+
def missing_checksum_dependencies
|
|
56
|
+
dependencies.reject { |dependency| sha256_checksums.key?(dependency) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Indicates whether the original lockfile contained a +CHECKSUMS+
|
|
60
|
+
# section.
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean] +true+ when the source lockfile had checksum metadata
|
|
63
|
+
def checksums_present?
|
|
64
|
+
checksums_section_present
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
12
68
|
# Starts the CLI with the provided argv.
|
|
13
69
|
def self.start(argv)
|
|
14
70
|
new(argv).run
|
|
@@ -45,7 +101,6 @@ module Gem
|
|
|
45
101
|
end
|
|
46
102
|
|
|
47
103
|
# Runs the verify subcommand.
|
|
48
|
-
# rubocop:disable Metrics/MethodLength
|
|
49
104
|
def verify
|
|
50
105
|
json_output = flag?("--json")
|
|
51
106
|
provenance_mode = flag?("--provenance")
|
|
@@ -60,26 +115,63 @@ module Gem
|
|
|
60
115
|
@stderr.puts e.message
|
|
61
116
|
1
|
|
62
117
|
end
|
|
63
|
-
# rubocop:enable Metrics/MethodLength
|
|
64
118
|
|
|
65
119
|
# Parses a GEM:VERSION[:PLATFORM] spec string.
|
|
66
|
-
def parse_gem_spec(spec)
|
|
120
|
+
def parse_gem_spec(spec, default_platform: "ruby")
|
|
67
121
|
name, version, platform = spec.split(":", 3)
|
|
68
122
|
raise Error, "Expected GEM:VERSION[:PLATFORM], got: #{spec}" if name.to_s.empty? || version.to_s.empty?
|
|
69
123
|
|
|
70
|
-
Dependency.new(name:, version:, platform: platform ||
|
|
124
|
+
Dependency.new(name:, version:, platform: platform || default_platform)
|
|
71
125
|
end
|
|
72
126
|
|
|
73
127
|
def resolve_dependencies
|
|
74
|
-
|
|
128
|
+
lockfile_path = option_value("--lockfile")
|
|
129
|
+
return resolve_explicit_dependencies unless lockfile_path
|
|
130
|
+
|
|
131
|
+
lockfile_data = @lockfile_parser_class.new(lockfile_path).parse
|
|
132
|
+
return [lockfile_data, lockfile_data.dependencies, lockfile_path] if @argv.empty?
|
|
133
|
+
|
|
134
|
+
filtered_data = filter_lockfile_data(lockfile_data, @argv.map { |spec| parse_gem_spec(spec, default_platform: nil) })
|
|
135
|
+
[filtered_data, filtered_data.dependencies, lockfile_path]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def resolve_explicit_dependencies
|
|
75
139
|
return [nil, @argv.map { |spec| parse_gem_spec(spec) }, nil] unless @argv.empty?
|
|
76
140
|
|
|
141
|
+
lockfile = "Gemfile.lock"
|
|
77
142
|
lockfile_data = @lockfile_parser_class.new(lockfile).parse
|
|
78
143
|
[lockfile_data, lockfile_data.dependencies, lockfile]
|
|
79
144
|
end
|
|
80
145
|
|
|
146
|
+
def filter_lockfile_data(lockfile_data, requested_dependencies)
|
|
147
|
+
dependencies = requested_dependencies.flat_map do |requested|
|
|
148
|
+
matches = matching_lockfile_dependencies(lockfile_data.dependencies, requested)
|
|
149
|
+
raise Error, "Gem not found in lockfile: #{requested.name}:#{requested.version}" if matches.empty?
|
|
150
|
+
|
|
151
|
+
matches
|
|
152
|
+
end.uniq
|
|
153
|
+
checksums = lockfile_data.checksums.select { |dependency, _algorithms| dependencies.include?(dependency) }
|
|
154
|
+
LockfileDataView.new(dependencies, checksums, lockfile_data.checksums_section_present)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def matching_lockfile_dependencies(lockfile_dependencies, requested)
|
|
158
|
+
lockfile_dependencies.select do |dependency|
|
|
159
|
+
dependency.name == requested.name &&
|
|
160
|
+
dependency.version == requested.version &&
|
|
161
|
+
(requested.platform.nil? || dependency.platform == requested.platform)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
81
165
|
def verifier_for(lockfile_data)
|
|
82
166
|
expected_checksums = lockfile_data&.sha256_checksums || {}
|
|
167
|
+
config = Configuration.load
|
|
168
|
+
return @verifier_class.new(expected_checksums:) unless config.checksum_providers?
|
|
169
|
+
|
|
170
|
+
client = RubygemsClient.new(
|
|
171
|
+
checksum_providers: RubygemsClient.default_checksum_providers + config.checksum_providers
|
|
172
|
+
)
|
|
173
|
+
@verifier_class.new(expected_checksums:, client:)
|
|
174
|
+
rescue ArgumentError
|
|
83
175
|
@verifier_class.new(expected_checksums:)
|
|
84
176
|
end
|
|
85
177
|
|
|
@@ -175,6 +267,6 @@ module Gem
|
|
|
175
267
|
0
|
|
176
268
|
end
|
|
177
269
|
end
|
|
178
|
-
# rubocop:enable Metrics/ClassLength
|
|
270
|
+
# rubocop:enable Metrics/ClassLength
|
|
179
271
|
end
|
|
180
272
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "gem/guardian/checksum_provider"
|
|
5
|
+
require "gem/guardian/error"
|
|
6
|
+
|
|
7
|
+
module Gem
|
|
8
|
+
module Guardian
|
|
9
|
+
# Project-level configuration loaded from `.gem-guardian.yml`.
|
|
10
|
+
#
|
|
11
|
+
# The configuration file is intentionally small and policy-oriented. Its
|
|
12
|
+
# first supported use case is declaring publisher checksum providers for
|
|
13
|
+
# private gem registries that do not expose RubyGems.org checksum metadata.
|
|
14
|
+
#
|
|
15
|
+
# @example Publisher checksum provider
|
|
16
|
+
# checksum_providers:
|
|
17
|
+
# - name: contribsys-sidekiq
|
|
18
|
+
# source: https://gems.contribsys.com/
|
|
19
|
+
# template: https://gems.contribsys.com/checksums/{filename}.sha256
|
|
20
|
+
class Configuration
|
|
21
|
+
DEFAULT_PATH = ".gem-guardian.yml"
|
|
22
|
+
|
|
23
|
+
attr_reader :path, :checksum_providers
|
|
24
|
+
|
|
25
|
+
# Loads configuration from `.gem-guardian.yml` in the current directory,
|
|
26
|
+
# or from `GEM_GUARDIAN_CONFIG` when that environment variable is set.
|
|
27
|
+
#
|
|
28
|
+
# @param path [String, nil] explicit configuration path
|
|
29
|
+
# @param cwd [String] working directory used for relative paths
|
|
30
|
+
# @return [Configuration] parsed configuration. Missing files produce an empty configuration.
|
|
31
|
+
# @raise [Error] when the YAML is invalid or has an unsupported shape
|
|
32
|
+
def self.load(path: ENV.fetch("GEM_GUARDIAN_CONFIG", DEFAULT_PATH), cwd: Dir.pwd)
|
|
33
|
+
full_path = absolute_path(path, cwd)
|
|
34
|
+
return new(path: full_path) unless File.file?(full_path)
|
|
35
|
+
|
|
36
|
+
data = YAML.safe_load_file(full_path, permitted_classes: [], aliases: false) || {}
|
|
37
|
+
raise Error, "#{full_path} must contain a YAML mapping" unless data.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
new(path: full_path, checksum_providers: build_checksum_providers(data.fetch("checksum_providers", [])))
|
|
40
|
+
rescue Psych::Exception => e
|
|
41
|
+
raise Error, "Invalid gem-guardian config #{full_path}: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param path [String, nil] source path of the loaded configuration
|
|
45
|
+
# @param checksum_providers [Array<#checksum_for>] configured checksum providers
|
|
46
|
+
def initialize(path: nil, checksum_providers: [])
|
|
47
|
+
@path = path
|
|
48
|
+
@checksum_providers = checksum_providers
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] whether the config declares any checksum providers
|
|
52
|
+
def checksum_providers?
|
|
53
|
+
!checksum_providers.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def absolute_path(path, cwd)
|
|
60
|
+
path = DEFAULT_PATH if path.to_s.empty?
|
|
61
|
+
File.absolute_path(path, cwd)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_checksum_providers(entries)
|
|
65
|
+
raise Error, "checksum_providers must be an array" unless entries.is_a?(Array)
|
|
66
|
+
|
|
67
|
+
entries.map { |entry| build_checksum_provider(entry) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_checksum_provider(entry)
|
|
71
|
+
raise Error, "checksum provider entries must be mappings" unless entry.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
template = entry["template"]
|
|
74
|
+
raise Error, "checksum provider template is required" if template.to_s.empty?
|
|
75
|
+
|
|
76
|
+
provider = ChecksumProvider::Url.new(
|
|
77
|
+
template: template,
|
|
78
|
+
provider_name: entry.fetch("name", "url")
|
|
79
|
+
)
|
|
80
|
+
source = entry["source"]
|
|
81
|
+
return provider if source.to_s.empty?
|
|
82
|
+
|
|
83
|
+
ChecksumProvider::SourceScoped.new(source:, provider:)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
module Gem
|
|
4
4
|
module Guardian
|
|
5
5
|
# A gem dependency identified by name, version, and platform.
|
|
6
|
-
Dependency = Data.define(:name, :version, :platform) do
|
|
6
|
+
Dependency = Data.define(:name, :version, :platform, :source) do
|
|
7
|
+
def initialize(name:, version:, platform:, source: nil)
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
|
|
7
11
|
# Returns the canonical .gem filename for this dependency.
|
|
8
12
|
def gem_filename
|
|
9
13
|
platform_suffix = platform && platform != "ruby" ? "-#{platform}" : ""
|
|
@@ -14,7 +14,7 @@ module Gem
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Verifies GitHub release checksum, signature, and attestation metadata.
|
|
17
|
-
# rubocop:disable Metrics/ClassLength, Metrics/
|
|
17
|
+
# rubocop:disable Metrics/ClassLength, Metrics/ParameterLists, Metrics/CyclomaticComplexity
|
|
18
18
|
class GitHubReleaseVerifier
|
|
19
19
|
def initialize(client: GitHubClient.new)
|
|
20
20
|
@client = client
|
|
@@ -195,6 +195,6 @@ module Gem
|
|
|
195
195
|
:unsupported
|
|
196
196
|
end
|
|
197
197
|
end
|
|
198
|
-
# rubocop:enable Metrics/ClassLength, Metrics/
|
|
198
|
+
# rubocop:enable Metrics/ClassLength, Metrics/ParameterLists, Metrics/CyclomaticComplexity
|
|
199
199
|
end
|
|
200
200
|
end
|