From 365dd2f931e9ad7b8c12aa202498daa04d5edfc8 Mon Sep 17 00:00:00 2001 From: Victor Pellan Date: Fri, 7 Feb 2025 17:48:03 +0100 Subject: [PATCH] Add stack trace collection to meta_struct and actions_handler --- lib/datadog/appsec/actions_handler.rb | 23 ++- .../appsec/actions_handler/stack_trace.rb | 76 +++++++ .../actions_handler/stack_trace/collector.rb | 61 ++++++ .../actions_handler/stack_trace/frame.rb | 30 +++ .../stack_trace/representor.rb | 27 +++ lib/datadog/appsec/configuration/settings.rb | 49 +++++ lib/datadog/appsec/context.rb | 9 +- lib/datadog/appsec/ext.rb | 2 + .../appsec/actions_handler/stack_trace.rbs | 13 ++ .../actions_handler/stack_trace/collector.rbs | 19 ++ .../actions_handler/stack_trace/frame.rbs | 19 ++ .../stack_trace/representor.rbs | 17 ++ sig/datadog/appsec/context.rbs | 10 +- sig/datadog/appsec/event.rbs | 2 +- sig/datadog/appsec/ext.rbs | 2 + .../stack_trace/collector_spec.rb | 125 ++++++++++++ .../actions_handler/stack_trace_spec.rb | 187 ++++++++++++++++++ spec/datadog/appsec/actions_handler_spec.rb | 52 +++++ .../appsec/configuration/settings_spec.rb | 184 +++++++++++++++++ spec/support/thread_backtrace_helpers.rb | 64 ++++++ 20 files changed, 961 insertions(+), 10 deletions(-) create mode 100644 lib/datadog/appsec/actions_handler/stack_trace.rb create mode 100644 lib/datadog/appsec/actions_handler/stack_trace/collector.rb create mode 100644 lib/datadog/appsec/actions_handler/stack_trace/frame.rb create mode 100644 lib/datadog/appsec/actions_handler/stack_trace/representor.rb create mode 100644 sig/datadog/appsec/actions_handler/stack_trace.rbs create mode 100644 sig/datadog/appsec/actions_handler/stack_trace/collector.rbs create mode 100644 sig/datadog/appsec/actions_handler/stack_trace/frame.rbs create mode 100644 sig/datadog/appsec/actions_handler/stack_trace/representor.rbs create mode 100644 spec/datadog/appsec/actions_handler/stack_trace/collector_spec.rb create mode 100644 spec/datadog/appsec/actions_handler/stack_trace_spec.rb create mode 100644 spec/support/thread_backtrace_helpers.rb diff --git a/lib/datadog/appsec/actions_handler.rb b/lib/datadog/appsec/actions_handler.rb index 75376de1748..99e48f780bb 100644 --- a/lib/datadog/appsec/actions_handler.rb +++ b/lib/datadog/appsec/actions_handler.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'actions_handler/stack_trace' + module Datadog module AppSec # this module encapsulates functions for handling actions that libddawf returns @@ -19,7 +21,26 @@ def interrupt_execution(action_params) throw(Datadog::AppSec::Ext::INTERRUPT, action_params) end - def generate_stack(_action_params); end + def generate_stack(action_params) + if Datadog.configuration.appsec.stack_trace.enabled + context = AppSec::Context.active + return if context.nil? || + ActionsHandler::StackTrace.skip_stack_trace?(context, group: AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY) + + collected_stack_frames = ActionsHandler::StackTrace.collect_stack_frames + utf8_stack_id = action_params['stack_id'].encode('UTF-8') if action_params['stack_id'] + stack_trace = ActionsHandler::StackTrace::Representor.new( + id: utf8_stack_id, + frames: collected_stack_frames + ) + + ActionsHandler::StackTrace.add_stack_trace_to_context( + stack_trace, + context, + group: AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + end + end def generate_schema(_action_params); end end diff --git a/lib/datadog/appsec/actions_handler/stack_trace.rb b/lib/datadog/appsec/actions_handler/stack_trace.rb new file mode 100644 index 00000000000..859870d6a86 --- /dev/null +++ b/lib/datadog/appsec/actions_handler/stack_trace.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative 'stack_trace/representor' +require_relative 'stack_trace/collector' + +require_relative '../../tracing/metadata/metastruct' + +module Datadog + module AppSec + module ActionsHandler + # Adds stack traces to meta_struct + module StackTrace + module_function + + def skip_stack_trace?(context, group:) + if context.trace.nil? && context.span.nil? + Datadog.logger.debug { 'Cannot find trace or service entry span to add stack trace' } + return true + end + + max_collect = Datadog.configuration.appsec.stack_trace.max_collect + return false if max_collect == 0 + + stack_traces_count = 0 + + unless context.trace.nil? + trace_dd_stack = context.trace.metastruct[AppSec::Ext::TAG_STACK_TRACE] + stack_traces_count += trace_dd_stack[group].size unless trace_dd_stack.nil? || trace_dd_stack[group].nil? + end + + unless context.span.nil? + span_dd_stack = context.span.metastruct[AppSec::Ext::TAG_STACK_TRACE] + stack_traces_count += span_dd_stack[group].size unless span_dd_stack.nil? || span_dd_stack[group].nil? + end + + stack_traces_count >= max_collect + end + + def collect_stack_frames + # caller_locations without params always returns an array but steep still thinks it can be nil + # So we add || [] but it will never run the second part anyway (either this or steep:ignore) + stack_frames = caller_locations || [] + # Steep thinks that path can still be nil and that include? is not a method of nil + # We must add a variable assignment to avoid this + stack_frames.reject! do |loc| + path = loc.path + next true if path.nil? + + path.include?('lib/datadog') + end + + StackTrace::Collector.collect(stack_frames) + end + + def add_stack_trace_to_context(stack_trace, context, group:) + # We use methods defined in Tracing::Metadata::Tagging, + # which means we can use both the trace and the service entry span + service_entry_op = (context.trace || context.span) + + dd_stack = service_entry_op.metastruct[AppSec::Ext::TAG_STACK_TRACE] + if dd_stack.nil? + service_entry_op.metastruct[AppSec::Ext::TAG_STACK_TRACE] = {} + dd_stack = service_entry_op.metastruct[AppSec::Ext::TAG_STACK_TRACE] + end + + dd_stack[AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY] ||= [] + stack_group = dd_stack[AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY] + + stack_group << stack_trace + rescue StandardError => e + Datadog.logger.debug("Unable to add stack_trace #{stack_trace.id} in metastruct, ignoring it. Caused by: #{e}") + end + end + end + end +end diff --git a/lib/datadog/appsec/actions_handler/stack_trace/collector.rb b/lib/datadog/appsec/actions_handler/stack_trace/collector.rb new file mode 100644 index 00000000000..a6c7cacf854 --- /dev/null +++ b/lib/datadog/appsec/actions_handler/stack_trace/collector.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative 'frame' + +module Datadog + module AppSec + module ActionsHandler + module StackTrace + # Represent a stack trace with its id and message in message pack + module Collector + class << self + def collect(locations) + return [] if locations.nil? || locations.empty? + + skip_frames = skip_frames(locations.size) + frames = [] + + locations.each_with_index do |location, index| + next if skip_frames.include?(index) + + frames << StackTrace::Frame.new( + id: index, + text: location.to_s.encode('UTF-8'), + file: file_path(location), + line: location.lineno, + function: function_label(location) + ) + end + frames + end + + private + + def skip_frames(locations_size) + max_depth = Datadog.configuration.appsec.stack_trace.max_depth + return [] if max_depth == 0 || locations_size <= max_depth + + top_frames_limit = (max_depth * Datadog.configuration.appsec.stack_trace.max_depth_top_percent / 100.0).round + bottom_frames_limit = locations_size - (max_depth - top_frames_limit) + (top_frames_limit...bottom_frames_limit) + end + + def file_path(location) + path = location.absolute_path || location.path + return if path.nil? + + path.encode('UTF-8') + end + + def function_label(location) + label = location.label + return if label.nil? + + label.encode('UTF-8') + end + end + end + end + end + end +end diff --git a/lib/datadog/appsec/actions_handler/stack_trace/frame.rb b/lib/datadog/appsec/actions_handler/stack_trace/frame.rb new file mode 100644 index 00000000000..d4d75a579c4 --- /dev/null +++ b/lib/datadog/appsec/actions_handler/stack_trace/frame.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module ActionsHandler + module StackTrace + # Formatted stack frame. + # This class extends a Struct as it's required by Steep to be able to add a method to it. + class Frame < Struct.new(:id, :text, :file, :line, :function, keyword_init: true) # rubocop:disable Style/StructInheritance + def to_msgpack(packer = nil) + packer ||= MessagePack::Packer.new + + packer.write_map_header(5) + packer.write('id') + packer.write(id) + packer.write('text') + packer.write(text) + packer.write('file') + packer.write(file) + packer.write('line') + packer.write(line) + packer.write('function') + packer.write(function) + packer + end + end + end + end + end +end diff --git a/lib/datadog/appsec/actions_handler/stack_trace/representor.rb b/lib/datadog/appsec/actions_handler/stack_trace/representor.rb new file mode 100644 index 00000000000..ce34433de06 --- /dev/null +++ b/lib/datadog/appsec/actions_handler/stack_trace/representor.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module ActionsHandler + module StackTrace + # Represent a stack trace with its id and message in message pack + class Representor < Struct.new(:id, :message, :frames, keyword_init: true) # rubocop:disable Style/StructInheritance + def to_msgpack(packer = nil) + packer ||= MessagePack::Packer.new + + packer.write_map_header(4) + packer.write('language') + packer.write('ruby') + packer.write('id') + packer.write(id) + packer.write('message') + packer.write(message) + packer.write('frames') + packer.write(frames) + packer + end + end + end + end + end +end diff --git a/lib/datadog/appsec/configuration/settings.rb b/lib/datadog/appsec/configuration/settings.rb index 74a3aabb62e..e9b668ca436 100644 --- a/lib/datadog/appsec/configuration/settings.rb +++ b/lib/datadog/appsec/configuration/settings.rb @@ -214,6 +214,55 @@ def self.add_settings!(base) o.default false end end + + settings :stack_trace do + option :enabled do |o| + o.type :bool + o.env 'DD_APPSEC_STACK_TRACE_ENABLED' + o.default true + end + + # The maximum number of stack frames to collect for each stack trace. + # If the number of frames in a stack trace exceeds this value, + # max_depth / 4 frames will be collected from the top, and max_depth * 3 / 4 from the bottom. + option :max_depth do |o| + o.type :int + o.env 'DD_APPSEC_MAX_STACK_TRACE_DEPTH' + o.default 32 + # 0 means no limit + o.setter do |value| + value = 0 if value.negative? + value + end + end + + # The percentage that decides the number of top stack frame to collect + # for each stack trace if there is more stack frames than max_depth. + # number_of_top_frames = max_depth * max_depth_top_percent / 100 + # Default is 75 + option :max_depth_top_percent do |o| + o.type :float + o.env 'DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT' + o.default 75 + o.setter do |value| + value = 100 if value > 100 + value = 0 if value < 0 + value + end + end + + # The maximum number of stack traces to collect for each exploit prevention event. + option :max_collect do |o| + o.type :int + o.env 'DD_APPSEC_MAX_STACK_TRACES' + o.default 2 + # 0 means no limit + o.setter do |value| + value = 0 if value < 0 + value + end + end + end end end end diff --git a/lib/datadog/appsec/context.rb b/lib/datadog/appsec/context.rb index 7038c376079..06b189d4257 100644 --- a/lib/datadog/appsec/context.rb +++ b/lib/datadog/appsec/context.rb @@ -60,10 +60,13 @@ def extract_schema end def export_metrics - return if @span.nil? + # Required to satisfy steep, as @span is defined as nilable. + # According to soutaro, this is because instance variable can be changed by other threads. + span = @span + return if span.nil? - Metrics::Exporter.export_waf_metrics(@metrics.waf, @span) - Metrics::Exporter.export_rasp_metrics(@metrics.rasp, @span) + Metrics::Exporter.export_waf_metrics(@metrics.waf, span) + Metrics::Exporter.export_rasp_metrics(@metrics.rasp, span) end def finalize diff --git a/lib/datadog/appsec/ext.rb b/lib/datadog/appsec/ext.rb index 6c76e708ad1..745789acb90 100644 --- a/lib/datadog/appsec/ext.rb +++ b/lib/datadog/appsec/ext.rb @@ -10,12 +10,14 @@ module Ext INTERRUPT = :datadog_appsec_interrupt CONTEXT_KEY = 'datadog.appsec.context' ACTIVE_CONTEXT_KEY = :datadog_appsec_active_context + EXPLOIT_PREVENTION_EVENT_CATEGORY = 'exploit' TAG_APPSEC_ENABLED = '_dd.appsec.enabled' TAG_APM_ENABLED = '_dd.apm.enabled' TAG_DISTRIBUTED_APPSEC_EVENT = '_dd.p.appsec' TELEMETRY_METRICS_NAMESPACE = 'appsec' + TAG_STACK_TRACE = '_dd.stack' end end end diff --git a/sig/datadog/appsec/actions_handler/stack_trace.rbs b/sig/datadog/appsec/actions_handler/stack_trace.rbs new file mode 100644 index 00000000000..363ecb8f9ff --- /dev/null +++ b/sig/datadog/appsec/actions_handler/stack_trace.rbs @@ -0,0 +1,13 @@ +module Datadog + module AppSec + module ActionsHandler + module StackTrace + def self.skip_stack_trace?: (Datadog::AppSec::Context context, group: String) -> bool + + def self.collect_stack_frames: () -> Array[StackTrace::Frame]? + + def self.add_stack_trace_to_context: (Datadog::AppSec::ActionsHandler::StackTrace::Representor stack_trace, Datadog::AppSec::Context context, group: String) -> void + end + end + end +end diff --git a/sig/datadog/appsec/actions_handler/stack_trace/collector.rbs b/sig/datadog/appsec/actions_handler/stack_trace/collector.rbs new file mode 100644 index 00000000000..3b88216308c --- /dev/null +++ b/sig/datadog/appsec/actions_handler/stack_trace/collector.rbs @@ -0,0 +1,19 @@ +module Datadog + module AppSec + module ActionsHandler + module StackTrace + module Collector + def self.collect: (Array[Thread::Backtrace::Location] locations) -> Array[Datadog::AppSec::ActionsHandler::StackTrace::Frame] + + private + + def self.skip_frames: (Integer locations_size) -> (Range[Integer] | Array[untyped]) + + def self.file_path: (Thread::Backtrace::Location location) -> String? + + def self.function_label: (Thread::Backtrace::Location location) -> String? + end + end + end + end +end diff --git a/sig/datadog/appsec/actions_handler/stack_trace/frame.rbs b/sig/datadog/appsec/actions_handler/stack_trace/frame.rbs new file mode 100644 index 00000000000..3945a3ff188 --- /dev/null +++ b/sig/datadog/appsec/actions_handler/stack_trace/frame.rbs @@ -0,0 +1,19 @@ +module Datadog + module AppSec + module ActionsHandler + module StackTrace + class Frame + attr_reader id: Integer + attr_reader text: String? + attr_reader file: String? + attr_reader line: Integer? + attr_reader function: String? + + def initialize: (?id: Integer, ?text: String?, ?file: String?, ?line: Integer?, ?function: String?) -> void + + def to_msgpack: ((::MessagePack::Packer | nil) packer) -> ::MessagePack::Packer + end + end + end + end +end diff --git a/sig/datadog/appsec/actions_handler/stack_trace/representor.rbs b/sig/datadog/appsec/actions_handler/stack_trace/representor.rbs new file mode 100644 index 00000000000..472444d8add --- /dev/null +++ b/sig/datadog/appsec/actions_handler/stack_trace/representor.rbs @@ -0,0 +1,17 @@ +module Datadog + module AppSec + module ActionsHandler + module StackTrace + class Representor + attr_reader id: String? + attr_reader message: String? + attr_reader frames: Array[StackTrace::Frame]? + + def initialize: (?id: String?, ?message: String?, ?frames: Array[StackTrace::Frame]?) -> void + + def to_msgpack: ((::MessagePack::Packer | nil) packer) -> ::MessagePack::Packer + end + end + end + end +end diff --git a/sig/datadog/appsec/context.rbs b/sig/datadog/appsec/context.rbs index 1a3ecc0896a..495a962fe9a 100644 --- a/sig/datadog/appsec/context.rbs +++ b/sig/datadog/appsec/context.rbs @@ -3,9 +3,9 @@ module Datadog class Context type input_data = SecurityEngine::Runner::input_data - @trace: Tracing::TraceOperation + @trace: Tracing::TraceOperation? - @span: Tracing::SpanOperation + @span: Tracing::SpanOperation? @events: ::Array[untyped] @@ -17,9 +17,9 @@ module Datadog ActiveContextError: ::StandardError - attr_reader trace: Tracing::TraceOperation + attr_reader trace: Tracing::TraceOperation? - attr_reader span: Tracing::SpanOperation + attr_reader span: Tracing::SpanOperation? attr_reader events: ::Array[untyped] @@ -27,7 +27,7 @@ module Datadog def self.deactivate: () -> void - def self.active: () -> Context + def self.active: () -> Context? def initialize: (Tracing::TraceOperation trace, Tracing::SpanOperation span, AppSec::Processor security_engine) -> void diff --git a/sig/datadog/appsec/event.rbs b/sig/datadog/appsec/event.rbs index 713f73a2129..23ca53defe9 100644 --- a/sig/datadog/appsec/event.rbs +++ b/sig/datadog/appsec/event.rbs @@ -24,7 +24,7 @@ module Datadog def self.gzip: (untyped value) -> untyped - def self.add_distributed_tags: (Tracing::TraceOperation trace) -> void + def self.add_distributed_tags: (Tracing::TraceOperation? trace) -> void end end end diff --git a/sig/datadog/appsec/ext.rbs b/sig/datadog/appsec/ext.rbs index 58bb2277da3..78bf0fad212 100644 --- a/sig/datadog/appsec/ext.rbs +++ b/sig/datadog/appsec/ext.rbs @@ -14,6 +14,7 @@ module Datadog CONTEXT_KEY: ::String ACTIVE_CONTEXT_KEY: ::Symbol + EXPLOIT_PREVENTION_EVENT_CATEGORY: ::String TAG_APPSEC_ENABLED: ::String @@ -22,6 +23,7 @@ module Datadog TAG_DISTRIBUTED_APPSEC_EVENT: ::String TELEMETRY_METRICS_NAMESPACE: ::String + TAG_STACK_TRACE: ::String end end end diff --git a/spec/datadog/appsec/actions_handler/stack_trace/collector_spec.rb b/spec/datadog/appsec/actions_handler/stack_trace/collector_spec.rb new file mode 100644 index 00000000000..d863a2dee3d --- /dev/null +++ b/spec/datadog/appsec/actions_handler/stack_trace/collector_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'ostruct' + +require 'datadog/appsec/actions_handler/stack_trace/collector' +require 'datadog/appsec/actions_handler/stack_trace/frame' +require 'datadog/appsec/ext' +require 'datadog/appsec/spec_helper' +require 'support/thread_backtrace_helpers' + +RSpec.describe Datadog::AppSec::ActionsHandler::StackTrace::Collector do + describe '.collect' do + subject(:collection) { described_class.collect(frames) } + + let(:max_depth) { nil } + let(:max_depth_top_percent) { nil } + + # "/app/spec/support/thread_backtrace_helpers.rb:12:in `block in locations_inside_nested_blocks'", + # "/app/spec/support/thread_backtrace_helpers.rb:14:in `block (2 levels) in locations_inside_nested_blocks'", + # "/app/spec/support/thread_backtrace_helpers.rb:16:in `block (3 levels) in locations_inside_nested_blocks'", + # "/app/spec/support/thread_backtrace_helpers.rb:16:in `block (4 levels) in locations_inside_nested_blocks'", + # "/app/spec/support/thread_backtrace_helpers.rb:16:in `block (5 levels) in locations_inside_nested_blocks'" + let(:frames) { ThreadBacktraceHelper.locations_inside_nested_blocks } + + before do + Datadog.configure do |c| + c.appsec.stack_trace.max_depth = max_depth if max_depth + c.appsec.stack_trace.max_depth_top_percent = max_depth_top_percent if max_depth_top_percent + end + end + + context 'with default values' do + it 'creates a stack trace with default values' do + expect(collection.count).to eq(5) + end + end + + context 'without values' do + let(:frames) { nil } + + it 'returns an empty array' do + expect(collection).to eq([]) + end + end + + context 'with max_depth set to 4' do + let(:max_depth) { 4 } + + it 'creates a stack trace with 4 frames, 3 top' do + expect(collection.count).to eq(4) + expect(collection[2].text).to eq(frames[2].to_s) + expect(collection[3].text).to eq(frames[4].to_s) + end + + context 'with max_depth_top_percent set to 25' do + let(:max_depth_top_percent) { 25 } + + it 'creates a stack trace with 4 frames, 1 top' do + expect(collection.count).to eq(4) + expect(collection[0].text).to eq(frames[0].to_s) + expect(collection[1].text).to eq(frames[2].to_s) + end + end + + context 'with max_depth_top_percent set to 100' do + let(:max_depth_top_percent) { 100 } + + it 'creates a stack trace with 4 top frames' do + expect(collection.count).to eq(4) + expect(collection[0].text).to eq(frames[0].to_s) + expect(collection[3].text).to eq(frames[3].to_s) + end + end + + context 'with max_depth_top_percent set to 0' do + let(:max_depth_top_percent) { 0 } + + it 'creates a stack trace with 4 bottom frames' do + expect(collection.count).to eq(4) + expect(collection[0].text).to eq(frames[1].to_s) + expect(collection[3].text).to eq(frames[4].to_s) + end + end + end + + context 'with max_depth set to 3' do + let(:max_depth) { 3 } + + context 'with max_depth_top_percent set to 66.67' do + let(:max_depth_top_percent) { 200 / 3.0 } + + it 'creates a stack trace with 3 frames, 2 top' do + expect(collection.count).to eq(3) + expect(collection[1].text).to eq(frames[1].to_s) + expect(collection[2].text).to eq(frames[4].to_s) + end + end + end + + context 'with max_depth set to 0' do + let(:max_depth) { 0 } + let(:frames) { ThreadBacktraceHelper.thousand_locations } + + it 'does not apply any limit' do + expect(collection.count).to eq(1000) + end + end + + context 'with values encoded in ASCII-8BIT' do + let(:frames) { ThreadBacktraceHelper.location_ascii_8bit } + + it 'creates a stack trace with correctly encoded values' do + expect(collection.count).to eq(1) + expect(collection[0].id).to eq(0) + expect(collection[0].text).to eq(frames[0].to_s) + expect(collection[0].text.encoding).to eq(Encoding::UTF_8) + expect(collection[0].file).to eq(frames[0].path) + expect(collection[0].file.encoding).to eq(Encoding::UTF_8) + expect(collection[0].line).to eq(frames[0].lineno) + expect(collection[0].function).to eq(frames[0].label) + expect(collection[0].function.encoding).to eq(Encoding::UTF_8) + end + end + end +end diff --git a/spec/datadog/appsec/actions_handler/stack_trace_spec.rb b/spec/datadog/appsec/actions_handler/stack_trace_spec.rb new file mode 100644 index 00000000000..a90e2110f3d --- /dev/null +++ b/spec/datadog/appsec/actions_handler/stack_trace_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'ostruct' + +require 'datadog/appsec/actions_handler/stack_trace/frame' +require 'datadog/appsec/actions_handler/stack_trace/representor' + +require 'datadog/appsec/actions_handler/stack_trace' +require 'datadog/appsec/ext' +require 'datadog/appsec/spec_helper' + +RSpec.describe Datadog::AppSec::ActionsHandler::StackTrace do + describe '.skip_stack_trace?' do + subject(:skip_stack_trace?) { described_class.skip_stack_trace?(context, group: group) } + + let(:trace_op) { Datadog::Tracing::TraceOperation.new } + let(:span_op) { Datadog::Tracing::SpanOperation.new('span_test') } + let(:context) { OpenStruct.new(trace: trace_op, span: span_op) } + let(:group) { Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY } + let(:max_collect) { 0 } + + before do + allow(Datadog.configuration.appsec.stack_trace).to receive(:max_collect).and_return(max_collect) + end + + context 'when max_collect is 0' do + it { is_expected.to be false } + end + + context 'when max_collect is 2' do + let(:max_collect) { 2 } + + context 'with no stack traces' do + it { is_expected.to be false } + end + + context 'with one element contained in same group in trace' do + before do + trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { group => [1] } + end + + it { is_expected.to be false } + end + + context 'with one element contained in same group in span' do + before do + span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { group => [1] } + end + + it { is_expected.to be false } + end + + context 'with two elements contained in same group in trace' do + before do + trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { group => [1, 2] } + end + + it { is_expected.to be true } + end + + context 'with two elements contained in same group in span' do + before do + span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { group => [1, 2] } + end + + it { is_expected.to be true } + end + + context 'with one element contained in same group in span and trace' do + before do + trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { group => [1] } + span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { group => [2] } + end + + it { is_expected.to be true } + end + + context 'with two elements contained in different group in trace' do + before do + trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { 'other_group' => [1, 2] } + end + + it { is_expected.to be false } + end + end + + context 'when both trace and span are nil' do + let(:trace_op) { nil } + let(:span_op) { nil } + + it { is_expected.to be true } + end + end + + describe '.collect_stack_frames' do + subject(:collect_stack_frames) { described_class.collect_stack_frames } + + it 'returns stack frames excluding those from datadog' do + expect(collect_stack_frames.any? { |loc| loc.file.include?('lib/datadog') }).to be false + end + end + + describe '.add_stack_trace_to_context' do + let(:stack_trace) do + Datadog::AppSec::ActionsHandler::StackTrace::Representor.new( + id: 'foo', + message: 'bar', + frames: [ + Datadog::AppSec::ActionsHandler::StackTrace::Frame.new( + id: 1, + text: 'frame 1', + file: 'file 1', + line: 1, + function: 'function 1' + ), + Datadog::AppSec::ActionsHandler::StackTrace::Frame.new( + id: 2, + text: 'frame 2', + file: 'file 2', + line: 2, + function: 'function 2' + ) + ] + ) + end + let(:trace_op) { Datadog::Tracing::TraceOperation.new } + let(:span_op) { Datadog::Tracing::SpanOperation.new('span_test') } + let(:context) { OpenStruct.new(trace: trace_op, span: span_op) } + let(:group) { Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY } + + context 'without existing dd.stack, with trace and span in context' do + it 'adds stack trace to trace' do + described_class.add_stack_trace_to_context(stack_trace, context, group: group) + + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].size).to eq(1) + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].first.id).to eq('foo') + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].first.message).to eq('bar') + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].first.frames.size).to eq(2) + end + end + + context 'with existing dd.stack, with trace and span in context' do + before do + trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { group => [1] } + end + + it 'adds stack trace to trace' do + described_class.add_stack_trace_to_context(stack_trace, context, group: group) + + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].size).to eq(2) + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group][1].id).to eq('foo') + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group][1].message).to eq('bar') + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group][1].frames.size).to eq(2) + end + end + + context 'without existing dd.stack, with only span in context' do + let(:context) { OpenStruct.new(span: span_op) } + + it 'adds stack trace to span' do + described_class.add_stack_trace_to_context(stack_trace, context, group: group) + + expect(span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].size).to eq(1) + expect(span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].first.id).to eq('foo') + expect(span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].first.message).to eq('bar') + expect(span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].first.frames.size).to eq(2) + end + end + + context 'with existing dd.stack, with only span in context' do + let(:context) { OpenStruct.new(span: span_op) } + + before do + span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE] = { group => [1] } + end + + it 'adds stack trace to span' do + described_class.add_stack_trace_to_context(stack_trace, context, group: group) + + expect(span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group].size).to eq(2) + expect(span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group][1].id).to eq('foo') + expect(span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group][1].message).to eq('bar') + expect(span_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE][group][1].frames.size).to eq(2) + end + end + end +end diff --git a/spec/datadog/appsec/actions_handler_spec.rb b/spec/datadog/appsec/actions_handler_spec.rb index 4d6cd44cd56..934e53ed34e 100644 --- a/spec/datadog/appsec/actions_handler_spec.rb +++ b/spec/datadog/appsec/actions_handler_spec.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true +require 'ostruct' + +require 'datadog/appsec/ext' require 'datadog/appsec/spec_helper' +require 'support/thread_backtrace_helpers' RSpec.describe Datadog::AppSec::ActionsHandler do describe '.handle' do @@ -97,4 +101,52 @@ end end end + + describe '.generate_stack' do + let(:generate_stack_action) { { 'stack_id' => 'foo' } } + let(:trace_op) { Datadog::Tracing::TraceOperation.new } + let(:span_op) { Datadog::Tracing::SpanOperation.new('span_test') } + let(:context) { OpenStruct.new(trace: trace_op, span: span_op) } + let(:stack_trace_enabled) { true } + + let(:stack_key) { Datadog::AppSec::Ext::TAG_STACK_TRACE } + let(:exploit_category) { Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY } + + before do + allow(Datadog.configuration.appsec.stack_trace).to receive(:enabled).and_return(stack_trace_enabled) + allow(Datadog::AppSec::Context).to receive(:active).and_return(context) + allow(Datadog::AppSec::ActionsHandler::StackTrace).to receive(:collect_stack_frames).and_return( + ThreadBacktraceHelper.locations_inside_nested_blocks + ) + described_class.generate_stack(generate_stack_action) + end + + context 'when stack trace is enabled and context contains trace and span' do + it 'adds stack trace representation to the trace' do + test_result = trace_op.metastruct[stack_key][exploit_category] + expect(test_result.size).to eq(1) + expect(test_result.first.id).to eq('foo') + expect(test_result.first.frames.size).to eq(5) + end + end + + context 'when stack trace is enabled and context contains only span' do + let(:context) { OpenStruct.new(span: span_op) } + + it 'adds stack trace representation to the span' do + test_result = span_op.metastruct[stack_key][exploit_category] + expect(test_result.size).to eq(1) + expect(test_result.first.id).to eq('foo') + expect(test_result.first.frames.size).to eq(5) + end + end + + context 'when stack trace is disabled' do + let(:stack_trace_enabled) { false } + + it 'does not add stack trace representation to the trace' do + expect(trace_op.metastruct[stack_key]).to be_nil + end + end + end end diff --git a/spec/datadog/appsec/configuration/settings_spec.rb b/spec/datadog/appsec/configuration/settings_spec.rb index 541546ae364..87f10c792f8 100644 --- a/spec/datadog/appsec/configuration/settings_spec.rb +++ b/spec/datadog/appsec/configuration/settings_spec.rb @@ -819,5 +819,189 @@ def patcher end end end + + describe 'stack_trace' do + describe '#enabled' do + subject(:enabled) { settings.appsec.stack_trace.enabled } + + context 'when DD_APPSEC_STACK_TRACE_ENABLED' do + around do |example| + ClimateControl.modify('DD_APPSEC_STACK_TRACE_ENABLED' => stack_trace_enabled) do + example.run + end + end + + context 'is not defined' do + let(:stack_trace_enabled) { nil } + + it { is_expected.to eq true } + end + + [true, false].each do |value| + context "is defined as #{value}" do + let(:stack_trace_enabled) { value.to_s } + + it { is_expected.to eq value } + end + end + end + end + + describe '#enabled=' do + subject(:set_stack_trace_enabled) { settings.appsec.stack_trace.enabled = stack_trace_enabled } + + [true, false].each do |value| + context "when given #{value}" do + let(:stack_trace_enabled) { value } + + before { set_stack_trace_enabled } + + it { expect(settings.appsec.stack_trace.enabled).to eq(value) } + end + end + end + + describe '#max_depth' do + subject(:max_depth) { settings.appsec.stack_trace.max_depth } + + context 'when DD_APPSEC_MAX_STACK_TRACE_DEPTH' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACE_DEPTH' => stack_trace_max_depth) do + example.run + end + end + + context 'is not defined' do + let(:stack_trace_max_depth) { nil } + + it { is_expected.to eq 32 } + end + + context 'is defined' do + let(:stack_trace_max_depth) { '64' } + + it { is_expected.to eq(64) } + end + end + end + + describe '#max_depth=' do + subject(:set_stack_trace_max_depth) { settings.appsec.stack_trace.max_depth = stack_trace_max_depth } + + context 'when given a value' do + let(:stack_trace_max_depth) { 64 } + + before { set_stack_trace_max_depth } + + it { expect(settings.appsec.stack_trace.max_depth).to eq(64) } + end + + context 'when given a negative value' do + let(:stack_trace_max_depth) { -1 } + + before { set_stack_trace_max_depth } + + it { expect(settings.appsec.stack_trace.max_depth).to eq(0) } + end + end + + describe '#max_depth_top_percent' do + subject(:max_depth_top_percent) { settings.appsec.stack_trace.max_depth_top_percent } + + context 'when DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT' => stack_trace_max_depth_top_percent) do + example.run + end + end + + context 'is not defined' do + let(:stack_trace_max_depth_top_percent) { nil } + + it { is_expected.to eq 75 } + end + + context 'is defined' do + let(:stack_trace_max_depth_top_percent) { '50' } + + it { is_expected.to eq(50) } + end + end + end + + describe '#max_depth_top_percent=' do + subject(:set_stack_trace_max_depth_top_percent) do + settings.appsec.stack_trace.max_depth_top_percent = stack_trace_max_depth_top_percent + end + + context 'when given a value' do + let(:stack_trace_max_depth_top_percent) { 50 } + + before { set_stack_trace_max_depth_top_percent } + + it { expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(50) } + end + + context 'when given a negative value' do + let(:stack_trace_max_depth_top_percent) { -1 } + + before { set_stack_trace_max_depth_top_percent } + + it { expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(0) } + end + + context 'when given a value higher than 100' do + let(:stack_trace_max_depth_top_percent) { 101 } + + before { set_stack_trace_max_depth_top_percent } + + it { expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(100) } + end + end + + describe '#max_collect' do + subject(:max_collect) { settings.appsec.stack_trace.max_collect } + + context 'when DD_APPSEC_MAX_STACK_TRACES' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACES' => stack_trace_max_collect) do + example.run + end + end + + context 'is not defined' do + let(:stack_trace_max_collect) { nil } + + it { is_expected.to eq 2 } + end + + context 'is defined' do + let(:stack_trace_max_collect) { '4' } + + it { is_expected.to eq(4) } + end + end + end + + describe '#max_collect=' do + subject(:set_stack_trace_max_collect) { settings.appsec.stack_trace.max_collect = stack_trace_max_collect } + + context 'when given a value' do + let(:stack_trace_max_collect) { 4 } + + before { set_stack_trace_max_collect } + + it { expect(settings.appsec.stack_trace.max_collect).to eq(4) } + end + + context 'when given a negative value' do + let(:stack_trace_max_collect) { -1 } + + before { set_stack_trace_max_collect } + + it { expect(settings.appsec.stack_trace.max_collect).to eq(0) } + end + end + end end end diff --git a/spec/support/thread_backtrace_helpers.rb b/spec/support/thread_backtrace_helpers.rb new file mode 100644 index 00000000000..2f47b832176 --- /dev/null +++ b/spec/support/thread_backtrace_helpers.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Module to test stack trace generation. Inspired by: +# https://github.com/ruby/ruby/blob/master/spec/ruby/core/thread/backtrace/location/fixtures/classes.rb +module ThreadBacktraceHelper + def self.locations + caller_locations + end + + # Deeply nested blocks to test max_depth and max_depth_top_percentage variables + def self.locations_inside_nested_blocks + first_level_location = nil + second_level_location = nil + third_level_location = nil + fourth_level_location = nil + fifth_level_location = nil + + # rubocop:disable Lint/UselessTimes + 1.times do + first_level_location = locations.first + 1.times do + second_level_location = locations.first + 1.times do + third_level_location = locations.first + 1.times do + fourth_level_location = locations.first + 1.times do + fifth_level_location = locations.first + end + end + end + end + end + # rubocop:enable Lint/UselessTimes + + [first_level_location, second_level_location, third_level_location, fourth_level_location, fifth_level_location] + end + + def self.thousand_locations + locations = [] + 1000.times do + locations << self.locations.first + end + locations + end + + LocationASCII8Bit = Struct.new(:text, :path, :lineno, :label, keyword_init: true) do + def to_s + text + end + end + + def self.location_ascii_8bit + location = locations.first + LocationASCII8Bit.new( + text: location.to_s.encode('ASCII-8BIT'), + path: (location.absolute_path || location.path).encode('ASCII-8BIT'), + lineno: location.lineno, + label: location.label.encode('ASCII-8BIT') + ) + + [location] + end +end