| # <@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; |