beskar 0.0.1 â 0.0.2
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/README.md +796 -18
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
- data/app/models/beskar/banned_ip.rb +152 -0
- data/app/models/beskar/security_event.rb +50 -0
- data/config/locales/en.yml +10 -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 +200 -0
- data/lib/beskar/engine.rb +105 -0
- data/lib/beskar/middleware/request_analyzer.rb +230 -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 +322 -0
- data/lib/beskar/templates/beskar_initializer.rb +107 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar.rb +31 -1
- data/lib/tasks/beskar_tasks.rake +112 -4
- metadata +108 -4
|
@@ -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
|
+
Rails.logger.warn "[Beskar::IpWhitelist] Invalid IP address: #{ip_address} - #{e.message}"
|
|
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
|
+
Rails.logger.warn "[Beskar::IpWhitelist] Skipping invalid entry: #{entry} - #{e.message}"
|
|
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
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Services
|
|
3
|
+
class Waf
|
|
4
|
+
# Common vulnerability scan patterns
|
|
5
|
+
VULNERABILITY_PATTERNS = {
|
|
6
|
+
wordpress: {
|
|
7
|
+
patterns: [
|
|
8
|
+
%r{/wp-admin}i,
|
|
9
|
+
%r{/wp-login\.php}i,
|
|
10
|
+
%r{/wp-content}i,
|
|
11
|
+
%r{/wp-includes}i,
|
|
12
|
+
%r{/xmlrpc\.php}i,
|
|
13
|
+
%r{/wp-config\.php}i,
|
|
14
|
+
%r{/wp-config\.bak}i,
|
|
15
|
+
%r{/wordpress}i
|
|
16
|
+
],
|
|
17
|
+
severity: :high,
|
|
18
|
+
description: "WordPress vulnerability scan"
|
|
19
|
+
},
|
|
20
|
+
php_admin: {
|
|
21
|
+
patterns: [
|
|
22
|
+
%r{/phpmyadmin}i,
|
|
23
|
+
%r{/pma}i,
|
|
24
|
+
%r{/admin\.php}i,
|
|
25
|
+
%r{/administrator}i,
|
|
26
|
+
%r{/admin/config\.php}i,
|
|
27
|
+
%r{/phpinfo\.php}i
|
|
28
|
+
],
|
|
29
|
+
severity: :high,
|
|
30
|
+
description: "PHP admin panel scan"
|
|
31
|
+
},
|
|
32
|
+
config_files: {
|
|
33
|
+
patterns: [
|
|
34
|
+
%r{/\.env},
|
|
35
|
+
%r{/\.git},
|
|
36
|
+
%r{/config\.php}i,
|
|
37
|
+
%r{/configuration\.php}i,
|
|
38
|
+
%r{/settings\.php}i,
|
|
39
|
+
%r{/database\.yml},
|
|
40
|
+
%r{/credentials\.yml}i
|
|
41
|
+
],
|
|
42
|
+
severity: :critical,
|
|
43
|
+
description: "Configuration file access attempt"
|
|
44
|
+
},
|
|
45
|
+
path_traversal: {
|
|
46
|
+
patterns: [
|
|
47
|
+
%r{/etc/passwd},
|
|
48
|
+
%r{/etc/shadow},
|
|
49
|
+
%r{/etc/hosts},
|
|
50
|
+
%r{\.\./},
|
|
51
|
+
%r{\.\.\\},
|
|
52
|
+
%r{%2e%2e/}i,
|
|
53
|
+
%r{%252e%252e/}i
|
|
54
|
+
],
|
|
55
|
+
severity: :critical,
|
|
56
|
+
description: "Path traversal attempt"
|
|
57
|
+
},
|
|
58
|
+
framework_debug: {
|
|
59
|
+
patterns: [
|
|
60
|
+
%r{/rails/info/routes},
|
|
61
|
+
%r{/__debug__},
|
|
62
|
+
%r{/debug},
|
|
63
|
+
%r{/telescope},
|
|
64
|
+
%r{/_profiler},
|
|
65
|
+
%r{/\.well-known}
|
|
66
|
+
],
|
|
67
|
+
severity: :medium,
|
|
68
|
+
description: "Framework debug endpoint scan"
|
|
69
|
+
},
|
|
70
|
+
cms_scan: {
|
|
71
|
+
patterns: [
|
|
72
|
+
%r{/joomla}i,
|
|
73
|
+
%r{/drupal}i,
|
|
74
|
+
%r{/magento}i,
|
|
75
|
+
%r{/prestashop}i,
|
|
76
|
+
%r{/typo3}i
|
|
77
|
+
],
|
|
78
|
+
severity: :medium,
|
|
79
|
+
description: "CMS detection scan"
|
|
80
|
+
},
|
|
81
|
+
common_exploits: {
|
|
82
|
+
patterns: [
|
|
83
|
+
%r{/shell\.php}i,
|
|
84
|
+
%r{/cmd\.php}i,
|
|
85
|
+
%r{/backdoor}i,
|
|
86
|
+
%r{/c99\.php}i,
|
|
87
|
+
%r{/r57\.php}i,
|
|
88
|
+
%r{/webshell}i
|
|
89
|
+
],
|
|
90
|
+
severity: :critical,
|
|
91
|
+
description: "Common exploit file access"
|
|
92
|
+
}
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
class << self
|
|
96
|
+
# Analyze a request for vulnerability scanning patterns
|
|
97
|
+
def analyze_request(request)
|
|
98
|
+
path = request.fullpath || request.path
|
|
99
|
+
return nil if path.blank?
|
|
100
|
+
|
|
101
|
+
detected_patterns = []
|
|
102
|
+
|
|
103
|
+
VULNERABILITY_PATTERNS.each do |category, config|
|
|
104
|
+
config[:patterns].each do |pattern|
|
|
105
|
+
if path.match?(pattern)
|
|
106
|
+
detected_patterns << {
|
|
107
|
+
category: category,
|
|
108
|
+
pattern: pattern.source,
|
|
109
|
+
severity: config[:severity],
|
|
110
|
+
description: config[:description],
|
|
111
|
+
matched_path: path
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if detected_patterns.any?
|
|
118
|
+
{
|
|
119
|
+
threat_detected: true,
|
|
120
|
+
patterns: detected_patterns,
|
|
121
|
+
highest_severity: calculate_highest_severity(detected_patterns),
|
|
122
|
+
ip_address: request.ip,
|
|
123
|
+
user_agent: request.user_agent,
|
|
124
|
+
timestamp: Time.current
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Check if request should be blocked based on violation history
|
|
132
|
+
def should_block?(ip_address)
|
|
133
|
+
config = waf_config
|
|
134
|
+
return false unless config[:enabled]
|
|
135
|
+
return false unless config[:auto_block]
|
|
136
|
+
|
|
137
|
+
# Check violation count in cache
|
|
138
|
+
violation_count = get_violation_count(ip_address)
|
|
139
|
+
threshold = config[:block_threshold] || 3
|
|
140
|
+
|
|
141
|
+
violation_count >= threshold
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Record a WAF violation
|
|
145
|
+
def record_violation(ip_address, analysis_result, whitelisted: false)
|
|
146
|
+
config = waf_config
|
|
147
|
+
return unless config[:enabled]
|
|
148
|
+
|
|
149
|
+
# Increment violation count
|
|
150
|
+
cache_key = "beskar:waf_violations:#{ip_address}"
|
|
151
|
+
current_count = Rails.cache.read(cache_key) || 0
|
|
152
|
+
new_count = current_count + 1
|
|
153
|
+
|
|
154
|
+
# Store with TTL from config (default 1 hour)
|
|
155
|
+
ttl = config[:violation_window] || 1.hour
|
|
156
|
+
Rails.cache.write(cache_key, new_count, expires_in: ttl)
|
|
157
|
+
|
|
158
|
+
# Log the violation
|
|
159
|
+
log_violation(ip_address, analysis_result, new_count)
|
|
160
|
+
|
|
161
|
+
# Create security event if configured
|
|
162
|
+
if config[:create_security_events]
|
|
163
|
+
create_security_event(ip_address, analysis_result)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Check if we should auto-block (skip if whitelisted, in monitor-only mode)
|
|
167
|
+
threshold = config[:block_threshold] || 3
|
|
168
|
+
|
|
169
|
+
if !whitelisted && config[:auto_block] && new_count >= threshold
|
|
170
|
+
if config[:monitor_only]
|
|
171
|
+
# Monitor-only mode: Log what WOULD happen but don't block
|
|
172
|
+
log_monitor_only_action(ip_address, analysis_result, new_count, threshold)
|
|
173
|
+
else
|
|
174
|
+
auto_block_ip(ip_address, analysis_result, new_count)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
new_count
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Get current violation count for an IP
|
|
182
|
+
def get_violation_count(ip_address)
|
|
183
|
+
cache_key = "beskar:waf_violations:#{ip_address}"
|
|
184
|
+
Rails.cache.read(cache_key) || 0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Reset violations for an IP
|
|
188
|
+
def reset_violations(ip_address)
|
|
189
|
+
cache_key = "beskar:waf_violations:#{ip_address}"
|
|
190
|
+
Rails.cache.delete(cache_key)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# Get WAF configuration
|
|
196
|
+
def waf_config
|
|
197
|
+
Beskar.configuration.waf || {}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Calculate highest severity from detected patterns
|
|
201
|
+
def calculate_highest_severity(patterns)
|
|
202
|
+
severities = patterns.map { |p| p[:severity] }
|
|
203
|
+
return :critical if severities.include?(:critical)
|
|
204
|
+
return :high if severities.include?(:high)
|
|
205
|
+
return :medium if severities.include?(:medium)
|
|
206
|
+
:low
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Log WAF violation
|
|
210
|
+
def log_violation(ip_address, analysis_result, violation_count)
|
|
211
|
+
severity_emoji = {
|
|
212
|
+
critical: "đ¨",
|
|
213
|
+
high: "â ī¸",
|
|
214
|
+
medium: "âĄ",
|
|
215
|
+
low: "âšī¸"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
emoji = severity_emoji[analysis_result[:highest_severity]] || "đ"
|
|
219
|
+
config = waf_config
|
|
220
|
+
monitor_mode_notice = config[:monitor_only] ? " [MONITOR-ONLY MODE]" : ""
|
|
221
|
+
|
|
222
|
+
Rails.logger.warn(
|
|
223
|
+
"[Beskar::WAF] #{emoji} Vulnerability scan detected#{monitor_mode_notice} " \
|
|
224
|
+
"(#{violation_count} violations) - " \
|
|
225
|
+
"IP: #{ip_address}, " \
|
|
226
|
+
"Severity: #{analysis_result[:highest_severity]}, " \
|
|
227
|
+
"Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}, " \
|
|
228
|
+
"Path: #{analysis_result[:patterns].first[:matched_path]}"
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Log what would happen in monitor-only mode (but don't actually block)
|
|
233
|
+
def log_monitor_only_action(ip_address, analysis_result, violation_count, threshold)
|
|
234
|
+
config = waf_config
|
|
235
|
+
duration = calculate_block_duration(violation_count, config)
|
|
236
|
+
|
|
237
|
+
Rails.logger.warn(
|
|
238
|
+
"[Beskar::WAF] đ MONITOR-ONLY: IP #{ip_address} WOULD BE BLOCKED " \
|
|
239
|
+
"(threshold reached: #{violation_count}/#{threshold} violations) - " \
|
|
240
|
+
"Duration would be: #{duration ? "#{duration / 3600.0} hours" : 'PERMANENT'}, " \
|
|
241
|
+
"Severity: #{analysis_result[:highest_severity]}, " \
|
|
242
|
+
"Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}. " \
|
|
243
|
+
"To enable blocking, set config.waf[:monitor_only] = false"
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Create security event for WAF violation
|
|
248
|
+
def create_security_event(ip_address, analysis_result)
|
|
249
|
+
config = waf_config
|
|
250
|
+
violation_count = get_violation_count(ip_address)
|
|
251
|
+
threshold = config[:block_threshold] || 3
|
|
252
|
+
would_be_blocked = violation_count >= threshold
|
|
253
|
+
|
|
254
|
+
Beskar::SecurityEvent.create!(
|
|
255
|
+
event_type: 'waf_violation',
|
|
256
|
+
ip_address: ip_address,
|
|
257
|
+
user_agent: analysis_result[:user_agent],
|
|
258
|
+
risk_score: severity_to_risk_score(analysis_result[:highest_severity]),
|
|
259
|
+
metadata: {
|
|
260
|
+
waf_analysis: analysis_result,
|
|
261
|
+
patterns_matched: analysis_result[:patterns].map { |p| p[:description] },
|
|
262
|
+
severity: analysis_result[:highest_severity],
|
|
263
|
+
monitor_only_mode: config[:monitor_only],
|
|
264
|
+
would_be_blocked: would_be_blocked,
|
|
265
|
+
violation_count: violation_count,
|
|
266
|
+
block_threshold: threshold
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
rescue => e
|
|
270
|
+
Rails.logger.error "[Beskar::WAF] Failed to create security event: #{e.message}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Auto-block an IP after threshold violations
|
|
274
|
+
def auto_block_ip(ip_address, analysis_result, violation_count)
|
|
275
|
+
config = waf_config
|
|
276
|
+
duration = calculate_block_duration(violation_count, config)
|
|
277
|
+
|
|
278
|
+
Beskar::BannedIp.ban!(
|
|
279
|
+
ip_address,
|
|
280
|
+
reason: 'waf_violation',
|
|
281
|
+
duration: duration,
|
|
282
|
+
permanent: duration.nil?,
|
|
283
|
+
details: "WAF violations: #{violation_count} - #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}",
|
|
284
|
+
metadata: {
|
|
285
|
+
violation_count: violation_count,
|
|
286
|
+
patterns: analysis_result[:patterns],
|
|
287
|
+
highest_severity: analysis_result[:highest_severity],
|
|
288
|
+
blocked_at: Time.current
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
Rails.logger.warn(
|
|
293
|
+
"[Beskar::WAF] đ Auto-blocked IP #{ip_address} " \
|
|
294
|
+
"after #{violation_count} violations " \
|
|
295
|
+
"(duration: #{duration ? "#{duration / 3600} hours" : 'permanent'})"
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Calculate block duration based on violation count
|
|
300
|
+
def calculate_block_duration(violation_count, config)
|
|
301
|
+
return nil if config[:permanent_block_after] && violation_count >= config[:permanent_block_after]
|
|
302
|
+
|
|
303
|
+
# Default escalating durations: 1h, 6h, 24h, 7d, permanent
|
|
304
|
+
base_durations = config[:block_durations] || [1.hour, 6.hours, 24.hours, 7.days]
|
|
305
|
+
index = [violation_count - (config[:block_threshold] || 3), base_durations.length - 1].min
|
|
306
|
+
base_durations[index]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Convert severity level to risk score
|
|
310
|
+
def severity_to_risk_score(severity)
|
|
311
|
+
case severity
|
|
312
|
+
when :critical then 95
|
|
313
|
+
when :high then 80
|
|
314
|
+
when :medium then 60
|
|
315
|
+
when :low then 40
|
|
316
|
+
else 50
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|