rails-profiler 0.30.5 → 0.30.7
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 +80 -0
- data/app/assets/builds/profiler-toolbar.js +2 -1
- data/app/assets/builds/profiler.js +17 -3
- data/app/controllers/profiler/api/ajax_controller.rb +0 -2
- data/app/controllers/profiler/api/cluster_controller.rb +0 -2
- data/app/controllers/profiler/api/console_controller.rb +0 -2
- data/app/controllers/profiler/api/env_vars_controller.rb +22 -7
- data/app/controllers/profiler/api/events_controller.rb +0 -1
- data/app/controllers/profiler/api/explain_controller.rb +0 -2
- data/app/controllers/profiler/api/function_profiling_controller.rb +0 -2
- data/app/controllers/profiler/api/jobs_controller.rb +0 -2
- data/app/controllers/profiler/api/outbound_http_controller.rb +0 -2
- data/app/controllers/profiler/api/profiles_controller.rb +0 -2
- data/app/controllers/profiler/api/slave_proxy_controller.rb +0 -2
- data/app/controllers/profiler/api/test_runner_controller.rb +0 -2
- data/app/controllers/profiler/api/tests_controller.rb +0 -2
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -1
- data/app/controllers/profiler/application_controller.rb +33 -2
- data/app/controllers/profiler/assets_controller.rb +3 -0
- data/app/controllers/profiler/profiles_controller.rb +0 -9
- data/lib/profiler/cluster/master_client.rb +4 -2
- data/lib/profiler/cluster/slave_proxy.rb +1 -0
- data/lib/profiler/collectors/database_collector.rb +5 -1
- data/lib/profiler/collectors/env_collector.rb +1 -1
- data/lib/profiler/collectors/mailer_collector.rb +13 -1
- data/lib/profiler/collectors/request_collector.rb +1 -1
- data/lib/profiler/configuration.rb +42 -4
- data/lib/profiler/explain_runner.rb +4 -0
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +6 -4
- data/lib/profiler/job_profiler.rb +2 -1
- data/lib/profiler/local_request.rb +163 -0
- data/lib/profiler/mcp/tools/list_env_vars.rb +4 -3
- data/lib/profiler/mcp/tools/reset_env_var.rb +2 -1
- data/lib/profiler/mcp/tools/set_env_var.rb +5 -0
- data/lib/profiler/middleware/cors_middleware.rb +47 -35
- data/lib/profiler/models/profile.rb +9 -3
- data/lib/profiler/railtie.rb +4 -5
- data/lib/profiler/redaction.rb +421 -0
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +1 -0
- metadata +4 -2
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
require "rack/utils"
|
|
6
|
+
require "active_support/parameter_filter"
|
|
7
|
+
|
|
8
|
+
module Profiler
|
|
9
|
+
# The single filter applied to everything the profiler captures: request
|
|
10
|
+
# params, headers, bodies and URLs (incoming and outgoing), SQL binds, job
|
|
11
|
+
# arguments, mailer arguments and ENV.
|
|
12
|
+
#
|
|
13
|
+
# It is built on ActiveSupport::ParameterFilter from the host application's
|
|
14
|
+
# Rails.application.config.filter_parameters plus
|
|
15
|
+
# Profiler.configuration.filter_parameters, so it follows Rails semantics:
|
|
16
|
+
# a symbol or string matches any key containing it, case-insensitively, at
|
|
17
|
+
# any nesting depth; a regexp is used as is; a proc rewrites values.
|
|
18
|
+
#
|
|
19
|
+
# It never raises: it runs inside notification subscribers and the Net::HTTP
|
|
20
|
+
# patch, where an exception would reach the application. When the filter
|
|
21
|
+
# fails (a proc that does not expect a value, say), the value is masked and
|
|
22
|
+
# the failure is logged once per error class, without the value.
|
|
23
|
+
module Redaction
|
|
24
|
+
MASK = "[FILTERED]"
|
|
25
|
+
|
|
26
|
+
# Matched by the filter on top of filter_parameters.
|
|
27
|
+
ALWAYS_FILTERED_HEADERS = %w[authorization proxy-authorization cookie set-cookie].freeze
|
|
28
|
+
|
|
29
|
+
# Headers whose value is a URL: their query string goes through the filter.
|
|
30
|
+
URL_HEADERS = %w[referer location content-location].freeze
|
|
31
|
+
|
|
32
|
+
JSON_TYPES = %r{\A(?:application|text)/(?:[\w.+-]+\+)?json\z}i
|
|
33
|
+
NDJSON_TYPES = %r{\A[\w.+-]+/x-ndjson\z}i
|
|
34
|
+
FORM_TYPE = "application/x-www-form-urlencoded"
|
|
35
|
+
MULTIPART_TYPE = %r{\Amultipart/}i
|
|
36
|
+
|
|
37
|
+
# The characters outside ASCII whose case folding yields ASCII letters
|
|
38
|
+
# (Unicode CaseFolding.txt): with any of them, a case-insensitive regexp
|
|
39
|
+
# can match an ASCII filter where a plain ASCII search cannot.
|
|
40
|
+
FOLDS_TO_ASCII = /[ßİʼnſǰẖ-ẚẞKff-st]/
|
|
41
|
+
|
|
42
|
+
# What makes a regexp filter unfit for a search of the whole body text:
|
|
43
|
+
# anchors, word boundaries and lookarounds can match a key on its own and
|
|
44
|
+
# fail on the same key inside the text.
|
|
45
|
+
CONTEXT_SENSITIVE_REGEXP = /\\[AzZbBG]|[\^$]|\(\?<?[=!]/
|
|
46
|
+
|
|
47
|
+
# Bound on the memo of names already tested against the filter.
|
|
48
|
+
KEY_CACHE_LIMIT = 10_000
|
|
49
|
+
|
|
50
|
+
# The value a name alone is tested with: procs in filter_parameters
|
|
51
|
+
# rewrite it in place, so it has to be a string, and a throwaway one.
|
|
52
|
+
PROBE = "x"
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
def enabled?
|
|
56
|
+
Profiler.configuration.redact_sensitive_data != false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The ParameterFilter, rebuilt only when the list of filters changes.
|
|
60
|
+
def parameter_filter
|
|
61
|
+
compiled[:filter]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Keys are masked by the filters that are not procs; the procs then
|
|
65
|
+
# rewrite each string left, once, under its own key. Running the procs
|
|
66
|
+
# through ParameterFilter instead would dup every other value first,
|
|
67
|
+
# Active Record models included.
|
|
68
|
+
def filter_hash(hash)
|
|
69
|
+
return hash unless enabled? && hash.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
state = compiled
|
|
72
|
+
masked = state[:plain].filter(hash)
|
|
73
|
+
state[:proc_filter] ? apply_procs_tree(masked, nil, hash, state[:procs]) : masked
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
report(e, "filtering a hash")
|
|
76
|
+
hash.transform_values { MASK }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Filters any value: hashes through the filter, arrays element by
|
|
80
|
+
# element, anything else unchanged (it carries no key to match).
|
|
81
|
+
def filter_value(value)
|
|
82
|
+
return value unless enabled?
|
|
83
|
+
|
|
84
|
+
case value
|
|
85
|
+
when Hash then filter_hash(value)
|
|
86
|
+
when Array then value.map { |v| filter_value(v) }
|
|
87
|
+
else value
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
report(e, "filtering a value")
|
|
91
|
+
MASK
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A value known by its name (SQL bind, mailer argument): MASK when the
|
|
95
|
+
# name matches the filter, else the value as the procs of
|
|
96
|
+
# filter_parameters rewrite it.
|
|
97
|
+
def filter_named(name, value)
|
|
98
|
+
return value unless enabled? && name
|
|
99
|
+
return MASK if sensitive_key?(name)
|
|
100
|
+
|
|
101
|
+
apply_procs([name.to_s], value)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Whether the name alone matches the filter. Memoized per name: header,
|
|
105
|
+
# ENV, column and key names come from a small set. A filter that raises
|
|
106
|
+
# makes the name sensitive.
|
|
107
|
+
def sensitive_key?(name)
|
|
108
|
+
key = name.to_s
|
|
109
|
+
state = compiled
|
|
110
|
+
known = state[:keys]
|
|
111
|
+
return known[key] if known.key?(key)
|
|
112
|
+
|
|
113
|
+
result = state[:plain].filter(key => +PROBE)[key] == MASK
|
|
114
|
+
known[key] = result if known.size < KEY_CACHE_LIMIT
|
|
115
|
+
result
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
report(e, "testing a name")
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Header names are tested as written and with dashes as underscores, so
|
|
122
|
+
# that X-Api-Key matches :_key as x_api_key would.
|
|
123
|
+
def sensitive_header?(name)
|
|
124
|
+
key = name.to_s.downcase
|
|
125
|
+
ALWAYS_FILTERED_HEADERS.include?(key) || sensitive_key?(key) || sensitive_key?(key.tr("-", "_"))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def filter_headers(headers)
|
|
129
|
+
return headers unless enabled? && headers.respond_to?(:each_with_object)
|
|
130
|
+
|
|
131
|
+
headers.each_with_object({}) do |(name, value), out|
|
|
132
|
+
out[name] = filter_header(name, value)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Filters a raw body according to its content type. JSON, NDJSON and
|
|
137
|
+
# form-urlencoded bodies have their filtered keys masked, multipart
|
|
138
|
+
# bodies are masked entirely, and every other type is kept as is: they
|
|
139
|
+
# carry no key the filter can read, and masking HTML would hide the very
|
|
140
|
+
# pages being profiled.
|
|
141
|
+
#
|
|
142
|
+
# Bodies usually arrive as binary strings (rack.input, Net::HTTP): they
|
|
143
|
+
# are read as UTF-8, or as bytes when they are not valid UTF-8.
|
|
144
|
+
def filter_body(raw, content_type)
|
|
145
|
+
return raw unless enabled? && raw.is_a?(String) && !raw.empty?
|
|
146
|
+
|
|
147
|
+
mime = content_type.to_s.split(";").first.to_s.strip.downcase
|
|
148
|
+
text = readable(raw)
|
|
149
|
+
if mime.match?(JSON_TYPES)
|
|
150
|
+
filter_json(text, mime)
|
|
151
|
+
elsif mime.match?(NDJSON_TYPES)
|
|
152
|
+
text.split("\n", -1).map { |line| line.strip.empty? ? line : filter_json(line, mime) }.join("\n")
|
|
153
|
+
elsif mime == FORM_TYPE
|
|
154
|
+
filter_query(text)
|
|
155
|
+
elsif mime.match?(MULTIPART_TYPE)
|
|
156
|
+
"[FILTERED: #{mime} body, #{raw.bytesize} bytes]"
|
|
157
|
+
else
|
|
158
|
+
raw
|
|
159
|
+
end
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
report(e, "filtering a #{mime} body")
|
|
162
|
+
"[FILTERED: #{mime} body that could not be filtered, #{raw.bytesize} bytes]"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Masks the filtered values of a query string, pair by pair, leaving the
|
|
166
|
+
# rest of the string exactly as it was.
|
|
167
|
+
def filter_query(query)
|
|
168
|
+
return query unless enabled? && query.is_a?(String) && !query.empty?
|
|
169
|
+
|
|
170
|
+
readable(query).split("&", -1).map do |pair|
|
|
171
|
+
name, value = pair.split("=", 2)
|
|
172
|
+
next pair if value.nil? || value.empty?
|
|
173
|
+
|
|
174
|
+
parsed = Rack::Utils.parse_nested_query(pair)
|
|
175
|
+
parameter_filter.filter(parsed) == parsed ? pair : "#{name}=#{MASK}"
|
|
176
|
+
rescue StandardError
|
|
177
|
+
# A pair Rack or the filter cannot handle is masked rather than trusted.
|
|
178
|
+
"#{name}=#{MASK}"
|
|
179
|
+
end.join("&")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A URL, or a list of them as Rack 3 gives a repeated header.
|
|
183
|
+
def filter_url(url)
|
|
184
|
+
return url unless enabled?
|
|
185
|
+
return url.map { |u| filter_url(u) } if url.is_a?(Array)
|
|
186
|
+
return url unless url.is_a?(String)
|
|
187
|
+
|
|
188
|
+
rest, fragment = readable(url).split("#", 2)
|
|
189
|
+
base, query = rest.split("?", 2)
|
|
190
|
+
return url if query.nil?
|
|
191
|
+
|
|
192
|
+
"#{base}?#{filter_query(query)}#{"##{fragment}" if fragment}"
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
report(e, "filtering a URL")
|
|
195
|
+
MASK
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ENV as the profiler shows it: names kept, values masked unless the
|
|
199
|
+
# name is in config.env_allowlist and does not match the filter.
|
|
200
|
+
def env_snapshot(env = ENV.to_h)
|
|
201
|
+
listed = env_allowlist_matcher
|
|
202
|
+
env.sort.to_h { |name, value| [name, env_visible?(name, listed) ? apply_procs([name], value) : MASK] }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def env_value(name, value)
|
|
206
|
+
env_visible?(name) ? apply_procs([name.to_s], value) : MASK
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ENV overrides with the same rule applied to their current and original
|
|
210
|
+
# values. The deleted and restore markers are kept: they are not values.
|
|
211
|
+
def env_overrides(overrides)
|
|
212
|
+
overrides.to_h do |name, entry|
|
|
213
|
+
next [name, entry] if env_visible?(name) || !entry.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
masked = entry.to_h do |field, value|
|
|
216
|
+
[field, keep_override_field?(field, value) ? value : MASK]
|
|
217
|
+
end
|
|
218
|
+
[name, masked]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def env_visible?(name, listed = env_allowlist_matcher)
|
|
223
|
+
listed.call(name.to_s) && !(enabled? && sensitive_key?(name))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Whether +value+, stripped, is the mask: a value exported while masked,
|
|
227
|
+
# which must never be written back as if it were the real one.
|
|
228
|
+
def mask?(value)
|
|
229
|
+
value.to_s.strip == MASK
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def compiled
|
|
235
|
+
filters = current_filters
|
|
236
|
+
state = @compiled
|
|
237
|
+
return state if state && state[:filters] == filters
|
|
238
|
+
|
|
239
|
+
@reported = Set.new
|
|
240
|
+
procs, plain = filters.partition { |f| f.respond_to?(:call) }
|
|
241
|
+
@compiled = {
|
|
242
|
+
filters: filters,
|
|
243
|
+
filter: ActiveSupport::ParameterFilter.new(filters, mask: MASK),
|
|
244
|
+
plain: ActiveSupport::ParameterFilter.new(plain, mask: MASK),
|
|
245
|
+
proc_filter: procs.empty? ? nil : ActiveSupport::ParameterFilter.new(procs, mask: MASK),
|
|
246
|
+
procs: procs,
|
|
247
|
+
keys: {}
|
|
248
|
+
}.merge(text_matchers(filters))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# What the JSON pre-test needs: the string filters, downcased, for a
|
|
252
|
+
# plain search, and as one case-insensitive alternation as
|
|
253
|
+
# ParameterFilter builds it, for text outside ASCII; the regexp filters
|
|
254
|
+
# that can be searched for in the whole text. Procs rewrite values
|
|
255
|
+
# whatever the key, dotted filters match a key path, and other regexps
|
|
256
|
+
# depend on where the key ends: any of them makes the filters opaque,
|
|
257
|
+
# and every body is then parsed.
|
|
258
|
+
def text_matchers(filters)
|
|
259
|
+
procs = filters.any? { |f| f.respond_to?(:call) }
|
|
260
|
+
regexps, strings = filters.reject { |f| f.respond_to?(:call) }.partition { |f| f.is_a?(Regexp) }
|
|
261
|
+
opaque = procs ||
|
|
262
|
+
filters.any? { |f| !f.respond_to?(:call) && f.to_s.include?(".") } ||
|
|
263
|
+
regexps.any? { |r| r.source.match?(CONTEXT_SENSITIVE_REGEXP) } ||
|
|
264
|
+
strings.any? { |s| !s.to_s.ascii_only? }
|
|
265
|
+
words = strings.map { |s| s.to_s.downcase }
|
|
266
|
+
{
|
|
267
|
+
opaque: opaque,
|
|
268
|
+
words: words,
|
|
269
|
+
words_regexp: words.empty? ? nil : Regexp.new(words.map { |w| Regexp.escape(w) }.join("|"), Regexp::IGNORECASE),
|
|
270
|
+
regexps: regexps
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def filter_header(name, value)
|
|
275
|
+
return MASK if sensitive_header?(name)
|
|
276
|
+
|
|
277
|
+
value = filter_url(value) if URL_HEADERS.include?(name.to_s.downcase)
|
|
278
|
+
apply_procs([name.to_s, name.to_s.tr("-", "_")], value)
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
report(e, "filtering a header")
|
|
281
|
+
MASK
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The value as the procs of filter_parameters rewrite it under each of
|
|
285
|
+
# +names+. Nothing to do without procs: the names were already tested.
|
|
286
|
+
#
|
|
287
|
+
# Only strings: any other object would be duplicated for the procs,
|
|
288
|
+
# which runs the application's copy hooks; it is shown through its
|
|
289
|
+
# inspect, as before. The procs run once: under the first of +names+
|
|
290
|
+
# that changes the value, so that a proc which undoes itself (reverse!)
|
|
291
|
+
# is not applied twice.
|
|
292
|
+
def apply_procs(names, value)
|
|
293
|
+
return value unless enabled? && value.is_a?(String)
|
|
294
|
+
|
|
295
|
+
filter = compiled[:proc_filter]
|
|
296
|
+
return value unless filter
|
|
297
|
+
|
|
298
|
+
names.uniq.each do |name|
|
|
299
|
+
result = filter.filter(name => value)[name]
|
|
300
|
+
return result unless result == value
|
|
301
|
+
end
|
|
302
|
+
value
|
|
303
|
+
rescue StandardError => e
|
|
304
|
+
report(e, "applying a filter_parameters proc")
|
|
305
|
+
MASK
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Walks a tree the non-proc filters have already masked and lets the
|
|
309
|
+
# procs rewrite each string leaf, once, as ParameterFilter does: a copy
|
|
310
|
+
# of the key and of the value, and the original params for a proc of
|
|
311
|
+
# arity 3. A leaf masked by the filter is the MASK object itself and is
|
|
312
|
+
# left alone, also as ParameterFilter does; a value that only reads
|
|
313
|
+
# "[FILTERED]" is a different object and still goes to the procs.
|
|
314
|
+
def apply_procs_tree(value, key, original, procs)
|
|
315
|
+
case value
|
|
316
|
+
when Hash then value.each_with_object(value.class.new) { |(k, v), out| out[k] = apply_procs_tree(v, k, original, procs) }
|
|
317
|
+
when Array then value.map { |v| apply_procs_tree(v, key, original, procs) }
|
|
318
|
+
when String then key.nil? || value.equal?(MASK) ? value : call_procs(procs, key, value, original)
|
|
319
|
+
else value
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def call_procs(procs, key, value, original)
|
|
324
|
+
key = key.dup if key.duplicable?
|
|
325
|
+
value = value.dup
|
|
326
|
+
procs.each { |b| b.arity == 2 ? b.call(key, value) : b.call(key, value, original) }
|
|
327
|
+
value
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
report(e, "applying a filter_parameters proc")
|
|
330
|
+
MASK
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def keep_override_field?(field, value)
|
|
334
|
+
value.nil? ||
|
|
335
|
+
value == EnvOverrideStore::DELETED_SENTINEL ||
|
|
336
|
+
value == EnvOverrideStore::RESTORE_SENTINEL ||
|
|
337
|
+
!%w[value original].include?(field)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def env_allowlist_matcher
|
|
341
|
+
allowlist = Profiler.configuration.env_allowlist
|
|
342
|
+
return ->(_name) { true } if allowlist == :all
|
|
343
|
+
|
|
344
|
+
regexps, names = Array(allowlist).partition { |entry| entry.is_a?(Regexp) }
|
|
345
|
+
names = names.to_set(&:to_s)
|
|
346
|
+
->(name) { names.include?(name) || regexps.any? { |r| r.match?(name) } }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def current_filters
|
|
350
|
+
filters = Array(Profiler.configuration.filter_parameters)
|
|
351
|
+
app_filters = rails_filter_parameters
|
|
352
|
+
app_filters.empty? ? filters : app_filters + filters
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def rails_filter_parameters
|
|
356
|
+
return [] unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
357
|
+
|
|
358
|
+
Array(::Rails.application.config.filter_parameters)
|
|
359
|
+
rescue StandardError
|
|
360
|
+
[]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The string as UTF-8 when it is valid UTF-8, else as bytes, so that
|
|
364
|
+
# string operations and regexps never meet an incompatible encoding.
|
|
365
|
+
def readable(string)
|
|
366
|
+
return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
|
|
367
|
+
|
|
368
|
+
utf8 = string.dup.force_encoding(Encoding::UTF_8)
|
|
369
|
+
utf8.valid_encoding? ? utf8 : string.b
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def filter_json(text, mime)
|
|
373
|
+
return text unless json_needs_parse?(text)
|
|
374
|
+
|
|
375
|
+
parsed = JSON.parse(text)
|
|
376
|
+
filtered = filter_value(parsed)
|
|
377
|
+
filtered == parsed ? text : JSON.generate(filtered)
|
|
378
|
+
rescue JSON::ParserError, JSON::GeneratorError
|
|
379
|
+
"[FILTERED: unparseable #{mime} body, #{text.bytesize} bytes]"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# A cheap pre-test that spares the parse of a body in whose text no
|
|
383
|
+
# filter matches anywhere: no key of it can match either. Any match, in
|
|
384
|
+
# a key or in a value, sends the body to the parser, which also masks
|
|
385
|
+
# an unreadable body entirely. Text that is not valid UTF-8, holds an
|
|
386
|
+
# escape (a key can be written password) or, for a plain ASCII
|
|
387
|
+
# search, a character that case-folds onto ASCII letters is not judged
|
|
388
|
+
# here, nor is any body under opaque filters.
|
|
389
|
+
def json_needs_parse?(text)
|
|
390
|
+
state = compiled
|
|
391
|
+
return true if state[:opaque]
|
|
392
|
+
return true if text.encoding != Encoding::UTF_8 || text.include?("\\")
|
|
393
|
+
|
|
394
|
+
state[:regexps].any? { |r| r.match?(text) } || words_in_text?(state, text)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def words_in_text?(state, text)
|
|
398
|
+
words = state[:words]
|
|
399
|
+
return false if words.empty?
|
|
400
|
+
return state[:words_regexp].match?(text) unless text.ascii_only? || !text.match?(FOLDS_TO_ASCII)
|
|
401
|
+
|
|
402
|
+
lowered = text.downcase(:ascii)
|
|
403
|
+
words.any? { |word| lowered.include?(word) }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Logs a failure of the filter once per error class, with no value and
|
|
407
|
+
# no message: either could carry what was being masked.
|
|
408
|
+
def report(error, during)
|
|
409
|
+
reported = (@reported ||= Set.new)
|
|
410
|
+
return if reported.include?(error.class)
|
|
411
|
+
|
|
412
|
+
reported << error.class
|
|
413
|
+
message = "[Profiler] Redaction: #{error.class} while #{during}; the value was masked instead."
|
|
414
|
+
logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
415
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
416
|
+
rescue StandardError
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
data/lib/profiler/version.rb
CHANGED
data/lib/profiler.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.30.
|
|
4
|
+
version: 0.30.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -168,6 +168,7 @@ files:
|
|
|
168
168
|
- lib/profiler/instrumentation/sidekiq_middleware.rb
|
|
169
169
|
- lib/profiler/instrumentation/thread_context_propagation.rb
|
|
170
170
|
- lib/profiler/job_profiler.rb
|
|
171
|
+
- lib/profiler/local_request.rb
|
|
171
172
|
- lib/profiler/mcp/body_formatter.rb
|
|
172
173
|
- lib/profiler/mcp/file_cache.rb
|
|
173
174
|
- lib/profiler/mcp/path_extractor.rb
|
|
@@ -208,6 +209,7 @@ files:
|
|
|
208
209
|
- lib/profiler/models/sql_query.rb
|
|
209
210
|
- lib/profiler/models/timeline_event.rb
|
|
210
211
|
- lib/profiler/railtie.rb
|
|
212
|
+
- lib/profiler/redaction.rb
|
|
211
213
|
- lib/profiler/sse/bus.rb
|
|
212
214
|
- lib/profiler/sse/event_bus.rb
|
|
213
215
|
- lib/profiler/sse/redis_event_bus.rb
|