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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/CONTRIBUTING.md +19 -11
- 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/credential_scanner.rb +2 -1
- data/lib/woods/console/dispatch_pipeline.rb +22 -4
- data/lib/woods/console/embedded_executor.rb +139 -31
- 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 +165 -25
- data/lib/woods/console/sql_output_policy.rb +535 -0
- data/lib/woods/console/sql_table_scanner.rb +163 -52
- data/lib/woods/console/sql_validator.rb +70 -10
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/table_gate.rb +2 -2
- 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 +9 -5
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Console
|
|
5
|
+
# Preserve protected column identity before legacy Console reads execute.
|
|
6
|
+
# This policy supplements the existing output redactor without changing
|
|
7
|
+
# the legacy tool surface or its explicitly opted-in mutation controls.
|
|
8
|
+
module SqlOutputPolicy # rubocop:disable Metrics/ModuleLength -- one policy keeps output identity and EAV provenance together
|
|
9
|
+
SELECT_EXPRESSION = /\A\s*
|
|
10
|
+
(?:(SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(\*|\w+(?:\.\w+)?)\s*\)|(\w+(?:\.\w+)?))
|
|
11
|
+
(?:\s+AS\s+(\w+))?\s*\z/ix
|
|
12
|
+
private_constant :SELECT_EXPRESSION
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def redaction_columns
|
|
17
|
+
@redaction_context.respond_to?(:redacted_columns) ? @redaction_context.redacted_columns : []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def redaction_key_values
|
|
21
|
+
@redaction_context.respond_to?(:redacted_key_values) ? @redaction_context.redacted_key_values : []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def refuse_redacted_column!(column)
|
|
25
|
+
return unless redaction_columns.any? { |name| name.to_s.casecmp?(column.to_s) }
|
|
26
|
+
|
|
27
|
+
raise ValidationError,
|
|
28
|
+
"Rejected: column '#{column}' is redacted (console_redacted_columns) and cannot be used " \
|
|
29
|
+
'as a scope, filter, aggregate, find, or order key.'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def refuse_protected_predicate_column!(column)
|
|
33
|
+
base = base_column_name(column.to_s)
|
|
34
|
+
return refuse_redacted_column!(base) if redaction_columns.any? { |name| name.to_s.casecmp?(base) }
|
|
35
|
+
return unless redacted_eav_value_columns.any? { |name| name.to_s.casecmp?(base) }
|
|
36
|
+
|
|
37
|
+
raise ValidationError,
|
|
38
|
+
"Rejected: EAV value column '#{base}' is redacted (console_redacted_key_values) and cannot " \
|
|
39
|
+
'be used as a scope, filter, or having predicate.'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def redacted_eav_value_columns
|
|
43
|
+
redaction_key_values.filter_map { |pattern| pattern['value_column'] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Keep legacy scope syntax while refusing protected values as inputs.
|
|
47
|
+
# The same check runs before every model read, including association
|
|
48
|
+
# scopes, so filtering cannot disclose a value hidden by the renderer.
|
|
49
|
+
def refuse_protected_scope!(scope)
|
|
50
|
+
case scope
|
|
51
|
+
when Hash
|
|
52
|
+
scope.each do |key, value|
|
|
53
|
+
column = key.to_s.sub(ScopePredicateParser::SUFFIX_PATTERN, '')
|
|
54
|
+
refuse_protected_predicate_column!(column)
|
|
55
|
+
refuse_protected_scope!(value) if value.is_a?(Hash)
|
|
56
|
+
end
|
|
57
|
+
when Array
|
|
58
|
+
refuse_protected_predicate_references!(scope.first) if scope.first.is_a?(String)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def refuse_orphan_eav_value_selection!(expressions, model_name = nil)
|
|
63
|
+
selected = directly_selected_columns(expressions)
|
|
64
|
+
|
|
65
|
+
redaction_key_values.each do |pattern|
|
|
66
|
+
values = selected.select { |column| base_column_name(column).casecmp?(pattern['value_column']) }
|
|
67
|
+
values.select! { |column| eav_source_has_key?(column, pattern, model_name) }
|
|
68
|
+
next if values.empty?
|
|
69
|
+
|
|
70
|
+
keys = selected.select { |column| base_column_name(column).casecmp?(pattern['key_column']) }
|
|
71
|
+
next if paired_eav_sources?(keys, values)
|
|
72
|
+
|
|
73
|
+
raise ValidationError,
|
|
74
|
+
"Rejected: selecting EAV value column '#{pattern['value_column']}' without its paired " \
|
|
75
|
+
"key column '#{pattern['key_column']}' bypasses redaction; select both columns so the " \
|
|
76
|
+
'value can be masked. Both columns must have the same unambiguous source.'
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def eav_source_has_key?(column, pattern, model_name)
|
|
81
|
+
columns = if column.include?('.')
|
|
82
|
+
@model_validator.columns_for_table(column.split('.')[0...-1].join('.'))
|
|
83
|
+
elsif model_name
|
|
84
|
+
@model_validator.columns_for(model_name)
|
|
85
|
+
end
|
|
86
|
+
columns.nil? || columns.include?(pattern['key_column'])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def paired_eav_sources?(keys, values)
|
|
90
|
+
return false unless keys.one? && values.one?
|
|
91
|
+
|
|
92
|
+
keys.first.split('.')[0...-1] == values.first.split('.')[0...-1]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def directly_selected_columns(expressions)
|
|
96
|
+
expressions.filter_map do |expr|
|
|
97
|
+
match = SELECT_EXPRESSION.match(expr)
|
|
98
|
+
next unless match
|
|
99
|
+
|
|
100
|
+
fn_arg, bare_col, alias_name = match.captures[1..]
|
|
101
|
+
next if fn_arg || alias_name
|
|
102
|
+
|
|
103
|
+
bare_col
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def refuse_redacted_select_shapes!(captures, model_name)
|
|
108
|
+
fn, fn_arg, bare_col, alias_name = captures
|
|
109
|
+
column = bare_col || fn_arg
|
|
110
|
+
validate_column_reference!(column, model_name) unless column == '*'
|
|
111
|
+
|
|
112
|
+
refuse_protected_alias_target!(alias_name) if alias_name
|
|
113
|
+
refuse_redacted_select_alias!(bare_col, alias_name) if alias_name
|
|
114
|
+
refuse_redacted_aggregate_expression!(fn_arg) if fn
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def refuse_protected_alias_target!(alias_name)
|
|
118
|
+
return unless protected_column_name?(alias_name)
|
|
119
|
+
|
|
120
|
+
raise ValidationError,
|
|
121
|
+
"Rejected: alias '#{alias_name}' names a protected output header; an alias must not " \
|
|
122
|
+
'collide with a redacted or EAV column name.'
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def protected_column_name?(name)
|
|
126
|
+
casecmp_member?(redaction_columns, name) ||
|
|
127
|
+
casecmp_member?(redacted_kv_columns, name)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def casecmp_member?(list, name)
|
|
131
|
+
list.any? { |entry| entry.to_s.casecmp?(name.to_s) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def refuse_redacted_select_alias!(column, alias_name)
|
|
135
|
+
return unless column
|
|
136
|
+
|
|
137
|
+
base = base_column_name(column)
|
|
138
|
+
if casecmp_member?(redaction_columns, base)
|
|
139
|
+
raise ValidationError,
|
|
140
|
+
"Rejected: aliasing redacted column '#{base}' as '#{alias_name}' bypasses output " \
|
|
141
|
+
'redaction. Select it unaliased; the value is masked.'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
return unless casecmp_member?(redacted_kv_columns, base)
|
|
145
|
+
|
|
146
|
+
raise ValidationError,
|
|
147
|
+
"Rejected: aliasing redacted key/value column '#{base}' as '#{alias_name}' bypasses " \
|
|
148
|
+
'EAV output redaction. Select it unaliased.'
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def refuse_redacted_aggregate_expression!(column)
|
|
152
|
+
return if column.nil? || column == '*'
|
|
153
|
+
|
|
154
|
+
base = base_column_name(column)
|
|
155
|
+
if casecmp_member?(redaction_columns, base)
|
|
156
|
+
raise ValidationError,
|
|
157
|
+
"Rejected: aggregating redacted column '#{base}' reads its value; it cannot be used " \
|
|
158
|
+
'as an aggregate input.'
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return unless casecmp_member?(redacted_kv_columns, base)
|
|
162
|
+
|
|
163
|
+
raise ValidationError,
|
|
164
|
+
"Rejected: aggregating redacted key/value column '#{base}' reads its value; it cannot " \
|
|
165
|
+
'be used as an aggregate input.'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def redacted_kv_columns
|
|
169
|
+
redaction_key_values
|
|
170
|
+
.flat_map { |pattern| [pattern['key_column'], pattern['value_column']] }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def base_column_name(column)
|
|
174
|
+
column.split('.').last
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_protected_sql_usage!(sql)
|
|
178
|
+
# This exact wrapper only preserves the inner statement's output
|
|
179
|
+
# headers. SqlValidator has already checked balanced delimiters.
|
|
180
|
+
limited = /\ASELECT \* FROM \(\n(.*)\n\) AS _limited LIMIT \d+\z/m.match(sql)
|
|
181
|
+
if limited
|
|
182
|
+
SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(limited[1])
|
|
183
|
+
return validate_protected_sql_usage!(limited[1])
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
sql_security_views(sql).each { |stripped| validate_protected_sql_view!(stripped) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_protected_sql_view!(stripped)
|
|
190
|
+
refuse_sql_column_alias_lists!(stripped)
|
|
191
|
+
refuse_composite_sql_projection!(stripped)
|
|
192
|
+
refuse_ambiguous_eav_sql!(stripped)
|
|
193
|
+
refuse_protected_sql_positions!(stripped)
|
|
194
|
+
protected = (redaction_columns + redacted_kv_columns).uniq
|
|
195
|
+
referenced = protected.select { |column| sql_identifier_referenced?(stripped, column) }
|
|
196
|
+
return if referenced.empty?
|
|
197
|
+
|
|
198
|
+
expressions, tail = protected_sql_projection(stripped)
|
|
199
|
+
selected = expressions.filter_map { |expression| direct_sql_column_name(expression) }
|
|
200
|
+
unsafe = unsafe_protected_sql_column(referenced, expressions, selected, tail, stripped)
|
|
201
|
+
return unless unsafe
|
|
202
|
+
|
|
203
|
+
raise ValidationError,
|
|
204
|
+
"Rejected: console_sql uses protected column '#{unsafe}' in an alias, aggregate, predicate, or " \
|
|
205
|
+
'unpaired EAV shape that cannot preserve redaction identity. Select protected columns directly ' \
|
|
206
|
+
'and unaliased, or use a structured Console tool.'
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Relation/CTE column lists rename fields by position, before output
|
|
210
|
+
# headers are available. Refuse them whenever either policy is active,
|
|
211
|
+
# independently of protected-name references or function validation.
|
|
212
|
+
def refuse_sql_column_alias_lists!(stripped)
|
|
213
|
+
return if redaction_columns.empty? && redacted_kv_columns.empty?
|
|
214
|
+
|
|
215
|
+
identifier = /(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_$\u0080-\u{10ffff}]*|"(?:""|[^"])+"|`(?:``|[^`])+`)/u
|
|
216
|
+
list = /#{identifier}\s*\(\s*#{identifier}(?:\s*,\s*#{identifier})*\s*\)/
|
|
217
|
+
cte = /#{list}\s*AS\s*(?:(?:NOT\s+)?MATERIALIZED\s*)?\(/i
|
|
218
|
+
relation = /\A\s*(?:AS\s+)?#{list}/i
|
|
219
|
+
renamed = stripped.match?(cte) || SqlTableScanner.relation_factors(stripped).any? do |factor|
|
|
220
|
+
sql_relation_alias_tail(factor).match?(relation)
|
|
221
|
+
end
|
|
222
|
+
return unless renamed
|
|
223
|
+
|
|
224
|
+
raise ValidationError,
|
|
225
|
+
'Rejected: SQL relation or CTE column alias lists cannot preserve redaction identity. ' \
|
|
226
|
+
'Select protected columns directly and unaliased, or use a structured Console tool.'
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Consume only the leading source, leaving its optional alias. Balanced
|
|
230
|
+
# sources include subqueries, ONLY(table), and table-function arguments;
|
|
231
|
+
# scalar functions elsewhere in the clause are not alias declarations.
|
|
232
|
+
def sql_relation_alias_tail(factor)
|
|
233
|
+
tokens = sql_policy_tokens(factor)
|
|
234
|
+
index = %w[ONLY LATERAL].include?(sql_token_text(tokens, 0)) ? 1 : 0
|
|
235
|
+
unless sql_token_text(tokens, index) == '('
|
|
236
|
+
index += 1
|
|
237
|
+
index += 2 while sql_token_text(tokens, index) == '.'
|
|
238
|
+
end
|
|
239
|
+
index = sql_after_parentheses(tokens, index) if sql_token_text(tokens, index) == '('
|
|
240
|
+
index += 1 if sql_token_text(tokens, index) == '*'
|
|
241
|
+
tokens[index] ? factor[tokens[index][:start]..] : ''
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def sql_after_parentheses(tokens, index)
|
|
245
|
+
depth = tokens[index][:depth]
|
|
246
|
+
closing = ((index + 1)...tokens.length).find do |offset|
|
|
247
|
+
tokens[offset][:text] == ')' && tokens[offset][:depth] == depth
|
|
248
|
+
end
|
|
249
|
+
closing ? closing + 1 : tokens.length
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Result metadata provides column names, not their table provenance.
|
|
253
|
+
# A joined/derived EAV row can supply a key from a different source.
|
|
254
|
+
# Keep direct single-source SQL reads and qualified structured joins;
|
|
255
|
+
# refuse ambiguous SQL shapes before fetching their values.
|
|
256
|
+
def refuse_ambiguous_eav_sql!(stripped)
|
|
257
|
+
return if sql_eav_patterns(stripped).empty?
|
|
258
|
+
return if SqlTableScanner.relation_factors(stripped).size <= 1
|
|
259
|
+
return unless stripped.include?('*') || redacted_eav_value_columns.any? do |column|
|
|
260
|
+
sql_identifier_referenced?(stripped, column)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
raise ValidationError,
|
|
264
|
+
'Rejected: SQL EAV values require one unambiguous source; use a structured Console query.'
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# PostgreSQL can return a table alias as one composite-valued column.
|
|
268
|
+
# Its header carries no identities for the protected fields inside it.
|
|
269
|
+
# Reject source names used as projection values, including nested forms.
|
|
270
|
+
def refuse_composite_sql_projection!(stripped)
|
|
271
|
+
return if redaction_columns.empty? && redacted_kv_columns.empty?
|
|
272
|
+
|
|
273
|
+
sources = SqlTableScanner.relation_factors(stripped).flat_map { |factor| sql_relation_names(factor) }
|
|
274
|
+
tokens = sql_policy_tokens(stripped)
|
|
275
|
+
return unless whole_row_value?(tokens, sources)
|
|
276
|
+
|
|
277
|
+
raise ValidationError,
|
|
278
|
+
'Rejected: whole-row SQL values cannot preserve protected field identity; select columns.'
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Track each query's clause separately, including correlated expressions
|
|
282
|
+
# without a FROM clause. Relation declarations and qualified columns are
|
|
283
|
+
# not values; a source token anywhere else may be a composite row.
|
|
284
|
+
def whole_row_value?(tokens, sources)
|
|
285
|
+
clauses = {}
|
|
286
|
+
query_levels = {}
|
|
287
|
+
tokens.each_with_index.any? do |token, index|
|
|
288
|
+
clause = sql_clause_at_token(token, clauses, query_levels)
|
|
289
|
+
next false unless clause && !%w[FROM JOIN STRAIGHT_JOIN].include?(clause)
|
|
290
|
+
next false unless sources.any? { |source| sql_unquote(token[:text]).casecmp?(source) }
|
|
291
|
+
|
|
292
|
+
!sql_nonvalue_reference?(tokens, index)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def sql_clause_at_token(token, clauses, query_levels)
|
|
297
|
+
word, depth = token.values_at(:text, :depth)
|
|
298
|
+
keyword = word.upcase
|
|
299
|
+
if word == '('
|
|
300
|
+
clauses[depth + 1] = 'EXPRESSION'
|
|
301
|
+
query_levels.delete(depth + 1)
|
|
302
|
+
end
|
|
303
|
+
if word == ')'
|
|
304
|
+
clauses.delete(depth + 1)
|
|
305
|
+
query_levels.delete(depth + 1)
|
|
306
|
+
end
|
|
307
|
+
query_levels[depth] = true if keyword == 'SELECT'
|
|
308
|
+
boundaries = %w[SELECT FROM JOIN STRAIGHT_JOIN ON WHERE HAVING ORDER GROUP LIMIT OFFSET]
|
|
309
|
+
clauses[depth] = keyword if query_levels[depth] && boundaries.include?(keyword)
|
|
310
|
+
clauses[depth]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def sql_nonvalue_reference?(tokens, index)
|
|
314
|
+
previous = index.positive? ? tokens[index - 1][:text].upcase : nil
|
|
315
|
+
return true if ['.', 'AS'].include?(previous)
|
|
316
|
+
return true if sql_token_text(tokens, index + 1) == '.' && sql_token_text(tokens, index + 2) != '*'
|
|
317
|
+
|
|
318
|
+
direct_sql_wildcard?(tokens, index)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def sql_token_text(tokens, index)
|
|
322
|
+
tokens[index]&.fetch(:text)&.upcase
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def direct_sql_wildcard?(tokens, index)
|
|
326
|
+
return false unless sql_token_text(tokens, index + 1) == '.' && sql_token_text(tokens, index + 2) == '*'
|
|
327
|
+
|
|
328
|
+
previous = index.positive? ? sql_token_text(tokens, index - 1) : nil
|
|
329
|
+
following = sql_token_text(tokens, index + 3)
|
|
330
|
+
%w[SELECT ,].include?(previous) && [nil, ',', 'FROM'].include?(following)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Comments and literal bodies have already been removed. Token offsets
|
|
334
|
+
# let projection and ordinal checks retain complete nested expressions.
|
|
335
|
+
def sql_policy_tokens(stripped)
|
|
336
|
+
depth = 0
|
|
337
|
+
stripped.to_enum(:scan, /"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|
|
|
338
|
+
[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_$\u0080-\u{10ffff}]*|[0-9]+|::|[^\s]/ux).map do
|
|
339
|
+
match = Regexp.last_match
|
|
340
|
+
word = match[0]
|
|
341
|
+
depth -= 1 if word == ')'
|
|
342
|
+
token = { text: word, depth: depth, start: match.begin(0), finish: match.end(0) }
|
|
343
|
+
depth += 1 if word == '('
|
|
344
|
+
token
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def sql_unquote(identifier)
|
|
349
|
+
identifier.sub(/\A["`]/, '').sub(/["`]\z/, '').gsub('""', '"').gsub('``', '`')
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def sql_select_lists(stripped)
|
|
353
|
+
tokens = sql_policy_tokens(stripped)
|
|
354
|
+
tokens.each_with_index.filter_map do |token, index|
|
|
355
|
+
next unless token[:text].casecmp?('SELECT')
|
|
356
|
+
|
|
357
|
+
finish = tokens[(index + 1)..].find { |candidate| sql_projection_end?(candidate, token) }
|
|
358
|
+
finish_at = finish ? finish[:start] : stripped.length
|
|
359
|
+
{ expressions: sql_projection_expressions(stripped[token[:finish]...finish_at]),
|
|
360
|
+
depth: token[:depth], start: token[:start], finish: finish_at }
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def sql_projection_end?(candidate, token)
|
|
365
|
+
candidate[:depth] < token[:depth] ||
|
|
366
|
+
(candidate[:depth] == token[:depth] &&
|
|
367
|
+
%w[FROM WHERE GROUP HAVING ORDER LIMIT UNION INTERSECT EXCEPT].include?(candidate[:text].upcase))
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def refuse_protected_sql_positions!(stripped)
|
|
371
|
+
lists = sql_select_lists(stripped)
|
|
372
|
+
sql_policy_tokens(stripped).each_cons(3) do |clause, by, position|
|
|
373
|
+
next unless %w[ORDER GROUP].include?(clause[:text].upcase) && by[:text].casecmp?('BY')
|
|
374
|
+
|
|
375
|
+
list = sql_projection_before(lists, clause)
|
|
376
|
+
next unless list
|
|
377
|
+
next unless sql_projection_expressions(stripped[position[:start]..]).any? do |item|
|
|
378
|
+
protected_sql_ordinal?(item, list[:expressions])
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
raise ValidationError,
|
|
382
|
+
'Rejected: positional SQL ordering or grouping cannot reference protected output columns.'
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def sql_projection_before(lists, clause)
|
|
387
|
+
lists.reverse.find do |candidate|
|
|
388
|
+
candidate[:depth] == clause[:depth] && candidate[:start] < clause[:start]
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def protected_sql_ordinal?(item, expressions)
|
|
393
|
+
ordinal = /\A\s*\(*\s*([0-9]+)\s*\)*(?:\s|\z)/.match(item)
|
|
394
|
+
return false unless ordinal && ordinal[1].to_i.positive?
|
|
395
|
+
|
|
396
|
+
expression = expressions[ordinal[1].to_i - 1]
|
|
397
|
+
return false unless expression
|
|
398
|
+
return true if expression.include?('*')
|
|
399
|
+
|
|
400
|
+
(redaction_columns + redacted_eav_value_columns).any? do |column|
|
|
401
|
+
sql_identifier_referenced?(expression, column)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def sql_relation_names(factor)
|
|
406
|
+
identifier = /(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_$\u0080-\u{10ffff}]*|"(?:""|[^"])+"|`(?:``|[^`])+`)/u
|
|
407
|
+
source = /\A\s*(?:ONLY\b\s*\(?\s*)?(#{identifier})(?:\s*\.\s*(#{identifier}))?/i
|
|
408
|
+
match = source.match(factor)
|
|
409
|
+
names = match ? [match[2] || match[1]] : []
|
|
410
|
+
rest = match ? factor[match.end(0)..].sub(/\A\s*\*/, '') : factor
|
|
411
|
+
aliases = /(?:\A|\))\s*(?:AS\s+)?(#{identifier})/i
|
|
412
|
+
names.concat(rest.scan(aliases).flatten)
|
|
413
|
+
|
|
414
|
+
names.map { |name| sql_unquote(name) }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def unsafe_protected_sql_column(referenced, expressions, selected, tail, stripped)
|
|
418
|
+
referenced.find do |column|
|
|
419
|
+
unsafe_protected_sql_reference?(column, expressions, selected, tail)
|
|
420
|
+
end || orphan_eav_sql_value(selected, stripped)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def protected_sql_projection(stripped)
|
|
424
|
+
list = sql_select_lists(stripped).first
|
|
425
|
+
return [[], stripped] unless list && list[:start].zero?
|
|
426
|
+
|
|
427
|
+
[list[:expressions], stripped[list[:finish]..]]
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def unsafe_protected_sql_reference?(column, expressions, selected, tail)
|
|
431
|
+
unsafe_projection = expressions.any? do |expression|
|
|
432
|
+
sql_identifier_referenced?(expression, column) && direct_sql_column_name(expression) != column
|
|
433
|
+
end
|
|
434
|
+
unsafe_tail = protected_sql_predicate_column?(column) && sql_identifier_referenced?(tail, column)
|
|
435
|
+
!selected.include?(column) || unsafe_projection || unsafe_tail
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def protected_sql_predicate_column?(column)
|
|
439
|
+
redaction_columns.include?(column) || redacted_eav_value_columns.include?(column)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def orphan_eav_sql_value(selected, stripped)
|
|
443
|
+
pattern = sql_eav_patterns(stripped).find do |candidate|
|
|
444
|
+
selected.include?(candidate['value_column']) && !selected.include?(candidate['key_column'])
|
|
445
|
+
end
|
|
446
|
+
pattern&.fetch('value_column')
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def sql_eav_patterns(stripped)
|
|
450
|
+
tables = SqlTableScanner.identifiers_in(stripped, dialect: sql_dialect, mysql_modes: mysql_quote_modes)
|
|
451
|
+
redaction_key_values.select do |pattern|
|
|
452
|
+
tables.any? do |table|
|
|
453
|
+
columns = @model_validator.columns_for_table(table)
|
|
454
|
+
columns.nil? || [pattern['key_column'], pattern['value_column']].all? { |column| columns.include?(column) }
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def sql_projection_expressions(projection)
|
|
460
|
+
commas = sql_policy_tokens(projection).select { |token| token[:text] == ',' && token[:depth].zero? }
|
|
461
|
+
start = 0
|
|
462
|
+
commas.map do |token|
|
|
463
|
+
expression = projection[start...token[:start]].strip
|
|
464
|
+
start = token[:finish]
|
|
465
|
+
expression
|
|
466
|
+
end + [projection[start..].strip]
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def direct_sql_column_name(expression)
|
|
470
|
+
identifier = /(?:[A-Za-z_]\w*|"(?:""|[^"])+"|`(?:``|[^`])+`)/
|
|
471
|
+
match = /\A(?:#{identifier}\.)?(#{identifier})\z/.match(expression)
|
|
472
|
+
return unless match
|
|
473
|
+
|
|
474
|
+
match[1].sub(/\A["`]/, '').sub(/["`]\z/, '').gsub('""', '"').gsub('``', '`')
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def refuse_protected_predicate_references!(template)
|
|
478
|
+
protected = (redaction_columns + redacted_eav_value_columns).uniq
|
|
479
|
+
referenced = protected.find { |column| sql_identifier_referenced?(template, column) }
|
|
480
|
+
refuse_protected_predicate_column!(referenced) if referenced
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def sql_identifier_referenced?(sql, column)
|
|
484
|
+
sql_security_views(sql).any? do |stripped|
|
|
485
|
+
stripped.match?(/(?<![A-Za-z0-9_$])#{Regexp.escape(column)}(?![A-Za-z0-9_$])/i)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def sql_security_views(sql)
|
|
490
|
+
dialects = sql_dialect ? [sql_dialect] : SqlValidator::KNOWN_DIALECTS
|
|
491
|
+
dialects.flat_map do |dialect|
|
|
492
|
+
SqlNoiseStripper.security_views(sql, dialect: dialect, mysql_modes: sql_dialect ? mysql_quote_modes : nil)
|
|
493
|
+
end.uniq
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def validate_sql_result_types!(result)
|
|
497
|
+
return unless sql_dialect == :postgres
|
|
498
|
+
return if redaction_columns.empty? && redacted_kv_columns.empty?
|
|
499
|
+
|
|
500
|
+
return if recognized_sql_result_types?(result)
|
|
501
|
+
|
|
502
|
+
raise ValidationError,
|
|
503
|
+
'Rejected: PostgreSQL returned an unrecognized result type that cannot preserve ' \
|
|
504
|
+
'protected field identity. ' \
|
|
505
|
+
'Select ordinary scalar columns or use a structured Console tool.'
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def recognized_sql_result_types?(result)
|
|
509
|
+
types = result.column_types if result.respond_to?(:column_types)
|
|
510
|
+
return false unless types
|
|
511
|
+
|
|
512
|
+
result.columns.each_with_index.all? do |column, index|
|
|
513
|
+
type = types[index] || types[column]
|
|
514
|
+
type&.type
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def mysql_quote_modes
|
|
519
|
+
return {} unless sql_dialect == :mysql
|
|
520
|
+
|
|
521
|
+
connection = active_connection
|
|
522
|
+
cache = Thread.current[:woods_console_mysql_quote_modes]
|
|
523
|
+
return cache[connection] if cache&.key?(connection)
|
|
524
|
+
|
|
525
|
+
modes = connection.select_value('SELECT @@SESSION.sql_mode').to_s.upcase.split(',')
|
|
526
|
+
result = {
|
|
527
|
+
ansi_quotes: modes.include?('ANSI_QUOTES'),
|
|
528
|
+
no_backslash_escapes: modes.include?('NO_BACKSLASH_ESCAPES')
|
|
529
|
+
}
|
|
530
|
+
cache[connection] = result if cache
|
|
531
|
+
result
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|