gem-guardian 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed534bc229391d74fb2b3ee945127ad3690236f3c27d28fe1aaf28a909c19a8c
4
- data.tar.gz: 4c30325c72ba731d6a9e5dc5e2f2ea3a08bf5147c8a29e02132f5bd9ec9fecac
3
+ metadata.gz: 75caa51bf7916d1feb83062445a665ca34d89b8e476cf015355b5d86566dbb76
4
+ data.tar.gz: 4520cec08edf53406f26988cc5d48cd5794307fd12d59c4b661ee0e94dfc12db
5
5
  SHA512:
6
- metadata.gz: e13ac5e36ffeb46ff9d2fa027d4a6af02b648389a9e7981a50ea9a28264aa32d8473ac2c07a748b002f24bb59339c10da138b405fa5ac9239346caec2e816fe9
7
- data.tar.gz: ffcc44114da845142bff58537d06970479d2694d0a91c52ee21f00a3a0e695cc8d5f7ec8aee0fb8b296495dbd2078fd76b15944f17371330e03b532feb1d5ed4
6
+ metadata.gz: 15c736bf8890ca30c0fef05508b14ed85b75bcb660c14843773348fa0c413213f6b79ac2ad39bbdd9edf3cc00c62d3f23667529b0f9b3f1231001b6c3804f020
7
+ data.tar.gz: 5f50fc8de20b9691dc3ea84c9ef2eb542f4b58981552ee237ac1314e4860c7d47779dd078f29a936d632a6342b322237d73546dd139f8e6ddde94adadd8ff8b5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-06-12
6
+
7
+ - Add `--json` output for CI-friendly verification reports.
8
+ - Add opt-in Trusted Publishing provenance verification for RubyGems releases.
9
+ - Verify provenance through RubyGems attestations for supported releases.
10
+
5
11
  ## [0.1.1] - 2026-06-12
6
12
 
7
13
  - 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. 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, and whether RubyGems exposes Trusted Publishing provenance for the gem being verified. It does **not** yet prove source provenance for releases that do not publish attestation data.
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
@@ -104,8 +115,6 @@ When you verify a lockfile that already contains Bundler `CHECKSUMS`, `gem-guard
104
115
 
105
116
  ## Roadmap
106
117
 
107
- - Machine-readable JSON output for CI.
108
- - Provenance verification for gems published through Trusted Publishing.
109
118
  - GitHub Release checksum/signature discovery.
110
119
  - Signed tag and release attestation checks.
111
120
 
@@ -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,88 @@
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
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)
19
+ @client = client
20
+ end
21
+
22
+ # Verifies Trusted Publishing provenance for +dependency+.
23
+ def verify(dependency, artifact_sha256: nil)
24
+ provenance = @client.trusted_publishing_provenance(dependency)
25
+ return unsupported_result(dependency) unless provenance
26
+
27
+ build_result(dependency, provenance, artifact_sha256)
28
+ rescue StandardError => e
29
+ error_result(dependency, artifact_sha256, e)
30
+ end
31
+
32
+ # Verifies provenance for each dependency-result pair.
33
+ def verify_all(results)
34
+ results.map { |result| verify(result.dependency, artifact_sha256: result.actual_sha256) }
35
+ end
36
+
37
+ private
38
+
39
+ # rubocop:disable Metrics/MethodLength
40
+ def build_result(dependency, provenance, artifact_sha256)
41
+ ProvenanceResult.new(**result_attributes(
42
+ dependency, provenance, artifact_sha256, provenance_status(provenance, artifact_sha256)
43
+ ))
44
+ end
45
+
46
+ def unsupported_result(dependency)
47
+ ProvenanceResult.new(**result_attributes(dependency, nil, nil, :unsupported))
48
+ end
49
+
50
+ def error_result(dependency, artifact_sha256, error)
51
+ ProvenanceResult.new(**result_attributes(dependency, nil, artifact_sha256, :error, error))
52
+ end
53
+
54
+ def result_attributes(dependency, provenance, artifact_sha256, status, error = nil)
55
+ {
56
+ dependency:,
57
+ status:,
58
+ trusted_publishing: provenance&.trusted_publishing,
59
+ repository: provenance&.repository,
60
+ ref: provenance&.ref,
61
+ workflow: provenance&.workflow,
62
+ issuer: provenance&.issuer,
63
+ subject: provenance&.subject,
64
+ expected_sha256: provenance&.sha256,
65
+ actual_sha256: artifact_sha256,
66
+ error:,
67
+ attestation_url: provenance&.attestation_url
68
+ }
69
+ end
70
+ # rubocop:enable Metrics/MethodLength
71
+
72
+ def provenance_status(provenance, artifact_sha256)
73
+ return :unsupported unless provenance.trusted_publishing
74
+ return :verified unless provenance.sha256 && artifact_sha256
75
+
76
+ secure_compare(provenance.sha256, artifact_sha256) ? :verified : :mismatch
77
+ end
78
+
79
+ def secure_compare(left, right)
80
+ left = left.to_s
81
+ right = right.to_s
82
+ return false unless left.bytesize == right.bytesize
83
+
84
+ left.bytes.zip(right.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b) }.zero?
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,99 @@
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
+ )
60
+ end
61
+
62
+ # Returns the non-error provenance fields.
63
+ # rubocop:disable Metrics/MethodLength
64
+ def provenance_fields(result)
65
+ {
66
+ status: result.status,
67
+ trusted_publishing: result.trusted_publishing,
68
+ repository: result.repository,
69
+ ref: result.ref,
70
+ workflow: result.workflow,
71
+ issuer: result.issuer,
72
+ subject: result.subject,
73
+ expected_sha256: result.expected_sha256,
74
+ actual_sha256: result.actual_sha256,
75
+ attestation_url: result.attestation_url
76
+ }
77
+ end
78
+ # rubocop:enable Metrics/MethodLength
79
+
80
+ # Returns the checksum payload for a verification result.
81
+ def checksum_hash(result)
82
+ {
83
+ status: result.status,
84
+ expected_sha256: result.expected_sha256,
85
+ actual_sha256: result.actual_sha256,
86
+ artifact_path: result.artifact_path,
87
+ checksum_source: result.checksum_source,
88
+ error: error_hash(result.error)
89
+ }
90
+ end
91
+
92
+ def error_hash(error)
93
+ return nil unless error
94
+
95
+ { class: error.class.name, message: error.message }
96
+ end
97
+ end
98
+ end
99
+ 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,44 @@ 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
+ end
87
+
88
+ # Prints a provenance checksum mismatch.
89
+ def print_mismatched_provenance_result(result, label)
90
+ @stdout.puts "PROVENANCE FAIL #{label}"
91
+ @stdout.puts " expected #{result.expected_sha256}"
92
+ @stdout.puts " actual #{result.actual_sha256}"
93
+ end
94
+
95
+ # Prints a provenance result when no trusted publishing data is available.
96
+ def print_unsupported_provenance_result(_result, label)
97
+ @stdout.puts "PROVENANCE UNSUPPORTED #{label}"
98
+ end
99
+
61
100
  # Prints the CLI usage text.
62
101
  def usage
63
102
  @stdout.puts(USAGE)
@@ -68,14 +107,17 @@ module Gem
68
107
  gem-guardian #{VERSION}
69
108
 
70
109
  Usage:
71
- gem-guardian verify [--lockfile Gemfile.lock]
110
+ gem-guardian verify [--lockfile Gemfile.lock] [--json] [--provenance]
72
111
  gem-guardian verify GEM:VERSION[:PLATFORM] [GEM:VERSION[:PLATFORM] ...]
73
112
  gem-guardian version
113
+ gem-guardian help
74
114
 
75
115
  Examples:
76
116
  gem-guardian verify
77
- gem-guardian verify sidekiq:8.0.8
117
+ gem-guardian verify sidekiq:8.1.6
118
+ gem-guardian verify cdc-sidekiq:0.1.1
78
119
  gem-guardian verify nokogiri:1.18.9:x86_64-linux
120
+ gem-guardian verify --json --provenance ratomic:0.4.1
79
121
  USAGE
80
122
 
81
123
  private
@@ -84,6 +126,25 @@ module Gem
84
126
  dependency = result.dependency
85
127
  "#{dependency.name} #{dependency.version} #{dependency.platform}"
86
128
  end
129
+
130
+ # Returns the provenance fields to render for a verified result.
131
+ def provenance_fields(result)
132
+ [
133
+ ["repository", result.repository],
134
+ ["workflow", result.workflow],
135
+ ["ref", result.ref],
136
+ ["issuer", result.issuer],
137
+ ["subject", result.subject],
138
+ ["sha256", result.expected_sha256],
139
+ ["attestation", result.attestation_url]
140
+ ]
141
+ end
142
+
143
+ # Formats one provenance field line.
144
+ def format_provenance_field(label, value)
145
+ format("%<label>11s %<value>s", label:, value:)
146
+ end
87
147
  end
148
+ # rubocop:enable Metrics/ClassLength
88
149
  end
89
150
  end
@@ -1,13 +1,30 @@
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
+ 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
+
11
28
  # Default RubyGems.org endpoint used by the client.
12
29
  DEFAULT_HOST = "https://rubygems.org"
13
30
 
@@ -28,6 +45,14 @@ module Gem
28
45
  sha.downcase
29
46
  end
30
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
+
31
56
  # Downloads the .gem file for +dependency+ into +destination+.
32
57
  def download_gem(dependency, destination)
33
58
  body = get("/downloads/#{dependency.gem_filename}")
@@ -50,6 +75,227 @@ module Gem
50
75
  version["sha"] || version["sha256"] || version["checksum"]
51
76
  end
52
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(/&nbsp;/, " ")
245
+ .gsub(/&amp;/, "&")
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
+
53
299
  # GETs +path+ from the configured host and returns the response body.
54
300
  def get(path)
55
301
  uri = URI("#{@host}#{path}")
@@ -71,5 +317,6 @@ module Gem
71
317
  value.nil? || value.to_s.empty?
72
318
  end
73
319
  end
320
+ # rubocop:enable Metrics/ClassLength
74
321
  end
75
322
  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.2.0"
7
7
  end
8
8
  end
data/lib/gem/guardian.rb CHANGED
@@ -12,5 +12,7 @@ require_relative "guardian/lockfile_parser"
12
12
  require_relative "guardian/rubygems_client"
13
13
  require_relative "guardian/artifact_store"
14
14
  require_relative "guardian/verifier"
15
+ require_relative "guardian/provenance_verifier"
16
+ require_relative "guardian/report_builder"
15
17
  require_relative "guardian/result_printer"
16
18
  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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kenneth Demanawa
@@ -44,6 +44,8 @@ files:
44
44
  - lib/gem/guardian/dependency.rb
45
45
  - lib/gem/guardian/error.rb
46
46
  - lib/gem/guardian/lockfile_parser.rb
47
+ - lib/gem/guardian/provenance_verifier.rb
48
+ - lib/gem/guardian/report_builder.rb
47
49
  - lib/gem/guardian/result_printer.rb
48
50
  - lib/gem/guardian/rubygems_client.rb
49
51
  - lib/gem/guardian/verifier.rb