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 +4 -4
- data/.github/workflows/release.yml +13 -0
- data/CHANGELOG.md +14 -0
- data/README.md +19 -11
- data/gem-guardian.gemspec +3 -1
- data/lib/gem/guardian/cli.rb +64 -8
- data/lib/gem/guardian/github_client.rb +58 -0
- data/lib/gem/guardian/github_release_verifier.rb +200 -0
- data/lib/gem/guardian/provenance_verifier.rb +105 -0
- data/lib/gem/guardian/report_builder.rb +120 -0
- data/lib/gem/guardian/result_printer.rb +89 -2
- data/lib/gem/guardian/rubygems_client.rb +252 -0
- data/lib/gem/guardian/version.rb +1 -1
- data/lib/gem/guardian.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a18ee9f2f2111d0eca38def30302a7d66b9b6b9b96df06909f883be2978c1bcd
|
|
4
|
+
data.tar.gz: e6a421612c4c50423ef7fe29f74f2bc2503cb624b813befb114bc679940fcd5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,
|
|
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, 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.
|
|
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
|
|
@@ -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
|
-
-
|
|
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
|
-
-
|
|
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")
|
|
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
|
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,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.
|
|
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(/ /, " ")
|
|
250
|
+
.gsub(/&/, "&")
|
|
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
|
data/lib/gem/guardian/version.rb
CHANGED
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.
|
|
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
|