File Coverage

blib/lib/Mail/Milter/Authentication/Handler/DMARC.pm
Criterion Covered Total %
statement 373 556 67.0
branch 139 272 51.1
condition 17 45 37.7
subroutine 34 37 91.8
pod 2 20 10.0
total 565 930 60.7


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::DMARC;
2 26     26   11617 use strict;
  26         76  
  26         805  
3 26     26   140 use warnings;
  26         66  
  26         678  
4 26     26   142 use base 'Mail::Milter::Authentication::Handler';
  26         61  
  26         2836  
5             our $VERSION = '20191206'; # VERSION
6              
7 26     26   186 use Data::Dumper;
  26         61  
  26         1568  
8 26     26   176 use English qw{ -no_match_vars };
  26         65  
  26         286  
9 26     26   13811 use Net::IP;
  26         73  
  26         4326  
10 26     26   220 use Sys::Syslog qw{:standard :macros};
  26         64  
  26         6926  
11 26     26   14727 use List::MoreUtils qw{ uniq };
  26         295738  
  26         184  
12              
13 26     26   39365 use Mail::DMARC::PurePerl 1.20160612;
  26         8648676  
  26         965  
14 26     26   285 use Mail::AuthenticationResults::Header::Entry;
  26         62  
  26         678  
15 26     26   159 use Mail::AuthenticationResults::Header::SubEntry;
  26         81  
  26         551  
16 26     26   150 use Mail::AuthenticationResults::Header::Comment;
  26         68  
  26         132365  
17              
18             my $PSL_CHECKED_TIME;
19              
20             sub default_config {
21             return {
22 0     0 0 0 'hide_none' => 0,
23             'use_arc' => 1,
24             'hard_reject' => 0,
25             'no_list_reject' => 1,
26             'arc_before_list' => 0,
27             'whitelisted' => [],
28             'detect_list_id' => 1,
29             'report_skip_to' => [ 'my_report_from_address@example.com' ],
30             'no_report' => 0,
31             'config_file' => '/etc/mail-dmarc.ini',
32             'no_reject_disposition' => 'quarantine',
33             'no_list_reject_disposition' => 'none',
34             };
35             }
36              
37             sub grafana_rows {
38 0     0 0 0 my ( $self ) = @_;
39 0         0 my @rows;
40 0         0 push @rows, $self->get_json( 'DMARC_metrics' );
41 0         0 return \@rows;
42             }
43              
44             sub is_whitelisted {
45 57     57 0 234 my ( $self ) = @_;
46 57         249 my $config = $self->handler_config();
47 57 100       277 return 0 if not exists( $config->{'whitelisted'} );
48 8         78 my $top_handler = $self->get_top_handler();
49 8         33 my $ip_obj = $top_handler->{'ip_object'};
50 8         26 my $whitelisted = 0;
51 8         24 foreach my $entry ( @{ $config->{'whitelisted'} } ) {
  8         66  
52             # This does not consider dkim/spf results added by a passing arc chain
53             # we consider this out of scope at this point.
54 10 50       168 if ( $entry =~ /^dnswl:/ ) {
    100          
    50          
55 0         0 my ( $dummy, $type, $rbl ) = split( /:/, $entry, 3 );
56 0 0       0 if ( $type eq 'spf' ) {
    0          
    0          
57 0         0 eval {
58 0         0 my $spf = $self->get_handler('SPF');
59 0 0       0 if ( $spf ) {
60 0         0 my $got_spf_result = $spf->{'dmarc_result'};
61 0 0       0 if ( $got_spf_result eq 'pass' ) {
62 0         0 my $got_spf_domain = $spf->{'dmarc_domain'};
63 0 0       0 if ( $self->rbl_check_domain( $got_spf_domain, $rbl ) ) {
64 0         0 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_INFO );
65 0         0 $whitelisted = 1;
66             }
67             }
68             }
69             };
70 0         0 $self->handle_exception( $@ );
71             }
72             elsif ( $type eq 'dkim' ) {
73 0         0 my $dkim_handler = $self->get_handler('DKIM');
74 0         0 foreach my $dkim_domain( sort keys %{ $dkim_handler->{'valid_domains'}} ) {
  0         0  
75 0 0       0 if ( $self->rbl_check_domain( $dkim_domain, $rbl ) ) {
76 0         0 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_INFO );
77 0         0 $whitelisted = 1;
78             }
79             }
80             }
81             elsif ( $type eq 'ip' ) {
82 0 0       0 if ( $self->rbl_check_ip( $ip_obj, $rbl ) ) {
83 0         0 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_INFO );
84 0         0 $whitelisted = 1;
85             }
86             }
87             }
88             elsif ( $entry =~ /^dkim:/ ) {
89 2         15 my ( $dummy, $dkim_domain ) = split( /:/, $entry, 2 );
90 2         14 my $dkim_handler = $self->get_handler('DKIM');
91 2 50       13 if ( exists( $dkim_handler->{'valid_domains'}->{ lc $dkim_domain } ) ) {
92 2         15 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_INFO );
93 2         6 $whitelisted = 1;
94             }
95             }
96             elsif ( $entry =~ /^spf:/ ) {
97 0         0 my ( $dummy, $spf_domain ) = split( /:/, $entry, 2 );
98 0         0 eval {
99 0         0 my $spf = $self->get_handler('SPF');
100 0 0       0 if ( $spf ) {
101 0         0 my $got_spf_result = $spf->{'dmarc_result'};
102 0 0       0 if ( $got_spf_result eq 'pass' ) {
103 0         0 my $got_spf_domain = $spf->{'dmarc_domain'};
104 0 0       0 if ( lc $got_spf_domain eq lc $spf_domain ) {
105 0         0 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_INFO );
106 0         0 $whitelisted = 1;
107             }
108             }
109             }
110             };
111 0         0 $self->handle_exception( $@ );
112             }
113             else {
114 8         90 my $whitelisted_obj = Net::IP->new($entry);
115 8   100     7811 my $is_overlap = $ip_obj->overlaps($whitelisted_obj) || 0;
116 8 50 66     1586 if (
      66        
      33        
117             $is_overlap == $IP_A_IN_B_OVERLAP
118             || $is_overlap == $IP_B_IN_A_OVERLAP # Should never happen
119             || $is_overlap == $IP_PARTIAL_OVERLAP # Should never happen
120             || $is_overlap == $IP_IDENTICAL
121             )
122             {
123 2         15 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_INFO );
124 2         9 $whitelisted = 1;
125             }
126             }
127 10 100       58 return $whitelisted if $whitelisted;
128             }
129 4         20 return $whitelisted;
130             }
131              
132             sub pre_loop_setup {
133 25     25 0 84 my ( $self ) = @_;
134 25         66 $PSL_CHECKED_TIME = time;
135 25         198 my $dmarc = Mail::DMARC::PurePerl->new();
136 25         629 my $config = $self->{'config'};
137 25 50       136 if ( exists ( $config->{ 'config_file' } ) ) {
138 0 0       0 $self->log_error( 'DMARC config file does not exist' ) if ! exists $config->{ 'config_file' };
139 0         0 $dmarc->config( $config->{ 'config_file' } );
140             }
141 25         65 my $psl = eval { $dmarc->get_public_suffix_list(); };
  25         212  
142 25         2157080 $self->handle_exception( $@ );
143 25 50       116 if ( $psl ) {
144 25         249 $self->{'thischild'}->loginfo( 'DMARC Preloaded PSL' );
145             }
146             else {
147 0         0 $self->{'thischild'}->logerror( 'DMARC Could not preload PSL' );
148             }
149 25         826 return;
150             }
151              
152             sub pre_fork_setup {
153 24     24 0 78 my ( $self ) = @_;
154 24         81 my $now = time;
155 24         291 my $dmarc = Mail::DMARC::PurePerl->new();
156 24         495 my $config = $self->{'config'};
157 24 50       117 if ( exists ( $config->{ 'config_file' } ) ) {
158 0 0       0 $self->log_error( 'DMARC config file does not exist' ) if ! exists $config->{ 'config_file' };
159 0         0 $dmarc->config( $config->{ 'config_file' } );
160             }
161 24         66 my $check_time = 60*10; # Check no more often than every 10 minutes
162 24 50       108 if ( $now > $PSL_CHECKED_TIME + $check_time ) {
163 0         0 $PSL_CHECKED_TIME = $now;
164 0 0       0 if ( $dmarc->can( 'check_public_suffix_list' ) ) {
165 0 0       0 if ( $dmarc->check_public_suffix_list() ) {
166 0         0 $self->{'thischild'}->loginfo( 'DMARC PSL file has changed and has been reloaded' );
167             }
168             else {
169 0         0 $self->{'thischild'}->loginfo( 'DMARC PSL file has not changed since last loaded' );
170             }
171             }
172             else {
173 0         0 $self->{'thischild'}->loginfo( 'DMARC PSL file update checking not available' );
174             }
175             }
176 24         105 return;
177             }
178              
179             sub register_metrics {
180             return {
181 25     25 1 221 'dmarc_total' => 'The number of emails processed for DMARC',
182             };
183             }
184              
185             sub _process_arc_dmarc_for {
186 0     0   0 my ( $self, $env_domain_from, $header_domain ) = @_;
187              
188 0         0 my $config = $self->handler_config();
189 0         0 my $dmarc = $self->new_dmarc_object();
190 0         0 $dmarc->source_ip( $self->ip_address() );
191              
192             # Set the DMARC Envelope From Domain
193 0 0       0 if ( $env_domain_from ne q{} ) {
194 0         0 eval {
195 0         0 $dmarc->envelope_from( $env_domain_from );
196             };
197 0 0       0 if ( my $error = $@ ) {
198 0         0 $self->handle_exception( $error );
199 0         0 return;
200             }
201             }
202              
203             # Add the Envelope To
204 0         0 eval {
205 0         0 $dmarc->envelope_to( lc $self->get_domain_from( $self->{'env_to'} ) );
206             };
207 0 0       0 if ( my $error = $@ ) {
208 0         0 $self->handle_exception( $error );
209             }
210              
211             # Add the From Header
212 0         0 eval { $dmarc->header_from( $header_domain ) };
  0         0  
213 0 0       0 if ( my $error = $@ ) {
214 0         0 $self->handle_exception( $error );
215 0         0 return;
216             }
217              
218             # Add the SPF Results Object
219 0         0 eval {
220 0         0 my $spf = $self->get_handler('SPF');
221 0 0       0 if ( $spf ) {
222              
223 0 0 0     0 if ( $spf->{'dmarc_result'} eq 'pass' && lc $spf->{'dmarc_domain'} eq lc $header_domain ) {
    0          
224             # Have a matching local entry, use it.
225             ## TODO take org domains into consideration here
226             $dmarc->spf(
227             'domain' => $spf->{'dmarc_domain'},
228             'scope' => $spf->{'dmarc_scope'},
229 0         0 'result' => $spf->{'dmarc_result'},
230             );
231             }
232             elsif ( my $arc_spf = $self->get_handler('ARC')->get_trusted_spf_results() ) {
233             # Pull from ARC if we can
234             push @$arc_spf, {
235             'domain' => $spf->{'dmarc_domain'},
236             'scope' => $spf->{'dmarc_scope'},
237 0         0 'result' => $spf->{'dmarc_result'},
238             };
239 0         0 $dmarc->spf( $arc_spf );
240             }
241             else {
242             # Nothing else matched, use the local entry anyway
243             $dmarc->spf(
244             'domain' => $spf->{'dmarc_domain'},
245             'scope' => $spf->{'dmarc_scope'},
246 0         0 'result' => $spf->{'dmarc_result'},
247             );
248             }
249              
250             }
251             else {
252 0         0 $dmarc->{'spf'} = [];
253             }
254             };
255 0 0       0 if ( my $error = $@ ) {
256 0         0 $self->handle_exception( $error );
257 0         0 $dmarc->{'spf'} = [];
258             }
259              
260             # Add the DKIM Results
261 0         0 my $dkim_handler = $self->get_handler('DKIM');
262 0         0 my @dkim_values;
263 0         0 my $arc_values = $self->get_handler('ARC')->get_trusted_dkim_results();
264 0 0       0 if ( $arc_values ) {
265 0         0 foreach my $arc_value ( @$arc_values ) {
266 0         0 push @dkim_values, $arc_value;
267             }
268             }
269 0         0 $dmarc->{'dkim'} = \@dkim_values;
270             # Add the local DKIM object is it exists
271 0 0       0 if ( $dkim_handler->{'has_dkim'} ) {
272 0         0 my $dkim_object = $self->get_object('dkim');
273 0 0       0 if ( $dkim_object ) {
274 0         0 $dmarc->dkim( $dkim_object );
275             }
276             }
277              
278             # Run the Validator
279 0         0 my $dmarc_result = $dmarc->validate();
280 0         0 return $dmarc_result;
281             }
282              
283             sub _process_dmarc_for {
284 79     79   275 my ( $self, $env_domain_from, $header_domain ) = @_;
285              
286 79         320 my $config = $self->handler_config();
287              
288             # Get a fresh DMARC object each time.
289 79         432 $self->destroy_object('dmarc');
290 79         365 my $dmarc = $self->get_dmarc_object();
291 79         453 $dmarc->source_ip( $self->ip_address() );
292              
293             # Set the DMARC Envelope From Domain
294 57 100       47698 if ( $env_domain_from ne q{} ) {
295 55         150 eval {
296 55         504 $dmarc->envelope_from( $env_domain_from );
297             };
298 55 50       29559 if ( my $error = $@ ) {
299 0         0 $self->handle_exception( $error );
300 0 0       0 if ( $error =~ /invalid envelope_from at / ) {
301 0         0 $self->log_error( 'DMARC Invalid envelope from <' . $env_domain_from . '>' );
302 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'permerror' } );
303 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
304 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'envelope from invalid' ) );
305 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
306 0         0 $self->_add_dmarc_header( $header );
307             }
308             else {
309 0         0 $self->log_error( 'DMARC Mail From Error for <' . $env_domain_from . '> ' . $error );
310 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'temperror' } );
311 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'temperror' );
312 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'envelope from failed' ) );
313 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
314 0         0 $self->_add_dmarc_header( $header );
315             }
316 0         0 return;
317             }
318             }
319              
320             # Add the Envelope To
321 57         142 eval {
322 57         322 $dmarc->envelope_to( lc $self->get_domain_from( $self->{'env_to'} ) );
323             };
324 57 50       16643 if ( my $error = $@ ) {
325 0         0 $self->handle_exception( $error );
326 0         0 $self->log_error( 'DMARC Rcpt To Error ' . $error );
327             }
328              
329             # Add the From Header
330 57         137 eval { $dmarc->header_from( $header_domain ) };
  57         467  
331 57 50       15257 if ( my $error = $@ ) {
332 0         0 $self->handle_exception( $error );
333 0         0 $self->log_error( 'DMARC Header From Error ' . $error );
334 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'permerror' } );
335 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
336 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'from header invalid' ) );
337 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
338 0         0 $self->_add_dmarc_header( $header );
339 0         0 return;
340             }
341              
342 57         330 my $have_arc = ( $self->is_handler_loaded( 'ARC' ) );
343 57 50       202 if ( $have_arc ) {
344             # Does our ARC handler have the necessary methods?
345 0 0       0 $have_arc = 0 unless $self->get_handler('ARC')->can( 'get_trusted_arc_authentication_results' );
346             }
347 57 50       260 $have_arc = 0 if ! $config->{ 'use_arc' };
348              
349             # Add the SPF Results Object
350 57         128 eval {
351 57         231 my $spf = $self->get_handler('SPF');
352 57 50       217 if ( $spf ) {
353             $dmarc->spf(
354             'domain' => $spf->{'dmarc_domain'},
355             'scope' => $spf->{'dmarc_scope'},
356 57         706 'result' => $spf->{'dmarc_result'},
357             );
358             }
359             else {
360 0         0 $dmarc->{'spf'} = [];
361             }
362             };
363 57 50       9722 if ( my $error = $@ ) {
364 0         0 $self->handle_exception( $error );
365 0         0 $self->log_error( 'DMARC SPF Error: ' . $error );
366 0         0 $dmarc->{'spf'} = [];
367             }
368              
369             # Add the DKIM Results
370 57         233 my $dkim_handler = $self->get_handler('DKIM');
371 57 50       326 if ( $dkim_handler->{'failmode'} ) {
    100          
372 0         0 $dmarc->{'dkim'} = [];
373             }
374             elsif ( $dkim_handler->{'has_dkim'} ) {
375 28         115 my $dkim_object = $self->get_object('dkim');
376 28 50       106 if ( $dkim_object ) {
377 28         178 $dmarc->dkim( $dkim_object );
378             }
379             else {
380 0         0 $dmarc->{'dkim'} = [];
381             }
382             }
383             else {
384 29         129 $dmarc->{'dkim'} = [];
385             }
386              
387             # Run the Validator
388 57         6909 my $dmarc_result = $dmarc->validate();
389 57         376526 my $is_subdomain = $dmarc->is_subdomain();
390              
391             # ToDo Set multiple dmarc_result objects here
392             # this will become relevant when the BIMI handler
393             # is production ready.
394 57         515 $self->set_object('dmarc_result', $dmarc_result, 1 );
395              
396 57         248 my $dmarc_code = $dmarc_result->result;
397 57         471 $self->dbgout( 'DMARCCode', $dmarc_code, LOG_INFO );
398              
399 57         145 my $dmarc_disposition = eval { $dmarc_result->disposition() };
  57         259  
400 57 50       416 if ( my $error = $@ ) {
401 0         0 $self->handle_exception( $error );
402 0 0       0 if ( $dmarc_code ne 'pass' ) {
403 0         0 $self->log_error( 'DMARCPolicyError ' . $error );
404             }
405             }
406 57         260 $self->dbgout( 'DMARCDisposition', $dmarc_disposition, LOG_INFO );
407 57         141 my $dmarc_disposition_evaluated = $dmarc_disposition;
408              
409 57 50       311 $self->dbgout( 'DMARCSubdomain', $is_subdomain ? 'yes' : 'no', LOG_INFO );
410              
411 57         131 my $dmarc_policy = eval{ $dmarc_result->published()->p(); };
  57         303  
412 57         4381 $self->handle_exception( $@ );
413             # If we didn't get a result, set to none.
414 57 100       196 $dmarc_policy = 'none' if ! $dmarc_policy;
415 57         123 my $dmarc_sub_policy = eval{ $dmarc_result->published()->sp(); };
  57         181  
416 57         1875 $self->handle_exception( $@ );
417             # If we didn't get a result, set to none.
418 57 50       296 $dmarc_sub_policy = 'default' if ! $dmarc_sub_policy;
419 57         432 $self->dbgout( 'DMARCPolicy', "$dmarc_policy $dmarc_sub_policy", LOG_INFO );
420              
421 57         225 my $policy_override;
422              
423 57         185 my $arc_aware_result = '';
424             # Re-evaluate non passes taking ARC into account if possible.
425 57 50 33     277 if ( $have_arc && $dmarc_code eq 'fail' ) {
426 0         0 my $arc_result = $self->_process_arc_dmarc_for( $env_domain_from, $header_domain );
427 0         0 $arc_aware_result = $arc_result->result;
428             }
429              
430 57         405 my $is_whitelisted = $self->is_whitelisted();
431              
432             # Reject mail and/or set policy override reasons
433 57 100       243 if ( $dmarc_code eq 'fail' ) {
434             # Policy override decisions.
435 24 50 33     333 if ( $arc_aware_result eq 'pass' ) {
    100          
    50          
436 0         0 $self->dbgout( 'DMARCReject', "Policy overridden using ARC Chain", LOG_INFO );
437 0         0 $dmarc_result->disposition('none');
438 0         0 $dmarc_disposition = 'none';
439 0         0 $dmarc_result->reason( 'type' => 'trusted_forwarder', 'comment' => 'Policy overriden using trusted ARC chain' );
440             }
441             elsif ( $is_whitelisted ) {
442 4         117 $self->dbgout( 'DMARCReject', "Policy reject overridden by whitelist", LOG_INFO );
443 4         20 $policy_override = 'trusted_forwarder';
444 4         35 $dmarc_result->reason( 'type' => $policy_override, 'comment' => 'Policy ignored due to local white list' );
445 4         775 $dmarc_result->disposition('none');
446 4         107 $dmarc_disposition = 'none';
447             }
448             elsif ( $config->{'no_list_reject'} && $self->{'is_list'} ) {
449 0 0 0     0 if ( $config->{'arc_before_list'} && $have_arc && $self->get_handler('ARC')->get_trusted_arc_authentication_results ) {
      0        
450 0         0 $self->dbgout( 'DMARCReject', "Policy reject not overridden for list mail with trusted ARC chain", LOG_INFO );
451             }
452             else {
453 0         0 $self->dbgout( 'DMARCReject', "Policy reject overridden for list mail", LOG_INFO );
454 0         0 $policy_override = 'mailing_list';
455 0         0 $dmarc_result->reason( 'type' => $policy_override, 'comment' => 'Policy ignored due to local mailing list policy' );
456 0   0     0 my $no_list_reject_disposition = $config->{ 'no_list_reject_disposition' } // 'none';
457 0         0 $dmarc_result->disposition( $no_list_reject_disposition );
458 0         0 $dmarc_disposition = $no_list_reject_disposition;
459             }
460             }
461              
462 24 100       101 if ( $dmarc_disposition eq 'reject' ) {
463 9 100       36 if ( $config->{'hard_reject'} ) {
464 2         60 $self->reject_mail( '550 5.7.0 DMARC policy violation' );
465 2         19 $self->dbgout( 'DMARCReject', "Policy reject", LOG_INFO );
466             }
467             else {
468 7         38 $policy_override = 'local_policy';
469 7         49 $dmarc_result->reason( 'type' => $policy_override, 'comment' => 'Reject ignored due to local policy' );
470 7   50     598 my $no_reject_disposition = $config->{ 'no_reject_disposition' } // 'quarantine';
471 7         48 $dmarc_result->disposition( $no_reject_disposition );
472 7         148 $dmarc_disposition = $no_reject_disposition;
473             }
474             }
475             }
476              
477 57 100       216 if ( $dmarc_disposition eq 'quarantine' ) {
478 7         110 $self->quarantine_mail( 'Quarantined due to DMARC policy' );
479             }
480              
481             # Add the AR Header
482 57         158 my @comments;
483 57 50 33     277 if ( !( $config->{'hide_none'} && $dmarc_code eq 'none' ) ) {
484 57         411 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( $dmarc_code );
485              
486             # What comments can we add?
487 57 50       4335 if ( $dmarc_policy ) {
488 57         369 push @comments, $self->format_header_entry( 'p', $dmarc_policy );
489 57         459 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.published-domain-policy' )->safe_set_value( $dmarc_policy ) );
490             }
491 57 50       6232 if ( $dmarc_sub_policy ne 'default' ) {
492 0         0 push @comments, $self->format_header_entry( 'sp', $dmarc_sub_policy );
493 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.published-subdomain-policy' )->safe_set_value( $dmarc_sub_policy ) );
494             }
495 57 100 66     510 if ( $config->{'detect_list_id'} && $self->{'is_list'} ) {
496 1         5 push @comments, 'has-list-id=yes';
497             }
498 57 50       207 if ( $dmarc_disposition ) {
499 57         233 push @comments, $self->format_header_entry( 'd', $dmarc_disposition );
500 57         271 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.applied-disposition' )->safe_set_value( $dmarc_disposition ) );
501             }
502 57 50       4936 if ( $dmarc_disposition_evaluated ) {
503 57         225 push @comments, $self->format_header_entry( 'd.eval', $dmarc_disposition_evaluated );
504 57         260 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.evaluated-disposition' )->safe_set_value( $dmarc_disposition_evaluated ) );
505             }
506 57 100       4943 if ( $policy_override ) {
507 11         71 push @comments, $self->format_header_entry( 'override', $policy_override );
508 11         54 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.override-reason' )->safe_set_value( $policy_override ) );
509             }
510 57 50       1188 if ( $arc_aware_result ) {
511 0         0 push @comments, $self->format_header_entry( 'arc_aware_result', $arc_aware_result );
512 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.arc-aware-result' )->safe_set_value( $arc_aware_result ) );
513             }
514              
515 57 50       241 if ( @comments ) {
516 57         267 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( join( ',', @comments ) ) );
517             }
518              
519 57 50 33     26408 my $policy_used = ( $is_subdomain && $dmarc_sub_policy ne 'default' ) ? 'sp' : 'p';
520 57         214 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.policy-from' )->safe_set_value( $policy_used ) );
521              
522 57         4956 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
523 57         5127 $self->_add_dmarc_header( $header );
524             }
525              
526             # Write Metrics
527             my $metric_data = {
528             'result' => $dmarc_code,
529             'disposition' => $dmarc_disposition,
530             'policy' => $dmarc_policy,
531 57 100       995 'is_list' => ( $self->{'is_list'} ? '1' : '0' ),
    100          
    50          
    50          
532             'is_whitelisted' => ( $is_whitelisted ? '1' : '0'),
533             'arc_aware_result' => $arc_aware_result,
534             'used_arc' => ( $arc_aware_result ? '1' : '0' ),
535             'is_subdomain' => ( $is_subdomain ? '1' : '0' ),
536             };
537 57         351 $self->metric_count( 'dmarc_total', $metric_data );
538              
539             # Try as best we can to save a report, but don't stress if it fails.
540 57         134 my $rua = eval { $dmarc_result->published()->rua(); };
  57         294  
541 57         2414 $self->handle_exception( $@ );
542 57 100       198 if ($rua) {
543 46 50       199 if ( ! $config->{'no_report'} ) {
544 46 50       174 if ( ! $self->{'skip_report'} ) {
545 46         205 $self->dbgout( 'DMARCReportTo', $rua, LOG_INFO );
546 46         114 push @{ $self->{'report_queue'} }, $dmarc;
  46         202  
547             }
548             else {
549 0         0 $self->dbgout( 'DMARCReportTo (skipped flag)', $rua, LOG_INFO );
550             }
551             }
552             else {
553 0         0 $self->dbgout( 'DMARCReportTo (skipped)', $rua, LOG_INFO );
554             }
555             }
556              
557 57         378 return;
558             }
559              
560             sub get_dmarc_object {
561 83     83 0 224 my ( $self ) = @_;
562 83         369 my $dmarc = $self->get_object('dmarc');
563 83 100       294 if ( $dmarc ) {
564 4         10 return $dmarc;
565             }
566              
567 79         310 $dmarc = $self->new_dmarc_object();
568 79         380 $self->set_object('dmarc', $dmarc,1 );
569 79         201 return $dmarc;
570             }
571              
572             sub new_dmarc_object {
573 153     153 0 420 my ( $self ) = @_;
574              
575 153         457 my $config = $self->{'config'};
576 153         396 my $dmarc;
577              
578 153         327 eval {
579 153         1968 $dmarc = Mail::DMARC::PurePerl->new();
580 153 50       3900 if ( exists ( $config->{ 'config_file' } ) ) {
581 0 0       0 $self->log_error( 'DMARC config file does not exist' ) if ! exists $config->{ 'config_file' };
582 0         0 $dmarc->config( $config->{ 'config_file' } );
583             }
584 153 50       1733 if ( $dmarc->can('set_resolver') ) {
585 153         597 my $resolver = $self->get_object('resolver');
586 153         994 $dmarc->set_resolver($resolver);
587             }
588 153 0 33     1592 if ( $config->{'debug'} && $config->{'logtoerr'} ) {
589 0         0 $dmarc->verbose(1);
590             }
591 153         759 $self->set_object('dmarc', $dmarc,1 );
592             };
593 153 50       617 if ( my $error = $@ ) {
594 0         0 $self->handle_exception( $error );
595 0         0 $self->log_error( 'DMARC IP Error ' . $error );
596 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
597 0         0 $self->add_auth_header( $header );
598 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'permerror' } );
599 0         0 $self->{'failmode'} = 1;
600             }
601              
602 153         450 return $dmarc;
603             }
604              
605             sub helo_callback {
606 82     82 0 262 my ( $self, $helo_host ) = @_;
607 82         287 $self->{'helo_name'} = $helo_host;
608 82 50       505 $self->{'report_queue'} = [] if ! $self->{'report_queue'};
609 82         315 return;
610             }
611              
612             sub envfrom_requires {
613 17     17 0 110 my ($self) = @_;
614 17         223 my @requires = qw{ SPF };
615 17         247 return \@requires;
616             }
617              
618             sub envfrom_callback {
619 82     82 0 275 my ( $self, $env_from ) = @_;
620 82 100       383 return if ( $self->is_local_ip_address() );
621 74 100       277 return if ( $self->is_trusted_ip_address() );
622 72 50       330 return if ( $self->is_authenticated() );
623 72         212 delete $self->{'from_header'};
624 72         352 $self->{'is_list'} = 0;
625 72         297 $self->{'skip_report'} = 0;
626 72         191 $self->{'failmode'} = 0;
627              
628 72 100       247 $env_from = q{} if $env_from eq '<>';
629              
630 72 50       225 if ( ! $self->is_handler_loaded( 'SPF' ) ) {
631 0         0 $self->log_error( 'DMARC Config Error: SPF is missing ');
632 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'error' } );
633 0         0 $self->{'failmode'} = 1;
634 0         0 return;
635             }
636 72 50       247 if ( ! $self->is_handler_loaded( 'DKIM' ) ) {
637 0         0 $self->log_error( 'DMARC Config Error: DKIM is missing ');
638 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'error' } );
639 0         0 $self->{'failmode'} = 1;
640 0         0 return;
641             }
642              
643 72 100       250 if ( $env_from ) {
644 67         307 $self->{ 'env_from' } = $env_from;
645             }
646             else {
647 5         27 $self->{ 'env_from' } = q{};
648             }
649              
650 72         388 $self->{ 'from_headers' } = [];
651              
652 72         217 return;
653             }
654              
655             sub check_skip_address {
656 72     72 0 224 my ( $self, $env_to ) = @_;
657 72         379 $env_to = lc $self->get_address_from( $env_to );
658 72         418 my $config = $self->handler_config();
659 72 50       313 return 0 if not exists( $config->{'report_skip_to'} );
660 0         0 foreach my $address ( @{ $config->{'report_skip_to'} } ) {
  0         0  
661 0 0       0 if ( lc $address eq lc $env_to ) {
662 0         0 $self->dbgout( 'DMARCReportSkip', 'Skip address detected: ' . $env_to, LOG_INFO );
663 0         0 $self->{'skip_report'} = 1;
664             }
665             }
666 0         0 return;
667             }
668              
669             sub envrcpt_callback {
670 82     82 0 279 my ( $self, $env_to ) = @_;
671 82 100       344 return if ( $self->is_local_ip_address() );
672 74 100       290 return if ( $self->is_trusted_ip_address() );
673 72 50       258 return if ( $self->is_authenticated() );
674              
675 72         365 $self->{ 'env_to' } = $env_to;
676 72         363 $self->check_skip_address( $env_to );
677              
678 72         231 return;
679             }
680              
681             sub header_callback {
682 727     727 0 2466 my ( $self, $header, $value ) = @_;
683 727 100       2529 return if ( $self->is_local_ip_address() );
684 610 100       1811 return if ( $self->is_trusted_ip_address() );
685 580 50       2202 return if ( $self->is_authenticated() );
686 580 50       1649 return if ( $self->{'failmode'} );
687              
688 580 100       1714 if ( lc $header eq 'list-id' ) {
689 1         9 $self->dbgout( 'DMARCListId', 'List ID detected: ' . $value, LOG_INFO );
690 1         11 $self->{'is_list'} = 1;
691             }
692 580 50       1531 if ( lc $header eq 'list-post' ) {
693 0         0 $self->dbgout( 'DMARCListId', 'List Post detected: ' . $value, LOG_INFO );
694 0         0 $self->{'is_list'} = 1;
695             }
696              
697 580 100       1571 if ( lc $header eq 'from' ) {
698 74 100       286 if ( exists $self->{'from_header'} ) {
699 4         17 $self->dbgout( 'DMARCFail', 'Multiple RFC5322 from fields', LOG_INFO );
700             }
701 74         292 $self->{'from_header'} = $value;
702 74         176 push @{ $self->{ 'from_headers' } }, $value;
  74         311  
703 74         431 my $domain = lc $self->get_domain_from( $value );
704 74 50       252 if ( $domain ) {
705 74         231 my $lookup = '_dmarc.'.$domain;
706 74         390 my $resolver = $self->get_object('resolver');
707 74         718 $resolver->bgsend( $lookup, 'TXT' );
708 74         119510 $self->dbgout( 'DNSEarlyLookup', "$lookup TXT", LOG_DEBUG );
709 74         515 my $dmarc = $self->new_dmarc_object();
710 74         185 my $org_domain = eval{ $dmarc->get_organizational_domain( $domain ) };
  74         708  
711 74         8591 $self->handle_exception( $@ );
712 74 100 66     619 if ( $org_domain && ($org_domain ne $domain) ) {
713 4         11 my $lookup = '_dmarc.'.$org_domain;
714 4         17 my $resolver = $self->get_object('resolver');
715 4         18 $resolver->bgsend( $lookup, 'TXT' );
716 4         3639 $self->dbgout( 'DNSEarlyLookup', "$lookup TXT", LOG_DEBUG );
717             }
718             }
719              
720             }
721 580         1478 return;
722             }
723              
724             sub eom_requires {
725 17     17 0 131 my ($self) = @_;
726 17         216 my @requires = qw{ DKIM };
727              
728 17 50       449 if ( $self->is_handler_loaded( 'ARC' ) ) {
729 0         0 push @requires, 'ARC';
730             }
731              
732 17         186 return \@requires;
733             }
734              
735             sub eom_callback {
736 82     82 0 281 my ($self) = @_;
737 82         406 my $config = $self->handler_config();
738              
739 82 100       385 return if ( $self->is_local_ip_address() );
740 74 100       368 return if ( $self->is_trusted_ip_address() );
741 72 50       364 return if ( $self->is_authenticated() );
742 72 50       316 return if ( $self->{'failmode'} );
743              
744 72         216 my $env_from = $self->{ 'env_from' };
745 72         569 my $env_domains_from = $self->get_domains_from($env_from);
746 72 100       254 $env_domains_from = [''] if ! @$env_domains_from;
747              
748 72         219 my $from_headers = $self->{ 'from_headers' };
749              
750             # Build a list of all from header domains used
751 72         161 my @header_domains;
752 72         279 foreach my $from_header ( @$from_headers ) {
753 74         280 my $from_header_header_domains = $self->get_domains_from( $from_header );
754 74         256 foreach my $header_domain ( @$from_header_header_domains ) {
755 78         265 push @header_domains, $header_domain;
756             }
757             }
758              
759 72         383 $self->{ 'dmarc_ar_headers' } = [];
760             # There will usually be only one, however this could be a source route
761             # so we consider multiples just incase
762 72         827 foreach my $env_domain_from ( uniq sort @$env_domains_from ) {
763 74         400 foreach my $header_domain ( uniq sort @header_domains ) {
764 79         166 eval {
765 79         507 $self->_process_dmarc_for( $env_domain_from, $header_domain );
766             };
767 79 100       346 if ( my $error = $@ ) {
768 22         85 $self->handle_exception( $error );
769 22 50       99 if ( $error =~ /invalid header_from at / ) {
770 0         0 $self->log_error( 'DMARC Error invalid header_from <' . $self->{'from_header'} . '>' );
771 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
772 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
773 0         0 $self->_add_dmarc_header( $header );
774             }
775             else {
776 22         102 $self->log_error( 'DMARC Error ' . $error );
777 22         99 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'temperror' );
778 22         1416 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
779 22         2031 $self->_add_dmarc_header( $header );
780             }
781             }
782 79         430 $self->check_timeout();
783             }
784             }
785              
786 72 100       206 if ( @{ $self->{ 'dmarc_ar_headers' } } ) {
  72         351  
787 69         193 foreach my $dmarc_header ( @{ $self->_get_unique_dmarc_headers() } ) {
  69         289  
788 75         390 $self->add_auth_header( $dmarc_header );
789             }
790             }
791             else {
792             # We got no headers at all? That's bogus!
793 3         17 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
794 3         177 $self->add_auth_header( $header );
795             }
796              
797 72         246 delete $self->{ 'dmarc_ar_headers' };
798              
799 72         319 return;
800             }
801              
802             sub can_sort_header {
803 8     8 1 23 my ( $self, $header ) = @_;
804 8 50       39 return 1 if $header eq 'dmarc';
805 0         0 return 0;
806             }
807              
808              
809             sub handler_header_sort {
810 8     8 0 22 my ( $self, $pa, $pb ) = @_;
811              
812             # ToDo, do this without stringify
813 8         29 my ( $result_a, $policy_a ) = $pa->as_string() =~ /^dmarc=([a-z]+) .*policy\.applied\-disposition=([a-z]+)/;
814 8         9397 my ( $result_b, $policy_b ) = $pb->as_string() =~ /^dmarc=([a-z]+) .*policy\.applied\-disposition=([a-z]+)/;
815              
816             # Fail then None then Pass
817 8 100       9579 if ( $result_a ne $result_b ) {
818 4 50       14 return -1 if $result_a eq 'fail';
819 4 50       34 return 1 if $result_b eq 'fail';
820 0 0       0 return -1 if $result_a eq 'none';
821 0 0       0 return 1 if $result_b eq 'none';
822             }
823              
824             # Reject then Quarantine then None
825 4 100       67 if ( $policy_a ne $policy_b ) {
826 2 50       8 return -1 if $policy_a eq 'reject';
827 2 50       7 return 1 if $policy_b eq 'reject';
828 2 50       17 return -1 if $policy_a eq 'quarantine';
829 0 0       0 return 1 if $policy_b eq 'quarantine';
830             }
831              
832 2         23 return $pa cmp $pb;
833             }
834              
835             sub _get_unique_dmarc_headers {
836 69     69   200 my ( $self ) = @_;
837              
838 69         217 my $unique_strings = {};
839 69         161 my @unique_headers;
840              
841             # Returns unique headers based on as_string for each header
842 69         164 foreach my $header ( @{ $self->{ 'dmarc_ar_headers' } } ) {
  69         319  
843 79         630 my $as_string = $header->as_string();
844 79 100       108167 next if exists $unique_strings->{ $as_string };
845 75         302 $unique_strings->{ $as_string } = 1;
846 75         251 push @unique_headers, $header;
847             }
848              
849 69         320 return \@unique_headers;
850             }
851              
852             sub _add_dmarc_header {
853 79     79   224 my ( $self, $header ) = @_;
854 79         172 push @{ $self->{ 'dmarc_ar_headers' } }, $header;
  79         329  
855 79         258 return;
856             }
857              
858             sub addheader_callback {
859 104     104 0 263 my $self = shift;
860 104         231 my $handler = shift;
861 104         299 return;
862             }
863              
864             sub _save_aggregate_reports {
865 97     97   272 my ( $self ) = @_;
866 97 100       542 return if ! $self->{'report_queue'};
867             # Try as best we can to save a report, but don't stress if it fails.
868 61         170 eval {
869 61         505 $self->set_handler_alarm( 2 * 1000000 ); # Allow no longer than 2 seconds for this!
870 61         200 while ( my $report = shift @{ $self->{'report_queue'} } ) {
  106         3583  
871 46         387 $report->save_aggregate();
872 45         26364779 $self->dbgout( 'DMARC Report saved for', $report->result()->published()->rua(), LOG_INFO );
873             }
874 60         632 $self->reset_alarm();
875             };
876 61 100       327 if ( my $Error = $@ ) {
877 1         9 $self->reset_alarm();
878 1         6 my $Type = $self->is_exception_type( $Error );
879 1 50       4 if ( $Type ) {
880 1 50       5 if ( $Type eq 'Timeout' ) {
881             # We have a timeout, is it global or is it ours?
882 1 50       6 if ( $self->get_time_remaining() > 0 ) {
883             # We have time left, but the aggregate save timed out
884             # Log this and move on!
885 1         6 $self->log_error( 'DMARC timeout saving reports' );
886 1         31 return;
887             }
888             }
889             }
890 0         0 $self->handle_exception( $Error );
891 0         0 $self->log_error( 'DMARC Report Error ' . $Error );
892             }
893 60         152 return;
894             }
895              
896             sub close_callback {
897 97     97 0 296 my ( $self ) = @_;
898 97         473 $self->_save_aggregate_reports();
899 97         334 delete $self->{'helo_name'};
900 97         295 delete $self->{'env_from'};
901 97         261 delete $self->{'env_to'};
902 97         255 delete $self->{'failmode'};
903 97         272 delete $self->{'skip_report'};
904 97         277 delete $self->{'is_list'};
905 97         224 delete $self->{'from_header'};
906 97         227 delete $self->{'from_heades'};
907 97         251 delete $self->{'report_queue'};
908 97         582 $self->destroy_object('dmarc');
909 97         465 $self->destroy_object('dmarc_result');
910 97         290 return;
911             }
912              
913             1;
914              
915             __END__
916              
917             =pod
918              
919             =encoding UTF-8
920              
921             =head1 NAME
922              
923             Mail::Milter::Authentication::Handler::DMARC
924              
925             =head1 VERSION
926              
927             version 20191206
928              
929             =head1 DESCRIPTION
930              
931             Module implementing the DMARC standard checks.
932              
933             This handler requires the SPF and DKIM handlers to be installed and active.
934              
935             =head1 CONFIGURATION
936              
937             "DMARC" : { | Config for the DMARC Module
938             | Requires DKIM and SPF
939             "hard_reject" : 0, | Reject mail which fails with a reject policy
940             "no_reject_disposition" : "quarantine", | What to report when hard_reject is 0
941             "no_list_reject" : 0, | Do not reject mail detected as mailing list
942             "arc_before_list" : 0, | Don't apply above list detection if we have trusted arc
943             "no_list_reject_disposition" : "none", | Disposition to use for mail detected as mailing list (defaults none)
944             "whitelisted" : [ | A list of ip addresses or CIDR ranges, or dkim domains
945             "10.20.30.40", | for which we do not want to hard reject mail on fail p=reject
946             "dkim:bad.forwarder.com", | (valid) DKIM signing domains can also be whitelisted by
947             "20.30.40.0/24" | having an entry such as "dkim:domain.com"
948             ],
949             "use_arc" : 1, | Use trusted ARC results if available
950             "hide_none" : 0, | Hide auth line if the result is 'none'
951             "detect_list_id" : "1", | Detect a list ID and modify the DMARC authentication header
952             | to note this, useful when making rules for junking email
953             | as mailing lists frequently cause false DMARC failures.
954             "report_skip_to" : [ | Do not send DMARC reports for emails to these addresses.
955             "dmarc@yourdomain.com", | This can be used to avoid report loops for email sent to
956             "dmarc@example.com" | your report from addresses.
957             ],
958             "no_report" : "1", | If set then we will not attempt to store DMARC reports.
959             "config_file" : "/etc/mail-dmarc.ini" | Optional path to dmarc config file
960             },
961              
962             =head1 AUTHOR
963              
964             Marc Bradshaw <marc@marcbradshaw.net>
965              
966             =head1 COPYRIGHT AND LICENSE
967              
968             This software is copyright (c) 2018 by Marc Bradshaw.
969              
970             This is free software; you can redistribute it and/or modify it under
971             the same terms as the Perl 5 programming language system itself.
972              
973             =cut