rack-ai 0.2.0 → 0.4.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 +56 -15
- data/examples/rails_integration_advanced.rb +353 -0
- data/examples/sinatra_microservice.rb +431 -0
- data/lib/rack/ai/configuration.rb +35 -44
- data/lib/rack/ai/features/classification.rb +8 -2
- data/lib/rack/ai/features/moderation.rb +17 -3
- data/lib/rack/ai/features/rate_limiter.rb +145 -0
- data/lib/rack/ai/features/security_scanner.rb +326 -0
- data/lib/rack/ai/middleware.rb +1 -1
- data/lib/rack/ai/utils/enhanced_logger.rb +3 -3
- data/lib/rack/ai/utils/logger.rb +2 -16
- data/lib/rack/ai/version.rb +1 -1
- data/lib/rack/ai.rb +2 -0
- metadata +5 -1
@@ -0,0 +1,145 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'digest'
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
module AI
|
7
|
+
module Features
|
8
|
+
class RateLimiter
|
9
|
+
attr_reader :name, :config
|
10
|
+
|
11
|
+
def initialize(config)
|
12
|
+
@name = :rate_limiter
|
13
|
+
@config = config
|
14
|
+
@cache = {}
|
15
|
+
@cleanup_interval = 300 # 5 minutes
|
16
|
+
@last_cleanup = Time.now
|
17
|
+
end
|
18
|
+
|
19
|
+
def enabled?
|
20
|
+
@config.feature_enabled?(:rate_limiter)
|
21
|
+
end
|
22
|
+
|
23
|
+
def process_response?
|
24
|
+
false
|
25
|
+
end
|
26
|
+
|
27
|
+
def process_request(env)
|
28
|
+
return { processed: false, reason: "disabled" } unless enabled?
|
29
|
+
|
30
|
+
client_id = extract_client_id(env)
|
31
|
+
current_time = Time.now
|
32
|
+
|
33
|
+
cleanup_expired_entries if should_cleanup?
|
34
|
+
|
35
|
+
# Get or initialize client data
|
36
|
+
client_data = @cache[client_id] ||= {
|
37
|
+
requests: [],
|
38
|
+
blocked_until: nil
|
39
|
+
}
|
40
|
+
|
41
|
+
# Check if client is currently blocked
|
42
|
+
if client_data[:blocked_until] && current_time < client_data[:blocked_until]
|
43
|
+
return {
|
44
|
+
processed: true,
|
45
|
+
action: :block,
|
46
|
+
reason: "rate_limited",
|
47
|
+
blocked_until: client_data[:blocked_until],
|
48
|
+
feature: @name,
|
49
|
+
timestamp: current_time.iso8601
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
# Clean old requests
|
54
|
+
window_start = current_time - rate_limit_window
|
55
|
+
client_data[:requests].reject! { |req_time| req_time < window_start }
|
56
|
+
|
57
|
+
# Check rate limit
|
58
|
+
if client_data[:requests].size >= max_requests_per_window
|
59
|
+
# Block client for penalty duration
|
60
|
+
client_data[:blocked_until] = current_time + penalty_duration
|
61
|
+
|
62
|
+
return {
|
63
|
+
processed: true,
|
64
|
+
action: :block,
|
65
|
+
reason: "rate_limit_exceeded",
|
66
|
+
requests_count: client_data[:requests].size,
|
67
|
+
blocked_until: client_data[:blocked_until],
|
68
|
+
feature: @name,
|
69
|
+
timestamp: current_time.iso8601
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
# Record this request
|
74
|
+
client_data[:requests] << current_time
|
75
|
+
|
76
|
+
{
|
77
|
+
processed: true,
|
78
|
+
action: :allow,
|
79
|
+
requests_count: client_data[:requests].size,
|
80
|
+
remaining_requests: max_requests_per_window - client_data[:requests].size,
|
81
|
+
feature: @name,
|
82
|
+
timestamp: current_time.iso8601
|
83
|
+
}
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def extract_client_id(env)
|
89
|
+
# Try multiple identification methods
|
90
|
+
client_ip = env['HTTP_X_FORWARDED_FOR']&.split(',')&.first&.strip ||
|
91
|
+
env['HTTP_X_REAL_IP'] ||
|
92
|
+
env['REMOTE_ADDR'] ||
|
93
|
+
'unknown'
|
94
|
+
|
95
|
+
user_agent = env['HTTP_USER_AGENT'] || 'unknown'
|
96
|
+
|
97
|
+
# Create a hash of IP + User Agent for better identification
|
98
|
+
Digest::SHA256.hexdigest("#{client_ip}:#{user_agent}")[0..16]
|
99
|
+
end
|
100
|
+
|
101
|
+
def rate_limit_window
|
102
|
+
get_config_value(:rate_limit_window, 60) # 1 minute default
|
103
|
+
end
|
104
|
+
|
105
|
+
def max_requests_per_window
|
106
|
+
get_config_value(:max_requests_per_window, 100) # 100 requests per minute default
|
107
|
+
end
|
108
|
+
|
109
|
+
def penalty_duration
|
110
|
+
get_config_value(:penalty_duration, 300) # 5 minutes default
|
111
|
+
end
|
112
|
+
|
113
|
+
def get_config_value(key, default)
|
114
|
+
return default unless @config.respond_to?(:rate_limiter)
|
115
|
+
return default unless @config.rate_limiter.respond_to?(key)
|
116
|
+
@config.rate_limiter.public_send(key)
|
117
|
+
end
|
118
|
+
|
119
|
+
def should_cleanup?
|
120
|
+
Time.now - @last_cleanup > @cleanup_interval
|
121
|
+
end
|
122
|
+
|
123
|
+
def cleanup_expired_entries
|
124
|
+
current_time = Time.now
|
125
|
+
window_start = current_time - rate_limit_window
|
126
|
+
|
127
|
+
@cache.each do |client_id, data|
|
128
|
+
# Remove expired requests
|
129
|
+
data[:requests].reject! { |req_time| req_time < window_start }
|
130
|
+
|
131
|
+
# Remove expired blocks
|
132
|
+
data[:blocked_until] = nil if data[:blocked_until] && current_time >= data[:blocked_until]
|
133
|
+
|
134
|
+
# Remove empty entries
|
135
|
+
if data[:requests].empty? && data[:blocked_until].nil?
|
136
|
+
@cache.delete(client_id)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
@last_cleanup = current_time
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,326 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
module AI
|
5
|
+
module Features
|
6
|
+
class SecurityScanner
|
7
|
+
attr_reader :name, :config
|
8
|
+
|
9
|
+
def initialize(config)
|
10
|
+
@name = :security_scanner
|
11
|
+
@config = config
|
12
|
+
end
|
13
|
+
|
14
|
+
def enabled?
|
15
|
+
@config.feature_enabled?(:security_scanner)
|
16
|
+
end
|
17
|
+
|
18
|
+
def process_response?
|
19
|
+
false
|
20
|
+
end
|
21
|
+
|
22
|
+
def process_request(env)
|
23
|
+
return { processed: false, reason: "disabled" } unless enabled?
|
24
|
+
|
25
|
+
threats = []
|
26
|
+
risk_score = 0.0
|
27
|
+
|
28
|
+
# Check for various security threats
|
29
|
+
threats.concat(check_sql_injection(env))
|
30
|
+
threats.concat(check_xss_attempts(env))
|
31
|
+
threats.concat(check_path_traversal(env))
|
32
|
+
threats.concat(check_command_injection(env))
|
33
|
+
threats.concat(check_suspicious_headers(env))
|
34
|
+
threats.concat(check_malicious_user_agents(env))
|
35
|
+
|
36
|
+
# Calculate overall risk score
|
37
|
+
risk_score = calculate_risk_score(threats)
|
38
|
+
threat_level = determine_threat_level(risk_score)
|
39
|
+
|
40
|
+
{
|
41
|
+
processed: true,
|
42
|
+
action: determine_action(threat_level),
|
43
|
+
threats: threats,
|
44
|
+
risk_score: risk_score,
|
45
|
+
threat_level: threat_level,
|
46
|
+
feature: @name,
|
47
|
+
timestamp: Time.now.iso8601
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def check_sql_injection(env)
|
54
|
+
threats = []
|
55
|
+
patterns = [
|
56
|
+
/union\s+select/i,
|
57
|
+
/drop\s+table/i,
|
58
|
+
/insert\s+into/i,
|
59
|
+
/delete\s+from/i,
|
60
|
+
/update\s+.*set/i,
|
61
|
+
/exec\s*\(/i,
|
62
|
+
/script\s*>/i,
|
63
|
+
/'.*or.*'.*='/i,
|
64
|
+
/1=1/i,
|
65
|
+
/admin'--/i
|
66
|
+
]
|
67
|
+
|
68
|
+
check_patterns_in_request(env, patterns, 'sql_injection').each do |threat|
|
69
|
+
threats << threat
|
70
|
+
end
|
71
|
+
|
72
|
+
threats
|
73
|
+
end
|
74
|
+
|
75
|
+
def check_xss_attempts(env)
|
76
|
+
threats = []
|
77
|
+
patterns = [
|
78
|
+
/<script[^>]*>.*?<\/script>/i,
|
79
|
+
/javascript:/i,
|
80
|
+
/on\w+\s*=/i,
|
81
|
+
/<iframe[^>]*>/i,
|
82
|
+
/<object[^>]*>/i,
|
83
|
+
/<embed[^>]*>/i,
|
84
|
+
/eval\s*\(/i,
|
85
|
+
/alert\s*\(/i,
|
86
|
+
/document\.cookie/i
|
87
|
+
]
|
88
|
+
|
89
|
+
check_patterns_in_request(env, patterns, 'xss_attempt').each do |threat|
|
90
|
+
threats << threat
|
91
|
+
end
|
92
|
+
|
93
|
+
threats
|
94
|
+
end
|
95
|
+
|
96
|
+
def check_path_traversal(env)
|
97
|
+
threats = []
|
98
|
+
patterns = [
|
99
|
+
/\.\.\//,
|
100
|
+
/\.\.\\/,
|
101
|
+
/%2e%2e%2f/i,
|
102
|
+
/%2e%2e%5c/i,
|
103
|
+
/etc\/passwd/i,
|
104
|
+
/windows\/system32/i
|
105
|
+
]
|
106
|
+
|
107
|
+
check_patterns_in_request(env, patterns, 'path_traversal').each do |threat|
|
108
|
+
threats << threat
|
109
|
+
end
|
110
|
+
|
111
|
+
threats
|
112
|
+
end
|
113
|
+
|
114
|
+
def check_command_injection(env)
|
115
|
+
threats = []
|
116
|
+
patterns = [
|
117
|
+
/;\s*cat\s+/i,
|
118
|
+
/;\s*ls\s+/i,
|
119
|
+
/;\s*rm\s+/i,
|
120
|
+
/;\s*wget\s+/i,
|
121
|
+
/;\s*curl\s+/i,
|
122
|
+
/\|\s*nc\s+/i,
|
123
|
+
/&&\s*whoami/i,
|
124
|
+
/`.*`/,
|
125
|
+
/\$\(.*\)/
|
126
|
+
]
|
127
|
+
|
128
|
+
check_patterns_in_request(env, patterns, 'command_injection').each do |threat|
|
129
|
+
threats << threat
|
130
|
+
end
|
131
|
+
|
132
|
+
threats
|
133
|
+
end
|
134
|
+
|
135
|
+
def check_suspicious_headers(env)
|
136
|
+
threats = []
|
137
|
+
|
138
|
+
# Check for suspicious User-Agent patterns
|
139
|
+
user_agent = env['HTTP_USER_AGENT']
|
140
|
+
if user_agent
|
141
|
+
suspicious_agents = [
|
142
|
+
/sqlmap/i,
|
143
|
+
/nikto/i,
|
144
|
+
/nessus/i,
|
145
|
+
/burp/i,
|
146
|
+
/w3af/i,
|
147
|
+
/acunetix/i,
|
148
|
+
/netsparker/i
|
149
|
+
]
|
150
|
+
|
151
|
+
suspicious_agents.each do |pattern|
|
152
|
+
if user_agent.match?(pattern)
|
153
|
+
threats << {
|
154
|
+
type: 'suspicious_user_agent',
|
155
|
+
pattern: pattern.source,
|
156
|
+
value: user_agent,
|
157
|
+
severity: 'high'
|
158
|
+
}
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# Check for suspicious headers
|
164
|
+
suspicious_headers = %w[
|
165
|
+
HTTP_X_FORWARDED_HOST
|
166
|
+
HTTP_X_ORIGINAL_URL
|
167
|
+
HTTP_X_REWRITE_URL
|
168
|
+
]
|
169
|
+
|
170
|
+
suspicious_headers.each do |header|
|
171
|
+
if env[header] && env[header].include?('..')
|
172
|
+
threats << {
|
173
|
+
type: 'suspicious_header',
|
174
|
+
header: header,
|
175
|
+
value: env[header],
|
176
|
+
severity: 'medium'
|
177
|
+
}
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
threats
|
182
|
+
end
|
183
|
+
|
184
|
+
def check_malicious_user_agents(env)
|
185
|
+
threats = []
|
186
|
+
user_agent = env['HTTP_USER_AGENT']
|
187
|
+
return threats unless user_agent
|
188
|
+
|
189
|
+
# Check for bot patterns that might be malicious
|
190
|
+
malicious_patterns = [
|
191
|
+
/python-requests/i,
|
192
|
+
/curl/i,
|
193
|
+
/wget/i,
|
194
|
+
/libwww/i,
|
195
|
+
/lwp-trivial/i,
|
196
|
+
/^$/ # Empty user agent
|
197
|
+
]
|
198
|
+
|
199
|
+
malicious_patterns.each do |pattern|
|
200
|
+
if user_agent.match?(pattern)
|
201
|
+
threats << {
|
202
|
+
type: 'potentially_malicious_bot',
|
203
|
+
pattern: pattern.source,
|
204
|
+
value: user_agent,
|
205
|
+
severity: 'low'
|
206
|
+
}
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
threats
|
211
|
+
end
|
212
|
+
|
213
|
+
def check_patterns_in_request(env, patterns, threat_type)
|
214
|
+
threats = []
|
215
|
+
|
216
|
+
# Check query string
|
217
|
+
if env['QUERY_STRING']
|
218
|
+
patterns.each do |pattern|
|
219
|
+
if env['QUERY_STRING'].match?(pattern)
|
220
|
+
threats << {
|
221
|
+
type: threat_type,
|
222
|
+
location: 'query_string',
|
223
|
+
pattern: pattern.source,
|
224
|
+
value: env['QUERY_STRING'],
|
225
|
+
severity: 'high'
|
226
|
+
}
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# Check POST data
|
232
|
+
if %w[POST PUT PATCH].include?(env['REQUEST_METHOD']) && env['rack.input']
|
233
|
+
begin
|
234
|
+
body = env['rack.input'].read
|
235
|
+
env['rack.input'].rewind if env['rack.input'].respond_to?(:rewind)
|
236
|
+
|
237
|
+
patterns.each do |pattern|
|
238
|
+
if body.match?(pattern)
|
239
|
+
threats << {
|
240
|
+
type: threat_type,
|
241
|
+
location: 'request_body',
|
242
|
+
pattern: pattern.source,
|
243
|
+
value: body[0..200], # Truncate for logging
|
244
|
+
severity: 'high'
|
245
|
+
}
|
246
|
+
end
|
247
|
+
end
|
248
|
+
rescue => e
|
249
|
+
# Log error but don't fail
|
250
|
+
Utils::Logger.warn("Error reading request body for security scan", { error: e.message })
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
# Check headers
|
255
|
+
env.each do |key, value|
|
256
|
+
next unless key.start_with?('HTTP_')
|
257
|
+
next unless value.is_a?(String)
|
258
|
+
|
259
|
+
patterns.each do |pattern|
|
260
|
+
if value.match?(pattern)
|
261
|
+
threats << {
|
262
|
+
type: threat_type,
|
263
|
+
location: 'headers',
|
264
|
+
header: key,
|
265
|
+
pattern: pattern.source,
|
266
|
+
value: value,
|
267
|
+
severity: 'medium'
|
268
|
+
}
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
threats
|
274
|
+
end
|
275
|
+
|
276
|
+
def calculate_risk_score(threats)
|
277
|
+
return 0.0 if threats.empty?
|
278
|
+
|
279
|
+
score = 0.0
|
280
|
+
threats.each do |threat|
|
281
|
+
case threat[:severity]
|
282
|
+
when 'high'
|
283
|
+
score += 0.4
|
284
|
+
when 'medium'
|
285
|
+
score += 0.2
|
286
|
+
when 'low'
|
287
|
+
score += 0.1
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
[score, 1.0].min # Cap at 1.0
|
292
|
+
end
|
293
|
+
|
294
|
+
def determine_threat_level(risk_score)
|
295
|
+
case risk_score
|
296
|
+
when 0.0...0.2
|
297
|
+
:low
|
298
|
+
when 0.2...0.5
|
299
|
+
:medium
|
300
|
+
when 0.5...0.8
|
301
|
+
:high
|
302
|
+
else
|
303
|
+
:critical
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
def determine_action(threat_level)
|
308
|
+
case threat_level
|
309
|
+
when :critical, :high
|
310
|
+
:block
|
311
|
+
when :medium
|
312
|
+
get_config_value(:medium_threat_action, :flag)
|
313
|
+
else
|
314
|
+
:allow
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def get_config_value(key, default)
|
319
|
+
return default unless @config.respond_to?(:security_scanner)
|
320
|
+
return default unless @config.security_scanner.respond_to?(key)
|
321
|
+
@config.security_scanner.public_send(key)
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
data/lib/rack/ai/middleware.rb
CHANGED
@@ -6,7 +6,7 @@ require 'json'
|
|
6
6
|
module Rack
|
7
7
|
module AI
|
8
8
|
module Utils
|
9
|
-
class
|
9
|
+
class AdvancedLogger
|
10
10
|
LOG_LEVELS = {
|
11
11
|
debug: ::Logger::DEBUG,
|
12
12
|
info: ::Logger::INFO,
|
@@ -123,8 +123,8 @@ module Rack
|
|
123
123
|
end
|
124
124
|
end
|
125
125
|
|
126
|
-
#
|
127
|
-
|
126
|
+
# Alias for backward compatibility
|
127
|
+
EnhancedLogger = AdvancedLogger
|
128
128
|
end
|
129
129
|
end
|
130
130
|
end
|
data/lib/rack/ai/utils/logger.rb
CHANGED
@@ -7,28 +7,13 @@ module Rack
|
|
7
7
|
module AI
|
8
8
|
module Utils
|
9
9
|
class Logger
|
10
|
-
def self.debug(message, context = {})
|
11
|
-
puts "[DEBUG] #{message} #{context}" if ENV['RACK_AI_DEBUG']
|
12
|
-
end
|
13
|
-
|
14
|
-
def self.info(message, context = {})
|
15
|
-
puts "[INFO] #{message} #{context}"
|
16
|
-
end
|
17
|
-
|
18
|
-
def self.warn(message, context = {})
|
19
|
-
puts "[WARN] #{message} #{context}"
|
20
|
-
end
|
21
|
-
|
22
|
-
def self.error(message, context = {})
|
23
|
-
puts "[ERROR] #{message} #{context}"
|
24
|
-
end
|
25
|
-
|
26
10
|
class << self
|
27
11
|
def logger
|
28
12
|
@logger ||= build_logger
|
29
13
|
end
|
30
14
|
|
31
15
|
def debug(message, metadata = {})
|
16
|
+
return unless ENV['RACK_AI_DEBUG']
|
32
17
|
log(:debug, message, metadata)
|
33
18
|
end
|
34
19
|
|
@@ -122,6 +107,7 @@ module Rack
|
|
122
107
|
end
|
123
108
|
end
|
124
109
|
end
|
110
|
+
|
125
111
|
end
|
126
112
|
end
|
127
113
|
end
|
data/lib/rack/ai/version.rb
CHANGED
data/lib/rack/ai.rb
CHANGED
@@ -16,6 +16,8 @@ require_relative "ai/features/logging"
|
|
16
16
|
require_relative "ai/features/enhancement"
|
17
17
|
require_relative "ai/features/rate_limiting"
|
18
18
|
require_relative "ai/features/anomaly_detection"
|
19
|
+
require_relative "ai/features/rate_limiter"
|
20
|
+
require_relative "ai/features/security_scanner"
|
19
21
|
require_relative "ai/utils/logger"
|
20
22
|
require_relative "ai/utils/metrics"
|
21
23
|
require_relative "ai/utils/sanitizer"
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rack-ai
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ahmet KAHRAMAN
|
@@ -243,7 +243,9 @@ files:
|
|
243
243
|
- benchmarks/performance_benchmark.rb
|
244
244
|
- examples/comprehensive_example.rb
|
245
245
|
- examples/rails_integration.rb
|
246
|
+
- examples/rails_integration_advanced.rb
|
246
247
|
- examples/sinatra_integration.rb
|
248
|
+
- examples/sinatra_microservice.rb
|
247
249
|
- lib/rack/ai.rb
|
248
250
|
- lib/rack/ai/configuration.rb
|
249
251
|
- lib/rack/ai/features/anomaly_detection.rb
|
@@ -252,9 +254,11 @@ files:
|
|
252
254
|
- lib/rack/ai/features/enhancement.rb
|
253
255
|
- lib/rack/ai/features/logging.rb
|
254
256
|
- lib/rack/ai/features/moderation.rb
|
257
|
+
- lib/rack/ai/features/rate_limiter.rb
|
255
258
|
- lib/rack/ai/features/rate_limiting.rb
|
256
259
|
- lib/rack/ai/features/routing.rb
|
257
260
|
- lib/rack/ai/features/security.rb
|
261
|
+
- lib/rack/ai/features/security_scanner.rb
|
258
262
|
- lib/rack/ai/middleware.rb
|
259
263
|
- lib/rack/ai/providers/base.rb
|
260
264
|
- lib/rack/ai/providers/huggingface.rb
|