woods 1.6.2 → 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.
@@ -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
- result = yield(connection)
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 pattern['sensitive_keys'].include?(hash[key_col].to_s)
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` (applies to SELECT only — DDL
245
- # and DML statements cannot be time-limited via this variable).
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 adapter.include?('mysql')
249
- connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
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
 
@@ -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],
@@ -24,14 +24,14 @@ module Woods
24
24
  # SqlNoiseStripper.strip_literals("SELECT 'it\\'s ok' FROM t", dialect: :mysql)
25
25
  # # => "SELECT '' FROM t"
26
26
  #
27
- module SqlNoiseStripper
27
+ module SqlNoiseStripper # rubocop:disable Metrics/ModuleLength
28
28
  # Strips SQL line comments (`-- ...`) and block comments (`/* ... */`).
29
29
  # Line comments are stripped to (but not including) the newline so that
30
30
  # newline-separated statement structure is preserved for callers that
31
31
  # check for multiple statements.
32
32
  #
33
- # Block comments are non-nested — real SQL engines do not support nested
34
- # block comments, and neither does this stripper.
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
@@ -40,7 +40,7 @@ module Woods
40
40
 
41
41
  def self.strip_comments(sql)
42
42
  out = sql.gsub(LINE_COMMENT, '')
43
- out.gsub(BLOCK_COMMENT, '')
43
+ out.gsub(BLOCK_COMMENT, ' ')
44
44
  end
45
45
 
46
46
  # Strips single-quoted string literals and (for the `:postgres` dialect)
@@ -53,21 +53,21 @@ module Woods
53
53
  # single-quote scanner.
54
54
  #
55
55
  # @param sql [String] the SQL string to process
56
- # @param dialect [Symbol] `:postgres` (default) or `:mysql`.
56
+ # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
57
57
  # - `:postgres` — single-quoted strings support `''` as an apostrophe
58
58
  # escape. Backslash is treated literally and does not escape quotes.
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 also stripped (MySQL does not use them, but stripping them is
63
- # harmless and keeps the two dialects consistent).
62
+ # are not recognized by the combined MySQL security scanner.
63
+ # - `:sqlite` — doubled apostrophes escape strings; backslashes and dollar signs are literal.
64
64
  # @return [String] a new string with all string literals replaced by `''`
65
65
  # @raise [ArgumentError] if an unsupported dialect is provided
66
66
  DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
67
67
  SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
68
68
  SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
69
69
 
70
- SUPPORTED_DIALECTS = %i[postgres mysql].freeze
70
+ SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
71
71
  private_constant :SUPPORTED_DIALECTS
72
72
 
73
73
  def self.strip_literals(sql, dialect: :postgres)
@@ -77,7 +77,7 @@ module Woods
77
77
 
78
78
  # Strip dollar-quoted strings first so stray apostrophes inside them
79
79
  # do not interfere with the single-quote scanner.
80
- out = sql.gsub(DOLLAR_QUOTED, "''")
80
+ out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
81
81
 
82
82
  pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
83
83
  out.gsub(pattern, "''")
@@ -97,13 +97,30 @@ module Woods
97
97
  # never under-detect: an unterminated literal is treated as an ordinary
98
98
  # character rather than swallowing the rest of the statement.
99
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
+ #
100
116
  # @param sql [String] the SQL string to process
101
- # @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
102
- # single-quote escape rules (see {.strip_literals}).
117
+ # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
118
+ # single-quote escape rules (see {.strip_literals}) and whether `#`
119
+ # opens a line comment. MySQL quote flags reflect session sql_mode.
103
120
  # @return [String] a new string with comments removed and every string
104
121
  # literal replaced by `''`
105
122
  # @raise [ArgumentError] if an unsupported dialect is provided
106
- 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
107
124
  unless SUPPORTED_DIALECTS.include?(dialect)
108
125
  raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{SUPPORTED_DIALECTS.inspect}"
109
126
  end
@@ -117,7 +134,11 @@ module Woods
117
134
  ch = sql[i]
118
135
 
119
136
  if ch == "'"
120
- close = single_quote_end(sql, i, mysql: mysql)
137
+ close = single_quote_end(
138
+ sql, i,
139
+ backslash_escapes: (mysql && !no_backslash_escapes) ||
140
+ (dialect == :postgres && postgres_escape_string?(sql, i))
141
+ )
121
142
  if close
122
143
  out << "''"
123
144
  i = close
@@ -126,7 +147,33 @@ module Woods
126
147
  out << ch
127
148
  i += 1
128
149
  end
129
- elsif ch == '$' && (tag = dollar_tag_at(sql, i))
150
+ elsif ch == '"'
151
+ close = quoted_span_end(sql, i, quote: '"',
152
+ backslash_escapes: mysql && !ansi_quotes && !no_backslash_escapes)
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.
170
+ out << sql[i...close]
171
+ i = close
172
+ else
173
+ out << ch
174
+ i += 1
175
+ end
176
+ elsif dialect == :postgres && ch == '$' && !preceded_by_word_char?(sql, i) && (tag = dollar_tag_at(sql, i))
130
177
  close = sql.index(tag, i + tag.length)
131
178
  if close
132
179
  out << "''"
@@ -135,13 +182,33 @@ module Woods
135
182
  out << ch
136
183
  i += 1
137
184
  end
138
- elsif ch == '-' && sql[i + 1] == '-'
185
+ elsif dash_comment?(sql, i, mysql: mysql) || (mysql && ch == '#')
139
186
  nl = sql.index("\n", i)
140
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
141
202
  elsif ch == '/' && sql[i + 1] == '*'
142
- close = sql.index('*/', i + 2)
203
+ close = block_comment_end(sql, i, nested: dialect == :postgres)
143
204
  if close
144
- i = close + 2
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
145
212
  else
146
213
  # Unterminated block comment: never under-detect. Leave it in
147
214
  # place (over-detection is safe; the old regex also required a
@@ -158,9 +225,42 @@ module Woods
158
225
  out
159
226
  end
160
227
 
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?
235
+
236
+ position = match.end(0)
237
+ end
238
+ nil
239
+ end
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?
260
+
161
261
  # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
162
262
  # `$tag$`) at the start of the given slice.
163
- DOLLAR_TAG = /\A\$\w*\$/
263
+ DOLLAR_TAG = /\A\$(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_\u0080-\u{10ffff}]*)?\$/u
164
264
  private_constant :DOLLAR_TAG
165
265
 
166
266
  # Return the dollar-quote tag opening at +index+, or nil.
@@ -172,29 +272,69 @@ module Woods
172
272
  end
173
273
  private_class_method :dollar_tag_at
174
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
+
175
288
  # Return the index just past the closing quote of the single-quoted
176
289
  # literal that opens at +start+, honoring `''` (both dialects) and `\'`
177
290
  # (MySQL only) escapes. Returns nil when the literal is unterminated.
178
291
  #
179
292
  # @api private
180
- def self.single_quote_end(sql, start, mysql:)
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:)
181
309
  i = start + 1
182
310
  len = sql.length
183
311
  while i < len
184
312
  c = sql[i]
185
- if mysql && c == '\\'
313
+ if backslash_escapes && c == '\\'
186
314
  i += 2
187
- elsif c == "'"
188
- return i + 1 unless sql[i + 1] == "'" # closing quote
315
+ elsif c == quote
316
+ return i + 1 unless sql[i + 1] == quote # closing delimiter
189
317
 
190
- i += 2 # doubled-quote escape — literal continues
318
+ i += 2 # doubled-delimiter escape — span continues
191
319
  else
192
320
  i += 1
193
321
  end
194
322
  end
195
323
  nil
196
324
  end
197
- private_class_method :single_quote_end
198
- end
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
199
339
  end
200
340
  end