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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Hooks
6
+ module Plugins
7
+ module Auth
8
+ # Generic shared secret validator for webhooks
9
+ #
10
+ # This validator provides simple shared secret authentication for webhook requests.
11
+ # It compares a secret value sent in a configurable HTTP header against the expected
12
+ # secret value. This is a common (though less secure than HMAC) authentication pattern
13
+ # used by various webhook providers.
14
+ #
15
+ # @example Basic configuration
16
+ # auth:
17
+ # type: shared_secret
18
+ # secret_env_key: WEBHOOK_SECRET
19
+ # header: Authorization
20
+ #
21
+ # @example Custom header configuration
22
+ # auth:
23
+ # type: shared_secret
24
+ # secret_env_key: SOME_OTHER_WEBHOOK_SECRET
25
+ # header: X-API-Key
26
+ #
27
+ # @note This validator performs direct string comparison of the shared secret.
28
+ # While simpler than HMAC, it provides less security since the secret is
29
+ # transmitted directly in the request header.
30
+ class SharedSecret < Base
31
+ # Default configuration values for shared secret validation
32
+ #
33
+ # @return [Hash<Symbol, String>] Default configuration settings
34
+ DEFAULT_CONFIG = {
35
+ header: "Authorization"
36
+ }.freeze
37
+
38
+ # Validate shared secret from webhook requests
39
+ #
40
+ # Performs secure comparison of the shared secret value from the configured
41
+ # header against the expected secret. Uses secure comparison to prevent
42
+ # timing attacks.
43
+ #
44
+ # @param payload [String] Raw request body (unused but required by interface)
45
+ # @param headers [Hash<String, String>] HTTP headers from the request
46
+ # @param config [Hash] Endpoint configuration containing validator settings
47
+ # @option config [Hash] :auth Validator-specific configuration
48
+ # @option config [String] :header ('Authorization') Header containing the secret
49
+ # @return [Boolean] true if secret is valid, false otherwise
50
+ # @raise [StandardError] Rescued internally, returns false on any error
51
+ # @note This method is designed to be safe and will never raise exceptions
52
+ # @note Uses Rack::Utils.secure_compare to prevent timing attacks
53
+ # @example Basic validation
54
+ # SharedSecret.valid?(
55
+ # payload: request_body,
56
+ # headers: request.headers,
57
+ # config: { auth: { header: 'Authorization' } }
58
+ # )
59
+ def self.valid?(payload:, headers:, config:)
60
+ secret = fetch_secret(config)
61
+
62
+ validator_config = build_config(config)
63
+
64
+ # Security: Check raw headers BEFORE normalization to detect tampering
65
+ return false unless headers.respond_to?(:each)
66
+
67
+ secret_header = validator_config[:header]
68
+
69
+ # Find the secret header with case-insensitive matching but preserve original value
70
+ raw_secret = nil
71
+ headers.each do |key, value|
72
+ if key.to_s.downcase == secret_header.downcase
73
+ raw_secret = value.to_s
74
+ break
75
+ end
76
+ end
77
+
78
+ return false if raw_secret.nil? || raw_secret.empty?
79
+
80
+ stripped_secret = raw_secret.strip
81
+
82
+ # Security: Reject secrets with leading/trailing whitespace
83
+ return false if raw_secret != stripped_secret
84
+
85
+ # Security: Reject secrets containing null bytes or other control characters
86
+ return false if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/)
87
+
88
+ # Use secure comparison to prevent timing attacks
89
+ Rack::Utils.secure_compare(secret, stripped_secret)
90
+ rescue StandardError => _e
91
+ false
92
+ end
93
+
94
+ private
95
+
96
+ # Build final configuration by merging defaults with provided config
97
+ #
98
+ # Combines default configuration values with user-provided settings,
99
+ # ensuring all required configuration keys are present with sensible defaults.
100
+ #
101
+ # @param config [Hash] Raw endpoint configuration
102
+ # @return [Hash<Symbol, Object>] Merged configuration with defaults applied
103
+ # @note Missing configuration values are filled with DEFAULT_CONFIG values
104
+ # @api private
105
+ def self.build_config(config)
106
+ validator_config = config.dig(:auth) || {}
107
+
108
+ DEFAULT_CONFIG.merge({
109
+ header: validator_config[:header] || DEFAULT_CONFIG[:header]
110
+ })
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Plugins
5
+ module Handlers
6
+ # Base class for all webhook handlers
7
+ #
8
+ # All custom handlers must inherit from this class and implement the #call method
9
+ class Base
10
+ # Process a webhook request
11
+ #
12
+ # @param payload [Hash, String] Parsed request body (JSON Hash) or raw string
13
+ # @param headers [Hash<String, String>] HTTP headers
14
+ # @param config [Hash] Merged endpoint configuration including opts section
15
+ # @return [Hash, String, nil] Response body (will be auto-converted to JSON)
16
+ # @raise [NotImplementedError] if not implemented by subclass
17
+ def call(payload:, headers:, config:)
18
+ raise NotImplementedError, "Handler must implement #call method"
19
+ end
20
+
21
+ # Short logger accessor for all subclasses
22
+ # @return [Hooks::Log] Logger instance
23
+ #
24
+ # Provides a convenient way for handlers to log messages without needing
25
+ # to reference the full Hooks::Log namespace.
26
+ #
27
+ # @example Logging an error in an inherited class
28
+ # log.error("oh no an error occured")
29
+ def log
30
+ Hooks::Log.instance
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Default handler when no custom handler is found
4
+ # This handler simply acknowledges receipt of the webhook and shows a few of the built-in features
5
+ class DefaultHandler < Hooks::Plugins::Handlers::Base
6
+ def call(payload:, headers:, config:)
7
+
8
+ log.info("🔔 Default handler invoked for webhook 🔔")
9
+
10
+ # do some basic processing
11
+ if payload
12
+ log.debug("received payload: #{payload.inspect}")
13
+ end
14
+
15
+ {
16
+ message: "webhook processed successfully",
17
+ handler: "DefaultHandler",
18
+ timestamp: Time.now.iso8601
19
+ }
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Plugins
5
+ # Base class for global lifecycle plugins
6
+ #
7
+ # Plugins can hook into request/response/error lifecycle events
8
+ class Lifecycle
9
+ # Called before handler execution
10
+ #
11
+ # @param env [Hash] Rack environment
12
+ def on_request(env)
13
+ # Override in subclass for pre-processing logic
14
+ end
15
+
16
+ # Called after successful handler execution
17
+ #
18
+ # @param env [Hash] Rack environment
19
+ # @param response [Hash] Handler response
20
+ def on_response(env, response)
21
+ # Override in subclass for post-processing logic
22
+ end
23
+
24
+ # Called when any error occurs during request processing
25
+ #
26
+ # @param exception [Exception] The raised exception
27
+ # @param env [Hash] Rack environment
28
+ def on_error(exception, env)
29
+ # Override in subclass for error handling logic
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Security
5
+ # List of dangerous class names that should not be loaded as handlers
6
+ # for security reasons. These classes provide system access that could
7
+ # be exploited if loaded dynamically.
8
+ #
9
+ # @return [Array<String>] Array of dangerous class names
10
+ DANGEROUS_CLASSES = %w[
11
+ File Dir Kernel Object Class Module Proc Method
12
+ IO Socket TCPSocket UDPSocket BasicSocket
13
+ Process Thread Fiber Mutex ConditionVariable
14
+ Marshal YAML JSON Pathname
15
+ ].freeze
16
+ end
17
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Utils
5
+ # Utility class for normalizing HTTP headers
6
+ #
7
+ # Provides a robust method to consistently format HTTP headers
8
+ # across the application, handling various edge cases and formats.
9
+ class Normalize
10
+ # Normalize a hash of HTTP headers
11
+ #
12
+ # @param headers [Hash, #each] Headers hash or hash-like object
13
+ # @return [Hash] Normalized headers hash with downcased keys and trimmed values
14
+ #
15
+ # @example Hash of headers normalization
16
+ # headers = { "Content-Type" => " application/json ", "X-GitHub-Event" => "push" }
17
+ # normalized = Normalize.headers(headers)
18
+ # # => { "content-type" => "application/json", "x-github-event" => "push" }
19
+ #
20
+ # @example Handle various input types
21
+ # Normalize.headers(nil) # => nil
22
+ # Normalize.headers({}) # => {}
23
+ # Normalize.headers({ "KEY" => ["a", "b"] }) # => { "key" => "a" }
24
+ # Normalize.headers({ "Key" => 123 }) # => { "key" => "123" }
25
+ def self.headers(headers)
26
+ # Handle nil input
27
+ return nil if headers.nil?
28
+
29
+ # Fast path for non-enumerable inputs (numbers, etc.)
30
+ return {} unless headers.respond_to?(:each)
31
+
32
+ normalized = {}
33
+
34
+ headers.each do |key, value|
35
+ # Skip nil keys or values entirely
36
+ next if key.nil? || value.nil?
37
+
38
+ # Convert key to string, downcase, and strip in one operation
39
+ normalized_key = key.to_s.downcase.strip
40
+ next if normalized_key.empty?
41
+
42
+ # Handle different value types efficiently
43
+ normalized_value = case value
44
+ when String
45
+ value.strip
46
+ when Array
47
+ # Take first non-empty element for multi-value headers
48
+ first_valid = value.find { |v| v && !v.to_s.strip.empty? }
49
+ first_valid ? first_valid.to_s.strip : nil
50
+ else
51
+ value.to_s.strip
52
+ end
53
+
54
+ # Only add if we have a non-empty value
55
+ normalized[normalized_key] = normalized_value if normalized_value && !normalized_value.empty?
56
+ end
57
+
58
+ normalized
59
+ end
60
+
61
+ # Normalize a single HTTP header name
62
+ #
63
+ # @param header [String] Header name to normalize
64
+ # @return [String, nil] Normalized header name (downcased and trimmed), or nil if input is nil
65
+ #
66
+ # @example Single header normalization
67
+ # Normalize.header(" Content-Type ") # => "content-type"
68
+ # Normalize.header("X-GitHub-Event") # => "x-github-event"
69
+ # Normalize.header("") # => ""
70
+ # Normalize.header(nil) # => nil
71
+ #
72
+ # @raise [ArgumentError] If input is not a String or nil
73
+ def self.header(header)
74
+ return nil if header.nil?
75
+ if header.is_a?(String)
76
+ header.downcase.strip
77
+ else
78
+ raise ArgumentError, "Expected a String for header normalization"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ VERSION = "0.0.2"
5
+ end
data/lib/hooks.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hooks/version"
4
+ require_relative "hooks/core/builder"
5
+
6
+ # Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.)
7
+ Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file|
8
+ require file
9
+ end
10
+
11
+ # Load all utils
12
+ Dir[File.join(__dir__, "hooks/utils/**/*.rb")].sort.each do |file|
13
+ require file
14
+ end
15
+
16
+ # Main module for the Hooks webhook server framework
17
+ module Hooks
18
+ # Build a Rack-compatible webhook server application
19
+ #
20
+ # @param config [String, Hash] Path to config file or config hash
21
+ # @param log [Logger] Custom logger instance (optional)
22
+ # @return [Object] Rack-compatible application
23
+ def self.build(config: nil, log: nil)
24
+ Core::Builder.new(
25
+ config:,
26
+ log:,
27
+ ).build
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,189 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hooks-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - github
8
+ - GrantBirki
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redacting-logger
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: retryable
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 3.0.5
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: '3.0'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 3.0.5
47
+ - !ruby/object:Gem::Dependency
48
+ name: dry-schema
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.14'
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 1.14.1
57
+ type: :runtime
58
+ prerelease: false
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - "~>"
62
+ - !ruby/object:Gem::Version
63
+ version: '1.14'
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 1.14.1
67
+ - !ruby/object:Gem::Dependency
68
+ name: grape
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '2.3'
74
+ type: :runtime
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '2.3'
81
+ - !ruby/object:Gem::Dependency
82
+ name: grape-swagger
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '2.1'
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 2.1.2
91
+ type: :runtime
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '2.1'
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 2.1.2
101
+ - !ruby/object:Gem::Dependency
102
+ name: puma
103
+ requirement: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '6.6'
108
+ type: :runtime
109
+ prerelease: false
110
+ version_requirements: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '6.6'
115
+ description: 'A Pluggable Webhook Server Framework written in Ruby
116
+
117
+ '
118
+ executables:
119
+ - hooks
120
+ extensions: []
121
+ extra_rdoc_files: []
122
+ files:
123
+ - LICENSE
124
+ - README.md
125
+ - bin/bundle
126
+ - bin/erb
127
+ - bin/hooks
128
+ - bin/htmldiff
129
+ - bin/irb
130
+ - bin/ldiff
131
+ - bin/puma
132
+ - bin/pumactl
133
+ - bin/racc
134
+ - bin/rdoc
135
+ - bin/ri
136
+ - bin/rspec
137
+ - bin/rubocop
138
+ - bin/ruby-parse
139
+ - bin/ruby-rewrite
140
+ - bin/rubygems-await
141
+ - bin/sigstore-cli
142
+ - bin/thor
143
+ - config.ru
144
+ - hooks.gemspec
145
+ - lib/hooks.rb
146
+ - lib/hooks/app/api.rb
147
+ - lib/hooks/app/auth/auth.rb
148
+ - lib/hooks/app/endpoints/catchall.rb
149
+ - lib/hooks/app/endpoints/health.rb
150
+ - lib/hooks/app/endpoints/version.rb
151
+ - lib/hooks/app/helpers.rb
152
+ - lib/hooks/core/builder.rb
153
+ - lib/hooks/core/config_loader.rb
154
+ - lib/hooks/core/config_validator.rb
155
+ - lib/hooks/core/log.rb
156
+ - lib/hooks/core/logger_factory.rb
157
+ - lib/hooks/core/plugin_loader.rb
158
+ - lib/hooks/plugins/auth/base.rb
159
+ - lib/hooks/plugins/auth/hmac.rb
160
+ - lib/hooks/plugins/auth/shared_secret.rb
161
+ - lib/hooks/plugins/handlers/base.rb
162
+ - lib/hooks/plugins/handlers/default.rb
163
+ - lib/hooks/plugins/lifecycle.rb
164
+ - lib/hooks/security.rb
165
+ - lib/hooks/utils/normalize.rb
166
+ - lib/hooks/version.rb
167
+ homepage: https://github.com/github/hooks
168
+ licenses:
169
+ - MIT
170
+ metadata:
171
+ bug_tracker_uri: https://github.com/github/hooks/issues
172
+ rdoc_options: []
173
+ require_paths:
174
+ - lib
175
+ required_ruby_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: 3.2.2
180
+ required_rubygems_version: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - ">="
183
+ - !ruby/object:Gem::Version
184
+ version: '0'
185
+ requirements: []
186
+ rubygems_version: 3.6.7
187
+ specification_version: 4
188
+ summary: A Pluggable Webhook Server Framework written in Ruby
189
+ test_files: []