karst 0.1.0 → 0.2.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "cgi"
4
4
  require "rack/utils"
5
+ require "active_support"
5
6
  require "active_support/number_helper"
6
7
  require "base64"
7
8
  require "digest"
@@ -80,13 +81,9 @@ module Karst
80
81
  .usable-principal button[type=submit]{background:#0f5132;border-color:#0f5132;color:#fff;font-weight:600}
81
82
  .usable-principal button[type=submit]:hover{background:#0a3d25}
82
83
  .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
84
  details{border:1px solid #e2e2e2;border-radius:.4rem;padding:.6rem .8rem;margin:.75rem 0}
86
85
  details summary{cursor:pointer;font-weight:600}
87
86
  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
87
  .evidence{display:flex;gap:1rem;flex-wrap:wrap;font-size:.9rem}
91
88
  .label{font-size:.72rem;font-weight:700;text-transform:uppercase;color:#666}
92
89
  .failed,.pending{border-left:4px solid #b3261e}
@@ -97,6 +94,7 @@ module Karst
97
94
  .population-attempt.hit .name{color:#0f5132}
98
95
  .population-attempt.untried{color:#777}
99
96
  .candidate-review{font-size:.9rem;color:#444;margin:.5rem 0 0}
97
+ .candidate-review label{display:flex;flex-direction:row;align-items:center;font-size:.9rem;margin:.35rem 0}
100
98
  .ordinary-sample{border:1px solid #ddd;border-radius:.5rem;padding:.8rem 1rem;margin:1rem 0}
101
99
  .ordinary-sample h2{margin:0 0 .45rem}
102
100
  .ordinary-sample details{margin:.55rem 0}
@@ -123,9 +121,7 @@ module Karst
123
121
  .population-attempt.hit .name{color:#7fd8a4}
124
122
  .population-attempt.untried{color:#8a8a90}
125
123
  .usable-principal button[type=submit]{background:#2e7d52;border-color:#2e7d52;color:#0b1a12}
126
- .related-state{background:#1c1d20;border-color:#3a3b3e}
127
124
  details{border-color:#2c2d31}
128
- .scenario{border-color:#33343a}
129
125
  .testing-banner{background:#3a2f0d;border-color:#6b5423;color:#f0e4c0}
130
126
  .hint{color:#d8a63d}
131
127
  .failed,.pending{border-left-color:#e5534b}
@@ -149,11 +145,12 @@ module Karst
149
145
  class << self
150
146
  # rubocop:disable Metrics/ParameterLists
151
147
  def render(params: {}, access_result: nil, csrf_token: nil, browser_identity_active: false,
152
- route_lookup_limitation: nil, unapproved_candidate_count: nil,
148
+ route_lookup_limitation: nil, unapproved_candidates: [], population_approval_error: nil,
153
149
  principal_source_selection_saved: false, principal_source_selection_error: nil)
154
150
  state = { csrf_token: csrf_token, browser_identity_active: browser_identity_active,
155
151
  route_lookup_limitation: route_lookup_limitation,
156
- unapproved_candidate_count: unapproved_candidate_count,
152
+ unapproved_candidates: unapproved_candidates,
153
+ population_approval_error: population_approval_error,
157
154
  principal_source_selection_saved: principal_source_selection_saved,
158
155
  principal_source_selection_error: principal_source_selection_error }
159
156
  [200, HEADERS.dup, [document(params, access_result, state)]]
@@ -198,9 +195,8 @@ module Karst
198
195
  fields = hidden("operation", "stop_test_as") + hidden("csrf_token", csrf_token) + hidden("path", path)
199
196
  <<~HTML
200
197
  <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>
198
+ <p><strong>Currently testing as an assumed user.</strong></p>
199
+ <p><small>Stopping signs this browser out; Karst does not restore your previous session.</small></p>
204
200
  <form action="/karst" method="post">#{fields}<button type="submit">Stop testing as</button></form>
205
201
  </div>
206
202
  HTML
@@ -284,8 +280,7 @@ module Karst
284
280
 
285
281
  def analyze_form(context, state)
286
282
  sources = principal_sources
287
- kind = sources && any_representative?(sources) ? "representative " : ""
288
- label = "Who can use this? (test #{Karst.config.access_sweep_limit} #{kind}users)"
283
+ label = "Who can use this? (test #{Karst.config.access_sweep_limit} users)"
289
284
  operation = "<input type=\"hidden\" name=\"operation\" value=\"access_sweep\">"
290
285
  button = "<button class=\"primary\" type=\"submit\">#{escape(label)}</button>"
291
286
  form = "<form action=\"/karst\" method=\"post\">#{context}#{operation}#{button}</form>"
@@ -297,20 +292,12 @@ module Karst
297
292
  "#{principal_source_selection_notice(state)}#{form}#{principal_source_hint(sources)}"
298
293
  end
299
294
 
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
295
  def principal_sources
305
296
  Identity.principal_sources
306
297
  rescue Identity::Error
307
298
  nil
308
299
  end
309
300
 
310
- def any_representative?(sources)
311
- sources.values.any? { |source| Access::PrincipalSampler.representative_capable?(source.evaluate) }
312
- end
313
-
314
301
  def principal_source_hint(sources)
315
302
  return "" if sources
316
303
 
@@ -406,8 +393,8 @@ module Karst
406
393
  # Every approved population appears here, including the ones
407
394
  # deliberately not run -- "not tried" is reported honestly rather
408
395
  # 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.
396
+ # reach this list; a name merely discovered after a failed analysis
397
+ # is never executed automatically.
411
398
  def populations_section(result, csrf_token)
412
399
  return "" if result.attempts.empty?
413
400
 
@@ -454,14 +441,6 @@ module Karst
454
441
  "could not be resolved#{detail}"
455
442
  end
456
443
 
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
444
  def users(count)
466
445
  count == 1 ? "user" : "users"
467
446
  end
@@ -470,7 +449,7 @@ module Karst
470
449
  initial = result.initial.outcomes.size
471
450
  population = result.population_request_count
472
451
  total = initial + population
473
- "<p class=\"meta\"><strong>Request accounting:</strong> #{escape(initial)} initial · " \
452
+ "<p class=\"meta\"><strong>Tested:</strong> #{escape(initial)} initial · " \
474
453
  "#{escape(population)} candidate population · #{escape(total)} total " \
475
454
  "#{users(total)}/requests · #{escape(total_seconds(result))}s elapsed.</p>"
476
455
  end
@@ -501,16 +480,6 @@ module Karst
501
480
  " from up to #{escape(size)} recent users"
502
481
  end
503
482
 
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
483
  def write_evidence(count)
515
484
  return "<p class=\"meta\">Database writes observed: 0</p>" if count.zero?
516
485
 
@@ -530,7 +499,7 @@ module Karst
530
499
  def usable_outcomes(outcomes, result, state)
531
500
  csrf_token = state[:csrf_token]
532
501
  body = if outcomes.empty?
533
- candidate_review(state[:unapproved_candidate_count])
502
+ candidate_review(state, result)
534
503
  else
535
504
  usable_cards(outcomes,
536
505
  result, csrf_token)
@@ -544,14 +513,34 @@ module Karst
544
513
  # contextual action, shown only when the analysis found nothing
545
514
  # usable and unapproved application-defined groups actually exist on
546
515
  # 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?
516
+ # workflow -- it names only the candidates which may be explicitly
517
+ # approved in this result, and never executes them merely by rendering.
518
+ def candidate_review(state, result)
519
+ candidates = state[:unapproved_candidates]
520
+ return "" if candidates.empty? || !state[:csrf_token]
551
521
 
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>"
522
+ fields = candidates.map { |candidate| candidate_checkbox(candidate) }.join
523
+ "<form class=\"candidate-review\" method=\"post\" action=\"/karst\">#{approval_alert(state)}" \
524
+ "<p>Karst found application-defined user groups in your app. " \
525
+ "Select only the populations Karst may try, then rerun this analysis:</p>#{fields}" \
526
+ "#{candidate_form_fields(state, result)}" \
527
+ "<button type=\"submit\">Approve selected and retry</button></form>"
528
+ end
529
+
530
+ def candidate_form_fields(state, result)
531
+ hidden("operation", "approve_populations") + hidden("csrf_token", state[:csrf_token]) +
532
+ hidden("path", result.path) + hidden("method", result.http_method)
533
+ end
534
+
535
+ def approval_alert(state)
536
+ error = state[:population_approval_error]
537
+ "<p class=\"hint\" role=\"alert\">#{escape(error)}</p>" if error
538
+ end
539
+
540
+ def candidate_checkbox(candidate)
541
+ key = [candidate.principal_source, candidate.model_name, candidate.method_name].join("::")
542
+ "<label><input type=\"checkbox\" name=\"population[]\" value=\"#{escape(key)}\">" \
543
+ "#{escape(candidate.method_name)} <small>(#{escape(candidate.model_name)})</small></label>"
555
544
  end
556
545
 
557
546
  def usable_cards(outcomes, result, csrf_token)
@@ -570,17 +559,15 @@ module Karst
570
559
  def usable_principal(outcome, result, csrf_token)
571
560
  writes = outcome.writes_observed ? " — ⚠ #{escape(outcome.write_count)} database writes observed" : ""
572
561
  action = test_as_form(outcome.principal, result.path, csrf_token)
573
- evidence = resource_evidence(outcome, result)
574
562
  "<article class=\"usable-principal\"><h4><span>#{principal_label(outcome.principal)}#{writes}</span>" \
575
563
  "#{action}</h4><p>#{outcome_title(outcome, prefix: 'Observed ')} · " \
576
- "#{escape(outcome.elapsed_ms)}ms</p>#{sampled_for(outcome)}#{evidence}</article>"
564
+ "#{escape(outcome.elapsed_ms)}ms</p>#{sampled_for(outcome)}</article>"
577
565
  end
578
566
 
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.
567
+ # A compact, secondary line below the observed outcome, never a card
568
+ # of its own, so it augments a usable principal without competing with
569
+ # Test as or the observed outcome for attention. Sampling evidence,
570
+ # not an authorization claim: see PrincipalSampler.
584
571
  def sampled_for(outcome)
585
572
  reasons = outcome.sampling_reasons
586
573
  return "" if reasons.nil? || reasons.empty?
@@ -588,30 +575,6 @@ module Karst
588
575
  "<p class=\"meta\">Sampled for: #{escape(reasons.join(' · '))}</p>"
589
576
  end
590
577
 
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
- ""
600
- end
601
-
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
607
- end
608
-
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
613
- end
614
-
615
578
  # -- Other observed outcomes (collapsed) ---------------------------------
616
579
 
617
580
  def observed_groups(outcomes, path, csrf_token)