lib/spf/eval.ex

defmodule Spf.Eval do
  @moduledoc """
  Functions to evaluate an SPF context.

  """

  alias Spf.DNS
  import Spf.Context

  @type dns_result :: Spf.DNS.dns_result()

  # API

  @doc """
  Say whether `str` contains the start of an SPF string.

  Leading whitespace is not considered an error, although technically it is a
  syntax error.

  """
  @spec spf?(binary) :: boolean
  def spf?(str) when is_binary(str),
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.5
    do: String.match?(str, ~r/^\s*v=spf1(\s|$)/i)

  # def spf?(_),
  #   do: false

  @doc """
  Given a [`context`](`t:Spf.Context.t/0`) retrieve and evaluate the associated SPF record.

  After an (attempted) evaluation, returns an updated context where:
  - `:verdict` is an `t:Spf.Context.verdict/0`
  - `:reason` shows the SPF term responsible for the verdict
  - `:explanation` is the expanded explain-string (if possible and applicable)
  - `:error` shows what (last) error was seen (if any)
  - `:ipt` which maps the prefixes seen during evaluation to their source
  - `:msg` which lists log messages accumulated during evaluation

  and other fields containing information gathered during the evaluation.

  The context is passed around accumulating information and tracks the state of
  the evaluation. Its `:log` is either `nil` or points to a `log/4`-function
  that then called with the `context`, `facility`, `severity` and a `message`
  so it can dump it to screen or somewhere else.

  """
  @spec evaluate(Spf.Context.t()) :: Spf.Context.t()
  def evaluate(ctx) do
    ctx
    |> check_domain()
    |> grep_spf()
    |> Spf.Parser.parse()
    |> eval()
    |> tap(
      &log(
        &1,
        :eval,
        :note,
        "spf[#{&1.nth}] #{&1.domain} - verdict #{&1.verdict}, reason #{&1.reason} (#{&1.duration} ms)"
      )
    )
  end

  @doc """
  Returns true if `name` is a validated name for given `domain`.

  The [`dns_result`](`t:dns_result/0`) should contain the ip addresses
  associated with given `name`. If any of the ip adresss match the given `ip`,
  the `name` is a validated domain name for given `domain`.

  If the `exact` flag is true, then the `name` is also required to
  end with given `domain` as well.

  Note that when trying to validate names during the expansion of the p-macro,
  this flag will be false.

  """
  # a name is validated iff it's ip == <ip> && possibly when name endswith? domain
  @spec validate?(dns_result, binary, binary, binary, boolean) :: boolean
  def validate?(dns_result, ip, name, domain, exact)

  def validate?({:ok, rrs}, ip, name, domain, exact) do
    pfx = Pfx.new(ip)

    if Enum.any?(rrs, fn ip -> Pfx.member?(ip, pfx) end) do
      if exact do
        String.downcase(name)
        |> String.ends_with?(String.downcase(domain))
      else
        true
      end
    else
      false
    end
  end

  def validate?({:error, _}, _ip, _name, _domain, _exact),
    do: false

  # Helpers

  @spec ascii?(binary) :: boolean
  defp ascii?(string) when is_binary(string),
    do: string == for(<<c <- string>>, c < 128, into: "", do: <<c>>)

  @spec evalname(Spf.Context.t(), binary, list, any) :: Spf.Context.t()
  defp evalname(ctx, domain, dual, value) do
    {ctx, dns} = DNS.resolve(ctx, domain, type: ctx.atype)

    case dns do
      {:error, reason} ->
        log(ctx, :eval, :warn, "#{ctx.atype} #{domain} - DNS error #{inspect(reason)}")

      {:ok, rrs} ->
        addip(ctx, rrs, dual, value)
    end
  end

  @spec explain(Spf.Context.t()) :: Spf.Context.t()
  defp explain(ctx) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-6.2
    # - donot track void answers
    # - get & store the explain-string pointed to by the ctx.explain token
    # - expand explain-string and store as ctx.explanation
    if ctx.verdict == :fail and ctx.explain do
      {_token, [domain], _range} = ctx.explain
      {ctx, dns} = DNS.resolve(ctx, domain, type: :txt, stats: false)

      case dns do
        {:error, reason} ->
          log(ctx, :dns, :warn, "txt #{domain} - DNS error #{reason}")

        {:ok, list} when length(list) > 1 ->
          log(ctx, :dns, :error, "txt #{domain} - too many explain txt records #{inspect(list)}")

        {:ok, [explain]} ->
          log(ctx, :dns, :info, "txt #{domain} -> '#{explain}'")
          |> Map.put(:explain_string, explain)
          |> Spf.Parser.explain()
      end
    else
      ctx
    end
  end

  @spec check_limits(Spf.Context.t()) :: Spf.Context.t()
  defp check_limits(ctx) do
    # only check for original SPF record, so we donot prematurely stop processing
    if ctx.nth == 0 do
      ctx =
        if ctx.num_dnsm > ctx.max_dnsm do
          error(
            ctx,
            :eval,
            :too_many_dnsm,
            "too many DNS mechanisms used (#{ctx.num_dnsm})",
            :permerror
          )
        else
          ctx
        end

      if ctx.num_dnsv > ctx.max_dnsv do
        error(
          ctx,
          :eval,
          :too_many_dnsv,
          "too many VOID DNS queries seen (#{ctx.num_dnsv})",
          :permerror
        )
      else
        ctx
      end
    else
      ctx
    end
  end

  @spec match(Spf.Context.t(), tuple, list) :: Spf.Context.t()
  defp match(%{error: error} = ctx, _term, _tail) when error != nil,
    # a fatal error was already recorded, so bailout
    do: ctx

  defp match(ctx, {_q, _token, range} = _term, tail) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.2

    term = spf_term(ctx, range)
    verdict = verdict(ctx)

    if verdict do
      # ctx.ip has a match, so set corresponding result and we're done
      log(ctx, :eval, :note, "#{term} - matches #{ctx.ip}")
      |> tick(:num_checks)
      |> Map.put(:verdict, verdict)
      |> Map.put(:reason, "#{term}")
    else
      # no match, so continue evaluation
      log(ctx, :eval, :info, "#{term} - no match")
      |> tick(:num_checks)
      |> evalp(tail)
    end
  end

  @spec validate(binary, Spf.Context.t(), tuple) :: Spf.Context.t()
  defp validate(name, ctx, {:ptr, [q, domain], range}) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.5
    {ctx, dns} = DNS.resolve(ctx, name, type: ctx.atype)
    term = spf_term(ctx, range)

    case validate?(dns, ctx.ip, name, domain, true) do
      true ->
        addip(ctx, [ctx.ip], [32, 128], {q, ctx.nth, term})
        |> log(:eval, :info, "#{term} - validated #{name} (#{ctx.ip}) for #{domain}")

      false ->
        log(ctx, :eval, :info, "#{term} - didn't validate #{name} (#{ctx.ip}) for #{domain}")
    end
  end

  @spec verdict(Spf.Context.t()) :: nil | atom
  defp verdict(ctx) do
    # used by match/1 to check if we currently have a match
    # - ipt[prefix] -> [{q, nth, token}] => list of tokens and SPF-id that added the prefix
    # - the token contains the qualifier that, if matched, says what the result should be
    # notes:
    # - prefixes can be contributed multiple times by Nx terms in Mx records
    # - the last {token, nth} to do so, is listed first
    # - so only check for the first token of the current ctx.nth
    # - having verdict does not necessarily stop evaluation (e.g. when inside an include)
    with {_pfx, qlist} <- Iptrie.lookup(ctx.ipt, ctx.ip),
         {data, _} <- List.keytake(qlist, ctx.nth, 1),
         q <- elem(data, 0) do
      qualify(q)
    else
      _ -> nil
    end
  end

  @spec qualify(pos_integer()) :: atom
  defp qualify(qualifier) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.2
    case qualifier do
      ?+ -> :pass
      ?- -> :fail
      ?~ -> :softfail
      ?? -> :neutral
    end
  end

  @spec check_domain(Spf.Context.t()) :: Spf.Context.t()
  defp check_domain(ctx) do
    # as a first action of evaluate(ctx), check the domain:
    # - if not a legal fqdn -> evaluation result is :none
    case Spf.DNS.check_domain(ctx.domain) do
      {:ok, _domain} ->
        ctx

      {:error, reason} ->
        error(ctx, :eval, :illegal_domain, "domain error (#{reason})", :none)
    end
  end

  @spec grep_spf(Spf.Context.t()) :: Spf.Context.t()
  defp grep_spf(ctx) do
    # either set ctx.spf to an SPF record, or set ctx.error to some atom error
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.3
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.4
    # *temperror* for:
    # - servfail (RCODE 2)
    # - some error (where RCODE !=0 (NOERROR) and RCODE!=3 (NXDOMAIN)), or
    # - timeout
    # *none* for:
    # - nxdomain
    # - zero_answers
    # - malformed domain
    {ctx, result} = Spf.DNS.resolve(ctx, ctx.domain, type: :txt, stats: false)

    case Spf.DNS.filter(result, &spf?/1) do
      {:ok, []} ->
        error(ctx, :eval, :no_spf, "no SPF record found", :none)

      {:ok, [spf]} ->
        if ascii?(spf),
          do: Map.put(ctx, :spf, spf),
          else: error(ctx, :eval, :non_ascii_spf, "SPF contains non-ascii characters", :permerror)

      {:ok, list} ->
        error(
          ctx,
          :eval,
          :too_many_spf,
          "too many SPF records found (#{length(list)})",
          :permerror
        )

      {:error, :nxdomain} ->
        error(ctx, :eval, :nxdomain, "txt #{ctx.domain} - DNS error (nxdomain)", :none)

      {:error, :zero_answers} ->
        error(ctx, :eval, :zero_answers, "txt #{ctx.domain} - DNS error (zero answers)", :none)

      {:error, :illegal_name} ->
        error(ctx, :eval, :illegal_name, "txt #{ctx.domain} - DNS error (illegal name)", :none)

      {:error, :timeout} ->
        error(ctx, :eval, :timeout, "txt #{ctx.domain} - DNS error (timeout)", :temperror)

      {:error, :servfail} ->
        error(ctx, :eval, :servfail, "txt #{ctx.domain} - DNS error (servfail)", :temperror)

      {:error, reason} ->
        error(ctx, :eval, :dns_error, "txt #{ctx.domain} - DNS error (#{reason})", :temperror)
    end
  end

  # Eval Terms

  @spec eval(Spf.Context.t()) :: Spf.Context.t()
  defp eval(%{error: error} = ctx) when error != nil,
    do: ctx

  defp eval(ctx) do
    evalp(ctx, ctx.ast)
    |> explain()
    |> Map.put(:duration, System.monotonic_time(:millisecond) - ctx.t0)
    |> check_limits()
  end

  @spec evalp(Spf.Context.t(), list) :: Spf.Context.t()
  defp evalp(ctx, []),
    # Nomore Terms
    do: ctx

  # A
  defp evalp(ctx, [{:a, [q, domain, dual], range} = term | tail]) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.3
    {ctx, dns} = DNS.resolve(ctx, domain, type: ctx.atype)
    spfterm = spf_term(ctx, range)

    case dns do
      {:error, reason} when reason in [:nxdomain, :zero_answers, :illegal_name] ->
        ctx

      {:error, reason} ->
        error(ctx, :eval, reason, "#{spfterm} - #{reason}", :temperror)

      {:ok, rrs} ->
        addip(ctx, rrs, dual, {q, ctx.nth, spfterm})
    end
    |> match(term, tail)
  end

  # All
  defp evalp(ctx, [{:all, [q], range} = _term | _tail]) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.1
    term = spf_term(ctx, range)

    log(ctx, :eval, :note, "#{term} - matches")
    |> tick(:num_checks)
    |> Map.put(:verdict, qualify(q))
    |> Map.put(:reason, "#{term}")
  end

  # EXISTS
  defp evalp(ctx, [{:exists, [q, domain], range} = term | tail]) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.7
    {ctx, dns} = DNS.resolve(ctx, domain, type: :a)
    spfterm = spf_term(ctx, range)
    spfsame? = ctx.domain == String.downcase(domain)

    case dns do
      {:error, reason} when reason in [:nxdomain, :zero_answers, :illegal_name] ->
        ctx

      {:error, reason} ->
        error(ctx, :eval, reason, "#{spfterm} - #{reason}", :temperror)

      {:ok, rrs} ->
        log(ctx, :eval, :info, "#{spfterm} - got DNS #{inspect(rrs)}")
        |> addip(ctx.ip, [32, 128], {q, ctx.nth, spfterm})
        |> test(:eval, :warn, spfsame?, "#{spfterm} - same as main SPF domain (#{ctx.domain})")
    end
    |> match(term, tail)
  end

  # INCLUDE
  defp evalp(ctx, [{:include, [q, domain], range} = _term | tail]) do
    term = spf_term(ctx, range)

    if loop?(ctx, domain) do
      # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.4
      # testsuite 14.2 include-loop
      error(
        ctx,
        :eval,
        :loop,
        "#{term} - loop: #{ctx.domain} cannot include #{domain}",
        :permerror
      )
    else
      ctx =
        log(ctx, :eval, :note, "#{term} - recurse")
        |> push(domain)
        |> evaluate()

      # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.2
      case ctx.verdict do
        v when v in [:neutral, :fail, :softfail] ->
          pop(ctx)
          |> log(:eval, :note, "#{term} - no match")
          |> evalp(tail)

        :pass ->
          pop(ctx)
          |> Map.put(:verdict, qualify(q))
          |> log(:eval, :note, "#{term} - match")
          |> Map.put(:reason, "#{term} - matched")

        v when v in [:none, :permerror] ->
          pop(ctx)
          |> error(:eval, :include, "#{term} - permanent error", :permerror)

        :temperror ->
          pop(ctx)
          |> error(:eval, :include, "#{term} - temporary error", :temperror)
      end
    end
  end

  # IP4/6
  defp evalp(ctx, [{ip, [q, pfx], range} = term | tail]) when ip in [:ip4, :ip6] do
    addip(ctx, [pfx], [32, 128], {q, ctx.nth, spf_term(ctx, range)})
    |> match(term, tail)
  end

  # MX
  defp evalp(ctx, [{:mx, [q, domain, dual], range} = term | tail]) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.4
    {ctx, dns} = DNS.resolve(ctx, domain, type: :mx)
    spfterm = spf_term(ctx, range)

    case dns do
      {:error, reason} when reason in [:nxdomain, :zero_answers, :illegal_name] ->
        ctx

      {:error, reason} ->
        error(ctx, :eval, reason, "#{spfterm} - #{reason}", :temperror)

      {:ok, [{0, "."}]} ->
        # https://www.rfc-editor.org/rfc/rfc7505.html#section-3
        log(ctx, :eval, :warn, "#{spfterm} - unusable due to null MX for #{domain}")

      {:ok, rrs} when length(rrs) > 10 ->
        # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.4
        error(ctx, :eval, :too_many_mtas, "#{spfterm} - too many mta's", :permerror)

      {:ok, rrs} ->
        Enum.map(rrs, fn {_pref, name} -> name end)
        |> Enum.take(10)
        |> Enum.reduce(ctx, fn name, acc -> evalname(acc, name, dual, {q, ctx.nth, spfterm}) end)
        |> log(:dns, :debug, "MX #{domain} #{inspect({q, ctx.nth, term})} added")
    end
    |> match(term, tail)
  end

  # PTR
  defp evalp(ctx, [{:ptr, [_q, domain], range} = term | tail]) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-5.5
    # https://www.rfc-editor.org/errata/eid4751
    domain = Spf.DNS.normalize(domain)
    {ctx, dns} = DNS.resolve(ctx, Pfx.dns_ptr(ctx.ip), type: :ptr)
    spfterm = spf_term(ctx, range)

    case dns do
      {:error, reason} when reason in [:nxdomain, :zero_answers, :illegal_name] ->
        ctx

      {:error, reason} ->
        error(ctx, :eval, reason, "#{spfterm} - #{reason}", :temperror)

      {:ok, rrs} ->
        # https://www.rfc-editor.org/errata/eid5227
        Enum.map(rrs, fn name -> Spf.DNS.normalize(name) end)
        |> Enum.filter(fn name -> String.ends_with?(name, domain) end)
        |> Enum.take(10)
        |> Enum.reduce(ctx, fn name, acc -> validate(name, acc, term) end)
    end
    |> match(term, tail)
  end

  # REDIRECT
  defp evalp(ctx, [{:redirect, [domain], range} | tail]) do
    # https://www.rfc-editor.org/rfc/rfc7208.html#section-6.1
    # - if redirect domain has no SPF -> permerror
    # - if redirect domain is malformed (seen by evaluate())-> permerror
    # - otherwise its result is the result for this SPF
    term = spf_term(ctx, range)
    trailing? = length(tail) > 0

    if loop?(ctx, domain) do
      # https://www.rfc-editor.org/rfc/rfc7208.html#section-4.6.4
      # testsuite 14.8 redirect-loop
      error(
        ctx,
        :eval,
        :loop,
        "#{term} - loop: #{ctx.domain} cannot redirect to #{domain}",
        :permerror
      )
    else
      ctx =
        test(ctx, :eval, :warn, trailing?, "#{term} - has trailing terms")
        |> log(:eval, :note, "#{term} - redirecting to #{domain}")
        |> redirect(domain)
        |> evaluate()

      if ctx.error in [:no_spf, :nxdomain] do
        error(ctx, :eval, :no_redir_spf, "#{term} - no SPF found for #{domain}", :permerror)
      else
        ctx
      end
    end
  end
end