gem-guardian 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gem
4
+ module Guardian
5
+ # Result object for a provenance verification attempt.
6
+ ProvenanceResult = Data.define(
7
+ :dependency, :status, :trusted_publishing, :repository, :ref, :workflow, :issuer, :subject,
8
+ :expected_sha256, :actual_sha256, :error, :attestation_url
9
+ ) do
10
+ # Returns true when provenance verification succeeded.
11
+ def verified?
12
+ status == :verified
13
+ end
14
+ end
15
+
16
+ # Verifies RubyGems Trusted Publishing provenance metadata.
17
+ class ProvenanceVerifier
18
+ def initialize(client: RubygemsClient.new)
19
+ @client = client
20
+ end
21
+
22
+ # Verifies Trusted Publishing provenance for +dependency+.
23
+ def verify(dependency, artifact_sha256: nil)
24
+ provenance = @client.trusted_publishing_provenance(dependency)
25
+ return unsupported_result(dependency) unless provenance
26
+
27
+ build_result(dependency, provenance, artifact_sha256)
28
+ rescue StandardError => e
29
+ error_result(dependency, artifact_sha256, e)
30
+ end
31
+
32
+ # Verifies provenance for each dependency-result pair.
33
+ def verify_all(results)
34
+ results.map { |result| verify(result.dependency, artifact_sha256: result.actual_sha256) }
35
+ end
36
+
37
+ private
38
+
39
+ # rubocop:disable Metrics/MethodLength
40
+ def build_result(dependency, provenance, artifact_sha256)
41
+ ProvenanceResult.new(**result_attributes(
42
+ dependency, provenance, artifact_sha256, provenance_status(provenance, artifact_sha256)
43
+ ))
44
+ end
45
+
46
+ def unsupported_result(dependency)
47
+ ProvenanceResult.new(**result_attributes(dependency, nil, nil, :unsupported))
48
+ end
49
+
50
+ def error_result(dependency, artifact_sha256, error)
51
+ ProvenanceResult.new(**result_attributes(dependency, nil, artifact_sha256, :error, error))
52
+ end
53
+
54
+ def result_attributes(dependency, provenance, artifact_sha256, status, error = nil)
55
+ {
56
+ dependency:,
57
+ status:,
58
+ trusted_publishing: provenance&.trusted_publishing,
59
+ repository: provenance&.repository,
60
+ ref: provenance&.ref,
61
+ workflow: provenance&.workflow,
62
+ issuer: provenance&.issuer,
63
+ subject: provenance&.subject,
64
+ expected_sha256: provenance&.sha256,
65
+ actual_sha256: artifact_sha256,
66
+ error:,
67
+ attestation_url: provenance&.attestation_url
68
+ }
69
+ end
70
+ # rubocop:enable Metrics/MethodLength
71
+
72
+ def provenance_status(provenance, artifact_sha256)
73
+ return :unsupported unless provenance.trusted_publishing
74
+ return :verified unless provenance.sha256 && artifact_sha256
75
+
76
+ secure_compare(provenance.sha256, artifact_sha256) ? :verified : :mismatch
77
+ end
78
+
79
+ def secure_compare(left, right)
80
+ left = left.to_s
81
+ right = right.to_s
82
+ return false unless left.bytesize == right.bytesize
83
+
84
+ left.bytes.zip(right.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b) }.zero?
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gem
4
+ module Guardian
5
+ # Builds machine-readable verification reports.
6
+ class ReportBuilder
7
+ # @param version [String] gem-guardian version string
8
+ def initialize(version:)
9
+ @version = version
10
+ end
11
+
12
+ # Returns a JSON-friendly hash for the current verification run.
13
+ def build(results, lockfile_data:, provenance_results: [], lockfile_path: nil)
14
+ {
15
+ version: @version,
16
+ command: "verify",
17
+ mode: lockfile_data ? "lockfile" : "explicit",
18
+ lockfile: lockfile_path,
19
+ checksums: checksum_summary(lockfile_data),
20
+ results: results.map.with_index { |result, index| build_result(result, provenance_results[index]) }
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def checksum_summary(lockfile_data)
27
+ return nil unless lockfile_data
28
+
29
+ {
30
+ coverage: {
31
+ covered: lockfile_data.dependencies.size - lockfile_data.missing_checksum_dependencies.size,
32
+ total: lockfile_data.dependencies.size
33
+ },
34
+ missing: lockfile_data.missing_checksum_dependencies.map do |dependency|
35
+ dependency_hash(dependency)
36
+ end
37
+ }
38
+ end
39
+
40
+ def build_result(result, provenance_result)
41
+ dependency_hash(result.dependency).merge(
42
+ checksum: checksum_hash(result)
43
+ ).tap do |hash|
44
+ hash[:provenance] = provenance_hash(provenance_result) if provenance_result
45
+ end
46
+ end
47
+
48
+ def dependency_hash(dependency)
49
+ {
50
+ name: dependency.name,
51
+ version: dependency.version,
52
+ platform: dependency.platform
53
+ }
54
+ end
55
+
56
+ def provenance_hash(result)
57
+ provenance_fields(result).merge(
58
+ error: error_hash(result.error)
59
+ )
60
+ end
61
+
62
+ # Returns the non-error provenance fields.
63
+ # rubocop:disable Metrics/MethodLength
64
+ def provenance_fields(result)
65
+ {
66
+ status: result.status,
67
+ trusted_publishing: result.trusted_publishing,
68
+ repository: result.repository,
69
+ ref: result.ref,
70
+ workflow: result.workflow,
71
+ issuer: result.issuer,
72
+ subject: result.subject,
73
+ expected_sha256: result.expected_sha256,
74
+ actual_sha256: result.actual_sha256,
75
+ attestation_url: result.attestation_url
76
+ }
77
+ end
78
+ # rubocop:enable Metrics/MethodLength
79
+
80
+ # Returns the checksum payload for a verification result.
81
+ def checksum_hash(result)
82
+ {
83
+ status: result.status,
84
+ expected_sha256: result.expected_sha256,
85
+ actual_sha256: result.actual_sha256,
86
+ artifact_path: result.artifact_path,
87
+ checksum_source: result.checksum_source,
88
+ error: error_hash(result.error)
89
+ }
90
+ end
91
+
92
+ def error_hash(error)
93
+ return nil unless error
94
+
95
+ { class: error.class.name, message: error.message }
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gem
4
+ module Guardian
5
+ # Formats verification results for human-readable CLI output.
6
+ # rubocop:disable Metrics/ClassLength
7
+ class ResultPrinter
8
+ # @param stdout [IO] output stream for formatted messages
9
+ def initialize(stdout:)
10
+ @stdout = stdout
11
+ end
12
+
13
+ # Prints a collection of verification results.
14
+ def print_results(results, lockfile_mode:)
15
+ results.each do |result|
16
+ print_result(result, lockfile_mode:)
17
+ end
18
+ end
19
+
20
+ # Prints one verification result.
21
+ def print_result(result, lockfile_mode:)
22
+ label = result_label(result)
23
+ case result.status
24
+ when :ok then print_ok_result(result, label, lockfile_mode)
25
+ when :mismatch then print_mismatch_result(result, label)
26
+ else print_error_result(result, label)
27
+ end
28
+ end
29
+
30
+ # Prints a successful verification result.
31
+ def print_ok_result(result, label, lockfile_mode)
32
+ prefix = lockfile_mode && result.checksum_source == :rubygems ? "FALLBACK" : "PASS"
33
+ @stdout.puts "#{prefix} #{label}"
34
+ @stdout.puts " sha256 #{result.actual_sha256}"
35
+ @stdout.puts " source #{result.checksum_source}" if lockfile_mode && result.checksum_source
36
+ end
37
+
38
+ # Prints a checksum mismatch.
39
+ def print_mismatch_result(result, label)
40
+ @stdout.puts "FAIL #{label}"
41
+ @stdout.puts " expected #{result.expected_sha256}"
42
+ @stdout.puts " actual #{result.actual_sha256}"
43
+ end
44
+
45
+ # Prints an unexpected verifier error.
46
+ def print_error_result(result, label)
47
+ @stdout.puts "ERROR #{label}"
48
+ @stdout.puts " #{result.error.class}: #{result.error.message}"
49
+ end
50
+
51
+ # Prints lockfile checksum coverage.
52
+ def print_lockfile_coverage(lockfile_data)
53
+ covered = lockfile_data.dependencies.size - lockfile_data.missing_checksum_dependencies.size
54
+ total = lockfile_data.dependencies.size
55
+ @stdout.puts "CHECKSUMS coverage: #{covered}/#{total}"
56
+
57
+ lockfile_data.missing_checksum_dependencies.each do |dependency|
58
+ @stdout.puts "MISSING #{dependency.name} #{dependency.version} #{dependency.platform}"
59
+ end
60
+ end
61
+
62
+ # Prints provenance verification results.
63
+ def print_provenance_results(results)
64
+ results.each do |result|
65
+ print_provenance_result(result)
66
+ end
67
+ end
68
+
69
+ # Prints one provenance verification result.
70
+ def print_provenance_result(result)
71
+ label = result_label(result)
72
+ case result.status
73
+ when :verified then print_verified_provenance_result(result, label)
74
+ when :mismatch then print_mismatched_provenance_result(result, label)
75
+ else print_unsupported_provenance_result(result, label)
76
+ end
77
+ end
78
+
79
+ # Prints a successful provenance verification result.
80
+ def print_verified_provenance_result(result, label)
81
+ @stdout.puts "PROVENANCE PASS #{label}"
82
+ @stdout.puts " source trusted-publishing"
83
+ provenance_fields(result).each do |label_name, value|
84
+ @stdout.puts format_provenance_field(label_name, value) if value
85
+ end
86
+ end
87
+
88
+ # Prints a provenance checksum mismatch.
89
+ def print_mismatched_provenance_result(result, label)
90
+ @stdout.puts "PROVENANCE FAIL #{label}"
91
+ @stdout.puts " expected #{result.expected_sha256}"
92
+ @stdout.puts " actual #{result.actual_sha256}"
93
+ end
94
+
95
+ # Prints a provenance result when no trusted publishing data is available.
96
+ def print_unsupported_provenance_result(_result, label)
97
+ @stdout.puts "PROVENANCE UNSUPPORTED #{label}"
98
+ end
99
+
100
+ # Prints the CLI usage text.
101
+ def usage
102
+ @stdout.puts(USAGE)
103
+ end
104
+
105
+ # CLI usage text.
106
+ USAGE = <<~USAGE.freeze
107
+ gem-guardian #{VERSION}
108
+
109
+ Usage:
110
+ gem-guardian verify [--lockfile Gemfile.lock] [--json] [--provenance]
111
+ gem-guardian verify GEM:VERSION[:PLATFORM] [GEM:VERSION[:PLATFORM] ...]
112
+ gem-guardian version
113
+ gem-guardian help
114
+
115
+ Examples:
116
+ gem-guardian verify
117
+ gem-guardian verify sidekiq:8.1.6
118
+ gem-guardian verify cdc-sidekiq:0.1.1
119
+ gem-guardian verify nokogiri:1.18.9:x86_64-linux
120
+ gem-guardian verify --json --provenance ratomic:0.4.1
121
+ USAGE
122
+
123
+ private
124
+
125
+ def result_label(result)
126
+ dependency = result.dependency
127
+ "#{dependency.name} #{dependency.version} #{dependency.platform}"
128
+ end
129
+
130
+ # Returns the provenance fields to render for a verified result.
131
+ def provenance_fields(result)
132
+ [
133
+ ["repository", result.repository],
134
+ ["workflow", result.workflow],
135
+ ["ref", result.ref],
136
+ ["issuer", result.issuer],
137
+ ["subject", result.subject],
138
+ ["sha256", result.expected_sha256],
139
+ ["attestation", result.attestation_url]
140
+ ]
141
+ end
142
+
143
+ # Formats one provenance field line.
144
+ def format_provenance_field(label, value)
145
+ format("%<label>11s %<value>s", label:, value:)
146
+ end
147
+ end
148
+ # rubocop:enable Metrics/ClassLength
149
+ end
150
+ end