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/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/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"