hooks-ruby 0.0.2

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.
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../security"
5
+
6
+ module Hooks
7
+ module Core
8
+ # Loads and caches all plugins (auth + handlers) at boot time
9
+ class PluginLoader
10
+ # Class-level registries for loaded plugins
11
+ @auth_plugins = {}
12
+ @handler_plugins = {}
13
+
14
+ class << self
15
+ attr_reader :auth_plugins, :handler_plugins
16
+
17
+ # Load all plugins at boot time
18
+ #
19
+ # @param config [Hash] Global configuration containing plugin directories
20
+ # @return [void]
21
+ def load_all_plugins(config)
22
+ # Clear existing registries
23
+ @auth_plugins = {}
24
+ @handler_plugins = {}
25
+
26
+ # Load built-in plugins first
27
+ load_builtin_plugins
28
+
29
+ # Load custom plugins if directories are configured
30
+ load_custom_auth_plugins(config[:auth_plugin_dir]) if config[:auth_plugin_dir]
31
+ load_custom_handler_plugins(config[:handler_plugin_dir]) if config[:handler_plugin_dir]
32
+
33
+ # Log loaded plugins
34
+ log_loaded_plugins
35
+ end
36
+
37
+ # Get auth plugin class by name
38
+ #
39
+ # @param plugin_name [String] Name of the auth plugin (e.g., "hmac", "shared_secret", "custom_auth")
40
+ # @return [Class] The auth plugin class
41
+ # @raise [StandardError] if plugin not found
42
+ def get_auth_plugin(plugin_name)
43
+ plugin_key = plugin_name.downcase
44
+ plugin_class = @auth_plugins[plugin_key]
45
+
46
+ unless plugin_class
47
+ raise StandardError, "Auth plugin '#{plugin_name}' not found. Available plugins: #{@auth_plugins.keys.join(', ')}"
48
+ end
49
+
50
+ plugin_class
51
+ end
52
+
53
+ # Get handler plugin class by name
54
+ #
55
+ # @param handler_name [String] Name of the handler (e.g., "DefaultHandler", "Team1Handler")
56
+ # @return [Class] The handler plugin class
57
+ # @raise [StandardError] if handler not found
58
+ def get_handler_plugin(handler_name)
59
+ plugin_class = @handler_plugins[handler_name]
60
+
61
+ unless plugin_class
62
+ raise StandardError, "Handler plugin '#{handler_name}' not found. Available handlers: #{@handler_plugins.keys.join(', ')}"
63
+ end
64
+
65
+ plugin_class
66
+ end
67
+
68
+ # Clear all loaded plugins (for testing purposes)
69
+ #
70
+ # @return [void]
71
+ def clear_plugins
72
+ @auth_plugins = {}
73
+ @handler_plugins = {}
74
+ end
75
+
76
+ private
77
+
78
+ # Load built-in plugins into registries
79
+ #
80
+ # @return [void]
81
+ def load_builtin_plugins
82
+ # Load built-in auth plugins
83
+ @auth_plugins["hmac"] = Hooks::Plugins::Auth::HMAC
84
+ @auth_plugins["shared_secret"] = Hooks::Plugins::Auth::SharedSecret
85
+
86
+ # Load built-in handler plugins
87
+ @handler_plugins["DefaultHandler"] = DefaultHandler
88
+ end
89
+
90
+ # Load custom auth plugins from directory
91
+ #
92
+ # @param auth_plugin_dir [String] Directory containing custom auth plugins
93
+ # @return [void]
94
+ def load_custom_auth_plugins(auth_plugin_dir)
95
+ return unless auth_plugin_dir && Dir.exist?(auth_plugin_dir)
96
+
97
+ Dir.glob(File.join(auth_plugin_dir, "*.rb")).sort.each do |file_path|
98
+ begin
99
+ load_custom_auth_plugin(file_path, auth_plugin_dir)
100
+ rescue => e
101
+ raise StandardError, "Failed to load auth plugin from #{file_path}: #{e.message}"
102
+ end
103
+ end
104
+ end
105
+
106
+ # Load custom handler plugins from directory
107
+ #
108
+ # @param handler_plugin_dir [String] Directory containing custom handler plugins
109
+ # @return [void]
110
+ def load_custom_handler_plugins(handler_plugin_dir)
111
+ return unless handler_plugin_dir && Dir.exist?(handler_plugin_dir)
112
+
113
+ Dir.glob(File.join(handler_plugin_dir, "*.rb")).sort.each do |file_path|
114
+ begin
115
+ load_custom_handler_plugin(file_path, handler_plugin_dir)
116
+ rescue => e
117
+ raise StandardError, "Failed to load handler plugin from #{file_path}: #{e.message}"
118
+ end
119
+ end
120
+ end
121
+
122
+ # Load a single custom auth plugin file
123
+ #
124
+ # @param file_path [String] Path to the auth plugin file
125
+ # @param auth_plugin_dir [String] Base directory for auth plugins
126
+ # @return [void]
127
+ def load_custom_auth_plugin(file_path, auth_plugin_dir)
128
+ # Security: Ensure the file path doesn't escape the auth plugin directory
129
+ normalized_auth_plugin_dir = Pathname.new(File.expand_path(auth_plugin_dir))
130
+ normalized_file_path = Pathname.new(File.expand_path(file_path))
131
+ unless normalized_file_path.descend.any? { |path| path == normalized_auth_plugin_dir }
132
+ raise SecurityError, "Auth plugin path outside of auth plugin directory: #{file_path}"
133
+ end
134
+
135
+ # Extract plugin name from file (e.g., custom_auth.rb -> CustomAuth)
136
+ file_name = File.basename(file_path, ".rb")
137
+ class_name = file_name.split("_").map(&:capitalize).join("")
138
+
139
+ # Security: Validate class name
140
+ unless valid_auth_plugin_class_name?(class_name)
141
+ raise StandardError, "Invalid auth plugin class name: #{class_name}"
142
+ end
143
+
144
+ # Load the file
145
+ require file_path
146
+
147
+ # Get the class and validate it
148
+ auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{class_name}")
149
+ unless auth_plugin_class < Hooks::Plugins::Auth::Base
150
+ raise StandardError, "Auth plugin class must inherit from Hooks::Plugins::Auth::Base: #{class_name}"
151
+ end
152
+
153
+ # Register the plugin (using the file_name as the key for lookup)
154
+ @auth_plugins[file_name] = auth_plugin_class
155
+ end
156
+
157
+ # Load a single custom handler plugin file
158
+ #
159
+ # @param file_path [String] Path to the handler plugin file
160
+ # @param handler_plugin_dir [String] Base directory for handler plugins
161
+ # @return [void]
162
+ def load_custom_handler_plugin(file_path, handler_plugin_dir)
163
+ # Security: Ensure the file path doesn't escape the handler plugin directory
164
+ normalized_handler_dir = Pathname.new(File.expand_path(handler_plugin_dir))
165
+ normalized_file_path = Pathname.new(File.expand_path(file_path))
166
+ unless normalized_file_path.descend.any? { |path| path == normalized_handler_dir }
167
+ raise SecurityError, "Handler plugin path outside of handler plugin directory: #{file_path}"
168
+ end
169
+
170
+ # Extract class name from file (e.g., team1_handler.rb -> Team1Handler)
171
+ file_name = File.basename(file_path, ".rb")
172
+ class_name = file_name.split("_").map(&:capitalize).join("")
173
+
174
+ # Security: Validate class name
175
+ unless valid_handler_class_name?(class_name)
176
+ raise StandardError, "Invalid handler class name: #{class_name}"
177
+ end
178
+
179
+ # Load the file
180
+ require file_path
181
+
182
+ # Get the class and validate it
183
+ handler_class = Object.const_get(class_name)
184
+ unless handler_class < Hooks::Plugins::Handlers::Base
185
+ raise StandardError, "Handler class must inherit from Hooks::Plugins::Handlers::Base: #{class_name}"
186
+ end
187
+
188
+ # Register the handler (using the class name as the key for lookup)
189
+ @handler_plugins[class_name] = handler_class
190
+ end
191
+
192
+ # Log summary of loaded plugins
193
+ #
194
+ # @return [void]
195
+ def log_loaded_plugins
196
+ return unless defined?(Hooks::Log) && Hooks::Log.instance
197
+
198
+ log = Hooks::Log.instance
199
+ # Skip logging if the logger is a test double (class name contains "Double")
200
+ return if log.class.name.include?("Double")
201
+
202
+ log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}"
203
+ log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}"
204
+ end
205
+
206
+ # Validate that an auth plugin class name is safe to load
207
+ #
208
+ # @param class_name [String] The class name to validate
209
+ # @return [Boolean] true if the class name is safe, false otherwise
210
+ def valid_auth_plugin_class_name?(class_name)
211
+ # Must be a string
212
+ return false unless class_name.is_a?(String)
213
+
214
+ # Must not be empty or only whitespace
215
+ return false if class_name.strip.empty?
216
+
217
+ # Must match a safe pattern: alphanumeric + underscore, starting with uppercase
218
+ # Examples: MyAuthPlugin, SomeCoolAuthPlugin, CustomAuth
219
+ return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
220
+
221
+ # Must not be a system/built-in class name
222
+ return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
223
+
224
+ true
225
+ end
226
+
227
+ # Validate that a handler class name is safe to load
228
+ #
229
+ # @param class_name [String] The class name to validate
230
+ # @return [Boolean] true if the class name is safe, false otherwise
231
+ def valid_handler_class_name?(class_name)
232
+ # Must be a string
233
+ return false unless class_name.is_a?(String)
234
+
235
+ # Must not be empty or only whitespace
236
+ return false if class_name.strip.empty?
237
+
238
+ # Must match a safe pattern: alphanumeric + underscore, starting with uppercase
239
+ # Examples: MyHandler, Team1Handler, GitHubHandler
240
+ return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
241
+
242
+ # Must not be a system/built-in class name
243
+ return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
244
+
245
+ true
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+ require_relative "../../core/log"
5
+
6
+ module Hooks
7
+ module Plugins
8
+ module Auth
9
+ # Abstract base class for request validators via authentication
10
+ #
11
+ # All custom Auth plugins must inherit from this class
12
+ class Base
13
+ # Validate request
14
+ #
15
+ # @param payload [String] Raw request body
16
+ # @param headers [Hash<String, String>] HTTP headers
17
+ # @param config [Hash] Endpoint configuration
18
+ # @return [Boolean] true if request is valid
19
+ # @raise [NotImplementedError] if not implemented by subclass
20
+ def self.valid?(payload:, headers:, config:)
21
+ raise NotImplementedError, "Validator must implement .valid? class method"
22
+ end
23
+
24
+ # Short logger accessor for all subclasses
25
+ # @return [Hooks::Log] Logger instance for request validation
26
+ #
27
+ # Provides a convenient way for validators to log messages without needing
28
+ # to reference the full Hooks::Log namespace.
29
+ #
30
+ # @example Logging an error in an inherited class
31
+ # log.error("oh no an error occured")
32
+ def self.log
33
+ Hooks::Log.instance
34
+ end
35
+
36
+ # Retrieve the secret from the environment variable based on the key set in the configuration
37
+ #
38
+ # Note: This method is intended to be used by subclasses
39
+ # It is a helper method and may not work with all authentication types
40
+ #
41
+ # @param config [Hash] Configuration hash containing :auth key
42
+ # @param secret_env_key [Symbol] The key to look up in the config for the environment variable name
43
+ # @return [String] The secret
44
+ # @raise [StandardError] if secret_env_key is missing or empty
45
+ def self.fetch_secret(config, secret_env_key_name: :secret_env_key)
46
+ secret_env_key = config.dig(:auth, secret_env_key_name)
47
+ if secret_env_key.nil? || !secret_env_key.is_a?(String) || secret_env_key.strip.empty?
48
+ raise StandardError, "authentication configuration incomplete: missing secret_env_key"
49
+ end
50
+
51
+ secret = ENV[secret_env_key]
52
+
53
+ if secret.nil? || !secret.is_a?(String) || secret.strip.empty?
54
+ raise StandardError, "authentication configuration incomplete: missing secret value bound to #{secret_env_key_name}"
55
+ end
56
+
57
+ return secret.strip
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "time"
5
+ require_relative "base"
6
+
7
+ module Hooks
8
+ module Plugins
9
+ module Auth
10
+ # Generic HMAC signature validator for webhooks
11
+ #
12
+ # This validator supports multiple webhook providers with different signature formats.
13
+ # It provides flexible configuration options to handle various HMAC-based authentication schemes.
14
+ #
15
+ # @example Basic configuration with algorithm prefix
16
+ # auth:
17
+ # type: HMAC
18
+ # secret_env_key: WEBHOOK_SECRET
19
+ # header: X-Hub-Signature-256
20
+ # algorithm: sha256
21
+ # format: "algorithm=signature"
22
+ #
23
+ # @example Configuration with timestamp validation
24
+ # auth:
25
+ # type: HMAC
26
+ # secret_env_key: WEBHOOK_SECRET
27
+ # header: X-Signature
28
+ # timestamp_header: X-Request-Timestamp
29
+ # timestamp_tolerance: 300 # 5 minutes
30
+ # algorithm: sha256
31
+ # format: "version=signature"
32
+ # version_prefix: "v0"
33
+ # payload_template: "{version}:{timestamp}:{body}"
34
+ class HMAC < Base
35
+ # Default configuration values for HMAC validation
36
+ #
37
+ # @return [Hash<Symbol, String|Integer>] Default configuration settings
38
+ # @note These values provide sensible defaults for most webhook implementations
39
+ DEFAULT_CONFIG = {
40
+ algorithm: "sha256",
41
+ format: "algorithm=signature", # Format: algorithm=hash
42
+ timestamp_tolerance: 300, # 5 minutes tolerance for timestamp validation
43
+ version_prefix: "v0" # Default version prefix for versioned signatures
44
+ }.freeze
45
+
46
+ # Mapping of signature format strings to internal format symbols
47
+ #
48
+ # @return [Hash<String, Symbol>] Format string to symbol mapping
49
+ # @note Supports three common webhook signature formats:
50
+ # - algorithm=signature: "sha256=abc123..." (GitHub, GitLab style)
51
+ # - signature_only: "abc123..." (Shopify style)
52
+ # - version=signature: "v0=abc123..." (Slack style)
53
+ FORMATS = {
54
+ "algorithm=signature" => :algorithm_prefixed, # "sha256=abc123..."
55
+ "signature_only" => :hash_only, # "abc123..."
56
+ "version=signature" => :version_prefixed # "v0=abc123..."
57
+ }.freeze
58
+
59
+ # Validate HMAC signature from webhook requests
60
+ #
61
+ # Performs comprehensive HMAC signature validation with support for multiple
62
+ # signature formats and optional timestamp validation. Uses secure comparison
63
+ # to prevent timing attacks.
64
+ #
65
+ # @param payload [String] Raw request body to validate
66
+ # @param headers [Hash<String, String>] HTTP headers from the request
67
+ # @param config [Hash] Endpoint configuration containing validator settings
68
+ # @option config [Hash] :auth Validator-specific configuration
69
+ # @option config [String] :header ('X-Signature') Header containing the signature
70
+ # @option config [String] :timestamp_header Header containing timestamp (optional)
71
+ # @option config [Integer] :timestamp_tolerance (300) Timestamp tolerance in seconds
72
+ # @option config [String] :algorithm ('sha256') HMAC algorithm to use
73
+ # @option config [String] :format ('algorithm=signature') Signature format
74
+ # @option config [String] :version_prefix ('v0') Version prefix for versioned signatures
75
+ # @option config [String] :payload_template Template for payload construction
76
+ # @return [Boolean] true if signature is valid, false otherwise
77
+ # @raise [StandardError] Rescued internally, returns false on any error
78
+ # @note This method is designed to be safe and will never raise exceptions
79
+ # @note Uses Rack::Utils.secure_compare to prevent timing attacks
80
+ # @example Basic validation
81
+ # HMAC.valid?(
82
+ # payload: request_body,
83
+ # headers: request.headers,
84
+ # config: { auth: { header: 'X-Signature' } }
85
+ # )
86
+ def self.valid?(payload:, headers:, config:)
87
+ # fetch the required secret from environment variable as specified in the config
88
+ secret = fetch_secret(config)
89
+
90
+ validator_config = build_config(config)
91
+
92
+ # Security: Check raw headers BEFORE normalization to detect tampering
93
+ return false unless headers.respond_to?(:each)
94
+
95
+ signature_header = validator_config[:header]
96
+
97
+ # Find the signature header with case-insensitive matching but preserve original value
98
+ raw_signature = nil
99
+ headers.each do |key, value|
100
+ if key.to_s.downcase == signature_header.downcase
101
+ raw_signature = value.to_s
102
+ break
103
+ end
104
+ end
105
+
106
+ return false if raw_signature.nil? || raw_signature.empty?
107
+
108
+ # Security: Reject signatures with leading/trailing whitespace
109
+ return false if raw_signature != raw_signature.strip
110
+
111
+ # Security: Reject signatures containing null bytes or other control characters
112
+ return false if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/)
113
+
114
+ # Now we can safely normalize headers for the rest of the validation
115
+ normalized_headers = normalize_headers(headers)
116
+ provided_signature = normalized_headers[signature_header.downcase]
117
+
118
+ # Validate timestamp if required (for services that include timestamp validation)
119
+ if validator_config[:timestamp_header]
120
+ return false unless valid_timestamp?(normalized_headers, validator_config)
121
+ end
122
+
123
+ # Compute expected signature
124
+ computed_signature = compute_signature(
125
+ payload:,
126
+ headers: normalized_headers,
127
+ secret:,
128
+ config: validator_config
129
+ )
130
+
131
+ # Use secure comparison to prevent timing attacks
132
+ Rack::Utils.secure_compare(computed_signature, provided_signature)
133
+ rescue StandardError => e
134
+ log.error("Auth::HMAC validation failed: #{e.message}")
135
+ false
136
+ end
137
+
138
+ private
139
+
140
+ # Build final configuration by merging defaults with provided config
141
+ #
142
+ # Combines default configuration values with user-provided settings,
143
+ # ensuring all required configuration keys are present with sensible defaults.
144
+ #
145
+ # @param config [Hash] Raw endpoint configuration
146
+ # @return [Hash<Symbol, Object>] Merged configuration with defaults applied
147
+ # @note Missing configuration values are filled with DEFAULT_CONFIG values
148
+ # @api private
149
+ def self.build_config(config)
150
+ validator_config = config.dig(:auth) || {}
151
+
152
+ algorithm = validator_config[:algorithm] || DEFAULT_CONFIG[:algorithm]
153
+ tolerance = validator_config[:timestamp_tolerance] || DEFAULT_CONFIG[:timestamp_tolerance]
154
+
155
+ DEFAULT_CONFIG.merge({
156
+ header: validator_config[:header] || "X-Signature",
157
+ timestamp_header: validator_config[:timestamp_header],
158
+ timestamp_tolerance: tolerance,
159
+ algorithm: algorithm,
160
+ format: validator_config[:format] || DEFAULT_CONFIG[:format],
161
+ version_prefix: validator_config[:version_prefix] || DEFAULT_CONFIG[:version_prefix],
162
+ payload_template: validator_config[:payload_template]
163
+ })
164
+ end
165
+
166
+ # Normalize headers using the Utils::Normalize class
167
+ #
168
+ # Converts header hash to normalized format with lowercase keys for
169
+ # case-insensitive header matching.
170
+ #
171
+ # @param headers [Hash<String, String>] Raw HTTP headers
172
+ # @return [Hash<String, String>] Normalized headers with lowercase keys
173
+ # @note Returns empty hash if headers is nil
174
+ # @api private
175
+ def self.normalize_headers(headers)
176
+ Utils::Normalize.headers(headers) || {}
177
+ end
178
+
179
+ # Validate timestamp if timestamp validation is configured
180
+ #
181
+ # Checks if the provided timestamp is within the configured tolerance
182
+ # of the current time. This prevents replay attacks using old requests.
183
+ #
184
+ # @param headers [Hash<String, String>] Normalized HTTP headers
185
+ # @param config [Hash<Symbol, Object>] Validator configuration
186
+ # @return [Boolean] true if timestamp is valid or not required, false otherwise
187
+ # @note Returns false if timestamp header is missing when required
188
+ # @note Tolerance is applied as absolute difference (past or future)
189
+ # @api private
190
+ def self.valid_timestamp?(headers, config)
191
+ timestamp_header = config[:timestamp_header]
192
+ return false unless timestamp_header
193
+
194
+ timestamp_header = timestamp_header.downcase
195
+ timestamp_value = headers[timestamp_header]
196
+
197
+ return false unless timestamp_value
198
+
199
+ # Security: Strict timestamp validation - must be only digits with no leading zeros
200
+ return false unless timestamp_value.match?(/\A[1-9]\d*\z/) || timestamp_value == "0"
201
+
202
+ timestamp = timestamp_value.to_i
203
+
204
+ # Ensure timestamp is a positive integer (reject zero and negative)
205
+ return false unless timestamp > 0
206
+
207
+ current_time = Time.now.to_i
208
+ tolerance = config[:timestamp_tolerance]
209
+
210
+ (current_time - timestamp).abs <= tolerance
211
+ end
212
+
213
+ # Compute HMAC signature based on configuration requirements
214
+ #
215
+ # Generates the expected HMAC signature for the given payload using the
216
+ # specified algorithm and formatting rules.
217
+ #
218
+ # @param payload [String] Raw request body
219
+ # @param headers [Hash<String, String>] Normalized HTTP headers
220
+ # @param secret [String] Secret key for HMAC computation
221
+ # @param config [Hash<Symbol, Object>] Validator configuration
222
+ # @return [String] Formatted HMAC signature
223
+ # @note The returned signature format depends on the configured format style
224
+ # @api private
225
+ def self.compute_signature(payload:, headers:, secret:, config:)
226
+ # Determine what to sign based on payload template
227
+ signing_payload = build_signing_payload(
228
+ payload:,
229
+ headers:,
230
+ config:
231
+ )
232
+
233
+ # Compute HMAC hash
234
+ algorithm = config[:algorithm]
235
+ computed_hash = OpenSSL::HMAC.hexdigest(
236
+ OpenSSL::Digest.new(algorithm),
237
+ secret,
238
+ signing_payload
239
+ )
240
+
241
+ # Format according to provider requirements
242
+ format_signature(computed_hash, config)
243
+ end
244
+
245
+ # Build the payload string to sign (handles templated payload requirements)
246
+ #
247
+ # Constructs the signing payload based on configuration. Some webhook services
248
+ # require specific payload formats that include metadata like timestamps.
249
+ #
250
+ # @param payload [String] Raw request body
251
+ # @param headers [Hash<String, String>] Normalized HTTP headers
252
+ # @param config [Hash<Symbol, Object>] Validator configuration
253
+ # @return [String] Payload string ready for HMAC computation
254
+ # @note When payload_template is provided, it supports variable substitution:
255
+ # - {version}: Replaced with version_prefix
256
+ # - {timestamp}: Replaced with timestamp from headers
257
+ # - {body}: Replaced with the raw payload
258
+ # @example Template usage
259
+ # template: "{version}:{timestamp}:{body}"
260
+ # result: "v0:1609459200:{"event":"push"}"
261
+ # @api private
262
+ def self.build_signing_payload(payload:, headers:, config:)
263
+ template = config[:payload_template]
264
+
265
+ if template
266
+ # Templated payload format (e.g., "v0:timestamp:body" for timestamp-based validation)
267
+ timestamp = headers[config[:timestamp_header].downcase]
268
+ template
269
+ .gsub("{version}", config[:version_prefix])
270
+ .gsub("{timestamp}", timestamp.to_s)
271
+ .gsub("{body}", payload)
272
+ else
273
+ # Standard: just the payload
274
+ payload
275
+ end
276
+ end
277
+
278
+ # Format the computed signature based on configuration requirements
279
+ #
280
+ # Applies the appropriate formatting to the computed HMAC hash based on
281
+ # the configured signature format style.
282
+ #
283
+ # @param hash [String] Raw HMAC hash (hexadecimal string)
284
+ # @param config [Hash<Symbol, Object>] Validator configuration
285
+ # @return [String] Formatted signature string
286
+ # @note Supported formats:
287
+ # - :algorithm_prefixed: "sha256=abc123..." (GitHub style)
288
+ # - :hash_only: "abc123..." (Shopify style)
289
+ # - :version_prefixed: "v0=abc123..." (Slack style)
290
+ # @note Defaults to algorithm_prefixed format for unknown format styles
291
+ # @api private
292
+ def self.format_signature(hash, config)
293
+ format_style = FORMATS[config[:format]]
294
+
295
+ case format_style
296
+ when :algorithm_prefixed
297
+ # Algorithm-prefixed format: "sha256=abc123..." (used by GitHub, GitLab, etc.)
298
+ "#{config[:algorithm]}=#{hash}"
299
+ when :hash_only
300
+ # Hash-only format: "abc123..." (used by Shopify, etc.)
301
+ hash
302
+ when :version_prefixed
303
+ # Version-prefixed format: "v0=abc123..." (used by Slack, etc.)
304
+ "#{config[:version_prefix]}=#{hash}"
305
+ else
306
+ # Default to algorithm-prefixed format
307
+ "#{config[:algorithm]}=#{hash}"
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end