diff --git a/Matrixfile b/Matrixfile index 304a1a3a48e..2fa275e5f14 100644 --- a/Matrixfile +++ b/Matrixfile @@ -258,6 +258,9 @@ 'redis-4' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ jruby', 'redis-5' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ jruby' }, + 'appsec:active_record' => { + 'relational_db' => '❌ 2.5 / ❌ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ jruby', + }, 'appsec:rack' => { 'rack-latest' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ jruby', 'rack-3' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ jruby', diff --git a/Rakefile b/Rakefile index 41426800e51..5ddc8e14b16 100644 --- a/Rakefile +++ b/Rakefile @@ -267,7 +267,7 @@ namespace :spec do end namespace :appsec do - task all: [:main, :rack, :rails, :sinatra, :devise, :graphql] + task all: [:main, :active_record, :rack, :rails, :sinatra, :devise, :graphql] # Datadog AppSec main specs desc '' # "Explicitly hiding from `rake -T`" @@ -280,6 +280,7 @@ namespace :spec do # Datadog AppSec integrations [ + :active_record, :rack, :sinatra, :rails, diff --git a/lib/datadog/appsec.rb b/lib/datadog/appsec.rb index 940bbc4b4b6..5f96dbdd5fa 100644 --- a/lib/datadog/appsec.rb +++ b/lib/datadog/appsec.rb @@ -56,6 +56,7 @@ def components require_relative 'appsec/contrib/rack/integration' require_relative 'appsec/contrib/sinatra/integration' require_relative 'appsec/contrib/rails/integration' +require_relative 'appsec/contrib/active_record/integration' require_relative 'appsec/contrib/devise/integration' require_relative 'appsec/contrib/graphql/integration' diff --git a/lib/datadog/appsec/contrib/active_record/instrumentation.rb b/lib/datadog/appsec/contrib/active_record/instrumentation.rb new file mode 100644 index 00000000000..3b226a9aafb --- /dev/null +++ b/lib/datadog/appsec/contrib/active_record/instrumentation.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module Contrib + module ActiveRecord + # AppSec module that will be prepended to ActiveRecord adapter + module Instrumentation + module_function + + 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' => db_system + } + + waf_timeout = Datadog.configuration.appsec.waf_timeout + result = scope.processor_context.run({}, ephemeral_data, waf_timeout) + + if result.status == :match + Datadog::AppSec::Event.tag_and_keep!(scope, result) + + event = { + waf_result: result, + trace: scope.trace, + span: scope.service_entry_span, + sql: sql, + actions: result.actions + } + scope.processor_context.events << event + end + end + + # patch for all adapters in ActiveRecord >= 7.1 + module InternalExecQueryAdapterPatch + def internal_exec_query(sql, *args, **rest) + Instrumentation.detect_sql_injection(sql, adapter_name) + + super + end + end + + # patch for postgres adapter in ActiveRecord < 7.1 + module ExecuteAndClearAdapterPatch + def execute_and_clear(sql, *args, **rest) + Instrumentation.detect_sql_injection(sql, adapter_name) + + super + end + end + + # patch for mysql2 and sqlite3 adapters in ActiveRecord < 7.1 + # this patch is also used when using JDBC adapter + module ExecQueryAdapterPatch + def exec_query(sql, *args, **rest) + Instrumentation.detect_sql_injection(sql, adapter_name) + + super + end + end + end + end + end + end +end diff --git a/lib/datadog/appsec/contrib/active_record/integration.rb b/lib/datadog/appsec/contrib/active_record/integration.rb new file mode 100644 index 00000000000..00002f491d8 --- /dev/null +++ b/lib/datadog/appsec/contrib/active_record/integration.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative '../integration' +require_relative 'patcher' + +module Datadog + module AppSec + module Contrib + module ActiveRecord + # This class provides helper methods that are used when patching ActiveRecord + class Integration + include Datadog::AppSec::Contrib::Integration + + MINIMUM_VERSION = Gem::Version.new('4') + + register_as :active_record, auto_patch: false + + def self.version + Gem.loaded_specs['activerecord'] && Gem.loaded_specs['activerecord'].version + end + + def self.loaded? + !defined?(::ActiveRecord).nil? + end + + def self.compatible? + super && version >= MINIMUM_VERSION + end + + def self.auto_instrument? + true + end + + def patcher + Patcher + end + end + end + end + end +end diff --git a/lib/datadog/appsec/contrib/active_record/patcher.rb b/lib/datadog/appsec/contrib/active_record/patcher.rb new file mode 100644 index 00000000000..dd0c6c220ae --- /dev/null +++ b/lib/datadog/appsec/contrib/active_record/patcher.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative '../patcher' +require_relative 'instrumentation' + +module Datadog + module AppSec + module Contrib + module ActiveRecord + # AppSec patcher module for ActiveRecord + module Patcher + include Datadog::AppSec::Contrib::Patcher + + module_function + + def patched? + Patcher.instance_variable_get(:@patched) + end + + def target_version + Integration.version + end + + def patch + ActiveSupport.on_load :active_record do + 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::Mysql2Adapter) + ::ActiveRecord::ConnectionAdapters::Mysql2Adapter.prepend(instrumentation_module) + end + + if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) + unless defined?(::ActiveRecord::ConnectionAdapters::JdbcAdapter) + instrumentation_module = Instrumentation::ExecuteAndClearAdapterPatch + end + + ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(instrumentation_module) + end + end + end + end + end + end + end +end diff --git a/sig/datadog/appsec/contrib/active_record/instrumentation.rbs b/sig/datadog/appsec/contrib/active_record/instrumentation.rbs new file mode 100644 index 00000000000..daea0a9f684 --- /dev/null +++ b/sig/datadog/appsec/contrib/active_record/instrumentation.rbs @@ -0,0 +1,23 @@ +module Datadog + module AppSec + module Contrib + module ActiveRecord + module Instrumentation + def self?.detect_sql_injection: (String sql, String adapter_name) -> void + + module InternalExecQueryAdapterPatch + def internal_exec_query: (String sql, *untyped args, **untyped rest) -> untyped + end + + module ExecuteAndClearAdapterPatch + def execute_and_clear: (String sql, *untyped args, **untyped rest) -> untyped + end + + module ExecQueryAdapterPatch + def exec_query: (String sql, *untyped args, **untyped rest) -> untyped + end + end + end + end + end +end diff --git a/sig/datadog/appsec/contrib/active_record/integration.rbs b/sig/datadog/appsec/contrib/active_record/integration.rbs new file mode 100644 index 00000000000..781f186efc1 --- /dev/null +++ b/sig/datadog/appsec/contrib/active_record/integration.rbs @@ -0,0 +1,23 @@ +module Datadog + module AppSec + module Contrib + module ActiveRecord + class Integration + include Datadog::AppSec::Contrib::Integration + + MINIMUM_VERSION: Gem::Version + + def self.version: () -> Gem::Version? + + def self.loaded?: () -> bool + + def self.compatible?: () -> bool + + def self.auto_instrument?: () -> true + + def patcher: () -> class + end + end + end + end +end diff --git a/sig/datadog/appsec/contrib/active_record/patcher.rbs b/sig/datadog/appsec/contrib/active_record/patcher.rbs new file mode 100644 index 00000000000..2850b974c00 --- /dev/null +++ b/sig/datadog/appsec/contrib/active_record/patcher.rbs @@ -0,0 +1,17 @@ +module Datadog + module AppSec + module Contrib + module ActiveRecord + module Patcher + include Datadog::AppSec::Contrib::Patcher + + def self?.patched?: () -> bool + + def self?.target_version: () -> Gem::Version? + + def self?.patch: () -> void + end + end + 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/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