diff --git a/lib/datadog/appsec.rb b/lib/datadog/appsec.rb index 968b9c6a1fb..72ef8c1cfc0 100644 --- a/lib/datadog/appsec.rb +++ b/lib/datadog/appsec.rb @@ -40,6 +40,11 @@ def reconfigure_lock(&block) appsec_component.reconfigure_lock(&block) end + def api_security_enabled? + Datadog.configuration.appsec.api_security.enabled && + Datadog.configuration.appsec.api_security.sample_rate.sample? + end + private def components diff --git a/lib/datadog/appsec/context.rb b/lib/datadog/appsec/context.rb index 393cad27069..3472a160514 100644 --- a/lib/datadog/appsec/context.rb +++ b/lib/datadog/appsec/context.rb @@ -6,11 +6,9 @@ module AppSec # interface sufficient for instrumentation to perform threat detection. class Context ActiveContextError = Class.new(StandardError) + WAFMetrics = Struct.new(:timeouts, :duration_ns, :duration_ext_ns, keyword_init: true) - attr_reader :trace, :span - - # NOTE: This is an intermediate state and will be changed - attr_reader :waf_runner + attr_reader :trace, :span, :events, :waf_metrics class << self def activate(context) @@ -34,18 +32,33 @@ def active def initialize(trace, span, security_engine) @trace = trace @span = span + @events = [] @security_engine = security_engine - @waf_runner = security_engine.new_context + @waf_runner = security_engine.new_runner + @waf_metrics = WAFMetrics.new(timeouts: 0, duration_ns: 0, duration_ext_ns: 0) + @mutex = Mutex.new end def run_waf(persistent_data, ephemeral_data, timeout = WAF::LibDDWAF::DDWAF_RUN_TIMEOUT) - @waf_runner.run(persistent_data, ephemeral_data, timeout) + result = @waf_runner.run(persistent_data, ephemeral_data, timeout) + + @mutex.synchronize do + @waf_metrics.timeouts += 1 if result.timeout? + @waf_metrics.duration_ns += result.duration_ns + @waf_metrics.duration_ext_ns += result.duration_ext_ns + end + + result end def run_rasp(_type, persistent_data, ephemeral_data, timeout = WAF::LibDDWAF::DDWAF_RUN_TIMEOUT) @waf_runner.run(persistent_data, ephemeral_data, timeout) end + def extract_schema + @waf_runner.run({ 'waf.context.processor' => { 'extract-schema' => true } }, {}) + end + def finalize @waf_runner.finalize end diff --git a/lib/datadog/appsec/contrib/active_record/instrumentation.rb b/lib/datadog/appsec/contrib/active_record/instrumentation.rb index 41a8e80523c..aa0cfcf3236 100644 --- a/lib/datadog/appsec/contrib/active_record/instrumentation.rb +++ b/lib/datadog/appsec/contrib/active_record/instrumentation.rb @@ -25,7 +25,7 @@ def detect_sql_injection(sql, adapter_name) waf_timeout = Datadog.configuration.appsec.waf_timeout result = context.run_rasp(Ext::RASP_SQLI, {}, ephemeral_data, waf_timeout) - if result.status == :match + if result.match? Datadog::AppSec::Event.tag_and_keep!(context, result) event = { @@ -35,7 +35,7 @@ def detect_sql_injection(sql, adapter_name) sql: sql, actions: result.actions } - context.waf_runner.events << event + context.events << event end end diff --git a/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb b/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb index 025b6e10359..3991a3b6669 100644 --- a/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb @@ -38,7 +38,7 @@ def watch_multiplex(gateway = Instrumentation.gateway) } Datadog::AppSec::Event.tag_and_keep!(context, result) - context.waf_runner.events << event + context.events << event end block = GraphQL::Reactive::Multiplex.publish(engine, gateway_multiplex) diff --git a/lib/datadog/appsec/contrib/graphql/reactive/multiplex.rb b/lib/datadog/appsec/contrib/graphql/reactive/multiplex.rb index ec6380cff52..5235ade4015 100644 --- a/lib/datadog/appsec/contrib/graphql/reactive/multiplex.rb +++ b/lib/datadog/appsec/contrib/graphql/reactive/multiplex.rb @@ -32,7 +32,7 @@ def self.subscribe(engine, context) waf_timeout = Datadog.configuration.appsec.waf_timeout result = context.run_waf(persistent_data, {}, waf_timeout) - next if result.status != :match + next unless result.match? yield result throw(:block, true) unless result.actions.empty? diff --git a/lib/datadog/appsec/contrib/rack/gateway/watcher.rb b/lib/datadog/appsec/contrib/rack/gateway/watcher.rb index cab7ae3128e..f3ef9e46699 100644 --- a/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/rack/gateway/watcher.rb @@ -30,7 +30,7 @@ def watch_request(gateway = Instrumentation.gateway) engine = AppSec::Reactive::Engine.new Rack::Reactive::Request.subscribe(engine, context) do |result| - if result.status == :match + if result.match? # TODO: should this hash be an Event instance instead? event = { waf_result: result, @@ -43,7 +43,7 @@ def watch_request(gateway = Instrumentation.gateway) # We want to keep the trace in case of security event context.trace.keep! if context.trace Datadog::AppSec::Event.tag_and_keep!(context, result) - context.waf_runner.events << event + context.events << event end end @@ -61,7 +61,7 @@ def watch_response(gateway = Instrumentation.gateway) engine = AppSec::Reactive::Engine.new Rack::Reactive::Response.subscribe(engine, context) do |result| - if result.status == :match + if result.match? # TODO: should this hash be an Event instance instead? event = { waf_result: result, @@ -74,7 +74,7 @@ def watch_response(gateway = Instrumentation.gateway) # We want to keep the trace in case of security event context.trace.keep! if context.trace Datadog::AppSec::Event.tag_and_keep!(context, result) - context.waf_runner.events << event + context.events << event end end @@ -92,7 +92,7 @@ def watch_request_body(gateway = Instrumentation.gateway) engine = AppSec::Reactive::Engine.new Rack::Reactive::RequestBody.subscribe(engine, context) do |result| - if result.status == :match + if result.match? # TODO: should this hash be an Event instance instead? event = { waf_result: result, @@ -105,7 +105,7 @@ def watch_request_body(gateway = Instrumentation.gateway) # We want to keep the trace in case of security event context.trace.keep! if context.trace Datadog::AppSec::Event.tag_and_keep!(context, result) - context.waf_runner.events << event + context.events << event end end diff --git a/lib/datadog/appsec/contrib/rack/reactive/request.rb b/lib/datadog/appsec/contrib/rack/reactive/request.rb index 31333851412..96122aea06a 100644 --- a/lib/datadog/appsec/contrib/rack/reactive/request.rb +++ b/lib/datadog/appsec/contrib/rack/reactive/request.rb @@ -55,7 +55,7 @@ def self.subscribe(engine, context) waf_timeout = Datadog.configuration.appsec.waf_timeout result = context.run_waf(persistent_data, {}, waf_timeout) - next if result.status != :match + next unless result.match? yield result throw(:block, true) unless result.actions.empty? diff --git a/lib/datadog/appsec/contrib/rack/reactive/request_body.rb b/lib/datadog/appsec/contrib/rack/reactive/request_body.rb index 0cf74aef33f..ec3373d1406 100644 --- a/lib/datadog/appsec/contrib/rack/reactive/request_body.rb +++ b/lib/datadog/appsec/contrib/rack/reactive/request_body.rb @@ -33,7 +33,7 @@ def self.subscribe(engine, context) waf_timeout = Datadog.configuration.appsec.waf_timeout result = context.run_waf(persistent_data, {}, waf_timeout) - next if result.status != :match + next unless result.match? yield result throw(:block, true) unless result.actions.empty? diff --git a/lib/datadog/appsec/contrib/rack/reactive/response.rb b/lib/datadog/appsec/contrib/rack/reactive/response.rb index 9eca3e0157b..8853ef5b7dd 100644 --- a/lib/datadog/appsec/contrib/rack/reactive/response.rb +++ b/lib/datadog/appsec/contrib/rack/reactive/response.rb @@ -39,7 +39,7 @@ def self.subscribe(engine, context) waf_timeout = Datadog.configuration.appsec.waf_timeout result = context.run_waf(persistent_data, {}, waf_timeout) - next if result.status != :match + next unless result.match? yield result throw(:block, true) unless result.actions.empty? diff --git a/lib/datadog/appsec/contrib/rack/request_middleware.rb b/lib/datadog/appsec/contrib/rack/request_middleware.rb index abbdbca7b30..52cfcefc420 100644 --- a/lib/datadog/appsec/contrib/rack/request_middleware.rb +++ b/lib/datadog/appsec/contrib/rack/request_middleware.rb @@ -92,20 +92,20 @@ def call(env) http_response = AppSec::Response.negotiate(env, block_actions).to_rack if block_actions - if (result = ctx.waf_runner.extract_schema) - ctx.waf_runner.events << { + if AppSec.api_security_enabled? + ctx.events << { trace: ctx.trace, span: ctx.span, - waf_result: result, + waf_result: ctx.extract_schema, } end - ctx.waf_runner.events.each do |e| + ctx.events.each do |e| e[:response] ||= gateway_response e[:request] ||= gateway_request end - AppSec::Event.record(ctx.span, *ctx.waf_runner.events) + AppSec::Event.record(ctx.span, *ctx.events) http_response ensure @@ -200,15 +200,13 @@ def add_request_tags(context, env) def add_waf_runtime_tags(context) span = context.span - context = context.waf_runner - - return unless span && context + return unless span - span.set_tag('_dd.appsec.waf.timeouts', context.timeouts) + span.set_tag('_dd.appsec.waf.timeouts', context.waf_metrics.timeouts) # these tags expect time in us - span.set_tag('_dd.appsec.waf.duration', context.time_ns / 1000.0) - span.set_tag('_dd.appsec.waf.duration_ext', context.time_ext_ns / 1000.0) + span.set_tag('_dd.appsec.waf.duration', context.waf_metrics.duration_ns / 1000.0) + span.set_tag('_dd.appsec.waf.duration_ext', context.waf_metrics.duration_ext_ns / 1000.0) end def to_rack_header(header) diff --git a/lib/datadog/appsec/contrib/rails/gateway/watcher.rb b/lib/datadog/appsec/contrib/rails/gateway/watcher.rb index 4ae52c73333..33228b4bf9b 100644 --- a/lib/datadog/appsec/contrib/rails/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/rails/gateway/watcher.rb @@ -26,7 +26,7 @@ def watch_request_action(gateway = Instrumentation.gateway) engine = AppSec::Reactive::Engine.new Rails::Reactive::Action.subscribe(engine, context) do |result| - if result.status == :match + if result.match? # TODO: should this hash be an Event instance instead? event = { waf_result: result, @@ -39,7 +39,7 @@ def watch_request_action(gateway = Instrumentation.gateway) # We want to keep the trace in case of security event context.trace.keep! if context.trace Datadog::AppSec::Event.tag_and_keep!(context, result) - context.waf_runner.events << event + context.events << event end end diff --git a/lib/datadog/appsec/contrib/rails/reactive/action.rb b/lib/datadog/appsec/contrib/rails/reactive/action.rb index b1589b3131e..c25dd3d65a8 100644 --- a/lib/datadog/appsec/contrib/rails/reactive/action.rb +++ b/lib/datadog/appsec/contrib/rails/reactive/action.rb @@ -39,7 +39,7 @@ def self.subscribe(engine, context) waf_timeout = Datadog.configuration.appsec.waf_timeout result = context.run_waf(persistent_data, {}, waf_timeout) - next if result.status != :match + next unless result.match? yield result throw(:block, true) unless result.actions.empty? diff --git a/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb b/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb index a9ca382b904..90f7e669e9b 100644 --- a/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb @@ -28,7 +28,7 @@ def watch_request_dispatch(gateway = Instrumentation.gateway) engine = AppSec::Reactive::Engine.new Rack::Reactive::RequestBody.subscribe(engine, context) do |result| - if result.status == :match + if result.match? # TODO: should this hash be an Event instance instead? event = { waf_result: result, @@ -41,7 +41,7 @@ def watch_request_dispatch(gateway = Instrumentation.gateway) # We want to keep the trace in case of security event context.trace.keep! if context.trace Datadog::AppSec::Event.tag_and_keep!(context, result) - context.waf_runner.events << event + context.events << event end end @@ -59,7 +59,7 @@ def watch_request_routed(gateway = Instrumentation.gateway) engine = AppSec::Reactive::Engine.new Sinatra::Reactive::Routed.subscribe(engine, context) do |result| - if result.status == :match + if result.match? # TODO: should this hash be an Event instance instead? event = { waf_result: result, @@ -72,7 +72,7 @@ def watch_request_routed(gateway = Instrumentation.gateway) # We want to keep the trace in case of security event context.trace.keep! if context.trace Datadog::AppSec::Event.tag_and_keep!(context, result) - context.waf_runner.events << event + context.events << event end end diff --git a/lib/datadog/appsec/contrib/sinatra/reactive/routed.rb b/lib/datadog/appsec/contrib/sinatra/reactive/routed.rb index 7227d0539d6..c06003c791b 100644 --- a/lib/datadog/appsec/contrib/sinatra/reactive/routed.rb +++ b/lib/datadog/appsec/contrib/sinatra/reactive/routed.rb @@ -34,7 +34,7 @@ def self.subscribe(engine, context) waf_timeout = Datadog.configuration.appsec.waf_timeout result = context.run_waf(persistent_data, {}, waf_timeout) - next if result.status != :match + next unless result.match? yield result throw(:block, true) unless result.actions.empty? diff --git a/lib/datadog/appsec/monitor/gateway/watcher.rb b/lib/datadog/appsec/monitor/gateway/watcher.rb index f7d29bf5601..04595ffe52c 100644 --- a/lib/datadog/appsec/monitor/gateway/watcher.rb +++ b/lib/datadog/appsec/monitor/gateway/watcher.rb @@ -24,7 +24,7 @@ def watch_user_id(gateway = Instrumentation.gateway) engine = AppSec::Reactive::Engine.new Monitor::Reactive::SetUser.subscribe(engine, context) do |result| - if result.status == :match + if result.match? # TODO: should this hash be an Event instance instead? event = { waf_result: result, @@ -37,7 +37,7 @@ def watch_user_id(gateway = Instrumentation.gateway) # We want to keep the trace in case of security event context.trace.keep! if context.trace Datadog::AppSec::Event.tag_and_keep!(context, result) - context.waf_runner.events << event + context.events << event end end diff --git a/lib/datadog/appsec/monitor/reactive/set_user.rb b/lib/datadog/appsec/monitor/reactive/set_user.rb index 64df8931f30..995af5d3dcb 100644 --- a/lib/datadog/appsec/monitor/reactive/set_user.rb +++ b/lib/datadog/appsec/monitor/reactive/set_user.rb @@ -32,7 +32,7 @@ def self.subscribe(engine, context) waf_timeout = Datadog.configuration.appsec.waf_timeout result = context.run_waf(persistent_data, {}, waf_timeout) - next if result.status != :match + next unless result.match? yield result throw(:block, true) unless result.actions.empty? diff --git a/lib/datadog/appsec/processor.rb b/lib/datadog/appsec/processor.rb index 0197e0d5938..398dc779c79 100644 --- a/lib/datadog/appsec/processor.rb +++ b/lib/datadog/appsec/processor.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true -require_relative 'processor/context' +require_relative 'security_engine/runner' module Datadog module AppSec # Processor integrates libddwaf into datadog/appsec + # NOTE: This class will be moved under AppSec::SecurityEngine namespace class Processor attr_reader :diagnostics, :addresses @@ -29,8 +30,8 @@ def finalize @handle.finalize end - def new_context - Context.new(@handle, telemetry: @telemetry) + def new_runner + SecurityEngine::Runner.new(@handle, telemetry: @telemetry) end private diff --git a/lib/datadog/appsec/processor/context.rb b/lib/datadog/appsec/processor/context.rb deleted file mode 100644 index 7750d43dba0..00000000000 --- a/lib/datadog/appsec/processor/context.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -module Datadog - module AppSec - class Processor - # Context manages a sequence of runs - class Context - LIBDDWAF_SUCCESSFUL_EXECUTION_CODES = [:ok, :match].freeze - - attr_reader :time_ns, :time_ext_ns, :timeouts, :events - - def initialize(handle, telemetry:) - @context = WAF::Context.new(handle) - @telemetry = telemetry - - @time_ns = 0.0 - @time_ext_ns = 0.0 - @timeouts = 0 - @events = [] - @run_mutex = Mutex.new - - @libddwaf_debug_tag = "libddwaf:#{WAF::VERSION::STRING} method:ddwaf_run" - end - - def run(persistent_data, ephemeral_data, timeout = WAF::LibDDWAF::DDWAF_RUN_TIMEOUT) - @run_mutex.lock - - start_ns = Core::Utils::Time.get_time(:nanosecond) - - persistent_data.reject! do |_, v| - next false if v.is_a?(TrueClass) || v.is_a?(FalseClass) - - v.nil? ? true : v.empty? - end - - ephemeral_data.reject! do |_, v| - next false if v.is_a?(TrueClass) || v.is_a?(FalseClass) - - v.nil? ? true : v.empty? - end - - _code, result = try_run(persistent_data, ephemeral_data, timeout) - - stop_ns = Core::Utils::Time.get_time(:nanosecond) - - # these updates are not thread safe and should be protected - @time_ns += result.total_runtime - @time_ext_ns += (stop_ns - start_ns) - @timeouts += 1 if result.timeout - - report_execution(result) - result - ensure - @run_mutex.unlock - end - - def extract_schema - return unless extract_schema? - - input = { - 'waf.context.processor' => { - 'extract-schema' => true - } - } - - _code, result = try_run(input, {}, WAF::LibDDWAF::DDWAF_RUN_TIMEOUT) - - report_execution(result) - result - end - - def finalize - @context.finalize - end - - private - - def try_run(persistent_data, ephemeral_data, timeout) - @context.run(persistent_data, ephemeral_data, timeout) - rescue WAF::LibDDWAF::Error => e - Datadog.logger.debug { "#{@libddwaf_debug_tag} execution error: #{e} backtrace: #{e.backtrace&.first(3)}" } - @telemetry.report(e, description: 'libddwaf-rb internal low-level error') - - [:err_internal, WAF::Result.new(:err_internal, [], 0.0, false, [], [])] - end - - def report_execution(result) - Datadog.logger.debug { "#{@libddwaf_debug_tag} execution timed out: #{result.inspect}" } if result.timeout - - if LIBDDWAF_SUCCESSFUL_EXECUTION_CODES.include?(result.status) - Datadog.logger.debug { "#{@libddwaf_debug_tag} execution result: #{result.inspect}" } - else - message = "#{@libddwaf_debug_tag} execution error: #{result.status.inspect}" - - Datadog.logger.debug { message } - @telemetry.error(message) - end - end - - def extract_schema? - Datadog.configuration.appsec.api_security.enabled && - Datadog.configuration.appsec.api_security.sample_rate.sample? - end - end - end - end -end diff --git a/lib/datadog/appsec/security_engine.rb b/lib/datadog/appsec/security_engine.rb new file mode 100644 index 00000000000..58c36bc8f2d --- /dev/null +++ b/lib/datadog/appsec/security_engine.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + # A namespace for secutiry library we use to detect and prevent threats. + module SecurityEngine + end + end +end diff --git a/lib/datadog/appsec/security_engine/result.rb b/lib/datadog/appsec/security_engine/result.rb new file mode 100644 index 00000000000..4fa780df36f --- /dev/null +++ b/lib/datadog/appsec/security_engine/result.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module SecurityEngine + # A namespace for value-objects representing the result of WAF check. + module Result + # A generic result without indication of its type. + class Base + attr_reader :events, :actions, :derivatives, :duration_ns, :duration_ext_ns + + def initialize(events:, actions:, derivatives:, timeout:, duration_ns:, duration_ext_ns:) + @events = events + @actions = actions + @derivatives = derivatives + + @timeout = timeout + @duration_ns = duration_ns + @duration_ext_ns = duration_ext_ns + end + + def timeout? + !!@timeout + end + + def match? + raise NotImplementedError + end + end + + # A result that indicates a security rule match + class Match < Base + def match? + true + end + end + + # A result that indicates a successful security rules check without a match + class Ok < Base + def match? + false + end + end + + # A result that indicates an internal security library error + class Error + attr_reader :events, :actions, :derivatives, :duration_ns, :duration_ext_ns + + def initialize(duration_ext_ns:) + @events = [] + @actions = @derivatives = {} + @duration_ns = 0 + @duration_ext_ns = duration_ext_ns + end + + def timeout? + false + end + + def match? + false + end + end + end + end + end +end diff --git a/lib/datadog/appsec/security_engine/runner.rb b/lib/datadog/appsec/security_engine/runner.rb new file mode 100644 index 00000000000..899561c9ed5 --- /dev/null +++ b/lib/datadog/appsec/security_engine/runner.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative 'result' + +module Datadog + module AppSec + module SecurityEngine + # A class that check input via security engine (WAF) and respond with result. + class Runner + SUCCESSFUL_EXECUTION_CODES = [:ok, :match].freeze + + def initialize(handle, telemetry:) + @mutex = Mutex.new + @context = WAF::Context.new(handle) + @telemetry = telemetry + + @debug_tag = "libddwaf:#{WAF::VERSION::STRING} method:ddwaf_run" + end + + def run(persistent_data, ephemeral_data, timeout = WAF::LibDDWAF::DDWAF_RUN_TIMEOUT) + @mutex.lock + + start_ns = Core::Utils::Time.get_time(:nanosecond) + persistent_data.reject! do |_, v| + next false if v.is_a?(TrueClass) || v.is_a?(FalseClass) + + v.nil? ? true : v.empty? + end + + ephemeral_data.reject! do |_, v| + next false if v.is_a?(TrueClass) || v.is_a?(FalseClass) + + v.nil? ? true : v.empty? + end + + _code, result = try_run(persistent_data, ephemeral_data, timeout) + stop_ns = Core::Utils::Time.get_time(:nanosecond) + + report_execution(result) + + unless SUCCESSFUL_EXECUTION_CODES.include?(result.status) + return Result::Error.new(duration_ext_ns: stop_ns - start_ns) + end + + klass = result.status == :match ? Result::Match : Result::Ok + klass.new( + events: result.events, + actions: result.actions, + derivatives: result.derivatives, + timeout: result.timeout, + duration_ns: result.total_runtime, + duration_ext_ns: (stop_ns - start_ns) + ) + ensure + @mutex.unlock + end + + def finalize + @context.finalize + end + + private + + def try_run(persistent_data, ephemeral_data, timeout) + @context.run(persistent_data, ephemeral_data, timeout) + rescue WAF::LibDDWAF::Error => e + Datadog.logger.debug { "#{@debug_tag} execution error: #{e} backtrace: #{e.backtrace&.first(3)}" } + @telemetry.report(e, description: 'libddwaf-rb internal low-level error') + + [:err_internal, WAF::Result.new(:err_internal, [], 0, false, [], [])] + end + + def report_execution(result) + Datadog.logger.debug { "#{@debug_tag} execution timed out: #{result.inspect}" } if result.timeout + + if SUCCESSFUL_EXECUTION_CODES.include?(result.status) + Datadog.logger.debug { "#{@debug_tag} execution result: #{result.inspect}" } + else + message = "#{@debug_tag} execution error: #{result.status.inspect}" + + Datadog.logger.debug { message } + @telemetry.error(message) + end + end + end + end + end +end diff --git a/sig/datadog/appsec.rbs b/sig/datadog/appsec.rbs index c4adceb753a..97b6274eaf4 100644 --- a/sig/datadog/appsec.rbs +++ b/sig/datadog/appsec.rbs @@ -11,6 +11,8 @@ module Datadog def self.reconfigure_lock: () { () -> void } -> void + def self.api_security_enabled?: () -> bool + private def self.components: () -> Datadog::Core::Configuration::Components diff --git a/sig/datadog/appsec/context.rbs b/sig/datadog/appsec/context.rbs index 522072741b2..03d8504b12f 100644 --- a/sig/datadog/appsec/context.rbs +++ b/sig/datadog/appsec/context.rbs @@ -1,15 +1,31 @@ module Datadog module AppSec class Context - type waf_data = ::Hash[::String, untyped] + class WAFMetrics < ::Struct[untyped] + attr_accessor timeouts: ::Integer + + attr_accessor duration_ns: ::Integer + + attr_accessor duration_ext_ns: ::Integer + + def self.new: (timeouts: ::Integer, duration_ns: ::Integer, duration_ext_ns: ::Integer) -> void + end + + type input_data = SecurityEngine::Runner::input_data @trace: Tracing::TraceOperation @span: Tracing::SpanOperation + @events: ::Array[untyped] + @security_engine: Processor - @waf_runner: Processor::Context + @waf_runner: SecurityEngine::Runner + + @waf_metrics: WAFMetrics + + @mutex: ::Mutex ActiveContextError: ::StandardError @@ -17,7 +33,9 @@ module Datadog attr_reader span: Tracing::SpanOperation - attr_reader waf_runner: Processor::Context + attr_reader events: ::Array[untyped] + + attr_reader waf_metrics: WAFMetrics def self.activate: (Context context) -> Context @@ -27,9 +45,9 @@ module Datadog def initialize: (Tracing::TraceOperation trace, Tracing::SpanOperation span, AppSec::Processor security_engine) -> void - def run_waf: (waf_data persistent_data, waf_data ephemeral_data, ?Integer timeout) -> WAF::Result + def run_waf: (input_data persistent_data, input_data ephemeral_data, ?Integer timeout) -> SecurityEngine::result - def run_rasp: (::Symbol _type, waf_data persistent_data, waf_data ephemeral_data, ?Integer timeout) -> WAF::Result + def run_rasp: (::Symbol _type, input_data persistent_data, input_data ephemeral_data, ?Integer timeout) -> SecurityEngine::result def finalize: () -> void end diff --git a/sig/datadog/appsec/contrib/rack/gateway/response.rbs b/sig/datadog/appsec/contrib/rack/gateway/response.rbs index 717b896bcee..1e10340e7a6 100644 --- a/sig/datadog/appsec/contrib/rack/gateway/response.rbs +++ b/sig/datadog/appsec/contrib/rack/gateway/response.rbs @@ -10,9 +10,9 @@ module Datadog attr_reader headers: untyped - attr_reader active_context: Datadog::AppSec::Processor::Context + attr_reader context: AppSec::Context - def initialize: (untyped body, untyped status, untyped headers, active_context: Datadog::AppSec::Processor::Context) -> void + def initialize: (untyped body, untyped status, untyped headers, context: AppSec::Context) -> void def response: () -> untyped end diff --git a/sig/datadog/appsec/processor.rbs b/sig/datadog/appsec/processor.rbs index 4c97de2ba40..3f953b921b6 100644 --- a/sig/datadog/appsec/processor.rbs +++ b/sig/datadog/appsec/processor.rbs @@ -22,7 +22,7 @@ module Datadog def finalize: () -> void - def new_context: () -> Context + def new_runner: () -> SecurityEngine::Runner private diff --git a/sig/datadog/appsec/processor/context.rbs b/sig/datadog/appsec/processor/context.rbs deleted file mode 100644 index 7f228da7605..00000000000 --- a/sig/datadog/appsec/processor/context.rbs +++ /dev/null @@ -1,51 +0,0 @@ -module Datadog - module AppSec - class Processor - class Context - @context: WAF::Context - - @telemetry: Core::Telemetry::Component - - @time_ns: ::Float - - @time_ext_ns: ::Float - - @timeouts: ::Integer - - @events: ::Array[untyped] - - @run_mutex: ::Thread::Mutex - - @libddwaf_debug_tag: ::String - - LIBDDWAF_SUCCESSFUL_EXECUTION_CODES: ::Array[::Symbol] - - attr_reader time_ns: ::Float - - attr_reader time_ext_ns: ::Float - - attr_reader timeouts: ::Integer - - attr_reader events: ::Array[untyped] - - def initialize: (WAF::Handle handle, telemetry: Core::Telemetry::Component) -> void - - def run: (Hash[untyped, untyped] persistent_data, Hash[untyped, untyped] ephemeral_data, ?::Integer timeout) -> WAF::Result - - def extract_schema: () -> WAF::Result? - - def finalize: () -> void - - private - - type waf_data = Hash[untyped, untyped] - - def try_run: (waf_data persistent_data, waf_data ephemeral_data, ::Integer timeout) -> [::Symbol, WAF::Result] - - def report_execution: (WAF::Result result) -> void - - def extract_schema?: () -> bool - end - end - end -end diff --git a/sig/datadog/appsec/security_engine.rbs b/sig/datadog/appsec/security_engine.rbs new file mode 100644 index 00000000000..d64e47c425d --- /dev/null +++ b/sig/datadog/appsec/security_engine.rbs @@ -0,0 +1,9 @@ +module Datadog + module AppSec + module SecurityEngine + type result = Result::Ok + | Result::Match + | Result::Error + end + end +end diff --git a/sig/datadog/appsec/security_engine/result.rbs b/sig/datadog/appsec/security_engine/result.rbs new file mode 100644 index 00000000000..8df62719fde --- /dev/null +++ b/sig/datadog/appsec/security_engine/result.rbs @@ -0,0 +1,81 @@ +module Datadog + module AppSec + module SecurityEngine + module Result + type data = ::String | ::Hash[::String, data] | ::Array[data] + type events = ::Array[::Hash[::String, data]] + type actions = ::Hash[::String, data] + type derivatives = ::Hash[::String, data] + + class Base + @events: events + + @actions: actions + + @derivatives: derivatives + + @timeout: bool + + @duration_ns: ::Integer + + @duration_ext_ns: ::Integer + + attr_reader events: events + + attr_reader actions: actions + + attr_reader derivatives: derivatives + + attr_reader duration_ns: ::Integer + + attr_reader duration_ext_ns: ::Integer + + def initialize: (events: events, actions: actions, derivatives: derivatives, timeout: bool, duration_ns: ::Integer, duration_ext_ns: ::Integer) -> void + + def timeout?: () -> bool + + def match?: () -> bool + end + + # A result that indicates a security rule match + class Match < Base + def match?: () -> true + end + + # A result that indicates a successful security rules check without a match + class Ok < Base + def match?: () -> false + end + + # A result that indicates an internal security library error + class Error + @events: events + + @actions: actions + + @derivatives: derivatives + + @duration_ns: ::Integer + + @duration_ext_ns: ::Integer + + attr_reader events: events + + attr_reader actions: actions + + attr_reader derivatives: derivatives + + attr_reader duration_ns: ::Integer + + attr_reader duration_ext_ns: ::Integer + + def initialize: (duration_ext_ns: ::Integer) -> void + + def timeout?: () -> false + + def match?: () -> false + end + end + end + end +end diff --git a/sig/datadog/appsec/security_engine/runner.rbs b/sig/datadog/appsec/security_engine/runner.rbs new file mode 100644 index 00000000000..5e1c97eb586 --- /dev/null +++ b/sig/datadog/appsec/security_engine/runner.rbs @@ -0,0 +1,32 @@ +module Datadog + module AppSec + module SecurityEngine + class Runner + type waf_run_result = [::Symbol, WAF::Result] + type input_data = ::Hash[::String, untyped] + + @mutex: ::Mutex + + @context: WAF::Context + + @telemetry: Core::Telemetry::Component + + @debug_tag: ::String + + SUCCESSFUL_EXECUTION_CODES: ::Array[::Symbol] + + def initialize: (WAF::Handle handle, telemetry: Core::Telemetry::Component) -> void + + def run: (input_data persistent_data, input_data ephemeral_data, ?::Integer timeout) -> SecurityEngine::result + + def finalize: () -> void + + private + + def try_run: (input_data persistent_data, input_data ephemeral_data, untyped timeout) -> waf_run_result + + def report_execution: (WAF::Result result) -> void + end + end + end +end diff --git a/sig/datadog/core/utils/time.rbs b/sig/datadog/core/utils/time.rbs index 39a01face69..9ad725a0260 100644 --- a/sig/datadog/core/utils/time.rbs +++ b/sig/datadog/core/utils/time.rbs @@ -2,7 +2,9 @@ module Datadog module Core module Utils module Time - def self?.get_time: (?::Symbol unit) -> ::Numeric + def self?.get_time: () -> ::Float + | (:float_second | :float_millisecond | :float_microsecond unit) -> ::Float + | (:second | :millisecond | :microsecond | :nanosecond unit) -> Integer def self?.now: () -> ::Time def self?.now_provider=: (^() -> ::Time block) -> void def self?.get_time_provider=: (^(?::Symbol unit) -> ::Numeric block) -> void diff --git a/spec/datadog/appsec/context_spec.rb b/spec/datadog/appsec/context_spec.rb index e490cd6aaf3..a5e64b60ac6 100644 --- a/spec/datadog/appsec/context_spec.rb +++ b/spec/datadog/appsec/context_spec.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true require 'datadog/appsec/spec_helper' -require 'datadog/appsec/context' +require 'datadog/appsec/processor' +require 'datadog/appsec/processor/rule_loader' +require 'datadog/appsec/processor/rule_merger' RSpec.describe Datadog::AppSec::Context do let(:span) { instance_double(Datadog::Tracing::SpanOperation) } @@ -18,11 +20,11 @@ end describe '.active' do - context 'with no active context' do + context 'when no active context is set' do it { expect(described_class.active).to be_nil } end - context 'with an active context' do + context 'when active context is set' do before { described_class.activate(context) } it { expect(described_class.active).to eq(context) } @@ -32,16 +34,16 @@ describe '.activate' do it { expect { described_class.activate(double) }.to raise_error(ArgumentError) } - context 'with no active context' do + context 'when no active context is set' do it { expect { described_class.activate(context) }.to change { described_class.active }.from(nil).to(context) } end - context 'with an active context' do + context 'when active context is already set' do before { described_class.activate(context) } subject(:activate_context) { described_class.activate(described_class.new(trace, span, processor)) } - it 'raises ActiveContextError and does not change the active context' do + it 'raises an error and does not change the active context' do expect { activate_context }.to raise_error(Datadog::AppSec::Context::ActiveContextError) .and(not_change { described_class.active }) end @@ -49,13 +51,13 @@ end describe '.deactivate' do - context 'with no active context' do + context 'when no active context is set' do it 'does not change the active context' do expect { described_class.deactivate }.to_not(change { described_class.active }) end end - context 'with an active context' do + context 'when active context is set' do before do described_class.activate(context) expect(context).to receive(:finalize).and_call_original @@ -66,7 +68,7 @@ end end - context 'with error during deactivation' do + context 'when error happen during deactivation' do before do described_class.activate(context) expect(context).to receive(:finalize).and_raise(RuntimeError.new('Ooops')) @@ -78,4 +80,93 @@ end end end + + describe '#run_waf' do + context 'when multiple same matching runs were made within a single context' do + let!(:run_results) do + persistent_data = { + 'server.request.headers.no_cookies' => { 'user-agent' => 'Nessus SOAP' } + } + + Array.new(3) { context.run_waf(persistent_data, {}, 10_000) } + end + + it 'returns a single match and rest is ok' do + expect(run_results).to match_array( + [ + kind_of(Datadog::AppSec::SecurityEngine::Result::Match), + kind_of(Datadog::AppSec::SecurityEngine::Result::Ok), + kind_of(Datadog::AppSec::SecurityEngine::Result::Ok) + ] + ) + end + end + + context 'when multiple different matching runs were made within a single context' do + let!(:run_results) do + persistent_data_1 = { 'server.request.query' => { 'q' => '1 OR 1;' } } + persistent_data_2 = { + 'server.request.headers.no_cookies' => { 'user-agent' => 'Nessus SOAP' } + } + + [ + context.run_waf(persistent_data_1, {}, 10_000), + context.run_waf(persistent_data_2, {}, 10_000), + ] + end + + it 'returns a single match and rest is ok' do + expect(run_results).to match_array( + [ + kind_of(Datadog::AppSec::SecurityEngine::Result::Match), + kind_of(Datadog::AppSec::SecurityEngine::Result::Match) + ] + ) + end + end + end + + describe '#extract_schema' do + it 'calls the waf runner with specific addresses' do + expect_any_instance_of(Datadog::AppSec::SecurityEngine::Runner).to receive(:run) + .with({ 'waf.context.processor' => { 'extract-schema' => true } }, {}) + .and_call_original + + expect(context.extract_schema).to be_instance_of(Datadog::AppSec::SecurityEngine::Result::Ok) + end + end + + describe '#waf_metrics' do + context 'when multiple calls were successful' do + let!(:run_results) do + persistent_data = { + 'server.request.headers.no_cookies' => { 'user-agent' => 'Nessus SOAP' } + } + Array.new(3) { context.run_waf(persistent_data, {}, 10_000) } + end + + it 'returns metrics containing 0 timeouts and cumulative durations' do + expect(context.waf_metrics.timeouts).to eq(0) + expect(context.waf_metrics.duration_ns).to be > 0 + expect(context.waf_metrics.duration_ext_ns).to be > 0 + expect(context.waf_metrics.duration_ns).to eq(run_results.sum(&:duration_ns)) + expect(context.waf_metrics.duration_ext_ns).to eq(run_results.sum(&:duration_ext_ns)) + end + end + + context 'when multiple calls have timeouts' do + let!(:run_results) do + persistent_data = { + 'server.request.headers.no_cookies' => { 'user-agent' => 'Nessus SOAP' } + } + Array.new(5) { context.run_waf(persistent_data, {}, 0) } + end + + it 'returns metrics containing 5 timeouts and cumulative durations' do + expect(context.waf_metrics.timeouts).to eq(5) + expect(context.waf_metrics.duration_ns).to eq(0) + expect(context.waf_metrics.duration_ext_ns).to eq(run_results.sum(&:duration_ext_ns)) + 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 index 59306b5a7ba..72b73b22162 100644 --- a/spec/datadog/appsec/contrib/active_record/mysql2_adapter_spec.rb +++ b/spec/datadog/appsec/contrib/active_record/mysql2_adapter_spec.rb @@ -96,12 +96,13 @@ 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_context).to( - receive(:run_rasp).and_return(instance_double(Datadog::AppSec::WAF::Result, status: :match, actions: {})) + it 'adds an event to processor context if waf result is a match' do + result = Datadog::AppSec::SecurityEngine::Result::Match.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 ) - expect(Datadog::AppSec.active_context.waf_runner.events).to receive(:<<).and_call_original + expect(Datadog::AppSec.active_context).to receive(:run_rasp).and_return(result) + expect(Datadog::AppSec.active_context.events).to receive(:<<).and_call_original User.where(name: 'Bob').to_a 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 index 1a718855f00..2ea1825d279 100644 --- a/spec/datadog/appsec/contrib/active_record/postgresql_adapter_spec.rb +++ b/spec/datadog/appsec/contrib/active_record/postgresql_adapter_spec.rb @@ -103,12 +103,13 @@ 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_context).to( - receive(:run_rasp).and_return(instance_double(Datadog::AppSec::WAF::Result, status: :match, actions: {})) + it 'adds an event to processor context if waf result is a match' do + result = Datadog::AppSec::SecurityEngine::Result::Match.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 ) - expect(Datadog::AppSec.active_context.waf_runner.events).to receive(:<<).and_call_original + expect(Datadog::AppSec.active_context).to receive(:run_rasp).and_return(result) + expect(Datadog::AppSec.active_context.events).to receive(:<<).and_call_original User.where(name: 'Bob').to_a 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 index c15246f9017..26607bca764 100644 --- a/spec/datadog/appsec/contrib/active_record/sqlite3_adapter_spec.rb +++ b/spec/datadog/appsec/contrib/active_record/sqlite3_adapter_spec.rb @@ -90,12 +90,13 @@ 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_context).to( - receive(:run_rasp).and_return(instance_double(Datadog::AppSec::WAF::Result, status: :match, actions: {})) + it 'adds an event to processor context if waf result is a match' do + result = Datadog::AppSec::SecurityEngine::Result::Match.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 ) - expect(Datadog::AppSec.active_context.waf_runner.events).to receive(:<<).and_call_original + expect(Datadog::AppSec.active_context).to receive(:run_rasp).and_return(result) + expect(Datadog::AppSec.active_context.events).to receive(:<<).and_call_original User.where(name: 'Bob').to_a end diff --git a/spec/datadog/appsec/contrib/graphql/reactive/multiplex_spec.rb b/spec/datadog/appsec/contrib/graphql/reactive/multiplex_spec.rb index b1354a738cb..353d9684984 100644 --- a/spec/datadog/appsec/contrib/graphql/reactive/multiplex_spec.rb +++ b/spec/datadog/appsec/contrib/graphql/reactive/multiplex_spec.rb @@ -42,19 +42,22 @@ end context 'all addresses have been published' do + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + it 'does call the waf context with the right arguments' do expect(engine).to receive(:subscribe).and_call_original + expect(appsec_context).to receive(:run_waf) + .with({ 'graphql.server.all_resolvers' => expected_arguments }, {}, Datadog.configuration.appsec.waf_timeout) + .and_return(waf_result) - waf_result = double(:waf_result, status: :ok, timeout: false) - expect(appsec_context).to receive(:run_waf).with( - { 'graphql.server.all_resolvers' => expected_arguments }, - {}, - Datadog.configuration.appsec.waf_timeout - ).and_return(waf_result) described_class.subscribe(engine, appsec_context) gateway_multiplex = Datadog::AppSec::Contrib::GraphQL::Gateway::Multiplex.new(multiplex) - result = described_class.publish(engine, gateway_multiplex) - expect(result).to be_nil + + expect(described_class.publish(engine, gateway_multiplex)).to be_nil end end diff --git a/spec/datadog/appsec/contrib/rack/reactive/request_body_spec.rb b/spec/datadog/appsec/contrib/rack/reactive/request_body_spec.rb index e4363628dd7..8140396edc4 100644 --- a/spec/datadog/appsec/contrib/rack/reactive/request_body_spec.rb +++ b/spec/datadog/appsec/contrib/rack/reactive/request_body_spec.rb @@ -39,20 +39,22 @@ end context 'all addresses have been published' do + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + it 'does call the waf context with the right arguments' do expect(engine).to receive(:subscribe).and_call_original expected_waf_arguments = { 'server.request.body' => { 'foo' => 'bar' } } + expect(appsec_context).to receive(:run_waf) + .with(expected_waf_arguments, {}, Datadog.configuration.appsec.waf_timeout) + .and_return(waf_result) - waf_result = double(:waf_result, status: :ok, timeout: false) - expect(appsec_context).to receive(:run_waf).with( - expected_waf_arguments, - {}, - Datadog.configuration.appsec.waf_timeout - ).and_return(waf_result) described_class.subscribe(engine, appsec_context) - result = described_class.publish(engine, request) - expect(result).to be_nil + expect(described_class.publish(engine, request)).to be_nil end end diff --git a/spec/datadog/appsec/contrib/rack/reactive/request_spec.rb b/spec/datadog/appsec/contrib/rack/reactive/request_spec.rb index b0327350d70..b3c6aff3764 100644 --- a/spec/datadog/appsec/contrib/rack/reactive/request_spec.rb +++ b/spec/datadog/appsec/contrib/rack/reactive/request_spec.rb @@ -65,6 +65,12 @@ end context 'all addresses have been published' do + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + it 'does call the waf context with the right arguments' do expect(engine).to receive(:subscribe).and_call_original @@ -78,15 +84,12 @@ 'server.request.method' => 'GET', } - waf_result = double(:waf_result, status: :ok, timeout: false) - expect(appsec_context).to receive(:run_waf).with( - expected_waf_arguments, - {}, - Datadog.configuration.appsec.waf_timeout - ).and_return(waf_result) + expect(appsec_context).to receive(:run_waf) + .with(expected_waf_arguments, {}, Datadog.configuration.appsec.waf_timeout) + .and_return(waf_result) + described_class.subscribe(engine, appsec_context) - result = described_class.publish(engine, request) - expect(result).to be_nil + expect(described_class.publish(engine, request)).to be_nil end end diff --git a/spec/datadog/appsec/contrib/rack/reactive/response_spec.rb b/spec/datadog/appsec/contrib/rack/reactive/response_spec.rb index fb15acf8f4e..a1dcfb507c3 100644 --- a/spec/datadog/appsec/contrib/rack/reactive/response_spec.rb +++ b/spec/datadog/appsec/contrib/rack/reactive/response_spec.rb @@ -48,7 +48,11 @@ context 'waf arguments' do before { expect(engine).to receive(:subscribe).and_call_original } - let(:waf_result) { double(:waf_result, status: :ok, timeout: false) } + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end context 'all addresses have been published' do let(:expected_waf_arguments) do @@ -65,16 +69,12 @@ end it 'does call the waf context with the right arguments' do - expect(appsec_context).to receive(:run_waf).with( - expected_waf_arguments, - {}, - Datadog.configuration.appsec.waf_timeout - ).and_return(waf_result) + expect(appsec_context).to receive(:run_waf) + .with(expected_waf_arguments, {}, Datadog.configuration.appsec.waf_timeout) + .and_return(waf_result) described_class.subscribe(engine, appsec_context) - result = described_class.publish(engine, response) - - expect(result).to be_nil + expect(described_class.publish(engine, response)).to be_nil end end end diff --git a/spec/datadog/appsec/contrib/rails/reactive/action_spec.rb b/spec/datadog/appsec/contrib/rails/reactive/action_spec.rb index 6ba2e54e988..9575624eb31 100644 --- a/spec/datadog/appsec/contrib/rails/reactive/action_spec.rb +++ b/spec/datadog/appsec/contrib/rails/reactive/action_spec.rb @@ -46,6 +46,12 @@ end context 'all addresses have been published' do + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + it 'does call the waf context with the right arguments' do expect(engine).to receive(:subscribe).and_call_original @@ -54,15 +60,12 @@ 'server.request.path_params' => { id: '1234' } } - waf_result = double(:waf_result, status: :ok, timeout: false) - expect(appsec_context).to receive(:run_waf).with( - expected_waf_arguments, - {}, - Datadog.configuration.appsec.waf_timeout - ).and_return(waf_result) + expect(appsec_context).to receive(:run_waf) + .with(expected_waf_arguments, {}, Datadog.configuration.appsec.waf_timeout) + .and_return(waf_result) + described_class.subscribe(engine, appsec_context) - result = described_class.publish(engine, request) - expect(result).to be_nil + expect(described_class.publish(engine, request)).to be_nil end end diff --git a/spec/datadog/appsec/contrib/sinatra/reactive/routed_spec.rb b/spec/datadog/appsec/contrib/sinatra/reactive/routed_spec.rb index 5183002b0cd..d777f895b3d 100644 --- a/spec/datadog/appsec/contrib/sinatra/reactive/routed_spec.rb +++ b/spec/datadog/appsec/contrib/sinatra/reactive/routed_spec.rb @@ -41,22 +41,24 @@ end context 'all addresses have been published' do - it 'does call the waf context with the right arguments' do - expect(engine).to receive(:subscribe).and_call_original + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + it 'does call the waf context with the right arguments' do expected_waf_arguments = { 'server.request.path_params' => { id: '1234' } } - waf_result = double(:waf_result, status: :ok, timeout: false) - expect(appsec_context).to receive(:run_waf).with( - expected_waf_arguments, - {}, - Datadog.configuration.appsec.waf_timeout - ).and_return(waf_result) + expect(engine).to receive(:subscribe).and_call_original + expect(appsec_context).to receive(:run_waf) + .with(expected_waf_arguments, {}, Datadog.configuration.appsec.waf_timeout) + .and_return(waf_result) + described_class.subscribe(engine, appsec_context) - result = described_class.publish(engine, [request, routed_params]) - expect(result).to be_nil + expect(described_class.publish(engine, [request, routed_params])).to be_nil end end diff --git a/spec/datadog/appsec/monitor/reactive/set_user_spec.rb b/spec/datadog/appsec/monitor/reactive/set_user_spec.rb index 757fc5f9ed8..00018716a23 100644 --- a/spec/datadog/appsec/monitor/reactive/set_user_spec.rb +++ b/spec/datadog/appsec/monitor/reactive/set_user_spec.rb @@ -29,20 +29,20 @@ end context 'all addresses have been published' do + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + it 'does call the waf context with the right arguments' do expect(engine).to receive(:subscribe).and_call_original + expect(appsec_context).to receive(:run_waf) + .with({ 'usr.id' => 1 }, {}, Datadog.configuration.appsec.waf_timeout) + .and_return(waf_result) - expected_waf_persisted_data = { 'usr.id' => 1 } - - waf_result = double(:waf_result, status: :ok, timeout: false) - expect(appsec_context).to receive(:run_waf).with( - expected_waf_persisted_data, - {}, - Datadog.configuration.appsec.waf_timeout - ).and_return(waf_result) described_class.subscribe(engine, appsec_context) - result = described_class.publish(engine, user) - expect(result).to be_nil + expect(described_class.publish(engine, user)).to be_nil end end diff --git a/spec/datadog/appsec/processor/context_spec.rb b/spec/datadog/appsec/processor/context_spec.rb deleted file mode 100644 index 7a435053528..00000000000 --- a/spec/datadog/appsec/processor/context_spec.rb +++ /dev/null @@ -1,339 +0,0 @@ -# frozen_string_literal: true - -require 'datadog/appsec/spec_helper' -require 'datadog/appsec/processor/context' -require 'datadog/appsec/processor/rule_loader' -require 'datadog/appsec/processor/rule_merger' - -RSpec.describe Datadog::AppSec::Processor::Context do - let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } - let(:ruleset) do - rules = Datadog::AppSec::Processor::RuleLoader.load_rules(ruleset: :recommended, telemetry: telemetry) - Datadog::AppSec::Processor::RuleMerger.merge(rules: [rules], telemetry: telemetry) - end - - let(:input_safe) { { 'server.request.headers.no_cookies' => { 'user-agent' => 'Ruby' } } } - let(:input_sqli) { { 'server.request.query' => { 'q' => '1 OR 1;' } } } - let(:input_scanner) { { 'server.request.headers.no_cookies' => { 'user-agent' => 'Nessus SOAP' } } } - let(:input_client_ip) { { 'http.client_ip' => '1.2.3.4' } } - - let(:client_ip) { '1.2.3.4' } - let(:input) { input_scanner } - let(:processor) { Datadog::AppSec::Processor.new(ruleset: ruleset, telemetry: telemetry) } - - let(:run_count) { 1 } - let(:timeout) { 10_000_000_000 } - - let(:runs) { Array.new(run_count) { context.run(input, {}, timeout) } } - let(:results) { runs } - let(:overall_runtime) { results.reduce(0) { |a, e| a + e.total_runtime } } - - let(:result) do - expect(results).to have_attributes(count: 1) - - results.first - end - - subject(:context) { processor.new_context } - - before { runs } - after do - context.finalize - processor.finalize - end - - it { expect(result.status).to eq :match } - it { expect(context.time_ns).to be > 0 } - it { expect(context.time_ext_ns).to be > 0 } - it { expect(context.time_ext_ns).to be > context.time_ns } - it { expect(context.time_ns).to eq(overall_runtime) } - it { expect(context.timeouts).to eq 0 } - - context 'with timeout' do - let(:timeout) { 0 } - - it { expect(result.status).to eq :ok } - it { expect(context.time_ns).to eq 0 } - it { expect(context.time_ext_ns).to be > 0 } - it { expect(context.timeouts).to eq run_count } - end - - context 'with multiple runs' do - let(:run_count) { 10 } - - it { expect(context.time_ns).to eq(overall_runtime) } - - context 'with timeout' do - let(:timeout) { 0 } - - it { expect(results.first.status).to eq :ok } - it { expect(context.time_ns).to eq 0 } - it { expect(context.time_ext_ns).to be > 0 } - it { expect(context.timeouts).to eq run_count } - end - end - - describe '#run' do - let(:matches) do - results.reject { |r| r.status == :ok } - end - - let(:events) { matches.flat_map(&:events) } - let(:actions) { matches.map(&:actions) } - - context 'clear key with empty values' do - it 'removes nil values' do - input = { - 'nil_value' => nil, - 'string_value' => 'hello' - } - expect(context.instance_variable_get(:@context)).to receive(:run).with( - { - 'string_value' => 'hello' - }, - {}, - timeout - ).and_call_original - - context.run(input, {}, timeout) - end - - it 'do not removes boolean values' do - input = { - 'false_value' => false, - 'true_value' => true - } - expect(context.instance_variable_get(:@context)).to receive(:run).with( - input, {}, timeout - ).and_call_original - - context.run(input, {}, timeout) - end - - it 'removes empty string values' do - input = { - 'empty_string_value' => '', - 'string_value' => 'hello' - } - expect(context.instance_variable_get(:@context)).to receive(:run).with( - { - 'string_value' => 'hello' - }, - {}, - timeout - ).and_call_original - - context.run(input, {}, timeout) - end - - it 'removes empty arrays values' do - input = { - 'empty_array' => [], - 'non_empty_array_value' => [1, 2], - } - expect(context.instance_variable_get(:@context)).to receive(:run).with( - { - 'non_empty_array_value' => [1, 2] - }, - {}, - timeout - ).and_call_original - - context.run(input, {}, timeout) - end - - it 'removes empty hash values' do - input = { - 'empty_hash' => {}, - 'non_empty_hash_value' => { 'hello' => 'world' }, - } - expect(context.instance_variable_get(:@context)).to receive(:run).with( - { - 'non_empty_hash_value' => { 'hello' => 'world' } - }, - {}, - timeout - ).and_call_original - - context.run(input, {}, timeout) - end - end - - context 'no attack' do - let(:input) { input_safe } - - it { expect(matches).to eq [] } - it { expect(events).to eq [] } - it { expect(actions).to eq [] } - end - - context 'one attack' do - let(:input) { input_scanner } - - it { expect(telemetry).not_to receive(:error) } - it { expect(matches).to have_attributes(count: 1) } - it { expect(events).to have_attributes(count: 1) } - it { expect(actions).to eq [{}] } - end - - context 'multiple attacks per run' do - let(:input) { input_scanner.merge(input_sqli) } - - it { expect(matches).to have_attributes(count: 1) } - it { expect(events).to have_attributes(count: 2) } - it { expect(actions).to eq [{}] } - end - - context 'multiple runs' do - context 'same attack' do - let(:runs) do - [ - context.run(input_scanner, {}, timeout), - context.run(input_scanner, {}, timeout) - ] - end - - # when the same attack is detected twice in the same context, it's - # only matching once therefore there's only one match result, thus - # one action list returned. - - it { expect(matches).to have_attributes(count: 1) } - it { expect(events).to have_attributes(count: 1) } - it { expect(actions).to eq [{}] } - end - - context 'different attacks' do - let(:runs) do - [ - context.run(input_sqli, {}, timeout), - context.run(input_scanner, {}, timeout) - ] - end - - # when two attacks are detected in the same context there are two - # match results, thus two action lists, one for each. - - it { expect(matches).to have_attributes(count: 2) } - it { expect(events).to have_attributes(count: 2) } - it { expect(actions).to eq [{}, {}] } - end - end - - context 'one blockable attack' do - let(:input) { input_client_ip } - - let(:ruleset) do - rules = Datadog::AppSec::Processor::RuleLoader.load_rules(ruleset: :recommended, telemetry: telemetry) - data = Datadog::AppSec::Processor::RuleLoader.load_data(ip_denylist: [client_ip]) - - Datadog::AppSec::Processor::RuleMerger.merge( - rules: [rules], - data: data, - telemetry: telemetry - ) - end - - it { expect(matches).to have_attributes(count: 1) } - it { expect(events).to have_attributes(count: 1) } - - it do - expect(actions).to( - eq([{ 'block_request' => { 'grpc_status_code' => '10', 'status_code' => '403', 'type' => 'auto' } }]) - ) - end - end - - context 'run failed with libddwaf error result' do - before do - allow(context.instance_variable_get(:@context)).to receive(:run).with(input, {}, timeout) - .and_return([result.status, result]) - end - - let(:result) do - instance_double( - Datadog::AppSec::WAF::Result, - status: :err_invalid_object, - total_runtime: 0.01, - timeout: false - ) - end - - it 'sends telemetry error' do - expect(telemetry).to receive(:error).with(/libddwaf:[\d.]+ method:ddwaf_run execution error: :err_invalid_object/) - - context.run(input, {}, timeout) - end - end - - context 'run failed with libddwaf low-level exception' do - before do - allow(context.instance_variable_get(:@context)).to receive(:run).with(input, {}, timeout) - .and_raise(Datadog::AppSec::WAF::LibDDWAF::Error, 'Could not convert persistent data') - end - - let(:result) { context.run(input, {}, timeout) } - - it 'sends telemetry report' do - expect(telemetry).to receive(:error).with(/libddwaf:[\d.]+ method:ddwaf_run execution error: :err_internal/) - expect(telemetry).to receive(:report) - .with(kind_of(Datadog::AppSec::WAF::LibDDWAF::Error), description: 'libddwaf-rb internal low-level error') - - expect(result.status).to eq(:err_internal) - end - end - end - - describe '#extract_schema' do - context 'when extrct_schema? returns true' do - around do |example| - ClimateControl.modify( - 'DD_EXPERIMENTAL_API_SECURITY_ENABLED' => 'true', - 'DD_API_SECURITY_REQUEST_SAMPLE_RATE' => '1' - ) do - example.run - end - end - - it 'calls the the WAF with the right arguments' do - input = { - 'waf.context.processor' => { - 'extract-schema' => true - } - } - - result = instance_double(Datadog::AppSec::WAF::Result, status: :ok, timeout: false) - - expect(context.instance_variable_get(:@context)).to receive(:run).with( - input, - {}, - Datadog::AppSec::WAF::LibDDWAF::DDWAF_RUN_TIMEOUT - ).and_return([result.status, result]) - - expect(context.extract_schema).to eq result - end - - it 'returns schema extraction information' do - input = { 'server.request.query' => { 'vin' => '4Y1SL65848Z411439' } } - context.run(input, {}, timeout) - - results = context.extract_schema - derivatives = results.derivatives - - expect(derivatives).to_not be_empty - expect(derivatives['_dd.appsec.s.req.query']).to eq([{ 'vin' => [8, { 'category' => 'pii', 'type' => 'vin' }] }]) - end - end - - context 'when extrct_schema? returns false' do - around do |example| - ClimateControl.modify('DD_EXPERIMENTAL_API_SECURITY_ENABLED' => 'false') do - example.run - end - end - - it 'returns nil' do - expect(context.extract_schema).to be_nil - end - end - end -end diff --git a/spec/datadog/appsec/processor_spec.rb b/spec/datadog/appsec/processor_spec.rb index bccda80a0e3..d18c53420ad 100644 --- a/spec/datadog/appsec/processor_spec.rb +++ b/spec/datadog/appsec/processor_spec.rb @@ -284,9 +284,9 @@ def diagnostics end end - describe '#new_context' do + describe '#new_runner' do let(:processor) { described_class.new(ruleset: ruleset, telemetry: telemetry) } - it { expect(processor.new_context).to be_instance_of(described_class::Context) } + it { expect(processor.new_runner).to be_instance_of(Datadog::AppSec::SecurityEngine::Runner) } end end diff --git a/spec/datadog/appsec/reactive/shared_examples.rb b/spec/datadog/appsec/reactive/shared_examples.rb index 4d1ebe37adc..98136c4f2dc 100644 --- a/spec/datadog/appsec/reactive/shared_examples.rb +++ b/spec/datadog/appsec/reactive/shared_examples.rb @@ -3,99 +3,62 @@ RSpec.shared_examples 'waf result' do context 'is a match' do it 'yields result and no blocking action' do - expect(engine).to receive(:subscribe).and_call_original + waf_result = Datadog::AppSec::SecurityEngine::Result::Match.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) - waf_result = double(:waf_result, status: :match, timeout: false, actions: []) + expect(engine).to receive(:subscribe).and_call_original expect(appsec_context).to receive(:run_waf).and_return(waf_result) + described_class.subscribe(engine, appsec_context) do |result| expect(result).to eq(waf_result) end - result = described_class.publish(engine, gateway) - expect(result).to be_nil + expect(described_class.publish(engine, gateway)).to be_nil end it 'yields result and blocking action. The publish method catches the resul as well' do - expect(engine).to receive(:subscribe).and_call_original + actions = { 'block_request' => { 'grpc_status_code' => '10', 'status_code' => '403', 'type' => 'auto' } } + waf_result = Datadog::AppSec::SecurityEngine::Result::Match.new( + events: [], actions: actions, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) - waf_result = double(:waf_result, status: :match, timeout: false, actions: ['block']) + expect(engine).to receive(:subscribe).and_call_original expect(appsec_context).to receive(:run_waf).and_return(waf_result) + described_class.subscribe(engine, appsec_context) do |result| expect(result).to eq(waf_result) end - block = described_class.publish(engine, gateway) - expect(block).to eq(true) + expect(described_class.publish(engine, gateway)).to eq(true) end end context 'is ok' do - it 'does not yield' do - expect(engine).to receive(:subscribe).and_call_original - - waf_result = double(:waf_result, status: :ok, timeout: false) - expect(appsec_context).to receive(:run_waf).and_return(waf_result) - expect { |b| described_class.subscribe(engine, appsec_context, &b) }.not_to yield_control - result = described_class.publish(engine, gateway) - expect(result).to be_nil + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) end - end - context 'is invalid_call' do it 'does not yield' do expect(engine).to receive(:subscribe).and_call_original - - waf_result = double(:waf_result, status: :invalid_call, timeout: false) expect(appsec_context).to receive(:run_waf).and_return(waf_result) expect { |b| described_class.subscribe(engine, appsec_context, &b) }.not_to yield_control - result = described_class.publish(engine, gateway) - expect(result).to be_nil - end - end - - context 'is invalid_rule' do - it 'does not yield' do - expect(engine).to receive(:subscribe).and_call_original - waf_result = double(:waf_result, status: :invalid_rule, timeout: false) - expect(appsec_context).to receive(:run_waf).and_return(waf_result) - expect { |b| described_class.subscribe(engine, appsec_context, &b) }.not_to yield_control - result = described_class.publish(engine, gateway) - expect(result).to be_nil + expect(described_class.publish(engine, gateway)).to be_nil end end - context 'is invalid_flow' do - it 'does not yield' do - expect(engine).to receive(:subscribe).and_call_original - - waf_result = double(:waf_result, status: :invalid_flow, timeout: false) - expect(appsec_context).to receive(:run_waf).and_return(waf_result) - expect { |b| described_class.subscribe(engine, appsec_context, &b) }.not_to yield_control - result = described_class.publish(engine, gateway) - expect(result).to be_nil + context 'is invalid_call' do + let(:waf_result) do + Datadog::AppSec::SecurityEngine::Result::Error.new(duration_ext_ns: 0) end - end - context 'is no_rule' do it 'does not yield' do expect(engine).to receive(:subscribe).and_call_original - - waf_result = double(:waf_result, status: :no_rule, timeout: false) expect(appsec_context).to receive(:run_waf).and_return(waf_result) expect { |b| described_class.subscribe(engine, appsec_context, &b) }.not_to yield_control - result = described_class.publish(engine, gateway) - expect(result).to be_nil - end - end - context 'is unknown' do - it 'does not yield' do - expect(engine).to receive(:subscribe).and_call_original - - waf_result = double(:waf_result, status: :foo, timeout: false) - expect(appsec_context).to receive(:run_waf).and_return(waf_result) - expect { |b| described_class.subscribe(engine, appsec_context, &b) }.not_to yield_control - result = described_class.publish(engine, gateway) - expect(result).to be_nil + expect(described_class.publish(engine, gateway)).to be_nil end end end diff --git a/spec/datadog/appsec/security_engine/result_spec.rb b/spec/datadog/appsec/security_engine/result_spec.rb new file mode 100644 index 00000000000..57ab104535a --- /dev/null +++ b/spec/datadog/appsec/security_engine/result_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'datadog/appsec/spec_helper' + +RSpec.describe Datadog::AppSec::SecurityEngine::Result do + describe '.new' do + context 'when initializing non-error result' do + subject(:result) do + described_class::Ok.new( + events: [1], + actions: { '2' => '2' }, + derivatives: { '3' => '3' }, + timeout: true, + duration_ns: 400, + duration_ext_ns: 500 + ) + end + + it { expect(result).to be_timeout } + it { expect(result.events).to eq([1]) } + it { expect(result.actions).to eq({ '2' => '2' }) } + it { expect(result.derivatives).to eq({ '3' => '3' }) } + it { expect(result.duration_ns).to eq(400) } + it { expect(result.duration_ext_ns).to eq(500) } + end + + context 'when initializing error result' do + subject(:result) { described_class::Error.new(duration_ext_ns: 100) } + + it { expect(result).not_to be_timeout } + it { expect(result.events).to eq([]) } + it { expect(result.actions).to eq({}) } + it { expect(result.derivatives).to eq({}) } + it { expect(result.duration_ns).to eq(0) } + it { expect(result.duration_ext_ns).to eq(100) } + end + end + + describe '#timeout?' do + context 'when result indicates timeout' do + subject(:result) do + described_class::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: true, duration_ns: 0, duration_ext_ns: 0 + ) + end + + it { expect(result).to be_timeout } + end + + context 'when result does not indicate timeout' do + subject(:result) do + described_class::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + + it { expect(result).not_to be_timeout } + end + end + + describe '#match?' do + context 'when result is a generic type' do + subject(:result) do + described_class::Base.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + + it { expect { result.match? }.to raise_error NotImplementedError } + end + + context 'when result is a "match" type' do + subject(:result) do + described_class::Match.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + + it { expect(result).to be_match } + end + + context 'when result is an "ok" type' do + subject(:result) do + described_class::Ok.new( + events: [], actions: {}, derivatives: {}, timeout: false, duration_ns: 0, duration_ext_ns: 0 + ) + end + + it { expect(result).not_to be_match } + end + + context 'when result is an "error" type' do + subject(:result) { described_class::Error.new(duration_ext_ns: 0) } + + it { expect(result).not_to be_match } + end + end +end diff --git a/spec/datadog/appsec/security_engine/runner_spec.rb b/spec/datadog/appsec/security_engine/runner_spec.rb new file mode 100644 index 00000000000..0c7258868c9 --- /dev/null +++ b/spec/datadog/appsec/security_engine/runner_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'datadog/appsec/spec_helper' +require 'datadog/appsec/processor' +require 'datadog/appsec/processor/rule_loader' +require 'datadog/appsec/processor/rule_merger' + +RSpec.describe Datadog::AppSec::SecurityEngine::Runner do + before do + # NOTE: This is an intermediate step and will be removed + rules = Datadog::AppSec::Processor::RuleLoader.load_rules(ruleset: :recommended, telemetry: telemetry) + ruleset = Datadog::AppSec::Processor::RuleMerger.merge(rules: [rules], telemetry: telemetry) + Datadog::AppSec::Processor.new(ruleset: ruleset, telemetry: telemetry) + + allow(Datadog::AppSec::WAF::Context).to receive(:new).and_return(waf_context) + end + + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:waf_handle) { instance_double(Datadog::AppSec::WAF::Handle) } + let(:waf_context) { instance_double(Datadog::AppSec::WAF::Context) } + + subject(:runner) { described_class.new(waf_handle, telemetry: telemetry) } + + describe '#run' do + context 'when keys contain values to clean' do + let(:result) do + instance_double( + Datadog::AppSec::WAF::Result, + status: :ok, + events: [], + actions: {}, + derivatives: {}, + total_runtime: 100, + timeout: false + ) + end + + it 'removes keys with nil values' do + expect(waf_context).to receive(:run) + .with({ 'addr.a' => 'a' }, { 'addr.b' => 'b' }, 1_000) + .and_return([result.status, result]) + + runner.run({ 'addr.a' => 'a', 'addr.aa' => nil }, { 'addr.b' => 'b', 'addr.bb' => nil }, 1_000) + end + + it 'removes keys with empty strings' do + expect(waf_context).to receive(:run) + .with({ 'addr.a' => 'a' }, { 'addr.b' => 'b' }, 1_000) + .and_return([result.status, result]) + + runner.run({ 'addr.a' => 'a', 'addr.aa' => '' }, { 'addr.b' => 'b', 'addr.bb' => '' }, 1_000) + end + + it 'removes keys with empty arrays' do + expect(waf_context).to receive(:run) + .with({ 'addr.a' => ['a'] }, { 'addr.b' => ['b'] }, 1_000) + .and_return([result.status, result]) + + runner.run({ 'addr.a' => ['a'], 'addr.aa' => [] }, { 'addr.b' => ['b'], 'addr.bb' => [] }, 1_000) + end + + it 'removes keys with empty hashes' do + expect(waf_context).to receive(:run) + .with({ 'addr.a' => { 'a' => '1' } }, { 'addr.b' => { 'b' => '2' } }, 1_000) + .and_return([result.status, result]) + + runner.run({ 'addr.a' => { 'a' => '1' }, 'addr.aa' => {} }, { 'addr.b' => { 'b' => '2' }, 'addr.bb' => {} }, 1_000) + end + + it 'does not remove keys with boolean values' do + expect(waf_context).to receive(:run) + .with({ 'addr.a' => 'a', 'addr.aa' => true }, { 'addr.b' => 'b', 'addr.bb' => false }, 1_000) + .and_return([result.status, result]) + + runner.run({ 'addr.a' => 'a', 'addr.aa' => true }, { 'addr.b' => 'b', 'addr.bb' => false }, 1_000) + end + end + + context 'when run succeeded with a match result' do + before do + allow(waf_context).to receive(:run) + .with({ 'addr.a' => 'a' }, {}, 1_000) + .and_return([waf_result.status, waf_result]) + end + + let(:waf_result) do + instance_double( + Datadog::AppSec::WAF::Result, + status: :match, + events: [], + actions: { + 'block_request' => { 'grpc_status_code' => '10', 'status_code' => '403', 'type' => 'auto' } + }, + derivatives: {}, + timeout: false, + total_runtime: 10 + ) + end + let(:result) { runner.run({ 'addr.a' => 'a' }, {}, 1_000) } + + it 'returns match result with filled fields' do + expect(result).to be_instance_of(Datadog::AppSec::SecurityEngine::Result::Match) + expect(result).not_to be_timeout + expect(result.events).to eq([]) + expect(result.actions).to eq( + { 'block_request' => { 'grpc_status_code' => '10', 'status_code' => '403', 'type' => 'auto' } } + ) + expect(result.derivatives).to eq({}) + expect(result.duration_ns).to eq(10) + expect(result.duration_ext_ns).to be > result.duration_ns + end + end + + context 'when run succeeded with an ok result' do + before do + allow(waf_context).to receive(:run) + .with({ 'addr.a' => 'a' }, {}, 1_000) + .and_return([waf_result.status, waf_result]) + end + + let(:waf_result) do + instance_double( + Datadog::AppSec::WAF::Result, + status: :ok, + events: [], + actions: {}, + derivatives: {}, + timeout: true, + total_runtime: 100 + ) + end + let(:result) { runner.run({ 'addr.a' => 'a' }, {}, 1_000) } + + it 'returns match result with filled fields' do + expect(result).to be_instance_of(Datadog::AppSec::SecurityEngine::Result::Ok) + expect(result).to be_timeout + expect(result.events).to eq([]) + expect(result.actions).to eq({}) + expect(result.derivatives).to eq({}) + expect(result.duration_ns).to eq(100) + expect(result.duration_ext_ns).to be > result.duration_ns + end + end + + context 'when run failed with libddwaf error result' do + before do + allow(waf_context).to receive(:run) + .with({ 'addr.a' => 'a' }, {}, 1_000) + .and_return([waf_result.status, waf_result]) + end + + let(:waf_result) do + instance_double(Datadog::AppSec::WAF::Result, status: :err_invalid_object, timeout: false) + end + + it 'sends telemetry error' do + expect(telemetry).to receive(:error) + .with(/libddwaf:[\d.]+ method:ddwaf_run execution error: :err_invalid_object/) + + runner.run({ 'addr.a' => 'a' }, {}, 1_000) + end + end + + context 'when run failed with libddwaf low-level exception' do + before do + allow(waf_context).to receive(:run) + .with({ 'addr.a' => 'a' }, {}, 1_000) + .and_raise(Datadog::AppSec::WAF::LibDDWAF::Error, 'Could not convert persistent data') + end + + let(:run_result) { runner.run({ 'addr.a' => 'a' }, {}, 1_000) } + + it 'sends telemetry report' do + expect(telemetry).to receive(:error) + .with(/libddwaf:[\d.]+ method:ddwaf_run execution error: :err_internal/) + + expect(telemetry).to receive(:report) + .with(kind_of(Datadog::AppSec::WAF::LibDDWAF::Error), description: 'libddwaf-rb internal low-level error') + + expect(run_result).to be_kind_of(Datadog::AppSec::SecurityEngine::Result::Error) + expect(run_result.duration_ext_ns).to be > 0 + end + end + end +end diff --git a/spec/datadog/kit/appsec/events_spec.rb b/spec/datadog/kit/appsec/events_spec.rb index 200ea4812d0..1369d4849b3 100644 --- a/spec/datadog/kit/appsec/events_spec.rb +++ b/spec/datadog/kit/appsec/events_spec.rb @@ -10,7 +10,7 @@ shared_context 'uses AppSec context' do before do - allow(processor).to receive(:new_context).and_return(instance_double(Datadog::AppSec::Processor::Context)) + allow(processor).to receive(:new_runner).and_return(instance_double(Datadog::AppSec::SecurityEngine::Runner)) allow(Datadog::AppSec).to receive(:active_context).and_return(appsec_active_context) end diff --git a/spec/datadog/kit/identity_spec.rb b/spec/datadog/kit/identity_spec.rb index 1531faffd8d..72c37d3fb41 100644 --- a/spec/datadog/kit/identity_spec.rb +++ b/spec/datadog/kit/identity_spec.rb @@ -213,7 +213,7 @@ context 'appsec' do before do - allow(processor).to receive(:new_context).and_return(instance_double(Datadog::AppSec::Processor::Context)) + allow(processor).to receive(:new_runner).and_return(instance_double(Datadog::AppSec::SecurityEngine::Runner)) allow(Datadog::AppSec).to receive(:active_context).and_return(appsec_active_context) end