dilisense_pep_client 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,456 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-validation"
4
+
5
+ module DilisensePepClient
6
+ # Industrial-grade input validation and sanitization system
7
+ #
8
+ # This class provides comprehensive input validation and sanitization specifically designed
9
+ # for financial services applications that handle sensitive PEP/sanctions screening data.
10
+ # It ensures data integrity, prevents injection attacks, and maintains compliance with
11
+ # security standards for financial institutions.
12
+ #
13
+ # Features:
14
+ # - Declarative validation contracts using dry-validation
15
+ # - Comprehensive input sanitization with security focus
16
+ # - API key format validation and security logging
17
+ # - Response data sanitization with size limits
18
+ # - Unicode normalization and dangerous character removal
19
+ # - Detailed error messages with context for debugging
20
+ # - Audit logging for validation events and security incidents
21
+ # - Protection against oversized responses and DoS attacks
22
+ #
23
+ # The validator handles two main screening types:
24
+ # - Individual screening: Personal data with DOB, gender, names
25
+ # - Entity screening: Company/organization names and identifiers
26
+ #
27
+ # All validation failures are logged as security events for monitoring and compliance.
28
+ # Sensitive data is automatically redacted in logs and error messages.
29
+ #
30
+ # @example Individual screening validation
31
+ # params = { names: "John Smith", dob: "01/01/1980", gender: "male" }
32
+ # validated = Validator.validate_individual_params(params)
33
+ #
34
+ # @example Entity screening validation
35
+ # params = { names: "Apple Inc", fuzzy_search: 1 }
36
+ # validated = Validator.validate_entity_params(params)
37
+ #
38
+ # @example API key validation
39
+ # sanitized_key = Validator.sanitize_api_key(raw_api_key)
40
+ class Validator
41
+ # Individual screening parameters validation contract
42
+ # Defines validation rules for person-based PEP screening with comprehensive checks
43
+ # for data format, mutual exclusivity, and security constraints
44
+ IndividualContract = Dry::Validation.Contract do
45
+ params do
46
+ optional(:names).maybe(:string)
47
+ optional(:search_all).maybe(:string)
48
+ optional(:dob).maybe(:string)
49
+ optional(:gender).maybe(:string)
50
+ optional(:fuzzy_search).maybe(:integer)
51
+ optional(:includes).maybe(:string)
52
+ end
53
+
54
+ rule(:names, :search_all) do
55
+ if values[:names] && values[:search_all]
56
+ key.failure("cannot use both 'names' and 'search_all' parameters")
57
+ end
58
+ end
59
+
60
+ rule(:names, :search_all) do
61
+ unless values[:names] || values[:search_all]
62
+ key.failure("either 'names' or 'search_all' parameter is required")
63
+ end
64
+ end
65
+
66
+ rule(:dob) do
67
+ if value && !valid_date_format?(value)
68
+ key.failure("must be in format DD/MM/YYYY, 00/MM/YYYY, DD/00/YYYY, or 00/00/YYYY")
69
+ end
70
+ end
71
+
72
+ rule(:gender) do
73
+ if value && !%w[male female].include?(value.downcase)
74
+ key.failure("must be 'male' or 'female'")
75
+ end
76
+ end
77
+
78
+ rule(:fuzzy_search) do
79
+ if value && ![1, 2].include?(value)
80
+ key.failure("must be 1 or 2")
81
+ end
82
+ end
83
+
84
+ rule(:names) do
85
+ if value && (value.length > 200 || value.strip.empty?)
86
+ key.failure("must be between 1 and 200 characters")
87
+ end
88
+ end
89
+
90
+ rule(:search_all) do
91
+ if value && (value.length > 200 || value.strip.empty?)
92
+ key.failure("must be between 1 and 200 characters")
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def valid_date_format?(date_str)
99
+ # Validate DD/MM/YYYY format with flexible day/month
100
+ return false unless date_str.match?(/^\d{2}\/\d{2}\/\d{4}$/)
101
+
102
+ parts = date_str.split("/")
103
+ day, month, year = parts.map(&:to_i)
104
+
105
+ # Validate year (reasonable range)
106
+ return false if year < 1900 || year > Date.today.year + 10
107
+
108
+ # Allow 00 for unknown day/month
109
+ return false if day > 31 || month > 12
110
+ return false if day < 0 || month < 0
111
+
112
+ true
113
+ end
114
+ end
115
+
116
+ # Entity screening parameters validation contract
117
+ # Defines validation rules for company/organization-based screening with focus
118
+ # on entity name formats and organizational identifier validation
119
+ EntityContract = Dry::Validation.Contract do
120
+ params do
121
+ optional(:names).maybe(:string)
122
+ optional(:search_all).maybe(:string)
123
+ optional(:fuzzy_search).maybe(:integer)
124
+ end
125
+
126
+ rule(:names, :search_all) do
127
+ if values[:names] && values[:search_all]
128
+ key.failure("cannot use both 'names' and 'search_all' parameters")
129
+ end
130
+ end
131
+
132
+ rule(:names, :search_all) do
133
+ unless values[:names] || values[:search_all]
134
+ key.failure("either 'names' or 'search_all' parameter is required")
135
+ end
136
+ end
137
+
138
+ rule(:fuzzy_search) do
139
+ if value && ![1, 2].include?(value)
140
+ key.failure("must be 1 or 2")
141
+ end
142
+ end
143
+
144
+ rule(:names) do
145
+ if value && (value.length > 300 || value.strip.empty?)
146
+ key.failure("must be between 1 and 300 characters")
147
+ end
148
+ end
149
+
150
+ rule(:search_all) do
151
+ if value && (value.length > 300 || value.strip.empty?)
152
+ key.failure("must be between 1 and 300 characters")
153
+ end
154
+ end
155
+ end
156
+
157
+ class << self
158
+ # Validate and sanitize individual screening parameters
159
+ # Applies comprehensive validation rules and sanitization for person-based screening
160
+ #
161
+ # @param params [Hash] Raw input parameters from user/API
162
+ # @return [Hash] Validated and sanitized parameters
163
+ # @raise [ValidationError] When validation rules fail
164
+ #
165
+ # @example Valid individual parameters
166
+ # params = {
167
+ # names: "John Smith",
168
+ # dob: "15/06/1985",
169
+ # gender: "male",
170
+ # fuzzy_search: 1
171
+ # }
172
+ # validated = validate_individual_params(params)
173
+ def validate_individual_params(params)
174
+ sanitized_params = sanitize_individual_params(params)
175
+ result = IndividualContract.call(sanitized_params)
176
+
177
+ if result.failure?
178
+ error_messages = extract_error_messages(result.errors)
179
+ raise ValidationError.new(
180
+ "Invalid individual screening parameters",
181
+ validation_errors: error_messages,
182
+ context: {
183
+ sanitized_params: sanitized_params,
184
+ original_params_keys: params.keys
185
+ }
186
+ )
187
+ end
188
+
189
+ # Log validation success for audit trail
190
+ Logger.logger.debug("Individual params validation successful", {
191
+ sanitized_params: sanitized_params,
192
+ validation_rules_applied: %w[
193
+ mutual_exclusion name_format length_limits
194
+ date_format gender_values fuzzy_search_range
195
+ ]
196
+ })
197
+
198
+ result.to_h
199
+ end
200
+
201
+ def validate_entity_params(params)
202
+ sanitized_params = sanitize_entity_params(params)
203
+ result = EntityContract.call(sanitized_params)
204
+
205
+ if result.failure?
206
+ error_messages = extract_error_messages(result.errors)
207
+ raise ValidationError.new(
208
+ "Invalid entity screening parameters",
209
+ validation_errors: error_messages,
210
+ context: {
211
+ sanitized_params: sanitized_params,
212
+ original_params_keys: params.keys
213
+ }
214
+ )
215
+ end
216
+
217
+ # Log validation success for audit
218
+ Logger.logger.debug("Entity params validation successful", {
219
+ sanitized_params: sanitized_params,
220
+ validation_rules_applied: %w[
221
+ mutual_exclusion name_format length_limits fuzzy_search_range
222
+ ]
223
+ })
224
+
225
+ result.to_h
226
+ end
227
+
228
+ def sanitize_api_key(api_key)
229
+ return nil if api_key.nil? || api_key.to_s.strip.empty?
230
+
231
+ key = api_key.to_s.strip
232
+
233
+ # Validate API key format (basic checks)
234
+ unless valid_api_key_format?(key)
235
+ raise ConfigurationError.new(
236
+ "Invalid API key format",
237
+ config_key: "api_key",
238
+ context: {
239
+ key_length: key.length,
240
+ key_format: detect_key_format(key)
241
+ }
242
+ )
243
+ end
244
+
245
+ # Log API key validation (without exposing the key)
246
+ Logger.log_security_event(
247
+ event_type: "api_key_validation",
248
+ details: {
249
+ key_length: key.length,
250
+ key_format: detect_key_format(key),
251
+ validation_result: "success"
252
+ },
253
+ severity: :low
254
+ )
255
+
256
+ key
257
+ end
258
+
259
+ def sanitize_response_data(data, max_size: 1_048_576) # 1MB default
260
+ return nil unless data
261
+
262
+ # Check response size
263
+ data_size = data.to_s.bytesize
264
+ if data_size > max_size
265
+ Logger.log_security_event(
266
+ event_type: "oversized_response",
267
+ details: {
268
+ response_size: data_size,
269
+ max_allowed: max_size,
270
+ truncated: true
271
+ },
272
+ severity: :medium
273
+ )
274
+
275
+ # Truncate large responses
276
+ truncated_data = data.to_s[0, max_size]
277
+ return "#{truncated_data}... [TRUNCATED: original size #{data_size} bytes]"
278
+ end
279
+
280
+ # Sanitize potentially sensitive data patterns
281
+ sanitized = data.to_s.gsub(
282
+ /\b(?:api[_-]?key|token|secret|password)\s*[=:]\s*[^\s&]+/i,
283
+ '[REDACTED_CREDENTIAL]'
284
+ )
285
+
286
+ sanitized
287
+ end
288
+
289
+ private
290
+
291
+ def sanitize_individual_params(params)
292
+ return {} unless params.is_a?(Hash)
293
+
294
+ sanitized = {}
295
+
296
+ params.each do |key, value|
297
+ sanitized_key = key.to_sym
298
+ sanitized_value = case sanitized_key
299
+ when :names, :search_all
300
+ sanitize_name_input(value)
301
+ when :dob
302
+ sanitize_date_input(value)
303
+ when :gender
304
+ sanitize_gender_input(value)
305
+ when :fuzzy_search
306
+ sanitize_integer_input(value, min: 1, max: 2)
307
+ when :includes
308
+ sanitize_includes_input(value)
309
+ else
310
+ # Unknown parameter - log and ignore
311
+ Logger.log_security_event(
312
+ event_type: "unknown_parameter",
313
+ details: {
314
+ parameter_name: key,
315
+ parameter_value: value.to_s[0, 50],
316
+ context: "individual_screening"
317
+ },
318
+ severity: :low
319
+ )
320
+ next
321
+ end
322
+
323
+ sanitized[sanitized_key] = sanitized_value if sanitized_value
324
+ end
325
+
326
+ sanitized
327
+ end
328
+
329
+ def sanitize_entity_params(params)
330
+ return {} unless params.is_a?(Hash)
331
+
332
+ sanitized = {}
333
+
334
+ params.each do |key, value|
335
+ sanitized_key = key.to_sym
336
+ sanitized_value = case sanitized_key
337
+ when :names, :search_all
338
+ sanitize_name_input(value, max_length: 300)
339
+ when :fuzzy_search
340
+ sanitize_integer_input(value, min: 1, max: 2)
341
+ else
342
+ # Unknown parameter - log and ignore
343
+ Logger.log_security_event(
344
+ event_type: "unknown_parameter",
345
+ details: {
346
+ parameter_name: key,
347
+ parameter_value: value.to_s[0, 50],
348
+ context: "entity_screening"
349
+ },
350
+ severity: :low
351
+ )
352
+ next
353
+ end
354
+
355
+ sanitized[sanitized_key] = sanitized_value if sanitized_value
356
+ end
357
+
358
+ sanitized
359
+ end
360
+
361
+ def sanitize_name_input(value, max_length: 200)
362
+ return nil if value.nil?
363
+
364
+ name = value.to_s.strip
365
+ return nil if name.empty?
366
+
367
+ # Remove potentially dangerous characters
368
+ name = name.gsub(/[<>\"'&\x00-\x1F\x7F-\x9F]/, '')
369
+
370
+ # Normalize unicode and whitespace
371
+ name = name.unicode_normalize(:nfc).squeeze(' ')
372
+
373
+ # Truncate if too long
374
+ name = name[0, max_length] if name.length > max_length
375
+
376
+ # Final validation
377
+ return nil if name.strip.empty?
378
+
379
+ name
380
+ end
381
+
382
+ def sanitize_date_input(value)
383
+ return nil if value.nil?
384
+
385
+ date_str = value.to_s.strip
386
+ return nil if date_str.empty?
387
+
388
+ # Only allow specific date format
389
+ return nil unless date_str.match?(/^\d{2}\/\d{2}\/\d{4}$/)
390
+
391
+ date_str
392
+ end
393
+
394
+ def sanitize_gender_input(value)
395
+ return nil if value.nil?
396
+
397
+ gender = value.to_s.strip.downcase
398
+ return nil unless %w[male female].include?(gender)
399
+
400
+ gender
401
+ end
402
+
403
+ def sanitize_integer_input(value, min: nil, max: nil)
404
+ return nil if value.nil?
405
+
406
+ int_value = case value
407
+ when Integer then value
408
+ when String then value.to_i if value.match?(/^\d+$/)
409
+ else nil
410
+ end
411
+
412
+ return nil unless int_value
413
+ return nil if min && int_value < min
414
+ return nil if max && int_value > max
415
+
416
+ int_value
417
+ end
418
+
419
+ def sanitize_includes_input(value)
420
+ return nil if value.nil?
421
+
422
+ includes = value.to_s.strip
423
+ return nil if includes.empty?
424
+
425
+ # Basic validation for source IDs (alphanumeric, underscore, comma, space)
426
+ return nil unless includes.match?(/^[a-zA-Z0-9_,\s]+$/)
427
+
428
+ # Normalize and clean up
429
+ includes.gsub(/\s+/, ' ').strip
430
+ end
431
+
432
+ def valid_api_key_format?(key)
433
+ # Basic API key format validation
434
+ return false if key.length < 10 || key.length > 200
435
+
436
+ # Check for reasonable character set
437
+ key.match?(/^[a-zA-Z0-9._-]+$/)
438
+ end
439
+
440
+ def detect_key_format(key)
441
+ case key.length
442
+ when 10..50 then "short_key"
443
+ when 51..100 then "medium_key"
444
+ when 101..200 then "long_key"
445
+ else "unknown"
446
+ end
447
+ end
448
+
449
+ def extract_error_messages(errors)
450
+ errors.to_h.map do |field, messages|
451
+ Array(messages).map { |msg| "#{field}: #{msg}" }
452
+ end.flatten
453
+ end
454
+ end
455
+ end
456
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DilisensePepClient
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dilisense_pep_client/version"
4
+ require_relative "dilisense_pep_client/configuration"
5
+ require_relative "dilisense_pep_client/errors"
6
+ require_relative "dilisense_pep_client/client"
7
+
8
+ # Main module for the Dilisense PEP/Sanctions screening Ruby client
9
+ # Provides a simple interface for screening individuals and entities
10
+ # against PEP (Politically Exposed Persons) and sanctions lists
11
+ #
12
+ # @example Basic configuration and usage
13
+ # # Configure the gem with your API key (usually done once at startup)
14
+ # DilisensePepClient.configure do |config|
15
+ # config.api_key = "your_api_key_here"
16
+ # config.timeout = 30 # Optional: customize timeout
17
+ # end
18
+ #
19
+ # # Screen an individual
20
+ # results = DilisensePepClient.check_individual(
21
+ # names: "Vladimir Putin",
22
+ # dob: "07/10/1952",
23
+ # gender: "male"
24
+ # )
25
+ #
26
+ # # Screen an entity/company
27
+ # results = DilisensePepClient.check_entity(names: "Bank Rossiya")
28
+ #
29
+ module DilisensePepClient
30
+ class << self
31
+ # Access the configuration object
32
+ # Returns the configuration instance which holds all settings
33
+ #
34
+ # @return [Configuration] Configuration instance
35
+ def configuration
36
+ @configuration ||= Configuration.new
37
+ end
38
+
39
+ # Configure the gem with a block
40
+ # This is the main way to set up the gem with your API key and preferences
41
+ #
42
+ # @yield [config] Configuration block
43
+ # @yieldparam config [Configuration] The configuration object to modify
44
+ #
45
+ # @example Set API key and timeout
46
+ # DilisensePepClient.configure do |config|
47
+ # config.api_key = "your_api_key"
48
+ # config.timeout = 45
49
+ # end
50
+ def configure
51
+ yield(configuration)
52
+ end
53
+
54
+ # Get or create the API client instance
55
+ # Uses singleton pattern to reuse the same client
56
+ #
57
+ # @return [Client] The API client instance
58
+ def client
59
+ @client ||= Client.new
60
+ end
61
+
62
+ # Convenience method to screen an individual
63
+ # Delegates to the client's check_individual method
64
+ #
65
+ # @param names [String, nil] Full name to search
66
+ # @param search_all [String, nil] Alternative search parameter
67
+ # @param dob [String, nil] Date of birth (DD/MM/YYYY)
68
+ # @param gender [String, nil] Gender (male/female)
69
+ # @param fuzzy_search [Integer, nil] Fuzzy search level (1 or 2)
70
+ # @param includes [String, nil] Source IDs to include
71
+ # @return [Array<Hash>] Array of matching individuals
72
+ #
73
+ # @example Screen with multiple parameters
74
+ # results = DilisensePepClient.check_individual(
75
+ # names: "John Smith",
76
+ # dob: "01/01/1980",
77
+ # fuzzy_search: 1
78
+ # )
79
+ def check_individual(names: nil, search_all: nil, dob: nil, gender: nil, fuzzy_search: nil, includes: nil)
80
+ client.check_individual(names: names, search_all: search_all, dob: dob, gender: gender, fuzzy_search: fuzzy_search, includes: includes)
81
+ end
82
+
83
+ # Convenience method to screen an entity/company
84
+ # Delegates to the client's check_entity method
85
+ #
86
+ # @param names [String, nil] Entity name to search
87
+ # @param search_all [String, nil] Alternative search parameter
88
+ # @param fuzzy_search [Integer, nil] Fuzzy search level (1 or 2)
89
+ # @return [Array<Hash>] Array of matching entities
90
+ #
91
+ # @example Screen a company
92
+ # results = DilisensePepClient.check_entity(names: "Apple Inc")
93
+ def check_entity(names: nil, search_all: nil, fuzzy_search: nil)
94
+ client.check_entity(names: names, search_all: search_all, fuzzy_search: fuzzy_search)
95
+ end
96
+
97
+ # Reset the gem to its initial state
98
+ # Clears configuration and client instance
99
+ # Useful for testing or reconfiguration
100
+ #
101
+ # @return [nil]
102
+ def reset!
103
+ @configuration = nil
104
+ @client = nil
105
+ end
106
+ end
107
+ end