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,551 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Services
|
|
3
|
+
class Waf
|
|
4
|
+
# Common vulnerability scan patterns
|
|
5
|
+
VULNERABILITY_PATTERNS = {
|
|
6
|
+
rails_exceptions: {
|
|
7
|
+
patterns: [
|
|
8
|
+
%r{/(?:users?|posts?|articles?|comments?|api/v\d+/\w+)/\d+\.(?:exe|bat|cmd|com|scr|vbs|jar|app|deb|rpm)$}i, # Rails resources with executable extensions
|
|
9
|
+
%r{/(?:users?|posts?|articles?|comments?|api/v\d+/\w+)\.(?:asp|aspx|jsp|do|action|cgi|pl|py|rb)$}i, # Rails routes with server-side script extensions
|
|
10
|
+
%r{\?format=(?:exe|bat|cmd|com|scr|vbs|jar|asp|aspx|jsp|php)$}i, # Suspicious format in query params
|
|
11
|
+
],
|
|
12
|
+
severity: :medium,
|
|
13
|
+
description: "Potential Rails exception triggering attempt"
|
|
14
|
+
},
|
|
15
|
+
ip_spoofing: {
|
|
16
|
+
patterns: [
|
|
17
|
+
%r{X-Forwarded-For.*X-Forwarded-For}i, # Multiple X-Forwarded-For headers in path (suspicious)
|
|
18
|
+
%r{Client-IP.*X-Forwarded-For}i, # Conflicting IP headers in path
|
|
19
|
+
],
|
|
20
|
+
severity: :high,
|
|
21
|
+
description: "Potential IP spoofing attempt"
|
|
22
|
+
},
|
|
23
|
+
record_scanning: {
|
|
24
|
+
patterns: [
|
|
25
|
+
%r{/(?:user|admin|account|profile|order|payment|invoice|document|file|download)/\d{6,}}i, # Large IDs that likely don't exist
|
|
26
|
+
%r{/(?:user|admin|account|profile)/(?:test|admin|root|administrator|superuser)}i, # Common test usernames
|
|
27
|
+
%r{/api/v\d+/(?:users?|accounts?|orders?|payments?)/(?:999999|123456|0|null|undefined)}i, # Obviously fake API IDs
|
|
28
|
+
],
|
|
29
|
+
severity: :low,
|
|
30
|
+
description: "Potential record enumeration/scanning"
|
|
31
|
+
},
|
|
32
|
+
wordpress: {
|
|
33
|
+
patterns: [
|
|
34
|
+
%r{/wp-admin}i,
|
|
35
|
+
%r{/wp-login\.php}i,
|
|
36
|
+
%r{/wp-content/.*\.php}i, # PHP files in wp-content are suspicious
|
|
37
|
+
%r{/wp-includes}i,
|
|
38
|
+
%r{/xmlrpc\.php}i,
|
|
39
|
+
%r{/wp-config\.php}i,
|
|
40
|
+
%r{/wp-config\.bak}i,
|
|
41
|
+
%r{/wordpress}i
|
|
42
|
+
],
|
|
43
|
+
severity: :high,
|
|
44
|
+
description: "WordPress vulnerability scan"
|
|
45
|
+
},
|
|
46
|
+
wordpress_static: {
|
|
47
|
+
patterns: [
|
|
48
|
+
%r{/wp-content/.*\.(?:css|js|jpe?g|png|gif|svg|webp|ico|woff2?|ttf|eot|map)$}i, # Static files in wp-content
|
|
49
|
+
%r{/wp-content/(?:uploads|themes|plugins)/[^.]*$}i, # Directory listing attempts
|
|
50
|
+
],
|
|
51
|
+
severity: :low,
|
|
52
|
+
description: "WordPress static file probe"
|
|
53
|
+
},
|
|
54
|
+
php_admin: {
|
|
55
|
+
patterns: [
|
|
56
|
+
%r{/phpmyadmin}i,
|
|
57
|
+
%r{/pma}i,
|
|
58
|
+
%r{/admin\.php}i,
|
|
59
|
+
%r{/administrator}i,
|
|
60
|
+
%r{/admin/config\.php}i,
|
|
61
|
+
%r{/phpinfo\.php}i
|
|
62
|
+
],
|
|
63
|
+
severity: :high,
|
|
64
|
+
description: "PHP admin panel scan"
|
|
65
|
+
},
|
|
66
|
+
config_files: {
|
|
67
|
+
patterns: [
|
|
68
|
+
%r{/\.env},
|
|
69
|
+
%r{/\.git},
|
|
70
|
+
%r{/config\.php}i,
|
|
71
|
+
%r{/configuration\.php}i,
|
|
72
|
+
%r{/settings\.php}i,
|
|
73
|
+
%r{/database\.yml},
|
|
74
|
+
%r{/credentials\.yml}i
|
|
75
|
+
],
|
|
76
|
+
severity: :critical,
|
|
77
|
+
description: "Configuration file access attempt"
|
|
78
|
+
},
|
|
79
|
+
path_traversal: {
|
|
80
|
+
patterns: [
|
|
81
|
+
%r{/etc/passwd},
|
|
82
|
+
%r{/etc/shadow},
|
|
83
|
+
%r{/etc/hosts},
|
|
84
|
+
%r{\.\./},
|
|
85
|
+
%r{\.\.\\},
|
|
86
|
+
%r{%2e%2e/}i,
|
|
87
|
+
%r{%252e%252e/}i
|
|
88
|
+
],
|
|
89
|
+
severity: :critical,
|
|
90
|
+
description: "Path traversal attempt"
|
|
91
|
+
},
|
|
92
|
+
framework_debug: {
|
|
93
|
+
patterns: [
|
|
94
|
+
%r{/rails/info/routes},
|
|
95
|
+
%r{/__debug__},
|
|
96
|
+
%r{/debug},
|
|
97
|
+
%r{/telescope},
|
|
98
|
+
%r{/_profiler},
|
|
99
|
+
%r{/\.well-known}
|
|
100
|
+
],
|
|
101
|
+
severity: :medium,
|
|
102
|
+
description: "Framework debug endpoint scan"
|
|
103
|
+
},
|
|
104
|
+
cms_scan: {
|
|
105
|
+
patterns: [
|
|
106
|
+
%r{/joomla}i,
|
|
107
|
+
%r{/drupal}i,
|
|
108
|
+
%r{/magento}i,
|
|
109
|
+
%r{/prestashop}i,
|
|
110
|
+
%r{/typo3}i
|
|
111
|
+
],
|
|
112
|
+
severity: :medium,
|
|
113
|
+
description: "CMS detection scan"
|
|
114
|
+
},
|
|
115
|
+
common_exploits: {
|
|
116
|
+
patterns: [
|
|
117
|
+
%r{/shell\.php}i,
|
|
118
|
+
%r{/cmd\.php}i,
|
|
119
|
+
%r{/backdoor}i,
|
|
120
|
+
%r{/c99\.php}i,
|
|
121
|
+
%r{/r57\.php}i,
|
|
122
|
+
%r{/webshell}i
|
|
123
|
+
],
|
|
124
|
+
severity: :critical,
|
|
125
|
+
description: "Common exploit file access"
|
|
126
|
+
}
|
|
127
|
+
}.freeze
|
|
128
|
+
|
|
129
|
+
# Configuration for RecordNotFound exclusion patterns
|
|
130
|
+
# These patterns will not trigger WAF violations
|
|
131
|
+
RECORD_NOT_FOUND_EXCLUSIONS = [
|
|
132
|
+
# Add default exclusions here if needed
|
|
133
|
+
# %r{/posts/.*}, # Example: exclude all posts paths
|
|
134
|
+
].freeze
|
|
135
|
+
|
|
136
|
+
class << self
|
|
137
|
+
# Analyze a request for vulnerability scanning patterns
|
|
138
|
+
def analyze_request(request)
|
|
139
|
+
path = request.fullpath || request.path
|
|
140
|
+
return nil if path.blank?
|
|
141
|
+
|
|
142
|
+
detected_patterns = []
|
|
143
|
+
|
|
144
|
+
VULNERABILITY_PATTERNS.each do |category, config|
|
|
145
|
+
config[:patterns].each do |pattern|
|
|
146
|
+
if path.match?(pattern)
|
|
147
|
+
detected_patterns << {
|
|
148
|
+
category: category,
|
|
149
|
+
pattern: pattern.source,
|
|
150
|
+
severity: config[:severity],
|
|
151
|
+
description: config[:description],
|
|
152
|
+
matched_path: path
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if detected_patterns.any?
|
|
159
|
+
{
|
|
160
|
+
threat_detected: true,
|
|
161
|
+
patterns: detected_patterns,
|
|
162
|
+
highest_severity: calculate_highest_severity(detected_patterns),
|
|
163
|
+
ip_address: request.ip,
|
|
164
|
+
user_agent: request.user_agent,
|
|
165
|
+
timestamp: Time.current
|
|
166
|
+
}
|
|
167
|
+
else
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Analyze Rails exceptions as potential security threats
|
|
173
|
+
def analyze_exception(exception, request)
|
|
174
|
+
case exception
|
|
175
|
+
when ActionController::UnknownFormat
|
|
176
|
+
{
|
|
177
|
+
threat_detected: true,
|
|
178
|
+
patterns: [{
|
|
179
|
+
category: :unknown_format,
|
|
180
|
+
pattern: "ActionController::UnknownFormat",
|
|
181
|
+
severity: :medium,
|
|
182
|
+
description: "Unknown format requested - potential scanner",
|
|
183
|
+
matched_path: request.fullpath
|
|
184
|
+
}],
|
|
185
|
+
highest_severity: :medium,
|
|
186
|
+
ip_address: request.ip,
|
|
187
|
+
user_agent: request.user_agent,
|
|
188
|
+
timestamp: Time.current,
|
|
189
|
+
exception_class: exception.class.name,
|
|
190
|
+
exception_message: exception.message
|
|
191
|
+
}
|
|
192
|
+
when ActionDispatch::RemoteIp::IpSpoofAttackError
|
|
193
|
+
{
|
|
194
|
+
threat_detected: true,
|
|
195
|
+
patterns: [{
|
|
196
|
+
category: :ip_spoof,
|
|
197
|
+
pattern: "ActionDispatch::RemoteIp::IpSpoofAttackError",
|
|
198
|
+
severity: :critical,
|
|
199
|
+
description: "IP spoofing attack detected",
|
|
200
|
+
matched_path: request.fullpath
|
|
201
|
+
}],
|
|
202
|
+
highest_severity: :critical,
|
|
203
|
+
ip_address: request.ip,
|
|
204
|
+
user_agent: request.user_agent,
|
|
205
|
+
timestamp: Time.current,
|
|
206
|
+
exception_class: exception.class.name,
|
|
207
|
+
exception_message: exception.message
|
|
208
|
+
}
|
|
209
|
+
when ActiveRecord::RecordNotFound
|
|
210
|
+
# Check if this path should be excluded from WAF
|
|
211
|
+
if should_exclude_record_not_found?(request.fullpath)
|
|
212
|
+
return nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
threat_detected: true,
|
|
217
|
+
patterns: [{
|
|
218
|
+
category: :record_not_found,
|
|
219
|
+
pattern: "ActiveRecord::RecordNotFound",
|
|
220
|
+
severity: :low,
|
|
221
|
+
description: "Record not found - potential enumeration scan",
|
|
222
|
+
matched_path: request.fullpath
|
|
223
|
+
}],
|
|
224
|
+
highest_severity: :low,
|
|
225
|
+
ip_address: request.ip,
|
|
226
|
+
user_agent: request.user_agent,
|
|
227
|
+
timestamp: Time.current,
|
|
228
|
+
exception_class: exception.class.name,
|
|
229
|
+
exception_message: exception.message
|
|
230
|
+
}
|
|
231
|
+
when ActionDispatch::Http::MimeNegotiation::InvalidType
|
|
232
|
+
{
|
|
233
|
+
threat_detected: true,
|
|
234
|
+
patterns: [{
|
|
235
|
+
category: :invalid_mime_type,
|
|
236
|
+
pattern: "ActionDispatch::Http::MimeNegotiation::InvalidType",
|
|
237
|
+
severity: :medium,
|
|
238
|
+
description: "Invalid MIME type requested - potential scanner",
|
|
239
|
+
matched_path: request.fullpath
|
|
240
|
+
}],
|
|
241
|
+
highest_severity: :medium,
|
|
242
|
+
ip_address: request.ip,
|
|
243
|
+
user_agent: request.user_agent,
|
|
244
|
+
timestamp: Time.current,
|
|
245
|
+
exception_class: exception.class.name,
|
|
246
|
+
exception_message: exception.message
|
|
247
|
+
}
|
|
248
|
+
else
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Check if a RecordNotFound exception should be excluded from WAF
|
|
254
|
+
def should_exclude_record_not_found?(path)
|
|
255
|
+
return false if path.blank?
|
|
256
|
+
|
|
257
|
+
# Check configured exclusions
|
|
258
|
+
exclusions = waf_config[:record_not_found_exclusions] || []
|
|
259
|
+
all_exclusions = RECORD_NOT_FOUND_EXCLUSIONS + exclusions
|
|
260
|
+
|
|
261
|
+
all_exclusions.any? { |pattern| path.match?(pattern) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Check if request should be blocked based on violation history
|
|
265
|
+
def should_block?(ip_address)
|
|
266
|
+
config = waf_config
|
|
267
|
+
return false unless config[:enabled]
|
|
268
|
+
return false unless config[:auto_block]
|
|
269
|
+
|
|
270
|
+
# Get current risk score (with decay applied)
|
|
271
|
+
current_score = get_current_score(ip_address)
|
|
272
|
+
threshold = config[:score_threshold] || 150
|
|
273
|
+
|
|
274
|
+
current_score >= threshold
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Record a WAF violation
|
|
278
|
+
def record_violation(ip_address, analysis_result, whitelisted: false)
|
|
279
|
+
config = waf_config
|
|
280
|
+
return unless config[:enabled]
|
|
281
|
+
|
|
282
|
+
Beskar::Logger.debug("[WAF] Recording violation for IP: #{ip_address}, whitelisted: #{whitelisted}", component: :WAF)
|
|
283
|
+
|
|
284
|
+
# Get current violations and add new one
|
|
285
|
+
cache_key = "beskar:waf_violations:#{ip_address}"
|
|
286
|
+
violations = Rails.cache.read(cache_key) || []
|
|
287
|
+
|
|
288
|
+
# Calculate risk score for this violation
|
|
289
|
+
risk_score = severity_to_risk_score(analysis_result[:highest_severity])
|
|
290
|
+
|
|
291
|
+
# Create new violation record
|
|
292
|
+
new_violation = {
|
|
293
|
+
timestamp: Time.current.to_i,
|
|
294
|
+
score: risk_score,
|
|
295
|
+
severity: analysis_result[:highest_severity],
|
|
296
|
+
category: analysis_result[:patterns].first[:category],
|
|
297
|
+
description: analysis_result[:patterns].first[:description],
|
|
298
|
+
path: analysis_result[:patterns].first[:matched_path]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
violations << new_violation
|
|
302
|
+
|
|
303
|
+
# Prune old violations (outside violation window and max tracked)
|
|
304
|
+
violations = prune_violations(violations, config)
|
|
305
|
+
|
|
306
|
+
# Calculate current score with decay
|
|
307
|
+
current_score = calculate_current_score(violations, config)
|
|
308
|
+
|
|
309
|
+
Beskar::Logger.debug("[WAF] Violation recorded for #{ip_address}: score=#{risk_score}, current_total=#{current_score.round(2)}, violations_count=#{violations.size}", component: :WAF)
|
|
310
|
+
|
|
311
|
+
# Store violations with TTL from config
|
|
312
|
+
ttl = config[:violation_window] || 6.hours
|
|
313
|
+
Rails.cache.write(cache_key, violations, expires_in: ttl)
|
|
314
|
+
|
|
315
|
+
# Log the violation
|
|
316
|
+
log_violation(ip_address, analysis_result, current_score, violations.size)
|
|
317
|
+
|
|
318
|
+
# Create security event if configured
|
|
319
|
+
if config[:create_security_events]
|
|
320
|
+
create_security_event(ip_address, analysis_result, current_score)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Check if we should auto-block (skip if whitelisted)
|
|
324
|
+
threshold = config[:score_threshold] || 150
|
|
325
|
+
|
|
326
|
+
Beskar::Logger.debug("[WAF] Auto-block check: whitelisted=#{whitelisted}, auto_block=#{config[:auto_block]}, score=#{current_score.round(2)}, threshold=#{threshold}", component: :WAF)
|
|
327
|
+
|
|
328
|
+
if !whitelisted && config[:auto_block] && current_score >= threshold
|
|
329
|
+
Beskar::Logger.info("[WAF] Score threshold reached for #{ip_address}, creating ban record", component: :WAF)
|
|
330
|
+
# Always create the ban record (even in monitor-only mode)
|
|
331
|
+
auto_block_ip(ip_address, analysis_result, current_score)
|
|
332
|
+
|
|
333
|
+
# But also log monitor-only message if in monitor mode
|
|
334
|
+
if Beskar.configuration.monitor_only?
|
|
335
|
+
log_monitor_only_action(ip_address, analysis_result, current_score, threshold)
|
|
336
|
+
end
|
|
337
|
+
else
|
|
338
|
+
Beskar::Logger.debug("[WAF] Not auto-blocking: conditions not met", component: :WAF)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
current_score
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Get current risk score for an IP (with decay applied)
|
|
345
|
+
def get_current_score(ip_address)
|
|
346
|
+
violations = get_violations(ip_address)
|
|
347
|
+
calculate_current_score(violations, waf_config)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Get violations for an IP
|
|
351
|
+
def get_violations(ip_address)
|
|
352
|
+
cache_key = "beskar:waf_violations:#{ip_address}"
|
|
353
|
+
Rails.cache.read(cache_key) || []
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Get violation count for an IP (number of violations tracked)
|
|
357
|
+
def get_violation_count(ip_address)
|
|
358
|
+
get_violations(ip_address).size
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Reset violations for an IP
|
|
362
|
+
def reset_violations(ip_address)
|
|
363
|
+
cache_key = "beskar:waf_violations:#{ip_address}"
|
|
364
|
+
Rails.cache.delete(cache_key)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
private
|
|
368
|
+
|
|
369
|
+
# Calculate current cumulative score with decay applied
|
|
370
|
+
def calculate_current_score(violations, config)
|
|
371
|
+
return 0.0 if violations.empty?
|
|
372
|
+
return violations.sum { |v| v[:score] } unless config[:decay_enabled]
|
|
373
|
+
|
|
374
|
+
now = Time.current.to_i
|
|
375
|
+
decay_rates = config[:decay_rates] || {}
|
|
376
|
+
|
|
377
|
+
violations.sum do |v|
|
|
378
|
+
age_seconds = now - v[:timestamp]
|
|
379
|
+
age_minutes = age_seconds / 60.0
|
|
380
|
+
|
|
381
|
+
# Get half-life for this severity (in minutes)
|
|
382
|
+
half_life = decay_rates[v[:severity]] || 60
|
|
383
|
+
|
|
384
|
+
# Exponential decay: score * (1/2)^(age/half_life)
|
|
385
|
+
# Equivalent to: score * e^(-ln(2) * age / half_life)
|
|
386
|
+
decay_factor = Math.exp(-Math.log(2) * age_minutes / half_life)
|
|
387
|
+
|
|
388
|
+
v[:score] * decay_factor
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Prune violations that are outside the window or exceed max tracked
|
|
393
|
+
def prune_violations(violations, config)
|
|
394
|
+
now = Time.current.to_i
|
|
395
|
+
window_seconds = (config[:violation_window] || 6.hours).to_i
|
|
396
|
+
max_tracked = config[:max_violations_tracked] || 50
|
|
397
|
+
|
|
398
|
+
# Remove violations outside the time window
|
|
399
|
+
recent = violations.select do |v|
|
|
400
|
+
(now - v[:timestamp]) <= window_seconds
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Keep only the most recent violations if we exceed max_tracked
|
|
404
|
+
if recent.size > max_tracked
|
|
405
|
+
recent.sort_by { |v| -v[:timestamp] }.first(max_tracked)
|
|
406
|
+
else
|
|
407
|
+
recent
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Get WAF configuration
|
|
412
|
+
def waf_config
|
|
413
|
+
Beskar.configuration.waf || {}
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Calculate highest severity from detected patterns
|
|
417
|
+
def calculate_highest_severity(patterns)
|
|
418
|
+
severities = patterns.map { |p| p[:severity] }
|
|
419
|
+
return :critical if severities.include?(:critical)
|
|
420
|
+
return :high if severities.include?(:high)
|
|
421
|
+
return :medium if severities.include?(:medium)
|
|
422
|
+
:low
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Log WAF violation
|
|
426
|
+
def log_violation(ip_address, analysis_result, current_score, violation_count)
|
|
427
|
+
severity_emoji = {
|
|
428
|
+
critical: "🚨",
|
|
429
|
+
high: "⚠️",
|
|
430
|
+
medium: "⚡",
|
|
431
|
+
low: "ℹ️"
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
emoji = severity_emoji[analysis_result[:highest_severity]] || "🔍"
|
|
435
|
+
config = waf_config
|
|
436
|
+
monitor_mode_notice = Beskar.configuration.monitor_only? ? " [MONITOR-ONLY MODE]" : ""
|
|
437
|
+
|
|
438
|
+
Beskar::Logger.warn("#{emoji} Vulnerability scan detected#{monitor_mode_notice} " \
|
|
439
|
+
"(score: #{current_score.round(2)}, violations: #{violation_count}) - " \
|
|
440
|
+
"IP: #{ip_address}, " \
|
|
441
|
+
"Severity: #{analysis_result[:highest_severity]}, " \
|
|
442
|
+
"Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}, " \
|
|
443
|
+
"Path: #{analysis_result[:patterns].first[:matched_path]}", component: :WAF)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Log what would happen in monitor-only mode (but don't actually block)
|
|
447
|
+
def log_monitor_only_action(ip_address, analysis_result, current_score, threshold)
|
|
448
|
+
config = waf_config
|
|
449
|
+
duration = calculate_block_duration(current_score, config)
|
|
450
|
+
|
|
451
|
+
Beskar::Logger.warn("🔍 MONITOR-ONLY: IP #{ip_address} WOULD BE BLOCKED " \
|
|
452
|
+
"(score threshold reached: #{current_score.round(2)}/#{threshold}) - " \
|
|
453
|
+
"Duration would be: #{duration ? "#{duration / 3600.0} hours" : 'PERMANENT'}, " \
|
|
454
|
+
"Severity: #{analysis_result[:highest_severity]}, " \
|
|
455
|
+
"Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}. " \
|
|
456
|
+
"To enable blocking, set config.monitor_only = false", component: :WAF)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Create security event for WAF violation
|
|
460
|
+
def create_security_event(ip_address, analysis_result, current_score)
|
|
461
|
+
config = waf_config
|
|
462
|
+
violation_count = get_violation_count(ip_address)
|
|
463
|
+
threshold = config[:score_threshold] || 150
|
|
464
|
+
would_be_blocked = current_score >= threshold
|
|
465
|
+
|
|
466
|
+
Beskar::SecurityEvent.create!(
|
|
467
|
+
event_type: 'waf_violation',
|
|
468
|
+
ip_address: ip_address,
|
|
469
|
+
user_agent: analysis_result[:user_agent],
|
|
470
|
+
risk_score: severity_to_risk_score(analysis_result[:highest_severity]),
|
|
471
|
+
metadata: {
|
|
472
|
+
waf_analysis: analysis_result,
|
|
473
|
+
patterns_matched: analysis_result[:patterns].map { |p| p[:description] },
|
|
474
|
+
severity: analysis_result[:highest_severity],
|
|
475
|
+
monitor_only_mode: Beskar.configuration.monitor_only?,
|
|
476
|
+
would_be_blocked: would_be_blocked,
|
|
477
|
+
violation_count: violation_count,
|
|
478
|
+
current_score: current_score.round(2),
|
|
479
|
+
score_threshold: threshold
|
|
480
|
+
}
|
|
481
|
+
)
|
|
482
|
+
rescue => e
|
|
483
|
+
Beskar::Logger.error("Failed to create security event: #{e.message}", component: :WAF)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Auto-block an IP after threshold violations
|
|
487
|
+
def auto_block_ip(ip_address, analysis_result, current_score)
|
|
488
|
+
config = waf_config
|
|
489
|
+
duration = calculate_block_duration(current_score, config)
|
|
490
|
+
violation_count = get_violation_count(ip_address)
|
|
491
|
+
|
|
492
|
+
Beskar::Logger.debug("[WAF] Attempting to ban IP #{ip_address} with duration: #{duration.inspect}", component: :WAF)
|
|
493
|
+
|
|
494
|
+
begin
|
|
495
|
+
banned_ip = Beskar::BannedIp.ban!(
|
|
496
|
+
ip_address,
|
|
497
|
+
reason: 'waf_violation',
|
|
498
|
+
duration: duration,
|
|
499
|
+
permanent: duration.nil?,
|
|
500
|
+
details: "WAF score: #{current_score.round(2)} (#{violation_count} violations) - #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}",
|
|
501
|
+
metadata: {
|
|
502
|
+
violation_count: violation_count,
|
|
503
|
+
risk_score: current_score.round(2),
|
|
504
|
+
patterns: analysis_result[:patterns],
|
|
505
|
+
highest_severity: analysis_result[:highest_severity],
|
|
506
|
+
blocked_at: Time.current
|
|
507
|
+
}
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
Beskar::Logger.warn("🔒 Auto-blocked IP #{ip_address} " \
|
|
511
|
+
"with score #{current_score.round(2)} (#{violation_count} violations) " \
|
|
512
|
+
"(duration: #{duration ? "#{duration / 3600} hours" : 'permanent'}), " \
|
|
513
|
+
"Ban ID: #{banned_ip.id}", component: :WAF)
|
|
514
|
+
rescue => e
|
|
515
|
+
Beskar::Logger.error("[WAF] Failed to create ban for #{ip_address}: #{e.class} - #{e.message}", component: :WAF)
|
|
516
|
+
raise
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Calculate block duration based on cumulative score
|
|
521
|
+
def calculate_block_duration(current_score, config)
|
|
522
|
+
permanent_threshold = config[:permanent_block_after]
|
|
523
|
+
return nil if permanent_threshold && current_score >= permanent_threshold
|
|
524
|
+
|
|
525
|
+
# Default escalating durations: 1h, 6h, 24h, 7d
|
|
526
|
+
base_durations = config[:block_durations] || [1.hour, 6.hours, 24.hours, 7.days]
|
|
527
|
+
score_threshold = config[:score_threshold] || 150
|
|
528
|
+
|
|
529
|
+
# Calculate how many times over the threshold we are
|
|
530
|
+
# 150-300 = 1x = 1 hour
|
|
531
|
+
# 300-450 = 2x = 6 hours
|
|
532
|
+
# 450-600 = 3x = 24 hours
|
|
533
|
+
# 600+ = 4x = 7 days
|
|
534
|
+
multiplier = ((current_score / score_threshold).floor - 1).clamp(0, base_durations.length - 1)
|
|
535
|
+
base_durations[multiplier]
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Convert severity level to risk score
|
|
539
|
+
def severity_to_risk_score(severity)
|
|
540
|
+
case severity
|
|
541
|
+
when :critical then 95
|
|
542
|
+
when :high then 80
|
|
543
|
+
when :medium then 60
|
|
544
|
+
when :low then 30
|
|
545
|
+
else 50
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
end
|
data/lib/beskar/version.rb
CHANGED
data/lib/beskar.rb
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
require "beskar/version"
|
|
2
|
+
require "beskar/configuration"
|
|
3
|
+
require "beskar/logger"
|
|
4
|
+
require "beskar/middleware"
|
|
5
|
+
require "beskar/middleware/request_analyzer"
|
|
6
|
+
require "beskar/models/security_trackable_generic"
|
|
7
|
+
require "beskar/models/security_trackable_devise"
|
|
8
|
+
require "beskar/models/security_trackable_authenticable"
|
|
9
|
+
require "beskar/models/security_trackable"
|
|
10
|
+
require "beskar/services/rate_limiter"
|
|
11
|
+
require "beskar/services/device_detector"
|
|
12
|
+
require "beskar/services/geolocation_service"
|
|
13
|
+
require "beskar/services/account_locker"
|
|
14
|
+
require "beskar/services/ip_whitelist"
|
|
15
|
+
require "beskar/services/waf"
|
|
2
16
|
require "beskar/engine"
|
|
3
17
|
|
|
4
18
|
module Beskar
|
|
5
|
-
|
|
19
|
+
class << self
|
|
20
|
+
attr_accessor :configuration
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.configure
|
|
24
|
+
self.configuration ||= Configuration.new
|
|
25
|
+
yield(configuration)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Convenience method to access the rate limiter
|
|
29
|
+
def self.rate_limiter
|
|
30
|
+
Services::RateLimiter
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check if a request should be rate limited
|
|
34
|
+
def self.rate_limited?(request, user = nil)
|
|
35
|
+
Services::RateLimiter.is_rate_limited?(request, user)
|
|
36
|
+
end
|
|
6
37
|
end
|