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
|
@@ -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
|
-
#
|
|
28
|
-
|
|
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.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
79
|
+
Beskar::Logger.debug("Could not get request from context: #{e.class}")
|
|
70
80
|
nil
|
|
71
81
|
end
|
|
72
82
|
|
|
73
|
-
# Devise-specific:
|
|
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
|
|
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
|