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