File Coverage

blib/lib/Mail/Milter/Authentication/Handler/DMARC.pm
Criterion Covered Total %
statement 469 646 72.6
branch 177 332 53.3
condition 30 60 50.0
subroutine 35 35 100.0
pod 2 22 9.0
total 713 1095 65.1


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::DMARC;
2 37     37   23062 use 5.20.0;
  37         189  
3 37     37   328 use strict;
  37         111  
  37         1017  
4 37     37   238 use warnings;
  37         182  
  37         1104  
5 37     37   283 use Mail::Milter::Authentication::Pragmas;
  37         168  
  37         575  
6             # ABSTRACT: Handler class for DMARC
7             our $VERSION = '3.20230629'; # VERSION
8 37     37   9734 use base 'Mail::Milter::Authentication::Handler';
  37         136  
  37         4331  
9 37     37   384 use List::MoreUtils qw{ uniq };
  37         110  
  37         1034  
10 37     37   54543 use Mail::DMARC::PurePerl 1.20160612;
  37         12215062  
  37         1573  
11 37     37   406 use Net::IP;
  37         131  
  37         390896  
12              
13             my $PSL_CHECKED_TIME;
14              
15             sub default_config {
16             return {
17 1     1 0 1771 'hide_none' => 0,
18             'use_arc' => 1,
19             'hard_reject' => 0,
20             'no_list_reject' => 1,
21             'arc_before_list' => 0,
22             'whitelisted' => [],
23             'policy_rbl_lookup' => {},
24             'detect_list_id' => 1,
25             'report_skip_to' => [ 'my_report_from_address@example.com' ],
26             'report_suppression_list' => 'rbl.example.com',
27             'no_report' => 0,
28             'hide_report_to' => 0,
29             'config_file' => '/etc/mail-dmarc.ini',
30             'no_reject_disposition' => 'quarantine',
31             'no_list_reject_disposition' => 'none',
32             'reject_on_multifrom' => 30,
33             'quarantine_on_multifrom' => 20,
34             'skip_on_multifrom' => 10,
35             };
36             }
37              
38             sub grafana_rows {
39 1     1 0 4941 my ( $self ) = @_;
40 1         3 my @rows;
41 1         12 push @rows, $self->get_json( 'DMARC_metrics' );
42 1         9 return \@rows;
43             }
44              
45             sub is_whitelisted {
46 111     111 0 440 my ( $self ) = @_;
47 111         534 my $config = $self->handler_config();
48 111 100       1029 return 0 if not exists( $config->{'whitelisted'} );
49 8         35 my $top_handler = $self->get_top_handler();
50 8         50 my $ip_obj = $top_handler->{'ip_object'};
51 8         34 my $whitelisted = 0;
52 8         31 foreach my $entry ( @{ $config->{'whitelisted'} } ) {
  8         91  
53             # This does not consider dkim/spf results added by a passing arc chain
54             # we consider this out of scope at this point.
55 10 50       224 if ( $entry =~ /^dnswl:/ ) {
    100          
    50          
56 0         0 my ( $dummy, $type, $rbl ) = split( /:/, $entry, 3 );
57 0 0       0 if ( $type eq 'spf' ) {
    0          
    0          
58 0         0 eval {
59 0         0 my $spf = $self->get_handler('SPF');
60 0 0       0 if ( $spf ) {
61 0         0 my $got_spf_result = $spf->{'dmarc_result'};
62 0 0       0 if ( $got_spf_result eq 'pass' ) {
63 0         0 my $got_spf_domain = $spf->{'dmarc_domain'};
64 0 0       0 if ( $self->rbl_check_domain( $got_spf_domain, $rbl ) ) {
65 0         0 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_DEBUG );
66 0         0 $whitelisted = 1;
67             }
68             }
69             }
70             };
71 0         0 $self->handle_exception( $@ );
72             }
73             elsif ( $type eq 'dkim' ) {
74 0         0 my $dkim_handler = $self->get_handler('DKIM');
75 0         0 foreach my $dkim_domain( sort keys %{ $dkim_handler->{'valid_domains'}} ) {
  0         0  
76 0 0       0 if ( $self->rbl_check_domain( $dkim_domain, $rbl ) ) {
77 0         0 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_DEBUG );
78 0         0 $whitelisted = 1;
79             }
80             }
81             }
82             elsif ( $type eq 'ip' ) {
83 0 0       0 if ( $self->rbl_check_ip( $ip_obj, $rbl ) ) {
84 0         0 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_DEBUG );
85 0         0 $whitelisted = 1;
86             }
87             }
88             }
89             elsif ( $entry =~ /^dkim:/ ) {
90 2         18 my ( $dummy, $dkim_domain ) = split( /:/, $entry, 2 );
91 2         23 my $dkim_handler = $self->get_handler('DKIM');
92 2 50       14 if ( exists( $dkim_handler->{'valid_domains'}->{ lc $dkim_domain } ) ) {
93 2         46 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_DEBUG );
94 2         9 $whitelisted = 1;
95             }
96             }
97             elsif ( $entry =~ /^spf:/ ) {
98 0         0 my ( $dummy, $spf_domain ) = split( /:/, $entry, 2 );
99 0         0 eval {
100 0         0 my $spf = $self->get_handler('SPF');
101 0 0       0 if ( $spf ) {
102 0         0 my $got_spf_result = $spf->{'dmarc_result'};
103 0 0       0 if ( $got_spf_result eq 'pass' ) {
104 0         0 my $got_spf_domain = $spf->{'dmarc_domain'};
105 0 0       0 if ( lc $got_spf_domain eq lc $spf_domain ) {
106 0         0 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_DEBUG );
107 0         0 $whitelisted = 1;
108             }
109             }
110             }
111             };
112 0         0 $self->handle_exception( $@ );
113             }
114             else {
115 8         95 my $whitelisted_obj = Net::IP->new($entry);
116 8 50       7304 if ( !$whitelisted_obj ) {
117 0         0 $self->log_error( 'DMARC: Could not parse whitelist IP '.$entry );
118             }
119             else {
120 8   100     107 my $is_overlap = $ip_obj->overlaps($whitelisted_obj) || 0;
121 8 50 66     1706 if (
      66        
      33        
122             $is_overlap == $IP_A_IN_B_OVERLAP
123             || $is_overlap == $IP_B_IN_A_OVERLAP # Should never happen
124             || $is_overlap == $IP_PARTIAL_OVERLAP # Should never happen
125             || $is_overlap == $IP_IDENTICAL
126             )
127             {
128 2         38 $self->dbgout( 'DMARCReject', "Whitelist hit " . $entry, LOG_DEBUG );
129 2         18 $whitelisted = 1;
130             }
131             }
132             }
133 10 100       75 return $whitelisted if $whitelisted;
134             }
135 4         20 return $whitelisted;
136             }
137              
138             sub pre_loop_setup {
139 37     37 0 126 my ( $self ) = @_;
140 37         119 $PSL_CHECKED_TIME = time;
141 37         392 my $dmarc = Mail::DMARC::PurePerl->new();
142 37         2007 my $config = $self->handler_config();
143 37 50       184 if ( exists ( $config->{ 'config_file' } ) ) {
144 0 0       0 $self->log_error( 'DMARC config file does not exist' ) if ! -e $config->{ 'config_file' };
145 0         0 $dmarc->config( $config->{ 'config_file' } );
146             }
147 37         111 my $psl = eval { $dmarc->get_public_suffix_list(); };
  37         428  
148 37         3244430 $self->handle_exception( $@ );
149 37 50       2708 if ( $psl ) {
150 37         465 $self->{'thischild'}->loginfo( 'DMARC Preloaded PSL' );
151             }
152             else {
153 0         0 $self->{'thischild'}->logerror( 'DMARC Could not preload PSL' );
154             }
155             }
156              
157             sub pre_fork_setup {
158 33     33 0 151 my ( $self ) = @_;
159 33         117 my $now = time;
160 33         413 my $dmarc = Mail::DMARC::PurePerl->new();
161 33         1993 my $config = $self->handler_config();
162 33 50       212 if ( exists ( $config->{ 'config_file' } ) ) {
163 0 0       0 $self->log_error( 'DMARC config file does not exist' ) if ! -e $config->{ 'config_file' };
164 0         0 $dmarc->config( $config->{ 'config_file' } );
165             }
166 33         111 my $check_time = 60*10; # Check no more often than every 10 minutes
167 33 50       416 if ( $now > $PSL_CHECKED_TIME + $check_time ) {
168 0         0 $PSL_CHECKED_TIME = $now;
169 0 0       0 if ( $dmarc->can( 'check_public_suffix_list' ) ) {
170 0 0       0 if ( $dmarc->check_public_suffix_list() ) {
171 0         0 $self->{'thischild'}->loginfo( 'DMARC PSL file has changed and has been reloaded' );
172             }
173             else {
174 0         0 $self->{'thischild'}->loginfo( 'DMARC PSL file has not changed since last loaded' );
175             }
176             }
177             else {
178 0         0 $self->{'thischild'}->loginfo( 'DMARC PSL file update checking not available' );
179             }
180             }
181             }
182              
183             sub register_metrics {
184             return {
185 37     37 1 734 'dmarc_total' => 'The number of emails processed for DMARC',
186             'dmarc_reports_total' => { type => 'gauge', help => 'The number of pending DMARC reports' },
187             };
188             }
189              
190             sub metrics_callback {
191 10     10 0 127 my ( $self ) = @_;
192 10         191 my $config = $self->handler_config();
193 10 50       227 return if $config->{'no_report'};
194              
195 10         48 eval {
196 10         113 my $time = time;
197 10         1721 my $backend = Mail::DMARC::Report::Store->new()->backend;
198 10         486261 my $current = $backend->query("SELECT COUNT(1) AS c FROM report WHERE end >= $time")->[0]->{c};
199 10         315595 my $pending = $backend->query("SELECT COUNT(1) AS c FROM report WHERE end < $time")->[0]->{c};
200 10         4745 $self->metric_set( 'dmarc_reports_total', { 'state' => 'current' }, $current );
201 10         185 $self->metric_set( 'dmarc_reports_total', { 'state' => 'pending' }, $pending );
202             };
203             }
204              
205             sub _process_arc_dmarc_for {
206 2     2   11 my ( $self, $env_domain_from, $header_domain ) = @_;
207              
208 2         8 my $config = $self->handler_config();
209 2         17 my $original_dmarc = $self->get_object('dmarc');
210 2         11 my $dmarc = $self->new_dmarc_object();
211 2         13 $dmarc->source_ip( $self->ip_address() );
212              
213             # Set the DMARC Envelope From Domain
214 2 50       1396 if ( $env_domain_from ne q{} ) {
215 2         7 eval {
216 2         7 $dmarc->envelope_from( $env_domain_from );
217             };
218 2 50       647 if ( my $error = $@ ) {
219 0         0 $self->handle_exception( $error );
220 0         0 $self->set_object('dmarc', $original_dmarc,1 ); # Restore original saved DMARC object
221 0         0 return;
222             }
223             }
224              
225             # Add the Envelope To
226 2 50       10 unless ( $config->{'hide_report_to'} ) {
227 2         8 eval {
228 2         18 $dmarc->envelope_to( lc $self->get_domain_from( $self->{'env_to'} ) );
229             };
230 2 50       606 if ( my $error = $@ ) {
231 0         0 $self->handle_exception( $error );
232             }
233             }
234              
235             # Add the From Header
236 2         6 eval { $dmarc->header_from( $header_domain ) };
  2         9  
237 2 50       588 if ( my $error = $@ ) {
238 0         0 $self->handle_exception( $error );
239 0         0 $self->set_object('dmarc', $original_dmarc,1 ); # Restore original saved DMARC object
240 0         0 return;
241             }
242              
243             # Add the SPF Results Object
244 2         7 eval {
245 2         14 my $spf = $self->get_handler('SPF');
246 2 50       9 if ( $spf ) {
247              
248 2 50 33     18 if ( $spf->{'dmarc_result'} eq 'pass' && lc $spf->{'dmarc_domain'} eq lc $header_domain ) {
    100          
249             # Have a matching local entry, use it.
250             ## TODO take org domains into consideration here
251             $dmarc->spf(
252             'domain' => $spf->{'dmarc_domain'},
253             'scope' => $spf->{'dmarc_scope'},
254 0         0 'result' => $spf->{'dmarc_result'},
255             );
256             }
257             elsif ( my $arc_spf = $self->get_handler('ARC')->get_trusted_spf_results() ) {
258             # Pull from ARC if we can
259             push @$arc_spf, {
260             'domain' => $spf->{'dmarc_domain'},
261             'scope' => $spf->{'dmarc_scope'},
262 1         11 'result' => $spf->{'dmarc_result'},
263             };
264 1         7 $dmarc->spf( $arc_spf );
265             }
266             else {
267             # Nothing else matched, use the local entry anyway
268             $dmarc->spf(
269             'domain' => $spf->{'dmarc_domain'},
270             'scope' => $spf->{'dmarc_scope'},
271 1         10 'result' => $spf->{'dmarc_result'},
272             );
273             }
274              
275             }
276             else {
277 0         0 $dmarc->{'spf'} = [];
278             }
279             };
280 2 50       252 if ( my $error = $@ ) {
281 0         0 $self->handle_exception( $error );
282 0         0 $dmarc->{'spf'} = [];
283             }
284              
285             # Add the DKIM Results
286 2         10 my $dkim_handler = $self->get_handler('DKIM');
287 2         6 my @dkim_values;
288 2         8 my $arc_values = $self->get_handler('ARC')->get_trusted_dkim_results();
289 2 100       11 if ( $arc_values ) {
290 1         5 foreach my $arc_value ( @$arc_values ) {
291 1         5 push @dkim_values, $arc_value;
292             }
293             }
294 2         9 $dmarc->{'dkim'} = \@dkim_values;
295             # Add the local DKIM object is it exists
296 2 50       9 if ( $dkim_handler->{'has_dkim'} ) {
297 0         0 my $dkim_object = $self->get_object('dkim');
298 0 0       0 if ( $dkim_object ) {
299 0         0 $dmarc->dkim( $dkim_object );
300             }
301             }
302              
303             # Run the Validator
304 2         11 my $dmarc_result = $dmarc->validate();
305 2         6754 return $dmarc_result;
306             }
307              
308             sub _process_dmarc_for {
309 111     111   632 my ( $self, $env_domain_from, $header_domain ) = @_;
310              
311 111         509 my $config = $self->handler_config();
312              
313 111 50       1142 if ( exists $self->{'processed'}->{ "$env_domain_from $header_domain" } ) {
314 0         0 $self->log_error( "DMARC already processed for $env_domain_from $header_domain" );
315 0         0 return;
316             }
317 111         895 $self->{'processed'}->{ "$env_domain_from $header_domain" } = 1;
318              
319 111 50       568 if ( $config->{'reject_on_multifrom'} ) {
320 0 0       0 if ( scalar keys $self->{'processed'}->%* == $config->{'reject_on_multifrom'} ) {
    0          
321 0         0 $self->log_error( 'DMARC limit reached, rejecting' );
322 0         0 $self->reject_mail( '550 5.7.0 DMARC policy violation' );
323 0         0 $self->log_error( "DMARC limit reached, skipping processing for $env_domain_from $header_domain" );
324 0         0 return;
325             }
326             elsif ( scalar keys $self->{'processed'}->%* > $config->{'reject_on_multifrom'} ) {
327 0         0 $self->log_error( "DMARC limit reached, skipping processing for $env_domain_from $header_domain" );
328 0         0 return;
329             }
330             }
331 111 50       514 if ( $config->{'quarantine_on_multifrom'} ) {
332 0 0       0 if ( scalar keys $self->{'processed'}->%* == $config->{'quarantine_on_multifrom'} ) {
    0          
333 0         0 $self->log_error( 'DMARC limit reached, quarantining' );
334 0         0 $self->quarantine_mail( 'Quarantined due to DMARC policy' );
335 0         0 $self->log_error( "DMARC limit reached, skipping processing for $env_domain_from $header_domain" );
336 0         0 return;
337             }
338             elsif ( scalar keys $self->{'processed'}->%* > $config->{'quarantine_on_multifrom'} ) {
339 0         0 $self->log_error( "DMARC limit reached, skipping processing for $env_domain_from $header_domain" );
340 0         0 return;
341             }
342             }
343 111 50       474 if ( $config->{'skip_on_multifrom'} ) {
344 0 0       0 if ( scalar keys $self->{'processed'}->%* >= $config->{'skip_on_multifrom'} ) {
345 0         0 $self->log_error( "DMARC limit reached, skipping processing for $env_domain_from $header_domain" );
346 0         0 return;
347             }
348             }
349              
350             # Get a fresh DMARC object each time.
351 111         807 $self->destroy_object('dmarc');
352 111         1008 my $dmarc = $self->get_dmarc_object();
353 111         893 $dmarc->source_ip( $self->ip_address() );
354              
355             # Set the DMARC Envelope From Domain
356 111 100       115534 if ( $env_domain_from ne q{} ) {
357 107         324 eval {
358 107         1105 $dmarc->envelope_from( $env_domain_from );
359             };
360 107 100       63103 if ( my $error = $@ ) {
361 2         18 $self->handle_exception( $error );
362 2         26 $self->log_error( 'DMARC Mail From Error for <' . $env_domain_from . '> ' . $error );
363             }
364             }
365              
366             # Add the Envelope To
367 111 50       678 unless ( $config->{'hide_report_to'} ) {
368 111         344 eval {
369 111         800 $dmarc->envelope_to( lc $self->get_domain_from( $self->{'env_to'} ) );
370             };
371 111 50       35717 if ( my $error = $@ ) {
372 0         0 $self->handle_exception( $error );
373 0         0 $self->log_error( 'DMARC Rcpt To Error ' . $error );
374             }
375             }
376              
377             # Add the From Header
378 111         338 eval { $dmarc->header_from( $header_domain ) };
  111         922  
379 111 50       33184 if ( my $error = $@ ) {
380 0         0 $self->handle_exception( $error );
381 0         0 $self->log_error( 'DMARC Header From Error ' . $error );
382 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'permerror' } );
383 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
384 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'from header invalid' ) );
385 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
386 0         0 $self->_add_dmarc_header( $header );
387 0         0 return;
388             }
389              
390 111         618 my $have_arc = ( $self->is_handler_loaded( 'ARC' ) );
391 111 100       513 if ( $have_arc ) {
392             # Does our ARC handler have the necessary methods?
393 10 50       64 $have_arc = 0 unless $self->get_handler('ARC')->can( 'get_trusted_arc_authentication_results' );
394             }
395 111 100       655 $have_arc = 0 if ! $config->{ 'use_arc' };
396              
397             # Add the SPF Results Object
398 111         286 eval {
399 111         588 my $spf = $self->get_handler('SPF');
400 111 50       532 if ( $spf ) {
401             $dmarc->spf(
402             'domain' => $spf->{'dmarc_domain'},
403             'scope' => $spf->{'dmarc_scope'},
404 111         1509 'result' => $spf->{'dmarc_result'},
405             );
406             }
407             else {
408 0         0 $dmarc->{'spf'} = [];
409             }
410             };
411 111 50       23168 if ( my $error = $@ ) {
412 0         0 $self->handle_exception( $error );
413 0         0 $self->log_error( 'DMARC SPF Error: ' . $error );
414 0         0 $dmarc->{'spf'} = [];
415             }
416              
417             # Add the DKIM Results
418 111         622 my $dkim_handler = $self->get_handler('DKIM');
419 111 50       842 if ( $dkim_handler->{'failmode'} ) {
    100          
420 0         0 $dmarc->{'dkim'} = [];
421             }
422             elsif ( $dkim_handler->{'has_dkim'} ) {
423 41         278 my $dkim_object = $self->get_object('dkim');
424 41 50       229 if ( $dkim_object ) {
425 41         380 $dmarc->dkim( $dkim_object );
426             }
427             else {
428 0         0 $dmarc->{'dkim'} = [];
429             }
430             }
431             else {
432 70         409 $dmarc->{'dkim'} = [];
433             }
434              
435             # Run the Validator
436 111         13016 my $dmarc_result = $dmarc->validate();
437 111         406327 my $is_subdomain = $dmarc->is_subdomain();
438              
439 111         1245 $self->set_object('dmarc_result', $dmarc_result, 1 );
440 111         868 my $dmarc_results = $self->get_object('dmarc_results');
441 111 100       708 $dmarc_results = [] if ! $dmarc_results;
442 111         357 push @$dmarc_results, $dmarc_result;
443 111         691 $self->set_object('dmarc_results',$dmarc_results,1);
444              
445 111         809 my $dmarc_code = $dmarc_result->result;
446 111         1236 $self->dbgout( 'DMARCCode', $dmarc_code, LOG_DEBUG );
447              
448 111         519 my $dmarc_disposition = eval { $dmarc_result->disposition() };
  111         681  
449 111 50       1080 if ( my $error = $@ ) {
450 0         0 $self->handle_exception( $error );
451 0 0       0 if ( $dmarc_code ne 'pass' ) {
452 0         0 $self->log_error( 'DMARCPolicyError ' . $error );
453             }
454             }
455 111         677 $self->dbgout( 'DMARCDisposition', $dmarc_disposition, LOG_DEBUG );
456 111         667 my $dmarc_disposition_evaluated = $dmarc_disposition;
457              
458 111 100       859 $self->dbgout( 'DMARCSubdomain', $is_subdomain ? 'yes' : 'no', LOG_DEBUG );
459              
460 111         419 my $dmarc_policy = eval{ $dmarc_result->published()->p(); };
  111         772  
461 111         17010 $self->handle_exception( $@ );
462             # If we didn't get a result, set to none.
463 111 100       602 $dmarc_policy = 'none' if ! $dmarc_policy;
464 111         343 my $dmarc_sub_policy = eval{ $dmarc_result->published()->sp(); };
  111         452  
465 111         8412 $self->handle_exception( $@ );
466             # If we didn't get a result, set to none.
467 111 50       792 $dmarc_sub_policy = 'default' if ! $dmarc_sub_policy;
468 111         886 $self->dbgout( 'DMARCPolicy', "$dmarc_policy $dmarc_sub_policy", LOG_DEBUG );
469              
470 111         526 my $policy_override;
471              
472 111         567 my $arc_aware_result = '';
473             # Re-evaluate non passes taking ARC into account if possible.
474 111 100 100     662 if ( $have_arc && $dmarc_code eq 'fail' ) {
475 2         14 my $arc_result = $self->_process_arc_dmarc_for( $env_domain_from, $header_domain );
476 2         11 $arc_aware_result = eval{$arc_result->result};
  2         11  
477 2         19 $self->handle_exception( $@ );
478 2 50       20 $arc_aware_result = '' if not defined $arc_aware_result;
479             }
480              
481 111         726 my $is_whitelisted = $self->is_whitelisted();
482              
483             # Reject mail and/or set policy override reasons
484 111 100       547 if ( $dmarc_code eq 'fail' ) {
485             # Policy override decisions.
486 30 100 33     331 if ( $arc_aware_result eq 'pass' ) {
    100          
    50          
487 1         6 $dmarc_result->disposition('none');
488 1         20 $dmarc_disposition = 'none';
489 1         4 my $comment = 'Policy overriden using trusted ARC chain';
490             # arc=pass as[2].d=d2.example as[2].s=s2 as[1].d=d1.example as[1].s=s3 remote-ip[1]=2001:DB8::1A
491 1         5 my $arc_object = $self->get_object('arc');
492 1         6 my $arc_signatures = $arc_object->{'signatures'};
493              
494 1         5 my $arc_handler = $self->get_handler('ARC');
495 1 50       9 if ( $arc_handler ) {
496 1 50       5 if ( $arc_handler->{ 'arc_result' } eq 'pass' ) {
497             # If it wasn't a pass then we wouldn't be in here.
498 1         4 $comment = 'arc=pass';
499 1         4 my $arc_auth_results = $arc_handler->{'arc_auth_results'};
500 1         5 foreach my $instance ( reverse sort keys %$arc_auth_results ) {
501 1         3 my $domain = '';
502 1         2 my $selector = '';
503 1         4 my $remote_ip = '';
504 1         5 foreach my $signature ( @$arc_signatures ) {
505 2 50       18 next if $signature->instance() ne $instance;
506 2         42 $domain = $signature->domain();
507 2         44 $selector = $signature->selector();
508             }
509 1         18 my $aar = $arc_auth_results->{$instance};
510 1         3 $remote_ip = eval{ $aar->search({ 'isa' => 'entry', 'key' => 'iprev' })->children()->[0]->search({ 'isa' => 'subentry', 'key' => 'smtp.remote-ip'})->children()->[0]->value(); };
  1         9  
511 1         241 $self->handle_exception( $@ );
512 1   33     11 $remote_ip //= eval{ $aar->search({ 'isa' => 'entry', 'key' => 'iprev' })->children()->[0]->search({ 'isa' => 'subentry', 'key' => 'policy.iprev'})->children()->[0]->value(); };
  1         31  
513 1         206 $self->handle_exception( $@ );
514              
515 1   50     6 $domain //= '';
516 1   50     4 $selector //= '';
517 1   50     8 $remote_ip //= '';
518              
519 1         9 $comment .= ' as['.$instance.'].d='.$domain.' as['.$instance.'].s='.$selector.' remote-ip['.$instance.']='.$remote_ip;
520             }
521             }
522             }
523 1         23 $self->dbgout( 'DMARCReject', "Policy overridden using ARC Chain: $comment", LOG_DEBUG );
524 1         10 $dmarc_result->reason( 'type' => 'local_policy', 'comment' => $comment );
525             }
526             elsif ( $is_whitelisted ) {
527 4         56 $self->dbgout( 'DMARCReject', "Policy reject overridden by whitelist", LOG_DEBUG );
528 4         31 $policy_override = 'trusted_forwarder';
529 4         46 $dmarc_result->reason( 'type' => $policy_override, 'comment' => 'Policy ignored due to local white list' );
530 4         977 $dmarc_result->disposition('none');
531 4         137 $dmarc_disposition = 'none';
532             }
533             elsif ( $config->{'no_list_reject'} && $self->{'is_list'} ) {
534 0 0 0     0 if ( $config->{'arc_before_list'} && $have_arc && $self->get_handler('ARC')->get_trusted_arc_authentication_results ) {
      0        
535 0         0 $self->dbgout( 'DMARCReject', "Policy reject not overridden for list mail with trusted ARC chain", LOG_DEBUG );
536             }
537             else {
538 0         0 $self->dbgout( 'DMARCReject', "Policy reject overridden for list mail", LOG_DEBUG );
539 0         0 $policy_override = 'mailing_list';
540 0         0 $dmarc_result->reason( 'type' => $policy_override, 'comment' => 'Policy ignored due to local mailing list policy' );
541 0   0     0 my $no_list_reject_disposition = $config->{ 'no_list_reject_disposition' } // 'none';
542 0         0 $dmarc_result->disposition( $no_list_reject_disposition );
543 0         0 $dmarc_disposition = $no_list_reject_disposition;
544             }
545             }
546              
547 30 100       278 if ( $dmarc_disposition eq 'reject' ) {
548 14 100       93 if ( $config->{'hard_reject'} ) {
549 2         31 $self->reject_mail( '550 5.7.0 DMARC policy violation' );
550 2         28 $self->dbgout( 'DMARCReject', "Policy reject", LOG_DEBUG );
551             }
552             else {
553 12         59 $policy_override = 'local_policy';
554 12         123 $dmarc_result->reason( 'type' => $policy_override, 'comment' => 'Reject ignored due to local policy' );
555 12   50     1423 my $no_reject_disposition = $config->{ 'no_reject_disposition' } // 'quarantine';
556 12         70 $dmarc_result->disposition( $no_reject_disposition );
557 12         339 $dmarc_disposition = $no_reject_disposition;
558             }
559             }
560             }
561              
562 111 100       570 if ( $dmarc_disposition eq 'quarantine' ) {
563 12         206 $self->quarantine_mail( 'Quarantined due to DMARC policy' );
564             }
565              
566             # Add the AR Header
567 111         1258 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( $dmarc_code );
568              
569             # Do any RBL lookups
570 111 0 0     8860 if ( $config->{'policy_rbl_lookup'} && ( $dmarc_code eq 'pass' || $arc_aware_result eq 'pass' )) {
      33        
571 0         0 foreach my $rbl ( sort keys $config->{'policy_rbl_lookup'}->%* ) {
572 0         0 my $rbl_data = $config->{'policy_rbl_lookup'}->{$rbl};
573 0         0 my $rbl_domain = $rbl_data->{'rbl'};
574 0         0 my $rbl_result = $self->rbl_check_domain( $header_domain, $rbl_domain );
575 0 0       0 if ( $rbl_result ) {
576             my $txt_result
577             = exists( $rbl_data->{'results'}->{$rbl_result} ) ? $rbl_data->{'results'}->{$rbl_result}
578 0 0       0 : exists( $rbl_data->{'results'}->{'*'} ) ? $rbl_data->{'results'}->{'*'}
    0          
579             : 'pass';
580 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.'.$rbl )->safe_set_value( $txt_result ) );
581             }
582             }
583             }
584              
585             # What comments can we add?
586 111         357 my @comments;
587 111 50       478 if ( $dmarc_policy ) {
588 111         1253 push @comments, $self->format_header_entry( 'p', $dmarc_policy );
589 111         1082 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.published-domain-policy' )->safe_set_value( $dmarc_policy ) );
590             }
591 111 50       12994 if ( $dmarc_sub_policy ne 'default' ) {
592 0         0 push @comments, $self->format_header_entry( 'sp', $dmarc_sub_policy );
593 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.published-subdomain-policy' )->safe_set_value( $dmarc_sub_policy ) );
594             }
595 111 100 100     1230 if ( $config->{'detect_list_id'} && $self->{'is_list'} ) {
596 1         3 push @comments, 'has-list-id=yes';
597             }
598 111 50       430 if ( $dmarc_disposition ) {
599 111         530 push @comments, $self->format_header_entry( 'd', $dmarc_disposition );
600 111         746 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.applied-disposition' )->safe_set_value( $dmarc_disposition ) );
601             }
602 111 50       11218 if ( $dmarc_disposition_evaluated ) {
603 111         820 push @comments, $self->format_header_entry( 'd.eval', $dmarc_disposition_evaluated );
604 111         764 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.evaluated-disposition' )->safe_set_value( $dmarc_disposition_evaluated ) );
605             }
606 111 100       11076 if ( $policy_override ) {
607 16         120 push @comments, $self->format_header_entry( 'override', $policy_override );
608 16         138 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.override-reason' )->safe_set_value( $policy_override ) );
609             }
610 111 100       2098 if ( $arc_aware_result ) {
611 2         13 push @comments, $self->format_header_entry( 'arc_aware_result', $arc_aware_result );
612 2         12 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.arc-aware-result' )->safe_set_value( $arc_aware_result ) );
613             }
614              
615 111 50       699 if ( @comments ) {
616 111         974 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( join( ',', @comments ) ) );
617             }
618              
619 111 50 66     54867 my $policy_used = ( $is_subdomain && $dmarc_sub_policy ne 'default' ) ? 'sp' : 'p';
620 111         537 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.policy-from' )->safe_set_value( $policy_used ) );
621              
622 111         11207 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
623 111         12029 $self->_add_dmarc_header( $header );
624              
625             # Write Metrics
626             my $metric_data = {
627             'result' => $dmarc_code,
628             'disposition' => $dmarc_disposition,
629             'policy' => $dmarc_policy,
630 111 100       2280 'is_list' => ( $self->{'is_list'} ? '1' : '0' ),
    100          
    100          
    100          
631             'is_whitelisted' => ( $is_whitelisted ? '1' : '0'),
632             'arc_aware_result' => $arc_aware_result,
633             'used_arc' => ( $arc_aware_result ? '1' : '0' ),
634             'is_subdomain' => ( $is_subdomain ? '1' : '0' ),
635             };
636 111         954 $self->metric_count( 'dmarc_total', $metric_data );
637              
638             # Try as best we can to save a report, but don't stress if it fails.
639 111         511 my $rua = eval { $dmarc_result->published()->rua(); };
  111         647  
640 111         9470 $self->handle_exception( $@ );
641 111 100       946 if ($rua) {
642 62 50       368 if ( ! $config->{'no_report'} ) {
643 62 50       295 if ( ! $self->{'skip_report'} ) {
644 62         363 $self->dbgout( 'DMARCReportTo', $rua, LOG_INFO );
645 62         293 push @{ $self->{'report_queue'} }, $dmarc;
  62         828  
646             }
647             else {
648 0         0 $self->dbgout( 'DMARCReportTo (skipped flag)', $rua, LOG_INFO );
649             }
650             }
651             else {
652 0         0 $self->dbgout( 'DMARCReportTo (skipped)', $rua, LOG_INFO );
653             }
654             }
655             }
656              
657             sub get_dmarc_object {
658 122     122 0 568 my ( $self ) = @_;
659 122         576 my $dmarc = $self->get_object('dmarc');
660 122 100       623 if ( $dmarc ) {
661 11         67 return $dmarc;
662             }
663              
664 111         580 $dmarc = $self->new_dmarc_object();
665 111         951 $self->set_object('dmarc', $dmarc,1 );
666 111         700 return $dmarc;
667             }
668              
669             sub new_dmarc_object {
670 219     219 0 767 my ( $self ) = @_;
671              
672 219         1037 my $config = $self->handler_config();
673 219         787 my $dmarc;
674              
675 219         616 eval {
676 219         3290 $dmarc = Mail::DMARC::PurePerl->new();
677 219 50       6065 if ( exists ( $config->{ 'config_file' } ) ) {
678 0 0       0 $self->log_error( 'DMARC config file does not exist' ) if ! -e $config->{ 'config_file' };
679 0         0 $dmarc->config( $config->{ 'config_file' } );
680             }
681 219 50       2185 if ( $dmarc->can('set_resolver') ) {
682 219         958 my $resolver = $self->get_object('resolver');
683 219         1610 $dmarc->set_resolver($resolver);
684             }
685 219 50 66     2572 if ( $self->config()->{'debug'} && $self->config()->{'logtoerr'} ) {
686 82         380 $dmarc->verbose(1);
687             }
688 219         1703 $self->set_object('dmarc', $dmarc,1 );
689             };
690 219 50       1467 if ( my $error = $@ ) {
691 0         0 $self->handle_exception( $error );
692 0         0 $self->log_error( 'DMARC IP Error ' . $error );
693 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
694 0         0 $self->add_auth_header( $header );
695 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'permerror' } );
696 0         0 $self->{'failmode'} = 1;
697             }
698              
699 219         952 return $dmarc;
700             }
701              
702             sub helo_callback {
703 114     114 0 662 my ( $self, $helo_host ) = @_;
704 114         523 $self->{'helo_name'} = $helo_host;
705 114 50       1063 $self->{'report_queue'} = [] if ! $self->{'report_queue'};
706             }
707              
708             sub envfrom_requires {
709 27     27 0 343 my ($self) = @_;
710 27         563 my @requires = qw{ SPF };
711 27         283 return \@requires;
712             }
713              
714             sub envfrom_callback {
715 114     114 0 835 my ( $self, $env_from ) = @_;
716 114 100       723 return if ( $self->is_local_ip_address() );
717 106 100       731 return if ( $self->is_trusted_ip_address() );
718 104 50       779 return if ( $self->is_authenticated() );
719 104         692 delete $self->{'from_header'};
720 104         695 $self->{'processed'} = {};
721 104         576 $self->{'is_list'} = 0;
722 104         420 $self->{'skip_report'} = 0;
723 104         358 $self->{'failmode'} = 0;
724              
725 104 100       446 $env_from = q{} if $env_from eq '<>';
726              
727 104 50       572 if ( ! $self->is_handler_loaded( 'SPF' ) ) {
728 0         0 $self->log_error( 'DMARC Config Error: SPF is missing ');
729 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'error' } );
730 0         0 $self->{'failmode'} = 1;
731 0         0 return;
732             }
733 104 50       674 if ( ! $self->is_handler_loaded( 'DKIM' ) ) {
734 0         0 $self->log_error( 'DMARC Config Error: DKIM is missing ');
735 0         0 $self->metric_count( 'dmarc_total', { 'result' => 'error' } );
736 0         0 $self->{'failmode'} = 1;
737 0         0 return;
738             }
739              
740 104 100       462 if ( $env_from ) {
741 99         627 $self->{ 'env_from' } = $env_from;
742             }
743             else {
744 5         63 $self->{ 'env_from' } = q{};
745             }
746              
747 104         596 $self->{ 'from_headers' } = [];
748             }
749              
750             sub check_skip_address {
751 104     104 0 585 my ( $self, $env_to ) = @_;
752 104         635 $env_to = lc $self->get_address_from( $env_to );
753 104         817 my $config = $self->handler_config();
754 104 50       935 return 0 if not exists( $config->{'report_skip_to'} );
755 0         0 foreach my $address ( @{ $config->{'report_skip_to'} } ) {
  0         0  
756 0 0       0 if ( lc $address eq lc $env_to ) {
757 0         0 $self->dbgout( 'DMARCReportSkip', 'Skip address detected: ' . $env_to, LOG_INFO );
758 0         0 $self->{'skip_report'} = 1;
759             }
760             }
761             }
762              
763             sub envrcpt_callback {
764 114     114 0 662 my ( $self, $env_to ) = @_;
765 114 100       573 return if ( $self->is_local_ip_address() );
766 106 100       838 return if ( $self->is_trusted_ip_address() );
767 104 50       766 return if ( $self->is_authenticated() );
768              
769 104         984 $self->{ 'env_to' } = $env_to;
770 104         925 $self->check_skip_address( $env_to );
771             }
772              
773             sub header_callback {
774 1013     1013 0 3464 my ( $self, $header, $value ) = @_;
775 1013 100       3676 return if ( $self->is_local_ip_address() );
776 896 100       3066 return if ( $self->is_trusted_ip_address() );
777 866 50       2962 return if ( $self->is_authenticated() );
778 866 50       3062 return if ( $self->{'failmode'} );
779              
780 866 100       2855 if ( lc $header eq 'list-id' ) {
781 1         10 $self->dbgout( 'DMARCListId', 'List ID detected: ' . $value, LOG_INFO );
782 1         4 $self->{'is_list'} = 1;
783             }
784 866 50       2411 if ( lc $header eq 'list-post' ) {
785 0         0 $self->dbgout( 'DMARCListId', 'List Post detected: ' . $value, LOG_INFO );
786 0         0 $self->{'is_list'} = 1;
787             }
788              
789 866 100       3098 if ( lc $header eq 'from' ) {
790 106 100       664 if ( exists $self->{'from_header'} ) {
791 4         23 $self->dbgout( 'DMARCFail', 'Multiple RFC5322 from fields', LOG_DEBUG );
792             }
793 106         552 $self->{'from_header'} = $value;
794 106         371 push @{ $self->{ 'from_headers' } }, $value;
  106         439  
795 106         776 my $domain = lc $self->get_domain_from( $value );
796 106 50       649 if ( $domain ) {
797 106         726 my $lookup = '_dmarc.'.$domain;
798 106         787 my $resolver = $self->get_object('resolver');
799 106         406 eval{ $resolver->bgsend( $lookup, 'TXT' ) };
  106         1133  
800 106         184830 $self->handle_exception( $@ );
801 106         1179 $self->dbgout( 'DNSEarlyLookup', "$lookup TXT", LOG_DEBUG );
802 106         929 my $dmarc = $self->new_dmarc_object();
803 106         308 my $org_domain = eval{ $dmarc->get_organizational_domain( $domain ) };
  106         1100  
804 106         16342 $self->handle_exception( $@ );
805 106 100 66     1325 if ( $org_domain && ($org_domain ne $domain) ) {
806 7         27 my $lookup = '_dmarc.'.$org_domain;
807 7         30 my $resolver = $self->get_object('resolver');
808 7         19 eval{ $resolver->bgsend( $lookup, 'TXT' ) };
  7         31  
809 7         7473 $self->handle_exception( $@ );
810 7         47 $self->dbgout( 'DNSEarlyLookup', "$lookup TXT", LOG_DEBUG );
811             }
812             }
813              
814             }
815             }
816              
817             sub eom_requires {
818 27     27 0 348 my ($self) = @_;
819 27         898 my @requires = qw{ DKIM };
820              
821 27 100       732 if ( $self->is_handler_loaded( 'ARC' ) ) {
822 1         4 push @requires, 'ARC';
823             }
824              
825 27         883 return \@requires;
826             }
827              
828             sub eom_callback {
829 114     114 0 544 my ($self) = @_;
830 114         592 my $config = $self->handler_config();
831              
832 114 100       778 return if ( $self->is_local_ip_address() );
833 106 100       661 return if ( $self->is_trusted_ip_address() );
834 104 50       662 return if ( $self->is_authenticated() );
835 104 50       729 return if ( $self->{'failmode'} );
836              
837 104         610 my $env_from = $self->{ 'env_from' };
838 104         814 my $env_domains_from = $self->get_domains_from($env_from);
839 104 100       619 $env_domains_from = [''] if ! @$env_domains_from;
840              
841 104         451 my $from_headers = $self->{ 'from_headers' };
842              
843             # Build a list of all from header domains used
844 104         397 my @header_domains;
845 104         618 foreach my $from_header ( @$from_headers ) {
846 106         495 my $from_header_header_domains = $self->get_domains_from( $from_header );
847 106         741 foreach my $header_domain ( @$from_header_header_domains ) {
848 110         507 push @header_domains, $header_domain;
849             }
850             }
851              
852 104         612 $self->{ 'dmarc_ar_headers' } = [];
853             # There will usually be only one, however this could be a source route
854             # so we consider multiples just incase
855 104         1192 foreach my $env_domain_from ( uniq sort @$env_domains_from ) {
856 106         749 foreach my $header_domain ( uniq sort @header_domains ) {
857 111         290 eval {
858 111         914 $self->_process_dmarc_for( $env_domain_from, $header_domain );
859             };
860 111 50       620 if ( my $error = $@ ) {
861 0         0 $self->handle_exception( $error );
862 0 0       0 if ( $error =~ /invalid header_from at / ) {
863 0         0 $self->log_error( 'DMARC Error invalid header_from <' . $self->{'from_header'} . '>' );
864 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
865 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
866 0         0 $self->_add_dmarc_header( $header );
867             }
868             else {
869 0         0 $self->log_error( 'DMARC Error ' . $error );
870 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'temperror' );
871 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'header.from' )->safe_set_value( $header_domain ) );
872 0         0 $self->_add_dmarc_header( $header );
873             }
874             }
875 111         706 $self->check_timeout();
876             }
877             }
878              
879 104 100       408 if ( @{ $self->{ 'dmarc_ar_headers' } } ) {
  104         656  
880 101         277 foreach my $dmarc_header ( @{ $self->_get_unique_dmarc_headers() } ) {
  101         676  
881 107 100 100     727 if ( !( $config->{'hide_none'} && $dmarc_header->value() eq 'none' ) ) {
882 102         863 $self->add_auth_header( $dmarc_header );
883             }
884             }
885             }
886             else {
887             # We got no headers at all? That's bogus!
888 3         18 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'dmarc' )->safe_set_value( 'permerror' );
889 3         216 $self->add_auth_header( $header );
890             }
891              
892 104         938 delete $self->{ 'dmarc_ar_headers' };
893             }
894              
895             sub can_sort_header {
896 8     8 1 34 my ( $self, $header ) = @_;
897 8 50       46 return 1 if $header eq 'dmarc';
898 0         0 return 0;
899             }
900              
901              
902             sub handler_header_sort {
903 8     8 0 26 my ( $self, $pa, $pb ) = @_;
904              
905             # ToDo, do this without stringify
906 8         30 my ( $result_a, $policy_a ) = $pa->as_string() =~ /^dmarc=([a-z]+) .*policy\.applied\-disposition=([a-z]+)/;
907 8         12544 my ( $result_b, $policy_b ) = $pb->as_string() =~ /^dmarc=([a-z]+) .*policy\.applied\-disposition=([a-z]+)/;
908              
909             # Fail then None then Pass
910 8 100       12575 if ( $result_a ne $result_b ) {
911 4 50       23 return -1 if $result_a eq 'fail';
912 4 50       29 return 1 if $result_b eq 'fail';
913 0 0       0 return -1 if $result_a eq 'none';
914 0 0       0 return 1 if $result_b eq 'none';
915             }
916              
917             # Reject then Quarantine then None
918 4 100       22 if ( $policy_a ne $policy_b ) {
919 2 50       12 return -1 if $policy_a eq 'reject';
920 2 50       11 return 1 if $policy_b eq 'reject';
921 2 50       21 return -1 if $policy_a eq 'quarantine';
922 0 0       0 return 1 if $policy_b eq 'quarantine';
923             }
924              
925 2         17 return $pa cmp $pb;
926             }
927              
928             sub _get_unique_dmarc_headers {
929 101     101   379 my ( $self ) = @_;
930              
931 101         366 my $unique_strings = {};
932 101         253 my @unique_headers;
933              
934             # Returns unique headers based on as_string for each header
935 101         266 foreach my $header ( @{ $self->{ 'dmarc_ar_headers' } } ) {
  101         529  
936 111         1138 my $as_string = $header->as_string();
937 111 100       211537 next if exists $unique_strings->{ $as_string };
938 107         502 $unique_strings->{ $as_string } = 1;
939 107         655 push @unique_headers, $header;
940             }
941              
942 101         726 return \@unique_headers;
943             }
944              
945             sub _add_dmarc_header {
946 111     111   496 my ( $self, $header ) = @_;
947 111         285 push @{ $self->{ 'dmarc_ar_headers' } }, $header;
  111         429  
948             }
949              
950             sub addheader_callback {
951 154     154 0 520 my $self = shift;
952 154         559 my $handler = shift;
953             }
954              
955             sub dequeue_callback {
956 3     3 0 53 my ($self) = @_;
957 3         143 my $dequeue_list = $self->get_dequeue_list('dmarc_report');
958             REPORT:
959 3         135 foreach my $id ( $dequeue_list->@* ) {
960 41         31027 my $report = $self->get_dequeue($id);
961 41 50       168 if ( $report ) {
962              
963 41         90 eval {
964 41         319 $self->set_handler_alarm( 5 * 1000000 ); # Allow no longer than 5 seconds for this!
965 41 50       564 if ( $report->can('set_resolver') ) {
966 41         1679 my $resolver = $self->get_object('resolver');
967 41         272 $report->set_resolver($resolver);
968             }
969              
970 41         430 my $domain = $report->{ 'header_from' };
971 41         111 my $org_domain = eval{ $report->get_organizational_domain( $domain ) };
  41         334  
972 41         4737 my $rua = $report->result()->published()->rua();
973              
974 41         1407 my $config = $self->handler_config();
975 41 50       170 if ( exists ( $config->{ 'report_suppression_list' } ) ) {
976 0 0       0 if ( $self->rbl_check_domain( $org_domain, $config->{ 'report_suppression_list' } ) ) {
977 0         0 $self->dbgout( 'Queued DMARC Report suppressed for', "$domain, $rua", LOG_INFO );
978 0         0 $self->delete_dequeue($id);
979 0         0 $self->reset_alarm();
980 0         0 next REPORT;
981             }
982             }
983 41         245 $report->save_aggregate();
984 41         1319937 $self->dbgout( 'Queued DMARC Report saved for', "$domain, $rua", LOG_INFO );
985 41         239 $self->delete_dequeue($id);
986 41         384 $self->reset_alarm();
987             };
988 41 50       2491 if ( my $Error = $@ ) {
989 0         0 $self->reset_alarm();
990 0         0 my $Type = $self->is_exception_type( $Error );
991 0 0       0 if ( $Type ) {
992 0 0       0 if ( $Type eq 'Timeout' ) {
993             # We have a timeout, is it global or is it ours?
994 0 0       0 if ( $self->get_time_remaining() > 0 ) {
995             # We have time left, but this aggregate save timed out
996             # Log this and move on!
997 0         0 $self->log_error("DMARC timeout saving reports for $id");
998             }
999             else {
1000 0         0 $self->handle_exception( $Error );
1001             }
1002             }
1003             }
1004 0         0 $self->log_error("DMARC Report save failed for $id: $Error");
1005 0         0 $self->error_dequeue($id);
1006             }
1007              
1008             }
1009             else {
1010 0         0 $self->log_error("DMARC Report dequeue failed for $id");
1011 0         0 $self->error_dequeue($id);
1012             }
1013             }
1014             }
1015              
1016             sub _save_aggregate_reports {
1017 118     118   411 my ( $self ) = @_;
1018 118 100       623 return if ! $self->{'report_queue'};
1019             # Try as best we can to save a report, but don't stress if it fails.
1020 75         216 eval {
1021 75         690 $self->set_handler_alarm( 2 * 1000000 ); # Allow no longer than 2 seconds for this!
1022 75         297 while ( my $report = shift @{ $self->{'report_queue'} } ) {
  131         839  
1023 56 50       499 if ( $report->can('set_resolver') ) {
1024 56         413 $report->set_resolver(undef);
1025             }
1026 56         1182 $self->add_dequeue('dmarc_report',$report);
1027 56         54771 $self->dbgout( 'DMARC Report queued for', $report->result()->published()->rua(), LOG_INFO );
1028             }
1029 75         764 $self->reset_alarm();
1030             };
1031 75 50       648 if ( my $Error = $@ ) {
1032 0         0 $self->reset_alarm();
1033 0         0 my $Type = $self->is_exception_type( $Error );
1034 0 0       0 if ( $Type ) {
1035 0 0       0 if ( $Type eq 'Timeout' ) {
1036             # We have a timeout, is it global or is it ours?
1037 0 0       0 if ( $self->get_time_remaining() > 0 ) {
1038             # We have time left, but the aggregate save timed out
1039             # Log this and move on!
1040 0         0 $self->log_error( 'DMARC timeout saving reports' );
1041 0         0 return;
1042             }
1043             }
1044             }
1045 0         0 $self->handle_exception( $Error );
1046 0         0 $self->log_error( 'DMARC Report Error ' . $Error );
1047             }
1048             }
1049              
1050             sub close_callback {
1051 118     118 0 489 my ( $self ) = @_;
1052 118         713 $self->_save_aggregate_reports();
1053 118         498 delete $self->{'helo_name'};
1054 118         416 delete $self->{'env_from'};
1055 118         348 delete $self->{'env_to'};
1056 118         295 delete $self->{'failmode'};
1057 118         304 delete $self->{'skip_report'};
1058 118         339 delete $self->{'is_list'};
1059 118         328 delete $self->{'from_header'};
1060 118         367 delete $self->{'from_headers'};
1061 118         317 delete $self->{'report_queue'};
1062 118         363 delete $self->{'processed'};
1063 118         631 $self->destroy_object('dmarc');
1064 118         646 $self->destroy_object('dmarc_result');
1065 118         707 $self->destroy_object('dmarc_results');
1066             }
1067              
1068             1;
1069              
1070             __END__
1071              
1072             =pod
1073              
1074             =encoding UTF-8
1075              
1076             =head1 NAME
1077              
1078             Mail::Milter::Authentication::Handler::DMARC - Handler class for DMARC
1079              
1080             =head1 VERSION
1081              
1082             version 3.20230629
1083              
1084             =head1 DESCRIPTION
1085              
1086             Module implementing the DMARC standard checks.
1087              
1088             This handler requires the SPF and DKIM handlers to be installed and active.
1089              
1090             =head1 CONFIGURATION
1091              
1092             "DMARC" : { | Config for the DMARC Module
1093             | Requires DKIM and SPF
1094             "hard_reject" : 0, | Reject mail which fails with a reject policy
1095             "no_reject_disposition" : "quarantine", | What to report when hard_reject is 0
1096             "no_list_reject" : 0, | Do not reject mail detected as mailing list
1097             "arc_before_list" : 0, | Don't apply above list detection if we have trusted arc
1098             "no_list_reject_disposition" : "none", | Disposition to use for mail detected as mailing list (defaults none)
1099             "reject_on_multifrom" : 20, | Reject mail if we detect more than X DMARC entities to process
1100             "quarantine_on_multifrom" : 15, | Quarantine mail if we detect more than X DMARC entities to process
1101             "skip_on_multifrom" : 10, | Skip further processing if we detect more than X DMARC entities to process
1102             "whitelisted" : [ | A list of ip addresses or CIDR ranges, or dkim domains
1103             "10.20.30.40", | for which we do not want to hard reject mail on fail p=reject
1104             "dkim:bad.forwarder.com", | (valid) DKIM signing domains can also be whitelisted by
1105             "20.30.40.0/24" | having an entry such as "dkim:domain.com"
1106             ],
1107             "policy_rbl_lookup" : { | Optionally lookup the from domain in a rbl and add a policy entry
1108             "foo" : { | the policy to add, this will translate to policy.foo
1109             "rbl" : "foo.rbl.example.com", | The RBL to use for this lookup
1110             "results" : { | Mapping of rbl results to policy entries
1111             "127.0.0.1" : "one", | A result of IP will give a corresponding policy entry
1112             "127.0.0.2" : "two",
1113             "*" : "star" | Fallback to the '*' entry if not found.
1114             | defaults to 'pass' if no entries and no fallback found
1115             }
1116             }
1117             },
1118             "use_arc" : 1, | Use trusted ARC results if available
1119             "hide_none" : 0, | Hide auth line if the result is 'none'
1120             "detect_list_id" : "1", | Detect a list ID and modify the DMARC authentication header
1121             | to note this, useful when making rules for junking email
1122             | as mailing lists frequently cause false DMARC failures.
1123             "report_skip_to" : [ | Do not send DMARC reports for emails to these addresses.
1124             "dmarc@yourdomain.com", | This can be used to avoid report loops for email sent to
1125             "dmarc@example.com" | your report from addresses.
1126             ],
1127             "report_suppression_list" : "rbl.example.com", | RBL used to look Org domains for which we want to suppress reporting
1128             "no_report" : "1", | If set then we will not attempt to store DMARC reports.
1129             "hide_report_to" : "1", | If set, remove envelope_to from DMARC reports
1130             "config_file" : "/etc/mail-dmarc.ini" | Optional path to dmarc config file
1131             },
1132              
1133             =head1 AUTHOR
1134              
1135             Marc Bradshaw <marc@marcbradshaw.net>
1136              
1137             =head1 COPYRIGHT AND LICENSE
1138              
1139             This software is copyright (c) 2020 by Marc Bradshaw.
1140              
1141             This is free software; you can redistribute it and/or modify it under
1142             the same terms as the Perl 5 programming language system itself.
1143              
1144             =cut