woods 1.6.3 → 1.6.4
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/CHANGELOG.md +25 -0
- data/CONTRIBUTING.md +13 -10
- data/README.md +1 -1
- data/exe/woods-console +1 -1
- data/exe/woods-mcp-http +17 -4
- data/lib/woods/cache/cache_middleware.rb +11 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +112 -27
- data/lib/woods/console/model_validator.rb +11 -1
- data/lib/woods/console/rack_middleware.rb +27 -6
- data/lib/woods/console/redactor.rb +46 -14
- data/lib/woods/console/safe_context.rb +56 -8
- data/lib/woods/console/server.rb +2 -1
- data/lib/woods/console/sql_noise_stripper.rb +147 -35
- data/lib/woods/console/sql_output_policy.rb +535 -0
- data/lib/woods/console/sql_table_scanner.rb +117 -46
- data/lib/woods/console/sql_validator.rb +60 -5
- data/lib/woods/mcp/bearer_auth.rb +29 -10
- data/lib/woods/mcp/http_transport_options.rb +2 -11
- data/lib/woods/mcp/origin_guard.rb +25 -51
- data/lib/woods/mcp/origin_policy.rb +113 -0
- data/lib/woods/railtie.rb +11 -4
- data/lib/woods/version.rb +1 -1
- metadata +8 -5
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'woods/mcp/http_transport_options'
|
|
5
5
|
require 'woods/observability/structured_logger'
|
|
6
|
+
require 'woods/mcp/bearer_auth'
|
|
7
|
+
require 'woods/mcp/origin_guard'
|
|
8
|
+
require 'rack/request'
|
|
6
9
|
|
|
7
10
|
module Woods
|
|
8
11
|
module Console
|
|
@@ -66,7 +69,7 @@ module Woods
|
|
|
66
69
|
# If your threat model requires stricter isolation, use the bridge mode instead
|
|
67
70
|
# (docs/CONSOLE_MCP_SETUP.md) which runs the executor in a separate process.
|
|
68
71
|
#
|
|
69
|
-
class RackMiddleware
|
|
72
|
+
class RackMiddleware # rubocop:disable Metrics/ClassLength -- mount-local authentication precedes legacy construction
|
|
70
73
|
# @param app [#call] The next Rack app in the middleware stack
|
|
71
74
|
# @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
|
|
72
75
|
# @param embedded_read_tools [Boolean] Enable sql/query tools in embedded mode (default: false)
|
|
@@ -77,8 +80,14 @@ module Woods
|
|
|
77
80
|
# @param unsafe_eval_audit_log_path [String, Pathname, nil] JSONL audit log
|
|
78
81
|
# path for every `console_eval` run. Required on the opt-in path. Takes
|
|
79
82
|
# precedence over `config.console_unsafe_eval_audit_log_path`.
|
|
80
|
-
def initialize(app,
|
|
81
|
-
|
|
83
|
+
def initialize(app, options = {}, **keywords)
|
|
84
|
+
raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
initialize_options(app, **options, **keywords)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, origin_policy: nil, # rubocop:disable Metrics/ParameterLists -- preserves legacy eval options alongside the shared HTTP policy
|
|
90
|
+
unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil)
|
|
82
91
|
@app = app
|
|
83
92
|
@path = path
|
|
84
93
|
@embedded_read_tools = embedded_read_tools
|
|
@@ -86,7 +95,15 @@ module Woods
|
|
|
86
95
|
@unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
|
|
87
96
|
@mutex = Mutex.new
|
|
88
97
|
@transport = nil
|
|
98
|
+
@origin_policy = origin_policy || Woods::MCP::OriginPolicy.new(
|
|
99
|
+
allowed_origins: Woods.configuration&.console_mcp_allowed_origins
|
|
100
|
+
)
|
|
101
|
+
authenticated = Woods::MCP::BearerAuth.new(
|
|
102
|
+
method(:handle_request), token: -> { Woods.configuration.console_mcp_token }
|
|
103
|
+
)
|
|
104
|
+
@guarded_request = Woods::MCP::OriginGuard.new(authenticated, policy: @origin_policy)
|
|
89
105
|
end
|
|
106
|
+
private :initialize_options
|
|
90
107
|
|
|
91
108
|
DISABLED_BODY = JSON.generate(
|
|
92
109
|
error: 'woods_console_disabled',
|
|
@@ -108,11 +125,15 @@ module Woods
|
|
|
108
125
|
return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
|
|
109
126
|
return [410, { 'content-type' => 'application/json' }, [DISABLED_BODY]] unless enabled?
|
|
110
127
|
|
|
111
|
-
|
|
128
|
+
@guarded_request.call(env)
|
|
112
129
|
end
|
|
113
130
|
|
|
114
131
|
private
|
|
115
132
|
|
|
133
|
+
def handle_request(env)
|
|
134
|
+
ensure_transport.handle_request(Rack::Request.new(env))
|
|
135
|
+
end
|
|
136
|
+
|
|
116
137
|
def enabled?
|
|
117
138
|
Woods.configuration.console_mcp_enabled
|
|
118
139
|
end
|
|
@@ -133,7 +154,7 @@ module Woods
|
|
|
133
154
|
|
|
134
155
|
server = build_embedded_server
|
|
135
156
|
@transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
136
|
-
server,
|
|
157
|
+
server, **@origin_policy.transport_options
|
|
137
158
|
)
|
|
138
159
|
server.transport = @transport
|
|
139
160
|
@transport
|
|
@@ -179,7 +200,7 @@ module Woods
|
|
|
179
200
|
config = Woods.configuration
|
|
180
201
|
introspection = build_model_introspection
|
|
181
202
|
Server.build_embedded(
|
|
182
|
-
model_validator: ModelValidator.new(registry: introspection[:registry]),
|
|
203
|
+
model_validator: ModelValidator.new(registry: introspection[:registry], table_names: introspection[:tables]),
|
|
183
204
|
safe_context: SafeContext.new(pool: ActiveRecord::Base.connection_pool),
|
|
184
205
|
redacted_columns: Array(config&.console_redacted_columns),
|
|
185
206
|
redacted_key_values: Array(config&.console_redacted_key_values),
|
|
@@ -65,7 +65,8 @@ module Woods
|
|
|
65
65
|
case key
|
|
66
66
|
when 'record' then value.is_a?(Hash) ? ctx.redact(value) : value
|
|
67
67
|
when 'records' then redact_hash_array(value, ctx)
|
|
68
|
-
when 'rows'
|
|
68
|
+
when 'rows' then redact_positional(value, plan)
|
|
69
|
+
when 'values' then redact_positional(value, plan, single_column: plan[:column_count] == 1)
|
|
69
70
|
when 'associations' then redact_association_map(value, ctx)
|
|
70
71
|
else value
|
|
71
72
|
end
|
|
@@ -93,7 +94,8 @@ module Woods
|
|
|
93
94
|
# `columns` header: the column-name mask plus any EAV key-value rules
|
|
94
95
|
# resolved to column indexes.
|
|
95
96
|
def positional_plan(columns, ctx)
|
|
96
|
-
{
|
|
97
|
+
{ column_count: columns.is_a?(Array) ? columns.length : nil,
|
|
98
|
+
mask: positional_mask(columns, ctx),
|
|
97
99
|
kv_rules: positional_kv_rules(columns, ctx) }
|
|
98
100
|
end
|
|
99
101
|
|
|
@@ -112,35 +114,65 @@ module Woods
|
|
|
112
114
|
# Resolve EAV patterns against a `columns` header into concrete index
|
|
113
115
|
# pairs. A rule only fires when both key_column and value_column are
|
|
114
116
|
# present in the header, and costs nothing per row otherwise.
|
|
117
|
+
#
|
|
118
|
+
# A duplicated key or value header (an `AS` alias can shadow the real
|
|
119
|
+
# column — CON-1) makes index attribution ambiguous: a last-index-wins
|
|
120
|
+
# lookup would let the shadow steal the mask from the secret. The
|
|
121
|
+
# executor refuses those selects up front; here, defense-in-depth
|
|
122
|
+
# fails toward masking — every cell under a value-named header is
|
|
123
|
+
# redacted unconditionally.
|
|
115
124
|
def positional_kv_rules(columns, ctx)
|
|
116
125
|
return [] unless columns.is_a?(Array)
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
ctx.redacted_key_values.filter_map
|
|
120
|
-
|
|
121
|
-
val_idx = index[pattern['value_column']]
|
|
122
|
-
next unless key_idx && val_idx
|
|
127
|
+
names = columns.map(&:to_s)
|
|
128
|
+
ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern, ctx) }
|
|
129
|
+
end
|
|
123
130
|
|
|
124
|
-
|
|
125
|
-
|
|
131
|
+
# One resolved rule for one EAV pattern, or nil when the header lacks
|
|
132
|
+
# either column. Unambiguous headers get the key/value index pair;
|
|
133
|
+
# duplicated headers get the unconditional mask list.
|
|
134
|
+
def positional_kv_rule(names, pattern, ctx)
|
|
135
|
+
key_idxs = names.each_index.select { |i| names[i] == pattern['key_column'] }
|
|
136
|
+
val_idxs = names.each_index.select { |i| names[i] == pattern['value_column'] }
|
|
137
|
+
return nil if key_idxs.empty? || val_idxs.empty?
|
|
138
|
+
return { mask_idxs: val_idxs } unless key_idxs.one? && val_idxs.one?
|
|
139
|
+
|
|
140
|
+
{ key_idx: key_idxs.first, val_idx: val_idxs.first,
|
|
141
|
+
key_matches: ->(value) { sensitive_key?(ctx, value, pattern) } }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def sensitive_key?(ctx, value, pattern)
|
|
145
|
+
return ctx.sensitive_key?(value, pattern) if ctx.respond_to?(:sensitive_key?)
|
|
146
|
+
|
|
147
|
+
pattern['sensitive_keys'].include?(value.to_s)
|
|
126
148
|
end
|
|
127
149
|
|
|
128
150
|
# Redact positional row data using a precomputed plan. Handles both
|
|
129
|
-
# nested arrays (multi-column pluck, sql/query rows) and
|
|
130
|
-
#
|
|
131
|
-
|
|
151
|
+
# nested arrays (multi-column pluck, sql/query rows) and single-column
|
|
152
|
+
# pluck values. Rails collapses the row for single-column pluck, so an
|
|
153
|
+
# Array/Hash value is still one cell and must be redacted as a whole.
|
|
154
|
+
def redact_positional(rows, plan, single_column: false)
|
|
132
155
|
return rows unless rows.is_a?(Array)
|
|
133
156
|
return rows if plan[:mask].nil? && plan[:kv_rules].empty?
|
|
134
157
|
|
|
135
158
|
rows.map do |row|
|
|
136
|
-
row.is_a?(Array)
|
|
159
|
+
if !single_column && row.is_a?(Array)
|
|
160
|
+
redact_row(row, plan)
|
|
161
|
+
else
|
|
162
|
+
redact_scalar(row, plan[:mask])
|
|
163
|
+
end
|
|
137
164
|
end
|
|
138
165
|
end
|
|
139
166
|
|
|
140
167
|
def redact_row(row, plan)
|
|
141
168
|
result = apply_mask(row, plan[:mask])
|
|
142
169
|
plan[:kv_rules].each do |rule|
|
|
143
|
-
|
|
170
|
+
if rule[:mask_idxs]
|
|
171
|
+
# Ambiguous (duplicated) headers: mask every value-named cell.
|
|
172
|
+
rule[:mask_idxs].each { |idx| result[idx] = '[REDACTED]' }
|
|
173
|
+
elsif rule[:key_matches].call(row[rule[:key_idx]])
|
|
174
|
+
result[rule[:val_idx]] = '[REDACTED]'
|
|
175
|
+
end
|
|
144
176
|
end
|
|
145
177
|
result
|
|
146
178
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'adapter_family'
|
|
4
|
+
|
|
3
5
|
# Stub for environments that don't load ActiveRecord
|
|
4
6
|
unless defined?(ActiveRecord::Rollback)
|
|
5
7
|
module ActiveRecord
|
|
@@ -86,7 +88,7 @@ module Woods
|
|
|
86
88
|
# ]
|
|
87
89
|
# )
|
|
88
90
|
#
|
|
89
|
-
class SafeContext
|
|
91
|
+
class SafeContext # rubocop:disable Metrics/ClassLength
|
|
90
92
|
# Thread-local key that exposes the connection currently leased for
|
|
91
93
|
# the in-flight #execute block. Handlers should prefer this over
|
|
92
94
|
# acquiring their own connection so every request stays on a single
|
|
@@ -183,6 +185,36 @@ module Woods
|
|
|
183
185
|
apply_key_value_redaction(redacted)
|
|
184
186
|
end
|
|
185
187
|
|
|
188
|
+
# Add runtime key types to a request-local redaction view. The original
|
|
189
|
+
# context and its execution policy are unchanged.
|
|
190
|
+
# @param types [Hash<String, Array<Object>>] Active Record attribute types
|
|
191
|
+
# @param raw [Boolean] whether keys are raw database cells
|
|
192
|
+
# @return [SafeContext]
|
|
193
|
+
def with_key_value_types(types, raw: false)
|
|
194
|
+
dup.tap do |context|
|
|
195
|
+
context.instance_variable_set(:@key_value_types, types)
|
|
196
|
+
context.instance_variable_set(:@raw_key_values, raw)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Match the database spelling and its application representation. If a
|
|
201
|
+
# type cannot safely transform a key, protect the value rather than
|
|
202
|
+
# returning a value whose sensitivity could not be established.
|
|
203
|
+
# @param value [Object] raw or cast EAV key
|
|
204
|
+
# @param pattern [Hash] normalized EAV policy
|
|
205
|
+
# @return [Boolean]
|
|
206
|
+
def sensitive_key?(value, pattern)
|
|
207
|
+
sensitive = pattern['sensitive_keys']
|
|
208
|
+
return true if sensitive.include?(value.to_s)
|
|
209
|
+
|
|
210
|
+
Array(@key_value_types&.fetch(pattern['key_column'], nil)).any? do |type|
|
|
211
|
+
alternate = @raw_key_values ? type.deserialize(value) : type.serialize(value)
|
|
212
|
+
sensitive.include?(alternate.to_s)
|
|
213
|
+
rescue StandardError
|
|
214
|
+
true
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
186
218
|
private
|
|
187
219
|
|
|
188
220
|
# Wrap one connection in a rolled-back transaction with timeout, and
|
|
@@ -194,8 +226,12 @@ module Woods
|
|
|
194
226
|
Thread.current[LEASED_CONNECTION_KEY] = connection
|
|
195
227
|
result = nil
|
|
196
228
|
connection.transaction do
|
|
197
|
-
set_timeout(connection)
|
|
198
|
-
|
|
229
|
+
restore_timeout = set_timeout(connection)
|
|
230
|
+
begin
|
|
231
|
+
result = yield(connection)
|
|
232
|
+
ensure
|
|
233
|
+
restore_timeout&.call
|
|
234
|
+
end
|
|
199
235
|
raise ActiveRecord::Rollback
|
|
200
236
|
end
|
|
201
237
|
result
|
|
@@ -225,7 +261,7 @@ module Woods
|
|
|
225
261
|
key_col = pattern['key_column']
|
|
226
262
|
val_col = pattern['value_column']
|
|
227
263
|
next unless hash.key?(key_col) && hash.key?(val_col)
|
|
228
|
-
next unless
|
|
264
|
+
next unless sensitive_key?(hash[key_col], pattern)
|
|
229
265
|
|
|
230
266
|
hash[val_col] = '[REDACTED]'
|
|
231
267
|
end
|
|
@@ -241,14 +277,16 @@ module Woods
|
|
|
241
277
|
# request, background job, etc.). Safe here because every #execute
|
|
242
278
|
# is wrapped in a transaction.
|
|
243
279
|
#
|
|
244
|
-
# MySQL uses `SET max_execution_time`
|
|
245
|
-
#
|
|
280
|
+
# MySQL uses session-scoped `SET max_execution_time` for SELECTs.
|
|
281
|
+
# Return a callback that restores the previous value before releasing
|
|
282
|
+
# the pooled connection; rollback alone does not reset this setting.
|
|
246
283
|
def set_timeout(connection, timeout_ms = @timeout_ms)
|
|
247
284
|
adapter = connection.adapter_name.downcase
|
|
248
|
-
if
|
|
249
|
-
connection
|
|
285
|
+
if AdapterFamily.for(connection) == :mysql
|
|
286
|
+
set_mysql_timeout(connection, timeout_ms)
|
|
250
287
|
else
|
|
251
288
|
connection.execute("SET LOCAL statement_timeout = '#{timeout_ms.to_i}ms'")
|
|
289
|
+
nil
|
|
252
290
|
end
|
|
253
291
|
rescue StandardError => e
|
|
254
292
|
# Unsupported adapter (SQLite, Trilogy on unsupported version, Oracle) —
|
|
@@ -259,6 +297,16 @@ module Woods
|
|
|
259
297
|
nil
|
|
260
298
|
end
|
|
261
299
|
|
|
300
|
+
def set_mysql_timeout(connection, timeout_ms)
|
|
301
|
+
maria = connection.respond_to?(:mariadb?) && connection.mariadb?
|
|
302
|
+
variable = maria ? 'max_statement_time' : 'max_execution_time'
|
|
303
|
+
timeout = maria ? timeout_ms.to_i / 1000.0 : timeout_ms.to_i
|
|
304
|
+
previous_value = Float(connection.select_value("SELECT @@SESSION.#{variable}"))
|
|
305
|
+
connection.execute("SET #{variable} = #{timeout}")
|
|
306
|
+
previous_value = previous_value.to_i unless maria
|
|
307
|
+
-> { connection.execute("SET #{variable} = #{previous_value}") }
|
|
308
|
+
end
|
|
309
|
+
|
|
262
310
|
def warn_timeout_unsupported(adapter, error)
|
|
263
311
|
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
264
312
|
|
data/lib/woods/console/server.rb
CHANGED
|
@@ -176,7 +176,7 @@ module Woods
|
|
|
176
176
|
# @param unsafe_eval_audit_log_path [String, Pathname, nil] JSONL audit log
|
|
177
177
|
# path for `console_eval`. Required when the opt-in is on.
|
|
178
178
|
# @return [MCP::Server] Configured server ready for transport
|
|
179
|
-
def build_embedded(model_validator:, safe_context:, redacted_columns: [], # rubocop:disable Metrics/ParameterLists
|
|
179
|
+
def build_embedded(model_validator:, safe_context:, redacted_columns: [], # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
|
|
180
180
|
redacted_key_values: [], connection: nil,
|
|
181
181
|
read_tools_enabled: false, model_tables: {},
|
|
182
182
|
model_reflections: {},
|
|
@@ -201,6 +201,7 @@ module Woods
|
|
|
201
201
|
table_gate = ctx&.table_gate
|
|
202
202
|
executor = EmbeddedExecutor.new(
|
|
203
203
|
model_validator: model_validator, safe_context: safe_context,
|
|
204
|
+
redaction_context: safe_ctx,
|
|
204
205
|
connection: connection, read_tools_enabled: read_tools_enabled,
|
|
205
206
|
table_gate: table_gate,
|
|
206
207
|
eval_guard: eval_wiring[:eval_guard],
|
|
@@ -30,8 +30,8 @@ module Woods
|
|
|
30
30
|
# newline-separated statement structure is preserved for callers that
|
|
31
31
|
# check for multiple statements.
|
|
32
32
|
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
33
|
+
# This legacy helper is not a security scanner. Use strip_noise for
|
|
34
|
+
# quote-aware and PostgreSQL nested-comment handling.
|
|
35
35
|
#
|
|
36
36
|
# @param sql [String] the SQL string to process
|
|
37
37
|
# @return [String] a new string with all SQL comments removed
|
|
@@ -59,9 +59,8 @@ module Woods
|
|
|
59
59
|
# Dollar-quoted strings (`$$...$$`, `$tag$...$tag$`) are also stripped.
|
|
60
60
|
# - `:mysql` — single-quoted strings support both `\'` (backslash-escape)
|
|
61
61
|
# and `''` (doubled-quote) as apostrophe escapes. Dollar-quoted strings
|
|
62
|
-
# are
|
|
63
|
-
#
|
|
64
|
-
# - `:sqlite` — doubled apostrophes escape strings; dollar signs stay literal.
|
|
62
|
+
# are not recognized by the combined MySQL security scanner.
|
|
63
|
+
# - `:sqlite` — doubled apostrophes escape strings; backslashes and dollar signs are literal.
|
|
65
64
|
# @return [String] a new string with all string literals replaced by `''`
|
|
66
65
|
# @raise [ArgumentError] if an unsupported dialect is provided
|
|
67
66
|
DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
|
|
@@ -98,13 +97,30 @@ module Woods
|
|
|
98
97
|
# never under-detect: an unterminated literal is treated as an ordinary
|
|
99
98
|
# character rather than swallowing the rest of the statement.
|
|
100
99
|
#
|
|
100
|
+
# `#` opens a MySQL line comment, mirroring `--`, but only under the
|
|
101
|
+
# `:mysql` dialect — PostgreSQL does not treat `#` as a comment, and
|
|
102
|
+
# collapsing it there would hide SQL that a real PostgreSQL server
|
|
103
|
+
# still executes. A MySQL `/*! ... */` executable comment is left
|
|
104
|
+
# visible as one span, including MariaDB's `/*M! ... */` spelling:
|
|
105
|
+
# its body can execute, so it must stay visible to every downstream
|
|
106
|
+
# scan without changing literal state outside the span. Leaving it
|
|
107
|
+
# visible under `:postgres` too is over-detection at
|
|
108
|
+
# worst, never under-detection, on a server where it really is inert.
|
|
109
|
+
# An ordinary `/* ... */` block comment is replaced by a single
|
|
110
|
+
# newline rather than vanishing outright, mirroring how a `--`/`#`
|
|
111
|
+
# line comment's own trailing newline survives: SqlValidator's
|
|
112
|
+
# statement-leader scan needs a durable marker showing a comment sat
|
|
113
|
+
# here so a comment-hidden statement (`SELECT 1 /*;*/ DELETE ...`)
|
|
114
|
+
# still reads as following a boundary once comments are gone.
|
|
115
|
+
#
|
|
101
116
|
# @param sql [String] the SQL string to process
|
|
102
117
|
# @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
|
|
103
|
-
# single-quote escape rules (see {.strip_literals})
|
|
118
|
+
# single-quote escape rules (see {.strip_literals}) and whether `#`
|
|
119
|
+
# opens a line comment. MySQL quote flags reflect session sql_mode.
|
|
104
120
|
# @return [String] a new string with comments removed and every string
|
|
105
121
|
# literal replaced by `''`
|
|
106
122
|
# @raise [ArgumentError] if an unsupported dialect is provided
|
|
107
|
-
def self.strip_noise(sql, dialect: :postgres) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
|
|
123
|
+
def self.strip_noise(sql, dialect: :postgres, ansi_quotes: false, no_backslash_escapes: false) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
|
|
108
124
|
unless SUPPORTED_DIALECTS.include?(dialect)
|
|
109
125
|
raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{SUPPORTED_DIALECTS.inspect}"
|
|
110
126
|
end
|
|
@@ -118,7 +134,11 @@ module Woods
|
|
|
118
134
|
ch = sql[i]
|
|
119
135
|
|
|
120
136
|
if ch == "'"
|
|
121
|
-
close = single_quote_end(
|
|
137
|
+
close = single_quote_end(
|
|
138
|
+
sql, i,
|
|
139
|
+
backslash_escapes: (mysql && !no_backslash_escapes) ||
|
|
140
|
+
(dialect == :postgres && postgres_escape_string?(sql, i))
|
|
141
|
+
)
|
|
122
142
|
if close
|
|
123
143
|
out << "''"
|
|
124
144
|
i = close
|
|
@@ -127,16 +147,33 @@ module Woods
|
|
|
127
147
|
out << ch
|
|
128
148
|
i += 1
|
|
129
149
|
end
|
|
130
|
-
elsif
|
|
131
|
-
close =
|
|
150
|
+
elsif ch == '"'
|
|
151
|
+
close = quoted_span_end(sql, i, quote: '"',
|
|
152
|
+
backslash_escapes: mysql && !ansi_quotes && !no_backslash_escapes)
|
|
132
153
|
if close
|
|
154
|
+
# MySQL parses double quotes as strings unless ANSI_QUOTES is
|
|
155
|
+
# enabled. Treating them as literals prevents a `#` inside the
|
|
156
|
+
# value from hiding live SQL. PostgreSQL uses them for
|
|
157
|
+
# identifiers, which must remain visible to table/column scans.
|
|
158
|
+
out << double_quote_replacement(sql, i, close, mysql: mysql && !ansi_quotes)
|
|
159
|
+
i = close
|
|
160
|
+
else
|
|
161
|
+
out << ch
|
|
162
|
+
i += 1
|
|
163
|
+
end
|
|
164
|
+
elsif (mysql || dialect == :sqlite) && ch == '`'
|
|
165
|
+
close = quoted_span_end(sql, i, quote: '`', backslash_escapes: false)
|
|
166
|
+
if close
|
|
167
|
+
# Backticks delimit identifiers. Preserve the token for table
|
|
168
|
+
# and protected-column scans while shielding comment markers
|
|
169
|
+
# inside it from the noise scanner.
|
|
133
170
|
out << sql[i...close]
|
|
134
171
|
i = close
|
|
135
172
|
else
|
|
136
173
|
out << ch
|
|
137
174
|
i += 1
|
|
138
175
|
end
|
|
139
|
-
elsif dialect
|
|
176
|
+
elsif dialect == :postgres && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
|
|
140
177
|
close = sql.index(tag, i + tag.length)
|
|
141
178
|
if close
|
|
142
179
|
out << "''"
|
|
@@ -145,14 +182,33 @@ module Woods
|
|
|
145
182
|
out << ch
|
|
146
183
|
i += 1
|
|
147
184
|
end
|
|
148
|
-
elsif
|
|
185
|
+
elsif dash_comment?(sql, i, mysql: mysql) || (mysql && ch == '#')
|
|
149
186
|
nl = sql.index("\n", i)
|
|
150
187
|
i = nl || len
|
|
188
|
+
elsif sql[i, 3] == '/*!' || sql[i, 4] == '/*M!'
|
|
189
|
+
close = block_comment_end(sql, i, nested: dialect == :postgres)
|
|
190
|
+
if close
|
|
191
|
+
comment = sql[i...close]
|
|
192
|
+
yield comment if block_given?
|
|
193
|
+
# An executable comment is a lexical boundary even when its
|
|
194
|
+
# version guard makes its body inert. A quote in that body must
|
|
195
|
+
# never change the scanner's state outside the comment.
|
|
196
|
+
out << comment
|
|
197
|
+
i = close
|
|
198
|
+
else
|
|
199
|
+
out << ch
|
|
200
|
+
i += 1
|
|
201
|
+
end
|
|
151
202
|
elsif ch == '/' && sql[i + 1] == '*'
|
|
152
|
-
close = sql
|
|
203
|
+
close = block_comment_end(sql, i, nested: dialect == :postgres)
|
|
153
204
|
if close
|
|
154
|
-
|
|
155
|
-
|
|
205
|
+
# Preserve a newline in place of the removed comment, mirroring
|
|
206
|
+
# line comments (see class docs): callers that check for
|
|
207
|
+
# statement structure (SqlValidator's statement-leader scan)
|
|
208
|
+
# need a survivable marker showing a comment sat here, the
|
|
209
|
+
# same way a `--`/`#` comment's own trailing newline does.
|
|
210
|
+
out << "\n"
|
|
211
|
+
i = close
|
|
156
212
|
else
|
|
157
213
|
# Unterminated block comment: never under-detect. Leave it in
|
|
158
214
|
# place (over-detection is safe; the old regex also required a
|
|
@@ -169,26 +225,42 @@ module Woods
|
|
|
169
225
|
out
|
|
170
226
|
end
|
|
171
227
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if
|
|
178
|
-
|
|
228
|
+
def self.block_comment_end(sql, start, nested:)
|
|
229
|
+
depth = 1
|
|
230
|
+
position = start + 2
|
|
231
|
+
while (match = %r{/\*|\*/}.match(sql, position))
|
|
232
|
+
depth += 1 if nested && match[0] == '/*'
|
|
233
|
+
depth -= 1 if match[0] == '*/'
|
|
234
|
+
return match.end(0) if depth.zero?
|
|
179
235
|
|
|
180
|
-
|
|
181
|
-
else
|
|
182
|
-
i += 1
|
|
183
|
-
end
|
|
236
|
+
position = match.end(0)
|
|
184
237
|
end
|
|
185
238
|
nil
|
|
186
239
|
end
|
|
187
|
-
private_class_method :
|
|
240
|
+
private_class_method :block_comment_end
|
|
241
|
+
|
|
242
|
+
# Session modes change MySQL quoting without changing the adapter name.
|
|
243
|
+
# Security consumers must reject SQL unsafe under any supported combination.
|
|
244
|
+
MYSQL_QUOTE_MODES = [false, true].product([false, true]).map do |ansi, no_backslash|
|
|
245
|
+
{ ansi_quotes: ansi, no_backslash_escapes: no_backslash }.freeze
|
|
246
|
+
end.freeze
|
|
247
|
+
|
|
248
|
+
def self.security_views(sql, dialect:, mysql_modes: nil)
|
|
249
|
+
modes = dialect == :mysql && mysql_modes.nil? ? MYSQL_QUOTE_MODES : [mysql_modes || {}]
|
|
250
|
+
modes.map { |mode| strip_noise(sql, dialect: dialect, **mode) }.uniq
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# MySQL requires whitespace/control after --; otherwise it is subtraction.
|
|
254
|
+
def self.dash_comment?(sql, index, mysql:)
|
|
255
|
+
return false unless sql[index, 2] == '--'
|
|
256
|
+
|
|
257
|
+
!mysql || sql[index + 2].nil? || sql[index + 2].match?(/[[:space:][:cntrl:]]/)
|
|
258
|
+
end
|
|
259
|
+
private_class_method :dash_comment?
|
|
188
260
|
|
|
189
261
|
# Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
|
|
190
262
|
# `$tag$`) at the start of the given slice.
|
|
191
|
-
DOLLAR_TAG = /\A
|
|
263
|
+
DOLLAR_TAG = /\A\$(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_\u0080-\u{10ffff}]*)?\$/u
|
|
192
264
|
private_constant :DOLLAR_TAG
|
|
193
265
|
|
|
194
266
|
# Return the dollar-quote tag opening at +index+, or nil.
|
|
@@ -200,29 +272,69 @@ module Woods
|
|
|
200
272
|
end
|
|
201
273
|
private_class_method :dollar_tag_at
|
|
202
274
|
|
|
275
|
+
# Whether the character immediately before +index+ is a word character
|
|
276
|
+
# (including high-bit characters). PostgreSQL allows `$` inside identifiers (`x$a$`), so a `$`
|
|
277
|
+
# is only a candidate dollar-quote opener when it does NOT follow an
|
|
278
|
+
# identifier character — otherwise `x$a$ FROM blocked, (SELECT 1 AS
|
|
279
|
+
# z$a$)` gets misread as one dollar-quoted literal spanning the real
|
|
280
|
+
# FROM clause.
|
|
281
|
+
#
|
|
282
|
+
# @api private
|
|
283
|
+
def self.preceded_by_word_char?(sql, index)
|
|
284
|
+
index.positive? && sql[index - 1].match?(/[A-Za-z0-9_$\u0080-\u{10ffff}]/u)
|
|
285
|
+
end
|
|
286
|
+
private_class_method :preceded_by_word_char?
|
|
287
|
+
|
|
203
288
|
# Return the index just past the closing quote of the single-quoted
|
|
204
289
|
# literal that opens at +start+, honoring `''` (both dialects) and `\'`
|
|
205
290
|
# (MySQL only) escapes. Returns nil when the literal is unterminated.
|
|
206
291
|
#
|
|
207
292
|
# @api private
|
|
208
|
-
def self.single_quote_end(sql, start,
|
|
293
|
+
def self.single_quote_end(sql, start, backslash_escapes:)
|
|
294
|
+
quoted_span_end(sql, start, quote: "'", backslash_escapes: backslash_escapes)
|
|
295
|
+
end
|
|
296
|
+
private_class_method :single_quote_end
|
|
297
|
+
|
|
298
|
+
def self.double_quote_replacement(sql, start, close, mysql:)
|
|
299
|
+
mysql ? "''" : sql[start...close]
|
|
300
|
+
end
|
|
301
|
+
private_class_method :double_quote_replacement
|
|
302
|
+
|
|
303
|
+
# Return the index just past a quoted span. Doubled delimiters escape
|
|
304
|
+
# themselves in every supported dialect; MySQL strings/identifiers and
|
|
305
|
+
# PostgreSQL E-strings additionally honor backslash escapes.
|
|
306
|
+
#
|
|
307
|
+
# @api private
|
|
308
|
+
def self.quoted_span_end(sql, start, quote:, backslash_escapes:)
|
|
209
309
|
i = start + 1
|
|
210
310
|
len = sql.length
|
|
211
311
|
while i < len
|
|
212
312
|
c = sql[i]
|
|
213
|
-
if
|
|
313
|
+
if backslash_escapes && c == '\\'
|
|
214
314
|
i += 2
|
|
215
|
-
elsif c ==
|
|
216
|
-
return i + 1 unless sql[i + 1] ==
|
|
315
|
+
elsif c == quote
|
|
316
|
+
return i + 1 unless sql[i + 1] == quote # closing delimiter
|
|
217
317
|
|
|
218
|
-
i += 2 # doubled-
|
|
318
|
+
i += 2 # doubled-delimiter escape — span continues
|
|
219
319
|
else
|
|
220
320
|
i += 1
|
|
221
321
|
end
|
|
222
322
|
end
|
|
223
323
|
nil
|
|
224
324
|
end
|
|
225
|
-
private_class_method :
|
|
226
|
-
|
|
325
|
+
private_class_method :quoted_span_end
|
|
326
|
+
|
|
327
|
+
# PostgreSQL E'...' strings opt into C-style backslash escapes. The E
|
|
328
|
+
# must begin a token; an identifier ending in e immediately before a
|
|
329
|
+
# quote is not an escape-string prefix.
|
|
330
|
+
#
|
|
331
|
+
# @api private
|
|
332
|
+
def self.postgres_escape_string?(sql, quote_index)
|
|
333
|
+
return false unless quote_index.positive? && sql[quote_index - 1].match?(/[eE]/)
|
|
334
|
+
|
|
335
|
+
quote_index < 2 || !sql[quote_index - 2].match?(/[A-Za-z0-9_$\u0080-\u{10ffff}]/u)
|
|
336
|
+
end
|
|
337
|
+
private_class_method :postgres_escape_string?
|
|
338
|
+
end # rubocop:enable Metrics/ModuleLength
|
|
227
339
|
end
|
|
228
340
|
end
|