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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +6 -1
  3. data/CHANGELOG.md +82 -2
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +118 -2
  6. data/docs/advanced-configuration.md +106 -0
  7. data/docs/rails8-authentication.md +24 -4
  8. data/docs/request-reproduction.md +242 -0
  9. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  10. data/lib/karst/access/approved_populations.rb +15 -6
  11. data/lib/karst/access/errors.rb +16 -0
  12. data/lib/karst/access/identity_probe.rb +151 -0
  13. data/lib/karst/access/local_path.rb +42 -0
  14. data/lib/karst/access/observed_endpoint.rb +46 -0
  15. data/lib/karst/access/outcome_groups.rb +93 -0
  16. data/lib/karst/access/population_discovery.rb +25 -1
  17. data/lib/karst/access/principal_source.rb +24 -3
  18. data/lib/karst/access/probe.rb +397 -0
  19. data/lib/karst/access/search.rb +14 -6
  20. data/lib/karst/access/sweep.rb +42 -112
  21. data/lib/karst/cli/identity_serialization.rb +82 -0
  22. data/lib/karst/cli/outcome_summary.rb +131 -0
  23. data/lib/karst/cli/principal_reference.rb +51 -0
  24. data/lib/karst/cli/reproduction.rb +267 -0
  25. data/lib/karst/cli/verification.rb +219 -55
  26. data/lib/karst/configuration.rb +52 -6
  27. data/lib/karst/identity/evidence.rb +110 -0
  28. data/lib/karst/identity/observation.rb +167 -0
  29. data/lib/karst/identity/warden_adapter.rb +26 -1
  30. data/lib/karst/identity.rb +157 -22
  31. data/lib/karst/mcp/compatibility.rb +24 -0
  32. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  33. data/lib/karst/mcp/reproduction.rb +58 -0
  34. data/lib/karst/mcp/server.rb +27 -8
  35. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  36. data/lib/karst/reproduction/curl.rb +73 -0
  37. data/lib/karst/reproduction/exercise.rb +246 -0
  38. data/lib/karst/reproduction/observation.rb +87 -0
  39. data/lib/karst/reproduction/sanitizer.rb +210 -0
  40. data/lib/karst/version.rb +1 -1
  41. data/lib/karst/web/middleware.rb +105 -16
  42. data/lib/karst/web/panel.rb +302 -47
  43. data/lib/karst.rb +2 -0
  44. data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
  45. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  46. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  47. metadata +33 -6
@@ -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[:csrf_token], state[:browser_identity_active])}" \
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, csrf_token, active)
193
- return "" unless active && Identity.browser_supported? && csrf_token
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.</p>"
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(sources)}"
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(sources)}"
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(sources)
302
- return "" if sources
328
+ def principal_source_hint(identity)
329
+ return "" if identity.principal_sources
303
330
 
304
- setup = Identity.setup_state
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
- csrf_token = state[:csrf_token]
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, csrf_token)}" \
388
- "#{populations_section(result, csrf_token)}#{write_evidence(write_count)}#{search_meta(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, csrf_token)}#{body}</section>"
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, csrf_token)
551
- return "" if outcomes.empty? || (Identity.browser_supported? && csrf_token)
779
+ def test_as_hint(outcomes, state, test_as_token)
780
+ return "" if outcomes.empty? || test_as_token
552
781
 
553
- state = Identity.setup_state
554
- return "<p class=\"hint\">#{escape(state.message)}</p>" if state.status == :ambiguous
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.group_by { |item| outcome_group_key(item) }
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(outcomes, path, csrf_token)
591
- first = outcomes.first
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 = usable_outcome?(first) ? "Verified usable" : "Not verified as usable"
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}#{action}</li>"
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 Identity.browser_supported? && csrf_token
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", "~> 0.9.0"
30
+ gem "mcp", *::Karst::Mcp::Compatibility::REQUIREMENTS
29
31
  rescue Gem::LoadError
30
- abort "Karst MCP requires the optional dependency. " \
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