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
data/lib/karst/web/panel.rb
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "cgi"
|
|
4
|
+
require "json"
|
|
4
5
|
require "rack/utils"
|
|
5
6
|
require "active_support"
|
|
6
7
|
require "active_support/number_helper"
|
|
7
8
|
require "base64"
|
|
8
9
|
require "digest"
|
|
10
|
+
require_relative "../access/outcome_groups"
|
|
9
11
|
|
|
10
12
|
module Karst
|
|
11
13
|
module Web
|
|
@@ -99,6 +101,21 @@ module Karst
|
|
|
99
101
|
.ordinary-sample h2{margin:0 0 .45rem}
|
|
100
102
|
.ordinary-sample details{margin:.55rem 0}
|
|
101
103
|
.write-warning{border:2px solid #b3261e;border-radius:.4rem;padding:.7rem .85rem;background:#fff7f6}
|
|
104
|
+
section.reproduce textarea{font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:.4rem .5rem;border:1px solid #ccc;border-radius:.3rem;width:100%;min-height:5rem;resize:vertical}
|
|
105
|
+
section.reproduce form{display:grid;gap:.75rem;margin:.6rem 0}
|
|
106
|
+
section.reproduce .row{display:flex;gap:.75rem;flex-wrap:wrap}
|
|
107
|
+
section.reproduce label{flex:1 1 10rem}
|
|
108
|
+
section.reproduce label input{width:100%}
|
|
109
|
+
.recipe{border:1px solid #ddd;border-radius:.5rem;padding:.9rem 1rem;margin:.9rem 0}
|
|
110
|
+
.recipe h3{font-size:.95rem;margin:.9rem 0 .3rem}
|
|
111
|
+
.recipe h3:first-child{margin-top:0}
|
|
112
|
+
.recipe p{margin:.2rem 0}
|
|
113
|
+
.recipe pre{overflow-x:auto;background:#f6f6f7;border:1px solid #e2e2e2;border-radius:.4rem;padding:.7rem .8rem;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;margin:.3rem 0 0}
|
|
114
|
+
.recipe dl{display:grid;grid-template-columns:auto 1fr;gap:.15rem .75rem;margin:.2rem 0;font-size:.9rem}
|
|
115
|
+
.recipe dt{font-weight:600;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
116
|
+
.recipe dd{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}
|
|
117
|
+
.redacted{color:#8a5b00;font-weight:600}
|
|
118
|
+
.unobserved{color:#666;font-size:.88rem}
|
|
102
119
|
small{color:#666}
|
|
103
120
|
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
104
121
|
@media (max-width:480px){
|
|
@@ -126,6 +143,11 @@ module Karst
|
|
|
126
143
|
.hint{color:#d8a63d}
|
|
127
144
|
.failed,.pending{border-left-color:#e5534b}
|
|
128
145
|
.write-warning{background:#321b1a}
|
|
146
|
+
section.reproduce textarea{background:#1f2023;border-color:#3a3b3e;color:#e4e4e6}
|
|
147
|
+
.recipe{border-color:#33343a}
|
|
148
|
+
.recipe pre{background:#1f2023;border-color:#2c2d31}
|
|
149
|
+
.redacted{color:#d8a63d}
|
|
150
|
+
.unobserved{color:#9a9aa0}
|
|
129
151
|
}
|
|
130
152
|
CSS
|
|
131
153
|
private_constant :STYLE
|
|
@@ -143,16 +165,24 @@ module Karst
|
|
|
143
165
|
|
|
144
166
|
# rubocop:disable Metrics/ClassLength
|
|
145
167
|
class << self
|
|
168
|
+
# `identity_snapshot` is the request's one Karst::Identity::Snapshot;
|
|
169
|
+
# every identity-configuration question this page asks (principal
|
|
170
|
+
# sources, setup readiness, browser Test As support) reads it, never
|
|
171
|
+
# the live configuration, so rendering N users never resolves
|
|
172
|
+
# configuration N times. Taken here when the caller has none.
|
|
146
173
|
# rubocop:disable Metrics/ParameterLists
|
|
147
174
|
def render(params: {}, access_result: nil, csrf_token: nil, browser_identity_active: false,
|
|
148
175
|
route_lookup_limitation: nil, unapproved_candidates: [], population_approval_error: nil,
|
|
149
|
-
principal_source_selection_saved: false, principal_source_selection_error: nil
|
|
176
|
+
principal_source_selection_saved: false, principal_source_selection_error: nil,
|
|
177
|
+
reproduction: nil, identity_snapshot: nil)
|
|
150
178
|
state = { csrf_token: csrf_token, browser_identity_active: browser_identity_active,
|
|
179
|
+
identity: identity_snapshot || Identity.snapshot,
|
|
151
180
|
route_lookup_limitation: route_lookup_limitation,
|
|
152
181
|
unapproved_candidates: unapproved_candidates,
|
|
153
182
|
population_approval_error: population_approval_error,
|
|
154
183
|
principal_source_selection_saved: principal_source_selection_saved,
|
|
155
|
-
principal_source_selection_error: principal_source_selection_error
|
|
184
|
+
principal_source_selection_error: principal_source_selection_error,
|
|
185
|
+
reproduction: reproduction }
|
|
156
186
|
[200, HEADERS.dup, [document(params, access_result, state)]]
|
|
157
187
|
end
|
|
158
188
|
# rubocop:enable Metrics/ParameterLists
|
|
@@ -177,9 +207,10 @@ module Karst
|
|
|
177
207
|
action = string_param(params, "action")
|
|
178
208
|
http_method = string_param(params, "method")
|
|
179
209
|
path = string_param(params, "path")
|
|
180
|
-
"#{testing_banner(path, state
|
|
210
|
+
"#{testing_banner(path, state)}" \
|
|
181
211
|
"#{route_header(http_method, path, state[:route_lookup_limitation])}" \
|
|
182
|
-
"#{access_section(http_method, path, controller, action, access_result, state)}"
|
|
212
|
+
"#{access_section(http_method, path, controller, action, access_result, state)}" \
|
|
213
|
+
"#{reproduction_section(params, http_method, path, state)}"
|
|
183
214
|
end
|
|
184
215
|
|
|
185
216
|
def string_param(params, key)
|
|
@@ -189,8 +220,9 @@ module Karst
|
|
|
189
220
|
|
|
190
221
|
# -- Testing-as banner ------------------------------------------------
|
|
191
222
|
|
|
192
|
-
def testing_banner(path,
|
|
193
|
-
|
|
223
|
+
def testing_banner(path, state)
|
|
224
|
+
csrf_token = state[:csrf_token]
|
|
225
|
+
return "" unless state[:browser_identity_active] && state[:identity].browser_supported? && csrf_token
|
|
194
226
|
|
|
195
227
|
fields = hidden("operation", "stop_test_as") + hidden("csrf_token", csrf_token) + hidden("path", path)
|
|
196
228
|
<<~HTML
|
|
@@ -239,7 +271,7 @@ module Karst
|
|
|
239
271
|
|
|
240
272
|
# -- Primary action: access analysis ------------------------------------
|
|
241
273
|
|
|
242
|
-
# rubocop:disable Metrics/ParameterLists
|
|
274
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
|
|
243
275
|
def access_section(http_method, path, controller, action, result, state)
|
|
244
276
|
return setup_notice_section(state) if path.empty? || state[:route_lookup_limitation]
|
|
245
277
|
|
|
@@ -248,12 +280,14 @@ module Karst
|
|
|
248
280
|
body = if http_method == "GET"
|
|
249
281
|
analyze_form(context, state)
|
|
250
282
|
else
|
|
251
|
-
"<p>Access analysis is available for GET routes only
|
|
283
|
+
"<p>Access analysis is available for GET routes only. " \
|
|
284
|
+
"Use <strong>Reproduce request</strong> below to issue this one request " \
|
|
285
|
+
"and get a recipe for it.</p>"
|
|
252
286
|
end
|
|
253
287
|
heading = "<h2 class=\"sr-only\">Access analysis</h2>"
|
|
254
288
|
"<section class=\"access\">#{heading}#{body}#{access_result(result, state)}</section>"
|
|
255
289
|
end
|
|
256
|
-
# rubocop:enable Metrics/ParameterLists
|
|
290
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
257
291
|
|
|
258
292
|
# A developer with several Devise models (or no automatic
|
|
259
293
|
# authentication integration at all) needs to know that before
|
|
@@ -269,17 +303,16 @@ module Karst
|
|
|
269
303
|
# away must still say so, rather than have the confirmation vanish
|
|
270
304
|
# the moment `sources` becomes truthy.
|
|
271
305
|
def setup_notice_section(state)
|
|
272
|
-
sources = principal_sources
|
|
306
|
+
sources = state[:identity].principal_sources
|
|
273
307
|
notice = principal_source_selection_notice(state)
|
|
274
308
|
return notice if sources
|
|
275
309
|
|
|
276
310
|
heading = "<h2 class=\"sr-only\">Access analysis</h2>"
|
|
277
|
-
body = "#{notice}#{principal_source_hint(
|
|
311
|
+
body = "#{notice}#{principal_source_hint(state[:identity])}"
|
|
278
312
|
"<section class=\"access\">#{heading}#{body}</section>"
|
|
279
313
|
end
|
|
280
314
|
|
|
281
315
|
def analyze_form(context, state)
|
|
282
|
-
sources = principal_sources
|
|
283
316
|
label = "Who can use this? (test #{Karst.config.access_sweep_limit} users)"
|
|
284
317
|
operation = "<input type=\"hidden\" name=\"operation\" value=\"access_sweep\">"
|
|
285
318
|
button = "<button class=\"primary\" type=\"submit\">#{escape(label)}</button>"
|
|
@@ -289,19 +322,13 @@ module Karst
|
|
|
289
322
|
# `sources` truthy must still tell the developer it worked, rather
|
|
290
323
|
# than have the confirmation vanish the moment it stops being
|
|
291
324
|
# needed.
|
|
292
|
-
"#{principal_source_selection_notice(state)}#{form}#{principal_source_hint(
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def principal_sources
|
|
296
|
-
Identity.principal_sources
|
|
297
|
-
rescue Identity::Error
|
|
298
|
-
nil
|
|
325
|
+
"#{principal_source_selection_notice(state)}#{form}#{principal_source_hint(state[:identity])}"
|
|
299
326
|
end
|
|
300
327
|
|
|
301
|
-
def principal_source_hint(
|
|
302
|
-
return "" if
|
|
328
|
+
def principal_source_hint(identity)
|
|
329
|
+
return "" if identity.principal_sources
|
|
303
330
|
|
|
304
|
-
setup =
|
|
331
|
+
setup = identity.setup_state
|
|
305
332
|
return principal_source_selection_form(setup) if setup.status == :ambiguous
|
|
306
333
|
|
|
307
334
|
custom_auth_hint
|
|
@@ -368,6 +395,206 @@ module Karst
|
|
|
368
395
|
"<input type=\"hidden\" name=\"#{name}\" value=\"#{escape(value)}\">"
|
|
369
396
|
end
|
|
370
397
|
|
|
398
|
+
# -- Request reproduction -----------------------------------------------
|
|
399
|
+
|
|
400
|
+
# The one place /karst answers "something calls this endpoint -- what
|
|
401
|
+
# request do I send to exercise the same behavior?". Deliberately a
|
|
402
|
+
# collapsed <details> below the access analysis rather than a peer of
|
|
403
|
+
# it: reproduction issues a real request with real non-database side
|
|
404
|
+
# effects, so it is something a developer opens on purpose, never
|
|
405
|
+
# something the page does on arrival. Everything below renders the
|
|
406
|
+
# same evidence document Karst::CLI::Reproduction hands the MCP tool
|
|
407
|
+
# and the CLI, so the panel cannot show a recipe those two would not.
|
|
408
|
+
def reproduction_section(params, http_method, path, state)
|
|
409
|
+
document = state[:reproduction]
|
|
410
|
+
return "" if path.empty? && document.nil?
|
|
411
|
+
return "" unless state[:csrf_token]
|
|
412
|
+
|
|
413
|
+
open = document ? " open" : ""
|
|
414
|
+
"<section class=\"reproduce\"><details#{open}><summary><strong>Reproduce request</strong></summary>" \
|
|
415
|
+
"#{reproduction_intro}#{reproduction_form(params, http_method, path, state)}" \
|
|
416
|
+
"#{reproduction_result(document)}</details></section>"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def reproduction_intro
|
|
420
|
+
"<p class=\"meta\">Karst issues this request once against your running application and shows what " \
|
|
421
|
+
"actually happened, plus a cURL command for exactly what it sent. Database writes are rolled " \
|
|
422
|
+
"back on the same connection; jobs, mail, outbound HTTP, files, and other connections are not. " \
|
|
423
|
+
"Secrets are replaced by placeholders, so a generated command may need a credential filled in.</p>"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def reproduction_form(params, http_method, path, state)
|
|
427
|
+
<<~HTML
|
|
428
|
+
<form action="/karst" method="post">
|
|
429
|
+
#{hidden('operation', 'reproduce_request')}#{hidden('csrf_token', state[:csrf_token])}
|
|
430
|
+
#{hidden('controller', string_param(params, 'controller'))}#{hidden('action', string_param(params, 'action'))}
|
|
431
|
+
<div class="row">
|
|
432
|
+
<label>Method <input name="method" value="#{escape(http_method.empty? ? 'GET' : http_method)}" required></label>
|
|
433
|
+
<label>Path <input name="path" value="#{escape(path)}" required></label>
|
|
434
|
+
</div>
|
|
435
|
+
<div class="row">
|
|
436
|
+
<label>Content type <input name="content_type" value="#{escape(string_param(params, 'content_type'))}" placeholder="application/json"></label>
|
|
437
|
+
</div>
|
|
438
|
+
<label>Body <textarea name="body" placeholder='{"serial_number": "ABC123"}'>#{escape(string_param(params, 'body'))}</textarea></label>
|
|
439
|
+
<label>Headers <small>one per line, as <code>Name: value</code></small>
|
|
440
|
+
<textarea name="headers" placeholder="Authorization: Bearer ...">#{escape(string_param(params, 'headers'))}</textarea></label>
|
|
441
|
+
<div><button class="primary" type="submit">Send once and build request</button></div>
|
|
442
|
+
</form>
|
|
443
|
+
HTML
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# rubocop:disable Metrics/AbcSize
|
|
447
|
+
def reproduction_result(document)
|
|
448
|
+
return "" unless document
|
|
449
|
+
return "<p class=\"hint\" role=\"alert\">#{escape(document[:error][:message])}</p>" if document[:error]
|
|
450
|
+
|
|
451
|
+
sections = [recipe_request(document[:request]), recipe_identity(document[:identity]),
|
|
452
|
+
recipe_execution(document[:execution]), recipe_response(document[:response]),
|
|
453
|
+
recipe_effects(document[:execution], document[:isolation]),
|
|
454
|
+
recipe_unobserved(document[:unobserved]), recipe_curl(document[:reproduce][:curl])]
|
|
455
|
+
"<div class=\"recipe\">#{sections.join}</div>"
|
|
456
|
+
end
|
|
457
|
+
# rubocop:enable Metrics/AbcSize
|
|
458
|
+
|
|
459
|
+
def recipe_request(request)
|
|
460
|
+
"<h3>Request</h3><p class=\"route-path\">#{escape(request[:method])} #{escape(request[:path])}</p>" \
|
|
461
|
+
"#{recipe_pairs('Route parameters', request[:route_params])}" \
|
|
462
|
+
"#{recipe_pairs('Query parameters', request[:query_params])}" \
|
|
463
|
+
"#{recipe_pairs('Headers', request[:headers])}" \
|
|
464
|
+
"#{recipe_body(request)}"
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def recipe_body(request)
|
|
468
|
+
return "" unless request[:body]
|
|
469
|
+
if request[:body_format] == "opaque"
|
|
470
|
+
return "<h3>Body</h3><p class=\"unobserved\">Karst sent a body it could not parse as JSON or " \
|
|
471
|
+
"form data, so it is not shown.</p>"
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
"<h3>Body <small>(#{escape(request[:body_format])})</small></h3>" \
|
|
475
|
+
"<pre>#{escape(JSON.pretty_generate(request[:body]))}</pre>"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Redacted values are visibly marked rather than silently mixed in
|
|
479
|
+
# with observed ones, so a developer copying a value out of this
|
|
480
|
+
# panel can always tell which is which.
|
|
481
|
+
def recipe_pairs(title, pairs)
|
|
482
|
+
return "" if pairs.nil? || pairs.empty?
|
|
483
|
+
|
|
484
|
+
rows = pairs.map do |name, value|
|
|
485
|
+
"<dt>#{escape(name)}</dt><dd>#{recipe_value(value)}</dd>"
|
|
486
|
+
end.join
|
|
487
|
+
"<h3>#{escape(title)}</h3><dl>#{rows}</dl>"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def recipe_value(value)
|
|
491
|
+
text = value.is_a?(String) ? value : JSON.generate(value)
|
|
492
|
+
return "<span class=\"redacted\">#{escape(text)}</span>" if placeholder?(text)
|
|
493
|
+
|
|
494
|
+
escape(text)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def placeholder?(text)
|
|
498
|
+
text.start_with?("<") && text.end_with?(">")
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# The same requested/observed/confirmation evidence verify_access
|
|
502
|
+
# reports, never a bare echo of the identity Karst assumed: a
|
|
503
|
+
# mismatch, an absence, or an unobservable outcome stays visible here
|
|
504
|
+
# exactly as it would in a "sent as User X" claim it is not entitled
|
|
505
|
+
# to make.
|
|
506
|
+
def recipe_identity(identity)
|
|
507
|
+
html = "<h3>Identity</h3><p>#{escape(identity_confirmation_label(identity))}</p>"
|
|
508
|
+
html += "<p class=\"unobserved\">#{escape(identity[:reason])}</p>" if identity[:reason]
|
|
509
|
+
html += identity_failure_detail(identity)
|
|
510
|
+
"#{html}<p class=\"unobserved\">Karst assumed this identity directly; it did not exercise how an " \
|
|
511
|
+
"external client would authenticate this endpoint.</p>"
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def identity_confirmation_label(identity)
|
|
515
|
+
requested = principal_summary(identity[:requested], "anonymous")
|
|
516
|
+
observed = principal_summary(identity[:observed], "no principal")
|
|
517
|
+
case identity[:confirmation]
|
|
518
|
+
when "confirmed" then "observed #{observed} (identity confirmed)"
|
|
519
|
+
when "confirmed_anonymous" then "observed no principal (anonymous confirmed)"
|
|
520
|
+
when "absent" then "requested #{requested}, observed no principal (NOT confirmed)"
|
|
521
|
+
when "mismatch" then "requested #{requested}, observed #{observed} (MISMATCH)"
|
|
522
|
+
when "contaminated" then "anonymous probe observed #{observed} (CONTAMINATED)"
|
|
523
|
+
else "identity unobservable#{": #{identity[:observation_error]}" if identity[:observation_error]}"
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def principal_summary(value, absent_label)
|
|
528
|
+
value ? "#{value[:model]} ##{value[:id]}" : absent_label
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def identity_failure_detail(identity)
|
|
532
|
+
detail = []
|
|
533
|
+
detail << "Identity setup: #{escape(identity[:establishment_error])}" if identity[:establishment_error]
|
|
534
|
+
detail << "Cleanup: #{escape(identity[:cleanup_error])}" if identity[:cleanup_error]
|
|
535
|
+
detail.map { |line| "<p class=\"unobserved\">#{line}</p>" }.join
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def recipe_execution(execution)
|
|
539
|
+
dispatched = if execution[:controller]
|
|
540
|
+
"#{escape(execution[:controller])}##{escape(execution[:action])}"
|
|
541
|
+
else
|
|
542
|
+
"<span class=\"unobserved\">no controller dispatched</span>"
|
|
543
|
+
end
|
|
544
|
+
"<h3>Observed execution</h3><p>#{dispatched}</p>#{recipe_controller_completed(execution)}" \
|
|
545
|
+
"#{recipe_execution_detail(execution)}#{recipe_rendered(execution[:rendered])}"
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def recipe_execution_detail(execution)
|
|
549
|
+
halted = execution[:halted_callback]
|
|
550
|
+
exception = execution[:exception_class]
|
|
551
|
+
"#{"<p>Halted at #{escape(halted)}</p>" if halted}" \
|
|
552
|
+
"#{"<p>Exception: #{escape(exception)} (during #{escape(execution[:exception_phase])})</p>" if exception}"
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def recipe_controller_completed(execution)
|
|
556
|
+
completed = execution[:controller_completed]
|
|
557
|
+
return "" if completed.nil?
|
|
558
|
+
|
|
559
|
+
"<p class=\"meta\">Controller completed: #{completed}</p>"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def recipe_rendered(rendered)
|
|
563
|
+
return "" if rendered.nil? || rendered.empty?
|
|
564
|
+
|
|
565
|
+
items = rendered.map do |template|
|
|
566
|
+
state = template[:completed] ? "" : " <span class=\"unobserved\">(raised)</span>"
|
|
567
|
+
"<li>#{escape(template[:virtual_path])}#{state}</li>"
|
|
568
|
+
end.join
|
|
569
|
+
"<p>Rendered:</p><ul>#{items}</ul>"
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def recipe_response(response)
|
|
573
|
+
return "<h3>Observed response</h3><p class=\"unobserved\">No response observed.</p>" unless response[:status]
|
|
574
|
+
|
|
575
|
+
type = response[:content_type]
|
|
576
|
+
redirect = response[:redirect]
|
|
577
|
+
"<h3>Observed response</h3><p>#{status_title(response[:status])}" \
|
|
578
|
+
"#{" · #{escape(type)}" if type}#{" → #{escape(redirect)}" if redirect}</p>"
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def recipe_effects(execution, isolation)
|
|
582
|
+
count = execution[:write_count]
|
|
583
|
+
"<h3>Observed effects</h3><p>#{escape(count)} database #{count == 1 ? 'write' : 'writes'} " \
|
|
584
|
+
"· rollback attempted on the same connection</p>" \
|
|
585
|
+
"<p class=\"unobserved\">Not isolated: #{escape(isolation[:not_isolated].join(', '))}.</p>"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def recipe_unobserved(unobserved)
|
|
589
|
+
return "" if unobserved.nil? || unobserved.empty?
|
|
590
|
+
|
|
591
|
+
"<h3>Not observed</h3><p class=\"unobserved\">#{escape(unobserved.join(', '))}</p>"
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def recipe_curl(curl)
|
|
595
|
+
"<h3>Reproduce</h3><pre><code>#{escape(curl)}</code></pre>"
|
|
596
|
+
end
|
|
597
|
+
|
|
371
598
|
def access_result(result, state)
|
|
372
599
|
return "" unless result
|
|
373
600
|
return "<p>Analysis unavailable: #{escape(result.message)}</p>" if result.is_a?(StandardError)
|
|
@@ -379,13 +606,17 @@ module Karst
|
|
|
379
606
|
# any automatic candidate-population retries as a single answer, so
|
|
380
607
|
# a usable user found through a population reads exactly like one
|
|
381
608
|
# found in the sample -- there is no second workflow to enter.
|
|
609
|
+
#
|
|
610
|
+
# Test As is offered per user, so whether it is available at all is
|
|
611
|
+
# decided once here: `test_as_token` is the CSRF token only when the
|
|
612
|
+
# snapshot says browser identity is supported, and nil otherwise.
|
|
382
613
|
def search_result(result, state)
|
|
383
|
-
|
|
614
|
+
test_as_token = state[:csrf_token] if state[:identity].browser_supported?
|
|
384
615
|
outcomes = result.all_outcomes
|
|
385
616
|
usable = outcomes.select { |outcome| usable_outcome?(outcome) }
|
|
386
617
|
write_count = outcomes.count(&:writes_observed)
|
|
387
|
-
"#{usable_outcomes(usable, result, state)}#{ordinary_sample(result,
|
|
388
|
-
"#{populations_section(result,
|
|
618
|
+
"#{usable_outcomes(usable, result, state, test_as_token)}#{ordinary_sample(result, nil)}" \
|
|
619
|
+
"#{populations_section(result, test_as_token)}#{write_evidence(write_count)}#{search_meta(result)}"
|
|
389
620
|
end
|
|
390
621
|
|
|
391
622
|
# -- Automatic candidate-population retries ----------------------------
|
|
@@ -496,17 +727,15 @@ module Karst
|
|
|
496
727
|
|
|
497
728
|
# -- Usable principals ---------------------------------------------------
|
|
498
729
|
|
|
499
|
-
def usable_outcomes(outcomes, result, state)
|
|
500
|
-
csrf_token = state[:csrf_token]
|
|
730
|
+
def usable_outcomes(outcomes, result, state, test_as_token)
|
|
501
731
|
body = if outcomes.empty?
|
|
502
732
|
candidate_review(state, result)
|
|
503
733
|
else
|
|
504
|
-
usable_cards(outcomes,
|
|
505
|
-
result, csrf_token)
|
|
734
|
+
usable_cards(outcomes, result, test_as_token)
|
|
506
735
|
end
|
|
507
736
|
heading = outcomes.empty? ? "No verified usable user found" : "Verified usable user"
|
|
508
737
|
"<section class=\"usable\"><h2>#{heading}</h2>" \
|
|
509
|
-
"#{test_as_hint(outcomes,
|
|
738
|
+
"#{test_as_hint(outcomes, state, test_as_token)}#{body}</section>"
|
|
510
739
|
end
|
|
511
740
|
|
|
512
741
|
# The one place /karst mentions candidate groups at all: a small
|
|
@@ -547,11 +776,11 @@ module Karst
|
|
|
547
776
|
outcomes.map { |outcome| usable_principal(outcome, result, csrf_token) }.join
|
|
548
777
|
end
|
|
549
778
|
|
|
550
|
-
def test_as_hint(outcomes,
|
|
551
|
-
return "" if outcomes.empty? ||
|
|
779
|
+
def test_as_hint(outcomes, state, test_as_token)
|
|
780
|
+
return "" if outcomes.empty? || test_as_token
|
|
552
781
|
|
|
553
|
-
|
|
554
|
-
return "<p class=\"hint\">#{escape(
|
|
782
|
+
setup = state[:identity].setup_state
|
|
783
|
+
return "<p class=\"hint\">#{escape(setup.message)}</p>" if setup.status == :ambiguous
|
|
555
784
|
|
|
556
785
|
custom_auth_hint
|
|
557
786
|
end
|
|
@@ -561,7 +790,33 @@ module Karst
|
|
|
561
790
|
action = test_as_form(outcome.principal, result.path, csrf_token)
|
|
562
791
|
"<article class=\"usable-principal\"><h4><span>#{principal_label(outcome.principal)}#{writes}</span>" \
|
|
563
792
|
"#{action}</h4><p>#{outcome_title(outcome, prefix: 'Observed ')} · " \
|
|
564
|
-
"#{escape(outcome.elapsed_ms)}ms</p>#{sampled_for(outcome)}</article>"
|
|
793
|
+
"#{escape(outcome.elapsed_ms)}ms</p>#{identity_note(outcome)}#{sampled_for(outcome)}</article>"
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# Karst asking the application to run as a principal is not evidence
|
|
797
|
+
# that it did. Silence here means the application itself resolved
|
|
798
|
+
# exactly the requested principal while running this request;
|
|
799
|
+
# anything else says so out loud, because "this user reached the
|
|
800
|
+
# page" would otherwise be a claim Karst cannot support.
|
|
801
|
+
def identity_note(outcome)
|
|
802
|
+
evidence = outcome.identity
|
|
803
|
+
return "" if evidence.nil? || evidence.confirmation == :confirmed
|
|
804
|
+
|
|
805
|
+
"<p class=\"meta\">⚠ #{escape(identity_summary(evidence))}</p>"
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def identity_summary(evidence)
|
|
809
|
+
case evidence.confirmation
|
|
810
|
+
when :confirmed_anonymous then "Ran anonymously: the application observed no principal."
|
|
811
|
+
when :absent then "The application observed no principal for this request — it did not run as this user."
|
|
812
|
+
when :mismatch then "The application observed #{observed_label(evidence)}, not the requested user."
|
|
813
|
+
when :contaminated then "Anonymous probe contaminated: the application observed #{observed_label(evidence)}."
|
|
814
|
+
else "Identity not runtime-confirmed: #{evidence.observation_error}"
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def observed_label(evidence)
|
|
819
|
+
evidence.observed ? "#{evidence.observed.model_name} ##{evidence.observed.id}" : "no principal"
|
|
565
820
|
end
|
|
566
821
|
|
|
567
822
|
# A compact, secondary line below the observed outcome, never a card
|
|
@@ -577,22 +832,19 @@ module Karst
|
|
|
577
832
|
|
|
578
833
|
# -- Other observed outcomes (collapsed) ---------------------------------
|
|
579
834
|
|
|
835
|
+
# Semantic outcome groups, largest first -- the same grouping the CLI
|
|
836
|
+
# and MCP evidence use (see Karst::Access::OutcomeGroups).
|
|
580
837
|
def observed_groups(outcomes, path, csrf_token)
|
|
581
|
-
outcomes.
|
|
582
|
-
.map { |_key, grouped| outcome_group(grouped, path, csrf_token) }.join
|
|
583
|
-
end
|
|
584
|
-
|
|
585
|
-
def outcome_group_key(item)
|
|
586
|
-
[item.status, item.redirect, item.exception_class, item.halted_callback,
|
|
587
|
-
item.writes_observed, item.write_count]
|
|
838
|
+
Access::OutcomeGroups.group(outcomes).map { |group| outcome_group(group, path, csrf_token) }.join
|
|
588
839
|
end
|
|
589
840
|
|
|
590
|
-
def outcome_group(
|
|
591
|
-
|
|
841
|
+
def outcome_group(group, path, csrf_token)
|
|
842
|
+
outcomes = group.outcomes
|
|
843
|
+
first = group.representative
|
|
592
844
|
title = outcome_title(first)
|
|
593
845
|
labels = outcomes.map { |item| outcome_principal(item, path, csrf_token) }.join
|
|
594
846
|
halt = halted_callback(first)
|
|
595
|
-
usability =
|
|
847
|
+
usability = group.usable ? "Verified usable" : "Not verified as usable"
|
|
596
848
|
"<details><summary>#{title}#{halt_summary(first)} — #{outcomes.size}</summary>" \
|
|
597
849
|
"#{halt}<p>#{usability}</p><ul>#{labels}</ul></details>"
|
|
598
850
|
end
|
|
@@ -621,11 +873,14 @@ module Karst
|
|
|
621
873
|
def outcome_principal(item, path, csrf_token)
|
|
622
874
|
writes = item.writes_observed ? " — ⚠ #{escape(item.write_count)} database writes observed" : ""
|
|
623
875
|
action = test_as_form(item.principal, path, csrf_token)
|
|
624
|
-
"<li>#{principal_label(item.principal)} — #{escape(item.elapsed_ms)}ms#{writes}
|
|
876
|
+
"<li>#{principal_label(item.principal)} — #{escape(item.elapsed_ms)}ms#{writes}" \
|
|
877
|
+
"#{identity_note(item)}#{action}</li>"
|
|
625
878
|
end
|
|
626
879
|
|
|
880
|
+
# `csrf_token` is the search's test-as token: nil whenever browser
|
|
881
|
+
# identity is unsupported (see #search_result).
|
|
627
882
|
def test_as_form(principal, path, csrf_token)
|
|
628
|
-
return "" unless
|
|
883
|
+
return "" unless csrf_token
|
|
629
884
|
|
|
630
885
|
fields = hidden("operation", "test_as") + hidden("csrf_token", csrf_token) + hidden("path", path) +
|
|
631
886
|
hidden("principal_type", principal.model_name) + hidden("principal_id", principal.id)
|
data/lib/karst.rb
CHANGED
|
@@ -24,6 +24,8 @@ require_relative "karst/access/population_approval"
|
|
|
24
24
|
require_relative "karst/access/approved_populations"
|
|
25
25
|
require_relative "karst/access/principal_source_selection"
|
|
26
26
|
require_relative "karst/access/selected_principal_sources"
|
|
27
|
+
require_relative "karst/reproduction/exercise"
|
|
28
|
+
require_relative "karst/reproduction/curl"
|
|
27
29
|
|
|
28
30
|
# Public entry point for Karst configuration.
|
|
29
31
|
module Karst
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "rails/command"
|
|
4
4
|
require_relative "../boot"
|
|
5
|
+
require "karst/mcp/compatibility"
|
|
6
|
+
require "karst/version"
|
|
5
7
|
|
|
6
8
|
module Rails
|
|
7
9
|
module Command
|
|
@@ -25,14 +27,39 @@ module Rails
|
|
|
25
27
|
|
|
26
28
|
def load_mcp!
|
|
27
29
|
begin
|
|
28
|
-
gem "mcp",
|
|
30
|
+
gem "mcp", *::Karst::Mcp::Compatibility::REQUIREMENTS
|
|
29
31
|
rescue Gem::LoadError
|
|
30
|
-
abort
|
|
31
|
-
'Add gem "mcp", "~> 0.9.0" to your Gemfile and run bundle install.'
|
|
32
|
+
abort mcp_unavailable_message
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
require "karst/mcp/server"
|
|
35
36
|
end
|
|
37
|
+
|
|
38
|
+
# "Not installed" and "installed at a version Karst does not support"
|
|
39
|
+
# need different fixes, so they get different messages: telling an
|
|
40
|
+
# application that already has mcp in its bundle to add it would
|
|
41
|
+
# send its developer looking for a problem they don't have.
|
|
42
|
+
def mcp_unavailable_message
|
|
43
|
+
requirement = ::Karst::Mcp::Compatibility.gemfile_requirement
|
|
44
|
+
installed = installed_mcp_version
|
|
45
|
+
if installed
|
|
46
|
+
"Karst MCP found mcp #{installed}, but Karst #{::Karst::VERSION} " \
|
|
47
|
+
"requires mcp #{requirement}. Constrain it in your Gemfile with " \
|
|
48
|
+
"gem \"mcp\", #{requirement} and run bundle update mcp."
|
|
49
|
+
else
|
|
50
|
+
"Karst MCP requires the optional dependency. " \
|
|
51
|
+
"Add gem \"mcp\", #{requirement} to your Gemfile and run bundle install."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The mcp version this process can see but could not activate, if
|
|
56
|
+
# any. Bundler marks every gem in the bundle as loaded, so under
|
|
57
|
+
# `bin/rails` this is the version the bundle resolved; outside
|
|
58
|
+
# Bundler, it is the newest one installed.
|
|
59
|
+
def installed_mcp_version
|
|
60
|
+
spec = Gem.loaded_specs["mcp"] || Gem::Specification.find_all_by_name("mcp").max_by(&:version)
|
|
61
|
+
spec&.version
|
|
62
|
+
end
|
|
36
63
|
end
|
|
37
64
|
end
|
|
38
65
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/command"
|
|
4
|
+
require "karst/cli/reproduction"
|
|
5
|
+
require_relative "../boot"
|
|
6
|
+
|
|
7
|
+
module Rails
|
|
8
|
+
module Command
|
|
9
|
+
module Karst
|
|
10
|
+
# Rails command entry point for the shared Reproduction adapter.
|
|
11
|
+
class ReproduceCommand < Base
|
|
12
|
+
include Karst::Boot
|
|
13
|
+
|
|
14
|
+
class_option :json, type: :boolean, default: false, desc: "Emit stable JSON evidence"
|
|
15
|
+
class_option :body, type: :string, desc: "Request body to send"
|
|
16
|
+
class_option :content_type, type: :string, desc: "Content type of --body (required with --body)"
|
|
17
|
+
class_option :header, type: :array, default: [], desc: "Request header, as 'Name: value' (repeatable)"
|
|
18
|
+
class_option :anonymous, type: :boolean, default: false,
|
|
19
|
+
desc: "Send without assuming any application identity"
|
|
20
|
+
class_option :as, type: :string,
|
|
21
|
+
desc: "Send as one specific existing principal instead of the ordinary sampled " \
|
|
22
|
+
"one, e.g. --as User:72. Resolved only through Karst's own configured " \
|
|
23
|
+
"principal source(s); cannot be combined with --anonymous"
|
|
24
|
+
class_option :base_url, type: :string, desc: "Base URL for the generated cURL command"
|
|
25
|
+
|
|
26
|
+
desc "Issue one local request and print a reproducible, redacted recipe for it"
|
|
27
|
+
def perform(*arguments)
|
|
28
|
+
method, path = parse(arguments)
|
|
29
|
+
boot_karst_application!
|
|
30
|
+
exit(reproduction(method, path).call)
|
|
31
|
+
rescue ArgumentError => e
|
|
32
|
+
document = { schema_version: ::Karst::CLI::Reproduction::SCHEMA_VERSION,
|
|
33
|
+
error: { type: "input_error", message: e.message } }
|
|
34
|
+
puts(options[:json] ? JSON.generate(document) : "Karst cannot reproduce this request:\n#{e.message}")
|
|
35
|
+
exit(2)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def reproduction(method, path)
|
|
41
|
+
::Karst::CLI::Reproduction.new(
|
|
42
|
+
path: path, http_method: method, body: options[:body], content_type: options[:content_type],
|
|
43
|
+
headers: headers, anonymous: options[:anonymous], as: options[:as],
|
|
44
|
+
base_url: options[:base_url], json: options[:json]
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def headers
|
|
49
|
+
Array(options[:header]).each_with_object({}) do |entry, result|
|
|
50
|
+
name, value = entry.to_s.split(":", 2)
|
|
51
|
+
raise ArgumentError, "expected --header 'Name: value'" if value.nil?
|
|
52
|
+
|
|
53
|
+
result[name.strip] = value.strip
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse(arguments)
|
|
58
|
+
raise ArgumentError, "a local application path is required" if arguments.empty?
|
|
59
|
+
return ["GET", arguments.first] if arguments.size == 1
|
|
60
|
+
raise ArgumentError, "expected METHOD PATH" unless arguments.size == 2
|
|
61
|
+
|
|
62
|
+
arguments
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|