diff --git a/sentry-ruby/lib/sentry/data_collection.rb b/sentry-ruby/lib/sentry/data_collection.rb index 60ca15920..601eda599 100644 --- a/sentry-ruby/lib/sentry/data_collection.rb +++ b/sentry-ruby/lib/sentry/data_collection.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "sentry/data_collection/key_value_collection" + module Sentry class DataCollection # Configuration for the categories of data collected by the SDK. @@ -34,25 +36,6 @@ class DataCollection outgoing_response ].freeze - # Configuration for key-value data collection. - class KeyValueCollection - # `mode` controls whether values are collected: - # - `:off` disables collection. - # - `:deny_list` collects values except those matching `terms`. - # - `:allow_list` collects only values matching `terms`. - # @return [:off, :deny_list, :allow_list] - attr_accessor :mode - - # `terms` contains the keys or patterns used by the selected mode. - # @return [Array, nil] - attr_accessor :terms - - def initialize(mode:, terms:) - @mode = mode - @terms = terms - end - end - class HttpHeaders # @return [KeyValueCollection] attr_accessor :request diff --git a/sentry-ruby/lib/sentry/data_collection/key_value_collection.rb b/sentry-ruby/lib/sentry/data_collection/key_value_collection.rb new file mode 100644 index 000000000..88d967b91 --- /dev/null +++ b/sentry-ruby/lib/sentry/data_collection/key_value_collection.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Sentry + class DataCollection + # Configuration for key-value data collection. + class KeyValueCollection + FILTERED_VALUE = "[Filtered]" + + # Key name fragments whose values must never be sent in plaintext. + module SensitiveDenylist + TERMS = %w[ + auth + token + secret + password + passwd + pwd + key + jwt + bearer + sso + saml + csrf + xsrf + credentials + session + sid + identity + ].freeze + end + + # `mode` controls whether values are collected: + # - `:off` disables collection. + # - `:deny_list` collects values except those matching `terms`. + # - `:allow_list` collects only values matching `terms`. + # @return [:off, :deny_list, :allow_list] + attr_accessor :mode + + # `terms` contains the keys or patterns used by the selected mode. + # @return [Array, nil] + attr_accessor :terms + + def initialize(mode:, terms:) + @mode = mode + @terms = terms + end + + # Applies this collection configuration without changing the input hash. + # Keys are retained whenever the category is collected; values that are not + # safe to send are replaced with FILTERED_VALUE. + # + # @param values [Hash] key-value data to filter + # @return [Hash] a new filtered hash, or an empty hash when collection is off + def filter(values) + return {} if mode == :off + + values.each_with_object({}) do |(key, value), filtered| + filtered[key] = safe_value?(key) ? value : FILTERED_VALUE + end + end + + private + + def safe_value?(key) + key_name = key.to_s + return false if sensitive?(key_name) + + case mode + when :deny_list + !matches_any_term?(key_name, terms) + when :allow_list + matches_any_term?(key_name, terms) + else + false + end + end + + def sensitive?(key) + matches_any_term?(key, SensitiveDenylist::TERMS) + end + + def matches_any_term?(key, terms) + Array(terms).any? { |term| key.downcase.include?(term.to_s.downcase) } + end + end + end +end diff --git a/sentry-ruby/spec/sentry/data_collection/key_value_collection_spec.rb b/sentry-ruby/spec/sentry/data_collection/key_value_collection_spec.rb new file mode 100644 index 000000000..ae3f4de50 --- /dev/null +++ b/sentry-ruby/spec/sentry/data_collection/key_value_collection_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "sentry/data_collection/key_value_collection" + +RSpec.describe Sentry::DataCollection::KeyValueCollection do + subject(:collection) { described_class.new(mode: mode, terms: terms) } + + let(:values) do + { + "Authorization" => "secret-token", + "page" => "2", + "display_name" => "Ada", + 42 => "numeric key" + } + end + let(:mode) { :deny_list } + let(:terms) { nil } + + describe "#filter" do + it "uses the collection configuration" do + expect(collection.filter(values)).to eq( + "Authorization" => "[Filtered]", + "page" => "2", + "display_name" => "Ada", + 42 => "numeric key" + ) + end + + context "when mode is :off" do + let(:mode) { :off } + + it "does not collect keys or values" do + expect(collection.filter(values)).to eq({}) + end + end + + context "when mode is :deny_list" do + it "matches sensitive terms partially and case-insensitively" do + expect(described_class.new(mode: :deny_list, terms: nil).filter( + { "X-Auth-Token" => "a", "ACCESS_SECRET_VALUE" => "b", "sideline" => "c" } + )).to eq( + "X-Auth-Token" => "[Filtered]", + "ACCESS_SECRET_VALUE" => "[Filtered]", + "sideline" => "[Filtered]" + ) + end + + it "applies additional deny terms" do + expect(described_class.new(mode: :deny_list, terms: ["USER", :internal]).filter( + { "user_id" => "1", "internal" => "value" } + )).to eq( + "user_id" => "[Filtered]", + "internal" => "[Filtered]" + ) + end + + it "covers every built-in sensitive term" do + values = Sentry::DataCollection::KeyValueCollection::SensitiveDenylist::TERMS.to_h do |term| + ["prefix-#{term.upcase}-suffix", "value"] + end + + expect(collection.filter(values).values.uniq).to eq(["[Filtered]"]) + end + end + + context "when mode is :allow_list" do + let(:mode) { :allow_list } + let(:terms) { ["page", "display"] } + + it "filters values whose keys are not allowed" do + expect(collection.filter(values)).to eq( + "Authorization" => "[Filtered]", + "page" => "2", + "display_name" => "Ada", + 42 => "[Filtered]" + ) + end + + it "still filters sensitive keys listed in the allow list" do + expect(described_class.new(mode: :allow_list, terms: ["token", "public"]).filter( + { "token" => "secret", "public" => "value" } + )).to eq("token" => "[Filtered]", "public" => "value") + end + end + + it "does not mutate the input" do + original = values.dup + + collection.filter(values) + + expect(values).to eq(original) + end + end +end