hooks-ruby 0.0.2 → 0.0.4
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 +4 -4
- data/README.md +22 -6
- data/hooks.gemspec +0 -1
- data/lib/hooks/app/api.rb +59 -11
- data/lib/hooks/app/auth/auth.rb +15 -6
- data/lib/hooks/app/endpoints/catchall.rb +15 -3
- data/lib/hooks/app/endpoints/health.rb +2 -2
- data/lib/hooks/app/endpoints/version.rb +1 -1
- data/lib/hooks/app/helpers.rb +39 -9
- data/lib/hooks/core/builder.rb +6 -0
- data/lib/hooks/core/component_access.rb +69 -0
- data/lib/hooks/core/config_loader.rb +41 -9
- data/lib/hooks/core/config_validator.rb +3 -0
- data/lib/hooks/core/failbot.rb +50 -0
- data/lib/hooks/core/global_components.rb +51 -0
- data/lib/hooks/core/log.rb +15 -0
- data/lib/hooks/core/plugin_loader.rb +190 -4
- data/lib/hooks/core/stats.rb +54 -0
- data/lib/hooks/plugins/auth/base.rb +4 -12
- data/lib/hooks/plugins/auth/hmac.rb +20 -18
- data/lib/hooks/plugins/auth/timestamp_validator.rb +133 -0
- data/lib/hooks/plugins/handlers/base.rb +5 -12
- data/lib/hooks/plugins/handlers/default.rb +32 -3
- data/lib/hooks/plugins/instruments/failbot.rb +32 -0
- data/lib/hooks/plugins/instruments/failbot_base.rb +72 -0
- data/lib/hooks/plugins/instruments/stats.rb +32 -0
- data/lib/hooks/plugins/instruments/stats_base.rb +88 -0
- data/lib/hooks/plugins/lifecycle.rb +5 -0
- data/lib/hooks/utils/retry.rb +37 -0
- data/lib/hooks/version.rb +4 -1
- data/lib/hooks.rb +23 -10
- metadata +11 -21
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hooks
|
4
|
+
module Core
|
5
|
+
# Global failbot component for error reporting
|
6
|
+
#
|
7
|
+
# This is a stub implementation that does nothing by default.
|
8
|
+
# Users can replace this with their own implementation for services
|
9
|
+
# like Sentry, Rollbar, etc.
|
10
|
+
class Failbot
|
11
|
+
# Report an error or exception
|
12
|
+
#
|
13
|
+
# @param error_or_message [Exception, String] Exception object or error message
|
14
|
+
# @param context [Hash] Optional context information
|
15
|
+
# @return [void]
|
16
|
+
def report(error_or_message, context = {})
|
17
|
+
# Override in subclass for actual error reporting
|
18
|
+
end
|
19
|
+
|
20
|
+
# Report a critical error
|
21
|
+
#
|
22
|
+
# @param error_or_message [Exception, String] Exception object or error message
|
23
|
+
# @param context [Hash] Optional context information
|
24
|
+
# @return [void]
|
25
|
+
def critical(error_or_message, context = {})
|
26
|
+
# Override in subclass for actual error reporting
|
27
|
+
end
|
28
|
+
|
29
|
+
# Report a warning
|
30
|
+
#
|
31
|
+
# @param message [String] Warning message
|
32
|
+
# @param context [Hash] Optional context information
|
33
|
+
# @return [void]
|
34
|
+
def warning(message, context = {})
|
35
|
+
# Override in subclass for actual warning reporting
|
36
|
+
end
|
37
|
+
|
38
|
+
# Capture an exception during block execution
|
39
|
+
#
|
40
|
+
# @param context [Hash] Optional context information
|
41
|
+
# @return [Object] Return value of the block
|
42
|
+
def capture(context = {})
|
43
|
+
yield
|
44
|
+
rescue => e
|
45
|
+
report(e, context)
|
46
|
+
raise
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hooks
|
4
|
+
module Core
|
5
|
+
# Global registry for shared components accessible throughout the application
|
6
|
+
class GlobalComponents
|
7
|
+
@test_stats = nil
|
8
|
+
@test_failbot = nil
|
9
|
+
|
10
|
+
# Get the global stats instance
|
11
|
+
# @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting
|
12
|
+
def self.stats
|
13
|
+
@test_stats || PluginLoader.get_instrument_plugin(:stats)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Get the global failbot instance
|
17
|
+
# @return [Hooks::Plugins::Instruments::FailbotBase] Failbot instance for error reporting
|
18
|
+
def self.failbot
|
19
|
+
@test_failbot || PluginLoader.get_instrument_plugin(:failbot)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Set a custom stats instance (for testing)
|
23
|
+
# @param stats_instance [Object] Custom stats instance
|
24
|
+
def self.stats=(stats_instance)
|
25
|
+
@test_stats = stats_instance
|
26
|
+
end
|
27
|
+
|
28
|
+
# Set a custom failbot instance (for testing)
|
29
|
+
# @param failbot_instance [Object] Custom failbot instance
|
30
|
+
def self.failbot=(failbot_instance)
|
31
|
+
@test_failbot = failbot_instance
|
32
|
+
end
|
33
|
+
|
34
|
+
# Reset components to default instances (for testing)
|
35
|
+
#
|
36
|
+
# @return [void]
|
37
|
+
def self.reset
|
38
|
+
@test_stats = nil
|
39
|
+
@test_failbot = nil
|
40
|
+
# Clear and reload default instruments
|
41
|
+
PluginLoader.clear_plugins
|
42
|
+
require_relative "../plugins/instruments/stats"
|
43
|
+
require_relative "../plugins/instruments/failbot"
|
44
|
+
PluginLoader.instance_variable_set(:@instrument_plugins, {
|
45
|
+
stats: Hooks::Plugins::Instruments::Stats.new,
|
46
|
+
failbot: Hooks::Plugins::Instruments::Failbot.new
|
47
|
+
})
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/hooks/core/log.rb
CHANGED
@@ -1,8 +1,23 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Hooks
|
4
|
+
# Global logger accessor module
|
5
|
+
#
|
6
|
+
# Provides a singleton-like access pattern for the application logger.
|
7
|
+
# The logger instance is set during application initialization and can
|
8
|
+
# be accessed throughout the application lifecycle.
|
9
|
+
#
|
10
|
+
# @example Setting the logger instance
|
11
|
+
# Hooks::Log.instance = Logger.new(STDOUT)
|
12
|
+
#
|
13
|
+
# @example Accessing the logger
|
14
|
+
# Hooks::Log.instance.info("Application started")
|
4
15
|
module Log
|
5
16
|
class << self
|
17
|
+
# Get or set the global logger instance
|
18
|
+
# @return [Logger] The global logger instance
|
19
|
+
# @attr_reader instance [Logger] Current logger instance
|
20
|
+
# @attr_writer instance [Logger] Set the logger instance
|
6
21
|
attr_accessor :instance
|
7
22
|
end
|
8
23
|
end
|
@@ -5,14 +5,16 @@ require_relative "../security"
|
|
5
5
|
|
6
6
|
module Hooks
|
7
7
|
module Core
|
8
|
-
# Loads and caches all plugins (auth + handlers) at boot time
|
8
|
+
# Loads and caches all plugins (auth + handlers + lifecycle + instruments) at boot time
|
9
9
|
class PluginLoader
|
10
10
|
# Class-level registries for loaded plugins
|
11
11
|
@auth_plugins = {}
|
12
12
|
@handler_plugins = {}
|
13
|
+
@lifecycle_plugins = []
|
14
|
+
@instrument_plugins = { stats: nil, failbot: nil }
|
13
15
|
|
14
16
|
class << self
|
15
|
-
attr_reader :auth_plugins, :handler_plugins
|
17
|
+
attr_reader :auth_plugins, :handler_plugins, :lifecycle_plugins, :instrument_plugins
|
16
18
|
|
17
19
|
# Load all plugins at boot time
|
18
20
|
#
|
@@ -22,6 +24,8 @@ module Hooks
|
|
22
24
|
# Clear existing registries
|
23
25
|
@auth_plugins = {}
|
24
26
|
@handler_plugins = {}
|
27
|
+
@lifecycle_plugins = []
|
28
|
+
@instrument_plugins = { stats: nil, failbot: nil }
|
25
29
|
|
26
30
|
# Load built-in plugins first
|
27
31
|
load_builtin_plugins
|
@@ -29,6 +33,11 @@ module Hooks
|
|
29
33
|
# Load custom plugins if directories are configured
|
30
34
|
load_custom_auth_plugins(config[:auth_plugin_dir]) if config[:auth_plugin_dir]
|
31
35
|
load_custom_handler_plugins(config[:handler_plugin_dir]) if config[:handler_plugin_dir]
|
36
|
+
load_custom_lifecycle_plugins(config[:lifecycle_plugin_dir]) if config[:lifecycle_plugin_dir]
|
37
|
+
load_custom_instrument_plugins(config[:instruments_plugin_dir]) if config[:instruments_plugin_dir]
|
38
|
+
|
39
|
+
# Load default instruments if no custom ones were loaded
|
40
|
+
load_default_instruments
|
32
41
|
|
33
42
|
# Log loaded plugins
|
34
43
|
log_loaded_plugins
|
@@ -65,12 +74,29 @@ module Hooks
|
|
65
74
|
plugin_class
|
66
75
|
end
|
67
76
|
|
77
|
+
# Get instrument plugin instance by type
|
78
|
+
#
|
79
|
+
# @param instrument_type [Symbol] Type of instrument (:stats or :failbot)
|
80
|
+
# @return [Object] The instrument plugin instance
|
81
|
+
# @raise [StandardError] if instrument not found
|
82
|
+
def get_instrument_plugin(instrument_type)
|
83
|
+
instrument_instance = @instrument_plugins[instrument_type]
|
84
|
+
|
85
|
+
unless instrument_instance
|
86
|
+
raise StandardError, "Instrument plugin '#{instrument_type}' not found"
|
87
|
+
end
|
88
|
+
|
89
|
+
instrument_instance
|
90
|
+
end
|
91
|
+
|
68
92
|
# Clear all loaded plugins (for testing purposes)
|
69
93
|
#
|
70
94
|
# @return [void]
|
71
95
|
def clear_plugins
|
72
96
|
@auth_plugins = {}
|
73
97
|
@handler_plugins = {}
|
98
|
+
@lifecycle_plugins = []
|
99
|
+
@instrument_plugins = { stats: nil, failbot: nil }
|
74
100
|
end
|
75
101
|
|
76
102
|
private
|
@@ -97,7 +123,7 @@ module Hooks
|
|
97
123
|
Dir.glob(File.join(auth_plugin_dir, "*.rb")).sort.each do |file_path|
|
98
124
|
begin
|
99
125
|
load_custom_auth_plugin(file_path, auth_plugin_dir)
|
100
|
-
rescue => e
|
126
|
+
rescue StandardError, SyntaxError => e
|
101
127
|
raise StandardError, "Failed to load auth plugin from #{file_path}: #{e.message}"
|
102
128
|
end
|
103
129
|
end
|
@@ -113,12 +139,44 @@ module Hooks
|
|
113
139
|
Dir.glob(File.join(handler_plugin_dir, "*.rb")).sort.each do |file_path|
|
114
140
|
begin
|
115
141
|
load_custom_handler_plugin(file_path, handler_plugin_dir)
|
116
|
-
rescue => e
|
142
|
+
rescue StandardError, SyntaxError => e
|
117
143
|
raise StandardError, "Failed to load handler plugin from #{file_path}: #{e.message}"
|
118
144
|
end
|
119
145
|
end
|
120
146
|
end
|
121
147
|
|
148
|
+
# Load custom lifecycle plugins from directory
|
149
|
+
#
|
150
|
+
# @param lifecycle_plugin_dir [String] Directory containing custom lifecycle plugins
|
151
|
+
# @return [void]
|
152
|
+
def load_custom_lifecycle_plugins(lifecycle_plugin_dir)
|
153
|
+
return unless lifecycle_plugin_dir && Dir.exist?(lifecycle_plugin_dir)
|
154
|
+
|
155
|
+
Dir.glob(File.join(lifecycle_plugin_dir, "*.rb")).sort.each do |file_path|
|
156
|
+
begin
|
157
|
+
load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
|
158
|
+
rescue StandardError, SyntaxError => e
|
159
|
+
raise StandardError, "Failed to load lifecycle plugin from #{file_path}: #{e.message}"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Load custom instrument plugins from directory
|
165
|
+
#
|
166
|
+
# @param instruments_plugin_dir [String] Directory containing custom instrument plugins
|
167
|
+
# @return [void]
|
168
|
+
def load_custom_instrument_plugins(instruments_plugin_dir)
|
169
|
+
return unless instruments_plugin_dir && Dir.exist?(instruments_plugin_dir)
|
170
|
+
|
171
|
+
Dir.glob(File.join(instruments_plugin_dir, "*.rb")).sort.each do |file_path|
|
172
|
+
begin
|
173
|
+
load_custom_instrument_plugin(file_path, instruments_plugin_dir)
|
174
|
+
rescue StandardError, SyntaxError => e
|
175
|
+
raise StandardError, "Failed to load instrument plugin from #{file_path}: #{e.message}"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
122
180
|
# Load a single custom auth plugin file
|
123
181
|
#
|
124
182
|
# @param file_path [String] Path to the auth plugin file
|
@@ -189,6 +247,90 @@ module Hooks
|
|
189
247
|
@handler_plugins[class_name] = handler_class
|
190
248
|
end
|
191
249
|
|
250
|
+
# Load a single custom lifecycle plugin file
|
251
|
+
#
|
252
|
+
# @param file_path [String] Path to the lifecycle plugin file
|
253
|
+
# @param lifecycle_plugin_dir [String] Base directory for lifecycle plugins
|
254
|
+
# @return [void]
|
255
|
+
def load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
|
256
|
+
# Security: Ensure the file path doesn't escape the lifecycle plugin directory
|
257
|
+
normalized_lifecycle_dir = Pathname.new(File.expand_path(lifecycle_plugin_dir))
|
258
|
+
normalized_file_path = Pathname.new(File.expand_path(file_path))
|
259
|
+
unless normalized_file_path.descend.any? { |path| path == normalized_lifecycle_dir }
|
260
|
+
raise SecurityError, "Lifecycle plugin path outside of lifecycle plugin directory: #{file_path}"
|
261
|
+
end
|
262
|
+
|
263
|
+
# Extract class name from file (e.g., logging_lifecycle.rb -> LoggingLifecycle)
|
264
|
+
file_name = File.basename(file_path, ".rb")
|
265
|
+
class_name = file_name.split("_").map(&:capitalize).join("")
|
266
|
+
|
267
|
+
# Security: Validate class name
|
268
|
+
unless valid_lifecycle_class_name?(class_name)
|
269
|
+
raise StandardError, "Invalid lifecycle plugin class name: #{class_name}"
|
270
|
+
end
|
271
|
+
|
272
|
+
# Load the file
|
273
|
+
require file_path
|
274
|
+
|
275
|
+
# Get the class and validate it
|
276
|
+
lifecycle_class = Object.const_get(class_name)
|
277
|
+
unless lifecycle_class < Hooks::Plugins::Lifecycle
|
278
|
+
raise StandardError, "Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle: #{class_name}"
|
279
|
+
end
|
280
|
+
|
281
|
+
# Register the plugin instance
|
282
|
+
@lifecycle_plugins << lifecycle_class.new
|
283
|
+
end
|
284
|
+
|
285
|
+
# Load a single custom instrument plugin file
|
286
|
+
#
|
287
|
+
# @param file_path [String] Path to the instrument plugin file
|
288
|
+
# @param instruments_plugin_dir [String] Base directory for instrument plugins
|
289
|
+
# @return [void]
|
290
|
+
def load_custom_instrument_plugin(file_path, instruments_plugin_dir)
|
291
|
+
# Security: Ensure the file path doesn't escape the instruments plugin directory
|
292
|
+
normalized_instruments_dir = Pathname.new(File.expand_path(instruments_plugin_dir))
|
293
|
+
normalized_file_path = Pathname.new(File.expand_path(file_path))
|
294
|
+
unless normalized_file_path.descend.any? { |path| path == normalized_instruments_dir }
|
295
|
+
raise SecurityError, "Instrument plugin path outside of instruments plugin directory: #{file_path}"
|
296
|
+
end
|
297
|
+
|
298
|
+
# Extract class name from file (e.g., custom_stats.rb -> CustomStats)
|
299
|
+
file_name = File.basename(file_path, ".rb")
|
300
|
+
class_name = file_name.split("_").map(&:capitalize).join("")
|
301
|
+
|
302
|
+
# Security: Validate class name
|
303
|
+
unless valid_instrument_class_name?(class_name)
|
304
|
+
raise StandardError, "Invalid instrument plugin class name: #{class_name}"
|
305
|
+
end
|
306
|
+
|
307
|
+
# Load the file
|
308
|
+
require file_path
|
309
|
+
|
310
|
+
# Get the class and validate it
|
311
|
+
instrument_class = Object.const_get(class_name)
|
312
|
+
|
313
|
+
# Determine instrument type based on inheritance
|
314
|
+
if instrument_class < Hooks::Plugins::Instruments::StatsBase
|
315
|
+
@instrument_plugins[:stats] = instrument_class.new
|
316
|
+
elsif instrument_class < Hooks::Plugins::Instruments::FailbotBase
|
317
|
+
@instrument_plugins[:failbot] = instrument_class.new
|
318
|
+
else
|
319
|
+
raise StandardError, "Instrument plugin class must inherit from StatsBase or FailbotBase: #{class_name}"
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
# Load default instrument implementations if no custom ones were loaded
|
324
|
+
#
|
325
|
+
# @return [void]
|
326
|
+
def load_default_instruments
|
327
|
+
require_relative "../plugins/instruments/stats"
|
328
|
+
require_relative "../plugins/instruments/failbot"
|
329
|
+
|
330
|
+
@instrument_plugins[:stats] ||= Hooks::Plugins::Instruments::Stats.new
|
331
|
+
@instrument_plugins[:failbot] ||= Hooks::Plugins::Instruments::Failbot.new
|
332
|
+
end
|
333
|
+
|
192
334
|
# Log summary of loaded plugins
|
193
335
|
#
|
194
336
|
# @return [void]
|
@@ -201,6 +343,8 @@ module Hooks
|
|
201
343
|
|
202
344
|
log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}"
|
203
345
|
log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}"
|
346
|
+
log.info "Loaded #{@lifecycle_plugins.size} lifecycle plugins"
|
347
|
+
log.info "Loaded instruments: #{@instrument_plugins.keys.select { |k| @instrument_plugins[k] }.join(', ')}"
|
204
348
|
end
|
205
349
|
|
206
350
|
# Validate that an auth plugin class name is safe to load
|
@@ -244,6 +388,48 @@ module Hooks
|
|
244
388
|
|
245
389
|
true
|
246
390
|
end
|
391
|
+
|
392
|
+
# Validate that a lifecycle plugin class name is safe to load
|
393
|
+
#
|
394
|
+
# @param class_name [String] The class name to validate
|
395
|
+
# @return [Boolean] true if the class name is safe, false otherwise
|
396
|
+
def valid_lifecycle_class_name?(class_name)
|
397
|
+
# Must be a string
|
398
|
+
return false unless class_name.is_a?(String)
|
399
|
+
|
400
|
+
# Must not be empty or only whitespace
|
401
|
+
return false if class_name.strip.empty?
|
402
|
+
|
403
|
+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
|
404
|
+
# Examples: LoggingLifecycle, MetricsLifecycle, CustomLifecycle
|
405
|
+
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
|
406
|
+
|
407
|
+
# Must not be a system/built-in class name
|
408
|
+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
|
409
|
+
|
410
|
+
true
|
411
|
+
end
|
412
|
+
|
413
|
+
# Validate that an instrument plugin class name is safe to load
|
414
|
+
#
|
415
|
+
# @param class_name [String] The class name to validate
|
416
|
+
# @return [Boolean] true if the class name is safe, false otherwise
|
417
|
+
def valid_instrument_class_name?(class_name)
|
418
|
+
# Must be a string
|
419
|
+
return false unless class_name.is_a?(String)
|
420
|
+
|
421
|
+
# Must not be empty or only whitespace
|
422
|
+
return false if class_name.strip.empty?
|
423
|
+
|
424
|
+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
|
425
|
+
# Examples: CustomStats, CustomFailbot, DatadogStats
|
426
|
+
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
|
427
|
+
|
428
|
+
# Must not be a system/built-in class name
|
429
|
+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
|
430
|
+
|
431
|
+
true
|
432
|
+
end
|
247
433
|
end
|
248
434
|
end
|
249
435
|
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hooks
|
4
|
+
module Core
|
5
|
+
# Global stats component for metrics reporting
|
6
|
+
#
|
7
|
+
# This is a stub implementation that does nothing by default.
|
8
|
+
# Users can replace this with their own implementation for services
|
9
|
+
# like DataDog, New Relic, etc.
|
10
|
+
class Stats
|
11
|
+
# Record a metric
|
12
|
+
#
|
13
|
+
# @param metric_name [String] Name of the metric
|
14
|
+
# @param value [Numeric] Value to record
|
15
|
+
# @param tags [Hash] Optional tags/labels for the metric
|
16
|
+
# @return [void]
|
17
|
+
def record(metric_name, value, tags = {})
|
18
|
+
# Override in subclass for actual metrics reporting
|
19
|
+
end
|
20
|
+
|
21
|
+
# Increment a counter
|
22
|
+
#
|
23
|
+
# @param metric_name [String] Name of the counter
|
24
|
+
# @param tags [Hash] Optional tags/labels for the metric
|
25
|
+
# @return [void]
|
26
|
+
def increment(metric_name, tags = {})
|
27
|
+
# Override in subclass for actual metrics reporting
|
28
|
+
end
|
29
|
+
|
30
|
+
# Record a timing metric
|
31
|
+
#
|
32
|
+
# @param metric_name [String] Name of the timing metric
|
33
|
+
# @param duration [Numeric] Duration in seconds
|
34
|
+
# @param tags [Hash] Optional tags/labels for the metric
|
35
|
+
# @return [void]
|
36
|
+
def timing(metric_name, duration, tags = {})
|
37
|
+
# Override in subclass for actual metrics reporting
|
38
|
+
end
|
39
|
+
|
40
|
+
# Measure execution time of a block
|
41
|
+
#
|
42
|
+
# @param metric_name [String] Name of the timing metric
|
43
|
+
# @param tags [Hash] Optional tags/labels for the metric
|
44
|
+
# @return [Object] Return value of the block
|
45
|
+
def measure(metric_name, tags = {})
|
46
|
+
start_time = Time.now
|
47
|
+
result = yield
|
48
|
+
duration = Time.now - start_time
|
49
|
+
timing(metric_name, duration, tags)
|
50
|
+
result
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
require "rack/utils"
|
4
4
|
require_relative "../../core/log"
|
5
|
+
require_relative "../../core/global_components"
|
6
|
+
require_relative "../../core/component_access"
|
5
7
|
|
6
8
|
module Hooks
|
7
9
|
module Plugins
|
@@ -10,6 +12,8 @@ module Hooks
|
|
10
12
|
#
|
11
13
|
# All custom Auth plugins must inherit from this class
|
12
14
|
class Base
|
15
|
+
extend Hooks::Core::ComponentAccess
|
16
|
+
|
13
17
|
# Validate request
|
14
18
|
#
|
15
19
|
# @param payload [String] Raw request body
|
@@ -21,18 +25,6 @@ module Hooks
|
|
21
25
|
raise NotImplementedError, "Validator must implement .valid? class method"
|
22
26
|
end
|
23
27
|
|
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
28
|
# Retrieve the secret from the environment variable based on the key set in the configuration
|
37
29
|
#
|
38
30
|
# Note: This method is intended to be used by subclasses
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require "openssl"
|
4
4
|
require "time"
|
5
5
|
require_relative "base"
|
6
|
+
require_relative "timestamp_validator"
|
6
7
|
|
7
8
|
module Hooks
|
8
9
|
module Plugins
|
@@ -39,6 +40,7 @@ module Hooks
|
|
39
40
|
DEFAULT_CONFIG = {
|
40
41
|
algorithm: "sha256",
|
41
42
|
format: "algorithm=signature", # Format: algorithm=hash
|
43
|
+
header: "X-Signature", # Default header containing the signature
|
42
44
|
timestamp_tolerance: 300, # 5 minutes tolerance for timestamp validation
|
43
45
|
version_prefix: "v0" # Default version prefix for versioned signatures
|
44
46
|
}.freeze
|
@@ -117,7 +119,10 @@ module Hooks
|
|
117
119
|
|
118
120
|
# Validate timestamp if required (for services that include timestamp validation)
|
119
121
|
if validator_config[:timestamp_header]
|
120
|
-
|
122
|
+
unless valid_timestamp?(normalized_headers, validator_config)
|
123
|
+
log.warn("Auth::HMAC validation failed: Invalid timestamp")
|
124
|
+
return false
|
125
|
+
end
|
121
126
|
end
|
122
127
|
|
123
128
|
# Compute expected signature
|
@@ -153,7 +158,7 @@ module Hooks
|
|
153
158
|
tolerance = validator_config[:timestamp_tolerance] || DEFAULT_CONFIG[:timestamp_tolerance]
|
154
159
|
|
155
160
|
DEFAULT_CONFIG.merge({
|
156
|
-
header: validator_config[:header] ||
|
161
|
+
header: validator_config[:header] || DEFAULT_CONFIG[:header],
|
157
162
|
timestamp_header: validator_config[:timestamp_header],
|
158
163
|
timestamp_tolerance: tolerance,
|
159
164
|
algorithm: algorithm,
|
@@ -180,6 +185,7 @@ module Hooks
|
|
180
185
|
#
|
181
186
|
# Checks if the provided timestamp is within the configured tolerance
|
182
187
|
# of the current time. This prevents replay attacks using old requests.
|
188
|
+
# Supports both ISO 8601 UTC timestamps and Unix timestamps.
|
183
189
|
#
|
184
190
|
# @param headers [Hash<String, String>] Normalized HTTP headers
|
185
191
|
# @param config [Hash<Symbol, Object>] Validator configuration
|
@@ -189,25 +195,21 @@ module Hooks
|
|
189
195
|
# @api private
|
190
196
|
def self.valid_timestamp?(headers, config)
|
191
197
|
timestamp_header = config[:timestamp_header]
|
198
|
+
tolerance = config[:timestamp_tolerance] || 300
|
192
199
|
return false unless timestamp_header
|
193
200
|
|
194
|
-
|
195
|
-
timestamp_value = headers[timestamp_header]
|
196
|
-
|
201
|
+
timestamp_value = headers[timestamp_header.downcase]
|
197
202
|
return false unless timestamp_value
|
198
203
|
|
199
|
-
|
200
|
-
|
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]
|
204
|
+
timestamp_validator.valid?(timestamp_value, tolerance)
|
205
|
+
end
|
209
206
|
|
210
|
-
|
207
|
+
# Get timestamp validator instance
|
208
|
+
#
|
209
|
+
# @return [TimestampValidator] Singleton timestamp validator instance
|
210
|
+
# @api private
|
211
|
+
def self.timestamp_validator
|
212
|
+
@timestamp_validator ||= TimestampValidator.new
|
211
213
|
end
|
212
214
|
|
213
215
|
# Compute HMAC signature based on configuration requirements
|
@@ -257,7 +259,7 @@ module Hooks
|
|
257
259
|
# - {body}: Replaced with the raw payload
|
258
260
|
# @example Template usage
|
259
261
|
# template: "{version}:{timestamp}:{body}"
|
260
|
-
# result: "v0:1609459200:{"event"
|
262
|
+
# result: "v0:1609459200:{\"event\":\"push\"}"
|
261
263
|
# @api private
|
262
264
|
def self.build_signing_payload(payload:, headers:, config:)
|
263
265
|
template = config[:payload_template]
|
@@ -287,7 +289,7 @@ module Hooks
|
|
287
289
|
# - :algorithm_prefixed: "sha256=abc123..." (GitHub style)
|
288
290
|
# - :hash_only: "abc123..." (Shopify style)
|
289
291
|
# - :version_prefixed: "v0=abc123..." (Slack style)
|
290
|
-
# @note Defaults to
|
292
|
+
# @note Defaults to algorithm-prefixed format for unknown format styles
|
291
293
|
# @api private
|
292
294
|
def self.format_signature(hash, config)
|
293
295
|
format_style = FORMATS[config[:format]]
|