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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +8 -11
- data/CHANGELOG.md +37 -50
- data/README.md +20 -8
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +8 -9
- data/docs/rails8-authentication.md +171 -0
- data/lib/karst/access/population_approval.rb +67 -0
- data/lib/karst/access/population_revocation.rb +34 -0
- data/lib/karst/configuration.rb +5 -5
- data/lib/karst/execution_context.rb +6 -7
- data/lib/karst/railtie.rb +4 -7
- data/lib/karst/version.rb +1 -1
- data/lib/karst/web/badge.rb +5 -31
- data/lib/karst/web/browser_identity.rb +10 -13
- data/lib/karst/web/csrf.rb +43 -0
- data/lib/karst/web/middleware.rb +50 -78
- data/lib/karst/web/panel.rb +44 -81
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +1 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +14 -1
- metadata +7 -14
- data/lib/karst/access/population_config_snippet.rb +0 -67
- data/lib/karst/access/population_preview.rb +0 -83
- data/lib/karst/access/resource_evidence.rb +0 -233
- data/lib/karst/spec/catalog.rb +0 -199
- data/lib/karst/spec/example_observation.rb +0 -31
- data/lib/karst/spec/observer.rb +0 -300
- data/lib/karst/spec/principal.rb +0 -12
- data/lib/karst/spec/reporter.rb +0 -83
- data/lib/karst/spec/request_observation.rb +0 -38
- data/lib/karst/spec/scenario.rb +0 -65
- data/lib/tasks/karst.rake +0 -34
data/lib/karst/web/panel.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
202
|
-
<p><small>Stopping
|
|
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
|
-
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
548
|
-
#
|
|
549
|
-
def candidate_review(
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
"<
|
|
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)}
|
|
564
|
+
"#{escape(outcome.elapsed_ms)}ms</p>#{sampled_for(outcome)}</article>"
|
|
577
565
|
end
|
|
578
566
|
|
|
579
|
-
# A compact, secondary line
|
|
580
|
-
#
|
|
581
|
-
#
|
|
582
|
-
#
|
|
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)
|