lib/mix/tasks/scr.audit.ex

defmodule Mix.Tasks.Scr.Audit do
  @moduledoc """
  Performs audit of passwords, detect duplicates and weak passwords.
  Exits with exit code `1` if at least one check fails.

  ## Usage

      $ mix scr.audit

  ## Check options

  - `--no-similarity` - to disable password similarity check
  - `--digits` - to enforce passwords having at least one digit
  - `--uppercase` - to enforce uppercase letters in password
  - `--mix-length=N` - to enforce minimul length requirement
  - `--no-plaintext` - to disable checking for unencrypted passwords

  ## Config override

  You can override config options by providing command line arguments.

  - `:cipher` - specify a cipher module to use;
  - `:priv_path` - path to `priv` directory;
  - `:prefix` - prefix to use (defaults to `default`);
  - `:password` - use a password that's different from the one that's
    configured.
  """

  @shortdoc "Performs audit of passwords"
  @requirements ["app.config"]

  use Mix.Task

  require Config
  alias Elixir.Config.Reader
  alias SecretVault.{CLI, Config}

  @spec run([String.t()]) :: no_return()
  def run(args) do
    otp_app = Mix.Project.config()[:app]

    config_file =
      Enum.find(Mix.Project.config_files(), fn file ->
        String.ends_with?(file, "/config.exs")
      end) ||
        raise "No config.exs found"

    envs = ~w[prod test dev]a

    config_opts =
      Config.available_options()
      |> Enum.map(&{&1, CLI.find_option(args, nil, "#{&1}")})
      |> Enum.reject(fn {_, value} -> is_nil(value) end)

    Enum.flat_map(envs, fn env ->
      # Mix.env(env)
      Reader.read!(config_file, env: env)

      otp_app
      |> Application.get_env(:secret_vault, [])
      |> Enum.flat_map(fn {prefix, _} ->
        prefix = to_string(prefix)

        {:ok, config} =
          Config.fetch_from_env(otp_app, "#{env}", prefix, config_opts)

        do_fetch(config)
      end)
    end)
    |> check(args)

    System.halt(Process.get(:status, 0))
  end

  defp do_fetch(config) do
    case SecretVault.fetch_all(config) do
      {:ok, secrets} ->
        for {name, value} <- secrets do
          {config, name, value}
        end

      {:error, {:unknown_prefix, _, _}} ->
        []
    end
  end

  defp check(secrets, args) do
    unless "--no-plaintext" in args do
      plaintext_check(secrets)
    end

    unless "--no-similarity" in args do
      similarity_check(secrets)
    end

    if "--digits" in args do
      digits_check(secrets)
    end

    if "--uppercase" in args do
      uppercase_check(secrets)
    end

    len = CLI.find_option(args, "l", "min-length") || "16"
    length_check(secrets, String.to_integer(len))
  end

  defp plaintext_check(secrets) do
    Enum.each(secrets, fn {config, _, _} = secret ->
      if config.cipher == SecretVault.Cipher.Plaintext do
        Mix.shell().error("#{pathify(secret)} contains plaintext password")
        Process.put(:status, 1)
      end
    end)
  end

  defp uppercase_check(secrets) do
    Enum.each(secrets, fn {_, _, value} = secret ->
      unless value =~ ~r/[A-Z]/ do
        Mix.shell().error(
          "#{pathify(secret)} does not contain uppercase symbols"
        )

        Process.put(:status, 1)
      end
    end)
  end

  defp length_check(secrets, len) do
    Enum.each(secrets, fn {_, _, value} = secret ->
      if byte_size(value) < len do
        Mix.shell().error("#{pathify(secret)} is too short")
        Process.put(:status, 1)
      end
    end)
  end

  defp digits_check(secrets) do
    Enum.each(secrets, fn {_, _, value} = secret ->
      unless value =~ ~r/\d/ do
        Mix.shell().error("#{pathify(secret)} does not contain digits")
        Process.put(:status, 1)
      end
    end)
  end

  defp similarity_check([{_, _, left_value} = left | rest]) do
    Enum.each(rest, fn {_, _, right_value} = right ->
      if String.jaro_distance(left_value, right_value) > 0.5 do
        Mix.shell().error(
          "#{pathify(right)} and #{pathify(left)} secrets are too similar"
        )

        Process.put(:status, 1)
      end
    end)

    similarity_check(rest)
  end

  defp similarity_check([]) do
    []
  end

  defp pathify({config, name, _}) do
    path = SecretVault.resolve_secret_path(config, name)

    case File.cwd() do
      {:ok, cwd} -> Path.relative_to(path, cwd)
      _ -> path
    end
  end
end