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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +177 -19
- data/README.md +142 -122
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +69 -25
- data/app/controllers/beskar/banned_ips_controller.rb +116 -141
- data/app/controllers/beskar/dashboard_controller.rb +20 -28
- data/app/controllers/beskar/security_events_controller.rb +37 -55
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +91 -132
- data/app/models/beskar/security_event.rb +37 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +16 -6
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +25 -89
- data/app/views/beskar/banned_ips/index.html.erb +20 -62
- data/app/views/beskar/banned_ips/new.html.erb +18 -138
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +18 -15
- data/app/views/beskar/dashboard/index.html.erb +6 -6
- data/app/views/beskar/security_events/index.html.erb +18 -16
- data/app/views/beskar/security_events/show.html.erb +10 -20
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/beskar/shared/_waf_analysis.html.erb +25 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +9 -76
- data/config/routes.rb +10 -21
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +188 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +84 -13
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +30 -35
- data/lib/beskar/middleware/request_analyzer.rb +49 -82
- data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
- data/lib/beskar/models/security_trackable_devise.rb +32 -23
- data/lib/beskar/models/security_trackable_generic.rb +169 -212
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +85 -76
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +148 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +103 -125
- data/lib/beskar/services/request_context.rb +69 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +166 -280
- data/lib/beskar/services/waf_request.rb +70 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +53 -4
- data/lib/generators/beskar/install/install_generator.rb +36 -36
- data/lib/generators/beskar/install/templates/initializer.rb.tt +105 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- 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
|
data/lib/beskar/services/waf.rb
CHANGED
|
@@ -1,131 +1,69 @@
|
|
|
1
1
|
module Beskar
|
|
2
2
|
module Services
|
|
3
3
|
class Waf
|
|
4
|
-
|
|
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+)
|
|
9
|
-
|
|
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|
|
|
26
|
-
%r{/(?:
|
|
27
|
-
|
|
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-
|
|
35
|
-
%r{/wp-
|
|
36
|
-
%r{/
|
|
37
|
-
|
|
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
|
|
49
|
-
|
|
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{/
|
|
57
|
-
|
|
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{
|
|
69
|
-
%r{
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
return nil if path.blank?
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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" :
|
|
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
|
|
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.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
|
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:
|
|
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" :
|
|
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}
|
|
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
|