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             package Mail::SpamAssassin::Plugin::RelayCountry;
64              
65 19     19   152 use Mail::SpamAssassin::Plugin;
  19         44  
  19         632  
66 19     19   117 use Mail::SpamAssassin::Logger;
  19         49  
  19         1189  
67 19     19   143 use Mail::SpamAssassin::Constants qw(:ip);
  19         50  
  19         2786  
68 19     19   158 use strict;
  19         58  
  19         523  
69 19     19   118 use warnings;
  19         73  
  19         776  
70             # use bytes;
71 19     19   130 use re 'taint';
  19         45  
  19         38289  
72              
73             our @ISA = qw(Mail::SpamAssassin::Plugin);
74              
75             # constructor: register the eval rule
76             sub new {
77 60     60 1 212 my $class = shift;
78 60         199 my $mailsaobject = shift;
79              
80             # some boilerplate...
81 60   33     402 $class = ref($class) || $class;
82 60         355 my $self = $class->SUPER::new($mailsaobject);
83 60         224 bless ($self, $class);
84              
85 60         361 $self->set_config($mailsaobject->{conf});
86 60         726 return $self;
87             }
88              
89             sub set_config {
90 60     60 0 178 my ($self, $conf) = @_;
91 60         146 my @cmds;
92              
93             =head1 USER PREFERENCES
94              
95             The following options can be used in both site-wide (C<local.cf>) and
96             user-specific (C<user_prefs>) configuration files to customize how
97             SpamAssassin handles incoming email messages.
98              
99             =over 4
100              
101             =item country_db_type STRING
102              
103             This option tells SpamAssassin which type of Geo database to use.
104             Valid database types are GeoIP, GeoIP2, DB_File and Fast.
105              
106             =back
107              
108             =cut
109              
110             push (@cmds, {
111             setting => 'country_db_type',
112             default => "GeoIP",
113             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
114             code => sub {
115 0     0   0 my ($self, $key, $value, $line) = @_;
116 0 0       0 if ($value !~ /^(?:GeoIP|GeoIP2|DB_File|Fast)$/) {
117 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
118             }
119 0         0 $self->{country_db_type} = $value;
120             }
121 60         744 });
122              
123             =over 4
124              
125             =item country_db_path STRING
126              
127             This option tells SpamAssassin where to find MaxMind GeoIP2 or IP::Country::DB_File database.
128              
129             If not defined, GeoIP2 default search includes:
130             /usr/local/share/GeoIP/GeoIP2-Country.mmdb
131             /usr/share/GeoIP/GeoIP2-Country.mmdb
132             /var/lib/GeoIP/GeoIP2-Country.mmdb
133             /usr/local/share/GeoIP/GeoLite2-Country.mmdb
134             /usr/share/GeoIP/GeoLite2-Country.mmdb
135             /var/lib/GeoIP/GeoLite2-Country.mmdb
136             (and same paths again for -City.mmdb, which also has country functionality)
137              
138             =back
139              
140             =cut
141              
142             push (@cmds, {
143             setting => 'country_db_path',
144             default => "",
145             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
146             code => sub {
147 0     0   0 my ($self, $key, $value, $line) = @_;
148 0 0 0     0 if (!defined $value || !length $value) {
149 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
150             }
151 0 0       0 if (!-e $value) {
152 0         0 info("config: country_db_path \"$value\" is not accessible");
153 0         0 $self->{country_db_path} = $value;
154 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
155             }
156 0         0 $self->{country_db_path} = $value;
157             }
158 60         563 });
159              
160             push (@cmds, {
161             setting => 'geoip2_default_db_path',
162             default => [
163             '/usr/local/share/GeoIP/GeoIP2-Country.mmdb',
164             '/usr/share/GeoIP/GeoIP2-Country.mmdb',
165             '/var/lib/GeoIP/GeoIP2-Country.mmdb',
166             '/usr/local/share/GeoIP/GeoLite2-Country.mmdb',
167             '/usr/share/GeoIP/GeoLite2-Country.mmdb',
168             '/var/lib/GeoIP/GeoLite2-Country.mmdb',
169             '/usr/local/share/GeoIP/GeoIP2-City.mmdb',
170             '/usr/share/GeoIP/GeoIP2-City.mmdb',
171             '/var/lib/GeoIP/GeoIP2-City.mmdb',
172             '/usr/local/share/GeoIP/GeoLite2-City.mmdb',
173             '/usr/share/GeoIP/GeoLite2-City.mmdb',
174             '/var/lib/GeoIP/GeoLite2-City.mmdb',
175             ],
176             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRINGLIST,
177             code => sub {
178 0     0   0 my ($self, $key, $value, $line) = @_;
179 0 0       0 if ($value eq '') {
180 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
181             }
182 0         0 push(@{$self->{geoip2_default_db_path}}, split(/\s+/, $value));
  0         0  
183             }
184 60         760 });
185            
186 60         371 $conf->{parser}->register_commands(\@cmds);
187             }
188              
189             sub get_country {
190 0     0 0 0 my ($self, $ip, $db, $dbv6, $country_db_type) = @_;
191 0         0 my $cc;
192 0         0 my $IP_PRIVATE = IP_PRIVATE;
193 0         0 my $IPV4_ADDRESS = IPV4_ADDRESS;
194              
195             # Private IPs will always be returned as '**'
196 0 0       0 if ($ip =~ /^$IP_PRIVATE$/o) {
    0          
    0          
    0          
    0          
197 0         0 $cc = "**";
198             }
199             elsif ($country_db_type eq "GeoIP") {
200 0 0       0 if ($ip =~ /^$IPV4_ADDRESS$/o) {
    0          
201 0         0 $cc = $db->country_code_by_addr($ip);
202             } elsif (defined $dbv6) {
203 0         0 $cc = $dbv6->country_code_by_addr_v6($ip);
204             }
205             }
206             elsif ($country_db_type eq "GeoIP2") {
207 0         0 my ($country, $country_rec);
208             eval {
209 0 0       0 if (index($db->metadata()->description()->{en}, 'City') != -1) {
210 0         0 $country = $db->city( ip => $ip );
211             } else {
212 0         0 $country = $db->country( ip => $ip );
213             }
214 0         0 $country_rec = $country->country();
215 0         0 $cc = $country_rec->iso_code();
216 0         0 1;
217 0 0       0 } or do {
218 0         0 $@ =~ s/\s+Trace begun.*//s;
219 0         0 dbg("metadata: RelayCountry: GeoIP2 failed: $@");
220             }
221             }
222             elsif ($country_db_type eq "DB_File") {
223 0 0       0 if ($ip =~ /^$IPV4_ADDRESS$/o ) {
224 0         0 $cc = $db->inet_atocc($ip);
225             } else {
226 0         0 $cc = $db->inet6_atocc($ip);
227             }
228             }
229             elsif ($country_db_type eq "Fast") {
230 0         0 $cc = $db->inet_atocc($ip);
231             }
232              
233 0   0     0 $cc ||= 'XX';
234              
235 0         0 return $cc;
236             }
237              
238             sub extract_metadata {
239 87     87 1 277 my ($self, $opts) = @_;
240 87         268 my $pms = $opts->{permsgstatus};
241              
242 87         410 my $db;
243             my $dbv6;
244 87         0 my $db_info; # will hold database info
245 87         0 my $db_type; # will hold database type
246              
247 87         275 my $country_db_type = $opts->{conf}->{country_db_type};
248 87         282 my $country_db_path = $opts->{conf}->{country_db_path};
249              
250 87 50       326 if ($country_db_type eq "GeoIP") {
    0          
    0          
251             eval {
252 87         15606 require Geo::IP;
253 0         0 $db = Geo::IP->open_type(Geo::IP->GEOIP_COUNTRY_EDITION, Geo::IP->GEOIP_STANDARD);
254 0 0       0 die "GeoIP.dat not found" unless $db;
255             # IPv6 requires version Geo::IP 1.39+ with GeoIP C API 1.4.7+
256 0 0 0     0 if (Geo::IP->VERSION >= 1.39 && Geo::IP->api eq 'CAPI') {
257 0         0 $dbv6 = Geo::IP->open_type(Geo::IP->GEOIP_COUNTRY_EDITION_V6, Geo::IP->GEOIP_STANDARD);
258 0 0       0 if (!$dbv6) {
259 0         0 dbg("metadata: RelayCountry: GeoIP: IPv6 support not enabled, GeoIPv6.dat not found");
260             }
261             } else {
262 0         0 dbg("metadata: RelayCountry: GeoIP: IPv6 support not enabled, versions Geo::IP 1.39, GeoIP C API 1.4.7 required");
263             }
264 0 0 0 0   0 $db_info = sub { return "Geo::IP IPv4: " . ($db->database_info || '?')." / IPv6: ".($dbv6 ? $dbv6->database_info || '?' : '?') };
  0   0     0  
265 0         0 1;
266 87 50       169 } or do {
267             # Fallback to IP::Country::Fast
268 87         587 dbg("metadata: RelayCountry: GeoIP: GeoIP.dat not found, trying IP::Country::Fast as fallback");
269 87         616 $country_db_type = "Fast";
270             }
271             }
272             elsif ($country_db_type eq "GeoIP2") {
273 0 0       0 if (!$country_db_path) {
274             # Try some default locations
275 0         0 foreach (@{$opts->{conf}->{geoip2_default_db_path}}) {
  0         0  
276 0 0       0 if (-f $_) {
277 0         0 $country_db_path = $_;
278 0         0 last;
279             }
280             }
281             }
282 0 0       0 if (-f $country_db_path) {
283             eval {
284 0         0 require GeoIP2::Database::Reader;
285 0         0 $db = GeoIP2::Database::Reader->new(
286             file => $country_db_path,
287             locales => [ 'en' ]
288             );
289 0 0       0 die "unknown error" unless $db;
290             $db_info = sub {
291 0     0   0 my $m = $db->metadata();
292 0         0 return "GeoIP2 ".$m->description()->{en}." / ".localtime($m->build_epoch());
293 0         0 };
294 0         0 1;
295 0 0       0 } or do {
296             # Fallback to IP::Country::Fast
297 0         0 $@ =~ s/\s+Trace begun.*//s;
298 0         0 dbg("metadata: RelayCountry: GeoIP2: ${country_db_path} load failed: $@, trying IP::Country::Fast as fallback");
299 0         0 $country_db_type = "Fast";
300             }
301             } else {
302             # Fallback to IP::Country::Fast
303 0 0       0 my $err = $country_db_path ?
304             "$country_db_path not found" : "database not found from default locations";
305 0         0 dbg("metadata: RelayCountry: GeoIP2: $err, trying IP::Country::Fast as fallback");
306 0         0 $country_db_type = "Fast";
307             }
308             }
309             elsif ($country_db_type eq "DB_File") {
310 0 0       0 if (-f $country_db_path) {
311             eval {
312 0         0 require IP::Country::DB_File;
313 0         0 $db = IP::Country::DB_File->new($country_db_path);
314 0 0       0 die "unknown error" unless $db;
315 0     0   0 $db_info = sub { return "IP::Country::DB_File ".localtime($db->db_time()); };
  0         0  
316 0         0 1;
317 0 0       0 } or do {
318             # Fallback to IP::Country::Fast
319 0         0 dbg("metadata: RelayCountry: DB_File: ${country_db_path} load failed: $@, trying IP::Country::Fast as fallback");
320 0         0 $country_db_type = "Fast";
321             }
322             } else {
323             # Fallback to IP::Country::Fast
324 0         0 dbg("metadata: RelayCountry: DB_File: ${country_db_path} not found, trying IP::Country::Fast as fallback");
325 0         0 $country_db_type = "Fast";
326             }
327             }
328              
329 87 50       602 if ($country_db_type eq "Fast") {
330 87 50       394 my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
  87         301  
331             eval {
332 87         12351 require IP::Country::Fast;
333 0         0 $db = IP::Country::Fast->new();
334 0     0   0 $db_info = sub { return "IP::Country::Fast ".localtime($db->db_time()); };
  0         0  
335 0         0 1;
336 87 50       206 } or do {
337 87 50       484 my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
  87         218  
338 87         442 dbg("metadata: RelayCountry: failed to load 'IP::Country::Fast', skipping: $eval_stat");
339 87         588 return 1;
340             }
341             }
342              
343 0 0         if (!$db) {
344 0           return 1;
345             }
346              
347 0           dbg("metadata: RelayCountry: Using database: ".$db_info->());
348 0           my $msg = $opts->{msg};
349              
350 0           my @cc_untrusted;
351 0           foreach my $relay (@{$msg->{metadata}->{relays_untrusted}}) {
  0            
352 0           my $ip = $relay->{ip};
353 0           my $cc = $self->get_country($ip, $db, $dbv6, $country_db_type);
354 0           push @cc_untrusted, $cc;
355             }
356              
357 0           my @cc_external;
358 0           foreach my $relay (@{$msg->{metadata}->{relays_external}}) {
  0            
359 0           my $ip = $relay->{ip};
360 0           my $cc = $self->get_country($ip, $db, $dbv6, $country_db_type);
361 0           push @cc_external, $cc;
362             }
363              
364 0           my @cc_auth;
365             my $found_auth;
366 0           foreach my $relay (@{$msg->{metadata}->{relays_trusted}}) {
  0            
367 0 0         if ($relay->{auth}) {
368 0           $found_auth = 1;
369             }
370 0 0         if ($found_auth) {
371 0           my $ip = $relay->{ip};
372 0           my $cc = $self->get_country($ip, $db, $dbv6, $country_db_type);
373 0           push @cc_auth, $cc;
374             }
375             }
376              
377 0           my @cc_all;
378 0           foreach my $relay (@{$msg->{metadata}->{relays_internal}}, @{$msg->{metadata}->{relays_external}}) {
  0            
  0            
379 0           my $ip = $relay->{ip};
380 0           my $cc = $self->get_country($ip, $db, $dbv6, $country_db_type);
381 0           push @cc_all, $cc;
382             }
383              
384 0           my $ccstr = join(' ', @cc_untrusted);
385 0           $msg->put_metadata("X-Relay-Countries", $ccstr);
386 0           dbg("metadata: X-Relay-Countries: $ccstr");
387 0 0         $pms->set_tag("RELAYCOUNTRY", @cc_untrusted == 1 ? $cc_untrusted[0] : \@cc_untrusted);
388              
389 0           $ccstr = join(' ', @cc_external);
390 0           $msg->put_metadata("X-Relay-Countries-External", $ccstr);
391 0           dbg("metadata: X-Relay-Countries-External: $ccstr");
392 0 0         $pms->set_tag("RELAYCOUNTRYEXT", @cc_external == 1 ? $cc_external[0] : \@cc_external);
393              
394 0           $ccstr = join(' ', @cc_auth);
395 0           $msg->put_metadata("X-Relay-Countries-Auth", $ccstr);
396 0           dbg("metadata: X-Relay-Countries-Auth: $ccstr");
397 0 0         $pms->set_tag("RELAYCOUNTRYAUTH", @cc_auth == 1 ? $cc_auth[0] : \@cc_auth);
398              
399 0           $ccstr = join(' ', @cc_all);
400 0           $msg->put_metadata("X-Relay-Countries-All", $ccstr);
401 0           dbg("metadata: X-Relay-Countries-All: $ccstr");
402 0 0         $pms->set_tag("RELAYCOUNTRYALL", @cc_all == 1 ? $cc_all[0] : \@cc_all);
403              
404 0           return 1;
405             }
406              
407             1;