karst 0.1.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -12
  3. data/CHANGELOG.md +114 -47
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +137 -9
  6. data/SECURITY.md +1 -1
  7. data/docs/advanced-configuration.md +112 -7
  8. data/docs/rails8-authentication.md +191 -0
  9. data/docs/request-reproduction.md +242 -0
  10. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  11. data/lib/karst/access/approved_populations.rb +15 -6
  12. data/lib/karst/access/errors.rb +16 -0
  13. data/lib/karst/access/identity_probe.rb +151 -0
  14. data/lib/karst/access/local_path.rb +42 -0
  15. data/lib/karst/access/observed_endpoint.rb +46 -0
  16. data/lib/karst/access/outcome_groups.rb +93 -0
  17. data/lib/karst/access/population_approval.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +25 -1
  19. data/lib/karst/access/population_revocation.rb +34 -0
  20. data/lib/karst/access/principal_source.rb +24 -3
  21. data/lib/karst/access/probe.rb +397 -0
  22. data/lib/karst/access/search.rb +14 -6
  23. data/lib/karst/access/sweep.rb +42 -112
  24. data/lib/karst/cli/identity_serialization.rb +82 -0
  25. data/lib/karst/cli/outcome_summary.rb +131 -0
  26. data/lib/karst/cli/principal_reference.rb +51 -0
  27. data/lib/karst/cli/reproduction.rb +267 -0
  28. data/lib/karst/cli/verification.rb +219 -55
  29. data/lib/karst/configuration.rb +57 -11
  30. data/lib/karst/execution_context.rb +6 -7
  31. data/lib/karst/identity/evidence.rb +110 -0
  32. data/lib/karst/identity/observation.rb +167 -0
  33. data/lib/karst/identity/warden_adapter.rb +26 -1
  34. data/lib/karst/identity.rb +157 -22
  35. data/lib/karst/mcp/compatibility.rb +24 -0
  36. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  37. data/lib/karst/mcp/reproduction.rb +58 -0
  38. data/lib/karst/mcp/server.rb +27 -8
  39. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  40. data/lib/karst/railtie.rb +4 -7
  41. data/lib/karst/reproduction/curl.rb +73 -0
  42. data/lib/karst/reproduction/exercise.rb +246 -0
  43. data/lib/karst/reproduction/observation.rb +87 -0
  44. data/lib/karst/reproduction/sanitizer.rb +210 -0
  45. data/lib/karst/version.rb +1 -1
  46. data/lib/karst/web/badge.rb +5 -31
  47. data/lib/karst/web/browser_identity.rb +10 -13
  48. data/lib/karst/web/csrf.rb +43 -0
  49. data/lib/karst/web/middleware.rb +145 -84
  50. data/lib/karst/web/panel.rb +343 -125
  51. data/lib/karst/web/populations_panel.rb +46 -336
  52. data/lib/karst.rb +3 -3
  53. data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
  54. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  55. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  56. metadata +38 -18
  57. data/lib/karst/access/population_config_snippet.rb +0 -67
  58. data/lib/karst/access/population_preview.rb +0 -83
  59. data/lib/karst/access/resource_evidence.rb +0 -233
  60. data/lib/karst/spec/catalog.rb +0 -199
  61. data/lib/karst/spec/example_observation.rb +0 -31
  62. data/lib/karst/spec/observer.rb +0 -300
  63. data/lib/karst/spec/principal.rb +0 -12
  64. data/lib/karst/spec/reporter.rb +0 -83
  65. data/lib/karst/spec/request_observation.rb +0 -38
  66. data/lib/karst/spec/scenario.rb +0 -65
  67. data/lib/tasks/karst.rake +0 -34
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
+ require "json"
4
5
  require "rack/utils"
6
+ require "active_support"
5
7
  require "active_support/number_helper"
6
8
  require "base64"
7
9
  require "digest"
10
+ require_relative "../access/outcome_groups"
8
11
 
9
12
  module Karst
10
13
  module Web
@@ -80,13 +83,9 @@ module Karst
80
83
  .usable-principal button[type=submit]{background:#0f5132;border-color:#0f5132;color:#fff;font-weight:600}
81
84
  .usable-principal button[type=submit]:hover{background:#0a3d25}
82
85
  .usable-principal p{margin:.5rem 0 0;color:#444;font-size:.92rem}
83
- .related-state{margin:.75rem 0 0;padding:.6rem .75rem;border-left:3px solid #ccc;background:#fafafa;font-size:.9rem}
84
- .related-state ul{margin:.25rem 0 0;padding-left:1.1rem}
85
86
  details{border:1px solid #e2e2e2;border-radius:.4rem;padding:.6rem .8rem;margin:.75rem 0}
86
87
  details summary{cursor:pointer;font-weight:600}
87
88
  details[open]>summary{margin-bottom:.5rem}
88
- .scenario{border:1px solid #ddd;border-radius:.4rem;padding:.75rem;margin:.6rem 0}
89
- .scenario h4{margin:.1rem 0}
90
89
  .evidence{display:flex;gap:1rem;flex-wrap:wrap;font-size:.9rem}
91
90
  .label{font-size:.72rem;font-weight:700;text-transform:uppercase;color:#666}
92
91
  .failed,.pending{border-left:4px solid #b3261e}
@@ -97,10 +96,26 @@ module Karst
97
96
  .population-attempt.hit .name{color:#0f5132}
98
97
  .population-attempt.untried{color:#777}
99
98
  .candidate-review{font-size:.9rem;color:#444;margin:.5rem 0 0}
99
+ .candidate-review label{display:flex;flex-direction:row;align-items:center;font-size:.9rem;margin:.35rem 0}
100
100
  .ordinary-sample{border:1px solid #ddd;border-radius:.5rem;padding:.8rem 1rem;margin:1rem 0}
101
101
  .ordinary-sample h2{margin:0 0 .45rem}
102
102
  .ordinary-sample details{margin:.55rem 0}
103
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}
104
119
  small{color:#666}
105
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}
106
121
  @media (max-width:480px){
@@ -123,13 +138,16 @@ module Karst
123
138
  .population-attempt.hit .name{color:#7fd8a4}
124
139
  .population-attempt.untried{color:#8a8a90}
125
140
  .usable-principal button[type=submit]{background:#2e7d52;border-color:#2e7d52;color:#0b1a12}
126
- .related-state{background:#1c1d20;border-color:#3a3b3e}
127
141
  details{border-color:#2c2d31}
128
- .scenario{border-color:#33343a}
129
142
  .testing-banner{background:#3a2f0d;border-color:#6b5423;color:#f0e4c0}
130
143
  .hint{color:#d8a63d}
131
144
  .failed,.pending{border-left-color:#e5534b}
132
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}
133
151
  }
134
152
  CSS
135
153
  private_constant :STYLE
@@ -147,15 +165,24 @@ module Karst
147
165
 
148
166
  # rubocop:disable Metrics/ClassLength
149
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.
150
173
  # rubocop:disable Metrics/ParameterLists
151
174
  def render(params: {}, access_result: nil, csrf_token: nil, browser_identity_active: false,
152
- route_lookup_limitation: nil, unapproved_candidate_count: nil,
153
- principal_source_selection_saved: false, principal_source_selection_error: nil)
175
+ route_lookup_limitation: nil, unapproved_candidates: [], population_approval_error: nil,
176
+ principal_source_selection_saved: false, principal_source_selection_error: nil,
177
+ reproduction: nil, identity_snapshot: nil)
154
178
  state = { csrf_token: csrf_token, browser_identity_active: browser_identity_active,
179
+ identity: identity_snapshot || Identity.snapshot,
155
180
  route_lookup_limitation: route_lookup_limitation,
156
- unapproved_candidate_count: unapproved_candidate_count,
181
+ unapproved_candidates: unapproved_candidates,
182
+ population_approval_error: population_approval_error,
157
183
  principal_source_selection_saved: principal_source_selection_saved,
158
- principal_source_selection_error: principal_source_selection_error }
184
+ principal_source_selection_error: principal_source_selection_error,
185
+ reproduction: reproduction }
159
186
  [200, HEADERS.dup, [document(params, access_result, state)]]
160
187
  end
161
188
  # rubocop:enable Metrics/ParameterLists
@@ -180,9 +207,10 @@ module Karst
180
207
  action = string_param(params, "action")
181
208
  http_method = string_param(params, "method")
182
209
  path = string_param(params, "path")
183
- "#{testing_banner(path, state[:csrf_token], state[:browser_identity_active])}" \
210
+ "#{testing_banner(path, state)}" \
184
211
  "#{route_header(http_method, path, state[:route_lookup_limitation])}" \
185
- "#{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)}"
186
214
  end
187
215
 
188
216
  def string_param(params, key)
@@ -192,15 +220,15 @@ module Karst
192
220
 
193
221
  # -- Testing-as banner ------------------------------------------------
194
222
 
195
- def testing_banner(path, csrf_token, active)
196
- 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
197
226
 
198
227
  fields = hidden("operation", "stop_test_as") + hidden("csrf_token", csrf_token) + hidden("path", path)
199
228
  <<~HTML
200
229
  <div class="testing-banner" role="status">
201
- <p><strong>Currently testing as an assumed browser identity.</strong></p>
202
- <p><small>Stopping clears to whatever identity your configured clear_browser_identity hook defines
203
- (commonly signed out) -- Karst does not restore a previous session.</small></p>
230
+ <p><strong>Currently testing as an assumed user.</strong></p>
231
+ <p><small>Stopping signs this browser out; Karst does not restore your previous session.</small></p>
204
232
  <form action="/karst" method="post">#{fields}<button type="submit">Stop testing as</button></form>
205
233
  </div>
206
234
  HTML
@@ -243,7 +271,7 @@ module Karst
243
271
 
244
272
  # -- Primary action: access analysis ------------------------------------
245
273
 
246
- # rubocop:disable Metrics/ParameterLists
274
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
247
275
  def access_section(http_method, path, controller, action, result, state)
248
276
  return setup_notice_section(state) if path.empty? || state[:route_lookup_limitation]
249
277
 
@@ -252,12 +280,14 @@ module Karst
252
280
  body = if http_method == "GET"
253
281
  analyze_form(context, state)
254
282
  else
255
- "<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>"
256
286
  end
257
287
  heading = "<h2 class=\"sr-only\">Access analysis</h2>"
258
288
  "<section class=\"access\">#{heading}#{body}#{access_result(result, state)}</section>"
259
289
  end
260
- # rubocop:enable Metrics/ParameterLists
290
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
261
291
 
262
292
  # A developer with several Devise models (or no automatic
263
293
  # authentication integration at all) needs to know that before
@@ -273,19 +303,17 @@ module Karst
273
303
  # away must still say so, rather than have the confirmation vanish
274
304
  # the moment `sources` becomes truthy.
275
305
  def setup_notice_section(state)
276
- sources = principal_sources
306
+ sources = state[:identity].principal_sources
277
307
  notice = principal_source_selection_notice(state)
278
308
  return notice if sources
279
309
 
280
310
  heading = "<h2 class=\"sr-only\">Access analysis</h2>"
281
- body = "#{notice}#{principal_source_hint(sources)}"
311
+ body = "#{notice}#{principal_source_hint(state[:identity])}"
282
312
  "<section class=\"access\">#{heading}#{body}</section>"
283
313
  end
284
314
 
285
315
  def analyze_form(context, state)
286
- sources = principal_sources
287
- kind = sources && any_representative?(sources) ? "representative " : ""
288
- label = "Who can use this? (test #{Karst.config.access_sweep_limit} #{kind}users)"
316
+ label = "Who can use this? (test #{Karst.config.access_sweep_limit} users)"
289
317
  operation = "<input type=\"hidden\" name=\"operation\" value=\"access_sweep\">"
290
318
  button = "<button class=\"primary\" type=\"submit\">#{escape(label)}</button>"
291
319
  form = "<form action=\"/karst\" method=\"post\">#{context}#{operation}#{button}</form>"
@@ -294,27 +322,13 @@ module Karst
294
322
  # `sources` truthy must still tell the developer it worked, rather
295
323
  # than have the confirmation vanish the moment it stops being
296
324
  # needed.
297
- "#{principal_source_selection_notice(state)}#{form}#{principal_source_hint(sources)}"
298
- end
299
-
300
- # Only ever type-checks each configured source's evaluated records
301
- # (see Access::PrincipalSampler.representative_capable?); it never
302
- # queries or enumerates any of them, so this is safe to compute on
303
- # every panel render.
304
- def principal_sources
305
- Identity.principal_sources
306
- rescue Identity::Error
307
- nil
308
- end
309
-
310
- def any_representative?(sources)
311
- sources.values.any? { |source| Access::PrincipalSampler.representative_capable?(source.evaluate) }
325
+ "#{principal_source_selection_notice(state)}#{form}#{principal_source_hint(state[:identity])}"
312
326
  end
313
327
 
314
- def principal_source_hint(sources)
315
- return "" if sources
328
+ def principal_source_hint(identity)
329
+ return "" if identity.principal_sources
316
330
 
317
- setup = Identity.setup_state
331
+ setup = identity.setup_state
318
332
  return principal_source_selection_form(setup) if setup.status == :ambiguous
319
333
 
320
334
  custom_auth_hint
@@ -381,6 +395,206 @@ module Karst
381
395
  "<input type=\"hidden\" name=\"#{name}\" value=\"#{escape(value)}\">"
382
396
  end
383
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
+
384
598
  def access_result(result, state)
385
599
  return "" unless result
386
600
  return "<p>Analysis unavailable: #{escape(result.message)}</p>" if result.is_a?(StandardError)
@@ -392,13 +606,17 @@ module Karst
392
606
  # any automatic candidate-population retries as a single answer, so
393
607
  # a usable user found through a population reads exactly like one
394
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.
395
613
  def search_result(result, state)
396
- csrf_token = state[:csrf_token]
614
+ test_as_token = state[:csrf_token] if state[:identity].browser_supported?
397
615
  outcomes = result.all_outcomes
398
616
  usable = outcomes.select { |outcome| usable_outcome?(outcome) }
399
617
  write_count = outcomes.count(&:writes_observed)
400
- "#{usable_outcomes(usable, result, state)}#{ordinary_sample(result, csrf_token)}" \
401
- "#{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)}"
402
620
  end
403
621
 
404
622
  # -- Automatic candidate-population retries ----------------------------
@@ -406,8 +624,8 @@ module Karst
406
624
  # Every approved population appears here, including the ones
407
625
  # deliberately not run -- "not tried" is reported honestly rather
408
626
  # than left to look like a failure. Only configured populations ever
409
- # reach this list; a name merely discovered at /karst/populations is
410
- # never executed automatically.
627
+ # reach this list; a name merely discovered after a failed analysis
628
+ # is never executed automatically.
411
629
  def populations_section(result, csrf_token)
412
630
  return "" if result.attempts.empty?
413
631
 
@@ -454,14 +672,6 @@ module Karst
454
672
  "could not be resolved#{detail}"
455
673
  end
456
674
 
457
- def dominant_halted_callback(outcomes)
458
- tally = Hash.new(0)
459
- outcomes.filter_map(&:halted_callback).each { |callback| tally[callback] += 1 }
460
- return nil if tally.empty?
461
-
462
- tally.max_by { |_callback, count| count }.first
463
- end
464
-
465
675
  def users(count)
466
676
  count == 1 ? "user" : "users"
467
677
  end
@@ -470,7 +680,7 @@ module Karst
470
680
  initial = result.initial.outcomes.size
471
681
  population = result.population_request_count
472
682
  total = initial + population
473
- "<p class=\"meta\"><strong>Request accounting:</strong> #{escape(initial)} initial · " \
683
+ "<p class=\"meta\"><strong>Tested:</strong> #{escape(initial)} initial · " \
474
684
  "#{escape(population)} candidate population · #{escape(total)} total " \
475
685
  "#{users(total)}/requests · #{escape(total_seconds(result))}s elapsed.</p>"
476
686
  end
@@ -501,16 +711,6 @@ module Karst
501
711
  " from up to #{escape(size)} recent users"
502
712
  end
503
713
 
504
- # A bounded candidate pool is reported explicitly rather than left
505
- # implicit, so this never reads as "every user was searched."
506
- def candidate_pool_note(result)
507
- size = result.candidate_pool_size
508
- return "" unless size
509
-
510
- delimited = ActiveSupport::NumberHelper.number_to_delimited(size)
511
- " · candidate pool: up to #{escape(delimited)} most recent users"
512
- end
513
-
514
714
  def write_evidence(count)
515
715
  return "<p class=\"meta\">Database writes observed: 0</p>" if count.zero?
516
716
 
@@ -527,42 +727,60 @@ module Karst
527
727
 
528
728
  # -- Usable principals ---------------------------------------------------
529
729
 
530
- def usable_outcomes(outcomes, result, state)
531
- csrf_token = state[:csrf_token]
730
+ def usable_outcomes(outcomes, result, state, test_as_token)
532
731
  body = if outcomes.empty?
533
- candidate_review(state[:unapproved_candidate_count])
732
+ candidate_review(state, result)
534
733
  else
535
- usable_cards(outcomes,
536
- result, csrf_token)
734
+ usable_cards(outcomes, result, test_as_token)
537
735
  end
538
736
  heading = outcomes.empty? ? "No verified usable user found" : "Verified usable user"
539
737
  "<section class=\"usable\"><h2>#{heading}</h2>" \
540
- "#{test_as_hint(outcomes, csrf_token)}#{body}</section>"
738
+ "#{test_as_hint(outcomes, state, test_as_token)}#{body}</section>"
541
739
  end
542
740
 
543
741
  # The one place /karst mentions candidate groups at all: a small
544
742
  # contextual action, shown only when the analysis found nothing
545
743
  # usable and unapproved application-defined groups actually exist on
546
744
  # a configured user source. Deliberately not a configuration
547
- # workflow -- it says what Karst found and offers to show it, and
548
- # names nothing it has not been approved to run.
549
- def candidate_review(count)
550
- return "" unless count&.positive?
745
+ # workflow -- it names only the candidates which may be explicitly
746
+ # approved in this result, and never executes them merely by rendering.
747
+ def candidate_review(state, result)
748
+ candidates = state[:unapproved_candidates]
749
+ return "" if candidates.empty? || !state[:csrf_token]
750
+
751
+ fields = candidates.map { |candidate| candidate_checkbox(candidate) }.join
752
+ "<form class=\"candidate-review\" method=\"post\" action=\"/karst\">#{approval_alert(state)}" \
753
+ "<p>Karst found application-defined user groups in your app. " \
754
+ "Select only the populations Karst may try, then rerun this analysis:</p>#{fields}" \
755
+ "#{candidate_form_fields(state, result)}" \
756
+ "<button type=\"submit\">Approve selected and retry</button></form>"
757
+ end
758
+
759
+ def candidate_form_fields(state, result)
760
+ hidden("operation", "approve_populations") + hidden("csrf_token", state[:csrf_token]) +
761
+ hidden("path", result.path) + hidden("method", result.http_method)
762
+ end
763
+
764
+ def approval_alert(state)
765
+ error = state[:population_approval_error]
766
+ "<p class=\"hint\" role=\"alert\">#{escape(error)}</p>" if error
767
+ end
551
768
 
552
- "<p class=\"candidate-review\">Karst found #{escape(count)} application-defined user " \
553
- "#{count == 1 ? 'group' : 'groups'} that could be tried. " \
554
- "<a href=\"/karst/populations\">Review candidate groups</a></p>"
769
+ def candidate_checkbox(candidate)
770
+ key = [candidate.principal_source, candidate.model_name, candidate.method_name].join("::")
771
+ "<label><input type=\"checkbox\" name=\"population[]\" value=\"#{escape(key)}\">" \
772
+ "#{escape(candidate.method_name)} <small>(#{escape(candidate.model_name)})</small></label>"
555
773
  end
556
774
 
557
775
  def usable_cards(outcomes, result, csrf_token)
558
776
  outcomes.map { |outcome| usable_principal(outcome, result, csrf_token) }.join
559
777
  end
560
778
 
561
- def test_as_hint(outcomes, csrf_token)
562
- 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
563
781
 
564
- state = Identity.setup_state
565
- 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
566
784
 
567
785
  custom_auth_hint
568
786
  end
@@ -570,66 +788,63 @@ module Karst
570
788
  def usable_principal(outcome, result, csrf_token)
571
789
  writes = outcome.writes_observed ? " — ⚠ #{escape(outcome.write_count)} database writes observed" : ""
572
790
  action = test_as_form(outcome.principal, result.path, csrf_token)
573
- evidence = resource_evidence(outcome, result)
574
791
  "<article class=\"usable-principal\"><h4><span>#{principal_label(outcome.principal)}#{writes}</span>" \
575
792
  "#{action}</h4><p>#{outcome_title(outcome, prefix: 'Observed ')} · " \
576
- "#{escape(outcome.elapsed_ms)}ms</p>#{sampled_for(outcome)}#{evidence}</article>"
793
+ "#{escape(outcome.elapsed_ms)}ms</p>#{identity_note(outcome)}#{sampled_for(outcome)}</article>"
577
794
  end
578
795
 
579
- # A compact, secondary line -- deliberately below the observed
580
- # outcome and above any resource evidence, never a card of its own --
581
- # so it augments a usable principal without competing with Test as,
582
- # the observed outcome, or resource evidence for attention. Sampling
583
- # evidence, not an authorization claim: see PrincipalSampler.
584
- def sampled_for(outcome)
585
- reasons = outcome.sampling_reasons
586
- return "" if reasons.nil? || reasons.empty?
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
587
804
 
588
- "<p class=\"meta\">Sampled for: #{escape(reasons.join(' · '))}</p>"
805
+ "<p class=\"meta\">⚠ #{escape(identity_summary(evidence))}</p>"
589
806
  end
590
807
 
591
- def resource_evidence(outcome, result)
592
- evidence = Access::ResourceEvidence.for_outcome(outcome: outcome, path: result.path,
593
- http_method: result.http_method)
594
- return "" if evidence.limitation || evidence.relationships.empty?
595
-
596
- "<div class=\"related-state\"><strong>Related state</strong>" \
597
- "#{relationship_groups(evidence.relationships)}</div>"
598
- rescue StandardError
599
- ""
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
600
816
  end
601
817
 
602
- def relationship_groups(relationships)
603
- relationships.group_by { |item| [item.from_model, item.from_id] }.map do |key, items|
604
- model, id = key
605
- "<p><strong>#{escape(model)} ##{escape(id)}</strong></p><ul>#{relationship_rows(items)}</ul>"
606
- end.join
818
+ def observed_label(evidence)
819
+ evidence.observed ? "#{evidence.observed.model_name} ##{evidence.observed.id}" : "no principal"
607
820
  end
608
821
 
609
- def relationship_rows(relationships)
610
- relationships.map do |item|
611
- "<li>#{escape(item.column)} → #{escape(item.to_model)} ##{escape(item.to_id)}</li>"
612
- end.join
822
+ # A compact, secondary line below the observed outcome, never a card
823
+ # of its own, so it augments a usable principal without competing with
824
+ # Test as or the observed outcome for attention. Sampling evidence,
825
+ # not an authorization claim: see PrincipalSampler.
826
+ def sampled_for(outcome)
827
+ reasons = outcome.sampling_reasons
828
+ return "" if reasons.nil? || reasons.empty?
829
+
830
+ "<p class=\"meta\">Sampled for: #{escape(reasons.join(' · '))}</p>"
613
831
  end
614
832
 
615
833
  # -- Other observed outcomes (collapsed) ---------------------------------
616
834
 
835
+ # Semantic outcome groups, largest first -- the same grouping the CLI
836
+ # and MCP evidence use (see Karst::Access::OutcomeGroups).
617
837
  def observed_groups(outcomes, path, csrf_token)
618
- outcomes.group_by { |item| outcome_group_key(item) }
619
- .map { |_key, grouped| outcome_group(grouped, path, csrf_token) }.join
620
- end
621
-
622
- def outcome_group_key(item)
623
- [item.status, item.redirect, item.exception_class, item.halted_callback,
624
- item.writes_observed, item.write_count]
838
+ Access::OutcomeGroups.group(outcomes).map { |group| outcome_group(group, path, csrf_token) }.join
625
839
  end
626
840
 
627
- def outcome_group(outcomes, path, csrf_token)
628
- first = outcomes.first
841
+ def outcome_group(group, path, csrf_token)
842
+ outcomes = group.outcomes
843
+ first = group.representative
629
844
  title = outcome_title(first)
630
845
  labels = outcomes.map { |item| outcome_principal(item, path, csrf_token) }.join
631
846
  halt = halted_callback(first)
632
- usability = usable_outcome?(first) ? "Verified usable" : "Not verified as usable"
847
+ usability = group.usable ? "Verified usable" : "Not verified as usable"
633
848
  "<details><summary>#{title}#{halt_summary(first)} — #{outcomes.size}</summary>" \
634
849
  "#{halt}<p>#{usability}</p><ul>#{labels}</ul></details>"
635
850
  end
@@ -658,11 +873,14 @@ module Karst
658
873
  def outcome_principal(item, path, csrf_token)
659
874
  writes = item.writes_observed ? " — ⚠ #{escape(item.write_count)} database writes observed" : ""
660
875
  action = test_as_form(item.principal, path, csrf_token)
661
- "<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>"
662
878
  end
663
879
 
880
+ # `csrf_token` is the search's test-as token: nil whenever browser
881
+ # identity is unsupported (see #search_result).
664
882
  def test_as_form(principal, path, csrf_token)
665
- return "" unless Identity.browser_supported? && csrf_token
883
+ return "" unless csrf_token
666
884
 
667
885
  fields = hidden("operation", "test_as") + hidden("csrf_token", csrf_token) + hidden("path", path) +
668
886
  hidden("principal_type", principal.model_name) + hidden("principal_id", principal.id)