beskar 0.1.0 → 0.2.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 +150 -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 +9 -15
- data/app/views/beskar/dashboard/index.html.erb +4 -4
- data/app/views/beskar/security_events/index.html.erb +8 -15
- data/app/views/beskar/security_events/show.html.erb +6 -20
- data/app/views/beskar/shared/_export_form.html.erb +10 -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 +175 -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 +41 -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 +141 -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 +99 -125
- data/lib/beskar/services/request_context.rb +64 -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 +164 -280
- data/lib/beskar/services/waf_request.rb +60 -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 +104 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- metadata +60 -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,9 @@ 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
13
|
|
|
14
14
|
# 1. Check if IP is whitelisted (whitelisted IPs skip blocking but still get logged)
|
|
15
15
|
is_whitelisted = Beskar::Services::IpWhitelist.whitelisted?(ip_address)
|
|
@@ -25,9 +25,11 @@ module Beskar
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# 3. Check rate limiting (unless whitelisted)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
rate_limit = Beskar.configuration.rate_limiting[:ip_attempts][:block_requests] ?
|
|
29
|
+
Beskar::Services::RateLimiter.check_ip_rate_limit(ip_address) : {allowed: true}
|
|
30
|
+
if !is_whitelisted && !rate_limit[:allowed]
|
|
31
|
+
# Observe denials separately in monitor mode; never create active bans.
|
|
32
|
+
if should_auto_block_rate_limit?(ip_address) && !Beskar.configuration.monitor_only?
|
|
31
33
|
Beskar::BannedIp.ban!(
|
|
32
34
|
ip_address,
|
|
33
35
|
reason: "rate_limit_abuse",
|
|
@@ -40,7 +42,7 @@ module Beskar
|
|
|
40
42
|
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block rate limit exceeded for IP: #{ip_address}, but monitor_only=true. Request proceeding normally.", component: :Middleware)
|
|
41
43
|
else
|
|
42
44
|
Beskar::Logger.warn("Rate limit exceeded for IP: #{ip_address}", component: :Middleware)
|
|
43
|
-
return rate_limit_response
|
|
45
|
+
return rate_limit_response(rate_limit[:retry_after])
|
|
44
46
|
end
|
|
45
47
|
end
|
|
46
48
|
|
|
@@ -54,6 +56,7 @@ module Beskar
|
|
|
54
56
|
# Log the violation (and create security event if configured)
|
|
55
57
|
# Pass whitelist status to prevent auto-blocking whitelisted IPs
|
|
56
58
|
current_score = Beskar::Services::Waf.record_violation(ip_address, waf_analysis, whitelisted: is_whitelisted)
|
|
59
|
+
waf_recorded = true
|
|
57
60
|
Beskar::Logger.debug("[RequestAnalyzer] Current score after recording: #{current_score.round(2)}", component: :Middleware)
|
|
58
61
|
|
|
59
62
|
# Log even for whitelisted IPs (but don't block)
|
|
@@ -66,7 +69,7 @@ module Beskar
|
|
|
66
69
|
Beskar::Logger.debug("[RequestAnalyzer] Should block IP #{ip_address}?: #{should_block}", component: :Middleware)
|
|
67
70
|
|
|
68
71
|
if Beskar.configuration.monitor_only?
|
|
69
|
-
# Monitor-only mode
|
|
72
|
+
# Monitor-only mode records observations without creating active bans.
|
|
70
73
|
if should_block
|
|
71
74
|
Beskar::Logger.warn("🔍 MONITOR-ONLY: Would block IP #{ip_address} " \
|
|
72
75
|
"with score #{current_score.round(2)}, but monitor_only=true. " \
|
|
@@ -82,7 +85,7 @@ module Beskar
|
|
|
82
85
|
end
|
|
83
86
|
end
|
|
84
87
|
else
|
|
85
|
-
Beskar::Logger.debug("[RequestAnalyzer] No WAF threat detected
|
|
88
|
+
Beskar::Logger.debug("[RequestAnalyzer] No WAF threat detected", component: :Middleware)
|
|
86
89
|
end
|
|
87
90
|
else
|
|
88
91
|
Beskar::Logger.debug("[RequestAnalyzer] WAF is disabled", component: :Middleware)
|
|
@@ -90,32 +93,33 @@ module Beskar
|
|
|
90
93
|
|
|
91
94
|
# 5. Process the request normally (will raise 404 if route not found)
|
|
92
95
|
Beskar::Logger.debug("[RequestAnalyzer] Passing request to application", component: :Middleware)
|
|
96
|
+
processing_host = true
|
|
93
97
|
@app.call(env)
|
|
98
|
+
rescue Beskar::Services::AuthenticationAttempt::Unavailable
|
|
99
|
+
Beskar::Services::AuthenticationAttempt.unavailable_response
|
|
94
100
|
rescue ActionController::UnknownFormat => e
|
|
95
101
|
# Analyze unknown format as potential scanner
|
|
96
102
|
if Beskar.configuration.waf_enabled?
|
|
97
|
-
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
103
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
98
104
|
end
|
|
99
105
|
# Re-raise to allow normal error handling
|
|
100
106
|
raise
|
|
101
107
|
rescue ActionDispatch::RemoteIp::IpSpoofAttackError => e
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
end
|
|
106
|
-
# Re-raise to allow normal error handling
|
|
108
|
+
# Attribute downstream errors only when Rails already resolved a trusted
|
|
109
|
+
# client IP. Never ban an address from a rejected proxy chain.
|
|
110
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) if !waf_recorded && ip_address && Beskar.configuration.waf_enabled?
|
|
107
111
|
raise
|
|
108
112
|
rescue ActiveRecord::RecordNotFound => e
|
|
109
113
|
# Analyze record not found as potential enumeration scan
|
|
110
114
|
if Beskar.configuration.waf_enabled?
|
|
111
|
-
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
115
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
112
116
|
end
|
|
113
117
|
# Re-raise to allow normal error handling
|
|
114
118
|
raise
|
|
115
119
|
rescue ActionDispatch::Http::MimeNegotiation::InvalidType => e
|
|
116
120
|
# Analyze invalid MIME type as potential scanner
|
|
117
121
|
if Beskar.configuration.waf_enabled?
|
|
118
|
-
handle_rails_exception(request, e, ip_address, is_whitelisted)
|
|
122
|
+
handle_rails_exception(request, e, ip_address, is_whitelisted) unless waf_recorded
|
|
119
123
|
end
|
|
120
124
|
# Re-raise to allow normal error handling
|
|
121
125
|
raise
|
|
@@ -126,68 +130,26 @@ module Beskar
|
|
|
126
130
|
end
|
|
127
131
|
# Re-raise to allow normal 404 handling
|
|
128
132
|
raise
|
|
133
|
+
rescue ActiveRecord::ActiveRecordError => error
|
|
134
|
+
raise if processing_host
|
|
135
|
+
Beskar::Logger.warn("Request security state unavailable (#{error.class})")
|
|
136
|
+
Beskar::Services::AuthenticationAttempt.unavailable_response
|
|
129
137
|
end
|
|
130
138
|
|
|
131
139
|
private
|
|
132
140
|
|
|
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
141
|
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
|
|
142
|
+
mode = Beskar.configuration.monitor_only? ? "observe" : "enforce"
|
|
143
|
+
key = "rate_denials:#{mode}:#{ip_address}"
|
|
144
|
+
Beskar::SecurityState.mutate(key, ttl: 1.hour) do |state|
|
|
145
|
+
data = state.fetch(key)
|
|
146
|
+
now = Time.current.to_f
|
|
147
|
+
# A fixed window: repeated requests cannot prolong old violations.
|
|
148
|
+
data.clear if data["window_end"] && data["window_end"] <= now
|
|
149
|
+
data["window_end"] ||= now + 1.hour
|
|
150
|
+
data["count"] = [data.fetch("count", 0) + 1, 5].min
|
|
151
|
+
data["count"] >= 5
|
|
188
152
|
end
|
|
189
|
-
|
|
190
|
-
false
|
|
191
153
|
end
|
|
192
154
|
|
|
193
155
|
def handle_rails_exception(request, exception, ip_address, is_whitelisted)
|
|
@@ -225,14 +187,11 @@ module Beskar
|
|
|
225
187
|
end
|
|
226
188
|
|
|
227
189
|
def log_404_for_waf(request, error)
|
|
228
|
-
# 404s on suspicious paths might indicate scanning
|
|
229
|
-
path = request.fullpath || request.path
|
|
230
|
-
|
|
231
190
|
# Only log if it matches WAF patterns (already analyzed in analyze_request)
|
|
232
191
|
waf_analysis = Beskar::Services::Waf.analyze_request(request)
|
|
233
192
|
|
|
234
193
|
if waf_analysis
|
|
235
|
-
Beskar::Logger.info("404
|
|
194
|
+
Beskar::Logger.info("404 matching WAF rules from #{Beskar::Services::RequestContext.ip(request)} " \
|
|
236
195
|
"(WAF patterns: #{waf_analysis[:patterns].map { |p| p[:description] }.join(", ")})", component: :Middleware)
|
|
237
196
|
end
|
|
238
197
|
end
|
|
@@ -241,20 +200,20 @@ module Beskar
|
|
|
241
200
|
[
|
|
242
201
|
403,
|
|
243
202
|
{
|
|
244
|
-
"
|
|
245
|
-
"
|
|
203
|
+
"content-type" => "text/html; charset=utf-8",
|
|
204
|
+
"x-beskar-blocked" => "true"
|
|
246
205
|
},
|
|
247
206
|
[render_blocked_page(message)]
|
|
248
207
|
]
|
|
249
208
|
end
|
|
250
209
|
|
|
251
|
-
def rate_limit_response
|
|
210
|
+
def rate_limit_response(retry_after)
|
|
252
211
|
[
|
|
253
212
|
429,
|
|
254
213
|
{
|
|
255
|
-
"
|
|
256
|
-
"
|
|
257
|
-
"
|
|
214
|
+
"content-type" => "text/html; charset=utf-8",
|
|
215
|
+
"retry-after" => [retry_after.to_i, 1].max.to_s,
|
|
216
|
+
"x-beskar-rate-limited" => "true"
|
|
258
217
|
},
|
|
259
218
|
[render_rate_limit_page]
|
|
260
219
|
]
|