gem-guardian 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed534bc229391d74fb2b3ee945127ad3690236f3c27d28fe1aaf28a909c19a8c
4
- data.tar.gz: 4c30325c72ba731d6a9e5dc5e2f2ea3a08bf5147c8a29e02132f5bd9ec9fecac
3
+ metadata.gz: a18ee9f2f2111d0eca38def30302a7d66b9b6b9b96df06909f883be2978c1bcd
4
+ data.tar.gz: e6a421612c4c50423ef7fe29f74f2bc2503cb624b813befb114bc679940fcd5f
5
5
  SHA512:
6
- metadata.gz: e13ac5e36ffeb46ff9d2fa027d4a6af02b648389a9e7981a50ea9a28264aa32d8473ac2c07a748b002f24bb59339c10da138b405fa5ac9239346caec2e816fe9
7
- data.tar.gz: ffcc44114da845142bff58537d06970479d2694d0a91c52ee21f00a3a0e695cc8d5f7ec8aee0fb8b296495dbd2078fd76b15944f17371330e03b532feb1d5ed4
6
+ metadata.gz: 3577f45013b8bb9b94905e7f2c081501ef3b863f2913faee08a0604184e5181ed278bd75a57cee9f9d571d00395b26c3f666ab1c126abe251c88cc54f24cb8c3
7
+ data.tar.gz: faa68291c29be60c7b42d7cf1452dc8e5d29222ee8174e22ed7bbb6f913c5405a264555fe16c020e934d23863232aec74ee2befb537a7bb06192ab52d154bffb
@@ -25,7 +25,20 @@ jobs:
25
25
  with:
26
26
  ruby-version: "3.4"
27
27
  bundler-cache: true
28
+ - name: Build gem artifact
29
+ run: gem build gem-guardian.gemspec
30
+ - name: Generate checksum
31
+ run: |
32
+ gem_file=$(ls gem-guardian-*.gem)
33
+ shasum -a 256 "$gem_file" > "$gem_file.sha256"
28
34
  - name: Run tests
29
35
  run: bundle exec rake
30
36
  - name: Release gem to RubyGems.org
31
37
  uses: rubygems/release-gem@v1
38
+ - name: Create GitHub release
39
+ uses: softprops/action-gh-release@v2
40
+ with:
41
+ generate_release_notes: true
42
+ files: |
43
+ gem-guardian-*.gem
44
+ gem-guardian-*.gem.sha256
data/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-06-12
6
+
7
+ - Discover GitHub Release checksum and signature assets.
8
+ - Verify signed Git tags and GitHub release attestations when provenance exposes a GitHub tag.
9
+ - Fall back to version-derived release tags when RubyGems provenance exposes only a commit SHA.
10
+ - Add GitHub release metadata to JSON and human-readable provenance output when available.
11
+ - Package the new GitHub verifier classes into the released gem.
12
+
13
+ ## [0.2.0] - 2026-06-12
14
+
15
+ - Add `--json` output for CI-friendly verification reports.
16
+ - Add opt-in Trusted Publishing provenance verification for RubyGems releases.
17
+ - Verify provenance through RubyGems attestations for supported releases.
18
+
5
19
  ## [0.1.1] - 2026-06-12
6
20
 
7
21
  - Parse Bundler `CHECKSUMS` entries from `Gemfile.lock`.
data/README.md CHANGED
@@ -8,13 +8,13 @@
8
8
 
9
9
  Consumer-side integrity verification for Ruby gems.
10
10
 
11
- `gem-guardian` audits Bundler checksum coverage and, where needed, verifies `.gem` artifacts against the SHA256 checksum reported by RubyGems.org. It is intentionally small: no Bundler monkeypatching, no install hooks, and no custom publishing flow required.
11
+ `gem-guardian` audits Bundler checksum coverage, verifies `.gem` artifacts against RubyGems SHA256 data when needed, and can verify Trusted Publishing provenance for supported releases, including GitHub release checksum/signature discovery and signed-tag attestation checks when the release data exposes them. It stays intentionally small: no Bundler monkeypatching, no install hooks, and no custom publishing flow required.
12
12
 
13
13
  ## Why
14
14
 
15
- RubyGems.org displays SHA256 checksums for published gem artifacts, and Bundler 2.6 can store and enforce checksums in `Gemfile.lock`. That means the most useful v0.1.0 is not a parallel verifier, but an audit tool that tells you whether your bundle is actually protected.
15
+ RubyGems.org displays SHA256 checksums for published gem artifacts, Bundler 2.6 can store and enforce checksums in `Gemfile.lock`, and RubyGems now exposes attestation data for Trusted Publishing releases. That means the most useful current release is an audit and verification tool that tells you whether your bundle and release metadata are actually protected.
16
16
 
17
- This v0.1.0 scope is:
17
+ This 0.2.0 scope is:
18
18
 
19
19
  ```text
20
20
  Gemfile.lock
@@ -23,10 +23,12 @@ CHECKSUMS coverage audit
23
23
 
24
24
  RubyGems.org checksum comparison when needed
25
25
 
26
+ Trusted Publishing provenance verification when available
27
+
26
28
  Actionable report for CI or local review
27
29
  ```
28
30
 
29
- This reports whether your lockfile is using Bundler checksum protection and whether any locked gems are missing expected checksum data. It does **not** yet prove source provenance such as signed tag CI build published gem.
31
+ This reports whether your lockfile is using Bundler checksum protection, whether any locked gems are missing expected checksum data, whether RubyGems exposes Trusted Publishing provenance for the gem being verified, and whether GitHub release assets and tag attestations are available for the release being inspected.
30
32
 
31
33
  ## Installation
32
34
 
@@ -34,7 +36,7 @@ From a local checkout:
34
36
 
35
37
  ```bash
36
38
  gem build gem-guardian.gemspec
37
- gem install ./gem-guardian-0.1.0.gem
39
+ gem install ./gem-guardian-0.2.0.gem
38
40
  ```
39
41
 
40
42
  ## Usage
@@ -43,7 +45,7 @@ Build and install the current release from a local checkout:
43
45
 
44
46
  ```bash
45
47
  gem build gem-guardian.gemspec
46
- gem install ./gem-guardian-0.1.1.gem
48
+ gem install ./gem-guardian-0.2.0.gem
47
49
  gem-guardian version
48
50
  ```
49
51
 
@@ -85,8 +87,17 @@ Use a non-default lockfile:
85
87
  gem-guardian verify --lockfile path/to/Gemfile.lock
86
88
  ```
87
89
 
90
+ Emit JSON for CI:
91
+
92
+ ```bash
93
+ gem-guardian verify --json
94
+ gem-guardian verify --json --provenance
95
+ ```
96
+
88
97
  When you verify a lockfile that already contains Bundler `CHECKSUMS`, `gem-guardian` reports coverage and compares the locked checksum to the downloaded artifact. When a checksum is missing, it falls back to RubyGems.org metadata and marks that verification accordingly.
89
98
 
99
+ Use `--provenance` to inspect Trusted Publishing metadata when RubyGems exposes it. Unsupported gems are reported, but they do not fail the run unless the provenance data is present and mismatched.
100
+
90
101
  ## Exit codes
91
102
 
92
103
  - `0` — all verified artifacts matched
@@ -100,14 +111,11 @@ When you verify a lockfile that already contains Bundler `CHECKSUMS`, `gem-guard
100
111
  - Downloads artifacts from RubyGems.org `/downloads/<gem-file>.gem` only when verification is needed.
101
112
  - Caches downloaded artifacts under the system temp directory.
102
113
  - Does not integrate into Bundler install hooks.
103
- - Does not yet verify Sigstore, SLSA, GitHub Actions provenance, or signed git tags.
114
+ - GitHub Release checksum/signature discovery and signed tag/release attestation checks are supported when the release metadata is available.
104
115
 
105
116
  ## Roadmap
106
117
 
107
- - Machine-readable JSON output for CI.
108
- - Provenance verification for gems published through Trusted Publishing.
109
- - GitHub Release checksum/signature discovery.
110
- - Signed tag and release attestation checks.
118
+ - Expand release provenance checks to additional publishing workflows beyond GitHub release provenance.
111
119
 
112
120
 
113
121
  ## License
data/gem-guardian.gemspec CHANGED
@@ -24,7 +24,9 @@ Gem::Specification.new do |spec|
24
24
  }
25
25
 
26
26
  spec.files = Dir.chdir(__dir__) do
27
- `git ls-files -z`.split("\x0").reject do |f|
27
+ tracked_files = `git ls-files -z`.split("\x0")
28
+ source_files = Dir["lib/**/*", "exe/*", "README.md", "LICENSE.txt", "CHANGELOG.md"]
29
+ (tracked_files + source_files).uniq.reject do |f|
28
30
  f.match(%r{\A(?:test|spec|features)/})
29
31
  end
30
32
  rescue StandardError
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  # Namespace for gem-guardian CLI code.
4
6
  module Gem
5
7
  # Command-line interface and output helpers.
6
8
  module Guardian
7
9
  # Command-line entry point for gem-guardian.
10
+ # rubocop:disable Metrics/ClassLength, Metrics/ParameterLists
8
11
  class CLI
9
12
  # Starts the CLI with the provided argv.
10
13
  def self.start(argv)
@@ -12,12 +15,15 @@ module Gem
12
15
  end
13
16
 
14
17
  def initialize(argv, stdout: $stdout, stderr: $stderr, verifier_class: Verifier,
15
- lockfile_parser_class: LockfileParser)
18
+ lockfile_parser_class: LockfileParser, provenance_verifier_class: ProvenanceVerifier,
19
+ report_builder_class: ReportBuilder)
16
20
  @argv = argv.dup
17
21
  @stdout = stdout
18
22
  @stderr = stderr
19
23
  @verifier_class = verifier_class
20
24
  @lockfile_parser_class = lockfile_parser_class
25
+ @provenance_verifier_class = provenance_verifier_class
26
+ @report_builder_class = report_builder_class
21
27
  @result_printer = ResultPrinter.new(stdout:)
22
28
  end
23
29
 
@@ -39,17 +45,22 @@ module Gem
39
45
  end
40
46
 
41
47
  # Runs the verify subcommand.
48
+ # rubocop:disable Metrics/MethodLength
42
49
  def verify
43
- lockfile_data, dependencies = resolve_dependencies
50
+ json_output = flag?("--json")
51
+ provenance_mode = flag?("--provenance")
52
+ lockfile_data, dependencies, lockfile_path = resolve_dependencies
44
53
  return no_dependencies if dependencies.empty?
45
54
 
46
55
  results = verifier_for(lockfile_data).verify_all(dependencies)
47
- print_verification_report(results, lockfile_data)
48
- verification_exit_status(results, lockfile_data)
56
+ provenance_results = provenance_results_for(results, provenance_mode)
57
+ output_verification(results, lockfile_data, provenance_results, json_output, lockfile_path)
58
+ verification_exit_status(results, lockfile_data, provenance_results)
49
59
  rescue Error => e
50
60
  @stderr.puts e.message
51
61
  1
52
62
  end
63
+ # rubocop:enable Metrics/MethodLength
53
64
 
54
65
  # Parses a GEM:VERSION[:PLATFORM] spec string.
55
66
  def parse_gem_spec(spec)
@@ -61,10 +72,10 @@ module Gem
61
72
 
62
73
  def resolve_dependencies
63
74
  lockfile = option_value("--lockfile") || "Gemfile.lock"
64
- return [nil, @argv.map { |spec| parse_gem_spec(spec) }] unless @argv.empty?
75
+ return [nil, @argv.map { |spec| parse_gem_spec(spec) }, nil] unless @argv.empty?
65
76
 
66
77
  lockfile_data = @lockfile_parser_class.new(lockfile).parse
67
- [lockfile_data, lockfile_data.dependencies]
78
+ [lockfile_data, lockfile_data.dependencies, lockfile]
68
79
  end
69
80
 
70
81
  def verifier_for(lockfile_data)
@@ -72,6 +83,35 @@ module Gem
72
83
  @verifier_class.new(expected_checksums:)
73
84
  end
74
85
 
86
+ def provenance_verifier_for
87
+ @provenance_verifier_class.new
88
+ end
89
+
90
+ def provenance_results_for(results, provenance_mode)
91
+ return [] unless provenance_mode
92
+
93
+ provenance_verifier_for.verify_all(results)
94
+ end
95
+
96
+ def output_verification(results, lockfile_data, provenance_results, json_output, lockfile_path)
97
+ if json_output
98
+ write_json_report(results, lockfile_data, provenance_results, lockfile_path)
99
+ else
100
+ write_human_report(results, lockfile_data, provenance_results)
101
+ end
102
+ end
103
+
104
+ def write_json_report(results, lockfile_data, provenance_results, lockfile_path)
105
+ @stdout.puts JSON.pretty_generate(
106
+ report_builder.build(results, lockfile_data:, provenance_results:, lockfile_path:)
107
+ )
108
+ end
109
+
110
+ def write_human_report(results, lockfile_data, provenance_results)
111
+ print_verification_report(results, lockfile_data)
112
+ @result_printer.print_provenance_results(provenance_results) unless provenance_results.empty?
113
+ end
114
+
75
115
  def print_verification_report(results, lockfile_data)
76
116
  lockfile_mode = !lockfile_data.nil?
77
117
  @result_printer.print_results(results, lockfile_mode:)
@@ -80,10 +120,11 @@ module Gem
80
120
  @result_printer.print_lockfile_coverage(lockfile_data)
81
121
  end
82
122
 
83
- def verification_exit_status(results, lockfile_data)
123
+ def verification_exit_status(results, lockfile_data, provenance_results = [])
84
124
  all_ok = results.all?(&:ok?)
85
125
  all_covered = lockfile_data.nil? || lockfile_data.missing_checksum_dependencies.empty?
86
- all_ok && all_covered ? 0 : 1
126
+ provenance_ok = provenance_results.all? { |result| !%i[mismatch error].include?(result.status) }
127
+ all_ok && all_covered && provenance_ok ? 0 : 1
87
128
  end
88
129
 
89
130
  def no_dependencies
@@ -114,11 +155,26 @@ module Gem
114
155
  value
115
156
  end
116
157
 
158
+ # Returns true when +name+ is present and removes it from argv.
159
+ def flag?(name)
160
+ index = @argv.index(name)
161
+ return false unless index
162
+
163
+ @argv.delete_at(index)
164
+ true
165
+ end
166
+
167
+ # Returns the report builder for structured output.
168
+ def report_builder
169
+ @report_builder_class.new(version: VERSION)
170
+ end
171
+
117
172
  # Prints usage text.
118
173
  def usage(_io = @stdout)
119
174
  @result_printer.usage
120
175
  0
121
176
  end
122
177
  end
178
+ # rubocop:enable Metrics/ClassLength, Metrics/ParameterLists
123
179
  end
124
180
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Gem
8
+ module Guardian
9
+ # Reads GitHub release and tag metadata for provenance checks.
10
+ class GitHubClient
11
+ # Default GitHub API endpoint used by the client.
12
+ DEFAULT_HOST = "https://api.github.com"
13
+
14
+ def initialize(host: DEFAULT_HOST, http: Net::HTTP)
15
+ @host = host.delete_suffix("/")
16
+ @http = http
17
+ end
18
+
19
+ # Returns the release payload for +repository+ and +tag+.
20
+ def release(repository, tag)
21
+ fetch_json("/repos/#{repository}/releases/tags/#{tag}")
22
+ rescue StandardError
23
+ nil
24
+ end
25
+
26
+ # Returns the tag verification payload for +repository+ and +tag+.
27
+ # rubocop:disable Metrics/CyclomaticComplexity
28
+ def tag_verification(repository, tag)
29
+ ref = fetch_json("/repos/#{repository}/git/ref/tags/#{tag}")
30
+ return unless ref.is_a?(Hash)
31
+
32
+ object = ref["object"]
33
+ return unless object.is_a?(Hash)
34
+ return object["verification"] if object["type"] == "tag" && object["verification"].is_a?(Hash)
35
+
36
+ commit = fetch_json("/repos/#{repository}/commits/#{object["sha"]}")
37
+ commit.is_a?(Hash) ? commit["commit"]&.fetch("verification", nil) : nil
38
+ rescue StandardError
39
+ nil
40
+ end
41
+ # rubocop:enable Metrics/CyclomaticComplexity
42
+
43
+ private
44
+
45
+ def fetch_json(path)
46
+ JSON.parse(get(path))
47
+ end
48
+
49
+ def get(path)
50
+ uri = URI("#{@host}#{path}")
51
+ response = @http.get_response(uri)
52
+ return response.body if response.is_a?(Net::HTTPSuccess)
53
+
54
+ raise Error, "GET #{uri} failed with #{response.code} #{response.message}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gem
4
+ module Guardian
5
+ # Result object for GitHub release provenance checks.
6
+ GitHubReleaseResult = Data.define(
7
+ :dependency, :status, :repository, :tag, :checksum_assets, :signature_assets, :signed_tag,
8
+ :signed_tag_reason, :release_attestation, :release_url, :error
9
+ ) do
10
+ # Returns true when the GitHub release checks succeeded.
11
+ def verified?
12
+ status == :verified
13
+ end
14
+ end
15
+
16
+ # Verifies GitHub release checksum, signature, and attestation metadata.
17
+ # rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/ParameterLists, Metrics/CyclomaticComplexity
18
+ class GitHubReleaseVerifier
19
+ def initialize(client: GitHubClient.new)
20
+ @client = client
21
+ end
22
+
23
+ # Verifies GitHub release metadata for +provenance+.
24
+ # rubocop:disable Metrics/AbcSize
25
+ def verify(provenance)
26
+ repository = github_repository(provenance.repository)
27
+ tag_candidates = github_tag_candidates(provenance)
28
+ return unsupported_result(provenance, repository, tag_candidates.first) unless repository && tag_candidates.any?
29
+
30
+ release, tag = release_for(repository, tag_candidates)
31
+ return unsupported_result(provenance, repository, tag) unless release
32
+
33
+ checksum_assets = discovered_assets(release, checksum_asset_name?)
34
+ signature_assets = discovered_assets(release, signature_asset_name?)
35
+ tag_verification = @client.tag_verification(repository, tag)
36
+ build_release_result(provenance, repository, tag, checksum_assets, signature_assets, tag_verification, release)
37
+ rescue StandardError => e
38
+ error_result(provenance, repository, tag, e)
39
+ end
40
+ # rubocop:enable Metrics/AbcSize
41
+
42
+ private
43
+
44
+ def release_for(repository, tag_candidates)
45
+ tag_candidates.each do |candidate|
46
+ release = @client.release(repository, candidate)
47
+ return [release, candidate] if release
48
+ end
49
+
50
+ [nil, tag_candidates.first]
51
+ end
52
+
53
+ def build_release_result(provenance, repository, tag, checksum_assets, signature_assets, tag_verification,
54
+ release)
55
+ signed_tag = signed_tag?(tag_verification)
56
+ attestation = release_attestation(release)
57
+ status = release_status(signed_tag, attestation, tag_verification)
58
+ GitHubReleaseResult.new(
59
+ dependency: provenance.dependency,
60
+ status: status,
61
+ repository:,
62
+ tag:,
63
+ checksum_assets:,
64
+ signature_assets:,
65
+ signed_tag:,
66
+ signed_tag_reason: verification_reason(tag_verification),
67
+ release_attestation: attestation,
68
+ release_url: release["html_url"],
69
+ error: nil
70
+ )
71
+ end
72
+
73
+ def unsupported_result(provenance, repository, tag)
74
+ GitHubReleaseResult.new(
75
+ dependency: provenance.dependency,
76
+ status: :unsupported,
77
+ repository: repository,
78
+ tag: tag,
79
+ checksum_assets: [],
80
+ signature_assets: [],
81
+ signed_tag: nil,
82
+ signed_tag_reason: nil,
83
+ release_attestation: nil,
84
+ release_url: nil,
85
+ error: nil
86
+ )
87
+ end
88
+
89
+ def error_result(provenance, repository, tag, error)
90
+ GitHubReleaseResult.new(
91
+ dependency: provenance.dependency,
92
+ status: :error,
93
+ repository: repository,
94
+ tag: tag,
95
+ checksum_assets: [],
96
+ signature_assets: [],
97
+ signed_tag: nil,
98
+ signed_tag_reason: nil,
99
+ release_attestation: nil,
100
+ release_url: nil,
101
+ error: error
102
+ )
103
+ end
104
+
105
+ def github_repository(repository)
106
+ return if repository.nil?
107
+
108
+ value = repository.to_s
109
+ return unless value.match?(%r{\Ahttps://github\.com/[^/]+/[^/]+\z}) || value.match?(%r{\A[^/]+/[^/]+\z})
110
+
111
+ value.delete_prefix("https://github.com/")
112
+ end
113
+
114
+ def github_tag(provenance)
115
+ github_tag_candidates(provenance).first
116
+ end
117
+
118
+ def github_tag_candidates(provenance)
119
+ [
120
+ tag_from_ref(provenance.ref),
121
+ tag_from_subject(provenance.subject),
122
+ tag_from_version(provenance.dependency.version),
123
+ provenance.dependency.version.to_s
124
+ ].compact.uniq
125
+ end
126
+
127
+ def tag_from_ref(ref)
128
+ ref = ref.to_s
129
+ return unless ref.start_with?("refs/tags/")
130
+
131
+ ref.delete_prefix("refs/tags/")
132
+ end
133
+
134
+ def tag_from_subject(subject)
135
+ match = subject.to_s.match(%r{:ref:refs/tags/([^:]+)\z})
136
+ match && match[1]
137
+ end
138
+
139
+ def tag_from_version(version)
140
+ version = version.to_s
141
+ return if version.empty?
142
+
143
+ "v#{version}"
144
+ end
145
+
146
+ def discovered_assets(release, matcher)
147
+ Array(release["assets"]).filter_map do |asset|
148
+ name = asset.is_a?(Hash) ? asset["name"].to_s : asset.to_s
149
+ name if matcher.call(name)
150
+ end
151
+ end
152
+
153
+ def checksum_asset_name?
154
+ @checksum_asset_name ||= lambda do |name|
155
+ name.match?(/\.(?:sha256|sha256sum|checksum|checksums|sha)\z/i) ||
156
+ name.match?(/\ASHA256SUMS(?:\.txt)?\z/i) ||
157
+ name.match?(/\Achecksums(?:\.txt)?\z/i)
158
+ end
159
+ end
160
+
161
+ def signature_asset_name?
162
+ @signature_asset_name ||= lambda do |name|
163
+ name.match?(/\.(?:sig|asc)\z/i) || name.match?(/\.(?:bundle|intoto\.jsonl)\z/i)
164
+ end
165
+ end
166
+
167
+ def signed_tag?(verification)
168
+ return nil unless verification.is_a?(Hash)
169
+
170
+ verification["verified"] == true || verification["verified"].to_s.casecmp("true").zero?
171
+ end
172
+
173
+ def verification_reason(verification)
174
+ return unless verification.is_a?(Hash)
175
+
176
+ verification["reason"]
177
+ end
178
+
179
+ def release_attestation(release)
180
+ value = release["attestations"] || release["attestation"] || release["provenance"] ||
181
+ release["artifact_attestations"]
182
+ return nil if value.nil?
183
+
184
+ value.is_a?(TrueClass) || value.is_a?(FalseClass) ? value : true
185
+ end
186
+
187
+ def release_status(signed_tag, attestation, verification)
188
+ return :error if verification.is_a?(Hash) &&
189
+ verification["verified"] == true &&
190
+ verification["reason"] == "invalid"
191
+ return :verified if signed_tag == true && attestation == true
192
+ return :mismatch if signed_tag == false
193
+ return :mismatch if attestation == false
194
+
195
+ :unsupported
196
+ end
197
+ end
198
+ # rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/ParameterLists, Metrics/CyclomaticComplexity
199
+ end
200
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gem
4
+ module Guardian
5
+ # Result object for a provenance verification attempt.
6
+ ProvenanceResult = Data.define(
7
+ :dependency, :status, :trusted_publishing, :repository, :ref, :workflow, :issuer, :subject,
8
+ :expected_sha256, :actual_sha256, :error, :attestation_url, :github_release
9
+ ) do
10
+ # Returns true when provenance verification succeeded.
11
+ def verified?
12
+ status == :verified
13
+ end
14
+ end
15
+
16
+ # Verifies RubyGems Trusted Publishing provenance metadata.
17
+ class ProvenanceVerifier
18
+ def initialize(client: RubygemsClient.new, github_release_verifier: GitHubReleaseVerifier.new)
19
+ @client = client
20
+ @github_release_verifier = github_release_verifier
21
+ end
22
+
23
+ # Verifies Trusted Publishing provenance for +dependency+.
24
+ def verify(dependency, artifact_sha256: nil)
25
+ provenance = @client.trusted_publishing_provenance(dependency)
26
+ return unsupported_result(dependency) unless provenance
27
+
28
+ build_result(dependency, provenance, artifact_sha256)
29
+ rescue StandardError => e
30
+ error_result(dependency, artifact_sha256, e)
31
+ end
32
+
33
+ # Verifies provenance for each dependency-result pair.
34
+ def verify_all(results)
35
+ results.map { |result| verify(result.dependency, artifact_sha256: result.actual_sha256) }
36
+ end
37
+
38
+ private
39
+
40
+ # rubocop:disable Metrics/MethodLength
41
+ def build_result(dependency, provenance, artifact_sha256)
42
+ github_release = github_release_result(provenance)
43
+ status = combine_status(provenance_status(provenance, artifact_sha256), github_release&.status)
44
+ ProvenanceResult.new(**result_attributes(dependency, provenance, artifact_sha256, status, github_release))
45
+ end
46
+
47
+ def unsupported_result(dependency)
48
+ ProvenanceResult.new(**result_attributes(dependency, nil, nil, :unsupported, nil))
49
+ end
50
+
51
+ def error_result(dependency, artifact_sha256, error)
52
+ ProvenanceResult.new(**result_attributes(dependency, nil, artifact_sha256, :error, nil, error))
53
+ end
54
+
55
+ # rubocop:disable Metrics/ParameterLists
56
+ def result_attributes(dependency, provenance, artifact_sha256, status, github_release = nil, error = nil)
57
+ {
58
+ dependency:,
59
+ status:,
60
+ trusted_publishing: provenance&.trusted_publishing,
61
+ repository: provenance&.repository,
62
+ ref: provenance&.ref,
63
+ workflow: provenance&.workflow,
64
+ issuer: provenance&.issuer,
65
+ subject: provenance&.subject,
66
+ expected_sha256: provenance&.sha256,
67
+ actual_sha256: artifact_sha256,
68
+ error:,
69
+ attestation_url: provenance&.attestation_url,
70
+ github_release:
71
+ }
72
+ end
73
+ # rubocop:enable Metrics/ParameterLists
74
+ # rubocop:enable Metrics/MethodLength
75
+
76
+ def provenance_status(provenance, artifact_sha256)
77
+ return :unsupported unless provenance.trusted_publishing
78
+ return :verified unless provenance.sha256 && artifact_sha256
79
+
80
+ secure_compare(provenance.sha256, artifact_sha256) ? :verified : :mismatch
81
+ end
82
+
83
+ def github_release_result(provenance)
84
+ @github_release_verifier.verify(provenance)
85
+ rescue StandardError
86
+ nil
87
+ end
88
+
89
+ def combine_status(provenance_status, github_release_status)
90
+ return github_release_status if %i[mismatch error].include?(github_release_status)
91
+ return provenance_status if github_release_status.nil? || github_release_status == :unsupported
92
+
93
+ provenance_status == :unsupported ? github_release_status : provenance_status
94
+ end
95
+
96
+ def secure_compare(left, right)
97
+ left = left.to_s
98
+ right = right.to_s
99
+ return false unless left.bytesize == right.bytesize
100
+
101
+ left.bytes.zip(right.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b) }.zero?
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gem
4
+ module Guardian
5
+ # Builds machine-readable verification reports.
6
+ class ReportBuilder
7
+ # @param version [String] gem-guardian version string
8
+ def initialize(version:)
9
+ @version = version
10
+ end
11
+
12
+ # Returns a JSON-friendly hash for the current verification run.
13
+ def build(results, lockfile_data:, provenance_results: [], lockfile_path: nil)
14
+ {
15
+ version: @version,
16
+ command: "verify",
17
+ mode: lockfile_data ? "lockfile" : "explicit",
18
+ lockfile: lockfile_path,
19
+ checksums: checksum_summary(lockfile_data),
20
+ results: results.map.with_index { |result, index| build_result(result, provenance_results[index]) }
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def checksum_summary(lockfile_data)
27
+ return nil unless lockfile_data
28
+
29
+ {
30
+ coverage: {
31
+ covered: lockfile_data.dependencies.size - lockfile_data.missing_checksum_dependencies.size,
32
+ total: lockfile_data.dependencies.size
33
+ },
34
+ missing: lockfile_data.missing_checksum_dependencies.map do |dependency|
35
+ dependency_hash(dependency)
36
+ end
37
+ }
38
+ end
39
+
40
+ def build_result(result, provenance_result)
41
+ dependency_hash(result.dependency).merge(
42
+ checksum: checksum_hash(result)
43
+ ).tap do |hash|
44
+ hash[:provenance] = provenance_hash(provenance_result) if provenance_result
45
+ end
46
+ end
47
+
48
+ def dependency_hash(dependency)
49
+ {
50
+ name: dependency.name,
51
+ version: dependency.version,
52
+ platform: dependency.platform
53
+ }
54
+ end
55
+
56
+ def provenance_hash(result)
57
+ provenance_fields(result).merge(
58
+ error: error_hash(result.error),
59
+ github_release: github_release_hash(result.github_release)
60
+ )
61
+ end
62
+
63
+ # Returns the non-error provenance fields.
64
+ # rubocop:disable Metrics/MethodLength
65
+ def provenance_fields(result)
66
+ {
67
+ status: result.status,
68
+ trusted_publishing: result.trusted_publishing,
69
+ repository: result.repository,
70
+ ref: result.ref,
71
+ workflow: result.workflow,
72
+ issuer: result.issuer,
73
+ subject: result.subject,
74
+ expected_sha256: result.expected_sha256,
75
+ actual_sha256: result.actual_sha256,
76
+ attestation_url: result.attestation_url
77
+ }
78
+ end
79
+ # rubocop:enable Metrics/MethodLength
80
+
81
+ # Returns the GitHub release details for a provenance result.
82
+ # rubocop:disable Metrics/MethodLength
83
+ def github_release_hash(result)
84
+ return nil unless result
85
+
86
+ {
87
+ status: result.status,
88
+ repository: result.repository,
89
+ tag: result.tag,
90
+ checksum_assets: result.checksum_assets,
91
+ signature_assets: result.signature_assets,
92
+ signed_tag: result.signed_tag,
93
+ signed_tag_reason: result.signed_tag_reason,
94
+ release_attestation: result.release_attestation,
95
+ release_url: result.release_url,
96
+ error: error_hash(result.error)
97
+ }
98
+ end
99
+ # rubocop:enable Metrics/MethodLength
100
+
101
+ # Returns the checksum payload for a verification result.
102
+ def checksum_hash(result)
103
+ {
104
+ status: result.status,
105
+ expected_sha256: result.expected_sha256,
106
+ actual_sha256: result.actual_sha256,
107
+ artifact_path: result.artifact_path,
108
+ checksum_source: result.checksum_source,
109
+ error: error_hash(result.error)
110
+ }
111
+ end
112
+
113
+ def error_hash(error)
114
+ return nil unless error
115
+
116
+ { class: error.class.name, message: error.message }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -3,6 +3,7 @@
3
3
  module Gem
4
4
  module Guardian
5
5
  # Formats verification results for human-readable CLI output.
6
+ # rubocop:disable Metrics/ClassLength
6
7
  class ResultPrinter
7
8
  # @param stdout [IO] output stream for formatted messages
8
9
  def initialize(stdout:)
@@ -58,6 +59,45 @@ module Gem
58
59
  end
59
60
  end
60
61
 
62
+ # Prints provenance verification results.
63
+ def print_provenance_results(results)
64
+ results.each do |result|
65
+ print_provenance_result(result)
66
+ end
67
+ end
68
+
69
+ # Prints one provenance verification result.
70
+ def print_provenance_result(result)
71
+ label = result_label(result)
72
+ case result.status
73
+ when :verified then print_verified_provenance_result(result, label)
74
+ when :mismatch then print_mismatched_provenance_result(result, label)
75
+ else print_unsupported_provenance_result(result, label)
76
+ end
77
+ end
78
+
79
+ # Prints a successful provenance verification result.
80
+ def print_verified_provenance_result(result, label)
81
+ @stdout.puts "PROVENANCE PASS #{label}"
82
+ @stdout.puts " source trusted-publishing"
83
+ provenance_fields(result).each do |label_name, value|
84
+ @stdout.puts format_provenance_field(label_name, value) if value
85
+ end
86
+ print_github_release_result(result.github_release) if result.github_release
87
+ end
88
+
89
+ # Prints a provenance checksum mismatch.
90
+ def print_mismatched_provenance_result(result, label)
91
+ @stdout.puts "PROVENANCE FAIL #{label}"
92
+ @stdout.puts " expected #{result.expected_sha256}"
93
+ @stdout.puts " actual #{result.actual_sha256}"
94
+ end
95
+
96
+ # Prints a provenance result when no trusted publishing data is available.
97
+ def print_unsupported_provenance_result(_result, label)
98
+ @stdout.puts "PROVENANCE UNSUPPORTED #{label}"
99
+ end
100
+
61
101
  # Prints the CLI usage text.
62
102
  def usage
63
103
  @stdout.puts(USAGE)
@@ -68,14 +108,17 @@ module Gem
68
108
  gem-guardian #{VERSION}
69
109
 
70
110
  Usage:
71
- gem-guardian verify [--lockfile Gemfile.lock]
111
+ gem-guardian verify [--lockfile Gemfile.lock] [--json] [--provenance]
72
112
  gem-guardian verify GEM:VERSION[:PLATFORM] [GEM:VERSION[:PLATFORM] ...]
73
113
  gem-guardian version
114
+ gem-guardian help
74
115
 
75
116
  Examples:
76
117
  gem-guardian verify
77
- gem-guardian verify sidekiq:8.0.8
118
+ gem-guardian verify sidekiq:8.1.6
119
+ gem-guardian verify cdc-sidekiq:0.1.1
78
120
  gem-guardian verify nokogiri:1.18.9:x86_64-linux
121
+ gem-guardian verify --json --provenance ratomic:0.4.1
79
122
  USAGE
80
123
 
81
124
  private
@@ -84,6 +127,50 @@ module Gem
84
127
  dependency = result.dependency
85
128
  "#{dependency.name} #{dependency.version} #{dependency.platform}"
86
129
  end
130
+
131
+ # Returns the provenance fields to render for a verified result.
132
+ def provenance_fields(result)
133
+ [
134
+ ["repository", result.repository],
135
+ ["workflow", result.workflow],
136
+ ["ref", result.ref],
137
+ ["issuer", result.issuer],
138
+ ["subject", result.subject],
139
+ ["sha256", result.expected_sha256],
140
+ ["attestation", result.attestation_url]
141
+ ]
142
+ end
143
+
144
+ # Returns the GitHub release fields to render for a provenance result.
145
+ # rubocop:disable Metrics/MethodLength
146
+ def github_release_fields(result)
147
+ [
148
+ ["github release", result.status],
149
+ ["release repo", result.repository],
150
+ ["release tag", result.tag],
151
+ ["checksum assets", result.checksum_assets.join(", ")],
152
+ ["signature assets", result.signature_assets.join(", ")],
153
+ ["signed tag", result.signed_tag],
154
+ ["tag reason", result.signed_tag_reason],
155
+ ["attestation", result.release_attestation],
156
+ ["release url", result.release_url]
157
+ ]
158
+ end
159
+ # rubocop:enable Metrics/MethodLength
160
+
161
+ # Prints a GitHub release provenance result.
162
+ def print_github_release_result(result)
163
+ @stdout.puts "GITHUB RELEASE #{result.status.to_s.upcase}"
164
+ github_release_fields(result).each do |label_name, value|
165
+ @stdout.puts format_provenance_field(label_name, value) if value
166
+ end
167
+ end
168
+
169
+ # Formats one provenance field line.
170
+ def format_provenance_field(label, value)
171
+ format("%<label>11s %<value>s", label:, value:)
172
+ end
87
173
  end
174
+ # rubocop:enable Metrics/ClassLength
88
175
  end
89
176
  end
@@ -1,13 +1,35 @@
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
9
10
  # Reads checksum metadata from RubyGems.org and downloads gem artifacts.
11
+ # rubocop:disable Metrics/ClassLength
10
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
+ # Matches the `Source Commit` field on the RubyGems provenance page.
19
+ SOURCE_COMMIT_PATTERN = %r{Source Commit\s+([A-Za-z0-9._/-]+@[A-Za-z0-9._-]+)}i
20
+ # Matches the `Build File` field on the RubyGems provenance page.
21
+ BUILD_FILE_PATTERN = /Build File\s+([^\s]+)/i
22
+ # Matches the transparency log URL shown on the RubyGems provenance page.
23
+ LOG_ENTRY_PATTERN = %r{transparency log entry\s*(https?://[^\s]+)}i
24
+ # Matches the SHA256 checksum shown on the RubyGems provenance page.
25
+ SHA256_PATTERN = /SHA 256 checksum\s*([a-f0-9]{64})/i
26
+ # Matches the provenance workflow label shown on the RubyGems provenance page.
27
+ WORKFLOW_PATTERN = /
28
+ Built and signed on\s+
29
+ ([A-Za-z0-9 ._-]+?)
30
+ (?:\s+Build summary|\s+Source Commit|\z)
31
+ /ix
32
+
11
33
  # Default RubyGems.org endpoint used by the client.
12
34
  DEFAULT_HOST = "https://rubygems.org"
13
35
 
@@ -28,6 +50,14 @@ module Gem
28
50
  sha.downcase
29
51
  end
30
52
 
53
+ # Returns trusted publishing provenance data for +dependency+ when RubyGems exposes it.
54
+ def trusted_publishing_provenance(dependency)
55
+ version = matching_version(dependency)
56
+ version && provenance_for(version) ||
57
+ attestation_api_provenance(dependency) ||
58
+ version_page_provenance(dependency)
59
+ end
60
+
31
61
  # Downloads the .gem file for +dependency+ into +destination+.
32
62
  def download_gem(dependency, destination)
33
63
  body = get("/downloads/#{dependency.gem_filename}")
@@ -50,6 +80,227 @@ module Gem
50
80
  version["sha"] || version["sha256"] || version["checksum"]
51
81
  end
52
82
 
83
+ # Extracts trusted publishing provenance data from a RubyGems version payload.
84
+ def provenance_for(version)
85
+ provenance = provenance_payload(version)
86
+ return unless provenance.any?
87
+
88
+ TrustedPublishingProvenance.new(**provenance_attributes(provenance).merge(trusted_publishing: true))
89
+ end
90
+
91
+ # Reads provenance details from the RubyGems version page HTML.
92
+ def version_page_provenance(dependency)
93
+ html = get("/gems/#{dependency.name}/versions/#{dependency.version}")
94
+ provenance = html_provenance_payload(html)
95
+ return unless provenance
96
+
97
+ TrustedPublishingProvenance.new(**provenance.merge(trusted_publishing: true))
98
+ rescue StandardError
99
+ nil
100
+ end
101
+
102
+ # Reads provenance details from the RubyGems attestations API.
103
+ def attestation_api_provenance(dependency)
104
+ attestation_id = dependency.gem_filename.delete_suffix(".gem")
105
+ attestations = JSON.parse(get("/api/v1/attestations/#{attestation_id}.json"))
106
+ attestations.each do |attestation|
107
+ provenance = attestation_bundle_provenance(attestation)
108
+ return TrustedPublishingProvenance.new(**provenance.merge(trusted_publishing: true)) if provenance
109
+ end
110
+ nil
111
+ rescue StandardError
112
+ nil
113
+ end
114
+
115
+ # Returns the provenance payload from a version hash.
116
+ def provenance_payload(version)
117
+ payload = version["provenance"] || version["trusted_publishing"] || version["attestation"]
118
+ payload = deep_find_provenance_hash(version) unless payload.is_a?(Hash)
119
+ payload = version if payload.nil? && trusted_publishing_flag?(version)
120
+ payload.is_a?(Hash) ? payload : {}
121
+ end
122
+
123
+ # Returns the first non-empty provenance string value for the provided keys.
124
+ def provenance_string(provenance, *keys)
125
+ keys.map { |key| provenance[key] }.find { |value| !blank?(value) }&.to_s
126
+ end
127
+
128
+ # Returns the extracted provenance attributes.
129
+ def provenance_attributes(provenance)
130
+ {
131
+ repository: provenance_string(provenance, "repository", "repository_url", "source_repository"),
132
+ ref: provenance_string(provenance, "ref", "source_ref", "git_ref", "tag", "source_commit"),
133
+ workflow: provenance_string(provenance, "workflow", "workflow_name", "build_file"),
134
+ issuer: provenance_string(provenance, "issuer"),
135
+ subject: provenance_string(provenance, "subject"),
136
+ sha256: provenance_string(provenance, "sha256", "checksum", "digest"),
137
+ attestation_url: provenance_string(provenance, "attestation_url", "provenance_url", "url",
138
+ "transparency_log_entry")
139
+ }
140
+ end
141
+
142
+ # Extracts provenance metadata from the visible RubyGems HTML page text.
143
+ # rubocop:disable Metrics/MethodLength
144
+ def html_provenance_payload(html)
145
+ text = normalized_text(html)
146
+ source_commit = capture_text(text, SOURCE_COMMIT_PATTERN)
147
+ build_file = capture_text(text, BUILD_FILE_PATTERN)
148
+ log_entry = capture_text(text, LOG_ENTRY_PATTERN)
149
+ sha256 = capture_text(text, SHA256_PATTERN)
150
+ workflow = capture_text(text, WORKFLOW_PATTERN)
151
+ return unless source_commit || build_file || log_entry || sha256 || workflow
152
+
153
+ repository, ref = parse_source_commit(source_commit)
154
+ {
155
+ repository:,
156
+ ref:,
157
+ workflow: workflow || "GitHub Actions",
158
+ issuer: "GitHub Actions",
159
+ subject: source_commit,
160
+ sha256: sha256,
161
+ attestation_url: log_entry
162
+ }
163
+ end
164
+ # rubocop:enable Metrics/MethodLength
165
+
166
+ # Extracts provenance metadata from a Sigstore attestation bundle.
167
+ def attestation_bundle_provenance(attestation)
168
+ bundle = attestation.is_a?(Hash) ? attestation : JSON.parse(attestation.to_s)
169
+ certificate = find_certificate(bundle)
170
+ return unless certificate
171
+
172
+ parse_attestation_certificate(certificate)
173
+ end
174
+
175
+ # Returns a provenance hash extracted from a leaf certificate.
176
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
177
+ def parse_attestation_certificate(certificate)
178
+ cert = certificate.is_a?(OpenSSL::X509::Certificate) ? certificate : OpenSSL::X509::Certificate.new(certificate)
179
+ extensions = cert.extensions.each_with_object({}) do |ext, memo|
180
+ memo[ext.oid] = ext.value
181
+ end
182
+
183
+ repo = extensions["1.3.6.1.4.1.57264.1.5"]
184
+ commit = extensions["1.3.6.1.4.1.57264.1.3"]
185
+ ref = extensions["1.3.6.1.4.1.57264.1.14"]
186
+ build_summary_url = extensions["1.3.6.1.4.1.57264.1.21"]
187
+ san = extensions["subjectAltName"]
188
+ build_file = build_file_from_subject_alt_name(san, repo, ref)
189
+
190
+ return unless repo || commit || ref || build_summary_url || build_file
191
+
192
+ {
193
+ repository: normalize_repository(repo),
194
+ ref: commit || ref,
195
+ workflow: build_file || build_summary_url,
196
+ issuer: "https://token.actions.githubusercontent.com",
197
+ subject: [repo, commit].compact.join("@"),
198
+ sha256: nil,
199
+ attestation_url: build_summary_url
200
+ }
201
+ rescue OpenSSL::X509::CertificateError, OpenSSL::ASN1::ASN1Error
202
+ nil
203
+ end
204
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
205
+
206
+ # Finds the first certificate-like payload in a nested attestation bundle.
207
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
208
+ def find_certificate(value)
209
+ case value
210
+ when String
211
+ return value if value.include?("BEGIN CERTIFICATE")
212
+ when Hash
213
+ value.each_value do |child|
214
+ found = find_certificate(child)
215
+ return found if found
216
+ end
217
+ when Array
218
+ value.each do |child|
219
+ found = find_certificate(child)
220
+ return found if found
221
+ end
222
+ end
223
+ nil
224
+ end
225
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
226
+
227
+ # Extracts the workflow file path from the SAN extension.
228
+ def build_file_from_subject_alt_name(san, repo, ref)
229
+ return unless san && repo && ref
230
+
231
+ match = san.match(%r{\AURI:https://github\.com/#{Regexp.escape(repo)}/(.+)@#{Regexp.escape(ref)}\z})
232
+ match && match[1]
233
+ end
234
+
235
+ # Normalizes the repository value to a full GitHub URL.
236
+ def normalize_repository(repository)
237
+ return if blank?(repository)
238
+
239
+ repository = repository.to_s
240
+ repository.start_with?("http") ? repository : "https://github.com/#{repository}"
241
+ end
242
+
243
+ # Returns a text-only version of the RubyGems HTML page.
244
+ def normalized_text(html)
245
+ html.to_s
246
+ .gsub(%r{<script.*?</script>}m, " ")
247
+ .gsub(%r{<style.*?</style>}m, " ")
248
+ .gsub(/<[^>]+>/, " ")
249
+ .gsub(/&nbsp;/, " ")
250
+ .gsub(/&amp;/, "&")
251
+ .gsub(/\s+/, " ")
252
+ end
253
+
254
+ # Captures the first matching string from +text+ for +pattern+.
255
+ def capture_text(text, pattern)
256
+ match = text.match(pattern)
257
+ match && match[1].to_s.strip
258
+ end
259
+
260
+ # Returns repository and ref values from a source commit string.
261
+ def parse_source_commit(source_commit)
262
+ return [nil, nil] if blank?(source_commit)
263
+
264
+ repository, ref = source_commit.split("@", 2)
265
+ [repository.start_with?("http") ? repository : "https://github.com/#{repository}", ref]
266
+ end
267
+
268
+ # Finds a nested provenance hash inside a RubyGems version payload.
269
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
270
+ def deep_find_provenance_hash(value)
271
+ case value
272
+ when Hash
273
+ return value if provenance_hash?(value)
274
+
275
+ value.each_value do |child|
276
+ found = deep_find_provenance_hash(child)
277
+ return found if found
278
+ end
279
+ when Array
280
+ value.each do |child|
281
+ found = deep_find_provenance_hash(child)
282
+ return found if found
283
+ end
284
+ end
285
+ nil
286
+ end
287
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
288
+
289
+ # Returns true when +value+ looks like a provenance record.
290
+ def provenance_hash?(value)
291
+ (value.keys & %w[
292
+ repository repository_url source_repository ref source_ref git_ref tag source_commit workflow
293
+ workflow_name build_file issuer subject sha256 checksum digest attestation_url provenance_url url
294
+ transparency_log_entry
295
+ ]).any?
296
+ end
297
+
298
+ # Returns true when the version payload advertises Trusted Publishing.
299
+ def trusted_publishing_flag?(version)
300
+ value = version["trusted_publishing"]
301
+ value == true || value.to_s.casecmp("true").zero?
302
+ end
303
+
53
304
  # GETs +path+ from the configured host and returns the response body.
54
305
  def get(path)
55
306
  uri = URI("#{@host}#{path}")
@@ -71,5 +322,6 @@ module Gem
71
322
  value.nil? || value.to_s.empty?
72
323
  end
73
324
  end
325
+ # rubocop:enable Metrics/ClassLength
74
326
  end
75
327
  end
@@ -3,6 +3,6 @@
3
3
  module Gem
4
4
  module Guardian
5
5
  # gem-guardian version.
6
- VERSION = "0.1.1"
6
+ VERSION = "0.3.0"
7
7
  end
8
8
  end
data/lib/gem/guardian.rb CHANGED
@@ -10,7 +10,11 @@ require_relative "guardian/checksum"
10
10
  require_relative "guardian/dependency"
11
11
  require_relative "guardian/lockfile_parser"
12
12
  require_relative "guardian/rubygems_client"
13
+ require_relative "guardian/github_client"
14
+ require_relative "guardian/github_release_verifier"
13
15
  require_relative "guardian/artifact_store"
14
16
  require_relative "guardian/verifier"
17
+ require_relative "guardian/provenance_verifier"
18
+ require_relative "guardian/report_builder"
15
19
  require_relative "guardian/result_printer"
16
20
  require_relative "guardian/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gem-guardian
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kenneth Demanawa
@@ -43,7 +43,11 @@ files:
43
43
  - lib/gem/guardian/cli.rb
44
44
  - lib/gem/guardian/dependency.rb
45
45
  - lib/gem/guardian/error.rb
46
+ - lib/gem/guardian/github_client.rb
47
+ - lib/gem/guardian/github_release_verifier.rb
46
48
  - lib/gem/guardian/lockfile_parser.rb
49
+ - lib/gem/guardian/provenance_verifier.rb
50
+ - lib/gem/guardian/report_builder.rb
47
51
  - lib/gem/guardian/result_printer.rb
48
52
  - lib/gem/guardian/rubygems_client.rb
49
53
  - lib/gem/guardian/verifier.rb