20
20
###########################################################################
22
# Originated by Matthias Leisi, 2006-12-15 (SpamAssassin enhancement #4770).
23
# Modifications by D. Stussy, 2010-12-15 (SpamAssassin enhancement #6484):
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.
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"
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.
41
# Some zones also support IPv6 lookups, for example:
42
# asn_lookup origin6.asn.cymru.com [_ASN_ _ASNCIDR_]
24
Mail::SpamAssassin::Plugin::ASN - SpamAssassin plugin to look up the Autonomous System Number (ASN) of the connecting IP address.
46
Mail::SpamAssassin::Plugin::ASN - SpamAssassin plugin to look up the
47
Autonomous System Number (ASN) of the connecting IP address.
36
This plugin uses DNS lookups to the services of
37
C<http://www.routeviews.org/> to do the actual work. Please make sure
38
that your use of the plugin does not overload their infrastructure -
59
This plugin uses DNS lookups to the services of an external DNS zone such
60
as at C<http://www.routeviews.org/> to do the actual work. Please make
61
sure that your use of the plugin does not overload their infrastructure -
39
62
this generally means that B<you should not use this plugin in a
40
63
high-volume environment> or that you should use a local mirror of the
41
zone (see C<ftp://ftp.routeviews.org/dnszones/>).
64
zone (see C<ftp://ftp.routeviews.org/dnszones/>). Other similar zones
43
67
=head1 TEMPLATE TAGS
45
69
This plugin allows you to create template tags containing the connecting
46
70
IP's AS number and route info for that AS number.
48
The default config will add a header that looks like this:
72
The default config will add a header field that looks like this:
50
74
X-Spam-ASN: AS24940 213.239.192.0/18
52
where "AS24940" is the ASN and "213.239.192.0/18" is the route
53
announced by that ASN where the connecting IP address came from. If
54
the AS announces multiple networks (more/less specific), they will
76
where "24940" is the ASN and "213.239.192.0/18" is the route
77
announced by that ASN where the connecting IP address came from.
78
If the AS announces multiple networks (more/less specific), they will
55
79
all be added to the C<_ASNCIDR_> tag, separated by spaces, eg:
57
X-Spam-ASN: AS1680 89.138.0.0/15 89.139.0.0/16
81
X-Spam-ASN: AS1680 89.138.0.0/15 89.139.0.0/16
83
Note that the literal "AS" before the ASN in the _ASN_ tag is configurable
84
through the I<asn_prefix> directive and may be set to an empty string.
59
86
=head1 CONFIGURATION
61
The standard ruleset contains a configuration that will add a header
88
The standard ruleset contains a configuration that will add a header field
62
89
containing ASN data to scanned messages. The bayes tokenizer will use the
63
added header for bayes calculations, and thus affect which BAYES_* rule will
64
trigger for a particular message.
90
added header field for bayes calculations, and thus affect which BAYES_* rule
91
will trigger for a particular message.
66
93
B<Note> that in most cases you should not score on the ASN data directly.
67
94
Bayes learning will probably trigger on the _ASNCIDR_ tag, but probably not
157
205
unless ($value =~ /^(\S+?)\.?(?:\s+_(\S+)_\s+_(\S+)_)?$/) {
158
206
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
161
my $asn_tag = (defined $2 ? $2 : 'ASN');
162
my $route_tag = (defined $3 ? $3 : 'ASNCIDR');
164
push @{$self->{main}->{conf}->{asnlookups}}, { zone=>$zone, asn_tag=>$asn_tag, route_tag=>$route_tag };
208
my ($zone, $asn_tag, $route_tag) = ($1, $2, $3);
209
$asn_tag = 'ASN' if !defined $asn_tag;
210
$route_tag = 'ASNCIDR' if !defined $route_tag;
211
push @{$conf->{asnlookups}},
212
{ zone=>$zone, asn_tag=>$asn_tag, route_tag=>$route_tag };
217
setting => 'clear_asn_lookups',
219
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NOARGS,
221
my ($conf, $key, $value, $line) = @_;
222
if (defined $value && $value ne '') {
223
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
225
delete $conf->{asnlookups};
230
setting => 'asn_prefix',
231
type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
234
my ($conf, $key, $value, $line) = @_;
235
$value = '' if !defined $value;
237
$value = $2 if $value =~ /^(['"])(.*)\1\z/; # strip quotes if any
238
$conf->{$key} = $value; # keep tainted
181
255
return; # no asn_lookups mean no tags need to be initialized
184
# get reversed IP-quad of last external relay to lookup
258
# get reversed IP address of last external relay to lookup
185
259
# don't return until we've initialized the template tags
186
my $reversed_ip_quad;
187
my $relay = $scanner->{relays_external}->[0];
188
if (!$scanner->is_dns_available()) {
260
my($ip,$reversed_ip);
261
my $relay = $pms->{relays_external}->[0];
262
$ip = $relay->{ip} if defined $relay;
263
if (!$pms->is_dns_available()) {
189
264
dbg("asn: DNS is not available, skipping ASN checks");
265
} elsif (!defined $ip) {
266
dbg("asn: no first external relay IP available, skipping ASN check");
190
267
} elsif ($relay->{ip_private}) {
191
268
dbg("asn: first external relay is a private IP, skipping ASN check");
194
if (defined $relay->{ip} && $relay->{ip} =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) {
195
$reversed_ip_quad = "$4.$3.$2.$1";
196
dbg("asn: using first external relay IP for lookups: %s", $relay->{ip});
270
$reversed_ip = reverse_ip_address($ip);
271
if (defined $reversed_ip) {
272
dbg("asn: using first external relay IP for lookups: %s", $ip);
198
dbg("asn: could not parse IP from first external relay, skipping ASN check");
274
dbg("asn: could not parse first external relay IP: %s, skipping", $ip);
202
# random note: we use arrays and array indices rather than hashes and hash
203
# keys in case someone wants the same zone added to multiple sets of tags
278
# we use arrays and array indices rather than hashes and hash keys
279
# in case someone wants the same zone added to multiple sets of tags
205
281
foreach my $entry (@{$conf->{asnlookups}}) {
206
282
# initialize the tag data so that if no result is returned from the DNS
207
# query we won't end up with a missing tag
208
unless (defined $scanner->{tag_data}->{$entry->{asn_tag}}) {
209
$scanner->{tag_data}->{$entry->{asn_tag}} = '';
211
unless (defined $scanner->{tag_data}->{$entry->{route_tag}}) {
212
$scanner->{tag_data}->{$entry->{route_tag}} = '';
214
next unless $reversed_ip_quad;
283
# query we won't end up with a missing tag. Don't use $pms->set_tag()
284
# here to avoid triggering any tag-dependent action unnecessarily
285
unless (defined $pms->{tag_data}->{$entry->{asn_tag}}) {
286
$pms->{tag_data}->{$entry->{asn_tag}} = '';
288
unless (defined $pms->{tag_data}->{$entry->{route_tag}}) {
289
$pms->{tag_data}->{$entry->{route_tag}} = '';
291
next unless $reversed_ip;
216
293
# do the DNS query, have the callback process the result
217
# rather than poll for them later
218
294
my $zone_index = $index;
219
my $zone = $reversed_ip_quad . '.' . $entry->{zone};
295
my $zone = $reversed_ip . '.' . $entry->{zone};
220
296
my $key = "asnlookup-${zone_index}-$entry->{zone}";
221
my $id = $scanner->{main}->{resolver}->bgsend($zone, 'TXT', undef, sub {
222
my ($pkt, $id, $timestamp) = @_;
223
$scanner->{async}->set_response_packet($id, $pkt, $key, $timestamp);
224
$self->process_dns_result($scanner, $pkt, $zone_index);
227
key=>$key, id=>$id, type=>'TXT',
228
zone => $zone, # serves to fetch other per-zone settings
230
$scanner->{async}->start_lookup($ent, $scanner->{master_deadline});
231
dbg("asn: launched DNS TXT query for %s.%s in background",
232
$reversed_ip_quad, $entry->{zone});
297
my $ent = $pms->{async}->bgsend_and_start_lookup(
299
{ key => $key, zone => $zone },
300
sub { my($ent, $pkt) = @_;
301
$self->process_dns_result($pms, $pkt, $zone_index) },
302
master_deadline => $pms->{master_deadline} );
304
dbg("asn: launched DNS TXT query for %s.%s in background",
305
$reversed_ip, $entry->{zone});
312
# TXT-RR format of response:
313
# 3 fields, each as one TXT RR <character-string> (RFC 1035): ASN IP MASK
314
# The latter two fields are combined to create a CIDR.
315
# or: At least 2 fields made of a single or multiple
316
# <character-string>s, fields are separated by a vertical bar.
317
# They will be the ASN and CIDR fields in any order.
318
# If only one field is returned, it is the ASN. There will
319
# be no CIDR field in that case.
238
321
sub process_dns_result {
239
my ($self, $scanner, $response, $zone_index) = @_;
322
my ($self, $pms, $pkt, $zone_index) = @_;
241
324
my $conf = $self->{main}->{conf};
244
327
my $asn_tag = $conf->{asnlookups}[$zone_index]->{asn_tag};
245
328
my $route_tag = $conf->{asnlookups}[$zone_index]->{route_tag};
247
my @answer = !defined $response ? () : $response->answer;
330
my($any_asn_updates, $any_route_updates, $tag_value);
332
my(@asn_tag_data, %asn_tag_data_seen);
333
$tag_value = $pms->get_tag($asn_tag);
334
if (defined $tag_value) {
335
my $prefix = $pms->{conf}->{asn_prefix};
336
if (defined $prefix && $prefix ne '') {
337
# must strip prefix before splitting on whitespace
338
$tag_value =~ s/(^| )\Q$prefix\E(?=\d+)/$1/gs;
340
@asn_tag_data = split(/ /,$tag_value);
341
%asn_tag_data_seen = map(($_,1), @asn_tag_data);
344
my(@route_tag_data, %route_tag_data_seen);
345
$tag_value = $pms->get_tag($route_tag);
346
if (defined $tag_value) {
347
@route_tag_data = split(/ /,$tag_value);
348
%route_tag_data_seen = map(($_,1), @route_tag_data);
351
# NOTE: $pkt will be undef if the DNS query was aborted (e.g. timed out)
352
my @answer = !defined $pkt ? () : $pkt->answer;
249
354
foreach my $rr (@answer) {
250
dbg("asn: %s: lookup result packet: '%s'", $zone, $rr->string);
251
if ($rr->type eq 'TXT') {
252
my @items = split(/ /, $rr->txtdata);
253
unless (@items == 3) {
254
dbg("asn: TXT query response format unknown, ignoring zone: %s, ".
255
"response: '%s'", $zone, $rr->txtdata);
258
unless ($scanner->{tag_data}->{$asn_tag} =~ /\b\QAS$items[0]\E\b/) {
259
if ($scanner->{tag_data}->{$asn_tag}) {
260
$scanner->{tag_data}->{$asn_tag} .= " AS$items[0]";
262
$scanner->{tag_data}->{$asn_tag} = "AS$items[0]";
265
unless ($scanner->{tag_data}->{$route_tag} =~
266
m{\b\Q$items[1]/$items[2]\E\b}) {
267
if ($scanner->{tag_data}->{$route_tag}) {
268
$scanner->{tag_data}->{$route_tag} .= " $items[1]/$items[2]";
270
$scanner->{tag_data}->{$route_tag} = "$items[1]/$items[2]";
355
dbg("asn: %s: lookup result packet: %s", $zone, $rr->string);
356
next if $rr->type ne 'TXT';
357
my @strings = $rr->char_str_list;
361
if (@strings > 1 && join('',@strings) !~ m{\|}) {
362
# routeviews.org style, multiple string fields in a TXT RR
364
if (@items >= 3 && $items[1] !~ m{/} && $items[2] =~ /^\d+\z/) {
365
$items[1] .= '/' . $items[2]; # append the net mask length to route
368
# cymru.com and spameatingmonkey.net style, or just a single field
369
@items = split(/\s*\|\s*/, join(' ',@strings));
372
my(@route_value, @asn_value);
373
if (@items && $items[0] =~ /(?: (?:^|\s+) (?:AS)? \d+ )+ \z/xsi) {
374
# routeviews.org and cymru.com style, ASN is the first field,
375
# possibly a whitespace-separated list (e.g. cymru.com)
376
@asn_value = split(' ',$items[0]);
377
@route_value = split(' ',$items[1]) if @items >= 2;
378
} elsif (@items > 1 && $items[1] =~ /(?: (?:^|\s+) (?:AS)? \d+ )+ \z/xsi) {
379
# spameatingmonkey.net style, ASN is the second field
380
@asn_value = split(' ',$items[1]);
381
@route_value = split(' ',$items[0]);
383
dbg("asn: unparseable response: %s", join(' ', map("\"$_\"",@strings)));
386
foreach my $route (@route_value) {
387
if (!defined $route || $route eq '') {
388
# ignore, just in case
389
} elsif ($route =~ m{/0+\z}) {
390
# unassigned/unannounced address space
391
} elsif ($route_tag_data_seen{$route}) {
392
dbg("asn: %s duplicate route %s", $route_tag, $route);
394
dbg("asn: %s added route %s", $route_tag, $route);
395
push(@route_tag_data, $route);
396
$route_tag_data_seen{$route} = 1;
397
$any_route_updates = 1;
401
foreach my $asn (@asn_value) {
402
$asn =~ s/^AS(?=\d+)//si;
403
if (!$asn || $asn == 4294967295) {
404
# unassigned/unannounced address space
405
} elsif ($asn_tag_data_seen{$asn}) {
406
dbg("asn: %s duplicate asn %s", $asn_tag, $asn);
408
dbg("asn: %s added asn %s", $asn_tag, $asn);
409
push(@asn_tag_data, $asn);
410
$asn_tag_data_seen{$asn} = 1;
411
$any_asn_updates = 1;
416
if ($any_asn_updates && @asn_tag_data) {
417
$pms->{msg}->put_metadata('X-ASN', join(' ',@asn_tag_data));
418
my $prefix = $pms->{conf}->{asn_prefix};
419
if (defined $prefix && $prefix ne '') { s/^/$prefix/ for @asn_tag_data }
420
$pms->set_tag($asn_tag,
421
@asn_tag_data == 1 ? $asn_tag_data[0] : \@asn_tag_data);
423
if ($any_route_updates && @route_tag_data) {
424
$pms->{msg}->put_metadata('X-ASN-Route', join(' ',@route_tag_data));
425
$pms->set_tag($route_tag,
426
@route_tag_data == 1 ? $route_tag_data[0] : \@route_tag_data);