flagkit 1.0.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/lib/flagkit/client.rb +443 -0
- data/lib/flagkit/core/cache.rb +162 -0
- data/lib/flagkit/core/encrypted_cache.rb +227 -0
- data/lib/flagkit/core/event_persistence.rb +513 -0
- data/lib/flagkit/core/event_queue.rb +190 -0
- data/lib/flagkit/core/polling_manager.rb +112 -0
- data/lib/flagkit/core/streaming_manager.rb +469 -0
- data/lib/flagkit/error/error_code.rb +98 -0
- data/lib/flagkit/error/error_sanitizer.rb +48 -0
- data/lib/flagkit/error/flagkit_error.rb +95 -0
- data/lib/flagkit/http/circuit_breaker.rb +145 -0
- data/lib/flagkit/http/http_client.rb +312 -0
- data/lib/flagkit/options.rb +222 -0
- data/lib/flagkit/types/evaluation_context.rb +121 -0
- data/lib/flagkit/types/evaluation_reason.rb +22 -0
- data/lib/flagkit/types/evaluation_result.rb +77 -0
- data/lib/flagkit/types/flag_state.rb +100 -0
- data/lib/flagkit/types/flag_type.rb +46 -0
- data/lib/flagkit/utils/security.rb +528 -0
- data/lib/flagkit/utils/version.rb +116 -0
- data/lib/flagkit/version.rb +5 -0
- data/lib/flagkit.rb +166 -0
- metadata +200 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module FlagKit
|
|
8
|
+
module Utils
|
|
9
|
+
# Security utilities for FlagKit SDK.
|
|
10
|
+
#
|
|
11
|
+
# Provides methods for detecting potential PII in data,
|
|
12
|
+
# validating API key usage, request signing with HMAC-SHA256,
|
|
13
|
+
# and cache encryption with AES-256-GCM.
|
|
14
|
+
module Security
|
|
15
|
+
# Common PII field patterns (case-insensitive)
|
|
16
|
+
PII_PATTERNS = %w[
|
|
17
|
+
email
|
|
18
|
+
phone
|
|
19
|
+
telephone
|
|
20
|
+
mobile
|
|
21
|
+
ssn
|
|
22
|
+
social_security
|
|
23
|
+
socialSecurity
|
|
24
|
+
credit_card
|
|
25
|
+
creditCard
|
|
26
|
+
card_number
|
|
27
|
+
cardNumber
|
|
28
|
+
cvv
|
|
29
|
+
password
|
|
30
|
+
passwd
|
|
31
|
+
secret
|
|
32
|
+
token
|
|
33
|
+
api_key
|
|
34
|
+
apiKey
|
|
35
|
+
private_key
|
|
36
|
+
privateKey
|
|
37
|
+
access_token
|
|
38
|
+
accessToken
|
|
39
|
+
refresh_token
|
|
40
|
+
refreshToken
|
|
41
|
+
auth_token
|
|
42
|
+
authToken
|
|
43
|
+
address
|
|
44
|
+
street
|
|
45
|
+
zip_code
|
|
46
|
+
zipCode
|
|
47
|
+
postal_code
|
|
48
|
+
postalCode
|
|
49
|
+
date_of_birth
|
|
50
|
+
dateOfBirth
|
|
51
|
+
dob
|
|
52
|
+
birth_date
|
|
53
|
+
birthDate
|
|
54
|
+
passport
|
|
55
|
+
driver_license
|
|
56
|
+
driverLicense
|
|
57
|
+
national_id
|
|
58
|
+
nationalId
|
|
59
|
+
bank_account
|
|
60
|
+
bankAccount
|
|
61
|
+
routing_number
|
|
62
|
+
routingNumber
|
|
63
|
+
iban
|
|
64
|
+
swift
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# Checks if a field name potentially contains PII.
|
|
69
|
+
#
|
|
70
|
+
# @param field_name [String] The field name to check
|
|
71
|
+
# @return [Boolean] true if the field name matches a PII pattern
|
|
72
|
+
#
|
|
73
|
+
# @example
|
|
74
|
+
# Security.potential_pii_field?("email") # => true
|
|
75
|
+
# Security.potential_pii_field?("userEmail") # => true
|
|
76
|
+
# Security.potential_pii_field?("plan") # => false
|
|
77
|
+
def potential_pii_field?(field_name)
|
|
78
|
+
return false if field_name.nil?
|
|
79
|
+
|
|
80
|
+
lower_name = field_name.to_s.downcase
|
|
81
|
+
patterns = config.additional_pii_patterns + PII_PATTERNS
|
|
82
|
+
patterns.any? { |pattern| lower_name.include?(pattern.downcase) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Detects potential PII in an object and returns the field paths.
|
|
86
|
+
#
|
|
87
|
+
# @param data [Hash] The data to scan for PII
|
|
88
|
+
# @param prefix [String] Optional prefix for nested field paths
|
|
89
|
+
# @return [Array<String>] Array of field paths that may contain PII
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# data = { email: "user@example.com", plan: "premium" }
|
|
93
|
+
# Security.detect_potential_pii(data) # => ["email"]
|
|
94
|
+
#
|
|
95
|
+
# @example Nested data
|
|
96
|
+
# data = { user: { email: "test@example.com" } }
|
|
97
|
+
# Security.detect_potential_pii(data) # => ["user.email"]
|
|
98
|
+
def detect_potential_pii(data, prefix = "")
|
|
99
|
+
return [] unless data.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
pii_fields = []
|
|
102
|
+
|
|
103
|
+
data.each do |key, value|
|
|
104
|
+
key_str = key.to_s
|
|
105
|
+
full_path = prefix.empty? ? key_str : "#{prefix}.#{key_str}"
|
|
106
|
+
|
|
107
|
+
pii_fields << full_path if potential_pii_field?(key_str)
|
|
108
|
+
|
|
109
|
+
# Recursively check nested hashes
|
|
110
|
+
if value.is_a?(Hash)
|
|
111
|
+
nested_pii = detect_potential_pii(value, full_path)
|
|
112
|
+
pii_fields.concat(nested_pii)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
pii_fields
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Warns about potential PII in data.
|
|
120
|
+
#
|
|
121
|
+
# @param data [Hash, nil] The data to check
|
|
122
|
+
# @param data_type [String, Symbol] The type of data ("context" or "event")
|
|
123
|
+
# @param logger [Object, nil] Optional logger instance
|
|
124
|
+
# @param strict_mode [Boolean] When true, raises SecurityError instead of warning
|
|
125
|
+
# @param has_private_attributes [Boolean] Whether private_attributes are configured
|
|
126
|
+
# @return [void]
|
|
127
|
+
# @raise [FlagKit::SecurityError] In strict mode when PII detected without private_attributes
|
|
128
|
+
#
|
|
129
|
+
# @example
|
|
130
|
+
# Security.warn_if_potential_pii({ email: "test@example.com" }, :context, logger)
|
|
131
|
+
def warn_if_potential_pii(data, data_type, logger = nil, strict_mode: false, has_private_attributes: false)
|
|
132
|
+
return if data.nil?
|
|
133
|
+
|
|
134
|
+
pii_fields = detect_potential_pii(data)
|
|
135
|
+
return if pii_fields.empty?
|
|
136
|
+
|
|
137
|
+
# In strict mode, raise error if PII detected without private_attributes
|
|
138
|
+
if strict_mode && !has_private_attributes
|
|
139
|
+
raise FlagKit::SecurityError.new(
|
|
140
|
+
FlagKit::ErrorCode::SECURITY_PII_DETECTED,
|
|
141
|
+
"Potential PII detected in #{data_type} data: #{pii_fields.join(', ')}. " \
|
|
142
|
+
"In strict_pii_mode, you must configure private_attributes for PII fields or remove the data."
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
return unless config.warn_on_potential_pii
|
|
147
|
+
return if logger.nil?
|
|
148
|
+
|
|
149
|
+
advice = case data_type.to_s
|
|
150
|
+
when "context"
|
|
151
|
+
"Consider adding these to privateAttributes."
|
|
152
|
+
else
|
|
153
|
+
"Consider removing sensitive data from events."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
message = "[FlagKit Security] Potential PII detected in #{data_type} data: " \
|
|
157
|
+
"#{pii_fields.join(', ')}. #{advice}"
|
|
158
|
+
|
|
159
|
+
logger.warn(message) if logger.respond_to?(:warn)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Checks if an API key is a server key.
|
|
163
|
+
#
|
|
164
|
+
# @param api_key [String] The API key to check
|
|
165
|
+
# @return [Boolean] true if the key starts with "srv_"
|
|
166
|
+
#
|
|
167
|
+
# @example
|
|
168
|
+
# Security.server_key?("srv_abc123") # => true
|
|
169
|
+
# Security.server_key?("sdk_abc123") # => false
|
|
170
|
+
def server_key?(api_key)
|
|
171
|
+
return false if api_key.nil?
|
|
172
|
+
|
|
173
|
+
api_key.to_s.start_with?("srv_")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Checks if an API key is a client/SDK key.
|
|
177
|
+
#
|
|
178
|
+
# @param api_key [String] The API key to check
|
|
179
|
+
# @return [Boolean] true if the key starts with "sdk_" or "cli_"
|
|
180
|
+
#
|
|
181
|
+
# @example
|
|
182
|
+
# Security.client_key?("sdk_abc123") # => true
|
|
183
|
+
# Security.client_key?("cli_abc123") # => true
|
|
184
|
+
# Security.client_key?("srv_abc123") # => false
|
|
185
|
+
def client_key?(api_key)
|
|
186
|
+
return false if api_key.nil?
|
|
187
|
+
|
|
188
|
+
key_str = api_key.to_s
|
|
189
|
+
key_str.start_with?("sdk_") || key_str.start_with?("cli_")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Warns if a server key is used in a browser-like environment.
|
|
193
|
+
#
|
|
194
|
+
# In Ruby, this primarily applies to environments where JavaScript
|
|
195
|
+
# is generated or where code might be exposed to clients (e.g., Rails
|
|
196
|
+
# views with JavaScript, Opal, or similar).
|
|
197
|
+
#
|
|
198
|
+
# @param api_key [String] The API key to check
|
|
199
|
+
# @param logger [Object, nil] Optional logger instance
|
|
200
|
+
# @return [void]
|
|
201
|
+
#
|
|
202
|
+
# @example
|
|
203
|
+
# Security.warn_if_server_key_in_browser("srv_abc123", logger)
|
|
204
|
+
def warn_if_server_key_in_browser(api_key, logger = nil)
|
|
205
|
+
return unless config.warn_on_server_key_in_browser
|
|
206
|
+
return unless browser_like_environment? && server_key?(api_key)
|
|
207
|
+
|
|
208
|
+
message = "[FlagKit Security] WARNING: Server keys (srv_) should not be used in browser environments. " \
|
|
209
|
+
"This exposes your server key in client-side code, which is a security risk. " \
|
|
210
|
+
"Use SDK keys (sdk_) for client-side applications instead. " \
|
|
211
|
+
"See: https://docs.flagkit.dev/sdk/security#api-keys"
|
|
212
|
+
|
|
213
|
+
# Always output to stderr for visibility
|
|
214
|
+
warn message
|
|
215
|
+
|
|
216
|
+
# Also log through the SDK logger if available
|
|
217
|
+
logger.warn(message) if logger.respond_to?(:warn)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Returns the current security configuration.
|
|
221
|
+
#
|
|
222
|
+
# @return [SecurityConfig] The current configuration
|
|
223
|
+
def config
|
|
224
|
+
@config ||= SecurityConfig.new
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Configures security settings.
|
|
228
|
+
#
|
|
229
|
+
# @yield [SecurityConfig] The configuration object to modify
|
|
230
|
+
# @return [SecurityConfig] The updated configuration
|
|
231
|
+
#
|
|
232
|
+
# @example
|
|
233
|
+
# Security.configure do |config|
|
|
234
|
+
# config.warn_on_potential_pii = true
|
|
235
|
+
# config.additional_pii_patterns = ["custom_field"]
|
|
236
|
+
# end
|
|
237
|
+
def configure
|
|
238
|
+
yield config if block_given?
|
|
239
|
+
config
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Resets configuration to defaults.
|
|
243
|
+
#
|
|
244
|
+
# @return [SecurityConfig] A new default configuration
|
|
245
|
+
def reset_config!
|
|
246
|
+
@config = SecurityConfig.new
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Gets the first 8 characters of an API key for identification.
|
|
250
|
+
# This is safe to expose as it doesn't reveal the full key.
|
|
251
|
+
#
|
|
252
|
+
# @param api_key [String] The API key
|
|
253
|
+
# @return [String] The key identifier (first 8 characters)
|
|
254
|
+
#
|
|
255
|
+
# @example
|
|
256
|
+
# Security.get_key_id("sdk_abc123xyz") # => "sdk_abc1"
|
|
257
|
+
def get_key_id(api_key)
|
|
258
|
+
return "" if api_key.nil?
|
|
259
|
+
|
|
260
|
+
api_key.to_s[0, 8]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Generates an HMAC-SHA256 signature.
|
|
264
|
+
#
|
|
265
|
+
# @param message [String] The message to sign
|
|
266
|
+
# @param key [String] The signing key
|
|
267
|
+
# @return [String] The hex-encoded signature
|
|
268
|
+
#
|
|
269
|
+
# @example
|
|
270
|
+
# signature = Security.generate_hmac_sha256("message", "secret")
|
|
271
|
+
def generate_hmac_sha256(message, key)
|
|
272
|
+
OpenSSL::HMAC.hexdigest("SHA256", key, message)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Creates a request signature for POST request bodies.
|
|
276
|
+
# Format: timestamp.body
|
|
277
|
+
#
|
|
278
|
+
# @param body [String] The request body (JSON string)
|
|
279
|
+
# @param api_key [String] The API key for signing
|
|
280
|
+
# @param timestamp [Integer, nil] Optional timestamp in milliseconds
|
|
281
|
+
# @return [Hash] Hash containing :signature, :timestamp, and :key_id
|
|
282
|
+
#
|
|
283
|
+
# @example
|
|
284
|
+
# result = Security.create_request_signature('{"key":"value"}', "sdk_abc123")
|
|
285
|
+
# # => { signature: "abc123...", timestamp: 1234567890, key_id: "sdk_abc1" }
|
|
286
|
+
def create_request_signature(body, api_key, timestamp: nil)
|
|
287
|
+
ts = timestamp || (Time.now.to_f * 1000).to_i
|
|
288
|
+
message = "#{ts}.#{body}"
|
|
289
|
+
signature = generate_hmac_sha256(message, api_key)
|
|
290
|
+
|
|
291
|
+
{
|
|
292
|
+
signature: signature,
|
|
293
|
+
timestamp: ts,
|
|
294
|
+
key_id: get_key_id(api_key)
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Signs a payload with HMAC-SHA256.
|
|
299
|
+
#
|
|
300
|
+
# @param data [Object] The data to sign (will be JSON encoded)
|
|
301
|
+
# @param api_key [String] The API key for signing
|
|
302
|
+
# @param timestamp [Integer, nil] Optional timestamp in milliseconds
|
|
303
|
+
# @return [Hash] Signed payload with :data, :signature, :timestamp, :key_id
|
|
304
|
+
#
|
|
305
|
+
# @example
|
|
306
|
+
# signed = Security.sign_payload({ events: [] }, "sdk_abc123")
|
|
307
|
+
def sign_payload(data, api_key, timestamp: nil)
|
|
308
|
+
ts = timestamp || (Time.now.to_f * 1000).to_i
|
|
309
|
+
payload = JSON.generate(data)
|
|
310
|
+
message = "#{ts}.#{payload}"
|
|
311
|
+
signature = generate_hmac_sha256(message, api_key)
|
|
312
|
+
|
|
313
|
+
{
|
|
314
|
+
data: data,
|
|
315
|
+
signature: signature,
|
|
316
|
+
timestamp: ts,
|
|
317
|
+
key_id: get_key_id(api_key)
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Verifies a signed payload.
|
|
322
|
+
#
|
|
323
|
+
# @param signed_payload [Hash] The signed payload to verify
|
|
324
|
+
# @param api_key [String] The API key for verification
|
|
325
|
+
# @param max_age_ms [Integer] Maximum age in milliseconds (default: 5 minutes)
|
|
326
|
+
# @return [Boolean] true if the signature is valid
|
|
327
|
+
#
|
|
328
|
+
# @example
|
|
329
|
+
# Security.verify_signed_payload(signed, "sdk_abc123")
|
|
330
|
+
def verify_signed_payload(signed_payload, api_key, max_age_ms: 300_000)
|
|
331
|
+
# Check timestamp age
|
|
332
|
+
age = (Time.now.to_f * 1000).to_i - signed_payload[:timestamp]
|
|
333
|
+
return false if age > max_age_ms || age.negative?
|
|
334
|
+
|
|
335
|
+
# Verify key ID matches
|
|
336
|
+
return false if signed_payload[:key_id] != get_key_id(api_key)
|
|
337
|
+
|
|
338
|
+
# Verify signature
|
|
339
|
+
payload = JSON.generate(signed_payload[:data])
|
|
340
|
+
message = "#{signed_payload[:timestamp]}.#{payload}"
|
|
341
|
+
expected_signature = generate_hmac_sha256(message, api_key)
|
|
342
|
+
|
|
343
|
+
signed_payload[:signature] == expected_signature
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Canonicalizes an object by sorting keys recursively.
|
|
347
|
+
# This ensures consistent JSON output for signature verification.
|
|
348
|
+
#
|
|
349
|
+
# @param obj [Object] The object to canonicalize
|
|
350
|
+
# @return [String] Canonical JSON string representation
|
|
351
|
+
#
|
|
352
|
+
# @example
|
|
353
|
+
# Security.canonicalize_object({ b: 2, a: 1 }) # => '{"a":1,"b":2}'
|
|
354
|
+
def canonicalize_object(obj)
|
|
355
|
+
JSON.generate(deep_sort_keys(obj))
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Verifies an HMAC-SHA256 signature for bootstrap data.
|
|
359
|
+
#
|
|
360
|
+
# @param bootstrap [Hash] The bootstrap data with :flags, :signature, :timestamp
|
|
361
|
+
# @param api_key [String] The API key for verification
|
|
362
|
+
# @param max_age_ms [Integer] Maximum age in milliseconds (default: 24 hours)
|
|
363
|
+
# @return [Hash] Result hash with :valid (Boolean) and :error (String or nil)
|
|
364
|
+
def verify_bootstrap_signature(bootstrap, api_key, max_age_ms: 86_400_000)
|
|
365
|
+
bootstrap = normalize_keys(bootstrap)
|
|
366
|
+
|
|
367
|
+
error = validate_bootstrap_fields(bootstrap) || validate_bootstrap_timestamp(bootstrap, max_age_ms)
|
|
368
|
+
return { valid: false, error: error } if error
|
|
369
|
+
|
|
370
|
+
verify_bootstrap_hmac(bootstrap, api_key)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
private
|
|
374
|
+
|
|
375
|
+
# Validates required bootstrap fields are present.
|
|
376
|
+
def validate_bootstrap_fields(bootstrap)
|
|
377
|
+
return "Missing signature" unless bootstrap[:signature]
|
|
378
|
+
return "Missing timestamp" unless bootstrap[:timestamp]
|
|
379
|
+
return "Missing flags" unless bootstrap[:flags]
|
|
380
|
+
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Validates bootstrap timestamp is within acceptable age range.
|
|
385
|
+
def validate_bootstrap_timestamp(bootstrap, max_age_ms)
|
|
386
|
+
timestamp = bootstrap[:timestamp].to_i
|
|
387
|
+
age = (Time.now.to_f * 1000).to_i - timestamp
|
|
388
|
+
|
|
389
|
+
return "Bootstrap data expired (age: #{age}ms, max: #{max_age_ms}ms)" if age > max_age_ms
|
|
390
|
+
return "Bootstrap timestamp is in the future" if age.negative?
|
|
391
|
+
|
|
392
|
+
nil
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Computes and verifies the HMAC signature for bootstrap data.
|
|
396
|
+
def verify_bootstrap_hmac(bootstrap, api_key)
|
|
397
|
+
canonical_flags = canonicalize_object(bootstrap[:flags])
|
|
398
|
+
message = "#{bootstrap[:timestamp]}.#{canonical_flags}"
|
|
399
|
+
expected = generate_hmac_sha256(message, api_key)
|
|
400
|
+
|
|
401
|
+
if secure_compare(expected, bootstrap[:signature].to_s)
|
|
402
|
+
{ valid: true, error: nil }
|
|
403
|
+
else
|
|
404
|
+
{ valid: false, error: "Invalid signature" }
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Performs constant-time string comparison to prevent timing attacks.
|
|
409
|
+
#
|
|
410
|
+
# @param a [String] First string
|
|
411
|
+
# @param b [String] Second string
|
|
412
|
+
# @return [Boolean] true if strings are equal
|
|
413
|
+
def secure_compare(expected, actual)
|
|
414
|
+
return false unless expected.bytesize == actual.bytesize
|
|
415
|
+
|
|
416
|
+
# Use OpenSSL's fixed_length_secure_compare for constant-time comparison
|
|
417
|
+
OpenSSL.fixed_length_secure_compare(expected, actual)
|
|
418
|
+
rescue NoMethodError
|
|
419
|
+
# Fallback for older Ruby versions without fixed_length_secure_compare
|
|
420
|
+
# This is a constant-time comparison implementation
|
|
421
|
+
l = expected.unpack("C*")
|
|
422
|
+
r = actual.unpack("C*")
|
|
423
|
+
result = 0
|
|
424
|
+
l.zip(r) { |x, y| result |= x ^ y }
|
|
425
|
+
result.zero?
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Recursively sorts hash keys for canonical representation.
|
|
429
|
+
#
|
|
430
|
+
# @param obj [Object] The object to process
|
|
431
|
+
# @return [Object] Object with sorted keys
|
|
432
|
+
def deep_sort_keys(obj)
|
|
433
|
+
case obj
|
|
434
|
+
when Hash
|
|
435
|
+
obj.keys.sort_by(&:to_s).each_with_object({}) do |key, sorted|
|
|
436
|
+
sorted[key] = deep_sort_keys(obj[key])
|
|
437
|
+
end
|
|
438
|
+
when Array
|
|
439
|
+
obj.map { |item| deep_sort_keys(item) }
|
|
440
|
+
else
|
|
441
|
+
obj
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Normalizes hash keys to symbols.
|
|
446
|
+
#
|
|
447
|
+
# @param hash [Hash] The hash to normalize
|
|
448
|
+
# @return [Hash] Hash with symbol keys
|
|
449
|
+
def normalize_keys(hash)
|
|
450
|
+
return hash unless hash.is_a?(Hash)
|
|
451
|
+
|
|
452
|
+
hash.transform_keys { |key| key.to_sym rescue key }
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Checks if we're in a browser-like environment.
|
|
456
|
+
#
|
|
457
|
+
# For Ruby, this checks for common indicators that code might
|
|
458
|
+
# be running in or generating client-side JavaScript:
|
|
459
|
+
# - Opal (Ruby to JavaScript compiler)
|
|
460
|
+
# - Browser gem
|
|
461
|
+
# - Environment variables indicating client-side context
|
|
462
|
+
#
|
|
463
|
+
# @return [Boolean] true if in a browser-like environment
|
|
464
|
+
def browser_like_environment?
|
|
465
|
+
# Check for Opal (Ruby compiled to JavaScript running in browser)
|
|
466
|
+
return true if defined?(RUBY_ENGINE) && RUBY_ENGINE == "opal"
|
|
467
|
+
|
|
468
|
+
# Check for environment variable that indicates browser context
|
|
469
|
+
return true if ENV["FLAGKIT_BROWSER_CONTEXT"] == "true"
|
|
470
|
+
|
|
471
|
+
false
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Security configuration options.
|
|
477
|
+
#
|
|
478
|
+
# @example
|
|
479
|
+
# config = SecurityConfig.new
|
|
480
|
+
# config.warn_on_potential_pii = true
|
|
481
|
+
# config.additional_pii_patterns = ["employee_id"]
|
|
482
|
+
class SecurityConfig
|
|
483
|
+
# @return [Boolean] Whether to warn about potential PII in context/events.
|
|
484
|
+
# Default: true in development, false in production
|
|
485
|
+
attr_accessor :warn_on_potential_pii
|
|
486
|
+
|
|
487
|
+
# @return [Boolean] Whether to warn when server keys are used in browser.
|
|
488
|
+
# Default: true
|
|
489
|
+
attr_accessor :warn_on_server_key_in_browser
|
|
490
|
+
|
|
491
|
+
# @return [Array<String>] Custom PII patterns to detect in addition to defaults
|
|
492
|
+
attr_accessor :additional_pii_patterns
|
|
493
|
+
|
|
494
|
+
# Creates a new SecurityConfig with default values.
|
|
495
|
+
#
|
|
496
|
+
# @param warn_on_potential_pii [Boolean] Enable PII warnings
|
|
497
|
+
# @param warn_on_server_key_in_browser [Boolean] Enable server key warnings
|
|
498
|
+
# @param additional_pii_patterns [Array<String>] Additional PII patterns
|
|
499
|
+
def initialize(
|
|
500
|
+
warn_on_potential_pii: default_warn_on_pii,
|
|
501
|
+
warn_on_server_key_in_browser: true,
|
|
502
|
+
additional_pii_patterns: []
|
|
503
|
+
)
|
|
504
|
+
@warn_on_potential_pii = warn_on_potential_pii
|
|
505
|
+
@warn_on_server_key_in_browser = warn_on_server_key_in_browser
|
|
506
|
+
@additional_pii_patterns = additional_pii_patterns
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Returns a hash representation of the configuration.
|
|
510
|
+
#
|
|
511
|
+
# @return [Hash] The configuration as a hash
|
|
512
|
+
def to_h
|
|
513
|
+
{
|
|
514
|
+
warn_on_potential_pii: warn_on_potential_pii,
|
|
515
|
+
warn_on_server_key_in_browser: warn_on_server_key_in_browser,
|
|
516
|
+
additional_pii_patterns: additional_pii_patterns
|
|
517
|
+
}
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
private
|
|
521
|
+
|
|
522
|
+
def default_warn_on_pii
|
|
523
|
+
env = ENV.fetch("RUBY_ENV", ENV.fetch("RAILS_ENV", ENV.fetch("RACK_ENV", "development")))
|
|
524
|
+
env != "production"
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlagKit
|
|
4
|
+
module Utils
|
|
5
|
+
# Semantic version comparison utilities for SDK version metadata handling.
|
|
6
|
+
#
|
|
7
|
+
# These utilities are used to compare the current SDK version against
|
|
8
|
+
# server-provided version requirements (min, recommended, latest).
|
|
9
|
+
module Version
|
|
10
|
+
# Maximum allowed value for version components (defensive limit).
|
|
11
|
+
MAX_VERSION_COMPONENT = 999_999_999
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Parse a semantic version string into numeric components.
|
|
15
|
+
# Returns nil if the version is not a valid semver.
|
|
16
|
+
#
|
|
17
|
+
# @param version [String] The version string to parse
|
|
18
|
+
# @return [Hash, nil] Hash with :major, :minor, :patch keys, or nil if invalid
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# Version.parse("1.2.3") # => { major: 1, minor: 2, patch: 3 }
|
|
22
|
+
# Version.parse("v1.2.3") # => { major: 1, minor: 2, patch: 3 }
|
|
23
|
+
# Version.parse("1.2.3-rc1") # => { major: 1, minor: 2, patch: 3 }
|
|
24
|
+
# Version.parse("invalid") # => nil
|
|
25
|
+
def parse(version)
|
|
26
|
+
return nil if version.nil? || !version.is_a?(String)
|
|
27
|
+
|
|
28
|
+
# Trim whitespace
|
|
29
|
+
trimmed = version.strip
|
|
30
|
+
return nil if trimmed.empty?
|
|
31
|
+
|
|
32
|
+
# Strip leading 'v' or 'V' if present
|
|
33
|
+
normalized = trimmed.start_with?("v", "V") ? trimmed[1..] : trimmed
|
|
34
|
+
|
|
35
|
+
# Match semver pattern (allows pre-release suffix but ignores it for comparison)
|
|
36
|
+
match = normalized.match(/^(\d+)\.(\d+)\.(\d+)/)
|
|
37
|
+
return nil unless match
|
|
38
|
+
|
|
39
|
+
major = match[1].to_i
|
|
40
|
+
minor = match[2].to_i
|
|
41
|
+
patch = match[3].to_i
|
|
42
|
+
|
|
43
|
+
# Validate components are within reasonable bounds
|
|
44
|
+
return nil if major.negative? || major > MAX_VERSION_COMPONENT
|
|
45
|
+
return nil if minor.negative? || minor > MAX_VERSION_COMPONENT
|
|
46
|
+
return nil if patch.negative? || patch > MAX_VERSION_COMPONENT
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
major: major,
|
|
50
|
+
minor: minor,
|
|
51
|
+
patch: patch
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Compare two semantic versions.
|
|
56
|
+
#
|
|
57
|
+
# @param version_a [String] First version
|
|
58
|
+
# @param version_b [String] Second version
|
|
59
|
+
# @return [Integer] Negative if a < b, 0 if a == b, positive if a > b.
|
|
60
|
+
# Returns 0 if either version is invalid.
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# Version.compare("1.0.0", "2.0.0") # => -1
|
|
64
|
+
# Version.compare("2.0.0", "1.0.0") # => 1
|
|
65
|
+
# Version.compare("1.0.0", "1.0.0") # => 0
|
|
66
|
+
def compare(version_a, version_b)
|
|
67
|
+
parsed_a = parse(version_a)
|
|
68
|
+
parsed_b = parse(version_b)
|
|
69
|
+
|
|
70
|
+
return 0 if parsed_a.nil? || parsed_b.nil?
|
|
71
|
+
|
|
72
|
+
# Compare major
|
|
73
|
+
if parsed_a[:major] != parsed_b[:major]
|
|
74
|
+
return parsed_a[:major] - parsed_b[:major]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Compare minor
|
|
78
|
+
if parsed_a[:minor] != parsed_b[:minor]
|
|
79
|
+
return parsed_a[:minor] - parsed_b[:minor]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Compare patch
|
|
83
|
+
parsed_a[:patch] - parsed_b[:patch]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check if version a is less than version b.
|
|
87
|
+
#
|
|
88
|
+
# @param version_a [String] First version
|
|
89
|
+
# @param version_b [String] Second version
|
|
90
|
+
# @return [Boolean] true if a < b
|
|
91
|
+
#
|
|
92
|
+
# @example
|
|
93
|
+
# Version.less_than?("1.0.0", "2.0.0") # => true
|
|
94
|
+
# Version.less_than?("2.0.0", "1.0.0") # => false
|
|
95
|
+
# Version.less_than?("1.0.0", "1.0.0") # => false
|
|
96
|
+
def less_than?(version_a, version_b)
|
|
97
|
+
compare(version_a, version_b).negative?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Check if version a is greater than or equal to version b.
|
|
101
|
+
#
|
|
102
|
+
# @param version_a [String] First version
|
|
103
|
+
# @param version_b [String] Second version
|
|
104
|
+
# @return [Boolean] true if a >= b
|
|
105
|
+
#
|
|
106
|
+
# @example
|
|
107
|
+
# Version.at_least?("2.0.0", "1.0.0") # => true
|
|
108
|
+
# Version.at_least?("1.0.0", "1.0.0") # => true
|
|
109
|
+
# Version.at_least?("1.0.0", "2.0.0") # => false
|
|
110
|
+
def at_least?(version_a, version_b)
|
|
111
|
+
compare(version_a, version_b) >= 0
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|