rails-profiler 0.30.9 → 0.30.11
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 +107 -0
- data/app/controllers/profiler/api/cluster_controller.rb +37 -1
- data/app/controllers/profiler/api/env_vars_controller.rb +6 -0
- data/app/controllers/profiler/api/slave_proxy_controller.rb +4 -2
- data/app/controllers/profiler/api/test_runner_controller.rb +3 -9
- data/app/controllers/profiler/application_controller.rb +12 -1
- data/app/controllers/profiler/profiles_controller.rb +2 -0
- data/config/routes.rb +15 -8
- data/lib/profiler/boot_env.rb +8 -0
- data/lib/profiler/cluster/master_client.rb +24 -6
- data/lib/profiler/cluster/security.rb +179 -0
- data/lib/profiler/cluster/slave_proxy.rb +24 -1
- data/lib/profiler/collectors/base_collector.rb +2 -0
- data/lib/profiler/collectors/console_collector.rb +2 -2
- data/lib/profiler/collectors/flamegraph_collector.rb +2 -2
- data/lib/profiler/collectors/i18n_collector.rb +1 -1
- data/lib/profiler/collectors/mailer_collector.rb +5 -5
- data/lib/profiler/configuration.rb +25 -2
- data/lib/profiler/console_profiler.rb +1 -1
- data/lib/profiler/env_override_store.rb +11 -0
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +2 -1
- data/lib/profiler/job_profiler.rb +2 -2
- data/lib/profiler/mcp/http_guard.rb +73 -0
- data/lib/profiler/mcp/server.rb +5 -2
- data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
- data/lib/profiler/mcp/tools/run_tests.rb +7 -1
- data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
- data/lib/profiler/models/profile.rb +14 -4
- data/lib/profiler/railtie.rb +5 -1
- data/lib/profiler/redaction.rb +98 -0
- data/lib/profiler/test_runner/discovery.rb +2 -2
- data/lib/profiler/test_runner/run_store.rb +39 -2
- data/lib/profiler/test_runner/runner.rb +144 -10
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +2 -0
- metadata +6 -3
|
@@ -86,7 +86,7 @@ module Profiler
|
|
|
86
86
|
|
|
87
87
|
sql = payload[:sql].to_s
|
|
88
88
|
add_event Models::TimelineEvent.new(
|
|
89
|
-
name:
|
|
89
|
+
name: Profiler::Redaction.truncate(sql, 80),
|
|
90
90
|
started_at: started,
|
|
91
91
|
finished_at: finished,
|
|
92
92
|
category: "sql",
|
|
@@ -100,7 +100,7 @@ module Profiler
|
|
|
100
100
|
op = name.split(".").first.sub("cache_", "")
|
|
101
101
|
key = payload[:key].to_s
|
|
102
102
|
add_event Models::TimelineEvent.new(
|
|
103
|
-
name: "cache_#{op}: #{
|
|
103
|
+
name: "cache_#{op}: #{Profiler::Redaction.truncate(key, 60)}",
|
|
104
104
|
started_at: started,
|
|
105
105
|
finished_at: finished,
|
|
106
106
|
category: "cache",
|
|
@@ -224,12 +224,12 @@ module Profiler
|
|
|
224
224
|
if mail.multipart?
|
|
225
225
|
html_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/html") }
|
|
226
226
|
text_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/plain") }
|
|
227
|
-
html = html_part&.body&.decoded&.then { |b| b
|
|
228
|
-
text = text_part&.body&.decoded&.then { |b| b
|
|
227
|
+
html = html_part&.body&.decoded&.then { |b| Profiler::Redaction.truncate(b, MAX_BODY_SIZE, "") }
|
|
228
|
+
text = text_part&.body&.decoded&.then { |b| Profiler::Redaction.truncate(b, MAX_BODY_SIZE, "") }
|
|
229
229
|
elsif mail.content_type.to_s.start_with?("text/html")
|
|
230
|
-
html = mail.body.decoded
|
|
230
|
+
html = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
|
|
231
231
|
else
|
|
232
|
-
text = mail.body.decoded
|
|
232
|
+
text = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
|
|
233
233
|
end
|
|
234
234
|
[html.nil? || html.empty? ? nil : html, text.nil? || text.empty? ? nil : text]
|
|
235
235
|
rescue StandardError
|
|
@@ -240,7 +240,7 @@ module Profiler
|
|
|
240
240
|
return "nil" if value.nil?
|
|
241
241
|
|
|
242
242
|
inspected = value.inspect
|
|
243
|
-
inspected
|
|
243
|
+
Profiler::Redaction.truncate(inspected, 300, "…")
|
|
244
244
|
rescue StandardError
|
|
245
245
|
value.to_s
|
|
246
246
|
end
|
|
@@ -36,12 +36,14 @@ module Profiler
|
|
|
36
36
|
:track_jobs,
|
|
37
37
|
:track_console,
|
|
38
38
|
:apply_env_overrides_when_disabled,
|
|
39
|
-
:track_tests,
|
|
39
|
+
:track_tests, :test_runner_allow_undiscovered_files,
|
|
40
40
|
:track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
|
|
41
41
|
:compress_bodies, :compress_body_threshold,
|
|
42
42
|
:redact_sensitive_data, :filter_parameters, :env_allowlist,
|
|
43
43
|
:name, :master_url, :self_url,
|
|
44
|
-
:cluster_heartbeat_interval, :cluster_offline_threshold
|
|
44
|
+
:cluster_heartbeat_interval, :cluster_offline_threshold,
|
|
45
|
+
:cluster_master, :cluster_secret, :cluster_require_secret,
|
|
46
|
+
:cluster_allowed_slave_urls, :cluster_allow_insecure_http
|
|
45
47
|
|
|
46
48
|
attr_writer :tmp_path
|
|
47
49
|
|
|
@@ -80,6 +82,9 @@ module Profiler
|
|
|
80
82
|
@track_console = true
|
|
81
83
|
@apply_env_overrides_when_disabled = false
|
|
82
84
|
@track_tests = false
|
|
85
|
+
# The test runner only runs the files listed by its discovery. true restores the
|
|
86
|
+
# previous behavior: any file under the Rails root.
|
|
87
|
+
@test_runner_allow_undiscovered_files = false
|
|
83
88
|
@track_mailers = true
|
|
84
89
|
@capture_mail_body = false
|
|
85
90
|
@sanitize_mailer_recipients = false
|
|
@@ -95,6 +100,12 @@ module Profiler
|
|
|
95
100
|
@self_url = nil
|
|
96
101
|
@cluster_heartbeat_interval = 15
|
|
97
102
|
@cluster_offline_threshold = 60
|
|
103
|
+
# Cluster security: see Profiler::Cluster::Security and the Cluster section of the README.
|
|
104
|
+
@cluster_master = false
|
|
105
|
+
@cluster_secret = nil
|
|
106
|
+
@cluster_require_secret = true
|
|
107
|
+
@cluster_allowed_slave_urls = []
|
|
108
|
+
@cluster_allow_insecure_http = false
|
|
98
109
|
end
|
|
99
110
|
|
|
100
111
|
def tmp_path
|
|
@@ -109,6 +120,18 @@ module Profiler
|
|
|
109
120
|
!slave?
|
|
110
121
|
end
|
|
111
122
|
|
|
123
|
+
# Whether this node serves the master-side cluster routes (register, heartbeat, slaves and
|
|
124
|
+
# the slave proxy). Read on each request by the route constraints.
|
|
125
|
+
def cluster_master?
|
|
126
|
+
cluster_master ? true : false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Whether /_profiler/mcp is routed. The stdio transport (rake profiler:mcp) does not depend
|
|
130
|
+
# on it.
|
|
131
|
+
def mcp_http_enabled?
|
|
132
|
+
mcp_enabled && mcp_transport.to_s == "http" ? true : false
|
|
133
|
+
end
|
|
134
|
+
|
|
112
135
|
def resolved_name
|
|
113
136
|
@name || (defined?(Rails) ? Rails.application.class.module_parent_name.underscore : "profiler")
|
|
114
137
|
end
|
|
@@ -39,7 +39,7 @@ module Profiler
|
|
|
39
39
|
profile = Models::Profile.new
|
|
40
40
|
profile.profile_type = "console"
|
|
41
41
|
profile.gem_version = Profiler::VERSION
|
|
42
|
-
profile.path =
|
|
42
|
+
profile.path = Redaction.truncate(@expression, 200)
|
|
43
43
|
profile.method = "CONSOLE"
|
|
44
44
|
|
|
45
45
|
console_collector = Collectors::ConsoleCollector.new(profile, expression: @expression)
|
|
@@ -107,7 +107,18 @@ module Profiler
|
|
|
107
107
|
warn "[Profiler] EnvOverrideStore: failed to check overrides at boot: #{e.message}"
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# Set by the test runner in the environment of the test process, to which it already gave
|
|
111
|
+
# the admitted overrides only (TestRunner::Runner.build_env).
|
|
112
|
+
TEST_RUNNER_CHILD_ENV = "PROFILER_TEST_RUNNER_CHILD"
|
|
113
|
+
RESERVED_KEY_ERROR = "#{TEST_RUNNER_CHILD_ENV} is reserved for the test runner"
|
|
114
|
+
|
|
115
|
+
# The env tools may not set or delete the test runner marker, whatever its case.
|
|
116
|
+
def self.reserved_key?(key)
|
|
117
|
+
key.to_s.upcase == TEST_RUNNER_CHILD_ENV
|
|
118
|
+
end
|
|
119
|
+
|
|
110
120
|
def apply!
|
|
121
|
+
return if ENV[TEST_RUNNER_CHILD_ENV] == "1"
|
|
111
122
|
return if blocked_reason
|
|
112
123
|
|
|
113
124
|
overrides = load_overrides
|
|
@@ -150,7 +150,8 @@ module Profiler
|
|
|
150
150
|
mime = content_type.split(";").first.to_s.strip
|
|
151
151
|
|
|
152
152
|
if mime.match?(BINARY_CONTENT_TYPES)
|
|
153
|
-
|
|
153
|
+
# Masked on the raw bytes, before the encoding hides them from any later search.
|
|
154
|
+
{ body: Base64.strict_encode64(Redaction.hide_credentials(body.b)), encoding: "base64" }
|
|
154
155
|
else
|
|
155
156
|
text = Redaction.filter_body(body, content_type)
|
|
156
157
|
.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
@@ -118,11 +118,11 @@ module Profiler
|
|
|
118
118
|
|
|
119
119
|
Redaction.filter_value(args).map do |arg|
|
|
120
120
|
case arg
|
|
121
|
-
when String then
|
|
121
|
+
when String then Redaction.truncate(arg, 200)
|
|
122
122
|
when Numeric, TrueClass, FalseClass, NilClass then arg
|
|
123
123
|
else
|
|
124
124
|
inspected = arg.inspect
|
|
125
|
-
|
|
125
|
+
Redaction.truncate(inspected, 200)
|
|
126
126
|
end
|
|
127
127
|
rescue
|
|
128
128
|
arg.to_s
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module MCP
|
|
8
|
+
# Checks a request to the MCP HTTP transport before the MCP server sees it, with the same
|
|
9
|
+
# rules as the profiler's controllers: the profiler must be enabled, the request authorized,
|
|
10
|
+
# and a POST must not be one that another site could forge from a browser.
|
|
11
|
+
#
|
|
12
|
+
# Forgery protection does not use the X-Profiler-Request header, which MCP clients do not
|
|
13
|
+
# send. A POST must carry Content-Type: application/json instead: a cross-site form or
|
|
14
|
+
# "simple" fetch can only send text/plain, form-urlencoded or multipart, and JSON needs a
|
|
15
|
+
# CORS preflight that the profiler grants to configured origins only. A browser also sends
|
|
16
|
+
# its Origin, which has to be the profiler's own or one of cors_allowed_origins. GET (the
|
|
17
|
+
# SSE stream) changes nothing, and DELETE always needs a preflight.
|
|
18
|
+
module HttpGuard
|
|
19
|
+
JSON_MEDIA_TYPE = "application/json"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Returns nil when the request may proceed, otherwise a Rack response.
|
|
23
|
+
def call(env)
|
|
24
|
+
config = Profiler.configuration
|
|
25
|
+
return deny("Profiler is disabled") unless config.enabled
|
|
26
|
+
|
|
27
|
+
request = ActionDispatch::Request.new(env)
|
|
28
|
+
return deny("Not authorized to access the profiler") unless config.authorized?(request)
|
|
29
|
+
return nil unless config.api_forgery_protection
|
|
30
|
+
|
|
31
|
+
forgery_denial(request, config)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def forgery_denial(request, config)
|
|
37
|
+
origin = request.get_header("HTTP_ORIGIN")
|
|
38
|
+
if origin && !origin.empty? && !allowed_origin?(origin, request, config)
|
|
39
|
+
return deny("Cross-origin request refused: Origin #{origin} is not allowed")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if request.post? && media_type(request) != JSON_MEDIA_TYPE
|
|
43
|
+
return deny("A POST to the MCP endpoint needs Content-Type: #{JSON_MEDIA_TYPE}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def media_type(request)
|
|
50
|
+
request.get_header("CONTENT_TYPE").to_s.split(";").first.to_s.strip.downcase
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def allowed_origin?(origin, request, config)
|
|
54
|
+
return true if Array(config.cors_allowed_origins).include?(origin)
|
|
55
|
+
|
|
56
|
+
same_origin?(origin, request.base_url)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def same_origin?(origin, base_url)
|
|
60
|
+
a = URI.parse(origin)
|
|
61
|
+
b = URI.parse(base_url)
|
|
62
|
+
a.scheme.to_s.casecmp?(b.scheme.to_s) && a.host.to_s.casecmp?(b.host.to_s) && a.port == b.port
|
|
63
|
+
rescue URI::InvalidURIError
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def deny(message)
|
|
68
|
+
[403, { "content-type" => "application/json" }, [{ error: message }.to_json]]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "mcp"
|
|
4
|
+
require_relative "http_guard"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
6
7
|
module MCP
|
|
@@ -12,7 +13,9 @@ module Profiler
|
|
|
12
13
|
|
|
13
14
|
def rack_app
|
|
14
15
|
# Memoized at class level — survives route reloads in development
|
|
15
|
-
@rack_app ||=
|
|
16
|
+
@rack_app ||= lambda do |env|
|
|
17
|
+
HttpGuard.call(env) || instance.http_transport.handle_request(Rack::Request.new(env))
|
|
18
|
+
end
|
|
16
19
|
end
|
|
17
20
|
end
|
|
18
21
|
|
|
@@ -389,7 +392,7 @@ module Profiler
|
|
|
389
392
|
def define_tool(name:, description:, input_schema:, handler:)
|
|
390
393
|
::MCP::Tool.define(name: name, description: description, input_schema: input_schema) do |server_context: nil, **args|
|
|
391
394
|
result = handler.call(args.transform_keys(&:to_s))
|
|
392
|
-
::MCP::Tool::Response.new(result)
|
|
395
|
+
result.is_a?(::MCP::Tool::Response) ? result : ::MCP::Tool::Response.new(result)
|
|
393
396
|
end
|
|
394
397
|
end
|
|
395
398
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "mcp"
|
|
3
4
|
require_relative "../slave_support"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
@@ -10,6 +11,12 @@ module Profiler
|
|
|
10
11
|
key = params["key"].to_s.strip
|
|
11
12
|
|
|
12
13
|
return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
|
|
14
|
+
if Profiler::EnvOverrideStore.reserved_key?(key)
|
|
15
|
+
return ::MCP::Tool::Response.new(
|
|
16
|
+
[{ type: "text", text: "Error: #{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged." }],
|
|
17
|
+
error: true
|
|
18
|
+
)
|
|
19
|
+
end
|
|
13
20
|
|
|
14
21
|
if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
|
|
15
22
|
proxy.patch_json("/_profiler/api/env_vars", { key: key, value: "" })
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
3
5
|
require_relative "../slave_support"
|
|
4
6
|
|
|
5
7
|
require_relative "../../test_runner/discovery"
|
|
@@ -41,7 +43,11 @@ module Profiler
|
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
run_started_at = Time.now
|
|
44
|
-
|
|
46
|
+
begin
|
|
47
|
+
run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
|
|
48
|
+
rescue Profiler::TestRunner::InvalidFileError => e
|
|
49
|
+
return ::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
|
|
50
|
+
end
|
|
45
51
|
|
|
46
52
|
output_pos = 0
|
|
47
53
|
deadline = Time.now + timeout_secs
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "mcp"
|
|
3
4
|
require_relative "../slave_support"
|
|
4
5
|
require_relative "../../redaction"
|
|
5
6
|
|
|
@@ -12,6 +13,12 @@ module Profiler
|
|
|
12
13
|
value = params["value"].to_s
|
|
13
14
|
|
|
14
15
|
return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
|
|
16
|
+
if Profiler::EnvOverrideStore.reserved_key?(key)
|
|
17
|
+
return ::MCP::Tool::Response.new(
|
|
18
|
+
[{ type: "text", text: "Error: #{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged." }],
|
|
19
|
+
error: true
|
|
20
|
+
)
|
|
21
|
+
end
|
|
15
22
|
if Profiler::Redaction.mask?(value)
|
|
16
23
|
return [{ type: "text", text: "Error: #{Profiler::Redaction::MASK} is the mask the profiler shows " \
|
|
17
24
|
"in place of a hidden value, not a value; #{key} was left unchanged." }]
|
|
@@ -9,7 +9,8 @@ require_relative "../redaction"
|
|
|
9
9
|
module Profiler
|
|
10
10
|
module Models
|
|
11
11
|
class Profile
|
|
12
|
-
|
|
12
|
+
attr_reader :path
|
|
13
|
+
attr_accessor :token, :method, :status, :duration, :memory,
|
|
13
14
|
:started_at, :finished_at, :params, :headers,
|
|
14
15
|
:response_headers, :collectors_data, :collectors_metadata,
|
|
15
16
|
:parent_token, :is_ajax, :profile_type,
|
|
@@ -50,8 +51,15 @@ module Profiler
|
|
|
50
51
|
@response_headers = Redaction.filter_headers(response_headers)
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
# Every collector stores its data here: logs, exception messages, dumps and SQL text are free
|
|
55
|
+
# text with no name to filter on, so the profiler's own credentials are masked by value.
|
|
53
56
|
def add_collector_data(name, data)
|
|
54
|
-
@collectors_data[name.to_s] = data
|
|
57
|
+
@collectors_data[name.to_s] = Redaction.hide_credentials(data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The path is free text for a console profile: the expression typed.
|
|
61
|
+
def path=(value)
|
|
62
|
+
@path = Redaction.hide_credentials(value)
|
|
55
63
|
end
|
|
56
64
|
|
|
57
65
|
def collector_data(name)
|
|
@@ -160,7 +168,9 @@ module Profiler
|
|
|
160
168
|
return { body: nil, encoding: "text" } if raw.nil? || raw.empty?
|
|
161
169
|
|
|
162
170
|
if binary_content_type?(content_type)
|
|
163
|
-
|
|
171
|
+
# Masked on the raw bytes, before the encoding hides them from any later search. A
|
|
172
|
+
# compressed format (zip, png, gzip...) does not hold the secret as it is.
|
|
173
|
+
{ body: Base64.strict_encode64(Redaction.hide_credentials(raw.b)), encoding: "base64" }
|
|
164
174
|
else
|
|
165
175
|
text = Redaction.filter_body(raw, content_type).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
166
176
|
if compress_body?(text)
|
|
@@ -195,7 +205,7 @@ module Profiler
|
|
|
195
205
|
def sanitize_params(params)
|
|
196
206
|
return {} unless params
|
|
197
207
|
|
|
198
|
-
return params.to_h.except(*LEGACY_FILTERED_PARAMS) unless Redaction.enabled?
|
|
208
|
+
return Redaction.hide_credentials(params.to_h.except(*LEGACY_FILTERED_PARAMS)) unless Redaction.enabled?
|
|
199
209
|
|
|
200
210
|
Redaction.filter_hash(params.to_h)
|
|
201
211
|
end
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -92,7 +92,11 @@ module Profiler
|
|
|
92
92
|
# Check slave? inside on_load — the app's own initializers (config/initializers/profiler.rb)
|
|
93
93
|
# set master_url AFTER railtie initializers run, so the check must happen after_initialize.
|
|
94
94
|
ActiveSupport.on_load(:after_initialize) do
|
|
95
|
-
next unless Profiler.configuration.enabled
|
|
95
|
+
next unless Profiler.configuration.enabled
|
|
96
|
+
|
|
97
|
+
require_relative "cluster/security"
|
|
98
|
+
Profiler::Cluster::Security.warn_about_configuration(Rails.logger)
|
|
99
|
+
next unless Profiler.configuration.slave?
|
|
96
100
|
|
|
97
101
|
require_relative "cluster/master_client"
|
|
98
102
|
Profiler::Cluster::MasterClient.new.start
|
data/lib/profiler/redaction.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "set"
|
|
5
5
|
require "rack/utils"
|
|
6
6
|
require "active_support/parameter_filter"
|
|
7
|
+
require_relative "cluster/security"
|
|
7
8
|
|
|
8
9
|
module Profiler
|
|
9
10
|
# The single filter applied to everything the profiler captures: request
|
|
@@ -44,6 +45,18 @@ module Profiler
|
|
|
44
45
|
# fail on the same key inside the text.
|
|
45
46
|
CONTEXT_SENSITIVE_REGEXP = /\\[AzZbBG]|[\^$]|\(\?<?[=!]/
|
|
46
47
|
|
|
48
|
+
# The profiler's own credentials are masked wherever they appear, by value,
|
|
49
|
+
# whatever name they travel under, and even with redact_sensitive_data
|
|
50
|
+
# off: the cluster secret opens every slave from the network, and a
|
|
51
|
+
# profile can be read on any node. Wraps every public filter, so that each
|
|
52
|
+
# capture point gets it.
|
|
53
|
+
module CredentialMasking
|
|
54
|
+
%i[filter_hash filter_value filter_named filter_headers filter_body filter_query filter_url
|
|
55
|
+
env_snapshot env_value env_overrides].each do |name|
|
|
56
|
+
define_method(name) { |*args| hide_credentials(super(*args)) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
47
60
|
# Bound on the memo of names already tested against the filter.
|
|
48
61
|
KEY_CACHE_LIMIT = 10_000
|
|
49
62
|
|
|
@@ -52,6 +65,41 @@ module Profiler
|
|
|
52
65
|
PROBE = "x"
|
|
53
66
|
|
|
54
67
|
class << self
|
|
68
|
+
prepend CredentialMasking
|
|
69
|
+
|
|
70
|
+
# Masks every occurrence of the profiler's own credentials in a value:
|
|
71
|
+
# a string equal to one becomes MASK, a string holding one has it
|
|
72
|
+
# replaced, hashes and arrays are walked. Only what changes is copied:
|
|
73
|
+
# a value holding no credential is returned as it is.
|
|
74
|
+
def hide_credentials(value)
|
|
75
|
+
credentials = credential_values
|
|
76
|
+
credentials.empty? ? value : hide_in(value, credentials)
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
report(e, "masking a profiler credential")
|
|
79
|
+
MASK
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Text cut to +max+ characters, with +ellipsis+ when cut, once the
|
|
83
|
+
# credentials are masked: cutting first would leave a prefix of one, or
|
|
84
|
+
# all of it once the pieces are put back together. Every capture point
|
|
85
|
+
# that shortens text goes through here.
|
|
86
|
+
def truncate(text, max, ellipsis = "...")
|
|
87
|
+
text = hide_credentials(text.to_s)
|
|
88
|
+
text.length > max ? "#{text[0, max]}#{ellipsis}" : text
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# How many trailing bytes of +text+, a stream read in pieces, could be
|
|
92
|
+
# the start of a credential and have to wait for the next piece: the
|
|
93
|
+
# longest end of the text that is also the beginning of a credential,
|
|
94
|
+
# 0 when there is none, so that other output is shown as it comes.
|
|
95
|
+
def held_back_bytes(text)
|
|
96
|
+
bytes = text.to_s.b
|
|
97
|
+
credential_values.map do |credential|
|
|
98
|
+
start = credential.b
|
|
99
|
+
[start.bytesize - 1, bytes.bytesize].min.downto(1).find { |k| bytes.end_with?(start.byteslice(0, k)) } || 0
|
|
100
|
+
end.max || 0
|
|
101
|
+
end
|
|
102
|
+
|
|
55
103
|
def enabled?
|
|
56
104
|
Profiler.configuration.redact_sensitive_data != false
|
|
57
105
|
end
|
|
@@ -231,6 +279,56 @@ module Profiler
|
|
|
231
279
|
|
|
232
280
|
private
|
|
233
281
|
|
|
282
|
+
# Only a secret the cluster would use (Cluster::Security.secret_problem
|
|
283
|
+
# nil: 32 characters or more): a shorter one is ignored by the cluster,
|
|
284
|
+
# and masking "true" or "/" everywhere would hide the very data profiled.
|
|
285
|
+
def credential_values
|
|
286
|
+
return [] unless Cluster::Security.configured_secret?
|
|
287
|
+
|
|
288
|
+
[Profiler.configuration.cluster_secret.to_s.strip]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def hide_in(value, credentials)
|
|
292
|
+
case value
|
|
293
|
+
when String then hide_in_string(value, credentials)
|
|
294
|
+
when Hash
|
|
295
|
+
changes = nil
|
|
296
|
+
value.each do |key, v|
|
|
297
|
+
masked = hide_in(v, credentials)
|
|
298
|
+
(changes ||= {})[key] = masked unless masked.equal?(v)
|
|
299
|
+
end
|
|
300
|
+
changes ? value.merge(changes) : value
|
|
301
|
+
when Array
|
|
302
|
+
copy = nil
|
|
303
|
+
value.each_with_index do |v, i|
|
|
304
|
+
masked = hide_in(v, credentials)
|
|
305
|
+
next if masked.equal?(v)
|
|
306
|
+
|
|
307
|
+
(copy ||= value.dup)[i] = masked
|
|
308
|
+
end
|
|
309
|
+
copy || value
|
|
310
|
+
else value
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The string itself when it holds no credential, which is the common
|
|
315
|
+
# case and costs one search; otherwise a copy, compared as bytes so that
|
|
316
|
+
# a binary body never meets an incompatible encoding.
|
|
317
|
+
def hide_in_string(string, credentials)
|
|
318
|
+
credentials.reduce(string) do |text, credential|
|
|
319
|
+
next text unless contains?(text, credential)
|
|
320
|
+
next MASK if text.b.strip == credential.b
|
|
321
|
+
|
|
322
|
+
text.b.gsub(credential.b, MASK).force_encoding(text.encoding)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def contains?(text, credential)
|
|
327
|
+
text.include?(credential)
|
|
328
|
+
rescue Encoding::CompatibilityError, ArgumentError
|
|
329
|
+
text.b.include?(credential.b)
|
|
330
|
+
end
|
|
331
|
+
|
|
234
332
|
def compiled
|
|
235
333
|
filters = current_filters
|
|
236
334
|
state = @compiled
|
|
@@ -24,8 +24,8 @@ module Profiler
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
globs.each do |glob|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
# base: keeps glob metacharacters of the root ([, {, *) out of the pattern
|
|
28
|
+
Dir.glob(glob, base: root).each do |relative|
|
|
29
29
|
parts = relative.split("/")
|
|
30
30
|
dir = parts[0..-2].join("/")
|
|
31
31
|
result[dir] ||= []
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "concurrent"
|
|
4
4
|
require "securerandom"
|
|
5
|
+
require_relative "../redaction"
|
|
5
6
|
|
|
6
7
|
module Profiler
|
|
7
8
|
module TestRunner
|
|
@@ -24,6 +25,9 @@ module Profiler
|
|
|
24
25
|
def initialize
|
|
25
26
|
@runs = Concurrent::Hash.new
|
|
26
27
|
@locks = Concurrent::Hash.new
|
|
28
|
+
@held = Concurrent::Hash.new
|
|
29
|
+
@output_finished = Concurrent::Hash.new
|
|
30
|
+
@held_lock = Mutex.new
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
def create(files:, framework:)
|
|
@@ -58,11 +62,38 @@ module Profiler
|
|
|
58
62
|
run
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
# Output is free text read back by the API, the SSE stream and run_tests, in the pieces it
|
|
66
|
+
# arrives in: the profiler's own credentials are masked by value, as in a profile. They
|
|
67
|
+
# are masked on the text joined with what was held back from the previous piece, and the
|
|
68
|
+
# last bytes that could start a credential wait for the next piece, so that none is ever
|
|
69
|
+
# shown cut in two. What is held back is released by finish_output, once the process has
|
|
70
|
+
# nothing more to print: a killed run still prints its summary after its status changed.
|
|
61
71
|
def append_output(id, chunk)
|
|
62
72
|
run = @runs[id]
|
|
63
73
|
return unless run
|
|
64
74
|
|
|
65
|
-
|
|
75
|
+
@held_lock.synchronize do
|
|
76
|
+
text = Profiler::Redaction.hide_credentials(@held.fetch(id, "".b) + chunk.to_s.b)
|
|
77
|
+
holdback = @output_finished[id] ? 0 : Profiler::Redaction.held_back_bytes(text)
|
|
78
|
+
cut = text.bytesize - holdback
|
|
79
|
+
@held[id] = text.byteslice(cut, text.bytesize - cut)
|
|
80
|
+
shown = text.byteslice(0, cut).force_encoding(chunk.to_s.encoding)
|
|
81
|
+
run.output_lines.push(shown) unless shown.empty?
|
|
82
|
+
end
|
|
83
|
+
signal(id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The end of the output, told by the reader of the process once it read everything: the
|
|
87
|
+
# bytes held back are shown, and anything appended later is shown as it comes.
|
|
88
|
+
def finish_output(id)
|
|
89
|
+
run = @runs[id]
|
|
90
|
+
return unless run
|
|
91
|
+
|
|
92
|
+
@held_lock.synchronize do
|
|
93
|
+
@output_finished[id] = true
|
|
94
|
+
rest = @held.delete(id)
|
|
95
|
+
run.output_lines.push(rest) if rest && !rest.empty?
|
|
96
|
+
end
|
|
66
97
|
signal(id)
|
|
67
98
|
end
|
|
68
99
|
|
|
@@ -97,6 +128,12 @@ module Profiler
|
|
|
97
128
|
lock[:mutex].synchronize { lock[:cond].broadcast }
|
|
98
129
|
end
|
|
99
130
|
|
|
131
|
+
def forget_output(id)
|
|
132
|
+
@held.delete(id)
|
|
133
|
+
@output_finished.delete(id)
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
100
137
|
def snapshot(run, position)
|
|
101
138
|
lines = run.output_lines.dup
|
|
102
139
|
{
|
|
@@ -109,7 +146,7 @@ module Profiler
|
|
|
109
146
|
|
|
110
147
|
def cleanup_old_runs
|
|
111
148
|
cutoff = Time.now - TTL
|
|
112
|
-
@runs.delete_if { |id, r| r.finished_at && r.finished_at < cutoff && @locks.delete(id) }
|
|
149
|
+
@runs.delete_if { |id, r| r.finished_at && r.finished_at < cutoff && @locks.delete(id) && forget_output(id) }
|
|
113
150
|
end
|
|
114
151
|
end
|
|
115
152
|
|