diff --git a/lib/datadog/appsec/contrib/active_record/instrumentation.rb b/lib/datadog/appsec/contrib/active_record/instrumentation.rb index 85ca0fc595d..3b226a9aafb 100644 --- a/lib/datadog/appsec/contrib/active_record/instrumentation.rb +++ b/lib/datadog/appsec/contrib/active_record/instrumentation.rb @@ -12,9 +12,14 @@ def detect_sql_injection(sql, adapter_name) scope = AppSec.active_scope return unless scope + # libddwaf expects db system to be lowercase, + # in case of sqlite adapter, libddwaf expects 'sqlite' as db system + db_system = adapter_name.downcase + db_system = 'sqlite' if db_system == 'sqlite3' + ephemeral_data = { 'server.db.statement' => sql, - 'server.db.system' => adapter_name.downcase.gsub(/\d{1}\z/, '') + 'server.db.system' => db_system } waf_timeout = Datadog.configuration.appsec.waf_timeout diff --git a/lib/datadog/appsec/contrib/active_record/integration.rb b/lib/datadog/appsec/contrib/active_record/integration.rb index 022067d4e19..00002f491d8 100644 --- a/lib/datadog/appsec/contrib/active_record/integration.rb +++ b/lib/datadog/appsec/contrib/active_record/integration.rb @@ -7,7 +7,7 @@ module Datadog module AppSec module Contrib module ActiveRecord - # Description of ActiveRecord integration + # This class provides helper methods that are used when patching ActiveRecord class Integration include Datadog::AppSec::Contrib::Integration diff --git a/lib/datadog/appsec/contrib/active_record/patcher.rb b/lib/datadog/appsec/contrib/active_record/patcher.rb index b6e65b97d51..dd0c6c220ae 100644 --- a/lib/datadog/appsec/contrib/active_record/patcher.rb +++ b/lib/datadog/appsec/contrib/active_record/patcher.rb @@ -23,27 +23,27 @@ def target_version def patch ActiveSupport.on_load :active_record do - if defined? ::ActiveRecord::ConnectionAdapters::SQLite3Adapter - ::ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(Patcher.prepended_class_name(:sqlite3)) + instrumentation_module = if ::ActiveRecord.gem_version >= Gem::Version.new('7.1') + Instrumentation::InternalExecQueryAdapterPatch + else + Instrumentation::ExecQueryAdapterPatch + end + + if defined?(::ActiveRecord::ConnectionAdapters::SQLite3Adapter) + ::ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(instrumentation_module) end - if defined? ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter - ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(Patcher.prepended_class_name(:postgresql)) + if defined?(::ActiveRecord::ConnectionAdapters::Mysql2Adapter) + ::ActiveRecord::ConnectionAdapters::Mysql2Adapter.prepend(instrumentation_module) end - if defined? ::ActiveRecord::ConnectionAdapters::Mysql2Adapter - ::ActiveRecord::ConnectionAdapters::Mysql2Adapter.prepend(Patcher.prepended_class_name(:mysql2)) - end - end - end + if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) + unless defined?(::ActiveRecord::ConnectionAdapters::JdbcAdapter) + instrumentation_module = Instrumentation::ExecuteAndClearAdapterPatch + end - def prepended_class_name(adapter_name) - if ::ActiveRecord.gem_version >= Gem::Version.new('7.1') - Instrumentation::InternalExecQueryAdapterPatch - elsif adapter_name == :postgresql && !defined?(::ActiveRecord::ConnectionAdapters::JdbcAdapter) - Instrumentation::ExecuteAndClearAdapterPatch - else - Instrumentation::ExecQueryAdapterPatch + ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(instrumentation_module) + end end end end diff --git a/sig/datadog/appsec/contrib/active_record/instrumentation.rbs b/sig/datadog/appsec/contrib/active_record/instrumentation.rbs index b81b4138642..daea0a9f684 100644 --- a/sig/datadog/appsec/contrib/active_record/instrumentation.rbs +++ b/sig/datadog/appsec/contrib/active_record/instrumentation.rbs @@ -3,7 +3,7 @@ module Datadog module Contrib module ActiveRecord module Instrumentation - def self?.detect_sqli: (String sql, String adapter_name) -> void + def self?.detect_sql_injection: (String sql, String adapter_name) -> void module InternalExecQueryAdapterPatch def internal_exec_query: (String sql, *untyped args, **untyped rest) -> untyped diff --git a/sig/datadog/appsec/contrib/active_record/patcher.rbs b/sig/datadog/appsec/contrib/active_record/patcher.rbs index 609b75bf6fb..2850b974c00 100644 --- a/sig/datadog/appsec/contrib/active_record/patcher.rbs +++ b/sig/datadog/appsec/contrib/active_record/patcher.rbs @@ -10,8 +10,6 @@ module Datadog def self?.target_version: () -> Gem::Version? def self?.patch: () -> void - - def self?.prepended_class_name: (Symbol adapter_name) -> class end end end diff --git a/spec/datadog/appsec/contrib/active_record/multi_adapter_spec.rb b/spec/datadog/appsec/contrib/active_record/multi_adapter_spec.rb deleted file mode 100644 index 4080ec0e426..00000000000 --- a/spec/datadog/appsec/contrib/active_record/multi_adapter_spec.rb +++ /dev/null @@ -1,171 +0,0 @@ -require 'datadog/appsec/spec_helper' -require 'active_record' - -require 'spec/datadog/tracing/contrib/rails/support/deprecation' - -if PlatformHelpers.jruby? - require 'activerecord-jdbc-adapter' -else - require 'mysql2' - require 'sqlite3' - require 'pg' -end - -RSpec.describe 'AppSec ActiveRecord integration' do - let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } - let(:ruleset) { Datadog::AppSec::Processor::RuleLoader.load_rules(ruleset: :recommended, telemetry: telemetry) } - let(:processor) { Datadog::AppSec::Processor.new(ruleset: ruleset, telemetry: telemetry) } - let(:context) { processor.new_context } - - let(:span) { Datadog::Tracing::SpanOperation.new('root') } - let(:trace) { Datadog::Tracing::TraceOperation.new } - - let!(:user_class) do - stub_const('User', Class.new(ActiveRecord::Base)).tap do |klass| - klass.establish_connection(db_config) - - klass.connection.create_table 'users', force: :cascade do |t| - t.string :name, null: false - t.string :email, null: false - t.timestamps - end - - # prevent internal sql requests from showing up - klass.count - klass.first - end - end - - before do - Datadog.configure do |c| - c.appsec.enabled = true - c.appsec.instrument :active_record - end - - Datadog::AppSec::Scope.activate_scope(trace, span, processor) - - raise_on_rails_deprecation! - end - - after do - Datadog.configuration.reset! - - Datadog::AppSec::Scope.deactivate_scope - processor.finalize - end - - shared_examples 'calls_waf_with_correct_arguments' do - it 'calls waf with correct arguments' do - expect(Datadog::AppSec.active_scope.processor_context).to( - receive(:run).with( - {}, - { - 'server.db.statement' => expected_db_statement, - 'server.db.system' => expected_db_system - }, - Datadog.configuration.appsec.waf_timeout - ).and_call_original - ) - - active_record_scope.to_a - end - - it 'adds an event to processor context if waf status is :match' do - expect(Datadog::AppSec.active_scope.processor_context).to( - receive(:run).and_return(double(status: :match, actions: {})) - ) - - expect(Datadog::AppSec.active_scope.processor_context.events).to receive(:<<).and_call_original - - active_record_scope.to_a - end - end - - context 'mysql2 adapter' do - let(:db_config) do - { - adapter: 'mysql2', - database: ENV.fetch('TEST_MYSQL_DB', 'mysql'), - host: ENV.fetch('TEST_MYSQL_HOST', '127.0.0.1'), - password: ENV.fetch('TEST_MYSQL_ROOT_PASSWORD', 'root'), - port: ENV.fetch('TEST_MYSQL_PORT', '3306') - } - end - - let(:expected_db_system) { 'mysql' } - - context 'when using .where' do - let(:active_record_scope) { User.where(name: 'Bob') } - let(:expected_db_statement) { "SELECT `users`.* FROM `users` WHERE `users`.`name` = 'Bob'" } - - include_examples 'calls_waf_with_correct_arguments' - end - - context 'when using .find_by_sql' do - let(:active_record_scope) { User.find_by_sql("SELECT * FROM users WHERE name = 'Bob'") } - let(:expected_db_statement) { "SELECT * FROM users WHERE name = 'Bob'" } - - include_examples 'calls_waf_with_correct_arguments' - end - end - - context 'postgres adapter' do - let(:db_config) do - { - adapter: 'postgresql', - database: ENV.fetch('TEST_POSTGRES_DB', 'postgres'), - host: ENV.fetch('TEST_POSTGRES_HOST', '127.0.0.1'), - port: ENV.fetch('TEST_POSTGRES_PORT', 5432), - username: ENV.fetch('TEST_POSTGRES_USER', 'postgres'), - password: ENV.fetch('TEST_POSTGRES_PASSWORD', 'postgres') - } - end - - let(:expected_db_system) { 'postgresql' } - - context 'when using .where' do - let(:active_record_scope) { User.where(name: 'Bob') } - let(:expected_db_statement) do - if PlatformHelpers.jruby? - 'SELECT "users".* FROM "users" WHERE "users"."name" = ?' - else - 'SELECT "users".* FROM "users" WHERE "users"."name" = $1' - end - end - - include_examples 'calls_waf_with_correct_arguments' - end - - context 'when using .find_by_sql' do - let(:active_record_scope) { User.find_by_sql("SELECT * FROM users WHERE name = 'Bob'") } - let(:expected_db_statement) { "SELECT * FROM users WHERE name = 'Bob'" } - - include_examples 'calls_waf_with_correct_arguments' - end - end - - context 'sqlite3 adapter' do - let(:db_config) do - { - adapter: 'sqlite3', - database: ':memory:' - } - end - - let(:expected_db_system) { 'sqlite' } - - context 'when using .where' do - let(:active_record_scope) { User.where(name: 'Bob') } - let(:expected_db_statement) { 'SELECT "users".* FROM "users" WHERE "users"."name" = ?' } - - include_examples 'calls_waf_with_correct_arguments' - end - - context 'when using .find_by_sql' do - let(:active_record_scope) { User.find_by_sql("SELECT * FROM users WHERE name = 'Bob'") } - let(:expected_db_statement) { "SELECT * FROM users WHERE name = 'Bob'" } - - include_examples 'calls_waf_with_correct_arguments' - end - end -end diff --git a/spec/datadog/appsec/contrib/active_record/mysql2_adapter_spec.rb b/spec/datadog/appsec/contrib/active_record/mysql2_adapter_spec.rb new file mode 100644 index 00000000000..6c5777adc1f --- /dev/null +++ b/spec/datadog/appsec/contrib/active_record/mysql2_adapter_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'datadog/appsec/spec_helper' +require 'active_record' + +require 'spec/datadog/tracing/contrib/rails/support/deprecation' + +if PlatformHelpers.jruby? + require 'activerecord-jdbc-adapter' +else + require 'mysql2' +end + +RSpec.describe 'AppSec ActiveRecord integration for Mysql2 adapter' do + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:ruleset) { Datadog::AppSec::Processor::RuleLoader.load_rules(ruleset: :recommended, telemetry: telemetry) } + let(:processor) { Datadog::AppSec::Processor.new(ruleset: ruleset, telemetry: telemetry) } + let(:context) { processor.new_context } + + let(:span) { Datadog::Tracing::SpanOperation.new('root') } + let(:trace) { Datadog::Tracing::TraceOperation.new } + + let!(:user_class) do + stub_const('User', Class.new(ActiveRecord::Base)).tap do |klass| + klass.establish_connection(db_config) + + klass.connection.create_table 'users', force: :cascade do |t| + t.string :name, null: false + t.string :email, null: false + t.timestamps + end + + # prevent internal sql requests from showing up + klass.count + klass.first + end + end + + let(:db_config) do + { + adapter: 'mysql2', + database: ENV.fetch('TEST_MYSQL_DB', 'mysql'), + host: ENV.fetch('TEST_MYSQL_HOST', '127.0.0.1'), + password: ENV.fetch('TEST_MYSQL_ROOT_PASSWORD', 'root'), + port: ENV.fetch('TEST_MYSQL_PORT', '3306') + } + end + + before do + Datadog.configure do |c| + c.appsec.enabled = true + c.appsec.instrument :active_record + end + + Datadog::AppSec::Scope.activate_scope(trace, span, processor) + + raise_on_rails_deprecation! + end + + after do + Datadog.configuration.reset! + + Datadog::AppSec::Scope.deactivate_scope + processor.finalize + end + + it 'calls waf with correct arguments when querying using .where' do + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).with( + {}, + { + 'server.db.statement' => "SELECT `users`.* FROM `users` WHERE `users`.`name` = 'Bob'", + 'server.db.system' => 'mysql2' + }, + Datadog.configuration.appsec.waf_timeout + ).and_call_original + ) + + User.where(name: 'Bob').to_a + end + + it 'calls waf with correct arguments when querying using .find_by_sql' do + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).with( + {}, + { + 'server.db.statement' => "SELECT * FROM users WHERE name = 'Bob'", + 'server.db.system' => 'mysql2' + }, + Datadog.configuration.appsec.waf_timeout + ).and_call_original + ) + + User.find_by_sql("SELECT * FROM users WHERE name = 'Bob'").to_a + end + + it 'adds an event to processor context if waf status is :match' do + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).and_return(instance_double(Datadog::AppSec::WAF::Result, status: :match, actions: {})) + ) + + expect(Datadog::AppSec.active_scope.processor_context.events).to receive(:<<).and_call_original + + User.where(name: 'Bob').to_a + end +end diff --git a/spec/datadog/appsec/contrib/active_record/patcher_spec.rb b/spec/datadog/appsec/contrib/active_record/patcher_spec.rb deleted file mode 100644 index 468b9631a15..00000000000 --- a/spec/datadog/appsec/contrib/active_record/patcher_spec.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -require 'datadog/appsec/spec_helper' -require 'datadog/appsec/contrib/active_record/patcher' - -RSpec.describe Datadog::AppSec::Contrib::ActiveRecord::Patcher do - describe '#prepended_class_name' do - context 'when ActiveRecord version is 7.1 or higher' do - before do - stub_const( - '::ActiveRecord', - Module.new do - module_function def gem_version - Gem::Version.new('7.1') - end - end - ) - end - - it 'returns Instrumentation::InternalExecQueryAdapterPatch' do - expect(described_class.prepended_class_name(:postgresql)).to eq( - Datadog::AppSec::Contrib::ActiveRecord::Instrumentation::InternalExecQueryAdapterPatch - ) - end - end - - context 'when ActiveRecord version is lower than 7.1' do - before do - stub_const( - '::ActiveRecord', - Module.new do - module_function def gem_version - Gem::Version.new('7.0') - end - end - ) - end - - context 'for postgresql adapter' do - context 'when ActiveRecord::ConnectionAdapters::JdbcAdapter is defined' do - before do - stub_const('::ActiveRecord::ConnectionAdapters::JdbcAdapter', Class.new) - end - - it 'returns Instrumentation::ExecQueryAdapterPatch' do - expect(described_class.prepended_class_name(:postgresql)).to eq( - Datadog::AppSec::Contrib::ActiveRecord::Instrumentation::ExecQueryAdapterPatch - ) - end - end - - it 'returns Instrumentation::ExecuteAndClearAdapterPatch' do - expect(described_class.prepended_class_name(:postgresql)).to eq( - Datadog::AppSec::Contrib::ActiveRecord::Instrumentation::ExecuteAndClearAdapterPatch - ) - end - end - - %i[mysql2 sqlite3].each do |adapter_name| - context "for #{adapter_name} adapter" do - it 'returns Instrumentation::ExecQueryAdapterPatch' do - expect(described_class.prepended_class_name(adapter_name)).to eq( - Datadog::AppSec::Contrib::ActiveRecord::Instrumentation::ExecQueryAdapterPatch - ) - end - end - end - end - end -end diff --git a/spec/datadog/appsec/contrib/active_record/postgresql_adapter_spec.rb b/spec/datadog/appsec/contrib/active_record/postgresql_adapter_spec.rb new file mode 100644 index 00000000000..5ebd8e0cacb --- /dev/null +++ b/spec/datadog/appsec/contrib/active_record/postgresql_adapter_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'datadog/appsec/spec_helper' +require 'active_record' + +require 'spec/datadog/tracing/contrib/rails/support/deprecation' + +if PlatformHelpers.jruby? + require 'activerecord-jdbc-adapter' +else + require 'pg' +end + +RSpec.describe 'AppSec ActiveRecord integration for Postgresql adapter' do + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:ruleset) { Datadog::AppSec::Processor::RuleLoader.load_rules(ruleset: :recommended, telemetry: telemetry) } + let(:processor) { Datadog::AppSec::Processor.new(ruleset: ruleset, telemetry: telemetry) } + let(:context) { processor.new_context } + + let(:span) { Datadog::Tracing::SpanOperation.new('root') } + let(:trace) { Datadog::Tracing::TraceOperation.new } + + let!(:user_class) do + stub_const('User', Class.new(ActiveRecord::Base)).tap do |klass| + klass.establish_connection(db_config) + + klass.connection.create_table 'users', force: :cascade do |t| + t.string :name, null: false + t.string :email, null: false + t.timestamps + end + + # prevent internal sql requests from showing up + klass.count + klass.first + end + end + + let(:db_config) do + { + adapter: 'postgresql', + database: ENV.fetch('TEST_POSTGRES_DB', 'postgres'), + host: ENV.fetch('TEST_POSTGRES_HOST', '127.0.0.1'), + port: ENV.fetch('TEST_POSTGRES_PORT', 5432), + username: ENV.fetch('TEST_POSTGRES_USER', 'postgres'), + password: ENV.fetch('TEST_POSTGRES_PASSWORD', 'postgres') + } + end + + before do + Datadog.configure do |c| + c.appsec.enabled = true + c.appsec.instrument :active_record + end + + Datadog::AppSec::Scope.activate_scope(trace, span, processor) + + raise_on_rails_deprecation! + end + + after do + Datadog.configuration.reset! + + Datadog::AppSec::Scope.deactivate_scope + processor.finalize + end + + it 'calls waf with correct arguments when querying using .where' do + expected_db_statement = if PlatformHelpers.jruby? + 'SELECT "users".* FROM "users" WHERE "users"."name" = ?' + else + 'SELECT "users".* FROM "users" WHERE "users"."name" = $1' + end + + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).with( + {}, + { + 'server.db.statement' => expected_db_statement, + 'server.db.system' => 'postgresql' + }, + Datadog.configuration.appsec.waf_timeout + ).and_call_original + ) + + User.where(name: 'Bob').to_a + end + + it 'calls waf with correct arguments when querying using .find_by_sql' do + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).with( + {}, + { + 'server.db.statement' => "SELECT * FROM users WHERE name = 'Bob'", + 'server.db.system' => 'postgresql' + }, + Datadog.configuration.appsec.waf_timeout + ).and_call_original + ) + + User.find_by_sql("SELECT * FROM users WHERE name = 'Bob'").to_a + end + + it 'adds an event to processor context if waf status is :match' do + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).and_return(instance_double(Datadog::AppSec::WAF::Result, status: :match, actions: {})) + ) + + expect(Datadog::AppSec.active_scope.processor_context.events).to receive(:<<).and_call_original + + User.where(name: 'Bob').to_a + end +end diff --git a/spec/datadog/appsec/contrib/active_record/sqlite3_adapter_spec.rb b/spec/datadog/appsec/contrib/active_record/sqlite3_adapter_spec.rb new file mode 100644 index 00000000000..778fe952a30 --- /dev/null +++ b/spec/datadog/appsec/contrib/active_record/sqlite3_adapter_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'datadog/appsec/spec_helper' +require 'active_record' + +require 'spec/datadog/tracing/contrib/rails/support/deprecation' + +if PlatformHelpers.jruby? + require 'activerecord-jdbc-adapter' +else + require 'sqlite3' +end + +RSpec.describe 'AppSec ActiveRecord integration for SQLite3 adapter' do + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:ruleset) { Datadog::AppSec::Processor::RuleLoader.load_rules(ruleset: :recommended, telemetry: telemetry) } + let(:processor) { Datadog::AppSec::Processor.new(ruleset: ruleset, telemetry: telemetry) } + let(:context) { processor.new_context } + + let(:span) { Datadog::Tracing::SpanOperation.new('root') } + let(:trace) { Datadog::Tracing::TraceOperation.new } + + let!(:user_class) do + stub_const('User', Class.new(ActiveRecord::Base)).tap do |klass| + klass.establish_connection(db_config) + + klass.connection.create_table 'users', force: :cascade do |t| + t.string :name, null: false + t.string :email, null: false + t.timestamps + end + + # prevent internal sql requests from showing up + klass.count + klass.first + end + end + + let(:db_config) do + { + adapter: 'sqlite3', + database: ':memory:' + } + end + + before do + Datadog.configure do |c| + c.appsec.enabled = true + c.appsec.instrument :active_record + end + + Datadog::AppSec::Scope.activate_scope(trace, span, processor) + + raise_on_rails_deprecation! + end + + after do + Datadog.configuration.reset! + + Datadog::AppSec::Scope.deactivate_scope + processor.finalize + end + + it 'calls waf with correct arguments when querying using .where' do + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).with( + {}, + { + 'server.db.statement' => 'SELECT "users".* FROM "users" WHERE "users"."name" = ?', + 'server.db.system' => 'sqlite' + }, + Datadog.configuration.appsec.waf_timeout + ).and_call_original + ) + + User.where(name: 'Bob').to_a + end + + it 'calls waf with correct arguments when querying using .find_by_sql' do + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).with( + {}, + { + 'server.db.statement' => "SELECT * FROM users WHERE name = 'Bob'", + 'server.db.system' => 'sqlite' + }, + Datadog.configuration.appsec.waf_timeout + ).and_call_original + ) + + User.find_by_sql("SELECT * FROM users WHERE name = 'Bob'").to_a + end + + it 'adds an event to processor context if waf status is :match' do + expect(Datadog::AppSec.active_scope.processor_context).to( + receive(:run).and_return(instance_double(Datadog::AppSec::WAF::Result, status: :match, actions: {})) + ) + + expect(Datadog::AppSec.active_scope.processor_context.events).to receive(:<<).and_call_original + + User.where(name: 'Bob').to_a + end +end