reputable 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,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Reputable
6
+ # Reputation management functionality
7
+ #
8
+ # RESILIENCE: All reputation methods are designed to fail silently.
9
+ # - Write operations return false on any failure
10
+ # - Read operations return nil on any failure
11
+ # - Boolean checks (trusted_ip?, blocked_ip?) return false on failure
12
+ # - Never raises exceptions during normal operation
13
+ module Reputation
14
+ VALID_STATUSES = %i[
15
+ trusted_verified
16
+ trusted_behavior
17
+ untrusted_challenge
18
+ untrusted_block
19
+ untrusted_ignore
20
+ ].freeze
21
+
22
+ VALID_ENTITY_TYPES = %i[ip asn ja4 session user].freeze
23
+
24
+ class << self
25
+ # Apply a reputation status to an entity
26
+ #
27
+ # @param entity_type [Symbol] Type of entity (:ip, :asn, :ja4, :session, :user)
28
+ # @param entity_id [String] The entity identifier (IP address, ASN, etc.)
29
+ # @param status [Symbol] Reputation status to apply
30
+ # @param options [Hash] Additional options
31
+ # @option options [String] :reason Human-readable reason for the status
32
+ # @option options [Integer] :ttl TTL in seconds (0 = forever, nil = use default)
33
+ # @option options [Hash] :metadata Additional metadata for audit
34
+ # @return [Boolean] true if successfully pushed to buffer, false otherwise
35
+ #
36
+ # @example Trust an IP after payment
37
+ # Reputable::Reputation.apply(
38
+ # entity_type: :ip,
39
+ # entity_id: "203.0.113.45",
40
+ # status: :trusted_verified,
41
+ # reason: "payment_completed",
42
+ # ttl: 0,
43
+ # metadata: { order_id: "order_123", amount: 150.00 }
44
+ # )
45
+ #
46
+ # @example Block a suspicious IP
47
+ # Reputable::Reputation.apply(
48
+ # entity_type: :ip,
49
+ # entity_id: "198.51.100.23",
50
+ # status: :untrusted_block,
51
+ # reason: "abuse_detected"
52
+ # )
53
+ def apply(entity_type:, entity_id:, status:, **options)
54
+ return false unless Reputable.enabled?
55
+ return false unless valid_entity_type?(entity_type)
56
+ return false unless valid_status?(status)
57
+
58
+ entry = build_reputation_entry(entity_type, entity_id, status, options)
59
+ push_to_buffer(entry)
60
+ rescue StandardError => e
61
+ Reputable.logger&.debug("Reputable apply error: #{e.class} - #{e.message}")
62
+ false
63
+ end
64
+
65
+ # Convenience method: Trust an IP (verified status, forever TTL)
66
+ def trust_ip(ip, reason: "manual_trust", **metadata)
67
+ apply(
68
+ entity_type: :ip,
69
+ entity_id: ip,
70
+ status: :trusted_verified,
71
+ reason: reason,
72
+ ttl: 0,
73
+ metadata: metadata
74
+ )
75
+ end
76
+
77
+ # Convenience method: Block an IP
78
+ def block_ip(ip, reason: "manual_block", ttl: nil, **metadata)
79
+ apply(
80
+ entity_type: :ip,
81
+ entity_id: ip,
82
+ status: :untrusted_block,
83
+ reason: reason,
84
+ ttl: ttl,
85
+ metadata: metadata
86
+ )
87
+ end
88
+
89
+ # Convenience method: Challenge an IP (CAPTCHA, etc.)
90
+ def challenge_ip(ip, reason: "suspicious_activity", ttl: nil, **metadata)
91
+ apply(
92
+ entity_type: :ip,
93
+ entity_id: ip,
94
+ status: :untrusted_challenge,
95
+ reason: reason,
96
+ ttl: ttl,
97
+ metadata: metadata
98
+ )
99
+ end
100
+
101
+ # Convenience method: Mark IP to be ignored in analytics
102
+ def ignore_ip(ip, reason: "internal_traffic", ttl: nil, **metadata)
103
+ apply(
104
+ entity_type: :ip,
105
+ entity_id: ip,
106
+ status: :untrusted_ignore,
107
+ reason: reason,
108
+ ttl: ttl,
109
+ metadata: metadata
110
+ )
111
+ end
112
+
113
+ # Trust a user (by internal user ID)
114
+ def trust_user(user_id, reason: "verified_user", **metadata)
115
+ apply(
116
+ entity_type: :user,
117
+ entity_id: user_id.to_s,
118
+ status: :trusted_verified,
119
+ reason: reason,
120
+ ttl: 0,
121
+ metadata: metadata
122
+ )
123
+ end
124
+
125
+ # Trust a session
126
+ def trust_session(session_id, reason: "verified_session", ttl: nil, **metadata)
127
+ apply(
128
+ entity_type: :session,
129
+ entity_id: session_id,
130
+ status: :trusted_verified,
131
+ reason: reason,
132
+ ttl: ttl || (24 * 3600), # Default 24 hours for sessions
133
+ metadata: metadata
134
+ )
135
+ end
136
+
137
+ # ========================================================================
138
+ # LOOKUP METHODS (O(1) Redis hash lookups)
139
+ # ========================================================================
140
+
141
+ # Lookup the current reputation status for an entity
142
+ # This is an O(1) Redis HGETALL operation
143
+ #
144
+ # @param entity_type [Symbol] Type of entity (:ip, :asn, :ja4, :session, :user)
145
+ # @param entity_id [String] The entity identifier
146
+ # @return [Hash, nil] Reputation data or nil if not found/expired/error
147
+ #
148
+ # @example Check IP reputation
149
+ # rep = Reputable::Reputation.lookup(:ip, "203.0.113.45")
150
+ # if rep && rep[:status] == "trusted_verified"
151
+ # # Allow through
152
+ # end
153
+ def lookup(entity_type, entity_id)
154
+ return nil unless Reputable.enabled?
155
+ return nil unless valid_entity_type?(entity_type)
156
+
157
+ key = "reputation:#{entity_type}:#{entity_id}"
158
+
159
+ data = Connection.safe_with(default: nil, context: "reputation_lookup") do |redis|
160
+ redis.hgetall(key)
161
+ end
162
+
163
+ return nil if data.nil? || data.empty?
164
+
165
+ # Check if expired (Redis TTL should handle this, but double-check)
166
+ expires_at = data["expires_at"].to_i
167
+ if expires_at > 0 && expires_at < (Time.now.to_f * 1000).to_i
168
+ return nil
169
+ end
170
+
171
+ {
172
+ status: data["status"],
173
+ reason: data["reason"],
174
+ source: data["source"],
175
+ updated_at: data["updated_at"].to_i,
176
+ expires_at: expires_at,
177
+ metadata: safe_parse_json(data["metadata"])
178
+ }
179
+ rescue StandardError => e
180
+ Reputable.logger&.debug("Reputable lookup error: #{e.class} - #{e.message}")
181
+ nil
182
+ end
183
+
184
+ # Quick lookup for IP reputation status
185
+ # Returns just the status string (or nil)
186
+ #
187
+ # @param ip [String] IP address
188
+ # @return [String, nil] Status string or nil
189
+ #
190
+ # @example
191
+ # status = Reputable::Reputation.lookup_ip("203.0.113.45")
192
+ # # => "trusted_verified" or nil
193
+ def lookup_ip(ip)
194
+ result = lookup(:ip, ip)
195
+ result&.dig(:status)
196
+ end
197
+
198
+ # Check if an IP is trusted (any trusted_* status)
199
+ #
200
+ # @param ip [String] IP address
201
+ # @return [Boolean]
202
+ #
203
+ # @example
204
+ # if Reputable::Reputation.trusted_ip?("203.0.113.45")
205
+ # # Skip CAPTCHA
206
+ # end
207
+ def trusted_ip?(ip)
208
+ status = lookup_ip(ip)
209
+ status&.start_with?("trusted")
210
+ end
211
+
212
+ # Check if an IP should be blocked
213
+ #
214
+ # @param ip [String] IP address
215
+ # @return [Boolean]
216
+ def blocked_ip?(ip)
217
+ lookup_ip(ip) == "untrusted_block"
218
+ end
219
+
220
+ # Check if an IP should be challenged
221
+ #
222
+ # @param ip [String] IP address
223
+ # @return [Boolean]
224
+ def challenged_ip?(ip)
225
+ lookup_ip(ip) == "untrusted_challenge"
226
+ end
227
+
228
+ # Lookup user reputation
229
+ #
230
+ # @param user_id [String, Integer] User ID
231
+ # @return [String, nil] Status string or nil
232
+ def lookup_user(user_id)
233
+ result = lookup(:user, user_id.to_s)
234
+ result&.dig(:status)
235
+ end
236
+
237
+ # Check if a user is trusted
238
+ #
239
+ # @param user_id [String, Integer] User ID
240
+ # @return [Boolean]
241
+ def trusted_user?(user_id)
242
+ status = lookup_user(user_id)
243
+ status&.start_with?("trusted")
244
+ end
245
+
246
+ # Lookup session reputation
247
+ #
248
+ # @param session_id [String] Session ID
249
+ # @return [String, nil] Status string or nil
250
+ def lookup_session(session_id)
251
+ result = lookup(:session, session_id)
252
+ result&.dig(:status)
253
+ end
254
+
255
+ # Check if a session is trusted
256
+ #
257
+ # @param session_id [String] Session ID
258
+ # @return [Boolean]
259
+ def trusted_session?(session_id)
260
+ status = lookup_session(session_id)
261
+ status&.start_with?("trusted")
262
+ end
263
+
264
+ private
265
+
266
+ def valid_entity_type?(entity_type)
267
+ VALID_ENTITY_TYPES.include?(entity_type.to_sym)
268
+ end
269
+
270
+ def valid_status?(status)
271
+ VALID_STATUSES.include?(status.to_sym)
272
+ end
273
+
274
+ def safe_parse_json(str)
275
+ return nil if str.nil? || str.empty?
276
+ JSON.parse(str)
277
+ rescue JSON::ParserError
278
+ nil
279
+ end
280
+
281
+ def build_reputation_entry(entity_type, entity_id, status, options)
282
+ ttl = options[:ttl]
283
+ ttl = Reputable.configuration.default_ttl_for(status) if ttl.nil?
284
+
285
+ {
286
+ ts: (Time.now.to_f * 1000).to_i,
287
+ entity_type: entity_type.to_s,
288
+ entity_id: entity_id.to_s,
289
+ status: status.to_s,
290
+ reason: options[:reason] || "manual",
291
+ ttl: ttl,
292
+ metadata: options[:metadata]
293
+ }.compact
294
+ end
295
+
296
+ def push_to_buffer(entry)
297
+ key = Reputable.configuration.reputation_buffer_key
298
+
299
+ Connection.safe_with(default: false, context: "reputation_push") do |redis|
300
+ redis.lpush(key, entry.to_json)
301
+ true
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Reputable
6
+ # Request tracking functionality
7
+ #
8
+ # RESILIENCE: All tracking methods are designed to fail silently.
9
+ # - Returns false on any failure
10
+ # - Never raises exceptions
11
+ # - Async mode runs in separate thread
12
+ module Tracker
13
+ class << self
14
+ # Track a request for behavioral analysis
15
+ #
16
+ # @param ip [String] Client IP address (required)
17
+ # @param path [String] Request path (required)
18
+ # @param options [Hash] Additional options
19
+ # @option options [String] :query Query string
20
+ # @option options [String] :method HTTP method (GET, POST, etc.)
21
+ # @option options [String] :session_id Session identifier
22
+ # @option options [Boolean] :session_present Whether a session cookie exists
23
+ # @option options [String] :user_id Internal user ID (for logged-in users)
24
+ # @option options [String] :fingerprint Browser fingerprint hash
25
+ # @option options [String] :user_agent User-Agent header
26
+ # @option options [String] :referer Referer header
27
+ # @option options [String] :country Country code (ISO 3166-1 alpha-2)
28
+ # @option options [Array<String>] :tags Custom classification tags
29
+ # @option options [Hash] :metadata Additional metadata
30
+ # @return [Boolean] true if successfully pushed to buffer, false otherwise
31
+ #
32
+ # @example Basic usage
33
+ # Reputable::Tracker.track_request(
34
+ # ip: "203.0.113.45",
35
+ # path: "/products/123"
36
+ # )
37
+ #
38
+ # @example Full usage
39
+ # Reputable::Tracker.track_request(
40
+ # ip: request.ip,
41
+ # path: request.path,
42
+ # query: request.query_string,
43
+ # method: request.request_method,
44
+ # session_id: session.id,
45
+ # session_present: true,
46
+ # user_agent: request.user_agent,
47
+ # referer: request.referer,
48
+ # tags: ["ctx:page:product", "trust:channel:organic"]
49
+ # )
50
+ def track_request(ip:, path:, **options)
51
+ return false unless Reputable.enabled?
52
+
53
+ entry = build_request_entry(ip, path, options)
54
+ push_to_buffer(:request, entry)
55
+ rescue StandardError => e
56
+ Reputable.logger&.debug("Reputable track_request error: #{e.class} - #{e.message}")
57
+ false
58
+ end
59
+
60
+ # Track a request asynchronously (fire-and-forget)
61
+ # Uses a thread to avoid blocking the request
62
+ # @return [Boolean] Always returns true (async)
63
+ def track_request_async(ip:, path:, **options)
64
+ return true unless Reputable.enabled?
65
+
66
+ Thread.new do
67
+ track_request(ip: ip, path: path, **options)
68
+ rescue StandardError
69
+ # Silently ignore all errors in async thread
70
+ end
71
+ true
72
+ end
73
+
74
+ private
75
+
76
+ def build_request_entry(ip, path, options)
77
+ {
78
+ ts: (Time.now.to_f * 1000).to_i,
79
+ ip: ip,
80
+ path: path,
81
+ query: options[:query],
82
+ method: options[:method] || "GET",
83
+ session_id: options[:session_id],
84
+ session_present: options[:session_present],
85
+ user_id: options[:user_id],
86
+ fingerprint: options[:fingerprint],
87
+ user_agent: options[:user_agent],
88
+ referer: options[:referer],
89
+ country: options[:country],
90
+ tags: options[:tags] || [],
91
+ metadata: options[:metadata]
92
+ }.compact
93
+ end
94
+
95
+ def push_to_buffer(type, entry)
96
+ key = case type
97
+ when :request then Reputable.configuration.request_buffer_key
98
+ when :reputation then Reputable.configuration.reputation_buffer_key
99
+ else return false
100
+ end
101
+
102
+ Connection.safe_with(default: false, context: "track_request") do |redis|
103
+ redis.lpush(key, entry.to_json)
104
+ true
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reputable
4
+ VERSION = "0.1.0"
5
+ end
data/lib/reputable.rb ADDED
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reputable/version"
4
+ require_relative "reputable/configuration"
5
+ require_relative "reputable/connection"
6
+ require_relative "reputable/tracker"
7
+ require_relative "reputable/reputation"
8
+ require_relative "reputable/middleware"
9
+
10
+ # Optional Rails integration
11
+ begin
12
+ require_relative "reputable/rails"
13
+ rescue LoadError
14
+ # Rails not available, skip
15
+ end
16
+
17
+ # Reputable - Bot detection and reputation scoring client
18
+ #
19
+ # RESILIENCE: This gem is designed to fail silently and never break your app.
20
+ # - All methods return safe defaults (false/nil) on any error
21
+ # - Can be disabled via REPUTABLE_ENABLED=false
22
+ # - Circuit breaker prevents connection storms
23
+ # - All Redis timeouts are configurable via ENV
24
+ #
25
+ # @example Basic setup
26
+ # Reputable.configure do |config|
27
+ # config.redis_url = "rediss://user:pass@dragonfly.example.com:6379"
28
+ # config.tenant_id = "my-tenant"
29
+ # end
30
+ #
31
+ # @example Track a request
32
+ # Reputable.track_request(
33
+ # ip: "203.0.113.45",
34
+ # path: "/products/123",
35
+ # session_present: true
36
+ # )
37
+ #
38
+ # @example Apply reputation after payment
39
+ # Reputable.trust_ip("203.0.113.45", reason: "payment_completed")
40
+ #
41
+ # @example Disable completely via ENV
42
+ # # In your environment: REPUTABLE_ENABLED=false
43
+ #
44
+ module Reputable
45
+ class Error < StandardError; end
46
+ class ConfigurationError < Error; end
47
+ class ConnectionError < Error; end
48
+
49
+ class << self
50
+ attr_accessor :logger
51
+
52
+ def configuration
53
+ @configuration ||= Configuration.new
54
+ end
55
+
56
+ def configure
57
+ yield(configuration)
58
+ Connection.reset! # Reset pool on reconfiguration
59
+ end
60
+
61
+ def reset!
62
+ @configuration = nil
63
+ Connection.reset!
64
+ end
65
+
66
+ # Check if Reputable is enabled
67
+ # Can be disabled via REPUTABLE_ENABLED=false environment variable
68
+ # @return [Boolean]
69
+ def enabled?
70
+ configuration.enabled?
71
+ end
72
+
73
+ # Check if Reputable is disabled
74
+ # @return [Boolean]
75
+ def disabled?
76
+ !enabled?
77
+ end
78
+
79
+ # Delegate tracking methods to Tracker
80
+ def track_request(ip:, path:, **options)
81
+ Tracker.track_request(ip: ip, path: path, **options)
82
+ end
83
+
84
+ def track_request_async(ip:, path:, **options)
85
+ Tracker.track_request_async(ip: ip, path: path, **options)
86
+ end
87
+
88
+ # Delegate reputation methods to Reputation module
89
+ def apply_reputation(entity_type:, entity_id:, status:, **options)
90
+ Reputation.apply(entity_type: entity_type, entity_id: entity_id, status: status, **options)
91
+ end
92
+
93
+ def trust_ip(ip, reason: "manual_trust", **metadata)
94
+ Reputation.trust_ip(ip, reason: reason, **metadata)
95
+ end
96
+
97
+ def block_ip(ip, reason: "manual_block", **metadata)
98
+ Reputation.block_ip(ip, reason: reason, **metadata)
99
+ end
100
+
101
+ def challenge_ip(ip, reason: "suspicious_activity", **metadata)
102
+ Reputation.challenge_ip(ip, reason: reason, **metadata)
103
+ end
104
+
105
+ def ignore_ip(ip, reason: "internal_traffic", **metadata)
106
+ Reputation.ignore_ip(ip, reason: reason, **metadata)
107
+ end
108
+
109
+ def trust_user(user_id, reason: "verified_user", **metadata)
110
+ Reputation.trust_user(user_id, reason: reason, **metadata)
111
+ end
112
+
113
+ def trust_session(session_id, reason: "verified_session", **metadata)
114
+ Reputation.trust_session(session_id, reason: reason, **metadata)
115
+ end
116
+
117
+ # Delegate lookup methods to Reputation module (O(1) Redis lookups)
118
+ def lookup_reputation(entity_type, entity_id)
119
+ Reputation.lookup(entity_type, entity_id)
120
+ end
121
+
122
+ def lookup_ip(ip)
123
+ Reputation.lookup_ip(ip)
124
+ end
125
+
126
+ def trusted_ip?(ip)
127
+ Reputation.trusted_ip?(ip)
128
+ end
129
+
130
+ def blocked_ip?(ip)
131
+ Reputation.blocked_ip?(ip)
132
+ end
133
+
134
+ def challenged_ip?(ip)
135
+ Reputation.challenged_ip?(ip)
136
+ end
137
+
138
+ def lookup_user(user_id)
139
+ Reputation.lookup_user(user_id)
140
+ end
141
+
142
+ def trusted_user?(user_id)
143
+ Reputation.trusted_user?(user_id)
144
+ end
145
+
146
+ def lookup_session(session_id)
147
+ Reputation.lookup_session(session_id)
148
+ end
149
+
150
+ def trusted_session?(session_id)
151
+ Reputation.trusted_session?(session_id)
152
+ end
153
+ end
154
+ end