karst 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +59 -0
  3. data/CHANGELOG.md +67 -0
  4. data/CODE_OF_CONDUCT.md +29 -0
  5. data/CONTRIBUTING.md +45 -0
  6. data/LICENSE +21 -0
  7. data/README.md +140 -0
  8. data/SECURITY.md +11 -0
  9. data/docs/advanced-configuration.md +188 -0
  10. data/lib/generators/karst/install/install_generator.rb +88 -0
  11. data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
  12. data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
  13. data/lib/karst/access/approved_populations.rb +128 -0
  14. data/lib/karst/access/candidate_population.rb +86 -0
  15. data/lib/karst/access/database_isolation.rb +62 -0
  16. data/lib/karst/access/population_approvals.rb +195 -0
  17. data/lib/karst/access/population_config_snippet.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +271 -0
  19. data/lib/karst/access/population_preview.rb +83 -0
  20. data/lib/karst/access/principal_sampler.rb +241 -0
  21. data/lib/karst/access/principal_selection.rb +90 -0
  22. data/lib/karst/access/principal_source.rb +143 -0
  23. data/lib/karst/access/principal_source_selection.rb +161 -0
  24. data/lib/karst/access/probe_application.rb +164 -0
  25. data/lib/karst/access/resource_evidence.rb +233 -0
  26. data/lib/karst/access/search.rb +265 -0
  27. data/lib/karst/access/selected_principal_sources.rb +65 -0
  28. data/lib/karst/access/sensitive_attribute_names.rb +26 -0
  29. data/lib/karst/access/sweep.rb +198 -0
  30. data/lib/karst/cli/verification.rb +182 -0
  31. data/lib/karst/configuration.rb +223 -0
  32. data/lib/karst/execution_context.rb +83 -0
  33. data/lib/karst/identity/devise_support.rb +90 -0
  34. data/lib/karst/identity/warden_adapter.rb +130 -0
  35. data/lib/karst/identity.rb +479 -0
  36. data/lib/karst/mcp/server.rb +63 -0
  37. data/lib/karst/mcp/verify_access_tool.rb +68 -0
  38. data/lib/karst/railtie.rb +30 -0
  39. data/lib/karst/spec/catalog.rb +199 -0
  40. data/lib/karst/spec/example_observation.rb +31 -0
  41. data/lib/karst/spec/observer.rb +300 -0
  42. data/lib/karst/spec/principal.rb +12 -0
  43. data/lib/karst/spec/reporter.rb +83 -0
  44. data/lib/karst/spec/request_observation.rb +38 -0
  45. data/lib/karst/spec/scenario.rb +65 -0
  46. data/lib/karst/value.rb +35 -0
  47. data/lib/karst/version.rb +5 -0
  48. data/lib/karst/web/badge.rb +183 -0
  49. data/lib/karst/web/browser_identity.rb +103 -0
  50. data/lib/karst/web/locality.rb +64 -0
  51. data/lib/karst/web/middleware.rb +377 -0
  52. data/lib/karst/web/panel.rb +699 -0
  53. data/lib/karst/web/populations_panel.rb +391 -0
  54. data/lib/karst/web/route_lookup.rb +65 -0
  55. data/lib/karst.rb +56 -0
  56. data/lib/rails/commands/karst/boot.rb +24 -0
  57. data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
  58. data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
  59. data/lib/tasks/karst.rake +34 -0
  60. metadata +138 -0
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+
5
+ module Karst
6
+ module Spec
7
+ # One RSpec example exercising one specific browser-facing controller/action
8
+ # request, built entirely from evidence already present in the JSON artifact
9
+ # Karst::Spec::Observer writes.
10
+ #
11
+ # `observed_status`/`observed_redirect` describe what this spec execution
12
+ # observed while it ran, never what the example asserted -- a failing or
13
+ # pending example still produces a Scenario, and `example_outcome` is how
14
+ # a consumer tells "verified" apart from "merely observed."
15
+ #
16
+ # An example that issues several browser-facing requests (a denied attempt
17
+ # followed by an allowed retry, a sign-in followed by the page it unlocks)
18
+ # legitimately produces one Scenario per such request: Karst never
19
+ # collapses an example down to "its last request," and never labels any
20
+ # request as authentication setup versus the subject under test.
21
+ #
22
+ # `principal_before`/`principal_after` are both kept, not collapsed to
23
+ # whichever was active when the request began: a signup or checkout
24
+ # scenario that establishes a session is exactly the case where the
25
+ # identity a request produces matters as much as the identity it started
26
+ # with, and either side alone would discard real evidence.
27
+ Scenario = Value.define(
28
+ :example_id,
29
+ :file_path,
30
+ :line_number,
31
+ :description_parts,
32
+ :full_description,
33
+ :karst_explicit,
34
+ :karst_name,
35
+ :example_outcome,
36
+ :controller,
37
+ :action,
38
+ :http_method,
39
+ :route_pattern,
40
+ :observed_path,
41
+ :observed_status,
42
+ :observed_redirect,
43
+ :principal_before,
44
+ :principal_after,
45
+ :principal_changed,
46
+ :sequence
47
+ ) do
48
+ # The most specific zero-config name available without repeating the
49
+ # whole describe chain: RSpec's own nesting already puts the most
50
+ # specific description last. Never invents a persona the spec itself
51
+ # did not name.
52
+ def name
53
+ karst_name || description_parts.last || full_description
54
+ end
55
+
56
+ def passed?
57
+ example_outcome == :passed
58
+ end
59
+
60
+ def explicit?
61
+ karst_explicit
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karst
4
+ # Small immutable value-object helper standing in for Ruby 3.2's
5
+ # Data.define, which Karst cannot rely on while supporting Ruby 2.7. Built
6
+ # on Struct(keyword_init: true), which already provides structural
7
+ # equality, keyword construction, and #members; the one thing Struct does
8
+ # not give for free is immutability, so .define freezes every instance its
9
+ # class produces. This matches Data's own contract exactly: the instance
10
+ # itself is frozen, but a member holding a mutable object (an Array, say)
11
+ # is not deep-frozen -- Data.define does not do that either, and Karst does
12
+ # not need it to.
13
+ module Value
14
+ # rubocop:disable Naming/BlockForwarding, Style/ArgumentsForwarding -- anonymous
15
+ # block forwarding (`&`) needs Ruby 3.1; this file runs on Ruby 2.7.
16
+ def self.define(*members, &block)
17
+ klass = Struct.new(*members, keyword_init: true, &block)
18
+ # rubocop:enable Naming/BlockForwarding, Style/ArgumentsForwarding
19
+
20
+ # Struct.new itself is a heavily overloaded class method (it doubles as
21
+ # the anonymous-subclass factory), so overriding it and calling super
22
+ # would climb straight past Struct's own keyword-init handling into
23
+ # that factory instead of a plain constructor. Overriding the ordinary
24
+ # instance method #initialize has no such trap.
25
+ klass.class_eval do
26
+ def initialize(...)
27
+ super
28
+ freeze
29
+ end
30
+ end
31
+
32
+ klass
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karst
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "rack/utils"
5
+
6
+ begin
7
+ require_relative "../spec/catalog"
8
+ rescue LoadError
9
+ # Degrades to a countless badge rather than preventing the host response
10
+ # from being served, mirroring Panel's own posture toward a missing Catalog.
11
+ end
12
+
13
+ module Karst
14
+ module Web
15
+ # Rewrites an eligible host HTML response to add a small, page-local link
16
+ # into Karst's route-scoped scenario catalog. Every check here exists to
17
+ # make injection safe to skip: Badge never guesses, never raises into the
18
+ # host application, and never touches a response it cannot confidently
19
+ # rewrite -- an untouched response is always the safe fallback.
20
+ #
21
+ # Route identity (controller/action/http_method) comes from
22
+ # Middleware, itself derived from a real process_action.action_controller
23
+ # notification; Badge never infers it from a path. `context.path` is
24
+ # carried along purely as display context for the panel (see
25
+ # Karst::Web::Panel), never as part of route identity.
26
+ module Badge
27
+ Context = Struct.new(:controller, :action, :http_method, :path, keyword_init: true)
28
+
29
+ # A generous ceiling, not a tuning knob: real pages are a few hundred KB
30
+ # at most, and this exists only to decline joining a body large enough
31
+ # that string-splicing it would be wasteful, not to police page weight.
32
+ MAX_INJECTABLE_BYTES = 5 * 1024 * 1024
33
+ private_constant :MAX_INJECTABLE_BYTES
34
+
35
+ BODY_CLOSE_TAG = %r{</body>}i
36
+ private_constant :BODY_CLOSE_TAG
37
+
38
+ # `all:initial` strips whatever the host page's own CSS would otherwise
39
+ # inherit onto the badge (fonts, colors, positioning contexts); every
40
+ # property Karst actually wants is re-declared afterward in the same
41
+ # attribute, so the cascade only ever sees Karst's own values.
42
+ BADGE_STYLE =
43
+ "all:initial;position:fixed;bottom:12px;right:12px;z-index:2147483647;" \
44
+ "display:inline-block;padding:.35rem .65rem;border-radius:999px;" \
45
+ "background:#202124;color:#fff;font:600 12px/1.4 -apple-system,system-ui,sans-serif;" \
46
+ "text-decoration:none;box-shadow:0 1px 4px rgba(0,0,0,.35)"
47
+ private_constant :BADGE_STYLE
48
+
49
+ class << self
50
+ # Returns a replacement [status, headers, body] triple, or nil when
51
+ # the response should be returned to the host application unchanged
52
+ # -- including when anything above raises, so a bug here can never
53
+ # turn into a broken host page.
54
+ def apply(status:, headers:, body:, context:)
55
+ return nil unless context
56
+ return nil unless html_response?(status: status, headers: headers)
57
+ # `to_ary` is Rack's own signal that a body is already fully
58
+ # buffered rather than genuinely streaming (Rack::ETag relies on
59
+ # exactly this same check for exactly this same reason). Rails
60
+ # delegates it down to the response's real stream object, so a
61
+ # Live-streaming response correctly reports false and is left
62
+ # alone. Under Rack 2 (Rails 7.0), ActionDispatch's older
63
+ # RackBody wrapper never exposes to_ary at all -- every response
64
+ # reports false there, so Badge never injects on that Rails
65
+ # series. That is a missing feature, not a bug: guessing
66
+ # bufferability by any other means risks blocking forever on a
67
+ # real streaming body, which this module will not do.
68
+ return nil unless body.respond_to?(:to_ary)
69
+
70
+ parts = body.to_ary
71
+ return nil unless bufferable?(parts)
72
+
73
+ rewrite(status: status, headers: headers, parts: parts, body: body, context: context)
74
+ rescue StandardError
75
+ nil
76
+ end
77
+
78
+ private
79
+
80
+ def html_response?(status:, headers:)
81
+ return false unless status.is_a?(Integer) && status >= 200 && status < 300
82
+ return false if header(headers, "content-disposition")
83
+ return false if content_encoded?(headers)
84
+
85
+ header(headers, "content-type").to_s.downcase.start_with?("text/html")
86
+ end
87
+
88
+ def content_encoded?(headers)
89
+ encoding = header(headers, "content-encoding").to_s.strip
90
+ !encoding.empty? && !encoding.casecmp?("identity")
91
+ end
92
+
93
+ def bufferable?(parts)
94
+ parts.is_a?(Array) && parts.all?(String) && parts.sum(&:bytesize) <= MAX_INJECTABLE_BYTES
95
+ end
96
+
97
+ def rewrite(status:, headers:, parts:, body:, context:)
98
+ original = parts.join
99
+ index = original.rindex(BODY_CLOSE_TAG)
100
+ return nil unless index
101
+
102
+ html = "#{original[0...index]}#{markup(headers: headers, context: context)}#{original[index..]}"
103
+ new_headers = headers.dup
104
+ set_content_length!(new_headers, html.bytesize)
105
+ strip_validators!(new_headers)
106
+ body.close if body.respond_to?(:close)
107
+ [status, new_headers, [html]]
108
+ end
109
+
110
+ def markup(headers:, context:)
111
+ href = escape("/karst?#{query_for(context)}")
112
+ label = escape(label_for(context))
113
+ style = inline_style_allowed?(headers) ? " style=\"#{BADGE_STYLE}\"" : ""
114
+ "<a href=\"#{href}\"#{style}>#{label}</a>"
115
+ end
116
+
117
+ def query_for(context)
118
+ Rack::Utils.build_query(
119
+ "controller" => context.controller, "action" => context.action,
120
+ "method" => context.http_method, "path" => context.path
121
+ )
122
+ end
123
+
124
+ def label_for(context)
125
+ catalog = load_catalog
126
+ return "Karst" unless catalog && catalog.status == :ready
127
+
128
+ count = catalog.scenarios_for(
129
+ controller: context.controller, action: context.action, http_method: context.http_method
130
+ ).size
131
+ "Karst · #{count}"
132
+ end
133
+
134
+ def load_catalog
135
+ return nil unless defined?(Karst::Spec::Catalog)
136
+
137
+ Karst::Spec::Catalog.load
138
+ rescue StandardError
139
+ nil
140
+ end
141
+
142
+ # A CSP the host response itself declares is the only signal Badge
143
+ # trusts: Karst never rewrites that header, only reads it, and falls
144
+ # back to an unstyled link rather than risk a silently-dropped style
145
+ # under a policy with no 'unsafe-inline'.
146
+ def inline_style_allowed?(headers)
147
+ csp = header(headers, "content-security-policy").to_s.strip
148
+ return true if csp.empty?
149
+
150
+ directives = csp.split(";").map(&:strip)
151
+ directive = directives.find { |item| item.start_with?("style-src") } ||
152
+ directives.find { |item| item.start_with?("default-src") }
153
+ directive.nil? || directive.include?("'unsafe-inline'")
154
+ end
155
+
156
+ def header(headers, name)
157
+ key = headers.keys.find { |candidate| candidate.to_s.casecmp?(name) }
158
+ key && headers[key]
159
+ end
160
+
161
+ def set_content_length!(headers, bytesize)
162
+ key = headers.keys.find { |candidate| candidate.to_s.casecmp?("content-length") }
163
+ headers[key || "content-length"] = bytesize.to_s
164
+ end
165
+
166
+ # ETag/Last-Modified validate a specific byte sequence; once Karst
167
+ # appends the badge, any validator computed on the host's original
168
+ # body no longer describes what is actually being served. Dropping
169
+ # them is safer than emitting a validator Karst never recomputed.
170
+ def strip_validators!(headers)
171
+ %w[etag last-modified].each do |name|
172
+ key = headers.keys.find { |candidate| candidate.to_s.casecmp?(name) }
173
+ headers.delete(key) if key
174
+ end
175
+ end
176
+
177
+ def escape(value)
178
+ CGI.escapeHTML(value.to_s)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "uri"
5
+ require "active_support/security_utils"
6
+
7
+ module Karst
8
+ module Web
9
+ # State-changing browser identity operations and their same-session CSRF
10
+ # token. Rails controller CSRF is unavailable because /karst is served at
11
+ # the Rack boundary, before Action Controller dispatch.
12
+ class BrowserIdentity
13
+ TOKEN_KEY = "karst.csrf_token"
14
+ ACTIVE_KEY = "karst.browser_identity_active"
15
+
16
+ # The exact Devise/Warden scope the currently assumed identity was
17
+ # established under (see Identity.assume_browser), retained for the
18
+ # lifetime of the browser session so #clear can hand it straight back
19
+ # to Identity.clear_browser instead of that having to guess which of
20
+ # several selected sources produced the principal being cleared.
21
+ SCOPE_KEY = "karst.browser_identity_scope"
22
+
23
+ def initialize(request)
24
+ @request = request
25
+ end
26
+
27
+ def token
28
+ session[TOKEN_KEY] ||= SecureRandom.hex(32)
29
+ end
30
+
31
+ def active?
32
+ session[ACTIVE_KEY] == true
33
+ end
34
+
35
+ def assume(params)
36
+ verify_token!(params["csrf_token"])
37
+ target = return_path(params["path"])
38
+ principal = Identity.resolve(model_name: params["principal_type"], id: params["principal_id"])
39
+ raise Identity::Unavailable, "principal is not in the configured source" unless principal
40
+
41
+ scope = Identity.assume_browser(@request, principal)
42
+ # Authentication hooks may clear or replace the host session. Rebuild
43
+ # Karst's control state only after that transition, and invalidate the
44
+ # token which authorized it rather than carrying pre-assumption state
45
+ # into the assumed identity.
46
+ session[ACTIVE_KEY] = true
47
+ session[SCOPE_KEY] = scope&.to_s
48
+ rotate_token!
49
+ target
50
+ end
51
+
52
+ def clear(params)
53
+ verify_token!(params["csrf_token"])
54
+ target = return_path(params["path"])
55
+ scope = session[SCOPE_KEY]
56
+ Identity.clear_browser(@request, scope: scope&.to_sym)
57
+ session.delete(ACTIVE_KEY)
58
+ session.delete(SCOPE_KEY)
59
+ target
60
+ end
61
+
62
+ private
63
+
64
+ def session
65
+ @request.session
66
+ rescue StandardError
67
+ raise Identity::Unavailable, "a writable Rack session is required"
68
+ end
69
+
70
+ def verify_token!(submitted)
71
+ expected = session[TOKEN_KEY]
72
+ valid = expected && submitted && expected.bytesize == submitted.bytesize &&
73
+ ActiveSupport::SecurityUtils.secure_compare(expected, submitted)
74
+ raise Identity::Unavailable, "invalid Karst CSRF token" unless valid
75
+ end
76
+
77
+ def rotate_token!
78
+ session[TOKEN_KEY] = SecureRandom.hex(32)
79
+ end
80
+
81
+ # A blank path is not a caller error: the panel renders this same
82
+ # hidden field for "Stop testing as" from a plain /karst visit with no
83
+ # ?path= in the query string (bookmarked, or reached without a route
84
+ # already selected) -- exactly what happens right after Test As
85
+ # navigates the browser away to the tested page and a developer comes
86
+ # back to /karst directly. Falling back to /karst itself keeps that
87
+ # button working instead of raising and leaving the assumed identity
88
+ # active.
89
+ def return_path(value)
90
+ return "/karst" if value.to_s.empty?
91
+
92
+ raw = value.to_s.split("?", 2).first
93
+ uri = URI.parse(raw)
94
+ valid = uri.relative? && raw.start_with?("/") && !raw.start_with?("//")
95
+ raise Identity::Unavailable, "return path must be a local application path" unless valid
96
+
97
+ raw
98
+ rescue URI::InvalidURIError
99
+ raise Identity::Unavailable, "return path must be a valid local application path"
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Karst
6
+ module Web
7
+ # Determines whether a Rack peer is local without consulting proxy headers.
8
+ # WSL's NAT makes a Windows browser appear as the Linux VM's default gateway,
9
+ # so that one address is accepted when the process is verifiably running in
10
+ # WSL. Other private-network peers remain untrusted.
11
+ class Locality
12
+ LOOPBACK_RANGES = [IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")].freeze
13
+ private_constant :LOOPBACK_RANGES
14
+
15
+ def initialize(environment: ENV, osrelease_path: "/proc/sys/kernel/osrelease", route_path: "/proc/net/route")
16
+ @environment = environment
17
+ @osrelease_path = osrelease_path
18
+ @route_path = route_path
19
+ end
20
+
21
+ def local?(remote_address)
22
+ address = IPAddr.new(remote_address.to_s)
23
+ loopback?(address) || wsl_gateway == address
24
+ rescue IPAddr::Error
25
+ false
26
+ end
27
+
28
+ private
29
+
30
+ def loopback?(address)
31
+ LOOPBACK_RANGES.any? { |range| range.include?(address) }
32
+ end
33
+
34
+ def wsl_gateway
35
+ return unless wsl?
36
+
37
+ gateway_address(default_gateway_hex)
38
+ rescue Errno::ENOENT, Errno::EACCES, IPAddr::Error
39
+ nil
40
+ end
41
+
42
+ def default_gateway_hex
43
+ route = File.foreach(@route_path).drop(1).find do |line|
44
+ fields = line.split
45
+ fields[1] == "00000000" && fields[3].to_i(16).anybits?(0x2)
46
+ end
47
+ route&.split&.fetch(2, nil)
48
+ end
49
+
50
+ def gateway_address(hex)
51
+ return unless hex&.match?(/\A[0-9A-Fa-f]{8}\z/)
52
+
53
+ IPAddr.new([hex].pack("H*").reverse.unpack("C4").join("."))
54
+ end
55
+
56
+ def wsl?
57
+ @environment.key?("WSL_INTEROP") || @environment.key?("WSL_DISTRO_NAME") ||
58
+ File.read(@osrelease_path).match?(/microsoft|wsl/i)
59
+ rescue Errno::ENOENT, Errno::EACCES
60
+ false
61
+ end
62
+ end
63
+ end
64
+ end