gem-guardian 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +24 -3
- data/.github/workflows/pages.yml +68 -0
- data/.github/workflows/release.yml +31 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +12 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +12 -1
- data/Gemfile +3 -3
- data/README.md +36 -13
- data/Rakefile +2 -1
- data/gem-guardian.gemspec +3 -1
- data/lib/gem/guardian/artifact_store.rb +4 -0
- data/lib/gem/guardian/checksum.rb +2 -0
- data/lib/gem/guardian/cli.rb +75 -63
- data/lib/gem/guardian/dependency.rb +2 -0
- data/lib/gem/guardian/error.rb +4 -0
- data/lib/gem/guardian/lockfile_parser.rb +95 -13
- data/lib/gem/guardian/result_printer.rb +89 -0
- data/lib/gem/guardian/rubygems_client.rb +23 -6
- data/lib/gem/guardian/verifier.rb +43 -20
- data/lib/gem/guardian/version.rb +2 -1
- data/lib/gem/guardian.rb +5 -0
- data/sig/gem/guardian/artifact_store.rbs +9 -0
- data/sig/gem/guardian/checksum.rbs +7 -0
- data/sig/gem/guardian/cli.rbs +31 -0
- data/sig/gem/guardian/dependency.rbs +13 -0
- data/sig/gem/guardian/error.rbs +15 -0
- data/sig/gem/guardian/lockfile_parser.rbs +36 -0
- data/sig/gem/guardian/rubygems_client.rbs +21 -0
- data/sig/gem/guardian/verifier.rbs +19 -0
- data/sig/gem/guardian/version.rbs +5 -0
- data/sig/gem/guardian.rbs +4 -0
- metadata +11 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed534bc229391d74fb2b3ee945127ad3690236f3c27d28fe1aaf28a909c19a8c
|
|
4
|
+
data.tar.gz: 4c30325c72ba731d6a9e5dc5e2f2ea3a08bf5147c8a29e02132f5bd9ec9fecac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e13ac5e36ffeb46ff9d2fa027d4a6af02b648389a9e7981a50ea9a28264aa32d8473ac2c07a748b002f24bb59339c10da138b405fa5ac9239346caec2e816fe9
|
|
7
|
+
data.tar.gz: ffcc44114da845142bff58537d06970479d2694d0a91c52ee21f00a3a0e695cc8d5f7ec8aee0fb8b296495dbd2078fd76b15944f17371330e03b532feb1d5ed4
|
data/.github/workflows/main.yml
CHANGED
|
@@ -17,7 +17,10 @@ jobs:
|
|
|
17
17
|
strategy:
|
|
18
18
|
matrix:
|
|
19
19
|
ruby:
|
|
20
|
-
- '3.2
|
|
20
|
+
- '3.2'
|
|
21
|
+
- '3.3'
|
|
22
|
+
- '3.4'
|
|
23
|
+
- '4.0'
|
|
21
24
|
|
|
22
25
|
steps:
|
|
23
26
|
- uses: actions/checkout@v6
|
|
@@ -28,5 +31,23 @@ jobs:
|
|
|
28
31
|
with:
|
|
29
32
|
ruby-version: ${{ matrix.ruby }}
|
|
30
33
|
bundler-cache: true
|
|
31
|
-
- name: Run the
|
|
32
|
-
run: bundle exec rake
|
|
34
|
+
- name: Run the test suite
|
|
35
|
+
run: COVERAGE=true bundle exec rake test
|
|
36
|
+
- name: Validate RBS signatures
|
|
37
|
+
run: bundle exec rake rbs:validate
|
|
38
|
+
|
|
39
|
+
lint:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
name: RuboCop
|
|
42
|
+
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v6
|
|
45
|
+
with:
|
|
46
|
+
persist-credentials: false
|
|
47
|
+
- name: Set up Ruby
|
|
48
|
+
uses: ruby/setup-ruby@v1
|
|
49
|
+
with:
|
|
50
|
+
ruby-version: "3.2"
|
|
51
|
+
bundler-cache: true
|
|
52
|
+
- name: Run RuboCop
|
|
53
|
+
run: bundle exec rubocop
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
name: Deploy YARD docs to GitHub Pages
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
pages: write
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: github-pages
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build:
|
|
17
|
+
name: Build YARD documentation
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout
|
|
22
|
+
uses: actions/checkout@v5
|
|
23
|
+
|
|
24
|
+
- name: Set up Ruby
|
|
25
|
+
uses: ruby/setup-ruby@v1
|
|
26
|
+
with:
|
|
27
|
+
ruby-version: "3.4"
|
|
28
|
+
bundler-cache: true
|
|
29
|
+
|
|
30
|
+
- name: Build YARD documentation
|
|
31
|
+
run: bundle exec yard doc
|
|
32
|
+
|
|
33
|
+
- name: Enforce YARD coverage
|
|
34
|
+
shell: bash
|
|
35
|
+
run: |
|
|
36
|
+
stats="$(bundle exec yard stats --no-progress --list-undoc)"
|
|
37
|
+
echo "$stats"
|
|
38
|
+
|
|
39
|
+
coverage="$(printf '%s\n' "$stats" | ruby -ne 'puts($1) if /([0-9]+(?:\.[0-9]+)?)% documented/ =~ $_')"
|
|
40
|
+
|
|
41
|
+
if [ -z "$coverage" ]; then
|
|
42
|
+
echo "Could not determine YARD documentation coverage."
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
ruby -e 'coverage = ARGV.fetch(0).to_f; abort("YARD documentation coverage #{coverage}% is below 95%") if coverage < 95.0' "$coverage"
|
|
47
|
+
|
|
48
|
+
- name: Disable Jekyll processing
|
|
49
|
+
run: touch doc/.nojekyll
|
|
50
|
+
|
|
51
|
+
- name: Upload Pages artifact
|
|
52
|
+
uses: actions/upload-pages-artifact@v3
|
|
53
|
+
with:
|
|
54
|
+
path: doc
|
|
55
|
+
|
|
56
|
+
deploy:
|
|
57
|
+
name: Deploy to GitHub Pages
|
|
58
|
+
needs: build
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
|
|
61
|
+
environment:
|
|
62
|
+
name: github-pages
|
|
63
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
64
|
+
|
|
65
|
+
steps:
|
|
66
|
+
- name: Deploy
|
|
67
|
+
id: deployment
|
|
68
|
+
uses: actions/deploy-pages@v4
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
id-token: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release:
|
|
14
|
+
name: Release gem to RubyGems.org
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
environment:
|
|
17
|
+
name: release
|
|
18
|
+
url: https://rubygems.org/gems/gem-guardian
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v6
|
|
21
|
+
with:
|
|
22
|
+
persist-credentials: false
|
|
23
|
+
- name: Set up Ruby
|
|
24
|
+
uses: ruby/setup-ruby@v1
|
|
25
|
+
with:
|
|
26
|
+
ruby-version: "3.4"
|
|
27
|
+
bundler-cache: true
|
|
28
|
+
- name: Run tests
|
|
29
|
+
run: bundle exec rake
|
|
30
|
+
- name: Release gem to RubyGems.org
|
|
31
|
+
uses: rubygems/release-gem@v1
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
plugins:
|
|
2
|
+
- rubocop-minitest
|
|
3
|
+
- rubocop-rake
|
|
4
|
+
|
|
1
5
|
AllCops:
|
|
6
|
+
NewCops: disable
|
|
2
7
|
TargetRubyVersion: 3.2
|
|
8
|
+
Exclude:
|
|
9
|
+
- 'coverage/**/*'
|
|
10
|
+
- 'doc/**/*'
|
|
11
|
+
- 'vendor/**/*'
|
|
12
|
+
- 'sig/**/*'
|
|
13
|
+
- 'tmp/**/*'
|
|
14
|
+
- 'test/**/*'
|
|
3
15
|
|
|
4
16
|
Style/StringLiterals:
|
|
5
17
|
EnforcedStyle: double_quotes
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.1] - 2026-06-12
|
|
6
|
+
|
|
7
|
+
- Parse Bundler `CHECKSUMS` entries from `Gemfile.lock`.
|
|
8
|
+
- Audit lockfiles for missing checksum coverage and report fallback verification.
|
|
9
|
+
- Raise test coverage to 95%+ line and branch.
|
|
10
|
+
- Curate `sig/` outputs so `rbs validate` passes cleanly.
|
|
11
|
+
- Add GitHub Actions Ruby matrix for `3.2`, `3.3`, `3.4`, and `4.0`.
|
|
12
|
+
- Run `rbs:validate` in CI.
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-06-12
|
|
4
15
|
|
|
5
16
|
- Initial MVP codebase.
|
|
6
17
|
- Verify explicit gems or all gems in `Gemfile.lock`.
|
data/Gemfile
CHANGED
|
@@ -4,12 +4,12 @@ source "https://rubygems.org"
|
|
|
4
4
|
|
|
5
5
|
gemspec
|
|
6
6
|
|
|
7
|
+
gem "minitest", "~> 6.0"
|
|
7
8
|
gem "pry", "~> 0.16.0"
|
|
8
9
|
gem "rake", "~> 13.4"
|
|
9
|
-
gem "minitest", "~> 6.0"
|
|
10
10
|
gem "rubocop", "~> 1.87"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.39.1"
|
|
12
|
+
gem "rubocop-rake", "~> 0.7.1"
|
|
11
13
|
gem "simplecov", "~> 0.22.0"
|
|
12
14
|
gem "steep", "~> 2.0"
|
|
13
15
|
gem "yard", "~> 0.9.44"
|
|
14
|
-
gem "rubocop-minitest", "~> 0.39.1"
|
|
15
|
-
gem "rubocop-rake", "~> 0.7.1"
|
data/README.md
CHANGED
|
@@ -8,25 +8,25 @@
|
|
|
8
8
|
|
|
9
9
|
Consumer-side integrity verification for Ruby gems.
|
|
10
10
|
|
|
11
|
-
`gem-guardian` verifies
|
|
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.
|
|
12
12
|
|
|
13
13
|
## Why
|
|
14
14
|
|
|
15
|
-
RubyGems.org displays SHA256 checksums for published gem artifacts, and
|
|
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.
|
|
16
16
|
|
|
17
|
-
This
|
|
17
|
+
This v0.1.0 scope is:
|
|
18
18
|
|
|
19
19
|
```text
|
|
20
|
-
Gemfile.lock
|
|
20
|
+
Gemfile.lock
|
|
21
21
|
↓
|
|
22
|
-
|
|
22
|
+
CHECKSUMS coverage audit
|
|
23
23
|
↓
|
|
24
|
-
|
|
24
|
+
RubyGems.org checksum comparison when needed
|
|
25
25
|
↓
|
|
26
|
-
|
|
26
|
+
Actionable report for CI or local review
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
This
|
|
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.
|
|
30
30
|
|
|
31
31
|
## Installation
|
|
32
32
|
|
|
@@ -39,6 +39,27 @@ gem install ./gem-guardian-0.1.0.gem
|
|
|
39
39
|
|
|
40
40
|
## Usage
|
|
41
41
|
|
|
42
|
+
Build and install the current release from a local checkout:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
gem build gem-guardian.gemspec
|
|
46
|
+
gem install ./gem-guardian-0.1.1.gem
|
|
47
|
+
gem-guardian version
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Show the built-in help:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
gem-guardian help
|
|
54
|
+
gem-guardian --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Prepare a locked project for checksum auditing:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
bundle lock --add-checksums
|
|
61
|
+
```
|
|
62
|
+
|
|
42
63
|
Verify all gems in `Gemfile.lock`:
|
|
43
64
|
|
|
44
65
|
```bash
|
|
@@ -64,6 +85,8 @@ Use a non-default lockfile:
|
|
|
64
85
|
gem-guardian verify --lockfile path/to/Gemfile.lock
|
|
65
86
|
```
|
|
66
87
|
|
|
88
|
+
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
|
+
|
|
67
90
|
## Exit codes
|
|
68
91
|
|
|
69
92
|
- `0` — all verified artifacts matched
|
|
@@ -72,19 +95,19 @@ gem-guardian verify --lockfile path/to/Gemfile.lock
|
|
|
72
95
|
|
|
73
96
|
## MVP constraints
|
|
74
97
|
|
|
75
|
-
-
|
|
76
|
-
-
|
|
98
|
+
- Audits `Gemfile.lock` for Bundler `CHECKSUMS` coverage.
|
|
99
|
+
- Uses RubyGems.org as a fallback checksum source when the lockfile is incomplete or an explicit gem is supplied.
|
|
100
|
+
- Downloads artifacts from RubyGems.org `/downloads/<gem-file>.gem` only when verification is needed.
|
|
77
101
|
- Caches downloaded artifacts under the system temp directory.
|
|
78
102
|
- Does not integrate into Bundler install hooks.
|
|
79
103
|
- Does not yet verify Sigstore, SLSA, GitHub Actions provenance, or signed git tags.
|
|
80
104
|
|
|
81
105
|
## Roadmap
|
|
82
106
|
|
|
83
|
-
-
|
|
84
|
-
- Support Bundler 2.6 `CHECKSUMS` sections as an offline expected-checksum source.
|
|
107
|
+
- Machine-readable JSON output for CI.
|
|
85
108
|
- Provenance verification for gems published through Trusted Publishing.
|
|
86
109
|
- GitHub Release checksum/signature discovery.
|
|
87
|
-
-
|
|
110
|
+
- Signed tag and release attestation checks.
|
|
88
111
|
|
|
89
112
|
|
|
90
113
|
## License
|
data/Rakefile
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bundler/gem_tasks"
|
|
3
4
|
require "rake/testtask"
|
|
4
5
|
|
|
5
6
|
Rake::TestTask.new(:test) do |t|
|
|
@@ -29,7 +30,7 @@ namespace :rbs do
|
|
|
29
30
|
|
|
30
31
|
desc "Validate curated RBS signatures with Steep"
|
|
31
32
|
task :validate do
|
|
32
|
-
sh "bundle exec
|
|
33
|
+
sh "bundle exec rbs validate sig"
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
desc "Open diff between curated and generated signatures"
|
data/gem-guardian.gemspec
CHANGED
|
@@ -9,7 +9,9 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.email = ["kenneth.c.demanawa@gmail.com"]
|
|
10
10
|
|
|
11
11
|
spec.summary = "Consumer-side integrity verification for Ruby gems."
|
|
12
|
-
spec.description =
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
Audits Bundler checksum coverage and verifies Ruby gem artifacts against RubyGems SHA256 checksums when needed.
|
|
14
|
+
DESC
|
|
13
15
|
spec.homepage = "https://github.com/kanutocd/gem-guardian"
|
|
14
16
|
spec.license = "MIT"
|
|
15
17
|
spec.required_ruby_version = ">= 3.2"
|
|
@@ -5,12 +5,16 @@ require "tmpdir"
|
|
|
5
5
|
|
|
6
6
|
module Gem
|
|
7
7
|
module Guardian
|
|
8
|
+
# Stores downloaded gem artifacts in a local cache directory.
|
|
8
9
|
class ArtifactStore
|
|
10
|
+
# @param client [RubygemsClient] downloader used when the artifact is not cached
|
|
11
|
+
# @param cache_dir [String] directory where downloaded artifacts are stored
|
|
9
12
|
def initialize(client:, cache_dir: File.join(Dir.tmpdir, "gem-guardian"))
|
|
10
13
|
@client = client
|
|
11
14
|
@cache_dir = cache_dir
|
|
12
15
|
end
|
|
13
16
|
|
|
17
|
+
# Returns the local path for +dependency+, downloading it if needed.
|
|
14
18
|
def path_for(dependency)
|
|
15
19
|
FileUtils.mkdir_p(@cache_dir)
|
|
16
20
|
path = File.join(@cache_dir, dependency.gem_filename)
|
data/lib/gem/guardian/cli.rb
CHANGED
|
@@ -1,60 +1,57 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Namespace for gem-guardian CLI code.
|
|
3
4
|
module Gem
|
|
5
|
+
# Command-line interface and output helpers.
|
|
4
6
|
module Guardian
|
|
7
|
+
# Command-line entry point for gem-guardian.
|
|
5
8
|
class CLI
|
|
9
|
+
# Starts the CLI with the provided argv.
|
|
6
10
|
def self.start(argv)
|
|
7
11
|
new(argv).run
|
|
8
12
|
end
|
|
9
13
|
|
|
10
|
-
def initialize(argv, stdout: $stdout, stderr: $stderr
|
|
14
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, verifier_class: Verifier,
|
|
15
|
+
lockfile_parser_class: LockfileParser)
|
|
11
16
|
@argv = argv.dup
|
|
12
17
|
@stdout = stdout
|
|
13
18
|
@stderr = stderr
|
|
19
|
+
@verifier_class = verifier_class
|
|
20
|
+
@lockfile_parser_class = lockfile_parser_class
|
|
21
|
+
@result_printer = ResultPrinter.new(stdout:)
|
|
14
22
|
end
|
|
15
23
|
|
|
24
|
+
# Dispatches the requested subcommand and returns an exit status.
|
|
16
25
|
def run
|
|
17
|
-
|
|
26
|
+
dispatch(@argv.shift)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def dispatch(command)
|
|
18
32
|
case command
|
|
19
|
-
when "verify"
|
|
20
|
-
|
|
21
|
-
when "
|
|
22
|
-
@stdout.puts VERSION
|
|
23
|
-
0
|
|
24
|
-
when "help", "--help", "-h", nil
|
|
25
|
-
usage
|
|
26
|
-
0
|
|
33
|
+
when "verify" then verify
|
|
34
|
+
when "version", "--version", "-v" then print_version
|
|
35
|
+
when "help", "--help", "-h", nil then usage
|
|
27
36
|
else
|
|
28
|
-
|
|
29
|
-
usage(@stderr)
|
|
30
|
-
2
|
|
37
|
+
unknown_command(command)
|
|
31
38
|
end
|
|
32
39
|
end
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
# Runs the verify subcommand.
|
|
36
42
|
def verify
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
dependencies = if gems.empty?
|
|
40
|
-
LockfileParser.new(lockfile).dependencies
|
|
41
|
-
else
|
|
42
|
-
gems.map { |spec| parse_gem_spec(spec) }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
if dependencies.empty?
|
|
46
|
-
@stderr.puts "No gems found to verify."
|
|
47
|
-
return 1
|
|
48
|
-
end
|
|
43
|
+
lockfile_data, dependencies = resolve_dependencies
|
|
44
|
+
return no_dependencies if dependencies.empty?
|
|
49
45
|
|
|
50
|
-
results =
|
|
51
|
-
|
|
52
|
-
results
|
|
46
|
+
results = verifier_for(lockfile_data).verify_all(dependencies)
|
|
47
|
+
print_verification_report(results, lockfile_data)
|
|
48
|
+
verification_exit_status(results, lockfile_data)
|
|
53
49
|
rescue Error => e
|
|
54
50
|
@stderr.puts e.message
|
|
55
51
|
1
|
|
56
52
|
end
|
|
57
53
|
|
|
54
|
+
# Parses a GEM:VERSION[:PLATFORM] spec string.
|
|
58
55
|
def parse_gem_spec(spec)
|
|
59
56
|
name, version, platform = spec.split(":", 3)
|
|
60
57
|
raise Error, "Expected GEM:VERSION[:PLATFORM], got: #{spec}" if name.to_s.empty? || version.to_s.empty?
|
|
@@ -62,6 +59,50 @@ module Gem
|
|
|
62
59
|
Dependency.new(name:, version:, platform: platform || "ruby")
|
|
63
60
|
end
|
|
64
61
|
|
|
62
|
+
def resolve_dependencies
|
|
63
|
+
lockfile = option_value("--lockfile") || "Gemfile.lock"
|
|
64
|
+
return [nil, @argv.map { |spec| parse_gem_spec(spec) }] unless @argv.empty?
|
|
65
|
+
|
|
66
|
+
lockfile_data = @lockfile_parser_class.new(lockfile).parse
|
|
67
|
+
[lockfile_data, lockfile_data.dependencies]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def verifier_for(lockfile_data)
|
|
71
|
+
expected_checksums = lockfile_data&.sha256_checksums || {}
|
|
72
|
+
@verifier_class.new(expected_checksums:)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_verification_report(results, lockfile_data)
|
|
76
|
+
lockfile_mode = !lockfile_data.nil?
|
|
77
|
+
@result_printer.print_results(results, lockfile_mode:)
|
|
78
|
+
return unless lockfile_data
|
|
79
|
+
|
|
80
|
+
@result_printer.print_lockfile_coverage(lockfile_data)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def verification_exit_status(results, lockfile_data)
|
|
84
|
+
all_ok = results.all?(&:ok?)
|
|
85
|
+
all_covered = lockfile_data.nil? || lockfile_data.missing_checksum_dependencies.empty?
|
|
86
|
+
all_ok && all_covered ? 0 : 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def no_dependencies
|
|
90
|
+
@stderr.puts "No gems found to verify."
|
|
91
|
+
1
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def print_version
|
|
95
|
+
@stdout.puts VERSION
|
|
96
|
+
0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def unknown_command(command)
|
|
100
|
+
@stderr.puts "Unknown command: #{command}"
|
|
101
|
+
usage(@stderr)
|
|
102
|
+
2
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns and removes an option value from argv.
|
|
65
106
|
def option_value(name)
|
|
66
107
|
index = @argv.index(name)
|
|
67
108
|
return unless index
|
|
@@ -73,39 +114,10 @@ module Gem
|
|
|
73
114
|
value
|
|
74
115
|
end
|
|
75
116
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
case result.status
|
|
81
|
-
when :ok
|
|
82
|
-
@stdout.puts "PASS #{label}"
|
|
83
|
-
@stdout.puts " sha256 #{result.actual_sha256}"
|
|
84
|
-
when :mismatch
|
|
85
|
-
@stdout.puts "FAIL #{label}"
|
|
86
|
-
@stdout.puts " expected #{result.expected_sha256}"
|
|
87
|
-
@stdout.puts " actual #{result.actual_sha256}"
|
|
88
|
-
else
|
|
89
|
-
@stdout.puts "ERROR #{label}"
|
|
90
|
-
@stdout.puts " #{result.error.class}: #{result.error.message}"
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def usage(io = @stdout)
|
|
96
|
-
io.puts <<~USAGE
|
|
97
|
-
gem-guardian #{VERSION}
|
|
98
|
-
|
|
99
|
-
Usage:
|
|
100
|
-
gem-guardian verify [--lockfile Gemfile.lock]
|
|
101
|
-
gem-guardian verify GEM:VERSION[:PLATFORM] [GEM:VERSION[:PLATFORM] ...]
|
|
102
|
-
gem-guardian version
|
|
103
|
-
|
|
104
|
-
Examples:
|
|
105
|
-
gem-guardian verify
|
|
106
|
-
gem-guardian verify sidekiq:8.0.8
|
|
107
|
-
gem-guardian verify nokogiri:1.18.9:x86_64-linux
|
|
108
|
-
USAGE
|
|
117
|
+
# Prints usage text.
|
|
118
|
+
def usage(_io = @stdout)
|
|
119
|
+
@result_printer.usage
|
|
120
|
+
0
|
|
109
121
|
end
|
|
110
122
|
end
|
|
111
123
|
end
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Gem
|
|
4
4
|
module Guardian
|
|
5
|
+
# A gem dependency identified by name, version, and platform.
|
|
5
6
|
Dependency = Data.define(:name, :version, :platform) do
|
|
7
|
+
# Returns the canonical .gem filename for this dependency.
|
|
6
8
|
def gem_filename
|
|
7
9
|
platform_suffix = platform && platform != "ruby" ? "-#{platform}" : ""
|
|
8
10
|
"#{name}-#{version}#{platform_suffix}.gem"
|
data/lib/gem/guardian/error.rb
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Gem
|
|
4
4
|
module Guardian
|
|
5
|
+
# Base error type for gem-guardian failures.
|
|
5
6
|
Error = Class.new(StandardError)
|
|
7
|
+
# Raised when RubyGems does not expose a checksum for a gem version.
|
|
6
8
|
ChecksumNotFound = Class.new(Error)
|
|
9
|
+
# Raised when downloading or writing a gem artifact fails.
|
|
7
10
|
ArtifactFetchError = Class.new(Error)
|
|
11
|
+
# Raised when a lockfile cannot be read or parsed.
|
|
8
12
|
LockfileError = Class.new(Error)
|
|
9
13
|
end
|
|
10
14
|
end
|
|
@@ -2,34 +2,116 @@
|
|
|
2
2
|
|
|
3
3
|
module Gem
|
|
4
4
|
module Guardian
|
|
5
|
+
# Parses Gemfile.lock and exposes dependencies and checksum data.
|
|
5
6
|
class LockfileParser
|
|
7
|
+
# Matches dependency lines in the specs section.
|
|
6
8
|
GEM_LINE = /^ {4}([A-Za-z0-9_.-]+) \(([^)]+)\)/
|
|
9
|
+
# Matches checksum lines in the CHECKSUMS section.
|
|
10
|
+
CHECKSUM_LINE = /^ {2}([A-Za-z0-9_.-]+) \(([^)]+)\) (.+)$/
|
|
11
|
+
# Parsed lockfile data for the verify command.
|
|
12
|
+
LockfileData = Data.define(:dependencies, :checksums, :checksums_section_present) do
|
|
13
|
+
# Returns the checksum for +dependency+ and +algorithm+, if present.
|
|
14
|
+
def checksum_for(dependency, algorithm = "sha256")
|
|
15
|
+
checksums.fetch(dependency, {}).fetch(algorithm, nil)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns a dependency => sha256 checksum map.
|
|
19
|
+
def sha256_checksums
|
|
20
|
+
checksums.each_with_object({}) do |(dependency, algorithms), memo|
|
|
21
|
+
digest = algorithms["sha256"]
|
|
22
|
+
memo[dependency] = digest if digest
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns dependencies that do not have a sha256 checksum.
|
|
27
|
+
def missing_checksum_dependencies
|
|
28
|
+
dependencies.reject { |dependency| sha256_checksums.key?(dependency) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns true if the lockfile contained a CHECKSUMS section.
|
|
32
|
+
def checksums_present?
|
|
33
|
+
checksums_section_present
|
|
34
|
+
end
|
|
35
|
+
end
|
|
7
36
|
|
|
8
37
|
def initialize(path = "Gemfile.lock")
|
|
9
38
|
@path = path
|
|
10
39
|
end
|
|
11
40
|
|
|
12
|
-
|
|
41
|
+
# Parses the lockfile into dependencies and checksum metadata.
|
|
42
|
+
def parse
|
|
13
43
|
raise LockfileError, "Lockfile not found: #{@path}" unless File.file?(@path)
|
|
14
44
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
specs_section = false if specs_section && line.match?(/^[A-Z]/)
|
|
19
|
-
next unless specs_section
|
|
20
|
-
|
|
21
|
-
match = GEM_LINE.match(line)
|
|
22
|
-
next unless match
|
|
45
|
+
dependencies = []
|
|
46
|
+
checksums = {}
|
|
47
|
+
section = nil
|
|
23
48
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
49
|
+
File.readlines(@path, chomp: true).each do |line|
|
|
50
|
+
section = section_for(line, section)
|
|
51
|
+
parse_specs_line(line, dependencies) if section == :specs
|
|
52
|
+
parse_checksums_line(line, checksums) if section == :checksums
|
|
28
53
|
end
|
|
54
|
+
|
|
55
|
+
LockfileData.new(dependencies, checksums, checksums.any?)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns the dependencies listed in the lockfile.
|
|
59
|
+
def dependencies
|
|
60
|
+
parse.dependencies
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns the raw checksum map extracted from the lockfile.
|
|
64
|
+
def checksums
|
|
65
|
+
parse.checksums
|
|
29
66
|
end
|
|
30
67
|
|
|
31
68
|
private
|
|
32
69
|
|
|
70
|
+
def section_for(line, current_section)
|
|
71
|
+
case line
|
|
72
|
+
when " specs:"
|
|
73
|
+
:specs
|
|
74
|
+
when "CHECKSUMS"
|
|
75
|
+
:checksums
|
|
76
|
+
when /^[A-Z]/
|
|
77
|
+
nil
|
|
78
|
+
else
|
|
79
|
+
current_section
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_specs_line(line, dependencies)
|
|
84
|
+
match = GEM_LINE.match(line)
|
|
85
|
+
return unless match
|
|
86
|
+
|
|
87
|
+
name = match[1]
|
|
88
|
+
version_and_platform = match[2]
|
|
89
|
+
version, platform = split_version_and_platform(version_and_platform)
|
|
90
|
+
dependencies << Dependency.new(name:, version:, platform:)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_checksums_line(line, checksums)
|
|
94
|
+
match = CHECKSUM_LINE.match(line)
|
|
95
|
+
return unless match
|
|
96
|
+
|
|
97
|
+
name = match[1]
|
|
98
|
+
version_and_platform = match[2]
|
|
99
|
+
checksum_blob = match[3]
|
|
100
|
+
version, platform = split_version_and_platform(version_and_platform)
|
|
101
|
+
dependency = Dependency.new(name:, version:, platform:)
|
|
102
|
+
checksums[dependency] ||= {}
|
|
103
|
+
register_checksum_pairs(checksums[dependency], checksum_blob)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def register_checksum_pairs(checksum_store, checksum_blob)
|
|
107
|
+
checksum_blob.split(",").each do |pair|
|
|
108
|
+
algorithm, digest = pair.split("=", 2).map(&:strip)
|
|
109
|
+
next if algorithm.to_s.empty? || digest.to_s.empty?
|
|
110
|
+
|
|
111
|
+
checksum_store[algorithm] = digest
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
33
115
|
# Bundler renders native platforms as `1.2.3-x86_64-linux` in the spec line.
|
|
34
116
|
# Ruby versions remain plain, for example `1.2.3`.
|
|
35
117
|
def split_version_and_platform(value)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gem
|
|
4
|
+
module Guardian
|
|
5
|
+
# Formats verification results for human-readable CLI output.
|
|
6
|
+
class ResultPrinter
|
|
7
|
+
# @param stdout [IO] output stream for formatted messages
|
|
8
|
+
def initialize(stdout:)
|
|
9
|
+
@stdout = stdout
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Prints a collection of verification results.
|
|
13
|
+
def print_results(results, lockfile_mode:)
|
|
14
|
+
results.each do |result|
|
|
15
|
+
print_result(result, lockfile_mode:)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Prints one verification result.
|
|
20
|
+
def print_result(result, lockfile_mode:)
|
|
21
|
+
label = result_label(result)
|
|
22
|
+
case result.status
|
|
23
|
+
when :ok then print_ok_result(result, label, lockfile_mode)
|
|
24
|
+
when :mismatch then print_mismatch_result(result, label)
|
|
25
|
+
else print_error_result(result, label)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Prints a successful verification result.
|
|
30
|
+
def print_ok_result(result, label, lockfile_mode)
|
|
31
|
+
prefix = lockfile_mode && result.checksum_source == :rubygems ? "FALLBACK" : "PASS"
|
|
32
|
+
@stdout.puts "#{prefix} #{label}"
|
|
33
|
+
@stdout.puts " sha256 #{result.actual_sha256}"
|
|
34
|
+
@stdout.puts " source #{result.checksum_source}" if lockfile_mode && result.checksum_source
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Prints a checksum mismatch.
|
|
38
|
+
def print_mismatch_result(result, label)
|
|
39
|
+
@stdout.puts "FAIL #{label}"
|
|
40
|
+
@stdout.puts " expected #{result.expected_sha256}"
|
|
41
|
+
@stdout.puts " actual #{result.actual_sha256}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Prints an unexpected verifier error.
|
|
45
|
+
def print_error_result(result, label)
|
|
46
|
+
@stdout.puts "ERROR #{label}"
|
|
47
|
+
@stdout.puts " #{result.error.class}: #{result.error.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Prints lockfile checksum coverage.
|
|
51
|
+
def print_lockfile_coverage(lockfile_data)
|
|
52
|
+
covered = lockfile_data.dependencies.size - lockfile_data.missing_checksum_dependencies.size
|
|
53
|
+
total = lockfile_data.dependencies.size
|
|
54
|
+
@stdout.puts "CHECKSUMS coverage: #{covered}/#{total}"
|
|
55
|
+
|
|
56
|
+
lockfile_data.missing_checksum_dependencies.each do |dependency|
|
|
57
|
+
@stdout.puts "MISSING #{dependency.name} #{dependency.version} #{dependency.platform}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Prints the CLI usage text.
|
|
62
|
+
def usage
|
|
63
|
+
@stdout.puts(USAGE)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# CLI usage text.
|
|
67
|
+
USAGE = <<~USAGE.freeze
|
|
68
|
+
gem-guardian #{VERSION}
|
|
69
|
+
|
|
70
|
+
Usage:
|
|
71
|
+
gem-guardian verify [--lockfile Gemfile.lock]
|
|
72
|
+
gem-guardian verify GEM:VERSION[:PLATFORM] [GEM:VERSION[:PLATFORM] ...]
|
|
73
|
+
gem-guardian version
|
|
74
|
+
|
|
75
|
+
Examples:
|
|
76
|
+
gem-guardian verify
|
|
77
|
+
gem-guardian verify sidekiq:8.0.8
|
|
78
|
+
gem-guardian verify nokogiri:1.18.9:x86_64-linux
|
|
79
|
+
USAGE
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def result_label(result)
|
|
84
|
+
dependency = result.dependency
|
|
85
|
+
"#{dependency.name} #{dependency.version} #{dependency.platform}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -6,7 +6,9 @@ require "uri"
|
|
|
6
6
|
|
|
7
7
|
module Gem
|
|
8
8
|
module Guardian
|
|
9
|
+
# Reads checksum metadata from RubyGems.org and downloads gem artifacts.
|
|
9
10
|
class RubygemsClient
|
|
11
|
+
# Default RubyGems.org endpoint used by the client.
|
|
10
12
|
DEFAULT_HOST = "https://rubygems.org"
|
|
11
13
|
|
|
12
14
|
def initialize(host: DEFAULT_HOST, http: Net::HTTP)
|
|
@@ -14,18 +16,19 @@ module Gem
|
|
|
14
16
|
@http = http
|
|
15
17
|
end
|
|
16
18
|
|
|
19
|
+
# Returns the expected SHA256 checksum for +dependency+.
|
|
17
20
|
def expected_sha256(dependency)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
version = matching_version(dependency)
|
|
22
|
+
sha = version && version_checksum(version)
|
|
23
|
+
if blank?(sha)
|
|
24
|
+
raise ChecksumNotFound,
|
|
25
|
+
"No SHA256 found for #{dependency.name} #{dependency.version} #{dependency.platform}"
|
|
21
26
|
end
|
|
22
27
|
|
|
23
|
-
sha = version && (version["sha"] || version["sha256"] || version["checksum"])
|
|
24
|
-
raise ChecksumNotFound, "No SHA256 found for #{dependency.name} #{dependency.version} #{dependency.platform}" if blank?(sha)
|
|
25
|
-
|
|
26
28
|
sha.downcase
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Downloads the .gem file for +dependency+ into +destination+.
|
|
29
32
|
def download_gem(dependency, destination)
|
|
30
33
|
body = get("/downloads/#{dependency.gem_filename}")
|
|
31
34
|
File.binwrite(destination, body)
|
|
@@ -36,6 +39,18 @@ module Gem
|
|
|
36
39
|
|
|
37
40
|
private
|
|
38
41
|
|
|
42
|
+
def matching_version(dependency)
|
|
43
|
+
versions = JSON.parse(get("/api/v1/versions/#{dependency.name}.json"))
|
|
44
|
+
versions.find do |item|
|
|
45
|
+
item["number"] == dependency.version && platform_matches?(item["platform"], dependency.platform)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def version_checksum(version)
|
|
50
|
+
version["sha"] || version["sha256"] || version["checksum"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# GETs +path+ from the configured host and returns the response body.
|
|
39
54
|
def get(path)
|
|
40
55
|
uri = URI("#{@host}#{path}")
|
|
41
56
|
response = @http.get_response(uri)
|
|
@@ -44,12 +59,14 @@ module Gem
|
|
|
44
59
|
raise Error, "GET #{uri} failed with #{response.code} #{response.message}"
|
|
45
60
|
end
|
|
46
61
|
|
|
62
|
+
# Compares a RubyGems platform string with the requested platform.
|
|
47
63
|
def platform_matches?(remote_platform, wanted_platform)
|
|
48
64
|
normalized_remote = remote_platform.to_s.empty? ? "ruby" : remote_platform.to_s
|
|
49
65
|
normalized_wanted = wanted_platform.to_s.empty? ? "ruby" : wanted_platform.to_s
|
|
50
66
|
normalized_remote == normalized_wanted
|
|
51
67
|
end
|
|
52
68
|
|
|
69
|
+
# Returns true when +value+ is nil or empty.
|
|
53
70
|
def blank?(value)
|
|
54
71
|
value.nil? || value.to_s.empty?
|
|
55
72
|
end
|
|
@@ -2,49 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
module Gem
|
|
4
4
|
module Guardian
|
|
5
|
-
|
|
5
|
+
# Result object for a single verification attempt.
|
|
6
|
+
VerificationResult = Data.define(:dependency, :expected_sha256, :actual_sha256, :artifact_path, :status, :error,
|
|
7
|
+
:checksum_source) do
|
|
8
|
+
# Returns true when the verification succeeded.
|
|
6
9
|
def ok?
|
|
7
10
|
status == :ok
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
|
|
14
|
+
# Verifies gem artifacts against an expected checksum source.
|
|
11
15
|
class Verifier
|
|
12
|
-
def initialize(client: RubygemsClient.new, artifact_store: nil)
|
|
16
|
+
def initialize(client: RubygemsClient.new, artifact_store: nil, expected_checksums: {})
|
|
13
17
|
@client = client
|
|
14
18
|
@artifact_store = artifact_store || ArtifactStore.new(client: @client)
|
|
19
|
+
@expected_checksums = expected_checksums
|
|
15
20
|
end
|
|
16
21
|
|
|
22
|
+
# Verifies one dependency and returns a VerificationResult.
|
|
17
23
|
def verify(dependency)
|
|
18
|
-
expected =
|
|
19
|
-
|
|
20
|
-
actual = Checksum.sha256_file(artifact_path)
|
|
21
|
-
status = secure_compare(expected, actual) ? :ok : :mismatch
|
|
22
|
-
|
|
23
|
-
VerificationResult.new(
|
|
24
|
-
dependency:,
|
|
25
|
-
expected_sha256: expected,
|
|
26
|
-
actual_sha256: actual,
|
|
27
|
-
artifact_path:,
|
|
28
|
-
status:,
|
|
29
|
-
error: nil
|
|
30
|
-
)
|
|
24
|
+
expected, checksum_source = expected_sha256_for(dependency)
|
|
25
|
+
build_verification_result(dependency, expected, checksum_source)
|
|
31
26
|
rescue StandardError => e
|
|
27
|
+
build_error_result(dependency, e)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Verifies each dependency in +dependencies+.
|
|
31
|
+
def verify_all(dependencies)
|
|
32
|
+
dependencies.map { |dependency| verify(dependency) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_verification_result(dependency, expected, checksum_source)
|
|
38
|
+
VerificationResult.new(**verification_attributes(dependency, expected, checksum_source))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_error_result(dependency, error)
|
|
32
42
|
VerificationResult.new(
|
|
33
43
|
dependency:,
|
|
34
44
|
expected_sha256: nil,
|
|
35
45
|
actual_sha256: nil,
|
|
36
46
|
artifact_path: nil,
|
|
37
47
|
status: :error,
|
|
38
|
-
error
|
|
48
|
+
error:,
|
|
49
|
+
checksum_source: nil
|
|
39
50
|
)
|
|
40
51
|
end
|
|
41
52
|
|
|
42
|
-
def
|
|
43
|
-
|
|
53
|
+
def verification_attributes(dependency, expected, checksum_source)
|
|
54
|
+
artifact_path = @artifact_store.path_for(dependency)
|
|
55
|
+
actual = Checksum.sha256_file(artifact_path)
|
|
56
|
+
{ dependency:, expected_sha256: expected, actual_sha256: actual, artifact_path:,
|
|
57
|
+
status: secure_compare(expected, actual) ? :ok : :mismatch, error: nil,
|
|
58
|
+
checksum_source: }
|
|
44
59
|
end
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
# Constant-time comparison for checksum strings.
|
|
48
62
|
def secure_compare(left, right)
|
|
49
63
|
left = left.to_s
|
|
50
64
|
right = right.to_s
|
|
@@ -52,6 +66,15 @@ module Gem
|
|
|
52
66
|
|
|
53
67
|
left.bytes.zip(right.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b) }.zero?
|
|
54
68
|
end
|
|
69
|
+
|
|
70
|
+
# Uses lockfile checksums first and falls back to RubyGems metadata.
|
|
71
|
+
def expected_sha256_for(dependency)
|
|
72
|
+
if @expected_checksums.key?(dependency)
|
|
73
|
+
[@expected_checksums.fetch(dependency), :lockfile]
|
|
74
|
+
else
|
|
75
|
+
[@client.expected_sha256(dependency), :rubygems]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
55
78
|
end
|
|
56
79
|
end
|
|
57
80
|
end
|
data/lib/gem/guardian/version.rb
CHANGED
data/lib/gem/guardian.rb
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# Gem Guardian provides small, explicit verification and audit helpers for Ruby gems.
|
|
2
|
+
#
|
|
3
|
+
# The library is intentionally organized as a set of focused objects rather than a
|
|
4
|
+
# framework so the CLI, tests, and signatures stay easy to reason about.
|
|
1
5
|
# frozen_string_literal: true
|
|
2
6
|
|
|
3
7
|
require_relative "guardian/version"
|
|
@@ -8,4 +12,5 @@ require_relative "guardian/lockfile_parser"
|
|
|
8
12
|
require_relative "guardian/rubygems_client"
|
|
9
13
|
require_relative "guardian/artifact_store"
|
|
10
14
|
require_relative "guardian/verifier"
|
|
15
|
+
require_relative "guardian/result_printer"
|
|
11
16
|
require_relative "guardian/cli"
|
data/sig/gem/guardian/cli.rbs
CHANGED
|
@@ -27,3 +27,34 @@ module Gem
|
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
|
+
module Gem
|
|
31
|
+
module Guardian
|
|
32
|
+
class CLI
|
|
33
|
+
@argv: untyped
|
|
34
|
+
|
|
35
|
+
@stdout: untyped
|
|
36
|
+
|
|
37
|
+
@stderr: untyped
|
|
38
|
+
|
|
39
|
+
def self.start: (untyped argv) -> untyped
|
|
40
|
+
|
|
41
|
+
def initialize: (untyped argv, ?stdout: untyped, ?stderr: untyped, ?verifier_class: untyped, ?lockfile_parser_class: untyped) -> void
|
|
42
|
+
|
|
43
|
+
def run: () -> untyped
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def verify: () -> untyped
|
|
48
|
+
|
|
49
|
+
def parse_gem_spec: (String spec) -> Dependency
|
|
50
|
+
|
|
51
|
+
def option_value: (String name) -> (String?)
|
|
52
|
+
|
|
53
|
+
def print_results: (untyped results, lockfile_mode: bool) -> void
|
|
54
|
+
|
|
55
|
+
def print_lockfile_coverage: (untyped lockfile_data) -> void
|
|
56
|
+
|
|
57
|
+
def usage: (?untyped io) -> void
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/sig/gem/guardian/error.rbs
CHANGED
|
@@ -9,3 +9,18 @@ module Gem
|
|
|
9
9
|
LockfileError: untyped
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
|
+
module Gem
|
|
13
|
+
module Guardian
|
|
14
|
+
class Error < StandardError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class ChecksumNotFound < Error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class ArtifactFetchError < Error
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class LockfileError < Error
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -17,3 +17,39 @@ module Gem
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
|
+
module Gem
|
|
21
|
+
module Guardian
|
|
22
|
+
class LockfileParser
|
|
23
|
+
GEM_LINE: ::Regexp
|
|
24
|
+
CHECKSUM_LINE: ::Regexp
|
|
25
|
+
|
|
26
|
+
class LockfileData
|
|
27
|
+
attr_reader dependencies: ::Array[Dependency]
|
|
28
|
+
|
|
29
|
+
attr_reader checksums: ::Hash[Dependency, ::Hash[String, String]]
|
|
30
|
+
|
|
31
|
+
attr_reader checksums_section_present: bool
|
|
32
|
+
|
|
33
|
+
def checksum_for: (Dependency dependency, ?String algorithm) -> String?
|
|
34
|
+
|
|
35
|
+
def sha256_checksums: () -> ::Hash[Dependency, String]
|
|
36
|
+
|
|
37
|
+
def missing_checksum_dependencies: () -> ::Array[Dependency]
|
|
38
|
+
|
|
39
|
+
def checksums_present?: () -> bool
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize: (?String path) -> void
|
|
43
|
+
|
|
44
|
+
def parse: () -> LockfileData
|
|
45
|
+
|
|
46
|
+
def dependencies: () -> ::Array[Dependency]
|
|
47
|
+
|
|
48
|
+
def checksums: () -> ::Hash[Dependency, ::Hash[String, String]]
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def split_version_and_platform: (String value) -> ::Array[String]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -23,3 +23,24 @@ module Gem
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
|
+
module Gem
|
|
27
|
+
module Guardian
|
|
28
|
+
class RubygemsClient
|
|
29
|
+
DEFAULT_HOST: String
|
|
30
|
+
|
|
31
|
+
def initialize: (?host: String, ?http: untyped) -> void
|
|
32
|
+
|
|
33
|
+
def expected_sha256: (Dependency dependency) -> String
|
|
34
|
+
|
|
35
|
+
def download_gem: (Dependency dependency, String destination) -> String
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def get: (String path) -> String
|
|
40
|
+
|
|
41
|
+
def platform_matches?: (untyped remote_platform, untyped wanted_platform) -> bool
|
|
42
|
+
|
|
43
|
+
def blank?: (untyped value) -> bool
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -19,3 +19,22 @@ module Gem
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
|
+
module Gem
|
|
23
|
+
module Guardian
|
|
24
|
+
VerificationResult: untyped
|
|
25
|
+
|
|
26
|
+
class Verifier
|
|
27
|
+
def initialize: (?client: RubygemsClient, ?artifact_store: ArtifactStore?, ?expected_checksums: ::Hash[Dependency, String]) -> void
|
|
28
|
+
|
|
29
|
+
def verify: (Dependency dependency) -> untyped
|
|
30
|
+
|
|
31
|
+
def verify_all: (::Array[Dependency] dependencies) -> ::Array[untyped]
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def secure_compare: (String left, String right) -> bool
|
|
36
|
+
|
|
37
|
+
def expected_sha256_for: (Dependency dependency) -> ::Array[untyped]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/sig/gem/guardian.rbs
CHANGED
metadata
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gem-guardian
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kenneth Demanawa
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies: []
|
|
13
|
-
description:
|
|
14
|
-
|
|
12
|
+
description: 'Audits Bundler checksum coverage and verifies Ruby gem artifacts against
|
|
13
|
+
RubyGems SHA256 checksums when needed.
|
|
14
|
+
|
|
15
|
+
'
|
|
15
16
|
email:
|
|
16
17
|
- kenneth.c.demanawa@gmail.com
|
|
17
18
|
executables:
|
|
@@ -20,9 +21,12 @@ extensions: []
|
|
|
20
21
|
extra_rdoc_files: []
|
|
21
22
|
files:
|
|
22
23
|
- ".github/workflows/main.yml"
|
|
24
|
+
- ".github/workflows/pages.yml"
|
|
25
|
+
- ".github/workflows/release.yml"
|
|
23
26
|
- ".gitignore"
|
|
24
27
|
- ".rubocop.yml"
|
|
25
28
|
- ".ruby-version"
|
|
29
|
+
- ".yardopts"
|
|
26
30
|
- CHANGELOG.md
|
|
27
31
|
- CODE_OF_CONDUCT.md
|
|
28
32
|
- Gemfile
|
|
@@ -40,6 +44,7 @@ files:
|
|
|
40
44
|
- lib/gem/guardian/dependency.rb
|
|
41
45
|
- lib/gem/guardian/error.rb
|
|
42
46
|
- lib/gem/guardian/lockfile_parser.rb
|
|
47
|
+
- lib/gem/guardian/result_printer.rb
|
|
43
48
|
- lib/gem/guardian/rubygems_client.rb
|
|
44
49
|
- lib/gem/guardian/verifier.rb
|
|
45
50
|
- lib/gem/guardian/version.rb
|
|
@@ -62,7 +67,6 @@ metadata:
|
|
|
62
67
|
source_code_uri: https://github.com/kanutocd/gem-guardian
|
|
63
68
|
changelog_uri: https://github.com/kanutocd/gem-guardian/blob/main/CHANGELOG.md
|
|
64
69
|
rubygems_mfa_required: 'true'
|
|
65
|
-
post_install_message:
|
|
66
70
|
rdoc_options: []
|
|
67
71
|
require_paths:
|
|
68
72
|
- lib
|
|
@@ -77,8 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
77
81
|
- !ruby/object:Gem::Version
|
|
78
82
|
version: '0'
|
|
79
83
|
requirements: []
|
|
80
|
-
rubygems_version: 3.
|
|
81
|
-
signing_key:
|
|
84
|
+
rubygems_version: 3.6.9
|
|
82
85
|
specification_version: 4
|
|
83
86
|
summary: Consumer-side integrity verification for Ruby gems.
|
|
84
87
|
test_files: []
|