karst 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +6 -1
- data/CHANGELOG.md +82 -2
- data/CONTRIBUTING.md +9 -1
- data/README.md +118 -2
- data/docs/advanced-configuration.md +106 -0
- data/docs/rails8-authentication.md +24 -4
- data/docs/request-reproduction.md +242 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
- data/lib/karst/access/approved_populations.rb +15 -6
- data/lib/karst/access/errors.rb +16 -0
- data/lib/karst/access/identity_probe.rb +151 -0
- data/lib/karst/access/local_path.rb +42 -0
- data/lib/karst/access/observed_endpoint.rb +46 -0
- data/lib/karst/access/outcome_groups.rb +93 -0
- data/lib/karst/access/population_discovery.rb +25 -1
- data/lib/karst/access/principal_source.rb +24 -3
- data/lib/karst/access/probe.rb +397 -0
- data/lib/karst/access/search.rb +14 -6
- data/lib/karst/access/sweep.rb +42 -112
- data/lib/karst/cli/identity_serialization.rb +82 -0
- data/lib/karst/cli/outcome_summary.rb +131 -0
- data/lib/karst/cli/principal_reference.rb +51 -0
- data/lib/karst/cli/reproduction.rb +267 -0
- data/lib/karst/cli/verification.rb +219 -55
- data/lib/karst/configuration.rb +52 -6
- data/lib/karst/identity/evidence.rb +110 -0
- data/lib/karst/identity/observation.rb +167 -0
- data/lib/karst/identity/warden_adapter.rb +26 -1
- data/lib/karst/identity.rb +157 -22
- data/lib/karst/mcp/compatibility.rb +24 -0
- data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
- data/lib/karst/mcp/reproduction.rb +58 -0
- data/lib/karst/mcp/server.rb +27 -8
- data/lib/karst/mcp/verify_access_tool.rb +36 -2
- data/lib/karst/reproduction/curl.rb +73 -0
- data/lib/karst/reproduction/exercise.rb +246 -0
- data/lib/karst/reproduction/observation.rb +87 -0
- data/lib/karst/reproduction/sanitizer.rb +210 -0
- data/lib/karst/version.rb +1 -1
- data/lib/karst/web/middleware.rb +105 -16
- data/lib/karst/web/panel.rb +302 -47
- data/lib/karst.rb +2 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +33 -6
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Request reproduction
|
|
2
|
+
|
|
3
|
+
> Something calls this Rails endpoint. What request do I send to exercise the same behavior?
|
|
4
|
+
|
|
5
|
+
That is the whole question this answers. A Jira story says an external system
|
|
6
|
+
`POST`s to `/api/v1/inspections`; the endpoint exists, someone else built it,
|
|
7
|
+
and reproducing it locally normally means reading routes, controllers,
|
|
8
|
+
`before_action` filters, and old specs until you can guess a working request.
|
|
9
|
+
|
|
10
|
+
Karst answers it the same way it answers every other question: by running the
|
|
11
|
+
real application and reporting what actually happened.
|
|
12
|
+
|
|
13
|
+
## What it is
|
|
14
|
+
|
|
15
|
+
You give Karst a method, a path, and (for a body-carrying method) a body.
|
|
16
|
+
Karst issues **exactly one** request through the real Rails stack, inside a
|
|
17
|
+
rolled-back database transaction, and reports:
|
|
18
|
+
|
|
19
|
+
- the controller and action that actually dispatched
|
|
20
|
+
- whether the controller lifecycle *completed* (`controller_completed`) --
|
|
21
|
+
distinct from dispatching: a controller can dispatch and still raise
|
|
22
|
+
- the route parameters the router actually bound
|
|
23
|
+
- the halted callback, if a `before_action` stopped the request
|
|
24
|
+
- any raised exception, and the most specific phase Karst can prove it
|
|
25
|
+
happened in -- `controller`, `render`, or `unknown`
|
|
26
|
+
- which templates, partials, and layouts were observed rendering (or
|
|
27
|
+
raising), by structural virtual path only -- never their content
|
|
28
|
+
- the response status and content type
|
|
29
|
+
- how many database writes the request made
|
|
30
|
+
- a **cURL command for exactly what Karst sent**
|
|
31
|
+
|
|
32
|
+
Every one of these is scoped strictly to the one request you asked for. If it
|
|
33
|
+
raised before producing a response, the response fields are `null` and named
|
|
34
|
+
in `unobserved` -- never a status, content type, or redirect left over from an
|
|
35
|
+
earlier request Karst ran on the same session (identity establishment, most
|
|
36
|
+
often).
|
|
37
|
+
|
|
38
|
+
Then you iterate. A first attempt that halts at `authenticate_api_key!` has
|
|
39
|
+
told you the endpoint's real gate. Add the header, send again, get a `201`,
|
|
40
|
+
and copy the cURL — which is now a command you have watched work, not one
|
|
41
|
+
assembled from reading code.
|
|
42
|
+
|
|
43
|
+
## This is observed behavior, not documentation
|
|
44
|
+
|
|
45
|
+
Karst observes the requests it issues. It does not record your application's
|
|
46
|
+
production traffic, and it never infers a request from routes, controller
|
|
47
|
+
source, or strong-parameter declarations.
|
|
48
|
+
|
|
49
|
+
The practical consequence: Karst can tell you **whether the request you
|
|
50
|
+
described works, and what it did**, but it cannot invent a body it has never
|
|
51
|
+
seen. What it removes is the two genuinely hard parts — knowing whether a
|
|
52
|
+
request works, and knowing what stopped it when it doesn't.
|
|
53
|
+
|
|
54
|
+
A field Karst could not observe is `null` and is named in `unobserved`, never
|
|
55
|
+
filled in with a plausible-looking value. If the path did not route, Karst
|
|
56
|
+
reports no controller rather than guessing one.
|
|
57
|
+
|
|
58
|
+
### What Karst deliberately does not capture
|
|
59
|
+
|
|
60
|
+
- **Your application's real traffic.** Karst installs no request recorder and
|
|
61
|
+
keeps no buffer of past requests. There is nothing to leak later, and
|
|
62
|
+
nothing running on an ordinary request.
|
|
63
|
+
- **How an external client authenticates.** Karst can sign in as one of your
|
|
64
|
+
own existing users, and it reports the callback that halted a request — but
|
|
65
|
+
it never claims to know that an endpoint "uses an API key". The halted
|
|
66
|
+
callback's name is evidence; anything beyond it would be a guess.
|
|
67
|
+
- **Response bodies.** Karst reports the response status and content type,
|
|
68
|
+
never the body.
|
|
69
|
+
|
|
70
|
+
## Secrets
|
|
71
|
+
|
|
72
|
+
A generated request that makes you fill in a credential is better than one
|
|
73
|
+
that leaks one, so Karst always chooses the former.
|
|
74
|
+
|
|
75
|
+
- **Parameters** pass through your application's own
|
|
76
|
+
`config.filter_parameters`, using Rails' own `ActiveSupport::ParameterFilter`
|
|
77
|
+
— the same rules, including nested hashes and Procs, that already keep those
|
|
78
|
+
values out of your logs. Karst then applies one additional, conservative
|
|
79
|
+
credential-name filter on top (`token`, `secret`, `api_key`, `password`,
|
|
80
|
+
`signature`, `session`, ...), because an application's `filter_parameters` is
|
|
81
|
+
tuned for its own logs and routinely misses credentials that only appear in a
|
|
82
|
+
third-party integration's payload.
|
|
83
|
+
- **Headers** are handled by name, never by value. `Authorization`, `Cookie`,
|
|
84
|
+
`X-Api-Key`, `X-CSRF-Token` and friends become `<AUTH_TOKEN>`,
|
|
85
|
+
`<SESSION_COOKIE>`, `<API_KEY>`, `<CSRF_TOKEN>`. Their real values are never
|
|
86
|
+
read, compared, or echoed. Headers that describe a request rather than
|
|
87
|
+
authorize it (`Content-Type`, `Accept`, ...) are shown as sent; anything else
|
|
88
|
+
whose name looks credential-shaped becomes `<FILTERED>`.
|
|
89
|
+
- **Referer and Origin are URLs, so name-based header handling isn't enough
|
|
90
|
+
for them.** A `Referer` routinely carries the query string of whatever page
|
|
91
|
+
linked here — `?token=`, `?code=`, `?api_key=` — and, for an OAuth-style
|
|
92
|
+
redirect, a fragment such as `#access_token=`. Karst keeps the scheme, host,
|
|
93
|
+
and path (useful context: "this came from the password-reset page") and
|
|
94
|
+
discards the query, fragment, and any userinfo — structurally, by parsing
|
|
95
|
+
the header as a URL, never by inspecting the value for what looks like a
|
|
96
|
+
credential. A value that does not parse as a URL at all is shown as
|
|
97
|
+
`<FILTERED>` rather than echoed.
|
|
98
|
+
- **Credentials in the path** — a password-reset token, a signed id — are
|
|
99
|
+
substituted back out of the URL too, so `/reset/s3cret` becomes
|
|
100
|
+
`/reset/<TOKEN>` in both the recipe and the cURL command.
|
|
101
|
+
- **A redirect Karst observes** has its query string and fragment stripped
|
|
102
|
+
the same way, for the same reason: an OAuth-style callback's `Location`
|
|
103
|
+
can carry `?code=` or `#access_token=` every bit as much as a `Referer`
|
|
104
|
+
can.
|
|
105
|
+
- **A body Karst cannot parse** (anything but JSON or form encoding) is sent
|
|
106
|
+
as given but never echoed back; the command shows `<BODY>`.
|
|
107
|
+
- **Nothing is persisted.** Karst writes no recipe, request, or credential to
|
|
108
|
+
disk, and `/karst` is served `no-store`.
|
|
109
|
+
|
|
110
|
+
One deliberate exception, for usability: the `/karst` form re-fills the body
|
|
111
|
+
and header boxes with what you just typed, so you can edit and resend. That is
|
|
112
|
+
ordinary form behavior on your own machine — but it does mean a credential you
|
|
113
|
+
paste there is visible on that page until you navigate away. The **recipe**
|
|
114
|
+
Karst generates, which is the part you copy into a ticket, never contains one.
|
|
115
|
+
|
|
116
|
+
## Reproducing a request
|
|
117
|
+
|
|
118
|
+
### At `/karst`
|
|
119
|
+
|
|
120
|
+
Open `/karst`, pick a URL, and expand **Reproduce request**. Fill in method,
|
|
121
|
+
path, content type, body, and any headers, then press **Send once and build
|
|
122
|
+
request**. The recipe appears below, ending in a copyable cURL command built
|
|
123
|
+
against your own development origin.
|
|
124
|
+
|
|
125
|
+
### From the shell
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
bin/rails karst:reproduce POST /api/v1/inspections \
|
|
129
|
+
--content-type application/json \
|
|
130
|
+
--body '{"serial_number":"ABC123","status":"passed"}' \
|
|
131
|
+
--header 'Authorization: Bearer real-key'
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Add `--json` for the same schema-versioned evidence document the MCP tool
|
|
135
|
+
returns. `--anonymous` sends without assuming any identity. `--as MODEL:ID`
|
|
136
|
+
(e.g. `--as User:72`) sends as one specific existing record instead of the
|
|
137
|
+
ordinary sampled one, resolved only through Karst's own configured principal
|
|
138
|
+
source — see [Running as a specific principal](advanced-configuration.md#running-as-a-specific-principal)
|
|
139
|
+
in the advanced configuration docs. `--as` is CLI-only and cannot be combined
|
|
140
|
+
with `--anonymous`. Exit code `0` means Karst observed a response, `1` means
|
|
141
|
+
the request raised, `2` means Karst could not issue it at all.
|
|
142
|
+
|
|
143
|
+
### From a coding agent
|
|
144
|
+
|
|
145
|
+
The MCP server exposes `reproduce_request(path:, method:, body:, content_type:,
|
|
146
|
+
headers:, anonymous:, base_url:)`. It returns the same document as `--json`:
|
|
147
|
+
request, identity, execution, response, `unobserved`, isolation, and the cURL
|
|
148
|
+
command.
|
|
149
|
+
|
|
150
|
+
MCP reproduction permits only `GET` and `HEAD` by default. `POST`, `PUT`,
|
|
151
|
+
`PATCH`, and `DELETE` fail with a structured `method_disabled` error before
|
|
152
|
+
Karst constructs or executes an application request. A host application can
|
|
153
|
+
make those methods available to MCP explicitly:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
Karst.configure { |config| config.mcp_mutating_requests = true }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
That opt-in applies only to the MCP surface. Human-driven reproduction through
|
|
160
|
+
`bin/rails karst:reproduce` and the `/karst` reproduction form continues to
|
|
161
|
+
support all six methods without it. Enabling the MCP option means an agent can
|
|
162
|
+
issue a real mutating request as an application principal; it is not a claim
|
|
163
|
+
that the request is safe or free of side effects.
|
|
164
|
+
|
|
165
|
+
It is a second tool rather than a mode of `verify_access` because the two have
|
|
166
|
+
different blast radii. `verify_access` answers "which existing user can reach
|
|
167
|
+
this page" by issuing up to 25 bounded GET requests automatically, and is
|
|
168
|
+
GET-only for a reason: running a mutating method as 25 users would mean 25
|
|
169
|
+
real creates, 25 enqueued jobs, and 25 delivered mails. `reproduce_request`
|
|
170
|
+
issues exactly one request that the caller fully specified.
|
|
171
|
+
|
|
172
|
+
## Identity
|
|
173
|
+
|
|
174
|
+
By default Karst sends the request as one existing user drawn from your
|
|
175
|
+
application's own configured principal source — the same machinery `/karst`
|
|
176
|
+
uses to answer "who can use this?", so you do not have to know how your app
|
|
177
|
+
signs anyone in. Pass `--anonymous` (or `anonymous: true`) for a genuinely
|
|
178
|
+
identity-free probe: Karst establishes no identity at all, and then verifies
|
|
179
|
+
that the application actually resolved none, which is usually what you want
|
|
180
|
+
for an endpoint an external system calls.
|
|
181
|
+
|
|
182
|
+
Karst assumes an identity directly rather than authenticating as an external
|
|
183
|
+
client would, so the recipe's `identity` document reports what it *observed*
|
|
184
|
+
the application resolve while running the request — not just what it asked
|
|
185
|
+
for — through the same `requested`/`observed`/`confirmation` evidence
|
|
186
|
+
`verify_access` reports:
|
|
187
|
+
|
|
188
|
+
- `confirmed` — the application ran as exactly the requested user.
|
|
189
|
+
- `confirmed_anonymous` — the application ran with no principal, as asked.
|
|
190
|
+
- `mismatch` — the application resolved a *different* principal than requested.
|
|
191
|
+
- `absent` — Karst's identity setup did not take, and the application resolved
|
|
192
|
+
no principal at all.
|
|
193
|
+
- `contaminated` — an anonymous probe was requested, but the application
|
|
194
|
+
resolved a principal anyway (stale session state, most often).
|
|
195
|
+
- `unobservable` — Karst has no way to see what the application resolved
|
|
196
|
+
(configure `config.observe_identity`, or use Karst's Devise/Warden
|
|
197
|
+
integration).
|
|
198
|
+
|
|
199
|
+
Only `confirmed` and `confirmed_anonymous` mean the recipe ran under the
|
|
200
|
+
identity it claims to.
|
|
201
|
+
|
|
202
|
+
## Limitations
|
|
203
|
+
|
|
204
|
+
- **Same-connection rollback is not side-effect isolation.** Database rollback
|
|
205
|
+
is attempted for writes on the request's own connection. Background jobs,
|
|
206
|
+
mail, outbound HTTP, files, Redis/external stores, and other database
|
|
207
|
+
connections are not.
|
|
208
|
+
One `POST` that enqueues a job really enqueues it. MCP therefore refuses
|
|
209
|
+
mutating methods by default; opting in does not broaden transaction isolation.
|
|
210
|
+
Human reproduction remains explicit and issues exactly one request, never a
|
|
211
|
+
sweep.
|
|
212
|
+
- **Development only.** Reproduction refuses to run outside
|
|
213
|
+
`Rails.env.development?`, refuses when `config.enabled` is false, and at
|
|
214
|
+
`/karst` requires a loopback peer, a same-origin POST, and Karst's own CSRF
|
|
215
|
+
token.
|
|
216
|
+
- **Local paths only.** A target with a scheme, a host, or a leading `//` is
|
|
217
|
+
refused rather than normalized.
|
|
218
|
+
- **`GET HEAD POST PUT PATCH DELETE` only.** Anything else is refused rather
|
|
219
|
+
than passed through.
|
|
220
|
+
- **Header names must be header names.** A name outside `[A-Za-z0-9-]+`, or a
|
|
221
|
+
CGI variable such as `REQUEST_METHOD` or `PATH_INFO`, is refused. Those
|
|
222
|
+
would be written into the Rack environment rather than sent as headers, so
|
|
223
|
+
the request Karst issued would stop matching the request Karst reports.
|
|
224
|
+
- **Multipart bodies are not parsed.** They are sent as given and shown as
|
|
225
|
+
`<BODY>`.
|
|
226
|
+
- **Karst cannot invent a body.** If nothing has ever told you what the
|
|
227
|
+
external system sends, Karst will faithfully report that your guess returned
|
|
228
|
+
a `422` — which is still more than reading the controller gives you, but it
|
|
229
|
+
is not the payload.
|
|
230
|
+
- **Redaction is name-based (and, for Referer/Origin/redirects, structural),
|
|
231
|
+
never value-based.** A custom header, or a query or body field, whose name
|
|
232
|
+
neither your application's `filter_parameters` nor Karst's own
|
|
233
|
+
credential-name net recognizes is shown exactly as sent — even if its value
|
|
234
|
+
happens to hold a secret (a bespoke `X-Callback-Url` header carrying a
|
|
235
|
+
token in its own query string, say). If your application accepts secrets
|
|
236
|
+
under names Karst has no way to guess, add them to
|
|
237
|
+
`config.filter_parameters`; Karst's net is a supplement to that, never a
|
|
238
|
+
replacement for it.
|
|
239
|
+
|
|
240
|
+
This is not API documentation, an OpenAPI generator, a request collection, or
|
|
241
|
+
an HTTP client. It makes one workflow easier: reproducing a request the
|
|
242
|
+
application actually handles.
|
|
@@ -12,6 +12,12 @@ Karst.configure do |config|
|
|
|
12
12
|
end
|
|
13
13
|
config.clear_identity = ->(session) { session.delete "/karst_test_logout" }
|
|
14
14
|
|
|
15
|
+
# How Karst observes which principal the application itself resolved while
|
|
16
|
+
# running a probe. Without it Karst reports identity as unobservable rather
|
|
17
|
+
# than assuming the requested principal is the one that ran.
|
|
18
|
+
# TODO: return this app's runtime principal (nil when it has none).
|
|
19
|
+
config.observe_identity = ->(context) { context.controller&.current_account }
|
|
20
|
+
|
|
15
21
|
# TODO: replace :account_id with this app's browser-session identity.
|
|
16
22
|
config.assume_browser_identity = ->(request, principal) { request.session[:account_id] = principal.id }
|
|
17
23
|
config.clear_browser_identity = ->(request) { request.session.delete(:account_id) }
|
|
@@ -42,16 +42,19 @@ module Karst
|
|
|
42
42
|
# populations extended by whatever its model has approved and
|
|
43
43
|
# confirmed. Returns the argument untouched when nothing applies, so
|
|
44
44
|
# the overwhelmingly common "no approvals" case costs one file stat.
|
|
45
|
-
|
|
45
|
+
# `record:`/`discovery:`, when given, are a caller's already-loaded
|
|
46
|
+
# PopulationApprovals::Record and PopulationDiscovery instance (see
|
|
47
|
+
# Karst::Identity::Snapshot, which resolves this once per operation
|
|
48
|
+
# and shares both with whatever else that operation needs to discover
|
|
49
|
+
# or confirm populations) -- reused instead of loading and parsing
|
|
50
|
+
# again. Omitted, each is loaded fresh exactly as before.
|
|
51
|
+
def merge(sources, record: nil, discovery: nil)
|
|
46
52
|
return sources unless sources && local_environment?
|
|
47
53
|
|
|
48
|
-
record
|
|
54
|
+
record ||= PopulationApprovals.load
|
|
49
55
|
return sources if record.entries.empty?
|
|
50
56
|
|
|
51
|
-
discovery
|
|
52
|
-
sources.each_with_object({}) do |(name, source), merged|
|
|
53
|
-
merged[name] = extend_source(source, record.entries, discovery)
|
|
54
|
-
end
|
|
57
|
+
merge_entries(sources, record.entries, discovery || PopulationDiscovery.new)
|
|
55
58
|
rescue StandardError
|
|
56
59
|
# Approval is an optional convenience layered over configuration
|
|
57
60
|
# Karst already had. If resolving it fails for any reason, the
|
|
@@ -88,6 +91,12 @@ module Karst
|
|
|
88
91
|
|
|
89
92
|
private
|
|
90
93
|
|
|
94
|
+
def merge_entries(sources, entries, discovery)
|
|
95
|
+
sources.each_with_object({}) do |(name, source), merged|
|
|
96
|
+
merged[name] = extend_source(source, entries, discovery)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
91
100
|
def extend_source(source, entries, discovery)
|
|
92
101
|
klass = source.record_klass
|
|
93
102
|
return source unless klass
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karst
|
|
4
|
+
module Access
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class UnsafeTarget < Error; end
|
|
7
|
+
class UnsupportedMethod < Error; end
|
|
8
|
+
class Unavailable < Error; end
|
|
9
|
+
|
|
10
|
+
# Karst could not observe a request it tried to run -- one of its own
|
|
11
|
+
# subscribers raised, or the request never reached the application. Never
|
|
12
|
+
# evidence about the application: an operation that raises this reports
|
|
13
|
+
# nothing for the request rather than incomplete evidence.
|
|
14
|
+
class ObservationError < Error; end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../identity"
|
|
4
|
+
require_relative "observed_endpoint"
|
|
5
|
+
|
|
6
|
+
module Karst
|
|
7
|
+
module Access
|
|
8
|
+
# One probe request's complete identity lifecycle, kept out of
|
|
9
|
+
# Access::Probe so the rule it exists to enforce stays readable:
|
|
10
|
+
#
|
|
11
|
+
# a requested identity is intent, an established one is setup, and only
|
|
12
|
+
# an observed one is evidence.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately never raises out of establishment or release. A probe whose
|
|
15
|
+
# identity setup failed is exactly the probe whose runtime observation
|
|
16
|
+
# matters most ("asked for User#123, application saw nobody, halted at
|
|
17
|
+
# authorize_admin"), so the request still runs and is still observed --
|
|
18
|
+
# the failure is recorded as evidence rather than swallowing the probe or
|
|
19
|
+
# being mistaken for an application exception.
|
|
20
|
+
class IdentityProbe
|
|
21
|
+
# Establishment outcomes:
|
|
22
|
+
#
|
|
23
|
+
# :established the requested identity's assume seam ran
|
|
24
|
+
# :failed that seam raised (identity was not established)
|
|
25
|
+
# :cleared an anonymous probe: no identity was established
|
|
26
|
+
# and any queued/configured identity was dropped
|
|
27
|
+
# :clear_failed an anonymous probe whose configured
|
|
28
|
+
# clear_identity hook raised
|
|
29
|
+
attr_reader :requested, :request_env
|
|
30
|
+
|
|
31
|
+
def initialize(principal)
|
|
32
|
+
@principal = principal
|
|
33
|
+
@anonymous = Identity.anonymous?(principal)
|
|
34
|
+
@requested = @anonymous ? nil : Identity.describe(principal)
|
|
35
|
+
@observations = {}
|
|
36
|
+
@thread = Thread.current
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def anonymous?
|
|
40
|
+
@anonymous
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def endpoint(application)
|
|
44
|
+
ObservedEndpoint.new(application, self)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def capture_env(env)
|
|
48
|
+
@request_env = env
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Called as the target request starts. Identity setup may already have
|
|
52
|
+
# issued requests on the same session (a sign-in endpoint, a clear
|
|
53
|
+
# hook), and only the target's own env may ever be observed -- so
|
|
54
|
+
# whatever setup left behind is forgotten here, and a target that never
|
|
55
|
+
# reaches the application leaves nothing to observe at all.
|
|
56
|
+
def begin_target
|
|
57
|
+
@request_env = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def establish(session)
|
|
61
|
+
@anonymous ? establish_anonymous(session) : establish_principal(session)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Runs in the caller's ensure, so it must survive anything the request
|
|
65
|
+
# did. An anonymous probe established nothing, but still releases: a
|
|
66
|
+
# configured clear hook is the application's own "make this anonymous
|
|
67
|
+
# again" seam, and a Warden principal queued for a request that never
|
|
68
|
+
# reached Warden must not survive into the next probe.
|
|
69
|
+
def release(session)
|
|
70
|
+
Identity.clear(session, principal: @principal) unless @anonymous
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
@cleanup_error = describe_error(e)
|
|
73
|
+
ensure
|
|
74
|
+
Identity::WardenAdapter.discard_pending!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Karst's own sign-in raised inside the target request itself (see
|
|
78
|
+
# Identity::EstablishmentError): identity was never established, and
|
|
79
|
+
# the request stopped before the application dispatched it. Whatever
|
|
80
|
+
# half-applied state its env holds is not the application's, so
|
|
81
|
+
# nothing is observed from it.
|
|
82
|
+
def establishment_failed(message)
|
|
83
|
+
@establishment = :failed
|
|
84
|
+
@establishment_error = message
|
|
85
|
+
@request_env = nil
|
|
86
|
+
@observations[:request_completion] ||=
|
|
87
|
+
Identity::Observation.new(principal: nil, source: nil,
|
|
88
|
+
error: "the request stopped during Karst's own sign-in, before the application ran")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Called at each moment worth observing. Ignores events raised on
|
|
92
|
+
# another thread: ActiveSupport::Notifications subscriptions are
|
|
93
|
+
# process-wide, and a concurrent request in a real development server
|
|
94
|
+
# must never be mistaken for this probe.
|
|
95
|
+
def observe(phase)
|
|
96
|
+
return unless Thread.current == @thread
|
|
97
|
+
|
|
98
|
+
@observations[phase] ||= Identity::Observer.observe(@request_env, requested: observable_principal)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def observed_this_probe?(phase)
|
|
102
|
+
@observations.key?(phase)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def evidence
|
|
106
|
+
Identity::EvidenceBuilder.build(requested: @requested, halt: @observations[:halted_callback],
|
|
107
|
+
completion: @observations[:request_completion],
|
|
108
|
+
establishment: @establishment,
|
|
109
|
+
establishment_error: @establishment_error,
|
|
110
|
+
cleanup_error: @cleanup_error)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def observable_principal
|
|
116
|
+
@anonymous ? nil : @principal
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def establish_principal(session)
|
|
120
|
+
Identity.assume(session, @principal)
|
|
121
|
+
@establishment = :established
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
@establishment = :failed
|
|
124
|
+
@establishment_error = describe_error(e)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Every probe already runs in its own freshly built integration session,
|
|
128
|
+
# which carries no cookies and no Warden proxy, so anonymity is
|
|
129
|
+
# established by construction rather than by logging something out.
|
|
130
|
+
# What can still leak across probes is Karst's own queued identity, so
|
|
131
|
+
# that is dropped explicitly; a configured clear_identity hook is then
|
|
132
|
+
# run as well, because it is the only seam that can reach application
|
|
133
|
+
# state Karst does not know about.
|
|
134
|
+
def establish_anonymous(session)
|
|
135
|
+
Identity::WardenAdapter.discard_pending!
|
|
136
|
+
@establishment = :cleared
|
|
137
|
+
hook = Karst.config.clear_identity
|
|
138
|
+
return unless hook.respond_to?(:call)
|
|
139
|
+
|
|
140
|
+
hook.call(session)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
@establishment = :clear_failed
|
|
143
|
+
@establishment_error = describe_error(e)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def describe_error(error)
|
|
147
|
+
"#{error.class}: #{error.message}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Karst
|
|
7
|
+
module Access
|
|
8
|
+
# The single definition of "a local application path Karst may execute".
|
|
9
|
+
# Access::Sweep and Reproduction::Exercise both route their caller-supplied
|
|
10
|
+
# target through here, so there is exactly one place to audit rather than
|
|
11
|
+
# one per caller -- a security check duplicated across callers is a
|
|
12
|
+
# security check that eventually diverges.
|
|
13
|
+
module LocalPath
|
|
14
|
+
Parsed = Struct.new(:path, :query, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Splits a target into its path and raw query string, refusing
|
|
18
|
+
# anything that is not a local application path. A scheme, an
|
|
19
|
+
# authority, or a protocol-relative "//host" target is rejected
|
|
20
|
+
# outright rather than normalized into something local-looking.
|
|
21
|
+
def parse(value)
|
|
22
|
+
raw = value.to_s
|
|
23
|
+
path, query = raw.split("?", 2)
|
|
24
|
+
path = path.to_s
|
|
25
|
+
uri = URI.parse(path)
|
|
26
|
+
local = uri.relative? && path.start_with?("/") && !path.start_with?("//")
|
|
27
|
+
raise UnsafeTarget, "target must be a local application path" unless local
|
|
28
|
+
|
|
29
|
+
Parsed.new(path: path, query: query)
|
|
30
|
+
rescue URI::InvalidURIError
|
|
31
|
+
raise UnsafeTarget, "target must be a valid local application path"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The query-stripped path, for callers (Access::Sweep) that
|
|
35
|
+
# deliberately never carry a query string.
|
|
36
|
+
def path(value)
|
|
37
|
+
parse(value).path
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karst
|
|
4
|
+
module Access
|
|
5
|
+
# Wraps the probe endpoint for exactly one probe request so Karst holds
|
|
6
|
+
# the request's own Rack env while it is still running.
|
|
7
|
+
#
|
|
8
|
+
# This is what makes halt-time identity observation possible: when
|
|
9
|
+
# "halted_callback.action_controller" fires, the request has not returned
|
|
10
|
+
# yet, so ActionDispatch::Integration::Session cannot hand Karst its env
|
|
11
|
+
# (it only records one afterwards, and not at all when the application
|
|
12
|
+
# raises). Rails has already put the dispatching controller instance on
|
|
13
|
+
# this same env object by then -- ActionController::Metal#set_request!
|
|
14
|
+
# sets env["action_controller.instance"] before any callback runs -- and
|
|
15
|
+
# every middleware in the probe stack mutates that one env in place, so
|
|
16
|
+
# holding it here is enough to observe the application's runtime identity
|
|
17
|
+
# both mid-request and after it, without instrumenting the application.
|
|
18
|
+
class ObservedEndpoint
|
|
19
|
+
def initialize(app, probe)
|
|
20
|
+
@app = app
|
|
21
|
+
@probe = probe
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(env)
|
|
25
|
+
@probe.capture_env(env)
|
|
26
|
+
@app.call(env)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# ActionDispatch::Integration::Session asks the endpoint it was given
|
|
30
|
+
# for things other than #call (#routes, for url_options), so this must
|
|
31
|
+
# stay transparent rather than merely callable.
|
|
32
|
+
def respond_to_missing?(name, include_private = false)
|
|
33
|
+
@app.respond_to?(name, include_private) || super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# rubocop:disable Naming/BlockForwarding, Style/ArgumentsForwarding -- anonymous
|
|
37
|
+
# argument/block forwarding needs Ruby 3.x; this file runs on Ruby 2.7.
|
|
38
|
+
def method_missing(name, *arguments, &block)
|
|
39
|
+
return @app.public_send(name, *arguments, &block) if @app.respond_to?(name)
|
|
40
|
+
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
# rubocop:enable Naming/BlockForwarding, Style/ArgumentsForwarding
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../value"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module Access
|
|
7
|
+
# The one definition of "these probes observed the same thing", shared by
|
|
8
|
+
# Access::Result#groups, the CLI/MCP evidence document, the human CLI
|
|
9
|
+
# summary, and the /karst panel -- so no surface can split or merge
|
|
10
|
+
# outcomes differently from another.
|
|
11
|
+
#
|
|
12
|
+
# A group is keyed by what Karst observed about the *request*: the
|
|
13
|
+
# response, where it dispatched, what it wrote, and whether the configured
|
|
14
|
+
# usable_access_outcome policy accepts it. Everything about the *probe*
|
|
15
|
+
# that made the request -- who it ran as, how that identity was observed,
|
|
16
|
+
# why that principal was sampled, how long it took -- is deliberately not
|
|
17
|
+
# part of the key: two users halted at the same callback are one outcome
|
|
18
|
+
# observed twice, not two outcomes. Those per-probe details stay attached
|
|
19
|
+
# to each member outcome, so nothing is lost by grouping.
|
|
20
|
+
#
|
|
21
|
+
# Nothing here normalizes observed values (no redirect path rewriting, no
|
|
22
|
+
# status bucketing): two outcomes group only when every semantic field is
|
|
23
|
+
# exactly equal, so grouping can merge equivalent observations but never
|
|
24
|
+
# reinterpret distinct ones as the same.
|
|
25
|
+
module OutcomeGroups
|
|
26
|
+
# Observed facts about the request. Two outcomes that differ in any of
|
|
27
|
+
# these are different outcomes.
|
|
28
|
+
SEMANTIC_FIELDS = %i[status redirect exception_class halted_callback controller action
|
|
29
|
+
writes_observed write_count database_rollback_attempted].freeze
|
|
30
|
+
|
|
31
|
+
# Facts about the probe, not the request's outcome. Volatile (elapsed
|
|
32
|
+
# time), per-identity (principal, identity evidence), or sampling
|
|
33
|
+
# provenance -- never part of group identity.
|
|
34
|
+
PROBE_METADATA = %i[principal identity sampling_reasons elapsed_ms].freeze
|
|
35
|
+
|
|
36
|
+
# `usable` is the configured usable_access_outcome verdict, shared by
|
|
37
|
+
# every member (it is part of the key), so a group can always be
|
|
38
|
+
# described as verified usable or not without consulting any one member.
|
|
39
|
+
Group = Value.define(:key, :outcomes, :usable) do
|
|
40
|
+
def count
|
|
41
|
+
outcomes.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Every member agrees on every SEMANTIC_FIELDS value, so any member
|
|
45
|
+
# describes the group; the first one observed is used.
|
|
46
|
+
def representative
|
|
47
|
+
outcomes.first
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Groups `outcomes` and orders the groups deterministically: largest
|
|
53
|
+
# first, then by the semantic key itself, so the same observations
|
|
54
|
+
# produce the same order regardless of the order they were probed in.
|
|
55
|
+
# Members keep the order they were probed in. `usable` is evaluated
|
|
56
|
+
# exactly once per outcome.
|
|
57
|
+
def group(outcomes, usable: Karst.config.usable_access_outcome)
|
|
58
|
+
groups = partition(outcomes, usable).map do |group_key, (verdict, members)|
|
|
59
|
+
Group.new(key: group_key, outcomes: members.freeze, usable: verdict)
|
|
60
|
+
end
|
|
61
|
+
groups.sort_by { |item| [-item.count, sort_key(item.key)] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# halted_callback is compared by its string form: Rails reports a
|
|
65
|
+
# named callback as a Symbol, and the serialized evidence reports it
|
|
66
|
+
# as a String, so the two must never become separate groups.
|
|
67
|
+
def key(outcome)
|
|
68
|
+
SEMANTIC_FIELDS.map do |field|
|
|
69
|
+
value = outcome.respond_to?(field) ? outcome.public_send(field) : nil
|
|
70
|
+
field == :halted_callback && !value.nil? ? value.to_s : value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# semantic key + verdict => [verdict, members in probe order]
|
|
77
|
+
def partition(outcomes, usable)
|
|
78
|
+
outcomes.each_with_object({}) do |outcome, grouped|
|
|
79
|
+
verdict = usable.call(outcome) ? true : false
|
|
80
|
+
(grouped[key(outcome) + [verdict]] ||= [verdict, []]).last << outcome
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A total order over heterogeneous values (Integer, String, booleans,
|
|
85
|
+
# nil) that never raises: nil sorts first, everything else by its
|
|
86
|
+
# string form.
|
|
87
|
+
def sort_key(values)
|
|
88
|
+
values.map { |value| value.nil? ? [0, ""] : [1, value.to_s] }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|