diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index baa872e118c..3b9ea847e79 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -12,8 +12,8 @@ env: REGISTRY: ghcr.io REPO: ghcr.io/datadog/dd-trace-rb ST_REF: main - FORCE_TESTS: -F tests/appsec/waf/test_addresses.py::Test_GraphQL -F tests/appsec/test_blocking_addresses.py::Test_BlockingGraphqlResolvers - FORCE_TESTS_SCENARIO: GRAPHQL_APPSEC + FORCE_TESTS: -F tests/appsec/test_asm_standalone.py + FORCE_TESTS_SCENARIO: APPSEC_STANDALONE jobs: build-harness: @@ -199,6 +199,7 @@ jobs: - APPSEC_DISABLED - APPSEC_BLOCKING_FULL_DENYLIST - APPSEC_REQUEST_BLOCKING + - APPSEC_STANDALONE include: - library: ruby app: rack diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d557822d1b..d2628a80ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +* AppSec: Add Experimental Standalone AppSec Threats billing ([#3965][]) + ## [2.4.0] - 2024-10-11 ### Added diff --git a/lib/datadog/appsec.rb b/lib/datadog/appsec.rb index c19c54770d9..a00403bc5f2 100644 --- a/lib/datadog/appsec.rb +++ b/lib/datadog/appsec.rb @@ -4,6 +4,7 @@ require_relative 'appsec/extensions' require_relative 'appsec/scope' require_relative 'appsec/ext' +require_relative 'appsec/utils' module Datadog # Namespace for Datadog AppSec instrumentation diff --git a/lib/datadog/appsec/configuration/settings.rb b/lib/datadog/appsec/configuration/settings.rb index e5dbecfb0e1..e8ed0a8240a 100644 --- a/lib/datadog/appsec/configuration/settings.rb +++ b/lib/datadog/appsec/configuration/settings.rb @@ -197,6 +197,14 @@ def self.add_settings!(base) o.type :bool, nilable: true o.env 'DD_APPSEC_SCA_ENABLED' end + + settings :standalone do + option :enabled do |o| + o.type :bool + o.env 'DD_EXPERIMENTAL_APPSEC_STANDALONE_ENABLED' + o.default false + end + end end end end diff --git a/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb b/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb index 1ddf89c1c98..68a1c5acf11 100644 --- a/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/graphql/gateway/watcher.rb @@ -38,11 +38,7 @@ def watch_multiplex(gateway = Instrumentation.gateway) actions: result.actions } - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') - end - + Datadog::AppSec::Event.tag_and_keep!(scope, result) scope.processor_context.events << event end diff --git a/lib/datadog/appsec/contrib/rack/gateway/watcher.rb b/lib/datadog/appsec/contrib/rack/gateway/watcher.rb index 112d5609e08..a66387329c3 100644 --- a/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/rack/gateway/watcher.rb @@ -41,11 +41,9 @@ def watch_request(gateway = Instrumentation.gateway) actions: result.actions } - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') - end - + # We want to keep the trace in case of security event + scope.trace.keep! if scope.trace + Datadog::AppSec::Event.tag_and_keep!(scope, result) scope.processor_context.events << event end end @@ -85,11 +83,9 @@ def watch_response(gateway = Instrumentation.gateway) actions: result.actions } - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') - end - + # We want to keep the trace in case of security event + scope.trace.keep! if scope.trace + Datadog::AppSec::Event.tag_and_keep!(scope, result) scope.processor_context.events << event end end @@ -129,11 +125,9 @@ def watch_request_body(gateway = Instrumentation.gateway) actions: result.actions } - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') - end - + # We want to keep the trace in case of security event + scope.trace.keep! if scope.trace + Datadog::AppSec::Event.tag_and_keep!(scope, result) scope.processor_context.events << event end end diff --git a/lib/datadog/appsec/contrib/rack/request_middleware.rb b/lib/datadog/appsec/contrib/rack/request_middleware.rb index 55323226019..cbb232c00e3 100644 --- a/lib/datadog/appsec/contrib/rack/request_middleware.rb +++ b/lib/datadog/appsec/contrib/rack/request_middleware.rb @@ -150,7 +150,9 @@ def add_appsec_tags(processor, scope) return unless trace && span - span.set_tag('_dd.appsec.enabled', 1) + span.set_metric(Datadog::AppSec::Ext::TAG_APPSEC_ENABLED, 1) + # We add this tag when ASM standalone is enabled to make sure we don't bill APM + span.set_metric(Datadog::AppSec::Ext::TAG_APM_ENABLED, 0) if Datadog.configuration.appsec.standalone.enabled span.set_tag('_dd.runtime_family', 'ruby') span.set_tag('_dd.appsec.waf.version', Datadog::AppSec::WAF::VERSION::BASE_STRING) diff --git a/lib/datadog/appsec/contrib/rails/gateway/watcher.rb b/lib/datadog/appsec/contrib/rails/gateway/watcher.rb index ea4b34f3713..ab1bfe612ec 100644 --- a/lib/datadog/appsec/contrib/rails/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/rails/gateway/watcher.rb @@ -38,11 +38,9 @@ def watch_request_action(gateway = Instrumentation.gateway) actions: result.actions } - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') - end - + # We want to keep the trace in case of security event + scope.trace.keep! if scope.trace + Datadog::AppSec::Event.tag_and_keep!(scope, result) scope.processor_context.events << event end end diff --git a/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb b/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb index 93ba40c4f92..91383478c29 100644 --- a/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +++ b/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb @@ -40,11 +40,9 @@ def watch_request_dispatch(gateway = Instrumentation.gateway) actions: result.actions } - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') - end - + # We want to keep the trace in case of security event + scope.trace.keep! if scope.trace + Datadog::AppSec::Event.tag_and_keep!(scope, result) scope.processor_context.events << event end end @@ -84,11 +82,9 @@ def watch_request_routed(gateway = Instrumentation.gateway) actions: result.actions } - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') - end - + # We want to keep the trace in case of security event + scope.trace.keep! if scope.trace + Datadog::AppSec::Event.tag_and_keep!(scope, result) scope.processor_context.events << event end end diff --git a/lib/datadog/appsec/event.rb b/lib/datadog/appsec/event.rb index 65077de96b6..01f0de86d20 100644 --- a/lib/datadog/appsec/event.rb +++ b/lib/datadog/appsec/event.rb @@ -137,6 +137,18 @@ def build_service_entry_tags(event_group) end # rubocop:enable Metrics/MethodLength + def tag_and_keep!(scope, waf_result) + # We want to keep the trace in case of security event + scope.trace.keep! if scope.trace + + if scope.service_entry_span + scope.service_entry_span.set_tag('appsec.blocked', 'true') if waf_result.actions.include?('block') + scope.service_entry_span.set_tag('appsec.event', 'true') + end + + add_distributed_tags(scope.trace) + end + private def compressed_and_base64_encoded(value) @@ -165,6 +177,18 @@ def gzip(value) gz.close sio.string end + + # Propagate to downstream services the information that the current distributed trace is + # containing at least one ASM security event. + def add_distributed_tags(trace) + return unless trace + + trace.set_tag( + Datadog::Tracing::Metadata::Ext::Distributed::TAG_DECISION_MAKER, + Datadog::Tracing::Sampling::Ext::Decision::ASM + ) + trace.set_tag(Datadog::AppSec::Ext::TAG_DISTRIBUTED_APPSEC_EVENT, '1') + end end end end diff --git a/lib/datadog/appsec/ext.rb b/lib/datadog/appsec/ext.rb index 2de81657dec..30c21b7d240 100644 --- a/lib/datadog/appsec/ext.rb +++ b/lib/datadog/appsec/ext.rb @@ -5,6 +5,10 @@ module AppSec module Ext INTERRUPT = :datadog_appsec_interrupt SCOPE_KEY = 'datadog.appsec.scope' + + TAG_APPSEC_ENABLED = '_dd.appsec.enabled' + TAG_APM_ENABLED = '_dd.apm.enabled' + TAG_DISTRIBUTED_APPSEC_EVENT = '_dd.p.appsec' end end end diff --git a/lib/datadog/appsec/monitor/gateway/watcher.rb b/lib/datadog/appsec/monitor/gateway/watcher.rb index 9262cc277a6..74fc4d3fd60 100644 --- a/lib/datadog/appsec/monitor/gateway/watcher.rb +++ b/lib/datadog/appsec/monitor/gateway/watcher.rb @@ -35,11 +35,9 @@ def watch_user_id(gateway = Instrumentation.gateway) actions: result.actions } - if scope.service_entry_span - scope.service_entry_span.set_tag('appsec.blocked', 'true') if result.actions.include?('block') - scope.service_entry_span.set_tag('appsec.event', 'true') - end - + # We want to keep the trace in case of security event + scope.trace.keep! if scope.trace + Datadog::AppSec::Event.tag_and_keep!(scope, result) scope.processor_context.events << event end end diff --git a/lib/datadog/appsec/utils.rb b/lib/datadog/appsec/utils.rb index 8e4083533db..b38ec5b96f5 100644 --- a/lib/datadog/appsec/utils.rb +++ b/lib/datadog/appsec/utils.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'utils/trace_operation' + module Datadog module AppSec # Utilities for AppSec diff --git a/lib/datadog/appsec/utils/trace_operation.rb b/lib/datadog/appsec/utils/trace_operation.rb new file mode 100644 index 00000000000..19f2b0b2187 --- /dev/null +++ b/lib/datadog/appsec/utils/trace_operation.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module Utils + # Utility class to to AppSec-specific trace operations + class TraceOperation + def self.appsec_standalone_reject?(trace) + Datadog.configuration.appsec.standalone.enabled && + (trace.nil? || trace.get_tag(Datadog::AppSec::Ext::TAG_DISTRIBUTED_APPSEC_EVENT) != '1') + end + end + end + end +end diff --git a/lib/datadog/core/remote/transport/http.rb b/lib/datadog/core/remote/transport/http.rb index ed87c0dc5cb..190a9c45646 100644 --- a/lib/datadog/core/remote/transport/http.rb +++ b/lib/datadog/core/remote/transport/http.rb @@ -120,6 +120,11 @@ def default_headers # Add container ID, if present. container_id = Datadog::Core::Environment::Container.container_id headers[Datadog::Core::Transport::Ext::HTTP::HEADER_CONTAINER_ID] = container_id unless container_id.nil? + # Sending this header to the agent will disable metrics computation (and billing) on the agent side + # by pretending it has already been done on the library side. + if Datadog.configuration.appsec.standalone.enabled + headers[Datadog::Core::Transport::Ext::HTTP::HEADER_CLIENT_COMPUTED_STATS] = 'yes' + end end end diff --git a/lib/datadog/core/transport/ext.rb b/lib/datadog/core/transport/ext.rb index 470d4b3ad09..e0830510b92 100644 --- a/lib/datadog/core/transport/ext.rb +++ b/lib/datadog/core/transport/ext.rb @@ -16,6 +16,7 @@ module HTTP # # Setting this header to any non-empty value enables this feature. HEADER_CLIENT_COMPUTED_TOP_LEVEL = 'Datadog-Client-Computed-Top-Level' + HEADER_CLIENT_COMPUTED_STATS = 'Datadog-Client-Computed-Stats' HEADER_META_LANG = 'Datadog-Meta-Lang' HEADER_META_LANG_VERSION = 'Datadog-Meta-Lang-Version' HEADER_META_LANG_INTERPRETER = 'Datadog-Meta-Lang-Interpreter' diff --git a/lib/datadog/tracing/component.rb b/lib/datadog/tracing/component.rb index 96c91ad48f2..34744e53ef6 100644 --- a/lib/datadog/tracing/component.rb +++ b/lib/datadog/tracing/component.rb @@ -73,6 +73,19 @@ def build_sampler(settings) return sampler end + # AppSec events are sent to the backend using traces. + # Standalone ASM billing means that we don't want to charge clients for APM traces, + # so we want to send the minimum amount of traces possible (idealy only traces that contains security events), + # but for features such as API Security, we need to send at least one trace per minute, + # to keep the service alive on the backend side. + if settings.appsec.standalone.enabled + post_sampler = Tracing::Sampling::RuleSampler.new( + [Tracing::Sampling::SimpleRule.new(sample_rate: 1.0)], + rate_limiter: Datadog::Core::TokenBucket.new(1.0 / 60, 1.0), + default_sample_rate: 1.0 / 60 + ) + end + # Sampling rules are provided if (rules = settings.tracing.sampling.rules) post_sampler = Tracing::Sampling::RuleSampler.parse( diff --git a/lib/datadog/tracing/contrib/ethon/easy_patch.rb b/lib/datadog/tracing/contrib/ethon/easy_patch.rb index af661e86b0c..72e2c104f6b 100644 --- a/lib/datadog/tracing/contrib/ethon/easy_patch.rb +++ b/lib/datadog/tracing/contrib/ethon/easy_patch.rb @@ -110,6 +110,10 @@ def datadog_before_request(continue_from: nil) datadog_tag_request + if Datadog::AppSec::Utils::TraceOperation.appsec_standalone_reject?(datadog_trace) + datadog_trace.sampling_priority = Tracing::Sampling::Ext::Priority::AUTO_REJECT + end + if datadog_configuration[:distributed_tracing] @datadog_original_headers ||= {} Contrib::HTTP.inject(datadog_trace, @datadog_original_headers) diff --git a/lib/datadog/tracing/contrib/excon/middleware.rb b/lib/datadog/tracing/contrib/excon/middleware.rb index e25ee1941cd..9bb3f8629ef 100644 --- a/lib/datadog/tracing/contrib/excon/middleware.rb +++ b/lib/datadog/tracing/contrib/excon/middleware.rb @@ -30,6 +30,9 @@ def request_call(datum) trace = Tracing.active_trace datum[:datadog_span] = span annotate!(span, datum) + if Datadog::AppSec::Utils::TraceOperation.appsec_standalone_reject?(trace) + trace.sampling_priority = Tracing::Sampling::Ext::Priority::AUTO_REJECT + end propagate!(trace, span, datum) if distributed_tracing? span diff --git a/lib/datadog/tracing/contrib/faraday/middleware.rb b/lib/datadog/tracing/contrib/faraday/middleware.rb index 9bdea9c364b..0795ba29b2c 100644 --- a/lib/datadog/tracing/contrib/faraday/middleware.rb +++ b/lib/datadog/tracing/contrib/faraday/middleware.rb @@ -29,6 +29,9 @@ def call(env) Tracing.trace(Ext::SPAN_REQUEST, on_error: request_options[:on_error]) do |span, trace| annotate!(span, env, request_options) + if Datadog::AppSec::Utils::TraceOperation.appsec_standalone_reject?(trace) + trace.sampling_priority = Tracing::Sampling::Ext::Priority::AUTO_REJECT + end propagate!(trace, span, env) if request_options[:distributed_tracing] && Tracing.enabled? app.call(env).on_complete { |resp| handle_response(span, resp, request_options) } end diff --git a/lib/datadog/tracing/contrib/http/circuit_breaker.rb b/lib/datadog/tracing/contrib/http/circuit_breaker.rb index ffa8e164ce8..d35ceda5a3e 100644 --- a/lib/datadog/tracing/contrib/http/circuit_breaker.rb +++ b/lib/datadog/tracing/contrib/http/circuit_breaker.rb @@ -29,6 +29,15 @@ def internal_request?(request) end def should_skip_distributed_tracing?(client_config) + if Datadog.configuration.appsec.standalone.enabled + # Skip distributed tracing so that we don't bill distributed traces in case of absence of + # upstream ASM event (_dd.p.appsec:1) and no local security event (which sets _dd.p.appsec:1 locally). + # If there is an ASM event, we still have to check if distributed tracing is enabled or not + return true unless Tracing.active_trace + + return true if Tracing.active_trace.get_tag(Datadog::AppSec::Ext::TAG_DISTRIBUTED_APPSEC_EVENT) != '1' + end + return !client_config[:distributed_tracing] if client_config && client_config.key?(:distributed_tracing) !Datadog.configuration.tracing[:http][:distributed_tracing] diff --git a/lib/datadog/tracing/contrib/http/instrumentation.rb b/lib/datadog/tracing/contrib/http/instrumentation.rb index cd59f93fccd..b913a7d41dd 100644 --- a/lib/datadog/tracing/contrib/http/instrumentation.rb +++ b/lib/datadog/tracing/contrib/http/instrumentation.rb @@ -35,6 +35,10 @@ def request(req, body = nil, &block) span.type = Tracing::Metadata::Ext::HTTP::TYPE_OUTBOUND span.resource = req.method + if Datadog::AppSec::Utils::TraceOperation.appsec_standalone_reject?(trace) + trace.sampling_priority = Tracing::Sampling::Ext::Priority::AUTO_REJECT + end + if Tracing.enabled? && !Contrib::HTTP.should_skip_distributed_tracing?(client_config) Contrib::HTTP.inject(trace, req) end diff --git a/lib/datadog/tracing/contrib/httpclient/instrumentation.rb b/lib/datadog/tracing/contrib/httpclient/instrumentation.rb index 5cbc784bf59..15c218bf333 100644 --- a/lib/datadog/tracing/contrib/httpclient/instrumentation.rb +++ b/lib/datadog/tracing/contrib/httpclient/instrumentation.rb @@ -30,6 +30,10 @@ def do_get_block(req, proxy, conn, &block) span.service = service_name(host, request_options, client_config) span.type = Tracing::Metadata::Ext::HTTP::TYPE_OUTBOUND + if Datadog::AppSec::Utils::TraceOperation.appsec_standalone_reject?(trace) + trace.sampling_priority = Tracing::Sampling::Ext::Priority::AUTO_REJECT + end + if Tracing.enabled? && !should_skip_distributed_tracing?(client_config) Contrib::HTTP.inject(trace, req.header) end diff --git a/lib/datadog/tracing/contrib/httprb/instrumentation.rb b/lib/datadog/tracing/contrib/httprb/instrumentation.rb index c39916ebb4a..7d0a11e7ff9 100644 --- a/lib/datadog/tracing/contrib/httprb/instrumentation.rb +++ b/lib/datadog/tracing/contrib/httprb/instrumentation.rb @@ -30,6 +30,10 @@ def perform(req, options) span.service = service_name(host, request_options, client_config) span.type = Tracing::Metadata::Ext::HTTP::TYPE_OUTBOUND + if Datadog::AppSec::Utils::TraceOperation.appsec_standalone_reject?(trace) + trace.sampling_priority = Tracing::Sampling::Ext::Priority::AUTO_REJECT + end + Contrib::HTTP.inject(trace, req) if Tracing.enabled? && !should_skip_distributed_tracing?(client_config) # Add additional request specific tags to the span. diff --git a/lib/datadog/tracing/contrib/rest_client/request_patch.rb b/lib/datadog/tracing/contrib/rest_client/request_patch.rb index cabb1b73161..4f340c5356d 100644 --- a/lib/datadog/tracing/contrib/rest_client/request_patch.rb +++ b/lib/datadog/tracing/contrib/rest_client/request_patch.rb @@ -25,6 +25,9 @@ def execute(&block) return super(&block) unless Tracing.enabled? datadog_trace_request(uri) do |_span, trace| + if Datadog::AppSec::Utils::TraceOperation.appsec_standalone_reject?(trace) + trace.sampling_priority = Tracing::Sampling::Ext::Priority::AUTO_REJECT + end Contrib::HTTP.inject(trace, processed_headers) if datadog_configuration[:distributed_tracing] super(&block) diff --git a/lib/datadog/tracing/sampling/rule_sampler.rb b/lib/datadog/tracing/sampling/rule_sampler.rb index 398926233e3..a6542edc9f3 100644 --- a/lib/datadog/tracing/sampling/rule_sampler.rb +++ b/lib/datadog/tracing/sampling/rule_sampler.rb @@ -29,7 +29,12 @@ def initialize( default_sample_rate: Datadog.configuration.tracing.sampling.default_rate, default_sampler: nil ) - @rules = rules + @rules = if default_sample_rate && !default_sampler + # Add to the end of the rule list a rule always matches any trace + rules << SimpleRule.new(sample_rate: default_sample_rate) + else + rules + end @rate_limiter = if rate_limiter rate_limiter elsif rate_limit @@ -37,12 +42,9 @@ def initialize( else Core::UnlimitedLimiter.new end - @default_sampler = if default_sampler default_sampler elsif default_sample_rate - # Add to the end of the rule list a rule always matches any trace - @rules << SimpleRule.new(sample_rate: default_sample_rate) nil else # TODO: Simplify .tags access, as `Tracer#tags` can't be arbitrarily changed anymore diff --git a/lib/datadog/tracing/transport/http.rb b/lib/datadog/tracing/transport/http.rb index 25a7e77c426..190dc31e408 100644 --- a/lib/datadog/tracing/transport/http.rb +++ b/lib/datadog/tracing/transport/http.rb @@ -74,6 +74,10 @@ def default_headers # Add container ID, if present. container_id = Datadog::Core::Environment::Container.container_id headers[Datadog::Core::Transport::Ext::HTTP::HEADER_CONTAINER_ID] = container_id unless container_id.nil? + # Pretend that stats computation are already done by the client + if Datadog.configuration.appsec.standalone.enabled + headers[Datadog::Core::Transport::Ext::HTTP::HEADER_CLIENT_COMPUTED_STATS] = 'yes' + end end end diff --git a/sig/datadog/appsec/event.rbs b/sig/datadog/appsec/event.rbs index 36d75ca6e55..b4b0e584513 100644 --- a/sig/datadog/appsec/event.rbs +++ b/sig/datadog/appsec/event.rbs @@ -13,7 +13,9 @@ module Datadog def self.record_via_span: (Datadog::Tracing::SpanOperation, *untyped events) -> untyped def self.build_service_entry_tags: (Array[Hash[::Symbol, untyped]] event_group) -> Hash[::String, untyped] - + + def self.tag_and_keep!: (Datadog::AppSec::Scope scope, Datadog::AppSec::WAF::Result waf_result) -> void + private def self.compressed_and_base64_encoded: (untyped value) -> untyped @@ -21,6 +23,8 @@ module Datadog def self.json_parse: (untyped value) -> untyped def self.gzip: (untyped value) -> untyped + + def self.add_distributed_tags: (Datadog::Tracing::TraceOperation trace) -> void end end end diff --git a/sig/datadog/appsec/ext.rbs b/sig/datadog/appsec/ext.rbs index 39703d21368..2ac545ba1d3 100644 --- a/sig/datadog/appsec/ext.rbs +++ b/sig/datadog/appsec/ext.rbs @@ -3,6 +3,10 @@ module Datadog module Ext INTERRUPT: Symbol SCOPE_KEY: String + + TAG_APPSEC_ENABLED: String + TAG_APM_ENABLED: String + TAG_DISTRIBUTED_APPSEC_EVENT: String end end end diff --git a/sig/datadog/appsec/utils/trace_operation.rbs b/sig/datadog/appsec/utils/trace_operation.rbs new file mode 100644 index 00000000000..cd7a86680a1 --- /dev/null +++ b/sig/datadog/appsec/utils/trace_operation.rbs @@ -0,0 +1,9 @@ +module Datadog + module AppSec + module Utils + class TraceOperation + def self.appsec_standalone_reject?: (Datadog::Tracing::TraceOperation trace) -> bool + end + end + end +end diff --git a/sig/datadog/core/transport/ext.rbs b/sig/datadog/core/transport/ext.rbs index 335e293b941..74a3d3e21b8 100644 --- a/sig/datadog/core/transport/ext.rbs +++ b/sig/datadog/core/transport/ext.rbs @@ -15,6 +15,8 @@ module Datadog HEADER_CLIENT_COMPUTED_TOP_LEVEL: ::String + HEADER_CLIENT_COMPUTED_STATS: ::String + HEADER_META_LANG: ::String HEADER_META_LANG_INTERPRETER_VENDOR: ::String diff --git a/spec/datadog/appsec/configuration/settings_spec.rb b/spec/datadog/appsec/configuration/settings_spec.rb index a19a0a94550..3d8115bc3fc 100644 --- a/spec/datadog/appsec/configuration/settings_spec.rb +++ b/spec/datadog/appsec/configuration/settings_spec.rb @@ -757,5 +757,45 @@ def patcher end end end + + describe 'standalone' do + describe '#enabled' do + subject(:enabled) { settings.appsec.standalone.enabled } + + context 'when DD_EXPERIMENTAL_APPSEC_STANDALONE_ENABLED' do + around do |example| + ClimateControl.modify('DD_EXPERIMENTAL_APPSEC_STANDALONE_ENABLED' => appsec_standalone_enabled) do + example.run + end + end + + context 'is not defined' do + let(:appsec_standalone_enabled) { nil } + + it { is_expected.to eq false } + end + + context 'is defined' do + let(:appsec_standalone_enabled) { 'true' } + + it { is_expected.to eq(true) } + end + end + end + + describe '#enabled=' do + subject(:set_appsec_standalone_enabled) { settings.appsec.standalone.enabled = appsec_standalone_enabled } + + [true, false].each do |value| + context "when given #{value}" do + let(:appsec_standalone_enabled) { value } + + before { set_appsec_standalone_enabled } + + it { expect(settings.appsec.standalone.enabled).to eq(value) } + end + end + end + end end end diff --git a/spec/datadog/appsec/contrib/rack/integration_test_spec.rb b/spec/datadog/appsec/contrib/rack/integration_test_spec.rb index 05eb3f25f2e..325541ed0b1 100644 --- a/spec/datadog/appsec/contrib/rack/integration_test_spec.rb +++ b/spec/datadog/appsec/contrib/rack/integration_test_spec.rb @@ -1,5 +1,6 @@ require 'datadog/tracing/contrib/support/spec_helper' require 'datadog/appsec/contrib/support/integration/shared_examples' +require 'datadog/appsec/spec_helper' require 'rack/test' require 'securerandom' @@ -18,8 +19,34 @@ RSpec.describe 'Rack integration tests' do include Rack::Test::Methods - let(:appsec_enabled) { true } + # We send the trace to a mocked agent to verify that the trace includes the headers that we want + # In the future, it might be a good idea to use the traces that the mocked agent + # receives in the tests/shared examples + let(:agent_http_client) do + Datadog::Tracing::Transport::HTTP.default do |t| + t.adapter agent_http_adapter + end + end + + let(:agent_http_adapter) { Datadog::Core::Transport::HTTP::Adapters::Net.new(agent_settings) } + + let(:agent_settings) do + Datadog::Core::Configuration::AgentSettingsResolver::AgentSettings.new( + adapter: nil, + ssl: false, + uds_path: nil, + hostname: 'localhost', + port: 6218, + timeout_seconds: 30, + ) + end + + let(:agent_tested_headers) { {} } + let(:tracing_enabled) { true } + let(:appsec_enabled) { true } + + let(:appsec_standalone_enabled) { false } let(:remote_enabled) { false } let(:appsec_ip_passlist) { [] } let(:appsec_ip_denylist) { [] } @@ -130,14 +157,45 @@ end before do + WebMock.enable! + stub_request(:get, 'http://localhost:3000/returnheaders') + .to_return do |request| + { + status: 200, + body: request.headers.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + # Mocked agent with correct headers + stub_request(:post, 'http://localhost:6218/v0.4/traces') + .with do |request| + agent_tested_headers <= request.headers + end + .to_return(status: 200) + + # DEV: Would it be faster to do another stub for requests that don't match the headers + # rather than waiting for the TCP connection to fail? + + # TODO: Mocked agent that matches a given body, then use it in the shared examples, + # That way it would be real integration tests + + # We must format the trace to have the same result as the agent + # This is especially important for _sampling_priority_v1 metric + unless remote_enabled Datadog.configure do |c| c.tracing.enabled = tracing_enabled + c.tracing.instrument :rack + c.tracing.instrument :http c.appsec.enabled = appsec_enabled - c.appsec.waf_timeout = 10_000_000 # in us + c.appsec.instrument :rack + + c.appsec.standalone.enabled = appsec_standalone_enabled + c.appsec.waf_timeout = 10_000_000 # in us c.appsec.ip_passlist = appsec_ip_passlist c.appsec.ip_denylist = appsec_ip_denylist c.appsec.user_id_denylist = appsec_user_id_denylist @@ -151,6 +209,9 @@ end after do + WebMock.reset! + WebMock.disable! + Datadog.configuration.reset! Datadog.registry[:rack].reset_configuration! end @@ -185,11 +246,12 @@ let(:client_ip) { remote_addr } let(:service_span) do - span = spans.find { |s| s.metrics.fetch('_dd.top_level', -1.0) > 0.0 } - - expect(span.name).to eq 'rack.request' + spans.find { |s| s.metrics.fetch('_dd.top_level', -1.0) > 0.0 } + end - span + let(:span) do + Datadog::Tracing::Transport::TraceFormatter.format!(trace) + spans.find { |s| s.name == 'rack.request' } end context 'with remote configuration' do @@ -588,6 +650,24 @@ end ) end + + map '/requestdownstream' do + run( + proc do |_env| + uri = URI('http://localhost:3000/returnheaders') + ext_request = nil + ext_response = nil + + Net::HTTP.start(uri.host, uri.port) do |http| + ext_request = Net::HTTP::Get.new(uri) + + ext_response = http.request(ext_request) + end + + [200, { 'Content-Type' => 'application/json' }, [ext_response.body]] + end + ) + end end end @@ -942,6 +1022,8 @@ end end end + + it_behaves_like 'appsec standalone billing' end end end diff --git a/spec/datadog/appsec/contrib/rails/integration_test_spec.rb b/spec/datadog/appsec/contrib/rails/integration_test_spec.rb index 211a33bba8b..b564e477c9f 100644 --- a/spec/datadog/appsec/contrib/rails/integration_test_spec.rb +++ b/spec/datadog/appsec/contrib/rails/integration_test_spec.rb @@ -1,5 +1,6 @@ require 'datadog/tracing/contrib/rails/rails_helper' require 'datadog/appsec/contrib/support/integration/shared_examples' +require 'datadog/appsec/spec_helper' require 'rack/test' require 'datadog/tracing' @@ -8,7 +9,33 @@ RSpec.describe 'Rails integration tests' do include Rack::Test::Methods + # We send the trace to a mocked agent to verify that the trace includes the headers that we want + # In the future, it might be a good idea to use the traces that the mocked agent + # receives in the tests/shared examples + let(:agent_http_client) do + Datadog::Tracing::Transport::HTTP.default do |t| + t.adapter agent_http_adapter + end + end + + let(:agent_http_adapter) { Datadog::Core::Transport::HTTP::Adapters::Net.new(agent_settings) } + + let(:agent_settings) do + Datadog::Core::Configuration::AgentSettingsResolver::AgentSettings.new( + adapter: nil, + ssl: false, + uds_path: nil, + hostname: 'localhost', + port: 6218, + timeout_seconds: 30, + ) + end + let(:sorted_spans) do + # We must format the trace to have the same result as the agent + # This is especially important for _sampling_priority_v1 metric + Datadog::Tracing::Transport::TraceFormatter.format!(trace) + chain = lambda do |start| loop.with_object([start]) do |_, o| # root reached (default) @@ -26,14 +53,19 @@ sort.call(spans) end + let(:agent_tested_headers) { {} } + let(:rack_span) { sorted_spans.reverse.find { |x| x.name == Datadog::Tracing::Contrib::Rack::Ext::SPAN_REQUEST } } - let(:appsec_enabled) { true } let(:tracing_enabled) { true } + let(:appsec_enabled) { true } + + let(:appsec_instrument_rack) { false } + + let(:appsec_standalone_enabled) { false } let(:appsec_ip_denylist) { [] } let(:appsec_user_id_denylist) { [] } let(:appsec_ruleset) { :recommended } - let(:nested_app) { false } let(:api_security_enabled) { false } let(:api_security_sample) { 0.0 } @@ -85,24 +117,57 @@ end before do + # It may have been better to add this endpoint to the Rails app, + # but I couldn't figure out how to call the Rails app from itself using Net::HTTP. + # Creating a WebMock and stubbing it was easier. + WebMock.enable! + stub_request(:get, 'http://localhost:3000/returnheaders') + .to_return do |request| + { + status: 200, + body: request.headers.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + # Mocked agent with correct headers + stub_request(:post, 'http://localhost:6218/v0.4/traces') + .with do |request| + agent_tested_headers <= request.headers + end + .to_return(status: 200) + + # DEV: Would it be faster to do another stub for requests that don't match the headers + # rather than waiting for the TCP connection to fail? + + # TODO: Mocked agent that matches a given body, then use it in the shared examples, + # That way it would be real integration tests + Datadog.configure do |c| c.tracing.enabled = tracing_enabled + c.tracing.instrument :rails + c.tracing.instrument :http c.appsec.enabled = appsec_enabled - c.appsec.waf_timeout = 10_000_000 # in us + c.appsec.instrument :rails + c.appsec.instrument :rack if appsec_instrument_rack + + c.appsec.standalone.enabled = appsec_standalone_enabled + c.appsec.waf_timeout = 10_000_000 # in us c.appsec.ip_denylist = appsec_ip_denylist c.appsec.user_id_denylist = appsec_user_id_denylist c.appsec.ruleset = appsec_ruleset c.appsec.api_security.enabled = api_security_enabled c.appsec.api_security.sample_rate = api_security_sample - - c.appsec.instrument :rack if nested_app end end after do + WebMock.reset! + WebMock.disable! + Datadog.configuration.reset! Datadog.registry[:rails].reset_configuration! end @@ -135,6 +200,20 @@ def set_user Datadog::Kit::Identity.set_user(Datadog::Tracing.active_trace, id: 'blocked-user-id') head :ok end + + def request_downstream + uri = URI('http://localhost:3000/returnheaders') + ext_request = nil + ext_response = nil + + Net::HTTP.start(uri.host, uri.port) do |http| + ext_request = Net::HTTP::Get.new('/returnheaders') + + ext_response = http.request(ext_request) + end + + render json: ext_response.body, content_type: 'application/json' + end end ) end @@ -149,11 +228,7 @@ def set_user let(:client_ip) { remote_addr } let(:service_span) do - span = sorted_spans.reverse.find { |s| s.metrics.fetch('_dd.top_level', -1.0) > 0.0 } - - expect(span.name).to eq 'rack.request' - - span + sorted_spans.reverse.find { |s| s.metrics.fetch('_dd.top_level', -1.0) > 0.0 } end let(:span) { rack_span } @@ -164,6 +239,7 @@ def set_user '/success' => 'test#success', [:post, '/success'] => 'test#success', '/set_user' => 'test#set_user', + '/requestdownstream' => 'test#request_downstream', } end @@ -404,7 +480,7 @@ def set_user end describe 'Nested apps' do - let(:nested_app) { true } + let(:appsec_instrument_rack) { true } let(:middlewares) do [ Datadog::Tracing::Contrib::Rack::TraceMiddleware, @@ -481,6 +557,8 @@ def set_user end end end + + it_behaves_like 'appsec standalone billing' end end end diff --git a/spec/datadog/appsec/contrib/sinatra/integration_test_spec.rb b/spec/datadog/appsec/contrib/sinatra/integration_test_spec.rb index fddbdc5b69a..e9f25a12b15 100644 --- a/spec/datadog/appsec/contrib/sinatra/integration_test_spec.rb +++ b/spec/datadog/appsec/contrib/sinatra/integration_test_spec.rb @@ -1,5 +1,6 @@ require 'datadog/tracing/contrib/support/spec_helper' require 'datadog/appsec/contrib/support/integration/shared_examples' +require 'datadog/appsec/spec_helper' require 'rack/test' require 'securerandom' @@ -18,7 +19,33 @@ RSpec.describe 'Sinatra integration tests' do include Rack::Test::Methods + # We send the trace to a mocked agent to verify that the trace includes the headers that we want + # In the future, it might be a good idea to use the traces that the mocked agent + # receives in the tests/shared examples + let(:agent_http_client) do + Datadog::Tracing::Transport::HTTP.default do |t| + t.adapter agent_http_adapter + end + end + + let(:agent_http_adapter) { Datadog::Core::Transport::HTTP::Adapters::Net.new(agent_settings) } + + let(:agent_settings) do + Datadog::Core::Configuration::AgentSettingsResolver::AgentSettings.new( + adapter: nil, + ssl: false, + uds_path: nil, + hostname: 'localhost', + port: 6218, + timeout_seconds: 30, + ) + end + let(:sorted_spans) do + # We must format the trace to have the same result as the agent + # This is especially important for _sampling_priority_v1 metric + Datadog::Tracing::Transport::TraceFormatter.format!(trace) + chain = lambda do |start| loop.with_object([start]) do |_, o| # root reached (default) @@ -36,12 +63,16 @@ sort.call(spans) end + let(:agent_tested_headers) { {} } + let(:sinatra_span) { sorted_spans.reverse.find { |x| x.name == Datadog::Tracing::Contrib::Sinatra::Ext::SPAN_REQUEST } } let(:route_span) { sorted_spans.find { |x| x.name == Datadog::Tracing::Contrib::Sinatra::Ext::SPAN_ROUTE } } let(:rack_span) { sorted_spans.reverse.find { |x| x.name == Datadog::Tracing::Contrib::Rack::Ext::SPAN_REQUEST } } - let(:appsec_enabled) { true } let(:tracing_enabled) { true } + let(:appsec_enabled) { true } + + let(:appsec_standalone_enabled) { false } let(:appsec_ip_denylist) { [] } let(:appsec_user_id_denylist) { [] } let(:appsec_ruleset) { :recommended } @@ -96,24 +127,54 @@ end before do + WebMock.enable! + stub_request(:get, 'http://localhost:3000/returnheaders') + .to_return do |request| + { + status: 200, + body: request.headers.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + # Mocked agent with correct headers + stub_request(:post, 'http://localhost:6218/v0.4/traces') + .with do |request| + agent_tested_headers <= request.headers + end + .to_return(status: 200) + + # DEV: Would it be faster to do another stub for requests that don't match the headers + # rather than waiting for the TCP connection to fail? + + # TODO: Mocked agent that matches a given body, then use it in the shared examples, + # That way it would be real integration tests + Datadog.configure do |c| c.tracing.enabled = tracing_enabled + c.tracing.instrument :sinatra + c.tracing.instrument :http c.appsec.enabled = appsec_enabled - c.appsec.waf_timeout = 10_000_000 # in us + c.appsec.instrument :sinatra + # TODO: test with c.appsec.instrument :rack + + c.appsec.standalone.enabled = appsec_standalone_enabled + c.appsec.waf_timeout = 10_000_000 # in us c.appsec.ip_denylist = appsec_ip_denylist c.appsec.user_id_denylist = appsec_user_id_denylist c.appsec.ruleset = appsec_ruleset c.appsec.api_security.enabled = api_security_enabled c.appsec.api_security.sample_rate = api_security_sample - - # TODO: test with c.appsec.instrument :rack end end after do + WebMock.reset! + WebMock.disable! + Datadog.configuration.reset! Datadog.registry[:rack].reset_configuration! Datadog.registry[:sinatra].reset_configuration! @@ -144,11 +205,7 @@ let(:client_ip) { remote_addr } let(:service_span) do - span = sorted_spans.reverse.find { |s| s.metrics.fetch('_dd.top_level', -1.0) > 0.0 } - - expect(span.name).to eq 'rack.request' - - span + sorted_spans.reverse.find { |s| s.metrics.fetch('_dd.top_level', -1.0) > 0.0 } end let(:span) { rack_span } @@ -168,6 +225,22 @@ Datadog::Kit::Identity.set_user(Datadog::Tracing.active_trace, id: 'blocked-user-id') 'ok' end + + get '/requestdownstream' do + content_type :json + + uri = URI('http://localhost:3000/returnheaders') + ext_request = nil + ext_response = nil + + Net::HTTP.start(uri.host, uri.port) do |http| + ext_request = Net::HTTP::Get.new(uri) + + ext_response = http.request(ext_request) + end + + ext_response.body + end end end @@ -399,6 +472,8 @@ it_behaves_like 'a trace with AppSec api security tags' end end + + it_behaves_like 'appsec standalone billing' end end end diff --git a/spec/datadog/appsec/contrib/support/integration/shared_examples.rb b/spec/datadog/appsec/contrib/support/integration/shared_examples.rb index 00c2a41b01a..bcf82aaa8aa 100644 --- a/spec/datadog/appsec/contrib/support/integration/shared_examples.rb +++ b/spec/datadog/appsec/contrib/support/integration/shared_examples.rb @@ -146,6 +146,7 @@ RSpec.shared_examples 'a trace without AppSec events' do it do expect(spans.select { |s| s.get_tag('appsec.event') }).to be_empty + expect(trace.send(:meta)['_dd.p.appsec']).to be_nil expect(service_span.send(:meta)['_dd.appsec.triggers']).to be_nil end end @@ -155,6 +156,7 @@ it do expect(spans.select { |s| s.get_tag('appsec.event') }).to_not be_empty + expect(trace.send(:meta)['_dd.p.appsec']).to eq('1') expect(service_span.send(:meta)['_dd.appsec.json']).to be_a String expect(spans.select { |s| s.get_tag('appsec.blocked') }).to_not be_empty if blocking_request end @@ -165,3 +167,289 @@ it_behaves_like 'a trace without AppSec events' end end + +RSpec.shared_examples 'a trace with ASM Standalone tags' do |params = {}| + let(:tag_apm_enabled) { params[:tag_apm_enabled] || 0 } + let(:tag_appsec_enabled) { params[:tag_appsec_enabled] || 1.0 } + let(:tag_appsec_propagation) { params[:tag_appsec_propagation] } + let(:tag_other_propagation) { params[:tag_other_propagation] || :any } + # We use a lambda as we may change the comparison type + let(:tag_sampling_priority_condition) { params[:tag_sampling_priority_condition] || ->(x) { x == 0 } } + let(:tag_trace_id) { params[:tag_trace_id] || headers_trace_id.to_i } + + it do + expect(span.send(:metrics)['_dd.apm.enabled']).to eq(tag_apm_enabled) + expect(span.send(:metrics)['_dd.appsec.enabled']).to eq(tag_appsec_enabled) + expect(span.send(:metrics)['_sampling_priority_v1']).to(satisfy { |x| tag_sampling_priority_condition.call(x) }) + + expect(span.send(:meta)['_dd.p.appsec']).to eq(tag_appsec_propagation) + expect(span.send(:meta)['_dd.p.other']).to eq(tag_other_propagation) unless tag_other_propagation == :any + + expect(span.send(:trace_id)).to eq(tag_trace_id) + expect(trace.send(:spans)[0].send(:trace_id)).to eq(tag_trace_id) + end +end + +RSpec.shared_examples 'a request with propagated headers' do |params = {}| + let(:res_origin) { params[:res_origin] } + let(:res_parent_id_not_equal) { params[:res_parent_id_not_equal] } + let(:res_tags) { params[:res_tags] } + let(:res_sampling_priority_condition) { params[:res_sampling_priority_condition] || ->(x) { x.nil? } } + let(:res_trace_id) { params[:res_trace_id] } + + let(:res_headers) { JSON.parse(response.body) } + + it do + expect(res_headers['X-Datadog-Origin']).to eq(res_origin) + expect(res_headers['X-Datadog-Parent']).to_not eq(res_parent_id_not_equal) if res_parent_id_not_equal + expect(res_headers['X-Datadog-Sampling-Priority']).to(satisfy { |x| res_sampling_priority_condition.call(x) }) + expect(res_headers['X-Datadog-Trace-Id']).to eq(res_trace_id) + expect(res_headers['X-Datadog-Tags'].split(',')).to include(*res_tags) if res_tags + end +end + +RSpec.shared_examples 'a trace sent to agent with Datadog-Client-Computed-Stats header' do + let(:agent_tested_headers) { { 'Datadog-Client-Computed-Stats' => 'yes' } } + + it do + agent_return = agent_http_client.send_traces(traces) + expect(agent_return.first.ok?).to be true + end +end + +RSpec.shared_examples 'appsec standalone billing' do + subject(:response) { get url, params, env } + + let(:appsec_standalone_enabled) { true } + + let(:url) { '/requestdownstream' } + let(:params) { {} } + let(:headers) do + { + 'HTTP_X_DATADOG_TRACE_ID' => headers_trace_id, + 'HTTP_X_DATADOG_PARENT_ID' => headers_parent_id, + 'HTTP_X_DATADOG_SAMPLING_PRIORITY' => headers_sampling_priority, + 'HTTP_X_DATADOG_ORIGIN' => headers_origin, + 'HTTP_X_DATADOG_TAGS' => headers_tags, + 'HTTP_USER_AGENT' => user_agent + } + end + let(:env) { headers } + + # Default values for headers + let(:headers_trace_id) { '1212121212121212121' } + let(:headers_parent_id) { '34343434' } + let(:headers_origin) { 'rum' } + let(:headers_sampling_priority) { '-1' } + let(:headers_tags) { '_dd.p.other=1' } + let(:user_agent) { nil } + + context 'without appsec upstream without attack and trace is kept with priority 1' do + context 'from -1 sampling priority' do + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_other_propagation: '1', + tag_sampling_priority_condition: ->(x) { x < 2 } + } + it_behaves_like 'a request with propagated headers' + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + + context 'from 0 sampling priority' do + let(:headers_sampling_priority) { '0' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_other_propagation: '1', + tag_sampling_priority_condition: ->(x) { x < 2 } + } + it_behaves_like 'a request with propagated headers' + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + + context 'from 1 sampling priority' do + let(:headers_sampling_priority) { '1' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_other_propagation: '1', + tag_sampling_priority_condition: ->(x) { x < 2 } + } + it_behaves_like 'a request with propagated headers' + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + + context 'from 2 sampling priority' do + let(:headers_sampling_priority) { '2' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_other_propagation: '1', + tag_sampling_priority_condition: ->(x) { x < 2 } + } + it_behaves_like 'a request with propagated headers' + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + end + + context 'without upstream appsec propagation with attack and trace is kept with priority 2' do + let(:user_agent) { 'Arachni/v1' } + + context 'from -1 sampling priority' do + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_appsec_propagation: '1', + tag_sampling_priority_condition: ->(x) { x == 2 } + } + it_behaves_like 'a request with propagated headers', + { + res_origin: 'rum', + res_parent_id_not_equal: '34343434', + res_tags: ['_dd.p.other=1', '_dd.p.appsec=1'], + res_sampling_priority_condition: ->(x) { x == '2' }, + res_trace_id: '1212121212121212121' + } + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + + context 'from 0 sampling priority' do + let(:headers_sampling_priority) { '0' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_appsec_propagation: '1', + tag_sampling_priority_condition: ->(x) { x == 2 } + } + it_behaves_like 'a request with propagated headers', + { + res_origin: 'rum', + res_parent_id_not_equal: '34343434', + res_tags: ['_dd.p.other=1', '_dd.p.appsec=1'], + res_sampling_priority_condition: ->(x) { x == '2' }, + res_trace_id: '1212121212121212121' + } + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + end + + context 'with upstream appsec propagation without attack and trace is propagated as is' do + let(:headers_tags) { '_dd.p.appsec=1' } + + context 'from 0 sampling priority' do + let(:headers_sampling_priority) { '0' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_appsec_propagation: '1', + tag_sampling_priority_condition: ->(x) { [0, 2].include?(x) } + } + it_behaves_like 'a request with propagated headers', + { + res_origin: 'rum', + res_parent_id_not_equal: '34343434', + res_tags: ['_dd.p.appsec=1'], + res_sampling_priority_condition: ->(x) { ['0', '2'].include?(x) }, + res_trace_id: '1212121212121212121' + } + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + + context 'from 1 sampling priority' do + let(:headers_sampling_priority) { '1' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_appsec_propagation: '1', + tag_sampling_priority_condition: ->(x) { [1, 2].include?(x) } + } + it_behaves_like 'a request with propagated headers', + { + res_origin: 'rum', + res_parent_id_not_equal: '34343434', + res_tags: ['_dd.p.appsec=1'], + res_sampling_priority_condition: ->(x) { ['1', '2'].include?(x) }, + res_trace_id: '1212121212121212121' + } + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + + context 'from 2 sampling priority' do + let(:headers_sampling_priority) { '2' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_appsec_propagation: '1', + tag_sampling_priority_condition: ->(x) { x == 2 } + } + it_behaves_like 'a request with propagated headers', + { + res_origin: 'rum', + res_parent_id_not_equal: '34343434', + res_tags: ['_dd.p.appsec=1'], + res_sampling_priority_condition: ->(x) { x == '2' }, + res_trace_id: '1212121212121212121' + } + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + end + + context 'with any upstream propagation with attack and raises trace priority to 2' do + let(:user_agent) { 'Arachni/v1' } + let(:headers_tags) { nil } + + context 'from -1 sampling priority' do + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_appsec_propagation: '1', + tag_sampling_priority_condition: ->(x) { x == 2 } + } + it_behaves_like 'a request with propagated headers', + { + res_origin: 'rum', + res_parent_id_not_equal: '34343434', + res_tags: ['_dd.p.appsec=1'], + res_sampling_priority_condition: ->(x) { x == '2' }, + res_trace_id: '1212121212121212121' + } + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + + context 'from 0 sampling priority' do + let(:headers_sampling_priority) { '0' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_appsec_propagation: '1', + tag_sampling_priority_condition: ->(x) { x == 2 } + } + it_behaves_like 'a request with propagated headers', + { + res_origin: 'rum', + res_parent_id_not_equal: '34343434', + res_tags: ['_dd.p.appsec=1'], + res_sampling_priority_condition: ->(x) { x == '2' }, + res_trace_id: '1212121212121212121' + } + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + + context 'from 1 sampling priority' do + let(:headers_sampling_priority) { '1' } + + it_behaves_like 'a trace with ASM Standalone tags', + { + tag_appsec_propagation: '1', + tag_sampling_priority_condition: ->(x) { x == 2 } + } + it_behaves_like 'a request with propagated headers', + { + res_origin: 'rum', + res_parent_id_not_equal: '34343434', + res_tags: ['_dd.p.appsec=1'], + res_sampling_priority_condition: ->(x) { x == '2' }, + res_trace_id: '1212121212121212121' + } + it_behaves_like 'a trace sent to agent with Datadog-Client-Computed-Stats header' + end + end +end diff --git a/spec/datadog/appsec/event_spec.rb b/spec/datadog/appsec/event_spec.rb index 1678afe3f75..ba03bb3bda2 100644 --- a/spec/datadog/appsec/event_spec.rb +++ b/spec/datadog/appsec/event_spec.rb @@ -361,4 +361,95 @@ end end end + + describe '.tag_and_keep!' do + let(:with_trace) { true } + let(:with_span) { true } + + let(:waf_actions) { [] } + let(:waf_result) do + dbl = double + + allow(dbl).to receive(:actions).and_return(waf_actions) + + dbl + end + + let(:scope) do + scope_trace = nil + scope_span = nil + + trace_operation = Datadog::Tracing::TraceOperation.new + trace_operation.measure('root') do |span, trace| + scope_trace = trace if with_trace + scope_span = span if with_span + end + + dbl = double + + allow(dbl).to receive(:trace).and_return(scope_trace) + allow(dbl).to receive(:service_entry_span).and_return(scope_span) + + dbl + end + + before do + # prevent rate limiter to bias tests + Datadog::AppSec::RateLimiter.reset! + + described_class.tag_and_keep!(scope, waf_result) + end + + context 'with no actions' do + it 'does not add appsec.blocked tag to span' do + expect(scope.service_entry_span.send(:meta)).to_not include('appsec.blocked') + expect(scope.service_entry_span.send(:meta)['appsec.event']).to eq('true') + expect(scope.trace.send(:meta)['_dd.p.dm']).to eq('-5') + expect(scope.trace.send(:meta)['_dd.p.appsec']).to eq('1') + end + end + + context 'with block action' do + let(:waf_actions) { ['block'] } + + it 'adds appsec.blocked tag to span' do + expect(scope.service_entry_span.send(:meta)['appsec.blocked']).to eq('true') + expect(scope.service_entry_span.send(:meta)['appsec.event']).to eq('true') + expect(scope.trace.send(:meta)['_dd.p.dm']).to eq('-5') + expect(scope.trace.send(:meta)['_dd.p.appsec']).to eq('1') + end + end + + context 'without service_entry_span' do + let(:with_span) { false } + + it 'does not add appsec span tags but still add distributed tags' do + expect(scope.service_entry_span).to be nil + expect(scope.trace.send(:meta)['_dd.p.dm']).to eq('-5') + expect(scope.trace.send(:meta)['_dd.p.appsec']).to eq('1') + end + end + + context 'without trace' do + let(:with_trace) { false } + + context 'with no actions' do + it 'does not add distributed tags but still add appsec span tags' do + expect(scope.trace).to be nil + expect(scope.service_entry_span.send(:meta)['appsec.blocked']).to be nil + expect(scope.service_entry_span.send(:meta)['appsec.event']).to eq('true') + end + end + + context 'with block action' do + let(:waf_actions) { ['block'] } + + it 'does not add distributed tags but still add appsec span tags' do + expect(scope.trace).to be nil + expect(scope.service_entry_span.send(:meta)['appsec.blocked']).to eq('true') + expect(scope.service_entry_span.send(:meta)['appsec.event']).to eq('true') + end + end + end + end end diff --git a/spec/datadog/appsec/utils/trace_operation_spec.rb b/spec/datadog/appsec/utils/trace_operation_spec.rb new file mode 100644 index 00000000000..15692054506 --- /dev/null +++ b/spec/datadog/appsec/utils/trace_operation_spec.rb @@ -0,0 +1,40 @@ +require 'datadog/appsec/spec_helper' +require 'datadog/appsec/utils/trace_operation' + +RSpec.describe Datadog::AppSec::Utils::TraceOperation do + describe '#appsec_standalone_reject?' do + subject(:appsec_standalone_reject?) do + described_class.appsec_standalone_reject?(trace_op) + end + + let(:trace_op) { Datadog::Tracing::TraceOperation.new(**options) } + let(:options) { {} } + let(:appsec_standalone) { false } + let(:distributed_appsec_event) { '0' } + + before do + allow(Datadog.configuration.appsec.standalone).to receive(:enabled).and_return(appsec_standalone) + trace_op.set_tag(Datadog::AppSec::Ext::TAG_DISTRIBUTED_APPSEC_EVENT, distributed_appsec_event) if trace_op + end + + it { is_expected.to be false } + + context 'when AppSec standalone is enabled' do + let(:appsec_standalone) { true } + + it { is_expected.to be true } + + context 'without a trace' do + let(:trace_op) { nil } + + it { is_expected.to be true } + end + + context 'with a distributed AppSec event' do + let(:distributed_appsec_event) { '1' } + + it { is_expected.to be false } + end + end + end +end diff --git a/spec/datadog/core/remote/transport/http_spec.rb b/spec/datadog/core/remote/transport/http_spec.rb index e9b1656802f..bad8a785464 100644 --- a/spec/datadog/core/remote/transport/http_spec.rb +++ b/spec/datadog/core/remote/transport/http_spec.rb @@ -70,6 +70,14 @@ it { is_expected.to have_attributes(:version => '42') } it { is_expected.to have_attributes(:endpoints => ['/info', '/v0/path']) } it { is_expected.to have_attributes(:config => { max_request_bytes: '1234' }) } + + it { expect(transport.client.api.headers).to_not include('Datadog-Client-Computed-Stats') } + + context 'with ASM standalone enabled' do + before { expect(Datadog.configuration.appsec.standalone).to receive(:enabled).and_return(true) } + + it { expect(transport.client.api.headers['Datadog-Client-Computed-Stats']).to eq('yes') } + end end end @@ -202,6 +210,14 @@ it { is_expected.to have_attributes(:targets => be_a(Hash)) } it { is_expected.to have_attributes(:target_files => be_a(Array)) } + it { expect(transport.client.api.headers).to_not include('Datadog-Client-Computed-Stats') } + + context 'with ASM standalone enabled' do + before { expect(Datadog.configuration.appsec.standalone).to receive(:enabled).and_return(true) } + + it { expect(transport.client.api.headers['Datadog-Client-Computed-Stats']).to eq('yes') } + end + context 'with a network error' do it 'raises a transport error' do expect(http_connection).to receive(:request).and_raise(IOError) diff --git a/spec/datadog/tracing/contrib/http/circuit_breaker_spec.rb b/spec/datadog/tracing/contrib/http/circuit_breaker_spec.rb index 4d656c7ed87..851ccddb86d 100644 --- a/spec/datadog/tracing/contrib/http/circuit_breaker_spec.rb +++ b/spec/datadog/tracing/contrib/http/circuit_breaker_spec.rb @@ -91,4 +91,68 @@ end end end + + describe '#should_skip_distributed_tracing?' do + subject(:should_skip_distributed_tracing?) { circuit_breaker.should_skip_distributed_tracing?(client_config) } + + let(:client_config) { nil } + let(:distributed_tracing) { true } + let(:appsec_standalone) { false } + let(:active_trace) { nil } + let(:distributed_appsec_event) { nil } + + before do + allow(Datadog.configuration.tracing[:http]).to receive(:[]).with(:distributed_tracing).and_return(distributed_tracing) + allow(Datadog.configuration.appsec.standalone).to receive(:enabled).and_return(appsec_standalone) + allow(Datadog::Tracing).to receive(:active_trace).and_return(active_trace) + allow(active_trace).to receive(:get_tag).with('_dd.p.appsec').and_return(distributed_appsec_event) if active_trace + end + + context 'when distributed tracing is enabled' do + it { is_expected.to be false } + end + + context 'when distributed tracing is disabled' do + let(:distributed_tracing) { false } + + it { is_expected.to be true } + end + + context 'when appsec standalone is enabled' do + let(:appsec_standalone) { true } + + context 'when there is no active trace' do + it { is_expected.to be true } + end + + context 'when there is an active trace' do + let(:active_trace) { instance_double(Datadog::Tracing::TraceOperation) } + + context 'when the active trace has no distributed appsec event' do + it { is_expected.to be true } + end + + context 'when the active trace has a distributed appsec event' do + # This should act like standalone appsec is disabled, as it does not return in the + # `if Datadog.configuration.appsec.standalone.enabled` block + # so we're only testing the "no client config, distributed tracing enabled" case here + let(:distributed_appsec_event) { '1' } + + it { is_expected.to be false } + end + end + end + + context 'given a client config with distributed_tracing disabled' do + let(:client_config) { { distributed_tracing: false } } + + it { is_expected.to be true } + end + + context 'given a client config with distributed_tracing enabled' do + let(:client_config) { { distributed_tracing: true } } + + it { is_expected.to be false } + end + end end diff --git a/spec/datadog/tracing/sampling/rule_sampler_spec.rb b/spec/datadog/tracing/sampling/rule_sampler_spec.rb index e63e2e9d6ca..d4eb379f82d 100644 --- a/spec/datadog/tracing/sampling/rule_sampler_spec.rb +++ b/spec/datadog/tracing/sampling/rule_sampler_spec.rb @@ -28,10 +28,18 @@ end end + shared_examples 'a token bucket rate limiter' do |options = { rate: 100, max_tokens: nil }| + it do + expect(rule_sampler.rate_limiter).to be_a(Datadog::Core::TokenBucket) + expect(rule_sampler.rate_limiter.rate).to eq(options[:rate]) + expect(rule_sampler.rate_limiter.max_tokens).to eq(options[:max_tokens] || options[:rate]) + end + end + describe '#initialize' do subject(:rule_sampler) { described_class.new(rules) } - it { expect(rule_sampler.rate_limiter).to be_a(Datadog::Core::TokenBucket) } + it_behaves_like 'a token bucket rate limiter', rate: 100 it { expect(rule_sampler.default_sampler).to be_a(Datadog::Tracing::Sampling::RateByServiceSampler) } context 'with rate_limit ENV' do @@ -40,7 +48,7 @@ .and_return(20.0) end - it { expect(rule_sampler.rate_limiter).to be_a(Datadog::Core::TokenBucket) } + it_behaves_like 'a token bucket rate limiter', rate: 20.0 end context 'with default_sample_rate ENV' do @@ -57,7 +65,7 @@ context 'with rate_limit' do subject(:rule_sampler) { described_class.new(rules, rate_limit: 1.0) } - it { expect(rule_sampler.rate_limiter).to be_a(Datadog::Core::TokenBucket) } + it_behaves_like 'a token bucket rate limiter', rate: 1.0 end context 'with nil rate_limit' do diff --git a/spec/datadog/tracing/transport/http_spec.rb b/spec/datadog/tracing/transport/http_spec.rb index 830f9d372f1..f7af5db609c 100644 --- a/spec/datadog/tracing/transport/http_spec.rb +++ b/spec/datadog/tracing/transport/http_spec.rb @@ -180,6 +180,22 @@ it { is_expected.to_not include(Datadog::Core::Transport::Ext::HTTP::HEADER_CONTAINER_ID) } end end + + context 'when Datadog.configuration.appsec.standalone.enabled' do + before { expect(Datadog.configuration.appsec.standalone).to receive(:enabled).and_return(asm_standalone_enabled) } + + context 'is true' do + let(:asm_standalone_enabled) { true } + + it { is_expected.to include(Datadog::Core::Transport::Ext::HTTP::HEADER_CLIENT_COMPUTED_STATS => 'yes') } + end + + context 'is false' do + let(:asm_standalone_enabled) { false } + + it { is_expected.to_not include(Datadog::Core::Transport::Ext::HTTP::HEADER_CLIENT_COMPUTED_STATS) } + end + end end describe '.default_adapter' do diff --git a/vendor/rbs/libddwaf/0/datadog/appsec/waf.rbs b/vendor/rbs/libddwaf/0/datadog/appsec/waf.rbs index 0418c3e1247..af8c30dd3e5 100644 --- a/vendor/rbs/libddwaf/0/datadog/appsec/waf.rbs +++ b/vendor/rbs/libddwaf/0/datadog/appsec/waf.rbs @@ -182,7 +182,8 @@ module Datadog attr_reader events: data attr_reader total_runtime: ::Float attr_reader timeout: bool - attr_reader actions: data + # Until we update libddwaf, actions is an array + attr_reader actions: ::Array[data] attr_reader derivatives: data def initialize: (::Symbol, data, ::Float, bool, data, data) -> void