beskar 0.0.1 → 0.0.2

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.
@@ -0,0 +1,113 @@
1
+ require 'ipaddr'
2
+
3
+ module Beskar
4
+ module Services
5
+ class IpWhitelist
6
+ class << self
7
+ # Check if an IP address is whitelisted
8
+ def whitelisted?(ip_address)
9
+ return false if ip_address.blank?
10
+ return false unless whitelist_entries.any?
11
+
12
+ ip = parse_ip(ip_address)
13
+ return false unless ip
14
+
15
+ whitelist_entries.any? do |entry|
16
+ match_entry?(ip, entry)
17
+ end
18
+ rescue IPAddr::InvalidAddressError, ArgumentError => e
19
+ Rails.logger.warn "[Beskar::IpWhitelist] Invalid IP address: #{ip_address} - #{e.message}"
20
+ false
21
+ end
22
+
23
+ # Get whitelist entries from configuration
24
+ def whitelist_entries
25
+ @whitelist_entries ||= begin
26
+ entries = Beskar.configuration.ip_whitelist || []
27
+ # Ensure it's an array
28
+ entries = [entries] unless entries.is_a?(Array)
29
+ entries.compact
30
+ end
31
+ end
32
+
33
+ # Clear cached whitelist (useful when config changes)
34
+ def clear_cache!
35
+ @whitelist_entries = nil
36
+ @parsed_entries = nil
37
+ end
38
+
39
+ # Validate whitelist configuration
40
+ def validate_configuration!
41
+ errors = []
42
+
43
+ whitelist_entries.each_with_index do |entry, index|
44
+ begin
45
+ parse_entry(entry)
46
+ rescue IPAddr::InvalidAddressError, ArgumentError => e
47
+ errors << "Entry #{index} (#{entry}): #{e.message}"
48
+ end
49
+ end
50
+
51
+ if errors.any?
52
+ raise ConfigurationError, "Invalid IP whitelist configuration:\n#{errors.join("\n")}"
53
+ end
54
+
55
+ true
56
+ end
57
+
58
+ private
59
+
60
+ # Parse IP address string to IPAddr object
61
+ def parse_ip(ip_string)
62
+ IPAddr.new(ip_string.to_s.strip)
63
+ rescue IPAddr::InvalidAddressError
64
+ nil
65
+ end
66
+
67
+ # Parse whitelist entry (can be single IP or CIDR notation)
68
+ def parse_entry(entry)
69
+ return nil if entry.blank?
70
+
71
+ entry_str = entry.to_s.strip
72
+
73
+ # Check if it's CIDR notation
74
+ if entry_str.include?('/')
75
+ IPAddr.new(entry_str)
76
+ else
77
+ # Single IP address
78
+ IPAddr.new(entry_str)
79
+ end
80
+ end
81
+
82
+ # Check if IP matches whitelist entry
83
+ def match_entry?(ip, entry)
84
+ parsed_entry = parsed_entries[entry]
85
+ return false unless parsed_entry
86
+
87
+ # IPAddr#include? handles both single IPs and CIDR ranges
88
+ parsed_entry.include?(ip)
89
+ rescue IPAddr::InvalidAddressError, ArgumentError
90
+ false
91
+ end
92
+
93
+ # Cache parsed entries for performance
94
+ def parsed_entries
95
+ @parsed_entries ||= begin
96
+ entries = {}
97
+ whitelist_entries.each do |entry|
98
+ begin
99
+ entries[entry] = parse_entry(entry)
100
+ rescue IPAddr::InvalidAddressError, ArgumentError => e
101
+ Rails.logger.warn "[Beskar::IpWhitelist] Skipping invalid entry: #{entry} - #{e.message}"
102
+ end
103
+ end
104
+ entries
105
+ end
106
+ end
107
+ end
108
+
109
+ # Error class for configuration issues
110
+ class ConfigurationError < StandardError; end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,257 @@
1
+ module Beskar
2
+ module Services
3
+ class RateLimiter
4
+ DEFAULT_CONFIG = {
5
+ ip_attempts: {
6
+ limit: 10,
7
+ period: 1.hour,
8
+ exponential_backoff: true
9
+ },
10
+ account_attempts: {
11
+ limit: 5,
12
+ period: 15.minutes,
13
+ exponential_backoff: true
14
+ },
15
+ global_attempts: {
16
+ limit: 100,
17
+ period: 1.minute
18
+ }
19
+ }.freeze
20
+
21
+ class << self
22
+ def check_authentication_attempt(request, result, user = nil)
23
+ ip_address = request.ip
24
+
25
+ # Check IP-based rate limiting
26
+ ip_result = check_ip_rate_limit(ip_address)
27
+
28
+ # Check account-based rate limiting if we have a user
29
+ account_result = user ? check_account_rate_limit(user) : {allowed: true}
30
+
31
+ # Check global rate limiting to prevent system overload
32
+ global_result = check_global_rate_limit
33
+
34
+ # Record the attempt
35
+ record_attempt(ip_address, result, user)
36
+
37
+ # Return most restrictive result
38
+ most_restrictive_result([ip_result, account_result, global_result])
39
+ end
40
+
41
+ def check_ip_rate_limit(ip_address)
42
+ config = Beskar.configuration.rate_limiting&.dig(:ip_attempts) || DEFAULT_CONFIG[:ip_attempts]
43
+ cache_key = "beskar:ip_attempts:#{ip_address}"
44
+ check_rate_limit(cache_key, config)
45
+ end
46
+
47
+ def check_account_rate_limit(user)
48
+ config = Beskar.configuration.rate_limiting&.dig(:account_attempts) || DEFAULT_CONFIG[:account_attempts]
49
+ cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
50
+ check_rate_limit(cache_key, config)
51
+ end
52
+
53
+ def check_global_rate_limit
54
+ config = Beskar.configuration.rate_limiting&.dig(:global_attempts) || DEFAULT_CONFIG[:global_attempts]
55
+ cache_key = "beskar:global_attempts"
56
+ check_rate_limit(cache_key, config)
57
+ end
58
+
59
+ def is_rate_limited?(request, user = nil)
60
+ result = check_authentication_attempt(request, :check, user)
61
+ !result[:allowed]
62
+ end
63
+
64
+ def time_until_allowed(request, user = nil)
65
+ result = check_authentication_attempt(request, :check, user)
66
+ result[:retry_after] || 0
67
+ end
68
+
69
+ def reset_rate_limit(ip_address: nil, user: nil)
70
+ if ip_address
71
+ Rails.cache.delete("beskar:ip_attempts:#{ip_address}")
72
+ Rails.cache.delete("beskar:ip_backoff:#{ip_address}")
73
+ end
74
+
75
+ if user
76
+ cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
77
+ Rails.cache.delete(cache_key)
78
+ Rails.cache.delete("beskar:account_backoff:#{user.class.name}:#{user.id}")
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def check_rate_limit(cache_key, config)
85
+ now = Time.current.to_i
86
+ period = config[:period].to_i
87
+ limit = config[:limit]
88
+ window_start = now - period
89
+
90
+ # Get current window data
91
+ window_data = Rails.cache.read(cache_key) || {}
92
+
93
+ # Clean old entries
94
+ cleaned_data = window_data.select { |timestamp, _| timestamp.to_i > window_start }
95
+
96
+ # Update cache with cleaned data if it changed
97
+ if cleaned_data != window_data
98
+ if cleaned_data.empty?
99
+ Rails.cache.delete(cache_key)
100
+ else
101
+ Rails.cache.write(cache_key, cleaned_data, expires_in: period + 60)
102
+ end
103
+ end
104
+
105
+ current_count = cleaned_data.values.sum
106
+
107
+ # Check if we're over the limit
108
+ if current_count >= limit
109
+ # Calculate retry after time with exponential backoff if configured
110
+ retry_after = calculate_retry_after(cache_key, config, current_count, limit)
111
+
112
+ reset_time = if cleaned_data.empty?
113
+ Time.at(now + period)
114
+ else
115
+ Time.at(cleaned_data.keys.map(&:to_i).min + period)
116
+ end
117
+
118
+ {
119
+ allowed: false,
120
+ count: current_count,
121
+ limit: limit,
122
+ reset_time: reset_time,
123
+ retry_after: retry_after,
124
+ reason: "rate_limit_exceeded"
125
+ }
126
+ else
127
+ {
128
+ allowed: true,
129
+ count: current_count,
130
+ limit: limit,
131
+ remaining: limit - current_count
132
+ }
133
+ end
134
+ end
135
+
136
+ def record_attempt(ip_address, result, user)
137
+ return if result == :check # Don't record check operations
138
+
139
+ now = Time.current.to_i
140
+
141
+ # Record IP attempt
142
+ ip_cache_key = "beskar:ip_attempts:#{ip_address}"
143
+ record_attempt_in_cache(ip_cache_key, now)
144
+
145
+ # Record account attempt if user exists
146
+ if user
147
+ account_cache_key = "beskar:account_attempts:#{user.class.name}:#{user.id}"
148
+ record_attempt_in_cache(account_cache_key, now)
149
+ end
150
+
151
+ # Record global attempt
152
+ global_cache_key = "beskar:global_attempts"
153
+ record_attempt_in_cache(global_cache_key, now, 1.minute)
154
+ end
155
+
156
+ def record_attempt_in_cache(cache_key, timestamp, expiry = 1.hour)
157
+ window_data = Rails.cache.read(cache_key) || {}
158
+ window_data[timestamp] = (window_data[timestamp] || 0) + 1
159
+ Rails.cache.write(cache_key, window_data, expires_in: expiry + 60)
160
+ end
161
+
162
+ def calculate_retry_after(cache_key, config, current_count, limit)
163
+ return 0 unless config[:exponential_backoff]
164
+
165
+ backoff_key = cache_key.gsub("_attempts:", "_backoff:")
166
+ failure_count = Rails.cache.read(backoff_key) || 0
167
+
168
+ # Increment failure count
169
+ Rails.cache.write(backoff_key, failure_count + 1, expires_in: 1.hour)
170
+
171
+ # Exponential backoff: 1min, 5min, 15min, 1hour, 4hours, 24hours
172
+ base_delays = [60, 300, 900, 3600, 14400, 86400] # in seconds
173
+ delay_index = [failure_count, base_delays.length - 1].min
174
+
175
+ base_delays[delay_index]
176
+ end
177
+
178
+ def most_restrictive_result(results)
179
+ # Find the most restrictive (not allowed) result
180
+ restricted = results.find { |r| !r[:allowed] }
181
+ return restricted if restricted
182
+
183
+ # If all are allowed, return the one with the lowest remaining count
184
+ results.min_by { |r| r[:remaining] || Float::INFINITY }
185
+ end
186
+ end
187
+
188
+ # Instance methods for more complex scenarios
189
+ def initialize(ip_address, user = nil)
190
+ @ip_address = ip_address
191
+ @user = user
192
+ end
193
+
194
+ def allowed?
195
+ self.class.check_ip_rate_limit(@ip_address)[:allowed] &&
196
+ (@user.nil? || self.class.check_account_rate_limit(@user)[:allowed])
197
+ end
198
+
199
+ def attempts_remaining
200
+ ip_result = self.class.check_ip_rate_limit(@ip_address)
201
+ account_result = @user ? self.class.check_account_rate_limit(@user) : {remaining: Float::INFINITY}
202
+
203
+ [ip_result[:remaining] || 0, account_result[:remaining] || 0].min
204
+ end
205
+
206
+ def time_until_reset
207
+ ip_result = self.class.check_ip_rate_limit(@ip_address)
208
+ account_result = @user ? self.class.check_account_rate_limit(@user) : {retry_after: 0}
209
+
210
+ [ip_result[:retry_after] || 0, account_result[:retry_after] || 0].max
211
+ end
212
+
213
+ def reset!
214
+ self.class.reset_rate_limit(ip_address: @ip_address, user: @user)
215
+ end
216
+
217
+ # Sliding window analysis for pattern detection
218
+ def suspicious_pattern?
219
+ return false unless @user
220
+
221
+ # Check for rapid-fire attempts
222
+ recent_events = @user.security_events
223
+ .login_failures
224
+ .recent(5.minutes.ago)
225
+ .order(:created_at)
226
+
227
+ return true if recent_events.count >= 3
228
+
229
+ # Check for distributed attack (same user, different IPs)
230
+ if recent_events.count >= 2
231
+ unique_ips = recent_events.pluck(:ip_address).uniq
232
+ return true if unique_ips.length >= 2
233
+ end
234
+
235
+ false
236
+ end
237
+
238
+ def attack_pattern_type
239
+ return :none unless suspicious_pattern?
240
+
241
+ recent_events = @user.security_events.login_failures.recent(5.minutes.ago)
242
+ unique_ips = recent_events.pluck(:ip_address).uniq
243
+ unique_emails = recent_events.map(&:attempted_email).compact.uniq
244
+
245
+ if unique_ips.length >= 2 && unique_emails.length == 1
246
+ :distributed_single_account
247
+ elsif unique_ips.length == 1 && unique_emails.length >= 3
248
+ :single_ip_multiple_accounts
249
+ elsif unique_ips.length == 1 && unique_emails.length == 1
250
+ :brute_force_single_account
251
+ else
252
+ :mixed_attack_pattern
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,322 @@
1
+ module Beskar
2
+ module Services
3
+ class Waf
4
+ # Common vulnerability scan patterns
5
+ VULNERABILITY_PATTERNS = {
6
+ wordpress: {
7
+ patterns: [
8
+ %r{/wp-admin}i,
9
+ %r{/wp-login\.php}i,
10
+ %r{/wp-content}i,
11
+ %r{/wp-includes}i,
12
+ %r{/xmlrpc\.php}i,
13
+ %r{/wp-config\.php}i,
14
+ %r{/wp-config\.bak}i,
15
+ %r{/wordpress}i
16
+ ],
17
+ severity: :high,
18
+ description: "WordPress vulnerability scan"
19
+ },
20
+ php_admin: {
21
+ patterns: [
22
+ %r{/phpmyadmin}i,
23
+ %r{/pma}i,
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"
31
+ },
32
+ config_files: {
33
+ patterns: [
34
+ %r{/\.env},
35
+ %r{/\.git},
36
+ %r{/config\.php}i,
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"
44
+ },
45
+ path_traversal: {
46
+ patterns: [
47
+ %r{/etc/passwd},
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"
57
+ },
58
+ framework_debug: {
59
+ patterns: [
60
+ %r{/rails/info/routes},
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"
69
+ },
70
+ cms_scan: {
71
+ patterns: [
72
+ %r{/joomla}i,
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"
80
+ },
81
+ common_exploits: {
82
+ patterns: [
83
+ %r{/shell\.php}i,
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"
92
+ }
93
+ }.freeze
94
+
95
+ class << self
96
+ # Analyze a request for vulnerability scanning patterns
97
+ def analyze_request(request)
98
+ path = request.fullpath || request.path
99
+ return nil if path.blank?
100
+
101
+ detected_patterns = []
102
+
103
+ VULNERABILITY_PATTERNS.each do |category, config|
104
+ config[:patterns].each do |pattern|
105
+ if path.match?(pattern)
106
+ detected_patterns << {
107
+ category: category,
108
+ pattern: pattern.source,
109
+ severity: config[:severity],
110
+ description: config[:description],
111
+ matched_path: path
112
+ }
113
+ end
114
+ end
115
+ end
116
+
117
+ if detected_patterns.any?
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
128
+ end
129
+ end
130
+
131
+ # Check if request should be blocked based on violation history
132
+ def should_block?(ip_address)
133
+ config = waf_config
134
+ return false unless config[:enabled]
135
+ return false unless config[:auto_block]
136
+
137
+ # Check violation count in cache
138
+ violation_count = get_violation_count(ip_address)
139
+ threshold = config[:block_threshold] || 3
140
+
141
+ violation_count >= threshold
142
+ end
143
+
144
+ # Record a WAF violation
145
+ def record_violation(ip_address, analysis_result, whitelisted: false)
146
+ config = waf_config
147
+ return unless config[:enabled]
148
+
149
+ # Increment violation count
150
+ cache_key = "beskar:waf_violations:#{ip_address}"
151
+ current_count = Rails.cache.read(cache_key) || 0
152
+ new_count = current_count + 1
153
+
154
+ # Store with TTL from config (default 1 hour)
155
+ ttl = config[:violation_window] || 1.hour
156
+ Rails.cache.write(cache_key, new_count, expires_in: ttl)
157
+
158
+ # Log the violation
159
+ log_violation(ip_address, analysis_result, new_count)
160
+
161
+ # Create security event if configured
162
+ if config[:create_security_events]
163
+ create_security_event(ip_address, analysis_result)
164
+ end
165
+
166
+ # Check if we should auto-block (skip if whitelisted, in monitor-only mode)
167
+ threshold = config[:block_threshold] || 3
168
+
169
+ if !whitelisted && config[:auto_block] && new_count >= threshold
170
+ if config[:monitor_only]
171
+ # Monitor-only mode: Log what WOULD happen but don't block
172
+ log_monitor_only_action(ip_address, analysis_result, new_count, threshold)
173
+ else
174
+ auto_block_ip(ip_address, analysis_result, new_count)
175
+ end
176
+ end
177
+
178
+ new_count
179
+ end
180
+
181
+ # Get current violation count for an IP
182
+ def get_violation_count(ip_address)
183
+ cache_key = "beskar:waf_violations:#{ip_address}"
184
+ Rails.cache.read(cache_key) || 0
185
+ end
186
+
187
+ # Reset violations for an IP
188
+ def reset_violations(ip_address)
189
+ cache_key = "beskar:waf_violations:#{ip_address}"
190
+ Rails.cache.delete(cache_key)
191
+ end
192
+
193
+ private
194
+
195
+ # Get WAF configuration
196
+ def waf_config
197
+ Beskar.configuration.waf || {}
198
+ end
199
+
200
+ # Calculate highest severity from detected patterns
201
+ def calculate_highest_severity(patterns)
202
+ severities = patterns.map { |p| p[:severity] }
203
+ return :critical if severities.include?(:critical)
204
+ return :high if severities.include?(:high)
205
+ return :medium if severities.include?(:medium)
206
+ :low
207
+ end
208
+
209
+ # Log WAF violation
210
+ def log_violation(ip_address, analysis_result, violation_count)
211
+ severity_emoji = {
212
+ critical: "🚨",
213
+ high: "âš ī¸",
214
+ medium: "⚡",
215
+ low: "â„šī¸"
216
+ }
217
+
218
+ emoji = severity_emoji[analysis_result[:highest_severity]] || "🔍"
219
+ config = waf_config
220
+ monitor_mode_notice = config[:monitor_only] ? " [MONITOR-ONLY MODE]" : ""
221
+
222
+ Rails.logger.warn(
223
+ "[Beskar::WAF] #{emoji} Vulnerability scan detected#{monitor_mode_notice} " \
224
+ "(#{violation_count} violations) - " \
225
+ "IP: #{ip_address}, " \
226
+ "Severity: #{analysis_result[:highest_severity]}, " \
227
+ "Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}, " \
228
+ "Path: #{analysis_result[:patterns].first[:matched_path]}"
229
+ )
230
+ end
231
+
232
+ # Log what would happen in monitor-only mode (but don't actually block)
233
+ def log_monitor_only_action(ip_address, analysis_result, violation_count, threshold)
234
+ config = waf_config
235
+ duration = calculate_block_duration(violation_count, config)
236
+
237
+ Rails.logger.warn(
238
+ "[Beskar::WAF] 🔍 MONITOR-ONLY: IP #{ip_address} WOULD BE BLOCKED " \
239
+ "(threshold reached: #{violation_count}/#{threshold} violations) - " \
240
+ "Duration would be: #{duration ? "#{duration / 3600.0} hours" : 'PERMANENT'}, " \
241
+ "Severity: #{analysis_result[:highest_severity]}, " \
242
+ "Patterns: #{analysis_result[:patterns].map { |p| p[:description] }.join(', ')}. " \
243
+ "To enable blocking, set config.waf[:monitor_only] = false"
244
+ )
245
+ end
246
+
247
+ # Create security event for WAF violation
248
+ def create_security_event(ip_address, analysis_result)
249
+ config = waf_config
250
+ violation_count = get_violation_count(ip_address)
251
+ threshold = config[:block_threshold] || 3
252
+ would_be_blocked = violation_count >= threshold
253
+
254
+ Beskar::SecurityEvent.create!(
255
+ event_type: 'waf_violation',
256
+ ip_address: ip_address,
257
+ user_agent: analysis_result[:user_agent],
258
+ risk_score: severity_to_risk_score(analysis_result[:highest_severity]),
259
+ metadata: {
260
+ waf_analysis: analysis_result,
261
+ patterns_matched: analysis_result[:patterns].map { |p| p[:description] },
262
+ severity: analysis_result[:highest_severity],
263
+ monitor_only_mode: config[:monitor_only],
264
+ would_be_blocked: would_be_blocked,
265
+ violation_count: violation_count,
266
+ block_threshold: threshold
267
+ }
268
+ )
269
+ rescue => e
270
+ Rails.logger.error "[Beskar::WAF] Failed to create security event: #{e.message}"
271
+ end
272
+
273
+ # Auto-block an IP after threshold violations
274
+ def auto_block_ip(ip_address, analysis_result, violation_count)
275
+ config = waf_config
276
+ duration = calculate_block_duration(violation_count, config)
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
+ )
291
+
292
+ Rails.logger.warn(
293
+ "[Beskar::WAF] 🔒 Auto-blocked IP #{ip_address} " \
294
+ "after #{violation_count} violations " \
295
+ "(duration: #{duration ? "#{duration / 3600} hours" : 'permanent'})"
296
+ )
297
+ end
298
+
299
+ # Calculate block duration based on violation count
300
+ def calculate_block_duration(violation_count, config)
301
+ return nil if config[:permanent_block_after] && violation_count >= config[:permanent_block_after]
302
+
303
+ # Default escalating durations: 1h, 6h, 24h, 7d, permanent
304
+ base_durations = config[:block_durations] || [1.hour, 6.hours, 24.hours, 7.days]
305
+ index = [violation_count - (config[:block_threshold] || 3), base_durations.length - 1].min
306
+ base_durations[index]
307
+ end
308
+
309
+ # Convert severity level to risk score
310
+ def severity_to_risk_score(severity)
311
+ case severity
312
+ when :critical then 95
313
+ when :high then 80
314
+ when :medium then 60
315
+ when :low then 40
316
+ else 50
317
+ end
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end