beskar 0.1.0 → 0.2.1

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 +177 -19
  3. data/README.md +142 -122
  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 +69 -25
  7. data/app/controllers/beskar/banned_ips_controller.rb +116 -141
  8. data/app/controllers/beskar/dashboard_controller.rb +20 -28
  9. data/app/controllers/beskar/security_events_controller.rb +37 -55
  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 +91 -132
  17. data/app/models/beskar/security_event.rb +37 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +16 -6
  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 +25 -89
  23. data/app/views/beskar/banned_ips/index.html.erb +20 -62
  24. data/app/views/beskar/banned_ips/new.html.erb +18 -138
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +18 -15
  27. data/app/views/beskar/dashboard/index.html.erb +6 -6
  28. data/app/views/beskar/security_events/index.html.erb +18 -16
  29. data/app/views/beskar/security_events/show.html.erb +10 -20
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/beskar/shared/_waf_analysis.html.erb +25 -0
  32. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  33. data/app/views/layouts/beskar/application.html.erb +9 -76
  34. data/config/routes.rb +10 -21
  35. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  36. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  37. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  38. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  39. data/docs/README.md +73 -0
  40. data/docs/archive/project-documentation.md +659 -0
  41. data/docs/audits/project-review.md +437 -0
  42. data/docs/audits/repair-status.md +216 -0
  43. data/docs/guides/audit-and-waf.md +188 -0
  44. data/docs/guides/audit-lifecycle.md +172 -0
  45. data/docs/guides/authentication.md +213 -0
  46. data/docs/guides/configuration.md +182 -0
  47. data/docs/guides/dashboard-and-search.md +251 -0
  48. data/docs/guides/notifications-and-recovery.md +157 -0
  49. data/docs/guides/risk-scoring.md +116 -0
  50. data/docs/operations/monitor-only-mode.md +85 -0
  51. data/docs/operations/security-hardening.md +167 -0
  52. data/docs/operations/state-storage.md +144 -0
  53. data/docs/research/rust-performance-assessment.md +69 -0
  54. data/lib/beskar/configuration.rb +84 -13
  55. data/lib/beskar/configuration_validator.rb +188 -0
  56. data/lib/beskar/devise_authentication.rb +24 -0
  57. data/lib/beskar/engine.rb +21 -88
  58. data/lib/beskar/logger.rb +30 -35
  59. data/lib/beskar/middleware/request_analyzer.rb +49 -82
  60. data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
  61. data/lib/beskar/models/security_trackable_devise.rb +32 -23
  62. data/lib/beskar/models/security_trackable_generic.rb +169 -212
  63. data/lib/beskar/risk_level.rb +22 -0
  64. data/lib/beskar/services/account_locker.rb +85 -76
  65. data/lib/beskar/services/administrative_audit.rb +36 -0
  66. data/lib/beskar/services/administrative_bans.rb +104 -0
  67. data/lib/beskar/services/audit_data.rb +72 -0
  68. data/lib/beskar/services/authentication.rb +31 -0
  69. data/lib/beskar/services/authentication_attempt.rb +148 -0
  70. data/lib/beskar/services/ban_expiry.rb +28 -0
  71. data/lib/beskar/services/device_detector.rb +32 -41
  72. data/lib/beskar/services/event_search.rb +58 -0
  73. data/lib/beskar/services/geolocation_service.rb +83 -114
  74. data/lib/beskar/services/ip_whitelist.rb +31 -40
  75. data/lib/beskar/services/location_assessment.rb +109 -0
  76. data/lib/beskar/services/native_account_lock.rb +82 -0
  77. data/lib/beskar/services/notifications.rb +46 -0
  78. data/lib/beskar/services/rate_limiter.rb +103 -125
  79. data/lib/beskar/services/request_context.rb +69 -0
  80. data/lib/beskar/services/risk_assessment.rb +58 -0
  81. data/lib/beskar/services/session_revocation.rb +62 -0
  82. data/lib/beskar/services/waf.rb +166 -280
  83. data/lib/beskar/services/waf_request.rb +70 -0
  84. data/lib/beskar/version.rb +1 -1
  85. data/lib/beskar/warden_authentication.rb +53 -0
  86. data/lib/beskar.rb +53 -4
  87. data/lib/generators/beskar/install/install_generator.rb +36 -36
  88. data/lib/generators/beskar/install/templates/initializer.rb.tt +105 -20
  89. data/lib/tasks/beskar_tasks.rake +15 -19
  90. metadata +61 -8
@@ -0,0 +1,62 @@
1
+ module Beskar
2
+ module Services
3
+ # Account-lifetime state. Never expire or clear generations on unlock: doing
4
+ # so would resurrect credentials issued before a lock. No cache authority.
5
+ class SessionRevocation
6
+ def self.token(user)
7
+ SecurityState.uncached { SecurityState.read(NativeAccountLock.key(user)).fetch("generation", "0") }
8
+ rescue ActiveRecord::ActiveRecordError
9
+ raise AuthenticationAttempt::Unavailable, "Session state unavailable"
10
+ end
11
+
12
+ def self.revoke!(user)
13
+ SecurityState.mutate(NativeAccountLock.key(user), ttl: nil) do |state|
14
+ rotate!(state.fetch(NativeAccountLock.key(user)))
15
+ NativeAccountLock.revoke_sessions!(user) if user.respond_to?(:beskar_access_locked?)
16
+ end
17
+ rescue ActiveRecord::ActiveRecordError
18
+ raise AuthenticationAttempt::Unavailable, "Session revocation unavailable"
19
+ end
20
+
21
+ def self.rotate!(data)
22
+ data["generation"] = SecureRandom.uuid
23
+ end
24
+
25
+ # Rails-native database sessions need no new session column. Check the
26
+ # persisted row as well as the account lock, including stale Current objects.
27
+ def self.native_session_allowed?(record, request:)
28
+ return false unless record&.persisted?
29
+ record.reload
30
+ user = record.user
31
+ user&.beskar_access_allowed?(request)
32
+ rescue ActiveRecord::RecordNotFound
33
+ false
34
+ rescue ActiveRecord::ActiveRecordError
35
+ raise AuthenticationAttempt::Unavailable, "Session state unavailable"
36
+ end
37
+
38
+ # Tokens must carry the generation captured at issuance inside their
39
+ # authenticated server-side record or signed claims, never a request param.
40
+ # Call on every protected request / WebSocket message, not just connect.
41
+ def self.allowed?(user, request:, token:)
42
+ return false unless user&.persisted? && token.is_a?(String)
43
+ # Cable connections can retain a model object after its database row is
44
+ # deleted. persisted? alone only describes that stale Ruby object.
45
+ user = user.class.uncached { user.class.find_by(user.class.primary_key => user.id) }
46
+ return false unless user
47
+ data = SecurityState.uncached { SecurityState.read(NativeAccountLock.key(user)) }
48
+ return false unless ActiveSupport::SecurityUtils.secure_compare(data.fetch("generation", "0"), token)
49
+ return true unless RequestContext.enforce?(RequestContext.ip(request))
50
+ if user.respond_to?(:beskar_access_locked?)
51
+ !NativeAccountLock.locked_data?(data)
52
+ elsif user.respond_to?(:access_locked?)
53
+ !user.access_locked?
54
+ else
55
+ true
56
+ end
57
+ rescue ActiveRecord::ActiveRecordError
58
+ raise AuthenticationAttempt::Unavailable, "Session state unavailable"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,131 +1,69 @@
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 = {
6
8
  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"
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"
22
12
  },
23
13
  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"
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"
31
18
  },
32
19
  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"
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"
45
25
  },
46
26
  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"
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"
53
30
  },
54
31
  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"
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"
65
35
  },
66
36
  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"
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"
78
41
  },
79
42
  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"
43
+ patterns: [%r{/(?:etc/(?:passwd|shadow|hosts))(?:/|\z)}i, %r{(?:\A|/)\.\.(?:/|\z)}],
44
+ severity: :critical, description: "Path traversal attempt"
91
45
  },
92
46
  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"
47
+ patterns: [%r{\A/(?:rails/info/routes|__debug__|debug|telescope|_profiler)(?:/|\z)}],
48
+ severity: :medium, description: "Framework debug endpoint scan"
103
49
  },
104
50
  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"
51
+ patterns: [%r{\A/(?:joomla|drupal|magento|prestashop|typo3)(?:/|\z)}i],
52
+ severity: :medium, description: "CMS detection scan"
114
53
  },
115
54
  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"
55
+ patterns: [%r{/(?:shell|cmd|c99|r57)\.php(?:/|\z)}i, %r{/(?:backdoor|webshell)(?:/|\z)}i],
56
+ severity: :critical, description: "Common exploit file access"
126
57
  }
127
58
  }.freeze
128
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
+
129
67
  # Configuration for RecordNotFound exclusion patterns
130
68
  # These patterns will not trigger WAF violations
131
69
  RECORD_NOT_FOUND_EXCLUSIONS = [
@@ -136,118 +74,42 @@ module Beskar
136
74
  class << self
137
75
  # Analyze a request for vulnerability scanning patterns
138
76
  def analyze_request(request)
139
- path = request.fullpath || request.path
140
- return nil if path.blank?
141
-
142
- detected_patterns = []
143
-
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
144
84
  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
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]}
155
89
  end
156
90
  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
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]}
169
94
  end
95
+ analysis_for(request, input, patterns) if patterns.any?
170
96
  end
171
97
 
172
- # Analyze Rails exceptions as potential security threats
173
98
  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
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)
251
113
  end
252
114
 
253
115
  # Check if a RecordNotFound exception should be excluded from WAF
@@ -281,43 +143,29 @@ module Beskar
281
143
 
282
144
  Beskar::Logger.debug("[WAF] Recording violation for IP: #{ip_address}, whitelisted: #{whitelisted}", component: :WAF)
283
145
 
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
146
+ analysis_result = sanitized_analysis(analysis_result)
147
+ key = state_key(ip_address, whitelisted: whitelisted)
289
148
  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)
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
314
162
 
315
163
  # Log the violation
316
164
  log_violation(ip_address, analysis_result, current_score, violations.size)
317
165
 
318
166
  # Create security event if configured
319
167
  if config[:create_security_events]
320
- create_security_event(ip_address, analysis_result, current_score)
168
+ create_security_event(ip_address, analysis_result, current_score, whitelisted: whitelisted, violation_count: violations.size)
321
169
  end
322
170
 
323
171
  # Check if we should auto-block (skip if whitelisted)
@@ -325,17 +173,12 @@ module Beskar
325
173
 
326
174
  Beskar::Logger.debug("[WAF] Auto-block check: whitelisted=#{whitelisted}, auto_block=#{config[:auto_block]}, score=#{current_score.round(2)}, threshold=#{threshold}", component: :WAF)
327
175
 
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
176
+ if !whitelisted && !IpWhitelist.whitelisted?(ip_address) && config[:auto_block] && current_score >= threshold
334
177
  if Beskar.configuration.monitor_only?
335
178
  log_monitor_only_action(ip_address, analysis_result, current_score, threshold)
179
+ else
180
+ auto_block_ip(ip_address, analysis_result, current_score)
336
181
  end
337
- else
338
- Beskar::Logger.debug("[WAF] Not auto-blocking: conditions not met", component: :WAF)
339
182
  end
340
183
 
341
184
  current_score
@@ -349,8 +192,8 @@ module Beskar
349
192
 
350
193
  # Get violations for an IP
351
194
  def get_violations(ip_address)
352
- cache_key = "beskar:waf_violations:#{ip_address}"
353
- Rails.cache.read(cache_key) || []
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)
354
197
  end
355
198
 
356
199
  # Get violation count for an IP (number of violations tracked)
@@ -360,12 +203,53 @@ module Beskar
360
203
 
361
204
  # Reset violations for an IP
362
205
  def reset_violations(ip_address)
363
- cache_key = "beskar:waf_violations:#{ip_address}"
364
- 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) }
365
209
  end
366
210
 
367
211
  private
368
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
+ request_path: input.audit_path, 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[:request_path] = WafRequest.audit_path(analysis[:request_path]) if analysis[:request_path].is_a?(String)
245
+ result
246
+ end
247
+
248
+ def state_key(ip_address, whitelisted: false)
249
+ mode = (Beskar.configuration.monitor_only? || whitelisted || IpWhitelist.whitelisted?(ip_address)) ? "observe" : "enforce"
250
+ "waf:#{mode}:#{IPAddr.new(ip_address.to_s)}"
251
+ end
252
+
369
253
  # Calculate current cumulative score with decay applied
370
254
  def calculate_current_score(violations, config)
371
255
  return 0.0 if violations.empty?
@@ -379,7 +263,7 @@ module Beskar
379
263
  age_minutes = age_seconds / 60.0
380
264
 
381
265
  # Get half-life for this severity (in minutes)
382
- half_life = decay_rates[v[:severity]] || 60
266
+ half_life = decay_rates[v[:severity].to_sym] || 60
383
267
 
384
268
  # Exponential decay: score * (1/2)^(age/half_life)
385
269
  # Equivalent to: score * e^(-ln(2) * age / half_life)
@@ -432,15 +316,14 @@ module Beskar
432
316
  }
433
317
 
434
318
  emoji = severity_emoji[analysis_result[:highest_severity]] || "🔍"
435
- config = waf_config
319
+ waf_config
436
320
  monitor_mode_notice = Beskar.configuration.monitor_only? ? " [MONITOR-ONLY MODE]" : ""
437
321
 
438
322
  Beskar::Logger.warn("#{emoji} Vulnerability scan detected#{monitor_mode_notice} " \
439
323
  "(score: #{current_score.round(2)}, violations: #{violation_count}) - " \
440
324
  "IP: #{ip_address}, " \
441
325
  "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)
326
+ "Rules: #{analysis_result[:patterns].map { |p| p[:rule_id] }.join(", ")}", component: :WAF)
444
327
  end
445
328
 
446
329
  # Log what would happen in monitor-only mode (but don't actually block)
@@ -450,37 +333,39 @@ module Beskar
450
333
 
451
334
  Beskar::Logger.warn("🔍 MONITOR-ONLY: IP #{ip_address} WOULD BE BLOCKED " \
452
335
  "(score threshold reached: #{current_score.round(2)}/#{threshold}) - " \
453
- "Duration would be: #{duration ? "#{duration / 3600.0} hours" : 'PERMANENT'}, " \
336
+ "Duration would be: #{duration ? "#{duration / 3600.0} hours" : "PERMANENT"}, " \
454
337
  "Severity: #{analysis_result[:highest_severity]}, " \
455
- "Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}. " \
338
+ "Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(", ")}. " \
456
339
  "To enable blocking, set config.monitor_only = false", component: :WAF)
457
340
  end
458
341
 
459
342
  # Create security event for WAF violation
460
- def create_security_event(ip_address, analysis_result, current_score)
343
+ def create_security_event(ip_address, analysis_result, current_score, whitelisted: false, violation_count: nil)
461
344
  config = waf_config
462
- violation_count = get_violation_count(ip_address)
345
+ violation_count ||= get_violation_count(ip_address)
463
346
  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
- )
347
+ would_be_blocked = config[:auto_block] && !whitelisted && !IpWhitelist.whitelisted?(ip_address) && current_score >= threshold
348
+
349
+ Beskar::SecurityEvent.transaction(requires_new: true) do
350
+ Beskar::SecurityEvent.create!(
351
+ event_type: "waf_violation",
352
+ ip_address: ip_address,
353
+ user_agent: nil,
354
+ risk_score: severity_to_risk_score(analysis_result[:highest_severity]),
355
+ metadata: {
356
+ waf_analysis: analysis_result,
357
+ patterns_matched: analysis_result[:patterns].map { |p| p[:description] },
358
+ severity: analysis_result[:highest_severity],
359
+ monitor_only_mode: Beskar.configuration.monitor_only?,
360
+ would_be_blocked: would_be_blocked,
361
+ violation_count: violation_count,
362
+ current_score: current_score.round(2),
363
+ score_threshold: threshold
364
+ }
365
+ )
366
+ end
482
367
  rescue => e
483
- Beskar::Logger.error("Failed to create security event: #{e.message}", component: :WAF)
368
+ Beskar::Logger.error("Failed to create security event (#{e.class})", component: :WAF)
484
369
  end
485
370
 
486
371
  # Auto-block an IP after threshold violations
@@ -494,11 +379,12 @@ module Beskar
494
379
  begin
495
380
  banned_ip = Beskar::BannedIp.ban!(
496
381
  ip_address,
497
- reason: 'waf_violation',
382
+ reason: "waf_violation",
498
383
  duration: duration,
499
384
  permanent: duration.nil?,
500
- details: "WAF score: #{current_score.round(2)} (#{violation_count} violations) - #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}",
385
+ details: "WAF score: #{current_score.round(2)} (#{violation_count} violations) - #{analysis_result[:patterns].map { |p| p[:description] }.join(", ")}",
501
386
  metadata: {
387
+ waf_analysis: analysis_result,
502
388
  violation_count: violation_count,
503
389
  risk_score: current_score.round(2),
504
390
  patterns: analysis_result[:patterns],
@@ -509,10 +395,10 @@ module Beskar
509
395
 
510
396
  Beskar::Logger.warn("🔒 Auto-blocked IP #{ip_address} " \
511
397
  "with score #{current_score.round(2)} (#{violation_count} violations) " \
512
- "(duration: #{duration ? "#{duration / 3600} hours" : 'permanent'}), " \
398
+ "(duration: #{duration ? "#{duration / 3600} hours" : "permanent"}), " \
513
399
  "Ban ID: #{banned_ip.id}", component: :WAF)
514
400
  rescue => e
515
- Beskar::Logger.error("[WAF] Failed to create ban for #{ip_address}: #{e.class} - #{e.message}", component: :WAF)
401
+ Beskar::Logger.error("[WAF] Failed to create ban for #{ip_address} (#{e.class})", component: :WAF)
516
402
  raise
517
403
  end
518
404
  end