beskar 0.0.2 → 0.1.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 +4 -4
- data/CHANGELOG.md +143 -0
- data/README.md +298 -110
- data/app/controllers/beskar/application_controller.rb +170 -0
- data/app/controllers/beskar/banned_ips_controller.rb +280 -0
- data/app/controllers/beskar/dashboard_controller.rb +70 -0
- data/app/controllers/beskar/security_events_controller.rb +182 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +6 -6
- data/app/models/beskar/banned_ip.rb +68 -27
- data/app/models/beskar/security_event.rb +14 -0
- data/app/services/beskar/banned_ip_manager.rb +78 -0
- data/app/views/beskar/banned_ips/edit.html.erb +259 -0
- data/app/views/beskar/banned_ips/index.html.erb +361 -0
- data/app/views/beskar/banned_ips/new.html.erb +310 -0
- data/app/views/beskar/banned_ips/show.html.erb +310 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +309 -0
- data/app/views/beskar/security_events/show.html.erb +307 -0
- data/app/views/layouts/beskar/application.html.erb +647 -5
- data/config/routes.rb +41 -0
- data/lib/beskar/configuration.rb +24 -10
- data/lib/beskar/engine.rb +4 -4
- data/lib/beskar/logger.rb +293 -0
- data/lib/beskar/middleware/request_analyzer.rb +128 -53
- data/lib/beskar/models/security_trackable_authenticable.rb +11 -11
- data/lib/beskar/models/security_trackable_devise.rb +5 -5
- data/lib/beskar/models/security_trackable_generic.rb +12 -12
- data/lib/beskar/services/account_locker.rb +12 -12
- data/lib/beskar/services/geolocation_service.rb +8 -8
- data/lib/beskar/services/ip_whitelist.rb +2 -2
- data/lib/beskar/services/waf.rb +307 -78
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar.rb +1 -0
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
- data/lib/tasks/beskar_tasks.rake +11 -2
- metadata +35 -6
- data/lib/beskar/templates/beskar_initializer.rb +0 -107
data/lib/beskar/configuration.rb
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
module Beskar
|
|
2
2
|
class Configuration
|
|
3
|
-
attr_accessor :rate_limiting, :security_tracking, :risk_based_locking, :geolocation, :ip_whitelist, :waf, :authentication_models, :emergency_password_reset
|
|
3
|
+
attr_accessor :rate_limiting, :security_tracking, :risk_based_locking, :geolocation, :ip_whitelist, :waf, :authentication_models, :emergency_password_reset, :monitor_only, :authenticate_admin
|
|
4
4
|
|
|
5
5
|
def initialize
|
|
6
|
+
@monitor_only = false # Global monitor-only mode - logs everything but doesn't block
|
|
6
7
|
@ip_whitelist = [] # Array of IP addresses or CIDR ranges
|
|
7
8
|
|
|
9
|
+
# Dashboard authentication - configure this to restrict access to the dashboard
|
|
10
|
+
# Example: config.authenticate_admin = proc { authenticate_admin! }
|
|
11
|
+
@authenticate_admin = nil
|
|
12
|
+
|
|
8
13
|
# Authentication models configuration
|
|
9
14
|
# Auto-detect by default, or can be explicitly configured
|
|
10
15
|
@authentication_models = {
|
|
@@ -16,12 +21,20 @@ module Beskar
|
|
|
16
21
|
@waf = {
|
|
17
22
|
enabled: false, # Master switch for WAF
|
|
18
23
|
auto_block: true, # Automatically block IPs after threshold
|
|
19
|
-
|
|
20
|
-
violation_window:
|
|
21
|
-
block_durations: [
|
|
22
|
-
permanent_block_after:
|
|
24
|
+
score_threshold: 150, # Cumulative risk score before blocking (replaces block_threshold)
|
|
25
|
+
violation_window: 6.hours, # Maximum time window to track violations
|
|
26
|
+
block_durations: [1.hour, 6.hours, 24.hours, 7.days], # Escalating block durations
|
|
27
|
+
permanent_block_after: 500, # Permanent block after cumulative score reaches this (nil = never)
|
|
23
28
|
create_security_events: true, # Create SecurityEvent records
|
|
24
|
-
|
|
29
|
+
record_not_found_exclusions: [], # Regex patterns to exclude from RecordNotFound detection
|
|
30
|
+
decay_enabled: true, # Enable exponential decay of violation scores over time
|
|
31
|
+
decay_rates: { # Decay rates by severity (half-life in minutes)
|
|
32
|
+
critical: 360, # Critical violations: 6 hour half-life
|
|
33
|
+
high: 120, # High violations: 2 hour half-life
|
|
34
|
+
medium: 45, # Medium violations: 45 minute half-life
|
|
35
|
+
low: 15 # Low violations: 15 minute half-life
|
|
36
|
+
},
|
|
37
|
+
max_violations_tracked: 50 # Maximum number of violations to track per IP (oldest pruned)
|
|
25
38
|
}
|
|
26
39
|
@security_tracking = {
|
|
27
40
|
enabled: true,
|
|
@@ -135,11 +148,12 @@ module Beskar
|
|
|
135
148
|
end
|
|
136
149
|
|
|
137
150
|
def waf_auto_block?
|
|
138
|
-
waf_enabled? && @waf[:auto_block] && !@
|
|
151
|
+
waf_enabled? && @waf[:auto_block] && !@monitor_only
|
|
139
152
|
end
|
|
140
153
|
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
# General monitor-only mode check (affects all blocking)
|
|
155
|
+
def monitor_only?
|
|
156
|
+
@monitor_only == true
|
|
143
157
|
end
|
|
144
158
|
|
|
145
159
|
# IP Whitelist configuration helpers
|
|
@@ -179,7 +193,7 @@ module Beskar
|
|
|
179
193
|
end
|
|
180
194
|
rescue => e
|
|
181
195
|
# Ignore errors during detection
|
|
182
|
-
|
|
196
|
+
Beskar::Logger.debug("Error detecting Rails auth model #{model.name}: #{e.message}")
|
|
183
197
|
end
|
|
184
198
|
end
|
|
185
199
|
|
data/lib/beskar/engine.rb
CHANGED
|
@@ -11,9 +11,9 @@ module Beskar
|
|
|
11
11
|
if defined?(Beskar::BannedIp)
|
|
12
12
|
Rails.application.executor.wrap do
|
|
13
13
|
Beskar::BannedIp.preload_cache!
|
|
14
|
-
|
|
14
|
+
Beskar::Logger.info("Preloaded banned IPs into cache")
|
|
15
15
|
rescue => e
|
|
16
|
-
|
|
16
|
+
Beskar::Logger.warn("Failed to preload banned IPs: #{e.message}")
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
end
|
|
@@ -35,7 +35,7 @@ module Beskar
|
|
|
35
35
|
security_event &&
|
|
36
36
|
user_was_just_locked?(user, security_event) &&
|
|
37
37
|
user.respond_to?(:access_locked?) && user.access_locked?
|
|
38
|
-
|
|
38
|
+
Beskar::Logger.warn("Signing out user #{user.id} due to high-risk lock")
|
|
39
39
|
auth.logout
|
|
40
40
|
throw :warden, scope: opts[:scope], message: :account_locked_due_to_high_risk
|
|
41
41
|
end
|
|
@@ -61,7 +61,7 @@ module Beskar
|
|
|
61
61
|
if model_class && model_class.respond_to?(:track_failed_authentication)
|
|
62
62
|
model_class.track_failed_authentication(request, scope)
|
|
63
63
|
else
|
|
64
|
-
|
|
64
|
+
Beskar::Logger.debug("No trackable model found for scope: #{scope}")
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
# Centralized logging module for consistent log formatting and flexible output handling
|
|
5
|
+
module Logger
|
|
6
|
+
class << self
|
|
7
|
+
# Available log levels
|
|
8
|
+
LOG_LEVELS = %i[debug info warn error fatal].freeze
|
|
9
|
+
|
|
10
|
+
# Generate logging methods for each level
|
|
11
|
+
LOG_LEVELS.each do |level|
|
|
12
|
+
define_method(level) do |message, component: nil|
|
|
13
|
+
log(level, message, component: component)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Main logging method that handles formatting and output
|
|
18
|
+
#
|
|
19
|
+
# @param level [Symbol] The log level (:debug, :info, :warn, :error, :fatal)
|
|
20
|
+
# @param message [String] The message to log
|
|
21
|
+
# @param component [String, Symbol, nil] Optional component name for more specific prefixes
|
|
22
|
+
#
|
|
23
|
+
# @example Basic usage
|
|
24
|
+
# Beskar::Logger.info("User authenticated successfully")
|
|
25
|
+
# # => [Beskar] User authenticated successfully
|
|
26
|
+
#
|
|
27
|
+
# @example With component
|
|
28
|
+
# Beskar::Logger.warn("Rate limit exceeded", component: :WAF)
|
|
29
|
+
# # => [Beskar::WAF] Rate limit exceeded
|
|
30
|
+
#
|
|
31
|
+
# @example With class as component
|
|
32
|
+
# Beskar::Logger.error("Failed to lock account", component: self.class)
|
|
33
|
+
# # => [Beskar::AccountLocker] Failed to lock account
|
|
34
|
+
def log(level, message, component: nil)
|
|
35
|
+
return unless should_log?(level)
|
|
36
|
+
|
|
37
|
+
formatted_message = format_message(message, component)
|
|
38
|
+
logger.send(level, formatted_message)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
# Fallback to stderr if logging fails
|
|
41
|
+
$stderr.puts "[Beskar::Logger] Failed to log message: #{e.message}"
|
|
42
|
+
$stderr.puts "[Beskar::Logger] Original message: #{formatted_message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Configure the logger instance
|
|
46
|
+
#
|
|
47
|
+
# @param logger_instance [Logger, nil] The logger to use, defaults to Rails.logger
|
|
48
|
+
def logger=(logger_instance)
|
|
49
|
+
@logger = logger_instance
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get the current logger instance
|
|
53
|
+
#
|
|
54
|
+
# @return [Logger] The configured logger or Rails.logger as default
|
|
55
|
+
def logger
|
|
56
|
+
@logger ||= default_logger
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Configure log level threshold
|
|
60
|
+
#
|
|
61
|
+
# @param level [Symbol, String] Minimum log level to output
|
|
62
|
+
def level=(level)
|
|
63
|
+
@level = level.to_sym if LOG_LEVELS.include?(level.to_sym)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get the current log level
|
|
67
|
+
#
|
|
68
|
+
# @return [Symbol] Current log level
|
|
69
|
+
def level
|
|
70
|
+
@level ||= :debug
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Reset logger configuration to defaults
|
|
74
|
+
def reset!
|
|
75
|
+
@logger = nil
|
|
76
|
+
@level = nil
|
|
77
|
+
@component_aliases = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Configure component name aliases for cleaner output
|
|
81
|
+
#
|
|
82
|
+
# @param aliases [Hash] Mapping of classes/modules to display names
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# Beskar::Logger.component_aliases = {
|
|
86
|
+
# 'Beskar::Services::Waf' => 'WAF',
|
|
87
|
+
# 'Beskar::Services::AccountLocker' => 'AccountLocker'
|
|
88
|
+
# }
|
|
89
|
+
def component_aliases=(aliases)
|
|
90
|
+
@component_aliases = aliases
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get component aliases
|
|
94
|
+
#
|
|
95
|
+
# @return [Hash] Current component aliases
|
|
96
|
+
def component_aliases
|
|
97
|
+
@component_aliases ||= default_component_aliases
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Format the log message with appropriate prefix
|
|
103
|
+
#
|
|
104
|
+
# @param message [String] The message to format
|
|
105
|
+
# @param component [String, Symbol, Class, nil] Component identifier
|
|
106
|
+
# @return [String] Formatted message with prefix
|
|
107
|
+
def format_message(message, component)
|
|
108
|
+
prefix = build_prefix(component)
|
|
109
|
+
"#{prefix} #{message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Build the log prefix based on component
|
|
113
|
+
#
|
|
114
|
+
# @param component [String, Symbol, Class, nil] Component identifier
|
|
115
|
+
# @return [String] Formatted prefix
|
|
116
|
+
def build_prefix(component)
|
|
117
|
+
return "[Beskar]" if component.nil?
|
|
118
|
+
|
|
119
|
+
component_name = normalize_component_name(component)
|
|
120
|
+
|
|
121
|
+
if component_name.nil? || component_name.empty?
|
|
122
|
+
"[Beskar]"
|
|
123
|
+
else
|
|
124
|
+
"[Beskar::#{component_name}]"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Normalize component name from various input types
|
|
129
|
+
#
|
|
130
|
+
# @param component [String, Symbol, Class] Component identifier
|
|
131
|
+
# @return [String, nil] Normalized component name
|
|
132
|
+
def normalize_component_name(component)
|
|
133
|
+
case component
|
|
134
|
+
when String
|
|
135
|
+
apply_component_alias(component)
|
|
136
|
+
when Symbol
|
|
137
|
+
component.to_s
|
|
138
|
+
when Class
|
|
139
|
+
apply_component_alias(component.name)
|
|
140
|
+
when Module
|
|
141
|
+
apply_component_alias(component.name)
|
|
142
|
+
else
|
|
143
|
+
component.to_s
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Apply component alias if configured
|
|
148
|
+
#
|
|
149
|
+
# @param component_name [String] Original component name
|
|
150
|
+
# @return [String] Aliased name or original
|
|
151
|
+
def apply_component_alias(component_name)
|
|
152
|
+
return nil if component_name.nil?
|
|
153
|
+
|
|
154
|
+
# First check exact matches
|
|
155
|
+
aliased = component_aliases[component_name]
|
|
156
|
+
return aliased if aliased
|
|
157
|
+
|
|
158
|
+
# Remove Beskar:: prefix if present for lookup
|
|
159
|
+
clean_name = component_name.sub(/^Beskar::/, '')
|
|
160
|
+
aliased = component_aliases[clean_name]
|
|
161
|
+
return aliased if aliased
|
|
162
|
+
|
|
163
|
+
# Check if it's already a simple component name (no ::)
|
|
164
|
+
return clean_name unless clean_name.include?('::')
|
|
165
|
+
|
|
166
|
+
# Extract the last component for nested classes
|
|
167
|
+
# e.g., "Beskar::Services::Waf" -> "Waf"
|
|
168
|
+
last_component = clean_name.split('::').last
|
|
169
|
+
component_aliases[clean_name] || last_component
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Check if message should be logged based on current level
|
|
173
|
+
#
|
|
174
|
+
# @param message_level [Symbol] Level of the message
|
|
175
|
+
# @return [Boolean] True if message should be logged
|
|
176
|
+
def should_log?(message_level)
|
|
177
|
+
level_value(message_level) >= level_value(level)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Convert log level to numeric value for comparison
|
|
181
|
+
#
|
|
182
|
+
# @param level_sym [Symbol] Log level
|
|
183
|
+
# @return [Integer] Numeric value
|
|
184
|
+
def level_value(level_sym)
|
|
185
|
+
LOG_LEVELS.index(level_sym) || 0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Get the default logger instance
|
|
189
|
+
#
|
|
190
|
+
# @return [Logger] Default logger (Rails.logger or stdlib Logger)
|
|
191
|
+
def default_logger
|
|
192
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
193
|
+
Rails.logger
|
|
194
|
+
else
|
|
195
|
+
require 'logger'
|
|
196
|
+
::Logger.new($stdout)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Default component name aliases for cleaner output
|
|
201
|
+
#
|
|
202
|
+
# @return [Hash] Default aliases
|
|
203
|
+
def default_component_aliases
|
|
204
|
+
{
|
|
205
|
+
'Beskar::Services::Waf' => 'WAF',
|
|
206
|
+
'Beskar::Services::WAF' => 'WAF',
|
|
207
|
+
'Services::Waf' => 'WAF',
|
|
208
|
+
'Services::WAF' => 'WAF',
|
|
209
|
+
'Beskar::Services::AccountLocker' => 'AccountLocker',
|
|
210
|
+
'Services::AccountLocker' => 'AccountLocker',
|
|
211
|
+
'Beskar::Services::RateLimiter' => 'RateLimiter',
|
|
212
|
+
'Services::RateLimiter' => 'RateLimiter',
|
|
213
|
+
'Beskar::Services::IpWhitelist' => 'IpWhitelist',
|
|
214
|
+
'Services::IpWhitelist' => 'IpWhitelist',
|
|
215
|
+
'Beskar::Services::GeolocationService' => 'GeolocationService',
|
|
216
|
+
'Services::GeolocationService' => 'GeolocationService',
|
|
217
|
+
'Beskar::Services::DeviceDetector' => 'DeviceDetector',
|
|
218
|
+
'Services::DeviceDetector' => 'DeviceDetector',
|
|
219
|
+
'Beskar::Middleware::RequestAnalyzer' => 'Middleware',
|
|
220
|
+
'Middleware::RequestAnalyzer' => 'Middleware',
|
|
221
|
+
'Beskar::Models::SecurityTrackableDevise' => 'SecurityTracking',
|
|
222
|
+
'Models::SecurityTrackableDevise' => 'SecurityTracking',
|
|
223
|
+
'Beskar::Models::SecurityTrackableAuthenticable' => 'SecurityTracking',
|
|
224
|
+
'Models::SecurityTrackableAuthenticable' => 'SecurityTracking',
|
|
225
|
+
'Beskar::Models::SecurityTrackableGeneric' => 'SecurityTracking',
|
|
226
|
+
'Models::SecurityTrackableGeneric' => 'SecurityTracking'
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Module to include in classes for instance-level logging
|
|
232
|
+
module ClassMethods
|
|
233
|
+
# Log a debug message with automatic component detection
|
|
234
|
+
def log_debug(message)
|
|
235
|
+
Beskar::Logger.debug(message, component: self)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Log an info message with automatic component detection
|
|
239
|
+
def log_info(message)
|
|
240
|
+
Beskar::Logger.info(message, component: self)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Log a warning message with automatic component detection
|
|
244
|
+
def log_warn(message)
|
|
245
|
+
Beskar::Logger.warn(message, component: self)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Log an error message with automatic component detection
|
|
249
|
+
def log_error(message)
|
|
250
|
+
Beskar::Logger.error(message, component: self)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Log a fatal message with automatic component detection
|
|
254
|
+
def log_fatal(message)
|
|
255
|
+
Beskar::Logger.fatal(message, component: self)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Module to include in classes for instance-level logging
|
|
260
|
+
module InstanceMethods
|
|
261
|
+
# Log a debug message with automatic component detection
|
|
262
|
+
def log_debug(message)
|
|
263
|
+
Beskar::Logger.debug(message, component: self.class)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Log an info message with automatic component detection
|
|
267
|
+
def log_info(message)
|
|
268
|
+
Beskar::Logger.info(message, component: self.class)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Log a warning message with automatic component detection
|
|
272
|
+
def log_warn(message)
|
|
273
|
+
Beskar::Logger.warn(message, component: self.class)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Log an error message with automatic component detection
|
|
277
|
+
def log_error(message)
|
|
278
|
+
Beskar::Logger.error(message, component: self.class)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Log a fatal message with automatic component detection
|
|
282
|
+
def log_fatal(message)
|
|
283
|
+
Beskar::Logger.fatal(message, component: self.class)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Convenience method to include both class and instance methods
|
|
288
|
+
def self.included(base)
|
|
289
|
+
base.extend(ClassMethods)
|
|
290
|
+
base.include(InstanceMethods)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -9,76 +9,116 @@ module Beskar
|
|
|
9
9
|
request = ActionDispatch::Request.new(env)
|
|
10
10
|
ip_address = request.ip
|
|
11
11
|
|
|
12
|
+
Beskar::Logger.debug("[RequestAnalyzer] Processing request from IP: #{ip_address}, Path: #{request.path}", component: :Middleware)
|
|
13
|
+
|
|
12
14
|
# 1. Check if IP is whitelisted (whitelisted IPs skip blocking but still get logged)
|
|
13
15
|
is_whitelisted = Beskar::Services::IpWhitelist.whitelisted?(ip_address)
|
|
14
16
|
|
|
15
|
-
# 2. Check if IP is banned (early exit for blocked IPs, unless whitelisted)
|
|
17
|
+
# 2. Check if IP is banned (early exit for blocked IPs, unless whitelisted or in monitor-only mode)
|
|
16
18
|
if !is_whitelisted && Beskar::BannedIp.banned?(ip_address)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
if Beskar.configuration.monitor_only?
|
|
20
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block request from banned IP: #{ip_address}, but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
21
|
+
else
|
|
22
|
+
Beskar::Logger.warn("Blocked request from banned IP: #{ip_address}", component: :Middleware)
|
|
23
|
+
return blocked_response("Your IP address has been blocked due to suspicious activity.")
|
|
24
|
+
end
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
# 3. Check rate limiting (unless whitelisted)
|
|
22
28
|
if !is_whitelisted && rate_limited?(request)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# Auto-block after excessive rate limiting violations
|
|
29
|
+
# Auto-block after excessive rate limiting violations (even in monitor-only mode - we create the ban record)
|
|
26
30
|
if should_auto_block_rate_limit?(ip_address)
|
|
27
31
|
Beskar::BannedIp.ban!(
|
|
28
32
|
ip_address,
|
|
29
|
-
reason:
|
|
33
|
+
reason: "rate_limit_abuse",
|
|
30
34
|
duration: 1.hour,
|
|
31
|
-
details:
|
|
35
|
+
details: "Excessive rate limit violations"
|
|
32
36
|
)
|
|
33
37
|
end
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
|
|
39
|
+
if Beskar.configuration.monitor_only?
|
|
40
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block rate limit exceeded for IP: #{ip_address}, but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
41
|
+
else
|
|
42
|
+
Beskar::Logger.warn("Rate limit exceeded for IP: #{ip_address}", component: :Middleware)
|
|
43
|
+
return rate_limit_response
|
|
44
|
+
end
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
# 4. Check WAF patterns (vulnerability scans)
|
|
39
48
|
if Beskar.configuration.waf_enabled?
|
|
49
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF enabled, analyzing request", component: :Middleware)
|
|
40
50
|
waf_analysis = Beskar::Services::Waf.analyze_request(request)
|
|
41
|
-
|
|
51
|
+
|
|
42
52
|
if waf_analysis
|
|
53
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF detected threat: #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")}", component: :Middleware)
|
|
43
54
|
# Log the violation (and create security event if configured)
|
|
44
55
|
# Pass whitelist status to prevent auto-blocking whitelisted IPs
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
current_score = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
|
|
57
|
+
Beskar::Logger.debug("[RequestAnalyzer] Current score after recording: #{current_score.round(2)}", component: :Middleware)
|
|
58
|
+
|
|
47
59
|
# Log even for whitelisted IPs (but don't block)
|
|
48
60
|
if is_whitelisted
|
|
49
|
-
|
|
50
|
-
"[
|
|
51
|
-
"(not blocking): #{waf_analysis[:patterns].map { |p| p[:description] }.join(', ')}"
|
|
52
|
-
)
|
|
61
|
+
Beskar::Logger.info("WAF violation from whitelisted IP #{ip_address} " \
|
|
62
|
+
"(not blocking): #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")}", component: :Middleware)
|
|
53
63
|
else
|
|
54
64
|
# Check if we should block
|
|
55
65
|
should_block = Beskar::Services::Waf.should_block?(ip_address)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
Beskar::Logger.debug("[RequestAnalyzer] Should block IP #{ip_address}?: #{should_block}", component: :Middleware)
|
|
67
|
+
|
|
68
|
+
if Beskar.configuration.monitor_only?
|
|
69
|
+
# Monitor-only mode: Just log, don't block (but ban record was created by WAF.record_violation)
|
|
59
70
|
if should_block
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"Request proceeding normally."
|
|
64
|
-
)
|
|
71
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
|
|
72
|
+
"with score #{current_score.round(2)}, but monitor_only=true. " \
|
|
73
|
+
"Request proceeding normally.", component: :Middleware)
|
|
65
74
|
end
|
|
66
|
-
elsif should_block
|
|
67
|
-
# Actually block the request
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
"after #{violation_count} WAF violations"
|
|
71
|
-
)
|
|
75
|
+
elsif should_block && !Beskar.configuration.monitor_only?
|
|
76
|
+
# Actually block the request (not in monitor-only mode)
|
|
77
|
+
Beskar::Logger.warn("🔒 Blocking IP #{ip_address} " \
|
|
78
|
+
"with WAF score #{current_score.round(2)}", component: :Middleware)
|
|
72
79
|
# Block already handled by WAF.record_violation auto-block logic
|
|
73
80
|
# But we return 403 immediately
|
|
74
81
|
return blocked_response("Access denied due to suspicious activity.")
|
|
75
82
|
end
|
|
76
83
|
end
|
|
84
|
+
else
|
|
85
|
+
Beskar::Logger.debug("[RequestAnalyzer] No WAF threat detected for path: #{request.path}", component: :Middleware)
|
|
77
86
|
end
|
|
87
|
+
else
|
|
88
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF is disabled", component: :Middleware)
|
|
78
89
|
end
|
|
79
90
|
|
|
80
91
|
# 5. Process the request normally (will raise 404 if route not found)
|
|
92
|
+
Beskar::Logger.debug("[RequestAnalyzer] Passing request to application", component: :Middleware)
|
|
81
93
|
@app.call(env)
|
|
94
|
+
rescue ActionController::UnknownFormat => e
|
|
95
|
+
# Analyze unknown format as potential scanner
|
|
96
|
+
if Beskar.configuration.waf_enabled?
|
|
97
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
98
|
+
end
|
|
99
|
+
# Re-raise to allow normal error handling
|
|
100
|
+
raise
|
|
101
|
+
rescue ActionDispatch::RemoteIp::IpSpoofAttackError => e
|
|
102
|
+
# Handle IP spoofing attack
|
|
103
|
+
if Beskar.configuration.waf_enabled?
|
|
104
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
105
|
+
end
|
|
106
|
+
# Re-raise to allow normal error handling
|
|
107
|
+
raise
|
|
108
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
109
|
+
# Analyze record not found as potential enumeration scan
|
|
110
|
+
if Beskar.configuration.waf_enabled?
|
|
111
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
112
|
+
end
|
|
113
|
+
# Re-raise to allow normal error handling
|
|
114
|
+
raise
|
|
115
|
+
rescue ActionDispatch::Http::MimeNegotiation::InvalidType => e
|
|
116
|
+
# Analyze invalid MIME type as potential scanner
|
|
117
|
+
if Beskar.configuration.waf_enabled?
|
|
118
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
119
|
+
end
|
|
120
|
+
# Re-raise to allow normal error handling
|
|
121
|
+
raise
|
|
82
122
|
rescue ActionController::RoutingError => e
|
|
83
123
|
# If WAF is enabled, log 404s as potential scanning attempts
|
|
84
124
|
if Beskar.configuration.waf_enabled?
|
|
@@ -94,7 +134,7 @@ module Beskar
|
|
|
94
134
|
# Check both IP rate limit and authentication abuse
|
|
95
135
|
ip_check = Beskar::Services::RateLimiter.check_ip_rate_limit(request.ip)
|
|
96
136
|
auth_abused = authentication_brute_force?(request.ip)
|
|
97
|
-
|
|
137
|
+
|
|
98
138
|
!ip_check[:allowed] || auth_abused
|
|
99
139
|
end
|
|
100
140
|
|
|
@@ -104,7 +144,7 @@ module Beskar
|
|
|
104
144
|
violations = Rails.cache.read(cache_key) || 0
|
|
105
145
|
violations += 1
|
|
106
146
|
Rails.cache.write(cache_key, violations, expires_in: 1.hour)
|
|
107
|
-
|
|
147
|
+
|
|
108
148
|
# Block after 5 rate limit violations in an hour
|
|
109
149
|
violations >= 5
|
|
110
150
|
end
|
|
@@ -112,53 +152,88 @@ module Beskar
|
|
|
112
152
|
def authentication_brute_force?(ip_address)
|
|
113
153
|
# Check authentication failure count from RateLimiter
|
|
114
154
|
cache_key = "beskar:ip_auth_failures:#{ip_address}"
|
|
115
|
-
|
|
155
|
+
|
|
116
156
|
# Get current failure count and timestamp
|
|
117
157
|
failure_data = Rails.cache.read(cache_key)
|
|
118
158
|
return false unless failure_data.is_a?(Hash)
|
|
119
|
-
|
|
159
|
+
|
|
120
160
|
# Count recent failures (within the configured period)
|
|
121
161
|
config = Beskar.configuration.rate_limiting[:ip_attempts] || {}
|
|
122
162
|
period = config[:period] || 1.hour
|
|
123
163
|
limit = config[:limit] || 10
|
|
124
|
-
|
|
164
|
+
|
|
125
165
|
now = Time.current.to_i
|
|
126
166
|
recent_failures = failure_data.select { |timestamp, _| now - timestamp.to_i < period.to_i }
|
|
127
|
-
|
|
167
|
+
|
|
128
168
|
# If too many auth failures, it's brute force
|
|
129
169
|
if recent_failures.length >= limit
|
|
130
|
-
# Auto-ban for authentication abuse
|
|
170
|
+
# Auto-ban for authentication abuse (create ban record even in monitor-only mode)
|
|
131
171
|
Beskar::BannedIp.ban!(
|
|
132
172
|
ip_address,
|
|
133
|
-
reason:
|
|
173
|
+
reason: "authentication_abuse",
|
|
134
174
|
duration: 1.hour,
|
|
135
175
|
details: "#{recent_failures.length} failed authentication attempts in #{period / 60} minutes",
|
|
136
|
-
metadata: {
|
|
176
|
+
metadata: {failure_count: recent_failures.length, detection_time: Time.current}
|
|
137
177
|
)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
178
|
+
|
|
179
|
+
if Beskar.configuration.monitor_only?
|
|
180
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would auto-block IP #{ip_address} " \
|
|
181
|
+
"for authentication brute force (#{recent_failures.length} failures), but monitor_only=true", component: :Middleware)
|
|
182
|
+
else
|
|
183
|
+
Beskar::Logger.warn("🔒 Auto-blocked IP #{ip_address} " \
|
|
184
|
+
"for authentication brute force (#{recent_failures.length} failures)", component: :Middleware)
|
|
185
|
+
end
|
|
186
|
+
|
|
144
187
|
return true
|
|
145
188
|
end
|
|
146
|
-
|
|
189
|
+
|
|
147
190
|
false
|
|
148
191
|
end
|
|
149
192
|
|
|
193
|
+
def handle_rails_exception(request, exception, ip_address, is_whitelisted)
|
|
194
|
+
# Analyze the exception using WAF
|
|
195
|
+
waf_analysis = Beskar::Services::Waf.analyze_exception(exception, request)
|
|
196
|
+
|
|
197
|
+
if waf_analysis
|
|
198
|
+
Beskar::Logger.debug("[RequestAnalyzer] WAF detected threat from exception: #{exception.class.name}", component: :Middleware)
|
|
199
|
+
|
|
200
|
+
# Record the violation (similar to regular WAF violations)
|
|
201
|
+
current_score = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
|
|
202
|
+
|
|
203
|
+
# Log for whitelisted IPs
|
|
204
|
+
if is_whitelisted
|
|
205
|
+
Beskar::Logger.info("Exception-based WAF violation from whitelisted IP #{ip_address} " \
|
|
206
|
+
"(not blocking): #{exception.class.name} - #{waf_analysis[:patterns].first[:description]}", component: :Middleware)
|
|
207
|
+
else
|
|
208
|
+
# Check if we should block
|
|
209
|
+
should_block = Beskar::Services::Waf.should_block?(ip_address)
|
|
210
|
+
|
|
211
|
+
if Beskar.configuration.monitor_only?
|
|
212
|
+
if should_block
|
|
213
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
|
|
214
|
+
"with score #{current_score.round(2)} (exception: #{exception.class.name}), " \
|
|
215
|
+
"but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
216
|
+
end
|
|
217
|
+
elsif should_block && !Beskar.configuration.monitor_only?
|
|
218
|
+
Beskar::Logger.warn("🔒 Blocking IP #{ip_address} " \
|
|
219
|
+
"with score #{current_score.round(2)} (exception: #{exception.class.name})", component: :Middleware)
|
|
220
|
+
# Note: We don't return blocked response here as exception is already raised
|
|
221
|
+
# The ban record is created by WAF.record_violation
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
150
227
|
def log_404_for_waf(request, error)
|
|
151
228
|
# 404s on suspicious paths might indicate scanning
|
|
152
229
|
path = request.fullpath || request.path
|
|
153
|
-
|
|
230
|
+
|
|
154
231
|
# Only log if it matches WAF patterns (already analyzed in analyze_request)
|
|
155
232
|
waf_analysis = Beskar::Services::Waf.analyze_request(request)
|
|
156
|
-
|
|
233
|
+
|
|
157
234
|
if waf_analysis
|
|
158
|
-
|
|
159
|
-
"[
|
|
160
|
-
"(WAF patterns: #{waf_analysis[:patterns].map { |p| p[:description] }.join(', ')})"
|
|
161
|
-
)
|
|
235
|
+
Beskar::Logger.info("404 on suspicious path from #{request.ip}: #{path} " \
|
|
236
|
+
"(WAF patterns: #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")})", component: :Middleware)
|
|
162
237
|
end
|
|
163
238
|
end
|
|
164
239
|
|