Skip to content

Commit 363b5f2

Browse files
solnicclaude
andauthored
refa: extract scrubber (#1050)
* feat(scrubber): introduce shared Sentry.Scrubber module Adds a framework-agnostic module that owns the canonical default sensitive key lists, the redaction placeholder, the credit-card detection heuristic, and the recursive map/list traversal used to scrub data before it is sent to Sentry. Existing integrations duplicate these primitives today; this module provides a single source of truth that follow-up commits will route PlugContext, PlugCapture, and LiveViewHook through. The default behavior matches the existing Sentry.PlugContext defaults ("*********" placeholder, ["password", "passwd", "secret"] for params, ["authorization", "authentication", "cookie"] for headers) so no existing scrubbing output changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(plug_context): delegate default scrubbers to Sentry.Scrubber Removes the duplicated denylist constants, placeholder, credit-card regex, and recursive scrub_map/scrub_list helpers from Sentry.PlugContext in favor of the shared Sentry.Scrubber module. Public function signatures and the documented default key sets are unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 386c9d6 commit 363b5f2

3 files changed

Lines changed: 283 additions & 34 deletions

File tree

lib/sentry/plug_context.ex

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,8 @@ defmodule Sentry.PlugContext do
153153
end
154154
end
155155

156-
@default_scrubbed_param_keys ["password", "passwd", "secret"]
157-
@default_scrubbed_header_keys ["authorization", "authentication", "cookie"]
158-
@scrubbed_value "*********"
156+
@default_scrubbed_param_keys Sentry.Scrubber.default_param_keys()
157+
@default_scrubbed_header_keys Sentry.Scrubber.default_header_keys()
159158
@default_plug_request_id_header "x-request-id"
160159

161160
@doc false
@@ -256,7 +255,7 @@ defmodule Sentry.PlugContext do
256255
def default_header_scrubber(conn) do
257256
conn.req_headers
258257
|> Map.new()
259-
|> Map.drop(@default_scrubbed_header_keys)
258+
|> Sentry.Scrubber.drop_keys()
260259
end
261260

262261
@doc """
@@ -268,35 +267,6 @@ defmodule Sentry.PlugContext do
268267
"""
269268
@spec default_body_scrubber(Plug.Conn.t()) :: map()
270269
def default_body_scrubber(conn) do
271-
scrub_map(conn.params, @default_scrubbed_param_keys)
270+
Sentry.Scrubber.scrub_map(conn.params)
272271
end
273-
274-
defp scrub_map(map, scrubbed_keys) do
275-
Map.new(map, fn {key, value} ->
276-
value =
277-
cond do
278-
key in scrubbed_keys -> @scrubbed_value
279-
is_binary(value) and value =~ credit_card_regex() -> @scrubbed_value
280-
is_struct(value) -> value |> Map.from_struct() |> scrub_map(scrubbed_keys)
281-
is_map(value) -> scrub_map(value, scrubbed_keys)
282-
is_list(value) -> scrub_list(value, scrubbed_keys)
283-
true -> value
284-
end
285-
286-
{key, value}
287-
end)
288-
end
289-
290-
defp scrub_list(list, scrubbed_keys) do
291-
Enum.map(list, fn value ->
292-
cond do
293-
is_struct(value) -> value |> Map.from_struct() |> scrub_map(scrubbed_keys)
294-
is_map(value) -> scrub_map(value, scrubbed_keys)
295-
is_list(value) -> scrub_list(value, scrubbed_keys)
296-
true -> value
297-
end
298-
end)
299-
end
300-
301-
defp credit_card_regex, do: ~r/^(?:\d[ -]*?){13,16}$/
302272
end

lib/sentry/scrubber.ex

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
defmodule Sentry.Scrubber do
2+
@moduledoc """
3+
Shared, framework-agnostic helpers for scrubbing sensitive data before it is
4+
sent to Sentry.
5+
6+
*Available since v13.1.0.*
7+
8+
This module owns the default sensitive key lists, the placeholder used in
9+
place of redacted values, the credit-card detection heuristic, and the
10+
recursive map/list traversal used by the rest of the SDK to redact values.
11+
Integrations such as `Sentry.PlugContext`, `Sentry.PlugCapture`, and
12+
`Sentry.LiveViewHook` delegate to the functions exposed here so that
13+
scrubbing rules live in a single place.
14+
15+
## Defaults
16+
17+
The default sensitive *parameter* keys (used for body params, query strings,
18+
and arbitrary maps) are:
19+
20+
#{Enum.map_join(["password", "passwd", "secret"], "\n", &" * `\"#{&1}\"`")}
21+
22+
The default sensitive *header* keys are:
23+
24+
#{Enum.map_join(["authorization", "authentication", "cookie"], "\n", &" * `\"#{&1}\"`")}
25+
26+
Values matching a credit-card-like pattern (13–16 digits, optionally
27+
separated by spaces or dashes) are also replaced with the placeholder.
28+
29+
## Custom scrubbing
30+
31+
All public functions accept an optional `:keys` option that overrides the
32+
default list of sensitive keys. This makes it possible to compose custom
33+
scrubbers on top of the defaults:
34+
35+
def scrub(map) do
36+
map
37+
|> Sentry.Scrubber.scrub_map(keys: ["password", "api_key"])
38+
|> Map.drop(["internal_notes"])
39+
end
40+
"""
41+
42+
@moduledoc since: "13.1.0"
43+
44+
@default_scrubbed_param_keys ["password", "passwd", "secret"]
45+
@default_scrubbed_header_keys ["authorization", "authentication", "cookie"]
46+
@scrubbed_value "*********"
47+
48+
@typedoc """
49+
Options accepted by the scrubbing functions in this module.
50+
"""
51+
@type option :: {:keys, [String.t()]}
52+
53+
@doc """
54+
The placeholder string used to replace scrubbed values.
55+
"""
56+
@spec scrubbed_value() :: String.t()
57+
def scrubbed_value, do: @scrubbed_value
58+
59+
@doc """
60+
Returns the default list of sensitive parameter keys.
61+
"""
62+
@spec default_param_keys() :: [String.t()]
63+
def default_param_keys, do: @default_scrubbed_param_keys
64+
65+
@doc """
66+
Returns the default list of sensitive header keys.
67+
"""
68+
@spec default_header_keys() :: [String.t()]
69+
def default_header_keys, do: @default_scrubbed_header_keys
70+
71+
@doc """
72+
Recursively scrubs a map.
73+
74+
Any value whose key is in the configured sensitive key list is replaced with
75+
the placeholder. Values matching the credit-card pattern are also replaced.
76+
Nested maps, structs, and lists are scrubbed recursively.
77+
78+
## Options
79+
80+
* `:keys` - the list of sensitive keys to redact. Defaults to
81+
`default_param_keys/0`.
82+
"""
83+
@spec scrub_map(map(), [option()]) :: map()
84+
def scrub_map(map, opts \\ []) when is_map(map) do
85+
keys = Keyword.get(opts, :keys, @default_scrubbed_param_keys)
86+
do_scrub_map(map, keys)
87+
end
88+
89+
@doc """
90+
Recursively scrubs a list, applying the same rules as `scrub_map/2` to any
91+
maps it contains.
92+
93+
## Options
94+
95+
See `scrub_map/2`.
96+
"""
97+
@spec scrub_list(list(), [option()]) :: list()
98+
def scrub_list(list, opts \\ []) when is_list(list) do
99+
keys = Keyword.get(opts, :keys, @default_scrubbed_param_keys)
100+
do_scrub_list(list, keys)
101+
end
102+
103+
@doc """
104+
Drops sensitive keys from a flat map.
105+
106+
This is the strategy used for HTTP headers, where the sensitive value should
107+
not appear in the payload at all.
108+
109+
## Options
110+
111+
* `:keys` - the list of sensitive keys to drop. Defaults to
112+
`default_header_keys/0`.
113+
"""
114+
@spec drop_keys(map(), [option()]) :: map()
115+
def drop_keys(map, opts \\ []) when is_map(map) do
116+
keys = Keyword.get(opts, :keys, @default_scrubbed_header_keys)
117+
Map.drop(map, keys)
118+
end
119+
120+
@doc """
121+
Scrubs the query string portion of a URL, replacing the value of any
122+
sensitive query parameter with the placeholder. URLs without a query string
123+
are returned unchanged.
124+
125+
## Options
126+
127+
See `scrub_map/2`.
128+
"""
129+
@spec scrub_url(String.t(), [option()]) :: String.t()
130+
def scrub_url(url, opts \\ []) when is_binary(url) do
131+
case URI.parse(url) do
132+
%URI{query: nil} ->
133+
url
134+
135+
%URI{query: ""} ->
136+
url
137+
138+
%URI{query: query} = uri ->
139+
URI.to_string(%{uri | query: scrub_query_string(query, opts)})
140+
end
141+
end
142+
143+
@doc """
144+
Scrubs an `application/x-www-form-urlencoded` query string, replacing the
145+
value of any sensitive parameter with the placeholder.
146+
147+
## Options
148+
149+
See `scrub_map/2`.
150+
"""
151+
@spec scrub_query_string(String.t(), [option()]) :: String.t()
152+
def scrub_query_string(query, opts \\ []) when is_binary(query) do
153+
keys = Keyword.get(opts, :keys, @default_scrubbed_param_keys)
154+
155+
query
156+
|> URI.query_decoder()
157+
|> Enum.map(fn {key, value} ->
158+
cond do
159+
key in keys -> {key, @scrubbed_value}
160+
is_binary(value) and value =~ credit_card_regex() -> {key, @scrubbed_value}
161+
true -> {key, value}
162+
end
163+
end)
164+
|> URI.encode_query()
165+
end
166+
167+
## Internal recursion
168+
169+
defp do_scrub_map(map, keys) do
170+
Map.new(map, fn {key, value} -> {key, scrub_value(key, value, keys)} end)
171+
end
172+
173+
defp do_scrub_list(list, keys) do
174+
Enum.map(list, fn value ->
175+
cond do
176+
is_struct(value) -> value |> Map.from_struct() |> do_scrub_map(keys)
177+
is_map(value) -> do_scrub_map(value, keys)
178+
is_list(value) -> do_scrub_list(value, keys)
179+
true -> value
180+
end
181+
end)
182+
end
183+
184+
defp scrub_value(key, value, keys) do
185+
cond do
186+
key in keys -> @scrubbed_value
187+
is_binary(value) and value =~ credit_card_regex() -> @scrubbed_value
188+
is_struct(value) -> value |> Map.from_struct() |> do_scrub_map(keys)
189+
is_map(value) -> do_scrub_map(value, keys)
190+
is_list(value) -> do_scrub_list(value, keys)
191+
true -> value
192+
end
193+
end
194+
195+
defp credit_card_regex, do: ~r/^(?:\d[ -]*?){13,16}$/
196+
end

test/sentry/scrubber_test.exs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
defmodule Sentry.ScrubberTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Sentry.Scrubber
5+
6+
describe "scrub_map/2" do
7+
test "redacts sensitive top-level keys" do
8+
assert Scrubber.scrub_map(%{"password" => "x", "ok" => 1}) ==
9+
%{"password" => "*********", "ok" => 1}
10+
end
11+
12+
test "recurses into nested maps" do
13+
assert Scrubber.scrub_map(%{"outer" => %{"secret" => "shh"}}) ==
14+
%{"outer" => %{"secret" => "*********"}}
15+
end
16+
17+
test "recurses into lists of maps" do
18+
assert Scrubber.scrub_map(%{"items" => [%{"passwd" => "1"}, %{"ok" => 2}]}) ==
19+
%{"items" => [%{"passwd" => "*********"}, %{"ok" => 2}]}
20+
end
21+
22+
test "redacts credit-card-shaped values" do
23+
assert Scrubber.scrub_map(%{"cc" => "4111111111111111"}) ==
24+
%{"cc" => "*********"}
25+
end
26+
27+
test "scrubs structs by converting them to maps" do
28+
uri = URI.parse("http://example.com")
29+
assert %{"u" => scrubbed} = Scrubber.scrub_map(%{"u" => uri})
30+
assert is_map(scrubbed)
31+
refute Map.has_key?(scrubbed, :__struct__)
32+
end
33+
34+
test "respects custom :keys option" do
35+
assert Scrubber.scrub_map(%{"api_key" => "x", "password" => "y"}, keys: ["api_key"]) ==
36+
%{"api_key" => "*********", "password" => "y"}
37+
end
38+
39+
test "leaves non-sensitive values untouched" do
40+
data = %{"name" => "alice", "age" => 30}
41+
assert Scrubber.scrub_map(data) == data
42+
end
43+
end
44+
45+
describe "drop_keys/2" do
46+
test "drops sensitive header keys by default" do
47+
assert Scrubber.drop_keys(%{"authorization" => "Bearer x", "x-trace" => "1"}) ==
48+
%{"x-trace" => "1"}
49+
end
50+
51+
test "respects custom :keys option" do
52+
assert Scrubber.drop_keys(%{"x-secret" => "1", "x-trace" => "1"}, keys: ["x-secret"]) ==
53+
%{"x-trace" => "1"}
54+
end
55+
end
56+
57+
describe "scrub_url/2" do
58+
test "redacts sensitive query parameters" do
59+
url = "http://example.com/foo?password=secret&visible=ok"
60+
scrubbed = Scrubber.scrub_url(url)
61+
refute scrubbed =~ "secret"
62+
assert scrubbed =~ "visible=ok"
63+
end
64+
65+
test "passes through URLs without query strings" do
66+
assert Scrubber.scrub_url("http://example.com/foo") == "http://example.com/foo"
67+
end
68+
69+
test "preserves scheme, host, port, and path" do
70+
scrubbed = Scrubber.scrub_url("https://example.com:8443/p?secret=x")
71+
assert scrubbed =~ "https://example.com:8443/p?"
72+
refute scrubbed =~ "secret=x"
73+
end
74+
end
75+
76+
describe "scrub_query_string/2" do
77+
test "redacts sensitive params" do
78+
scrubbed = Scrubber.scrub_query_string("password=hunter2&visible=ok")
79+
refute scrubbed =~ "hunter2"
80+
assert scrubbed =~ "visible=ok"
81+
end
82+
end
83+
end

0 commit comments

Comments
 (0)