File Coverage

lib/Mail/DMARC/Base.pm
Criterion Covered Total %
statement 176 210 83.8
branch 67 108 62.0
condition 12 28 42.8
subroutine 38 40 95.0
pod 7 24 29.1
total 300 410 73.1


line stmt bran cond sub pod time code
1             package Mail::DMARC::Base;
2             our $VERSION = '1.20211209';
3 16     16   8723 use strict;
  16         40  
  16         495  
4 16     16   89 use warnings;
  16         45  
  16         459  
5 16     16   247 use 5.10.0;
  16         62  
6              
7 16     16   99 use Carp;
  16         35  
  16         1007  
8 16     16   8818 use Config::Tiny;
  16         18731  
  16         555  
9 16     16   5061 use File::ShareDir;
  16         254767  
  16         897  
10 16     16   13799 use HTTP::Tiny;
  16         654981  
  16         779  
11 16     16   7304 use IO::File;
  16         18334  
  16         2203  
12 16     16   10828 use Net::DNS::Resolver;
  16         1015022  
  16         3226  
13 16     16   17235 use Net::IDN::Encode qw/domain_to_unicode/;
  16         2053614  
  16         1555  
14 16     16   13150 use Net::IP;
  16         993598  
  16         3866  
15 16     16   12134 use Regexp::Common 2013031301 qw /net/;
  16         46853  
  16         92  
16 16     16   54054 use Socket;
  16         101  
  16         10735  
17 16     16   10425 use Socket6 qw//; # don't export symbols
  16         24040  
  16         14283  
18              
19             sub new {
20 99     99 0 13388 my ( $class, @args ) = @_;
21 99 50       385 croak "invalid args" if scalar @args % 2 != 0;
22 99         1082 return bless {
23             config_file => 'mail-dmarc.ini',
24             @args, # this may override config_file
25             }, $class;
26             }
27              
28             my $_fake_time;
29             sub time { ## no critic
30             # Ability to return a fake time for testing
31 33     33 0 87 my ( $self ) = @_;
32 33 100       110 my $time = defined $Mail::DMARC::Base::_fake_time ? $Mail::DMARC::Base::_fake_time : time;
33 33         723 return $time;
34             }
35             sub set_fake_time {
36 8     8 0 116 my ( $self, $time ) = @_;
37 8         24 $Mail::DMARC::Base::_fake_time = $time;
38 8         23 return;
39             }
40              
41             sub config {
42 2196     2196 0 43888 my ( $self, $file, @too_many ) = @_;
43 2196 50       4661 croak "invalid args" if scalar @too_many;
44 2196 100 66     13443 return $self->{config} if ref $self->{config} && !$file;
45 47         314 return $self->{config} = $self->get_config($file);
46             }
47              
48             sub get_prefix {
49 25     25 0 594 my ($self, $subdir) = @_;
50 25 100       80 return map { $_ . ($subdir ? $subdir : '') } qw[ /usr/local/ /opt/local/ / ./ ];
  100         355  
51             }
52              
53             sub get_sharefile {
54 22     22 0 816 my ($self, $file) = @_;
55              
56 22         186 my $match = File::ShareDir::dist_file( 'Mail-DMARC', $file );
57 22 50       5112 print "using $match for $file\n" if $self->verbose;
58 22         175 return $match;
59             }
60              
61             sub get_config {
62 47     47 0 107 my $self = shift;
63 47 50 66     383 my $file = shift || $ENV{MAIL_DMARC_CONFIG_FILE} || $self->{config_file} or croak;
64 47 100       1671 return Config::Tiny->read($file) if -r $file; # fully qualified
65 12         116 foreach my $d ($self->get_prefix('etc')) {
66 48 100       823 next if !-d $d;
67 24 50       383 next if !-e "$d/$file";
68 0 0       0 croak "unreadable file: $d/$file" if !-r "$d/$file";
69 0         0 my $Config = Config::Tiny->new;
70 0         0 return Config::Tiny->read("$d/$file");
71             }
72              
73 12 100       157 if ($file ne 'mail-dmarc.ini') {
74 1         250 croak "unable to find requested config file $file\n";
75             }
76 11         89 return Config::Tiny->read( $self->get_sharefile('mail-dmarc.ini') );
77             }
78              
79             sub any_inet_ntop {
80 24     24 0 2862 my ( $self, $ip_bin ) = @_;
81 24 50       71 $ip_bin or croak "missing IP in request";
82              
83 24 100       73 if ( length $ip_bin == 16 ) {
84 6         50 return Socket6::inet_ntop( AF_INET6, $ip_bin );
85             }
86              
87 18         160 return Socket6::inet_ntop( AF_INET, $ip_bin );
88             }
89              
90             sub any_inet_pton {
91 14     14 0 4076 my ( $self, $ip_txt ) = @_;
92 14 50       49 $ip_txt or croak "missing IP in request";
93              
94 14 100       64 if ( $ip_txt =~ /:/ ) {
95 4   33     36 return Socket6::inet_pton( AF_INET6, $ip_txt )
96             || croak "invalid IPv6: $ip_txt";
97             }
98              
99 10   33     94 return Socket6::inet_pton( AF_INET, $ip_txt )
100             || croak "invalid IPv4: $ip_txt";
101             }
102              
103             {
104             my $public_suffixes;
105             my $public_suffixes_stamp;
106              
107             sub get_public_suffix_list {
108 341     341 0 576 my ( $self ) = @_;
109 341 100       791 if ( $public_suffixes ) { return $public_suffixes; }
  334         617  
110 16     16   189 no warnings 'once'; ## no critic
  16         37  
  16         32692  
111 7         17 $Mail::DMARC::psl_loads++;
112 7         56 my $file = $self->find_psl_file();
113 7         111 $public_suffixes_stamp = ( stat( $file ) )[9];
114              
115 6 50   6   59 open my $fh, '<:encoding(UTF-8)', $file
  6         11  
  6         125  
  7         701  
116             or croak "unable to open $file for read: $!\n";
117             # load PSL into hash for fast lookups, esp. for long running daemons
118 78141         165920 my %psl = map { $_ => 1 }
119 96768         142957 grep { $_ !~ /^[\/\s]/ } # weed out comments & whitespace
120 7         9915 map { chomp($_); $_ } ## no critic, remove line endings
  96768         162214  
  96768         118457  
121             <$fh>;
122 7         15130 close $fh;
123 7         130 return $public_suffixes = \%psl;
124             }
125              
126             sub check_public_suffix_list {
127 2     2 0 3797 my ( $self ) = @_;
128 2         11 my $file = $self->find_psl_file();
129 2         31 my $new_public_suffixes_stamp = ( stat( $file ) )[9];
130 2 100       22 if ( $new_public_suffixes_stamp != $public_suffixes_stamp ) {
131 1         2815 $public_suffixes = undef;
132 1         17 $self->get_public_suffix_list();
133 1         12 return 1;
134             }
135 1         55 return 0;
136             }
137             }
138              
139             sub is_public_suffix {
140 340     340 1 10926 my ( $self, $zone ) = @_;
141              
142 340 50       712 croak "missing zone name!" if !$zone;
143              
144 340         808 my $public_suffixes = $self->get_public_suffix_list();
145              
146 340 100       1018 $zone = domain_to_unicode( $zone ) if $zone =~ /xn--/;
147              
148 340 100       44750 return 1 if $public_suffixes->{$zone};
149              
150 164         551 my @labels = split /\./, $zone;
151 164         634 $zone = join '.', '*', (@labels)[ 1 .. scalar(@labels) - 1 ];
152              
153 164 100       518 return 1 if $public_suffixes->{$zone};
154 163         662 return 0;
155             }
156              
157             sub update_psl_file {
158 0     0 1 0 my ($self, $dryrun) = @_;
159              
160 0         0 my $psl_file = $self->find_psl_file();
161              
162 0 0       0 die "No Public Suffix List file found\n" if ( ! $psl_file );
163 0 0       0 die "Public suffix list file $psl_file not found\n" if ( ! -f $psl_file );
164 0 0       0 die "Cannot write to Public Suffix List file $psl_file\n" if ( ! -w $psl_file );
165              
166 0         0 my $url = 'https://publicsuffix.org/list/effective_tld_names.dat';
167 0 0       0 if ( $dryrun ) {
168 0         0 print "Will attempt to update the Public Suffix List file at $psl_file (dryrun mode)\n";
169 0         0 return;
170             }
171              
172 0         0 my $response = HTTP::Tiny->new->mirror( $url, $psl_file );
173 0         0 my $content = $response->{'content'};
174 0 0       0 if ( !$response->{'success'} ) {
175 0         0 my $status = $response->{'status'};
176 0         0 die "HTTP Request for Public Suffix List file failed with error $status ($content)\n";
177             }
178             else {
179 0 0       0 if ( $response->{'status'} eq '304' ) {
180 0         0 print "Public Suffix List file $psl_file not modified\n";
181             }
182             else {
183 0         0 print "Public Suffix List file $psl_file updated\n";
184             }
185             }
186 0         0 return;
187             }
188              
189             sub find_psl_file {
190 10     10 0 2284 my ($self) = @_;
191              
192 10   50     76 my $file = $self->config->{dns}{public_suffix_list} || 'share/public_suffix_list';
193 10 0 33     4060 if ( $file =~ /^\// && -f $file && -r $file ) {
      33        
194 0 0       0 print "using $file for Public Suffix List\n" if $self->verbose;
195 0         0 return $file;
196             }
197 10         16 my $path;
198 10         60 foreach $path ($self->get_prefix('share/' . $file)) { ## no critic
199 40 50 33     709 last if ( -f $path && -r $path );
200             }
201 10 50 33     70 if ($path && -r $path) {
202 0 0       0 print "using $path for Public Suffix List\n" if $self->verbose;
203 0         0 return $path;
204             };
205              
206             # Fallback to included suffic list
207 10         56 return $self->get_sharefile('public_suffix_list');
208             }
209              
210             sub has_dns_rr {
211 73     73 1 3992 my ( $self, $type, $domain ) = @_;
212              
213 73         166 my @matches;
214 73         353 my $res = $self->get_resolver();
215 73 100       365 my $query = $res->query( $domain, $type ) or do {
216 40 50       2997831 return 0 if ! wantarray;
217 0         0 return @matches;
218             };
219 33         1197470 for my $rr ( $query->answer ) {
220 61 100       4228 next if $rr->type ne $type;
221 57 50       1075 push @matches, $rr->type eq 'A' ? $rr->address
    100          
    50          
    50          
    100          
    50          
    100          
222             : $rr->type eq 'PTR' ? $rr->ptrdname
223             : $rr->type eq 'NS' ? $rr->nsdname
224             : $rr->type eq 'TXT' ? $rr->txtdata
225             : $rr->type eq 'SPF' ? $rr->txtdata
226             : $rr->type eq 'AAAA' ? $rr->address
227             : $rr->type eq 'MX' ? $rr->exchange
228             : $rr->answer;
229             }
230 33 50       4111 return scalar @matches if ! wantarray;
231 0         0 return @matches;
232             }
233              
234             sub epoch_to_iso {
235 37     37 0 587 my ($self, $epoch) = @_;
236              
237 37         995 my @fields = localtime( $epoch );
238              
239 37         211 my $ss = sprintf( "%02i", $fields[0] ); # seconds
240 37         75 my $mn = sprintf( "%02i", $fields[1] ); # minutes
241 37         72 my $hh = sprintf( "%02i", $fields[2] ); # hours (24 hour clock)
242              
243 37         73 my $dd = sprintf( "%02i", $fields[3] ); # day of month
244 37         67 my $mm = sprintf( "%02i", $fields[4] + 1 ); # month
245 37         67 my $yy = ( $fields[5] + 1900 ); # year
246              
247 37         303 return "$yy-$mm-$dd" .'T'."$hh:$mn:$ss";
248             }
249              
250             sub get_resolver {
251 97     97 1 1874 my $self = shift;
252 97   50     622 my $timeout = shift || $self->config->{dns}{timeout} || 5;
253 97 100       4096 return $self->{resolver} if defined $self->{resolver};
254 11         214 $self->{resolver} = Net::DNS::Resolver->new( dnsrch => 0 );
255 11         6340 $self->{resolver}->tcp_timeout($timeout);
256 11         269 $self->{resolver}->udp_timeout($timeout);
257 11         250 return $self->{resolver};
258             }
259              
260             sub set_resolver {
261 0     0 1 0 my ($self,$resolver) = @_;
262 0         0 $self->{resolver} = $resolver;
263 0         0 return;
264             }
265              
266             sub is_valid_ip {
267 21     21 1 12075 my ( $self, $ip ) = @_;
268              
269             # Using Regexp::Common removes perl 5.8 compat
270             # Perl 5.008009 does not support the pattern $RE{net}{IPv6}.
271             # You need Perl 5.01 or later
272              
273 21 100       93 if ( $ip =~ /:/ ) {
274 1         8 return Net::IP->new( $ip, 6 );
275             }
276              
277 20         185 return Net::IP->new( $ip, 4 );
278             }
279              
280             sub is_valid_domain {
281 68     68 1 3747 my ( $self, $domain ) = @_;
282 68 100       579 return 0 if $domain !~ /^$RE{net}{domain}{-rfc1101}{-nospace}$/x;
283 65         23083 my $tld = ( split /\./, lc $domain )[-1];
284 65 100       283 return 1 if $self->is_public_suffix($tld);
285 5         18 $tld = join( '.', ( split /\./, $domain )[ -2, -1 ] );
286 5 50       13 return 1 if $self->is_public_suffix($tld);
287 5         307 return 0;
288             }
289              
290             sub is_valid_spf_scope {
291 53     53 0 132 my ($self, $scope ) = @_;
292 53 50       97 return lc $scope if grep { lc $scope eq $_ } qw/ mfrom helo /;
  106         415  
293 0         0 carp "$scope is not a valid SPF scope";
294 0         0 return;
295             }
296              
297             sub is_valid_spf_result {
298 53     53 0 124 my ($self, $result ) = @_;
299 53 50       113 return 1 if grep { lc $result eq $_ }
  371         778  
300             qw/ fail neutral none pass permerror softfail temperror /;
301 0         0 carp "$result is not a valid SPF result";
302 0         0 return;
303             }
304              
305             sub slurp {
306 11     11 0 66 my ( $self, $file ) = @_;
307 11 50       622 open my $FH, '<', $file or croak "unable to read $file: $!";
308 11         41 my $contents = do { local $/; <$FH> }; ## no critic (Local)
  11         75  
  11         1363  
309 11         190 close $FH;
310 11         136 return $contents;
311             }
312              
313             sub verbose {
314 232 100   232 0 1195 return $_[0]->{verbose} if 1 == scalar @_;
315 4         24 return $_[0]->{verbose} = $_[1];
316             }
317              
318             1;
319              
320             __END__