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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/bin/bundle +109 -0
- data/bin/erb +27 -0
- data/bin/hooks +27 -0
- data/bin/htmldiff +27 -0
- data/bin/irb +27 -0
- data/bin/ldiff +27 -0
- data/bin/puma +27 -0
- data/bin/pumactl +27 -0
- data/bin/racc +27 -0
- data/bin/rdoc +27 -0
- data/bin/ri +27 -0
- data/bin/rspec +27 -0
- data/bin/rubocop +27 -0
- data/bin/ruby-parse +27 -0
- data/bin/ruby-rewrite +27 -0
- data/bin/rubygems-await +27 -0
- data/bin/sigstore-cli +27 -0
- data/bin/thor +27 -0
- data/config.ru +6 -0
- data/hooks.gemspec +36 -0
- data/lib/hooks/app/api.rb +112 -0
- data/lib/hooks/app/auth/auth.rb +48 -0
- data/lib/hooks/app/endpoints/catchall.rb +84 -0
- data/lib/hooks/app/endpoints/health.rb +20 -0
- data/lib/hooks/app/endpoints/version.rb +18 -0
- data/lib/hooks/app/helpers.rb +95 -0
- data/lib/hooks/core/builder.rb +97 -0
- data/lib/hooks/core/config_loader.rb +152 -0
- data/lib/hooks/core/config_validator.rb +131 -0
- data/lib/hooks/core/log.rb +9 -0
- data/lib/hooks/core/logger_factory.rb +99 -0
- data/lib/hooks/core/plugin_loader.rb +250 -0
- data/lib/hooks/plugins/auth/base.rb +62 -0
- data/lib/hooks/plugins/auth/hmac.rb +313 -0
- data/lib/hooks/plugins/auth/shared_secret.rb +115 -0
- data/lib/hooks/plugins/handlers/base.rb +35 -0
- data/lib/hooks/plugins/handlers/default.rb +21 -0
- data/lib/hooks/plugins/lifecycle.rb +33 -0
- data/lib/hooks/security.rb +17 -0
- data/lib/hooks/utils/normalize.rb +83 -0
- data/lib/hooks/version.rb +5 -0
- data/lib/hooks.rb +29 -0
- metadata +189 -0
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "config_loader"
|
4
|
+
require_relative "config_validator"
|
5
|
+
require_relative "logger_factory"
|
6
|
+
require_relative "plugin_loader"
|
7
|
+
require_relative "../app/api"
|
8
|
+
|
9
|
+
module Hooks
|
10
|
+
module Core
|
11
|
+
# Main builder that orchestrates the webhook server setup
|
12
|
+
class Builder
|
13
|
+
# Initialize builder with configuration options
|
14
|
+
#
|
15
|
+
# @param config [String, Hash] Path to config file or config hash
|
16
|
+
# @param log [Logger] Custom logger instance
|
17
|
+
def initialize(config: nil, log: nil)
|
18
|
+
@log = log
|
19
|
+
@config_input = config
|
20
|
+
end
|
21
|
+
|
22
|
+
# Build and return Rack-compatible application
|
23
|
+
#
|
24
|
+
# @return [Object] Rack-compatible application
|
25
|
+
def build
|
26
|
+
# Load and validate configuration
|
27
|
+
config = load_and_validate_config
|
28
|
+
|
29
|
+
# Create logger unless a custom logger is provided
|
30
|
+
if @log.nil?
|
31
|
+
@log = LoggerFactory.create(
|
32
|
+
log_level: config[:log_level],
|
33
|
+
custom_logger: @custom_logger
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Load all plugins at boot time
|
38
|
+
load_plugins(config)
|
39
|
+
|
40
|
+
# Load endpoints
|
41
|
+
endpoints = load_endpoints(config)
|
42
|
+
|
43
|
+
# Log startup
|
44
|
+
@log.info "starting hooks server v#{Hooks::VERSION}"
|
45
|
+
@log.info "config: #{endpoints.size} endpoints loaded"
|
46
|
+
@log.info "environment: #{config[:environment]}"
|
47
|
+
@log.info "available endpoints: #{endpoints.map { |e| e[:path] }.join(', ')}"
|
48
|
+
|
49
|
+
# Build and return Grape API class
|
50
|
+
Hooks::App::API.create(
|
51
|
+
config:,
|
52
|
+
endpoints:,
|
53
|
+
log: @log
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
# Load and validate all configuration
|
60
|
+
#
|
61
|
+
# @return [Hash] Validated global configuration
|
62
|
+
def load_and_validate_config
|
63
|
+
# Load base config from file/hash and environment
|
64
|
+
config = ConfigLoader.load(config_path: @config_input)
|
65
|
+
|
66
|
+
# Validate global configuration
|
67
|
+
ConfigValidator.validate_global_config(config)
|
68
|
+
rescue ConfigValidator::ValidationError => e
|
69
|
+
raise ConfigurationError, "Configuration validation failed: #{e.message}"
|
70
|
+
end
|
71
|
+
|
72
|
+
# Load and validate endpoint configurations
|
73
|
+
#
|
74
|
+
# @param config [Hash] Global configuration
|
75
|
+
# @return [Array<Hash>] Array of validated endpoint configurations
|
76
|
+
def load_endpoints(config)
|
77
|
+
endpoints = ConfigLoader.load_endpoints(config[:endpoints_dir])
|
78
|
+
ConfigValidator.validate_endpoints(endpoints)
|
79
|
+
rescue ConfigValidator::ValidationError => e
|
80
|
+
raise ConfigurationError, "Endpoint validation failed: #{e.message}"
|
81
|
+
end
|
82
|
+
|
83
|
+
# Load all plugins at boot time
|
84
|
+
#
|
85
|
+
# @param config [Hash] Global configuration
|
86
|
+
# @return [void]
|
87
|
+
def load_plugins(config)
|
88
|
+
PluginLoader.load_all_plugins(config)
|
89
|
+
rescue => e
|
90
|
+
raise ConfigurationError, "Plugin loading failed: #{e.message}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Configuration error
|
95
|
+
class ConfigurationError < StandardError; end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module Hooks
|
7
|
+
module Core
|
8
|
+
# Loads and merges configuration from files and environment variables
|
9
|
+
class ConfigLoader
|
10
|
+
DEFAULT_CONFIG = {
|
11
|
+
handler_plugin_dir: "./plugins/handlers",
|
12
|
+
auth_plugin_dir: "./plugins/auth",
|
13
|
+
log_level: "info",
|
14
|
+
request_limit: 1_048_576,
|
15
|
+
request_timeout: 30,
|
16
|
+
root_path: "/webhooks",
|
17
|
+
health_path: "/health",
|
18
|
+
version_path: "/version",
|
19
|
+
environment: "production",
|
20
|
+
production: true,
|
21
|
+
endpoints_dir: "./config/endpoints",
|
22
|
+
use_catchall_route: false,
|
23
|
+
symbolize_payload: true,
|
24
|
+
normalize_headers: true
|
25
|
+
}.freeze
|
26
|
+
|
27
|
+
# Load and merge configuration from various sources
|
28
|
+
#
|
29
|
+
# @param config_path [String, Hash] Path to config file or config hash
|
30
|
+
# @return [Hash] Merged configuration
|
31
|
+
def self.load(config_path: nil)
|
32
|
+
config = DEFAULT_CONFIG.dup
|
33
|
+
|
34
|
+
# Load from file if path provided
|
35
|
+
if config_path.is_a?(String) && File.exist?(config_path)
|
36
|
+
file_config = load_config_file(config_path)
|
37
|
+
config.merge!(file_config) if file_config
|
38
|
+
elsif config_path.is_a?(Hash)
|
39
|
+
config.merge!(config_path)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Override with environment variables
|
43
|
+
config.merge!(load_env_config)
|
44
|
+
|
45
|
+
# Convert string keys to symbols for consistency
|
46
|
+
config = symbolize_keys(config)
|
47
|
+
|
48
|
+
if config[:environment] == "production"
|
49
|
+
config[:production] = true
|
50
|
+
else
|
51
|
+
config[:production] = false
|
52
|
+
end
|
53
|
+
|
54
|
+
return config
|
55
|
+
end
|
56
|
+
|
57
|
+
# Load endpoint configurations from directory
|
58
|
+
#
|
59
|
+
# @param endpoints_dir [String] Directory containing endpoint config files
|
60
|
+
# @return [Array<Hash>] Array of endpoint configurations
|
61
|
+
def self.load_endpoints(endpoints_dir)
|
62
|
+
return [] unless endpoints_dir && Dir.exist?(endpoints_dir)
|
63
|
+
|
64
|
+
endpoints = []
|
65
|
+
files = Dir.glob(File.join(endpoints_dir, "*.{yml,yaml,json}"))
|
66
|
+
|
67
|
+
files.each do |file|
|
68
|
+
endpoint_config = load_config_file(file)
|
69
|
+
if endpoint_config
|
70
|
+
endpoints << symbolize_keys(endpoint_config)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
endpoints
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# Load configuration from YAML or JSON file
|
80
|
+
#
|
81
|
+
# @param file_path [String] Path to config file
|
82
|
+
# @return [Hash, nil] Parsed configuration or nil if error
|
83
|
+
def self.load_config_file(file_path)
|
84
|
+
content = File.read(file_path)
|
85
|
+
|
86
|
+
result = case File.extname(file_path).downcase
|
87
|
+
when ".json"
|
88
|
+
JSON.parse(content)
|
89
|
+
when ".yml", ".yaml"
|
90
|
+
YAML.safe_load(content, permitted_classes: [Symbol])
|
91
|
+
else
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
|
95
|
+
result
|
96
|
+
rescue => _e
|
97
|
+
# In production, we'd log this error
|
98
|
+
nil
|
99
|
+
end
|
100
|
+
|
101
|
+
# Load configuration from environment variables
|
102
|
+
#
|
103
|
+
# @return [Hash] Configuration from ENV vars
|
104
|
+
def self.load_env_config
|
105
|
+
env_config = {}
|
106
|
+
|
107
|
+
env_mappings = {
|
108
|
+
"HOOKS_HANDLER_PLUGIN_DIR" => :handler_plugin_dir,
|
109
|
+
"HOOKS_AUTH_PLUGIN_DIR" => :auth_plugin_dir,
|
110
|
+
"HOOKS_LOG_LEVEL" => :log_level,
|
111
|
+
"HOOKS_REQUEST_LIMIT" => :request_limit,
|
112
|
+
"HOOKS_REQUEST_TIMEOUT" => :request_timeout,
|
113
|
+
"HOOKS_ROOT_PATH" => :root_path,
|
114
|
+
"HOOKS_HEALTH_PATH" => :health_path,
|
115
|
+
"HOOKS_VERSION_PATH" => :version_path,
|
116
|
+
"HOOKS_ENVIRONMENT" => :environment,
|
117
|
+
"HOOKS_ENDPOINTS_DIR" => :endpoints_dir
|
118
|
+
}
|
119
|
+
|
120
|
+
env_mappings.each do |env_key, config_key|
|
121
|
+
value = ENV[env_key]
|
122
|
+
next unless value
|
123
|
+
|
124
|
+
# Convert numeric values
|
125
|
+
case config_key
|
126
|
+
when :request_limit, :request_timeout
|
127
|
+
env_config[config_key] = value.to_i
|
128
|
+
else
|
129
|
+
env_config[config_key] = value
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
env_config
|
134
|
+
end
|
135
|
+
|
136
|
+
# Recursively convert string keys to symbols
|
137
|
+
#
|
138
|
+
# @param obj [Hash, Array, Object] Object to convert
|
139
|
+
# @return [Hash, Array, Object] Converted object
|
140
|
+
def self.symbolize_keys(obj)
|
141
|
+
case obj
|
142
|
+
when Hash
|
143
|
+
obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
|
144
|
+
when Array
|
145
|
+
obj.map { |v| symbolize_keys(v) }
|
146
|
+
else
|
147
|
+
obj
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-schema"
|
4
|
+
require_relative "../security"
|
5
|
+
|
6
|
+
module Hooks
|
7
|
+
module Core
|
8
|
+
# Validates configuration using Dry::Schema
|
9
|
+
class ConfigValidator
|
10
|
+
# Custom validation error
|
11
|
+
class ValidationError < StandardError; end
|
12
|
+
|
13
|
+
# Global configuration schema
|
14
|
+
GLOBAL_CONFIG_SCHEMA = Dry::Schema.Params do
|
15
|
+
optional(:handler_dir).filled(:string) # For backward compatibility
|
16
|
+
optional(:handler_plugin_dir).filled(:string)
|
17
|
+
optional(:auth_plugin_dir).maybe(:string)
|
18
|
+
optional(:log_level).filled(:string, included_in?: %w[debug info warn error])
|
19
|
+
optional(:request_limit).filled(:integer, gt?: 0)
|
20
|
+
optional(:request_timeout).filled(:integer, gt?: 0)
|
21
|
+
optional(:root_path).filled(:string)
|
22
|
+
optional(:health_path).filled(:string)
|
23
|
+
optional(:version_path).filled(:string)
|
24
|
+
optional(:environment).filled(:string, included_in?: %w[development production])
|
25
|
+
optional(:endpoints_dir).filled(:string)
|
26
|
+
optional(:use_catchall_route).filled(:bool)
|
27
|
+
optional(:symbolize_payload).filled(:bool)
|
28
|
+
optional(:normalize_headers).filled(:bool)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Endpoint configuration schema
|
32
|
+
ENDPOINT_CONFIG_SCHEMA = Dry::Schema.Params do
|
33
|
+
required(:path).filled(:string)
|
34
|
+
required(:handler).filled(:string)
|
35
|
+
|
36
|
+
optional(:auth).hash do
|
37
|
+
required(:type).filled(:string)
|
38
|
+
optional(:secret_env_key).filled(:string)
|
39
|
+
optional(:header).filled(:string)
|
40
|
+
optional(:algorithm).filled(:string)
|
41
|
+
optional(:timestamp_header).filled(:string)
|
42
|
+
optional(:timestamp_tolerance).filled(:integer, gt?: 0)
|
43
|
+
optional(:format).filled(:string)
|
44
|
+
optional(:version_prefix).filled(:string)
|
45
|
+
optional(:payload_template).filled(:string)
|
46
|
+
end
|
47
|
+
|
48
|
+
optional(:opts).hash
|
49
|
+
end
|
50
|
+
|
51
|
+
# Validate global configuration
|
52
|
+
#
|
53
|
+
# @param config [Hash] Configuration to validate
|
54
|
+
# @return [Hash] Validated configuration
|
55
|
+
# @raise [ValidationError] if validation fails
|
56
|
+
def self.validate_global_config(config)
|
57
|
+
result = GLOBAL_CONFIG_SCHEMA.call(config)
|
58
|
+
|
59
|
+
if result.failure?
|
60
|
+
raise ValidationError, "Invalid global configuration: #{result.errors.to_h}"
|
61
|
+
end
|
62
|
+
|
63
|
+
result.to_h
|
64
|
+
end
|
65
|
+
|
66
|
+
# Validate endpoint configuration with additional security checks
|
67
|
+
#
|
68
|
+
# @param config [Hash] Endpoint configuration to validate
|
69
|
+
# @return [Hash] Validated configuration
|
70
|
+
# @raise [ValidationError] if validation fails
|
71
|
+
def self.validate_endpoint_config(config)
|
72
|
+
result = ENDPOINT_CONFIG_SCHEMA.call(config)
|
73
|
+
|
74
|
+
if result.failure?
|
75
|
+
raise ValidationError, "Invalid endpoint configuration: #{result.errors.to_h}"
|
76
|
+
end
|
77
|
+
|
78
|
+
validated_config = result.to_h
|
79
|
+
|
80
|
+
# Security: Additional validation for handler name
|
81
|
+
handler_name = validated_config[:handler]
|
82
|
+
unless valid_handler_name?(handler_name)
|
83
|
+
raise ValidationError, "Invalid handler name: #{handler_name}"
|
84
|
+
end
|
85
|
+
|
86
|
+
validated_config
|
87
|
+
end
|
88
|
+
|
89
|
+
# Validate array of endpoint configurations
|
90
|
+
#
|
91
|
+
# @param endpoints [Array<Hash>] Array of endpoint configurations
|
92
|
+
# @return [Array<Hash>] Array of validated configurations
|
93
|
+
# @raise [ValidationError] if any validation fails
|
94
|
+
def self.validate_endpoints(endpoints)
|
95
|
+
validated_endpoints = []
|
96
|
+
|
97
|
+
endpoints.each_with_index do |endpoint, index|
|
98
|
+
begin
|
99
|
+
validated_endpoints << validate_endpoint_config(endpoint)
|
100
|
+
rescue ValidationError => e
|
101
|
+
raise ValidationError, "Endpoint #{index}: #{e.message}"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
validated_endpoints
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
# Validate that a handler name is safe
|
111
|
+
#
|
112
|
+
# @param handler_name [String] The handler name to validate
|
113
|
+
# @return [Boolean] true if the handler name is safe, false otherwise
|
114
|
+
def self.valid_handler_name?(handler_name)
|
115
|
+
# Must be a string
|
116
|
+
return false unless handler_name.is_a?(String)
|
117
|
+
|
118
|
+
# Must not be empty or only whitespace
|
119
|
+
return false if handler_name.strip.empty?
|
120
|
+
|
121
|
+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
|
122
|
+
return false unless handler_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
|
123
|
+
|
124
|
+
# Must not be a system/built-in class name
|
125
|
+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(handler_name)
|
126
|
+
|
127
|
+
true
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "redacting_logger"
|
4
|
+
require "logger"
|
5
|
+
require "json"
|
6
|
+
require "securerandom"
|
7
|
+
|
8
|
+
module Hooks
|
9
|
+
module Core
|
10
|
+
# Factory for creating structured JSON loggers
|
11
|
+
class LoggerFactory
|
12
|
+
# Create a structured JSON logger
|
13
|
+
#
|
14
|
+
# @param log_level [String] Log level (debug, info, warn, error)
|
15
|
+
# @param custom_logger [Logger] Custom logger instance (optional)
|
16
|
+
# @return [Logger] Configured logger instance
|
17
|
+
def self.create(log_level: "info", custom_logger: nil)
|
18
|
+
return custom_logger if custom_logger
|
19
|
+
|
20
|
+
$stdout.sync = true # don't buffer - flush immediately
|
21
|
+
|
22
|
+
# Create a new logger
|
23
|
+
logger = RedactingLogger.new(
|
24
|
+
$stdout, # The device to log to (defaults to $stdout if not provided)
|
25
|
+
redact_patterns: [], # An array of Regexp patterns to redact from the logs
|
26
|
+
level: parse_log_level(log_level), # The log level to use
|
27
|
+
redacted_msg: "[REDACTED]", # The message to replace the redacted patterns with
|
28
|
+
use_default_patterns: true # Whether to use the default built-in patterns or not
|
29
|
+
)
|
30
|
+
|
31
|
+
logger.formatter = json_formatter
|
32
|
+
logger
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# Parse string log level to Logger constant
|
38
|
+
#
|
39
|
+
# @param level [String] Log level string
|
40
|
+
# @return [Integer] Logger level constant
|
41
|
+
def self.parse_log_level(level)
|
42
|
+
case level.to_s.downcase
|
43
|
+
when "debug" then Logger::DEBUG
|
44
|
+
when "info" then Logger::INFO
|
45
|
+
when "warn" then Logger::WARN
|
46
|
+
when "error" then Logger::ERROR
|
47
|
+
else Logger::INFO
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# JSON formatter for structured logging
|
52
|
+
#
|
53
|
+
# @return [Proc] Formatter procedure
|
54
|
+
def self.json_formatter
|
55
|
+
proc do |severity, datetime, progname, msg|
|
56
|
+
log_entry = {
|
57
|
+
timestamp: datetime.iso8601,
|
58
|
+
level: severity.downcase,
|
59
|
+
message: msg
|
60
|
+
}
|
61
|
+
|
62
|
+
# Add request context if available in thread local storage
|
63
|
+
if Thread.current[:hooks_request_context]
|
64
|
+
log_entry.merge!(Thread.current[:hooks_request_context])
|
65
|
+
end
|
66
|
+
|
67
|
+
"#{log_entry.to_json}\n"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Helper for setting request context in logs
|
73
|
+
module LogContext
|
74
|
+
# Set request context for current thread
|
75
|
+
#
|
76
|
+
# @param context [Hash] Request context data
|
77
|
+
def self.set(context)
|
78
|
+
Thread.current[:hooks_request_context] = context
|
79
|
+
end
|
80
|
+
|
81
|
+
# Clear request context for current thread
|
82
|
+
def self.clear
|
83
|
+
Thread.current[:hooks_request_context] = nil
|
84
|
+
end
|
85
|
+
|
86
|
+
# Execute block with request context
|
87
|
+
#
|
88
|
+
# @param context [Hash] Request context data
|
89
|
+
# @yield Block to execute with context
|
90
|
+
def self.with(context)
|
91
|
+
old_context = Thread.current[:hooks_request_context]
|
92
|
+
Thread.current[:hooks_request_context] = context
|
93
|
+
yield
|
94
|
+
ensure
|
95
|
+
Thread.current[:hooks_request_context] = old_context
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|