lib/sobelow/config/secrets.ex

defmodule Sobelow.Config.Secrets do
  @moduledoc """
  # Hard-coded Secrets

  In the event of a source-code disclosure via file read
  vulnerability, accidental commit, etc, hard-coded secrets
  may be exposed to an attacker. This may result in
  database access, cookie forgery, and other issues.

  Sobelow detects missing hard-coded secrets by checking the prod
  configuration.

  Hard-coded secrets checks can be ignored with the following command:

      $ mix sobelow -i Config.Secrets
  """
  alias Sobelow.Config

  @uid 10
  @finding_type "Config.Secrets: Hardcoded Secret"

  use Sobelow.Finding

  def run(dir_path, configs) do
    Enum.each(configs, fn conf ->
      path = dir_path <> conf

      if conf != "config.exs" do
        Config.get_configs_by_file(:secret_key_base, path)
        |> enumerate_secrets(path)
      end

      Config.get_fuzzy_configs("password", path)
      |> enumerate_fuzzy_secrets(path)

      Config.get_fuzzy_configs("secret", path)
      |> enumerate_fuzzy_secrets(path)
    end)
  end

  defp enumerate_secrets(secrets, file) do
    Enum.each(secrets, fn {fun, key, val} ->
      if is_binary(val) && String.length(val) > 0 && !is_env_var?(val) do
        add_finding(file, Parse.get_fun_line(fun), fun, key, val)
      end
    end)
  end

  defp enumerate_fuzzy_secrets(secrets, file) do
    Enum.each(secrets, fn {fun, vals} ->
      Enum.each(vals, fn {k, v} ->
        if is_binary(v) && String.length(v) > 0 && !is_env_var?(v) do
          add_finding(file, Parse.get_fun_line(fun), fun, k, v)
        end
      end)
    end)
  end

  def is_env_var?("${" <> rest) do
    String.ends_with?(rest, "}")
  end

  def is_env_var?(_), do: false

  defp add_finding(file, line_no, fun, key, val) do
    {vuln_line_no, vuln_line_col} = get_vuln_line(file, line_no, val)

    finding =
      %Finding{
        type: @finding_type,
        filename: Utils.normalize_path(file),
        fun_source: fun,
        vuln_source: :highlight_all,
        vuln_line_no: vuln_line_no,
        vuln_col_no: vuln_line_col,
        confidence: :high
      }
      |> Finding.fetch_fingerprint()

    file_header = "File: #{finding.filename}"
    line_header = "Line: #{finding.vuln_line_no}"
    key_header = "Key: #{key}"

    case Sobelow.get_env(:format) do
      "json" ->
        json_finding = [
          type: finding.type,
          file: finding.filename,
          line: finding.vuln_line_no,
          key: key
        ]

        Sobelow.log_finding(json_finding, finding)

      "txt" ->
        Sobelow.log_finding(finding)

        Print.print_custom_finding_metadata(finding, [
          file_header,
          line_header,
          key_header
        ])

      "compact" ->
        Print.log_compact_finding(finding)

      "flycheck" ->
        Print.log_flycheck_finding(finding)

      _ ->
        Sobelow.log_finding(finding)
    end
  end

  defp get_vuln_line(file, config_line_no, secret) do
    {_, secrets} =
      File.read!(file)
      |> String.replace("\"#{secret}\"", "@sobelow_secret")
      |> Code.string_to_quoted()
      |> Macro.prewalk([], &get_vuln_line/2)

    Enum.find(secrets, config_line_no, &(&1 > config_line_no))
  end

  defp get_vuln_line({:@, _, [{:sobelow_secret, _, _}]} = ast, acc) do
    line_no = Parse.get_fun_line(ast)
    line_col = Parse.get_fun_column(ast)
    {ast, [{line_no, line_col} | acc]}
  end

  defp get_vuln_line(ast, acc), do: {ast, acc}
end