hooks-ruby 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae1757617b84df588de7eb343799b93d1dfc6326c3d54ab1a2d578aa2d3e3b8e
4
- data.tar.gz: a39cfa8162346d536ab5a543dfcf7517162282ba3a74098cd276bc76c636a41c
3
+ metadata.gz: efc93a22af173a3085d2af45a0787501cf4e520e0aa917c7b4b8ee477a477820
4
+ data.tar.gz: 06c6177f4356d06e59ceaf2f7e9c62848752ee1a5d298981c2820c1732906424
5
5
  SHA512:
6
- metadata.gz: da5353fda88b8d348e7b743e155642376ad886b5e77a38cb08148a9a6fbb5bece88c099125bb4738d41a09e3d3e0b5c29bd317bafa3789caa595d02f1c7217a2
7
- data.tar.gz: 0ba9c613e80a889da7b1fd7b6b4b84b53190aae6db27184d7caa0a90806c3d266f0fff06c4bff1cef702c0879f772056fde5c1b697d817852130167eb0724ebd
6
+ metadata.gz: e4bed95c7e1f41cd7188847773b83e1d7e1a463122545cc5c0330348539f81b2ed254c9e7ff161b2922b8f064ec46174288b3557be4c2d63604405ab931adb0d
7
+ data.tar.gz: fe7cdde0b508f5bcf1e354f6c3b2583530ad27f27d63686712d05830581b849fc2d8e61cad5ba1dd8d4283e182b823625d4e164bd9fa4909ae6be7be3d0ce360
data/README.md CHANGED
@@ -50,9 +50,11 @@ Here is a very high-level overview of how Hooks works:
50
50
  ```yaml
51
51
  # file: config/endpoints/hello.yml
52
52
  path: /hello
53
- handler: MyCustomHandler # This is a custom handler plugin you would define in the plugins/handlers directory
53
+ handler: my_custom_handler # This is a custom handler plugin you would define in the plugins/handlers directory (snake_case)
54
54
  ```
55
55
 
56
+ > Note: If your handler's class name is `MyCustomHandler`, you would define it in the `plugins/handlers/my_custom_handler.rb` file. The `handler` field in the endpoint configuration file should be the snake_case version of the class name. So if your handler class is `MyCustomHandler`, you would use `my_custom_handler` in the endpoint configuration file.
57
+
56
58
  3. Now create a corresponding handler plugin in the `plugins/handlers` directory. Here is an example of a simple handler plugin:
57
59
 
58
60
  ```ruby
@@ -64,7 +66,7 @@ Here is a very high-level overview of how Hooks works:
64
66
  # For this example, we will just return a success message
65
67
  {
66
68
  status: "success",
67
- handler: "MyCustomHandler",
69
+ handler: "my_custom_handler",
68
70
  payload_received: payload,
69
71
  timestamp: Time.now.utc.iso8601
70
72
  }
@@ -208,16 +210,16 @@ Endpoint configurations are defined in the `config/endpoints` directory. Each en
208
210
  ```yaml
209
211
  # file: config/endpoints/hello.yml
210
212
  path: /hello # becomes /webhooks/hello based on the root_path in hooks.yml
211
- handler: HelloHandler # This is a custom handler plugin you would define in the plugins/handlers
213
+ handler: hello_handler # This is a custom handler plugin you would define in the plugins/handlers
212
214
  ```
213
215
 
214
216
  ```yaml
215
217
  # file: config/endpoints/goodbye.yml
216
218
  path: /goodbye # becomes /webhooks/goodbye based on the root_path in hooks.yml
217
- handler: GoodbyeHandler # This is another custom handler plugin you would define in the plugins/handlers
219
+ handler: goodbye_handler # This is another custom handler plugin you would define in the plugins/handlers
218
220
 
219
221
  auth:
220
- type: Goodbye # This is a custom authentication plugin you would define in the plugins/auth
222
+ type: goodbye # This is a custom authentication plugin you would define in the plugins/auth
221
223
  secret_env_key: GOODBYE_API_KEY # the name of the environment variable containing the secret
222
224
  header: Authorization
223
225
 
@@ -255,7 +257,7 @@ class GoodbyeHandler < Hooks::Plugins::Handlers::Base
255
257
  # Ditto for the goodbye endpoint
256
258
  {
257
259
  message: "goodbye webhook processed successfully",
258
- handler: "GoodbyeHandler",
260
+ handler: "goodbye_handler",
259
261
  timestamp: Time.now.utc.iso8601
260
262
  }
261
263
  end
data/lib/hooks/app/api.rb CHANGED
@@ -4,6 +4,7 @@ require "grape"
4
4
  require "json"
5
5
  require "securerandom"
6
6
  require_relative "helpers"
7
+ #require_relative "network/ip_filtering"
7
8
  require_relative "auth/auth"
8
9
  require_relative "rack_env_builder"
9
10
  require_relative "../plugins/handlers/base"
@@ -82,6 +83,13 @@ module Hooks
82
83
  plugin.on_request(rack_env)
83
84
  end
84
85
 
86
+ # TODO: IP filtering before processing the request if defined
87
+ # If IP filtering is enabled at either global or endpoint level, run the filtering rules
88
+ # before processing the request
89
+ #if config[:ip_filtering] || endpoint_config[:ip_filtering]
90
+ #ip_filtering!(headers, endpoint_config, config, request_context, rack_env)
91
+ #end
92
+
85
93
  enforce_request_limits(config, request_context)
86
94
  request.body.rewind
87
95
  raw_body = request.body.read
@@ -74,7 +74,7 @@ module Hooks
74
74
 
75
75
  # Load handler class
76
76
  #
77
- # @param handler_class_name [String] The name of the handler class to load
77
+ # @param handler_class_name [String] The name of the handler in snake_case (e.g., "github_handler")
78
78
  # @return [Object] An instance of the loaded handler class
79
79
  # @raise [StandardError] If handler cannot be found
80
80
  def load_handler(handler_class_name)
@@ -72,6 +72,8 @@ module Hooks
72
72
  end
73
73
 
74
74
  # Add HTTP headers to the environment with proper Rack naming convention
75
+ # Note: This will generally add headers like HTTP_X_CUSTOM_HEADER. For example, the HTTP_X_FORWARDED_FOR
76
+ # is a common header that is used to pass the original client IP address through proxies.
75
77
  #
76
78
  # @param rack_env [Hash] Environment hash to modify
77
79
  def add_http_headers(rack_env)
@@ -45,6 +45,11 @@ module Hooks
45
45
  optional(:format).filled(:string)
46
46
  optional(:version_prefix).filled(:string)
47
47
  optional(:payload_template).filled(:string)
48
+ optional(:header_format).filled(:string)
49
+ optional(:signature_key).filled(:string)
50
+ optional(:timestamp_key).filled(:string)
51
+ optional(:structured_header_separator).filled(:string)
52
+ optional(:key_value_separator).filled(:string)
48
53
  end
49
54
 
50
55
  optional(:opts).hash
@@ -120,11 +125,12 @@ module Hooks
120
125
  # Must not be empty or only whitespace
121
126
  return false if handler_name.strip.empty?
122
127
 
123
- # Must match a safe pattern: alphanumeric + underscore, starting with uppercase
124
- return false unless handler_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
128
+ # Must match strict snake_case pattern: starts with lowercase, no trailing/consecutive underscores
129
+ return false unless handler_name.match?(/\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/)
125
130
 
126
- # Must not be a system/built-in class name
127
- return false if Hooks::Security::DANGEROUS_CLASSES.include?(handler_name)
131
+ # Convert to PascalCase for security check (since DANGEROUS_CLASSES uses PascalCase)
132
+ pascal_case_name = handler_name.split("_").map(&:capitalize).join("")
133
+ return false if Hooks::Security::DANGEROUS_CLASSES.include?(pascal_case_name)
128
134
 
129
135
  true
130
136
  end
@@ -61,11 +61,13 @@ module Hooks
61
61
 
62
62
  # Get handler plugin class by name
63
63
  #
64
- # @param handler_name [String] Name of the handler (e.g., "DefaultHandler", "Team1Handler")
64
+ # @param handler_name [String] Name of the handler in snake_case (e.g., "github_handler", "team_1_handler")
65
65
  # @return [Class] The handler plugin class
66
66
  # @raise [StandardError] if handler not found
67
67
  def get_handler_plugin(handler_name)
68
- plugin_class = @handler_plugins[handler_name]
68
+ # Convert snake_case to PascalCase for registry lookup
69
+ pascal_case_name = handler_name.split("_").map(&:capitalize).join("")
70
+ plugin_class = @handler_plugins[pascal_case_name]
69
71
 
70
72
  unless plugin_class
71
73
  raise StandardError, "Handler plugin '#{handler_name}' not found. Available handlers: #{@handler_plugins.keys.join(', ')}"
@@ -4,6 +4,7 @@ require "rack/utils"
4
4
  require_relative "../../core/log"
5
5
  require_relative "../../core/global_components"
6
6
  require_relative "../../core/component_access"
7
+ require_relative "timestamp_validator"
7
8
 
8
9
  module Hooks
9
10
  module Plugins
@@ -14,6 +15,10 @@ module Hooks
14
15
  class Base
15
16
  extend Hooks::Core::ComponentAccess
16
17
 
18
+ # Security constants shared across auth validators
19
+ MAX_HEADER_VALUE_LENGTH = ENV.fetch("HOOKS_MAX_HEADER_VALUE_LENGTH", 1024).to_i # Prevent DoS attacks via large header values
20
+ MAX_PAYLOAD_SIZE = ENV.fetch("HOOKS_MAX_PAYLOAD_SIZE", 10 * 1024 * 1024).to_i # 10MB limit for payload validation
21
+
17
22
  # Validate request
18
23
  #
19
24
  # @param payload [String] Raw request body
@@ -49,6 +54,13 @@ module Hooks
49
54
  return secret.strip
50
55
  end
51
56
 
57
+ # Get timestamp validator instance
58
+ #
59
+ # @return [TimestampValidator] Singleton timestamp validator instance
60
+ def self.timestamp_validator
61
+ TimestampValidator.new
62
+ end
63
+
52
64
  # Find a header value by name with case-insensitive matching
53
65
  #
54
66
  # @param headers [Hash] HTTP headers from the request
@@ -67,6 +79,61 @@ module Hooks
67
79
  end
68
80
  nil
69
81
  end
82
+
83
+ # Validate headers object for security issues
84
+ #
85
+ # @param headers [Object] Headers to validate
86
+ # @return [Boolean] true if headers are valid
87
+ def self.valid_headers?(headers)
88
+ unless headers.respond_to?(:each)
89
+ log.warn("Auth validation failed: Invalid headers object")
90
+ return false
91
+ end
92
+ true
93
+ end
94
+
95
+ # Validate payload size for security issues
96
+ #
97
+ # @param payload [String] Payload to validate
98
+ # @return [Boolean] true if payload is valid
99
+ def self.valid_payload_size?(payload)
100
+ return true if payload.nil?
101
+
102
+ if payload.bytesize > MAX_PAYLOAD_SIZE
103
+ log.warn("Auth validation failed: Payload size exceeds maximum limit of #{MAX_PAYLOAD_SIZE} bytes")
104
+ return false
105
+ end
106
+ true
107
+ end
108
+
109
+ # Validate header value for security issues
110
+ #
111
+ # @param header_value [String] Header value to validate
112
+ # @param header_name [String] Header name for logging
113
+ # @return [Boolean] true if header value is valid
114
+ def self.valid_header_value?(header_value, header_name)
115
+ return false if header_value.nil? || header_value.empty?
116
+
117
+ # Check length to prevent DoS
118
+ if header_value.length > MAX_HEADER_VALUE_LENGTH
119
+ log.warn("Auth validation failed: #{header_name} exceeds maximum length")
120
+ return false
121
+ end
122
+
123
+ # Check for whitespace tampering
124
+ if header_value != header_value.strip
125
+ log.warn("Auth validation failed: #{header_name} contains leading/trailing whitespace")
126
+ return false
127
+ end
128
+
129
+ # Check for control characters
130
+ if header_value.match?(/[\u0000-\u001f\u007f-\u009f]/)
131
+ log.warn("Auth validation failed: #{header_name} contains control characters")
132
+ return false
133
+ end
134
+
135
+ true
136
+ end
70
137
  end
71
138
  end
72
139
  end
@@ -3,7 +3,6 @@
3
3
  require "openssl"
4
4
  require "time"
5
5
  require_relative "base"
6
- require_relative "timestamp_validator"
7
6
 
8
7
  module Hooks
9
8
  module Plugins
@@ -32,7 +31,23 @@ module Hooks
32
31
  # format: "version=signature"
33
32
  # version_prefix: "v0"
34
33
  # payload_template: "{version}:{timestamp}:{body}"
34
+ #
35
+ # @example Configuration for Tailscale-style structured headers
36
+ # auth:
37
+ # type: HMAC
38
+ # secret_env_key: WEBHOOK_SECRET
39
+ # header: Tailscale-Webhook-Signature
40
+ # algorithm: sha256
41
+ # format: "signature_only"
42
+ # header_format: "structured"
43
+ # signature_key: "v1"
44
+ # timestamp_key: "t"
45
+ # payload_template: "{timestamp}.{body}"
46
+ # timestamp_tolerance: 300 # 5 minutes
35
47
  class HMAC < Base
48
+ # Security constants
49
+ MAX_SIGNATURE_LENGTH = ENV.fetch("HOOKS_MAX_SIGNATURE_LENGTH", 1024).to_i # Prevent DoS attacks via large signatures
50
+
36
51
  # Default configuration values for HMAC validation
37
52
  #
38
53
  # @return [Hash<Symbol, String|Integer>] Default configuration settings
@@ -42,7 +57,8 @@ module Hooks
42
57
  format: "algorithm=signature", # Format: algorithm=hash
43
58
  header: "X-Signature", # Default header containing the signature
44
59
  timestamp_tolerance: 300, # 5 minutes tolerance for timestamp validation
45
- version_prefix: "v0" # Default version prefix for versioned signatures
60
+ version_prefix: "v0", # Default version prefix for versioned signatures
61
+ header_format: "simple" # Header format: "simple" or "structured"
46
62
  }.freeze
47
63
 
48
64
  # Mapping of signature format strings to internal format symbols
@@ -75,6 +91,11 @@ module Hooks
75
91
  # @option config [String] :format ('algorithm=signature') Signature format
76
92
  # @option config [String] :version_prefix ('v0') Version prefix for versioned signatures
77
93
  # @option config [String] :payload_template Template for payload construction
94
+ # @option config [String] :header_format ('simple') Header format: 'simple' or 'structured'
95
+ # @option config [String] :signature_key ('v1') Key for signature in structured headers
96
+ # @option config [String] :timestamp_key ('t') Key for timestamp in structured headers
97
+ # @option config [String] :structured_header_separator (',') Separator for structured headers
98
+ # @option config [String] :key_value_separator ('=') Separator for key-value pairs in structured headers
78
99
  # @return [Boolean] true if signature is valid, false otherwise
79
100
  # @raise [StandardError] Rescued internally, returns false on any error
80
101
  # @note This method is designed to be safe and will never raise exceptions
@@ -91,11 +112,9 @@ module Hooks
91
112
 
92
113
  validator_config = build_config(config)
93
114
 
94
- # Security: Check raw headers BEFORE normalization to detect tampering
95
- unless headers.respond_to?(:each)
96
- log.warn("Auth::HMAC validation failed: Invalid headers object")
97
- return false
98
- end
115
+ # Security: Check raw headers and payload BEFORE processing
116
+ return false unless valid_headers?(headers)
117
+ return false unless valid_payload_size?(payload)
99
118
 
100
119
  signature_header = validator_config[:header]
101
120
 
@@ -107,23 +126,37 @@ module Hooks
107
126
  return false
108
127
  end
109
128
 
110
- # Security: Reject signatures with leading/trailing whitespace
111
- if raw_signature != raw_signature.strip
112
- log.warn("Auth::HMAC validation failed: Signature contains leading/trailing whitespace")
113
- return false
114
- end
115
-
116
- # Security: Reject signatures containing null bytes or other control characters
117
- if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/)
118
- log.warn("Auth::HMAC validation failed: Signature contains control characters")
119
- return false
120
- end
129
+ # Validate signature format using shared validation but with HMAC-specific length limit
130
+ return false unless validate_signature_format(raw_signature)
121
131
 
122
132
  # Now we can safely normalize headers for the rest of the validation
123
133
  normalized_headers = normalize_headers(headers)
124
- provided_signature = normalized_headers[signature_header.downcase]
134
+
135
+ # Handle structured headers (e.g., Tailscale format: "t=123,v1=abc")
136
+ if validator_config[:header_format] == "structured"
137
+ parsed_signature_data = parse_structured_header(raw_signature, validator_config)
138
+ if parsed_signature_data.nil?
139
+ log.warn("Auth::HMAC validation failed: Could not parse structured signature header")
140
+ return false
141
+ end
142
+
143
+ provided_signature = parsed_signature_data[:signature]
144
+
145
+ # For structured headers, timestamp comes from the signature header itself
146
+ if parsed_signature_data[:timestamp]
147
+ normalized_headers = normalized_headers.merge(
148
+ "extracted_timestamp" => parsed_signature_data[:timestamp]
149
+ )
150
+ # Override timestamp_header to use our extracted timestamp
151
+ validator_config = validator_config.merge(timestamp_header: "extracted_timestamp")
152
+ end
153
+ else
154
+ provided_signature = normalized_headers[signature_header.downcase]
155
+ end
125
156
 
126
157
  # Validate timestamp if required (for services that include timestamp validation)
158
+ # It should be noted that not all HMAC implementations require timestamp validation,
159
+ # so this is optional based on configuration.
127
160
  if validator_config[:timestamp_header]
128
161
  unless valid_timestamp?(normalized_headers, validator_config)
129
162
  log.warn("Auth::HMAC validation failed: Invalid timestamp")
@@ -154,6 +187,22 @@ module Hooks
154
187
 
155
188
  private
156
189
 
190
+ # Validate signature format for HMAC (uses HMAC-specific length limit)
191
+ #
192
+ # @param signature [String] Raw signature to validate
193
+ # @return [Boolean] true if signature is valid
194
+ # @api private
195
+ def self.validate_signature_format(signature)
196
+ # Check signature length with HMAC-specific limit
197
+ if signature.length > MAX_SIGNATURE_LENGTH
198
+ log.warn("Auth::HMAC validation failed: Signature length exceeds maximum limit of #{MAX_SIGNATURE_LENGTH} characters")
199
+ return false
200
+ end
201
+
202
+ # Use shared validation for other checks
203
+ valid_header_value?(signature, "Signature")
204
+ end
205
+
157
206
  # Build final configuration by merging defaults with provided config
158
207
  #
159
208
  # Combines default configuration values with user-provided settings,
@@ -176,7 +225,12 @@ module Hooks
176
225
  algorithm: algorithm,
177
226
  format: validator_config[:format] || DEFAULT_CONFIG[:format],
178
227
  version_prefix: validator_config[:version_prefix] || DEFAULT_CONFIG[:version_prefix],
179
- payload_template: validator_config[:payload_template]
228
+ payload_template: validator_config[:payload_template],
229
+ header_format: validator_config[:header_format] || DEFAULT_CONFIG[:header_format],
230
+ signature_key: validator_config[:signature_key] || "v1",
231
+ timestamp_key: validator_config[:timestamp_key] || "t",
232
+ structured_header_separator: validator_config[:structured_header_separator] || ",",
233
+ key_value_separator: validator_config[:key_value_separator] || "="
180
234
  })
181
235
  end
182
236
 
@@ -216,14 +270,6 @@ module Hooks
216
270
  timestamp_validator.valid?(timestamp_value, tolerance)
217
271
  end
218
272
 
219
- # Get timestamp validator instance
220
- #
221
- # @return [TimestampValidator] Singleton timestamp validator instance
222
- # @api private
223
- def self.timestamp_validator
224
- @timestamp_validator ||= TimestampValidator.new
225
- end
226
-
227
273
  # Compute HMAC signature based on configuration requirements
228
274
  #
229
275
  # Generates the expected HMAC signature for the given payload using the
@@ -321,6 +367,44 @@ module Hooks
321
367
  "#{config[:algorithm]}=#{hash}"
322
368
  end
323
369
  end
370
+
371
+ # Parse structured signature header containing comma-separated key-value pairs
372
+ #
373
+ # Parses signature headers like "t=1663781880,v1=0123456789abcdef..." used by
374
+ # providers like Tailscale that include multiple values in a single header.
375
+ #
376
+ # @param header_value [String] Raw signature header value
377
+ # @param config [Hash<Symbol, Object>] Validator configuration
378
+ # @return [Hash<Symbol, String>, nil] Parsed data with :signature and :timestamp keys, or nil if parsing fails
379
+ # @note Returns nil if the header format is invalid or required keys are missing
380
+ # @api private
381
+ def self.parse_structured_header(header_value, config)
382
+ signature_key = config[:signature_key]
383
+ timestamp_key = config[:timestamp_key]
384
+ separator = config[:structured_header_separator]
385
+ key_value_separator = config[:key_value_separator]
386
+
387
+ # Parse comma-separated key-value pairs
388
+ pairs = {}
389
+ header_value.split(separator).each do |pair|
390
+ key, value = pair.split(key_value_separator, 2)
391
+ return nil if key.nil? || value.nil?
392
+
393
+ pairs[key.strip] = value.strip
394
+ end
395
+
396
+ # Extract required signature
397
+ signature = pairs[signature_key]
398
+ return nil if signature.nil? || signature.empty?
399
+
400
+ result = { signature: signature }
401
+
402
+ # Extract optional timestamp
403
+ timestamp = pairs[timestamp_key]
404
+ result[:timestamp] = timestamp if timestamp && !timestamp.empty?
405
+
406
+ result
407
+ end
324
408
  end
325
409
  end
326
410
  end
@@ -61,11 +61,9 @@ module Hooks
61
61
 
62
62
  validator_config = build_config(config)
63
63
 
64
- # Security: Check raw headers BEFORE normalization to detect tampering
65
- unless headers.respond_to?(:each)
66
- log.warn("Auth::SharedSecret validation failed: Invalid headers object")
67
- return false
68
- end
64
+ # Security: Check raw headers and payload BEFORE processing
65
+ return false unless valid_headers?(headers)
66
+ return false unless valid_payload_size?(payload)
69
67
 
70
68
  secret_header = validator_config[:header]
71
69
 
@@ -77,19 +75,13 @@ module Hooks
77
75
  return false
78
76
  end
79
77
 
80
- stripped_secret = raw_secret.strip
81
-
82
- # Security: Reject secrets with leading/trailing whitespace
83
- if raw_secret != stripped_secret
84
- log.warn("Auth::SharedSecret validation failed: Secret contains leading/trailing whitespace")
78
+ # Validate secret format using shared validation
79
+ unless valid_header_value?(raw_secret, "Secret")
80
+ log.warn("Auth::SharedSecret validation failed: Invalid secret format")
85
81
  return false
86
82
  end
87
83
 
88
- # Security: Reject secrets containing null bytes or other control characters
89
- if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/)
90
- log.warn("Auth::SharedSecret validation failed: Secret contains control characters")
91
- return false
92
- end
84
+ stripped_secret = raw_secret.strip
93
85
 
94
86
  # Use secure comparison to prevent timing attacks
95
87
  result = Rack::Utils.secure_compare(secret, stripped_secret)
@@ -106,12 +98,6 @@ module Hooks
106
98
 
107
99
  private
108
100
 
109
- # Short logger accessor for auth module
110
- # @return [Hooks::Log] Logger instance
111
- def self.log
112
- Hooks::Log.instance
113
- end
114
-
115
101
  # Build final configuration by merging defaults with provided config
116
102
  #
117
103
  # Combines default configuration values with user-provided settings,
data/lib/hooks/version.rb CHANGED
@@ -4,5 +4,5 @@
4
4
  module Hooks
5
5
  # Current version of the Hooks webhook framework
6
6
  # @return [String] The version string following semantic versioning
7
- VERSION = "0.2.0".freeze
7
+ VERSION = "0.3.0".freeze
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hooks-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - github