karst 0.1.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 +7 -0
- data/ARCHITECTURE.md +59 -0
- data/CHANGELOG.md +67 -0
- data/CODE_OF_CONDUCT.md +29 -0
- data/CONTRIBUTING.md +45 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +11 -0
- data/docs/advanced-configuration.md +188 -0
- data/lib/generators/karst/install/install_generator.rb +88 -0
- data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
- data/lib/karst/access/approved_populations.rb +128 -0
- data/lib/karst/access/candidate_population.rb +86 -0
- data/lib/karst/access/database_isolation.rb +62 -0
- data/lib/karst/access/population_approvals.rb +195 -0
- data/lib/karst/access/population_config_snippet.rb +67 -0
- data/lib/karst/access/population_discovery.rb +271 -0
- data/lib/karst/access/population_preview.rb +83 -0
- data/lib/karst/access/principal_sampler.rb +241 -0
- data/lib/karst/access/principal_selection.rb +90 -0
- data/lib/karst/access/principal_source.rb +143 -0
- data/lib/karst/access/principal_source_selection.rb +161 -0
- data/lib/karst/access/probe_application.rb +164 -0
- data/lib/karst/access/resource_evidence.rb +233 -0
- data/lib/karst/access/search.rb +265 -0
- data/lib/karst/access/selected_principal_sources.rb +65 -0
- data/lib/karst/access/sensitive_attribute_names.rb +26 -0
- data/lib/karst/access/sweep.rb +198 -0
- data/lib/karst/cli/verification.rb +182 -0
- data/lib/karst/configuration.rb +223 -0
- data/lib/karst/execution_context.rb +83 -0
- data/lib/karst/identity/devise_support.rb +90 -0
- data/lib/karst/identity/warden_adapter.rb +130 -0
- data/lib/karst/identity.rb +479 -0
- data/lib/karst/mcp/server.rb +63 -0
- data/lib/karst/mcp/verify_access_tool.rb +68 -0
- data/lib/karst/railtie.rb +30 -0
- data/lib/karst/spec/catalog.rb +199 -0
- data/lib/karst/spec/example_observation.rb +31 -0
- data/lib/karst/spec/observer.rb +300 -0
- data/lib/karst/spec/principal.rb +12 -0
- data/lib/karst/spec/reporter.rb +83 -0
- data/lib/karst/spec/request_observation.rb +38 -0
- data/lib/karst/spec/scenario.rb +65 -0
- data/lib/karst/value.rb +35 -0
- data/lib/karst/version.rb +5 -0
- data/lib/karst/web/badge.rb +183 -0
- data/lib/karst/web/browser_identity.rb +103 -0
- data/lib/karst/web/locality.rb +64 -0
- data/lib/karst/web/middleware.rb +377 -0
- data/lib/karst/web/panel.rb +699 -0
- data/lib/karst/web/populations_panel.rb +391 -0
- data/lib/karst/web/route_lookup.rb +65 -0
- data/lib/karst.rb +56 -0
- data/lib/rails/commands/karst/boot.rb +24 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
- data/lib/tasks/karst.rake +34 -0
- metadata +138 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
require "active_support/number_helper"
|
|
6
|
+
require "base64"
|
|
7
|
+
require "digest"
|
|
8
|
+
|
|
9
|
+
module Karst
|
|
10
|
+
module Web
|
|
11
|
+
# Read-only HTML presentation of route access results for existing users.
|
|
12
|
+
# All artifact and query-string values cross #escape before entering the
|
|
13
|
+
# document.
|
|
14
|
+
# rubocop:disable Metrics/ModuleLength
|
|
15
|
+
module Panel
|
|
16
|
+
SCRIPT = <<~JS
|
|
17
|
+
document.addEventListener("submit", function(event) {
|
|
18
|
+
var form = event.target;
|
|
19
|
+
if (!form.matches("form.test-as")) return;
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
fetch(form.action, {
|
|
22
|
+
method: "POST", body: new FormData(form), credentials: "same-origin",
|
|
23
|
+
headers: { "Accept": "application/json" }
|
|
24
|
+
}).then(function(response) {
|
|
25
|
+
if (!response.ok) throw new Error("Test as failed");
|
|
26
|
+
return response.json();
|
|
27
|
+
}).then(function(result) {
|
|
28
|
+
window.location.assign(result.location);
|
|
29
|
+
}).catch(function() {
|
|
30
|
+
window.alert("Karst could not change the browser identity. Reload and try again.");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
JS
|
|
34
|
+
private_constant :SCRIPT
|
|
35
|
+
|
|
36
|
+
SCRIPT_HASH = Base64.strict_encode64(Digest::SHA256.digest(SCRIPT))
|
|
37
|
+
private_constant :SCRIPT_HASH
|
|
38
|
+
|
|
39
|
+
CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; " \
|
|
40
|
+
"script-src 'sha256-#{SCRIPT_HASH}'; connect-src 'self'; frame-ancestors 'none'".freeze
|
|
41
|
+
private_constant :CONTENT_SECURITY_POLICY
|
|
42
|
+
|
|
43
|
+
HEADERS = {
|
|
44
|
+
"content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
|
|
45
|
+
"x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY",
|
|
46
|
+
"content-security-policy" => CONTENT_SECURITY_POLICY
|
|
47
|
+
}.freeze
|
|
48
|
+
private_constant :HEADERS
|
|
49
|
+
|
|
50
|
+
STYLE = <<~CSS
|
|
51
|
+
:root{color-scheme:light dark}
|
|
52
|
+
*{box-sizing:border-box}
|
|
53
|
+
body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1.25rem;color:#1a1a1a;background:#fff}
|
|
54
|
+
h1{font-size:1.4rem;margin:0 0 1rem}
|
|
55
|
+
h2{font-size:.85rem;text-transform:uppercase;letter-spacing:.05em;color:#555;margin:1.75rem 0 .6rem;border-bottom:1px solid #e2e2e2;padding-bottom:.35rem}
|
|
56
|
+
h3{font-size:1rem;margin:0}
|
|
57
|
+
h4{margin:0}
|
|
58
|
+
.route-path{margin:0;color:#555;font-size:.95rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
59
|
+
.route-lookup{margin-top:.6rem;border:0;padding:0}
|
|
60
|
+
.route-lookup summary{cursor:pointer;color:#555;font-size:.85rem;font-weight:400}
|
|
61
|
+
.route-lookup form{margin-top:.6rem;display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end}
|
|
62
|
+
label{display:flex;flex-direction:column;font-size:.78rem;font-weight:600;gap:.25rem;color:#444}
|
|
63
|
+
input{font:inherit;padding:.4rem .5rem;border:1px solid #ccc;border-radius:.3rem}
|
|
64
|
+
button{font:inherit;padding:.45rem .8rem;border:1px solid #ccc;border-radius:.35rem;background:#f4f4f4;cursor:pointer}
|
|
65
|
+
button:hover{background:#eaeaea}
|
|
66
|
+
button:focus-visible,input:focus-visible,summary:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
|
|
67
|
+
button.primary{background:#202124;border-color:#202124;color:#fff;font-weight:600;padding:.65rem 1.15rem;font-size:.95rem}
|
|
68
|
+
button.primary:hover{background:#3a3b3e}
|
|
69
|
+
.hint{color:#8a5b00;font-size:.85rem;margin:.4rem 0}
|
|
70
|
+
.testing-banner{background:#fff7e0;border:1px solid #eacb6b;border-radius:.4rem;padding:.75rem 1rem;margin-bottom:1.25rem}
|
|
71
|
+
.testing-banner form{margin-top:.5rem}
|
|
72
|
+
.testing-banner p{margin:0}
|
|
73
|
+
.testing-banner p+p{margin-top:.35rem}
|
|
74
|
+
section.access{margin-bottom:1rem}
|
|
75
|
+
.meta{color:#555;font-size:.88rem}
|
|
76
|
+
section.usable{margin:1rem 0}
|
|
77
|
+
.usable-principal{border:1px solid #ddd;border-radius:.5rem;padding:.9rem 1rem;margin:.75rem 0}
|
|
78
|
+
.usable-principal h4{display:flex;justify-content:space-between;align-items:center;gap:.75rem;margin:0;flex-wrap:wrap}
|
|
79
|
+
.test-as{border:0;padding:0;margin:0;display:inline}
|
|
80
|
+
.usable-principal button[type=submit]{background:#0f5132;border-color:#0f5132;color:#fff;font-weight:600}
|
|
81
|
+
.usable-principal button[type=submit]:hover{background:#0a3d25}
|
|
82
|
+
.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
|
+
details{border:1px solid #e2e2e2;border-radius:.4rem;padding:.6rem .8rem;margin:.75rem 0}
|
|
86
|
+
details summary{cursor:pointer;font-weight:600}
|
|
87
|
+
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
|
+
.evidence{display:flex;gap:1rem;flex-wrap:wrap;font-size:.9rem}
|
|
91
|
+
.label{font-size:.72rem;font-weight:700;text-transform:uppercase;color:#666}
|
|
92
|
+
.failed,.pending{border-left:4px solid #b3261e}
|
|
93
|
+
section.populations{margin:1rem 0}
|
|
94
|
+
.population-attempt{padding:.35rem 0;font-size:.92rem;border-bottom:1px solid #eee}
|
|
95
|
+
.population-attempt:last-child{border-bottom:0}
|
|
96
|
+
.population-attempt .name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:600}
|
|
97
|
+
.population-attempt.hit .name{color:#0f5132}
|
|
98
|
+
.population-attempt.untried{color:#777}
|
|
99
|
+
.candidate-review{font-size:.9rem;color:#444;margin:.5rem 0 0}
|
|
100
|
+
.ordinary-sample{border:1px solid #ddd;border-radius:.5rem;padding:.8rem 1rem;margin:1rem 0}
|
|
101
|
+
.ordinary-sample h2{margin:0 0 .45rem}
|
|
102
|
+
.ordinary-sample details{margin:.55rem 0}
|
|
103
|
+
.write-warning{border:2px solid #b3261e;border-radius:.4rem;padding:.7rem .85rem;background:#fff7f6}
|
|
104
|
+
small{color:#666}
|
|
105
|
+
.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
|
+
@media (max-width:480px){
|
|
107
|
+
body{margin:1rem auto;padding:0 .85rem}
|
|
108
|
+
.usable-principal h4{flex-direction:column;align-items:flex-start}
|
|
109
|
+
.route-lookup form{flex-direction:column;align-items:stretch}
|
|
110
|
+
}
|
|
111
|
+
@media (prefers-color-scheme:dark){
|
|
112
|
+
body{background:#16171a;color:#e4e4e6}
|
|
113
|
+
h2{color:#a7a7ad;border-color:#2c2d31}
|
|
114
|
+
.route-path{color:#a7a7ad}
|
|
115
|
+
.meta,small,.candidate-review{color:#9a9aa0}
|
|
116
|
+
input{background:#1f2023;border-color:#3a3b3e;color:#e4e4e6}
|
|
117
|
+
button{background:#26272b;border-color:#3a3b3e;color:#e4e4e6}
|
|
118
|
+
button:hover{background:#303136}
|
|
119
|
+
button.primary{background:#e4e4e6;border-color:#e4e4e6;color:#16171a}
|
|
120
|
+
button.primary:hover{background:#c9c9cc}
|
|
121
|
+
.usable-principal{border-color:#33343a}
|
|
122
|
+
.population-attempt{border-color:#2c2d31}
|
|
123
|
+
.population-attempt.hit .name{color:#7fd8a4}
|
|
124
|
+
.population-attempt.untried{color:#8a8a90}
|
|
125
|
+
.usable-principal button[type=submit]{background:#2e7d52;border-color:#2e7d52;color:#0b1a12}
|
|
126
|
+
.related-state{background:#1c1d20;border-color:#3a3b3e}
|
|
127
|
+
details{border-color:#2c2d31}
|
|
128
|
+
.scenario{border-color:#33343a}
|
|
129
|
+
.testing-banner{background:#3a2f0d;border-color:#6b5423;color:#f0e4c0}
|
|
130
|
+
.hint{color:#d8a63d}
|
|
131
|
+
.failed,.pending{border-left-color:#e5534b}
|
|
132
|
+
.write-warning{background:#321b1a}
|
|
133
|
+
}
|
|
134
|
+
CSS
|
|
135
|
+
private_constant :STYLE
|
|
136
|
+
|
|
137
|
+
# Population-attempt states with no observed result of their own to
|
|
138
|
+
# describe (see Karst::Access::Search). The three that do -- :usable,
|
|
139
|
+
# :no_match, :unresolved -- render from their own evidence instead.
|
|
140
|
+
STATIC_ATTEMPT_STATES = {
|
|
141
|
+
empty: "no matching records",
|
|
142
|
+
already_tried: "every candidate was already tested above",
|
|
143
|
+
skipped: "not tried — a usable user was already found",
|
|
144
|
+
budget_exhausted: "not tried — the retry request budget was reached"
|
|
145
|
+
}.freeze
|
|
146
|
+
private_constant :STATIC_ATTEMPT_STATES
|
|
147
|
+
|
|
148
|
+
# rubocop:disable Metrics/ClassLength
|
|
149
|
+
class << self
|
|
150
|
+
# rubocop:disable Metrics/ParameterLists
|
|
151
|
+
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)
|
|
154
|
+
state = { csrf_token: csrf_token, browser_identity_active: browser_identity_active,
|
|
155
|
+
route_lookup_limitation: route_lookup_limitation,
|
|
156
|
+
unapproved_candidate_count: unapproved_candidate_count,
|
|
157
|
+
principal_source_selection_saved: principal_source_selection_saved,
|
|
158
|
+
principal_source_selection_error: principal_source_selection_error }
|
|
159
|
+
[200, HEADERS.dup, [document(params, access_result, state)]]
|
|
160
|
+
end
|
|
161
|
+
# rubocop:enable Metrics/ParameterLists
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def document(params, access_result, state)
|
|
166
|
+
<<~HTML
|
|
167
|
+
<!DOCTYPE html>
|
|
168
|
+
<html lang="en"><head><meta charset="utf-8"><title>Karst</title>
|
|
169
|
+
<style>#{STYLE}</style>
|
|
170
|
+
<script>#{SCRIPT}</script>
|
|
171
|
+
</head><body>
|
|
172
|
+
<h1>Karst</h1>
|
|
173
|
+
#{page_body(params, access_result, state)}
|
|
174
|
+
</body></html>
|
|
175
|
+
HTML
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def page_body(params, access_result, state)
|
|
179
|
+
controller = string_param(params, "controller")
|
|
180
|
+
action = string_param(params, "action")
|
|
181
|
+
http_method = string_param(params, "method")
|
|
182
|
+
path = string_param(params, "path")
|
|
183
|
+
"#{testing_banner(path, state[:csrf_token], state[:browser_identity_active])}" \
|
|
184
|
+
"#{route_header(http_method, path, state[:route_lookup_limitation])}" \
|
|
185
|
+
"#{access_section(http_method, path, controller, action, access_result, state)}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def string_param(params, key)
|
|
189
|
+
value = params[key]
|
|
190
|
+
value.is_a?(String) ? value.strip : ""
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# -- Testing-as banner ------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def testing_banner(path, csrf_token, active)
|
|
196
|
+
return "" unless active && Identity.browser_supported? && csrf_token
|
|
197
|
+
|
|
198
|
+
fields = hidden("operation", "stop_test_as") + hidden("csrf_token", csrf_token) + hidden("path", path)
|
|
199
|
+
<<~HTML
|
|
200
|
+
<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>
|
|
204
|
+
<form action="/karst" method="post">#{fields}<button type="submit">Stop testing as</button></form>
|
|
205
|
+
</div>
|
|
206
|
+
HTML
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# -- Compact route header ----------------------------------------------
|
|
210
|
+
|
|
211
|
+
def route_header(http_method, path, limitation)
|
|
212
|
+
identity = path.empty? ? "<p>No URL selected yet.</p>" : route_identity(http_method, path)
|
|
213
|
+
lookup = route_lookup(http_method, path, limitation)
|
|
214
|
+
"<header class=\"route\">#{identity}#{lookup}</header>"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def route_identity(http_method, path)
|
|
218
|
+
"<p class=\"route-path\">#{method_prefix(http_method)}#{escape(path)}</p>"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def method_prefix(http_method)
|
|
222
|
+
http_method.empty? ? "" : "#{escape(http_method)} "
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# rubocop:disable Metrics/MethodLength
|
|
226
|
+
def route_lookup(http_method, path, limitation)
|
|
227
|
+
open = path.empty?
|
|
228
|
+
summary = open ? "What URL are you trying to test?" : "Test a different URL"
|
|
229
|
+
attr = open ? " open" : ""
|
|
230
|
+
message = limitation ? "<p class=\"hint\" role=\"alert\">#{escape(limitation)}</p>" : ""
|
|
231
|
+
<<~HTML
|
|
232
|
+
<details class="route-lookup"#{attr}><summary>#{summary}</summary>
|
|
233
|
+
#{message}
|
|
234
|
+
<form action="/karst" method="get">
|
|
235
|
+
<input type="hidden" name="operation" value="route_lookup">
|
|
236
|
+
<label>Path <input name="path" value="#{escape(path)}" placeholder="/organizations" required></label>
|
|
237
|
+
<label>Method <input name="method" value="#{escape(http_method.empty? ? 'GET' : http_method)}" placeholder="GET" required></label>
|
|
238
|
+
<button type="submit">Use this URL</button>
|
|
239
|
+
</form></details>
|
|
240
|
+
HTML
|
|
241
|
+
end
|
|
242
|
+
# rubocop:enable Metrics/MethodLength
|
|
243
|
+
|
|
244
|
+
# -- Primary action: access analysis ------------------------------------
|
|
245
|
+
|
|
246
|
+
# rubocop:disable Metrics/ParameterLists
|
|
247
|
+
def access_section(http_method, path, controller, action, result, state)
|
|
248
|
+
return setup_notice_section(state) if path.empty? || state[:route_lookup_limitation]
|
|
249
|
+
|
|
250
|
+
context = hidden("controller", controller) + hidden("action", action) +
|
|
251
|
+
hidden("method", http_method) + hidden("path", path)
|
|
252
|
+
body = if http_method == "GET"
|
|
253
|
+
analyze_form(context, state)
|
|
254
|
+
else
|
|
255
|
+
"<p>Access analysis is available for GET routes only.</p>"
|
|
256
|
+
end
|
|
257
|
+
heading = "<h2 class=\"sr-only\">Access analysis</h2>"
|
|
258
|
+
"<section class=\"access\">#{heading}#{body}#{access_result(result, state)}</section>"
|
|
259
|
+
end
|
|
260
|
+
# rubocop:enable Metrics/ParameterLists
|
|
261
|
+
|
|
262
|
+
# A developer with several Devise models (or no automatic
|
|
263
|
+
# authentication integration at all) needs to know that before
|
|
264
|
+
# picking a URL to analyze -- not only after submitting the route
|
|
265
|
+
# lookup form, which they would have no reason to do while /karst
|
|
266
|
+
# otherwise looks entirely blank. Mirrors analyze_form's own hint so
|
|
267
|
+
# selecting a model or reading the custom-auth pointer works
|
|
268
|
+
# identically whether or not a route is already selected.
|
|
269
|
+
#
|
|
270
|
+
# The save notice itself is rendered unconditionally, exactly like
|
|
271
|
+
# analyze_form's own -- a selection saved from the bare /karst page
|
|
272
|
+
# (no route picked yet) that happens to resolve the ambiguity right
|
|
273
|
+
# away must still say so, rather than have the confirmation vanish
|
|
274
|
+
# the moment `sources` becomes truthy.
|
|
275
|
+
def setup_notice_section(state)
|
|
276
|
+
sources = principal_sources
|
|
277
|
+
notice = principal_source_selection_notice(state)
|
|
278
|
+
return notice if sources
|
|
279
|
+
|
|
280
|
+
heading = "<h2 class=\"sr-only\">Access analysis</h2>"
|
|
281
|
+
body = "#{notice}#{principal_source_hint(sources)}"
|
|
282
|
+
"<section class=\"access\">#{heading}#{body}</section>"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
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)"
|
|
289
|
+
operation = "<input type=\"hidden\" name=\"operation\" value=\"access_sweep\">"
|
|
290
|
+
button = "<button class=\"primary\" type=\"submit\">#{escape(label)}</button>"
|
|
291
|
+
form = "<form action=\"/karst\" method=\"post\">#{context}#{operation}#{button}</form>"
|
|
292
|
+
# The save notice is independent of whether the save just resolved
|
|
293
|
+
# the ambiguity below: a save that succeeded and immediately made
|
|
294
|
+
# `sources` truthy must still tell the developer it worked, rather
|
|
295
|
+
# than have the confirmation vanish the moment it stops being
|
|
296
|
+
# 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) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def principal_source_hint(sources)
|
|
315
|
+
return "" if sources
|
|
316
|
+
|
|
317
|
+
setup = Identity.setup_state
|
|
318
|
+
return principal_source_selection_form(setup) if setup.status == :ambiguous
|
|
319
|
+
|
|
320
|
+
custom_auth_hint
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def custom_auth_hint
|
|
324
|
+
url = "https://github.com/SilenceDogood1984/karst/blob/main/docs/advanced-configuration.md#custom-or-non-devise-authentication"
|
|
325
|
+
"<p class=\"hint\" role=\"alert\">Karst couldn't determine how this app authenticates users. " \
|
|
326
|
+
"<a href=\"#{url}\">Set up custom authentication</a></p>"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The one place Karst asks a developer to pick which ambiguous
|
|
330
|
+
# Devise model(s) to test, right where the old "configure
|
|
331
|
+
# config.principals" hint used to sit -- no initializer, no separate
|
|
332
|
+
# page. Only ever offers the models Devise.mappings itself currently
|
|
333
|
+
# reports (see Karst::Identity::DeviseSupport); saving is handled by
|
|
334
|
+
# Karst::Web::Middleware, which only ever persists a submitted name
|
|
335
|
+
# that matches one of those same mappings (see
|
|
336
|
+
# Karst::Access::PrincipalSourceSelection).
|
|
337
|
+
def principal_source_selection_form(setup)
|
|
338
|
+
candidates = Identity::DeviseSupport.mappings.sort_by { |mapping| mapping.model.name }
|
|
339
|
+
return "<p class=\"hint\" role=\"alert\">#{escape(setup.message)}</p>" if candidates.size < 2
|
|
340
|
+
|
|
341
|
+
<<~HTML
|
|
342
|
+
<div class="hint" role="alert">
|
|
343
|
+
#{principal_source_selection_intro(candidates)}
|
|
344
|
+
#{principal_source_selection_fields(candidates)}
|
|
345
|
+
</div>
|
|
346
|
+
HTML
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def principal_source_selection_intro(candidates)
|
|
350
|
+
names = candidates.map { |mapping| mapping.model.name }
|
|
351
|
+
"<p>Karst found #{names.size} user types: #{escape(names.join(', '))}.</p>"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def principal_source_selection_fields(candidates)
|
|
355
|
+
selected = Access::SelectedPrincipalSources.mappings.map { |mapping| mapping.model.name }
|
|
356
|
+
rows = candidates.map { |mapping| principal_source_checkbox(mapping, selected) }.join
|
|
357
|
+
<<~HTML
|
|
358
|
+
<form action="/karst" method="post">
|
|
359
|
+
<input type="hidden" name="operation" value="select_principal_sources">
|
|
360
|
+
<p>Which should Karst test?</p>
|
|
361
|
+
#{rows}
|
|
362
|
+
<button type="submit">Save</button>
|
|
363
|
+
</form>
|
|
364
|
+
HTML
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def principal_source_checkbox(mapping, selected)
|
|
368
|
+
name = mapping.model.name
|
|
369
|
+
box = "<input type=\"checkbox\" name=\"principal[]\" value=\"#{escape(name)}\"" \
|
|
370
|
+
"#{' checked' if selected.include?(name)}>"
|
|
371
|
+
"<label>#{box} #{escape(name)}</label><br>"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def principal_source_selection_notice(state)
|
|
375
|
+
saved = "<p class=\"hint\" role=\"status\">Selection saved.</p>" if state[:principal_source_selection_saved]
|
|
376
|
+
error = state[:principal_source_selection_error]
|
|
377
|
+
"#{saved}#{"<p class=\"hint\" role=\"alert\">#{escape(error)}</p>" if error}"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def hidden(name, value)
|
|
381
|
+
"<input type=\"hidden\" name=\"#{name}\" value=\"#{escape(value)}\">"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def access_result(result, state)
|
|
385
|
+
return "" unless result
|
|
386
|
+
return "<p>Analysis unavailable: #{escape(result.message)}</p>" if result.is_a?(StandardError)
|
|
387
|
+
|
|
388
|
+
search_result(result, state)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Renders one Karst::Access::Search::Result: the ordinary sample and
|
|
392
|
+
# any automatic candidate-population retries as a single answer, so
|
|
393
|
+
# a usable user found through a population reads exactly like one
|
|
394
|
+
# found in the sample -- there is no second workflow to enter.
|
|
395
|
+
def search_result(result, state)
|
|
396
|
+
csrf_token = state[:csrf_token]
|
|
397
|
+
outcomes = result.all_outcomes
|
|
398
|
+
usable = outcomes.select { |outcome| usable_outcome?(outcome) }
|
|
399
|
+
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)}"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# -- Automatic candidate-population retries ----------------------------
|
|
405
|
+
|
|
406
|
+
# Every approved population appears here, including the ones
|
|
407
|
+
# deliberately not run -- "not tried" is reported honestly rather
|
|
408
|
+
# 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.
|
|
411
|
+
def populations_section(result, csrf_token)
|
|
412
|
+
return "" if result.attempts.empty?
|
|
413
|
+
|
|
414
|
+
rows = result.attempts.map { |attempt| population_attempt(attempt, result.path, csrf_token) }.join
|
|
415
|
+
"<section class=\"populations\"><h2>Candidate populations</h2>#{rows}</section>"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def population_attempt(attempt, path, csrf_token)
|
|
419
|
+
details = attempt.result ? observed_groups(attempt.result.outcomes, path, csrf_token) : ""
|
|
420
|
+
"<div class=\"population-attempt #{attempt_class(attempt)}\">" \
|
|
421
|
+
"<span class=\"name\">#{escape(attempt.name)}</span><br>#{attempt_state(attempt)}#{details}</div>"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def attempt_class(attempt)
|
|
425
|
+
case attempt.state
|
|
426
|
+
when :usable then "hit"
|
|
427
|
+
when :skipped, :budget_exhausted then "untried"
|
|
428
|
+
else ""
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def attempt_state(attempt)
|
|
433
|
+
case attempt.state
|
|
434
|
+
when :usable then population_hit(attempt)
|
|
435
|
+
when :no_match then population_miss(attempt)
|
|
436
|
+
when :unresolved then population_unresolved(attempt)
|
|
437
|
+
else STATIC_ATTEMPT_STATES.fetch(attempt.state, "not tried")
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def population_hit(attempt)
|
|
442
|
+
outcome = attempt.result.outcomes.find { |item| usable_outcome?(item) }
|
|
443
|
+
"#{escape(attempt.result.outcomes.size)} #{users(attempt.result.outcomes.size)} tested<br>" \
|
|
444
|
+
"#{principal_label(outcome.principal)} → #{outcome_title(outcome)} ✓"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def population_miss(attempt)
|
|
448
|
+
outcomes = attempt.result.outcomes
|
|
449
|
+
"#{escape(outcomes.size)} #{users(outcomes.size)} tested<br>none verified usable"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def population_unresolved(attempt)
|
|
453
|
+
detail = attempt.error ? " (#{escape(attempt.error)})" : ""
|
|
454
|
+
"could not be resolved#{detail}"
|
|
455
|
+
end
|
|
456
|
+
|
|
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
|
+
def users(count)
|
|
466
|
+
count == 1 ? "user" : "users"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def search_meta(result)
|
|
470
|
+
initial = result.initial.outcomes.size
|
|
471
|
+
population = result.population_request_count
|
|
472
|
+
total = initial + population
|
|
473
|
+
"<p class=\"meta\"><strong>Request accounting:</strong> #{escape(initial)} initial · " \
|
|
474
|
+
"#{escape(population)} candidate population · #{escape(total)} total " \
|
|
475
|
+
"#{users(total)}/requests · #{escape(total_seconds(result))}s elapsed.</p>"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def total_seconds(result)
|
|
479
|
+
total = result.initial.elapsed_ms + result.attempted.sum { |attempt| attempt.result.elapsed_ms }
|
|
480
|
+
(total / 1000.0).round(2)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# The ordinary sample's own result stays visible even when a
|
|
484
|
+
# population later succeeded: "nothing recent worked, and here is
|
|
485
|
+
# what stopped them" is the evidence that explains why a population
|
|
486
|
+
# was tried at all.
|
|
487
|
+
def ordinary_sample(result, _csrf_token)
|
|
488
|
+
outcomes = result.initial.outcomes
|
|
489
|
+
usable = outcomes.count { |outcome| usable_outcome?(outcome) }
|
|
490
|
+
result_text = usable.positive? ? "#{usable} verified usable" : "No verified usable user"
|
|
491
|
+
pool = ordinary_pool(result.initial)
|
|
492
|
+
"<section class=\"ordinary-sample\"><h2>Ordinary sample</h2>" \
|
|
493
|
+
"<p>#{escape(outcomes.size)} #{users(outcomes.size)} tested#{pool}<br>#{result_text}</p>" \
|
|
494
|
+
"<strong>Observed:</strong>#{observed_groups(outcomes, result.path, nil)}</section>"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def ordinary_pool(initial)
|
|
498
|
+
return "" unless initial.candidate_pool_size
|
|
499
|
+
|
|
500
|
+
size = ActiveSupport::NumberHelper.number_to_delimited(initial.candidate_pool_size)
|
|
501
|
+
" from up to #{escape(size)} recent users"
|
|
502
|
+
end
|
|
503
|
+
|
|
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
|
+
def write_evidence(count)
|
|
515
|
+
return "<p class=\"meta\">Database writes observed: 0</p>" if count.zero?
|
|
516
|
+
|
|
517
|
+
"<div class=\"write-warning\" role=\"alert\"><strong>⚠ Database writes observed during " \
|
|
518
|
+
"#{escape(count)} #{count == 1 ? 'probe' : 'probes'}.</strong><br>" \
|
|
519
|
+
"Rollback was attempted on the same Active Record connection." \
|
|
520
|
+
"<br><small>Jobs, mail, external HTTP, files, Redis, and other database connections are not " \
|
|
521
|
+
"isolated by same-connection rollback.</small></div>"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def usable_outcome?(outcome)
|
|
525
|
+
Karst.config.usable_access_outcome.call(outcome)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# -- Usable principals ---------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def usable_outcomes(outcomes, result, state)
|
|
531
|
+
csrf_token = state[:csrf_token]
|
|
532
|
+
body = if outcomes.empty?
|
|
533
|
+
candidate_review(state[:unapproved_candidate_count])
|
|
534
|
+
else
|
|
535
|
+
usable_cards(outcomes,
|
|
536
|
+
result, csrf_token)
|
|
537
|
+
end
|
|
538
|
+
heading = outcomes.empty? ? "No verified usable user found" : "Verified usable user"
|
|
539
|
+
"<section class=\"usable\"><h2>#{heading}</h2>" \
|
|
540
|
+
"#{test_as_hint(outcomes, csrf_token)}#{body}</section>"
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# The one place /karst mentions candidate groups at all: a small
|
|
544
|
+
# contextual action, shown only when the analysis found nothing
|
|
545
|
+
# usable and unapproved application-defined groups actually exist on
|
|
546
|
+
# 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?
|
|
551
|
+
|
|
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>"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def usable_cards(outcomes, result, csrf_token)
|
|
558
|
+
outcomes.map { |outcome| usable_principal(outcome, result, csrf_token) }.join
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def test_as_hint(outcomes, csrf_token)
|
|
562
|
+
return "" if outcomes.empty? || (Identity.browser_supported? && csrf_token)
|
|
563
|
+
|
|
564
|
+
state = Identity.setup_state
|
|
565
|
+
return "<p class=\"hint\">#{escape(state.message)}</p>" if state.status == :ambiguous
|
|
566
|
+
|
|
567
|
+
custom_auth_hint
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def usable_principal(outcome, result, csrf_token)
|
|
571
|
+
writes = outcome.writes_observed ? " — ⚠ #{escape(outcome.write_count)} database writes observed" : ""
|
|
572
|
+
action = test_as_form(outcome.principal, result.path, csrf_token)
|
|
573
|
+
evidence = resource_evidence(outcome, result)
|
|
574
|
+
"<article class=\"usable-principal\"><h4><span>#{principal_label(outcome.principal)}#{writes}</span>" \
|
|
575
|
+
"#{action}</h4><p>#{outcome_title(outcome, prefix: 'Observed ')} · " \
|
|
576
|
+
"#{escape(outcome.elapsed_ms)}ms</p>#{sampled_for(outcome)}#{evidence}</article>"
|
|
577
|
+
end
|
|
578
|
+
|
|
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?
|
|
587
|
+
|
|
588
|
+
"<p class=\"meta\">Sampled for: #{escape(reasons.join(' · '))}</p>"
|
|
589
|
+
end
|
|
590
|
+
|
|
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
|
+
# -- Other observed outcomes (collapsed) ---------------------------------
|
|
616
|
+
|
|
617
|
+
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]
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def outcome_group(outcomes, path, csrf_token)
|
|
628
|
+
first = outcomes.first
|
|
629
|
+
title = outcome_title(first)
|
|
630
|
+
labels = outcomes.map { |item| outcome_principal(item, path, csrf_token) }.join
|
|
631
|
+
halt = halted_callback(first)
|
|
632
|
+
usability = usable_outcome?(first) ? "Verified usable" : "Not verified as usable"
|
|
633
|
+
"<details><summary>#{title}#{halt_summary(first)} — #{outcomes.size}</summary>" \
|
|
634
|
+
"#{halt}<p>#{usability}</p><ul>#{labels}</ul></details>"
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def halt_summary(outcome)
|
|
638
|
+
outcome.halted_callback ? " · halted at #{escape(outcome.halted_callback)}" : ""
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def halted_callback(outcome)
|
|
642
|
+
return "" unless outcome.halted_callback
|
|
643
|
+
|
|
644
|
+
"<p>Halted callback: #{escape(outcome.halted_callback)}</p>"
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def outcome_title(outcome, prefix: "")
|
|
648
|
+
title = if outcome.exception_class
|
|
649
|
+
"Exception: #{escape(outcome.exception_class)}"
|
|
650
|
+
elsif outcome.redirect
|
|
651
|
+
"#{escape(outcome.status)} → #{escape(outcome.redirect)}"
|
|
652
|
+
else
|
|
653
|
+
status_title(outcome.status)
|
|
654
|
+
end
|
|
655
|
+
"#{prefix}#{title}"
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def outcome_principal(item, path, csrf_token)
|
|
659
|
+
writes = item.writes_observed ? " — ⚠ #{escape(item.write_count)} database writes observed" : ""
|
|
660
|
+
action = test_as_form(item.principal, path, csrf_token)
|
|
661
|
+
"<li>#{principal_label(item.principal)} — #{escape(item.elapsed_ms)}ms#{writes}#{action}</li>"
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def test_as_form(principal, path, csrf_token)
|
|
665
|
+
return "" unless Identity.browser_supported? && csrf_token
|
|
666
|
+
|
|
667
|
+
fields = hidden("operation", "test_as") + hidden("csrf_token", csrf_token) + hidden("path", path) +
|
|
668
|
+
hidden("principal_type", principal.model_name) + hidden("principal_id", principal.id)
|
|
669
|
+
button = "<button type=\"submit\">Test as</button>"
|
|
670
|
+
" <form class=\"test-as\" action=\"/karst\" method=\"post\">#{fields}#{button}</form>"
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def status_title(status)
|
|
674
|
+
phrase = Rack::Utils::HTTP_STATUS_CODES[status]
|
|
675
|
+
phrase ? "#{escape(status)} #{escape(phrase)}" : escape(status)
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def principal_label(principal)
|
|
679
|
+
identifier = principal.respond_to?(:authentication_identifier) && principal.authentication_identifier
|
|
680
|
+
key = principal.respond_to?(:authentication_key) && principal.authentication_key
|
|
681
|
+
return escape(principal.display_label) unless identifier
|
|
682
|
+
|
|
683
|
+
identity = if key == :email
|
|
684
|
+
"<a href=\"mailto:#{escape(identifier)}\">#{escape(identifier)}</a>"
|
|
685
|
+
else
|
|
686
|
+
escape(identifier)
|
|
687
|
+
end
|
|
688
|
+
"#{identity} · #{escape(principal.model_name)} ##{escape(principal.id)}"
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def escape(value)
|
|
692
|
+
CGI.escapeHTML(value.to_s)
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
# rubocop:enable Metrics/ClassLength
|
|
696
|
+
end
|
|
697
|
+
# rubocop:enable Metrics/ModuleLength
|
|
698
|
+
end
|
|
699
|
+
end
|