Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 53 additions & 27 deletions lib/code_to_query/validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class Validator
def validate(intent_hash, current_user: nil, allow_tables: nil)
preprocessed = preprocess_exists_filters(intent_hash)

if !preprocessed.key?('limit') && CodeToQuery.config.default_limit
if fetch_value(preprocessed, :limit).nil? && CodeToQuery.config.default_limit
preprocessed = preprocessed.merge('limit' => CodeToQuery.config.default_limit)
end

Expand All @@ -56,17 +56,17 @@ def validate(intent_hash, current_user: nil, allow_tables: nil)
original_metrics = intent_hash['_metrics'] || intent_hash[:_metrics]
validated['_metrics'] = original_metrics if original_metrics.is_a?(Hash)

Array(validated['filters']).each_with_index do |f, idx|
op = f['op'].to_s
Array(fetch_value(validated, :filters)).each_with_index do |f, idx|
op = fetch_value(f, :op).to_s
if %w[exists not_exists].include?(op)
unless f['related_table'].to_s.strip != '' && f['fk_column'].to_s.strip != ''
unless fetch_value(f, :related_table).to_s.strip != '' && fetch_value(f, :fk_column).to_s.strip != ''
raise ArgumentError, "Invalid intent: filters[#{idx}] requires related_table and fk_column for #{op}"
end

f['base_column'] ||= 'id'
f['column'] ||= 'id'
assign_default_value(f, :base_column, 'id')
assign_default_value(f, :column, 'id')
else
unless f['column'].to_s.strip != ''
unless fetch_value(f, :column).to_s.strip != ''
raise ArgumentError, "Invalid intent: filters[#{idx}].column must be filled"
end
end
Expand All @@ -77,12 +77,38 @@ def validate(intent_hash, current_user: nil, allow_tables: nil)

private

def fetch_value(hash, key)
return unless hash.respond_to?(:key?) && hash.respond_to?(:[])

return hash[key] if hash.key?(key)

if key.is_a?(Symbol)
string_key = key.to_s
return hash[string_key] if hash.key?(string_key)
end

if key.is_a?(String)
symbol_key = key.to_sym
return hash[symbol_key] if hash.key?(symbol_key)
end

nil
end

def assign_default_value(hash, key, value)
return unless hash.respond_to?(:key?) && hash.respond_to?(:[]=)
return unless fetch_value(hash, key).nil?

hash[key.to_sym] = value
end

def preprocess_exists_filters(intent_hash)
intent = intent_hash.dup
filters_key = intent.key?('filters') ? 'filters' : :filters

if intent['filters'].is_a?(Array)
intent['filters'] = intent['filters'].map do |filter|
if filter.is_a?(Hash) && %w[exists not_exists].include?(filter['op'].to_s) && filter['column'].nil?
if intent[filters_key].is_a?(Array)
intent[filters_key] = intent[filters_key].map do |filter|
if filter.is_a?(Hash) && %w[exists not_exists].include?(fetch_value(filter, :op).to_s) && fetch_value(filter, :column).nil?
filter.merge('column' => 'id')
else
filter
Expand All @@ -96,7 +122,7 @@ def preprocess_exists_filters(intent_hash)
def enforce_allowlists!(intent, current_user:, allow_tables:)
# Enforce table allowlist if provided (from user input)
if Array(allow_tables).any?
table = intent['table']
table = fetch_value(intent, :table)
if (table.to_s.strip != '') && !Array(allow_tables).map { |t| t.to_s.downcase }.include?(table.to_s.downcase)
raise ArgumentError, "Invalid intent: table '#{table}' not allowed"
end
Expand All @@ -106,7 +132,7 @@ def enforce_allowlists!(intent, current_user:, allow_tables:)
adapter = CodeToQuery.config.policy_adapter
return unless adapter.respond_to?(:call)

policy_info = safe_call_policy_adapter(adapter, current_user, table: intent['table'], intent: intent)
policy_info = safe_call_policy_adapter(adapter, current_user, table: fetch_value(intent, :table), intent: intent)
if policy_info.nil?
return handle_policy_failure('Policy adapter returned nil') if policy_adapter_fail_open?

Expand All @@ -119,15 +145,15 @@ def enforce_allowlists!(intent, current_user:, allow_tables:)
raise CodeToQuery::PolicyAdapterError, message
end

allowed_tables = Array(policy_info[:allowed_tables] || policy_info['allowed_tables']).map { |t| t.to_s.downcase }
allowed_tables = Array(fetch_value(policy_info, :allowed_tables)).map { |t| t.to_s.downcase }
if allowed_tables.any?
table = intent['table']
table = fetch_value(intent, :table)
if (table.to_s.strip != '') && !allowed_tables.include?(table.to_s.downcase)
raise ArgumentError, "Invalid intent: table '#{table}' not permitted by policy"
end
end

allowed_columns = policy_info[:allowed_columns] || policy_info['allowed_columns'] || {}
allowed_columns = fetch_value(policy_info, :allowed_columns) || {}
return if allowed_columns.nil? || allowed_columns.empty?

# Normalize map keys to strings with lowercase table and column names
Expand All @@ -136,10 +162,10 @@ def enforce_allowlists!(intent, current_user:, allow_tables:)
normalized[tbl.to_s.downcase] = Array(cols).map { |c| c.to_s.downcase }
end

main_table = intent['table'].to_s.downcase
main_table = fetch_value(intent, :table).to_s.downcase

# Columns in SELECT
Array(intent['columns']).each do |col|
Array(fetch_value(intent, :columns)).each do |col|
next if col == '*'
next unless normalized[main_table]&.any?
unless normalized[main_table].include?(col.to_s.downcase)
Expand All @@ -148,8 +174,8 @@ def enforce_allowlists!(intent, current_user:, allow_tables:)
end

# ORDER BY columns
Array(intent['order']).each do |o|
col = o['column']
Array(fetch_value(intent, :order)).each do |o|
col = fetch_value(o, :column)
next if col.nil?
next unless normalized[main_table]&.any?
unless normalized[main_table].include?(col.to_s.downcase)
Expand All @@ -158,38 +184,38 @@ def enforce_allowlists!(intent, current_user:, allow_tables:)
end

# DISTINCT ON columns
Array(intent['distinct_on']).each do |col|
Array(fetch_value(intent, :distinct_on)).each do |col|
next unless normalized[main_table]&.any?
unless normalized[main_table].include?(col.to_s.downcase)
raise ArgumentError, "Invalid intent: distinct_on column '#{col}' not permitted on '#{main_table}'"
end
end

# GROUP BY
Array(intent['group_by']).each do |col|
Array(fetch_value(intent, :group_by)).each do |col|
next unless normalized[main_table]&.any?
unless normalized[main_table].include?(col.to_s.downcase)
raise ArgumentError, "Invalid intent: group_by column '#{col}' not permitted on '#{main_table}'"
end
end

# WHERE filters
Array(intent['filters']).each do |f|
op = f['op'].to_s
Array(fetch_value(intent, :filters)).each do |f|
op = fetch_value(f, :op).to_s
if %w[exists not_exists].include?(op)
related_table = f['related_table']
related_table = fetch_value(f, :related_table)
rel_cols = normalized[related_table.to_s.downcase]
next if rel_cols.nil? || rel_cols.empty?

Array(f['related_filters']).each do |rf|
col = rf['column']
Array(fetch_value(f, :related_filters)).each do |rf|
col = fetch_value(rf, :column)
next if col.nil?
unless rel_cols.include?(col.to_s.downcase)
raise ArgumentError, "Invalid intent: filter column '#{col}' not permitted on '#{related_table}'"
end
end
else
col = f['column']
col = fetch_value(f, :column)
next if col.nil?

cols = normalized[main_table]
Expand Down
52 changes: 52 additions & 0 deletions spec/code_to_query/guardrails/adversarial_fixture_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

# rubocop:disable RSpec/LeakyLocalVariable

require 'spec_helper'
require 'yaml'

adversarial_sql_fixture_path = File.expand_path('../../fixtures/adversarial/sql_linter.yml', __dir__)
adversarial_sql_cases = YAML.safe_load_file(adversarial_sql_fixture_path, permitted_classes: [], aliases: false).freeze
adversarial_sql_adapters = %w[postgres mysql sqlite].freeze
adversarial_sql_allowed_adapters = (adversarial_sql_adapters + %w[any]).freeze
adversarial_sql_expected_categories = %w[
comment_obfuscation
dangerous_function
encoded_payload
literal_smuggling
prompt_injection
stacked_statement
system_table
union
].freeze

RSpec.describe 'Adversarial SQL fixture corpus' do
adversarial_sql_cases.each do |test_case|
adapter = test_case.fetch('adapter')
adapters = adapter == 'any' ? adversarial_sql_adapters : [adapter]

adapters.each do |adapter_name|
context "with #{test_case.fetch('category')} fixture #{test_case.fetch('id')} on #{adapter_name}" do
it 'rejects the SQL with the expected reason' do
linter = CodeToQuery::Guardrails::SqlLinter.new(
stub_config(adapter: adapter_name.to_sym, max_limit: 1000, max_joins: 3, policy_adapter: nil),
allow_tables: %w[users orders]
)

expect { linter.check!(test_case.fetch('sql')) }
.to raise_error(SecurityError, /#{Regexp.escape(test_case.fetch('reason'))}/)
end
end
end
end

it 'keeps every fixture categorized with an expected rejection reason' do
categories = adversarial_sql_cases.map { |test_case| test_case.fetch('category') }.uniq
adapters = adversarial_sql_cases.map { |test_case| test_case.fetch('adapter') }.uniq

expect(categories).to match_array(adversarial_sql_expected_categories)
expect(adapters - adversarial_sql_allowed_adapters).to be_empty
expect(adversarial_sql_cases).to all(include('id', 'category', 'adapter', 'sql', 'reason'))
end
end
# rubocop:enable RSpec/LeakyLocalVariable
53 changes: 53 additions & 0 deletions spec/code_to_query/validator_adversarial_fixture_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

# rubocop:disable RSpec/LeakyLocalVariable

require 'spec_helper'
require 'yaml'

adversarial_validator_fixture_path = File.expand_path('../fixtures/adversarial/validator.yml', __dir__)
adversarial_validator_cases = YAML.safe_load_file(adversarial_validator_fixture_path, permitted_classes: [], aliases: false).freeze
adversarial_validator_expected_categories = %w[
literal_smuggling
policy_bypass
prompt_injection
system_table
].freeze

RSpec.describe CodeToQuery::Validator do
after do
CodeToQuery.config.policy_adapter = nil
CodeToQuery.config.policy_adapter_fail_open = false
end

adversarial_validator_cases.each do |test_case|
context "with #{test_case.fetch('category')} fixture #{test_case.fetch('id')}" do
it 'rejects the intent with the expected reason' do
policy = test_case['policy']
policy_adapter = policy && lambda do |_user, **_context|
{
allowed_tables: policy.fetch('allowed_tables', []),
allowed_columns: policy.fetch('allowed_columns', {})
}
end

stub_config(policy_adapter: policy_adapter, policy_adapter_fail_open: false)

expect do
described_class.new.validate(
test_case.fetch('intent'),
allow_tables: test_case.fetch('allow_tables', nil)
)
end.to raise_error(ArgumentError, /#{Regexp.escape(test_case.fetch('reason'))}/)
end
end
end

it 'keeps every fixture categorized with an expected rejection reason' do
categories = adversarial_validator_cases.map { |test_case| test_case.fetch('category') }.uniq

expect(categories).to match_array(adversarial_validator_expected_categories)
expect(adversarial_validator_cases).to all(include('id', 'category', 'intent', 'reason'))
end
end
# rubocop:enable RSpec/LeakyLocalVariable
27 changes: 27 additions & 0 deletions spec/code_to_query/validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
let(:config) { stub_config(adapter: :postgres, default_limit: 100) }
let(:validator) { described_class.new }

after do
CodeToQuery.config.policy_adapter = nil
CodeToQuery.config.policy_adapter_fail_open = false
end

describe '#validate' do
context 'with valid basic intent' do
let(:intent) do
Expand Down Expand Up @@ -98,6 +103,28 @@
expect(result[:filters].first[:op]).to eq('exists')
expect(result[:filters].first[:related_table]).to eq('orders')
end

it 'adds default exists columns using the validated intent key style' do
intent = {
'type' => 'select',
'table' => 'users',
'columns' => ['*'],
'filters' => [
{
'op' => 'exists',
'related_table' => 'orders',
'fk_column' => 'user_id'
}
]
}

result = validator.validate(intent)
filter = result[:filters].first

expect(filter[:column]).to eq('id')
expect(filter[:base_column]).to eq('id')
expect(filter).not_to include('column', 'base_column')
end
end

context 'with not_exists filter' do
Expand Down
Loading
Loading