Skip to content

Commit

Permalink
Merge pull request #4167 from DataDog/appsec-add-sqli
Browse files Browse the repository at this point in the history
Add ActiveRecord instrumentation to detect SQLi in AppSec
  • Loading branch information
y9v authored Dec 2, 2024
2 parents 28675b6 + 971f329 commit ca7cc9d
Show file tree
Hide file tree
Showing 12 changed files with 558 additions and 1 deletion.
3 changes: 3 additions & 0 deletions Matrixfile
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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`"
Expand All @@ -280,6 +280,7 @@ namespace :spec do

# Datadog AppSec integrations
[
:active_record,
:rack,
:sinatra,
:rails,
Expand Down
1 change: 1 addition & 0 deletions lib/datadog/appsec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
73 changes: 73 additions & 0 deletions lib/datadog/appsec/contrib/active_record/instrumentation.rb
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions lib/datadog/appsec/contrib/active_record/integration.rb
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions lib/datadog/appsec/contrib/active_record/patcher.rb
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions sig/datadog/appsec/contrib/active_record/instrumentation.rbs
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions sig/datadog/appsec/contrib/active_record/integration.rbs
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions sig/datadog/appsec/contrib/active_record/patcher.rbs
Original file line number Diff line number Diff line change
@@ -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
106 changes: 106 additions & 0 deletions spec/datadog/appsec/contrib/active_record/mysql2_adapter_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit ca7cc9d

Please sign in to comment.