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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +6 -1
  3. data/CHANGELOG.md +82 -2
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +118 -2
  6. data/docs/advanced-configuration.md +106 -0
  7. data/docs/rails8-authentication.md +24 -4
  8. data/docs/request-reproduction.md +242 -0
  9. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  10. data/lib/karst/access/approved_populations.rb +15 -6
  11. data/lib/karst/access/errors.rb +16 -0
  12. data/lib/karst/access/identity_probe.rb +151 -0
  13. data/lib/karst/access/local_path.rb +42 -0
  14. data/lib/karst/access/observed_endpoint.rb +46 -0
  15. data/lib/karst/access/outcome_groups.rb +93 -0
  16. data/lib/karst/access/population_discovery.rb +25 -1
  17. data/lib/karst/access/principal_source.rb +24 -3
  18. data/lib/karst/access/probe.rb +397 -0
  19. data/lib/karst/access/search.rb +14 -6
  20. data/lib/karst/access/sweep.rb +42 -112
  21. data/lib/karst/cli/identity_serialization.rb +82 -0
  22. data/lib/karst/cli/outcome_summary.rb +131 -0
  23. data/lib/karst/cli/principal_reference.rb +51 -0
  24. data/lib/karst/cli/reproduction.rb +267 -0
  25. data/lib/karst/cli/verification.rb +219 -55
  26. data/lib/karst/configuration.rb +52 -6
  27. data/lib/karst/identity/evidence.rb +110 -0
  28. data/lib/karst/identity/observation.rb +167 -0
  29. data/lib/karst/identity/warden_adapter.rb +26 -1
  30. data/lib/karst/identity.rb +157 -22
  31. data/lib/karst/mcp/compatibility.rb +24 -0
  32. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  33. data/lib/karst/mcp/reproduction.rb +58 -0
  34. data/lib/karst/mcp/server.rb +27 -8
  35. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  36. data/lib/karst/reproduction/curl.rb +73 -0
  37. data/lib/karst/reproduction/exercise.rb +246 -0
  38. data/lib/karst/reproduction/observation.rb +87 -0
  39. data/lib/karst/reproduction/sanitizer.rb +210 -0
  40. data/lib/karst/version.rb +1 -1
  41. data/lib/karst/web/middleware.rb +105 -16
  42. data/lib/karst/web/panel.rb +302 -47
  43. data/lib/karst.rb +2 -0
  44. data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
  45. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  46. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  47. 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Karst
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end