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
@@ -1,28 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/notifications"
4
- require "uri"
5
- require_relative "probe_application"
6
- require_relative "database_isolation"
3
+ require_relative "errors"
4
+ require_relative "local_path"
5
+ require_relative "probe"
6
+ require_relative "outcome_groups"
7
7
  require_relative "../identity"
8
8
  require_relative "../value"
9
9
 
10
10
  module Karst
11
11
  module Access
12
- class Error < StandardError; end
13
- class UnsafeTarget < Error; end
14
- class UnsupportedMethod < Error; end
15
- class Unavailable < Error; end
16
-
17
12
  # sampling_reasons is a frozen Array of short evidence strings (e.g.
18
13
  # "role=local_admin", "source=authors") explaining why PrincipalSampler
19
14
  # or PrincipalSelection deliberately included this principal, or an
20
15
  # empty Array when the principal came from plain first-N/fill sampling
21
16
  # or was supplied directly rather than through a sampler. This is
22
17
  # sampling evidence, not an authorization claim.
18
+ #
19
+ # `principal` is the *requested* identity -- what Karst was asked to
20
+ # execute as, nil for a deliberate anonymous probe -- and is intent, not
21
+ # evidence. What the application actually resolved lives in `identity`
22
+ # (a Karst::Identity::Evidence), and only its #confirmation says whether
23
+ # the two agree. Nothing may conclude "this request ran as User#123" from
24
+ # `principal` alone.
25
+ #
26
+ # controller/action are the controller class name and action the request
27
+ # actually dispatched to, observed from the probe request's own env, or
28
+ # nil when it never reached a controller.
29
+ #
30
+ # Every evidence field is Access::Probe's observation of the target
31
+ # request alone: write_count never includes identity setup/teardown.
23
32
  Outcome = Value.define(:principal, :status, :redirect, :exception_class,
24
33
  :writes_observed, :write_count, :elapsed_ms, :database_rollback_attempted,
25
- :sampling_reasons, :body_marker_observed, :halted_callback)
34
+ :sampling_reasons, :halted_callback, :identity, :controller, :action)
26
35
 
27
36
  # candidate_pool_size is nil unless the caller supplying `principals` (see
28
37
  # Access::PrincipalSampler::Result) knows it sampled from a bounded
@@ -31,14 +40,20 @@ module Karst
31
40
  # principal was considered.
32
41
  Result = Value.define(:path, :http_method, :outcomes, :elapsed_ms, :aborted_reason, :database_isolation,
33
42
  :candidate_pool_size) do
43
+ # Semantic outcome groups (see Access::OutcomeGroups), largest first,
44
+ # as semantic key => member outcomes.
34
45
  def groups
35
- outcomes.group_by { |item| [item.status, item.redirect, item.exception_class, item.halted_callback] }
46
+ OutcomeGroups.group(outcomes).to_h { |group| [group.key, group.outcomes] }
36
47
  end
37
48
  end
38
49
 
39
- # Sequentially observes one concrete local GET using a fresh integration
40
- # session and a rollback-only transaction for every bounded principal.
41
- # rubocop:disable Metrics/ClassLength
50
+ # Runs one concrete local GET as each of a bounded set of principals, in
51
+ # order, and collects what happened to each. Every request is one
52
+ # Access::Probe call -- a fresh session and a rollback-only transaction per
53
+ # principal -- so a sweep's per-principal evidence is exactly what
54
+ # reproducing that request as that principal observes. Sweep itself owns
55
+ # only the population: bounding it, and attaching why each principal was
56
+ # sampled.
42
57
  class Sweep
43
58
  # sampling_reasons optionally maps a principal (by Ruby equality, so
44
59
  # the same Active Record identity even across separate instances) to
@@ -46,9 +61,8 @@ module Karst
46
61
  # Access::PrincipalSampler::Candidate/PrincipalSelection. A principal
47
62
  # with no entry simply gets an empty Array on its Outcome.
48
63
  # rubocop:disable Metrics/ParameterLists
49
- # rubocop:disable Metrics/MethodLength
50
64
  def initialize(path:, principals:, http_method: "GET", limit: Karst.config.access_sweep_limit,
51
- application: nil, candidate_pool_size: nil, sampling_reasons: {}, body_includes: nil)
65
+ application: nil, candidate_pool_size: nil, sampling_reasons: {})
52
66
  @path = normalize_path(path)
53
67
  @http_method = http_method.to_s.upcase
54
68
  raise UnsupportedMethod, "access sweeps support GET only" unless @http_method == "GET"
@@ -56,23 +70,20 @@ module Karst
56
70
 
57
71
  @principals = principals
58
72
  @limit = limit
59
- @application = application || Rails.application
60
- @probe_application = build_probe_application
73
+ @probe = Probe.new(application || Rails.application)
61
74
  @candidate_pool_size = candidate_pool_size
62
75
  @sampling_reasons = sampling_reasons
63
- @body_includes = body_includes
64
76
  end
65
- # rubocop:enable Metrics/MethodLength
66
77
  # rubocop:enable Metrics/ParameterLists
67
78
 
68
79
  def call
69
80
  raise Unavailable, "access sweeps are development-only" unless Rails.env.development?
70
81
  raise Unavailable, "Karst is disabled (config.enabled)" unless Karst.enabled?
71
82
 
72
- require "action_dispatch/testing/integration" unless defined?(ActionDispatch::Integration::Session)
73
-
74
83
  started = monotonic
75
- outcomes = bounded_principals.map { |principal| probe(principal) }
84
+ outcomes = bounded_principals.map do |principal|
85
+ outcome(principal, @probe.call(principal: principal, http_method: @http_method, target: @path))
86
+ end
76
87
  Result.new(path: @path, http_method: @http_method, outcomes: outcomes.freeze,
77
88
  elapsed_ms: elapsed(started), aborted_reason: nil,
78
89
  database_isolation: :same_connection_rollback_attempted,
@@ -82,14 +93,7 @@ module Karst
82
93
  private
83
94
 
84
95
  def normalize_path(value)
85
- raw = value.to_s.split("?", 2).first
86
- uri = URI.parse(raw)
87
- local = uri.relative? && raw.start_with?("/") && !raw.start_with?("//")
88
- raise UnsafeTarget, "target must be a local application path" unless local
89
-
90
- raw
91
- rescue URI::InvalidURIError
92
- raise UnsafeTarget, "target must be a valid local application path"
96
+ LocalPath.path(value)
93
97
  end
94
98
 
95
99
  def valid_limit?(limit)
@@ -102,87 +106,14 @@ module Karst
102
106
  source.each.lazy.take(@limit).to_a
103
107
  end
104
108
 
105
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
106
- def probe(principal)
107
- session = ActionDispatch::Integration::Session.new(@probe_application)
108
- configure_host(session)
109
- started = monotonic
110
- status = redirect = exception_class = nil
111
- body_marker_observed = nil
112
- halted_callback = nil
113
- writes = 0
114
- callback = lambda do |_name, _start, _finish, _id, payload|
115
- writes += 1 if DatabaseIsolation.mutation?(payload[:sql])
116
- end
117
- halt_observer = lambda do |_name, _start, _finish, _id, payload|
118
- halted_callback = payload[:filter]
119
- end
120
-
121
- with_rollback do
122
- ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
123
- Karst::Identity.with(session, principal) do
124
- ActiveSupport::Notifications.subscribed(halt_observer, "halted_callback.action_controller") do
125
- session.get(@path)
126
- end
127
- rendered_exception = request_exception(session)
128
- if rendered_exception
129
- exception_class = rendered_exception.class.name
130
- else
131
- status = session.response.status
132
- if @body_includes && session.response.respond_to?(:body)
133
- body_marker_observed = session.response.body.to_s.include?(@body_includes.to_s)
134
- end
135
- redirect = clean_redirect(session.response.location) if status >= 300 && status < 400
136
- end
137
- end
138
- end
139
- rescue StandardError => e
140
- exception_class = e.class.name
141
- end
142
- Outcome.new(principal: Karst::Identity.describe(principal), status: status, redirect: redirect,
143
- exception_class: exception_class, writes_observed: writes.positive?, write_count: writes,
144
- elapsed_ms: elapsed(started), database_rollback_attempted: true,
109
+ def outcome(principal, observed)
110
+ writes = observed.write_count
111
+ Outcome.new(principal: observed.identity.requested, status: observed.status, redirect: observed.redirect,
112
+ exception_class: observed.exception_class, writes_observed: writes.positive?,
113
+ write_count: writes, elapsed_ms: observed.elapsed_ms, database_rollback_attempted: true,
145
114
  sampling_reasons: (@sampling_reasons[principal] || []).freeze,
146
- body_marker_observed: body_marker_observed, halted_callback: halted_callback)
147
- end
148
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
149
-
150
- def with_rollback
151
- raise Unavailable, "Active Record rollback isolation is unavailable" unless defined?(ActiveRecord::Base)
152
-
153
- ActiveRecord::Base.transaction(requires_new: true) do
154
- yield
155
- raise ActiveRecord::Rollback
156
- end
157
- end
158
-
159
- def build_probe_application
160
- ProbeApplication.for(@application)
161
- rescue ProbeApplication::ConstructionError => e
162
- raise Unavailable, e.message, cause: e
163
- end
164
-
165
- def configure_host(session)
166
- return unless @probe_application.respond_to?(:host) && @probe_application.host
167
-
168
- session.host!(@probe_application.host)
169
- end
170
-
171
- # Rails may either re-raise an application exception or render it through
172
- # ShowExceptions, depending on host and Rails-version configuration. The
173
- # latter records the original exception in the integration request env.
174
- def request_exception(session)
175
- return unless session.respond_to?(:request) && session.request
176
-
177
- session.request.get_header("action_dispatch.exception")
178
- end
179
-
180
- def clean_redirect(location)
181
- return nil if location.to_s.empty?
182
-
183
- URI.parse(location).tap { |uri| uri.query = nil }.to_s
184
- rescue URI::InvalidURIError
185
- location.to_s.split("?", 2).first
115
+ halted_callback: observed.halted_callback, identity: observed.identity,
116
+ controller: observed.controller, action: observed.action)
186
117
  end
187
118
 
188
119
  def monotonic
@@ -193,6 +124,5 @@ module Karst
193
124
  ((monotonic - started) * 1000.0).round(1)
194
125
  end
195
126
  end
196
- # rubocop:enable Metrics/ClassLength
197
127
  end
198
128
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karst
4
+ module CLI
5
+ # The one place a Karst::Identity::Evidence becomes the JSON/MCP-facing
6
+ # identity document, shared by Karst::CLI::Verification and
7
+ # Karst::CLI::Reproduction so the two schemas can never describe identity
8
+ # evidence differently -- a probe's `identity` document means the same
9
+ # thing regardless of which adapter produced it.
10
+ module IdentitySerialization
11
+ private
12
+
13
+ # The whole point of this schema. `requested` is intent, `observed` is
14
+ # what the application resolved while running the request, and
15
+ # `confirmation` is the only field that says whether a principal claim
16
+ # about this request is true. Anything other than
17
+ # "confirmed"/"confirmed_anonymous" means it is not.
18
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
19
+ def identity_document(evidence)
20
+ return nil unless evidence
21
+
22
+ {
23
+ requested: evidence.requested && principal(evidence.requested),
24
+ observed: observed_principal(evidence.observed),
25
+ confirmation: evidence.confirmation.to_s,
26
+ observed_at: evidence.observed_at&.to_s,
27
+ observation_source: evidence.observation_source&.to_s,
28
+ observation_error: evidence.observation_error,
29
+ establishment: evidence.establishment&.to_s,
30
+ establishment_error: evidence.establishment_error,
31
+ cleanup_error: evidence.cleanup_error,
32
+ changed_during_request: evidence.changed_during_request
33
+ }
34
+ end
35
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
36
+
37
+ def observed_principal(value)
38
+ return nil unless value
39
+
40
+ { model: value.model_name.to_s, id: primitive_id(value.id) }
41
+ end
42
+
43
+ def principal(value)
44
+ # JSON is also the MCP contract. Framework-inferred login identifiers
45
+ # must never cross that machine-readable boundary. An application-
46
+ # authored principal_label remains explicit configuration and keeps
47
+ # its longstanding serialization behavior.
48
+ label = if Karst.config.principal_label
49
+ value.display_label.to_s
50
+ else
51
+ "#{value.model_name} ##{value.id}"
52
+ end
53
+ { model: value.model_name.to_s, id: primitive_id(value.id), label: label }
54
+ end
55
+
56
+ def primitive_id(value)
57
+ value.is_a?(Integer) ? value : value.to_s
58
+ end
59
+
60
+ # Says what the application actually did with identity, never what was
61
+ # asked of it.
62
+ def identity_line(evidence)
63
+ case evidence.confirmation
64
+ when :confirmed then "observed #{observed_label(evidence)} (identity confirmed)"
65
+ when :confirmed_anonymous then "observed no principal (anonymous confirmed)"
66
+ when :absent then "requested #{requested_label(evidence)}, observed no principal (NOT confirmed)"
67
+ when :mismatch then "requested #{requested_label(evidence)}, observed #{observed_label(evidence)} (MISMATCH)"
68
+ when :contaminated then "anonymous probe observed #{observed_label(evidence)} (CONTAMINATED)"
69
+ else "identity unobservable: #{evidence.observation_error}"
70
+ end
71
+ end
72
+
73
+ def observed_label(evidence)
74
+ evidence.observed ? "#{evidence.observed.model_name} ##{evidence.observed.id}" : "no principal"
75
+ end
76
+
77
+ def requested_label(evidence)
78
+ evidence.requested ? "#{evidence.requested.model_name} ##{evidence.requested.id}" : "anonymous"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+
5
+ module Karst
6
+ module CLI
7
+ # The bounded, human-readable outcome lines for one probe stage (the
8
+ # ordinary sample, one population retry), built from already-computed
9
+ # Access::OutcomeGroups groups.
10
+ #
11
+ # Counts, not conclusions: each line is one semantic outcome group and
12
+ # the number of users that observed it, largest first. The largest group
13
+ # is only ever marked by position -- Karst never calls it "dominant",
14
+ # "most", or "typical", so a close split (12 / 11 / 9) reads as exactly
15
+ # that. When one group is strictly larger than every other, its users are
16
+ # not listed (it is the outcome the rest differ from); every other group
17
+ # names up to MAX_EXAMPLES of its users, which are the ones worth
18
+ # inspecting. When the largest count is tied, no group is singled out and
19
+ # every group names examples.
20
+ #
21
+ # Output stays bounded however many users were tested: at most
22
+ # MAX_OUTCOMES group lines (verified-usable groups are always shown -- they
23
+ # are the answer to the question the sweep asks), one examples line per
24
+ # listed group, and one line summarizing whatever else was observed. The
25
+ # complete per-user evidence stays in `--json`.
26
+ class OutcomeSummary
27
+ MAX_OUTCOMES = 5
28
+ MAX_EXAMPLES = 3
29
+
30
+ def initialize(groups, indent: " ")
31
+ @groups = groups
32
+ @indent = indent
33
+ end
34
+
35
+ def lines
36
+ return [] if @groups.empty?
37
+
38
+ width = shown.map { |group| group.count.to_s.size }.max
39
+ rows = shown.flat_map { |group| group_lines(group, width) }
40
+ rows << hidden_line if hidden.any?
41
+ rows
42
+ end
43
+
44
+ # One outcome group as a single line of observed facts, in Karst's own
45
+ # terms (see Web::Panel#outcome_title and CLI::Verification's
46
+ # response evidence) -- never "denied", "allowed", or any other
47
+ # authorization claim the application itself did not make.
48
+ def describe(group)
49
+ item = group.representative
50
+ parts = [response(item), (dispatch(item) if dispatch_varies?),
51
+ (writes(item.write_count) if item.writes_observed), ("verified usable" if group.usable)]
52
+ parts.insert(1, "halted at #{item.halted_callback}") if item.halted_callback
53
+ parts.compact.join(" · ")
54
+ end
55
+
56
+ private
57
+
58
+ def group_lines(group, width)
59
+ line = ["#{@indent}#{group.count.to_s.rjust(width)} #{describe(group)}"]
60
+ line << "#{@indent}#{' ' * width} #{examples(group)}" if examples?(group)
61
+ line
62
+ end
63
+
64
+ def shown
65
+ @shown ||= @groups.each_with_index.select { |group, index| index < MAX_OUTCOMES || group.usable }
66
+ .map(&:first)
67
+ end
68
+
69
+ def hidden
70
+ @hidden ||= @groups - shown
71
+ end
72
+
73
+ def hidden_line
74
+ users = hidden.sum(&:count)
75
+ "#{@indent}+#{hidden.size} more #{hidden.size == 1 ? 'outcome' : 'outcomes'} " \
76
+ "(#{users} #{users == 1 ? 'user' : 'users'}); --json lists every outcome"
77
+ end
78
+
79
+ def examples?(group)
80
+ return false if @groups.size == 1
81
+
82
+ !(unique_largest? && group.equal?(@groups.first))
83
+ end
84
+
85
+ def unique_largest?
86
+ @groups.size == 1 || @groups[0].count > @groups[1].count
87
+ end
88
+
89
+ def examples(group)
90
+ labels = group.outcomes.first(MAX_EXAMPLES).map { |item| label(item.principal) }
91
+ remaining = group.count - labels.size
92
+ labels << "+#{remaining} more" if remaining.positive?
93
+ labels.join(", ")
94
+ end
95
+
96
+ def label(principal)
97
+ principal ? principal.display_label.to_s : "anonymous request"
98
+ end
99
+
100
+ def response(item)
101
+ return "exception #{item.exception_class}" if item.exception_class
102
+ return "no response observed" unless item.status
103
+
104
+ status = status_title(item.status)
105
+ item.redirect ? "#{status} → #{item.redirect}" : status
106
+ end
107
+
108
+ def status_title(status)
109
+ phrase = Rack::Utils::HTTP_STATUS_CODES[status]
110
+ phrase ? "#{status} #{phrase}" : status.to_s
111
+ end
112
+
113
+ # Two groups must never read identically. Dispatch is part of the
114
+ # semantic key but normally uniform for one path, so it is printed only
115
+ # when groups in this stage actually disagree about it.
116
+ def dispatch_varies?
117
+ return @dispatch_varies if defined?(@dispatch_varies)
118
+
119
+ @dispatch_varies = @groups.map { |group| dispatch(group.representative) }.uniq.size > 1
120
+ end
121
+
122
+ def dispatch(item)
123
+ item.controller ? "#{item.controller}##{item.action}" : "no controller dispatched"
124
+ end
125
+
126
+ def writes(count)
127
+ "#{count} database #{count == 1 ? 'write' : 'writes'}"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../identity"
4
+
5
+ module Karst
6
+ module CLI
7
+ # Parses and resolves a human-supplied "--as MODEL:ID" reference (e.g.
8
+ # "User:72") into an existing principal, entirely through
9
+ # Karst::Identity.resolve -- the same configured-source-only lookup the
10
+ # browser's own Test As form already uses (see
11
+ # Karst::Web::BrowserIdentity#assume). There is no bypass here: a model
12
+ # name or id that Identity.resolve does not recognize resolves to
13
+ # nothing, exactly as it would for Test As, rather than falling back to
14
+ # Object#const_get/Model.find against an arbitrary class name.
15
+ module PrincipalReference
16
+ Reference = Struct.new(:model_name, :id)
17
+
18
+ # Format-only validation, with no dependency on a booted application or
19
+ # configured principal sources -- callers use this to reject a
20
+ # malformed --as value immediately, before Identity.resolve is even
21
+ # reachable.
22
+ def self.parse(value)
23
+ model_name, id = value.to_s.split(":", 2)
24
+ if model_name.to_s.empty? || id.to_s.empty?
25
+ raise ArgumentError, "--as must be MODEL:ID, e.g. \"User:72\" (got #{value.inspect})"
26
+ end
27
+
28
+ Reference.new(model_name, id)
29
+ end
30
+
31
+ # Resolves `value` to an existing principal, or raises ArgumentError --
32
+ # never nil, and never a fallback to sampling or to anonymous. A
33
+ # caller who explicitly asked to run as one principal gets that
34
+ # principal or a clear failure, not a silent substitute.
35
+ #
36
+ # `sources:`, when given, is the operation's already-resolved principal
37
+ # source Hash (see Karst::Identity::Snapshot).
38
+ def self.resolve(value, sources: nil)
39
+ reference = parse(value)
40
+ lookup = { model_name: reference.model_name, id: reference.id }
41
+ lookup[:sources] = sources if sources
42
+ principal = Identity.resolve(**lookup)
43
+ return principal if principal
44
+
45
+ raise ArgumentError,
46
+ "--as #{value} did not resolve: Karst found no #{reference.model_name} ##{reference.id} " \
47
+ "in its configured principal source(s)"
48
+ end
49
+ end
50
+ end
51
+ end