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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +177 -19
  3. data/README.md +142 -122
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +69 -25
  7. data/app/controllers/beskar/banned_ips_controller.rb +116 -141
  8. data/app/controllers/beskar/dashboard_controller.rb +20 -28
  9. data/app/controllers/beskar/security_events_controller.rb +37 -55
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +91 -132
  17. data/app/models/beskar/security_event.rb +37 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +16 -6
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +25 -89
  23. data/app/views/beskar/banned_ips/index.html.erb +20 -62
  24. data/app/views/beskar/banned_ips/new.html.erb +18 -138
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +18 -15
  27. data/app/views/beskar/dashboard/index.html.erb +6 -6
  28. data/app/views/beskar/security_events/index.html.erb +18 -16
  29. data/app/views/beskar/security_events/show.html.erb +10 -20
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/beskar/shared/_waf_analysis.html.erb +25 -0
  32. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  33. data/app/views/layouts/beskar/application.html.erb +9 -76
  34. data/config/routes.rb +10 -21
  35. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  36. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  37. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  38. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  39. data/docs/README.md +73 -0
  40. data/docs/archive/project-documentation.md +659 -0
  41. data/docs/audits/project-review.md +437 -0
  42. data/docs/audits/repair-status.md +216 -0
  43. data/docs/guides/audit-and-waf.md +188 -0
  44. data/docs/guides/audit-lifecycle.md +172 -0
  45. data/docs/guides/authentication.md +213 -0
  46. data/docs/guides/configuration.md +182 -0
  47. data/docs/guides/dashboard-and-search.md +251 -0
  48. data/docs/guides/notifications-and-recovery.md +157 -0
  49. data/docs/guides/risk-scoring.md +116 -0
  50. data/docs/operations/monitor-only-mode.md +85 -0
  51. data/docs/operations/security-hardening.md +167 -0
  52. data/docs/operations/state-storage.md +144 -0
  53. data/docs/research/rust-performance-assessment.md +69 -0
  54. data/lib/beskar/configuration.rb +84 -13
  55. data/lib/beskar/configuration_validator.rb +188 -0
  56. data/lib/beskar/devise_authentication.rb +24 -0
  57. data/lib/beskar/engine.rb +21 -88
  58. data/lib/beskar/logger.rb +30 -35
  59. data/lib/beskar/middleware/request_analyzer.rb +49 -82
  60. data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
  61. data/lib/beskar/models/security_trackable_devise.rb +32 -23
  62. data/lib/beskar/models/security_trackable_generic.rb +169 -212
  63. data/lib/beskar/risk_level.rb +22 -0
  64. data/lib/beskar/services/account_locker.rb +85 -76
  65. data/lib/beskar/services/administrative_audit.rb +36 -0
  66. data/lib/beskar/services/administrative_bans.rb +104 -0
  67. data/lib/beskar/services/audit_data.rb +72 -0
  68. data/lib/beskar/services/authentication.rb +31 -0
  69. data/lib/beskar/services/authentication_attempt.rb +148 -0
  70. data/lib/beskar/services/ban_expiry.rb +28 -0
  71. data/lib/beskar/services/device_detector.rb +32 -41
  72. data/lib/beskar/services/event_search.rb +58 -0
  73. data/lib/beskar/services/geolocation_service.rb +83 -114
  74. data/lib/beskar/services/ip_whitelist.rb +31 -40
  75. data/lib/beskar/services/location_assessment.rb +109 -0
  76. data/lib/beskar/services/native_account_lock.rb +82 -0
  77. data/lib/beskar/services/notifications.rb +46 -0
  78. data/lib/beskar/services/rate_limiter.rb +103 -125
  79. data/lib/beskar/services/request_context.rb +69 -0
  80. data/lib/beskar/services/risk_assessment.rb +58 -0
  81. data/lib/beskar/services/session_revocation.rb +62 -0
  82. data/lib/beskar/services/waf.rb +166 -280
  83. data/lib/beskar/services/waf_request.rb +70 -0
  84. data/lib/beskar/version.rb +1 -1
  85. data/lib/beskar/warden_authentication.rb +53 -0
  86. data/lib/beskar.rb +53 -4
  87. data/lib/generators/beskar/install/install_generator.rb +36 -36
  88. data/lib/generators/beskar/install/templates/initializer.rb.tt +105 -20
  89. data/lib/tasks/beskar_tasks.rake +15 -19
  90. 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
- # Preload banned IPs into cache on startup
10
- config.after_initialize do
11
- if defined?(Beskar::BannedIp)
12
- Rails.application.executor.wrap do
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
- # Track successful authentication and check for high-risk locks
24
- Warden::Manager.after_set_user except: :fetch do |user, auth, opts|
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
- if env
55
- request = ActionDispatch::Request.new(env)
56
- scope = opts[:scope]
57
-
58
- # Try to get model class from configuration
59
- model_class = Beskar.configuration&.model_class_for_scope(scope)
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
- # Helper method to check if user was just locked
72
- def self.user_was_just_locked?(user, security_event)
73
- return false unless Beskar.configuration.risk_based_locking_enabled?
74
- return false unless security_event
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
- # Add engine migrations to host app's migration paths
88
- initializer "beskar.append_migrations" do |app|
89
- # Don't add migrations if we're inside the engine itself (testing)
90
- if !root.to_s.include?(app.root.to_s) && !app.root.to_s.include?(root.to_s)
91
- engine_migrations = root.join("db", "migrate").to_s
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 StandardError => e
39
+ rescue => e
40
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}"
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
- def logger=(logger_instance)
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
- def component_aliases=(aliases)
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('::').last
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 'logger'
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
- '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'
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 = request.ip
10
+ ip_address = Beskar::Services::RequestContext.ip(request)
11
11
 
12
- Beskar::Logger.debug("[RequestAnalyzer] Processing request from IP: #{ip_address}, Path: #{request.path}", component: :Middleware)
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
- if !is_whitelisted && rate_limited?(request)
29
- # Auto-block after excessive rate limiting violations (even in monitor-only mode - we create the ban record)
30
- if should_auto_block_rate_limit?(ip_address)
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: Just log, don't block (but ban record was created by WAF.record_violation)
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 for path: #{request.path}", component: :Middleware)
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
- # 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
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
- # Check how many times this IP has been rate limited in the past hour
143
- cache_key = "beskar:rate_limit_violations:#{ip_address}"
144
- violations = Rails.cache.read(cache_key) || 0
145
- violations += 1
146
- Rails.cache.write(cache_key, violations, expires_in: 1.hour)
147
-
148
- # Block after 5 rate limit violations in an hour
149
- violations >= 5
150
- end
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 on suspicious path from #{request.ip}: #{path} " \
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
- "Content-Type" => "text/html",
245
- "X-Beskar-Blocked" => "true"
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
- "Content-Type" => "text/html",
256
- "Retry-After" => "3600",
257
- "X-Beskar-Rate-Limited" => "true"
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
  ]