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