karst 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +6 -1
- data/CHANGELOG.md +82 -2
- data/CONTRIBUTING.md +9 -1
- data/README.md +118 -2
- data/docs/advanced-configuration.md +106 -0
- data/docs/rails8-authentication.md +24 -4
- data/docs/request-reproduction.md +242 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
- data/lib/karst/access/approved_populations.rb +15 -6
- data/lib/karst/access/errors.rb +16 -0
- data/lib/karst/access/identity_probe.rb +151 -0
- data/lib/karst/access/local_path.rb +42 -0
- data/lib/karst/access/observed_endpoint.rb +46 -0
- data/lib/karst/access/outcome_groups.rb +93 -0
- data/lib/karst/access/population_discovery.rb +25 -1
- data/lib/karst/access/principal_source.rb +24 -3
- data/lib/karst/access/probe.rb +397 -0
- data/lib/karst/access/search.rb +14 -6
- data/lib/karst/access/sweep.rb +42 -112
- data/lib/karst/cli/identity_serialization.rb +82 -0
- data/lib/karst/cli/outcome_summary.rb +131 -0
- data/lib/karst/cli/principal_reference.rb +51 -0
- data/lib/karst/cli/reproduction.rb +267 -0
- data/lib/karst/cli/verification.rb +219 -55
- data/lib/karst/configuration.rb +52 -6
- data/lib/karst/identity/evidence.rb +110 -0
- data/lib/karst/identity/observation.rb +167 -0
- data/lib/karst/identity/warden_adapter.rb +26 -1
- data/lib/karst/identity.rb +157 -22
- data/lib/karst/mcp/compatibility.rb +24 -0
- data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
- data/lib/karst/mcp/reproduction.rb +58 -0
- data/lib/karst/mcp/server.rb +27 -8
- data/lib/karst/mcp/verify_access_tool.rb +36 -2
- data/lib/karst/reproduction/curl.rb +73 -0
- data/lib/karst/reproduction/exercise.rb +246 -0
- data/lib/karst/reproduction/observation.rb +87 -0
- data/lib/karst/reproduction/sanitizer.rb +210 -0
- data/lib/karst/version.rb +1 -1
- data/lib/karst/web/middleware.rb +105 -16
- data/lib/karst/web/panel.rb +302 -47
- data/lib/karst.rb +2 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +33 -6
|
@@ -14,6 +14,9 @@ module Karst
|
|
|
14
14
|
|
|
15
15
|
Candidate = Value.define(:model_name, :method_name, :principal_source)
|
|
16
16
|
|
|
17
|
+
UNRESOLVED = Object.new.freeze
|
|
18
|
+
private_constant :UNRESOLVED
|
|
19
|
+
|
|
17
20
|
ModelGroup = Value.define(:model_name, :candidate_names, :principal_source) do
|
|
18
21
|
def candidates
|
|
19
22
|
candidate_names.map do |method_name|
|
|
@@ -28,6 +31,20 @@ module Karst
|
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
|
|
34
|
+
# `principal_sources:`, when given, is either the caller's already-
|
|
35
|
+
# resolved effective principal source Hash (see
|
|
36
|
+
# Karst::Identity::Snapshot), or a zero-argument callable that resolves
|
|
37
|
+
# to one -- used, at most once, lazily, only if a check here actually
|
|
38
|
+
# needs it (#confirms? never does). The callable form lets a caller
|
|
39
|
+
# hand over a source of truth that is not resolved yet at construction
|
|
40
|
+
# time but will be by the time this discovery's #call actually runs
|
|
41
|
+
# (see Karst::Web::Middleware, which builds one discovery instance
|
|
42
|
+
# before resolving the operation's Identity::Snapshot and reuses it
|
|
43
|
+
# afterward). Omitted, configuration is resolved here instead.
|
|
44
|
+
def initialize(principal_sources: UNRESOLVED)
|
|
45
|
+
@principal_sources = principal_sources
|
|
46
|
+
end
|
|
47
|
+
|
|
31
48
|
def call
|
|
32
49
|
Result.new(model_groups: model_groups, load_warning: @load_warning)
|
|
33
50
|
end
|
|
@@ -119,7 +136,7 @@ module Karst
|
|
|
119
136
|
# and re-parse model source once per model group too.
|
|
120
137
|
def principal_source_klasses
|
|
121
138
|
@principal_source_klasses ||= begin
|
|
122
|
-
sources =
|
|
139
|
+
sources = resolved_principal_sources || {}
|
|
123
140
|
sources.each_with_object({}) do |(name, source), memo|
|
|
124
141
|
klass = source.record_klass
|
|
125
142
|
memo[name] = klass if klass
|
|
@@ -127,6 +144,13 @@ module Karst
|
|
|
127
144
|
end
|
|
128
145
|
end
|
|
129
146
|
|
|
147
|
+
def resolved_principal_sources
|
|
148
|
+
return @principal_sources.call if @principal_sources.respond_to?(:call)
|
|
149
|
+
return Karst.config.principal_sources if @principal_sources.equal?(UNRESOLVED)
|
|
150
|
+
|
|
151
|
+
@principal_sources
|
|
152
|
+
end
|
|
153
|
+
|
|
130
154
|
# A deliberately small Ripper AST reader. It recognizes only literal
|
|
131
155
|
# names and the two callable forms accepted here: -> {} and lambda {}.
|
|
132
156
|
# rubocop:disable Metrics/ClassLength
|
|
@@ -19,12 +19,15 @@ module Karst
|
|
|
19
19
|
class PrincipalSource
|
|
20
20
|
attr_reader :name, :records, :populations
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
# `evaluation` is internal: the shared memo cell #evaluated_once copies
|
|
23
|
+
# carry. nil (the default) evaluates `records` on every call.
|
|
24
|
+
def initialize(name:, records:, populations: {}, evaluation: nil)
|
|
23
25
|
raise ArgumentError, "principal source #{name.inspect} must be callable" unless records.respond_to?(:call)
|
|
24
26
|
|
|
25
27
|
@name = name.to_sym
|
|
26
28
|
@records = records
|
|
27
29
|
@populations = self.class.normalize_populations(@name, populations)
|
|
30
|
+
@evaluation = evaluation
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
# Evaluates the configured records callable. Never enumerates or
|
|
@@ -32,7 +35,10 @@ module Karst
|
|
|
32
35
|
# Relation, exactly like Karst::Identity.principals already did for
|
|
33
36
|
# the single-source case.
|
|
34
37
|
def evaluate
|
|
35
|
-
records.call
|
|
38
|
+
return records.call unless @evaluation
|
|
39
|
+
return @evaluation[:records] if @evaluation.key?(:records)
|
|
40
|
+
|
|
41
|
+
@evaluation[:records] = records.call
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
# The Active Record class this source's records ultimately belong to,
|
|
@@ -53,6 +59,20 @@ module Karst
|
|
|
53
59
|
nil
|
|
54
60
|
end
|
|
55
61
|
|
|
62
|
+
# A copy of this source whose records callable runs at most once, for
|
|
63
|
+
# as long as the copy (and any #with_populations copy of it) lives: the
|
|
64
|
+
# first #evaluate result is reused by every later #evaluate and
|
|
65
|
+
# #record_klass. Configuration#principal_sources hands out these
|
|
66
|
+
# copies, one set per resolution, so a single operation holding one
|
|
67
|
+
# resolution (see Karst::Identity::Snapshot) never re-runs an
|
|
68
|
+
# application callable to sample, match approvals, and check Devise
|
|
69
|
+
# scopes, while the next resolution still evaluates afresh. `records`
|
|
70
|
+
# itself is unchanged, and a callable that raises is not memoized: each
|
|
71
|
+
# caller sees the failure, as before.
|
|
72
|
+
def evaluated_once
|
|
73
|
+
self.class.new(name: @name, records: @records, populations: @populations, evaluation: {})
|
|
74
|
+
end
|
|
75
|
+
|
|
56
76
|
# A copy of this source with `extra` populations appended after its own
|
|
57
77
|
# configured ones. Used by Karst::Access::ApprovedPopulations to fold
|
|
58
78
|
# locally approved discovered scopes into the effective configuration,
|
|
@@ -66,7 +86,8 @@ module Karst
|
|
|
66
86
|
merged = self.class.normalize_populations(@name, extra).reject { |name, _| @populations.key?(name) }
|
|
67
87
|
return self if merged.empty?
|
|
68
88
|
|
|
69
|
-
self.class.new(name: @name, records: @records, populations: @populations.merge(merged)
|
|
89
|
+
self.class.new(name: @name, records: @records, populations: @populations.merge(merged),
|
|
90
|
+
evaluation: @evaluation)
|
|
70
91
|
end
|
|
71
92
|
|
|
72
93
|
# Accepts a raw Hash of name => (callable, or {records:,
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "probe_application"
|
|
7
|
+
require_relative "database_isolation"
|
|
8
|
+
require_relative "identity_probe"
|
|
9
|
+
require_relative "../identity"
|
|
10
|
+
require_relative "../value"
|
|
11
|
+
|
|
12
|
+
module Karst
|
|
13
|
+
module Access
|
|
14
|
+
# The one place Karst executes a request against the application and
|
|
15
|
+
# observes what it did.
|
|
16
|
+
#
|
|
17
|
+
# Access::Sweep ("which identities get through this route?") and
|
|
18
|
+
# Reproduction::Exercise ("what does this exact request do?") issue every
|
|
19
|
+
# request through #call and differ only in what they ask and how they
|
|
20
|
+
# present the answer. So one request, as one identity, against one target
|
|
21
|
+
# produces one observation, whichever feature -- and whichever CLI
|
|
22
|
+
# command, MCP tool, or panel operation above it -- asked.
|
|
23
|
+
#
|
|
24
|
+
# Every probe runs in three phases, and only the middle one is the request
|
|
25
|
+
# under test:
|
|
26
|
+
#
|
|
27
|
+
# setup Access::IdentityProbe#establish (sign-in requests and hooks),
|
|
28
|
+
# plus Karst's own Warden sign-in when it can only be applied
|
|
29
|
+
# inside the target request itself (see Identity.establishing)
|
|
30
|
+
# target exactly one request, observed
|
|
31
|
+
# teardown Access::IdentityProbe#release (sign-out requests and hooks)
|
|
32
|
+
#
|
|
33
|
+
# Target evidence -- mutating SQL, the halted callback, the controller
|
|
34
|
+
# lifecycle, renders -- is recorded only by subscriptions that exist for
|
|
35
|
+
# the target request's own duration, only on this thread, and never while
|
|
36
|
+
# Karst is doing its own identity work inside that request. A sign-in
|
|
37
|
+
# that inserts a session row, a sign-out that deletes it, or a Devise
|
|
38
|
+
# :trackable UPDATE is setup/teardown, never the route's behavior; a write
|
|
39
|
+
# the route itself makes always is. Writes are counted when attempted, so
|
|
40
|
+
# the rollback below never erases them from the evidence.
|
|
41
|
+
#
|
|
42
|
+
# All three phases run inside one rollback-only transaction on the same
|
|
43
|
+
# connection -- containment for database writes on that connection and
|
|
44
|
+
# nothing else (jobs, mail, outbound HTTP, files, and other connections are
|
|
45
|
+
# not contained).
|
|
46
|
+
#
|
|
47
|
+
# Three kinds of failure are kept apart rather than folded together:
|
|
48
|
+
#
|
|
49
|
+
# - The target raising (or rendering a captured exception) is observed
|
|
50
|
+
# application behavior: exception_class/exception_phase.
|
|
51
|
+
# - Identity setup failing is identity evidence (identity.establishment
|
|
52
|
+
# :failed and its error). The target still runs and is still
|
|
53
|
+
# observed -- except when Karst's own in-request sign-in raised, in
|
|
54
|
+
# which case the route never ran and nothing is reported for it.
|
|
55
|
+
# - Karst failing to observe (one of its own subscribers raising, a
|
|
56
|
+
# misconfigured observe_identity, a request that never reached the
|
|
57
|
+
# application) fails the call with ObservationError or the
|
|
58
|
+
# configuration error itself. Evidence Karst could not gather is never
|
|
59
|
+
# reported as the application having done nothing.
|
|
60
|
+
class Probe
|
|
61
|
+
# The authoritative observation of one target request.
|
|
62
|
+
#
|
|
63
|
+
# controller/action are the controller the target request dispatched to,
|
|
64
|
+
# read from its own Rack env (nil when it reached none).
|
|
65
|
+
# controller_completed is whether process_action finished without an
|
|
66
|
+
# exception (nil when process_action never ran). status, redirect
|
|
67
|
+
# (query-stripped), response_content_type, and route_params (the
|
|
68
|
+
# router's own path_parameters, minus controller/action) are read only
|
|
69
|
+
# when the target completed, so a raising target never inherits an
|
|
70
|
+
# earlier request's response. halted_callback is the filter exactly as
|
|
71
|
+
# Rails reported it. write_count counts mutating SQL statements the
|
|
72
|
+
# target attempted. elapsed_ms covers the whole probe, setup and
|
|
73
|
+
# teardown included.
|
|
74
|
+
Observation = Value.define(
|
|
75
|
+
:identity, :controller, :action, :controller_completed, :status, :redirect, :response_content_type,
|
|
76
|
+
:halted_callback, :exception_class, :exception_phase, :rendered, :write_count, :route_params, :elapsed_ms
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def initialize(application)
|
|
80
|
+
@endpoint = ProbeApplication.for(application)
|
|
81
|
+
rescue ProbeApplication::ConstructionError => e
|
|
82
|
+
raise Unavailable, e.message, cause: e
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# `target` is a local path, including any query string, exactly as it
|
|
86
|
+
# is sent. `body` travels as the request body, never as query
|
|
87
|
+
# parameters.
|
|
88
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
89
|
+
def call(principal:, target:, http_method: "GET", body: nil, headers: {})
|
|
90
|
+
identity = IdentityProbe.new(principal)
|
|
91
|
+
session = open_session(identity)
|
|
92
|
+
method = http_method.to_s.downcase.to_sym
|
|
93
|
+
window = TargetWindow.new(identity)
|
|
94
|
+
started = monotonic
|
|
95
|
+
with_rollback do
|
|
96
|
+
# Never raises: a probe whose identity could not be established
|
|
97
|
+
# still runs, and reports what the application actually saw.
|
|
98
|
+
identity.establish(session)
|
|
99
|
+
begin
|
|
100
|
+
window.run { session.process(method, target, params: body, headers: headers) }
|
|
101
|
+
# Strictly before release: clearing the identity is exactly what
|
|
102
|
+
# would make a completed request look anonymous, and a sign-out
|
|
103
|
+
# request would replace the target's env and response.
|
|
104
|
+
identity.observe(:request_completion)
|
|
105
|
+
window.capture(session)
|
|
106
|
+
ensure
|
|
107
|
+
identity.release(session)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
window.verify!
|
|
111
|
+
window.observation(elapsed(started))
|
|
112
|
+
end
|
|
113
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# One fresh integration session per probe, reused for setup, target,
|
|
118
|
+
# and teardown so cookies behave like one real client.
|
|
119
|
+
def open_session(identity)
|
|
120
|
+
require "action_dispatch/testing/integration" unless defined?(ActionDispatch::Integration::Session)
|
|
121
|
+
|
|
122
|
+
session = ActionDispatch::Integration::Session.new(identity.endpoint(@endpoint))
|
|
123
|
+
session.host!(@endpoint.host) if @endpoint.respond_to?(:host) && @endpoint.host
|
|
124
|
+
session
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def with_rollback
|
|
128
|
+
raise Unavailable, "Active Record rollback isolation is unavailable" unless defined?(ActiveRecord::Base)
|
|
129
|
+
|
|
130
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
131
|
+
yield
|
|
132
|
+
raise ActiveRecord::Rollback
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def monotonic
|
|
137
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def elapsed(started)
|
|
141
|
+
((monotonic - started) * 1000.0).round(1)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Everything observed about one target request: open only while that
|
|
145
|
+
# request runs, on the thread that runs it.
|
|
146
|
+
# rubocop:disable Metrics/ClassLength
|
|
147
|
+
class TargetWindow
|
|
148
|
+
def initialize(identity)
|
|
149
|
+
@identity = identity
|
|
150
|
+
@thread = Thread.current
|
|
151
|
+
@writes = 0
|
|
152
|
+
@render_events = []
|
|
153
|
+
@paused = false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# rubocop:disable Naming/BlockForwarding -- anonymous block forwarding
|
|
157
|
+
# needs Ruby 3.1; this file runs on Ruby 2.7.
|
|
158
|
+
def run(&request)
|
|
159
|
+
@identity.begin_target
|
|
160
|
+
subscribed(&request)
|
|
161
|
+
@completed = true
|
|
162
|
+
rescue Identity::EstablishmentError => e
|
|
163
|
+
# Karst's own sign-in raised inside the request, before the
|
|
164
|
+
# application dispatched it: identity setup failed, and the route
|
|
165
|
+
# never ran.
|
|
166
|
+
@identity.establishment_failed(e.message)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
target_raised(e)
|
|
169
|
+
end
|
|
170
|
+
# rubocop:enable Naming/BlockForwarding
|
|
171
|
+
|
|
172
|
+
def capture(session)
|
|
173
|
+
env = @identity.request_env
|
|
174
|
+
@controller, @action = dispatched(env)
|
|
175
|
+
@controller_completed = !@dispatch.key?(:exception) if @dispatch
|
|
176
|
+
return unless @completed && env
|
|
177
|
+
|
|
178
|
+
# Rails may render an application exception through ShowExceptions
|
|
179
|
+
# rather than re-raise it, depending on host and Rails-version
|
|
180
|
+
# configuration; the original is then recorded on the env.
|
|
181
|
+
@exception = env["action_dispatch.exception"]
|
|
182
|
+
return if @exception
|
|
183
|
+
|
|
184
|
+
# Only now is session.request/session.response provably the target's:
|
|
185
|
+
# ActionDispatch::Integration::Session reassigns them only after the
|
|
186
|
+
# request returns without raising.
|
|
187
|
+
@status = session.response.status
|
|
188
|
+
@redirect = clean_redirect(session.response.location) if @status >= 300 && @status < 400
|
|
189
|
+
@response_content_type = response_content_type(session)
|
|
190
|
+
@route_params = route_params(session)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A misconfigured observation seam is a configuration error and says
|
|
194
|
+
# so; any other failure of Karst's own observation means the evidence
|
|
195
|
+
# is incomplete, and none of it is reported.
|
|
196
|
+
def verify!
|
|
197
|
+
return unless @failure
|
|
198
|
+
raise @failure if @failure.is_a?(Identity::ConfigurationError)
|
|
199
|
+
|
|
200
|
+
raise ObservationError,
|
|
201
|
+
"Karst could not observe this request (#{@failure.class}: #{@failure.message}); " \
|
|
202
|
+
"no evidence was reported for it",
|
|
203
|
+
cause: @failure
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def observation(elapsed_ms)
|
|
207
|
+
Observation.new(
|
|
208
|
+
identity: @identity.evidence, controller: @controller, action: @action,
|
|
209
|
+
controller_completed: @controller_completed, status: @status, redirect: @redirect,
|
|
210
|
+
response_content_type: @response_content_type, halted_callback: @halted_callback,
|
|
211
|
+
exception_class: @exception&.class&.name, exception_phase: (exception_phase if @exception),
|
|
212
|
+
rendered: rendered, write_count: @writes, route_params: @route_params, elapsed_ms: elapsed_ms
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Only an exception from a request that actually reached the
|
|
219
|
+
# application is the application's own; anything earlier is Karst
|
|
220
|
+
# failing to issue the request at all.
|
|
221
|
+
def target_raised(error)
|
|
222
|
+
if @identity.request_env
|
|
223
|
+
@exception = error
|
|
224
|
+
else
|
|
225
|
+
@failure ||= error # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Notification subscriptions are process-wide; #recording? keeps a
|
|
230
|
+
# concurrent request on another thread of a real development server
|
|
231
|
+
# from ever being counted as this target's evidence.
|
|
232
|
+
def subscribed(&block)
|
|
233
|
+
notifications = ActiveSupport::Notifications
|
|
234
|
+
notifications.subscribed(guarded(:record_write), "sql.active_record") do
|
|
235
|
+
notifications.subscribed(guarded(:record_halt), "halted_callback.action_controller") do
|
|
236
|
+
notifications.subscribed(guarded(:record_render), "!render_template.action_view") do
|
|
237
|
+
notifications.subscribed(guarded(:record_dispatch), "process_action.action_controller", &block)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# A subscriber that raises would raise into the application's own
|
|
244
|
+
# instrumented code -- a SQL statement, a callback chain, a
|
|
245
|
+
# transaction -- and surface as the target's exception. Karst's
|
|
246
|
+
# failure is kept as Karst's instead (see #verify!).
|
|
247
|
+
def guarded(handler)
|
|
248
|
+
lambda do |_name, _start, _finish, _id, payload|
|
|
249
|
+
send(handler, payload) if recording?
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
@failure ||= e
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def recording?
|
|
256
|
+
Thread.current.equal?(@thread) && !@paused && !Identity.establishing?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def record_write(payload)
|
|
260
|
+
@writes += 1 if DatabaseIsolation.mutation?(payload[:sql])
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def record_halt(payload)
|
|
264
|
+
@halted_callback ||= payload[:filter]
|
|
265
|
+
# The identity the application had established when this access
|
|
266
|
+
# decision was made -- not after the request unwound. Karst's own
|
|
267
|
+
# observation, so nothing it causes is the target's evidence.
|
|
268
|
+
pause { @identity.observe(:halted_callback) }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# "!render_template.action_view" -- not the higher-level, no-bang
|
|
272
|
+
# "render_template.action_view" -- because it is the one event
|
|
273
|
+
# ActionView::Template itself fires for every template, partial, and
|
|
274
|
+
# layout it renders, and the only one carrying that template's own
|
|
275
|
+
# relative virtual_path rather than an absolute source file. It fires
|
|
276
|
+
# once per Template#render call, whether that call completed or raised
|
|
277
|
+
# (payload[:exception]/[:exception_object] are set first when it
|
|
278
|
+
# raised), so it is a faithful, ordered record of what the target
|
|
279
|
+
# request rendered.
|
|
280
|
+
def record_render(payload)
|
|
281
|
+
return unless payload[:virtual_path]
|
|
282
|
+
|
|
283
|
+
@render_events << { virtual_path: payload[:virtual_path].to_s, completed: !payload.key?(:exception),
|
|
284
|
+
exception_object: payload[:exception_object] }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The last process_action to finish is the outermost one.
|
|
288
|
+
def record_dispatch(payload)
|
|
289
|
+
@dispatch = payload
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def pause
|
|
293
|
+
@paused = true
|
|
294
|
+
yield
|
|
295
|
+
ensure
|
|
296
|
+
@paused = false
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Rails puts the dispatching controller on the request env before any
|
|
300
|
+
# callback runs, so this is present even for a halted request.
|
|
301
|
+
def dispatched(env)
|
|
302
|
+
instance = env && env["action_controller.instance"]
|
|
303
|
+
return [nil, nil] unless instance
|
|
304
|
+
|
|
305
|
+
[presence(instance.class.name), presence(instance.respond_to?(:action_name) ? instance.action_name : nil)]
|
|
306
|
+
rescue StandardError
|
|
307
|
+
[nil, nil]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def presence(value)
|
|
311
|
+
value.to_s.empty? ? nil : value.to_s
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def rendered
|
|
315
|
+
@render_events.map { |event| { virtual_path: event[:virtual_path], completed: event[:completed] }.freeze }
|
|
316
|
+
.freeze
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# The most specific phase Karst can prove an observed exception
|
|
320
|
+
# occurred in, never guessed from its class or message.
|
|
321
|
+
#
|
|
322
|
+
# A render-level exception reaches the controller wrapped in a new
|
|
323
|
+
# ActionView::Template::Error (see ActionView::Template#handle_render_
|
|
324
|
+
# error), so the object the request ultimately raised is not the one
|
|
325
|
+
# "!render_template.action_view" recorded -- it is that object's
|
|
326
|
+
# #cause, or its #cause's #cause for a partial nested inside a
|
|
327
|
+
# template inside a layout. Walking #cause is therefore required to
|
|
328
|
+
# recognize a render exception at all once it has unwound past the
|
|
329
|
+
# template that raised it.
|
|
330
|
+
def exception_phase
|
|
331
|
+
return "render" if @render_events.any? { |event| same_exception?(@exception, event[:exception_object]) }
|
|
332
|
+
return "controller" if @dispatch&.key?(:exception)
|
|
333
|
+
|
|
334
|
+
"unknown"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def same_exception?(exception, candidate)
|
|
338
|
+
return false unless candidate
|
|
339
|
+
|
|
340
|
+
chain = exception
|
|
341
|
+
depth = 0
|
|
342
|
+
while chain && depth < 20
|
|
343
|
+
return true if chain.equal?(candidate)
|
|
344
|
+
|
|
345
|
+
chain = chain.respond_to?(:cause) ? chain.cause : nil
|
|
346
|
+
depth += 1
|
|
347
|
+
end
|
|
348
|
+
false
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The router's own path_parameters -- observed routing, not a second
|
|
352
|
+
# recognize_path guess that could disagree with what dispatched.
|
|
353
|
+
# nil when Karst observed no route.
|
|
354
|
+
def route_params(session)
|
|
355
|
+
raw = session.request.path_parameters
|
|
356
|
+
return nil unless raw.is_a?(Hash) && !raw.empty?
|
|
357
|
+
|
|
358
|
+
raw.each_with_object({}) do |(key, value), result|
|
|
359
|
+
result[key.to_s] = value unless %w[controller action].include?(key.to_s)
|
|
360
|
+
end
|
|
361
|
+
rescue StandardError
|
|
362
|
+
nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def response_content_type(session)
|
|
366
|
+
value = session.response.content_type if session.response.respond_to?(:content_type)
|
|
367
|
+
value.to_s.empty? ? nil : value.to_s
|
|
368
|
+
rescue StandardError
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Query-and-fragment-stripped, never just query-stripped: a redirect
|
|
373
|
+
# target is exactly as capable of carrying a credential in its
|
|
374
|
+
# fragment (an OAuth-style "/callback#access_token=...") as in its
|
|
375
|
+
# query string, and a fragment is never sent back to the server on
|
|
376
|
+
# the follow-up request, so there is no reason reproduction needs it
|
|
377
|
+
# either. The regex fallback below mirrors that -- it drops
|
|
378
|
+
# everything from the first "?" *or* "#", whichever comes first --
|
|
379
|
+
# so a Location Karst cannot even parse as a URI still cannot leak
|
|
380
|
+
# through the one delimiter the query-only split used to miss.
|
|
381
|
+
def clean_redirect(location)
|
|
382
|
+
text = location.to_s
|
|
383
|
+
return nil if text.empty?
|
|
384
|
+
|
|
385
|
+
uri = URI.parse(text)
|
|
386
|
+
uri.query = nil
|
|
387
|
+
uri.fragment = nil
|
|
388
|
+
uri.to_s
|
|
389
|
+
rescue StandardError
|
|
390
|
+
text.split(/[?#]/, 2).first
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
private_constant :TargetWindow
|
|
394
|
+
# rubocop:enable Metrics/ClassLength
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
data/lib/karst/access/search.rb
CHANGED
|
@@ -14,11 +14,11 @@ module Karst
|
|
|
14
14
|
#
|
|
15
15
|
# This is deliberately an orchestrator built on top of the existing
|
|
16
16
|
# primitives rather than new behavior inside them. Access::Sweep still
|
|
17
|
-
#
|
|
18
|
-
# observation, exception, and halted-callback
|
|
19
|
-
# CandidatePopulation still owns resolving one
|
|
20
|
-
# bounded records. Search only decides what to
|
|
21
|
-
# it chose not to run.
|
|
17
|
+
# issues every request (each one an Access::Probe, which owns every
|
|
18
|
+
# rollback, write observation, exception, and halted-callback
|
|
19
|
+
# observation), and CandidatePopulation still owns resolving one
|
|
20
|
+
# configured callable into bounded records. Search only decides what to
|
|
21
|
+
# run next and records what it chose not to run.
|
|
22
22
|
#
|
|
23
23
|
# Approval boundary: the populations considered here are exactly the
|
|
24
24
|
# ones on the Karst::Access::PrincipalSource objects handed to this
|
|
@@ -164,10 +164,18 @@ module Karst
|
|
|
164
164
|
def attempt_population(source_name, name, callable, source)
|
|
165
165
|
return skip(source_name, name, :budget_exhausted) unless budget_remaining.positive?
|
|
166
166
|
|
|
167
|
-
resolved =
|
|
167
|
+
resolved = resolve_population(source_name, name, callable, source)
|
|
168
168
|
return resolved if resolved.is_a?(PopulationAttempt)
|
|
169
169
|
|
|
170
170
|
sweep_population(source_name, name, resolved[:records], resolved[:population])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A population that fails to *resolve* is reported as :unresolved. A
|
|
174
|
+
# failure while *probing* its records is not a fact about the
|
|
175
|
+
# population, so it is never folded into that state: it propagates
|
|
176
|
+
# exactly as it would from the ordinary sample.
|
|
177
|
+
def resolve_population(source_name, name, callable, source)
|
|
178
|
+
candidate_records(source_name, name, callable, source)
|
|
171
179
|
rescue StandardError => e
|
|
172
180
|
skip(source_name, name, :unresolved, error: "#{e.class}: #{e.message}")
|
|
173
181
|
end
|