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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +103 -6
- data/lib/generators/logister/templates/logister.rb +8 -0
- data/lib/logister/active_job_reporter.rb +11 -5
- data/lib/logister/client.rb +165 -40
- data/lib/logister/configuration.rb +8 -2
- data/lib/logister/context_helpers.rb +7 -7
- data/lib/logister/context_store.rb +10 -0
- data/lib/logister/event_sanitizer.rb +210 -0
- data/lib/logister/middleware.rb +114 -21
- data/lib/logister/railtie.rb +8 -0
- data/lib/logister/redaction.rb +127 -0
- data/lib/logister/reporter.rb +28 -24
- data/lib/logister/request_subscriber.rb +3 -11
- data/lib/logister/sql_subscriber.rb +2 -2
- data/lib/logister/version.rb +1 -1
- data/lib/logister.rb +72 -25
- metadata +4 -2
|
@@ -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
|
|
25
|
-
service = config
|
|
26
|
-
release = config
|
|
27
|
-
repository = config
|
|
28
|
-
commit_sha = config
|
|
29
|
-
branch = config
|
|
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)
|
|
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
|
data/lib/logister/middleware.rb
CHANGED
|
@@ -3,49 +3,106 @@ require_relative "context_store"
|
|
|
3
3
|
|
|
4
4
|
module Logister
|
|
5
5
|
class Middleware
|
|
6
|
-
FILTERED_HEADER_PLACEHOLDER =
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
70
|
+
error,
|
|
30
71
|
context: request_context
|
|
31
72
|
)
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
data/lib/logister/railtie.rb
CHANGED
|
@@ -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
|