-
Notifications
You must be signed in to change notification settings - Fork 373
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4167 from DataDog/appsec-add-sqli
Add ActiveRecord instrumentation to detect SQLi in AppSec
- Loading branch information
Showing
12 changed files
with
558 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
73 changes: 73 additions & 0 deletions
73
lib/datadog/appsec/contrib/active_record/instrumentation.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
23
sig/datadog/appsec/contrib/active_record/instrumentation.rbs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
106
spec/datadog/appsec/contrib/active_record/mysql2_adapter_spec.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.