Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 2 additions & 19 deletions sentry-ruby/lib/sentry/data_collection.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<String>, nil]
attr_accessor :terms

def initialize(mode:, terms:)
@mode = mode
@terms = terms
end
end

class HttpHeaders
# @return [KeyValueCollection]
attr_accessor :request
Expand Down
87 changes: 87 additions & 0 deletions sentry-ruby/lib/sentry/data_collection/key_value_collection.rb
Original file line number Diff line number Diff line change
@@ -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<String>, 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
Original file line number Diff line number Diff line change
@@ -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
Loading