lib/openagents/log_safety.ex

main at 58e6347eeb72 · 2 KB

defmodule OpenAgents.LogSafety do
  @moduledoc "Content-free acceptance scanner for application and platform log exports."

  @credential_pattern ~r/(?:Bearer\s+[A-Za-z0-9._~+\/-]{8,}|(?:gh[opusr]_|github_pat_|smct_|sig_|oa_pat_|sk-)[A-Za-z0-9._~-]{8,})/i
  @oauth_query_pattern ~r/(?:\?|&)(?:code|state|verifier|access_token)=/i
  @userinfo_pattern ~r|[a-z][a-z0-9+.-]*://[^\s/@:]+:[^\s/@]+@|i
  @scp_userinfo_pattern ~r{(?:^|\s)[^\s/:@]+:[^\s/@]+@[^\s]+}i
  @private_field_pattern ~r/["']?(?:content|messages?|prompt|transcript|memory|raw_arguments|tool_arguments|tool_result|sdp|authorization|cookie|poll_secret|access_token)["']?\s*(?:=>|:|=)\s*([^,\n}]+)/i

  @patterns [
    credential: @credential_pattern,
    oauth_query: @oauth_query_pattern,
    url_userinfo: @userinfo_pattern,
    url_userinfo: @scp_userinfo_pattern
  ]

  @spec scan(Enumerable.t()) :: :ok | {:error, [map()]}
  def scan(lines) do
    findings =
      lines
      |> Stream.with_index(1)
      |> Enum.flat_map(fn {line, line_number} -> findings(line, line_number) end)

    if findings == [], do: :ok, else: {:error, findings}
  end

  @spec redact(String.t()) :: String.t()
  def redact(text) when is_binary(text) do
    text
    |> then(&Regex.replace(@credential_pattern, &1, "[REDACTED_CREDENTIAL]"))
    |> then(
      &Regex.replace(
        ~r/([?&](?:code|state|verifier|access_token)=)[^&\s]*/i,
        &1,
        "\\1[FILTERED]"
      )
    )
    |> then(&Regex.replace(@userinfo_pattern, &1, "https://[REDACTED_CREDENTIAL]@"))
    |> then(&Regex.replace(@scp_userinfo_pattern, &1, " [REDACTED_CREDENTIAL_URL]"))
    |> then(
      &Regex.replace(@private_field_pattern, &1, fn full, value ->
        String.replace_suffix(full, value, "[FILTERED]")
      end)
    )
  end

  @spec scan_file(Path.t()) :: :ok | {:error, [map()] | :unreadable}
  def scan_file(path) when is_binary(path) do
    path
    |> File.stream!([], :line)
    |> scan()
  rescue
    File.Error -> {:error, :unreadable}
  end

  defp findings(line, line_number) do
    pattern_findings =
      Enum.flat_map(@patterns, fn {kind, pattern} ->
        if Regex.match?(pattern, line), do: [%{kind: kind, line: line_number}], else: []
      end)

    private_field? =
      @private_field_pattern
      |> Regex.scan(line, capture: :all_but_first)
      |> Enum.any?(fn [value] -> not String.contains?(value, "[FILTERED]") end)

    if private_field?,
      do: pattern_findings ++ [%{kind: :private_field, line: line_number}],
      else: pattern_findings
  end
end