~ubuntu-branches/ubuntu/utopic/spamassassin/utopic-updates

« back to all changes in this revision

Viewing changes to lib/Mail/SpamAssassin/Plugin/ASN.pm

  • Committer: Package Import Robot
  • Author(s): Noah Meyerhans
  • Date: 2014-02-14 22:45:15 UTC
  • mfrom: (0.8.1) (0.6.2) (5.1.22 sid)
  • Revision ID: package-import@ubuntu.com-20140214224515-z1es2twos8xh7n2y
Tags: 3.4.0-1
* New upstream version! (Closes: 738963, 738872, 738867)
* Scrub the environment when switching to the debian-spamd user in
  postinst and cron.daily. (Closes: 738951)
* Enhancements to postinst to better manage ownership of
  /var/lib/spamassassin, via Iain Lane <iain.lane@canonical.com>
  (Closes: 738974)

Show diffs side-by-side

added added

removed removed

Lines of Context:
18
18
# </@LICENSE>
19
19
#
20
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 origin6.asn.cymru.com [_ASN_ _ASNCIDR_]
21
43
 
22
44
=head1 NAME
23
45
 
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.
25
48
 
26
49
=head1 SYNOPSIS
27
50
 
33
56
 
34
57
=head1 DESCRIPTION
35
58
 
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
 
65
may also be used.
42
66
 
43
67
=head1 TEMPLATE TAGS
44
68
 
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.
47
71
 
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:
49
73
 
50
74
 X-Spam-ASN: AS24940 213.239.192.0/18
51
75
 
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:
56
80
 
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
 
82
 
 
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.
58
85
 
59
86
=head1 CONFIGURATION
60
87
 
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.
65
92
 
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
69
96
 
70
97
=head1 SEE ALSO
71
98
 
72
 
http://www.routeviews.org/ - all data regarding routing, ASNs etc
 
99
http://www.routeviews.org/ - all data regarding routing, ASNs, etc....
73
100
 
74
101
http://issues.apache.org/SpamAssassin/show_bug.cgi?id=4770 -
75
102
SpamAssassin Issue #4770 concerning this plugin
88
115
use Mail::SpamAssassin;
89
116
use Mail::SpamAssassin::Plugin;
90
117
use Mail::SpamAssassin::Logger;
 
118
use Mail::SpamAssassin::Util qw(reverse_ip_address);
91
119
use Mail::SpamAssassin::Dns;
92
120
 
93
121
our @ISA = qw(Mail::SpamAssassin::Plugin);
97
125
  $class = ref($class) || $class;
98
126
  my $self = $class->SUPER::new($mailsa);
99
127
  bless ($self, $class);
100
 
  
 
128
 
101
129
  $self->set_config($mailsa->{conf});
102
130
 
103
131
  return $self;
121
149
 
122
150
If no tags are specified the AS number will be added to the _ASN_ tag and the
123
151
routing info will be added to the _ASNCIDR_ tag.  You must specify either none
124
 
or both of the tags.  Tags must start and end with an underscore.
 
152
or both of the tag names.  Tag names must start and end with an underscore.
125
153
 
126
154
If two or more I<asn_lookup>s use the same set of template tags, the results of
127
155
their lookups will be appended to each other in the template tag values in no
143
171
 
144
172
=back
145
173
 
 
174
=over 4
 
175
 
 
176
=item clear_asn_lookups
 
177
 
 
178
=back
 
179
 
 
180
Removes any previously declared I<asn_lookup> entries from a list of queries.
 
181
 
 
182
=over 4
 
183
 
 
184
=item asn_prefix 'prefix_string'       (default: 'AS')
 
185
 
 
186
The string specified in the argument is prepended to each ASN when storing
 
187
it as a tag. This prefix is rather redundant, but its default value 'AS'
 
188
is kept for backward compatibility with versions of SpamAssassin earlier
 
189
than 3.4.0. A sensible setting is an empty string. The argument may be (but
 
190
need not be) enclosed in single or double quotes for clarity.
 
191
 
 
192
=back
 
193
 
146
194
=cut
147
195
 
148
196
  push (@cmds, {
149
197
    setting => 'asn_lookup',
150
198
    is_admin => 1,
151
199
    code => sub {
152
 
      my ($self, $key, $value, $line) = @_;
 
200
      my ($conf, $key, $value, $line) = @_;
153
201
      unless (defined $value && $value !~ /^$/) {
154
202
        return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
155
203
      }
157
205
      unless ($value =~ /^(\S+?)\.?(?:\s+_(\S+)_\s+_(\S+)_)?$/) {
158
206
        return $Mail::SpamAssassin::Conf::INVALID_VALUE;
159
207
      }
160
 
      my $zone = $1.'.';
161
 
      my $asn_tag = (defined $2 ? $2 : 'ASN');
162
 
      my $route_tag = (defined $3 ? $3 : 'ASNCIDR');
163
 
 
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 };
 
213
    }
 
214
  });
 
215
 
 
216
  push (@cmds, {
 
217
    setting => 'clear_asn_lookups',
 
218
    is_admin => 1,
 
219
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_NOARGS,
 
220
    code => sub {
 
221
      my ($conf, $key, $value, $line) = @_;
 
222
      if (defined $value && $value ne '') {
 
223
        return $Mail::SpamAssassin::Conf::INVALID_VALUE;
 
224
      }
 
225
      delete $conf->{asnlookups};
 
226
    }
 
227
  });
 
228
 
 
229
  push (@cmds, {
 
230
    setting => 'asn_prefix',
 
231
    type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
 
232
    default => 'AS',
 
233
    code => sub {
 
234
      my ($conf, $key, $value, $line) = @_;
 
235
      $value = ''  if !defined $value;
 
236
      local($1,$2);
 
237
      $value = $2  if $value =~ /^(['"])(.*)\1\z/;  # strip quotes if any
 
238
      $conf->{$key} = $value;  # keep tainted
165
239
    }
166
240
  });
167
241
 
173
247
sub parsed_metadata {
174
248
  my ($self, $opts) = @_;
175
249
 
176
 
  my $scanner = $opts->{permsgstatus};
 
250
  my $pms = $opts->{permsgstatus};
177
251
  my $conf = $self->{main}->{conf};
178
252
 
179
253
  unless ($conf->{asnlookups}) {
181
255
    return; # no asn_lookups mean no tags need to be initialized
182
256
  }
183
257
 
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");
192
269
  } else {
193
 
    local($1,$2,$3,$4);
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);
197
273
    } else {
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);
199
275
    }
200
276
  }
201
277
 
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
204
280
  my $index = 0;
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}} = '';
210
 
    }
211
 
    unless (defined $scanner->{tag_data}->{$entry->{route_tag}}) {
212
 
      $scanner->{tag_data}->{$entry->{route_tag}} = '';
213
 
    }
214
 
    next unless $reversed_ip_quad;
215
 
  
 
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}} = '';
 
287
    }
 
288
    unless (defined $pms->{tag_data}->{$entry->{route_tag}}) {
 
289
      $pms->{tag_data}->{$entry->{route_tag}} = '';
 
290
    }
 
291
    next unless $reversed_ip;
 
292
 
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);
225
 
    });
226
 
    my $ent = {
227
 
      key=>$key, id=>$id, type=>'TXT',
228
 
      zone => $zone,  # serves to fetch other per-zone settings
229
 
    };
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});
233
 
 
234
 
    $index++;
 
297
    my $ent = $pms->{async}->bgsend_and_start_lookup(
 
298
        $zone, 'TXT', undef,
 
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} );
 
303
    if ($ent) {
 
304
      dbg("asn: launched DNS TXT query for %s.%s in background",
 
305
          $reversed_ip, $entry->{zone});
 
306
      $index++;
 
307
    }
235
308
  }
236
309
}
237
310
 
 
311
#
 
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.
 
320
#
238
321
sub process_dns_result {
239
 
  my ($self, $scanner, $response, $zone_index) = @_;
 
322
  my ($self, $pms, $pkt, $zone_index) = @_;
240
323
 
241
324
  my $conf = $self->{main}->{conf};
242
325
 
244
327
  my $asn_tag = $conf->{asnlookups}[$zone_index]->{asn_tag};
245
328
  my $route_tag = $conf->{asnlookups}[$zone_index]->{route_tag};
246
329
 
247
 
  my @answer = !defined $response ? () : $response->answer;
 
330
  my($any_asn_updates, $any_route_updates, $tag_value);
 
331
 
 
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;
 
339
    }
 
340
    @asn_tag_data = split(/ /,$tag_value);
 
341
    %asn_tag_data_seen = map(($_,1), @asn_tag_data);
 
342
  }
 
343
 
 
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);
 
349
  }
 
350
 
 
351
  # NOTE: $pkt will be undef if the DNS query was aborted (e.g. timed out)
 
352
  my @answer = !defined $pkt ? () : $pkt->answer;
248
353
 
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);
256
 
        next;
257
 
      }
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]";
261
 
        } else {
262
 
          $scanner->{tag_data}->{$asn_tag} = "AS$items[0]";
263
 
        }
264
 
      }
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]";
269
 
        } else {
270
 
          $scanner->{tag_data}->{$route_tag} = "$items[1]/$items[2]";
271
 
        }
272
 
      }
273
 
    }
274
 
  }
275
 
 
276
 
  return;
 
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;
 
358
    next if !@strings;
 
359
 
 
360
    my @items;
 
361
    if (@strings > 1 && join('',@strings) !~ m{\|}) {
 
362
      # routeviews.org style, multiple string fields in a TXT RR
 
363
      @items = @strings;
 
364
      if (@items >= 3 && $items[1] !~ m{/} && $items[2] =~ /^\d+\z/) {
 
365
        $items[1] .= '/' . $items[2];  # append the net mask length to route
 
366
      }
 
367
    } else {
 
368
      # cymru.com and spameatingmonkey.net style, or just a single field
 
369
      @items = split(/\s*\|\s*/, join(' ',@strings));
 
370
    }
 
371
 
 
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]);
 
382
    } else {
 
383
      dbg("asn: unparseable response: %s", join(' ', map("\"$_\"",@strings)));
 
384
    }
 
385
 
 
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);
 
393
      } else {
 
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;
 
398
      }
 
399
    }
 
400
 
 
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);
 
407
      } else {
 
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;
 
412
      }
 
413
    }
 
414
  }
 
415
 
 
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);
 
422
  }
 
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);
 
427
  }
277
428
}
278
429
 
279
430
1;