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,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module Karst
|
|
7
|
+
module Reproduction
|
|
8
|
+
# Renders one Karst::Reproduction::Observation as a runnable cURL command.
|
|
9
|
+
#
|
|
10
|
+
# The command contains exactly what Karst sent, with redacted values
|
|
11
|
+
# replaced by placeholders -- never a header, parameter, or credential
|
|
12
|
+
# Karst invented to make the command look complete. If the request needs
|
|
13
|
+
# an API key Karst never sent, the command will not contain one; the
|
|
14
|
+
# recipe's observed execution (the halted callback that stopped the
|
|
15
|
+
# request) is what tells the engineer that, and it is honest evidence
|
|
16
|
+
# rather than a guess.
|
|
17
|
+
module Curl
|
|
18
|
+
# A deliberately generic local default. Callers that know the
|
|
19
|
+
# developer's real origin (the /karst panel does) pass it in; nothing
|
|
20
|
+
# here ever bakes in a deployed host, which would be neither portable
|
|
21
|
+
# nor safe to paste into a ticket.
|
|
22
|
+
DEFAULT_BASE_URL = "http://localhost:3000"
|
|
23
|
+
|
|
24
|
+
# Methods curl infers from the request shape anyway; spelling them out
|
|
25
|
+
# adds noise without adding meaning.
|
|
26
|
+
IMPLIED_METHODS = ["GET"].freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def render(observation, base_url: DEFAULT_BASE_URL)
|
|
30
|
+
parts = ["curl#{method_flag(observation)} #{quote(url(observation, base_url))}"]
|
|
31
|
+
header_flags(observation).each { |flag| parts << flag }
|
|
32
|
+
data = data_flag(observation)
|
|
33
|
+
parts << data if data
|
|
34
|
+
parts.join(" \\\n ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def url(observation, base_url = DEFAULT_BASE_URL)
|
|
38
|
+
query = Rack::Utils.build_nested_query(observation.query_params)
|
|
39
|
+
suffix = query.to_s.empty? ? "" : "?#{query}"
|
|
40
|
+
"#{base_url.to_s.chomp('/')}#{observation.url_path}#{suffix}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def method_flag(observation)
|
|
46
|
+
method = observation.http_method.to_s.upcase
|
|
47
|
+
IMPLIED_METHODS.include?(method) ? "" : " -X #{method}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def header_flags(observation)
|
|
51
|
+
observation.headers.map { |name, value| "-H #{quote("#{name}: #{value}")}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def data_flag(observation)
|
|
55
|
+
case observation.body_representation
|
|
56
|
+
when :json then "-d #{quote(JSON.pretty_generate(observation.body_params))}"
|
|
57
|
+
when :form then "-d #{quote(Rack::Utils.build_nested_query(observation.body_params))}"
|
|
58
|
+
when :opaque then "-d #{quote('<BODY>')}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Single quotes are the only shell quoting that treats every byte
|
|
63
|
+
# literally, so a JSON body full of double quotes, backslashes, and
|
|
64
|
+
# "$" needs no per-character escaping -- only the single quote
|
|
65
|
+
# itself, which closes the literal, emits an escaped quote, and
|
|
66
|
+
# reopens it.
|
|
67
|
+
def quote(value)
|
|
68
|
+
"'#{value.to_s.gsub("'", "'\\\\''")}'"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
require_relative "observation"
|
|
6
|
+
require_relative "sanitizer"
|
|
7
|
+
require_relative "../access/errors"
|
|
8
|
+
require_relative "../access/local_path"
|
|
9
|
+
require_relative "../access/probe"
|
|
10
|
+
require_relative "../identity"
|
|
11
|
+
|
|
12
|
+
module Karst
|
|
13
|
+
module Reproduction
|
|
14
|
+
# Issues exactly one request against the real application and reports what
|
|
15
|
+
# happened.
|
|
16
|
+
#
|
|
17
|
+
# This is deliberately not Access::Sweep with the GET restriction lifted.
|
|
18
|
+
# A sweep runs one route as up to 25 different users to answer "who can
|
|
19
|
+
# reach this"; running a mutating method that way would mean 25 real
|
|
20
|
+
# creates, 25 enqueued jobs, and 25 delivered mails, which is exactly why
|
|
21
|
+
# Sweep refuses anything but GET and will keep refusing. Reproduction
|
|
22
|
+
# answers a different question -- "what request exercises this behavior"
|
|
23
|
+
# -- and one request is the entire answer, so the blast radius is one
|
|
24
|
+
# request, always explicitly asked for, never automatic.
|
|
25
|
+
#
|
|
26
|
+
# The request is issued and observed by Access::Probe -- the same call,
|
|
27
|
+
# same identity lifecycle, same target window, and same same-connection
|
|
28
|
+
# rollback Access::Sweep uses for every principal it probes -- so a
|
|
29
|
+
# reproduced request reports exactly the evidence a sweep reports for
|
|
30
|
+
# the same request and identity. Containment has the same honest limit:
|
|
31
|
+
# database writes on this connection are rolled back; jobs, mail,
|
|
32
|
+
# outbound HTTP, files, and other connections are not. Callers are
|
|
33
|
+
# expected to say so out loud, and every Karst surface that exposes this
|
|
34
|
+
# does.
|
|
35
|
+
#
|
|
36
|
+
# What Exercise owns is the request itself and its presentation: which
|
|
37
|
+
# methods, headers, and bodies are acceptable, and how an observation
|
|
38
|
+
# becomes a sanitized, reproducible recipe. `principal` is requested
|
|
39
|
+
# identity (intent); the Observation this class returns carries a
|
|
40
|
+
# Karst::Identity::Evidence, never a bare echo of what was asked for. A
|
|
41
|
+
# caller that wants a genuinely anonymous probe passes
|
|
42
|
+
# Karst::Identity::ANONYMOUS (the default): Karst then establishes no
|
|
43
|
+
# identity and verifies at runtime that the application resolved none,
|
|
44
|
+
# exactly as an anonymous verify_access probe does -- a bare absence of a
|
|
45
|
+
# principal is not the same claim.
|
|
46
|
+
# rubocop:disable Metrics/ClassLength
|
|
47
|
+
class Exercise
|
|
48
|
+
METHODS = %w[GET HEAD POST PUT PATCH DELETE].freeze
|
|
49
|
+
|
|
50
|
+
JSON_CONTENT_TYPE = %r{\Aapplication/(?:[\w.+-]+\+)?json\b}i
|
|
51
|
+
private_constant :JSON_CONTENT_TYPE
|
|
52
|
+
|
|
53
|
+
FORM_CONTENT_TYPE = %r{\Aapplication/x-www-form-urlencoded\b}i
|
|
54
|
+
private_constant :FORM_CONTENT_TYPE
|
|
55
|
+
|
|
56
|
+
# Exactly ActionDispatch::Http::Headers' own idea of a header name.
|
|
57
|
+
# Anything outside this shape is passed through to the Rack env
|
|
58
|
+
# verbatim by Http::Headers#[]= rather than being prefixed with
|
|
59
|
+
# HTTP_, so a name like "rack.session" or "action_dispatch.request.
|
|
60
|
+
# parameters" would not be a header at all -- it would be a caller
|
|
61
|
+
# writing directly into the request environment Karst is supposed to
|
|
62
|
+
# be observing.
|
|
63
|
+
HEADER_NAME = /\A[A-Za-z0-9-]+\z/
|
|
64
|
+
private_constant :HEADER_NAME
|
|
65
|
+
|
|
66
|
+
# CGI variables Http::Headers maps a header name onto directly instead
|
|
67
|
+
# of prefixing. Letting a caller set these would let the request Karst
|
|
68
|
+
# issues differ from the request Karst reports -- a recipe describing a
|
|
69
|
+
# request nobody made. CONTENT_TYPE is deliberately absent: it is a
|
|
70
|
+
# real request header, and Karst sets it itself from `content_type`.
|
|
71
|
+
RESERVED = %w[
|
|
72
|
+
AUTH_TYPE CONTENT_LENGTH GATEWAY_INTERFACE HTTPS PATH_INFO PATH_TRANSLATED QUERY_STRING
|
|
73
|
+
REMOTE_ADDR REMOTE_HOST REMOTE_IDENT REMOTE_USER REQUEST_METHOD SCRIPT_NAME
|
|
74
|
+
SERVER_NAME SERVER_PORT SERVER_PROTOCOL SERVER_SOFTWARE
|
|
75
|
+
].freeze
|
|
76
|
+
private_constant :RESERVED
|
|
77
|
+
|
|
78
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/AbcSize, Metrics/MethodLength
|
|
79
|
+
def initialize(path:, http_method: "GET", body: nil, content_type: nil, headers: {},
|
|
80
|
+
principal: Karst::Identity::ANONYMOUS, application: nil)
|
|
81
|
+
@http_method = http_method.to_s.strip.upcase
|
|
82
|
+
raise Access::UnsupportedMethod, "unsupported HTTP method" unless METHODS.include?(@http_method)
|
|
83
|
+
|
|
84
|
+
target = Access::LocalPath.parse(path)
|
|
85
|
+
@path = target.path
|
|
86
|
+
@query_params = Rack::Utils.parse_nested_query(target.query.to_s)
|
|
87
|
+
@body = body.to_s.empty? ? nil : body.to_s
|
|
88
|
+
@content_type = content_type.to_s.strip
|
|
89
|
+
raise ArgumentError, "a content type is required when sending a request body" if @body && @content_type.empty?
|
|
90
|
+
|
|
91
|
+
@headers = normalize_headers(headers)
|
|
92
|
+
# A bare nil is treated exactly like Karst::Identity::ANONYMOUS
|
|
93
|
+
# (the default) rather than as some third, identity-lifecycle-free
|
|
94
|
+
# state: a caller that passes no principal still gets a real
|
|
95
|
+
# anonymous probe, with the application's own runtime state verified
|
|
96
|
+
# rather than silently assumed.
|
|
97
|
+
@principal = principal || Karst::Identity::ANONYMOUS
|
|
98
|
+
@application = application || Rails.application
|
|
99
|
+
@probe = Access::Probe.new(@application)
|
|
100
|
+
end
|
|
101
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/AbcSize, Metrics/MethodLength
|
|
102
|
+
|
|
103
|
+
def call
|
|
104
|
+
raise Access::Unavailable, "request reproduction is development-only" unless Rails.env.development?
|
|
105
|
+
raise Access::Unavailable, "Karst is disabled (config.enabled)" unless Karst.enabled?
|
|
106
|
+
|
|
107
|
+
# The query string travels in the target, exactly as a real client
|
|
108
|
+
# sends it; the body travels only as the body, so a GET never grows
|
|
109
|
+
# one it was not given.
|
|
110
|
+
build(@probe.call(principal: @principal, http_method: @http_method, target: target, body: @body,
|
|
111
|
+
headers: outgoing_headers))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def target
|
|
117
|
+
query = Rack::Utils.build_nested_query(@query_params)
|
|
118
|
+
query.empty? ? @path : "#{@path}?#{query}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def outgoing_headers
|
|
122
|
+
headers = @headers.dup
|
|
123
|
+
headers["Content-Type"] = @content_type unless @content_type.empty?
|
|
124
|
+
headers
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
128
|
+
def build(observed)
|
|
129
|
+
route = route_state(observed.route_params)
|
|
130
|
+
body_params, representation = body_state
|
|
131
|
+
Observation.new(
|
|
132
|
+
http_method: @http_method, url_path: displayed_path(route), query_params: sanitized_query,
|
|
133
|
+
route_params: route[:sanitized], body_params: body_params, body_representation: representation,
|
|
134
|
+
content_type: @content_type.empty? ? nil : @content_type, headers: Sanitizer.headers(outgoing_headers),
|
|
135
|
+
controller: observed.controller, action: observed.action,
|
|
136
|
+
controller_completed: observed.controller_completed,
|
|
137
|
+
status: observed.status, response_content_type: observed.response_content_type,
|
|
138
|
+
redirect: observed.redirect, halted_callback: observed.halted_callback&.to_s,
|
|
139
|
+
exception_class: observed.exception_class, exception_phase: observed.exception_phase,
|
|
140
|
+
rendered: observed.rendered, writes_observed: observed.write_count.positive?,
|
|
141
|
+
write_count: observed.write_count, database_rollback_attempted: true,
|
|
142
|
+
elapsed_ms: observed.elapsed_ms, identity: observed.identity,
|
|
143
|
+
unobserved: unobserved(observed, route).freeze
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
147
|
+
|
|
148
|
+
def unobserved(observed, route)
|
|
149
|
+
missing = []
|
|
150
|
+
missing << "controller" unless observed.controller
|
|
151
|
+
missing << "action" unless observed.action
|
|
152
|
+
missing << "controller_completed" if observed.controller_completed.nil?
|
|
153
|
+
missing << "route_params" unless route[:observed]
|
|
154
|
+
missing << "status" if observed.status.nil?
|
|
155
|
+
missing << "response_content_type" if observed.response_content_type.nil?
|
|
156
|
+
missing
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Absent route parameters (a 404, a routing error, a target that
|
|
160
|
+
# raised) mean Karst observed no route, which is reported as such
|
|
161
|
+
# rather than filled in.
|
|
162
|
+
def route_state(raw)
|
|
163
|
+
return { raw: {}, sanitized: {}, observed: false } unless raw
|
|
164
|
+
|
|
165
|
+
{ raw: raw, sanitized: sanitize(raw), observed: true }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A path segment that turned out to be a credential (a password-reset
|
|
169
|
+
# token, a signed id) is still sitting in the URL after the parameter
|
|
170
|
+
# itself was masked. Substituting the placeholder back into the path
|
|
171
|
+
# keeps the generated command from leaking through the one channel
|
|
172
|
+
# parameter filtering does not cover.
|
|
173
|
+
def displayed_path(route)
|
|
174
|
+
route[:sanitized].reduce(@path) do |path, (name, value)|
|
|
175
|
+
raw = route[:raw][name]
|
|
176
|
+
next path unless Sanitizer.masked?(value) && raw.is_a?(String) && !raw.empty?
|
|
177
|
+
|
|
178
|
+
path.gsub(raw, "<#{name.upcase}>")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def sanitized_query
|
|
183
|
+
sanitize(@query_params)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def sanitize(params)
|
|
187
|
+
Sanitizer.parameters(params, filters: filter_parameters)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The reproduced application's own filter_parameters, never a global:
|
|
191
|
+
# Karst issued this request against @application, so @application's
|
|
192
|
+
# rules are the ones that decide what may be shown back. Always an
|
|
193
|
+
# Array, never nil -- Sanitizer.rails_filter treats a nil `filters` as
|
|
194
|
+
# "none given, read Rails.application" for callers outside a specific
|
|
195
|
+
# application, and this method already has one: if @application's own
|
|
196
|
+
# config raises, the honest answer is "this application declared no
|
|
197
|
+
# filters", never a silent fall-through to whatever Rails.application
|
|
198
|
+
# happens to be (which, when @application is a different object
|
|
199
|
+
# entirely -- as in a test harness -- would filter this application's
|
|
200
|
+
# parameters by a stranger's rules instead of its own).
|
|
201
|
+
def filter_parameters
|
|
202
|
+
Array(@application.config.filter_parameters)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
[]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def body_state
|
|
208
|
+
return [{}, :none] unless @body
|
|
209
|
+
return [sanitize(parsed_json), :json] if json_body?
|
|
210
|
+
return [sanitize(Rack::Utils.parse_nested_query(@body)), :form] if form_body?
|
|
211
|
+
|
|
212
|
+
[{}, :opaque]
|
|
213
|
+
rescue JSON::ParserError
|
|
214
|
+
[{}, :opaque]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def json_body?
|
|
218
|
+
@content_type.match?(JSON_CONTENT_TYPE) && parsed_json.is_a?(Hash)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def form_body?
|
|
222
|
+
@content_type.match?(FORM_CONTENT_TYPE)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def parsed_json
|
|
226
|
+
@parsed_json ||= JSON.parse(@body)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def normalize_headers(headers)
|
|
230
|
+
(headers || {}).each_with_object({}) do |(name, value), result|
|
|
231
|
+
key = name.to_s.strip
|
|
232
|
+
next if key.empty?
|
|
233
|
+
|
|
234
|
+
raise ArgumentError, "#{key.inspect} is not a request header name" unless header_name?(key)
|
|
235
|
+
|
|
236
|
+
result[key] = value.to_s
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def header_name?(name)
|
|
241
|
+
name.match?(HEADER_NAME) && !RESERVED.include?(name.upcase.tr("-", "_"))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
# rubocop:enable Metrics/ClassLength
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../value"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module Reproduction
|
|
7
|
+
# One request Karst actually issued, and what actually happened.
|
|
8
|
+
#
|
|
9
|
+
# Every member is either something Karst observed or an explicit
|
|
10
|
+
# placeholder standing in for something Karst redacted. Nothing here is
|
|
11
|
+
# inferred from routes, controller source, or strong-parameter
|
|
12
|
+
# declarations: a field Karst could not observe is nil and is named in
|
|
13
|
+
# #unobserved, so a consumer can say "Karst did not see this" instead of
|
|
14
|
+
# reading an absent value as a negative result.
|
|
15
|
+
#
|
|
16
|
+
# `identity` is a Karst::Identity::Evidence, carrying the identity Karst
|
|
17
|
+
# was asked to send this request under (requested), what Karst's own
|
|
18
|
+
# seam established (establishment), and what the application itself
|
|
19
|
+
# resolved while running the request (observed) -- exactly the same
|
|
20
|
+
# lifecycle Access::Sweep runs. There is deliberately no bare "principal"
|
|
21
|
+
# field here: a requested/assumed identity is intent, not evidence about
|
|
22
|
+
# what ran, and only `identity.confirmation` may be read to decide
|
|
23
|
+
# whether they agree.
|
|
24
|
+
#
|
|
25
|
+
# body_representation says how faithfully body_params describes what was
|
|
26
|
+
# sent:
|
|
27
|
+
#
|
|
28
|
+
# :none no request body was sent
|
|
29
|
+
# :json a JSON object body, parsed and sanitized key by key
|
|
30
|
+
# :form a form-encoded body, parsed and sanitized key by key
|
|
31
|
+
# :opaque a body Karst sent verbatim but will not echo back, because
|
|
32
|
+
# it could not parse it well enough to sanitize it
|
|
33
|
+
#
|
|
34
|
+
# controller_completed, exception_class/exception_phase, and response
|
|
35
|
+
# (status/redirect/response_content_type) are three separate target-scoped
|
|
36
|
+
# facts, deliberately never inferred from one another:
|
|
37
|
+
#
|
|
38
|
+
# controller_completed true -- process_action.action_controller finished
|
|
39
|
+
# with no exception
|
|
40
|
+
# false -- it finished carrying one
|
|
41
|
+
# nil/unobserved -- it never fired for this request
|
|
42
|
+
# at all (nothing dispatched)
|
|
43
|
+
#
|
|
44
|
+
# exception_phase the most specific instrumentation-proven phase
|
|
45
|
+
# an observed exception occurred in --
|
|
46
|
+
# "controller", "render", or "unknown" -- derived
|
|
47
|
+
# by matching the exception object (and its
|
|
48
|
+
# #cause chain, since ActionView wraps a render
|
|
49
|
+
# exception in ActionView::Template::Error before
|
|
50
|
+
# it reaches the controller) against instrumented
|
|
51
|
+
# render events. nil whenever exception_class is
|
|
52
|
+
# nil: there is no phase to report for a request
|
|
53
|
+
# that raised nothing.
|
|
54
|
+
#
|
|
55
|
+
# Both are read off ActiveSupport::Notifications payloads captured while
|
|
56
|
+
# the target request ran, never off session.request/session.response --
|
|
57
|
+
# those two objects are only updated by ActionDispatch::Integration::Session
|
|
58
|
+
# *after* Rack::Test's app.call returns without raising. A target request
|
|
59
|
+
# that raises before producing a response leaves them holding whatever the
|
|
60
|
+
# previous request on the same session (identity establishment, most
|
|
61
|
+
# often) last wrote, so reading them post-hoc would silently attribute an
|
|
62
|
+
# earlier request's status/content type/location to this one.
|
|
63
|
+
#
|
|
64
|
+
# rendered is an Array of { virtual_path:, completed: } entries, one per
|
|
65
|
+
# ActionView template/partial/layout Karst observed the target request
|
|
66
|
+
# attempt (via "!render_template.action_view", the only notification
|
|
67
|
+
# exposing a template's own relative virtual_path rather than its
|
|
68
|
+
# absolute source file), in the order each one finished or raised.
|
|
69
|
+
# completed is false exactly when that specific render carried an
|
|
70
|
+
# exception; never inferred from the request's overall outcome.
|
|
71
|
+
Observation = Value.define(
|
|
72
|
+
:http_method, :url_path, :query_params, :route_params, :body_params, :body_representation,
|
|
73
|
+
:content_type, :headers, :controller, :action, :controller_completed,
|
|
74
|
+
:status, :response_content_type, :redirect,
|
|
75
|
+
:halted_callback, :exception_class, :exception_phase, :rendered, :writes_observed, :write_count,
|
|
76
|
+
:database_rollback_attempted, :elapsed_ms, :identity, :unobserved
|
|
77
|
+
) do
|
|
78
|
+
def observed?(field)
|
|
79
|
+
!unobserved.include?(field.to_s)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def body?
|
|
83
|
+
body_representation != :none
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/parameter_filter"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Karst
|
|
7
|
+
module Reproduction
|
|
8
|
+
# Everything Karst is willing to show back to a developer about a request
|
|
9
|
+
# it issued passes through here first.
|
|
10
|
+
#
|
|
11
|
+
# The application's own Rails::Application#config.filter_parameters is the
|
|
12
|
+
# authority: Karst builds an ActiveSupport::ParameterFilter from it rather
|
|
13
|
+
# than reimplementing filtering, so a parameter the application already
|
|
14
|
+
# considers sensitive is sensitive here too, including through Rails'
|
|
15
|
+
# own nested-hash and Proc handling. Karst then applies one conservative
|
|
16
|
+
# credential-name net *on top of* that result -- never instead of it --
|
|
17
|
+
# because an application's filter_parameters is tuned for its own logs
|
|
18
|
+
# and routinely misses credentials that only appear in a third-party
|
|
19
|
+
# integration's payload.
|
|
20
|
+
#
|
|
21
|
+
# Header handling is deliberately not symmetric with parameter handling:
|
|
22
|
+
# a credential-bearing header's value is never read, compared, or
|
|
23
|
+
# echoed. It is replaced by a named placeholder purely on the strength
|
|
24
|
+
# of its name, so a generated request always tells the engineer which
|
|
25
|
+
# credential to supply without Karst ever having handled it.
|
|
26
|
+
#
|
|
27
|
+
# A URL-shaped header (Referer, Origin) is a third case: its *name* is
|
|
28
|
+
# not credential-shaped, so the name-based rules above would echo it
|
|
29
|
+
# verbatim, but its *value* is a URL that can structurally carry a query
|
|
30
|
+
# string or fragment -- exactly where a password-reset token or an
|
|
31
|
+
# OAuth access_token tends to live. Those two headers are parsed and
|
|
32
|
+
# stripped down to scheme/host/path rather than treated as safe or
|
|
33
|
+
# matched against the credential-name net; see URL_HEADERS.
|
|
34
|
+
module Sanitizer
|
|
35
|
+
# ActiveSupport::ParameterFilter's own mask. Rewritten to Karst's
|
|
36
|
+
# angle-bracket form so a recipe uses one visual convention for
|
|
37
|
+
# "you must supply this yourself" rather than two.
|
|
38
|
+
RAILS_MASK = "[FILTERED]"
|
|
39
|
+
MASK = "<FILTERED>"
|
|
40
|
+
|
|
41
|
+
# Headers whose value Karst may echo verbatim: they describe the shape
|
|
42
|
+
# of the request, never authorize it. An allowlist, so a header Karst
|
|
43
|
+
# has never considered is handled by the rules below rather than
|
|
44
|
+
# trusted by default. Deliberately excludes Referer and Origin, even
|
|
45
|
+
# though both are otherwise request-shape headers -- see URL_HEADERS.
|
|
46
|
+
SAFE_HEADERS = %w[
|
|
47
|
+
accept accept-charset accept-encoding accept-language cache-control
|
|
48
|
+
content-length content-type host if-match if-none-match
|
|
49
|
+
user-agent x-request-id x-requested-with
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
# Headers that are themselves URLs, so their name alone cannot make
|
|
53
|
+
# them safe to echo verbatim: a Referer routinely carries the query
|
|
54
|
+
# string (and, for an OAuth-style redirect, the fragment) of whatever
|
|
55
|
+
# page linked here, and either can hold a token, code, or other
|
|
56
|
+
# credential a request-shape header has no business exposing.
|
|
57
|
+
# #sanitized_url_header keeps only what identifies the origin/path --
|
|
58
|
+
# never a value comparison, just discarding the URL components that
|
|
59
|
+
# are structurally capable of carrying a secret. Origin is included
|
|
60
|
+
# for the same reason even though the header's own semantics never
|
|
61
|
+
# define a query, fragment, or userinfo component: Karst's headers
|
|
62
|
+
# are caller-supplied strings, not values the HTTP client validated
|
|
63
|
+
# against that grammar.
|
|
64
|
+
URL_HEADERS = %w[referer origin].freeze
|
|
65
|
+
|
|
66
|
+
# Named placeholders for the headers a Rails application actually
|
|
67
|
+
# authenticates with. The placeholder is chosen from the header name
|
|
68
|
+
# alone; the real value is never inspected.
|
|
69
|
+
CREDENTIAL_HEADERS = {
|
|
70
|
+
"authorization" => "<AUTH_TOKEN>",
|
|
71
|
+
"proxy-authorization" => "<AUTH_TOKEN>",
|
|
72
|
+
"www-authenticate" => "<AUTH_TOKEN>",
|
|
73
|
+
"cookie" => "<SESSION_COOKIE>",
|
|
74
|
+
"set-cookie" => "<SESSION_COOKIE>",
|
|
75
|
+
"api-key" => "<API_KEY>",
|
|
76
|
+
"x-api-key" => "<API_KEY>",
|
|
77
|
+
"x-auth-token" => "<AUTH_TOKEN>",
|
|
78
|
+
"x-access-token" => "<AUTH_TOKEN>",
|
|
79
|
+
"x-csrf-token" => "<CSRF_TOKEN>",
|
|
80
|
+
"x-xsrf-token" => "<CSRF_TOKEN>"
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
# Karst's supplementary net, applied after the application's own
|
|
84
|
+
# filter_parameters. Deliberately credential-shaped rather than a
|
|
85
|
+
# general PII list (Karst::Access::SensitiveAttributeNames already
|
|
86
|
+
# owns that, for a different job): a QA recipe is useless if every
|
|
87
|
+
# ordinary business field is masked, but it is dangerous if one
|
|
88
|
+
# credential is not.
|
|
89
|
+
CREDENTIAL_TOKENS = %w[
|
|
90
|
+
auth authorization bearer certificate cookie credential credentials
|
|
91
|
+
csrf cvv hmac jwt key nonce otp passphrase password passwd pin pwd
|
|
92
|
+
salt secret session sig signature ssn token xsrf
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
# Matched as substrings too, so a run-together name ("apikey",
|
|
96
|
+
# "accesstoken") is caught even though tokenizing cannot split it.
|
|
97
|
+
CREDENTIAL_SUBSTRINGS = %w[password secret token apikey credential signature].freeze
|
|
98
|
+
|
|
99
|
+
class << self
|
|
100
|
+
# A deep-filtered copy of any params-like Hash, with String keys.
|
|
101
|
+
#
|
|
102
|
+
# `filters` is the filter_parameters of the application whose request
|
|
103
|
+
# is being reproduced. Karst::Reproduction::Exercise always passes the
|
|
104
|
+
# application it actually issued the request against, rather than
|
|
105
|
+
# letting this class reach for Rails.application: those are the same
|
|
106
|
+
# object in every real application, and reading the global instead
|
|
107
|
+
# would quietly filter one application's parameters with another
|
|
108
|
+
# application's rules.
|
|
109
|
+
def parameters(params, filters: nil)
|
|
110
|
+
return {} if params.nil?
|
|
111
|
+
|
|
112
|
+
supplement(rails_filter(filters).filter(stringify(params)))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# name => value-or-placeholder, with canonical header casing.
|
|
116
|
+
def headers(pairs)
|
|
117
|
+
(pairs || {}).each_with_object({}) do |(name, value), result|
|
|
118
|
+
result[canonical(name)] = header_value(name, value)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# True when a sanitized value is a placeholder rather than something
|
|
123
|
+
# Karst actually observed. Callers use this to keep "redacted" and
|
|
124
|
+
# "observed" visibly distinct instead of blurring them together.
|
|
125
|
+
def masked?(value)
|
|
126
|
+
value.is_a?(String) && value.start_with?("<") && value.end_with?(">")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def credential_name?(name)
|
|
130
|
+
normalized = name.to_s.downcase
|
|
131
|
+
return true if CREDENTIAL_SUBSTRINGS.any? { |fragment| normalized.include?(fragment) }
|
|
132
|
+
|
|
133
|
+
normalized.split(/[^a-z0-9]+/).any? { |token| CREDENTIAL_TOKENS.include?(token) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def header_value(name, value)
|
|
139
|
+
key = name.to_s.downcase.tr("_", "-")
|
|
140
|
+
return sanitized_url_header(value) if URL_HEADERS.include?(key)
|
|
141
|
+
return value.to_s if SAFE_HEADERS.include?(key)
|
|
142
|
+
|
|
143
|
+
placeholder = CREDENTIAL_HEADERS[key]
|
|
144
|
+
return placeholder if placeholder
|
|
145
|
+
return MASK if credential_name?(key)
|
|
146
|
+
|
|
147
|
+
value.to_s
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Structural, not value-based: parses the header as a URL and drops
|
|
151
|
+
# every component capable of carrying a credential (userinfo, query,
|
|
152
|
+
# fragment), keeping only scheme/host/path so the request context
|
|
153
|
+
# ("this came from the password-reset page") survives without the
|
|
154
|
+
# secrets a query or fragment on that URL might hold. A value Karst
|
|
155
|
+
# cannot parse as a URL at all is masked outright rather than echoed
|
|
156
|
+
# -- there is no structure left to trust it by.
|
|
157
|
+
def sanitized_url_header(value)
|
|
158
|
+
uri = URI.parse(value.to_s)
|
|
159
|
+
uri.user = nil
|
|
160
|
+
uri.password = nil
|
|
161
|
+
uri.query = nil
|
|
162
|
+
uri.fragment = nil
|
|
163
|
+
uri.to_s
|
|
164
|
+
rescue StandardError
|
|
165
|
+
MASK
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def canonical(name)
|
|
169
|
+
name.to_s.downcase.tr("_", "-").split("-").map(&:capitalize).join("-")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Rebuilt per call rather than memoized: a spec (and a reloading
|
|
173
|
+
# development application) can change filter_parameters between
|
|
174
|
+
# requests, and a stale filter here would silently stop masking
|
|
175
|
+
# something the application now considers sensitive.
|
|
176
|
+
def rails_filter(filters)
|
|
177
|
+
ActiveSupport::ParameterFilter.new(Array(filters || rails_filter_parameters))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def rails_filter_parameters
|
|
181
|
+
return [] unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
182
|
+
|
|
183
|
+
Array(Rails.application.config.filter_parameters)
|
|
184
|
+
rescue StandardError
|
|
185
|
+
[]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def stringify(value)
|
|
189
|
+
case value
|
|
190
|
+
when Hash then value.each_with_object({}) { |(key, item), result| result[key.to_s] = stringify(item) }
|
|
191
|
+
when Array then value.map { |item| stringify(item) }
|
|
192
|
+
else value
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def supplement(value)
|
|
197
|
+
case value
|
|
198
|
+
when Hash
|
|
199
|
+
value.each_with_object({}) do |(key, item), result|
|
|
200
|
+
result[key] = credential_name?(key) ? MASK : supplement(item)
|
|
201
|
+
end
|
|
202
|
+
when Array then value.map { |item| supplement(item) }
|
|
203
|
+
when RAILS_MASK then MASK
|
|
204
|
+
else value
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/karst/version.rb
CHANGED