From 04b6f866269796c36da7ea22d410d2d2b80dc4c8 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 5 May 2026 10:08:06 +0000 Subject: [PATCH 1/4] refactor: introduce `Sentry.Scrubber` Move the recursive map/list scrubbing logic and default sensitive parameter keys out of Sentry.PlugContext and into a new framework-agnostic Sentry.Scrubber module so it can be reused by other parts of the SDK (for example, Sentry.LiveViewHook). PlugContext now delegates to Sentry.Scrubber.scrub_map/1 from default_body_scrubber/1. No behavior change. Co-Authored-By: Claude Opus 4.7 --- lib/sentry/plug_context.ex | 35 +------- lib/sentry/scrubber.ex | 135 ++++++++++++++++++++++++++++++ test/plug_capture_test.exs | 2 +- test/sentry/plug_context_test.exs | 18 ++-- test/sentry/scrubber_test.exs | 116 +++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 43 deletions(-) create mode 100644 lib/sentry/scrubber.ex create mode 100644 test/sentry/scrubber_test.exs diff --git a/lib/sentry/plug_context.ex b/lib/sentry/plug_context.ex index 8387d4e4f..567b96e9b 100644 --- a/lib/sentry/plug_context.ex +++ b/lib/sentry/plug_context.ex @@ -153,9 +153,7 @@ defmodule Sentry.PlugContext do end end - @default_scrubbed_param_keys ["password", "passwd", "secret"] @default_scrubbed_header_keys ["authorization", "authentication", "cookie"] - @scrubbed_value "*********" @default_plug_request_id_header "x-request-id" @doc false @@ -264,39 +262,10 @@ defmodule Sentry.PlugContext do The default scrubbed keys are: - #{Enum.map_join(@default_scrubbed_param_keys, "\n", &"* `#{&1}`")} + #{Enum.map_join(Sentry.Scrubber.default_scrubbed_param_keys(), "\n", &"* `#{&1}`")} """ @spec default_body_scrubber(Plug.Conn.t()) :: map() def default_body_scrubber(conn) do - scrub_map(conn.params, @default_scrubbed_param_keys) + Sentry.Scrubber.scrub_map(conn.params) end - - defp scrub_map(map, scrubbed_keys) do - Map.new(map, fn {key, value} -> - value = - cond do - key in scrubbed_keys -> @scrubbed_value - is_binary(value) and value =~ credit_card_regex() -> @scrubbed_value - is_struct(value) -> value |> Map.from_struct() |> scrub_map(scrubbed_keys) - is_map(value) -> scrub_map(value, scrubbed_keys) - is_list(value) -> scrub_list(value, scrubbed_keys) - true -> value - end - - {key, value} - end) - end - - defp scrub_list(list, scrubbed_keys) do - Enum.map(list, fn value -> - cond do - is_struct(value) -> value |> Map.from_struct() |> scrub_map(scrubbed_keys) - is_map(value) -> scrub_map(value, scrubbed_keys) - is_list(value) -> scrub_list(value, scrubbed_keys) - true -> value - end - end) - end - - defp credit_card_regex, do: ~r/^(?:\d[ -]*?){13,16}$/ end diff --git a/lib/sentry/scrubber.ex b/lib/sentry/scrubber.ex new file mode 100644 index 000000000..05f8016a2 --- /dev/null +++ b/lib/sentry/scrubber.ex @@ -0,0 +1,135 @@ +defmodule Sentry.Scrubber do + @default_scrubbed_param_keys [ + "auth", + "token", + "secret", + "password", + "passwd", + "pwd", + "key", + "jwt", + "bearer", + "sso", + "saml", + "csrf", + "xsrf", + "credentials", + "session", + "sid", + "identity" + ] + + @scrubbed_value "[Filtered]" + + @moduledoc """ + Provides scrubbing of sensitive data from values before they are sent to Sentry. + + This module is used internally to redact sensitive values like passwords, tokens, and credit card + numbers before they appear in Sentry events and breadcrumbs. + + Scrubbing follows the [Sentry Data Collection + spec](https://develop.sentry.dev/sdk/foundations/client/data-collection/): + + * The placeholder value is `"[Filtered]"` + * Keys are matched case-insensitively as substrings — for example, the + term `"auth"` matches `"Authorization"`, `"X-Auth-Token"`, and + `"oauth_token"` + * Key names are always preserved; only values are replaced + + ## Default sensitive denylist + + The following terms are scrubbed by default: + + #{Enum.map_join(@default_scrubbed_param_keys, "\n", &" * `\"#{&1}\"`")} + """ + + @moduledoc since: "13.1.0" + + @doc """ + Returns the list of parameter keys scrubbed by default. + """ + @spec default_scrubbed_param_keys() :: [String.t()] + def default_scrubbed_param_keys, do: @default_scrubbed_param_keys + + @doc """ + Returns the placeholder value used to replace scrubbed data. + """ + @spec scrubbed_value() :: String.t() + def scrubbed_value, do: @scrubbed_value + + @doc """ + Scrubs a map of sensitive parameter values using the default denylist. + + See `scrub_map/2` to extend the denylist with additional terms. + """ + @spec scrub_map(map()) :: map() + def scrub_map(map) when is_map(map) do + scrub_map(map, []) + end + + @doc """ + Scrubs a map of sensitive parameter values using the default denylist plus + any `extra_terms`. + + Keys are matched case-insensitively as substrings. Values are replaced with + `"[Filtered]"` when their key matches a denylist term or when the value + itself looks like a credit card number. The function recurses into nested + maps and lists. + """ + @spec scrub_map(map(), [String.t()]) :: map() + def scrub_map(map, extra_terms) when is_map(map) and is_list(extra_terms) do + do_scrub_map(map, denylist(extra_terms)) + end + + @doc """ + Returns `"[Filtered]"`, used as a fallback when a value (e.g. an unparseable + cookie or body string) cannot be inspected for sensitive keys. + """ + @spec scrub_string(String.t()) :: String.t() + def scrub_string(value) when is_binary(value), do: @scrubbed_value + + defp do_scrub_map(map, denylist) do + Map.new(map, fn {key, value} -> + value = + cond do + sensitive_key?(key, denylist) -> @scrubbed_value + is_binary(value) and value =~ credit_card_regex() -> @scrubbed_value + is_struct(value) -> value |> Map.from_struct() |> do_scrub_map(denylist) + is_map(value) -> do_scrub_map(value, denylist) + is_list(value) -> do_scrub_list(value, denylist) + true -> value + end + + {key, value} + end) + end + + defp do_scrub_list(list, denylist) do + Enum.map(list, fn value -> + cond do + is_struct(value) -> value |> Map.from_struct() |> do_scrub_map(denylist) + is_map(value) -> do_scrub_map(value, denylist) + is_list(value) -> do_scrub_list(value, denylist) + true -> value + end + end) + end + + defp denylist(extra_terms) do + Enum.map(@default_scrubbed_param_keys ++ extra_terms, &String.downcase/1) + end + + defp sensitive_key?(key, denylist) when is_binary(key) do + downcased = String.downcase(key) + Enum.any?(denylist, &String.contains?(downcased, &1)) + end + + defp sensitive_key?(key, denylist) + when is_atom(key) and not is_nil(key) and not is_boolean(key) do + sensitive_key?(Atom.to_string(key), denylist) + end + + defp sensitive_key?(_key, _denylist), do: false + + defp credit_card_regex, do: ~r/^(?:\d[ -]*?){13,16}$/ +end diff --git a/test/plug_capture_test.exs b/test/plug_capture_test.exs index b1c6626f7..8d98a873f 100644 --- a/test/plug_capture_test.exs +++ b/test/plug_capture_test.exs @@ -181,7 +181,7 @@ defmodule Sentry.PlugCaptureTest do assert [exception] = event.exception assert exception.type == "Phoenix.ActionClauseError" - assert exception.value =~ ~s(params: %{"password" => "*********"}) + assert exception.value =~ ~s(params: %{"password" => "[Filtered]"}) end test "can render feedback form in Phoenix ErrorView" do diff --git a/test/sentry/plug_context_test.exs b/test/sentry/plug_context_test.exs index 865916b2f..ed54e3641 100644 --- a/test/sentry/plug_context_test.exs +++ b/test/sentry/plug_context_test.exs @@ -152,16 +152,16 @@ defmodule Sentry.PlugContextTest do assert request_context.cookies == %{} assert request_context.data == %{ - "another_cc" => "*********", - "cc" => "*********", + "another_cc" => "[Filtered]", + "cc" => "[Filtered]", "count" => 334, - "credit_card" => "*********", - "passwd" => "*********", - "password" => "*********", - "secret" => "*********", - "user" => %{"password" => "*********"}, + "credit_card" => "[Filtered]", + "passwd" => "[Filtered]", + "password" => "[Filtered]", + "secret" => "[Filtered]", + "user" => %{"password" => "[Filtered]"}, "payments" => [ - %{"yet_another_cc" => "*********"} + %{"yet_another_cc" => "[Filtered]"} ] } end @@ -173,7 +173,7 @@ defmodule Sentry.PlugContextTest do call(conn, []) assert Sentry.Context.get_all().request.data == %{ - "password" => "*********", + "password" => "[Filtered]", "image" => %{ content_type: nil, filename: "my_image.png", diff --git a/test/sentry/scrubber_test.exs b/test/sentry/scrubber_test.exs new file mode 100644 index 000000000..2484d3158 --- /dev/null +++ b/test/sentry/scrubber_test.exs @@ -0,0 +1,116 @@ +defmodule Sentry.ScrubberTest do + use ExUnit.Case, async: true + + alias Sentry.Scrubber + + describe "scrub_map/1" do + test "redacts default sensitive keys" do + assert Scrubber.scrub_map(%{"password" => "hunter2", "username" => "alice"}) == + %{"password" => "[Filtered]", "username" => "alice"} + + assert Scrubber.scrub_map(%{"passwd" => "x", "secret" => "y", "ok" => "z"}) == + %{"passwd" => "[Filtered]", "secret" => "[Filtered]", "ok" => "z"} + end + + test "matches keys case-insensitively" do + assert Scrubber.scrub_map(%{"Password" => "x", "PASSWORD" => "y"}) == + %{"Password" => "[Filtered]", "PASSWORD" => "[Filtered]"} + end + + test "matches keys as substrings (e.g. auth matches Authorization)" do + assert Scrubber.scrub_map(%{"Authorization" => "Bearer xyz"}) == + %{"Authorization" => "[Filtered]"} + + assert Scrubber.scrub_map(%{"X-Auth-Token" => "abc"}) == + %{"X-Auth-Token" => "[Filtered]"} + + assert Scrubber.scrub_map(%{"api_key" => "k", "session_id" => "s"}) == + %{"api_key" => "[Filtered]", "session_id" => "[Filtered]"} + end + + test "matches every term in the spec denylist" do + for term <- [ + "auth", + "token", + "secret", + "password", + "passwd", + "pwd", + "key", + "jwt", + "bearer", + "sso", + "saml", + "csrf", + "xsrf", + "credentials", + "session", + "sid", + "identity" + ] do + assert Scrubber.scrub_map(%{term => "v"}) == %{term => "[Filtered]"} + end + end + + test "redacts credit card-like values" do + assert Scrubber.scrub_map(%{"card" => "4111111111111111"}) == + %{"card" => "[Filtered]"} + end + + test "leaves non-sensitive data untouched" do + data = %{"username" => "alice", "age" => 30, "active" => true} + assert Scrubber.scrub_map(data) == data + end + + test "scrubs atom keys" do + assert Scrubber.scrub_map(%{password: "x", name: "alice"}) == + %{password: "[Filtered]", name: "alice"} + end + + test "recurses into nested maps" do + assert Scrubber.scrub_map(%{"user" => %{"password" => "x", "name" => "alice"}}) == + %{"user" => %{"password" => "[Filtered]", "name" => "alice"}} + end + + test "recurses into lists of maps" do + assert Scrubber.scrub_map(%{"users" => [%{"password" => "x"}, %{"password" => "y"}]}) == + %{"users" => [%{"password" => "[Filtered]"}, %{"password" => "[Filtered]"}]} + end + + test "recurses into structs by converting them to maps" do + struct = %URI{scheme: "https", host: "example.com"} + result = Scrubber.scrub_map(%{"uri" => struct}) + assert result["uri"].host == "example.com" + end + end + + describe "scrub_map/2" do + test "extends the denylist with extra terms" do + assert Scrubber.scrub_map(%{"my_custom" => "v", "ok" => "x"}, ["custom"]) == + %{"my_custom" => "[Filtered]", "ok" => "x"} + end + end + + describe "scrub_string/1" do + test "always returns the placeholder" do + assert Scrubber.scrub_string("anything at all") == "[Filtered]" + end + end + + describe "default_scrubbed_param_keys/0" do + test "returns the spec-conformant default sensitive keys" do + keys = Scrubber.default_scrubbed_param_keys() + assert "auth" in keys + assert "token" in keys + assert "jwt" in keys + assert "session" in keys + assert length(keys) == 17 + end + end + + describe "scrubbed_value/0" do + test "returns the spec-conformant placeholder" do + assert Scrubber.scrubbed_value() == "[Filtered]" + end + end +end From c6f0e606065eafdbbb031abcb998e2f30f255039 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 5 May 2026 10:54:22 +0000 Subject: [PATCH 2/4] fix(plug): Preserve header names when scrubbing values The Sentry data collection spec requires that key/header **names** are always preserved while only **values** for sensitive keys are replaced with the placeholder. The previous default_header_scrubber dropped the 'authorization', 'authentication', and 'cookie' headers entirely, hiding from operators the fact that those headers were even present. Now header names are kept and only their values are scrubbed via Sentry.Scrubber.scrub_map/2 using the spec denylist plus 'cookie'. Co-Authored-By: Claude Opus 4.7 --- lib/sentry/plug_context.ex | 12 ++++++------ test/sentry/plug_context_test.exs | 7 ++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/sentry/plug_context.ex b/lib/sentry/plug_context.ex index 567b96e9b..d9b96e9da 100644 --- a/lib/sentry/plug_context.ex +++ b/lib/sentry/plug_context.ex @@ -153,7 +153,6 @@ defmodule Sentry.PlugContext do end end - @default_scrubbed_header_keys ["authorization", "authentication", "cookie"] @default_plug_request_id_header "x-request-id" @doc false @@ -244,17 +243,18 @@ defmodule Sentry.PlugContext do end @doc """ - Scrubs the headers of a request. + Scrubs the values of sensitive request headers. - The default scrubbed headers are: - - #{Enum.map_join(@default_scrubbed_header_keys, "\n", &"* `#{&1}`")} + Header **names** are always preserved; values for headers whose name matches + the `Sentry.Scrubber` denylist (substring, case-insensitive) are replaced + with `"[Filtered]"`. The `cookie` header is also always scrubbed since its + raw value typically carries session identifiers. """ @spec default_header_scrubber(Plug.Conn.t()) :: map() def default_header_scrubber(conn) do conn.req_headers |> Map.new() - |> Map.drop(@default_scrubbed_header_keys) + |> Sentry.Scrubber.scrub_map(["cookie"]) end @doc """ diff --git a/test/sentry/plug_context_test.exs b/test/sentry/plug_context_test.exs index ed54e3641..facc89897 100644 --- a/test/sentry/plug_context_test.exs +++ b/test/sentry/plug_context_test.exs @@ -148,7 +148,12 @@ defmodule Sentry.PlugContextTest do request_context = Sentry.Context.get_all().request - assert request_context.headers == %{"content-type" => "application/json"} + assert request_context.headers == %{ + "authentication" => "[Filtered]", + "authorization" => "[Filtered]", + "content-type" => "application/json", + "cookie" => "[Filtered]" + } assert request_context.cookies == %{} assert request_context.data == %{ From 2b2475d631fbd6413262186caa0f04ef0729d9da Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 5 May 2026 10:54:54 +0000 Subject: [PATCH 3/4] fix(plug): Preserve cookie names when scrubbing values Per the Sentry data collection spec, key names must be preserved and only values may be replaced. The previous default_cookie_scrubber returned an empty map, hiding which cookies were present on the request. The new default keeps cookie names and replaces every value with '[Filtered]', giving operators visibility while still preventing session leaks. Co-Authored-By: Claude Opus 4.7 --- lib/sentry/plug_context.ex | 15 ++++++++++++--- test/sentry/plug_context_test.exs | 6 +++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/sentry/plug_context.ex b/lib/sentry/plug_context.ex index d9b96e9da..a72a3f421 100644 --- a/lib/sentry/plug_context.ex +++ b/lib/sentry/plug_context.ex @@ -227,11 +227,20 @@ defmodule Sentry.PlugContext do defp apply_fun_with_conn(conn, fun, _default) when is_function(fun, 1), do: fun.(conn) @doc """ - Scrubs **all** cookies off of the request. + Scrubs cookie values while preserving cookie names. + + Every cookie value is replaced with `"[Filtered]"`. Cookie names are + preserved. This conforms to the Sentry data collection spec, which + requires key/header names to be kept and only values to be redacted. + Because cookies frequently carry session identifiers and other + credentials, the safe default is to scrub all values. """ @spec default_cookie_scrubber(Plug.Conn.t()) :: map() - def default_cookie_scrubber(_conn) do - %{} + def default_cookie_scrubber(conn) do + conn + |> Plug.Conn.fetch_cookies() + |> Map.fetch!(:req_cookies) + |> Map.new(fn {name, _value} -> {name, Sentry.Scrubber.scrubbed_value()} end) end @doc """ diff --git a/test/sentry/plug_context_test.exs b/test/sentry/plug_context_test.exs index facc89897..fc7c79feb 100644 --- a/test/sentry/plug_context_test.exs +++ b/test/sentry/plug_context_test.exs @@ -154,7 +154,11 @@ defmodule Sentry.PlugContextTest do "content-type" => "application/json", "cookie" => "[Filtered]" } - assert request_context.cookies == %{} + + assert request_context.cookies == %{ + "regular" => "[Filtered]", + "secret" => "[Filtered]" + } assert request_context.data == %{ "another_cc" => "[Filtered]", From db229437a5f5cdd7b4fb27af4411c5f34ec9c356 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 5 May 2026 10:55:51 +0000 Subject: [PATCH 4/4] fix(plug): Scrub sensitive query string parameters The query_string field of the request interface was sent verbatim, even when callers had configured a url_scrubber. This bypassed the scrubber for the query portion and could leak tokens, passwords, and session ids passed via the URL. Add default_query_string_scrubber/1 which parses the query, redacts values whose keys match the Sentry.Scrubber denylist, and re-encodes. The empty query string short-circuits to avoid an unnecessary parse. Co-Authored-By: Claude Opus 4.7 --- lib/sentry/plug_context.ex | 21 ++++++++++++++++++++- test/sentry/plug_context_test.exs | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/sentry/plug_context.ex b/lib/sentry/plug_context.ex index a72a3f421..a3c56f858 100644 --- a/lib/sentry/plug_context.ex +++ b/lib/sentry/plug_context.ex @@ -176,7 +176,7 @@ defmodule Sentry.PlugContext do url: apply_fun_with_conn(conn, url_scrubber, Plug.Conn.request_url(conn)), method: conn.method, data: apply_fun_with_conn(conn, body_scrubber, %{}), - query_string: conn.query_string, + query_string: default_query_string_scrubber(conn), cookies: apply_fun_with_conn(conn, cookie_scrubber, %{}), headers: apply_fun_with_conn(conn, header_scrubber, %{}), env: %{ @@ -251,6 +251,25 @@ defmodule Sentry.PlugContext do Plug.Conn.request_url(conn) end + @doc """ + Scrubs the query string of a request. + + Parses the query string, redacts values whose key matches the + `Sentry.Scrubber` denylist, and re-encodes it. The original key order + is not preserved (it is not preserved by `Plug.Conn.fetch_query_params/1` + either). + """ + @spec default_query_string_scrubber(Plug.Conn.t()) :: String.t() + def default_query_string_scrubber(%{query_string: ""}), do: "" + + def default_query_string_scrubber(conn) do + conn + |> Plug.Conn.fetch_query_params() + |> Map.fetch!(:query_params) + |> Sentry.Scrubber.scrub_map() + |> URI.encode_query() + end + @doc """ Scrubs the values of sensitive request headers. diff --git a/test/sentry/plug_context_test.exs b/test/sentry/plug_context_test.exs index fc7c79feb..3dce42f79 100644 --- a/test/sentry/plug_context_test.exs +++ b/test/sentry/plug_context_test.exs @@ -117,6 +117,25 @@ defmodule Sentry.PlugContextTest do assert "http://www.example.com/secret-token/****" == Sentry.Context.get_all().request.url end + test "default query string scrubbing redacts sensitive params" do + conn = conn(:get, "/test?password=hunter2&keep=this&api_token=abc") + call(conn, []) + + qs = Sentry.Context.get_all().request.query_string + decoded = URI.decode_query(qs) + + assert decoded["password"] == "[Filtered]" + assert decoded["api_token"] == "[Filtered]" + assert decoded["keep"] == "this" + end + + test "default query string scrubbing leaves an empty query string alone" do + conn = conn(:get, "/test") + call(conn, []) + + assert Sentry.Context.get_all().request.query_string == "" + end + test "allows configuring request id header", %{conn: conn} do conn |> put_resp_header("my-request-id", "abc123")