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
@@ -16,17 +16,31 @@ module Beskar
16
16
 
17
17
  # Make handle_high_risk_lock public (it's private in Generic)
18
18
  public
19
-
19
+
20
+ def beskar_access_locked?
21
+ Services::NativeAccountLock.locked?(self)
22
+ end
23
+
24
+ def beskar_access_allowed?(request)
25
+ !Services::RequestContext.enforce?(Services::RequestContext.ip(request)) || !beskar_access_locked?
26
+ end
27
+
28
+ def with_beskar_session(request, generation: nil, &block)
29
+ Services::NativeAccountLock.with_session(self, request, generation: generation, &block)
30
+ end
31
+
20
32
  # Rails 8 auth-specific: Handle high risk lock by destroying sessions
21
33
  # Public method called when high-risk event is detected
22
34
  def handle_high_risk_lock(security_event, request)
35
+ return unless Services::RequestContext.enforce?(Services::RequestContext.ip(request))
36
+ return unless beskar_access_locked?
23
37
  reason = determine_lock_reason(security_event)
24
-
38
+
25
39
  Beskar::Logger.warn("Rails auth high-risk lock detected: #{reason}")
26
-
27
- # Destroy all sessions to immediately lock out attacker
28
- destroy_all_sessions(except: request.session.id)
29
-
40
+
41
+ # NativeAccountLock already revoked every database session atomically
42
+ # with the lock. A Rack session ID is not a sessions-table primary key.
43
+
30
44
  # Check if this warrants emergency password reset
31
45
  if should_reset_password?(security_event, reason)
32
46
  perform_emergency_password_reset(security_event, reason)
@@ -50,117 +64,82 @@ module Beskar
50
64
  Beskar::Logger.warn("Model does not have sessions association, cannot destroy sessions")
51
65
  end
52
66
  rescue => e
53
- Beskar::Logger.error("Failed to destroy sessions: #{e.message}")
67
+ Beskar::Logger.error("Failed to destroy sessions: #{e.class}")
54
68
  end
55
69
 
56
70
  # Determine if emergency password reset is warranted
57
71
  def should_reset_password?(security_event, reason)
58
72
  config = Beskar.configuration.emergency_password_reset
59
73
  return false unless config[:enabled]
74
+ return false unless Services::RequestContext.enforce?(security_event.ip_address)
60
75
 
61
- case reason
62
- when :impossible_travel
63
- # Count impossible travel events in recent history
64
- recent_impossible_travel = security_events
65
- .where(event_type: ["account_locked", "login_success"])
66
- .where("created_at >= ?", 24.hours.ago)
67
- .where("metadata->>'geolocation' LIKE ?", '%impossible_travel%')
68
- .count
69
-
70
- recent_impossible_travel >= (config[:impossible_travel_threshold] || 3)
71
-
72
- when :suspicious_device
73
- # Multiple suspicious device logins
74
- recent_suspicious = security_events
75
- .where(event_type: "account_locked")
76
- .where("created_at >= ?", 24.hours.ago)
77
- .where("metadata->>'device_info' LIKE ?", '%suspicious%')
78
- .count
79
-
80
- recent_suspicious >= (config[:suspicious_device_threshold] || 5)
81
-
82
- else
83
- # For other reasons, check total lock count
84
- recent_locks = security_events
85
- .where(event_type: "account_locked")
86
- .where("created_at >= ?", 24.hours.ago)
87
- .count
88
-
89
- recent_locks >= (config[:total_locks_threshold] || 5)
76
+ events = security_events.where(event_type: "account_locked")
77
+ .where("created_at >= ?", 24.hours.ago)
78
+ threshold = case reason
79
+ when :impossible_travel then config[:impossible_travel_threshold] || 3
80
+ when :suspicious_device then config[:suspicious_device_threshold] || 5
81
+ else config[:total_locks_threshold] || 5
90
82
  end
83
+
84
+ count = 0
85
+ events.find_each do |event|
86
+ data = (event.metadata || {}).deep_stringify_keys
87
+ context = data["additional_context"] || {}
88
+ matches = case reason
89
+ when :impossible_travel
90
+ data["reason"] == "impossible_travel" ||
91
+ data.dig("geolocation", "impossible_travel") == true ||
92
+ context.dig("geolocation", "impossible_travel") == true
93
+ when :suspicious_device
94
+ data["reason"] == "suspicious_device" ||
95
+ data.dig("device_info", "suspicious") == true ||
96
+ context.dig("device_info", "suspicious") == true
97
+ else true
98
+ end
99
+ count += 1 if matches
100
+ return true if count >= threshold
101
+ end
102
+ false
91
103
  end
92
104
 
93
- # Perform emergency password reset
105
+ # Password invalidation and its mandatory recovery audit are one transaction.
106
+ # Notification hooks run after commit, never inside a retryable state block.
94
107
  def perform_emergency_password_reset(security_event, reason)
95
108
  config = Beskar.configuration.emergency_password_reset
96
-
97
- # Generate a cryptographically secure random password
98
- new_password = SecureRandom.base58(32)
99
-
100
- begin
101
- # Update password
109
+ return false unless config[:enabled]
110
+ return false unless Services::RequestContext.enforce?(security_event.ip_address)
111
+
112
+ self.class.transaction(requires_new: true) do
113
+ new_password = SecureRandom.base58(32)
102
114
  update!(password: new_password, password_confirmation: new_password)
103
-
104
- # Log the reset event
115
+ Services::NativeAccountLock.require_manual_unlock!(self) if config[:require_manual_unlock]
116
+ revoke_beskar_sessions!
105
117
  security_events.create!(
106
- event_type: "emergency_password_reset",
107
- ip_address: security_event.ip_address,
108
- user_agent: security_event.user_agent,
109
- metadata: {
110
- reason: reason.to_s,
111
- triggering_event_id: security_event.id,
112
- timestamp: Time.current.iso8601,
113
- reset_method: "automatic"
114
- },
115
- risk_score: 100
116
- )
117
-
118
- # Send notification to user
119
- if config[:send_notification]
120
- send_emergency_reset_notification(reason)
121
- end
122
-
123
- # Notify security team
124
- if config[:notify_security_team]
125
- notify_security_team_of_reset(reason, security_event)
126
- end
127
-
128
- Beskar::Logger.warn("Emergency password reset performed for user #{id}, reason: #{reason}")
129
-
130
- rescue => e
131
- Beskar::Logger.error("Failed to perform emergency password reset: #{e.message}")
132
-
133
- # Create failed reset event
134
- security_events.create!(
135
- event_type: "emergency_password_reset_failed",
136
- ip_address: security_event.ip_address,
137
- user_agent: security_event.user_agent,
138
- metadata: {
139
- reason: reason.to_s,
140
- error: e.message,
141
- timestamp: Time.current.iso8601
142
- },
143
- risk_score: 100
118
+ event_type: "emergency_password_reset", ip_address: security_event.ip_address,
119
+ user_agent: security_event.user_agent, risk_score: 100,
120
+ metadata: {reason: reason.to_s, triggering_event_id: security_event.id,
121
+ authentication_attempt_id: security_event.beskar_attempt&.id,
122
+ timestamp: Time.current.iso8601, reset_method: "automatic"}
144
123
  )
145
124
  end
125
+ # Keep the extension hooks independent: a failed user hook must not
126
+ # suppress the security-team notification or undo the committed reset.
127
+ Services::Notifications.after_commit { send_emergency_reset_notification(reason) } if config[:send_notification]
128
+ Services::Notifications.after_commit { notify_security_team_of_reset(reason, security_event) } if config[:notify_security_team]
129
+ true
130
+ rescue => error
131
+ Beskar::Logger.error("Emergency password reset failed (#{error.class})")
132
+ false
146
133
  end
147
134
 
148
135
  # Send notification to user about emergency password reset
149
136
  def send_emergency_reset_notification(reason)
150
- # This should be implemented by the application
151
- # Example: UserMailer.emergency_password_reset(self, reason).deliver_later
152
- Beskar::Logger.info("Would send emergency reset notification to user #{id}")
153
- rescue => e
154
- Beskar::Logger.error("Failed to send emergency reset notification: #{e.message}")
137
+ Services::Notifications.enqueue(self, "emergency_password_reset")
155
138
  end
156
139
 
157
140
  # Notify security team about emergency password reset
158
141
  def notify_security_team_of_reset(reason, security_event)
159
- # This should be implemented by the application
160
- # Example: SecurityMailer.emergency_reset_alert(self, reason, security_event).deliver_later
161
- Beskar::Logger.info("Would notify security team about reset for user #{id}")
162
- rescue => e
163
- Beskar::Logger.error("Failed to notify security team: #{e.message}")
142
+ Services::Notifications.enqueue(self, "security_team_reset")
164
143
  end
165
144
  end
166
145
  end
@@ -9,11 +9,21 @@ module Beskar
9
9
  included do
10
10
  # Include the generic functionality first
11
11
  include Beskar::Models::SecurityTrackableGeneric
12
+ prepend SessionCredentials
12
13
 
13
- # Hook into Devise callbacks if Devise is present and available
14
- if defined?(Devise) && respond_to?(:after_database_authentication)
15
- # Track successful authentications
16
- after_database_authentication :track_successful_login
14
+ after_update :revoke_beskar_sessions_after_lock
15
+
16
+ # The engine registers the single Warden outcome callback. Devise's
17
+ # after_database_authentication is an instance hook, not a callback macro.
18
+ end
19
+
20
+ module SessionCredentials
21
+ def authenticatable_salt
22
+ Digest::SHA256.hexdigest([super, beskar_session_token].to_json)
23
+ end
24
+
25
+ def rememberable_value
26
+ Digest::SHA256.hexdigest([super, beskar_session_token].to_json)
17
27
  end
18
28
  end
19
29
 
@@ -25,34 +35,34 @@ module Beskar
25
35
  return
26
36
  end
27
37
 
28
- if current_request = request_from_context
38
+ if (current_request = request_from_context)
29
39
  track_authentication_event(current_request, :success)
30
40
  end
31
41
  rescue => e
32
- Beskar::Logger.warn("Failed to track successful login: #{e.message}")
42
+ Beskar::Logger.warn("Failed to track successful login: #{e.class}")
33
43
  nil
34
44
  end
35
45
 
36
46
  # PUBLIC method called from Warden callback in engine.rb
37
47
  # Checks if account was just locked due to high risk and signs out if needed
38
- def check_high_risk_lock_and_signout(auth)
48
+ def check_high_risk_lock_and_signout(auth, scope: nil, attempt: nil)
39
49
  return unless Beskar.configuration.risk_based_locking_enabled?
40
-
41
- # Check if there's a very recent lock event (within last 5 seconds)
42
- recent_lock = security_events
43
- .where(event_type: ["account_locked", "lock_attempted"])
44
- .where("created_at >= ?", 5.seconds.ago)
45
- .exists?
46
-
47
- if recent_lock
48
- Beskar::Logger.warn("High-risk lock detected, signing out user #{id}")
49
- auth.logout
50
- throw :warden, message: :account_locked_due_to_high_risk
51
- end
50
+ return unless scope
51
+ attempt ||= Services::AuthenticationAttempt.current(auth.request, scope) if auth.respond_to?(:request)
52
+ return unless attempt && attempt.user == self && attempt.scope == scope.to_s && attempt.locked_now
53
+ return unless Services::RequestContext.enforce?(attempt.ip_address)
54
+ auth.logout(scope)
55
+ throw :warden, scope: scope, message: :account_locked_due_to_high_risk
52
56
  end
53
57
 
54
58
  private
55
59
 
60
+ # Runs in the user save transaction, including Devise's own failed-password
61
+ # lock and ordinary updates of locked_at, not just Beskar risk-based locks.
62
+ def revoke_beskar_sessions_after_lock
63
+ revoke_beskar_sessions! if has_attribute?(:locked_at) && saved_change_to_locked_at? && locked_at.present?
64
+ end
65
+
56
66
  # Devise-specific: Try to get request from various Warden/Devise contexts
57
67
  def request_from_context
58
68
  # Try to get request from various contexts
@@ -66,16 +76,15 @@ module Beskar
66
76
  Warden::Manager.current_request
67
77
  end
68
78
  rescue => e
69
- Beskar::Logger.debug("Could not get request from context: #{e.message}")
79
+ Beskar::Logger.debug("Could not get request from context: #{e.class}")
70
80
  nil
71
81
  end
72
82
 
73
- # Devise-specific: Handle high risk lock by creating lock event
83
+ # Devise-specific: The current attempt carries the completed lock result.
74
84
  # The actual sign-out is handled by Warden callback in engine.rb
75
85
  def handle_high_risk_lock(security_event, request)
76
86
  Beskar::Logger.debug("Devise account locked - Warden callback will handle sign-out")
77
- # The lock event is already created by AccountLocker service
78
- # The Warden callback will detect it and perform the actual sign-out
87
+ # The Warden callback uses the attempt, independently of audit writes.
79
88
  end
80
89
  end
81
90
  end