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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -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 +9 -15
- data/app/views/beskar/dashboard/index.html.erb +4 -4
- data/app/views/beskar/security_events/index.html.erb +8 -15
- data/app/views/beskar/security_events/show.html.erb +6 -20
- data/app/views/beskar/shared/_export_form.html.erb +10 -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 +175 -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 +41 -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 +141 -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 +99 -125
- data/lib/beskar/services/request_context.rb +64 -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 +164 -280
- data/lib/beskar/services/waf_request.rb +60 -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 +104 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- 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
|
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,52 @@ 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
|
+
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
|
-
|
|
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
|
-
"
|
|
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" :
|
|
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
|
|
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.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
|
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:
|
|
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" :
|
|
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}
|
|
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
|