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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/app/assets/builds/profiler-toolbar.js +2 -1
  4. data/app/assets/builds/profiler.js +17 -3
  5. data/app/controllers/profiler/api/ajax_controller.rb +0 -2
  6. data/app/controllers/profiler/api/cluster_controller.rb +0 -2
  7. data/app/controllers/profiler/api/console_controller.rb +0 -2
  8. data/app/controllers/profiler/api/env_vars_controller.rb +22 -7
  9. data/app/controllers/profiler/api/events_controller.rb +0 -1
  10. data/app/controllers/profiler/api/explain_controller.rb +0 -2
  11. data/app/controllers/profiler/api/function_profiling_controller.rb +0 -2
  12. data/app/controllers/profiler/api/jobs_controller.rb +0 -2
  13. data/app/controllers/profiler/api/outbound_http_controller.rb +0 -2
  14. data/app/controllers/profiler/api/profiles_controller.rb +0 -2
  15. data/app/controllers/profiler/api/slave_proxy_controller.rb +0 -2
  16. data/app/controllers/profiler/api/test_runner_controller.rb +0 -2
  17. data/app/controllers/profiler/api/tests_controller.rb +0 -2
  18. data/app/controllers/profiler/api/toolbar_controller.rb +0 -1
  19. data/app/controllers/profiler/application_controller.rb +33 -2
  20. data/app/controllers/profiler/assets_controller.rb +3 -0
  21. data/app/controllers/profiler/profiles_controller.rb +0 -9
  22. data/lib/profiler/cluster/master_client.rb +4 -2
  23. data/lib/profiler/cluster/slave_proxy.rb +1 -0
  24. data/lib/profiler/collectors/database_collector.rb +5 -1
  25. data/lib/profiler/collectors/env_collector.rb +1 -1
  26. data/lib/profiler/collectors/mailer_collector.rb +13 -1
  27. data/lib/profiler/collectors/request_collector.rb +1 -1
  28. data/lib/profiler/configuration.rb +42 -4
  29. data/lib/profiler/explain_runner.rb +4 -0
  30. data/lib/profiler/instrumentation/net_http_instrumentation.rb +6 -4
  31. data/lib/profiler/job_profiler.rb +2 -1
  32. data/lib/profiler/local_request.rb +163 -0
  33. data/lib/profiler/mcp/tools/list_env_vars.rb +4 -3
  34. data/lib/profiler/mcp/tools/reset_env_var.rb +2 -1
  35. data/lib/profiler/mcp/tools/set_env_var.rb +5 -0
  36. data/lib/profiler/middleware/cors_middleware.rb +47 -35
  37. data/lib/profiler/models/profile.rb +9 -3
  38. data/lib/profiler/railtie.rb +4 -5
  39. data/lib/profiler/redaction.rb +421 -0
  40. data/lib/profiler/version.rb +1 -1
  41. data/lib/profiler.rb +1 -0
  42. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.5"
4
+ VERSION = "0.30.7"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "profiler/version"
4
4
  require_relative "profiler/configuration"
5
+ require_relative "profiler/redaction"
5
6
 
6
7
  module Profiler
7
8
  class Error < StandardError; end
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.5
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-03 00:00:00.000000000 Z
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