beskar 0.0.2 → 0.2.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +274 -0
  3. data/README.md +412 -204
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +214 -0
  7. data/app/controllers/beskar/banned_ips_controller.rb +255 -0
  8. data/app/controllers/beskar/dashboard_controller.rb +62 -0
  9. data/app/controllers/beskar/security_events_controller.rb +164 -0
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +105 -105
  17. data/app/models/beskar/security_event.rb +51 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +88 -0
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +195 -0
  23. data/app/views/beskar/banned_ips/index.html.erb +319 -0
  24. data/app/views/beskar/banned_ips/new.html.erb +190 -0
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +304 -0
  27. data/app/views/beskar/dashboard/index.html.erb +280 -0
  28. data/app/views/beskar/security_events/index.html.erb +302 -0
  29. data/app/views/beskar/security_events/show.html.erb +293 -0
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +581 -6
  33. data/config/routes.rb +30 -0
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +105 -20
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +288 -0
  58. data/lib/beskar/middleware/request_analyzer.rb +133 -99
  59. data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
  60. data/lib/beskar/models/security_trackable_devise.rb +34 -25
  61. data/lib/beskar/models/security_trackable_generic.rb +171 -214
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +90 -81
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +311 -198
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +54 -4
  86. data/lib/generators/beskar/install/install_generator.rb +158 -0
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
  88. data/lib/tasks/beskar_tasks.rake +25 -20
  89. metadata +93 -12
  90. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -1,131 +1,126 @@
1
1
  module Beskar
2
2
  module Services
3
3
  class Waf
4
- # Common vulnerability scan patterns
4
+ RULES_VERSION = 1
5
+ # Root/segment boundaries prevent matches inside ordinary words. Matching
6
+ # uses a decoded path, not arbitrary query values; dot segments are retained.
5
7
  VULNERABILITY_PATTERNS = {
8
+ rails_exceptions: {
9
+ patterns: [%r{/(?:users?|posts?|articles?|comments?|api/v\d+/\w+)/\d+\.(?:exe|bat|cmd|com|scr|vbs|jar|app|deb|rpm)\z}i,
10
+ %r{/(?:users?|posts?|articles?|comments?|api/v\d+/\w+)\.(?:asp|aspx|jsp|do|action|cgi|pl|py|rb)\z}i],
11
+ severity: :medium, description: "Potential Rails exception triggering attempt"
12
+ },
13
+ record_scanning: {
14
+ patterns: [%r{/(?:user|admin|account|profile|order|payment|invoice|document|file|download)/\d{6,}(?:/|\z)}i,
15
+ %r{\A/(?:user|admin|account|profile)/(?:test|admin|root|administrator|superuser)(?:/|\z)}i,
16
+ %r{/api/v\d+/(?:users?|accounts?|orders?|payments?)/(?:999999|123456|0|null|undefined)(?:/|\z)}i],
17
+ severity: :low, description: "Potential record enumeration/scanning"
18
+ },
6
19
  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"
20
+ patterns: [%r{/wp-admin(?:/|\z)}i, %r{/wp-login\.php(?:/|\z)}i,
21
+ %r{/wp-content/.*\.php(?:/|\z)}i, %r{/wp-includes(?:/|\z)}i,
22
+ %r{/xmlrpc\.php(?:/|\z)}i, %r{/wp-config\.(?:php|bak)(?:/|\z)}i,
23
+ %r{\A/wordpress(?:/|\z)}i],
24
+ severity: :high, description: "WordPress vulnerability scan"
25
+ },
26
+ wordpress_static: {
27
+ patterns: [%r{/wp-content/.*\.(?:css|js|jpe?g|png|gif|svg|webp|ico|woff2?|ttf|eot|map)\z}i,
28
+ %r{/wp-content/(?:uploads|themes|plugins)/[^.]*\z}i],
29
+ severity: :low, description: "WordPress static file probe"
19
30
  },
20
31
  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"
32
+ patterns: [%r{\A/(?:phpmyadmin|pma|administrator)(?:/|\z)}i,
33
+ %r{/(?:admin|phpinfo)\.php(?:/|\z)}i, %r{/admin/config\.php(?:/|\z)}i],
34
+ severity: :high, description: "PHP admin panel scan"
31
35
  },
32
36
  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"
37
+ patterns: [%r{/(?:\.env(?:\.[a-z0-9_-]+)?|\.git)(?:/|\z)}i,
38
+ %r{/(?:config|configuration|settings)\.php(?:/|\z)}i,
39
+ %r{/(?:database|credentials)\.yml(?:\.enc)?(?:/|\z)}i],
40
+ severity: :critical, description: "Configuration file access attempt"
44
41
  },
45
42
  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"
43
+ patterns: [%r{/(?:etc/(?:passwd|shadow|hosts))(?:/|\z)}i, %r{(?:\A|/)\.\.(?:/|\z)}],
44
+ severity: :critical, description: "Path traversal attempt"
57
45
  },
58
46
  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"
47
+ patterns: [%r{\A/(?:rails/info/routes|__debug__|debug|telescope|_profiler)(?:/|\z)}],
48
+ severity: :medium, description: "Framework debug endpoint scan"
69
49
  },
70
50
  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"
51
+ patterns: [%r{\A/(?:joomla|drupal|magento|prestashop|typo3)(?:/|\z)}i],
52
+ severity: :medium, description: "CMS detection scan"
80
53
  },
81
54
  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"
55
+ patterns: [%r{/(?:shell|cmd|c99|r57)\.php(?:/|\z)}i, %r{/(?:backdoor|webshell)(?:/|\z)}i],
56
+ severity: :critical, description: "Common exploit file access"
92
57
  }
93
58
  }.freeze
94
59
 
60
+ EXCEPTION_RULES = {
61
+ "ActionController::UnknownFormat" => [:unknown_format, :medium, "Unknown format requested - potential scanner"],
62
+ "ActionDispatch::RemoteIp::IpSpoofAttackError" => [:ip_spoof, :critical, "IP spoofing attack detected"],
63
+ "ActiveRecord::RecordNotFound" => [:record_not_found, :low, "Record not found - potential enumeration scan"],
64
+ "ActionDispatch::Http::MimeNegotiation::InvalidType" => [:invalid_mime_type, :medium, "Invalid MIME type requested - potential scanner"]
65
+ }.freeze
66
+
67
+ # Configuration for RecordNotFound exclusion patterns
68
+ # These patterns will not trigger WAF violations
69
+ RECORD_NOT_FOUND_EXCLUSIONS = [
70
+ # Add default exclusions here if needed
71
+ # %r{/posts/.*}, # Example: exclude all posts paths
72
+ ].freeze
73
+
95
74
  class << self
96
75
  # Analyze a request for vulnerability scanning patterns
97
76
  def analyze_request(request)
98
- path = request.fullpath || request.path
99
- return nil if path.blank?
100
-
101
- detected_patterns = []
102
-
77
+ input = WafRequest.new(request)
78
+ return nil if input.path.blank? && !input.problem
79
+ patterns = []
80
+ if input.problem && !input.excluded?(:malformed_path)
81
+ patterns << {category: :malformed_path, rule_id: "malformed_path:#{input.problem}",
82
+ severity: :medium, description: "Malformed or oversized request path"}
83
+ end
103
84
  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
85
+ next if input.excluded?(category)
86
+ config[:patterns].each_with_index do |pattern, index|
87
+ next unless input.path.match?(pattern)
88
+ patterns << {category: category, rule_id: "#{category}:#{index}", severity: config[:severity], description: config[:description]}
114
89
  end
115
90
  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
91
+ if input.suspicious_format? && !input.excluded?(:rails_exceptions)
92
+ patterns << {category: :rails_exceptions, rule_id: "rails_exceptions:format",
93
+ severity: :medium, description: VULNERABILITY_PATTERNS[:rails_exceptions][:description]}
128
94
  end
95
+ analysis_for(request, input, patterns) if patterns.any?
96
+ end
97
+
98
+ def analyze_exception(exception, request)
99
+ rule = EXCEPTION_RULES[exception.class.name]
100
+ return unless rule
101
+ category, severity, description = rule
102
+ input = WafRequest.new(request)
103
+ return if input.excluded?(category)
104
+ return if category == :record_not_found && should_exclude_record_not_found?(input.path)
105
+ policy = waf_config.fetch(:exception_detection, :suspicious)
106
+ return if policy == :none
107
+ # A Rails error alone is not proof of abuse. Broad legacy scoring must
108
+ # be opted into; resolved IP-spoof exceptions remain a distinct signal.
109
+ return unless policy == :all || category == :ip_spoof || analyze_request(request)
110
+ analysis_for(request, input, [{category: category, rule_id: "exception:#{category}",
111
+ severity: severity, description: description}])
112
+ .merge(exception_class: exception.class.name)
113
+ end
114
+
115
+ # Check if a RecordNotFound exception should be excluded from WAF
116
+ def should_exclude_record_not_found?(path)
117
+ return false if path.blank?
118
+
119
+ # Check configured exclusions
120
+ exclusions = waf_config[:record_not_found_exclusions] || []
121
+ all_exclusions = RECORD_NOT_FOUND_EXCLUSIONS + exclusions
122
+
123
+ all_exclusions.any? { |pattern| path.match?(pattern) }
129
124
  end
130
125
 
131
126
  # Check if request should be blocked based on violation history
@@ -134,11 +129,11 @@ module Beskar
134
129
  return false unless config[:enabled]
135
130
  return false unless config[:auto_block]
136
131
 
137
- # Check violation count in cache
138
- violation_count = get_violation_count(ip_address)
139
- threshold = config[:block_threshold] || 3
132
+ # Get current risk score (with decay applied)
133
+ current_score = get_current_score(ip_address)
134
+ threshold = config[:score_threshold] || 150
140
135
 
141
- violation_count >= threshold
136
+ current_score >= threshold
142
137
  end
143
138
 
144
139
  # Record a WAF violation
@@ -146,52 +141,156 @@ module Beskar
146
141
  config = waf_config
147
142
  return unless config[:enabled]
148
143
 
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)
144
+ Beskar::Logger.debug("[WAF] Recording violation for IP: #{ip_address}, whitelisted: #{whitelisted}", component: :WAF)
145
+
146
+ analysis_result = sanitized_analysis(analysis_result)
147
+ key = state_key(ip_address, whitelisted: whitelisted)
148
+ risk_score = severity_to_risk_score(analysis_result[:highest_severity])
149
+ violations, current_score = SecurityState.mutate(key, ttl: config[:violation_window] || 6.hours) do |state|
150
+ entries = Array(state[key]["violations"]).map { |entry| entry.deep_symbolize_keys.slice(:timestamp, :score, :severity, :category, :rule_id) }
151
+ entries << {
152
+ timestamp: Time.current.to_i,
153
+ score: risk_score,
154
+ severity: analysis_result[:highest_severity],
155
+ category: analysis_result[:patterns].first[:category],
156
+ rule_id: analysis_result[:patterns].first[:rule_id]
157
+ }
158
+ entries = prune_violations(entries, config)
159
+ state[key]["violations"] = entries
160
+ [entries, calculate_current_score(entries, config)]
161
+ end
157
162
 
158
163
  # Log the violation
159
- log_violation(ip_address, analysis_result, new_count)
164
+ log_violation(ip_address, analysis_result, current_score, violations.size)
160
165
 
161
166
  # Create security event if configured
162
167
  if config[:create_security_events]
163
- create_security_event(ip_address, analysis_result)
168
+ create_security_event(ip_address, analysis_result, current_score, whitelisted: whitelisted, violation_count: violations.size)
164
169
  end
165
170
 
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)
171
+ # Check if we should auto-block (skip if whitelisted)
172
+ threshold = config[:score_threshold] || 150
173
+
174
+ Beskar::Logger.debug("[WAF] Auto-block check: whitelisted=#{whitelisted}, auto_block=#{config[:auto_block]}, score=#{current_score.round(2)}, threshold=#{threshold}", component: :WAF)
175
+
176
+ if !whitelisted && !IpWhitelist.whitelisted?(ip_address) && config[:auto_block] && current_score >= threshold
177
+ if Beskar.configuration.monitor_only?
178
+ log_monitor_only_action(ip_address, analysis_result, current_score, threshold)
173
179
  else
174
- auto_block_ip(ip_address, analysis_result, new_count)
180
+ auto_block_ip(ip_address, analysis_result, current_score)
175
181
  end
176
182
  end
177
183
 
178
- new_count
184
+ current_score
179
185
  end
180
186
 
181
- # Get current violation count for an IP
187
+ # Get current risk score for an IP (with decay applied)
188
+ def get_current_score(ip_address)
189
+ violations = get_violations(ip_address)
190
+ calculate_current_score(violations, waf_config)
191
+ end
192
+
193
+ # Get violations for an IP
194
+ def get_violations(ip_address)
195
+ entries = Array(SecurityState.read(state_key(ip_address))["violations"]).map { |entry| entry.deep_symbolize_keys.slice(:timestamp, :score, :severity, :category, :rule_id) }
196
+ prune_violations(entries, waf_config)
197
+ end
198
+
199
+ # Get violation count for an IP (number of violations tracked)
182
200
  def get_violation_count(ip_address)
183
- cache_key = "beskar:waf_violations:#{ip_address}"
184
- Rails.cache.read(cache_key) || 0
201
+ get_violations(ip_address).size
185
202
  end
186
203
 
187
204
  # Reset violations for an IP
188
205
  def reset_violations(ip_address)
189
- cache_key = "beskar:waf_violations:#{ip_address}"
190
- Rails.cache.delete(cache_key)
206
+ ip_address = IPAddr.new(ip_address.to_s).to_s
207
+ keys = ["waf:enforce:#{ip_address}", "waf:observe:#{ip_address}"]
208
+ SecurityState.mutate(keys, ttl: 1.second) { |state| state.each_value(&:clear) }
191
209
  end
192
210
 
193
211
  private
194
212
 
213
+ def analysis_for(request, input, patterns)
214
+ {threat_detected: true, patterns: patterns, highest_severity: calculate_highest_severity(patterns),
215
+ ip_address: RequestContext.ip(request), request_method: input.method, rules_version: RULES_VERSION,
216
+ decoding_passes: input.decoding_passes, timestamp: Time.current}
217
+ end
218
+
219
+ # A public caller may supply an old-style analysis containing raw URLs
220
+ # or exception messages. Project onto rule evidence before any sink.
221
+ def sanitized_analysis(analysis)
222
+ severities = %i[critical high medium low]
223
+ severity = severities.find { |level| level.to_s == analysis[:highest_severity].to_s }
224
+ raise ArgumentError, "WAF analysis requires a valid severity" unless severity
225
+ passes = analysis[:decoding_passes]
226
+ result = {threat_detected: true, highest_severity: severity, rules_version: RULES_VERSION,
227
+ decoding_passes: (passes.is_a?(Integer) && (0..2).cover?(passes)) ? passes : 0, timestamp: Time.current}
228
+ categories = VULNERABILITY_PATTERNS.keys + EXCEPTION_RULES.values.map(&:first) + [:malformed_path]
229
+ result[:patterns] = Array(analysis[:patterns]).first(32).map do |pattern|
230
+ category = categories.find { |value| value.to_s == pattern[:category].to_s } || :custom
231
+ config = VULNERABILITY_PATTERNS[category]
232
+ exception = EXCEPTION_RULES.values.find { |rule| rule.first == category }
233
+ description = config&.fetch(:description) || exception&.last || "Custom WAF rule"
234
+ ids = config ? config[:patterns].each_index.map { |index| "#{category}:#{index}" } : []
235
+ ids << "rails_exceptions:format" if category == :rails_exceptions
236
+ ids << "exception:#{category}" if exception
237
+ ids.concat(%w[oversized_path invalid_encoding excessive_encoding].map { |problem| "malformed_path:#{problem}" }) if category == :malformed_path
238
+ rule_id = ids.include?(pattern[:rule_id]) ? pattern[:rule_id] : "custom"
239
+ {category: category, rule_id: rule_id, severity: severities.find { |value| value.to_s == pattern[:severity].to_s } || severity, description: description}
240
+ end
241
+ raise ArgumentError, "WAF analysis requires matched rules" if result[:patterns].empty?
242
+ result[:exception_class] = analysis[:exception_class] if EXCEPTION_RULES.key?(analysis[:exception_class])
243
+ result[:request_method] = analysis[:request_method] if %w[GET HEAD POST PUT PATCH DELETE OPTIONS CONNECT TRACE OTHER].include?(analysis[:request_method])
244
+ result
245
+ end
246
+
247
+ def state_key(ip_address, whitelisted: false)
248
+ mode = (Beskar.configuration.monitor_only? || whitelisted || IpWhitelist.whitelisted?(ip_address)) ? "observe" : "enforce"
249
+ "waf:#{mode}:#{IPAddr.new(ip_address.to_s)}"
250
+ end
251
+
252
+ # Calculate current cumulative score with decay applied
253
+ def calculate_current_score(violations, config)
254
+ return 0.0 if violations.empty?
255
+ return violations.sum { |v| v[:score] } unless config[:decay_enabled]
256
+
257
+ now = Time.current.to_i
258
+ decay_rates = config[:decay_rates] || {}
259
+
260
+ violations.sum do |v|
261
+ age_seconds = now - v[:timestamp]
262
+ age_minutes = age_seconds / 60.0
263
+
264
+ # Get half-life for this severity (in minutes)
265
+ half_life = decay_rates[v[:severity].to_sym] || 60
266
+
267
+ # Exponential decay: score * (1/2)^(age/half_life)
268
+ # Equivalent to: score * e^(-ln(2) * age / half_life)
269
+ decay_factor = Math.exp(-Math.log(2) * age_minutes / half_life)
270
+
271
+ v[:score] * decay_factor
272
+ end
273
+ end
274
+
275
+ # Prune violations that are outside the window or exceed max tracked
276
+ def prune_violations(violations, config)
277
+ now = Time.current.to_i
278
+ window_seconds = (config[:violation_window] || 6.hours).to_i
279
+ max_tracked = config[:max_violations_tracked] || 50
280
+
281
+ # Remove violations outside the time window
282
+ recent = violations.select do |v|
283
+ (now - v[:timestamp]) <= window_seconds
284
+ end
285
+
286
+ # Keep only the most recent violations if we exceed max_tracked
287
+ if recent.size > max_tracked
288
+ recent.sort_by { |v| -v[:timestamp] }.first(max_tracked)
289
+ else
290
+ recent
291
+ end
292
+ end
293
+
195
294
  # Get WAF configuration
196
295
  def waf_config
197
296
  Beskar.configuration.waf || {}
@@ -207,7 +306,7 @@ module Beskar
207
306
  end
208
307
 
209
308
  # Log WAF violation
210
- def log_violation(ip_address, analysis_result, violation_count)
309
+ def log_violation(ip_address, analysis_result, current_score, violation_count)
211
310
  severity_emoji = {
212
311
  critical: "🚨",
213
312
  high: "⚠️",
@@ -216,94 +315,108 @@ module Beskar
216
315
  }
217
316
 
218
317
  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) - " \
318
+ waf_config
319
+ monitor_mode_notice = Beskar.configuration.monitor_only? ? " [MONITOR-ONLY MODE]" : ""
320
+
321
+ Beskar::Logger.warn("#{emoji} Vulnerability scan detected#{monitor_mode_notice} " \
322
+ "(score: #{current_score.round(2)}, violations: #{violation_count}) - " \
225
323
  "IP: #{ip_address}, " \
226
324
  "Severity: #{analysis_result[:highest_severity]}, " \
227
- "Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}, " \
228
- "Path: #{analysis_result[:patterns].first[:matched_path]}"
229
- )
325
+ "Rules: #{analysis_result[:patterns].map { |p| p[:rule_id] }.join(", ")}", component: :WAF)
230
326
  end
231
327
 
232
328
  # 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)
329
+ def log_monitor_only_action(ip_address, analysis_result, current_score, threshold)
234
330
  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'}, " \
331
+ duration = calculate_block_duration(current_score, config)
332
+
333
+ Beskar::Logger.warn("🔍 MONITOR-ONLY: IP #{ip_address} WOULD BE BLOCKED " \
334
+ "(score threshold reached: #{current_score.round(2)}/#{threshold}) - " \
335
+ "Duration would be: #{duration ? "#{duration / 3600.0} hours" : "PERMANENT"}, " \
241
336
  "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
- )
337
+ "Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(", ")}. " \
338
+ "To enable blocking, set config.monitor_only = false", component: :WAF)
245
339
  end
246
340
 
247
341
  # Create security event for WAF violation
248
- def create_security_event(ip_address, analysis_result)
342
+ def create_security_event(ip_address, analysis_result, current_score, whitelisted: false, violation_count: nil)
249
343
  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
- )
344
+ violation_count ||= get_violation_count(ip_address)
345
+ threshold = config[:score_threshold] || 150
346
+ would_be_blocked = config[:auto_block] && !whitelisted && !IpWhitelist.whitelisted?(ip_address) && current_score >= threshold
347
+
348
+ Beskar::SecurityEvent.transaction(requires_new: true) do
349
+ Beskar::SecurityEvent.create!(
350
+ event_type: "waf_violation",
351
+ ip_address: ip_address,
352
+ user_agent: nil,
353
+ risk_score: severity_to_risk_score(analysis_result[:highest_severity]),
354
+ metadata: {
355
+ waf_analysis: analysis_result,
356
+ patterns_matched: analysis_result[:patterns].map { |p| p[:description] },
357
+ severity: analysis_result[:highest_severity],
358
+ monitor_only_mode: Beskar.configuration.monitor_only?,
359
+ would_be_blocked: would_be_blocked,
360
+ violation_count: violation_count,
361
+ current_score: current_score.round(2),
362
+ score_threshold: threshold
363
+ }
364
+ )
365
+ end
269
366
  rescue => e
270
- Rails.logger.error "[Beskar::WAF] Failed to create security event: #{e.message}"
367
+ Beskar::Logger.error("Failed to create security event (#{e.class})", component: :WAF)
271
368
  end
272
369
 
273
370
  # Auto-block an IP after threshold violations
274
- def auto_block_ip(ip_address, analysis_result, violation_count)
371
+ def auto_block_ip(ip_address, analysis_result, current_score)
275
372
  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
- )
373
+ duration = calculate_block_duration(current_score, config)
374
+ violation_count = get_violation_count(ip_address)
291
375
 
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
- )
376
+ Beskar::Logger.debug("[WAF] Attempting to ban IP #{ip_address} with duration: #{duration.inspect}", component: :WAF)
377
+
378
+ begin
379
+ banned_ip = Beskar::BannedIp.ban!(
380
+ ip_address,
381
+ reason: "waf_violation",
382
+ duration: duration,
383
+ permanent: duration.nil?,
384
+ details: "WAF score: #{current_score.round(2)} (#{violation_count} violations) - #{analysis_result[:patterns].map { |p| p[:description] }.join(", ")}",
385
+ metadata: {
386
+ violation_count: violation_count,
387
+ risk_score: current_score.round(2),
388
+ patterns: analysis_result[:patterns],
389
+ highest_severity: analysis_result[:highest_severity],
390
+ blocked_at: Time.current
391
+ }
392
+ )
393
+
394
+ Beskar::Logger.warn("🔒 Auto-blocked IP #{ip_address} " \
395
+ "with score #{current_score.round(2)} (#{violation_count} violations) " \
396
+ "(duration: #{duration ? "#{duration / 3600} hours" : "permanent"}), " \
397
+ "Ban ID: #{banned_ip.id}", component: :WAF)
398
+ rescue => e
399
+ Beskar::Logger.error("[WAF] Failed to create ban for #{ip_address} (#{e.class})", component: :WAF)
400
+ raise
401
+ end
297
402
  end
298
403
 
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]
404
+ # Calculate block duration based on cumulative score
405
+ def calculate_block_duration(current_score, config)
406
+ permanent_threshold = config[:permanent_block_after]
407
+ return nil if permanent_threshold && current_score >= permanent_threshold
302
408
 
303
- # Default escalating durations: 1h, 6h, 24h, 7d, permanent
409
+ # Default escalating durations: 1h, 6h, 24h, 7d
304
410
  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]
411
+ score_threshold = config[:score_threshold] || 150
412
+
413
+ # Calculate how many times over the threshold we are
414
+ # 150-300 = 1x = 1 hour
415
+ # 300-450 = 2x = 6 hours
416
+ # 450-600 = 3x = 24 hours
417
+ # 600+ = 4x = 7 days
418
+ multiplier = ((current_score / score_threshold).floor - 1).clamp(0, base_durations.length - 1)
419
+ base_durations[multiplier]
307
420
  end
308
421
 
309
422
  # Convert severity level to risk score
@@ -312,7 +425,7 @@ module Beskar
312
425
  when :critical then 95
313
426
  when :high then 80
314
427
  when :medium then 60
315
- when :low then 40
428
+ when :low then 30
316
429
  else 50
317
430
  end
318
431
  end