diff --git a/Steepfile b/Steepfile index 26bf7f7fc24..89821e2cd93 100644 --- a/Steepfile +++ b/Steepfile @@ -157,6 +157,7 @@ target :datadog do library 'zlib' library 'time' library 'pp' + library 'forwardable' # Load all dependency signatures from the `vendor/rbs` directory repo_path 'vendor/rbs' diff --git a/lib/datadog/appsec/actions_handler.rb b/lib/datadog/appsec/actions_handler.rb index 75376de1748..c1cf7a618e5 100644 --- a/lib/datadog/appsec/actions_handler.rb +++ b/lib/datadog/appsec/actions_handler.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require_relative 'actions_handler/stack_trace_in_metastruct' +require_relative 'actions_handler/stack_trace_collection' + module Datadog module AppSec # this module encapsulates functions for handling actions that libddawf returns @@ -19,7 +22,32 @@ def interrupt_execution(action_params) throw(Datadog::AppSec::Ext::INTERRUPT, action_params) end - def generate_stack(_action_params); end + def generate_stack(action_params) + return unless Datadog.configuration.appsec.stack_trace.enabled + + context = AppSec.active_context + if context.nil? || context.trace.nil? && context.span.nil? + Datadog.logger.debug { 'Cannot find trace or service entry span to add stack trace' } + return + end + + # Check that the sum of stack_trace count in trace and entry_span does not exceed configuration + span_stack = StackTraceInMetastruct.create(context.span&.metastruct) + trace_stack = StackTraceInMetastruct.create(context.trace&.metastruct) + config = Datadog.configuration.appsec.stack_trace + return if config.max_collect != 0 && span_stack.count + trace_stack.count >= config.max_collect + + # Generate stacktrace + stack_id = action_params['stack_id'].encode('UTF-8') + stack_frames = StackTraceCollection.collect( + max_depth: config.max_depth, + top_percent: config.max_depth_top_percent + ) + + # Add newly created stacktrace to metastruct + stack = context.trace.nil? ? span_stack : trace_stack + stack.push({ language: 'ruby', id: stack_id, frames: stack_frames }) + end def generate_schema(_action_params); end end diff --git a/lib/datadog/appsec/actions_handler/stack_trace_collection.rb b/lib/datadog/appsec/actions_handler/stack_trace_collection.rb new file mode 100644 index 00000000000..78b11b87218 --- /dev/null +++ b/lib/datadog/appsec/actions_handler/stack_trace_collection.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module ActionsHandler + # Module that collects the stack trace into formatted hash + module StackTraceCollection + module_function + + def collect(max_depth:, top_percent:) + locations = StackTraceCollection.filter_map_datadog_locations(caller_locations || []) + + return [] if locations.empty? + return StackTraceCollection.convert(locations) if max_depth.zero? || locations.size <= max_depth + + top_limit = (max_depth * top_percent / 100.0).round + bottom_limit = locations.size - (max_depth - top_limit) + + locations.slice!(top_limit...bottom_limit) + StackTraceCollection.convert(locations) + end + + def filter_map_datadog_locations(locations) + locations.each_with_object([]) do |location, result| + text = location.to_s + next if text.include?('lib/datadog') + + result << { + text: text, + file: location.absolute_path || location.path, + line: location.lineno, + function: location.label + } + end + end + + def convert(locations) + locations.each_with_index do |location, index| + location[:id] = index + # Strings can be frozen so we need to copy them + location[:text] = location[:text].encode('UTF-8') + location[:file] = location[:file]&.encode('UTF-8') + location[:function] = location[:function]&.encode('UTF-8') + end + end + end + end + end +end diff --git a/lib/datadog/appsec/actions_handler/stack_trace_in_metastruct.rb b/lib/datadog/appsec/actions_handler/stack_trace_in_metastruct.rb new file mode 100644 index 00000000000..cedf09a14ae --- /dev/null +++ b/lib/datadog/appsec/actions_handler/stack_trace_in_metastruct.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Datadog + module AppSec + module ActionsHandler + # Object that holds a metastruct, and modify the exploit group stack traces + class StackTraceInMetastruct + # Implementation with empty metastruct + class Noop + def count + 0 + end + + def push(_) + nil + end + end + + def self.create(metastruct) + metastruct.nil? ? Noop.new : new(metastruct) + end + + def initialize(metastruct) + @metastruct = metastruct + end + + def count + @metastruct.dig(AppSec::Ext::TAG_STACK_TRACE, AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY)&.size || 0 + end + + def push(stack_trace) + @metastruct[AppSec::Ext::TAG_STACK_TRACE] ||= {} + @metastruct[AppSec::Ext::TAG_STACK_TRACE][AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY] ||= [] + @metastruct[AppSec::Ext::TAG_STACK_TRACE][AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY] << stack_trace + end + end + end + end +end diff --git a/lib/datadog/appsec/configuration/settings.rb b/lib/datadog/appsec/configuration/settings.rb index 1413fde15bb..f81f2b45383 100644 --- a/lib/datadog/appsec/configuration/settings.rb +++ b/lib/datadog/appsec/configuration/settings.rb @@ -267,6 +267,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..0195ffaea96 100644 --- a/lib/datadog/appsec/context.rb +++ b/lib/datadog/appsec/context.rb @@ -62,8 +62,12 @@ def extract_schema def export_metrics return if @span.nil? - Metrics::Exporter.export_waf_metrics(@metrics.waf, @span) - Metrics::Exporter.export_rasp_metrics(@metrics.rasp, @span) + # This does not caused a steep error previously because + # @span was wrongly defined as a SpanOperation that cannot be nil in context.rbs. + # Even though we check that @span is not nil, steep consideres that the thread can pause after that check, + # and another thread change it to nil. This does not happen in our case, which is why steep:ignore has been added. + Metrics::Exporter.export_waf_metrics(@metrics.waf, @span) # steep:ignore ArgumentTypeMismatch + Metrics::Exporter.export_rasp_metrics(@metrics.rasp, @span) # steep:ignore ArgumentTypeMismatch 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/lib/datadog/tracing/metadata.rb b/lib/datadog/tracing/metadata.rb index d5a066b9452..9476fbe80a1 100644 --- a/lib/datadog/tracing/metadata.rb +++ b/lib/datadog/tracing/metadata.rb @@ -2,6 +2,7 @@ require_relative 'metadata/analytics' require_relative 'metadata/tagging' +require_relative 'metadata/metastruct' require_relative 'metadata/errors' module Datadog diff --git a/lib/datadog/tracing/metadata/metastruct.rb b/lib/datadog/tracing/metadata/metastruct.rb new file mode 100644 index 00000000000..27b23396487 --- /dev/null +++ b/lib/datadog/tracing/metadata/metastruct.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'forwardable' + +module Datadog + module Tracing + module Metadata + # Adds complex structures tagging behavior through metastruct + class Metastruct + extend Forwardable + + MERGER = proc do |_, v1, v2| + if v1.is_a?(Hash) && v2.is_a?(Hash) + v1.merge(v2, &MERGER) + elsif v1.is_a?(Array) && v2.is_a?(Array) + v1.concat(v2) + elsif v2.nil? + v1 + else + v2 + end + end + + def self.empty + new({}) + end + + def initialize(metastruct) + @metastruct = metastruct + end + + # Deep merge two metastructs + # If the types are not both Arrays or Hashes, the second one will overwrite the first one + # + # Example with same types: + # metastruct = { a: { b: [1, 2] } } + # second = { a: { b: [3, 4], c: 5 } } + # result = { a: { b: [1, 2, 3, 4], c: 5 } } + # + # Example with different types: + # metastruct = { a: { b: 1 } } + # second = { a: { b: [2, 3] } } + # result = { a: { b: [2, 3] } } + def deep_merge!(second) + @metastruct.merge!(second.to_h, &MERGER) + end + + def_delegators :@metastruct, :[], :[]=, :dig, :to_h + + def pretty_print(q) + q.seplist @metastruct.each do |key, value| + q.text "#{key} => #{value}\n" + end + end + + def to_msgpack(packer = nil) + packer ||= MessagePack::Packer.new + + packer.write(@metastruct.transform_values(&:to_msgpack)) + end + end + end + end +end diff --git a/lib/datadog/tracing/span.rb b/lib/datadog/tracing/span.rb index 5eaa73d7387..db8e5b39041 100644 --- a/lib/datadog/tracing/span.rb +++ b/lib/datadog/tracing/span.rb @@ -21,6 +21,7 @@ class Span :end_time, :id, :meta, + :metastruct, :metrics, :name, :parent_id, @@ -53,6 +54,7 @@ def initialize( end_time: nil, id: nil, meta: nil, + metastruct: {}, metrics: nil, parent_id: 0, resource: name, @@ -75,6 +77,7 @@ def initialize( @trace_id = trace_id || Tracing::Utils.next_id @meta = meta || {} + @metastruct = Tracing::Metadata::Metastruct.new(metastruct) @metrics = metrics || {} @status = status || 0 @@ -144,6 +147,7 @@ def to_hash error: @status, meta: @meta, metrics: @metrics, + meta_struct: @metastruct.to_h, name: @name, parent_id: @parent_id, resource: @resource, @@ -185,12 +189,16 @@ def pretty_print(q) q.text "#{key} => #{value}" end end - q.group(2, 'Metrics: [', ']') do + q.group(2, 'Metrics: [', "]\n") do q.breakable q.seplist @metrics.each do |key, value| q.text "#{key} => #{value}" end end + q.group(2, 'Metastruct: ') do + q.breakable + q.pp metastruct + end end end diff --git a/lib/datadog/tracing/span_operation.rb b/lib/datadog/tracing/span_operation.rb index 483da6ae1b1..9e9f97cb4df 100644 --- a/lib/datadog/tracing/span_operation.rb +++ b/lib/datadog/tracing/span_operation.rb @@ -36,7 +36,8 @@ class SpanOperation :service, :start_time, :trace_id, - :type + :type, + :metastruct attr_accessor :links, :status, :span_events def initialize( @@ -88,6 +89,7 @@ def initialize( # Set tags if provided. set_tags(tags) if tags + @metastruct = Tracing::Metadata::Metastruct.empty # Some other SpanOperation-specific behavior @events = events || Events.new @@ -289,6 +291,7 @@ def to_hash id: @id, meta: meta, metrics: metrics, + metastruct: @metastruct.to_h, name: @name, parent_id: @parent_id, resource: @resource, @@ -328,12 +331,16 @@ def pretty_print(q) q.text "#{key} => #{value}" end end - q.group(2, 'Metrics: [', ']') do + q.group(2, 'Metrics: [', "]\n") do q.breakable q.seplist metrics.each do |key, value| q.text "#{key} => #{value}" end end + q.group(2, 'Metastruct: ') do + q.breakable + q.pp metastruct + end end end @@ -456,6 +463,7 @@ def build_span id: @id, meta: Core::Utils::SafeDup.frozen_or_dup(meta), metrics: Core::Utils::SafeDup.frozen_or_dup(metrics), + metastruct: @metastruct.to_h.dup, parent_id: @parent_id, resource: @resource, service: @service, diff --git a/lib/datadog/tracing/trace_operation.rb b/lib/datadog/tracing/trace_operation.rb index eb85182af75..702989c63d8 100644 --- a/lib/datadog/tracing/trace_operation.rb +++ b/lib/datadog/tracing/trace_operation.rb @@ -4,7 +4,7 @@ require_relative '../core/utils' require_relative 'tracer' require_relative 'event' -require_relative 'metadata/tagging' +require_relative 'metadata' require_relative 'sampling/ext' require_relative 'span_operation' require_relative 'trace_digest' @@ -25,7 +25,7 @@ module Tracing # # @public_api class TraceOperation - include Metadata::Tagging + include Metadata DEFAULT_MAX_LENGTH = 100_000 @@ -46,7 +46,8 @@ class TraceOperation :max_length, :parent_span_id, :trace_state, - :trace_state_unknown_fields + :trace_state_unknown_fields, + :metastruct attr_writer \ :name, @@ -73,6 +74,7 @@ def initialize( profiling_enabled: nil, tags: nil, metrics: nil, + metastruct: {}, trace_state: nil, trace_state_unknown_fields: nil, remote_parent: false, @@ -105,6 +107,7 @@ def initialize( # Generic tags set_tags(tags) if tags set_tags(metrics) if metrics + @metastruct = Tracing::Metadata::Metastruct.new(metastruct) # State @root_span = nil @@ -369,6 +372,7 @@ def fork_clone trace_state_unknown_fields: (@trace_state_unknown_fields && @trace_state_unknown_fields.dup), tags: meta.dup, metrics: metrics.dup, + metastruct: @metastruct.to_h.dup, remote_parent: @remote_parent ) end @@ -508,6 +512,7 @@ def build_trace(spans, partial = false) service: service, tags: meta, metrics: metrics, + metastruct: @metastruct.to_h.dup, root_span_id: !partial ? root_span && root_span.id : nil, profiling_enabled: @profiling_enabled, ) diff --git a/lib/datadog/tracing/trace_segment.rb b/lib/datadog/tracing/trace_segment.rb index 6d0903d9a9d..e8f660a120e 100644 --- a/lib/datadog/tracing/trace_segment.rb +++ b/lib/datadog/tracing/trace_segment.rb @@ -58,6 +58,7 @@ def initialize( service: nil, tags: nil, metrics: nil, + metastruct: {}, profiling_enabled: nil ) @id = id @@ -68,6 +69,7 @@ def initialize( # The caller is expected to have done that @meta = (tags && tags.dup) || {} @metrics = (metrics && metrics.dup) || {} + @metastruct = Tracing::Metadata::Metastruct.new(metastruct) # Set well-known tags, defaulting to getting the values from tags @agent_sample_rate = agent_sample_rate || agent_sample_rate_tag @@ -146,7 +148,8 @@ def high_order_tid attr_reader \ :root_span_id, :meta, - :metrics + :metrics, + :metastruct private diff --git a/lib/datadog/tracing/transport/serializable_trace.rb b/lib/datadog/tracing/transport/serializable_trace.rb index b3786903312..6ce63820978 100644 --- a/lib/datadog/tracing/transport/serializable_trace.rb +++ b/lib/datadog/tracing/transport/serializable_trace.rb @@ -69,7 +69,7 @@ def initialize(span, native_events_supported:) def to_msgpack(packer = nil) packer ||= MessagePack::Packer.new - number_of_elements_to_write = 11 + number_of_elements_to_write = 12 number_of_elements_to_write += 1 if span.events.any? && @native_events_supported @@ -117,6 +117,9 @@ def to_msgpack(packer = nil) packer.write(span.meta) packer.write('metrics') packer.write(span.metrics) + packer.write('meta_struct') + # We encapsulate the resulting msgpack in a binary msgpack + packer.write(span.metastruct) packer.write('span_links') packer.write(span.links.map(&:to_hash)) packer.write('error') diff --git a/lib/datadog/tracing/transport/trace_formatter.rb b/lib/datadog/tracing/transport/trace_formatter.rb index 2140ccebdd0..05a7eebd7a3 100644 --- a/lib/datadog/tracing/transport/trace_formatter.rb +++ b/lib/datadog/tracing/transport/trace_formatter.rb @@ -43,6 +43,7 @@ def format! # Apply generic trace tags. Any more specific value will be overridden # by the subsequent calls below. set_trace_tags! + set_metastruct! set_resource! @@ -89,6 +90,12 @@ def set_trace_tags! root_span.set_tags(trace.send(:metrics)) end + def set_metastruct! + return if partial? + + root_span.metastruct.deep_merge!(trace.send(:metastruct)) + end + def tag_agent_sample_rate! return unless trace.agent_sample_rate diff --git a/lib/datadog/tracing/transport/traces.rb b/lib/datadog/tracing/transport/traces.rb index 441477ac7ca..ff0d42d2f3f 100644 --- a/lib/datadog/tracing/transport/traces.rb +++ b/lib/datadog/tracing/transport/traces.rb @@ -127,6 +127,7 @@ def initialize(apis, default_api) end def send_traces(traces) + # object that extends Datadog::Core::Encoding::Encoder (MsgpackEncoder or JSONEncoder) encoder = current_api.encoder chunker = Datadog::Tracing::Transport::Traces::Chunker.new( encoder, diff --git a/sig/datadog/appsec/actions_handler.rbs b/sig/datadog/appsec/actions_handler.rbs index fd9fd4ecaa1..de6a1e9124b 100644 --- a/sig/datadog/appsec/actions_handler.rbs +++ b/sig/datadog/appsec/actions_handler.rbs @@ -1,6 +1,8 @@ module Datadog module AppSec module ActionsHandler + type stack_value = String | Integer | nil + def handle: (Datadog::AppSec::SecurityEngine::Result::actions actions_hash) -> void def interrupt_execution: (Datadog::AppSec::SecurityEngine::Result::action action_params) -> void @@ -8,6 +10,10 @@ module Datadog def generate_stack: (Datadog::AppSec::SecurityEngine::Result::action action_params) -> void def generate_schema: (Datadog::AppSec::SecurityEngine::Result::action action_params) -> void + + private + + def generate_stack_trace: (Integer max_depth, Float top_percent) -> (Hash[Symbol, stack_value | Array[Hash[Symbol, stack_value]]]) end end end diff --git a/sig/datadog/appsec/actions_handler/rasp_stack_trace.rbs b/sig/datadog/appsec/actions_handler/rasp_stack_trace.rbs new file mode 100644 index 00000000000..bc2387605a7 --- /dev/null +++ b/sig/datadog/appsec/actions_handler/rasp_stack_trace.rbs @@ -0,0 +1,25 @@ +module Datadog + module AppSec + module ActionsHandler + class StackTraceInMetastruct + type stack_trace = Hash[Symbol, String | nil | Array[Datadog::AppSec::ActionsHandler::StackTraceCollection::stack_frame]] + + class Noop + def count: () -> Integer + + def push: (stack_trace stack_trace) -> void + end + + @metastruct: Datadog::Tracing::Metadata::Metastruct + + def self.create: (Datadog::Tracing::Metadata::Metastruct? metastruct) -> (StackTraceInMetastruct::Noop | StackTraceInMetastruct) + + def initialize: (Datadog::Tracing::Metadata::Metastruct metastruct) -> void + + def count: () -> Integer + + def push: (stack_trace stack_trace) -> void + end + end + end +end diff --git a/sig/datadog/appsec/actions_handler/stack_trace_collection.rbs b/sig/datadog/appsec/actions_handler/stack_trace_collection.rbs new file mode 100644 index 00000000000..b5c66e059a6 --- /dev/null +++ b/sig/datadog/appsec/actions_handler/stack_trace_collection.rbs @@ -0,0 +1,15 @@ +module Datadog + module AppSec + module ActionsHandler + module StackTraceCollection + type stack_frame = Hash[Symbol, String | Integer | nil] + + def self.convert: (Array[stack_frame] locations) -> Array[stack_frame] + + def self.filter_map_datadog_locations: (Array[Thread::Backtrace::Location] locations) -> Array[stack_frame] + + def self.collect: (max_depth: Integer, top_percent: Float) -> Array[stack_frame] + 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/sig/datadog/tracing/metadata/metastruct.rbs b/sig/datadog/tracing/metadata/metastruct.rbs new file mode 100644 index 00000000000..a4c3c58cddd --- /dev/null +++ b/sig/datadog/tracing/metadata/metastruct.rbs @@ -0,0 +1,31 @@ +module Datadog + module Tracing + module Metadata + class Metastruct + extend Forwardable + + type metastruct = Hash[String, untyped] + + type merger_value = Hash[untyped, untyped] | Array[untyped] | nil + + MERGER: ^(untyped, merger_value, merger_value) -> merger_value + + @metastruct: metastruct + + def self.empty: () -> Metastruct + + def initialize: (metastruct metastruct) -> void + + def deep_merge!: (Metastruct second) -> void + + def dig: (*String keys) -> untyped + + def []: (String key) -> untyped + + def []=: (String key, untyped value) -> void + + def to_h: () -> metastruct + end + end + end +end diff --git a/sig/datadog/tracing/span.rbs b/sig/datadog/tracing/span.rbs index b610dcaa7eb..4bb184005a9 100644 --- a/sig/datadog/tracing/span.rbs +++ b/sig/datadog/tracing/span.rbs @@ -6,13 +6,14 @@ module Datadog attr_accessor end_time: (Time | nil) attr_accessor id: Integer attr_accessor meta: Hash[String, String] + attr_accessor metastruct: Metadata::Metastruct attr_accessor metrics: Hash[String, Float] attr_accessor name: String attr_accessor parent_id: Integer attr_accessor resource: String attr_accessor service: (String | nil) - attr_accessor links: Array[untyped] - attr_accessor events: Array[untyped] + attr_accessor links: Array[Datadog::Tracing::SpanLink] + attr_accessor events: Array[Datadog::Tracing::SpanEvent] attr_accessor type: (String | nil) attr_accessor start_time: (Time | nil) attr_accessor status: Integer @@ -25,6 +26,7 @@ module Datadog ?end_time: (Time | nil), ?id: (Integer | nil), ?meta: (Hash[String, String] | nil), + ?metastruct: (Metadata::Metastruct::metastruct), ?metrics: (Hash[String, Float] | nil), ?parent_id: Integer, ?resource: String, @@ -34,12 +36,15 @@ module Datadog ?type: (String | nil), ?trace_id: (Integer | nil), ?service_entry: (bool | nil), - ?links: (Array[untyped] | nil), - ?events: (Array[untyped] | nil) + ?links: (Array[Datadog::Tracing::SpanLink] | nil), + ?events: (Array[Datadog::Tracing::SpanEvent] | nil) ) -> void def started?: -> bool def stopped?: -> bool + + alias finished? stopped? + def duration: -> (Float | nil) def set_error: (Exception e) -> void def ==: (Span other) -> bool diff --git a/sig/datadog/tracing/span_operation.rbs b/sig/datadog/tracing/span_operation.rbs index a50641c0aa8..8a74f445071 100644 --- a/sig/datadog/tracing/span_operation.rbs +++ b/sig/datadog/tracing/span_operation.rbs @@ -28,6 +28,8 @@ module Datadog attr_reader type: untyped + attr_reader metastruct: Metadata::Metastruct + attr_accessor status: untyped def initialize: ( diff --git a/sig/datadog/tracing/trace_operation.rbs b/sig/datadog/tracing/trace_operation.rbs index 551ce4ccf48..899b623c3b8 100644 --- a/sig/datadog/tracing/trace_operation.rbs +++ b/sig/datadog/tracing/trace_operation.rbs @@ -1,7 +1,7 @@ module Datadog module Tracing class TraceOperation - include Metadata::Tagging + include Metadata DEFAULT_MAX_LENGTH: ::Integer @@ -18,12 +18,13 @@ module Datadog attr_reader id: untyped attr_reader max_length: untyped attr_reader parent_span_id: untyped + attr_reader metastruct: Metadata::Metastruct attr_writer name: untyped attr_writer resource: untyped attr_writer sampled: untyped attr_writer service: untyped - def initialize: (?agent_sample_rate: untyped?, ?events: untyped?, ?hostname: untyped?, ?id: untyped?, ?max_length: untyped, ?name: untyped?, ?origin: untyped?, ?parent_span_id: untyped?, ?rate_limiter_rate: untyped?, ?resource: untyped?, ?rule_sample_rate: untyped?, ?sample_rate: untyped?, ?sampled: untyped?, ?sampling_priority: untyped?, ?service: untyped?, ?tags: untyped?, ?metrics: untyped?, ?remote_parent: untyped?) -> void + def initialize: (?agent_sample_rate: untyped?, ?events: untyped?, ?hostname: untyped?, ?id: untyped?, ?max_length: untyped, ?name: untyped?, ?origin: untyped?, ?parent_span_id: untyped?, ?rate_limiter_rate: untyped?, ?resource: untyped?, ?rule_sample_rate: untyped?, ?sample_rate: untyped?, ?sampled: untyped?, ?sampling_priority: untyped?, ?service: untyped?, ?tags: untyped?, ?metastruct: Metadata::Metastruct::metastruct, ?metrics: untyped?, ?remote_parent: untyped?) -> void def full?: () -> untyped def finished_span_count: () -> untyped def finished?: () -> untyped diff --git a/sig/datadog/tracing/trace_segment.rbs b/sig/datadog/tracing/trace_segment.rbs index 41f81d3114f..a5bfbf2a416 100644 --- a/sig/datadog/tracing/trace_segment.rbs +++ b/sig/datadog/tracing/trace_segment.rbs @@ -22,7 +22,7 @@ module Datadog attr_reader sampling_priority: untyped attr_reader service: untyped - def initialize: (untyped spans, ?agent_sample_rate: untyped?, ?hostname: untyped?, ?id: untyped?, ?lang: untyped?, ?name: untyped?, ?origin: untyped?, ?process_id: untyped?, ?rate_limiter_rate: untyped?, ?resource: untyped?, ?root_span_id: untyped?, ?rule_sample_rate: untyped?, ?runtime_id: untyped?, ?sample_rate: untyped?, ?sampling_priority: untyped?, ?service: untyped?, ?tags: untyped?, ?metrics: untyped?) -> void + def initialize: (untyped spans, ?agent_sample_rate: untyped?, ?hostname: untyped?, ?id: untyped?, ?lang: untyped?, ?name: untyped?, ?origin: untyped?, ?process_id: untyped?, ?rate_limiter_rate: untyped?, ?resource: untyped?, ?root_span_id: untyped?, ?rule_sample_rate: untyped?, ?runtime_id: untyped?, ?sample_rate: untyped?, ?sampling_priority: untyped?, ?service: untyped?, ?tags: untyped?, ?metrics: untyped?, ?metastruct: Metadata::Metastruct::metastruct) -> void def any?: () -> untyped def count: () -> untyped def empty?: () -> untyped @@ -36,6 +36,7 @@ module Datadog attr_reader root_span_id: untyped attr_reader meta: untyped attr_reader metrics: untyped + attr_reader metastruct: Metadata::Metastruct private diff --git a/spec/datadog/appsec/actions_handler/stack_trace_collection_spec.rb b/spec/datadog/appsec/actions_handler/stack_trace_collection_spec.rb new file mode 100644 index 00000000000..9b67ae49b72 --- /dev/null +++ b/spec/datadog/appsec/actions_handler/stack_trace_collection_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'datadog/appsec/actions_handler/stack_trace_collection' +require 'support/thread_backtrace_helpers' + +RSpec.describe Datadog::AppSec::ActionsHandler::StackTraceCollection do + describe '.collect' do + # "/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'", + # "/app/lib/datadog/appsec/actions_handler/stack_trace_collection.rb:12:in `block in locations_inside_nested_blocks" + let(:frames) { ThreadBacktraceHelper.locations_inside_nested_blocks_with_datadog_frame } + + before do + allow(described_class).to receive(:filter_map_datadog_locations).and_return( + described_class.filter_map_datadog_locations(frames) + ) + end + + context 'with values larger than stack trace' do + subject(:collection) { described_class.collect(max_depth: 10, top_percent: 75) } + + it 'returns stack frames excluding those from datadog' do + expect(collection.any? { |loc| loc[:text].include?('lib/datadog') }).to be false + end + + it 'returns the correct number of stack frames' do + expect(collection.size).to eq(5) + end + end + + context 'with max_depth set to 4' do + subject(:collection) { described_class.collect(max_depth: 4, top_percent: 75) } + + 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 + subject(:collection) { described_class.collect(max_depth: 4, 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 + subject(:collection) { described_class.collect(max_depth: 4, 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 + subject(:collection) { described_class.collect(max_depth: 4, 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 and max_depth_top_percent set to 66.67' do + subject(:collection) { described_class.collect(max_depth: 3, 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 +end diff --git a/spec/datadog/appsec/actions_handler/stack_trace_in_metastruct_spec.rb b/spec/datadog/appsec/actions_handler/stack_trace_in_metastruct_spec.rb new file mode 100644 index 00000000000..63fe2d5d84c --- /dev/null +++ b/spec/datadog/appsec/actions_handler/stack_trace_in_metastruct_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'datadog/appsec/actions_handler/stack_trace_in_metastruct' +require 'datadog/tracing/metadata/metastruct' + +RSpec.describe Datadog::AppSec::ActionsHandler::StackTraceInMetastruct do + subject(:stack_trace_in_metastruct) { described_class.create(metastruct) } + let(:metastruct) { Datadog::Tracing::Metadata::Metastruct.new(metastruct_hash) } + let(:metastruct_hash) { {} } + + describe '.count' do + subject(:count) { stack_trace_in_metastruct.count } + + context 'with nil as metastruct' do + let(:metastruct) { nil } + + it { is_expected.to eq 0 } + end + + context 'with empty metastruct' do + it { is_expected.to eq 0 } + end + + context 'with metastruct containing non-exploit stack traces' do + let(:metastruct_hash) do + { + '_dd.stack' => { + 'vulnerabilities' => [1, 2] + } + } + end + + it { is_expected.to eq 0 } + end + + context 'with metastruct containing exploit stack traces' do + let(:metastruct_hash) do + { + '_dd.stack' => { + 'exploit' => [1, 2] + } + } + end + + it { is_expected.to eq 2 } + end + end + + describe '.push' do + before do + stack_trace_in_metastruct.push({ language: 'ruby', stack_id: 'foo', frames: [] }) + end + + context 'with empty metastruct' do + it 'adds a new stack trace to the metastruct' do + expect(metastruct.to_h).to eq( + '_dd.stack' => { + 'exploit' => [ + { + language: 'ruby', + stack_id: 'foo', + frames: [] + } + ] + } + ) + end + end + + context 'with existing exploit stack traces in different group' do + let(:metastruct_hash) do + { + '_dd.stack' => { + 'vulnerabilities' => [1, 2] + } + } + end + + it 'adds a new stack trace to the metastruct' do + expect(metastruct.to_h).to eq( + '_dd.stack' => { + 'vulnerabilities' => [1, 2], + 'exploit' => [ + { + language: 'ruby', + stack_id: 'foo', + frames: [] + } + ] + } + ) + end + end + + context 'with existing exploit stack traces in the same group' do + let(:metastruct_hash) do + { + '_dd.stack' => { + 'exploit' => [1, 2] + } + } + end + + it 'adds a new stack trace to the metastruct' do + expect(metastruct.to_h).to eq( + '_dd.stack' => { + 'exploit' => [ + 1, + 2, + { + language: 'ruby', + stack_id: 'foo', + frames: [] + } + ] + } + ) + end + end + end +end diff --git a/spec/datadog/appsec/actions_handler_spec.rb b/spec/datadog/appsec/actions_handler_spec.rb index 4d6cd44cd56..f096a6619c1 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,220 @@ end end end + + describe '.generate_stack' do + before do + allow(Datadog::AppSec::ActionsHandler::StackTraceCollection).to receive(:collect).and_return( + [ + { + id: 0, + text: "/app/spec/support/thread_backtrace_helpers.rb:12:in `block in locations_inside_nested_blocks'", + file: '/app/spec/support/thread_backtrace_helpers.rb', + line: 12, + function: 'block in locations_inside_nested_blocks' + } + ] + ) + end + + context 'when stack trace is enabled and context contains trace and span' do + let(:span_metastruct) { {} } + + let(:span_op) do + instance_double( + Datadog::Tracing::SpanOperation, + metastruct: Datadog::Tracing::Metadata::Metastruct.new(span_metastruct) + ) + end + + let(:trace_metastruct) { {} } + + let(:trace_op) do + instance_double( + Datadog::Tracing::TraceOperation, + metastruct: Datadog::Tracing::Metadata::Metastruct.new(trace_metastruct) + ) + end + + let(:context) { instance_double(Datadog::AppSec::Context, trace: trace_op, span: span_op) } + + before { allow(Datadog::AppSec).to receive(:active_context).and_return(context) } + + it 'adds stack trace to the trace' do + described_class.generate_stack({ 'stack_id' => 'foo' }) + + trace_op_result = trace_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(trace_op_result.size).to eq(1) + expect(trace_op_result.first[:id]).to eq('foo') + expect(trace_op_result.first[:frames].size).to eq(1) + end + + context 'when max_collect is 2' do + context 'when max_collect is 2 with two elements contained in same group in trace' do + before { allow(Datadog.configuration.appsec.stack_trace).to receive(:max_collect).and_return(2) } + + let(:trace_metastruct) do + { + Datadog::AppSec::Ext::TAG_STACK_TRACE => { + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY => [1, 2] + } + } + end + + it 'does not add stack trace to the trace nor the span' do + described_class.generate_stack({ 'stack_id' => 'foo' }) + + trace_op_result = trace_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(trace_op_result.size).to eq(2) + expect(trace_op_result[0]).to eq(1) + expect(trace_op_result[1]).to eq(2) + span_op_result = span_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(span_op_result).to be_nil + end + end + + context 'when max_collect is 2 with two elements contained in same group in span' do + before { allow(Datadog.configuration.appsec.stack_trace).to receive(:max_collect).and_return(2) } + + let(:span_metastruct) do + { + Datadog::AppSec::Ext::TAG_STACK_TRACE => { + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY => [1, 2] + } + } + end + + it 'does not add stack trace to the trace nor the span' do + described_class.generate_stack({ 'stack_id' => 'foo' }) + + span_op_result = span_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(span_op_result.size).to eq(2) + expect(span_op_result[0]).to eq(1) + expect(span_op_result[1]).to eq(2) + trace_op_result = trace_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(trace_op_result).to be_nil + end + end + + context 'when max_collect is 2 with one element contained in same group in span and trace' do + before { allow(Datadog.configuration.appsec.stack_trace).to receive(:max_collect).and_return(2) } + + let(:trace_metastruct) do + { + Datadog::AppSec::Ext::TAG_STACK_TRACE => { + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY => [1] + } + } + end + + let(:span_metastruct) do + { + Datadog::AppSec::Ext::TAG_STACK_TRACE => { + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY => [2] + } + } + end + + it 'does not add stack trace to the trace nor the span' do + described_class.generate_stack({ 'stack_id' => 'foo' }) + + trace_op_result = trace_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(trace_op_result.size).to eq(1) + expect(trace_op_result.first).to eq(1) + span_op_result = span_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(span_op_result.size).to eq(1) + expect(span_op_result.first).to eq(2) + end + end + + context 'when max_collect is 2 with two elements contained in different group in trace' do + before { allow(Datadog.configuration.appsec.stack_trace).to receive(:max_collect).and_return(2) } + + let(:trace_metastruct) do + { + Datadog::AppSec::Ext::TAG_STACK_TRACE => { + 'other_group' => [1, 2] + } + } + end + + it 'does add stack trace to the trace' do + described_class.generate_stack({ 'stack_id' => 'foo' }) + + trace_op_result = trace_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(trace_op_result.size).to eq(1) + expect(trace_op_result.first[:id]).to eq('foo') + end + end + end + end + + context 'when stack trace is enabled and context contains only span' do + before { allow(Datadog::AppSec).to receive(:active_context).and_return(context) } + + let(:span_op) do + instance_double( + Datadog::Tracing::SpanOperation, + metastruct: Datadog::Tracing::Metadata::Metastruct.new({}) + ) + end + + let(:context) { instance_double(Datadog::AppSec::Context, trace: nil, span: span_op) } + + it 'adds stack trace to the span' do + described_class.generate_stack({ 'stack_id' => 'foo' }) + + test_result = span_op.metastruct.dig( + Datadog::AppSec::Ext::TAG_STACK_TRACE, + Datadog::AppSec::Ext::EXPLOIT_PREVENTION_EVENT_CATEGORY + ) + expect(test_result.size).to eq(1) + expect(test_result.first[:id]).to eq('foo') + expect(test_result.first[:frames].size).to eq(1) + end + end + + context 'when stack trace is disabled' do + before { allow(Datadog.configuration.appsec.stack_trace).to receive(:enabled).and_return(false) } + + let(:trace_op) do + instance_double( + Datadog::Tracing::TraceOperation, + metastruct: Datadog::Tracing::Metadata::Metastruct.new({}) + ) + end + + let(:context) { instance_double(Datadog::AppSec::Context, trace: trace_op, span: nil) } + + it 'does not add stack trace to the trace' do + described_class.generate_stack({ 'stack_id' => 'foo' }) + + expect(trace_op.metastruct[Datadog::AppSec::Ext::TAG_STACK_TRACE]).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 63445f9e7b4..99570d411d6 100644 --- a/spec/datadog/appsec/configuration/settings_spec.rb +++ b/spec/datadog/appsec/configuration/settings_spec.rb @@ -930,5 +930,155 @@ def patcher end end end + + describe 'stack_trace' do + describe '#enabled' do + context 'when DD_APPSEC_STACK_TRACE_ENABLED' do + around do |example| + ClimateControl.modify('DD_APPSEC_STACK_TRACE_ENABLED' => enabled_env_var) do + example.run + end + end + + context 'is not defined' do + let(:enabled_env_var) { nil } + it { expect(settings.appsec.stack_trace.enabled).to eq true } + end + + [true, false].each do |value| + context "is defined as #{value}" do + let(:enabled_env_var) { value.to_s } + it { expect(settings.appsec.stack_trace.enabled).to eq(value) } + end + end + end + end + + describe '#enabled=' do + [true, false].each do |value| + context "when given #{value}" do + it "sets enabled to #{value}" do + settings.appsec.stack_trace.enabled = value + expect(settings.appsec.stack_trace.enabled).to eq(value) + end + end + end + end + + describe '#max_depth' do + context 'when DD_APPSEC_MAX_STACK_TRACE_DEPTH' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACE_DEPTH' => max_depth_env_var) do + example.run + end + end + + context 'is not defined' do + let(:max_depth_env_var) { nil } + it { expect(settings.appsec.stack_trace.max_depth).to eq(32) } + end + + context 'is defined' do + let(:max_depth_env_var) { '64' } + it { expect(settings.appsec.stack_trace.max_depth).to eq(64) } + end + end + end + + describe '#max_depth=' do + context 'when given a value' do + it 'sets max_depth to given value' do + settings.appsec.stack_trace.max_depth = 64 + expect(settings.appsec.stack_trace.max_depth).to eq(64) + end + end + + context 'when given a negative value' do + it 'sets max_depth to 0' do + settings.appsec.stack_trace.max_depth = -1 + expect(settings.appsec.stack_trace.max_depth).to eq(0) + end + end + end + + describe '#max_depth_top_percent' do + context 'when DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACE_DEPTH_TOP_PERCENT' => max_depth_top_percent_env_var) do + example.run + end + end + + context 'is not defined' do + let(:max_depth_top_percent_env_var) { nil } + it { expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(75) } + end + + context 'is defined' do + let(:max_depth_top_percent_env_var) { '50' } + it { expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(50) } + end + end + end + + describe '#max_depth_top_percent=' do + context 'when given a value' do + it 'sets max_depth_top_percent to given value' do + settings.appsec.stack_trace.max_depth_top_percent = 50 + expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(50) + end + end + + context 'when given a negative value' do + it 'sets max_depth_top_percent to 0' do + settings.appsec.stack_trace.max_depth_top_percent = -1 + expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(0) + end + end + + context 'when given a value higher than 100' do + it 'sets max_depth_top_percent to 100' do + settings.appsec.stack_trace.max_depth_top_percent = 101 + expect(settings.appsec.stack_trace.max_depth_top_percent).to eq(100) + end + end + end + + describe '#max_collect' do + context 'when DD_APPSEC_MAX_STACK_TRACES' do + around do |example| + ClimateControl.modify('DD_APPSEC_MAX_STACK_TRACES' => max_collect_env_var) do + example.run + end + end + + context 'is not defined' do + let(:max_collect_env_var) { nil } + it { expect(settings.appsec.stack_trace.max_collect).to eq(2) } + end + + context 'is defined' do + let(:max_collect_env_var) { '4' } + it { expect(settings.appsec.stack_trace.max_collect).to eq(4) } + end + end + end + + describe '#max_collect=' do + context 'when given a value' do + it 'sets max_collect to given value' do + settings.appsec.stack_trace.max_collect = 4 + expect(settings.appsec.stack_trace.max_collect).to eq(4) + end + end + + context 'when given a negative value' do + it 'sets max_collect to 0' do + settings.appsec.stack_trace.max_collect = -1 + expect(settings.appsec.stack_trace.max_collect).to eq(0) + end + end + end + end end end diff --git a/spec/datadog/tracing/metadata/metastruct_spec.rb b/spec/datadog/tracing/metadata/metastruct_spec.rb new file mode 100644 index 00000000000..631a64913d9 --- /dev/null +++ b/spec/datadog/tracing/metadata/metastruct_spec.rb @@ -0,0 +1,64 @@ +require 'datadog/tracing/metadata/metastruct' + +RSpec.describe Datadog::Tracing::Metadata::Metastruct do + subject(:metastruct) { test_object.instance_variable_get(:@metastruct) } + let(:test_object) { described_class.new(preexisting_metastruct) } + let(:preexisting_metastruct) { {} } + + describe '.empty' do + let(:test_object) { described_class.empty } + + it { is_expected.to eq({}) } + end + + describe '#initialize' do + context 'when setting meta struct' do + context 'with empty metastruct' do + it { is_expected.to eq({}) } + end + + context 'with not empty metastruct' do + let(:preexisting_metastruct) { { 'key' => 'value' } } + + it { is_expected.to eq({ 'key' => 'value' }) } + end + end + end + + describe '#deep_merge!' do + context 'when merging meta struct' do + before do + test_object.deep_merge!(new_metastruct) + end + + context 'with empty preexisting metastruct' do + let(:new_metastruct) { { 'key' => 'value' } } + + it { is_expected.to eq({ 'key' => 'value' }) } + end + + context 'with simple preexisting metastruct' do + let(:preexisting_metastruct) { { 'old_key' => 'old_value' } } + + context 'with new key' do + let(:new_metastruct) { { 'new_key' => 'new_value' } } + + it { is_expected.to eq({ 'old_key' => 'old_value', 'new_key' => 'new_value' }) } + end + + context 'with existing key' do + let(:new_metastruct) { { 'old_key' => 'new_value' } } + + it { is_expected.to eq({ 'old_key' => 'new_value' }) } + end + end + + context 'with nested preexisting metastruct, containing arrays' do + let(:preexisting_metastruct) { { 'key' => { 'nested_key' => ['value1'] } } } + let(:new_metastruct) { { 'key' => { 'nested_key' => ['value2'], 'second_nested_key' => ['value3'] } } } + + it { is_expected.to eq({ 'key' => { 'nested_key' => ['value1', 'value2'], 'second_nested_key' => ['value3'] } }) } + end + end + end +end diff --git a/spec/datadog/tracing/metadata_spec.rb b/spec/datadog/tracing/metadata_spec.rb index fad196bac57..4502020f6bd 100644 --- a/spec/datadog/tracing/metadata_spec.rb +++ b/spec/datadog/tracing/metadata_spec.rb @@ -13,7 +13,7 @@ expect(ancestors.first(5)).to include( described_class::Analytics, described_class::Tagging, - described_class::Errors + described_class::Errors, ) end end diff --git a/spec/datadog/tracing/span_spec.rb b/spec/datadog/tracing/span_spec.rb index 0c6bca7f3f0..80eb3d7ab44 100644 --- a/spec/datadog/tracing/span_spec.rb +++ b/spec/datadog/tracing/span_spec.rb @@ -243,6 +243,7 @@ resource: 'my.span', type: nil, meta: {}, + meta_struct: {}, metrics: {}, span_links: [], error: 0 diff --git a/spec/datadog/tracing/trace_operation_spec.rb b/spec/datadog/tracing/trace_operation_spec.rb index d168a99f73c..83cbabcb3fe 100644 --- a/spec/datadog/tracing/trace_operation_spec.rb +++ b/spec/datadog/tracing/trace_operation_spec.rb @@ -12,6 +12,7 @@ require 'datadog/tracing/trace_operation' require 'datadog/tracing/trace_segment' require 'datadog/tracing/utils' +require 'datadog/tracing/metadata/metastruct' RSpec.describe Datadog::Tracing::TraceOperation do subject(:trace_op) { described_class.new(**options) } @@ -35,6 +36,7 @@ profiling_enabled: profiling_enabled, tags: tags, metrics: metrics, + metastruct: metastruct, trace_state: trace_state, trace_state_unknown_fields: trace_state_unknown_fields, remote_parent: remote_parent, @@ -56,6 +58,7 @@ let(:profiling_enabled) { 'profiling_enabled' } let(:tags) { { 'foo' => 'bar' }.merge(distributed_tags) } let(:metrics) { { 'baz' => 42.0 } } + let(:metastruct) { { 'foo' => 'bar' } } let(:trace_state) { 'my-trace-state' } let(:trace_state_unknown_fields) { 'any;field;really' } @@ -100,6 +103,8 @@ expect(trace_op.send(:metrics)).to eq({}) end + it { expect(trace_op.send(:metastruct).to_h).to eq({}) } + context 'when 128 bit trace id generation enabled' do before do allow(Datadog.configuration.tracing).to receive(:trace_id_128_bit_generation_enabled).and_return(true) @@ -270,6 +275,13 @@ it { expect(trace_op.send(:metrics)).to eq({ 'baz' => 42.0 }) } end + + context ':metastruct' do + subject(:options) { { metastruct: metastruct } } + let(:metastruct) { { 'foo' => 'bar' } } + + it { expect(trace_op.send(:metastruct).to_h).to eq({ 'foo' => 'bar' }) } + end end end diff --git a/spec/datadog/tracing/trace_segment_spec.rb b/spec/datadog/tracing/trace_segment_spec.rb index 6e36a7a84bb..23b3aa97f28 100644 --- a/spec/datadog/tracing/trace_segment_spec.rb +++ b/spec/datadog/tracing/trace_segment_spec.rb @@ -164,6 +164,13 @@ it { expect(trace_segment.send(:metrics)).to eq({ 'foo' => 42.0 }) } end + context ':metastruct' do + let(:options) { { metastruct: metastruct } } + let(:metastruct) { { 'foo' => 'bar' } } + + it { expect(trace_segment.send(:metastruct).to_h).to eq({ 'foo' => 'bar' }) } + end + context ':profiling_enabled' do let(:options) { { profiling_enabled: true } } diff --git a/spec/datadog/tracing/transport/serializable_trace_spec.rb b/spec/datadog/tracing/transport/serializable_trace_spec.rb index afef82a68ef..78eefae03bc 100644 --- a/spec/datadog/tracing/transport/serializable_trace_spec.rb +++ b/spec/datadog/tracing/transport/serializable_trace_spec.rb @@ -39,6 +39,7 @@ 'resource', 'type', 'meta', + 'meta_struct', 'metrics', 'span_links', 'error', diff --git a/spec/datadog/tracing/transport/trace_formatter_spec.rb b/spec/datadog/tracing/transport/trace_formatter_spec.rb index 7b126e35f1d..f1323a02693 100644 --- a/spec/datadog/tracing/transport/trace_formatter_spec.rb +++ b/spec/datadog/tracing/transport/trace_formatter_spec.rb @@ -35,6 +35,7 @@ sample_rate: sample_rate, sampling_priority: sampling_priority, tags: trace_tags, + metastruct: metastruct, profiling_enabled: profiling_enabled, } end @@ -50,6 +51,7 @@ let(:runtime_id) { 'trace.runtime_id' } let(:sample_rate) { rand } let(:sampling_priority) { Datadog::Tracing::Sampling::Ext::Priority::USER_KEEP } + let(:metastruct) { nil } let(:profiling_enabled) { true } end @@ -64,6 +66,13 @@ '_dd.p.tid' => 'aaaaaaaaaaaaaaaa' } end + + let(:metastruct) do + { + 'foo' => 'bar', + 'baz' => { 'value' => 42 } + } + end end shared_context 'git environment stub' do @@ -204,6 +213,18 @@ ) end end + + context 'metastruct' do + it 'sets root span metastruct from trace metastruct' do + format! + expect(root_span.metastruct.to_h).to include( + { + 'foo' => 'bar', + 'baz' => { 'value' => 42 } + } + ) + end + end end shared_examples 'root span without generic tags' do @@ -216,6 +237,11 @@ it { expect(root_span.meta).to_not include('_dd.p.dm') } it { expect(root_span.meta).to_not include('_dd.p.tid') } end + + context 'metastruct' do + it { expect(root_span.metastruct.to_h).to_not include('foo') } + it { expect(root_span.metastruct.to_h).to_not include('baz') } + end end shared_examples 'first span with no git metadata' do diff --git a/spec/support/thread_backtrace_helpers.rb b/spec/support/thread_backtrace_helpers.rb new file mode 100644 index 00000000000..161c8a0d248 --- /dev/null +++ b/spec/support/thread_backtrace_helpers.rb @@ -0,0 +1,65 @@ +# 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 + + CustomLocation = Struct.new(:text, :path, :lineno, :label, keyword_init: true) do + def to_s + text + end + end + + def self.locations_inside_nested_blocks_with_datadog_frame + locations_inside_nested_blocks << CustomLocation.new( + text: '/app/lib/datadog/appsec/actions_handler/stack_trace_collection.rb:12:in `block in locations_inside_nested_blocks', # rubocop:disable Layout/LineLength + path: '/app/lib/datadog/appsec/actions_handler/stack_trace_collection', + lineno: 12, + label: 'block in locations_inside_nested_blocks' + ) + end + + def self.location_ascii_8bit + location = locations.first + CustomLocation.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