Skip to content

Commit a207aae

Browse files
sl0thentr0pyOpenAI
andcommitted
feat(data-collection): request data collection
Co-Authored-By: OpenAI <noreply@example.com>
1 parent 14a6918 commit a207aae

10 files changed

Lines changed: 214 additions & 101 deletions

File tree

sentry-ruby/lib/sentry/data_collection.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ class DataCollection
2828
# data_collection.stack_frame_variables = true
2929
# data_collection.frame_context_lines = 5
3030
# end
31+
3132
MODES = %i[off deny_list allow_list].freeze
33+
34+
PII_HEADER_SNIPPETS = %w[forwarded -ip _ip remote via _user -user].freeze
35+
3236
BODY_TYPES = %i[
3337
incoming_request
3438
outgoing_request
@@ -102,6 +106,11 @@ def initialize(document:, variables:)
102106
# @default `3`
103107
attr_accessor :frame_context_lines
104108

109+
# Filters key-value data using the default sensitive denylist.
110+
def self.filter(values)
111+
KeyValueCollection.new(mode: :deny_list, terms: nil).filter(values)
112+
end
113+
105114
# Builds data collection settings compatible with the legacy send_default_pii
106115
# configuration.
107116
def self.backfill(configuration)
@@ -112,8 +121,10 @@ def self.backfill(configuration)
112121
# TODO-neel-data map to exact ruby behaviour for backwards compat behavior
113122
data_collection.user_info = false
114123
data_collection.cookies.mode = :off
115-
data_collection.http_headers.request.mode = :off
116-
data_collection.http_headers.response.mode = :off
124+
data_collection.http_headers.request.mode = :deny_list
125+
data_collection.http_headers.request.terms = PII_HEADER_SNIPPETS
126+
data_collection.http_headers.response.mode = :deny_list
127+
data_collection.http_headers.response.terms = PII_HEADER_SNIPPETS
117128
data_collection.http_bodies = []
118129
data_collection.url_query_params.mode = :off
119130
data_collection.graphql.document = false
@@ -132,7 +143,7 @@ def initialize
132143
request: KeyValueCollection.new(mode: :deny_list, terms: nil),
133144
response: KeyValueCollection.new(mode: :deny_list, terms: nil)
134145
)
135-
@http_bodies = nil
146+
@http_bodies = BODY_TYPES
136147
@url_query_params = KeyValueCollection.new(mode: :deny_list, terms: nil)
137148
@database_query_data = true
138149
@graphql = GraphQL.new(document: true, variables: true)

sentry-ruby/lib/sentry/data_collection/key_value_collection.rb

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ class KeyValueCollection
77
FILTERED_VALUE = "[Filtered]"
88

99
# Keys from this list are ALWAYS filtered, regardless of :mode
10-
# TODO-neel-data cookies have separate list, see JS
1110
SENSITIVE_DENY_LIST = %w[
1211
auth
1312
token
@@ -30,6 +29,30 @@ class KeyValueCollection
3029
set-cookie
3130
].freeze
3231

32+
# Additional terms applied to cookie names only. These cover common
33+
# opaque session, identity-provider, and load-balancer cookies without
34+
# making the general header denylist overly broad.
35+
SENSITIVE_COOKIE_NAME_DENY_LIST = %w[
36+
.sid
37+
sessid
38+
remember
39+
oidc
40+
pkce
41+
nonce
42+
__secure-
43+
__host-
44+
awsalb
45+
awselb
46+
akamai
47+
__stripe
48+
cognito
49+
firebase
50+
supabase
51+
sb-
52+
mfa
53+
2fa
54+
].freeze
55+
3356
# `mode` controls whether values are collected:
3457
# - `:off` disables collection.
3558
# - `:deny_list` collects values except those matching `terms`.
@@ -56,19 +79,19 @@ def terms=(terms)
5679
#
5780
# @param values [Hash] key-value data to filter
5881
# @return [Hash] a new filtered hash, or an empty hash when collection is off
59-
def filter(values)
82+
def filter(values, cookie: false)
6083
return {} if mode == :off
6184

6285
values.each_with_object({}) do |(key, value), filtered|
63-
filtered[key] = safe_value?(key) ? value : FILTERED_VALUE
86+
filtered[key] = safe_value?(key, cookie: cookie) ? value : FILTERED_VALUE
6487
end
6588
end
6689

6790
private
6891

69-
def safe_value?(key)
92+
def safe_value?(key, cookie: false)
7093
key_downcase = key.to_s.downcase
71-
return false if sensitive?(key_downcase)
94+
return false if sensitive?(key_downcase, cookie: cookie)
7295

7396
case mode
7497
when :deny_list
@@ -80,8 +103,9 @@ def safe_value?(key)
80103
end
81104
end
82105

83-
def sensitive?(key)
84-
SENSITIVE_DENY_LIST.any? { |term| key.include?(term) }
106+
def sensitive?(key, cookie: false)
107+
SENSITIVE_DENY_LIST.any? { |term| key.include?(term) } ||
108+
(cookie && SENSITIVE_COOKIE_NAME_DENY_LIST.any? { |term| key.include?(term) })
85109
end
86110

87111
def matches_any_term?(key)

sentry-ruby/lib/sentry/event.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class Event
2828

2929
MAX_MESSAGE_SIZE_IN_BYTES = 1024 * 8
3030

31-
SKIP_INSPECTION_ATTRIBUTES = [:@modules, :@stacktrace_builder, :@send_default_pii, :@data_collection, :@trusted_proxies, :@rack_env_whitelist]
31+
SKIP_INSPECTION_ATTRIBUTES = [:@modules, :@stacktrace_builder, :@data_collection, :@trusted_proxies, :@rack_env_whitelist]
3232

3333
include CustomInspection
3434

@@ -73,7 +73,6 @@ def initialize(configuration:, integration_meta: nil, message: nil)
7373
@modules = configuration.gem_specs if configuration.send_modules
7474

7575
# configuration options to help events process data
76-
@send_default_pii = configuration.send_default_pii
7776
@data_collection = configuration.data_collection
7877
@trusted_proxies = configuration.trusted_proxies
7978
@stacktrace_builder = configuration.stacktrace_builder
@@ -128,7 +127,11 @@ def to_json_compatible
128127
private
129128

130129
def add_request_interface(env)
131-
@request = Sentry::RequestInterface.new(env: env, send_default_pii: @send_default_pii, rack_env_whitelist: @rack_env_whitelist)
130+
@request = Sentry::RequestInterface.new(
131+
env: env,
132+
data_collection: @data_collection,
133+
rack_env_whitelist: @rack_env_whitelist
134+
)
132135
end
133136

134137
def serialize_attributes

sentry-ruby/lib/sentry/interfaces/request.rb

Lines changed: 43 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
# frozen_string_literal: true
22

3+
require "json"
4+
35
module Sentry
46
class RequestInterface < Interface
57
REQUEST_ID_HEADERS = %w[action_dispatch.request_id HTTP_X_REQUEST_ID].freeze
68
CONTENT_HEADERS = %w[CONTENT_TYPE CONTENT_LENGTH].freeze
7-
IP_HEADERS = [
8-
"REMOTE_ADDR",
9-
"HTTP_CLIENT_IP",
10-
"HTTP_X_REAL_IP",
11-
"HTTP_X_FORWARDED_FOR"
12-
].freeze
139

1410
# Regex to detect lowercase chars — match? is allocation-free (no MatchData/String)
1511
LOWERCASE_PATTERN = /[a-z]/.freeze
@@ -27,10 +23,10 @@ class RequestInterface < Interface
2723
# @return [Hash]
2824
attr_accessor :data
2925

30-
# @return [String]
26+
# @return [String, Hash]
3127
attr_accessor :query_string
3228

33-
# @return [String]
29+
# @return [Hash]
3430
attr_accessor :cookies
3531

3632
# @return [Hash]
@@ -40,33 +36,24 @@ class RequestInterface < Interface
4036
attr_accessor :env
4137

4238
# @param env [Hash]
43-
# @param send_default_pii [Boolean]
39+
# @param data_collection [DataCollection]
40+
# @param send_default_pii [Boolean] Deprecated compatibility input, unused.
4441
# @param rack_env_whitelist [Array]
42+
# @see Configuration#data_collection
4543
# @see Configuration#send_default_pii
4644
# @see Configuration#rack_env_whitelist
47-
def initialize(env:, send_default_pii:, rack_env_whitelist:)
45+
def initialize(env:, data_collection:, rack_env_whitelist:, send_default_pii: nil)
4846
env = env.dup
49-
50-
unless send_default_pii
51-
# need to completely wipe out ip addresses
52-
RequestInterface::IP_HEADERS.each do |header|
53-
env.delete(header)
54-
end
55-
end
56-
5747
request = ::Rack::Request.new(env)
58-
59-
if send_default_pii
60-
self.data = read_data_from(request)
61-
self.cookies = request.cookies
62-
self.query_string = request.query_string
63-
end
64-
65-
self.url = request.scheme && request.url.split("?").first
66-
self.method = request.request_method
67-
68-
self.headers = filter_and_format_headers(env, send_default_pii)
69-
self.env = filter_and_format_env(env, rack_env_whitelist)
48+
query = data_collection.url_query_params.filter(request.GET) rescue nil
49+
50+
self.method = request.request_method
51+
self.url = request.scheme && request.url.split("?").first
52+
self.query_string = query unless query&.empty?
53+
self.cookies = data_collection.cookies.filter(request.cookies, cookie: true)
54+
self.data = read_data_from(request) if data_collection.http_bodies.include?(:incoming_request)
55+
self.headers = filter_and_format_headers(env, data_collection.http_headers.request)
56+
self.env = filter_and_format_env(env, data_collection.http_headers.request, rack_env_whitelist)
7057
end
7158

7259
private
@@ -75,25 +62,31 @@ def read_data_from(request)
7562
return "Skipped non-rewindable request body" unless request.body.respond_to?(:rewind)
7663

7764
if request.form_data?
78-
request.POST
79-
elsif request.body # JSON requests, etc
80-
data = request.body.read(MAX_BODY_LIMIT)
81-
data = Utils::EncodingHelper.encode_to_utf_8(data.to_s)
82-
request.body.rewind
83-
data
65+
DataCollection.filter(request.POST)
66+
else
67+
body = request.body.read(MAX_BODY_LIMIT)
68+
body = Utils::EncodingHelper.encode_to_utf_8(body.to_s)
69+
70+
if request.media_type == "application/json" || request.media_type&.end_with?("+json")
71+
parsed_body = JSON.parse(body)
72+
parsed_body.is_a?(Hash) ? DataCollection.filter(parsed_body) : parsed_body
73+
else
74+
body
75+
end
8476
end
85-
rescue IOError => e
77+
rescue JSON::ParserError, IOError => e
8678
e.message
79+
ensure
80+
request.body.rewind if request.body.respond_to?(:rewind)
8781
end
8882

89-
def filter_and_format_headers(env, send_default_pii)
83+
def filter_and_format_headers(env, collection)
9084
env.each_with_object({}) do |(key, value), memo|
9185
begin
9286
key = key.to_s # rack env can contain symbols
9387
next memo["X-Request-Id"] ||= Utils::RequestId.read_from(env) if Utils::RequestId::REQUEST_ID_HEADERS.include?(key)
9488
next if is_server_protocol?(key, value, env["SERVER_PROTOCOL"])
9589
next if is_skippable_header?(key)
96-
next if key == "HTTP_AUTHORIZATION" && !send_default_pii
9790

9891
# Rack stores headers as HTTP_WHAT_EVER, we need What-Ever
9992
key = key.delete_prefix("HTTP_")
@@ -107,12 +100,13 @@ def filter_and_format_headers(env, send_default_pii)
107100
Sentry.sdk_logger.warn(LOGGER_PROGNAME) { "Error raised while formatting headers: #{e.message}" }
108101
next
109102
end
103+
end.then do |e|
104+
collection.filter(e)
110105
end
111106
end
112107

113108
def is_skippable_header?(key)
114109
key.match?(LOWERCASE_PATTERN) || # lower-case envs aren't real http headers
115-
key == "HTTP_COOKIE" || # Cookies don't go here, they go somewhere else
116110
!(key.start_with?("HTTP_") || CONTENT_HEADERS.include?(key))
117111
end
118112

@@ -134,11 +128,15 @@ def self.rack_3_or_above?
134128
Gem::Version.new(::Rack.release) >= Gem::Version.new("3.0")
135129
end
136130

137-
def filter_and_format_env(env, rack_env_whitelist)
138-
return env if rack_env_whitelist.empty?
139-
140-
env.select do |k, _v|
141-
rack_env_whitelist.include? k.to_s
131+
def filter_and_format_env(env, collection, rack_env_whitelist)
132+
if rack_env_whitelist.empty?
133+
env
134+
else
135+
env.select do |k, _v|
136+
rack_env_whitelist.include? k.to_s
137+
end
138+
end.then do |e|
139+
collection.filter(e)
142140
end
143141
end
144142
end

sentry-ruby/spec/sentry/client/event_sending_spec.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,19 @@
348348
end
349349
end
350350

351+
context "when scope contains a malformed query string", when: :rack_available? do
352+
before do
353+
configuration.background_worker_threads = 0
354+
stub_request(:post, "http://sentry.localdomain/sentry/api/42/envelope/").to_return(status: 200)
355+
scope.set_rack_env(Rack::MockRequest.env_for("/test?a=%"))
356+
end
357+
358+
it "still captures the event" do
359+
expect(client.capture_event(event, scope)).to eq(event)
360+
expect(event.request.query_string).to be_nil
361+
end
362+
end
363+
351364
context "when scope.apply_to_event fails" do
352365
before do
353366
scope.add_event_processor do

sentry-ruby/spec/sentry/data_collection/key_value_collection_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
it "normalizes terms assigned after initialization" do
5858
collection.terms = ["USER"]
5959

60-
expect(collection.filter("user_id" => "1")).to eq("user_id" => "[Filtered]")
60+
expect(collection.filter({ "user_id" => "1" })).to eq("user_id" => "[Filtered]")
6161
end
6262

6363
it "covers every built-in sensitive term" do

sentry-ruby/spec/sentry/data_collection_spec.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,26 @@
33
RSpec.describe Sentry::DataCollection do
44
subject(:data_collection) { described_class.new }
55

6+
describe ".filter" do
7+
it "applies the default sensitive denylist" do
8+
expect(described_class.filter("token" => "secret", "page" => "2")).to eq(
9+
"token" => "[Filtered]",
10+
"page" => "2"
11+
)
12+
end
13+
end
14+
615
describe ".backfill" do
716
it "uses the send_default_pii=false defaults" do
817
configuration = Sentry::Configuration.new
918
data_collection = described_class.backfill(configuration)
1019

1120
expect(data_collection.user_info).to eq(false)
1221
expect(data_collection.cookies.mode).to eq(:off)
13-
expect(data_collection.http_headers.request.mode).to eq(:off)
14-
expect(data_collection.http_headers.response.mode).to eq(:off)
22+
expect(data_collection.http_headers.request.mode).to eq(:deny_list)
23+
expect(data_collection.http_headers.request.terms).to eq(described_class::PII_HEADER_SNIPPETS)
24+
expect(data_collection.http_headers.response.mode).to eq(:deny_list)
25+
expect(data_collection.http_headers.request.terms).to eq(described_class::PII_HEADER_SNIPPETS)
1526
expect(data_collection.http_bodies).to eq([])
1627
expect(data_collection.url_query_params.mode).to eq(:off)
1728
expect(data_collection.database_query_data).to eq(false)
@@ -37,10 +48,10 @@
3748
expect(data_collection.cookies.mode).to eq(:deny_list)
3849
expect(data_collection.cookies.terms).to be_nil
3950
expect(data_collection.http_headers.request.mode).to eq(:deny_list)
40-
expect(data_collection.http_headers.request.terms).to be_nil
51+
expect(data_collection.http_headers.request.terms).to eq(nil)
4152
expect(data_collection.http_headers.response.mode).to eq(:deny_list)
42-
expect(data_collection.http_headers.response.terms).to be_nil
43-
expect(data_collection.http_bodies).to be_nil
53+
expect(data_collection.http_headers.request.terms).to eq(nil)
54+
expect(data_collection.http_bodies).to eq(described_class::BODY_TYPES)
4455
expect(data_collection.url_query_params.mode).to eq(:deny_list)
4556
expect(data_collection.url_query_params.terms).to be_nil
4657
expect(data_collection.database_query_data).to eq(true)

0 commit comments

Comments
 (0)