From 8ce1b58692974c7ec31d2aa5e3d1c241d08034bf Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 24 Sep 2026 11:17:49 +0000 Subject: [PATCH] feat(scrubber)!: replace redacted values with [Filtered] --- lib/sentry/scrubber.ex | 6 ++- test/plug_capture_test.exs | 8 ++-- .../integrations/oban/error_reporter_test.exs | 2 +- test/sentry/live_view_hook_test.exs | 8 ++-- test/sentry/plug_context_test.exs | 26 +++++------ .../scrubber/stacktrace_scrubber_test.exs | 12 ++--- test/sentry/scrubber_test.exs | 44 +++++++++---------- .../tracing/tests/param_scrubbing.spec.ts | 7 +-- .../tracing/tests/scrubbing_fixtures.ts | 6 +-- 9 files changed, 61 insertions(+), 58 deletions(-) diff --git a/lib/sentry/scrubber.ex b/lib/sentry/scrubber.ex index d3db2bed..502c0007 100644 --- a/lib/sentry/scrubber.ex +++ b/lib/sentry/scrubber.ex @@ -128,7 +128,7 @@ defmodule Sentry.Scrubber do alias Sentry.Callback - @scrubbed_value "*********" + @scrubbed_value "[Filtered]" @scrubber_pdict_key {__MODULE__, :scrubber} @scrubber_names [:body_scrubber, :header_scrubber, :cookie_scrubber, :url_scrubber] @@ -245,7 +245,9 @@ defmodule Sentry.Scrubber do ] @doc """ - The placeholder string used to replace scrubbed values. + Returns the placeholder that replaces redacted values: `"[Filtered]"`. + + *Changed in 14.0.0:* returns `"[Filtered]"` instead of `"*********"`. """ @doc since: "13.1.0" @spec scrubbed_value() :: String.t() diff --git a/test/plug_capture_test.exs b/test/plug_capture_test.exs index ed82e06f..a48119dd 100644 --- a/test/plug_capture_test.exs +++ b/test/plug_capture_test.exs @@ -287,7 +287,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]"}) refute exception.value =~ ~s(query_string: "password=secret"), "query_string leaked into exception value: #{exception.value}" @@ -313,7 +313,7 @@ defmodule Sentry.PlugCaptureTest do assert [exception] = event.exception # Sensitive data is still scrubbed (proves the scrubber ran)... - assert exception.value =~ ~s(params: %{"password" => "*********"}) + assert exception.value =~ ~s(params: %{"password" => "[Filtered]"}) # ...routing metadata in the allow-list survives... assert exception.value =~ "phoenix_controller" @@ -411,7 +411,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]"}) refute exception.value =~ ~s(query_string: "password=secret") assert log =~ ~r/domain=(\w+\.)*sentry \[error\]\s+:scrubber callback failed/ @@ -430,7 +430,7 @@ defmodule Sentry.PlugCaptureTest do assert [exception] = event.exception assert exception.type == "Phoenix.ActionClauseError" - assert exception.value =~ ~s(%{"password" => "*********"}) + assert exception.value =~ ~s(%{"password" => "[Filtered]"}) refute exception.value =~ "secret" end diff --git a/test/sentry/integrations/oban/error_reporter_test.exs b/test/sentry/integrations/oban/error_reporter_test.exs index eb681bec..2b90c39a 100644 --- a/test/sentry/integrations/oban/error_reporter_test.exs +++ b/test/sentry/integrations/oban/error_reporter_test.exs @@ -399,7 +399,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do event = assert_sentry_report(:event, fingerprint: [@worker_as_string, "{{ default }}"]) - assert event.extra[:args]["password"] == "*********" + assert event.extra[:args]["password"] == "[Filtered]" assert event.extra[:args]["id"] == "123" assert event.extra[:args]["entity"] == "user" end diff --git a/test/sentry/live_view_hook_test.exs b/test/sentry/live_view_hook_test.exs index 60d012c5..f3c29593 100644 --- a/test/sentry/live_view_hook_test.exs +++ b/test/sentry/live_view_hook_test.exs @@ -238,8 +238,8 @@ defmodule Sentry.LiveViewHookTest do event: "login", params: %{ "email" => "user@example.com", - "password" => "*********", - "card" => "*********" + "password" => "[Filtered]", + "card" => "[Filtered]" } } end @@ -250,7 +250,7 @@ defmodule Sentry.LiveViewHookTest do breadcrumbs = get_sentry_context(view).breadcrumbs mount_breadcrumb = Enum.find(breadcrumbs, &(&1.category == "web.live_view.mount")) - assert mount_breadcrumb.data == %{"password" => "*********", "visible" => "ok"} + assert mount_breadcrumb.data == %{"password" => "[Filtered]", "visible" => "ok"} end test "scrubs sensitive query params from URI in handle_params breadcrumb", %{conn: conn} do @@ -329,7 +329,7 @@ defmodule Sentry.LiveViewHookTest do assert event_breadcrumb.data == %{ event: "submit", params: %{ - "api_key" => "*********", + "api_key" => "[Filtered]", "other" => "not-redacted" } } diff --git a/test/sentry/plug_context_test.exs b/test/sentry/plug_context_test.exs index 16cb3d18..a55094f0 100644 --- a/test/sentry/plug_context_test.exs +++ b/test/sentry/plug_context_test.exs @@ -27,7 +27,7 @@ defmodule Sentry.PlugContextTest do def query_url_scrubber(conn) do conn |> Plug.Conn.request_url() - |> String.replace(~r/api_key=[^&]+/, "api_key=*********") + |> String.replace(~r/api_key=[^&]+/, "api_key=[Filtered]") end def remote_address_reader(conn) do @@ -250,10 +250,10 @@ defmodule Sentry.PlugContextTest do conn = conn(:get, "/test?api_key=sk_live_secret123&hello=world") call(conn, url_scrubber: {__MODULE__, :query_url_scrubber}) - assert "http://www.example.com/test?api_key=*********&hello=world" == + assert "http://www.example.com/test?api_key=[Filtered]&hello=world" == Sentry.Context.get_all().request.url - assert "api_key=*********&hello=world" == Sentry.Context.get_all().request.query_string + assert "api_key=[Filtered]&hello=world" == Sentry.Context.get_all().request.query_string end test "url_scrubber: nil falls back to the request URL unchanged" do @@ -301,16 +301,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 @@ -322,7 +322,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", @@ -344,7 +344,7 @@ defmodule Sentry.PlugContextTest do assert scrubbed.cookies == %{} assert scrubbed.req_headers == [{"x-keep", "yes"}] - assert scrubbed.params == %{"password" => "*********", "ok" => "fine"} + assert scrubbed.params == %{"password" => "[Filtered]", "ok" => "fine"} end test "honors a custom body_scrubber when scrub/1 is called downstream", diff --git a/test/sentry/scrubber/stacktrace_scrubber_test.exs b/test/sentry/scrubber/stacktrace_scrubber_test.exs index 42d4c211..327c326d 100644 --- a/test/sentry/scrubber/stacktrace_scrubber_test.exs +++ b/test/sentry/scrubber/stacktrace_scrubber_test.exs @@ -21,11 +21,11 @@ defmodule Sentry.Scrubber.StacktraceScrubberTest do assert [scrubbed_conn, scrubbed_map, "plain", 42] = StacktraceScrubber.scrub_args(args) # the conn is scrubbed as a conn - assert scrubbed_conn.params == %{"password" => "*********", "name" => "Alice"} + assert scrubbed_conn.params == %{"password" => "[Filtered]", "name" => "Alice"} assert scrubbed_conn.req_headers == [{"x-keep", "yes"}] # a plain map is key-scrubbed - assert scrubbed_map == %{"password" => "*********", "ok" => "fine"} + assert scrubbed_map == %{"password" => "[Filtered]", "ok" => "fine"} end test "scrubs a non-Plug.Conn struct's fields but keeps its type" do @@ -38,8 +38,8 @@ defmodule Sentry.Scrubber.StacktraceScrubberTest do assert is_struct(scrubbed, Card) # ...while its fields are scrubbed by value (credit-card heuristic) and by # name (the atom key :secret matches the sensitive-key list). - assert scrubbed.card_number == "*********" - assert scrubbed.secret == "*********" + assert scrubbed.card_number == "[Filtered]" + assert scrubbed.secret == "[Filtered]" assert scrubbed.name == "Alice" end @@ -59,7 +59,7 @@ defmodule Sentry.Scrubber.StacktraceScrubberTest do # the standalone params arg is scrubbed independently (default keys only): the # "password" value is redacted, but "ssn" (not a default key) is left intact — # proving the conn's scrubbed params are NOT mirrored onto it. - assert scrubbed_params == %{"password" => "*********", "ssn" => "123-45-6789"} + assert scrubbed_params == %{"password" => "[Filtered]", "ssn" => "123-45-6789"} end end @@ -71,7 +71,7 @@ defmodule Sentry.Scrubber.StacktraceScrubberTest do assert %FunctionClauseError{args: [scrubbed_conn, "x"]} = StacktraceScrubber.scrub(exception) - assert scrubbed_conn.params == %{"password" => "*********", "name" => "Alice"} + assert scrubbed_conn.params == %{"password" => "[Filtered]", "name" => "Alice"} end test "applies a custom args_scrubber callback to the exception's args" do diff --git a/test/sentry/scrubber_test.exs b/test/sentry/scrubber_test.exs index ece40512..6fc04275 100644 --- a/test/sentry/scrubber_test.exs +++ b/test/sentry/scrubber_test.exs @@ -36,14 +36,14 @@ defmodule Sentry.ScrubberTest do _ = Scrubber.new(body_scrubber: fn _conn -> %{"marker" => "unregistered"} end) conn = %Plug.Conn{params: %{"password" => "hunter2"}} - assert Scrubber.scrub(conn).params == %{"password" => "*********"} + assert Scrubber.scrub(conn).params == %{"password" => "[Filtered]"} end end describe "scrub/2" do test "redacts sensitive top-level keys" do assert Scrubber.scrub(%{"password" => "x", "ok" => 1}) == - %{"password" => "*********", "ok" => 1} + %{"password" => "[Filtered]", "ok" => 1} end test "leaves a key that is not valid UTF-8 alone rather than failing on it" do @@ -52,7 +52,7 @@ defmodule Sentry.ScrubberTest do end test "redacts a key that contains a sensitive term in any casing" do - assert Scrubber.scrub(%{"X-Auth-Token" => "x"}) == %{"X-Auth-Token" => "*********"} + assert Scrubber.scrub(%{"X-Auth-Token" => "x"}) == %{"X-Auth-Token" => "[Filtered]"} end test "leaves a key containing no sensitive term untouched" do @@ -61,22 +61,22 @@ defmodule Sentry.ScrubberTest do test "redacts sensitive keys given as atoms (e.g. struct fields)" do assert Scrubber.scrub(%{password: "x", ok: 1}) == - %{password: "*********", ok: 1} + %{password: "[Filtered]", ok: 1} end test "recurses into nested maps" do assert Scrubber.scrub(%{"outer" => %{"secret" => "shh"}}) == - %{"outer" => %{"secret" => "*********"}} + %{"outer" => %{"secret" => "[Filtered]"}} end test "recurses into lists of maps" do assert Scrubber.scrub(%{"items" => [%{"passwd" => "1"}, %{"ok" => 2}]}) == - %{"items" => [%{"passwd" => "*********"}, %{"ok" => 2}]} + %{"items" => [%{"passwd" => "[Filtered]"}, %{"ok" => 2}]} end test "redacts credit-card-shaped values" do assert Scrubber.scrub(%{"cc" => "4111111111111111"}) == - %{"cc" => "*********"} + %{"cc" => "[Filtered]"} end test "scrubs structs by converting them to maps" do @@ -88,7 +88,7 @@ defmodule Sentry.ScrubberTest do test "respects custom :keys option" do assert Scrubber.scrub(%{"api_key" => "x", "password" => "y"}, keys: ["api_key"]) == - %{"api_key" => "*********", "password" => "y"} + %{"api_key" => "[Filtered]", "password" => "y"} end test "leaves non-sensitive values untouched" do @@ -222,8 +222,8 @@ defmodule Sentry.ScrubberTest do test "leaves the placeholder readable rather than percent-encoding it" do scrubbed = Scrubber.scrub_query_string("password=hunter2") - assert scrubbed == "password=#{Scrubber.scrubbed_value()}" - refute scrubbed =~ "%2A" + assert scrubbed == "password=[Filtered]" + refute scrubbed =~ "%5B" end test "passes through params it keeps byte for byte" do @@ -323,8 +323,8 @@ defmodule Sentry.ScrubberTest do test "scrubs params with default sensitive keys", %{scrubbed: scrubbed} do assert scrubbed.params == %{ - "user" => %{"email" => "alice@example.com", "password" => "*********"}, - "_csrf_token" => "*********" + "user" => %{"email" => "alice@example.com", "password" => "[Filtered]"}, + "_csrf_token" => "[Filtered]" } end @@ -338,7 +338,7 @@ defmodule Sentry.ScrubberTest do test "scrubs query_params with default sensitive keys", %{scrubbed: scrubbed} do assert scrubbed.query_params == %{ "redirect_to" => "/dashboard", - "secret" => "*********" + "secret" => "[Filtered]" } end @@ -367,7 +367,7 @@ defmodule Sentry.ScrubberTest do end test "scrubs path_params with default sensitive keys", %{scrubbed: scrubbed} do - assert scrubbed.path_params == %{"id" => "42", "secret" => "*********"} + assert scrubbed.path_params == %{"id" => "42", "secret" => "[Filtered]"} end test "scrubs sensitive params out of query_string", %{scrubbed: scrubbed} do @@ -447,7 +447,7 @@ defmodule Sentry.ScrubberTest do scrubbed = Scrubber.scrub(conn, assigns: :clear, private: :clear) # default fields still scrubbed - assert scrubbed.params == %{"password" => "*********"} + assert scrubbed.params == %{"password" => "[Filtered]"} # overridden fields cleared wholesale assert scrubbed.assigns == %{} assert scrubbed.private == %{} @@ -462,10 +462,10 @@ defmodule Sentry.ScrubberTest do scrubbed = Scrubber.scrub(conn, body_params: :params, query_params: :params) assert scrubbed.body_params == %{ - "user" => %{"password" => "*********", "email" => "a@b.c"} + "user" => %{"password" => "[Filtered]", "email" => "a@b.c"} } - assert scrubbed.query_params == %{"secret" => "*********", "page" => "1"} + assert scrubbed.query_params == %{"secret" => "[Filtered]", "page" => "1"} end test ":params override leaves %Plug.Conn.Unfetched{} untouched" do @@ -635,7 +635,7 @@ defmodule Sentry.ScrubberTest do scrubbed = Scrubber.scrub(conn) assert scrubbed.cookies == %{} - assert scrubbed.params == %{"password" => "*********", "name" => "Alice"} + assert scrubbed.params == %{"password" => "[Filtered]", "name" => "Alice"} assert is_list(scrubbed.req_headers) assert {"x-keep", "yes"} in scrubbed.req_headers refute Enum.any?(scrubbed.req_headers, fn {k, _v} -> k == "authorization" end) @@ -656,7 +656,7 @@ defmodule Sentry.ScrubberTest do # The current process never registered a scrubber, so scrub/1 lazily # initializes defaults instead of inheriting the task's marker scrubber. scrubbed = Scrubber.scrub(conn) - assert scrubbed.params == %{"password" => "*********"} + assert scrubbed.params == %{"password" => "[Filtered]"} end test "validates the opts shape on put" do @@ -679,7 +679,7 @@ defmodule Sentry.ScrubberTest do assert is_struct(scrubbed, Plug.Conn) assert scrubbed.cookies == %{} assert scrubbed.req_headers == [{"x-keep", "yes"}] - assert scrubbed.params == %{"password" => "*********"} + assert scrubbed.params == %{"password" => "[Filtered]"} end test "honors a registered conn scrubber for the Plug.Conn dispatch path" do @@ -696,7 +696,7 @@ defmodule Sentry.ScrubberTest do test "scrubs a plain map with default sensitive keys" do assert Scrubber.scrub(%{"password" => "x", "ok" => 1}) == - %{"password" => "*********", "ok" => 1} + %{"password" => "[Filtered]", "ok" => 1} end test "returns integers, atoms, binaries, and lists unchanged" do @@ -723,7 +723,7 @@ defmodule Sentry.ScrubberTest do refute is_struct(scrubbed) # Once the struct is a map, value-based heuristics reach its fields: the # credit-card-shaped value is redacted, non-sensitive data is preserved. - assert scrubbed.card_number == "*********" + assert scrubbed.card_number == "[Filtered]" assert scrubbed.name == "Alice" end end diff --git a/test_integrations/tracing/tests/param_scrubbing.spec.ts b/test_integrations/tracing/tests/param_scrubbing.spec.ts index bf6bc44f..470b5e81 100644 --- a/test_integrations/tracing/tests/param_scrubbing.spec.ts +++ b/test_integrations/tracing/tests/param_scrubbing.spec.ts @@ -28,6 +28,7 @@ if (REAL_DSN && !process.env.SENTRY_DSN) { const RUN_ID = process.env.SENTRY_E2E_RUN_ID ?? `run-${Date.now()}`; const VALUES = probeValues(RUN_ID); const EXPECTED_QUERY = scrubbedQuery(VALUES, RUN_ID); +const ENCODED_PLACEHOLDER = encodeURIComponent(PLACEHOLDER); async function openDemoPage(page: Page): Promise { await page.goto(`${PHOENIX_URL}${demoPagePath(VALUES, RUN_ID)}`); @@ -181,10 +182,10 @@ test.describe("parameter scrubbing", () => { expect(conn.queryString).toBe(EXPECTED_QUERY); expect(conn.queryString, "the placeholder was form-encoded").not.toContain( - "%2A" + ENCODED_PLACEHOLDER ); - expect(event.request.query_string).not.toContain("%2A"); - expect(event.request.url).not.toContain("%2A"); + expect(event.request.query_string).not.toContain(ENCODED_PLACEHOLDER); + expect(event.request.url).not.toContain(ENCODED_PLACEHOLDER); expect(conn.queryString).toContain("note=a%20b~c"); expect(conn.queryString).toContain("&flag&"); diff --git a/test_integrations/tracing/tests/scrubbing_fixtures.ts b/test_integrations/tracing/tests/scrubbing_fixtures.ts index d0b60667..edb57646 100644 --- a/test_integrations/tracing/tests/scrubbing_fixtures.ts +++ b/test_integrations/tracing/tests/scrubbing_fixtures.ts @@ -1,6 +1,6 @@ import type { SentryEvent } from "./helpers"; -export const PLACEHOLDER = "*********"; +export const PLACEHOLDER = "[Filtered]"; export interface ProbeValues { pathSecret: string; @@ -66,10 +66,10 @@ function probeQuery(values: ProbeValues, runId: string): string { const CONN_FIELD_PATTERNS = { request_path: /request_path: "([^"]*)"/, - path_info: /path_info: (\[[^\]]*\])/, + path_info: /path_info: (\[\s*(?:"[^"]*"(?:,\s*)?)*\])/, path_params: /path_params: (%\{[^}]*\})/, query_string: /query_string: "([^"]*)"/, - script_name: /script_name: (\[[^\]]*\])/, + script_name: /script_name: (\[\s*(?:"[^"]*"(?:,\s*)?)*\])/, } as const; export interface ConnFields {