beskar 0.1.0 → 0.2.1
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 +177 -19
- data/README.md +142 -122
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +69 -25
- data/app/controllers/beskar/banned_ips_controller.rb +116 -141
- data/app/controllers/beskar/dashboard_controller.rb +20 -28
- data/app/controllers/beskar/security_events_controller.rb +37 -55
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +91 -132
- data/app/models/beskar/security_event.rb +37 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +16 -6
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +25 -89
- data/app/views/beskar/banned_ips/index.html.erb +20 -62
- data/app/views/beskar/banned_ips/new.html.erb +18 -138
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +18 -15
- data/app/views/beskar/dashboard/index.html.erb +6 -6
- data/app/views/beskar/security_events/index.html.erb +18 -16
- data/app/views/beskar/security_events/show.html.erb +10 -20
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/beskar/shared/_waf_analysis.html.erb +25 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +9 -76
- data/config/routes.rb +10 -21
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +188 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +84 -13
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +30 -35
- data/lib/beskar/middleware/request_analyzer.rb +49 -82
- data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
- data/lib/beskar/models/security_trackable_devise.rb +32 -23
- data/lib/beskar/models/security_trackable_generic.rb +169 -212
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +85 -76
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +148 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +103 -125
- data/lib/beskar/services/request_context.rb +69 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +166 -280
- data/lib/beskar/services/waf_request.rb +70 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +53 -4
- data/lib/generators/beskar/install/install_generator.rb +36 -36
- data/lib/generators/beskar/install/templates/initializer.rb.tt +105 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- metadata +61 -8
data/lib/beskar/engine.rb
CHANGED
|
@@ -6,105 +6,38 @@ module Beskar
|
|
|
6
6
|
app.config.middleware.use ::Beskar::Middleware::RequestAnalyzer
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
Beskar::BannedIp.preload_cache!
|
|
14
|
-
Beskar::Logger.info("Preloaded banned IPs into cache")
|
|
15
|
-
rescue => e
|
|
16
|
-
Beskar::Logger.warn("Failed to preload banned IPs: #{e.message}")
|
|
17
|
-
end
|
|
9
|
+
initializer "beskar.warden_callbacks", after: :load_config_initializers do
|
|
10
|
+
if defined?(Devise)
|
|
11
|
+
require "devise/strategies/database_authenticatable"
|
|
12
|
+
Devise::Strategies::DatabaseAuthenticatable.prepend(Beskar::DeviseAuthentication)
|
|
18
13
|
end
|
|
19
|
-
end
|
|
20
14
|
|
|
21
|
-
initializer "beskar.warden_callbacks", after: :load_config_initializers do |app|
|
|
22
15
|
if defined?(Warden)
|
|
23
|
-
|
|
24
|
-
Warden::
|
|
25
|
-
# Only proceed if Beskar security tracking is available and enabled
|
|
26
|
-
if user.respond_to?(:track_authentication_event) && auth.request
|
|
27
|
-
# Track the authentication event (creates security event)
|
|
28
|
-
security_event = user.track_authentication_event(auth.request, :success)
|
|
29
|
-
|
|
30
|
-
# Check if account was locked due to high risk (only if immediate_signout is enabled)
|
|
31
|
-
# This happens AFTER successful authentication but BEFORE the request completes
|
|
32
|
-
# Requires :lockable module to be enabled on the user model
|
|
33
|
-
if Beskar.configuration.immediate_signout? &&
|
|
34
|
-
Beskar.configuration.risk_based_locking_enabled? &&
|
|
35
|
-
security_event &&
|
|
36
|
-
user_was_just_locked?(user, security_event) &&
|
|
37
|
-
user.respond_to?(:access_locked?) && user.access_locked?
|
|
38
|
-
Beskar::Logger.warn("Signing out user #{user.id} due to high-risk lock")
|
|
39
|
-
auth.logout
|
|
40
|
-
throw :warden, scope: opts[:scope], message: :account_locked_due_to_high_risk
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Alternative approach using after_authentication is available but not enabled by default
|
|
46
|
-
# Uncomment this to use the alternative approach (more targeted, only on authentication)
|
|
47
|
-
# Warden::Manager.after_authentication do |user, auth, opts|
|
|
48
|
-
# if user.respond_to?(:check_high_risk_lock_and_signout)
|
|
49
|
-
# user.check_high_risk_lock_and_signout(auth)
|
|
50
|
-
# end
|
|
51
|
-
# end
|
|
16
|
+
Warden::Strategies::Base.prepend(Beskar::WardenStrategyAdmission)
|
|
17
|
+
Warden::Proxy.prepend(Beskar::WardenSessionAdmission)
|
|
52
18
|
|
|
53
19
|
Warden::Manager.before_failure do |env, opts|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if model_class && model_class.respond_to?(:track_failed_authentication)
|
|
62
|
-
model_class.track_failed_authentication(request, scope)
|
|
63
|
-
else
|
|
64
|
-
Beskar::Logger.debug("No trackable model found for scope: #{scope}")
|
|
65
|
-
end
|
|
66
|
-
end
|
|
20
|
+
next unless env
|
|
21
|
+
request = ActionDispatch::Request.new(env)
|
|
22
|
+
attempt = Services::AuthenticationAttempt.current(request, opts[:scope])
|
|
23
|
+
next unless attempt && !attempt.completed
|
|
24
|
+
model = Beskar.configuration.model_class_for_scope(opts[:scope])
|
|
25
|
+
model.track_failed_authentication(request, opts[:scope], attempt: attempt) if model&.respond_to?(:track_failed_authentication)
|
|
67
26
|
end
|
|
68
27
|
end
|
|
69
28
|
end
|
|
70
29
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return false unless user&.respond_to?(:security_events)
|
|
76
|
-
|
|
77
|
-
# Check if an account_locked or lock_attempted event was just created
|
|
78
|
-
recent_lock = user.security_events
|
|
79
|
-
.where(event_type: [ "account_locked", "lock_attempted" ])
|
|
80
|
-
.where("created_at >= ?", 10.seconds.ago)
|
|
81
|
-
.order(created_at: :desc)
|
|
82
|
-
.first
|
|
83
|
-
|
|
84
|
-
recent_lock.present?
|
|
30
|
+
initializer "beskar.register_configuration_validation", after: :load_config_initializers do |app|
|
|
31
|
+
# Register after host configuration files, so their after_initialize hooks
|
|
32
|
+
# precede this one. Resolve app/jobs only after the main autoloader is ready.
|
|
33
|
+
app.config.after_initialize { Beskar.configuration.validate!.seal! }
|
|
85
34
|
end
|
|
86
35
|
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# Add to Rails paths
|
|
94
|
-
app.config.paths["db/migrate"] << engine_migrations
|
|
95
|
-
end
|
|
36
|
+
# Compatibility helper: only the current attempt's actual lock qualifies.
|
|
37
|
+
def self.user_was_just_locked?(user, security_event)
|
|
38
|
+
return false unless Beskar.configuration.risk_based_locking_enabled?
|
|
39
|
+
attempt = security_event&.beskar_attempt
|
|
40
|
+
!!(attempt && attempt.user == user && attempt.locked_now)
|
|
96
41
|
end
|
|
97
|
-
|
|
98
|
-
# Ensure ActiveRecord sees the engine migrations after initialization
|
|
99
|
-
# config.after_initialize do |app|
|
|
100
|
-
# unless root.to_s.include?(app.root.to_s)
|
|
101
|
-
# engine_migrations = root.join("db", "migrate").to_s
|
|
102
|
-
|
|
103
|
-
# # Update ActiveRecord::Tasks paths
|
|
104
|
-
# current_paths = Array(ActiveRecord::Tasks::DatabaseTasks.migrations_paths)
|
|
105
|
-
# current_paths << engine_migrations
|
|
106
|
-
# ActiveRecord::Tasks::DatabaseTasks.migrations_paths = current_paths.uniq
|
|
107
|
-
# end
|
|
108
|
-
# end
|
|
109
42
|
end
|
|
110
43
|
end
|
data/lib/beskar/logger.rb
CHANGED
|
@@ -36,18 +36,15 @@ module Beskar
|
|
|
36
36
|
|
|
37
37
|
formatted_message = format_message(message, component)
|
|
38
38
|
logger.send(level, formatted_message)
|
|
39
|
-
rescue
|
|
39
|
+
rescue => e
|
|
40
40
|
# Fallback to stderr if logging fails
|
|
41
|
-
|
|
42
|
-
$stderr.puts "[Beskar::Logger] Original message: #{formatted_message}"
|
|
41
|
+
Kernel.warn "[Beskar::Logger] Logging unavailable (#{e.class})"
|
|
43
42
|
end
|
|
44
43
|
|
|
45
44
|
# Configure the logger instance
|
|
46
45
|
#
|
|
47
46
|
# @param logger_instance [Logger, nil] The logger to use, defaults to Rails.logger
|
|
48
|
-
|
|
49
|
-
@logger = logger_instance
|
|
50
|
-
end
|
|
47
|
+
attr_writer :logger
|
|
51
48
|
|
|
52
49
|
# Get the current logger instance
|
|
53
50
|
#
|
|
@@ -86,9 +83,7 @@ module Beskar
|
|
|
86
83
|
# 'Beskar::Services::Waf' => 'WAF',
|
|
87
84
|
# 'Beskar::Services::AccountLocker' => 'AccountLocker'
|
|
88
85
|
# }
|
|
89
|
-
|
|
90
|
-
@component_aliases = aliases
|
|
91
|
-
end
|
|
86
|
+
attr_writer :component_aliases
|
|
92
87
|
|
|
93
88
|
# Get component aliases
|
|
94
89
|
#
|
|
@@ -156,16 +151,16 @@ module Beskar
|
|
|
156
151
|
return aliased if aliased
|
|
157
152
|
|
|
158
153
|
# Remove Beskar:: prefix if present for lookup
|
|
159
|
-
clean_name = component_name.sub(/^Beskar::/,
|
|
154
|
+
clean_name = component_name.sub(/^Beskar::/, "")
|
|
160
155
|
aliased = component_aliases[clean_name]
|
|
161
156
|
return aliased if aliased
|
|
162
157
|
|
|
163
158
|
# Check if it's already a simple component name (no ::)
|
|
164
|
-
return clean_name unless clean_name.include?(
|
|
159
|
+
return clean_name unless clean_name.include?("::")
|
|
165
160
|
|
|
166
161
|
# Extract the last component for nested classes
|
|
167
162
|
# e.g., "Beskar::Services::Waf" -> "Waf"
|
|
168
|
-
last_component = clean_name.split(
|
|
163
|
+
last_component = clean_name.split("::").last
|
|
169
164
|
component_aliases[clean_name] || last_component
|
|
170
165
|
end
|
|
171
166
|
|
|
@@ -192,7 +187,7 @@ module Beskar
|
|
|
192
187
|
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
193
188
|
Rails.logger
|
|
194
189
|
else
|
|
195
|
-
require
|
|
190
|
+
require "logger"
|
|
196
191
|
::Logger.new($stdout)
|
|
197
192
|
end
|
|
198
193
|
end
|
|
@@ -202,28 +197,28 @@ module Beskar
|
|
|
202
197
|
# @return [Hash] Default aliases
|
|
203
198
|
def default_component_aliases
|
|
204
199
|
{
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
200
|
+
"Beskar::Services::Waf" => "WAF",
|
|
201
|
+
"Beskar::Services::WAF" => "WAF",
|
|
202
|
+
"Services::Waf" => "WAF",
|
|
203
|
+
"Services::WAF" => "WAF",
|
|
204
|
+
"Beskar::Services::AccountLocker" => "AccountLocker",
|
|
205
|
+
"Services::AccountLocker" => "AccountLocker",
|
|
206
|
+
"Beskar::Services::RateLimiter" => "RateLimiter",
|
|
207
|
+
"Services::RateLimiter" => "RateLimiter",
|
|
208
|
+
"Beskar::Services::IpWhitelist" => "IpWhitelist",
|
|
209
|
+
"Services::IpWhitelist" => "IpWhitelist",
|
|
210
|
+
"Beskar::Services::GeolocationService" => "GeolocationService",
|
|
211
|
+
"Services::GeolocationService" => "GeolocationService",
|
|
212
|
+
"Beskar::Services::DeviceDetector" => "DeviceDetector",
|
|
213
|
+
"Services::DeviceDetector" => "DeviceDetector",
|
|
214
|
+
"Beskar::Middleware::RequestAnalyzer" => "Middleware",
|
|
215
|
+
"Middleware::RequestAnalyzer" => "Middleware",
|
|
216
|
+
"Beskar::Models::SecurityTrackableDevise" => "SecurityTracking",
|
|
217
|
+
"Models::SecurityTrackableDevise" => "SecurityTracking",
|
|
218
|
+
"Beskar::Models::SecurityTrackableAuthenticable" => "SecurityTracking",
|
|
219
|
+
"Models::SecurityTrackableAuthenticable" => "SecurityTracking",
|
|
220
|
+
"Beskar::Models::SecurityTrackableGeneric" => "SecurityTracking",
|
|
221
|
+
"Models::SecurityTrackableGeneric" => "SecurityTracking"
|
|
227
222
|
}
|
|
228
223
|
end
|
|
229
224
|
end
|
|
@@ -7,9 +7,15 @@ module Beskar
|
|
|
7
7
|
|
|
8
8
|
def call(env)
|
|
9
9
|
request = ActionDispatch::Request.new(env)
|
|
10
|
-
ip_address =
|
|
10
|
+
ip_address = Beskar::Services::RequestContext.ip(request)
|
|
11
11
|
|
|
12
|
-
Beskar::Logger.debug("[RequestAnalyzer] Processing request from IP: #{ip_address}
|
|
12
|
+
Beskar::Logger.debug("[RequestAnalyzer] Processing request from IP: #{ip_address}", component: :Middleware)
|
|
13
|
+
|
|
14
|
+
# A request with no usable address has nothing to ban or score.
|
|
15
|
+
unless ip_address
|
|
16
|
+
processing_host = true
|
|
17
|
+
return @app.call(env)
|
|
18
|
+
end
|
|
13
19
|
|
|
14
20
|
# 1. Check if IP is whitelisted (whitelisted IPs skip blocking but still get logged)
|
|
15
21
|
is_whitelisted = Beskar::Services::IpWhitelist.whitelisted?(ip_address)
|
|
@@ -25,9 +31,11 @@ module Beskar
|
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
# 3. Check rate limiting (unless whitelisted)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
rate_limit = Beskar.configuration.rate_limiting[:ip_attempts][:block_requests] ?
|
|
35
|
+
Beskar::Services::RateLimiter.check_ip_rate_limit(ip_address) : {allowed: true}
|
|
36
|
+
if !is_whitelisted && !rate_limit[:allowed]
|
|
37
|
+
# Observe denials separately in monitor mode; never create active bans.
|
|
38
|
+
if should_auto_block_rate_limit?(ip_address) && !Beskar.configuration.monitor_only?
|
|
31
39
|
Beskar::BannedIp.ban!(
|
|
32
40
|
ip_address,
|
|
33
41
|
reason: "rate_limit_abuse",
|
|
@@ -40,7 +48,7 @@ module Beskar
|
|
|
40
48
|
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block rate limit exceeded for IP: #{ip_address}, but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
41
49
|
else
|
|
42
50
|
Beskar::Logger.warn("Rate limit exceeded for IP: #{ip_address}", component: :Middleware)
|
|
43
|
-
return rate_limit_response
|
|
51
|
+
return rate_limit_response(rate_limit[:retry_after])
|
|
44
52
|
end
|
|
45
53
|
end
|
|
46
54
|
|
|
@@ -54,6 +62,7 @@ module Beskar
|
|
|
54
62
|
# Log the violation (and create security event if configured)
|
|
55
63
|
# Pass whitelist status to prevent auto-blocking whitelisted IPs
|
|
56
64
|
current_score = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
|
|
65
|
+
waf_recorded = true
|
|
57
66
|
Beskar::Logger.debug("[RequestAnalyzer] Current score after recording: #{current_score.round(2)}", component: :Middleware)
|
|
58
67
|
|
|
59
68
|
# Log even for whitelisted IPs (but don't block)
|
|
@@ -66,7 +75,7 @@ module Beskar
|
|
|
66
75
|
Beskar::Logger.debug("[RequestAnalyzer] Should block IP #{ip_address}?: #{should_block}", component: :Middleware)
|
|
67
76
|
|
|
68
77
|
if Beskar.configuration.monitor_only?
|
|
69
|
-
# Monitor-only mode
|
|
78
|
+
# Monitor-only mode records observations without creating active bans.
|
|
70
79
|
if should_block
|
|
71
80
|
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
|
|
72
81
|
"with score #{current_score.round(2)}, but monitor_only=true. " \
|
|
@@ -82,7 +91,7 @@ module Beskar
|
|
|
82
91
|
end
|
|
83
92
|
end
|
|
84
93
|
else
|
|
85
|
-
Beskar::Logger.debug("[RequestAnalyzer] No WAF threat detected
|
|
94
|
+
Beskar::Logger.debug("[RequestAnalyzer] No WAF threat detected", component: :Middleware)
|
|
86
95
|
end
|
|
87
96
|
else
|
|
88
97
|
Beskar::Logger.debug("[RequestAnalyzer] WAF is disabled", component: :Middleware)
|
|
@@ -90,32 +99,33 @@ module Beskar
|
|
|
90
99
|
|
|
91
100
|
# 5. Process the request normally (will raise 404 if route not found)
|
|
92
101
|
Beskar::Logger.debug("[RequestAnalyzer] Passing request to application", component: :Middleware)
|
|
102
|
+
processing_host = true
|
|
93
103
|
@app.call(env)
|
|
104
|
+
rescue Beskar::Services::AuthenticationAttempt::Unavailable
|
|
105
|
+
Beskar::Services::AuthenticationAttempt.unavailable_response
|
|
94
106
|
rescue ActionController::UnknownFormat => e
|
|
95
107
|
# Analyze unknown format as potential scanner
|
|
96
108
|
if Beskar.configuration.waf_enabled?
|
|
97
|
-
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
109
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
98
110
|
end
|
|
99
111
|
# Re-raise to allow normal error handling
|
|
100
112
|
raise
|
|
101
113
|
rescue ActionDispatch::RemoteIp::IpSpoofAttackError => e
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
end
|
|
106
|
-
# Re-raise to allow normal error handling
|
|
114
|
+
# Attribute downstream errors only when Rails already resolved a trusted
|
|
115
|
+
# client IP. Never ban an address from a rejected proxy chain.
|
|
116
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) if !waf_recorded && ip_address && Beskar.configuration.waf_enabled?
|
|
107
117
|
raise
|
|
108
118
|
rescue ActiveRecord::RecordNotFound => e
|
|
109
119
|
# Analyze record not found as potential enumeration scan
|
|
110
120
|
if Beskar.configuration.waf_enabled?
|
|
111
|
-
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
121
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
112
122
|
end
|
|
113
123
|
# Re-raise to allow normal error handling
|
|
114
124
|
raise
|
|
115
125
|
rescue ActionDispatch::Http::MimeNegotiation::InvalidType => e
|
|
116
126
|
# Analyze invalid MIME type as potential scanner
|
|
117
127
|
if Beskar.configuration.waf_enabled?
|
|
118
|
-
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
128
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
119
129
|
end
|
|
120
130
|
# Re-raise to allow normal error handling
|
|
121
131
|
raise
|
|
@@ -126,71 +136,31 @@ module Beskar
|
|
|
126
136
|
end
|
|
127
137
|
# Re-raise to allow normal 404 handling
|
|
128
138
|
raise
|
|
139
|
+
rescue ActiveRecord::ActiveRecordError => error
|
|
140
|
+
raise if processing_host
|
|
141
|
+
Beskar::Logger.warn("Request security state unavailable (#{error.class})")
|
|
142
|
+
Beskar::Services::AuthenticationAttempt.unavailable_response
|
|
129
143
|
end
|
|
130
144
|
|
|
131
145
|
private
|
|
132
146
|
|
|
133
|
-
def rate_limited?(request)
|
|
134
|
-
# Check both IP rate limit and authentication abuse
|
|
135
|
-
ip_check = Beskar::Services::RateLimiter.check_ip_rate_limit(request.ip)
|
|
136
|
-
auth_abused = authentication_brute_force?(request.ip)
|
|
137
|
-
|
|
138
|
-
!ip_check[:allowed] || auth_abused
|
|
139
|
-
end
|
|
140
|
-
|
|
141
147
|
def should_auto_block_rate_limit?(ip_address)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def authentication_brute_force?(ip_address)
|
|
153
|
-
# Check authentication failure count from RateLimiter
|
|
154
|
-
cache_key = "beskar:ip_auth_failures:#{ip_address}"
|
|
155
|
-
|
|
156
|
-
# Get current failure count and timestamp
|
|
157
|
-
failure_data = Rails.cache.read(cache_key)
|
|
158
|
-
return false unless failure_data.is_a?(Hash)
|
|
159
|
-
|
|
160
|
-
# Count recent failures (within the configured period)
|
|
161
|
-
config = Beskar.configuration.rate_limiting[:ip_attempts] || {}
|
|
162
|
-
period = config[:period] || 1.hour
|
|
163
|
-
limit = config[:limit] || 10
|
|
164
|
-
|
|
165
|
-
now = Time.current.to_i
|
|
166
|
-
recent_failures = failure_data.select { |timestamp, _| now - timestamp.to_i < period.to_i }
|
|
167
|
-
|
|
168
|
-
# If too many auth failures, it's brute force
|
|
169
|
-
if recent_failures.length >= limit
|
|
170
|
-
# Auto-ban for authentication abuse (create ban record even in monitor-only mode)
|
|
171
|
-
Beskar::BannedIp.ban!(
|
|
172
|
-
ip_address,
|
|
173
|
-
reason: "authentication_abuse",
|
|
174
|
-
duration: 1.hour,
|
|
175
|
-
details: "#{recent_failures.length} failed authentication attempts in #{period / 60} minutes",
|
|
176
|
-
metadata: {failure_count: recent_failures.length, detection_time: Time.current}
|
|
177
|
-
)
|
|
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
|
-
|
|
187
|
-
return true
|
|
148
|
+
mode = Beskar.configuration.monitor_only? ? "observe" : "enforce"
|
|
149
|
+
key = "rate_denials:#{mode}:#{ip_address}"
|
|
150
|
+
Beskar::SecurityState.mutate(key, ttl: 1.hour) do |state|
|
|
151
|
+
data = state.fetch(key)
|
|
152
|
+
now = Time.current.to_f
|
|
153
|
+
# A fixed window: repeated requests cannot prolong old violations.
|
|
154
|
+
data.clear if data["window_end"] && data["window_end"] <= now
|
|
155
|
+
data["window_end"] ||= now + 1.hour
|
|
156
|
+
data["count"] = [data.fetch("count", 0) + 1, 5].min
|
|
157
|
+
data["count"] >= 5
|
|
188
158
|
end
|
|
189
|
-
|
|
190
|
-
false
|
|
191
159
|
end
|
|
192
160
|
|
|
193
161
|
def handle_rails_exception(request, exception, ip_address, is_whitelisted)
|
|
162
|
+
return unless ip_address
|
|
163
|
+
|
|
194
164
|
# Analyze the exception using WAF
|
|
195
165
|
waf_analysis = Beskar::Services::Waf.analyze_exception(exception, request)
|
|
196
166
|
|
|
@@ -225,14 +195,11 @@ module Beskar
|
|
|
225
195
|
end
|
|
226
196
|
|
|
227
197
|
def log_404_for_waf(request, error)
|
|
228
|
-
# 404s on suspicious paths might indicate scanning
|
|
229
|
-
path = request.fullpath || request.path
|
|
230
|
-
|
|
231
198
|
# Only log if it matches WAF patterns (already analyzed in analyze_request)
|
|
232
199
|
waf_analysis = Beskar::Services::Waf.analyze_request(request)
|
|
233
200
|
|
|
234
201
|
if waf_analysis
|
|
235
|
-
Beskar::Logger.info("404
|
|
202
|
+
Beskar::Logger.info("404 matching WAF rules from #{Beskar::Services::RequestContext.ip(request)} " \
|
|
236
203
|
"(WAF patterns: #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")})", component: :Middleware)
|
|
237
204
|
end
|
|
238
205
|
end
|
|
@@ -241,20 +208,20 @@ module Beskar
|
|
|
241
208
|
[
|
|
242
209
|
403,
|
|
243
210
|
{
|
|
244
|
-
"
|
|
245
|
-
"
|
|
211
|
+
"content-type" => "text/html; charset=utf-8",
|
|
212
|
+
"x-beskar-blocked" => "true"
|
|
246
213
|
},
|
|
247
214
|
[render_blocked_page(message)]
|
|
248
215
|
]
|
|
249
216
|
end
|
|
250
217
|
|
|
251
|
-
def rate_limit_response
|
|
218
|
+
def rate_limit_response(retry_after)
|
|
252
219
|
[
|
|
253
220
|
429,
|
|
254
221
|
{
|
|
255
|
-
"
|
|
256
|
-
"
|
|
257
|
-
"
|
|
222
|
+
"content-type" => "text/html; charset=utf-8",
|
|
223
|
+
"retry-after" => [retry_after.to_i, 1].max.to_s,
|
|
224
|
+
"x-beskar-rate-limited" => "true"
|
|
258
225
|
},
|
|
259
226
|
[render_rate_limit_page]
|
|
260
227
|
]
|