beskar 0.1.0 → 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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -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 +9 -15
  27. data/app/views/beskar/dashboard/index.html.erb +4 -4
  28. data/app/views/beskar/security_events/index.html.erb +8 -15
  29. data/app/views/beskar/security_events/show.html.erb +6 -20
  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 +9 -76
  33. data/config/routes.rb +10 -21
  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 +84 -13
  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 +30 -35
  58. data/lib/beskar/middleware/request_analyzer.rb +41 -82
  59. data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
  60. data/lib/beskar/models/security_trackable_devise.rb +32 -23
  61. data/lib/beskar/models/security_trackable_generic.rb +169 -212
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +85 -76
  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 +164 -280
  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 +53 -4
  86. data/lib/generators/beskar/install/install_generator.rb +36 -36
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +104 -20
  88. data/lib/tasks/beskar_tasks.rake +15 -19
  89. metadata +60 -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,52 @@ 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
+ 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
+
369
252
  # Calculate current cumulative score with decay applied
370
253
  def calculate_current_score(violations, config)
371
254
  return 0.0 if violations.empty?
@@ -379,7 +262,7 @@ module Beskar
379
262
  age_minutes = age_seconds / 60.0
380
263
 
381
264
  # Get half-life for this severity (in minutes)
382
- half_life = decay_rates[v[:severity]] || 60
265
+ half_life = decay_rates[v[:severity].to_sym] || 60
383
266
 
384
267
  # Exponential decay: score * (1/2)^(age/half_life)
385
268
  # Equivalent to: score * e^(-ln(2) * age / half_life)
@@ -432,15 +315,14 @@ module Beskar
432
315
  }
433
316
 
434
317
  emoji = severity_emoji[analysis_result[:highest_severity]] || "🔍"
435
- config = waf_config
318
+ waf_config
436
319
  monitor_mode_notice = Beskar.configuration.monitor_only? ? " [MONITOR-ONLY MODE]" : ""
437
320
 
438
321
  Beskar::Logger.warn("#{emoji} Vulnerability scan detected#{monitor_mode_notice} " \
439
322
  "(score: #{current_score.round(2)}, violations: #{violation_count}) - " \
440
323
  "IP: #{ip_address}, " \
441
324
  "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)
325
+ "Rules: #{analysis_result[:patterns].map { |p| p[:rule_id] }.join(", ")}", component: :WAF)
444
326
  end
445
327
 
446
328
  # Log what would happen in monitor-only mode (but don't actually block)
@@ -450,37 +332,39 @@ module Beskar
450
332
 
451
333
  Beskar::Logger.warn("🔍 MONITOR-ONLY: IP #{ip_address} WOULD BE BLOCKED " \
452
334
  "(score threshold reached: #{current_score.round(2)}/#{threshold}) - " \
453
- "Duration would be: #{duration ? "#{duration / 3600.0} hours" : 'PERMANENT'}, " \
335
+ "Duration would be: #{duration ? "#{duration / 3600.0} hours" : "PERMANENT"}, " \
454
336
  "Severity: #{analysis_result[:highest_severity]}, " \
455
- "Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}. " \
337
+ "Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(", ")}. " \
456
338
  "To enable blocking, set config.monitor_only = false", component: :WAF)
457
339
  end
458
340
 
459
341
  # Create security event for WAF violation
460
- def create_security_event(ip_address, analysis_result, current_score)
342
+ def create_security_event(ip_address, analysis_result, current_score, whitelisted: false, violation_count: nil)
461
343
  config = waf_config
462
- violation_count = get_violation_count(ip_address)
344
+ violation_count ||= get_violation_count(ip_address)
463
345
  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
- )
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
482
366
  rescue => e
483
- Beskar::Logger.error("Failed to create security event: #{e.message}", component: :WAF)
367
+ Beskar::Logger.error("Failed to create security event (#{e.class})", component: :WAF)
484
368
  end
485
369
 
486
370
  # Auto-block an IP after threshold violations
@@ -494,10 +378,10 @@ module Beskar
494
378
  begin
495
379
  banned_ip = Beskar::BannedIp.ban!(
496
380
  ip_address,
497
- reason: 'waf_violation',
381
+ reason: "waf_violation",
498
382
  duration: duration,
499
383
  permanent: duration.nil?,
500
- details: "WAF score: #{current_score.round(2)} (#{violation_count} violations) - #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}",
384
+ details: "WAF score: #{current_score.round(2)} (#{violation_count} violations) - #{analysis_result[:patterns].map { |p| p[:description] }.join(", ")}",
501
385
  metadata: {
502
386
  violation_count: violation_count,
503
387
  risk_score: current_score.round(2),
@@ -509,10 +393,10 @@ module Beskar
509
393
 
510
394
  Beskar::Logger.warn("🔒 Auto-blocked IP #{ip_address} " \
511
395
  "with score #{current_score.round(2)} (#{violation_count} violations) " \
512
- "(duration: #{duration ? "#{duration / 3600} hours" : 'permanent'}), " \
396
+ "(duration: #{duration ? "#{duration / 3600} hours" : "permanent"}), " \
513
397
  "Ban ID: #{banned_ip.id}", component: :WAF)
514
398
  rescue => e
515
- Beskar::Logger.error("[WAF] Failed to create ban for #{ip_address}: #{e.class} - #{e.message}", component: :WAF)
399
+ Beskar::Logger.error("[WAF] Failed to create ban for #{ip_address} (#{e.class})", component: :WAF)
516
400
  raise
517
401
  end
518
402
  end