beskar 0.0.1 → 0.1.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 +143 -0
- data/README.md +987 -21
- data/app/controllers/beskar/application_controller.rb +170 -0
- data/app/controllers/beskar/banned_ips_controller.rb +280 -0
- data/app/controllers/beskar/dashboard_controller.rb +70 -0
- data/app/controllers/beskar/security_events_controller.rb +182 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
- data/app/models/beskar/banned_ip.rb +193 -0
- data/app/models/beskar/security_event.rb +64 -0
- data/app/services/beskar/banned_ip_manager.rb +78 -0
- data/app/views/beskar/banned_ips/edit.html.erb +259 -0
- data/app/views/beskar/banned_ips/index.html.erb +361 -0
- data/app/views/beskar/banned_ips/new.html.erb +310 -0
- data/app/views/beskar/banned_ips/show.html.erb +310 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +309 -0
- data/app/views/beskar/security_events/show.html.erb +307 -0
- data/app/views/layouts/beskar/application.html.erb +647 -5
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +25 -0
- data/db/migrate/20251016000002_create_beskar_banned_ips.rb +23 -0
- data/lib/beskar/configuration.rb +214 -0
- data/lib/beskar/engine.rb +105 -0
- data/lib/beskar/logger.rb +293 -0
- data/lib/beskar/middleware/request_analyzer.rb +305 -0
- data/lib/beskar/middleware.rb +4 -0
- data/lib/beskar/models/security_trackable.rb +25 -0
- data/lib/beskar/models/security_trackable_authenticable.rb +167 -0
- data/lib/beskar/models/security_trackable_devise.rb +82 -0
- data/lib/beskar/models/security_trackable_generic.rb +355 -0
- data/lib/beskar/services/account_locker.rb +263 -0
- data/lib/beskar/services/device_detector.rb +250 -0
- data/lib/beskar/services/geolocation_service.rb +392 -0
- data/lib/beskar/services/ip_whitelist.rb +113 -0
- data/lib/beskar/services/rate_limiter.rb +257 -0
- data/lib/beskar/services/waf.rb +551 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar.rb +32 -1
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
- data/lib/tasks/beskar_tasks.rake +121 -4
- metadata +138 -5
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require 'ipaddr'
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
module Services
|
|
5
|
+
class IpWhitelist
|
|
6
|
+
class << self
|
|
7
|
+
# Check if an IP address is whitelisted
|
|
8
|
+
def whitelisted?(ip_address)
|
|
9
|
+
return false if ip_address.blank?
|
|
10
|
+
return false unless whitelist_entries.any?
|
|
11
|
+
|
|
12
|
+
ip = parse_ip(ip_address)
|
|
13
|
+
return false unless ip
|
|
14
|
+
|
|
15
|
+
whitelist_entries.any? do |entry|
|
|
16
|
+
match_entry?(ip, entry)
|
|
17
|
+
end
|
|
18
|
+
rescue IPAddr::InvalidAddressError, ArgumentError => e
|
|
19
|
+
Beskar::Logger.warn("Invalid IP address: #{ip_address} - #{e.message}", component: :IpWhitelist)
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get whitelist entries from configuration
|
|
24
|
+
def whitelist_entries
|
|
25
|
+
@whitelist_entries ||= begin
|
|
26
|
+
entries = Beskar.configuration.ip_whitelist || []
|
|
27
|
+
# Ensure it's an array
|
|
28
|
+
entries = [entries] unless entries.is_a?(Array)
|
|
29
|
+
entries.compact
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Clear cached whitelist (useful when config changes)
|
|
34
|
+
def clear_cache!
|
|
35
|
+
@whitelist_entries = nil
|
|
36
|
+
@parsed_entries = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Validate whitelist configuration
|
|
40
|
+
def validate_configuration!
|
|
41
|
+
errors = []
|
|
42
|
+
|
|
43
|
+
whitelist_entries.each_with_index do |entry, index|
|
|
44
|
+
begin
|
|
45
|
+
parse_entry(entry)
|
|
46
|
+
rescue IPAddr::InvalidAddressError, ArgumentError => e
|
|
47
|
+
errors << "Entry #{index} (#{entry}): #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if errors.any?
|
|
52
|
+
raise ConfigurationError, "Invalid IP whitelist configuration:\n#{errors.join("\n")}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Parse IP address string to IPAddr object
|
|
61
|
+
def parse_ip(ip_string)
|
|
62
|
+
IPAddr.new(ip_string.to_s.strip)
|
|
63
|
+
rescue IPAddr::InvalidAddressError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Parse whitelist entry (can be single IP or CIDR notation)
|
|
68
|
+
def parse_entry(entry)
|
|
69
|
+
return nil if entry.blank?
|
|
70
|
+
|
|
71
|
+
entry_str = entry.to_s.strip
|
|
72
|
+
|
|
73
|
+
# Check if it's CIDR notation
|
|
74
|
+
if entry_str.include?('/')
|
|
75
|
+
IPAddr.new(entry_str)
|
|
76
|
+
else
|
|
77
|
+
# Single IP address
|
|
78
|
+
IPAddr.new(entry_str)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if IP matches whitelist entry
|
|
83
|
+
def match_entry?(ip, entry)
|
|
84
|
+
parsed_entry = parsed_entries[entry]
|
|
85
|
+
return false unless parsed_entry
|
|
86
|
+
|
|
87
|
+
# IPAddr#include? handles both single IPs and CIDR ranges
|
|
88
|
+
parsed_entry.include?(ip)
|
|
89
|
+
rescue IPAddr::InvalidAddressError, ArgumentError
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Cache parsed entries for performance
|
|
94
|
+
def parsed_entries
|
|
95
|
+
@parsed_entries ||= begin
|
|
96
|
+
entries = {}
|
|
97
|
+
whitelist_entries.each do |entry|
|
|
98
|
+
begin
|
|
99
|
+
entries[entry] = parse_entry(entry)
|
|
100
|
+
rescue IPAddr::InvalidAddressError, ArgumentError => e
|
|
101
|
+
Beskar::Logger.warn("Skipping invalid entry: #{entry} - #{e.message}", component: :IpWhitelist)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
entries
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Error class for configuration issues
|
|
110
|
+
class ConfigurationError < StandardError; end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Services
|
|
3
|
+
class RateLimiter
|
|
4
|
+
DEFAULT_CONFIG = {
|
|
5
|
+
ip_attempts: {
|
|
6
|
+
limit: 10,
|
|
7
|
+
period: 1.hour,
|
|
8
|
+
exponential_backoff: true
|
|
9
|
+
},
|
|
10
|
+
account_attempts: {
|
|
11
|
+
limit: 5,
|
|
12
|
+
period: 15.minutes,
|
|
13
|
+
exponential_backoff: true
|
|
14
|
+
},
|
|
15
|
+
global_attempts: {
|
|
16
|
+
limit: 100,
|
|
17
|
+
period: 1.minute
|
|
18
|
+
}
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def check_authentication_attempt(request, result, user = nil)
|
|
23
|
+
ip_address = request.ip
|
|
24
|
+
|
|
25
|
+
# Check IP-based rate limiting
|
|
26
|
+
ip_result = check_ip_rate_limit(ip_address)
|
|
27
|
+
|
|
28
|
+
# Check account-based rate limiting if we have a user
|
|
29
|
+
account_result = user ? check_account_rate_limit(user) : {allowed: true}
|
|
30
|
+
|
|
31
|
+
# Check global rate limiting to prevent system overload
|
|
32
|
+
global_result = check_global_rate_limit
|
|
33
|
+
|
|
34
|
+
# Record the attempt
|
|
35
|
+
record_attempt(ip_address, result, user)
|
|
36
|
+
|
|
37
|
+
# Return most restrictive result
|
|
38
|
+
most_restrictive_result([ip_result, account_result, global_result])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def check_ip_rate_limit(ip_address)
|
|
42
|
+
config = Beskar.configuration.rate_limiting&.dig(:ip_attempts) || DEFAULT_CONFIG[:ip_attempts]
|
|
43
|
+
cache_key = "beskar:ip_attempts:#{ip_address}"
|
|
44
|
+
check_rate_limit(cache_key, config)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check_account_rate_limit(user)
|
|
48
|
+
config = Beskar.configuration.rate_limiting&.dig(:account_attempts) || DEFAULT_CONFIG[:account_attempts]
|
|
49
|
+
cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
|
|
50
|
+
check_rate_limit(cache_key, config)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def check_global_rate_limit
|
|
54
|
+
config = Beskar.configuration.rate_limiting&.dig(:global_attempts) || DEFAULT_CONFIG[:global_attempts]
|
|
55
|
+
cache_key = "beskar:global_attempts"
|
|
56
|
+
check_rate_limit(cache_key, config)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def is_rate_limited?(request, user = nil)
|
|
60
|
+
result = check_authentication_attempt(request, :check, user)
|
|
61
|
+
!result[:allowed]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def time_until_allowed(request, user = nil)
|
|
65
|
+
result = check_authentication_attempt(request, :check, user)
|
|
66
|
+
result[:retry_after] || 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reset_rate_limit(ip_address: nil, user: nil)
|
|
70
|
+
if ip_address
|
|
71
|
+
Rails.cache.delete("beskar:ip_attempts:#{ip_address}")
|
|
72
|
+
Rails.cache.delete("beskar:ip_backoff:#{ip_address}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if user
|
|
76
|
+
cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
|
|
77
|
+
Rails.cache.delete(cache_key)
|
|
78
|
+
Rails.cache.delete("beskar:account_backoff:#{user.class.name}:#{user.id}")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def check_rate_limit(cache_key, config)
|
|
85
|
+
now = Time.current.to_i
|
|
86
|
+
period = config[:period].to_i
|
|
87
|
+
limit = config[:limit]
|
|
88
|
+
window_start = now - period
|
|
89
|
+
|
|
90
|
+
# Get current window data
|
|
91
|
+
window_data = Rails.cache.read(cache_key) || {}
|
|
92
|
+
|
|
93
|
+
# Clean old entries
|
|
94
|
+
cleaned_data = window_data.select { |timestamp, _| timestamp.to_i > window_start }
|
|
95
|
+
|
|
96
|
+
# Update cache with cleaned data if it changed
|
|
97
|
+
if cleaned_data != window_data
|
|
98
|
+
if cleaned_data.empty?
|
|
99
|
+
Rails.cache.delete(cache_key)
|
|
100
|
+
else
|
|
101
|
+
Rails.cache.write(cache_key, cleaned_data, expires_in: period + 60)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
current_count = cleaned_data.values.sum
|
|
106
|
+
|
|
107
|
+
# Check if we're over the limit
|
|
108
|
+
if current_count >= limit
|
|
109
|
+
# Calculate retry after time with exponential backoff if configured
|
|
110
|
+
retry_after = calculate_retry_after(cache_key, config, current_count, limit)
|
|
111
|
+
|
|
112
|
+
reset_time = if cleaned_data.empty?
|
|
113
|
+
Time.at(now + period)
|
|
114
|
+
else
|
|
115
|
+
Time.at(cleaned_data.keys.map(&:to_i).min + period)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
{
|
|
119
|
+
allowed: false,
|
|
120
|
+
count: current_count,
|
|
121
|
+
limit: limit,
|
|
122
|
+
reset_time: reset_time,
|
|
123
|
+
retry_after: retry_after,
|
|
124
|
+
reason: "rate_limit_exceeded"
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
{
|
|
128
|
+
allowed: true,
|
|
129
|
+
count: current_count,
|
|
130
|
+
limit: limit,
|
|
131
|
+
remaining: limit - current_count
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def record_attempt(ip_address, result, user)
|
|
137
|
+
return if result == :check # Don't record check operations
|
|
138
|
+
|
|
139
|
+
now = Time.current.to_i
|
|
140
|
+
|
|
141
|
+
# Record IP attempt
|
|
142
|
+
ip_cache_key = "beskar:ip_attempts:#{ip_address}"
|
|
143
|
+
record_attempt_in_cache(ip_cache_key, now)
|
|
144
|
+
|
|
145
|
+
# Record account attempt if user exists
|
|
146
|
+
if user
|
|
147
|
+
account_cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
|
|
148
|
+
record_attempt_in_cache(account_cache_key, now)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Record global attempt
|
|
152
|
+
global_cache_key = "beskar:global_attempts"
|
|
153
|
+
record_attempt_in_cache(global_cache_key, now, 1.minute)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def record_attempt_in_cache(cache_key, timestamp, expiry = 1.hour)
|
|
157
|
+
window_data = Rails.cache.read(cache_key) || {}
|
|
158
|
+
window_data[timestamp] = (window_data[timestamp] || 0) + 1
|
|
159
|
+
Rails.cache.write(cache_key, window_data, expires_in: expiry + 60)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def calculate_retry_after(cache_key, config, current_count, limit)
|
|
163
|
+
return 0 unless config[:exponential_backoff]
|
|
164
|
+
|
|
165
|
+
backoff_key = cache_key.gsub("_attempts:", "_backoff:")
|
|
166
|
+
failure_count = Rails.cache.read(backoff_key) || 0
|
|
167
|
+
|
|
168
|
+
# Increment failure count
|
|
169
|
+
Rails.cache.write(backoff_key, failure_count + 1, expires_in: 1.hour)
|
|
170
|
+
|
|
171
|
+
# Exponential backoff: 1min, 5min, 15min, 1hour, 4hours, 24hours
|
|
172
|
+
base_delays = [60, 300, 900, 3600, 14400, 86400] # in seconds
|
|
173
|
+
delay_index = [failure_count, base_delays.length - 1].min
|
|
174
|
+
|
|
175
|
+
base_delays[delay_index]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def most_restrictive_result(results)
|
|
179
|
+
# Find the most restrictive (not allowed) result
|
|
180
|
+
restricted = results.find { |r| !r[:allowed] }
|
|
181
|
+
return restricted if restricted
|
|
182
|
+
|
|
183
|
+
# If all are allowed, return the one with the lowest remaining count
|
|
184
|
+
results.min_by { |r| r[:remaining] || Float::INFINITY }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Instance methods for more complex scenarios
|
|
189
|
+
def initialize(ip_address, user = nil)
|
|
190
|
+
@ip_address = ip_address
|
|
191
|
+
@user = user
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def allowed?
|
|
195
|
+
self.class.check_ip_rate_limit(@ip_address)[:allowed] &&
|
|
196
|
+
(@user.nil? || self.class.check_account_rate_limit(@user)[:allowed])
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def attempts_remaining
|
|
200
|
+
ip_result = self.class.check_ip_rate_limit(@ip_address)
|
|
201
|
+
account_result = @user ? self.class.check_account_rate_limit(@user) : {remaining: Float::INFINITY}
|
|
202
|
+
|
|
203
|
+
[ip_result[:remaining] || 0, account_result[:remaining] || 0].min
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def time_until_reset
|
|
207
|
+
ip_result = self.class.check_ip_rate_limit(@ip_address)
|
|
208
|
+
account_result = @user ? self.class.check_account_rate_limit(@user) : {retry_after: 0}
|
|
209
|
+
|
|
210
|
+
[ip_result[:retry_after] || 0, account_result[:retry_after] || 0].max
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def reset!
|
|
214
|
+
self.class.reset_rate_limit(ip_address: @ip_address, user: @user)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Sliding window analysis for pattern detection
|
|
218
|
+
def suspicious_pattern?
|
|
219
|
+
return false unless @user
|
|
220
|
+
|
|
221
|
+
# Check for rapid-fire attempts
|
|
222
|
+
recent_events = @user.security_events
|
|
223
|
+
.login_failures
|
|
224
|
+
.recent(5.minutes.ago)
|
|
225
|
+
.order(:created_at)
|
|
226
|
+
|
|
227
|
+
return true if recent_events.count >= 3
|
|
228
|
+
|
|
229
|
+
# Check for distributed attack (same user, different IPs)
|
|
230
|
+
if recent_events.count >= 2
|
|
231
|
+
unique_ips = recent_events.pluck(:ip_address).uniq
|
|
232
|
+
return true if unique_ips.length >= 2
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def attack_pattern_type
|
|
239
|
+
return :none unless suspicious_pattern?
|
|
240
|
+
|
|
241
|
+
recent_events = @user.security_events.login_failures.recent(5.minutes.ago)
|
|
242
|
+
unique_ips = recent_events.pluck(:ip_address).uniq
|
|
243
|
+
unique_emails = recent_events.map(&:attempted_email).compact.uniq
|
|
244
|
+
|
|
245
|
+
if unique_ips.length >= 2 && unique_emails.length == 1
|
|
246
|
+
:distributed_single_account
|
|
247
|
+
elsif unique_ips.length == 1 && unique_emails.length >= 3
|
|
248
|
+
:single_ip_multiple_accounts
|
|
249
|
+
elsif unique_ips.length == 1 && unique_emails.length == 1
|
|
250
|
+
:brute_force_single_account
|
|
251
|
+
else
|
|
252
|
+
:mixed_attack_pattern
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|