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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -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 +9 -15
  27. data/app/views/beskar/dashboard/index.html.erb +4 -4
  28. data/app/views/beskar/security_events/index.html.erb +8 -15
  29. data/app/views/beskar/security_events/show.html.erb +6 -20
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +9 -76
  33. data/config/routes.rb +10 -21
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +84 -13
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +30 -35
  58. data/lib/beskar/middleware/request_analyzer.rb +41 -82
  59. data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
  60. data/lib/beskar/models/security_trackable_devise.rb +32 -23
  61. data/lib/beskar/models/security_trackable_generic.rb +169 -212
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +85 -76
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +164 -280
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +53 -4
  86. data/lib/generators/beskar/install/install_generator.rb +36 -36
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +104 -20
  88. data/lib/tasks/beskar_tasks.rake +15 -19
  89. 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
- # 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,9 @@ 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
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
- 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)
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: Just log, don't block (but ban record was created by WAF.record_violation)
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 for path: #{request.path}", component: :Middleware)
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
- # 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
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
- # 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
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 on suspicious path from #{request.ip}: #{path} " \
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
- "Content-Type" => "text/html",
245
- "X-Beskar-Blocked" => "true"
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
- "Content-Type" => "text/html",
256
- "Retry-After" => "3600",
257
- "X-Beskar-Rate-Limited" => "true"
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
  ]