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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +987 -21
  4. data/app/controllers/beskar/application_controller.rb +170 -0
  5. data/app/controllers/beskar/banned_ips_controller.rb +280 -0
  6. data/app/controllers/beskar/dashboard_controller.rb +70 -0
  7. data/app/controllers/beskar/security_events_controller.rb +182 -0
  8. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
  9. data/app/models/beskar/banned_ip.rb +193 -0
  10. data/app/models/beskar/security_event.rb +64 -0
  11. data/app/services/beskar/banned_ip_manager.rb +78 -0
  12. data/app/views/beskar/banned_ips/edit.html.erb +259 -0
  13. data/app/views/beskar/banned_ips/index.html.erb +361 -0
  14. data/app/views/beskar/banned_ips/new.html.erb +310 -0
  15. data/app/views/beskar/banned_ips/show.html.erb +310 -0
  16. data/app/views/beskar/dashboard/index.html.erb +280 -0
  17. data/app/views/beskar/security_events/index.html.erb +309 -0
  18. data/app/views/beskar/security_events/show.html.erb +307 -0
  19. data/app/views/layouts/beskar/application.html.erb +647 -5
  20. data/config/locales/en.yml +10 -0
  21. data/config/routes.rb +41 -0
  22. data/db/migrate/20251016000001_create_beskar_security_events.rb +25 -0
  23. data/db/migrate/20251016000002_create_beskar_banned_ips.rb +23 -0
  24. data/lib/beskar/configuration.rb +214 -0
  25. data/lib/beskar/engine.rb +105 -0
  26. data/lib/beskar/logger.rb +293 -0
  27. data/lib/beskar/middleware/request_analyzer.rb +305 -0
  28. data/lib/beskar/middleware.rb +4 -0
  29. data/lib/beskar/models/security_trackable.rb +25 -0
  30. data/lib/beskar/models/security_trackable_authenticable.rb +167 -0
  31. data/lib/beskar/models/security_trackable_devise.rb +82 -0
  32. data/lib/beskar/models/security_trackable_generic.rb +355 -0
  33. data/lib/beskar/services/account_locker.rb +263 -0
  34. data/lib/beskar/services/device_detector.rb +250 -0
  35. data/lib/beskar/services/geolocation_service.rb +392 -0
  36. data/lib/beskar/services/ip_whitelist.rb +113 -0
  37. data/lib/beskar/services/rate_limiter.rb +257 -0
  38. data/lib/beskar/services/waf.rb +551 -0
  39. data/lib/beskar/version.rb +1 -1
  40. data/lib/beskar.rb +32 -1
  41. data/lib/generators/beskar/install/install_generator.rb +158 -0
  42. data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
  43. data/lib/tasks/beskar_tasks.rake +121 -4
  44. 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