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.
@@ -1,12 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "openssl"
4
5
  require "net/http"
5
6
  require "uri"
6
7
 
7
8
  module Gem
8
9
  module Guardian
10
+ # Reads checksum metadata from RubyGems.org and downloads gem artifacts.
11
+ # rubocop:disable Metrics/ClassLength
9
12
  class RubygemsClient
13
+ # Trusted Publishing provenance metadata extracted from RubyGems version data.
14
+ TrustedPublishingProvenance = Data.define(
15
+ :trusted_publishing, :repository, :ref, :workflow, :issuer, :subject, :sha256, :attestation_url
16
+ )
17
+
18
+ SOURCE_COMMIT_PATTERN = %r{Source Commit\s+([A-Za-z0-9._/-]+@[A-Za-z0-9._-]+)}i
19
+ BUILD_FILE_PATTERN = /Build File\s+([^\s]+)/i
20
+ LOG_ENTRY_PATTERN = %r{transparency log entry\s*(https?://[^\s]+)}i
21
+ SHA256_PATTERN = /SHA 256 checksum\s*([a-f0-9]{64})/i
22
+ WORKFLOW_PATTERN = /
23
+ Built and signed on\s+
24
+ ([A-Za-z0-9 ._-]+?)
25
+ (?:\s+Build summary|\s+Source Commit|\z)
26
+ /ix
27
+
28
+ # Default RubyGems.org endpoint used by the client.
10
29
  DEFAULT_HOST = "https://rubygems.org"
11
30
 
12
31
  def initialize(host: DEFAULT_HOST, http: Net::HTTP)
@@ -14,18 +33,27 @@ module Gem
14
33
  @http = http
15
34
  end
16
35
 
36
+ # Returns the expected SHA256 checksum for +dependency+.
17
37
  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)
38
+ version = matching_version(dependency)
39
+ sha = version && version_checksum(version)
40
+ if blank?(sha)
41
+ raise ChecksumNotFound,
42
+ "No SHA256 found for #{dependency.name} #{dependency.version} #{dependency.platform}"
21
43
  end
22
44
 
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
45
  sha.downcase
27
46
  end
28
47
 
48
+ # Returns trusted publishing provenance data for +dependency+ when RubyGems exposes it.
49
+ def trusted_publishing_provenance(dependency)
50
+ version = matching_version(dependency)
51
+ version && provenance_for(version) ||
52
+ attestation_api_provenance(dependency) ||
53
+ version_page_provenance(dependency)
54
+ end
55
+
56
+ # Downloads the .gem file for +dependency+ into +destination+.
29
57
  def download_gem(dependency, destination)
30
58
  body = get("/downloads/#{dependency.gem_filename}")
31
59
  File.binwrite(destination, body)
@@ -36,6 +64,239 @@ module Gem
36
64
 
37
65
  private
38
66
 
67
+ def matching_version(dependency)
68
+ versions = JSON.parse(get("/api/v1/versions/#{dependency.name}.json"))
69
+ versions.find do |item|
70
+ item["number"] == dependency.version && platform_matches?(item["platform"], dependency.platform)
71
+ end
72
+ end
73
+
74
+ def version_checksum(version)
75
+ version["sha"] || version["sha256"] || version["checksum"]
76
+ end
77
+
78
+ # Extracts trusted publishing provenance data from a RubyGems version payload.
79
+ def provenance_for(version)
80
+ provenance = provenance_payload(version)
81
+ return unless provenance.any?
82
+
83
+ TrustedPublishingProvenance.new(**provenance_attributes(provenance).merge(trusted_publishing: true))
84
+ end
85
+
86
+ # Reads provenance details from the RubyGems version page HTML.
87
+ def version_page_provenance(dependency)
88
+ html = get("/gems/#{dependency.name}/versions/#{dependency.version}")
89
+ provenance = html_provenance_payload(html)
90
+ return unless provenance
91
+
92
+ TrustedPublishingProvenance.new(**provenance.merge(trusted_publishing: true))
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ # Reads provenance details from the RubyGems attestations API.
98
+ def attestation_api_provenance(dependency)
99
+ attestation_id = dependency.gem_filename.delete_suffix(".gem")
100
+ attestations = JSON.parse(get("/api/v1/attestations/#{attestation_id}.json"))
101
+ attestations.each do |attestation|
102
+ provenance = attestation_bundle_provenance(attestation)
103
+ return TrustedPublishingProvenance.new(**provenance.merge(trusted_publishing: true)) if provenance
104
+ end
105
+ nil
106
+ rescue StandardError
107
+ nil
108
+ end
109
+
110
+ # Returns the provenance payload from a version hash.
111
+ def provenance_payload(version)
112
+ payload = version["provenance"] || version["trusted_publishing"] || version["attestation"]
113
+ payload = deep_find_provenance_hash(version) unless payload.is_a?(Hash)
114
+ payload = version if payload.nil? && trusted_publishing_flag?(version)
115
+ payload.is_a?(Hash) ? payload : {}
116
+ end
117
+
118
+ # Returns the first non-empty provenance string value for the provided keys.
119
+ def provenance_string(provenance, *keys)
120
+ keys.map { |key| provenance[key] }.find { |value| !blank?(value) }&.to_s
121
+ end
122
+
123
+ # Returns the extracted provenance attributes.
124
+ def provenance_attributes(provenance)
125
+ {
126
+ repository: provenance_string(provenance, "repository", "repository_url", "source_repository"),
127
+ ref: provenance_string(provenance, "ref", "source_ref", "git_ref", "tag", "source_commit"),
128
+ workflow: provenance_string(provenance, "workflow", "workflow_name", "build_file"),
129
+ issuer: provenance_string(provenance, "issuer"),
130
+ subject: provenance_string(provenance, "subject"),
131
+ sha256: provenance_string(provenance, "sha256", "checksum", "digest"),
132
+ attestation_url: provenance_string(provenance, "attestation_url", "provenance_url", "url",
133
+ "transparency_log_entry")
134
+ }
135
+ end
136
+
137
+ # Extracts provenance metadata from the visible RubyGems HTML page text.
138
+ # rubocop:disable Metrics/MethodLength
139
+ def html_provenance_payload(html)
140
+ text = normalized_text(html)
141
+ source_commit = capture_text(text, SOURCE_COMMIT_PATTERN)
142
+ build_file = capture_text(text, BUILD_FILE_PATTERN)
143
+ log_entry = capture_text(text, LOG_ENTRY_PATTERN)
144
+ sha256 = capture_text(text, SHA256_PATTERN)
145
+ workflow = capture_text(text, WORKFLOW_PATTERN)
146
+ return unless source_commit || build_file || log_entry || sha256 || workflow
147
+
148
+ repository, ref = parse_source_commit(source_commit)
149
+ {
150
+ repository:,
151
+ ref:,
152
+ workflow: workflow || "GitHub Actions",
153
+ issuer: "GitHub Actions",
154
+ subject: source_commit,
155
+ sha256: sha256,
156
+ attestation_url: log_entry
157
+ }
158
+ end
159
+ # rubocop:enable Metrics/MethodLength
160
+
161
+ # Extracts provenance metadata from a Sigstore attestation bundle.
162
+ def attestation_bundle_provenance(attestation)
163
+ bundle = attestation.is_a?(Hash) ? attestation : JSON.parse(attestation.to_s)
164
+ certificate = find_certificate(bundle)
165
+ return unless certificate
166
+
167
+ parse_attestation_certificate(certificate)
168
+ end
169
+
170
+ # Returns a provenance hash extracted from a leaf certificate.
171
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
172
+ def parse_attestation_certificate(certificate)
173
+ cert = certificate.is_a?(OpenSSL::X509::Certificate) ? certificate : OpenSSL::X509::Certificate.new(certificate)
174
+ extensions = cert.extensions.each_with_object({}) do |ext, memo|
175
+ memo[ext.oid] = ext.value
176
+ end
177
+
178
+ repo = extensions["1.3.6.1.4.1.57264.1.5"]
179
+ commit = extensions["1.3.6.1.4.1.57264.1.3"]
180
+ ref = extensions["1.3.6.1.4.1.57264.1.14"]
181
+ build_summary_url = extensions["1.3.6.1.4.1.57264.1.21"]
182
+ san = extensions["subjectAltName"]
183
+ build_file = build_file_from_subject_alt_name(san, repo, ref)
184
+
185
+ return unless repo || commit || ref || build_summary_url || build_file
186
+
187
+ {
188
+ repository: normalize_repository(repo),
189
+ ref: commit || ref,
190
+ workflow: build_file || build_summary_url,
191
+ issuer: "https://token.actions.githubusercontent.com",
192
+ subject: [repo, commit].compact.join("@"),
193
+ sha256: nil,
194
+ attestation_url: build_summary_url
195
+ }
196
+ rescue OpenSSL::X509::CertificateError, OpenSSL::ASN1::ASN1Error
197
+ nil
198
+ end
199
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
200
+
201
+ # Finds the first certificate-like payload in a nested attestation bundle.
202
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
203
+ def find_certificate(value)
204
+ case value
205
+ when String
206
+ return value if value.include?("BEGIN CERTIFICATE")
207
+ when Hash
208
+ value.each_value do |child|
209
+ found = find_certificate(child)
210
+ return found if found
211
+ end
212
+ when Array
213
+ value.each do |child|
214
+ found = find_certificate(child)
215
+ return found if found
216
+ end
217
+ end
218
+ nil
219
+ end
220
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
221
+
222
+ # Extracts the workflow file path from the SAN extension.
223
+ def build_file_from_subject_alt_name(san, repo, ref)
224
+ return unless san && repo && ref
225
+
226
+ match = san.match(%r{\AURI:https://github\.com/#{Regexp.escape(repo)}/(.+)@#{Regexp.escape(ref)}\z})
227
+ match && match[1]
228
+ end
229
+
230
+ # Normalizes the repository value to a full GitHub URL.
231
+ def normalize_repository(repository)
232
+ return if blank?(repository)
233
+
234
+ repository = repository.to_s
235
+ repository.start_with?("http") ? repository : "https://github.com/#{repository}"
236
+ end
237
+
238
+ # Returns a text-only version of the RubyGems HTML page.
239
+ def normalized_text(html)
240
+ html.to_s
241
+ .gsub(%r{<script.*?</script>}m, " ")
242
+ .gsub(%r{<style.*?</style>}m, " ")
243
+ .gsub(/<[^>]+>/, " ")
244
+ .gsub(/&nbsp;/, " ")
245
+ .gsub(/&amp;/, "&")
246
+ .gsub(/\s+/, " ")
247
+ end
248
+
249
+ # Captures the first matching string from +text+ for +pattern+.
250
+ def capture_text(text, pattern)
251
+ match = text.match(pattern)
252
+ match && match[1].to_s.strip
253
+ end
254
+
255
+ # Returns repository and ref values from a source commit string.
256
+ def parse_source_commit(source_commit)
257
+ return [nil, nil] if blank?(source_commit)
258
+
259
+ repository, ref = source_commit.split("@", 2)
260
+ [repository.start_with?("http") ? repository : "https://github.com/#{repository}", ref]
261
+ end
262
+
263
+ # Finds a nested provenance hash inside a RubyGems version payload.
264
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
265
+ def deep_find_provenance_hash(value)
266
+ case value
267
+ when Hash
268
+ return value if provenance_hash?(value)
269
+
270
+ value.each_value do |child|
271
+ found = deep_find_provenance_hash(child)
272
+ return found if found
273
+ end
274
+ when Array
275
+ value.each do |child|
276
+ found = deep_find_provenance_hash(child)
277
+ return found if found
278
+ end
279
+ end
280
+ nil
281
+ end
282
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
283
+
284
+ # Returns true when +value+ looks like a provenance record.
285
+ def provenance_hash?(value)
286
+ (value.keys & %w[
287
+ repository repository_url source_repository ref source_ref git_ref tag source_commit workflow
288
+ workflow_name build_file issuer subject sha256 checksum digest attestation_url provenance_url url
289
+ transparency_log_entry
290
+ ]).any?
291
+ end
292
+
293
+ # Returns true when the version payload advertises Trusted Publishing.
294
+ def trusted_publishing_flag?(version)
295
+ value = version["trusted_publishing"]
296
+ value == true || value.to_s.casecmp("true").zero?
297
+ end
298
+
299
+ # GETs +path+ from the configured host and returns the response body.
39
300
  def get(path)
40
301
  uri = URI("#{@host}#{path}")
41
302
  response = @http.get_response(uri)
@@ -44,15 +305,18 @@ module Gem
44
305
  raise Error, "GET #{uri} failed with #{response.code} #{response.message}"
45
306
  end
46
307
 
308
+ # Compares a RubyGems platform string with the requested platform.
47
309
  def platform_matches?(remote_platform, wanted_platform)
48
310
  normalized_remote = remote_platform.to_s.empty? ? "ruby" : remote_platform.to_s
49
311
  normalized_wanted = wanted_platform.to_s.empty? ? "ruby" : wanted_platform.to_s
50
312
  normalized_remote == normalized_wanted
51
313
  end
52
314
 
315
+ # Returns true when +value+ is nil or empty.
53
316
  def blank?(value)
54
317
  value.nil? || value.to_s.empty?
55
318
  end
56
319
  end
320
+ # rubocop:enable Metrics/ClassLength
57
321
  end
58
322
  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.2.0"
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,7 @@ 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/provenance_verifier"
16
+ require_relative "guardian/report_builder"
17
+ require_relative "guardian/result_printer"
11
18
  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