File Coverage

blib/lib/Mail/SpamAssassin/Plugin/RelayEval.pm
Criterion Covered Total %
statement 35 164 21.3
branch 0 72 0.0
condition 1 69 1.4
subroutine 7 23 30.4
pod 1 14 7.1
total 44 342 12.8


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             package Mail::SpamAssassin::Plugin::RelayEval;
19              
20 22     22   150 use Mail::SpamAssassin::Plugin;
  22         52  
  22         660  
21 22     22   140 use Mail::SpamAssassin::Logger;
  22         107  
  22         1338  
22 22     22   156 use Mail::SpamAssassin::Constants qw(:ip);
  22         48  
  22         2435  
23              
24 22     22   157 use strict;
  22         57  
  22         517  
25 22     22   123 use warnings;
  22         69  
  22         716  
26             # use bytes;
27 22     22   145 use re 'taint';
  22         45  
  22         50585  
28              
29             our @ISA = qw(Mail::SpamAssassin::Plugin);
30              
31             # constructor: register the eval rule
32             sub new {
33 63     63 1 234 my $class = shift;
34 63         168 my $mailsaobject = shift;
35              
36             # some boilerplate...
37 63   33     517 $class = ref($class) || $class;
38 63         394 my $self = $class->SUPER::new($mailsaobject);
39 63         180 bless ($self, $class);
40              
41             # the important bit!
42 63         315 $self->register_eval_rule("check_for_numeric_helo");
43 63         227 $self->register_eval_rule("check_for_illegal_ip");
44 63         211 $self->register_eval_rule("check_all_trusted");
45 63         228 $self->register_eval_rule("check_no_relays");
46 63         241 $self->register_eval_rule("check_relays_unparseable");
47 63         218 $self->register_eval_rule("check_for_sender_no_reverse");
48 63         197 $self->register_eval_rule("check_for_from_domain_in_received_headers");
49 63         213 $self->register_eval_rule("check_for_forged_received_trail");
50 63         197 $self->register_eval_rule("check_for_forged_received_ip_helo");
51 63         229 $self->register_eval_rule("helo_ip_mismatch");
52 63         194 $self->register_eval_rule("check_for_no_rdns_dotcom_helo");
53              
54 63         534 return $self;
55             }
56              
57             # tvd: why isn't this just RegistrarBoundaries ?
58             sub hostname_to_domain {
59 0     0 0   my ($hostname) = @_;
60              
61 0 0         if ($hostname !~ /[a-zA-Z]/) { return $hostname; } # IP address
  0            
62              
63 0           my @parts = split(/\./, $hostname);
64 0 0 0       if (@parts > 1 && $parts[-1] =~ /(?:\S{3,}|ie|fr|de)/) {
    0          
65 0           return join('.', @parts[-2..-1]);
66             }
67             elsif (@parts > 2) {
68 0           return join('.', @parts[-3..-1]);
69             }
70             else {
71 0           return $hostname;
72             }
73             }
74              
75             sub _helo_forgery_whitelisted {
76 0     0     my ($helo, $rdns) = @_;
77 0 0 0       if ($helo eq 'msn.com' && $rdns eq 'hotmail.com') { return 1; }
  0            
78 0           0;
79             }
80              
81             sub check_for_numeric_helo {
82 0     0 0   my ($self, $pms) = @_;
83              
84 0           my $rcvd = $pms->{relays_untrusted_str};
85              
86 0 0         if ($rcvd) {
87 0           my $IP_ADDRESS = IPV4_ADDRESS;
88 0           my $IP_PRIVATE = IP_PRIVATE;
89 0           local $1;
90             # no re "strict"; # since perl 5.21.8: Ranges of ASCII printables...
91 0 0 0       if ($rcvd =~ /\bhelo=($IP_ADDRESS)(?=[\000-\040,;\[()<>]|\z)/i # Bug 5878
92             && $1 !~ /$IP_PRIVATE/) {
93 0           return 1;
94             }
95             }
96 0           return 0;
97             }
98              
99             sub check_for_illegal_ip {
100 0     0 0   my ($self, $pms) = @_;
101             # Bug 6295, no longer in use, kept for compatibility with old rules
102 0           dbg('eval: the "check_for_illegal_ip" eval rule no longer available, '.
103             'please update your rules');
104 0           return 0;
105             }
106              
107             # note using IPv4 addresses for now due to empty strings matching IP_ADDRESS
108             # due to bug in pure IPv6 address regular expression
109             sub helo_ip_mismatch {
110 0     0 0   my ($self, $pms) = @_;
111 0           my $IP_ADDRESS = IPV4_ADDRESS;
112 0           my $IP_PRIVATE = IP_PRIVATE;
113              
114 0           for my $relay (@{$pms->{relays_untrusted}}) {
  0            
115             # is HELO usable?
116             next unless ($relay->{helo} =~ m/^$IP_ADDRESS$/ &&
117 0 0 0       $relay->{helo} !~ /$IP_PRIVATE/);
118             # compare HELO with IP
119             return 1 if ($relay->{ip} =~ m/^$IP_ADDRESS$/ &&
120             $relay->{ip} !~ m/$IP_PRIVATE/ &&
121             $relay->{helo} ne $relay->{ip} &&
122             # different IP is okay if in same /24
123             $relay->{helo} =~ /^(\d+\.\d+\.\d+\.)/ &&
124 0 0 0       index($relay->{ip}, $1) != 0);
      0        
      0        
      0        
125             }
126              
127 0           0;
128             }
129              
130             ###########################################################################
131              
132             sub check_all_trusted {
133 0     0 0   my ($self, $pms) = @_;
134             return $pms->{num_relays_trusted}
135             && !$pms->{num_relays_untrusted}
136 0   0       && !$pms->{num_relays_unparseable};
137             }
138              
139             sub check_no_relays {
140 0     0 0   my ($self, $pms) = @_;
141             return !$pms->{num_relays_trusted}
142             && !$pms->{num_relays_untrusted}
143 0   0       && !$pms->{num_relays_unparseable};
144             }
145              
146             sub check_relays_unparseable {
147 0     0 0   my ($self, $pms) = @_;
148 0           return $pms->{num_relays_unparseable};
149             }
150              
151             # Check if the apparent sender (in the last received header) had
152             # no reverse lookup for it's IP
153             #
154             # Look for headers like:
155             #
156             # Received: from mx1.eudoramail.com ([204.32.147.84])
157             sub check_for_sender_no_reverse {
158 0     0 0   my ($self, $pms) = @_;
159              
160             # Sender received header is the last in the sequence
161             my $srcvd = $pms->{relays_untrusted}->
162 0           [$pms->{num_relays_untrusted} - 1];
163              
164 0 0         return 0 unless (defined $srcvd);
165              
166             # Ignore if the from host is domainless (has no dot)
167 0 0         return 0 unless ($srcvd->{rdns} =~ /\./);
168              
169             # Ignore if the from host is from a private IP range
170 0 0         return 0 if ($srcvd->{ip_private});
171              
172 0           return 1;
173             } # check_for_sender_no_reverse()
174              
175             #Received: from dragnet.sjc.ebay.com (dragnet.sjc.ebay.com [10.6.21.14])
176             # by bashir.ebay.com (8.10.2/8.10.2) with SMTP id g29JpwB10940
177             # for <rod@begbie.com>; Sat, 9 Mar 2002 11:51:58 -0800
178              
179             sub check_for_from_domain_in_received_headers {
180 0     0 0   my ($self, $pms, $domain, $desired) = @_;
181            
182 0 0         if (exists $pms->{from_domain_in_received}) {
183 0 0         if (exists $pms->{from_domain_in_received}->{$domain}) {
184 0 0         if ($desired eq 'true') {
185             # See use of '0e0' below for why we force int() here:
186 0           return int($pms->{from_domain_in_received}->{$domain});
187             }
188             else {
189             # And why we deliberately do NOT use integers here:
190 0           return !$pms->{from_domain_in_received}->{$domain};
191             }
192             }
193             } else {
194 0           $pms->{from_domain_in_received} = {};
195             }
196              
197 0           my $from = $pms->get('From:addr');
198 0 0         if ($from !~ /\b\Q$domain\E/i) {
199             # '0e0' is Perl idiom for "true but zero":
200 0           $pms->{from_domain_in_received}->{$domain} = '0e0';
201 0           return 0;
202             }
203              
204 0           my $rcvd = $pms->{relays_trusted_str}."\n".$pms->{relays_untrusted_str};
205              
206 0 0         if ($rcvd =~ / rdns=\S*\b${domain} [^\]]*by=\S*\b${domain} /) {
207 0           $pms->{from_domain_in_received}->{$domain} = 1;
208 0           return ($desired eq 'true');
209             }
210              
211 0           $pms->{from_domain_in_received}->{$domain} = 0;
212 0           return ($desired ne 'true');
213             }
214              
215             sub check_for_no_rdns_dotcom_helo {
216 0     0 0   my ($self, $pms) = @_;
217 0 0         if (!exists $pms->{no_rdns_dotcom_helo}) { $self->_check_received_helos($pms); }
  0            
218 0           return $pms->{no_rdns_dotcom_helo};
219             }
220              
221             # Bug 1133
222              
223             # Some spammers will, through HELO, tell the server that their machine
224             # name *is* the relay; don't know why. An example:
225              
226             # from mail1.mailwizards.com (m448-mp1.cvx1-b.col.dial.ntli.net
227             # [213.107.233.192])
228             # by mail1.mailwizards.com
229              
230             # When this occurs for real, the from name and HELO name will be the
231             # same, unless the "helo" name is localhost, or the from and by hostsnames
232             # themselves are localhost
233             sub _check_received_helos {
234 0     0     my ($self, $pms) = @_;
235              
236 0           for (my $i = 0; $i < $pms->{num_relays_untrusted}; $i++) {
237 0           my $rcvd = $pms->{relays_untrusted}->[$i];
238              
239             # Ignore where IP is in private IP space
240 0 0         next if ($rcvd->{ip_private});
241              
242 0           my $from_host = $rcvd->{rdns};
243 0           my $helo_host = $rcvd->{helo};
244 0           my $by_host = $rcvd->{by};
245 0           my $no_rdns = $rcvd->{no_reverse_dns};
246              
247 0 0         next unless defined($helo_host);
248              
249             # Check for a faked dotcom HELO, e.g.
250             # Received: from mx02.hotmail.com (www.sucasita.com.mx [148.223.251.99])...
251             # this can be a stronger spamsign than the normal case, since the
252             # big dotcoms don't screw up their rDNS normally ;), so less FPs.
253             # Since spammers like sending out their mails from the dotcoms (esp.
254             # hotmail and AOL) this will catch those forgeries.
255             #
256             # allow stuff before the dot-com for both from-name and HELO-name,
257             # so HELO="outgoing.aol.com" and from="mx34853495.mx.aol.com" works OK.
258             #
259 0           $pms->{no_rdns_dotcom_helo} = 0;
260 0 0         if ($helo_host =~ /(?:\.|^)(lycos\.com|lycos\.co\.uk|hotmail\.com
261             |localhost\.com|excite\.com|caramail\.com
262             |cs\.com|aol\.com|msn\.com|yahoo\.com|drizzle\.com)$/ix)
263             {
264 0           my $dom = $1;
265              
266             # ok, let's catch the case where there's *no* reverse DNS there either
267 0 0         if ($no_rdns) {
268 0           dbg2("eval: Received: no rDNS for dotcom HELO: from=$from_host HELO=$helo_host");
269 0           $pms->{no_rdns_dotcom_helo} = 1;
270             }
271             }
272             }
273             } # _check_received_helos()
274              
275             # FORGED_RCVD_TRAIL
276             sub check_for_forged_received_trail {
277 0     0 0   my ($self, $pms) = @_;
278 0 0         $self->_check_for_forged_received($pms) unless exists $pms->{mismatch_from};
279 0           return ($pms->{mismatch_from} > 1);
280             }
281              
282             # FORGED_RCVD_IP_HELO
283             sub check_for_forged_received_ip_helo {
284 0     0 0   my ($self, $pms) = @_;
285 0 0         $self->_check_for_forged_received($pms) unless exists $pms->{mismatch_ip_helo};
286 0           return ($pms->{mismatch_ip_helo} > 0);
287             }
288              
289             sub _check_for_forged_received {
290 0     0     my ($self, $pms) = @_;
291              
292 0           $pms->{mismatch_from} = 0;
293 0           $pms->{mismatch_ip_helo} = 0;
294              
295 0           my $IP_PRIVATE = IP_PRIVATE;
296              
297 0           my @fromip = map { $_->{ip} } @{$pms->{relays_untrusted}};
  0            
  0            
298             # just pick up domains for these
299             my @by = map {
300 0           hostname_to_domain ($_->{lc_by});
301 0           } @{$pms->{relays_untrusted}};
  0            
302             my @from = map {
303 0           hostname_to_domain ($_->{lc_rdns});
304 0           } @{$pms->{relays_untrusted}};
  0            
305             my @helo = map {
306 0           hostname_to_domain ($_->{lc_helo});
307 0           } @{$pms->{relays_untrusted}};
  0            
308            
309 0           for (my $i = 0; $i < $pms->{num_relays_untrusted}; $i++) {
310 0 0 0       next if (!defined $by[$i] || $by[$i] !~ /^\w+(?:[\w.-]+\.)+\w+$/);
311              
312 0 0 0       if (defined ($from[$i]) && defined($fromip[$i])) {
313 0 0         if ($from[$i] =~ /^localhost(?:\.localdomain)?$/) {
314 0 0         if ($fromip[$i] eq '127.0.0.1') {
315             # valid: bouncing around inside 1 machine, via the localhost
316             # interface (freshmeat newsletter does this). TODO: this
317             # may be obsolete, I think we do this in Received.pm anyway
318 0           $from[$i] = undef;
319             }
320             }
321             }
322              
323 0           my $frm = $from[$i];
324 0           my $hlo = $helo[$i];
325 0           my $by = $by[$i];
326              
327 0 0         dbg2("eval: forged-HELO: from=".(defined $frm ? $frm : "(undef)").
    0          
    0          
328             " helo=".(defined $hlo ? $hlo : "(undef)").
329             " by=".(defined $by ? $by : "(undef)"));
330              
331             # note: this code won't catch IP-address HELOs, but we already have
332             # a separate rule for that anyway.
333              
334 0 0         next unless ($by =~ /^\w+(?:[\w.-]+\.)+\w+$/);
335              
336 0           my $fip = $fromip[$i];
337              
338 0 0 0       if (defined($hlo) && defined($fip)) {
339 0 0 0       if ($hlo =~ /^\d+\.\d+\.\d+\.\d+$/
      0        
340             && $fip =~ /^\d+\.\d+\.\d+\.\d+$/
341             && $fip ne $hlo)
342             {
343 0           $hlo =~ /^(\d+\.\d+)\.\d+\.\d+$/; my $hclassb = $1;
  0            
344 0           $fip =~ /^(\d+\.\d+)\.\d+\.\d+$/; my $fclassb = $1;
  0            
345              
346             # allow private IP addrs here, could be a legit screwup
347 0 0 0       if ($hclassb && $fclassb &&
      0        
      0        
348             $hclassb ne $fclassb &&
349             !($hlo =~ /$IP_PRIVATE/o))
350             {
351 0           dbg2("eval: forged-HELO: massive mismatch on IP-addr HELO: '$hlo' != '$fip'");
352 0           $pms->{mismatch_ip_helo}++;
353             }
354             }
355             }
356              
357 0           my $prev = $from[$i-1];
358 0 0 0       if (defined($prev) && $i > 0
      0        
      0        
      0        
359             && $prev =~ /^\w+(?:[\w.-]+\.)+\w+$/
360             && $by ne $prev && !_helo_forgery_whitelisted($by, $prev))
361             {
362 0           dbg2("eval: forged-HELO: mismatch on from: '$prev' != '$by'");
363 0           $pms->{mismatch_from}++;
364             }
365             }
366             }
367              
368             ###########################################################################
369              
370             # support eval-test verbose debugs using "-Deval"
371             sub dbg2 {
372 0 0   0 0   if (would_log('dbg', 'eval') == 2) {
373 0           dbg(@_);
374             }
375             }
376              
377             1;