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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +17 -8
- data/lib/gem/guardian/cli.rb +64 -8
- data/lib/gem/guardian/provenance_verifier.rb +88 -0
- data/lib/gem/guardian/report_builder.rb +99 -0
- data/lib/gem/guardian/result_printer.rb +63 -2
- data/lib/gem/guardian/rubygems_client.rb +247 -0
- data/lib/gem/guardian/version.rb +1 -1
- data/lib/gem/guardian.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75caa51bf7916d1feb83062445a665ca34d89b8e476cf015355b5d86566dbb76
|
|
4
|
+
data.tar.gz: 4520cec08edf53406f26988cc5d48cd5794307fd12d59c4b661ee0e94dfc12db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
data/lib/gem/guardian/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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.
|
|
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(/ /, " ")
|
|
245
|
+
.gsub(/&/, "&")
|
|
246
|
+
.gsub(/\s+/, " ")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Captures the first matching string from +text+ for +pattern+.
|
|
250
|
+
def capture_text(text, pattern)
|
|
251
|
+
match = text.match(pattern)
|
|
252
|
+
match && match[1].to_s.strip
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Returns repository and ref values from a source commit string.
|
|
256
|
+
def parse_source_commit(source_commit)
|
|
257
|
+
return [nil, nil] if blank?(source_commit)
|
|
258
|
+
|
|
259
|
+
repository, ref = source_commit.split("@", 2)
|
|
260
|
+
[repository.start_with?("http") ? repository : "https://github.com/#{repository}", ref]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Finds a nested provenance hash inside a RubyGems version payload.
|
|
264
|
+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
265
|
+
def deep_find_provenance_hash(value)
|
|
266
|
+
case value
|
|
267
|
+
when Hash
|
|
268
|
+
return value if provenance_hash?(value)
|
|
269
|
+
|
|
270
|
+
value.each_value do |child|
|
|
271
|
+
found = deep_find_provenance_hash(child)
|
|
272
|
+
return found if found
|
|
273
|
+
end
|
|
274
|
+
when Array
|
|
275
|
+
value.each do |child|
|
|
276
|
+
found = deep_find_provenance_hash(child)
|
|
277
|
+
return found if found
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
nil
|
|
281
|
+
end
|
|
282
|
+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
283
|
+
|
|
284
|
+
# Returns true when +value+ looks like a provenance record.
|
|
285
|
+
def provenance_hash?(value)
|
|
286
|
+
(value.keys & %w[
|
|
287
|
+
repository repository_url source_repository ref source_ref git_ref tag source_commit workflow
|
|
288
|
+
workflow_name build_file issuer subject sha256 checksum digest attestation_url provenance_url url
|
|
289
|
+
transparency_log_entry
|
|
290
|
+
]).any?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Returns true when the version payload advertises Trusted Publishing.
|
|
294
|
+
def trusted_publishing_flag?(version)
|
|
295
|
+
value = version["trusted_publishing"]
|
|
296
|
+
value == true || value.to_s.casecmp("true").zero?
|
|
297
|
+
end
|
|
298
|
+
|
|
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
|
data/lib/gem/guardian/version.rb
CHANGED
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.
|
|
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
|