File Coverage

blib/lib/Mail/SpamAssassin/Plugin/RelayCountry.pm
Criterion Covered Total %
statement 52 190 27.3
branch 6 84 7.1
condition 1 15 6.6
subroutine 9 17 52.9
pod 2 4 50.0
total 70 310 22.5


line stmt bran cond sub pod time code
1             # <@LICENSE>
2             # Licensed to the Apache Software Foundation (ASF) under one or more
3             # contributor license agreements. See the NOTICE file distributed with
4             # this work for additional information regarding copyright ownership.
5             # The ASF licenses this file to you under the Apache License, Version 2.0
6             # (the "License"); you may not use this file except in compliance with
7             # the License. You may obtain a copy of the License at:
8             #
9             # http://www.apache.org/licenses/LICENSE-2.0
10             #
11             # Unless required by applicable law or agreed to in writing, software
12             # distributed under the License is distributed on an "AS IS" BASIS,
13             # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14             # See the License for the specific language governing permissions and
15             # limitations under the License.
16             # </@LICENSE>
17              
18             =head1 NAME
19              
20             RelayCountry - add message metadata indicating the country code of each relay
21              
22             =head1 SYNOPSIS
23              
24             loadplugin Mail::SpamAssassin::Plugin::RelayCountry
25              
26             =head1 DESCRIPTION
27              
28             The RelayCountry plugin attempts to determine the domain country codes
29             of each relay used in the delivery path of messages and add that information
30             to the message metadata.
31              
32             Following metadata headers and tags are added:
33              
34             X-Relay-Countries _RELAYCOUNTRY_
35             All untrusted relays. Contains all relays starting from the
36             trusted_networks border. This method has been used by default since
37             early SA versions.
38              
39             X-Relay-Countries-External _RELAYCOUNTRYEXT_
40             All external relays. Contains all relays starting from the
41             internal_networks border. Could be useful in some cases when
42             trusted/msa_networks extend beyond the internal border and those
43             need to be checked too.
44              
45             X-Relay-Countries-All _RELAYCOUNTRYALL_
46             All possible relays (internal + external).
47              
48             X-Relay-Countries-Auth _RELAYCOUNTRYAUTH_
49             Auth will contain all relays starting from the first relay that used
50             authentication. For example, this could be used to check for hacked
51             local users coming in from unexpected countries. If there are no
52             authenticated relays, this will be empty.
53              
54             =head1 REQUIREMENT
55              
56             This plugin requires the GeoIP2, Geo::IP, IP::Country::DB_File or
57             IP::Country::Fast module from CPAN.
58             For backward compatibility IP::Country::Fast is used as fallback if no db_type
59             is specified in the config file.
60              
61             =cut
62              
63              
64             use Mail::SpamAssassin::Plugin;
65 20     20   140 use Mail::SpamAssassin::Logger;
  20         43  
  20         606  
66 20     20   111 use Mail::SpamAssassin::Constants qw(:ip);
  20         49  
  20         1166  
67 20     20   131 use strict;
  20         39  
  20         2479  
68 20     20   155 use warnings;
  20         52  
  20         503  
69 20     20   100 # use bytes;
  20         32  
  20         738  
70             use re 'taint';
71 20     20   111  
  20         43  
  20         35098  
72             our @ISA = qw(Mail::SpamAssassin::Plugin);
73              
74             # constructor: register the eval rule
75             my $class = shift;
76             my $mailsaobject = shift;
77 61     61 1 205  
78 61         153 # some boilerplate...
79             $class = ref($class) || $class;
80             my $self = $class->SUPER::new($mailsaobject);
81 61   33     376 bless ($self, $class);
82 61         348  
83 61         172 $self->set_config($mailsaobject->{conf});
84             return $self;
85 61         287 }
86 61         920  
87             my ($self, $conf) = @_;
88             my @cmds;
89              
90 61     61 0 172 =head1 USER PREFERENCES
91 61         115  
92             The following options can be used in both site-wide (C<local.cf>) and
93             user-specific (C<user_prefs>) configuration files to customize how
94             SpamAssassin handles incoming email messages.
95              
96             =over 4
97              
98             =item country_db_type STRING
99              
100             This option tells SpamAssassin which type of Geo database to use.
101             Valid database types are GeoIP, GeoIP2, DB_File and Fast.
102              
103             =back
104              
105             =cut
106              
107             push (@cmds, {
108             setting => 'country_db_type',
109             default => "GeoIP",
110             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
111             code => sub {
112             my ($self, $key, $value, $line) = @_;
113             if ($value !~ /^(?:GeoIP|GeoIP2|DB_File|Fast)$/) {
114             return $Mail::SpamAssassin::Conf::INVALID_VALUE;
115 0     0   0 }
116 0 0       0 $self->{country_db_type} = $value;
117 0         0 }
118             });
119 0         0  
120             =over 4
121 61         628  
122             =item country_db_path STRING
123              
124             This option tells SpamAssassin where to find MaxMind GeoIP2 or IP::Country::DB_File database.
125              
126             If not defined, GeoIP2 default search includes:
127             /usr/local/share/GeoIP/GeoIP2-Country.mmdb
128             /usr/share/GeoIP/GeoIP2-Country.mmdb
129             /var/lib/GeoIP/GeoIP2-Country.mmdb
130             /usr/local/share/GeoIP/GeoLite2-Country.mmdb
131             /usr/share/GeoIP/GeoLite2-Country.mmdb
132             /var/lib/GeoIP/GeoLite2-Country.mmdb
133             (and same paths again for -City.mmdb, which also has country functionality)
134              
135             =back
136              
137             =cut
138              
139             push (@cmds, {
140             setting => 'country_db_path',
141             default => "",
142             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
143             code => sub {
144             my ($self, $key, $value, $line) = @_;
145             if (!defined $value || !length $value) {
146             return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
147 0     0   0 }
148 0 0 0     0 if (!-e $value) {
149 0         0 info("config: country_db_path \"$value\" is not accessible");
150             $self->{country_db_path} = $value;
151 0 0       0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
152 0         0 }
153 0         0 $self->{country_db_path} = $value;
154 0         0 }
155             });
156 0         0  
157             push (@cmds, {
158 61         504 setting => 'geoip2_default_db_path',
159             default => [
160             '/usr/local/share/GeoIP/GeoIP2-Country.mmdb',
161             '/usr/share/GeoIP/GeoIP2-Country.mmdb',
162             '/var/lib/GeoIP/GeoIP2-Country.mmdb',
163             '/usr/local/share/GeoIP/GeoLite2-Country.mmdb',
164             '/usr/share/GeoIP/GeoLite2-Country.mmdb',
165             '/var/lib/GeoIP/GeoLite2-Country.mmdb',
166             '/usr/local/share/GeoIP/GeoIP2-City.mmdb',
167             '/usr/share/GeoIP/GeoIP2-City.mmdb',
168             '/var/lib/GeoIP/GeoIP2-City.mmdb',
169             '/usr/local/share/GeoIP/GeoLite2-City.mmdb',
170             '/usr/share/GeoIP/GeoLite2-City.mmdb',
171             '/var/lib/GeoIP/GeoLite2-City.mmdb',
172             ],
173             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRINGLIST,
174             code => sub {
175             my ($self, $key, $value, $line) = @_;
176             if ($value eq '') {
177             return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
178 0     0   0 }
179 0 0       0 push(@{$self->{geoip2_default_db_path}}, split(/\s+/, $value));
180 0         0 }
181             });
182 0         0
  0         0  
183             $conf->{parser}->register_commands(\@cmds);
184 61         666 }
185              
186 61         325 my ($self, $ip, $db, $dbv6, $country_db_type) = @_;
187             my $cc;
188             my $IP_PRIVATE = IP_PRIVATE;
189             my $IPV4_ADDRESS = IPV4_ADDRESS;
190 0     0 0 0  
191 0         0 # Private IPs will always be returned as '**'
192 0         0 if ($ip =~ /^$IP_PRIVATE$/o) {
193 0         0 $cc = "**";
194             }
195             elsif ($country_db_type eq "GeoIP") {
196 0 0       0 if ($ip =~ /^$IPV4_ADDRESS$/o) {
    0          
    0          
    0          
    0          
197 0         0 $cc = $db->country_code_by_addr($ip);
198             } elsif (defined $dbv6) {
199             $cc = $dbv6->country_code_by_addr_v6($ip);
200 0 0       0 }
    0          
201 0         0 }
202             elsif ($country_db_type eq "GeoIP2") {
203 0         0 my ($country, $country_rec);
204             eval {
205             if (index($db->metadata()->description()->{en}, 'City') != -1) {
206             $country = $db->city( ip => $ip );
207 0         0 } else {
208             $country = $db->country( ip => $ip );
209 0 0       0 }
210 0         0 $country_rec = $country->country();
211             $cc = $country_rec->iso_code();
212 0         0 1;
213             } or do {
214 0         0 $@ =~ s/\s+Trace begun.*//s;
215 0         0 dbg("metadata: RelayCountry: GeoIP2 failed: $@");
216 0         0 }
217 0 0       0 }
218 0         0 elsif ($country_db_type eq "DB_File") {
219 0         0 if ($ip =~ /^$IPV4_ADDRESS$/o ) {
220             $cc = $db->inet_atocc($ip);
221             } else {
222             $cc = $db->inet6_atocc($ip);
223 0 0       0 }
224 0         0 }
225             elsif ($country_db_type eq "Fast") {
226 0         0 $cc = $db->inet_atocc($ip);
227             }
228              
229             $cc ||= 'XX';
230 0         0  
231             return $cc;
232             }
233 0   0     0  
234             my ($self, $opts) = @_;
235 0         0 my $pms = $opts->{permsgstatus};
236              
237             my $db;
238             my $dbv6;
239 87     87 1 215 my $db_info; # will hold database info
240 87         220 my $db_type; # will hold database type
241              
242 87         341 my $country_db_type = $opts->{conf}->{country_db_type};
243             my $country_db_path = $opts->{conf}->{country_db_path};
244 87         0  
245 87         0 if ($country_db_type eq "GeoIP") {
246             eval {
247 87         211 require Geo::IP;
248 87         201 $db = Geo::IP->open_type(Geo::IP->GEOIP_COUNTRY_EDITION, Geo::IP->GEOIP_STANDARD);
249             die "GeoIP.dat not found" unless $db;
250 87 50       272 # IPv6 requires version Geo::IP 1.39+ with GeoIP C API 1.4.7+
    0          
    0          
251             if (Geo::IP->VERSION >= 1.39 && Geo::IP->api eq 'CAPI') {
252 87         12853 $dbv6 = Geo::IP->open_type(Geo::IP->GEOIP_COUNTRY_EDITION_V6, Geo::IP->GEOIP_STANDARD);
253 0         0 if (!$dbv6) {
254 0 0       0 dbg("metadata: RelayCountry: GeoIP: IPv6 support not enabled, GeoIPv6.dat not found");
255             }
256 0 0 0     0 } else {
257 0         0 dbg("metadata: RelayCountry: GeoIP: IPv6 support not enabled, versions Geo::IP 1.39, GeoIP C API 1.4.7 required");
258 0 0       0 }
259 0         0 $db_info = sub { return "Geo::IP IPv4: " . ($db->database_info || '?')." / IPv6: ".($dbv6 ? $dbv6->database_info || '?' : '?') };
260             1;
261             } or do {
262 0         0 # Fallback to IP::Country::Fast
263             dbg("metadata: RelayCountry: GeoIP: GeoIP.dat not found, trying IP::Country::Fast as fallback");
264 0 0 0 0   0 $country_db_type = "Fast";
  0   0     0  
265 0         0 }
266 87 50       163 }
267             elsif ($country_db_type eq "GeoIP2") {
268 87         456 if (!$country_db_path) {
269 87         234 # Try some default locations
270             foreach (@{$opts->{conf}->{geoip2_default_db_path}}) {
271             if (-f $_) {
272             $country_db_path = $_;
273 0 0       0 last;
274             }
275 0         0 }
  0         0  
276 0 0       0 }
277 0         0 if (-f $country_db_path) {
278 0         0 eval {
279             require GeoIP2::Database::Reader;
280             $db = GeoIP2::Database::Reader->new(
281             file => $country_db_path,
282 0 0       0 locales => [ 'en' ]
283             );
284 0         0 die "unknown error" unless $db;
285 0         0 $db_info = sub {
286             my $m = $db->metadata();
287             return "GeoIP2 ".$m->description()->{en}." / ".localtime($m->build_epoch());
288             };
289 0 0       0 1;
290             } or do {
291 0     0   0 # Fallback to IP::Country::Fast
292 0         0 $@ =~ s/\s+Trace begun.*//s;
293 0         0 dbg("metadata: RelayCountry: GeoIP2: ${country_db_path} load failed: $@, trying IP::Country::Fast as fallback");
294 0         0 $country_db_type = "Fast";
295 0 0       0 }
296             } else {
297 0         0 # Fallback to IP::Country::Fast
298 0         0 my $err = $country_db_path ?
299 0         0 "$country_db_path not found" : "database not found from default locations";
300             dbg("metadata: RelayCountry: GeoIP2: $err, trying IP::Country::Fast as fallback");
301             $country_db_type = "Fast";
302             }
303 0 0       0 }
304             elsif ($country_db_type eq "DB_File") {
305 0         0 if (-f $country_db_path) {
306 0         0 eval {
307             require IP::Country::DB_File;
308             $db = IP::Country::DB_File->new($country_db_path);
309             die "unknown error" unless $db;
310 0 0       0 $db_info = sub { return "IP::Country::DB_File ".localtime($db->db_time()); };
311             1;
312 0         0 } or do {
313 0         0 # Fallback to IP::Country::Fast
314 0 0       0 dbg("metadata: RelayCountry: DB_File: ${country_db_path} load failed: $@, trying IP::Country::Fast as fallback");
315 0     0   0 $country_db_type = "Fast";
  0         0  
316 0         0 }
317 0 0       0 } else {
318             # Fallback to IP::Country::Fast
319 0         0 dbg("metadata: RelayCountry: DB_File: ${country_db_path} not found, trying IP::Country::Fast as fallback");
320 0         0 $country_db_type = "Fast";
321             }
322             }
323              
324 0         0 if ($country_db_type eq "Fast") {
325 0         0 my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
326             eval {
327             require IP::Country::Fast;
328             $db = IP::Country::Fast->new();
329 87 50       463 $db_info = sub { return "IP::Country::Fast ".localtime($db->db_time()); };
330 87 50       317 1;
  87         226  
331             } or do {
332 87         10155 my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
333 0         0 dbg("metadata: RelayCountry: failed to load 'IP::Country::Fast', skipping: $eval_stat");
334 0     0   0 return 1;
  0         0  
335 0         0 }
336 87 50       151 }
337 87 50       409  
  87         193  
338 87         411 if (!$db) {
339 87         469 return 1;
340             }
341              
342             dbg("metadata: RelayCountry: Using database: ".$db_info->());
343 0 0         my $msg = $opts->{msg};
344 0            
345             my @cc_untrusted;
346             foreach my $relay (@{$msg->{metadata}->{relays_untrusted}}) {
347 0           my $ip = $relay->{ip};
348 0           my $cc = $self->get_country($ip, $db, $dbv6, $country_db_type);
349             push @cc_untrusted, $cc;
350 0           }
351 0            
  0            
352 0           my @cc_external;
353 0           foreach my $relay (@{$msg->{metadata}->{relays_external}}) {
354 0           my $ip = $relay->{ip};
355             my $cc = $self->get_country($ip, $db, $dbv6, $country_db_type);
356             push @cc_external, $cc;
357 0           }
358 0            
  0            
359 0           my @cc_auth;
360 0           my $found_auth;
361 0           foreach my $relay (@{$msg->{metadata}->{relays_trusted}}) {
362             if ($relay->{auth}) {
363             $found_auth = 1;
364 0           }
365             if ($found_auth) {
366 0           my $ip = $relay->{ip};
  0            
367 0 0         my $cc = $self->get_country($ip, $db, $dbv6, $country_db_type);
368 0           push @cc_auth, $cc;
369             }
370 0 0         }
371 0            
372 0           my @cc_all;
373 0           foreach my $relay (@{$msg->{metadata}->{relays_internal}}, @{$msg->{metadata}->{relays_external}}) {
374             my $ip = $relay->{ip};
375             my $cc = $self->get_country($ip, $db, $dbv6, $country_db_type);
376             push @cc_all, $cc;
377 0           }
378 0            
  0            
  0            
379 0           my $ccstr = join(' ', @cc_untrusted);
380 0           $msg->put_metadata("X-Relay-Countries", $ccstr);
381 0           dbg("metadata: X-Relay-Countries: $ccstr");
382             $pms->set_tag("RELAYCOUNTRY", @cc_untrusted == 1 ? $cc_untrusted[0] : \@cc_untrusted);
383              
384 0           $ccstr = join(' ', @cc_external);
385 0           $msg->put_metadata("X-Relay-Countries-External", $ccstr);
386 0           dbg("metadata: X-Relay-Countries-External: $ccstr");
387 0 0         $pms->set_tag("RELAYCOUNTRYEXT", @cc_external == 1 ? $cc_external[0] : \@cc_external);
388              
389 0           $ccstr = join(' ', @cc_auth);
390 0           $msg->put_metadata("X-Relay-Countries-Auth", $ccstr);
391 0           dbg("metadata: X-Relay-Countries-Auth: $ccstr");
392 0 0         $pms->set_tag("RELAYCOUNTRYAUTH", @cc_auth == 1 ? $cc_auth[0] : \@cc_auth);
393              
394 0           $ccstr = join(' ', @cc_all);
395 0           $msg->put_metadata("X-Relay-Countries-All", $ccstr);
396 0           dbg("metadata: X-Relay-Countries-All: $ccstr");
397 0 0         $pms->set_tag("RELAYCOUNTRYALL", @cc_all == 1 ? $cc_all[0] : \@cc_all);
398              
399 0           return 1;
400 0           }
401 0            
402 0 0         1;