pinqloq 1.1.2

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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log_level"
4
+ require_relative "log_source_type"
5
+
6
+ module Pinqloq
7
+ class LogEntry
8
+ attr_accessor :log_level, :event, :date, :app_version_name, :device_identifier,
9
+ :log_source_type, :collection_name, :correlation_id, :path, :metadata, :detail
10
+
11
+ def initialize(
12
+ event:,
13
+ log_level: LogLevel::INFORMATION,
14
+ date: nil,
15
+ app_version_name: nil,
16
+ device_identifier: nil,
17
+ log_source_type: LogSourceType::BACKEND,
18
+ collection_name: nil,
19
+ correlation_id: nil,
20
+ path: nil,
21
+ metadata: nil,
22
+ detail: nil
23
+ )
24
+ @event = event
25
+ @log_level = log_level
26
+ @date = date
27
+ @app_version_name = app_version_name
28
+ @device_identifier = device_identifier
29
+ @log_source_type = log_source_type
30
+ @collection_name = collection_name
31
+ @correlation_id = correlation_id
32
+ @path = path
33
+ @metadata = metadata
34
+ @detail = detail
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinqloq
4
+ module LogFailureReason
5
+ UNAUTHORIZED = :unauthorized
6
+ FORBIDDEN = :forbidden
7
+ QUEUE_FULL = :queue_full
8
+ HTTP_ERROR = :http_error
9
+ TIMEOUT = :timeout
10
+ NETWORK = :network
11
+ end
12
+
13
+ LogError = Struct.new(:reason, :status_code, :message, :cause, keyword_init: true)
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinqloq
4
+ module LogLevel
5
+ DEBUG = 1
6
+ INFORMATION = 2
7
+ WARNING = 3
8
+ ERROR = 4
9
+ FATAL = 5
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinqloq
4
+ module LogSourceType
5
+ DEVICE = 1
6
+ BACKEND = 2
7
+
8
+ NAMES = {
9
+ DEVICE => "Device",
10
+ BACKEND => "Backend"
11
+ }.freeze
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinqloq
4
+ class Logger
5
+ DEVICE_IDENTIFIER_REQUIRED_MESSAGE =
6
+ "Pinqloq: device_identifier is required. Set it on the entry, or configure the global " \
7
+ "Options#device_identifier fallback."
8
+
9
+ def initialize(buffer, dispatcher, options)
10
+ @buffer = buffer
11
+ @dispatcher = dispatcher
12
+ @options = options
13
+ end
14
+
15
+ def enqueue(entry, on_sent: nil, on_failed: nil)
16
+ ensure_device_identifier!(entry)
17
+ written = @buffer.enqueue(entry, on_sent: on_sent, on_failed: on_failed)
18
+ @dispatcher.notify_enqueued
19
+ written
20
+ end
21
+
22
+ def enqueue_many(entries, on_sent: nil, on_failed: nil)
23
+ entries.each { |entry| ensure_device_identifier!(entry) }
24
+ written = @buffer.enqueue_many(entries, on_sent: on_sent, on_failed: on_failed)
25
+ @dispatcher.notify_enqueued
26
+ written
27
+ end
28
+
29
+ private
30
+
31
+ def ensure_device_identifier!(entry)
32
+ entry_value = entry.device_identifier&.strip
33
+ global_value = @options.device_identifier&.strip
34
+
35
+ return if (entry_value && !entry_value.empty?) || (global_value && !global_value.empty?)
36
+
37
+ raise DEVICE_IDENTIFIER_REQUIRED_MESSAGE
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinqloq
4
+ INGEST_BASE_ADDRESS = "https://pinqloq-external-api.pinqponq.io"
5
+
6
+ class Options
7
+ DEFAULT_BULK_PATH = "api/client-logs/bulk"
8
+ DEFAULT_BATCH_SIZE = 200
9
+ DEFAULT_FLUSH_INTERVAL = 2.0
10
+ DEFAULT_QUEUE_CAPACITY = 10_000
11
+ DEFAULT_HTTP_TIMEOUT = 10.0
12
+
13
+ attr_reader :secret_key, :api_logs_collection_name, :bulk_path, :batch_size,
14
+ :flush_interval, :queue_capacity, :http_timeout, :app_version_name, :device_identifier
15
+
16
+ def initialize(
17
+ secret_key:,
18
+ api_logs_collection_name: nil,
19
+ bulk_path: DEFAULT_BULK_PATH,
20
+ batch_size: DEFAULT_BATCH_SIZE,
21
+ flush_interval: DEFAULT_FLUSH_INTERVAL,
22
+ queue_capacity: DEFAULT_QUEUE_CAPACITY,
23
+ http_timeout: DEFAULT_HTTP_TIMEOUT,
24
+ app_version_name: nil,
25
+ device_identifier: nil
26
+ )
27
+ raise ArgumentError, "Pinqloq: secret_key is required." if secret_key.nil? || secret_key.strip.empty?
28
+
29
+ @secret_key = secret_key
30
+ @api_logs_collection_name = api_logs_collection_name
31
+ @bulk_path = bulk_path
32
+ @batch_size = [1, batch_size].max
33
+ @flush_interval = [0.001, flush_interval].max
34
+ @queue_capacity = [1, queue_capacity].max
35
+ @http_timeout = [0.001, http_timeout].max
36
+ @app_version_name = app_version_name
37
+ @device_identifier = device_identifier
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinqloq
4
+ module Rack
5
+ class CapturedBody
6
+ def initialize(body, max_bytes)
7
+ @body = body
8
+ @max_bytes = max_bytes
9
+ @chunks = []
10
+ @captured_bytes = 0
11
+ end
12
+
13
+ def each(&block)
14
+ @body.each do |chunk|
15
+ capture(chunk)
16
+ block.call(chunk)
17
+ end
18
+ end
19
+
20
+ def close
21
+ @body.close if @body.respond_to?(:close)
22
+ end
23
+
24
+ def captured_string
25
+ @chunks.join.dup.force_encoding("UTF-8")
26
+ end
27
+
28
+ private
29
+
30
+ def capture(chunk)
31
+ return if @captured_bytes >= @max_bytes
32
+
33
+ remaining = @max_bytes - @captured_bytes
34
+ slice = chunk.bytesize > remaining ? chunk.byteslice(0, remaining) : chunk
35
+
36
+ @chunks << slice
37
+ @captured_bytes += slice.bytesize
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinqloq
4
+ module Rack
5
+ module PathMatch
6
+ module_function
7
+
8
+ def matches_any_prefix?(path, prefixes)
9
+ return false if prefixes.nil? || prefixes.empty?
10
+
11
+ lower_path = path.downcase
12
+ prefixes.any? { |prefix| matches_segment_prefix?(lower_path, prefix.downcase) }
13
+ end
14
+
15
+ def matches_segment_prefix?(lower_path, prefix)
16
+ normalized = prefix.start_with?("/") ? prefix : "/#{prefix}"
17
+ trimmed = (normalized.length > 1 && normalized.end_with?("/")) ? normalized[0..-2] : normalized
18
+
19
+ return false unless lower_path.start_with?(trimmed)
20
+
21
+ lower_path.length == trimmed.length || lower_path[trimmed.length] == "/"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "securerandom"
5
+ require "stringio"
6
+ require_relative "path_match"
7
+ require_relative "captured_body"
8
+ require_relative "../redaction/redaction"
9
+ require_relative "../log_level"
10
+ require_relative "../log_source_type"
11
+ require_relative "../internal/throttled_warn"
12
+
13
+ module Pinqloq
14
+ module Rack
15
+ class RequestLogging
16
+ MAX_BODY_CHARACTERS = 32 * 1024
17
+ MAX_BODY_BYTES = 4 * MAX_BODY_CHARACTERS
18
+
19
+ DEVICE_IDENTIFIER_HEADER_NAME = "device-identifier"
20
+ CORRELATION_ID_HEADER_NAME = "correlation-id"
21
+
22
+ DEVICE_IDENTIFIER_REQUIRED_MESSAGE =
23
+ "Pinqloq: the required device_identifier could not be resolved. Send the " \
24
+ "'device-identifier' request header, or configure resolve_device_identifier, or set " \
25
+ "Options#device_identifier."
26
+
27
+ SERVER_ERROR_STATUS_THRESHOLD = 500
28
+ CLIENT_ERROR_STATUS_THRESHOLD = 400
29
+
30
+ SELECTOR_WARNING_THROTTLE_SECONDS = 60
31
+
32
+ def initialize(
33
+ app,
34
+ logger:,
35
+ pinqloq_options:,
36
+ exclude_paths: [],
37
+ resolve_device_identifier: nil,
38
+ resolve_app_version_name: nil,
39
+ metadata: {},
40
+ detail: {},
41
+ redact_fields: [],
42
+ redact_paths: []
43
+ )
44
+ @app = app
45
+ @logger = logger
46
+ @pinqloq_options = pinqloq_options
47
+ @exclude_paths = exclude_paths
48
+ @resolve_device_identifier = resolve_device_identifier
49
+ @resolve_app_version_name = resolve_app_version_name
50
+ @metadata_enrichers = metadata
51
+ @detail_enrichers = detail
52
+ @redact_fields = redact_fields
53
+ @redact_paths = redact_paths
54
+ end
55
+
56
+ def call(env)
57
+ request = ::Rack::Request.new(env)
58
+ path = request.path
59
+
60
+ return @app.call(env) if PathMatch.matches_any_prefix?(path, @exclude_paths)
61
+
62
+ device_identifier = resolve_device_identifier(request)
63
+ if device_identifier.nil? || device_identifier.strip.empty?
64
+ return [400, { "Content-Type" => "text/plain" }, [DEVICE_IDENTIFIER_REQUIRED_MESSAGE]]
65
+ end
66
+
67
+ redact_plan = build_redact_plan(path)
68
+ started_at = monotonic_now
69
+
70
+ request_headers = truncate(Redaction.serialize_headers(request_header_hash(env), redact_plan))
71
+ input_json = truncate(Redaction.apply_body_redaction(read_request_body(request), redact_plan))
72
+ correlation_id = resolve_correlation_id(request)
73
+ app_version_name = resolve_selector(@resolve_app_version_name, request, "resolve_app_version_name")
74
+
75
+ status, headers, body = @app.call(env)
76
+
77
+ captured_body = CapturedBody.new(body, MAX_BODY_BYTES)
78
+ wrapped_body = ::Rack::BodyProxy.new(captured_body) do
79
+ finish_log(
80
+ method: request.request_method,
81
+ path: path,
82
+ status: status,
83
+ headers: headers,
84
+ elapsed_ms: ((monotonic_now - started_at) * 1000).round,
85
+ device_identifier: device_identifier,
86
+ app_version_name: app_version_name,
87
+ correlation_id: correlation_id,
88
+ request_headers: request_headers,
89
+ input_json: input_json,
90
+ output_json: truncate(Redaction.apply_body_redaction(captured_body.captured_string, redact_plan)),
91
+ request: request,
92
+ redact_plan: redact_plan
93
+ )
94
+ end
95
+
96
+ [status, headers, wrapped_body]
97
+ end
98
+
99
+ private
100
+
101
+ def build_redact_plan(path)
102
+ if !@redact_paths.empty? && PathMatch.matches_any_prefix?(path, @redact_paths)
103
+ Redaction::Plan::ALL
104
+ else
105
+ Redaction::Plan.new(redact_all: false, declared_names: @redact_fields)
106
+ end
107
+ end
108
+
109
+ def finish_log(method:, path:, status:, headers:, elapsed_ms:, device_identifier:, app_version_name:,
110
+ correlation_id:, request_headers:, input_json:, output_json:, request:, redact_plan:)
111
+ response_headers = truncate(Redaction.serialize_headers(headers, redact_plan))
112
+
113
+ metadata = {}
114
+ metadata["event"] = "#{method} #{path}".strip
115
+ apply_enrichers(metadata, @metadata_enrichers, request, status, headers)
116
+ resolved_event_name = metadata.delete("event")
117
+
118
+ metadata["method"] = method
119
+ metadata["statusCode"] = status.to_s
120
+ metadata["durationMs"] = elapsed_ms.to_s
121
+ metadata["RequestMethod"] = method
122
+ metadata["ResponseCode"] = status.to_s
123
+
124
+ detail = {}
125
+ apply_enrichers(detail, @detail_enrichers, request, status, headers)
126
+ detail["InputJson"] = input_json
127
+ detail["OutputJson"] = output_json
128
+ detail["RequestHeaders"] = request_headers
129
+ detail["ResponseHeaders"] = response_headers
130
+
131
+ @logger.enqueue(
132
+ Pinqloq::LogEntry.new(
133
+ event: resolved_event_name,
134
+ log_level: resolve_log_level(status),
135
+ device_identifier: device_identifier,
136
+ app_version_name: (app_version_name.nil? || app_version_name.empty?) ? nil : app_version_name,
137
+ log_source_type: LogSourceType::BACKEND,
138
+ correlation_id: correlation_id,
139
+ path: path,
140
+ metadata: metadata,
141
+ detail: detail
142
+ )
143
+ )
144
+ end
145
+
146
+ def resolve_log_level(status)
147
+ return LogLevel::ERROR if status >= SERVER_ERROR_STATUS_THRESHOLD
148
+ return LogLevel::WARNING if status >= CLIENT_ERROR_STATUS_THRESHOLD
149
+
150
+ LogLevel::INFORMATION
151
+ end
152
+
153
+ def resolve_device_identifier(request)
154
+ overridden = resolve_selector(@resolve_device_identifier, request, "resolve_device_identifier")
155
+ return overridden unless overridden.nil? || overridden.strip.empty?
156
+
157
+ header = request.get_header(rack_header_key(DEVICE_IDENTIFIER_HEADER_NAME))
158
+ return header unless header.nil? || header.strip.empty?
159
+
160
+ @pinqloq_options.device_identifier
161
+ end
162
+
163
+ def resolve_correlation_id(request)
164
+ header = request.get_header(rack_header_key(CORRELATION_ID_HEADER_NAME))
165
+ (header && !header.strip.empty?) ? header : SecureRandom.uuid
166
+ end
167
+
168
+ def resolve_selector(selector, request, throttle_key)
169
+ return nil unless selector
170
+
171
+ selector.call(request)
172
+ rescue StandardError => e
173
+ Internal::ThrottledWarn.warn_throttled(
174
+ throttle_key,
175
+ SELECTOR_WARNING_THROTTLE_SECONDS,
176
+ "Pinqloq: #{throttle_key} raised an exception; ignored. #{e.class}: #{e.message}"
177
+ )
178
+ nil
179
+ end
180
+
181
+ def apply_enrichers(target, enrichers, request, status, headers)
182
+ enrichers.each do |key, selector|
183
+ value =
184
+ begin
185
+ selector.call(request, status, headers)
186
+ rescue StandardError => e
187
+ Internal::ThrottledWarn.warn_throttled(
188
+ "enricher:#{key}",
189
+ SELECTOR_WARNING_THROTTLE_SECONDS,
190
+ "Pinqloq: the '#{key}' enricher raised an exception; ignored. #{e.class}: #{e.message}"
191
+ )
192
+ next
193
+ end
194
+
195
+ target[key.to_s] = value.to_s unless value.nil?
196
+ end
197
+ end
198
+
199
+ def request_header_hash(env)
200
+ headers = env.each_with_object({}) do |(key, value), acc|
201
+ next unless key.start_with?("HTTP_")
202
+
203
+ header_name = key.sub(/\AHTTP_/, "").tr("_", "-")
204
+ acc[header_name] = value
205
+ end
206
+
207
+ headers["Content-Type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
208
+ headers["Content-Length"] = env["CONTENT_LENGTH"] if env["CONTENT_LENGTH"]
209
+ headers
210
+ end
211
+
212
+ def rack_header_key(header_name)
213
+ "HTTP_#{header_name.upcase.tr('-', '_')}"
214
+ end
215
+
216
+ def read_request_body(request)
217
+ input = request.get_header("rack.input")
218
+ return "" unless input
219
+
220
+ if input.respond_to?(:rewind)
221
+ body = input.read(MAX_BODY_BYTES) || ""
222
+ input.rewind
223
+ return body.force_encoding("UTF-8")
224
+ end
225
+
226
+ full_body = (input.read || "").force_encoding("UTF-8")
227
+ request.set_header("rack.input", StringIO.new(full_body))
228
+ full_body[0, MAX_BODY_BYTES] || ""
229
+ end
230
+
231
+ def truncate(value)
232
+ value.length <= MAX_BODY_CHARACTERS ? value : value[0, MAX_BODY_CHARACTERS]
233
+ end
234
+
235
+ def monotonic_now
236
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "redaction_plan"
5
+ require_relative "../internal/throttled_warn"
6
+
7
+ module Pinqloq
8
+ module Redaction
9
+ REDACTED_VALUE = "*****REDACTED*****"
10
+
11
+ UNPARSEABLE_SENSITIVE_BODY_VALUE =
12
+ "*****REDACTED: body carries a credential field and is not parseable JSON " \
13
+ "(non-JSON content type, or longer than the capture limit)*****"
14
+
15
+ MALFORMED_BODY_WARNING_THROTTLE_SECONDS = 60
16
+
17
+ class << self
18
+ def redact_properties(value, plan)
19
+ case value
20
+ when Array
21
+ value.map { |item| redact_properties(item, plan) }
22
+ when Hash
23
+ value.each_with_object({}) do |(key, item), result|
24
+ result[key] = plan.should_redact?(key.to_s) ? REDACTED_VALUE : redact_properties(item, plan)
25
+ end
26
+ else
27
+ value
28
+ end
29
+ end
30
+
31
+ def redact_fully(value)
32
+ case value
33
+ when Array
34
+ value.map { |item| redact_fully(item) }
35
+ when Hash
36
+ value.each_with_object({}) { |(key, item), result| result[key] = redact_fully(item) }
37
+ else
38
+ REDACTED_VALUE
39
+ end
40
+ end
41
+
42
+ def redact_json_properties(body, plan, sensitive)
43
+ return body if body.strip.empty?
44
+
45
+ JSON.generate(redact_properties(JSON.parse(body), plan))
46
+ rescue JSON::ParserError => e
47
+ Internal::ThrottledWarn.warn_throttled(
48
+ :redact_json_properties,
49
+ MALFORMED_BODY_WARNING_THROTTLE_SECONDS,
50
+ "Pinqloq: a captured body could not be parsed as JSON; falling back to whole-body handling. #{e.class}: #{e.message}"
51
+ )
52
+ sensitive ? UNPARSEABLE_SENSITIVE_BODY_VALUE : body
53
+ end
54
+
55
+ def redact_json_fully(body)
56
+ return body if body.strip.empty?
57
+
58
+ JSON.generate(redact_fully(JSON.parse(body)))
59
+ rescue JSON::ParserError => e
60
+ Internal::ThrottledWarn.warn_throttled(
61
+ :redact_json_fully,
62
+ MALFORMED_BODY_WARNING_THROTTLE_SECONDS,
63
+ "Pinqloq: a captured body under a redact_all plan could not be parsed as JSON; masking it wholesale. #{e.class}: #{e.message}"
64
+ )
65
+ REDACTED_VALUE
66
+ end
67
+
68
+ def apply_body_redaction(body, plan)
69
+ return redact_json_fully(body) if plan.redact_all
70
+
71
+ mentions_credential = Plan.contains_always_redacted_name?(body) || plan.contains_declared_name?(body)
72
+
73
+ return body if !plan.has_declared_redactions? && !mentions_credential
74
+
75
+ redact_json_properties(body, plan, mentions_credential)
76
+ end
77
+
78
+ def serialize_headers(headers, plan)
79
+ result = headers.each_with_object({}) do |(key, value), acc|
80
+ next if value.nil?
81
+
82
+ string_value = value.is_a?(Array) ? value.join(", ") : value.to_s
83
+ acc[key.to_s] = plan.should_redact?(key.to_s) ? REDACTED_VALUE : string_value
84
+ end
85
+
86
+ JSON.generate(result)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Pinqloq
6
+ module Redaction
7
+ ALWAYS_REDACTED_NAMES = Set.new(
8
+ [
9
+ "authorization",
10
+ "proxy-authorization",
11
+ "cookie",
12
+ "set-cookie",
13
+ "x-api-key",
14
+ "x-secret-key",
15
+ "x-auth-token",
16
+ "x-access-token",
17
+ "x-csrf-token",
18
+ "x-xsrf-token",
19
+ "secret_key",
20
+ "password",
21
+ "newpassword",
22
+ "oldpassword",
23
+ "currentpassword",
24
+ "passwordconfirmation",
25
+ "confirmpassword",
26
+ "secret",
27
+ "secretkey",
28
+ "clientsecret",
29
+ "apikey",
30
+ "accesstoken",
31
+ "refreshtoken",
32
+ "idtoken",
33
+ "token",
34
+ "otp",
35
+ "otpcode",
36
+ "verificationcode",
37
+ "pin",
38
+ "privatekey",
39
+ "cardnumber",
40
+ "cvv",
41
+ "cvc",
42
+ "securitycode",
43
+ "iban",
44
+ "ssn"
45
+ ]
46
+ ).freeze
47
+
48
+ module_function
49
+
50
+ def bounded_name?(body, index, length)
51
+ end_index = index + length
52
+
53
+ before = index.positive? ? body[index - 1] : nil
54
+ at_start = body[index]
55
+ after = end_index < body.length ? body[end_index] : nil
56
+
57
+ start_bounded = index.zero? || !letter_or_digit?(before) || (upper?(at_start) && !upper?(before))
58
+ end_bounded = end_index == body.length || !letter_or_digit?(after) || upper?(after)
59
+
60
+ start_bounded && end_bounded
61
+ end
62
+
63
+ def letter_or_digit?(char)
64
+ !char.nil? && char =~ /[A-Za-z0-9]/ ? true : false
65
+ end
66
+
67
+ def upper?(char)
68
+ !char.nil? && char =~ /[A-Z]/ ? true : false
69
+ end
70
+
71
+ def contains_whole_name?(body, names)
72
+ lower_body = body.downcase
73
+
74
+ names.each do |name|
75
+ next if name.empty?
76
+
77
+ search_from = 0
78
+ while search_from <= lower_body.length - name.length
79
+ index = lower_body.index(name, search_from)
80
+ break if index.nil?
81
+
82
+ return true if bounded_name?(body, index, name.length)
83
+
84
+ search_from = index + 1
85
+ end
86
+ end
87
+
88
+ false
89
+ end
90
+
91
+ class Plan
92
+ attr_reader :redact_all
93
+
94
+ def initialize(redact_all: false, declared_names: [])
95
+ @redact_all = redact_all
96
+ @declared_names = Set.new(declared_names.map(&:downcase))
97
+ end
98
+
99
+ def has_declared_redactions?
100
+ @redact_all || !@declared_names.empty?
101
+ end
102
+
103
+ def should_redact?(property_or_header_name)
104
+ lower = property_or_header_name.downcase
105
+ @redact_all || ALWAYS_REDACTED_NAMES.include?(lower) || @declared_names.include?(lower)
106
+ end
107
+
108
+ def contains_declared_name?(body)
109
+ Redaction.contains_whole_name?(body, @declared_names)
110
+ end
111
+
112
+ def self.contains_always_redacted_name?(body)
113
+ Redaction.contains_whole_name?(body, ALWAYS_REDACTED_NAMES)
114
+ end
115
+
116
+ NONE = new(redact_all: false, declared_names: [])
117
+ ALL = new(redact_all: true, declared_names: [])
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinqloq
4
+ VERSION = "1.1.2"
5
+ end