defmodule Codex.AppServer.Sanitizer do @moduledoc false import Kernel, except: [inspect: 2] defmodule Values do @moduledoc false @enforce_keys [:exact, :substrings, :count, :total_bytes] @derive {Inspect, only: [:count, :total_bytes]} defstruct [:exact, :substrings, :count, :total_bytes] @type t :: %__MODULE__{ exact: [binary()], substrings: [binary()], count: non_neg_integer(), total_bytes: non_neg_integer() } end defimpl Jason.Encoder, for: Values do @spec encode(term(), term()) :: no_return() def encode(_values, _opts) do raise ArgumentError, "redaction values are transient and cannot be encoded" end end @redacted "[REDACTED]" @secret_names ~w( access_key access_token apikey api_key authorization bearer client_secret cookie credential credentials password private_key refresh_token secret session_token signing_key token tokens auth_tokens ) # Keys whose name trips the `@secret_names` heuristic but whose value is never # credential material: usage counters, and the app-server # `item/tool/requestUserInput` question flag `isSecret` (a boolean telling a # client to mask the answer it collects, not a secret itself). @safe_keys ~w( cached_input_tokens input_tokens output_tokens reasoning_output_tokens total_tokens is_secret ) # Literal scanners for the fixed credential shapes this module redacts. They # are hand-rolled binary matchers on purpose: this repository bans pattern # sigils and the pattern module in shipped code (`Codex.ForbiddenTokensTest`). @whitespace [?\s, ?\t, ?\n, ?\r, ?\f, ?\v] @value_stops [?,, ?;] @assignment_separators [?=, ?:] @json_separators [?:] @bearer_extra [?., ?_, ?~, ?+, ?/, ?-] @json_secret_key_parts [ ~w(access key), ~w(access token), ~w(api key), ~w(authorization), ~w(bearer), ~w(client secret), ~w(codex api key), ~w(cookie), ~w(credential), ~w(credentials), ~w(openai api key), ~w(password), ~w(private key), ~w(refresh token), ~w(secret), ~w(session token), ~w(signing key), ~w(token), ~w(tokens) ] @json_secret_key_separators ["", "_", "-"] @json_secret_keys Enum.flat_map(@json_secret_key_parts, fn [first | rest] -> Enum.reduce(rest, [first], fn part, prefixes -> for prefix <- prefixes, separator <- @json_secret_key_separators, do: prefix <> separator <> part end) end) @min_json_secret_key_bytes Enum.min(Enum.map(@json_secret_keys, &byte_size/1)) @max_json_secret_key_bytes Enum.max(Enum.map(@json_secret_keys, &byte_size/1)) @assignment_keys ~w( codex_api_key openai_api_key access_token refresh_token client_secret password private_key session_token ) @authorization_keyword "authorization" @bearer_keyword "bearer" @openai_key_prefix "sk-" @min_openai_key_bytes 8 # Every scanner requires a fixed first byte (or literal), so scanning can jump # straight to the next candidate instead of testing every offset. These anchor # sets are derived from the keywords the matchers themselves use so they can # never drift out of sync. @json_anchors ["\""] @authorization_anchors [ binary_part(@authorization_keyword, 0, 1), String.upcase(binary_part(@authorization_keyword, 0, 1)) ] @bearer_anchors [ binary_part(@bearer_keyword, 0, 1), String.upcase(binary_part(@bearer_keyword, 0, 1)) ] @assignment_anchors @assignment_keys |> Enum.flat_map(fn key -> first = binary_part(key, 0, 1) [first, String.upcase(first)] end) |> Enum.uniq() @openai_anchors [@openai_key_prefix] @max_dynamic_values 32 @max_dynamic_value_bytes 4_096 @max_dynamic_total_bytes 16_384 @min_exact_bytes 4 @min_substring_bytes 8 @spec values(term()) :: Values.t() def values(source) do exact = source |> collect_values() |> Enum.filter(&exact_candidate?/1) |> Enum.uniq() |> Enum.sort_by(&byte_size/1, :desc) |> take_bounded_values() %Values{ exact: exact, substrings: Enum.filter(exact, &(byte_size(&1) >= @min_substring_bytes)), count: length(exact), total_bytes: Enum.reduce(exact, 0, &(byte_size(&1) + &2)) } end @spec empty_values() :: Values.t() def empty_values, do: %Values{exact: [], substrings: [], count: 0, total_bytes: 0} @spec empty?(Values.t()) :: boolean() def empty?(%Values{count: count}), do: count == 0 @spec term(term()) :: term() def term(value), do: term(value, empty_values()) @spec term(term(), Values.t()) :: term() def term(%Values{}, %Values{}), do: @redacted def term(%_{} = struct, %Values{} = values), do: struct |> Map.from_struct() |> term(values) def term(map, %Values{} = values) when is_map(map) do Map.new(map, fn {key, value} -> sanitized_key = if is_binary(key), do: text(key, values), else: key if secret_key?(key), do: {sanitized_key, @redacted}, else: {sanitized_key, term(value, values)} end) end def term(list, %Values{} = values) when is_list(list) do if List.ascii_printable?(list) do list |> to_string() |> text(values) else Enum.map(list, &term(&1, values)) end end def term(tuple, %Values{} = values) when is_tuple(tuple), do: tuple |> Tuple.to_list() |> Enum.map(&term(&1, values)) |> List.to_tuple() def term(value, %Values{} = values) when is_binary(value), do: text(value, values) def term(value, %Values{}), do: value @spec text(iodata()) :: String.t() def text(value), do: text(value, empty_values()) @spec text(iodata(), Values.t()) :: String.t() def text(value, %Values{} = values) do value |> IO.iodata_to_binary() |> scan(&match_json_secret/2, @json_anchors) |> scan(&match_authorization/2, @authorization_anchors) |> scan(&match_assignment/2, @assignment_anchors) |> scan(&match_bearer/2, @bearer_anchors) |> scan(&match_openai_key/2, @openai_anchors) |> redact_dynamic(values) end @spec inspect(term()) :: String.t() def inspect(value), do: inspect(value, empty_values()) @spec inspect(term(), Values.t()) :: String.t() def inspect(value, %Values{} = values), do: value |> term(values) |> Kernel.inspect() defp redact_dynamic(text, %Values{exact: exact, substrings: substrings}) do if text in exact do @redacted else Enum.reduce(substrings, text, &:binary.replace(&2, &1, @redacted, [:global])) end end defp collect_values(value) when is_binary(value), do: [value] defp collect_values(value) when is_map(value) do value |> Map.values() |> Enum.flat_map(&collect_values/1) end defp collect_values(value) when is_list(value) do if List.ascii_printable?(value) do [to_string(value)] else Enum.flat_map(value, &collect_values/1) end end defp collect_values(value) when is_tuple(value) do value |> Tuple.to_list() |> Enum.flat_map(&collect_values/1) end defp collect_values(_value), do: [] defp exact_candidate?(value) do size = byte_size(value) size >= @min_exact_bytes and size <= @max_dynamic_value_bytes end defp take_bounded_values(values) do values |> Enum.reduce_while({[], 0}, fn value, {acc, total_bytes} -> value_bytes = byte_size(value) cond do length(acc) >= @max_dynamic_values -> {:halt, {acc, total_bytes}} total_bytes + value_bytes > @max_dynamic_total_bytes -> {:cont, {acc, total_bytes}} true -> {:cont, {[value | acc], total_bytes + value_bytes}} end end) |> elem(0) |> Enum.reverse() end defp secret_key?(key) do normalized = key |> to_string() |> Macro.underscore() |> String.downcase() normalized not in @safe_keys and not String.ends_with?(normalized, ["_ref", "_refs", "_id", "_ids"]) and Enum.any?(@secret_names, fn name -> normalized == name or String.ends_with?(normalized, "_" <> name) end) end # Left-to-right, non-overlapping scan: the earliest anchored position where # `matcher` succeeds is replaced and scanning resumes after the bytes it # consumed. Matchers receive the byte preceding their position so they can # require a word boundary. defp scan(binary, matcher, anchors), do: scan(binary, matcher, anchor_pattern(anchors), 0, 0, []) # `:binary.match/3` recompiles a raw pattern list on every call, so the five # anchor automatons are compiled once and memoized for the node's lifetime. defp anchor_pattern(anchors) do key = {__MODULE__, :anchor_pattern, anchors} case :persistent_term.get(key, nil) do nil -> pattern = :binary.compile_pattern(anchors) :persistent_term.put(key, pattern) pattern pattern -> pattern end end defp scan(binary, matcher, anchors, position, start, acc) do case next_anchor(binary, anchors, position) do nil -> finish(binary, start, acc) index -> scan_anchor(binary, matcher, anchors, {index, start}, acc) end end defp scan_anchor(binary, matcher, anchors, {index, start}, acc) do case matcher.(drop(binary, index), previous_byte(binary, index)) do {:match, replacement, remaining} -> resume = byte_size(binary) - byte_size(remaining) literal = binary_part(binary, start, index - start) scan(binary, matcher, anchors, resume, resume, [replacement, literal | acc]) :nomatch -> scan(binary, matcher, anchors, index + 1, start, acc) end end defp finish(binary, 0, []), do: binary defp finish(binary, start, acc), do: [drop(binary, start) | acc] |> Enum.reverse() |> IO.iodata_to_binary() defp next_anchor(binary, anchors, position) do size = byte_size(binary) if position >= size do nil else case :binary.match(binary, anchors, scope: {position, size - position}) do {index, _length} -> index :nomatch -> nil end end end defp previous_byte(_binary, 0), do: nil defp previous_byte(binary, index), do: :binary.at(binary, index - 1) # "" : "" defp match_json_secret(<> = binary, _previous) do with {:ok, key, after_key} <- take_quoted(rest), true <- json_secret_key?(key), {:ok, after_separator} <- skip_separator(after_key, @json_separators), <> <- after_separator, {:ok, _value, remaining} <- take_quoted(value) do prefix = binary_part(binary, 0, byte_size(binary) - byte_size(after_separator) + 1) {:match, [prefix, @redacted, ?"], remaining} else _other -> :nomatch end end defp match_json_secret(_binary, _previous), do: :nomatch defp json_secret_key?(key) when byte_size(key) >= @min_json_secret_key_bytes and byte_size(key) <= @max_json_secret_key_bytes, do: downcase_ascii(key) in @json_secret_keys defp json_secret_key?(_key), do: false # Authorization [=:] [Bearer ] defp match_authorization(binary, _previous) do with {:ok, after_keyword} <- take_prefix_ci(binary, @authorization_keyword), {:ok, after_separator} <- skip_separator(after_keyword, @assignment_separators), {:ok, remaining} <- take_authorization_value(after_separator) do prefix = binary_part(binary, 0, byte_size(binary) - byte_size(after_separator)) {:match, [prefix, @redacted], remaining} else _other -> :nomatch end end # The `Bearer` prefix is optional: when nothing follows it, the keyword itself # is the redacted value. defp take_authorization_value(binary) do case take_bearer_prefix(binary) do {:ok, after_bearer} -> take_bearer_or_plain_value(binary, after_bearer) :error -> take_unquoted_value(binary) end end defp take_bearer_or_plain_value(binary, after_bearer) do case take_unquoted_value(after_bearer) do {:ok, _remaining} = taken -> taken :error -> take_unquoted_value(binary) end end # [=:] defp match_assignment(binary, _previous) do with {:ok, after_keyword} <- take_any_prefix_ci(binary, @assignment_keys), {:ok, after_separator} <- skip_separator(after_keyword, @assignment_separators), {:ok, remaining} <- take_unquoted_value(after_separator) do prefix = binary_part(binary, 0, byte_size(binary) - byte_size(after_separator)) {:match, [prefix, @redacted], remaining} else _other -> :nomatch end end # Bearer defp match_bearer(binary, _previous) do with {:ok, after_keyword} <- take_prefix_ci(binary, @bearer_keyword), {:ok, after_whitespace} <- take_required_whitespace(after_keyword), {:ok, remaining} <- take_run(after_whitespace, &bearer_byte?/1) do prefix = binary_part(binary, 0, byte_size(binary) - byte_size(after_whitespace)) {:match, [prefix, @redacted], remaining} else _other -> :nomatch end end # Word boundary, "sk-", then at least eight key bytes ending on a word byte. defp match_openai_key(<<@openai_key_prefix, rest::binary>> = binary, previous) do if word_byte?(previous), do: :nomatch, else: match_openai_key_body(binary, rest) end defp match_openai_key(_binary, _previous), do: :nomatch defp match_openai_key_body(binary, rest) do run = binary_part(rest, 0, run_length(rest, &key_byte?/1)) trimmed = String.trim_trailing(run, "-") if byte_size(trimmed) >= @min_openai_key_bytes do consumed = byte_size(@openai_key_prefix) + byte_size(trimmed) {:match, [@redacted], drop(binary, consumed)} else :nomatch end end defp take_bearer_prefix(binary) do case take_prefix_ci(binary, @bearer_keyword) do {:ok, after_keyword} -> take_required_whitespace(after_keyword) :error -> :error end end defp take_quoted(binary) do case :binary.match(binary, "\"") do {position, 1} -> {:ok, binary_part(binary, 0, position), drop(binary, position + 1)} :nomatch -> :error end end defp skip_separator(binary, separators) do case skip_whitespace(binary) do <> -> if byte in separators, do: {:ok, skip_whitespace(rest)}, else: :error _other -> :error end end defp take_prefix_ci(binary, keyword) do size = byte_size(keyword) if prefix_ci?(binary, keyword, 0, size), do: {:ok, drop(binary, size)}, else: :error end defp prefix_ci?(_binary, _keyword, index, size) when index >= size, do: true defp prefix_ci?(binary, keyword, index, size) when byte_size(binary) > index do downcase_byte(:binary.at(binary, index)) == :binary.at(keyword, index) and prefix_ci?(binary, keyword, index + 1, size) end defp prefix_ci?(_binary, _keyword, _index, _size), do: false defp take_any_prefix_ci(_binary, []), do: :error defp take_any_prefix_ci(binary, [keyword | rest]) do case take_prefix_ci(binary, keyword) do {:ok, _remaining} = taken -> taken :error -> take_any_prefix_ci(binary, rest) end end defp take_required_whitespace(binary), do: take_run(binary, &whitespace?/1) defp take_unquoted_value(binary), do: take_run(binary, &value_byte?/1) defp take_run(binary, predicate) do case run_length(binary, predicate) do 0 -> :error count -> {:ok, drop(binary, count)} end end defp skip_whitespace(binary), do: drop(binary, run_length(binary, &whitespace?/1)) defp run_length(binary, predicate), do: run_length(binary, predicate, 0) defp run_length(binary, predicate, count) when byte_size(binary) > count do if predicate.(:binary.at(binary, count)), do: run_length(binary, predicate, count + 1), else: count end defp run_length(_binary, _predicate, count), do: count defp drop(binary, count), do: binary_part(binary, count, byte_size(binary) - count) defp downcase_ascii(binary), do: for(<>, into: <<>>, do: <>) defp downcase_byte(byte) when byte in ?A..?Z, do: byte + 32 defp downcase_byte(byte), do: byte defp whitespace?(byte), do: byte in @whitespace defp value_byte?(byte), do: not whitespace?(byte) and byte not in @value_stops defp bearer_byte?(byte), do: alphanumeric?(byte) or byte in @bearer_extra defp key_byte?(byte), do: word_byte?(byte) or byte == ?- defp word_byte?(nil), do: false defp word_byte?(byte), do: alphanumeric?(byte) or byte == ?_ defp alphanumeric?(byte), do: byte in ?a..?z or byte in ?A..?Z or byte in ?0..?9 end