From 363b5f215ec36c13f34483c05ac0f0f4185bdcf2 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 07:23:54 +0200 Subject: [PATCH 1/2] 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 * 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 --------- Co-authored-by: Claude Opus 4.7 --- lib/sentry/plug_context.ex | 38 +------ lib/sentry/scrubber.ex | 196 ++++++++++++++++++++++++++++++++++ test/sentry/scrubber_test.exs | 83 ++++++++++++++ 3 files changed, 283 insertions(+), 34 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 8387d4e4..b03517fc 100644 --- a/lib/sentry/plug_context.ex +++ b/lib/sentry/plug_context.ex @@ -153,9 +153,8 @@ defmodule Sentry.PlugContext do end end - @default_scrubbed_param_keys ["password", "passwd", "secret"] - @default_scrubbed_header_keys ["authorization", "authentication", "cookie"] - @scrubbed_value "*********" + @default_scrubbed_param_keys Sentry.Scrubber.default_param_keys() + @default_scrubbed_header_keys Sentry.Scrubber.default_header_keys() @default_plug_request_id_header "x-request-id" @doc false @@ -256,7 +255,7 @@ defmodule Sentry.PlugContext do def default_header_scrubber(conn) do conn.req_headers |> Map.new() - |> Map.drop(@default_scrubbed_header_keys) + |> Sentry.Scrubber.drop_keys() end @doc """ @@ -268,35 +267,6 @@ defmodule Sentry.PlugContext do """ @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 00000000..95133767 --- /dev/null +++ b/lib/sentry/scrubber.ex @@ -0,0 +1,196 @@ +defmodule Sentry.Scrubber do + @moduledoc """ + Shared, framework-agnostic helpers for scrubbing sensitive data before it is + sent to Sentry. + + *Available since v13.1.0.* + + This module owns the default sensitive key lists, the placeholder used in + place of redacted values, the credit-card detection heuristic, and the + recursive map/list traversal used by the rest of the SDK to redact values. + Integrations such as `Sentry.PlugContext`, `Sentry.PlugCapture`, and + `Sentry.LiveViewHook` delegate to the functions exposed here so that + scrubbing rules live in a single place. + + ## Defaults + + The default sensitive *parameter* keys (used for body params, query strings, + and arbitrary maps) are: + + #{Enum.map_join(["password", "passwd", "secret"], "\n", &" * `\"#{&1}\"`")} + + The default sensitive *header* keys are: + + #{Enum.map_join(["authorization", "authentication", "cookie"], "\n", &" * `\"#{&1}\"`")} + + Values matching a credit-card-like pattern (13–16 digits, optionally + separated by spaces or dashes) are also replaced with the placeholder. + + ## Custom scrubbing + + All public functions accept an optional `:keys` option that overrides the + default list of sensitive keys. This makes it possible to compose custom + scrubbers on top of the defaults: + + def scrub(map) do + map + |> Sentry.Scrubber.scrub_map(keys: ["password", "api_key"]) + |> Map.drop(["internal_notes"]) + end + """ + + @moduledoc since: "13.1.0" + + @default_scrubbed_param_keys ["password", "passwd", "secret"] + @default_scrubbed_header_keys ["authorization", "authentication", "cookie"] + @scrubbed_value "*********" + + @typedoc """ + Options accepted by the scrubbing functions in this module. + """ + @type option :: {:keys, [String.t()]} + + @doc """ + The placeholder string used to replace scrubbed values. + """ + @spec scrubbed_value() :: String.t() + def scrubbed_value, do: @scrubbed_value + + @doc """ + Returns the default list of sensitive parameter keys. + """ + @spec default_param_keys() :: [String.t()] + def default_param_keys, do: @default_scrubbed_param_keys + + @doc """ + Returns the default list of sensitive header keys. + """ + @spec default_header_keys() :: [String.t()] + def default_header_keys, do: @default_scrubbed_header_keys + + @doc """ + Recursively scrubs a map. + + Any value whose key is in the configured sensitive key list is replaced with + the placeholder. Values matching the credit-card pattern are also replaced. + Nested maps, structs, and lists are scrubbed recursively. + + ## Options + + * `:keys` - the list of sensitive keys to redact. Defaults to + `default_param_keys/0`. + """ + @spec scrub_map(map(), [option()]) :: map() + def scrub_map(map, opts \\ []) when is_map(map) do + keys = Keyword.get(opts, :keys, @default_scrubbed_param_keys) + do_scrub_map(map, keys) + end + + @doc """ + Recursively scrubs a list, applying the same rules as `scrub_map/2` to any + maps it contains. + + ## Options + + See `scrub_map/2`. + """ + @spec scrub_list(list(), [option()]) :: list() + def scrub_list(list, opts \\ []) when is_list(list) do + keys = Keyword.get(opts, :keys, @default_scrubbed_param_keys) + do_scrub_list(list, keys) + end + + @doc """ + Drops sensitive keys from a flat map. + + This is the strategy used for HTTP headers, where the sensitive value should + not appear in the payload at all. + + ## Options + + * `:keys` - the list of sensitive keys to drop. Defaults to + `default_header_keys/0`. + """ + @spec drop_keys(map(), [option()]) :: map() + def drop_keys(map, opts \\ []) when is_map(map) do + keys = Keyword.get(opts, :keys, @default_scrubbed_header_keys) + Map.drop(map, keys) + end + + @doc """ + Scrubs the query string portion of a URL, replacing the value of any + sensitive query parameter with the placeholder. URLs without a query string + are returned unchanged. + + ## Options + + See `scrub_map/2`. + """ + @spec scrub_url(String.t(), [option()]) :: String.t() + def scrub_url(url, opts \\ []) when is_binary(url) do + case URI.parse(url) do + %URI{query: nil} -> + url + + %URI{query: ""} -> + url + + %URI{query: query} = uri -> + URI.to_string(%{uri | query: scrub_query_string(query, opts)}) + end + end + + @doc """ + Scrubs an `application/x-www-form-urlencoded` query string, replacing the + value of any sensitive parameter with the placeholder. + + ## Options + + See `scrub_map/2`. + """ + @spec scrub_query_string(String.t(), [option()]) :: String.t() + def scrub_query_string(query, opts \\ []) when is_binary(query) do + keys = Keyword.get(opts, :keys, @default_scrubbed_param_keys) + + query + |> URI.query_decoder() + |> Enum.map(fn {key, value} -> + cond do + key in keys -> {key, @scrubbed_value} + is_binary(value) and value =~ credit_card_regex() -> {key, @scrubbed_value} + true -> {key, value} + end + end) + |> URI.encode_query() + end + + ## Internal recursion + + defp do_scrub_map(map, keys) do + Map.new(map, fn {key, value} -> {key, scrub_value(key, value, keys)} end) + end + + defp do_scrub_list(list, keys) do + Enum.map(list, fn value -> + cond do + is_struct(value) -> value |> Map.from_struct() |> do_scrub_map(keys) + is_map(value) -> do_scrub_map(value, keys) + is_list(value) -> do_scrub_list(value, keys) + true -> value + end + end) + end + + defp scrub_value(key, value, keys) do + cond do + key in keys -> @scrubbed_value + is_binary(value) and value =~ credit_card_regex() -> @scrubbed_value + is_struct(value) -> value |> Map.from_struct() |> do_scrub_map(keys) + is_map(value) -> do_scrub_map(value, keys) + is_list(value) -> do_scrub_list(value, keys) + true -> value + end + end + + defp credit_card_regex, do: ~r/^(?:\d[ -]*?){13,16}$/ +end diff --git a/test/sentry/scrubber_test.exs b/test/sentry/scrubber_test.exs new file mode 100644 index 00000000..627ccade --- /dev/null +++ b/test/sentry/scrubber_test.exs @@ -0,0 +1,83 @@ +defmodule Sentry.ScrubberTest do + use ExUnit.Case, async: true + + alias Sentry.Scrubber + + describe "scrub_map/2" do + test "redacts sensitive top-level keys" do + assert Scrubber.scrub_map(%{"password" => "x", "ok" => 1}) == + %{"password" => "*********", "ok" => 1} + end + + test "recurses into nested maps" do + assert Scrubber.scrub_map(%{"outer" => %{"secret" => "shh"}}) == + %{"outer" => %{"secret" => "*********"}} + end + + test "recurses into lists of maps" do + assert Scrubber.scrub_map(%{"items" => [%{"passwd" => "1"}, %{"ok" => 2}]}) == + %{"items" => [%{"passwd" => "*********"}, %{"ok" => 2}]} + end + + test "redacts credit-card-shaped values" do + assert Scrubber.scrub_map(%{"cc" => "4111111111111111"}) == + %{"cc" => "*********"} + end + + test "scrubs structs by converting them to maps" do + uri = URI.parse("http://example.com") + assert %{"u" => scrubbed} = Scrubber.scrub_map(%{"u" => uri}) + assert is_map(scrubbed) + refute Map.has_key?(scrubbed, :__struct__) + end + + test "respects custom :keys option" do + assert Scrubber.scrub_map(%{"api_key" => "x", "password" => "y"}, keys: ["api_key"]) == + %{"api_key" => "*********", "password" => "y"} + end + + test "leaves non-sensitive values untouched" do + data = %{"name" => "alice", "age" => 30} + assert Scrubber.scrub_map(data) == data + end + end + + describe "drop_keys/2" do + test "drops sensitive header keys by default" do + assert Scrubber.drop_keys(%{"authorization" => "Bearer x", "x-trace" => "1"}) == + %{"x-trace" => "1"} + end + + test "respects custom :keys option" do + assert Scrubber.drop_keys(%{"x-secret" => "1", "x-trace" => "1"}, keys: ["x-secret"]) == + %{"x-trace" => "1"} + end + end + + describe "scrub_url/2" do + test "redacts sensitive query parameters" do + url = "http://example.com/foo?password=secret&visible=ok" + scrubbed = Scrubber.scrub_url(url) + refute scrubbed =~ "secret" + assert scrubbed =~ "visible=ok" + end + + test "passes through URLs without query strings" do + assert Scrubber.scrub_url("http://example.com/foo") == "http://example.com/foo" + end + + test "preserves scheme, host, port, and path" do + scrubbed = Scrubber.scrub_url("https://example.com:8443/p?secret=x") + assert scrubbed =~ "https://example.com:8443/p?" + refute scrubbed =~ "secret=x" + end + end + + describe "scrub_query_string/2" do + test "redacts sensitive params" do + scrubbed = Scrubber.scrub_query_string("password=hunter2&visible=ok") + refute scrubbed =~ "hunter2" + assert scrubbed =~ "visible=ok" + end + end +end From 9c47687715f6afdcbad09874d3f6bef542ce232f Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 6 May 2026 10:18:24 +0000 Subject: [PATCH 2/2] fix(live_view): scrub sensitive data from LiveView breadcrumbs Sentry.LiveViewHook previously stored raw event params, handle_params params, and URIs directly in breadcrumbs. Form submissions over the LiveView WebSocket frequently contain passwords, tokens, and other secrets, which were forwarded to Sentry unredacted. The hook now passes breadcrumb data through Sentry.Scrubber.scrub_map/2 and URIs through Sentry.Scrubber.scrub_url/2 before adding them to the breadcrumb trail. Users can override the scrubber by passing a {module, function, args} tuple via on_mount opts, mirroring the override mechanism already provided by Sentry.PlugCapture: on_mount {Sentry.LiveViewHook, scrubber: {MyApp.Scrubber, :scrub, []}} Co-Authored-By: Claude Opus 4.7 --- lib/sentry/live_view_hook.ex | 96 ++++++++++++++++++++++++++--- test/sentry/live_view_hook_test.exs | 70 ++++++++++++++++++++- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/lib/sentry/live_view_hook.ex b/lib/sentry/live_view_hook.ex index 31144377..fea52a43 100644 --- a/lib/sentry/live_view_hook.ex +++ b/lib/sentry/live_view_hook.ex @@ -39,6 +39,28 @@ if Code.ensure_loaded?(Phoenix.LiveView) do You can also set this in your `MyAppWeb` module, so that all LiveViews that `use MyAppWeb, :live_view` will have this hook. + + ## Scrubbing Sensitive Data + + *Available since v13.1.0.* + + LiveView events and `handle_params` calls frequently carry user-submitted + form data, which may include passwords or other sensitive values. Before + storing this data in breadcrumbs, this hook scrubs it using + `Sentry.Scrubber.scrub_map/2`. URI query strings stored in breadcrumbs are + scrubbed via `Sentry.Scrubber.scrub_url/2`. + + To customize the scrubbing logic, pass a `:scrubber` option when attaching + the hook. The scrubber must be a `{module, function, args}` tuple; the + breadcrumb `data` map is prepended to `args` before invoking the function, + which must return a map. + + on_mount {Sentry.LiveViewHook, scrubber: {MyApp.Scrubber, :scrub, []}} + + The default scrubber is equivalent to: + + {Sentry.LiveViewHook, :default_scrubber, []} + """ @moduledoc since: "10.5.0" @@ -49,16 +71,73 @@ if Code.ensure_loaded?(Phoenix.LiveView) do require Logger + @scrubber_pdict_key {__MODULE__, :scrubber} + # See also: # https://develop.sentry.dev/sdk/event-payloads/request/ @doc false - @spec on_mount(:default, map() | :not_mounted_at_router, map(), struct()) :: {:cont, struct()} - def on_mount(:default, %{} = params, _session, socket), do: on_mount(params, socket) - def on_mount(:default, :not_mounted_at_router, _session, socket), do: {:cont, socket} + @spec on_mount(:default | keyword(), map() | :not_mounted_at_router, map(), struct()) :: + {:cont, struct()} + def on_mount(:default, params, session, socket), + do: on_mount([], params, session, socket) + + def on_mount(opts, %{} = params, _session, socket) when is_list(opts) do + store_scrubber(opts) + on_mount(params, socket) + end + + def on_mount(opts, :not_mounted_at_router, _session, socket) when is_list(opts) do + store_scrubber(opts) + {:cont, socket} + end + + @doc """ + The default scrubber applied to LiveView breadcrumb data. + + Delegates to `Sentry.Scrubber.scrub_map/2` with the default sensitive + parameter keys. + """ + @doc since: "13.1.0" + @spec default_scrubber(map()) :: map() + def default_scrubber(data) when is_map(data) do + Sentry.Scrubber.scrub_map(data) + end ## Helpers + defp store_scrubber(opts) do + case Keyword.get(opts, :scrubber, {__MODULE__, :default_scrubber, []}) do + {mod, fun, args} = scrubber when is_atom(mod) and is_atom(fun) and is_list(args) -> + Process.put(@scrubber_pdict_key, scrubber) + + other -> + raise ArgumentError, + "expected :scrubber to be a {module, function, args} tuple, got: #{inspect(other)}" + end + end + + defp scrub(data) when is_map(data) do + {mod, fun, args} = + Process.get(@scrubber_pdict_key, {__MODULE__, :default_scrubber, []}) + + case apply(mod, fun, [data | args]) do + result when is_map(result) -> + result + + other -> + Logger.error( + "Sentry.LiveViewHook scrubber returned non-map value: #{inspect(other)}; " <> + "falling back to redacted data", + event_source: :logger + ) + + %{} + end + end + + defp scrub_uri(uri) when is_binary(uri), do: Sentry.Scrubber.scrub_url(uri) + defp on_mount(params, %Phoenix.LiveView.Socket{} = socket) do Context.set_extra_context(%{socket_id: socket.id}) Context.set_request_context(%{url: socket.host_uri}) @@ -66,7 +145,7 @@ if Code.ensure_loaded?(Phoenix.LiveView) do Context.add_breadcrumb(%{ category: "web.live_view.mount", message: "Mounted live view", - data: params + data: scrub(params) }) if uri = get_connect_info_if_root(socket, :uri) do @@ -105,7 +184,7 @@ if Code.ensure_loaded?(Phoenix.LiveView) do Context.add_breadcrumb(%{ category: "web.live_view.event", message: inspect(event), - data: %{event: event, params: params} + data: scrub(%{event: event, params: params}) }) {:cont, socket} @@ -121,13 +200,14 @@ if Code.ensure_loaded?(Phoenix.LiveView) do end defp handle_params_hook(params, uri, socket) do + scrubbed_uri = scrub_uri(uri) Context.set_extra_context(%{socket_id: socket.id}) - Context.set_request_context(%{url: uri}) + Context.set_request_context(%{url: scrubbed_uri}) Context.add_breadcrumb(%{ category: "web.live_view.params", - message: "#{uri}", - data: %{params: params, uri: uri} + message: "#{scrubbed_uri}", + data: scrub(%{params: params, uri: scrubbed_uri}) }) {:cont, socket} diff --git a/test/sentry/live_view_hook_test.exs b/test/sentry/live_view_hook_test.exs index ad95e484..06e93faf 100644 --- a/test/sentry/live_view_hook_test.exs +++ b/test/sentry/live_view_hook_test.exs @@ -14,7 +14,7 @@ defmodule SentryTest.Live do {:ok, socket} end - def handle_event("refresh", _params, socket) do + def handle_event(_event, _params, socket) do {:noreply, socket} end @@ -23,6 +23,22 @@ defmodule SentryTest.Live do end end +defmodule SentryTest.CustomScrubber do + def scrub(data), do: Sentry.Scrubber.scrub_map(data, keys: ["api_key"]) +end + +defmodule SentryTest.CustomScrubberLive do + use Phoenix.LiveView + + on_mount {Sentry.LiveViewHook, scrubber: {SentryTest.CustomScrubber, :scrub, []}} + + def render(assigns), do: ~H"

custom

" + + def mount(_params, _session, socket), do: {:ok, socket} + + def handle_event(_event, _params, socket), do: {:noreply, socket} +end + defmodule SentryTest.LiveComponent do use Phoenix.LiveComponent @@ -66,6 +82,7 @@ defmodule SentryTest.Router do scope "/" do get "/dead_test", SentryTest.PageController, :page live "/hook_test", SentryTest.Live + live "/custom_scrubber", SentryTest.CustomScrubberLive end end @@ -164,6 +181,57 @@ defmodule Sentry.LiveViewHookTest do assert Logger.metadata() == [] end + test "scrubs sensitive data from breadcrumbs by default", %{conn: conn} do + {:ok, view, _html} = live(conn, "/hook_test") + + render_hook(view, :login, %{ + "email" => "user@example.com", + "password" => "supersecret", + "card" => "4111111111111111" + }) + + [event_breadcrumb | _] = get_sentry_context(view).breadcrumbs + + assert event_breadcrumb.data == %{ + event: "login", + params: %{ + "email" => "user@example.com", + "password" => "*********", + "card" => "*********" + } + } + end + + test "scrubs sensitive query params from URI in handle_params breadcrumb", %{conn: conn} do + {:ok, view, _html} = live(conn, "/hook_test?password=supersecret&visible=ok") + + breadcrumbs = get_sentry_context(view).breadcrumbs + params_breadcrumb = Enum.find(breadcrumbs, &(&1.category == "web.live_view.params")) + + refute params_breadcrumb.data.uri =~ "supersecret" + assert params_breadcrumb.data.uri =~ "password=%2A%2A%2A%2A%2A%2A%2A%2A%2A" + assert params_breadcrumb.data.uri =~ "visible=ok" + end + + test "uses a user-supplied scrubber when configured", %{conn: conn} do + {:ok, view, _html} = live(conn, "/custom_scrubber") + + render_hook(view, :submit, %{ + "api_key" => "topsecret", + "other" => "not-redacted" + }) + + [event_breadcrumb | _] = get_sentry_context(view).breadcrumbs + + assert event_breadcrumb.data == %{ + event: "submit", + params: %{ + "api_key" => "*********", + "other" => "not-redacted" + } + } + end + defp get_sentry_context(view) do {:dictionary, pdict} = Process.info(view.pid, :dictionary)