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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../karst"
|
|
5
|
+
require_relative "identity_serialization"
|
|
6
|
+
require_relative "principal_reference"
|
|
7
|
+
require_relative "../reproduction/exercise"
|
|
8
|
+
require_relative "../reproduction/curl"
|
|
9
|
+
|
|
10
|
+
module Karst
|
|
11
|
+
module CLI
|
|
12
|
+
# The one adapter over Karst::Reproduction::Exercise, shared by
|
|
13
|
+
# `bin/rails karst:reproduce`, the MCP reproduce_request tool, and the
|
|
14
|
+
# /karst panel -- exactly as Karst::CLI::Verification is shared by
|
|
15
|
+
# `karst:verify` and verify_access. Nothing downstream re-runs an
|
|
16
|
+
# Exercise or re-serializes an Observation, so the recipe an agent reads
|
|
17
|
+
# and the recipe a developer reads cannot drift apart.
|
|
18
|
+
#
|
|
19
|
+
# This class owns two decisions Exercise deliberately does not: which
|
|
20
|
+
# identity to send the request under (the first candidate Karst's own
|
|
21
|
+
# sampler offers, or a genuine anonymous probe), and how an Observation
|
|
22
|
+
# becomes a stable evidence document.
|
|
23
|
+
# rubocop:disable Metrics/ClassLength
|
|
24
|
+
class Reproduction
|
|
25
|
+
include IdentitySerialization
|
|
26
|
+
|
|
27
|
+
# 3 (was 2): `execution` gained controller_completed, exception_phase,
|
|
28
|
+
# and rendered -- additive -- but `response` (status/content_type/
|
|
29
|
+
# redirect) also stopped trusting session.request/session.response
|
|
30
|
+
# once a target request raised before producing one. Those fields
|
|
31
|
+
# could previously read back an *earlier* request's status, content
|
|
32
|
+
# type, or location on the same reproduction session (identity
|
|
33
|
+
# establishment's own request, most often); they now report nil, named
|
|
34
|
+
# in `unobserved`, in exactly that situation. This is a correctness
|
|
35
|
+
# fix, not a new failure mode: any consumer reading `response.status`
|
|
36
|
+
# after `execution.exception_class` was already set was reading a
|
|
37
|
+
# value Karst should never have reported.
|
|
38
|
+
SCHEMA_VERSION = 3
|
|
39
|
+
|
|
40
|
+
# Same-connection rollback contains database writes and nothing else.
|
|
41
|
+
# Named in the document rather than left implicit, so an agent
|
|
42
|
+
# reasoning about whether to issue a mutating request sees the real
|
|
43
|
+
# boundary instead of assuming "isolated" means isolated.
|
|
44
|
+
NOT_ISOLATED = ["background jobs", "mail", "outbound HTTP", "files", "other database connections"].freeze
|
|
45
|
+
|
|
46
|
+
# `as`, when given, is a human-only "MODEL:ID" reference (e.g.
|
|
47
|
+
# "User:72") naming one existing principal to send this one request as,
|
|
48
|
+
# instead of the ordinary sampled candidate -- see
|
|
49
|
+
# Karst::CLI::PrincipalReference. Resolved exclusively through
|
|
50
|
+
# Karst::Identity.resolve, so it can never select a record outside a
|
|
51
|
+
# configured principal source. Mutually exclusive with `anonymous`;
|
|
52
|
+
# nothing about this reaches the MCP reproduce_request tool, which
|
|
53
|
+
# never accepts it.
|
|
54
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
|
|
55
|
+
def initialize(path:, http_method: "GET", body: nil, content_type: nil, headers: {},
|
|
56
|
+
anonymous: false, as: nil, base_url: nil, output: $stdout, json: false)
|
|
57
|
+
PrincipalReference.parse(as) if as
|
|
58
|
+
raise ArgumentError, "--anonymous and --as cannot be combined" if anonymous && as
|
|
59
|
+
|
|
60
|
+
@path = path
|
|
61
|
+
@http_method = http_method
|
|
62
|
+
@body = body
|
|
63
|
+
@content_type = content_type
|
|
64
|
+
@headers = headers || {}
|
|
65
|
+
@anonymous = anonymous
|
|
66
|
+
@as = as
|
|
67
|
+
@base_url = base_url.to_s.empty? ? Karst::Reproduction::Curl::DEFAULT_BASE_URL : base_url.to_s
|
|
68
|
+
@output = output
|
|
69
|
+
@json = json
|
|
70
|
+
end
|
|
71
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
72
|
+
|
|
73
|
+
def call
|
|
74
|
+
observation = run
|
|
75
|
+
@output.puts(@json ? JSON.generate(document(observation)) : human(observation))
|
|
76
|
+
observation.exception_class ? 1 : 0
|
|
77
|
+
rescue Access::Error, Identity::Error, ArgumentError => e
|
|
78
|
+
@output.puts(@json ? JSON.generate(error_document(e)) : "Karst cannot reproduce this request:\n#{e.message}")
|
|
79
|
+
2
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The stable evidence document, with no dependency on @output. Always
|
|
83
|
+
# returns a Hash -- either the recipe or error_document(e) -- so no
|
|
84
|
+
# adapter has to decide how a Karst failure becomes a transport error.
|
|
85
|
+
def evidence
|
|
86
|
+
document(run)
|
|
87
|
+
rescue Access::Error, Identity::Error, ArgumentError => e
|
|
88
|
+
error_document(e)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# rubocop:disable Metrics/MethodLength
|
|
94
|
+
def run
|
|
95
|
+
@identity_reason = nil
|
|
96
|
+
principal = if @anonymous
|
|
97
|
+
Identity::ANONYMOUS
|
|
98
|
+
elsif @as
|
|
99
|
+
PrincipalReference.resolve(@as)
|
|
100
|
+
else
|
|
101
|
+
sampled_principal
|
|
102
|
+
end
|
|
103
|
+
Karst::Reproduction::Exercise.new(
|
|
104
|
+
path: @path, http_method: @http_method, body: @body, content_type: @content_type,
|
|
105
|
+
headers: @headers, principal: principal
|
|
106
|
+
).call
|
|
107
|
+
end
|
|
108
|
+
# rubocop:enable Metrics/MethodLength
|
|
109
|
+
|
|
110
|
+
# One candidate, not a sample: reproduction issues exactly one request,
|
|
111
|
+
# so asking the sampler for more than one would query for records
|
|
112
|
+
# Karst has already decided it will never send. Falling back to
|
|
113
|
+
# Identity::ANONYMOUS (rather than a bare nil) when no candidate is
|
|
114
|
+
# available means this still runs, and is still confirmed, as a real
|
|
115
|
+
# anonymous probe -- never a request whose identity Karst simply never
|
|
116
|
+
# got around to establishing.
|
|
117
|
+
def sampled_principal
|
|
118
|
+
selection = Access::PrincipalSelection.new(sources: Identity.principal_sources, limit: 1).call
|
|
119
|
+
principal = selection.principals.first
|
|
120
|
+
return principal if principal
|
|
121
|
+
|
|
122
|
+
@identity_reason = "no principal was available from the configured principal source"
|
|
123
|
+
Identity::ANONYMOUS
|
|
124
|
+
rescue Identity::Error => e
|
|
125
|
+
@identity_reason = e.message
|
|
126
|
+
Identity::ANONYMOUS
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# rubocop:disable Metrics/MethodLength
|
|
130
|
+
def document(observation)
|
|
131
|
+
{
|
|
132
|
+
schema_version: SCHEMA_VERSION,
|
|
133
|
+
request: request(observation),
|
|
134
|
+
identity: identity(observation),
|
|
135
|
+
execution: execution(observation),
|
|
136
|
+
response: response(observation),
|
|
137
|
+
unobserved: observation.unobserved,
|
|
138
|
+
isolation: { database: "same_connection_rollback_attempted", not_isolated: NOT_ISOLATED },
|
|
139
|
+
reproduce: { curl: curl(observation) },
|
|
140
|
+
summary: { elapsed_ms: observation.elapsed_ms }
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
# rubocop:enable Metrics/MethodLength
|
|
144
|
+
|
|
145
|
+
def request(observation)
|
|
146
|
+
{
|
|
147
|
+
method: observation.http_method, path: observation.url_path,
|
|
148
|
+
url: Karst::Reproduction::Curl.url(observation, @base_url),
|
|
149
|
+
query_params: observation.query_params, route_params: observation.route_params,
|
|
150
|
+
content_type: observation.content_type, body_format: observation.body_representation.to_s,
|
|
151
|
+
body: (observation.body? ? observation.body_params : nil), headers: observation.headers
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The same requested/observed/confirmation document verify_access
|
|
156
|
+
# produces (see Karst::CLI::IdentitySerialization), plus `reason`: why
|
|
157
|
+
# Karst ran this request under no identity at all, when that was not
|
|
158
|
+
# what the caller asked for (an anonymous request the caller
|
|
159
|
+
# deliberately chose carries no reason -- there is nothing to explain).
|
|
160
|
+
def identity(observation)
|
|
161
|
+
identity_document(observation.identity).merge(reason: @identity_reason)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def execution(observation)
|
|
165
|
+
{
|
|
166
|
+
controller: observation.controller, action: observation.action,
|
|
167
|
+
controller_completed: observation.controller_completed,
|
|
168
|
+
halted_callback: observation.halted_callback, exception_class: observation.exception_class,
|
|
169
|
+
exception_phase: observation.exception_phase, rendered: observation.rendered,
|
|
170
|
+
writes_observed: observation.writes_observed, write_count: observation.write_count,
|
|
171
|
+
database_rollback_attempted: observation.database_rollback_attempted
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def response(observation)
|
|
176
|
+
{ status: observation.status, content_type: observation.response_content_type,
|
|
177
|
+
redirect: observation.redirect }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def curl(observation)
|
|
181
|
+
Karst::Reproduction::Curl.render(observation, base_url: @base_url)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# observation_error: Karst could not observe the request it ran, so it
|
|
185
|
+
# reports nothing about the application rather than partial evidence.
|
|
186
|
+
def error_document(error)
|
|
187
|
+
type = if error.is_a?(Identity::Error) then "configuration_error"
|
|
188
|
+
elsif error.is_a?(Access::ObservationError) then "observation_error"
|
|
189
|
+
else "input_error"
|
|
190
|
+
end
|
|
191
|
+
{ schema_version: SCHEMA_VERSION, error: { type: type, message: error.message } }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def human(observation)
|
|
195
|
+
lines = ["Karst request reproduction", "", "Request",
|
|
196
|
+
" #{observation.http_method} #{observation.url_path}"]
|
|
197
|
+
append_identity(lines, observation)
|
|
198
|
+
append_execution(lines, observation)
|
|
199
|
+
append_response(lines, observation)
|
|
200
|
+
append_effects(lines, observation)
|
|
201
|
+
append_unobserved(lines, observation)
|
|
202
|
+
lines.push("", "Reproduce", curl(observation).lines.map { |line| " #{line}" }.join.rstrip)
|
|
203
|
+
lines.join("\n")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def append_identity(lines, observation)
|
|
207
|
+
lines.push("", "Identity", " #{identity_line(observation.identity)}")
|
|
208
|
+
lines << " (#{@identity_reason})" if @identity_reason
|
|
209
|
+
lines << " Karst assumed this identity directly; it did not exercise how an external " \
|
|
210
|
+
"client would authenticate this endpoint."
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def append_execution(lines, observation)
|
|
214
|
+
lines.push("", "Observed execution")
|
|
215
|
+
lines << if observation.controller
|
|
216
|
+
" #{observation.controller}##{observation.action}"
|
|
217
|
+
else
|
|
218
|
+
" no controller dispatched"
|
|
219
|
+
end
|
|
220
|
+
append_execution_detail(lines, observation)
|
|
221
|
+
append_rendered(lines, observation.rendered)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def append_execution_detail(lines, observation)
|
|
225
|
+
lines << " controller completed: #{observation.controller_completed}" \
|
|
226
|
+
unless observation.controller_completed.nil?
|
|
227
|
+
lines << " halted at #{observation.halted_callback}" if observation.halted_callback
|
|
228
|
+
lines << " exception #{observation.exception_class} (during #{observation.exception_phase})" \
|
|
229
|
+
if observation.exception_class
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def append_rendered(lines, rendered)
|
|
233
|
+
return if rendered.empty?
|
|
234
|
+
|
|
235
|
+
lines << " rendered:"
|
|
236
|
+
rendered.each do |template|
|
|
237
|
+
lines << " #{template[:virtual_path]}#{' (raised)' unless template[:completed]}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def append_response(lines, observation)
|
|
242
|
+
lines.push("", "Observed response")
|
|
243
|
+
type = observation.response_content_type
|
|
244
|
+
lines << if observation.status
|
|
245
|
+
" #{observation.status}#{" #{type}" if type}"
|
|
246
|
+
else
|
|
247
|
+
" no response observed"
|
|
248
|
+
end
|
|
249
|
+
lines << " redirect #{observation.redirect}" if observation.redirect
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def append_effects(lines, observation)
|
|
253
|
+
lines.push("", "Observed effects",
|
|
254
|
+
" #{observation.write_count} database #{observation.write_count == 1 ? 'write' : 'writes'} " \
|
|
255
|
+
"(rollback attempted on the same connection)")
|
|
256
|
+
lines << " Not isolated: #{NOT_ISOLATED.join(', ')}."
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def append_unobserved(lines, observation)
|
|
260
|
+
return if observation.unobserved.empty?
|
|
261
|
+
|
|
262
|
+
lines.push("", "Not observed", " #{observation.unobserved.join(', ')}")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
# rubocop:enable Metrics/ClassLength
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
require_relative "../../karst"
|
|
6
|
+
require_relative "identity_serialization"
|
|
7
|
+
require_relative "principal_reference"
|
|
8
|
+
require_relative "outcome_summary"
|
|
9
|
+
require_relative "../access/outcome_groups"
|
|
5
10
|
|
|
6
11
|
module Karst
|
|
7
12
|
module CLI
|
|
@@ -12,14 +17,53 @@ module Karst
|
|
|
12
17
|
# place, keeping the versioned contract auditable.
|
|
13
18
|
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
|
|
14
19
|
class Verification
|
|
15
|
-
|
|
20
|
+
include IdentitySerialization
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
# 2 (was 1): every principal field is now explicitly either a
|
|
23
|
+
# *requested* identity (what Karst was asked to run as) or an
|
|
24
|
+
# *observed* one (what the application itself resolved at runtime),
|
|
25
|
+
# and each outcome carries an `identity` document saying whether the
|
|
26
|
+
# two agree. The ambiguous v1 `principal`/`verified_principal` keys are
|
|
27
|
+
# gone rather than renamed in place: a consumer reading "principal" and
|
|
28
|
+
# believing it described the request that actually ran is precisely the
|
|
29
|
+
# false attribution this schema exists to make impossible.
|
|
30
|
+
#
|
|
31
|
+
# 3 (was 2): `outcomes` groups are now semantic -- keyed by what the
|
|
32
|
+
# request observed (see Access::OutcomeGroups), never by per-probe
|
|
33
|
+
# timing -- so equivalent outcomes from several users form one group
|
|
34
|
+
# instead of one group per user. A group therefore no longer carries
|
|
35
|
+
# `elapsed_ms` (it had no single value once groups held more than one
|
|
36
|
+
# probe; `summary.elapsed_ms` and `verified_outcome.elapsed_ms` are
|
|
37
|
+
# unchanged), gains `verified_usable` (the configured policy's verdict,
|
|
38
|
+
# shared by every member), and groups are ordered largest first.
|
|
39
|
+
SCHEMA_VERSION = 3
|
|
40
|
+
|
|
41
|
+
# A deliberate identity-free probe: no principal is established, and
|
|
42
|
+
# the application is expected to resolve none. Requires no principal
|
|
43
|
+
# source at all, so a route can be probed anonymously in an application
|
|
44
|
+
# Karst could not otherwise test.
|
|
45
|
+
ANONYMOUS = :anonymous
|
|
46
|
+
|
|
47
|
+
# `as`, when given, is a human-only "MODEL:ID" reference (e.g.
|
|
48
|
+
# "User:72") naming one existing principal to run this probe as
|
|
49
|
+
# instead of sampling one -- see Karst::CLI::PrincipalReference. It is
|
|
50
|
+
# resolved exclusively through Karst::Identity.resolve, so it can never
|
|
51
|
+
# select a record outside a configured principal source. Mutually
|
|
52
|
+
# exclusive with `identity: "anonymous"`; nothing about this reaches
|
|
53
|
+
# the MCP verify_access tool, which never accepts it.
|
|
54
|
+
# rubocop:disable Metrics/ParameterLists
|
|
55
|
+
def initialize(path:, http_method: "GET", output: $stdout, json: false, identity: nil, as: nil)
|
|
18
56
|
@path = path
|
|
19
57
|
@http_method = http_method
|
|
20
58
|
@output = output
|
|
21
59
|
@json = json
|
|
60
|
+
@identity = normalize_identity(identity)
|
|
61
|
+
PrincipalReference.parse(as) if as
|
|
62
|
+
raise ArgumentError, "--anonymous and --as cannot be combined" if anonymous? && as
|
|
63
|
+
|
|
64
|
+
@as = as
|
|
22
65
|
end
|
|
66
|
+
# rubocop:enable Metrics/ParameterLists
|
|
23
67
|
|
|
24
68
|
def call
|
|
25
69
|
result = run_search
|
|
@@ -44,13 +88,64 @@ module Karst
|
|
|
44
88
|
|
|
45
89
|
private
|
|
46
90
|
|
|
91
|
+
def normalize_identity(value)
|
|
92
|
+
return nil if value.nil?
|
|
93
|
+
return ANONYMOUS if value.to_s == ANONYMOUS.to_s
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, "identity must be \"anonymous\" when given"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def anonymous?
|
|
99
|
+
@identity == ANONYMOUS
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# An additive third value alongside the documented "anonymous" and
|
|
103
|
+
# "application_identities": a --as probe is still a real application
|
|
104
|
+
# identity, just a human-selected one rather than one Karst sampled --
|
|
105
|
+
# a distinction worth reporting honestly rather than folding into
|
|
106
|
+
# "application_identities" and implying this result came from the
|
|
107
|
+
# ordinary search.
|
|
108
|
+
def probe_identity
|
|
109
|
+
return "anonymous" if anonymous?
|
|
110
|
+
return "specific_principal" if @as
|
|
111
|
+
|
|
112
|
+
"application_identities"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# One Identity::Snapshot for the whole operation: readiness, --as
|
|
116
|
+
# resolution, and the search itself all read the same single
|
|
117
|
+
# resolution of the effective principal sources.
|
|
47
118
|
def run_search
|
|
48
|
-
|
|
49
|
-
|
|
119
|
+
return anonymous_search if anonymous?
|
|
120
|
+
|
|
121
|
+
snapshot = Identity.snapshot
|
|
122
|
+
validate_setup!(snapshot.setup_state)
|
|
123
|
+
return as_search(snapshot) if @as
|
|
124
|
+
|
|
125
|
+
Access::Search.new(path: @path, http_method: @http_method, sources: snapshot.principal_sources!).call
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One request against exactly the requested principal, never a sample
|
|
129
|
+
# and never a population retry: --as means "run as this one existing
|
|
130
|
+
# record," not "start a search that happens to prefer it." Wrapped in
|
|
131
|
+
# Access::Search::Result (with no attempts) purely so every downstream
|
|
132
|
+
# adapter method below stays the same for every probe kind.
|
|
133
|
+
def as_search(snapshot)
|
|
134
|
+
principal = PrincipalReference.resolve(@as, sources: snapshot.principal_sources!)
|
|
135
|
+
sweep = Access::Sweep.new(path: @path, http_method: @http_method, principals: [principal], limit: 1).call
|
|
136
|
+
Access::Search::Result.new(initial: sweep, attempts: [].freeze)
|
|
50
137
|
end
|
|
51
138
|
|
|
52
|
-
|
|
53
|
-
|
|
139
|
+
# One request, no principal source consulted and none required: the
|
|
140
|
+
# whole point is that nothing is authenticated. Reuses Search::Result so
|
|
141
|
+
# every adapter below stays identical for both probe kinds.
|
|
142
|
+
def anonymous_search
|
|
143
|
+
sweep = Access::Sweep.new(path: @path, http_method: @http_method,
|
|
144
|
+
principals: [Identity::ANONYMOUS], limit: 1).call
|
|
145
|
+
Access::Search::Result.new(initial: sweep, attempts: [].freeze)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_setup!(state)
|
|
54
149
|
return if state.status.to_s.start_with?("ready_")
|
|
55
150
|
|
|
56
151
|
message = state.message || "no principal source is configured"
|
|
@@ -58,13 +153,16 @@ module Karst
|
|
|
58
153
|
end
|
|
59
154
|
|
|
60
155
|
def document(result)
|
|
156
|
+
@groups = {}.compare_by_identity
|
|
61
157
|
winner = result.verified_outcome
|
|
62
158
|
{
|
|
63
159
|
schema_version: SCHEMA_VERSION,
|
|
64
160
|
request: { method: result.http_method, path: result.path },
|
|
161
|
+
probe: { identity: probe_identity },
|
|
162
|
+
provenance: provenance,
|
|
65
163
|
verified_usable: !winner.nil?,
|
|
66
|
-
|
|
67
|
-
verified_outcome: winner && outcome(winner
|
|
164
|
+
verified_identity: winner && identity_document(winner.identity),
|
|
165
|
+
verified_outcome: winner && outcome(winner),
|
|
68
166
|
source: result.verified_source,
|
|
69
167
|
sample: sweep(result.initial),
|
|
70
168
|
populations: result.attempts.map { |attempt| population(attempt) },
|
|
@@ -72,86 +170,146 @@ module Karst
|
|
|
72
170
|
}
|
|
73
171
|
end
|
|
74
172
|
|
|
173
|
+
# What execution produced these observations. Deliberately cheap and
|
|
174
|
+
# certain: versions, environment, and when the probe ran. It does not
|
|
175
|
+
# yet carry an application source digest/commit -- a consumer that must
|
|
176
|
+
# know the evidence still matches the code it is reasoning about needs
|
|
177
|
+
# freshness machinery this adapter does not own.
|
|
178
|
+
def provenance
|
|
179
|
+
{
|
|
180
|
+
karst_version: Karst::VERSION,
|
|
181
|
+
rails_version: defined?(Rails::VERSION::STRING) ? Rails::VERSION::STRING : nil,
|
|
182
|
+
rails_env: defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.to_s : nil,
|
|
183
|
+
ruby_version: RUBY_VERSION,
|
|
184
|
+
observed_at: Time.now.utc.iso8601
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
75
188
|
def sweep(result)
|
|
189
|
+
groups = groups_for(result)
|
|
76
190
|
{
|
|
77
191
|
candidate_pool_size: result.candidate_pool_size,
|
|
78
192
|
users_tested: result.outcomes.size,
|
|
79
|
-
verified_usable:
|
|
193
|
+
verified_usable: groups.any?(&:usable),
|
|
80
194
|
database_isolation: result.database_isolation.to_s,
|
|
81
|
-
outcomes: grouped_outcomes(
|
|
195
|
+
outcomes: grouped_outcomes(groups)
|
|
82
196
|
}
|
|
83
197
|
end
|
|
84
198
|
|
|
199
|
+
# Semantic groups for one stage's sweep result, computed once per
|
|
200
|
+
# document so the usable policy runs once per outcome here.
|
|
201
|
+
def groups_for(sweep_result)
|
|
202
|
+
@groups ||= {}.compare_by_identity
|
|
203
|
+
@groups[sweep_result] ||= Access::OutcomeGroups.group(sweep_result.outcomes)
|
|
204
|
+
end
|
|
205
|
+
|
|
85
206
|
def population(attempt)
|
|
86
207
|
data = { name: attempt.name.to_s, source: attempt.source_name.to_s, state: attempt.state.to_s }
|
|
87
208
|
data[:reason] = attempt.error if attempt.error
|
|
88
209
|
return data unless attempt.result
|
|
89
210
|
|
|
90
|
-
data.merge(users_tested: attempt.result.outcomes.size, outcomes: grouped_outcomes(attempt.result
|
|
211
|
+
data.merge(users_tested: attempt.result.outcomes.size, outcomes: grouped_outcomes(groups_for(attempt.result)))
|
|
91
212
|
end
|
|
92
213
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
214
|
+
# Outcomes that observed the same thing (see Access::OutcomeGroups) are
|
|
215
|
+
# reported once, with every probe's own identity evidence listed under
|
|
216
|
+
# it -- so "three requests halted at authorize_admin" never flattens
|
|
217
|
+
# into one claim about who made them. Per-probe timing belongs to the
|
|
218
|
+
# probe, not the outcome, so a group carries none.
|
|
219
|
+
def grouped_outcomes(groups)
|
|
220
|
+
groups.map do |group|
|
|
221
|
+
evidence = outcome(group.representative).tap { |fields| fields.delete(:elapsed_ms) }
|
|
222
|
+
evidence.merge(verified_usable: group.usable, count: group.count,
|
|
223
|
+
identities: group.outcomes.map { |item| identity_document(item.identity) })
|
|
96
224
|
end
|
|
97
225
|
end
|
|
98
226
|
|
|
99
|
-
def outcome(item
|
|
100
|
-
|
|
227
|
+
def outcome(item)
|
|
228
|
+
{
|
|
101
229
|
status: item.status, redirect: item.redirect, exception_class: item.exception_class,
|
|
102
230
|
halted_callback: item.halted_callback&.to_s, writes_observed: item.writes_observed,
|
|
103
231
|
write_count: item.write_count, database_rollback_attempted: item.database_rollback_attempted,
|
|
104
|
-
elapsed_ms: item.elapsed_ms
|
|
232
|
+
elapsed_ms: item.elapsed_ms, controller: item.controller, action: item.action
|
|
105
233
|
}
|
|
106
|
-
data[:principal] = principal(item.principal) if include_principal
|
|
107
|
-
data
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def principal(value)
|
|
111
|
-
# JSON is also the MCP contract. Framework-inferred login identifiers
|
|
112
|
-
# must never cross that machine-readable boundary. An application-
|
|
113
|
-
# authored principal_label remains explicit configuration and keeps
|
|
114
|
-
# its longstanding serialization behavior.
|
|
115
|
-
label = if Karst.config.principal_label
|
|
116
|
-
value.display_label.to_s
|
|
117
|
-
else
|
|
118
|
-
"#{value.model_name} ##{value.id}"
|
|
119
|
-
end
|
|
120
|
-
{ model: value.model_name.to_s, id: primitive_id(value.id), label: label }
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def primitive_id(value)
|
|
124
|
-
value.is_a?(Integer) ? value : value.to_s
|
|
125
234
|
end
|
|
126
235
|
|
|
236
|
+
# observation_error: Karst could not observe the request it ran, so it
|
|
237
|
+
# reports nothing about the application rather than partial evidence.
|
|
127
238
|
def error_document(error)
|
|
128
|
-
type = error.is_a?(Identity::Error)
|
|
239
|
+
type = if error.is_a?(Identity::Error) then "configuration_error"
|
|
240
|
+
elsif error.is_a?(Access::ObservationError) then "observation_error"
|
|
241
|
+
else "input_error"
|
|
242
|
+
end
|
|
129
243
|
{ schema_version: SCHEMA_VERSION, error: { type: type, message: error.message } }
|
|
130
244
|
end
|
|
131
245
|
|
|
132
246
|
def human(result)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
"
|
|
136
|
-
|
|
247
|
+
@groups = {}.compare_by_identity
|
|
248
|
+
lines = ["Karst verification", "", "#{result.http_method} #{result.path}",
|
|
249
|
+
"Probe identity: #{probe_identity_description}", "",
|
|
250
|
+
"#{probe_section_heading}: #{stage_counts(result.initial)}"]
|
|
251
|
+
append_outcomes(lines, result.initial, indent: " ")
|
|
137
252
|
append_populations(lines, result)
|
|
138
253
|
append_result(lines, result)
|
|
139
254
|
lines.join("\n")
|
|
140
255
|
end
|
|
141
256
|
|
|
142
|
-
def
|
|
143
|
-
|
|
257
|
+
def probe_identity_description
|
|
258
|
+
return "anonymous" if anonymous?
|
|
259
|
+
return "the specific principal requested via --as" if @as
|
|
260
|
+
|
|
261
|
+
"the application's own identities"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def probe_section_heading
|
|
265
|
+
return "Probe" if anonymous?
|
|
266
|
+
return "Requested" if @as
|
|
267
|
+
|
|
268
|
+
"Sample"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# How many were probed, and -- for a principal probe -- how many of
|
|
272
|
+
# them the configured usable policy accepted. An anonymous probe is a
|
|
273
|
+
# request, not a user, and has no "verified usable user" to count.
|
|
274
|
+
def stage_counts(sweep_result)
|
|
275
|
+
size = sweep_result.outcomes.size
|
|
276
|
+
return "#{size} #{size == 1 ? 'request' : 'requests'}" if anonymous?
|
|
277
|
+
|
|
278
|
+
usable = groups_for(sweep_result).select(&:usable).sum(&:count)
|
|
279
|
+
"#{size} #{size == 1 ? 'user' : 'users'} tested, #{usable} verified usable"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The bounded outcome summary (see CLI::OutcomeSummary), then identity
|
|
283
|
+
# and write evidence for the whole stage -- never just its first probe.
|
|
284
|
+
def append_outcomes(lines, sweep_result, indent:)
|
|
285
|
+
outcomes = sweep_result.outcomes
|
|
286
|
+
return if outcomes.empty?
|
|
287
|
+
|
|
288
|
+
lines.concat(OutcomeSummary.new(groups_for(sweep_result), indent: indent).lines)
|
|
289
|
+
append_identity_evidence(lines, outcomes, indent)
|
|
290
|
+
append_write_evidence(lines, outcomes, indent)
|
|
144
291
|
end
|
|
145
292
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
293
|
+
# One probe: its full identity evidence line. Several: how many probes
|
|
294
|
+
# reached each identity confirmation state, so an unconfirmed identity
|
|
295
|
+
# is visible even when its outcome matches everyone else's.
|
|
296
|
+
def append_identity_evidence(lines, outcomes, indent)
|
|
297
|
+
if outcomes.size == 1
|
|
298
|
+
lines << "#{indent}#{identity_line(outcomes.first.identity)}" if outcomes.first.identity
|
|
299
|
+
return
|
|
300
|
+
end
|
|
149
301
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
lines << "
|
|
153
|
-
|
|
154
|
-
|
|
302
|
+
tally = outcomes.map { |item| item.identity ? item.identity.confirmation.to_s : "not recorded" }.tally
|
|
303
|
+
states = tally.sort_by { |state, count| [-count, state] }.map { |state, count| "#{count} #{state}" }
|
|
304
|
+
lines << "#{indent}identity: #{states.join(', ')}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def append_write_evidence(lines, outcomes, indent)
|
|
308
|
+
writing = outcomes.count(&:writes_observed)
|
|
309
|
+
return if writing.zero?
|
|
310
|
+
|
|
311
|
+
lines << "#{indent}WARNING: database writes observed in #{writing} of #{outcomes.size} " \
|
|
312
|
+
"#{outcomes.size == 1 ? 'request' : 'requests'} (rollback attempted on the same connection)"
|
|
155
313
|
end
|
|
156
314
|
|
|
157
315
|
def append_populations(lines, result)
|
|
@@ -161,21 +319,27 @@ module Karst
|
|
|
161
319
|
result.attempts.each do |attempt|
|
|
162
320
|
count = attempt.result&.outcomes&.size || 0
|
|
163
321
|
lines << " #{attempt.name}: #{attempt.state} (#{count} users tested)"
|
|
164
|
-
|
|
322
|
+
append_outcomes(lines, attempt.result, indent: " ") if attempt.result
|
|
165
323
|
end
|
|
166
324
|
end
|
|
167
325
|
|
|
168
326
|
def append_result(lines, result)
|
|
169
327
|
lines.push("", "Result")
|
|
170
|
-
|
|
171
|
-
|
|
328
|
+
winner = result.verified_outcome
|
|
329
|
+
if winner
|
|
330
|
+
lines << " verified usable: #{winner_label(winner)}"
|
|
331
|
+
lines << " #{identity_line(winner.identity)}" if winner.identity
|
|
172
332
|
source = result.verified_source
|
|
173
333
|
lines << " source: #{source[:type]}#{"=#{source[:name]}" if source[:name]}"
|
|
174
334
|
else
|
|
175
|
-
lines << " no verified usable user found"
|
|
335
|
+
lines << (anonymous? ? " not usable anonymously" : " no verified usable user found")
|
|
176
336
|
end
|
|
177
337
|
lines << " #{result.request_count} requests in #{result.elapsed_ms} ms"
|
|
178
338
|
end
|
|
339
|
+
|
|
340
|
+
def winner_label(winner)
|
|
341
|
+
winner.principal ? winner.principal.display_label : "anonymous request"
|
|
342
|
+
end
|
|
179
343
|
end
|
|
180
344
|
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
|
|
181
345
|
end
|