File Coverage

blib/lib/Mail/SpamAssassin/Plugin/ASN.pm
Criterion Covered Total %
statement 47 185 25.4
branch 3 90 3.3
condition 2 56 3.5
subroutine 11 18 61.1
pod 2 5 40.0
total 65 354 18.3


line stmt bran cond sub pod time code
1             # SpamAssassin - ASN Lookup Plugin
2             #
3             # <@LICENSE>
4             # Licensed to the Apache Software Foundation (ASF) under one or more
5             # contributor license agreements. See the NOTICE file distributed with
6             # this work for additional information regarding copyright ownership.
7             # The ASF licenses this file to you under the Apache License, Version 2.0
8             # (the "License"); you may not use this file except in compliance with
9             # the License. You may obtain a copy of the License at:
10             #
11             # http://www.apache.org/licenses/LICENSE-2.0
12             #
13             # Unless required by applicable law or agreed to in writing, software
14             # distributed under the License is distributed on an "AS IS" BASIS,
15             # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16             # See the License for the specific language governing permissions and
17             # limitations under the License.
18             # </@LICENSE>
19             #
20             ###########################################################################
21             #
22             # Originated by Matthias Leisi, 2006-12-15 (SpamAssassin enhancement #4770).
23             # Modifications by D. Stussy, 2010-12-15 (SpamAssassin enhancement #6484):
24             #
25             # Since SA 3.4.0 a fixed text prefix (such as AS) to each ASN is configurable
26             # through an asn_prefix directive. Its value is 'AS' by default for backward
27             # compatibility with SA 3.3.*, but is rather redundant and can be set to an
28             # empty string for clarity if desired.
29             #
30             # Enhanced TXT-RR decoding for alternative formats from other DNS zones.
31             # Some of the supported formats of TXT RR are (quoted strings here represent
32             # individual string fields in a TXT RR):
33             # "1103" "192.88.99.0" "24"
34             # "559 1103 1239 1257 1299 | 192.88.99.0/24 | US | iana | 2001-06-01"
35             # "192.88.99.0/24 | AS1103 | SURFnet, The Netherlands | 2002-10-15 | EU"
36             # "15169 | 2a00:1450::/32 | IE | ripencc | 2009-10-05"
37             # "as1103"
38             # Multiple routes are sometimes provided by returning multiple TXT records
39             # (e.g. from cymru.com). This form of a response is handled as well.
40             #
41             # Some zones also support IPv6 lookups, for example:
42             # asn_lookup_ipv6 origin6.asn.cymru.com [_ASN_ _ASNCIDR_]
43              
44             =head1 NAME
45              
46             Mail::SpamAssassin::Plugin::ASN - SpamAssassin plugin to look up the
47             Autonomous System Number (ASN) of the connecting IP address.
48              
49             =head1 SYNOPSIS
50              
51             loadplugin Mail::SpamAssassin::Plugin::ASN
52              
53             asn_lookup asn.routeviews.org _ASN_ _ASNCIDR_
54            
55             asn_lookup_ipv6 origin6.asn.cymru.com _ASN_ _ASNCIDR_
56              
57             add_header all ASN _ASN_ _ASNCIDR_
58              
59             header TEST_AS1234 X-ASN =~ /^1234$/
60              
61             =head1 DESCRIPTION
62              
63             This plugin uses DNS lookups to the services of an external DNS zone such
64             as at C<http://www.routeviews.org/> to do the actual work. Please make
65             sure that your use of the plugin does not overload their infrastructure -
66             this generally means that B<you should not use this plugin in a
67             high-volume environment> or that you should use a local mirror of the
68             zone (see C<ftp://ftp.routeviews.org/dnszones/>). Other similar zones
69             may also be used.
70              
71             =head1 TEMPLATE TAGS
72              
73             This plugin allows you to create template tags containing the connecting
74             IP's AS number and route info for that AS number.
75              
76             The default config will add a header field that looks like this:
77              
78             X-Spam-ASN: AS24940 213.239.192.0/18
79              
80             where "24940" is the ASN and "213.239.192.0/18" is the route
81             announced by that ASN where the connecting IP address came from.
82             If the AS announces multiple networks (more/less specific), they will
83             all be added to the C<_ASNCIDR_> tag, separated by spaces, eg:
84              
85             X-Spam-ASN: AS1680 89.138.0.0/15 89.139.0.0/16
86              
87             Note that the literal "AS" before the ASN in the _ASN_ tag is configurable
88             through the I<asn_prefix> directive and may be set to an empty string.
89              
90             =head1 CONFIGURATION
91              
92             The standard ruleset contains a configuration that will add a header field
93             containing ASN data to scanned messages. The bayes tokenizer will use the
94             added header field for bayes calculations, and thus affect which BAYES_* rule
95             will trigger for a particular message.
96              
97             B<Note> that in most cases you should not score on the ASN data directly.
98             Bayes learning will probably trigger on the _ASNCIDR_ tag, but probably not
99             very well on the _ASN_ tag alone.
100              
101             =head1 SEE ALSO
102              
103             http://www.routeviews.org/ - all data regarding routing, ASNs, etc....
104              
105             http://issues.apache.org/SpamAssassin/show_bug.cgi?id=4770 -
106             SpamAssassin Issue #4770 concerning this plugin
107              
108             =head1 STATUS
109              
110             No in-depth analysis of the usefulness of bayes tokenization of ASN data has
111             been performed.
112              
113             =cut
114              
115             package Mail::SpamAssassin::Plugin::ASN;
116              
117 19     19   141 use strict;
  19         43  
  19         664  
118 19     19   118 use warnings;
  19         54  
  19         749  
119 19     19   140 use re 'taint';
  19         60  
  19         694  
120              
121 19     19   121 use Mail::SpamAssassin::Plugin;
  19         39  
  19         508  
122 19     19   121 use Mail::SpamAssassin::Logger;
  19         59  
  19         1163  
123 19     19   141 use Mail::SpamAssassin::Util qw(reverse_ip_address);
  19         46  
  19         957  
124 19     19   130 use Mail::SpamAssassin::Dns;
  19         38  
  19         720  
125 19     19   122 use Mail::SpamAssassin::Constants qw(:ip);
  19         67  
  19         44429  
126              
127             our @ISA = qw(Mail::SpamAssassin::Plugin);
128              
129             our $txtdata_can_provide_a_list;
130              
131             my $IPV4_ADDRESS = IPV4_ADDRESS;
132              
133             sub new {
134 60     60 1 262 my ($class, $mailsa) = @_;
135 60   33     448 $class = ref($class) || $class;
136 60         399 my $self = $class->SUPER::new($mailsa);
137 60         173 bless ($self, $class);
138              
139 60         317 $self->set_config($mailsa->{conf});
140              
141             #$txtdata_can_provide_a_list = Net::DNS->VERSION >= 0.69;
142             #more robust version check from Damyan Ivanov - Bug 7095
143 60         1689 $txtdata_can_provide_a_list = version->parse(Net::DNS->VERSION) >= version->parse('0.69');
144              
145 60         856 return $self;
146             }
147              
148             ###########################################################################
149              
150             sub set_config {
151 60     60 0 209 my ($self, $conf) = @_;
152 60         146 my @cmds;
153              
154             =head1 ADMINISTRATOR SETTINGS
155              
156             =over 4
157              
158             =item asn_lookup asn-zone.example.com [ _ASNTAG_ _ASNCIDRTAG_ ]
159              
160             Use this to lookup the ASN info in the specified zone for the first external
161             IPv4 address and add the AS number to the first specified tag and routing info
162             to the second specified tag.
163              
164             If no tags are specified the AS number will be added to the _ASN_ tag and the
165             routing info will be added to the _ASNCIDR_ tag. You must specify either none
166             or both of the tag names. Tag names must start and end with an underscore.
167              
168             If two or more I<asn_lookup>s use the same set of template tags, the results of
169             their lookups will be appended to each other in the template tag values in no
170             particular order. Duplicate results will be omitted when combining results.
171             In a similar fashion, you can also use the same template tag for both the AS
172             number tag and the routing info tag.
173              
174             Examples:
175              
176             asn_lookup asn.routeviews.org
177              
178             asn_lookup asn.routeviews.org _ASN_ _ASNCIDR_
179             asn_lookup myview.example.com _MYASN_ _MYASNCIDR_
180              
181             asn_lookup asn.routeviews.org _COMBINEDASN_ _COMBINEDASNCIDR_
182             asn_lookup myview.example.com _COMBINEDASN_ _COMBINEDASNCIDR_
183              
184             asn_lookup in1tag.example.net _ASNDATA_ _ASNDATA_
185              
186             =item asn_lookup_ipv6 asn-zone6.example.com [_ASN_ _ASNCIDR_]
187              
188             Use specified zone for lookups of IPv6 addresses. If zone supports both
189             IPv4 and IPv6 queries, use both asn_lookup and asn_lookup_ipv6 for the same
190             zone.
191              
192             =item clear_asn_lookups
193              
194             Removes any previously declared I<asn_lookup> entries from a list of queries.
195              
196             =item asn_prefix 'prefix_string' (default: 'AS')
197              
198             The string specified in the argument is prepended to each ASN when storing
199             it as a tag. This prefix is rather redundant, but its default value 'AS'
200             is kept for backward compatibility with versions of SpamAssassin earlier
201             than 3.4.0. A sensible setting is an empty string. The argument may be (but
202             need not be) enclosed in single or double quotes for clarity.
203              
204             =back
205              
206             =cut
207              
208             push (@cmds, {
209             setting => 'asn_lookup',
210             is_admin => 1,
211             code => sub {
212 0     0   0 my ($conf, $key, $value, $line) = @_;
213 0 0 0     0 unless (defined $value && $value !~ /^$/) {
214 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
215             }
216 0         0 local($1,$2,$3);
217 0 0       0 unless ($value =~ /^(\S+?)\.?(?:\s+_(\S+)_\s+_(\S+)_)?$/) {
218 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
219             }
220 0         0 my ($zone, $asn_tag, $route_tag) = ($1, $2, $3);
221 0 0       0 $asn_tag = 'ASN' if !defined $asn_tag;
222 0 0       0 $route_tag = 'ASNCIDR' if !defined $route_tag;
223 0         0 push @{$conf->{asnlookups}},
  0         0  
224             { zone=>$zone, asn_tag=>$asn_tag, route_tag=>$route_tag };
225             }
226 60         689 });
227              
228             push (@cmds, {
229             setting => 'asn_lookup_ipv6',
230             is_admin => 1,
231             code => sub {
232 0     0   0 my ($conf, $key, $value, $line) = @_;
233 0 0 0     0 unless (defined $value && $value !~ /^$/) {
234 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
235             }
236 0         0 local($1,$2,$3);
237 0 0       0 unless ($value =~ /^(\S+?)\.?(?:\s+_(\S+)_\s+_(\S+)_)?$/) {
238 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
239             }
240 0         0 my ($zone, $asn_tag, $route_tag) = ($1, $2, $3);
241 0 0       0 $asn_tag = 'ASN' if !defined $asn_tag;
242 0 0       0 $route_tag = 'ASNCIDR' if !defined $route_tag;
243 0         0 push @{$conf->{asnlookups_ipv6}},
  0         0  
244             { zone=>$zone, asn_tag=>$asn_tag, route_tag=>$route_tag };
245             }
246 60         527 });
247              
248             push (@cmds, {
249             setting => 'clear_asn_lookups',
250             is_admin => 1,
251             type => $Mail::SpamAssassin::Conf::CONF_TYPE_NOARGS,
252             code => sub {
253 0     0   0 my ($conf, $key, $value, $line) = @_;
254 0 0 0     0 if (defined $value && $value ne '') {
255 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
256             }
257 0         0 delete $conf->{asnlookups};
258 0         0 delete $conf->{asnlookups_ipv6};
259             }
260 60         498 });
261              
262             push (@cmds, {
263             setting => 'asn_prefix',
264             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
265             default => 'AS',
266             code => sub {
267 0     0   0 my ($conf, $key, $value, $line) = @_;
268 0 0       0 $value = '' if !defined $value;
269 0         0 local($1,$2);
270 0 0       0 $value = $2 if $value =~ /^(['"])(.*)\1\z/; # strip quotes if any
271 0         0 $conf->{$key} = $value; # keep tainted
272             }
273 60         527 });
274              
275 60         328 $conf->{parser}->register_commands(\@cmds);
276             }
277              
278             # ---------------------------------------------------------------------------
279              
280             sub parsed_metadata {
281 81     81 1 245 my ($self, $opts) = @_;
282              
283 81         194 my $pms = $opts->{permsgstatus};
284 81         208 my $conf = $self->{main}->{conf};
285              
286 81 100       277 if (!$pms->is_dns_available()) {
287 77         320 dbg("asn: DNS is not available, skipping ASN checks");
288 77         263 return;
289             }
290              
291 4 50 33     31 if (!$conf->{asnlookups} && !$conf->{asnlookups_ipv6}) {
292 4         17 dbg("asn: no asn_lookups configured, skipping ASN lookups");
293 4         13 return;
294             }
295              
296             # initialize the tag data so that if no result is returned from the DNS
297             # query we won't end up with a missing tag. Don't use $pms->set_tag()
298             # here to avoid triggering any tag-dependent action unnecessarily
299 0 0         if ($conf->{asnlookups}) {
300 0           foreach my $entry (@{$conf->{asnlookups}}) {
  0            
301 0   0       $pms->{tag_data}->{$entry->{asn_tag}} ||= '';
302 0   0       $pms->{tag_data}->{$entry->{route_tag}} ||= '';
303             }
304             }
305 0 0         if ($conf->{asnlookups_ipv6}) {
306 0           foreach my $entry (@{$conf->{asnlookups_ipv6}}) {
  0            
307 0   0       $pms->{tag_data}->{$entry->{asn_tag}} ||= '';
308 0   0       $pms->{tag_data}->{$entry->{route_tag}} ||= '';
309             }
310             }
311              
312             # get reversed IP address of last external relay to lookup
313             # don't return until we've initialized the template tags
314 0           my $relay = $pms->{relays_external}->[0];
315 0 0         if (!defined $relay) {
    0          
316 0           dbg("asn: no first external relay IP available, skipping ASN check");
317 0           return;
318             } elsif ($relay->{ip_private}) {
319 0           dbg("asn: first external relay is a private IP, skipping ASN check");
320 0           return;
321             }
322              
323 0           my $ip = $relay->{ip};
324 0           my $reversed_ip = reverse_ip_address($ip);
325 0 0         if (defined $reversed_ip) {
326 0           dbg("asn: using first external relay IP for lookups: %s", $ip);
327             } else {
328 0           dbg("asn: could not parse first external relay IP: %s, skipping", $ip);
329 0           return;
330             }
331              
332 0           my $lookup_zone;
333 0 0         if ($ip =~ /^$IPV4_ADDRESS$/o) {
334 0 0         if (!defined $conf->{asnlookups}) {
335 0           dbg("asn: asn_lookup for IPv4 not defined, skipping");
336 0           return;
337             }
338 0           $lookup_zone = "asnlookups";
339             } else {
340 0 0         if (!defined $conf->{asnlookups_ipv6}) {
341 0           dbg("asn: asn_lookup_ipv6 for IPv6 not defined, skipping");
342 0           return;
343             }
344 0           $lookup_zone = "asnlookups_ipv6";
345             }
346            
347             # we use arrays and array indices rather than hashes and hash keys
348             # in case someone wants the same zone added to multiple sets of tags
349 0           my $index = 0;
350 0           foreach my $entry (@{$conf->{$lookup_zone}}) {
  0            
351             # do the DNS query, have the callback process the result
352 0           my $zone_index = $index;
353 0           my $zone = $reversed_ip . '.' . $entry->{zone};
354 0           my $key = "asnlookup-${lookup_zone}-${zone_index}-".$entry->{zone};
355             my $ent = $pms->{async}->bgsend_and_start_lookup($zone, 'TXT', undef,
356             { type => 'ASN', key => $key, zone => $lookup_zone },
357 0     0     sub { my($ent, $pkt) = @_;
358 0           $self->process_dns_result($pms, $pkt, $zone_index, $lookup_zone) },
359             master_deadline => $pms->{master_deadline}
360 0           );
361 0 0         $pms->register_async_rule_start($key) if $ent;
362 0           $index++;
363             }
364             }
365              
366             #
367             # TXT-RR format of response:
368             # 3 fields, each as one TXT RR <character-string> (RFC 1035): ASN IP MASK
369             # The latter two fields are combined to create a CIDR.
370             # or: At least 2 fields made of a single or multiple
371             # <character-string>s, fields are separated by a vertical bar.
372             # They will be the ASN and CIDR fields in any order.
373             # If only one field is returned, it is the ASN. There will
374             # be no CIDR field in that case.
375             #
376             sub process_dns_result {
377 0     0 0   my ($self, $pms, $pkt, $zone_index, $lookup_zone) = @_;
378              
379 0           my $conf = $self->{main}->{conf};
380              
381 0           my $zone = $conf->{$lookup_zone}[$zone_index]->{zone};
382 0           my $asn_tag = $conf->{$lookup_zone}[$zone_index]->{asn_tag};
383 0           my $route_tag = $conf->{$lookup_zone}[$zone_index]->{route_tag};
384              
385 0           my($any_asn_updates, $any_route_updates, $tag_value);
386              
387 0           my(@asn_tag_data, %asn_tag_data_seen);
388 0           $tag_value = $pms->get_tag($asn_tag);
389 0 0         if (defined $tag_value) {
390 0           my $prefix = $pms->{conf}->{asn_prefix};
391 0 0 0       if (defined $prefix && $prefix ne '') {
392             # must strip prefix before splitting on whitespace
393 0           $tag_value =~ s/(^| )\Q$prefix\E(?=\d+)/$1/gs;
394             }
395 0           @asn_tag_data = split(/ /,$tag_value);
396 0           %asn_tag_data_seen = map(($_,1), @asn_tag_data);
397             }
398              
399 0           my(@route_tag_data, %route_tag_data_seen);
400 0           $tag_value = $pms->get_tag($route_tag);
401 0 0         if (defined $tag_value) {
402 0           @route_tag_data = split(/ /,$tag_value);
403 0           %route_tag_data_seen = map(($_,1), @route_tag_data);
404             }
405              
406             # NOTE: $pkt will be undef if the DNS query was aborted (e.g. timed out)
407 0 0         my @answer = !defined $pkt ? () : $pkt->answer;
408              
409 0           foreach my $rr (@answer) {
410             #dbg("asn: %s: lookup result packet: %s", $zone, $rr->string);
411 0 0         next if $rr->type ne 'TXT';
412 0 0         my @strings = $txtdata_can_provide_a_list ? $rr->txtdata :
413             $rr->char_str_list; # historical
414 0 0         next if !@strings;
415 0 0         for (@strings) { utf8::encode($_) if utf8::is_utf8($_) }
  0            
416              
417 0           my @items;
418 0 0 0       if (@strings > 1 && join('',@strings) !~ m{\|}) {
419             # routeviews.org style, multiple string fields in a TXT RR
420 0           @items = @strings;
421 0 0 0       if (@items >= 3 && $items[1] !~ m{/} && $items[2] =~ /^\d+\z/) {
      0        
422 0           $items[1] .= '/' . $items[2]; # append the net mask length to route
423             }
424             } else {
425             # cymru.com and spameatingmonkey.net style, or just a single field
426 0           @items = split(/\s*\|\s*/, join(' ',@strings));
427             }
428              
429 0           my(@route_value, @asn_value);
430 0 0 0       if (@items && $items[0] =~ /(?: (?:^|\s+) (?:AS)? \d+ )+ \z/xsi) {
    0 0        
431             # routeviews.org and cymru.com style, ASN is the first field,
432             # possibly a whitespace-separated list (e.g. cymru.com)
433 0           @asn_value = split(' ',$items[0]);
434 0 0         @route_value = split(' ',$items[1]) if @items >= 2;
435             } elsif (@items > 1 && $items[1] =~ /(?: (?:^|\s+) (?:AS)? \d+ )+ \z/xsi) {
436             # spameatingmonkey.net style, ASN is the second field
437 0           @asn_value = split(' ',$items[1]);
438 0           @route_value = split(' ',$items[0]);
439             } else {
440 0           dbg("asn: unparseable response: %s", join(' ', map("\"$_\"",@strings)));
441             }
442              
443 0           foreach my $route (@route_value) {
444 0 0 0       if (!defined $route || $route eq '') {
    0          
    0          
445             # ignore, just in case
446             } elsif ($route =~ m{/0+\z}) {
447             # unassigned/unannounced address space
448             } elsif ($route_tag_data_seen{$route}) {
449 0           dbg("asn: %s duplicate route %s", $route_tag, $route);
450             } else {
451 0           dbg("asn: %s added route %s", $route_tag, $route);
452 0           push(@route_tag_data, $route);
453 0           $route_tag_data_seen{$route} = 1;
454 0           $any_route_updates = 1;
455             }
456             }
457              
458 0           foreach my $asn (@asn_value) {
459 0           $asn =~ s/^AS(?=\d+)//si;
460 0 0 0       if (!$asn || $asn == 4294967295) {
    0          
461             # unassigned/unannounced address space
462             } elsif ($asn_tag_data_seen{$asn}) {
463 0           dbg("asn: %s duplicate asn %s", $asn_tag, $asn);
464             } else {
465 0           dbg("asn: %s added asn %s", $asn_tag, $asn);
466 0           push(@asn_tag_data, $asn);
467 0           $asn_tag_data_seen{$asn} = 1;
468 0           $any_asn_updates = 1;
469             }
470             }
471             }
472              
473 0 0 0       if ($any_asn_updates && @asn_tag_data) {
474 0           $pms->{msg}->put_metadata('X-ASN', join(' ',@asn_tag_data));
475 0           my $prefix = $pms->{conf}->{asn_prefix};
476 0 0 0       if (defined $prefix && $prefix ne '') { s/^/$prefix/ for @asn_tag_data }
  0            
477 0 0         $pms->set_tag($asn_tag,
478             @asn_tag_data == 1 ? $asn_tag_data[0] : \@asn_tag_data);
479             }
480 0 0 0       if ($any_route_updates && @route_tag_data) {
481             # Bayes already has X-ASN, Route is pointless duplicate, skip
482             #$pms->{msg}->put_metadata('X-ASN-Route', join(' ',@route_tag_data));
483 0 0         $pms->set_tag($route_tag,
484             @route_tag_data == 1 ? $route_tag_data[0] : \@route_tag_data);
485             }
486             }
487              
488             # Version features
489 0     0 0   sub has_asn_lookup_ipv6 { 1 }
490              
491             1;