karst 0.1.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 +7 -0
- data/ARCHITECTURE.md +59 -0
- data/CHANGELOG.md +67 -0
- data/CODE_OF_CONDUCT.md +29 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +11 -0
- data/docs/advanced-configuration.md +188 -0
- data/lib/generators/karst/install/install_generator.rb +88 -0
- data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
- data/lib/karst/access/approved_populations.rb +128 -0
- data/lib/karst/access/candidate_population.rb +86 -0
- data/lib/karst/access/database_isolation.rb +62 -0
- data/lib/karst/access/population_approvals.rb +195 -0
- data/lib/karst/access/population_config_snippet.rb +67 -0
- data/lib/karst/access/population_discovery.rb +271 -0
- data/lib/karst/access/population_preview.rb +83 -0
- data/lib/karst/access/principal_sampler.rb +241 -0
- data/lib/karst/access/principal_selection.rb +90 -0
- data/lib/karst/access/principal_source.rb +143 -0
- data/lib/karst/access/principal_source_selection.rb +161 -0
- data/lib/karst/access/probe_application.rb +164 -0
- data/lib/karst/access/resource_evidence.rb +233 -0
- data/lib/karst/access/search.rb +265 -0
- data/lib/karst/access/selected_principal_sources.rb +65 -0
- data/lib/karst/access/sensitive_attribute_names.rb +26 -0
- data/lib/karst/access/sweep.rb +198 -0
- data/lib/karst/cli/verification.rb +182 -0
- data/lib/karst/configuration.rb +223 -0
- data/lib/karst/execution_context.rb +83 -0
- data/lib/karst/identity/devise_support.rb +90 -0
- data/lib/karst/identity/warden_adapter.rb +130 -0
- data/lib/karst/identity.rb +479 -0
- data/lib/karst/mcp/server.rb +63 -0
- data/lib/karst/mcp/verify_access_tool.rb +68 -0
- data/lib/karst/railtie.rb +30 -0
- data/lib/karst/spec/catalog.rb +199 -0
- data/lib/karst/spec/example_observation.rb +31 -0
- data/lib/karst/spec/observer.rb +300 -0
- data/lib/karst/spec/principal.rb +12 -0
- data/lib/karst/spec/reporter.rb +83 -0
- data/lib/karst/spec/request_observation.rb +38 -0
- data/lib/karst/spec/scenario.rb +65 -0
- data/lib/karst/value.rb +35 -0
- data/lib/karst/version.rb +5 -0
- data/lib/karst/web/badge.rb +183 -0
- data/lib/karst/web/browser_identity.rb +103 -0
- data/lib/karst/web/locality.rb +64 -0
- data/lib/karst/web/middleware.rb +377 -0
- data/lib/karst/web/panel.rb +699 -0
- data/lib/karst/web/populations_panel.rb +391 -0
- data/lib/karst/web/route_lookup.rb +65 -0
- data/lib/karst.rb +56 -0
- data/lib/rails/commands/karst/boot.rb +24 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
- data/lib/tasks/karst.rake +34 -0
- metadata +138 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "locality"
|
|
4
|
+
require_relative "panel"
|
|
5
|
+
require_relative "populations_panel"
|
|
6
|
+
require_relative "badge"
|
|
7
|
+
require_relative "browser_identity"
|
|
8
|
+
require_relative "route_lookup"
|
|
9
|
+
require_relative "../execution_context"
|
|
10
|
+
require "rack/utils"
|
|
11
|
+
require "rack/request"
|
|
12
|
+
require "json"
|
|
13
|
+
require "active_support/notifications"
|
|
14
|
+
require_relative "../access/sweep"
|
|
15
|
+
require_relative "../access/search"
|
|
16
|
+
require_relative "../access/principal_selection"
|
|
17
|
+
require_relative "../access/candidate_population"
|
|
18
|
+
require_relative "../access/population_discovery"
|
|
19
|
+
require_relative "../access/population_approvals"
|
|
20
|
+
require_relative "../access/approved_populations"
|
|
21
|
+
require_relative "../access/population_preview"
|
|
22
|
+
require_relative "../access/population_config_snippet"
|
|
23
|
+
require_relative "../access/principal_source_selection"
|
|
24
|
+
require_relative "../identity/devise_support"
|
|
25
|
+
|
|
26
|
+
module Karst
|
|
27
|
+
module Web
|
|
28
|
+
# Owns Karst's development-only HTTP evidence surface directly at the Rack
|
|
29
|
+
# boundary, rather than through a Rails engine. An engine would mount routes,
|
|
30
|
+
# load ActionView, and integrate into the host application's controller
|
|
31
|
+
# stack; Karst needs none of that to answer "what evidence does Karst
|
|
32
|
+
# currently hold," and all of it would blur the line between Karst's own
|
|
33
|
+
# page and the host application it is inspecting.
|
|
34
|
+
#
|
|
35
|
+
# Beyond serving /karst itself, this middleware also gives every other
|
|
36
|
+
# development HTML response a tiny link back into /karst, already scoped
|
|
37
|
+
# to the controller/action that produced it (see Badge). Karst sees a
|
|
38
|
+
# request before Rails routes it, so the controller/action that will
|
|
39
|
+
# eventually handle it is not yet known -- that evidence only exists once
|
|
40
|
+
# ActionController has actually dispatched the request, and is captured
|
|
41
|
+
# here via a real process_action.action_controller notification rather
|
|
42
|
+
# than guessed from the request path. Request-local state, not global
|
|
43
|
+
# mutable state, carries that evidence from the notification callback
|
|
44
|
+
# (which fires nested inside @app.call, on whatever thread or fiber is
|
|
45
|
+
# serving this request) back out to the code injecting the badge, so
|
|
46
|
+
# concurrent Puma requests never cross-contaminate each other's context.
|
|
47
|
+
# rubocop:disable Metrics/ClassLength
|
|
48
|
+
class Middleware
|
|
49
|
+
OWNED_PATH = "/karst"
|
|
50
|
+
private_constant :OWNED_PATH
|
|
51
|
+
|
|
52
|
+
POPULATIONS_PATH = "/karst/populations"
|
|
53
|
+
private_constant :POPULATIONS_PATH
|
|
54
|
+
|
|
55
|
+
CANDIDATE_SEPARATOR = "::"
|
|
56
|
+
private_constant :CANDIDATE_SEPARATOR
|
|
57
|
+
|
|
58
|
+
CONTEXT_KEY = :karst_web_request_context
|
|
59
|
+
private_constant :CONTEXT_KEY
|
|
60
|
+
|
|
61
|
+
def initialize(app)
|
|
62
|
+
@app = app
|
|
63
|
+
@locality = Locality.new
|
|
64
|
+
self.class.ensure_context_capture!
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def call(env)
|
|
68
|
+
return call_owned(env) if owned?(env)
|
|
69
|
+
|
|
70
|
+
return @app.call(env) unless development? && @locality.local?(env["REMOTE_ADDR"])
|
|
71
|
+
|
|
72
|
+
call_with_badge(env)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
78
|
+
def call_owned(env)
|
|
79
|
+
return @app.call(env) unless development? && @locality.local?(env["REMOTE_ADDR"])
|
|
80
|
+
return call_populations(env) if env["PATH_INFO"] == POPULATIONS_PATH
|
|
81
|
+
|
|
82
|
+
params = owned_params(env)
|
|
83
|
+
lookup = recognize_manual_route(env, params)
|
|
84
|
+
params = lookup.params if lookup
|
|
85
|
+
selection, denied = principal_source_selection_result(env, params)
|
|
86
|
+
return denied if denied
|
|
87
|
+
|
|
88
|
+
browser_identity = BrowserIdentity.new(Rack::Request.new(env))
|
|
89
|
+
identity_response = mutate_browser_identity(env, params, browser_identity)
|
|
90
|
+
return identity_response if identity_response
|
|
91
|
+
|
|
92
|
+
result = analyze(env, params)
|
|
93
|
+
Panel.render(params: params, access_result: result, route_lookup_limitation: lookup&.limitation,
|
|
94
|
+
csrf_token: browser_token(browser_identity),
|
|
95
|
+
browser_identity_active: browser_identity_active?(browser_identity),
|
|
96
|
+
unapproved_candidate_count: unapproved_candidate_count(result),
|
|
97
|
+
principal_source_selection_saved: !selection.nil? && selection.error.nil?,
|
|
98
|
+
principal_source_selection_error: selection&.error)
|
|
99
|
+
end
|
|
100
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
101
|
+
|
|
102
|
+
# [selection_record, nil] once saved, [nil, forbidden_response] when an
|
|
103
|
+
# attempted save was refused, or [nil, nil] for any other operation.
|
|
104
|
+
# Unlike every other operation Karst serves at /karst, saving a
|
|
105
|
+
# selection writes local state that outlives the request -- exactly
|
|
106
|
+
# like approving a candidate population -- and this page issues no
|
|
107
|
+
# CSRF token of its own precisely while ambiguous
|
|
108
|
+
# (Identity.browser_supported? is false until a selection resolves the
|
|
109
|
+
# ambiguity), so the check is same-origin rather than token-based; see
|
|
110
|
+
# #approved_origin?.
|
|
111
|
+
def principal_source_selection_result(env, params)
|
|
112
|
+
return [nil, nil] unless saving_principal_source_selection?(env, params)
|
|
113
|
+
return [nil, forbidden] unless approved_origin?(env)
|
|
114
|
+
|
|
115
|
+
[save_principal_source_selection(params), nil]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def saving_principal_source_selection?(env, params)
|
|
119
|
+
env["REQUEST_METHOD"] == "POST" && params["operation"] == "select_principal_sources"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Only a model Devise itself currently maps can ever be written --
|
|
123
|
+
# exactly like candidate-population approval only ever writes a
|
|
124
|
+
# currently discovered scope -- so this form can never seed the file
|
|
125
|
+
# with an arbitrary submitted class name.
|
|
126
|
+
def save_principal_source_selection(params)
|
|
127
|
+
submitted = Array(params["principal"]).map(&:to_s)
|
|
128
|
+
names = Identity::DeviseSupport.mappings.map { |mapping| mapping.model.name }
|
|
129
|
+
.select { |name| submitted.include?(name) }
|
|
130
|
+
Access::PrincipalSourceSelection.replace(names)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Unlike every other operation Karst serves, approving writes local
|
|
134
|
+
# state that outlives the request, so every POST to this path must
|
|
135
|
+
# have come from Karst's own page. There is no Rack session to hang a
|
|
136
|
+
# CSRF token on here (this page deliberately touches none), so the
|
|
137
|
+
# check is same-origin rather than token-based; a cross-site form POST
|
|
138
|
+
# cannot forge Origin, so it cannot approve anything. GET -- the page
|
|
139
|
+
# itself -- stays unauthenticated exactly like /karst.
|
|
140
|
+
def call_populations(env)
|
|
141
|
+
return forbidden unless approved_origin?(env)
|
|
142
|
+
|
|
143
|
+
params = owned_params(env)
|
|
144
|
+
discovery = Access::PopulationDiscovery.new.call
|
|
145
|
+
saving = saving_approvals?(env, params)
|
|
146
|
+
record = saving ? save_approvals(discovery, params) : Access::PopulationApprovals.load
|
|
147
|
+
render_populations(discovery, params, record, saving)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def render_populations(discovery, params, record, saved)
|
|
151
|
+
approved = approved_candidates(discovery, record)
|
|
152
|
+
snippet = Access::PopulationConfigSnippet.generate(approved) if params["generate_snippet"]
|
|
153
|
+
Web::PopulationsPanel.render(
|
|
154
|
+
discovery: discovery, approved: record.entries, stale: stale_approvals(record),
|
|
155
|
+
snippet: snippet, preview: population_preview(discovery, params),
|
|
156
|
+
storage_path: Access::PopulationApprovals.display_path, storage_error: record.error, saved: saved
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def saving_approvals?(env, params)
|
|
161
|
+
env["REQUEST_METHOD"] == "POST" && params.key?("save_approvals")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Only a candidate the *current* discovery result actually lists can
|
|
165
|
+
# ever be written, so the file cannot be seeded through this form with
|
|
166
|
+
# a model/scope pair Karst would refuse to confirm later anyway.
|
|
167
|
+
def save_approvals(discovery, params)
|
|
168
|
+
raw = Array(params["population"]).map(&:to_s)
|
|
169
|
+
entries = discovery.candidates.filter_map do |candidate|
|
|
170
|
+
next unless raw.include?(candidate_key(candidate))
|
|
171
|
+
|
|
172
|
+
Access::PopulationApprovals::Entry.new(model_name: candidate.model_name,
|
|
173
|
+
method_name: candidate.method_name.to_s)
|
|
174
|
+
end
|
|
175
|
+
Access::PopulationApprovals.replace(entries)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Discovery candidates (which carry principal-source metadata the
|
|
179
|
+
# snippet generator needs) for the approved entries that are still
|
|
180
|
+
# discovered at all.
|
|
181
|
+
def approved_candidates(discovery, record)
|
|
182
|
+
discovery.candidates.select { |candidate| record.approved?(candidate.model_name, candidate.method_name) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def stale_approvals(record)
|
|
186
|
+
Access::ApprovedPopulations.stale(safe_principal_sources, record: record)
|
|
187
|
+
rescue StandardError
|
|
188
|
+
[].freeze
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def safe_principal_sources
|
|
192
|
+
Identity.principal_sources
|
|
193
|
+
rescue Identity::Error
|
|
194
|
+
{}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# An absent Origin (a non-browser client, or an older browser that
|
|
198
|
+
# only sends Referer) falls back to Referer; a POST carrying neither
|
|
199
|
+
# is refused rather than trusted.
|
|
200
|
+
def approved_origin?(env)
|
|
201
|
+
return true unless env["REQUEST_METHOD"] == "POST"
|
|
202
|
+
|
|
203
|
+
request = Rack::Request.new(env)
|
|
204
|
+
expected = "#{request.scheme}://#{request.host_with_port}"
|
|
205
|
+
origin = env["HTTP_ORIGIN"]
|
|
206
|
+
return origin == expected if origin
|
|
207
|
+
|
|
208
|
+
referer = env["HTTP_REFERER"].to_s
|
|
209
|
+
referer == expected || referer.start_with?("#{expected}/")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def forbidden
|
|
213
|
+
[403, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, ["Forbidden"]]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def population_preview(discovery, params)
|
|
217
|
+
key = params["preview"].to_s
|
|
218
|
+
return nil if key.empty?
|
|
219
|
+
|
|
220
|
+
candidate = discovery.candidates.find { |item| candidate_key(item) == key }
|
|
221
|
+
return nil unless candidate
|
|
222
|
+
|
|
223
|
+
Access::PopulationPreview.call(model_name: candidate.model_name, method_name: candidate.method_name,
|
|
224
|
+
discovery_result: discovery)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def candidate_key(candidate)
|
|
228
|
+
"#{candidate.model_name}#{CANDIDATE_SEPARATOR}#{candidate.method_name}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def call_with_badge(env)
|
|
232
|
+
Karst::ExecutionContext[CONTEXT_KEY] = nil
|
|
233
|
+
status, headers, body = @app.call(env)
|
|
234
|
+
context = Karst::ExecutionContext[CONTEXT_KEY]
|
|
235
|
+
|
|
236
|
+
Badge.apply(status: status, headers: headers, body: body, context: context) || [status, headers, body]
|
|
237
|
+
ensure
|
|
238
|
+
Karst::ExecutionContext.delete(CONTEXT_KEY)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def owned?(env)
|
|
242
|
+
[OWNED_PATH, POPULATIONS_PATH].include?(env["PATH_INFO"])
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def owned_params(env)
|
|
246
|
+
query = Rack::Utils.parse_nested_query(env["QUERY_STRING"].to_s)
|
|
247
|
+
return query unless env["REQUEST_METHOD"] == "POST"
|
|
248
|
+
|
|
249
|
+
query.merge(Rack::Request.new(env).POST)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def recognize_manual_route(env, params)
|
|
253
|
+
return unless env["REQUEST_METHOD"] == "GET" && params["operation"] == "route_lookup"
|
|
254
|
+
|
|
255
|
+
RouteLookup.new(path: params["path"], http_method: params["method"]).call
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# One analysis operation, not two: Access::Search runs the ordinary
|
|
259
|
+
# bounded sample and, only if that finds nothing usable, automatically
|
|
260
|
+
# retries each *approved* candidate population in configuration order
|
|
261
|
+
# (see Karst::Access::Search). There is deliberately no separate
|
|
262
|
+
# "try this population" operation for a developer to press --
|
|
263
|
+
# and no path by which a merely discovered, unapproved population
|
|
264
|
+
# name can be executed.
|
|
265
|
+
def analyze(env, params)
|
|
266
|
+
return nil unless env["REQUEST_METHOD"] == "POST"
|
|
267
|
+
return nil unless params["operation"] == "access_sweep"
|
|
268
|
+
|
|
269
|
+
Access::Search.new(path: params["path"], http_method: params["method"],
|
|
270
|
+
sources: Identity.principal_sources).call
|
|
271
|
+
rescue Access::Error, Identity::Error, ArgumentError => e
|
|
272
|
+
e
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# How many application-defined groups on an already-configured user
|
|
276
|
+
# source a developer could still approve. Computed only after an
|
|
277
|
+
# analysis that found nothing usable -- the one moment the answer is
|
|
278
|
+
# actionable -- so an ordinary panel render never parses model source,
|
|
279
|
+
# and the main page never turns into a population-configuration
|
|
280
|
+
# workflow. Discovery executes nothing; see PopulationDiscovery.
|
|
281
|
+
def unapproved_candidate_count(result)
|
|
282
|
+
return nil unless result.is_a?(Access::Search::Result) && result.verified_outcome.nil?
|
|
283
|
+
|
|
284
|
+
record = Access::PopulationApprovals.load
|
|
285
|
+
count = Access::PopulationDiscovery.new.call.candidates.count do |candidate|
|
|
286
|
+
candidate.principal_source && !record.approved?(candidate.model_name, candidate.method_name)
|
|
287
|
+
end
|
|
288
|
+
count.positive? ? count : nil
|
|
289
|
+
rescue StandardError
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def mutate_browser_identity(env, params, browser_identity)
|
|
294
|
+
return unless env["REQUEST_METHOD"] == "POST"
|
|
295
|
+
|
|
296
|
+
path = case params["operation"]
|
|
297
|
+
when "test_as" then browser_identity.assume(params)
|
|
298
|
+
when "stop_test_as" then browser_identity.clear(params)
|
|
299
|
+
end
|
|
300
|
+
path && identity_navigation_response(env, params, path)
|
|
301
|
+
rescue Identity::Error
|
|
302
|
+
forbidden
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def identity_navigation_response(env, params, path)
|
|
306
|
+
if params["operation"] == "test_as" && env["HTTP_ACCEPT"].to_s.include?("application/json")
|
|
307
|
+
body = JSON.generate(location: path)
|
|
308
|
+
[200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [body]]
|
|
309
|
+
else
|
|
310
|
+
[303, { "location" => path, "cache-control" => "no-store" }, []]
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def browser_token(browser_identity)
|
|
315
|
+
browser_identity.token if Identity.browser_supported?
|
|
316
|
+
rescue Identity::Error
|
|
317
|
+
nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def browser_identity_active?(browser_identity)
|
|
321
|
+
Identity.browser_supported? && browser_identity.active?
|
|
322
|
+
rescue Identity::Error
|
|
323
|
+
false
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Re-checked per request as defense in depth: the middleware is only
|
|
327
|
+
# inserted into the stack in development (see Railtie), but this keeps
|
|
328
|
+
# that guarantee independent of how or when the middleware was inserted.
|
|
329
|
+
# config.enabled is read live rather than at insertion time, so turning
|
|
330
|
+
# Karst off never depends on initializer ordering.
|
|
331
|
+
def development?
|
|
332
|
+
Rails.env.development? && Karst.enabled?
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
class << self
|
|
336
|
+
# One subscription for the process lifetime of this middleware class,
|
|
337
|
+
# regardless of how many instances Rack::Builder creates: the
|
|
338
|
+
# notification is process-wide by nature, and its callback only ever
|
|
339
|
+
# writes into the current thread/fiber's own IsolatedExecutionState
|
|
340
|
+
# slot, so a single subscription safely serves every request.
|
|
341
|
+
def ensure_context_capture!
|
|
342
|
+
@context_capture_mutex ||= Mutex.new
|
|
343
|
+
@context_capture_mutex.synchronize do
|
|
344
|
+
next if @context_capture_installed
|
|
345
|
+
|
|
346
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
347
|
+
capture_context(args.last)
|
|
348
|
+
end
|
|
349
|
+
@context_capture_installed = true
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
private
|
|
354
|
+
|
|
355
|
+
def capture_context(payload)
|
|
356
|
+
return unless payload.respond_to?(:[])
|
|
357
|
+
|
|
358
|
+
Karst::ExecutionContext[CONTEXT_KEY] = Badge::Context.new(
|
|
359
|
+
controller: payload[:controller], action: payload[:action],
|
|
360
|
+
http_method: payload[:method], path: strip_query(payload[:path])
|
|
361
|
+
)
|
|
362
|
+
rescue StandardError
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Mirrors Karst::Spec::Observer's own treatment of request paths: a
|
|
367
|
+
# query string can carry a token (password reset, OAuth callback,
|
|
368
|
+
# signed URL), and this path is only ever contextual display evidence
|
|
369
|
+
# in the panel, never route identity, so it is never worth the risk.
|
|
370
|
+
def strip_query(value)
|
|
371
|
+
value.to_s.split("?").first
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
# rubocop:enable Metrics/ClassLength
|
|
376
|
+
end
|
|
377
|
+
end
|