rack-ai 0.1.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.
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module AI
5
+ module Features
6
+ class Security
7
+ attr_reader :name, :provider, :config
8
+
9
+ def initialize(provider, config)
10
+ @name = :security
11
+ @provider = provider
12
+ @config = config
13
+ end
14
+
15
+ def enabled?
16
+ @config.feature_enabled?(:security)
17
+ end
18
+
19
+ def process_response?
20
+ false
21
+ end
22
+
23
+ def process_request(env)
24
+ request_data = @provider.build_request_data(env)
25
+
26
+ # Detect various security threats
27
+ anomaly_result = @provider.detect_anomalies(request_data)
28
+ injection_result = detect_injection_attacks(env)
29
+ rate_limit_result = check_rate_limiting(env)
30
+
31
+ # Combine all security checks
32
+ threat_level = determine_overall_threat_level([
33
+ anomaly_result[:threat_level],
34
+ injection_result[:threat_level],
35
+ rate_limit_result[:threat_level]
36
+ ])
37
+
38
+ result = {
39
+ threat_level: threat_level,
40
+ anomaly_detection: anomaly_result,
41
+ injection_detection: injection_result,
42
+ rate_limiting: rate_limit_result,
43
+ action: determine_security_action(threat_level),
44
+ feature: @name,
45
+ timestamp: Time.now.iso8601
46
+ }
47
+
48
+ # Add detailed security metadata
49
+ result[:security_headers] = analyze_security_headers(env)
50
+ result[:suspicious_patterns] = identify_suspicious_patterns(env)
51
+
52
+ result
53
+ end
54
+
55
+ private
56
+
57
+ def detect_injection_attacks(env)
58
+ threats = []
59
+ threat_level = :low
60
+
61
+ # SQL Injection detection
62
+ sql_patterns = [
63
+ /union\s+select/i,
64
+ /drop\s+table/i,
65
+ /insert\s+into/i,
66
+ /delete\s+from/i,
67
+ /'\s*or\s*'1'\s*=\s*'1/i,
68
+ /'\s*;\s*drop/i
69
+ ]
70
+
71
+ # XSS detection
72
+ xss_patterns = [
73
+ /<script[^>]*>/i,
74
+ /javascript:/i,
75
+ /on\w+\s*=/i,
76
+ /<iframe[^>]*>/i,
77
+ /eval\s*\(/i
78
+ ]
79
+
80
+ # Command injection detection
81
+ cmd_patterns = [
82
+ /;\s*cat\s+/i,
83
+ /;\s*ls\s+/i,
84
+ /;\s*rm\s+/i,
85
+ /\|\s*nc\s+/i,
86
+ /&&\s*curl/i
87
+ ]
88
+
89
+ # Check query string and body
90
+ content_to_check = []
91
+ content_to_check << env["QUERY_STRING"] if env["QUERY_STRING"]
92
+
93
+ if %w[POST PUT PATCH].include?(env["REQUEST_METHOD"])
94
+ input = env["rack.input"]
95
+ if input && input.respond_to?(:read)
96
+ body_content = input.read
97
+ input.rewind if input.respond_to?(:rewind)
98
+ content_to_check << body_content
99
+ end
100
+ end
101
+
102
+ content_to_check.each do |content|
103
+ next if content.nil? || content.empty?
104
+
105
+ if sql_patterns.any? { |pattern| content.match?(pattern) }
106
+ threats << "sql_injection"
107
+ threat_level = :high
108
+ end
109
+
110
+ if xss_patterns.any? { |pattern| content.match?(pattern) }
111
+ threats << "xss_attempt"
112
+ threat_level = [:high, threat_level].max
113
+ end
114
+
115
+ if cmd_patterns.any? { |pattern| content.match?(pattern) }
116
+ threats << "command_injection"
117
+ threat_level = :high
118
+ end
119
+ end
120
+
121
+ # Check for prompt injection if this looks like an LLM endpoint
122
+ if llm_endpoint?(env) && content_to_check.any? { |c| prompt_injection?(c) }
123
+ threats << "prompt_injection"
124
+ threat_level = [:medium, threat_level].max
125
+ end
126
+
127
+ {
128
+ threats: threats,
129
+ threat_level: threat_level,
130
+ patterns_detected: threats.size
131
+ }
132
+ end
133
+
134
+ def check_rate_limiting(env)
135
+ client_ip = env["REMOTE_ADDR"]
136
+ user_agent = env["HTTP_USER_AGENT"]
137
+
138
+ # Simple in-memory rate limiting (in production, use Redis)
139
+ @rate_limiter ||= {}
140
+ current_time = Time.now.to_i
141
+ window_size = 60 # 1 minute window
142
+
143
+ # Clean old entries
144
+ @rate_limiter.delete_if { |key, data| current_time - data[:window_start] > window_size }
145
+
146
+ # Check rate for IP
147
+ ip_key = "ip:#{client_ip}"
148
+ ip_data = @rate_limiter[ip_key] ||= { count: 0, window_start: current_time }
149
+
150
+ if current_time - ip_data[:window_start] > window_size
151
+ ip_data[:count] = 0
152
+ ip_data[:window_start] = current_time
153
+ end
154
+
155
+ ip_data[:count] += 1
156
+
157
+ # Determine threat level based on request rate
158
+ rate_limit = @config.rate_limit
159
+ threat_level = if ip_data[:count] > rate_limit * 2
160
+ :high
161
+ elsif ip_data[:count] > rate_limit
162
+ :medium
163
+ else
164
+ :low
165
+ end
166
+
167
+ {
168
+ client_ip: client_ip,
169
+ request_count: ip_data[:count],
170
+ window_start: ip_data[:window_start],
171
+ threat_level: threat_level,
172
+ rate_exceeded: ip_data[:count] > rate_limit
173
+ }
174
+ end
175
+
176
+ def determine_overall_threat_level(threat_levels)
177
+ return :high if threat_levels.include?(:high)
178
+ return :medium if threat_levels.include?(:medium)
179
+ :low
180
+ end
181
+
182
+ def determine_security_action(threat_level)
183
+ case threat_level
184
+ when :high
185
+ :block
186
+ when :medium
187
+ :flag
188
+ else
189
+ :allow
190
+ end
191
+ end
192
+
193
+ def analyze_security_headers(env)
194
+ headers = {}
195
+
196
+ # Check for security-related headers
197
+ security_headers = %w[
198
+ HTTP_X_FORWARDED_FOR
199
+ HTTP_X_REAL_IP
200
+ HTTP_X_FORWARDED_PROTO
201
+ HTTP_AUTHORIZATION
202
+ HTTP_X_API_KEY
203
+ ]
204
+
205
+ security_headers.each do |header|
206
+ headers[header.downcase] = env[header] if env[header]
207
+ end
208
+
209
+ {
210
+ present_headers: headers.keys,
211
+ forwarded_for: env["HTTP_X_FORWARDED_FOR"],
212
+ real_ip: env["HTTP_X_REAL_IP"],
213
+ has_auth: !!(env["HTTP_AUTHORIZATION"] || env["HTTP_X_API_KEY"])
214
+ }
215
+ end
216
+
217
+ def identify_suspicious_patterns(env)
218
+ patterns = []
219
+
220
+ # Check for suspicious user agents
221
+ user_agent = env["HTTP_USER_AGENT"] || ""
222
+ if user_agent.empty?
223
+ patterns << "missing_user_agent"
224
+ elsif user_agent.match?(/curl|wget|python|bot|crawler/i)
225
+ patterns << "automated_client"
226
+ end
227
+
228
+ # Check for suspicious paths
229
+ path = env["PATH_INFO"] || ""
230
+ if path.match?(/\.\.|\/etc\/|\/proc\/|\/var\//)
231
+ patterns << "path_traversal_attempt"
232
+ end
233
+
234
+ if path.match?(/admin|config|backup|\.env|\.git/)
235
+ patterns << "sensitive_path_access"
236
+ end
237
+
238
+ # Check for unusual request methods
239
+ method = env["REQUEST_METHOD"]
240
+ if %w[TRACE CONNECT].include?(method)
241
+ patterns << "unusual_http_method"
242
+ end
243
+
244
+ # Check for missing referer on POST requests
245
+ if method == "POST" && !env["HTTP_REFERER"]
246
+ patterns << "missing_referer_on_post"
247
+ end
248
+
249
+ patterns
250
+ end
251
+
252
+ def llm_endpoint?(env)
253
+ path = env["PATH_INFO"] || ""
254
+ path.match?(/\/api\/.*(?:chat|completion|generate|prompt)/i)
255
+ end
256
+
257
+ def prompt_injection?(content)
258
+ # Common prompt injection patterns
259
+ injection_patterns = [
260
+ /ignore\s+previous\s+instructions/i,
261
+ /forget\s+everything/i,
262
+ /you\s+are\s+now/i,
263
+ /system\s*:\s*you/i,
264
+ /\[INST\]/i,
265
+ /\<\|system\|\>/i,
266
+ /act\s+as\s+if/i,
267
+ /pretend\s+to\s+be/i
268
+ ]
269
+
270
+ injection_patterns.any? { |pattern| content.match?(pattern) }
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Rack
6
+ module AI
7
+ class Middleware
8
+ attr_reader :app, :config, :provider, :features
9
+
10
+ def initialize(app, **options)
11
+ @app = app
12
+ @config = build_config(options)
13
+ @provider = build_provider
14
+ @features = build_features
15
+ @thread_pool = Concurrent::ThreadPoolExecutor.new(
16
+ min_threads: 2,
17
+ max_threads: 10,
18
+ max_queue: 100
19
+ )
20
+
21
+ validate_configuration!
22
+ end
23
+
24
+ def call(env)
25
+ # Initialize AI context in env
26
+ env["rack.ai"] = {
27
+ results: {},
28
+ metadata: {},
29
+ start_time: Time.now
30
+ }
31
+
32
+ # Process request through AI features
33
+ blocked_response = process_request(env) if should_process_request?(env)
34
+
35
+ # Return blocked response if request should be blocked
36
+ return blocked_response if blocked_response
37
+
38
+ # Call the next middleware/app
39
+ status, headers, body = @app.call(env)
40
+
41
+ # Process response through AI features
42
+ process_response(env, status, headers, body) if should_process_response?(env)
43
+
44
+ # Add AI headers if configured
45
+ add_ai_headers(headers, env) if @config.explain_decisions
46
+
47
+ [status, headers, body]
48
+ rescue => e
49
+ handle_error(e, env)
50
+ # Fail-safe: continue with original request if AI processing fails
51
+ @config.fail_safe ? @app.call(env) : raise
52
+ ensure
53
+ # Record metrics
54
+ record_metrics(env) if @config.metrics_enabled
55
+ end
56
+
57
+ private
58
+
59
+ def build_config(options)
60
+ config = Configuration.new
61
+ options.each { |key, value| config.public_send("#{key}=", value) if config.respond_to?("#{key}=") }
62
+ config
63
+ end
64
+
65
+ def build_provider
66
+ case @config.provider
67
+ when :openai
68
+ Providers::OpenAI.new(@config.provider_config)
69
+ when :huggingface
70
+ Providers::HuggingFace.new(@config.provider_config)
71
+ when :local
72
+ Providers::Local.new(@config.provider_config)
73
+ else
74
+ raise ConfigurationError, "Unknown provider: #{@config.provider}"
75
+ end
76
+ end
77
+
78
+ def build_features
79
+ feature_map = {
80
+ classification: Features::Classification,
81
+ moderation: Features::Moderation,
82
+ caching: Features::Caching,
83
+ routing: Features::Routing,
84
+ logging: Features::Logging,
85
+ enhancement: Features::Enhancement,
86
+ security: Features::Security
87
+ }
88
+
89
+ features = @config.features.map do |feature_name|
90
+ feature_class = feature_map[feature_name.to_sym]
91
+ raise ConfigurationError, "Unknown feature: #{feature_name}" unless feature_class
92
+
93
+ feature_class.new(@provider, @config)
94
+ end
95
+
96
+ Utils::Logger.debug("Built features: #{features.map(&:name)}")
97
+ features
98
+ end
99
+
100
+ def validate_configuration!
101
+ @config.validate!
102
+ @provider.validate! if @provider.respond_to?(:validate!)
103
+ end
104
+
105
+ def should_process_request?(env)
106
+ # Skip processing for certain paths or conditions
107
+ return false if env["PATH_INFO"] == "/health"
108
+ return false if env["REQUEST_METHOD"] == "OPTIONS"
109
+ true
110
+ end
111
+
112
+ def should_process_response?(env)
113
+ # Only process responses if features require it
114
+ @features.any? { |feature| feature.process_response? }
115
+ end
116
+
117
+ def process_request(env)
118
+ if @config.async_processing
119
+ process_request_async(env)
120
+ else
121
+ process_request_sync(env)
122
+ end
123
+ end
124
+
125
+ def process_request_sync(env)
126
+ blocked_response = nil
127
+
128
+ @features.each do |feature|
129
+ next unless feature.enabled?
130
+
131
+ begin
132
+ result = feature.process_request(env)
133
+ env["rack.ai"][:results][feature.name] = result
134
+
135
+ # Handle blocking decisions (e.g., block malicious requests)
136
+ if result[:action] == :block
137
+ blocked_response = build_blocked_response(result)
138
+ break
139
+ end
140
+ rescue => e
141
+ handle_feature_error(feature, e, env)
142
+ end
143
+ end
144
+
145
+ blocked_response
146
+ end
147
+
148
+ def process_request_async(env)
149
+ blocked_response = nil
150
+ futures = @features.map do |feature|
151
+ next unless feature.enabled?
152
+
153
+ Concurrent::Future.execute(executor: @thread_pool) do
154
+ begin
155
+ feature.process_request(env)
156
+ rescue => e
157
+ { error: e.message, feature: feature.name }
158
+ end
159
+ end
160
+ end.compact
161
+
162
+ # Wait for critical features (security, moderation)
163
+ critical_features = [:security, :moderation]
164
+ futures.each_with_index do |future, index|
165
+ feature = @features[index]
166
+ next unless critical_features.include?(feature.name)
167
+
168
+ result = future.value(@config.timeout)
169
+ env["rack.ai"][:results][feature.name] = result
170
+
171
+ if result[:action] == :block
172
+ blocked_response = build_blocked_response(result)
173
+ break
174
+ end
175
+ end
176
+
177
+ # Collect remaining results asynchronously
178
+ Concurrent::Future.execute(executor: @thread_pool) do
179
+ futures.each_with_index do |future, index|
180
+ feature = @features[index]
181
+ next if critical_features.include?(feature.name)
182
+
183
+ result = future.value
184
+ env["rack.ai"][:results][feature.name] = result
185
+ end
186
+ end
187
+
188
+ blocked_response
189
+ end
190
+
191
+ def process_response(env, status, headers, body)
192
+ @features.each do |feature|
193
+ next unless feature.enabled? && feature.process_response?
194
+
195
+ begin
196
+ result = feature.process_response(env, status, headers, body)
197
+ env["rack.ai"][:results]["#{feature.name}_response"] = result
198
+ rescue => e
199
+ handle_feature_error(feature, e, env)
200
+ end
201
+ end
202
+ end
203
+
204
+ def build_blocked_response(result)
205
+ status = result[:status] || 403
206
+ headers = { "Content-Type" => "application/json" }
207
+ body = [{
208
+ error: "Request blocked by AI security",
209
+ reason: result[:reason],
210
+ request_id: result[:request_id]
211
+ }.to_json]
212
+
213
+ [status, headers, body]
214
+ end
215
+
216
+ def add_ai_headers(headers, env)
217
+ ai_results = env["rack.ai"][:results]
218
+ headers["X-AI-Processed"] = "true"
219
+ headers["X-AI-Features"] = @config.features.join(",")
220
+ headers["X-AI-Processing-Time"] = "#{((Time.now - env["rack.ai"][:start_time]) * 1000).round(2)}ms"
221
+
222
+ if @config.explain_decisions
223
+ explanations = ai_results.map { |feature, result| "#{feature}:#{result[:confidence] || 'N/A'}" }
224
+ headers["X-AI-Decisions"] = explanations.join(";")
225
+ end
226
+ end
227
+
228
+ def handle_error(error, env)
229
+ Utils::Logger.error("Rack::AI middleware error", {
230
+ error: error.message,
231
+ backtrace: error.backtrace&.first(5),
232
+ request_path: env["PATH_INFO"],
233
+ request_method: env["REQUEST_METHOD"]
234
+ })
235
+ end
236
+
237
+ def handle_feature_error(feature, error, env)
238
+ Utils::Logger.warn("Feature #{feature.name} error", {
239
+ error: error.message,
240
+ feature: feature.name,
241
+ request_path: env["PATH_INFO"]
242
+ })
243
+
244
+ env["rack.ai"][:results][feature.name] = {
245
+ error: error.message,
246
+ processed: false
247
+ }
248
+
249
+ # Re-raise error if fail_safe is disabled
250
+ raise error unless @config.fail_safe
251
+ end
252
+
253
+ def record_metrics(env)
254
+ processing_time = Time.now - env["rack.ai"][:start_time]
255
+ Utils::Metrics.record("rack_ai.processing_time", processing_time)
256
+ Utils::Metrics.increment("rack_ai.requests_processed")
257
+
258
+ @features.each do |feature|
259
+ result = env["rack.ai"][:results][feature.name]
260
+ next unless result && !result[:error]
261
+
262
+ Utils::Metrics.increment("rack_ai.feature.#{feature.name}.processed")
263
+ Utils::Metrics.record("rack_ai.feature.#{feature.name}.confidence", result[:confidence]) if result[:confidence]
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module AI
5
+ module Providers
6
+ class Base
7
+ attr_reader :config
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ # Abstract methods that must be implemented by subclasses
14
+ def classify_request(request_data)
15
+ raise NotImplementedError, "#{self.class} must implement #classify_request"
16
+ end
17
+
18
+ def moderate_content(content, options = {})
19
+ raise NotImplementedError, "#{self.class} must implement #moderate_content"
20
+ end
21
+
22
+ def analyze_patterns(data)
23
+ raise NotImplementedError, "#{self.class} must implement #analyze_patterns"
24
+ end
25
+
26
+ def detect_anomalies(request_data)
27
+ raise NotImplementedError, "#{self.class} must implement #detect_anomalies"
28
+ end
29
+
30
+ def enhance_content(content, enhancement_type)
31
+ raise NotImplementedError, "#{self.class} must implement #enhance_content"
32
+ end
33
+
34
+ # Common validation method
35
+ def validate!
36
+ raise ConfigurationError, "API key is required" if requires_api_key? && !@config[:api_key]
37
+ raise ConfigurationError, "API URL is required" if requires_api_url? && !@config[:api_url]
38
+ end
39
+
40
+ # Health check
41
+ def healthy?
42
+ ping
43
+ rescue
44
+ false
45
+ end
46
+
47
+ def ping
48
+ raise NotImplementedError, "#{self.class} must implement #ping"
49
+ end
50
+
51
+ def build_request_data(env)
52
+ {
53
+ method: env["REQUEST_METHOD"],
54
+ path: env["PATH_INFO"],
55
+ query_string: env["QUERY_STRING"],
56
+ user_agent: env["HTTP_USER_AGENT"],
57
+ remote_ip: env["REMOTE_ADDR"],
58
+ headers: extract_safe_headers(env),
59
+ timestamp: Time.now.iso8601
60
+ }
61
+ end
62
+
63
+ protected
64
+
65
+ def requires_api_key?
66
+ true
67
+ end
68
+
69
+ def requires_api_url?
70
+ false
71
+ end
72
+
73
+ def extract_safe_headers(env)
74
+ safe_headers = {}
75
+ env.each do |key, value|
76
+ next unless key.start_with?("HTTP_")
77
+
78
+ header_name = key.sub("HTTP_", "").downcase
79
+ next if sensitive_header?(header_name)
80
+
81
+ safe_headers[header_name] = value
82
+ end
83
+ safe_headers
84
+ end
85
+
86
+ def sensitive_header?(header_name)
87
+ %w[authorization cookie session_id x_api_key].include?(header_name)
88
+ end
89
+
90
+ def handle_api_error(response)
91
+ case response.status
92
+ when 401
93
+ raise ProviderError, "Authentication failed - check API key"
94
+ when 403
95
+ raise ProviderError, "Access forbidden - insufficient permissions"
96
+ when 429
97
+ raise ProviderError, "Rate limit exceeded - please retry later"
98
+ when 500..599
99
+ raise ProviderError, "Provider service error - #{response.status}"
100
+ else
101
+ raise ProviderError, "Unexpected response - #{response.status}: #{response.body}"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end