# <@LICENSE>
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to you under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at:
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# </@LICENSE>

=head1 NAME

FromNameSpoof - perform various tests to detect spoof attempts using the
From header name section

=head1 SYNOPSIS

loadplugin    Mail::SpamAssassin::Plugin::FromNameSpoof

 # From:name and From:addr do not match, matching depends on C<fns_check> setting
 header  __PLUGIN_FROMNAME_SPOOF  eval:check_fromname_spoof()
  
 # From:name and From:addr do not match (same as above rule and C<fns_check 0>)
 header  __PLUGIN_FROMNAME_DIFFERENT  eval:check_fromname_different()

 # From:name and From:addr domains differ
 header  __PLUGIN_FROMNAME_DOMAIN_DIFFER  eval:check_fromname_domain_differ()

 # From:name looks like it contains an email address (not same as From:addr)
 header  __PLUGIN_FROMNAME_EMAIL  eval:check_fromname_contains_email()

 # From:name matches any To:addr
 header  __PLUGIN_FROMNAME_EQUALS_TO  eval:check_fromname_equals_to()

 # From:name and From:addr owners differ
 header  __PLUGIN_FROMNAME_OWNERS_DIFFER  eval:check_fromname_owners_differ()

 # From:name matches Reply-To:addr
 header  __PLUGIN_FROMNAME_EQUALS_REPLYTO  eval:check_fromname_equals_replyto()

=head1 DESCRIPTION

Perform various tests against From:name header to detect spoofing. Steps in place to 
ensure minimal FPs.

=head1 CONFIGURATION

The plugin allows you to skip emails that have been DKIM signed by specific senders:

  fns_ignore_dkim googlegroups.com

FromNameSpoof allows for a configurable closeness when matching the From:addr and From:name,
the closeness can be adjusted with:

  fns_extrachars 50

B<Note> that FromNameSpoof detects the "owner" of a domain by the following search:

  <owner>.<tld>

By default FromNameSpoof will ignore the TLD when comparing addresses:

  fns_check 1

Check levels:

  0 - Strict checking of From:name != From:addr
  1 - Allow for different TLDs
  2 - Allow for different aliases but same domain

"Owner" info can also be mapped as aliases with C<fns_add_addrlist>.  For
example, to consider "googlemail.com" as "gmail":

  fns_add_addrlist (gmail) *@googlemail.com

=head1 TAGS

The following tags are added to the set if a spoof is detected. They are available for 
use in reports, header fields, other plugins, etc.:

  _FNSFNAMEADDR_
    Detected spoof address from From:name header

  _FNSFNAMEDOMAIN_
    Detected spoof domain from From:name header

  _FNSFNAMEOWNER_
    Detected spoof owner from From:name header

  _FNSFADDRADDR_
    Actual From:addr address

  _FNSFADDRDOMAIN_ 
    Actual From:addr domain

  _FNSFADDROWNER_
    Actual From:addr owner

=head1 EXAMPLE 

  header  __PLUGIN_FROMNAME_SPOOF  eval:check_fromname_spoof()
  header  __PLUGIN_FROMNAME_EQUALS_TO  eval:check_fromname_equals_to()
  meta     FROMNAME_SPOOF_EQUALS_TO (__PLUGIN_FROMNAME_SPOOF && __PLUGIN_FROMNAME_EQUALS_TO)
  describe FROMNAME_SPOOF_EQUALS_TO From:name is spoof to look like To: address
  score    FROMNAME_SPOOF_EQUALS_TO 1.2

=cut

package Mail::SpamAssassin::Plugin::FromNameSpoof;

use strict;
use warnings;
use re 'taint';

use Mail::SpamAssassin::Plugin;

use vars qw(@ISA);
@ISA = qw(Mail::SpamAssassin::Plugin);

my $VERSION = 1.0;

sub dbg { my $msg = shift; Mail::SpamAssassin::Plugin::dbg("FromNameSpoof: $msg", @_); }

# constructor: register the eval rule
sub new {
  my $class = shift;
  my $mailsaobject = shift;

  # some boilerplate...
  $class = ref($class) || $class;
  my $self = $class->SUPER::new($mailsaobject);
  bless ($self, $class);

  $self->set_config($mailsaobject->{conf});

  # the important bit!
  $self->register_eval_rule("check_fromname_spoof", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
  $self->register_eval_rule("check_fromname_different", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
  $self->register_eval_rule("check_fromname_domain_differ", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
  $self->register_eval_rule("check_fromname_contains_email", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
  $self->register_eval_rule("check_fromname_equals_to", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
  $self->register_eval_rule("check_fromname_owners_differ", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
  $self->register_eval_rule("check_fromname_equals_replyto", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS);
  return $self;
}

sub set_config {
  my ($self, $conf) = @_;
  my @cmds = ();

  push (@cmds, {
    setting => 'fns_add_addrlist',
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
    code => sub {
      my ($self, $key, $value, $line) = @_;
      local($1,$2);
      if ($value !~ /^ \( (.+?) \) \s+ (.+) \z/sx) {
        return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
      }
      my $listname = "FNS_".lc($1);
      $self->{parser}->add_to_addrlist($listname, split(/\s+/, lc $2));
      $self->{fns_addrlists}{$listname} = 1;
    }
  });

  push (@cmds, {
    setting => 'fns_remove_addrlist',
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
    code => sub {
      my ($self, $key, $value, $line) = @_;
      local($1,$2);
      if ($value !~ /^ \( (.+?) \) \s+ (.+) \z/sx) {
        return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
      }
      my $listname = "FNS_".lc($1);
      $self->{parser}->remove_from_addrlist($listname, split (/\s+/, lc $2));
    }
  });

  push(@cmds, {
    setting => 'fns_extrachars',
    default => 50,
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
  });

  push (@cmds, {
    setting => 'fns_ignore_dkim',
    default => {},
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
    code => sub {
      my ($self, $key, $value, $line) = @_;
      if ($value eq '') {
        return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
      }
      $self->{fns_ignore_dkim}->{$_} = 1 foreach (split(/\s+/, lc $value));
    }
  });

  push (@cmds, {
    setting => 'fns_ignore_headers',
    default => {},
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
    code => sub {
      my ($self, $key, $value, $line) = @_;
      if ($value eq '') {
        return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
      }
      $self->{fns_ignore_header}->{$_} = 1 foreach (split(/\s+/, $value));
    }
  });

  push(@cmds, {
    setting => 'fns_check',
    default => 1,
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
    code => sub {
      my ($self, $key, $value, $line) = @_;
      if ($value eq '') {
        return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
      }
      if ($value !~ /^[012]$/) {
        return $Mail::SpamAssassin::Conf::INVALID_VALUE;
      }
      $self->{fns_check} = $value;
    }
  });

  $conf->{parser}->register_commands(\@cmds);
}

sub parsed_metadata {
  my ($self, $opts) = @_;
  my $pms = $opts->{permsgstatus};

  # If fns_ignore_dkim used, force wait for DKIM results
  if (%{$pms->{conf}->{fns_ignore_dkim}}) {
    if ($self->{main}->{local_tests_only}) {
      dbg("local tests only, ignoring fns_ignore_dkim setting");
    }
    # Check that DKIM module is loaded (a bit kludgy check)
    elsif (exists $pms->{conf}->{dkim_timeout}) {
      # Initialize async queue, any eval calls will queue their checks
      $pms->{fromname_async_queue} = [];
      # Process and finish queue as soon as DKIM is ready
      $pms->action_depends_on_tags('DKIMDOMAIN', sub {
        $self->_check_async_queue($pms);
      });
    } else {
      dbg("DKIM plugin not loaded, ignoring fns_ignore_dkim setting");
    }
  }
}

sub _check_eval {
  my ($self, $pms, $result) = @_;

  if (exists $pms->{fromname_async_queue}) {
    my $rulename = $pms->get_current_eval_rule_name();
    push @{$pms->{fromname_async_queue}}, sub {
      if ($result->()) {
        $pms->got_hit($rulename, '', ruletype => 'header');
      } else {
        $pms->rule_ready($rulename);
      }
    };
    return; # return undef for async status
  }

  $self->_check_fromnamespoof($pms);
  # make sure not to return undef, as this is not async anymore
  return $result->() || 0;
}

sub check_fromname_spoof {
  my ($self, $pms, $check_lvl) = @_;

  # Some deprecated eval parameter, was not documented?
  if (!defined $check_lvl || $check_lvl !~ /^[012]$/) {
    $check_lvl = $pms->{conf}->{fns_check};
  }

  my $result = sub {
    my @array = (
      ($pms->{fromname_address_different}),
      ($pms->{fromname_address_different} && $pms->{fromname_owner_different}),
      ($pms->{fromname_address_different} && $pms->{fromname_domain_different})
    );
    $array[$check_lvl];
  };

  return $self->_check_eval($pms, $result);
}

sub check_fromname_different {
  my ($self, $pms) = @_;

  my $result = sub {
    $pms->{fromname_address_different};
  };

  return $self->_check_eval($pms, $result);
}

sub check_fromname_domain_differ {
  my ($self, $pms) = @_;

  my $result = sub {
    $pms->{fromname_domain_different};
  };

  return $self->_check_eval($pms, $result);
}

sub check_fromname_contains_email {
  my ($self, $pms) = @_;

  my $result = sub {
    $pms->{fromname_contains_email};
  };

  return $self->_check_eval($pms, $result);
}

sub check_fromname_equals_to {
  my ($self, $pms) = @_;

  my $result = sub {
    $pms->{fromname_equals_to_addr};
  };

  return $self->_check_eval($pms, $result);
}

sub check_fromname_owners_differ {
  my ($self, $pms) = @_;

  my $result = sub {
    $pms->{fromname_owner_different};
  };

  return $self->_check_eval($pms, $result);
}

sub check_fromname_equals_replyto {
  my ($self, $pms) = @_;

  my $result = sub {
    $pms->{fromname_equals_replyto};
  };

  return $self->_check_eval($pms, $result);
}

sub check_cleanup {
  my ($self, $opts) = @_;

  $self->_check_async_queue($opts->{permsgstatus});
}

# Shall only be called when DKIMDOMAIN is ready, or from check_cleanup() to
# make sure _check_fromnamespoof is called if DKIMDOMAIN was never set
sub _check_async_queue {
  my ($self, $pms) = @_;

  if (exists $pms->{fromname_async_queue}) {
    $self->_check_fromnamespoof($pms);
    $_->() foreach (@{$pms->{fromname_async_queue}});
    # No more async queueing needed.  If any evals are called later, they
    # will act on the results directly.
    delete $pms->{fromname_async_queue};
  }
}

sub _check_fromnamespoof {
  my ($self, $pms) = @_;

  return if $pms->{fromname_checked};
  $pms->{fromname_checked} = 1;

  my $conf = $pms->{conf};

  foreach my $addr (split(/\s+/, $pms->get_tag('DKIMDOMAIN')||'')) {
    if ($conf->{fns_ignore_dkim}->{lc $addr}) {
      dbg("ignoring, DKIM signed: $addr");
      return;
    }
  }

  foreach my $iheader (keys %{$conf->{fns_ignore_header}}) {
    if ($pms->get($iheader)) {
      dbg("ignoring, header $iheader found");
      return;
    }
  }

  # Parse From addr
  my $from_addr = lc $pms->get('From:addr');
  my $from_domain = $self->{main}->{registryboundaries}->uri_to_domain("mailto:$from_addr");
  return unless defined $from_domain;

  # Parse From name
  my $fromname = lc $pms->get('From:name');
  # Very common to have From address cloned into name, ignore?
  #if ($fromname eq $from_addr) {
  #  dbg("ignoring, From-name is exactly same as From addr: $fromname");
  #  return;
  #}
  my ($fromname_addr, $fromname_domain);
  if ($fromname =~ /\b([\w\.\!\#\$\%\&\'\*\+\/\=\?\^\_\`\{\|\}\~-]+\@\w[\w-]*\.\w[\w.-]++)\b/i) {
    $fromname_addr = $1;
    $fromname_domain = $self->{main}->{registryboundaries}->uri_to_domain("mailto:$fromname_addr");
    # No valid domain/TLD found? Any reason to keep testing a possibly obfuscated one?
    if (!defined $fromname_domain) {
      dbg("no From-name addr found");
      return;
    }
    $pms->{fromname_contains_email} = 1; # check_fromname_contains_email hit
    # Calculate "closeness" (this really needs documentation, as it's hard to understand)
    my $nochar = ($fromname =~ y/a-z0-9//c);
    $nochar -= ($fromname_addr =~ y/a-z0-9//c);
    my $len = length($fromname) + $nochar - length($fromname_addr);
    unless ($len <= $conf->{fns_extrachars}) {
      dbg("not enough closeness for From-name/addr: $fromname <=> $fromname_addr ($len <= $conf->{fns_extrachars})");
      return;
    }
  } else {
    # No point continuing if email was not found inside name
    dbg("no From-name addr found");
    return;
  }

  # Parse owners
  my $list_refs = {};
  if ($conf->{fns_addrlists}) {
    my @lists = keys %{$conf->{fns_addrlists}};
    foreach my $list (@lists) {
      $list_refs->{$list} = $conf->{$list};
    }
    dbg("using addrlists for owner aliases: ".join(', ', map { s/^FNS_//r; } @lists));
  }
  my $fromname_owner = $self->_find_address_owner($fromname_addr, $fromname_domain, $list_refs);
  my $from_owner = $self->_find_address_owner($from_addr, $from_domain, $list_refs);

  dbg("Parsed From-name addr/domain/owner: $fromname_addr/$fromname_domain/$fromname_owner");
  dbg("Parsed From-addr addr/domain/owner: $from_addr/$from_domain/$from_owner");

  if ($fromname_addr ne $from_addr) {
    dbg("From-name addr differs from From addr: $fromname_addr != $from_addr");
    $pms->{fromname_address_different} = 1;
  }
  if ($fromname_domain ne $from_domain) {
    dbg("From-name domain differs from From domain: $fromname_domain != $from_domain");
    $pms->{fromname_domain_different} = 1;
  }
  if ($fromname_owner ne $from_owner) {
    dbg("From-name owner differs from From owner: $fromname_owner != $from_owner");
    $pms->{fromname_owner_different} = 1;
  }

  # Check Reply-To related
  my $replyto_addr = lc $pms->get('Reply-To:addr');
  if ($fromname_addr eq $replyto_addr) {
    dbg("From-name addr is same as Reply-To addr: $fromname_addr");
    $pms->{fromname_equals_replyto} = 1;
  }

  # Check To related
  foreach my $to_addr ($pms->all_to_addrs()) {
    if ($fromname_addr eq $to_addr) {
      dbg("From-name addr is same as To addr: $fromname_addr");
      $pms->{fromname_equals_to_addr} = 1;
      last;
    }
  }

  # Set tags
  if ($pms->{fromname_address_different} || $pms->{fromname_owner_different}) {
    $pms->set_tag("FNSFNAMEADDR", $fromname_addr);
    $pms->set_tag("FNSFNAMEDOMAIN", $fromname_domain);
    $pms->set_tag("FNSFNAMEOWNER", $fromname_owner);
    $pms->set_tag("FNSFADDRADDR", $from_addr);
    $pms->set_tag("FNSFADDRDOMAIN", $from_domain);
    $pms->set_tag("FNSFADDROWNER", $from_owner);
  }
}

sub _find_address_owner {
  my ($self, $addr, $addr_domain, $list_refs) = @_;

  # Check fns addrlist first for user defined mapping
  foreach my $owner (keys %{$list_refs}) {
    foreach my $listaddr (keys %{$list_refs->{$owner}}) {
      if ($addr =~ $list_refs->{$owner}{$listaddr}) {
        $owner =~ s/^FNS_//;
        return lc $owner;
      }
    }
  }

  # If we have subdomain addr foo.bar@sub.domain.com,
  # this will try to recheck foo.bar@domain.com from addrlist
  local($1,$2);
  if ($addr =~ /^([^\@]+)\@(.+)$/) {
    if ($2 ne $addr_domain) {
      return $self->_find_address_owner("$1\@$addr_domain", $addr_domain, $list_refs);
    }
  }

  # Grab the first component of TLD
  if ($addr_domain =~ /^([^.]+)\./) {
    return $1;
  } else {
    return $addr_domain;
  }
}

1;
