logister-ruby 0.5.1 → 0.6.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.
@@ -21,12 +21,12 @@ module Logister
21
21
 
22
22
  def deployment_context
23
23
  config = Logister.configuration if Logister.respond_to?(:configuration)
24
- environment = config&.respond_to?(:environment) ? config.environment.to_s.presence : nil
25
- service = config&.respond_to?(:service) ? config.service.to_s.presence : nil
26
- release = config&.respond_to?(:release) ? config.release.to_s.presence : nil
27
- repository = config&.respond_to?(:repository) ? config.repository.to_s.presence : nil
28
- commit_sha = config&.respond_to?(:commit_sha) ? config.commit_sha.to_s.presence : nil
29
- branch = config&.respond_to?(:branch) ? config.branch.to_s.presence : nil
24
+ environment = config.respond_to?(:environment) ? config.environment.to_s.presence : nil
25
+ service = config.respond_to?(:service) ? config.service.to_s.presence : nil
26
+ release = config.respond_to?(:release) ? config.release.to_s.presence : nil
27
+ repository = config.respond_to?(:repository) ? config.repository.to_s.presence : nil
28
+ commit_sha = config.respond_to?(:commit_sha) ? config.commit_sha.to_s.presence : nil
29
+ branch = config.respond_to?(:branch) ? config.branch.to_s.presence : nil
30
30
 
31
31
  {
32
32
  deployment: {
@@ -105,7 +105,7 @@ module Logister
105
105
 
106
106
  "#{segments[0]}.#{segments[1]}.#{segments[2]}.0"
107
107
  else
108
- "#{parsed.mask(64).to_s}/64"
108
+ "#{parsed.mask(64)}/64"
109
109
  end
110
110
  rescue StandardError
111
111
  ip.to_s
@@ -43,6 +43,16 @@ module Logister
43
43
  request_scope[:trace_context] = value
44
44
  end
45
45
 
46
+ # User identity lives in the request scope so resetting the scope at the end
47
+ # of a request or job also clears it.
48
+ def user
49
+ request_scope[:user]
50
+ end
51
+
52
+ def user=(value)
53
+ request_scope[:user] = value
54
+ end
55
+
46
56
  def breadcrumbs
47
57
  request_scope[:breadcrumbs].dup
48
58
  end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require_relative 'redaction'
5
+
6
+ module Logister
7
+ # Converts an event into JSON-safe primitives before it is serialized:
8
+ # applies the shared redaction spec, scrubs invalid UTF-8, bounds string
9
+ # length, collection size, nesting depth, and total key count. The limits stay
10
+ # below the Logister ingest limits so one oversized event is trimmed locally
11
+ # instead of being rejected by the server.
12
+ class EventSanitizer
13
+ TRUNCATED = '[TRUNCATED]'
14
+ TRUNCATION_MARKER_KEY = 'logister_truncated'
15
+
16
+ # SDK-generated envelope fields: never redacted, so correlation survives.
17
+ ENVELOPE_KEYS = Set.new(%w[
18
+ uuid event_id event_type trace_id span_id parent_span_id request_id session_id
19
+ requestId traceId spanId parentSpanId sessionId
20
+ ]).freeze
21
+
22
+ # Large, low-priority context collections are visited last so the key
23
+ # budget is spent on the exception, request, and user context first.
24
+ DEFERRED_CONTEXT_KEYS = Set.new(%w[breadcrumbs dependencyCalls]).freeze
25
+
26
+ Limits = Struct.new(:max_string_bytes, :max_message_bytes, :max_items, :max_depth, :max_keys, keyword_init: true)
27
+
28
+ DEFAULT_LIMITS = Limits.new(
29
+ max_string_bytes: 16 * 1024,
30
+ max_message_bytes: 32 * 1024,
31
+ max_items: 100,
32
+ max_depth: 10,
33
+ max_keys: 400
34
+ ).freeze
35
+
36
+ COMPACT_LIMITS = Limits.new(
37
+ max_string_bytes: 1024,
38
+ max_message_bytes: 4096,
39
+ max_items: 20,
40
+ max_depth: 6,
41
+ max_keys: 200
42
+ ).freeze
43
+
44
+ def self.call(payload, extra_keys: Redaction.configured_keys, limits: DEFAULT_LIMITS)
45
+ new(extra_keys: extra_keys, limits: limits).sanitize_payload(payload)
46
+ end
47
+
48
+ def initialize(extra_keys:, limits:)
49
+ @extra_keys = extra_keys
50
+ @limits = limits
51
+ @keys_used = 0
52
+ @truncated = false
53
+ end
54
+
55
+ def sanitize_payload(payload)
56
+ result = {}
57
+ payload.to_h.each do |key, value|
58
+ name = key.to_s
59
+ result[key] = case name
60
+ when 'context'
61
+ sanitize_context(value)
62
+ when 'message'
63
+ value.nil? ? nil : string(value.to_s, @limits.max_message_bytes)
64
+ else
65
+ envelope_value(value)
66
+ end
67
+ @keys_used += 1
68
+ end
69
+ result
70
+ end
71
+
72
+ private
73
+
74
+ def sanitize_context(context)
75
+ return sanitize(context, 1) unless context.is_a?(Hash)
76
+
77
+ deferred, primary = context.to_a.partition { |key, _| DEFERRED_CONTEXT_KEYS.include?(key.to_s) }
78
+ result = {}
79
+ (primary + deferred).each do |key, value|
80
+ unless take_key
81
+ @truncated = true
82
+ break
83
+ end
84
+
85
+ name = utf8(key.to_s)
86
+ result[name] = if ENVELOPE_KEYS.include?(name)
87
+ envelope_value(value)
88
+ elsif Redaction.sensitive_key?(name, @extra_keys)
89
+ Redaction::REDACTED
90
+ else
91
+ sanitize(value, 2)
92
+ end
93
+ end
94
+ result[TRUNCATION_MARKER_KEY] = true if @truncated
95
+ result
96
+ end
97
+
98
+ def envelope_value(value)
99
+ case value
100
+ when String then truncate(utf8(value), @limits.max_string_bytes)
101
+ when Symbol then value.to_s
102
+ when Hash, Array then sanitize(value, 1)
103
+ else primitive(value)
104
+ end
105
+ end
106
+
107
+ def sanitize(value, depth)
108
+ case value
109
+ when nil, true, false, Integer then value
110
+ when Float then value.finite? ? value : value.to_s
111
+ when String then string(value)
112
+ when Symbol then string(value.to_s)
113
+ when Hash then sanitize_hash(value, depth)
114
+ when Array, Set then sanitize_array(value.to_a, depth)
115
+ else sanitize_object(value, depth)
116
+ end
117
+ end
118
+
119
+ def sanitize_hash(hash, depth)
120
+ return mark_truncated(TRUNCATED) if depth > @limits.max_depth
121
+
122
+ result = {}
123
+ hash.each_with_index do |(key, value), index|
124
+ if index >= @limits.max_items || !take_key
125
+ @truncated = true
126
+ break
127
+ end
128
+
129
+ key = utf8(key.to_s)
130
+ result[key] = Redaction.sensitive_key?(key, @extra_keys) ? Redaction::REDACTED : sanitize(value, depth + 1)
131
+ end
132
+ result
133
+ end
134
+
135
+ def sanitize_array(array, depth)
136
+ return mark_truncated(TRUNCATED) if depth > @limits.max_depth
137
+
138
+ @truncated = true if array.length > @limits.max_items
139
+ array.first(@limits.max_items).map { |item| sanitize(item, depth + 1) }
140
+ end
141
+
142
+ def sanitize_object(value, depth)
143
+ case value
144
+ when Time, DateTime then value.iso8601
145
+ when Date then value.iso8601
146
+ when Exception then { 'class' => value.class.name, 'message' => string(value.message.to_s) }
147
+ when Struct then sanitize_hash(value.to_h, depth)
148
+ else
149
+ # Never enumerate lazy relations or IO-like objects; they can run
150
+ # queries or block. Everything else uses its JSON representation.
151
+ return string("#<#{value.class.name}>") if lazy_collection?(value)
152
+ return string(value.to_s) unless value.respond_to?(:as_json)
153
+
154
+ converted = value.as_json
155
+ return string(value.to_s) if converted.equal?(value)
156
+
157
+ sanitize(converted, depth)
158
+ end
159
+ rescue StandardError
160
+ string("#<#{value.class.name}>")
161
+ end
162
+
163
+ def lazy_collection?(value)
164
+ (defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)) ||
165
+ value.is_a?(Enumerator) || value.is_a?(IO) || value.is_a?(Proc)
166
+ end
167
+
168
+ def primitive(value)
169
+ case value
170
+ when nil, true, false, Integer then value
171
+ when Float then value.finite? ? value : value.to_s
172
+ else string(value.to_s)
173
+ end
174
+ end
175
+
176
+ def string(value, max_bytes = @limits.max_string_bytes)
177
+ # Bound regex work on huge strings, keeping enough slack that a credential
178
+ # straddling the truncation point is still recognized and redacted.
179
+ value = value.byteslice(0, max_bytes + 4096) if value.bytesize > max_bytes + 4096
180
+ truncate(Redaction.scrub_string(utf8(value), @extra_keys), max_bytes)
181
+ end
182
+
183
+ def utf8(value)
184
+ value = value.dup.force_encoding(Encoding::UTF_8) if value.encoding == Encoding::BINARY
185
+ value = value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless value.encoding == Encoding::UTF_8
186
+ value.valid_encoding? ? value : value.scrub
187
+ rescue StandardError
188
+ value.to_s.b.force_encoding(Encoding::UTF_8).scrub
189
+ end
190
+
191
+ def truncate(value, max_bytes)
192
+ return value if value.bytesize <= max_bytes
193
+
194
+ @truncated = true
195
+ "#{value.byteslice(0, max_bytes).scrub('')}…#{TRUNCATED}"
196
+ end
197
+
198
+ def take_key
199
+ return false if @keys_used >= @limits.max_keys
200
+
201
+ @keys_used += 1
202
+ true
203
+ end
204
+
205
+ def mark_truncated(value)
206
+ @truncated = true
207
+ value
208
+ end
209
+ end
210
+ end
@@ -3,49 +3,106 @@ require_relative "context_store"
3
3
 
4
4
  module Logister
5
5
  class Middleware
6
- FILTERED_HEADER_PLACEHOLDER = "[FILTERED]".freeze
7
- SENSITIVE_HEADERS = %w[authorization cookie set-cookie x-api-key x-csrf-token].freeze
6
+ FILTERED_HEADER_PLACEHOLDER = Logister::Redaction::REDACTED
7
+
8
+ # Only these request headers are captured. Add more with
9
+ # `config.capture_headers`; the shared sensitive-key rule still applies to
10
+ # every captured header name, and values are scrubbed for credentials.
11
+ CAPTURED_HEADERS = %w[
12
+ accept accept-encoding accept-language cache-control content-length content-type
13
+ dnt host origin referer user-agent via
14
+ traceparent tracestate x-request-id x-amzn-trace-id
15
+ x-b3-traceid x-b3-spanid x-b3-parentspanid x-b3-sampled
16
+ x-datadog-trace-id x-datadog-parent-id x-datadog-sampling-priority
17
+ x-forwarded-for x-forwarded-host x-forwarded-proto x-forwarded-port x-real-ip
18
+ sec-fetch-dest sec-fetch-mode sec-fetch-site sec-fetch-user
19
+ sec-ch-ua sec-ch-ua-mobile sec-ch-ua-platform upgrade-insecure-requests
20
+ ].freeze
21
+ IP_HEADERS = %w[x-forwarded-for x-real-ip].freeze
8
22
 
9
23
  def initialize(app)
10
24
  @app = app
11
25
  end
12
26
 
13
27
  def call(env)
28
+ trace_context = begin_request(env)
29
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
30
+ status, headers, body = @app.call(env)
31
+ [status, with_request_id_header(headers, trace_context), body]
32
+ rescue StandardError => e
33
+ report_exception(e, env, started_at)
34
+ raise
35
+ ensure
36
+ end_request(env, trace_context)
37
+ end
38
+
39
+ private
40
+
41
+ def begin_request(env)
14
42
  Logister::ContextStore.reset_request_scope!
15
43
  trace_context = Logister::TraceContext.from_headers(
16
44
  traceparent: env["HTTP_TRACEPARENT"], request_id: env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
17
45
  )
18
46
  Logister::ContextStore.trace_context = trace_context
19
47
  env["logister.trace_context"] = trace_context
20
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
- status, headers, body = @app.call(env)
22
- headers = headers.merge("x-request-id" => trace_context.request_id)
23
- [status, headers, body]
48
+ trace_context
24
49
  rescue StandardError => e
25
- request = ActionDispatch::Request.new(env)
26
- request_context = build_request_context(request, env, error: e, started_at: started_at)
50
+ Logister.log_internal_error("middleware setup", e)
51
+ nil
52
+ end
53
+
54
+ def with_request_id_header(headers, trace_context)
55
+ return headers unless trace_context
56
+
57
+ headers.merge("x-request-id" => trace_context.request_id)
58
+ rescue StandardError => e
59
+ Logister.log_internal_error("middleware response header", e)
60
+ headers
61
+ end
62
+
63
+ # Context building and reporting run in their own rescue so a telemetry
64
+ # failure can never replace the application's exception.
65
+ def report_exception(error, env, started_at)
66
+ request = build_request(env)
67
+ request_context = build_request_context(request, env, error: error, started_at: started_at)
27
68
 
28
69
  Logister.report_error(
29
- e,
70
+ error,
30
71
  context: request_context
31
72
  )
32
- raise
33
- ensure
73
+ rescue StandardError => e
74
+ Logister.log_internal_error("middleware error report", e)
75
+ nil
76
+ end
77
+
78
+ def end_request(env, trace_context)
34
79
  Logister::ContextStore.clear_request_summary(trace_context&.request_id)
35
80
  request_id = env["action_dispatch.request_id"]
36
81
  Logister::ContextStore.clear_request_summary(request_id)
37
82
  Logister::ContextStore.reset_request_scope!
83
+ rescue StandardError => e
84
+ Logister.log_internal_error("middleware cleanup", e)
85
+ nil
38
86
  end
39
87
 
40
- private
88
+ # Rails apps get ActionDispatch::Request, including filtered parameters.
89
+ # Plain Rack apps get Rack::Request; parameters are then omitted because no
90
+ # host filter is configured, and the redacted URL still records the query.
91
+ def build_request(env)
92
+ return ActionDispatch::Request.new(env) if defined?(ActionDispatch::Request)
93
+
94
+ require "rack/request" unless defined?(Rack::Request)
95
+ Rack::Request.new(env)
96
+ end
41
97
 
42
98
  def build_request_context(request, env, error:, started_at:)
43
99
  request_id = env["action_dispatch.request_id"].to_s.presence
44
100
  path = request.path.to_s
45
101
  method = request.request_method.to_s
46
- params = request.filtered_parameters.to_h
102
+ params = request_params(request)
47
103
  headers = extract_headers(env)
48
- referer = request.referer.to_s.presence || headers["Referer"]
104
+ referer = Logister::Redaction.redact_url(request.referer.to_s.presence || headers["Referer"])
105
+ url = Logister::Redaction.redact_url(request_url(request))
49
106
  http_version = env["HTTP_VERSION"].to_s.presence || env["SERVER_PROTOCOL"].to_s.presence
50
107
  rails_action = rails_action_name(params)
51
108
  response_status = response_status_for(error)
@@ -71,11 +128,11 @@ module Logister
71
128
  railsAction: rails_action,
72
129
  referer: referer,
73
130
  requestId: request_id,
74
- url: request.original_url.to_s.presence,
131
+ url: url,
75
132
  response: {
76
133
  status: request_summary[:status] || response_status,
77
134
  contentType: request.content_type.to_s.presence,
78
- format: request_summary[:format] || request.format&.to_s.presence,
135
+ format: request_summary[:format] || request_format(request),
79
136
  durationMs: duration_ms
80
137
  }.compact,
81
138
  route: {
@@ -100,7 +157,7 @@ module Logister
100
157
  railsAction: rails_action,
101
158
  referer: referer,
102
159
  requestId: request_id,
103
- url: request.original_url.to_s.presence
160
+ url: url
104
161
  }.compact
105
162
  }.compact
106
163
 
@@ -114,6 +171,29 @@ module Logister
114
171
  )
115
172
  end
116
173
 
174
+ def request_params(request)
175
+ return {} unless request.respond_to?(:filtered_parameters)
176
+
177
+ request.filtered_parameters.to_h
178
+ rescue StandardError
179
+ {}
180
+ end
181
+
182
+ def request_url(request)
183
+ url = request.respond_to?(:original_url) ? request.original_url : request.url
184
+ url.to_s.presence
185
+ rescue StandardError
186
+ nil
187
+ end
188
+
189
+ def request_format(request)
190
+ return nil unless request.respond_to?(:format)
191
+
192
+ request.format&.to_s.presence
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
117
197
  def rails_action_name(params)
118
198
  return nil unless params.is_a?(Hash)
119
199
 
@@ -125,13 +205,14 @@ module Logister
125
205
  end
126
206
 
127
207
  def extract_headers(env)
208
+ allowed = captured_header_names
128
209
  headers = {}
129
210
 
130
211
  env.each do |key, value|
131
212
  next unless value.is_a?(String)
132
213
 
133
214
  header_name = rack_env_to_header_name(key)
134
- next unless header_name
215
+ next unless header_name && allowed.include?(header_name.downcase)
135
216
 
136
217
  headers[header_name] = filter_header_value(header_name, value)
137
218
  end
@@ -139,6 +220,13 @@ module Logister
139
220
  headers.sort.to_h
140
221
  end
141
222
 
223
+ def captured_header_names
224
+ extra = Array(Logister.configuration.capture_headers).map { |name| name.to_s.downcase.tr("_", "-") }
225
+ CAPTURED_HEADERS | extra
226
+ rescue StandardError
227
+ CAPTURED_HEADERS
228
+ end
229
+
142
230
  def rack_env_to_header_name(key)
143
231
  if key.start_with?("HTTP_")
144
232
  key.delete_prefix("HTTP_").split("_").map(&:capitalize).join("-")
@@ -146,17 +234,22 @@ module Logister
146
234
  "Content-Type"
147
235
  elsif key == "CONTENT_LENGTH"
148
236
  "Content-Length"
149
- else
150
- nil
151
237
  end
152
238
  end
153
239
 
154
240
  def filter_header_value(name, value)
155
- return FILTERED_HEADER_PLACEHOLDER if SENSITIVE_HEADERS.include?(name.to_s.downcase)
241
+ normalized = name.to_s.downcase
242
+ return FILTERED_HEADER_PLACEHOLDER if Logister::Redaction.sensitive_key?(normalized)
243
+ return anonymize_ip_list(value) if IP_HEADERS.include?(normalized)
244
+ return Logister::Redaction.redact_url(value) if normalized == "referer"
156
245
 
157
246
  value
158
247
  end
159
248
 
249
+ def anonymize_ip_list(value)
250
+ value.split(",").map { |ip| Logister::ContextHelpers.anonymize_ip(ip.strip) }.compact.join(", ")
251
+ end
252
+
160
253
  def current_user(env)
161
254
  controller = env["action_controller.instance"]
162
255
  return nil unless controller
@@ -38,6 +38,14 @@ module Logister
38
38
  copy_setting(app, config, :capture_request_spans)
39
39
  copy_setting(app, config, :capture_sql_breadcrumbs)
40
40
  copy_setting(app, config, :sql_breadcrumb_min_duration_ms)
41
+ copy_setting(app, config, :redact_keys)
42
+ copy_setting(app, config, :capture_headers)
43
+ copy_setting(app, config, :max_event_bytes)
44
+ copy_setting(app, config, :sync_max_retries)
45
+ copy_setting(app, config, :delivery_observer)
46
+ copy_setting(app, config, :repository)
47
+ copy_setting(app, config, :commit_sha)
48
+ copy_setting(app, config, :branch)
41
49
  end
42
50
  end
43
51
 
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Logister
6
+ # Shared default redaction spec used by every Logister SDK. Keep the key lists
7
+ # and value patterns identical across SDKs; add project-specific keys through
8
+ # `config.redact_keys` instead of changing these defaults.
9
+ module Redaction
10
+ REDACTED = '[REDACTED]'
11
+
12
+ SENSITIVE_KEYS = Set.new(%w[
13
+ password passwd pwd secret token apikey authorization proxyauthorization
14
+ cookie setcookie sessiontoken sessionkey credential credentials privatekey
15
+ clientsecret accesstoken refreshtoken idtoken authtoken xapikey xauthtoken
16
+ xaccesstoken xcsrftoken xxsrftoken signature ssn creditcard cardnumber cvv
17
+ ]).freeze
18
+
19
+ SENSITIVE_KEY_FRAGMENTS = %w[
20
+ token secret password passwd apikey authorization credential cookie
21
+ ].freeze
22
+
23
+ AUTH_SCHEME_PATTERN = %r{\b(Bearer|Basic)\s+[A-Za-z0-9\-._~+/]+=*}i
24
+ JWT_PATTERN = /\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/
25
+ LOGISTER_KEY_PATTERN = /\blgst_[A-Za-z0-9_-]+/
26
+ # A `name=value` pair at the start of a string or after a query, form, or
27
+ # whitespace separator. Only values whose name is sensitive are replaced.
28
+ USERINFO_PASSWORD_PATTERN = %r{\A([a-z][a-z0-9+.-]*://[^/?#@:]*):[^/?#@]*@}i
29
+ QUERY_PAIR_PATTERN = /(\A|[?&;\s])([^=&;#?\s]+)=([^&;#\s]*)/
30
+ # Single-quoted strings (with '' escapes and optional E/N/X/B prefixes),
31
+ # dollar-quoted bodies, hex blobs, and numbers that are not part of an
32
+ # identifier or a `$1` bind placeholder. Double quotes are left alone because
33
+ # PostgreSQL and SQLite use them for identifiers.
34
+ SQL_STRING_PATTERN = /(?<![A-Za-z0-9_])[EeNnXxBb]?'(?:[^'\\]|''|\\.)*'/
35
+ SQL_DOLLAR_PATTERN = /\$([A-Za-z_][A-Za-z0-9_]*|)\$.*?\$\1\$/m
36
+ SQL_NUMBER_PATTERN = /(?<![A-Za-z0-9_$.])-?(?:0x[0-9A-Fa-f]+|\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)(?![A-Za-z0-9_])/
37
+
38
+ module_function
39
+
40
+ def normalize_key(key)
41
+ key.to_s.downcase.gsub(/[^a-z0-9]/, '')
42
+ end
43
+
44
+ def sensitive_key?(key, extra_keys = configured_keys)
45
+ normalized = normalize_key(key)
46
+ return false if normalized.empty?
47
+
48
+ SENSITIVE_KEYS.include?(normalized) ||
49
+ extra_keys.include?(normalized) ||
50
+ SENSITIVE_KEY_FRAGMENTS.any? { |fragment| normalized.include?(fragment) }
51
+ end
52
+
53
+ # Scrubs credentials embedded in free text: auth schemes, JWTs, Logister
54
+ # keys, and sensitive `name=value` pairs such as query parameters.
55
+ def scrub_string(value, extra_keys = configured_keys)
56
+ return value unless value.is_a?(String) && !value.empty?
57
+
58
+ scrubbed = value.gsub(AUTH_SCHEME_PATTERN) { "#{Regexp.last_match(1)} #{REDACTED}" }
59
+ scrubbed = scrubbed.gsub(JWT_PATTERN, REDACTED)
60
+ scrubbed = scrubbed.gsub(LOGISTER_KEY_PATTERN, REDACTED)
61
+ return scrubbed unless scrubbed.include?('=')
62
+
63
+ scrubbed.gsub(QUERY_PAIR_PATTERN) do
64
+ prefix, name, pair_value = Regexp.last_match.captures
65
+ if !pair_value.empty? && sensitive_key?(decode_component(name), extra_keys)
66
+ "#{prefix}#{name}=#{REDACTED}"
67
+ else
68
+ Regexp.last_match(0)
69
+ end
70
+ end
71
+ end
72
+
73
+ # Keeps scheme, host, and path; replaces credentials in userinfo and the
74
+ # values of sensitive query parameters; drops the fragment.
75
+ def redact_url(value, extra_keys = configured_keys)
76
+ return value unless value.is_a?(String) && !value.empty?
77
+
78
+ uri = URI.parse(value)
79
+ uri.fragment = nil
80
+ uri.query = redact_query(uri.query, extra_keys) if uri.query
81
+ scrub_string(redact_userinfo(uri.to_s), extra_keys)
82
+ rescue URI::Error, ArgumentError
83
+ scrub_string(redact_userinfo(value.split('#', 2).first), extra_keys)
84
+ end
85
+
86
+ def redact_userinfo(url)
87
+ url.sub(USERINFO_PASSWORD_PATTERN) { "#{Regexp.last_match(1)}:#{REDACTED}@" }
88
+ end
89
+
90
+ def redact_query(query, extra_keys = configured_keys)
91
+ return query if query.nil? || query.empty?
92
+
93
+ query.split('&', -1).map do |pair|
94
+ name, separator, pair_value = pair.partition('=')
95
+ if !separator.empty? && !pair_value.empty? && sensitive_key?(decode_component(name), extra_keys)
96
+ "#{name}=#{REDACTED}"
97
+ else
98
+ pair
99
+ end
100
+ end.join('&')
101
+ end
102
+
103
+ def strip_sql_literals(sql)
104
+ sql.to_s
105
+ .gsub(SQL_DOLLAR_PATTERN, '?')
106
+ .gsub(SQL_STRING_PATTERN, '?')
107
+ .gsub(SQL_NUMBER_PATTERN, '?')
108
+ end
109
+
110
+ def configured_keys
111
+ keys = Logister.configuration.redact_keys if Logister.respond_to?(:configuration)
112
+ normalize_keys(keys)
113
+ rescue StandardError
114
+ Set.new
115
+ end
116
+
117
+ def normalize_keys(keys)
118
+ Set.new(Array(keys).map { |key| normalize_key(key) }.reject(&:empty?))
119
+ end
120
+
121
+ def decode_component(name)
122
+ URI.decode_www_form_component(name)
123
+ rescue ArgumentError
124
+ name
125
+ end
126
+ end
127
+ end