File Coverage

blib/lib/Mail/Milter/Authentication/Handler/DMARC.pm
Criterion Covered Total %
statement 500 683 73.2
branch 185 348 53.1
condition 34 66 51.5
subroutine 35 35 100.0
pod 2 22 9.0
total 756 1154 65.5


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