Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 88 additions & 8 deletions lib/sentry/live_view_hook.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -49,24 +71,81 @@ 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})

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
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
38 changes: 4 additions & 34 deletions lib/sentry/plug_context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 """
Expand All @@ -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
196 changes: 196 additions & 0 deletions lib/sentry/scrubber.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading