beskar 0.0.2 → 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 +298 -110
- 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 +6 -6
- data/app/models/beskar/banned_ip.rb +68 -27
- data/app/models/beskar/security_event.rb +14 -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/routes.rb +41 -0
- data/lib/beskar/configuration.rb +24 -10
- data/lib/beskar/engine.rb +4 -4
- data/lib/beskar/logger.rb +293 -0
- data/lib/beskar/middleware/request_analyzer.rb +128 -53
- data/lib/beskar/models/security_trackable_authenticable.rb +11 -11
- data/lib/beskar/models/security_trackable_devise.rb +5 -5
- data/lib/beskar/models/security_trackable_generic.rb +12 -12
- data/lib/beskar/services/account_locker.rb +12 -12
- data/lib/beskar/services/geolocation_service.rb +8 -8
- data/lib/beskar/services/ip_whitelist.rb +2 -2
- data/lib/beskar/services/waf.rb +307 -78
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar.rb +1 -0
- 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 +11 -2
- metadata +35 -6
- data/lib/beskar/templates/beskar_initializer.rb +0 -107
data/lib/beskar/services/waf.rb
CHANGED
|
@@ -3,11 +3,37 @@ module Beskar
|
|
|
3
3
|
class Waf
|
|
4
4
|
# Common vulnerability scan patterns
|
|
5
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
|
+
},
|
|
6
32
|
wordpress: {
|
|
7
33
|
patterns: [
|
|
8
34
|
%r{/wp-admin}i,
|
|
9
35
|
%r{/wp-login\.php}i,
|
|
10
|
-
%r{/wp-content}i,
|
|
36
|
+
%r{/wp-content/.*\.php}i, # PHP files in wp-content are suspicious
|
|
11
37
|
%r{/wp-includes}i,
|
|
12
38
|
%r{/xmlrpc\.php}i,
|
|
13
39
|
%r{/wp-config\.php}i,
|
|
@@ -17,6 +43,14 @@ module Beskar
|
|
|
17
43
|
severity: :high,
|
|
18
44
|
description: "WordPress vulnerability scan"
|
|
19
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
|
+
},
|
|
20
54
|
php_admin: {
|
|
21
55
|
patterns: [
|
|
22
56
|
%r{/phpmyadmin}i,
|
|
@@ -92,6 +126,13 @@ module Beskar
|
|
|
92
126
|
}
|
|
93
127
|
}.freeze
|
|
94
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
|
+
|
|
95
136
|
class << self
|
|
96
137
|
# Analyze a request for vulnerability scanning patterns
|
|
97
138
|
def analyze_request(request)
|
|
@@ -128,17 +169,109 @@ module Beskar
|
|
|
128
169
|
end
|
|
129
170
|
end
|
|
130
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
|
+
|
|
131
264
|
# Check if request should be blocked based on violation history
|
|
132
265
|
def should_block?(ip_address)
|
|
133
266
|
config = waf_config
|
|
134
267
|
return false unless config[:enabled]
|
|
135
268
|
return false unless config[:auto_block]
|
|
136
269
|
|
|
137
|
-
#
|
|
138
|
-
|
|
139
|
-
threshold = config[:
|
|
270
|
+
# Get current risk score (with decay applied)
|
|
271
|
+
current_score = get_current_score(ip_address)
|
|
272
|
+
threshold = config[:score_threshold] || 150
|
|
140
273
|
|
|
141
|
-
|
|
274
|
+
current_score >= threshold
|
|
142
275
|
end
|
|
143
276
|
|
|
144
277
|
# Record a WAF violation
|
|
@@ -146,42 +279,83 @@ module Beskar
|
|
|
146
279
|
config = waf_config
|
|
147
280
|
return unless config[:enabled]
|
|
148
281
|
|
|
149
|
-
|
|
282
|
+
Beskar::Logger.debug("[WAF] Recording violation for IP: #{ip_address}, whitelisted: #{whitelisted}", component: :WAF)
|
|
283
|
+
|
|
284
|
+
# Get current violations and add new one
|
|
150
285
|
cache_key = "beskar:waf_violations:#{ip_address}"
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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)
|
|
157
314
|
|
|
158
315
|
# Log the violation
|
|
159
|
-
log_violation(ip_address, analysis_result,
|
|
316
|
+
log_violation(ip_address, analysis_result, current_score, violations.size)
|
|
160
317
|
|
|
161
318
|
# Create security event if configured
|
|
162
319
|
if config[:create_security_events]
|
|
163
|
-
create_security_event(ip_address, analysis_result)
|
|
320
|
+
create_security_event(ip_address, analysis_result, current_score)
|
|
164
321
|
end
|
|
165
322
|
|
|
166
|
-
# Check if we should auto-block (skip if whitelisted
|
|
167
|
-
threshold = config[:
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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)
|
|
175
336
|
end
|
|
337
|
+
else
|
|
338
|
+
Beskar::Logger.debug("[WAF] Not auto-blocking: conditions not met", component: :WAF)
|
|
176
339
|
end
|
|
177
340
|
|
|
178
|
-
|
|
341
|
+
current_score
|
|
179
342
|
end
|
|
180
343
|
|
|
181
|
-
# Get current
|
|
182
|
-
def
|
|
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)
|
|
183
352
|
cache_key = "beskar:waf_violations:#{ip_address}"
|
|
184
|
-
Rails.cache.read(cache_key) ||
|
|
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
|
|
185
359
|
end
|
|
186
360
|
|
|
187
361
|
# Reset violations for an IP
|
|
@@ -192,6 +366,48 @@ module Beskar
|
|
|
192
366
|
|
|
193
367
|
private
|
|
194
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
|
+
|
|
195
411
|
# Get WAF configuration
|
|
196
412
|
def waf_config
|
|
197
413
|
Beskar.configuration.waf || {}
|
|
@@ -207,7 +423,7 @@ module Beskar
|
|
|
207
423
|
end
|
|
208
424
|
|
|
209
425
|
# Log WAF violation
|
|
210
|
-
def log_violation(ip_address, analysis_result, violation_count)
|
|
426
|
+
def log_violation(ip_address, analysis_result, current_score, violation_count)
|
|
211
427
|
severity_emoji = {
|
|
212
428
|
critical: "🚨",
|
|
213
429
|
high: "⚠️",
|
|
@@ -217,40 +433,36 @@ module Beskar
|
|
|
217
433
|
|
|
218
434
|
emoji = severity_emoji[analysis_result[:highest_severity]] || "🔍"
|
|
219
435
|
config = waf_config
|
|
220
|
-
monitor_mode_notice =
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
"
|
|
224
|
-
"(#{violation_count} violations) - " \
|
|
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}) - " \
|
|
225
440
|
"IP: #{ip_address}, " \
|
|
226
441
|
"Severity: #{analysis_result[:highest_severity]}, " \
|
|
227
442
|
"Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}, " \
|
|
228
|
-
"Path: #{analysis_result[:patterns].first[:matched_path]}"
|
|
229
|
-
)
|
|
443
|
+
"Path: #{analysis_result[:patterns].first[:matched_path]}", component: :WAF)
|
|
230
444
|
end
|
|
231
445
|
|
|
232
446
|
# Log what would happen in monitor-only mode (but don't actually block)
|
|
233
|
-
def log_monitor_only_action(ip_address, analysis_result,
|
|
447
|
+
def log_monitor_only_action(ip_address, analysis_result, current_score, threshold)
|
|
234
448
|
config = waf_config
|
|
235
|
-
duration = calculate_block_duration(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
"
|
|
239
|
-
"(threshold reached: #{violation_count}/#{threshold} violations) - " \
|
|
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}) - " \
|
|
240
453
|
"Duration would be: #{duration ? "#{duration / 3600.0} hours" : 'PERMANENT'}, " \
|
|
241
454
|
"Severity: #{analysis_result[:highest_severity]}, " \
|
|
242
455
|
"Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}. " \
|
|
243
|
-
"To enable blocking, set config.
|
|
244
|
-
)
|
|
456
|
+
"To enable blocking, set config.monitor_only = false", component: :WAF)
|
|
245
457
|
end
|
|
246
458
|
|
|
247
459
|
# Create security event for WAF violation
|
|
248
|
-
def create_security_event(ip_address, analysis_result)
|
|
460
|
+
def create_security_event(ip_address, analysis_result, current_score)
|
|
249
461
|
config = waf_config
|
|
250
462
|
violation_count = get_violation_count(ip_address)
|
|
251
|
-
threshold = config[:
|
|
252
|
-
would_be_blocked =
|
|
253
|
-
|
|
463
|
+
threshold = config[:score_threshold] || 150
|
|
464
|
+
would_be_blocked = current_score >= threshold
|
|
465
|
+
|
|
254
466
|
Beskar::SecurityEvent.create!(
|
|
255
467
|
event_type: 'waf_violation',
|
|
256
468
|
ip_address: ip_address,
|
|
@@ -260,50 +472,67 @@ module Beskar
|
|
|
260
472
|
waf_analysis: analysis_result,
|
|
261
473
|
patterns_matched: analysis_result[:patterns].map { |p| p[:description] },
|
|
262
474
|
severity: analysis_result[:highest_severity],
|
|
263
|
-
monitor_only_mode:
|
|
475
|
+
monitor_only_mode: Beskar.configuration.monitor_only?,
|
|
264
476
|
would_be_blocked: would_be_blocked,
|
|
265
477
|
violation_count: violation_count,
|
|
266
|
-
|
|
478
|
+
current_score: current_score.round(2),
|
|
479
|
+
score_threshold: threshold
|
|
267
480
|
}
|
|
268
481
|
)
|
|
269
482
|
rescue => e
|
|
270
|
-
|
|
483
|
+
Beskar::Logger.error("Failed to create security event: #{e.message}", component: :WAF)
|
|
271
484
|
end
|
|
272
485
|
|
|
273
486
|
# Auto-block an IP after threshold violations
|
|
274
|
-
def auto_block_ip(ip_address, analysis_result,
|
|
487
|
+
def auto_block_ip(ip_address, analysis_result, current_score)
|
|
275
488
|
config = waf_config
|
|
276
|
-
duration = calculate_block_duration(
|
|
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
|
-
)
|
|
489
|
+
duration = calculate_block_duration(current_score, config)
|
|
490
|
+
violation_count = get_violation_count(ip_address)
|
|
291
491
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
297
518
|
end
|
|
298
519
|
|
|
299
|
-
# Calculate block duration based on
|
|
300
|
-
def calculate_block_duration(
|
|
301
|
-
|
|
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
|
|
302
524
|
|
|
303
|
-
# Default escalating durations: 1h, 6h, 24h, 7d
|
|
525
|
+
# Default escalating durations: 1h, 6h, 24h, 7d
|
|
304
526
|
base_durations = config[:block_durations] || [1.hour, 6.hours, 24.hours, 7.days]
|
|
305
|
-
|
|
306
|
-
|
|
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]
|
|
307
536
|
end
|
|
308
537
|
|
|
309
538
|
# Convert severity level to risk score
|
|
@@ -312,7 +541,7 @@ module Beskar
|
|
|
312
541
|
when :critical then 95
|
|
313
542
|
when :high then 80
|
|
314
543
|
when :medium then 60
|
|
315
|
-
when :low then
|
|
544
|
+
when :low then 30
|
|
316
545
|
else 50
|
|
317
546
|
end
|
|
318
547
|
end
|
data/lib/beskar/version.rb
CHANGED
data/lib/beskar.rb
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/migration'
|
|
5
|
+
|
|
6
|
+
module Beskar
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a Beskar initializer and runs migrations"
|
|
14
|
+
|
|
15
|
+
def self.next_migration_number(path)
|
|
16
|
+
if @prev_migration_nr
|
|
17
|
+
@prev_migration_nr += 1
|
|
18
|
+
else
|
|
19
|
+
@prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
|
|
20
|
+
end
|
|
21
|
+
@prev_migration_nr.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def copy_initializer
|
|
25
|
+
template "initializer.rb.tt", "config/initializers/beskar.rb"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mount_engine
|
|
29
|
+
route_text = "mount Beskar::Engine => '/beskar'"
|
|
30
|
+
|
|
31
|
+
# Check if the route already exists
|
|
32
|
+
if File.read("config/routes.rb").include?(route_text)
|
|
33
|
+
say "Route already mounted, skipping...", :yellow
|
|
34
|
+
else
|
|
35
|
+
route route_text
|
|
36
|
+
say "Mounted Beskar engine at /beskar", :green
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def copy_migrations
|
|
41
|
+
# Copy migrations from the engine to the host app
|
|
42
|
+
migration_source = File.expand_path("../../../../../db/migrate", __dir__)
|
|
43
|
+
|
|
44
|
+
if Dir.exist?(migration_source)
|
|
45
|
+
Dir.glob("#{migration_source}/*.rb").each do |migration|
|
|
46
|
+
migration_name = File.basename(migration).sub(/^\d+_/, '')
|
|
47
|
+
|
|
48
|
+
# Check if migration already exists
|
|
49
|
+
if migration_already_exists?(migration_name)
|
|
50
|
+
say "Migration #{migration_name} already exists, skipping...", :yellow
|
|
51
|
+
else
|
|
52
|
+
migration_template migration, "db/migrate/#{migration_name}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
say "Migrations copied. Run 'rails db:migrate' to create the tables.", :green
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# CSS Zero is no longer required - styles are embedded in the dashboard
|
|
61
|
+
# def install_css_zero
|
|
62
|
+
# # Removed - dashboard now uses embedded styles
|
|
63
|
+
# end
|
|
64
|
+
|
|
65
|
+
def show_readme
|
|
66
|
+
readme_content = <<~README
|
|
67
|
+
|
|
68
|
+
===============================================================================
|
|
69
|
+
🛡️ Beskar Installation Complete!
|
|
70
|
+
===============================================================================
|
|
71
|
+
|
|
72
|
+
Next steps:
|
|
73
|
+
|
|
74
|
+
1. Run migrations to create the security tables:
|
|
75
|
+
$ rails db:migrate
|
|
76
|
+
|
|
77
|
+
2. Configure authentication for the dashboard in config/initializers/beskar.rb
|
|
78
|
+
|
|
79
|
+
For Devise users:
|
|
80
|
+
config.authenticate_admin = proc do
|
|
81
|
+
authenticate_admin!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
For custom authentication:
|
|
85
|
+
config.authenticate_admin = proc do
|
|
86
|
+
redirect_to main_app.root_path unless current_user&.admin?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
3. Add Beskar concerns to your User model (or authentication model):
|
|
90
|
+
|
|
91
|
+
class User < ApplicationRecord
|
|
92
|
+
include Beskar::Models::SecurityTrackable
|
|
93
|
+
|
|
94
|
+
# For Devise users, also add:
|
|
95
|
+
include Beskar::Models::SecurityTrackableDevise
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
4. Access the security dashboard at:
|
|
99
|
+
http://localhost:3000/beskar
|
|
100
|
+
|
|
101
|
+
5. Optional: Configure additional settings in config/initializers/beskar.rb
|
|
102
|
+
- IP whitelist
|
|
103
|
+
- WAF rules
|
|
104
|
+
- Rate limiting
|
|
105
|
+
- Geolocation
|
|
106
|
+
- Risk-based locking
|
|
107
|
+
|
|
108
|
+
===============================================================================
|
|
109
|
+
📚 Documentation
|
|
110
|
+
===============================================================================
|
|
111
|
+
|
|
112
|
+
Dashboard Guide: https://github.com/humadroid-io/beskar/blob/main/DASHBOARD.md
|
|
113
|
+
Configuration: https://github.com/humadroid-io/beskar/blob/main/README.md
|
|
114
|
+
|
|
115
|
+
===============================================================================
|
|
116
|
+
⚠️ Important for Production
|
|
117
|
+
===============================================================================
|
|
118
|
+
|
|
119
|
+
1. ALWAYS configure authentication for the dashboard
|
|
120
|
+
2. Set monitor_only = false when ready to block threats
|
|
121
|
+
3. Configure your IP whitelist to prevent locking yourself out
|
|
122
|
+
4. Set up database indexes for large-scale deployments:
|
|
123
|
+
|
|
124
|
+
$ rails generate beskar:indexes
|
|
125
|
+
$ rails db:migrate
|
|
126
|
+
|
|
127
|
+
===============================================================================
|
|
128
|
+
💡 Quick Tips
|
|
129
|
+
===============================================================================
|
|
130
|
+
|
|
131
|
+
- Start with monitor_only = true to observe without blocking
|
|
132
|
+
- Use the dashboard to review security events before enabling blocking
|
|
133
|
+
- Configure email notifications for high-risk events
|
|
134
|
+
- Export security data regularly for analysis
|
|
135
|
+
- Consider implementing custom risk scoring for your use case
|
|
136
|
+
|
|
137
|
+
===============================================================================
|
|
138
|
+
|
|
139
|
+
README
|
|
140
|
+
|
|
141
|
+
say readme_content, :green
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def migration_already_exists?(migration_name)
|
|
147
|
+
Dir.glob("db/migrate/*_#{migration_name}").any?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def migration_template(source, destination)
|
|
151
|
+
migration_number = self.class.next_migration_number(nil)
|
|
152
|
+
file_name = "#{migration_number}_#{destination}"
|
|
153
|
+
|
|
154
|
+
copy_file source, file_name
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|