| # <@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> |
| # |
| # Author: Giovanni Bechis <gbechis@apache.org> |
| |
| =head1 NAME |
| |
| Mail::SpamAssassin::Plugin::DMARC - check DMARC policy |
| |
| =head1 SYNOPSIS |
| |
| loadplugin Mail::SpamAssassin::Plugin::DMARC |
| |
| ifplugin Mail::SpamAssassin::Plugin::DMARC |
| header DMARC_PASS eval:check_dmarc_pass() |
| describe DMARC_PASS DMARC pass policy |
| tflags DMARC_PASS net nice |
| score DMARC_PASS -0.001 |
| |
| header DMARC_REJECT eval:check_dmarc_reject() |
| describe DMARC_REJECT DMARC reject policy |
| tflags DMARC_REJECT net |
| score DMARC_REJECT 0.001 |
| |
| header DMARC_QUAR eval:check_dmarc_quarantine() |
| describe DMARC_QUAR DMARC quarantine policy |
| tflags DMARC_QUAR net |
| score DMARC_QUAR 0.001 |
| |
| header DMARC_NONE eval:check_dmarc_none() |
| describe DMARC_NONE DMARC none policy |
| tflags DMARC_NONE net |
| score DMARC_NONE 0.001 |
| |
| header DMARC_MISSING eval:check_dmarc_missing() |
| describe DMARC_MISSING Missing DMARC policy |
| tflags DMARC_MISSING net |
| score DMARC_MISSING 0.001 |
| endif |
| |
| =head1 DESCRIPTION |
| |
| This plugin checks if emails match DMARC policy, the plugin needs both DKIM |
| and SPF plugins enabled. |
| |
| =cut |
| |
| package Mail::SpamAssassin::Plugin::DMARC; |
| |
| use strict; |
| use warnings; |
| use re 'taint'; |
| |
| my $VERSION = 0.2; |
| |
| use Mail::SpamAssassin; |
| use Mail::SpamAssassin::Plugin; |
| use Mail::SpamAssassin::Util qw(is_fqdn_valid); |
| |
| our @ISA = qw(Mail::SpamAssassin::Plugin); |
| |
| sub dbg { my $msg = shift; Mail::SpamAssassin::Logger::dbg("DMARC: $msg", @_); } |
| sub info { my $msg = shift; Mail::SpamAssassin::Logger::info("DMARC: $msg", @_); } |
| |
| sub new { |
| my ($class, $mailsa) = @_; |
| |
| $class = ref($class) || $class; |
| my $self = $class->SUPER::new($mailsa); |
| bless ($self, $class); |
| |
| $self->set_config($mailsa->{conf}); |
| $self->register_eval_rule("check_dmarc_pass", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS); |
| $self->register_eval_rule("check_dmarc_reject", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS); |
| $self->register_eval_rule("check_dmarc_quarantine", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS); |
| $self->register_eval_rule("check_dmarc_none", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS); |
| $self->register_eval_rule("check_dmarc_missing", $Mail::SpamAssassin::Conf::TYPE_HEAD_EVALS); |
| |
| return $self; |
| } |
| |
| sub set_config { |
| my ($self, $conf) = @_; |
| my @cmds; |
| |
| =over 4 |
| |
| =item dmarc_save_reports ( 0 | 1 ) (default: 0) |
| |
| Store DMARC reports using Mail::DMARC::Store, mail-dmarc.ini must be configured to save and send DMARC reports. |
| |
| =back |
| |
| =cut |
| |
| push(@cmds, { |
| setting => 'dmarc_save_reports', |
| is_admin => 1, |
| default => 0, |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOL, |
| }); |
| |
| $conf->{parser}->register_commands(\@cmds); |
| } |
| |
| sub parsed_metadata { |
| my ($self, $opts) = @_; |
| my $pms = $opts->{permsgstatus}; |
| |
| # Force waiting of SPF and DKIM results |
| $pms->{dmarc_async_queue} = []; |
| } |
| |
| sub _check_eval { |
| my ($self, $pms, $result) = @_; |
| |
| if (exists $pms->{dmarc_async_queue}) { |
| my $rulename = $pms->get_current_eval_rule_name(); |
| push @{$pms->{dmarc_async_queue}}, sub { |
| if ($result->()) { |
| $pms->got_hit($rulename, '', ruletype => 'header'); |
| } else { |
| $pms->rule_ready($rulename); |
| } |
| }; |
| return; # return undef for async status |
| } |
| |
| $self->_check_dmarc($pms); |
| # make sure not to return undef, as this is not async anymore |
| return $result->() || 0; |
| } |
| |
| sub check_dmarc_pass { |
| my ($self, $pms, $name) = @_; |
| |
| my $result = sub { |
| defined $pms->{dmarc_result} && |
| $pms->{dmarc_result} eq 'pass' && |
| $pms->{dmarc_policy} ne 'no policy available'; |
| }; |
| |
| return $self->_check_eval($pms, $result); |
| } |
| |
| sub check_dmarc_reject { |
| my ($self, $pms, $name) = @_; |
| |
| my $result = sub { |
| defined $pms->{dmarc_result} && |
| $pms->{dmarc_result} eq 'fail' && |
| $pms->{dmarc_policy} eq 'reject'; |
| }; |
| |
| return $self->_check_eval($pms, $result); |
| } |
| |
| sub check_dmarc_quarantine { |
| my ($self, $pms, $name) = @_; |
| |
| my $result = sub { |
| defined $pms->{dmarc_result} && |
| $pms->{dmarc_result} eq 'fail' && |
| $pms->{dmarc_policy} eq 'quarantine'; |
| }; |
| |
| return $self->_check_eval($pms, $result); |
| } |
| |
| sub check_dmarc_none { |
| my ($self, $pms, $name) = @_; |
| |
| my $result = sub { |
| defined $pms->{dmarc_result} && |
| $pms->{dmarc_result} eq 'fail' && |
| $pms->{dmarc_policy} eq 'none'; |
| }; |
| |
| return $self->_check_eval($pms, $result); |
| } |
| |
| sub check_dmarc_missing { |
| my ($self, $pms, $name) = @_; |
| |
| my $result = sub { |
| defined $pms->{dmarc_result} && |
| $pms->{dmarc_policy} eq 'no policy available'; |
| }; |
| |
| return $self->_check_eval($pms, $result); |
| } |
| |
| sub check_tick { |
| my ($self, $opts) = @_; |
| |
| $self->_check_async_queue($opts->{permsgstatus}); |
| } |
| |
| sub check_cleanup { |
| my ($self, $opts) = @_; |
| |
| # Finish it whether SPF and DKIM is ready or not |
| $self->_check_async_queue($opts->{permsgstatus}, 1); |
| } |
| |
| sub _check_async_queue { |
| my ($self, $pms, $finish) = @_; |
| |
| return unless exists $pms->{dmarc_async_queue}; |
| |
| # Check if SPF or DKIM is ready |
| if ($finish || ($pms->{spf_checked} && $pms->{dkim_checked_signature})) { |
| $self->_check_dmarc($pms); |
| $_->() foreach (@{$pms->{dmarc_async_queue}}); |
| # No more async queueing needed. If any evals are called later, they |
| # will act on the results directly. |
| delete $pms->{dmarc_async_queue}; |
| } |
| } |
| |
| sub _check_dmarc { |
| my ($self, $pms, $name) = @_; |
| |
| return unless $pms->is_dns_available(); |
| |
| # Load DMARC module |
| if (!exists $self->{has_mail_dmarc}) { |
| my $eval_stat; |
| eval { |
| require Mail::DMARC::PurePerl; |
| } or do { |
| $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; |
| }; |
| if (!defined($eval_stat)) { |
| dbg("using Mail::DMARC::PurePerl for DMARC checks"); |
| $self->{has_mail_dmarc} = 1; |
| } else { |
| dbg("cannot load Mail::DMARC::PurePerl: module: $eval_stat"); |
| dbg("Mail::DMARC::PurePerl is required for DMARC checks, DMARC checks disabled"); |
| $self->{has_mail_dmarc} = undef; |
| } |
| } |
| |
| return if !$self->{has_mail_dmarc}; |
| return if $pms->{dmarc_checked}; |
| $pms->{dmarc_checked} = 1; |
| |
| my $lasthop = $pms->{relays_external}->[0]; |
| if (!defined $lasthop) { |
| dbg("no external relay found, skipping DMARC check"); |
| return; |
| } |
| |
| my $from_addr = ($pms->get('From:first:addr'))[0]; |
| return if not defined $from_addr; |
| return if index($from_addr, '@') == -1; |
| |
| my $mfrom_domain = ($pms->get('EnvelopeFrom:first:addr:host'))[0]; |
| if (!defined $mfrom_domain) { |
| $mfrom_domain = ($pms->get('From:first:addr:domain'))[0]; |
| return if !defined $mfrom_domain; |
| dbg("EnvelopeFrom header not found, using From"); |
| } |
| if(!is_fqdn_valid($mfrom_domain)) { |
| dbg("Invalid domain name $mfrom_domain"); |
| return; |
| } |
| |
| my $spf_status = 'none'; |
| if ($pms->{spf_pass}) { $spf_status = 'pass'; } |
| elsif ($pms->{spf_fail}) { $spf_status = 'fail'; } |
| elsif ($pms->{spf_permerror}) { $spf_status = 'fail'; } |
| elsif ($pms->{spf_none}) { $spf_status = 'fail'; } |
| elsif ($pms->{spf_neutral}) { $spf_status = 'neutral'; } |
| elsif ($pms->{spf_softfail}) { $spf_status = 'softfail'; } |
| |
| my $spf_helo_status = 'none'; |
| if ($pms->{spf_helo_pass}) { $spf_helo_status = 'pass'; } |
| elsif ($pms->{spf_helo_fail}) { $spf_helo_status = 'fail'; } |
| elsif ($pms->{spf_helo_permerror}) { $spf_helo_status = 'fail'; } |
| elsif ($pms->{spf_helo_none}) { $spf_helo_status = 'fail'; } |
| elsif ($pms->{spf_helo_neutral}) { $spf_helo_status = 'neutral'; } |
| elsif ($pms->{spf_helo_softfail}) { $spf_helo_status = 'softfail'; } |
| |
| my $dmarc = Mail::DMARC::PurePerl->new(); |
| $dmarc->source_ip($lasthop->{ip}); |
| $dmarc->header_from_raw($from_addr); |
| |
| my $suppl_attrib = $pms->{msg}->{suppl_attrib}; |
| if (defined $suppl_attrib && exists $suppl_attrib->{dkim_signatures}) { |
| my $dkim_signatures = $suppl_attrib->{dkim_signatures}; |
| foreach my $signature ( @$dkim_signatures ) { |
| $dmarc->dkim( domain => $signature->domain, result => $signature->result ); |
| dbg("DKIM result for domain " . $signature->domain . ": " . $signature->result); |
| } |
| } else { |
| $dmarc->dkim($pms->{dkim_verifier}) if (ref($pms->{dkim_verifier})); |
| } |
| |
| my $result; |
| eval { |
| $dmarc->spf([ |
| { |
| scope => 'mfrom', |
| domain => $mfrom_domain, |
| result => $spf_status, |
| }, |
| { |
| scope => 'helo', |
| domain => $lasthop->{lc_helo}, |
| result => $spf_helo_status, |
| }, |
| ]); |
| $result = $dmarc->validate(); |
| }; |
| if ($@) { |
| dbg("error while evaluating domain $mfrom_domain: $@"); |
| return; |
| } |
| |
| my $dmarc_arc_verified = 0; |
| if (($result->result ne 'pass') and (ref($pms->{arc_verifier}) and ($pms->{arc_verifier}->result eq 'pass'))) { |
| undef $result; |
| $dmarc_arc_verified = 1; |
| # if DMARC fails retry by reading data from AAR headers |
| # use Mail::SpamAssassin::Plugin::AuthRes if available to read ARC signature details |
| my @spf_parsed = sort { ( $a->{authres_parsed}{spf}{arc_index} // 0 ) <=> ( $b->{authres_parsed}{spf}{arc_index} // 0 ) } @{$pms->{authres_parsed}{spf} // []}; |
| my $old_arc_index = 0; |
| foreach my $spf_parse ( @spf_parsed ) { |
| last if not defined $spf_parse->{arc_index}; |
| last if $old_arc_index > $spf_parse->{arc_index}; |
| dbg("Evaluate DMARC using AAR spf information for index $spf_parse->{arc_index}"); |
| if(exists $spf_parse->{properties}{smtp}{mailfrom}) { |
| my $mfrom_dom = $spf_parse->{properties}{smtp}{mailfrom}; |
| if($mfrom_dom =~ /\@(.*)/) { |
| $mfrom_dom = $1; |
| } else { |
| $mfrom_dom = $mfrom_domain |
| } |
| $dmarc->spf([ |
| { |
| scope => 'mfrom', |
| domain => $mfrom_dom, |
| result => $spf_parse->{result}, |
| } |
| ]); |
| } |
| if(exists $spf_parse->{properties}{smtp}{helo}) { |
| $dmarc->spf([ |
| { |
| scope => 'helo', |
| domain => $spf_parse->{properties}{smtp}{helo}, |
| result => $spf_parse->{result}, |
| } |
| ]); |
| } |
| $old_arc_index = $spf_parse->{arc_index}; |
| } |
| |
| my @tmp_arc_seals; |
| my @arc_seals; |
| if(defined $pms->{arc_verifier}{seals}) { |
| @tmp_arc_seals = @{$pms->{arc_verifier}{seals}}; |
| @arc_seals = sort { ( $a->{arc_verifier}{seals}{tags_by_name}{i}{value} // 0 ) <=> ( $b->{arc_verifier}{seals}{tags_by_name}{i}{value} // 0 ) } @tmp_arc_seals; |
| foreach my $seals ( @arc_seals ) { |
| if(exists($seals->{tags_by_name}{d}) and exists($pms->{arc_author_domains}->{$mfrom_domain})) { |
| dbg("Evaluate DMARC using AAR dkim information for index $seals->{tags_by_name}{i}{value} on domain $mfrom_domain and selector $seals->{tags_by_name}{s}{value}. Result is $seals->{verify_result}"); |
| my $arc_result = $seals->{verify_result}; |
| if($seals->{verify_result} eq 'invalid') { |
| $arc_result = 'permerror'; |
| } |
| $dmarc->dkim(domain => $mfrom_domain, selector => $seals->{tags_by_name}{s}{value}, result => $arc_result); |
| last; |
| } |
| } |
| } |
| |
| eval { $result = $dmarc->validate(); }; |
| if ($@) { |
| dbg("error while validating domain $mfrom_domain: $@"); |
| return; |
| } |
| } |
| |
| if(defined $result and ($result->result ne 'none') and (defined $result->{published}) and ($result->published->can('stringify'))) { |
| dbg("Evaluated DMARC record \"" . $result->published->stringify . "\" for domain $mfrom_domain"); |
| } |
| |
| # Report that DMARC failed but it has been overridden because of AAR headers |
| if(ref($pms->{arc_verifier}) and ($pms->{arc_verifier}->result) and ($dmarc_arc_verified)) { |
| $result->reason->[0]{type} = 'local_policy'; |
| $result->reason->[0]{comment} = "arc=" . $pms->{arc_verifier}->result; |
| my $cnt = 1; |
| foreach my $seals ( @{$pms->{arc_verifier}{seals}} ) { |
| if(exists($seals->{tags_by_name}{d}) and exists($seals->{tags_by_name}{s})) { |
| $result->reason->[0]{comment} .= " as[$cnt].d=$seals->{tags_by_name}{d}{value} as[$cnt].s=$seals->{tags_by_name}{s}{value}"; |
| $cnt++; |
| } |
| } |
| if($cnt gt 1) { |
| $result->reason->[0]{comment} .= " remote-ip[1]=$lasthop->{ip}"; |
| } |
| } |
| |
| if (defined($pms->{dmarc_result} = $result->result)) { |
| if ($pms->{conf}->{dmarc_save_reports}) { |
| my $rua = eval { $result->published()->rua(); }; |
| if (defined $rua && index($rua, 'mailto:') >= 0) { |
| eval { $dmarc->save_aggregate(); }; |
| if ($@) { |
| info("report could not be saved: $@"); |
| } else { |
| dbg("report will be sent to $rua"); |
| } |
| } |
| } |
| |
| if (defined $result->reason->[0]{comment} && |
| $result->reason->[0]{comment} eq 'too many policies') { |
| dbg("result: no policy available (too many policies)"); |
| $pms->{dmarc_policy} = 'no policy available'; |
| } elsif ($result->result eq 'pass') { |
| dbg("result: pass"); |
| $pms->{dmarc_policy} = $result->published->p; |
| } elsif ($result->result ne 'none') { |
| dbg("result: $result->{result}, disposition: $result->{disposition}, dkim: $result->{dkim}, spf: $result->{spf} (spf: $spf_status, spf_helo: $spf_helo_status)"); |
| $pms->{dmarc_policy} = $result->disposition; |
| } else { |
| dbg("result: no policy available"); |
| $pms->{dmarc_policy} = 'no policy available'; |
| } |
| } |
| } |
| |
| 1; |
| |