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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e343f954f0d514206e5bcc10d935a094d916ee1456cbb4774a113c909e879dc
4
- data.tar.gz: 2230036e48e13af43b32090d1cbe4b933785b5407fda0f8376bebd41763615fe
3
+ metadata.gz: ed534bc229391d74fb2b3ee945127ad3690236f3c27d28fe1aaf28a909c19a8c
4
+ data.tar.gz: 4c30325c72ba731d6a9e5dc5e2f2ea3a08bf5147c8a29e02132f5bd9ec9fecac
5
5
  SHA512:
6
- metadata.gz: 36bf27a64fb281d8e50a81a9efdadc5e83ee694e81e79cb46c36799f443568f917934001f10568a1f417740688cabd204aa26154fd791145416b7d25c3d837dc
7
- data.tar.gz: 9e5331025935a3a5d007b2f684e59110cf0bbe79deaacb1e93b976aa06075ea9e52f7199c3ff92b2c90c5317b9da7c5eb03e796724b2966a1f23c37c738426e3
6
+ metadata.gz: e13ac5e36ffeb46ff9d2fa027d4a6af02b648389a9e7981a50ea9a28264aa32d8473ac2c07a748b002f24bb59339c10da138b405fa5ac9239346caec2e816fe9
7
+ data.tar.gz: ffcc44114da845142bff58537d06970479d2694d0a91c52ee21f00a3a0e695cc8d5f7ec8aee0fb8b296495dbd2078fd76b15944f17371330e03b532feb1d5ed4
@@ -17,7 +17,10 @@ jobs:
17
17
  strategy:
18
18
  matrix:
19
19
  ruby:
20
- - '3.2.11'
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 default task
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
@@ -8,3 +8,4 @@
8
8
  /tmp/
9
9
  Gemfile.lock
10
10
  *.gem
11
+ /.ignoreme/
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
@@ -0,0 +1,7 @@
1
+ --title "gem-guardian API Documentation"
2
+ --protected
3
+ --markup markdown
4
+ --readme README.md
5
+ 'lib/**/*.rb'
6
+ -
7
+ --files CHANGELOG.md LICENSE.txt CODE_OF_CONDUCT.md
data/CHANGELOG.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0
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 downloaded `.gem` artifacts against the SHA256 checksum reported by RubyGems.org. It is intentionally small: no Bundler monkeypatching, no install hooks, and no custom publishing flow required.
11
+ `gem-guardian` audits Bundler checksum coverage 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 modern Bundler can store checksums in `Gemfile.lock`. But there is still room for a simple consumer-side verification workflow that can be run explicitly in CI or locally.
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 MVP verifies:
17
+ This v0.1.0 scope is:
18
18
 
19
19
  ```text
20
- Gemfile.lock / explicit gem version
20
+ Gemfile.lock
21
21
 
22
- RubyGems.org expected SHA256
22
+ CHECKSUMS coverage audit
23
23
 
24
- Downloaded .gem artifact
24
+ RubyGems.org checksum comparison when needed
25
25
 
26
- Local SHA256 comparison
26
+ Actionable report for CI or local review
27
27
  ```
28
28
 
29
- This proves that the local artifact matches what RubyGems.org serves. It does **not** yet prove source provenance such as signed tag → CI build → published gem.
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
- - Uses RubyGems.org as the checksum source of truth.
76
- - Downloads artifacts from RubyGems.org `/downloads/<gem-file>.gem`.
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
- - `gem-guardian lock` to emit or update checksum metadata.
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
- - Machine-readable JSON output for CI.
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 steep check"
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 = "Verifies Ruby gem artifacts against RubyGems SHA256 checksums using Gemfile.lock or explicit gem names."
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)
@@ -4,9 +4,11 @@ require "digest"
4
4
 
5
5
  module Gem
6
6
  module Guardian
7
+ # Local checksum helpers.
7
8
  module Checksum
8
9
  module_function
9
10
 
11
+ # Returns the SHA256 hex digest for the file at +path+.
10
12
  def sha256_file(path)
11
13
  Digest::SHA256.file(path).hexdigest
12
14
  end
@@ -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
- command = @argv.shift
26
+ dispatch(@argv.shift)
27
+ end
28
+
29
+ private
30
+
31
+ def dispatch(command)
18
32
  case command
19
- when "verify"
20
- verify
21
- when "version", "--version", "-v"
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
- @stderr.puts "Unknown command: #{command}"
29
- usage(@stderr)
30
- 2
37
+ unknown_command(command)
31
38
  end
32
39
  end
33
40
 
34
- private
35
-
41
+ # Runs the verify subcommand.
36
42
  def verify
37
- lockfile = option_value("--lockfile") || "Gemfile.lock"
38
- gems = @argv
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 = Verifier.new.verify_all(dependencies)
51
- print_results(results)
52
- results.all?(&:ok?) ? 0 : 1
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
- def print_results(results)
77
- results.each do |result|
78
- dependency = result.dependency
79
- label = "#{dependency.name} #{dependency.version} #{dependency.platform}"
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"
@@ -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
- def dependencies
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
- specs_section = false
16
- File.readlines(@path, chomp: true).filter_map do |line|
17
- specs_section = true if line == " specs:"
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
- name = match[1]
25
- version_and_platform = match[2]
26
- version, platform = split_version_and_platform(version_and_platform)
27
- Dependency.new(name:, version:, platform:)
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
- versions = JSON.parse(get("/api/v1/versions/#{dependency.name}.json"))
19
- version = versions.find do |item|
20
- item["number"] == dependency.version && platform_matches?(item["platform"], dependency.platform)
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
- VerificationResult = Data.define(:dependency, :expected_sha256, :actual_sha256, :artifact_path, :status, :error) do
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 = @client.expected_sha256(dependency)
19
- artifact_path = @artifact_store.path_for(dependency)
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: e
48
+ error:,
49
+ checksum_source: nil
39
50
  )
40
51
  end
41
52
 
42
- def verify_all(dependencies)
43
- dependencies.map { |dependency| verify(dependency) }
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
- private
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Gem
4
4
  module Guardian
5
- VERSION = "0.1.0"
5
+ # gem-guardian version.
6
+ VERSION = "0.1.1"
6
7
  end
7
8
  end
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"
@@ -11,3 +11,12 @@ module Gem
11
11
  end
12
12
  end
13
13
  end
14
+ module Gem
15
+ module Guardian
16
+ class ArtifactStore
17
+ def initialize: (client: RubygemsClient, ?cache_dir: String) -> void
18
+
19
+ def path_for: (Dependency dependency) -> String
20
+ end
21
+ end
22
+ end
@@ -5,3 +5,10 @@ module Gem
5
5
  end
6
6
  end
7
7
  end
8
+ module Gem
9
+ module Guardian
10
+ module Checksum
11
+ def self.sha256_file: (String path) -> String
12
+ end
13
+ end
14
+ end
@@ -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
@@ -3,3 +3,16 @@ module Gem
3
3
  Dependency: untyped
4
4
  end
5
5
  end
6
+ module Gem
7
+ module Guardian
8
+ class Dependency
9
+ attr_reader name: String
10
+
11
+ attr_reader version: String
12
+
13
+ attr_reader platform: String?
14
+
15
+ def gem_filename: () -> String
16
+ end
17
+ end
18
+ end
@@ -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
@@ -3,3 +3,8 @@ module Gem
3
3
  VERSION: "0.1.0"
4
4
  end
5
5
  end
6
+ module Gem
7
+ module Guardian
8
+ VERSION: String
9
+ end
10
+ end
data/sig/gem/guardian.rbs CHANGED
@@ -0,0 +1,4 @@
1
+ module Gem
2
+ module Guardian
3
+ end
4
+ end
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.0
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: 2026-06-12 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
- description: Verifies Ruby gem artifacts against RubyGems SHA256 checksums using Gemfile.lock
14
- or explicit gem names.
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.4.19
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: []