home *** CD-ROM | disk | FTP | other *** search
/ ftp.f-secure.com / 2014.06.ftp.f-secure.com.tar / ftp.f-secure.com / support / hotfix / fsis / IS-SpamControl.fsfix / iufssc / lib / Mail / SpamAssassin / EvalTests.pm < prev    next >
Text File  |  2006-11-29  |  94KB  |  3,187 lines

  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. #     http://www.apache.org/licenses/LICENSE-2.0
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. # </@LICENSE>
  15.  
  16. package Mail::SpamAssassin::EvalTests;
  17. 1;
  18.  
  19. package Mail::SpamAssassin::PerMsgStatus;
  20.  
  21. use strict;
  22. use warnings;
  23. use bytes;
  24.  
  25. use Mail::SpamAssassin::Conf;
  26. use Mail::SpamAssassin::Dns;
  27. use Mail::SpamAssassin::Locales;
  28. use Mail::SpamAssassin::MailingList;
  29. use Mail::SpamAssassin::PerMsgStatus;
  30. use Mail::SpamAssassin::Constants qw(:ip);
  31.  
  32. use Digest::SHA1 qw(sha1_hex);
  33. use Fcntl;
  34. use File::Path;
  35. use Time::Local;
  36. use File::Basename;
  37.  
  38. #use constant HAS_DB_FILE => eval { require DB_File; };
  39. use constant HAS_SDBM => eval { require SDBM_File; };
  40.  
  41. use vars qw{
  42.   $CCTLDS_WITH_LOTS_OF_OPEN_RELAYS
  43.   $ROUND_THE_WORLD_RELAYERS
  44.   $WORD_OBFUSCATION_CHARS 
  45.   $CHARSETS_LIKELY_TO_FP_AS_CAPS
  46. };
  47.  
  48. # sad but true. sort it out, sysadmins!
  49. $CCTLDS_WITH_LOTS_OF_OPEN_RELAYS = qr{(?:kr|cn|cl|ar|hk|il|th|tw|sg|za|tr|ma|ua|in|pe|br)};
  50. $ROUND_THE_WORLD_RELAYERS = qr{(?:net|com|ca)};
  51.  
  52. # Here's how that RE was determined... relay rape by country (as of my
  53. # spam collection on Dec 12 2001):
  54. #
  55. #     10 in     10 ua     11 ma     11 tr     11 za     12 gr
  56. #     13 pl     14 se     15 hu     17 sg     19 dk     19 pt
  57. #     19 th     21 us     22 hk     24 il     26 ch     27 ar
  58. #     27 es     29 cz     32 cl     32 mx     37 nl     38 fr
  59. #     41 it     43 ru     59 au     62 uk     67 br     70 ca
  60. #    104 tw    111 de    123 jp    130 cn    191 kr
  61. #
  62. # However, since some ccTLDs just have more hosts/domains (skewing those
  63. # figures), I cut down this list using data from
  64. # http://www.isc.org/ds/WWW-200107/. I used both hostcount and domain counts
  65. # for figuring this. any ccTLD with > about 40000 domains is left out of this
  66. # regexp.  Then I threw in some unscientific seasoning to taste. ;)
  67.  
  68. $WORD_OBFUSCATION_CHARS = '*_.,/|-+=';
  69.  
  70. # Charsets which use capital letters heavily in their encoded representation.
  71. $CHARSETS_LIKELY_TO_FP_AS_CAPS = qr{[-_a-z0-9]*(?:
  72.       koi|jp|jis|euc|gb|big5|isoir|cp1251|georgianps|pt154|tis
  73.     )[-_a-z0-9]*}ix;
  74.  
  75. ###########################################################################
  76. # HEAD TESTS:
  77. ###########################################################################
  78.  
  79. # From and To have same address, but are not exactly the same and
  80. # neither contains intermediate spaces.
  81. sub check_for_from_to_same {
  82.   my ($self) = @_;
  83.  
  84.   my $hdr_from = $self->get('From');
  85.   my $hdr_to = $self->get('To');
  86.   return 0 if (!length($hdr_from) || !length($hdr_to) ||
  87.            $hdr_from eq $hdr_to);
  88.  
  89.   my $addr_from = $self->get('From:addr');
  90.   my $addr_to = $self->get('To:addr');
  91.   # BUG: From:addr and To:addr sometimes contain whitespace
  92.   $addr_from =~ s/\s+//g;
  93.   $addr_to =~ s/\s+//g;
  94.   return 0 if (!length($addr_from) || !length($addr_to) ||
  95.            $addr_from ne $addr_to);
  96.  
  97.   if ($hdr_from =~ /^\s*\S+\s*$/ && $hdr_to =~ /^\s*\S+\s*$/) {
  98.     return 1;
  99.   }
  100. }
  101.  
  102. sub check_for_matching_env_and_hdr_from {
  103.   my ($self) =@_;
  104.   # two blank headers match so don't bother checking
  105.   return (lc $self->get('EnvelopeFrom:addr') eq lc $self->get('From:addr'));
  106. }
  107.  
  108. sub sorted_recipients {
  109.   my ($self) = @_;
  110.  
  111.   if (!exists $self->{tocc_sorted}) {
  112.     $self->_check_recipients();
  113.   }
  114.   return $self->{tocc_sorted};
  115. }
  116.  
  117. sub similar_recipients {
  118.   my ($self, $min, $max) = @_;
  119.  
  120.   if (!exists $self->{tocc_similar}) {
  121.     $self->_check_recipients();
  122.   }
  123.   return (($min eq 'undef' || $self->{tocc_similar} >= $min) &&
  124.       ($max eq 'undef' || $self->{tocc_similar} < $max));
  125. }
  126.  
  127. # best experimentally derived values
  128. use constant TOCC_SORTED_COUNT => 7;
  129. use constant TOCC_SIMILAR_COUNT => 5;
  130. use constant TOCC_SIMILAR_LENGTH => 2;
  131.  
  132. sub _check_recipients {
  133.   my ($self) = @_;
  134.  
  135.   my @inputs;
  136.  
  137.   # ToCc: pseudo-header works best, but sometimes Bcc: is better
  138.   for ('ToCc', 'Bcc') {
  139.     my $to = $self->get($_);    # get recipients
  140.     $to =~ s/\(.*?\)//g;    # strip out the (comments)
  141.     push(@inputs, ($to =~ m/([\w.=-]+\@\w+(?:[\w.-]+\.)+\w+)/g));
  142.     last if scalar(@inputs) >= TOCC_SIMILAR_COUNT;
  143.   }
  144.  
  145.   # remove duplicate addresses only when they appear next to each other
  146.   my @address;
  147.   my $previous = '';
  148.   while (my $current = shift @inputs) {
  149.     push(@address, ($previous = $current)) if lc($current) ne lc($previous);
  150.     last if @address == 256;
  151.   }
  152.  
  153.   # ideas that had both poor S/O ratios and poor hit rates:
  154.   # - testing for reverse sorted recipient lists
  155.   # - testing To: and Cc: headers separately
  156.   $self->{tocc_sorted} = (scalar(@address) >= TOCC_SORTED_COUNT &&
  157.               join(',', @address) eq (join(',', sort @address)));
  158.  
  159.   # a good S/O ratio and hit rate is achieved by comparing 2-byte
  160.   # substrings and requiring 5 or more addresses
  161.   $self->{tocc_similar} = 0;
  162.   if (scalar (@address) >= TOCC_SIMILAR_COUNT) {
  163.     my @user = map { substr($_,0,TOCC_SIMILAR_LENGTH) } @address;
  164.     my @fqhn = map { m/\@(.*)/ } @address;
  165.     my @host = map { substr($_,0,TOCC_SIMILAR_LENGTH) } @fqhn;
  166.     my $hits = 0;
  167.     my $combinations = 0;
  168.     for (my $i = 0; $i <= $#address; $i++) {
  169.       for (my $j = $i+1; $j <= $#address; $j++) {
  170.     $hits++ if $user[$i] eq $user[$j];
  171.     $hits++ if $host[$i] eq $host[$j] && $fqhn[$i] ne $fqhn[$j];
  172.     $combinations++;
  173.       }
  174.     }
  175.     $self->{tocc_similar} = $hits / $combinations;
  176.   }
  177. }
  178.  
  179. ###########################################################################
  180.  
  181. # Message-ID for untrusted message was added by a trusted relay
  182. sub message_id_from_mta {
  183.   my ($self) = @_;
  184.  
  185.   my $id = $self->get('MESSAGEID');
  186.  
  187.   if ($id && $self->{num_relays_untrusted} > 0) {
  188.     for my $rcvd (@{$self->{relays_untrusted}}[0], @{$self->{relays_trusted}})
  189.     {
  190.       return 1 if $rcvd->{id} && (index(lc($id), lc($rcvd->{id})) != -1);
  191.     }
  192.   }
  193.   return 0;
  194. }
  195.  
  196. ###########################################################################
  197.  
  198. # FORGED_RCVD_TRAIL
  199. sub check_for_forged_received_trail {
  200.   my ($self) = @_;
  201.   $self->_check_for_forged_received unless exists $self->{mismatch_from};
  202.   return ($self->{mismatch_from} > 1);
  203. }
  204.  
  205. # FORGED_RCVD_HELO
  206. sub check_for_forged_received_helo {
  207.   my ($self) = @_;
  208.   $self->_check_for_forged_received unless exists $self->{mismatch_helo};
  209.   return ($self->{mismatch_helo} > 0);
  210. }
  211.  
  212. # FORGED_RCVD_IP_HELO
  213. sub check_for_forged_received_ip_helo {
  214.   my ($self) = @_;
  215.   $self->_check_for_forged_received unless exists $self->{mismatch_ip_helo};
  216.   return ($self->{mismatch_ip_helo} > 0);
  217. }
  218.  
  219. sub _check_for_forged_received {
  220.   my ($self) = @_;
  221.  
  222.   $self->{mismatch_from} = 0;
  223.   $self->{mismatch_helo} = 0;
  224.   $self->{mismatch_ip_helo} = 0;
  225.  
  226.   my $IP_PRIVATE = IP_PRIVATE;
  227.  
  228.   my @fromip = map { $_->{ip} } @{$self->{relays_untrusted}};
  229.   # just pick up domains for these
  230.   my @by = map {
  231.                hostname_to_domain ($_->{lc_by});
  232.              } @{$self->{relays_untrusted}};
  233.   my @from = map {
  234.                hostname_to_domain ($_->{lc_rdns});
  235.              } @{$self->{relays_untrusted}};
  236.   my @helo = map {
  237.                hostname_to_domain ($_->{lc_helo});
  238.              } @{$self->{relays_untrusted}};
  239.  
  240.   for (my $i = 0; $i < $self->{num_relays_untrusted}; $i++) {
  241.     next if (!defined $by[$i] || $by[$i] !~ /^\w+(?:[\w.-]+\.)+\w+$/);
  242.  
  243.     if (defined ($from[$i]) && defined($fromip[$i])) {
  244.       if ($from[$i] =~ /^localhost(?:\.localdomain)?$/) {
  245.         if ($fromip[$i] eq '127.0.0.1') {
  246.           # valid: bouncing around inside 1 machine, via the localhost
  247.           # interface (freshmeat newsletter does this).  TODO: this
  248.       # may be obsolete, I think we do this in Received.pm anyway
  249.           $from[$i] = undef;
  250.         }
  251.       }
  252.     }
  253.  
  254.     my $frm = $from[$i];
  255.     my $hlo = $helo[$i];
  256.     my $by = $by[$i];
  257.  
  258.     dbg("eval: forged-HELO: from=".(defined $frm ? $frm : "(undef)").
  259.             " helo=".(defined $hlo ? $hlo : "(undef)").
  260.             " by=".(defined $by ? $by : "(undef)"));
  261.  
  262.     # note: this code won't catch IP-address HELOs, but we already have
  263.     # a separate rule for that anyway.
  264.  
  265.     next unless ($by =~ /^\w+(?:[\w.-]+\.)+\w+$/);
  266.  
  267.     if (defined($hlo) && defined($frm)
  268.         && $hlo =~ /^\w+(?:[\w.-]+\.)+\w+$/
  269.         && $frm =~ /^\w+(?:[\w.-]+\.)+\w+$/
  270.         && $frm ne $hlo && !helo_forgery_whitelisted($frm, $hlo))
  271.     {
  272.       dbg("eval: forged-HELO: mismatch on HELO: '$hlo' != '$frm'");
  273.       $self->{mismatch_helo}++;
  274.     }
  275.  
  276.     my $fip = $fromip[$i];
  277.  
  278.     if (defined($hlo) && defined($fip)) {
  279.       if ($hlo =~ /^\d+\.\d+\.\d+\.\d+$/
  280.           && $fip =~ /^\d+\.\d+\.\d+\.\d+$/
  281.           && $fip ne $hlo)
  282.       {
  283.     $hlo =~ /^(\d+\.\d+)\.\d+\.\d+$/; my $hclassb = $1;
  284.     $fip =~ /^(\d+\.\d+)\.\d+\.\d+$/; my $fclassb = $1;
  285.  
  286.     # allow private IP addrs here, could be a legit screwup
  287.     if ($hclassb && $fclassb && 
  288.         $hclassb ne $fclassb &&
  289.         !($hlo =~ /$IP_PRIVATE/o))
  290.     {
  291.       dbg("eval: forged-HELO: massive mismatch on IP-addr HELO: '$hlo' != '$fip'");
  292.       $self->{mismatch_ip_helo}++;
  293.     }
  294.       }
  295.     }
  296.  
  297.     my $prev = $from[$i-1];
  298.     if (defined($prev) && $i > 0
  299.         && $prev =~ /^\w+(?:[\w.-]+\.)+\w+$/
  300.         && $by ne $prev && !helo_forgery_whitelisted($by, $prev))
  301.     {
  302.       dbg("eval: forged-HELO: mismatch on from: '$prev' != '$by'");
  303.       $self->{mismatch_from}++;
  304.     }
  305.   }
  306. }
  307.  
  308. sub helo_forgery_whitelisted {
  309.   my ($helo, $rdns) = @_;
  310.   if ($helo eq 'msn.com' && $rdns eq 'hotmail.com') { return 1; }
  311.   0;
  312. }
  313.  
  314. sub hostname_to_domain {
  315.   my ($hostname) = @_;
  316.  
  317.   if ($hostname !~ /[a-zA-Z]/) { return $hostname; }    # IP address
  318.  
  319.   my @parts = split(/\./, $hostname);
  320.   if (@parts > 1 && $parts[-1] =~ /(?:\S{3,}|ie|fr|de)/) {
  321.     return join('.', @parts[-2..-1]);
  322.   }
  323.   elsif (@parts > 2) {
  324.     return join('.', @parts[-3..-1]);
  325.   }
  326.   else {
  327.     return $hostname;
  328.   }
  329. }
  330.  
  331. # FORGED_HOTMAIL_RCVD
  332. sub _check_for_forged_hotmail_received_headers {
  333.   my ($self) = @_;
  334.  
  335.   if (defined $self->{hotmail_addr_but_no_hotmail_received}) { return; }
  336.  
  337.   $self->{hotmail_addr_with_forged_hotmail_received} = 0;
  338.   $self->{hotmail_addr_but_no_hotmail_received} = 0;
  339.  
  340.   my $rcvd = $self->get('Received');
  341.   $rcvd =~ s/\s+/ /gs;        # just spaces, simplify the regexp
  342.  
  343.   return if ($rcvd =~
  344.     /from mail pickup service by hotmail\.com with Microsoft SMTPSVC;/);
  345.  
  346.   # Microsoft passes Hotmail mail directly to MSN Group servers.
  347.   return if $self->check_for_msn_groups_headers();
  348.  
  349.   my $ip = $self->get('X-Originating-Ip');
  350.   my $IP_ADDRESS = IP_ADDRESS;
  351.  
  352.   if ($ip =~ /$IP_ADDRESS/) { $ip = 1; } else { $ip = 0; }
  353.  
  354.   # Hotmail formats its received headers like this:
  355.   # Received: from hotmail.com (f135.law8.hotmail.com [216.33.241.135])
  356.   # spammers do not ;)
  357.  
  358.   if ($self->gated_through_received_hdr_remover()) { return; }
  359.  
  360.   if ($rcvd =~ /from \S*hotmail.com \(\S+\.hotmail(?:\.msn)?\.com[ \)]/ && $ip)
  361.                 { return; }
  362.   if ($rcvd =~ /from \S+ by \S+\.hotmail(?:\.msn)?\.com with HTTP\;/ && $ip)
  363.                 { return; }
  364.   if ($rcvd =~ /from \[66\.218.\S+\] by \S+\.yahoo\.com/ && $ip)
  365.                 { return; }
  366.  
  367.   if ($rcvd =~ /(?:from |HELO |helo=)\S*hotmail\.com\b/) {
  368.     # HELO'd as hotmail.com, despite not being hotmail
  369.     $self->{hotmail_addr_with_forged_hotmail_received} = 1;
  370.   } else {
  371.     # check to see if From claimed to be @hotmail.com
  372.     my $from = $self->get('From:addr');
  373.     if ($from !~ /hotmail.com/) { return; }
  374.     $self->{hotmail_addr_but_no_hotmail_received} = 1;
  375.   }
  376. }
  377.  
  378. # FORGED_HOTMAIL_RCVD
  379. sub check_for_forged_hotmail_received_headers {
  380.   my ($self) = @_;
  381.   $self->_check_for_forged_hotmail_received_headers();
  382.   return $self->{hotmail_addr_with_forged_hotmail_received};
  383. }
  384.  
  385. # SEMIFORGED_HOTMAIL_RCVD
  386. sub check_for_no_hotmail_received_headers {
  387.   my ($self) = @_;
  388.   $self->_check_for_forged_hotmail_received_headers();
  389.   return $self->{hotmail_addr_but_no_hotmail_received};
  390. }
  391.  
  392. # MSN_GROUPS
  393. sub check_for_msn_groups_headers {
  394.   my ($self) = @_;
  395.  
  396.   return 0 unless ($self->get('To') =~ /<(\S+)\@groups\.msn\.com>/i);
  397.   my $listname = $1;
  398.  
  399.   # from Theo Van Dinter, see
  400.   # http://www.hughes-family.org/bugzilla/show_bug.cgi?id=591
  401.   # Updated by DOS, based on messages from Bob Menschel, bug 4301
  402.  
  403.   return 0 unless $self->get('Received') =~ /from mail pickup service by ((?:p\d\d\.)groups\.msn\.com)\b/;
  404.   my $server = $1;
  405.  
  406.   if ($listname =~ /^notifications$/) {
  407.     return 0 unless $self->get('Message-Id') =~ /^<\S+\@$server>/;
  408.   } else {
  409.     return 0 unless $self->get('Message-Id') =~ /^<$listname-\S+\@groups\.msn\.com>/;
  410.     return 0 unless $self->get('EnvelopeFrom') =~ /$listname-bounce\@groups\.msn\.com/;
  411.   }
  412.   return 1;
  413.  
  414. # MSN Groups
  415. # Return-path: <ListName-bounce@groups.msn.com>
  416. # Received: from groups.msn.com (tk2dcpuba02.msn.com [65.54.195.210]) by
  417. #    dogma.slashnull.org (8.11.6/8.11.6) with ESMTP id g72K35v10457 for
  418. #    <zzzzzzzzzzzz@jmason.org>; Fri, 2 Aug 2002 21:03:05 +0100
  419. # Received: from mail pickup service by groups.msn.com with Microsoft
  420. #    SMTPSVC; Fri, 2 Aug 2002 13:01:30 -0700
  421. # Message-id: <ListName-1392@groups.msn.com>
  422. # X-loop: notifications@groups.msn.com
  423. # Reply-to: "List Full Name" <ListName@groups.msn.com>
  424. # To: "List Full Name" <ListName@groups.msn.com>
  425.  
  426. # Return-path: <ListName-bounce@groups.msn.com>
  427. # Received: from p04.groups.msn.com ([65.54.195.216]) etc...
  428. # Received: from mail pickup service by p04.groups.msn.com with Microsoft SMTPSVC;
  429. #          Thu, 5 May 2005 20:30:37 -0700
  430. # X-Originating-Ip: 207.68.170.30
  431. # From: =?iso-8859-1?B?IqSj4/D9pEbzeN9s9vLw6qQiIA==?=<zzzzzzzz@hotmail.com>
  432. # To: "Managers of List Name" <notifications@groups.msn.com>
  433. # Subject: =?iso-8859-1?Q?APPROVAL_NEEDED:_=A4=A3=E3=F0=FD=A4F=F3x=DFl?=
  434. #         =?iso-8859-1?Q?=F6=F2=F0=EA=A4_applied_to_join_List_Name=2C?=
  435. #         =?iso-8859-1?Q?_an_MSN_Group?=
  436. # Date: Thu, 5 May 2005 20:30:37 -0700
  437. # MIME-Version: 1.0
  438. # Content-Type: multipart/alternative;
  439. #         boundary="----=_NextPart_000_333944_01C551B1.4BBA02B0"
  440. # X-MimeOLE: Produced By Microsoft MimeOLE V5.50.4927.1200
  441. # Message-ID: <TK2DCPUBA042cv0aGlt00020aa3@p04.groups.msn.com>
  442.  
  443. # Return-path: <ListName-bounce@groups.msn.com>
  444. # Received: from [65.54.208.83] (helo=p05.groups.msn.com) etc...
  445. # Received: from mail pickup service by p05.groups.msn.com with Microsoft SMTPSVC;
  446. #          Fri, 6 May 2005 14:59:25 -0700
  447. # X-Originating-Ip: 207.68.170.30
  448. # Message-Id: <ListName-101@groups.msn.com>
  449. # Reply-To: "List Name" <ListName@groups.msn.com>
  450. # From: "whoever" <zzzzzzzzzz@hotmail.com>
  451. # To: "List Name" <ListName@groups.msn.com>
  452. # Subject: whatever
  453. # Date: Fri, 6 May 2005 14:59:25 -0700
  454.  
  455. }
  456.  
  457. ###########################################################################
  458.  
  459. sub check_for_forged_eudoramail_received_headers {
  460.   my ($self) = @_;
  461.  
  462.   my $from = $self->get('From:addr');
  463.   if ($from !~ /eudoramail.com/) { return 0; }
  464.  
  465.   my $rcvd = $self->get('Received');
  466.   $rcvd =~ s/\s+/ /gs;        # just spaces, simplify the regexp
  467.  
  468.   my $ip = $self->get('X-Sender-Ip');
  469.   my $IP_ADDRESS = IP_ADDRESS;
  470.   if ($ip =~ /$IP_ADDRESS/) { $ip = 1; } else { $ip = 0; }
  471.  
  472.   # Eudoramail formats its received headers like this:
  473.   # Received: from Unknown/Local ([?.?.?.?]) by shared1-mail.whowhere.com;
  474.   #      Thu Nov 29 13:44:25 2001
  475.   # Message-Id: <JGDHDEHPPJECDAAA@shared1-mail.whowhere.com>
  476.   # Organization: QUALCOMM Eudora Web-Mail  (http://www.eudoramail.com:80)
  477.   # X-Sender-Ip: 192.175.21.146
  478.   # X-Mailer: MailCity Service
  479.  
  480.   if ($self->gated_through_received_hdr_remover()) { return 0; }
  481.  
  482.   if ($rcvd =~ /by \S*whowhere.com\;/ && $ip) { return 0; }
  483.   
  484.   return 1;
  485. }
  486.  
  487. ###########################################################################
  488.  
  489. sub check_for_forged_excite_received_headers {
  490.   my ($self) = @_;
  491.  
  492.   my $from = $self->get('From:addr');
  493.   if ($from !~ /excite.com/) { return 0; }
  494.  
  495.   my $rcvd = $self->get('Received');
  496.   $rcvd =~ s/\s+/ /gs;        # just spaces, simplify the regexp
  497.  
  498.   # Excite formats its received headers like this:
  499.   # Received: from bucky.excite.com ([198.3.99.218]) by vaxc.cc.monash.edu.au
  500.   #    (PMDF V6.0-24 #38147) with ESMTP id
  501.   #    <01K53WHA3OGCA5W9MM@vaxc.cc.monash.edu.au> for luv@luv.asn.au;
  502.   #    Sat, 23 Jun 2001 13:36:20 +1000
  503.   # Received: from hippie.excite.com ([199.172.148.180]) by bucky.excite.com
  504.   #    (InterMail vM.4.01.02.39 201-229-119-122) with ESMTP id
  505.   #    <20010623033612.NRCY6361.bucky.excite.com@hippie.excite.com> for
  506.   #    <luv@luv.asn.au>; Fri, 22 Jun 2001 20:36:12 -0700
  507.   # spammers do not ;)
  508.  
  509.   if ($self->gated_through_received_hdr_remover()) { return 0; }
  510.  
  511.   if ($rcvd =~ /from \S*excite.com (\S+) by \S*excite.com/) { return 0; }
  512.   
  513.   return 1;
  514. }
  515.  
  516. ###########################################################################
  517.  
  518. sub check_for_forged_yahoo_received_headers {
  519.   my ($self) = @_;
  520.  
  521.   my $from = $self->get('From:addr');
  522.   if ($from !~ /yahoo\.com$/) { return 0; }
  523.  
  524.   my $rcvd = $self->get('Received');
  525.   
  526.   if ($self->get("Resent-From") && $self->get("Resent-To")) {
  527.     my $xrcvd = $self->get("X-Received");
  528.     $rcvd = $xrcvd if $xrcvd;
  529.   }
  530.   $rcvd =~ s/\s+/ /gs;        # just spaces, simplify the regexp
  531.  
  532.   # not sure about this
  533.   #if ($rcvd !~ /from \S*yahoo\.com/) { return 0; }
  534.  
  535.   if ($self->gated_through_received_hdr_remover()) { return 0; }
  536.  
  537.   # bug 3740: ignore bounces from Yahoo!.   only honoured if the
  538.   # correct rDNS shows up in the trusted relay list, or first untrusted relay
  539.   if ($from eq 'MAILER-DAEMON@yahoo.com' &&
  540.       ($self->{relays_trusted_str} =~ / rdns=\S+\.yahoo\.com /
  541.         || $self->{relays_untrusted_str} =~ /^[^\]]+ rdns=\S+\.yahoo\.com /))
  542.             { return 0; }
  543.  
  544.   if ($rcvd =~ /by web\S+\.mail\S*\.yahoo\.com via HTTP/) { return 0; }
  545.   if ($rcvd =~ /by smtp\S+\.yahoo\.com with SMTP/) { return 0; }
  546.   my $IP_ADDRESS = IP_ADDRESS;
  547.   if ($rcvd =~
  548.       /from \[$IP_ADDRESS\] by \S+\.(?:groups|scd|dcn)\.yahoo\.com with NNFMP/) {
  549.     return 0;
  550.   }
  551.  
  552.   # used in "forward this news item to a friend" links.  There's no better
  553.   # received hdrs to match on, unfortunately.  I'm not sure if the next test is
  554.   # still useful, as a result.
  555.   #
  556.   # search for msgid <20020929140301.451A92940A9@xent.com>, subject "Yahoo!
  557.   # News Story - Top Stories", date Sep 29 2002 on
  558.   # <http://xent.com/pipermail/fork/> for an example.
  559.   #
  560.   if ($rcvd =~ /\bmailer\d+\.bulk\.scd\.yahoo\.com\b/
  561.                 && $from =~ /\@reply\.yahoo\.com$/) { return 0; }
  562.  
  563.   if ($rcvd =~ /by \w+\.\w+\.yahoo\.com \(\d+\.\d+\.\d+\/\d+\.\d+\.\d+\)(?: with ESMTP)? id \w+/) {
  564.       # possibly sent from "mail this story to a friend"
  565.       return 0;
  566.   }
  567.  
  568.   return 1;
  569. }
  570.  
  571. sub check_for_forged_juno_received_headers {
  572.   my ($self) = @_;
  573.  
  574.   my $from = $self->get('From:addr');
  575.   if($from !~ /\bjuno.com/) { return 0; }
  576.  
  577.   if($self->gated_through_received_hdr_remover()) { return 0; }
  578.  
  579.   my $xmailer = $self->get('X-Mailer');
  580.   my $xorig = $self->get('X-Originating-IP');
  581.   my $rcvd = $self->get('Received');
  582.   my $IP_ADDRESS = IP_ADDRESS;
  583.  
  584.   if (!$xorig) {  # New style Juno has no X-Originating-IP header, and other changes
  585.     if($rcvd !~ /from.*\b(?:juno|untd)\.com.*[\[\(]$IP_ADDRESS[\]\)].*by/
  586.         && $rcvd !~ / cookie\.(?:juno|untd)\.com /) { return 1; }
  587.     if($xmailer !~ /Juno /) { return 1; }
  588.   } else {
  589.     if($rcvd =~ /from.*\bmail\.com.*\[$IP_ADDRESS\].*by/) {
  590.       if($xmailer !~ /\bmail\.com/) { return 1; }
  591.     } elsif($rcvd =~ /from (webmail\S+\.untd\.com) \(\1 \[$IP_ADDRESS\]\) by/) {
  592.       if($xmailer !~ /^Webmail Version \d/) { return 1; }
  593.     } else {
  594.       return 1;
  595.     }
  596.     if($xorig !~ /$IP_ADDRESS/) { return 1; }
  597.   }
  598.  
  599.   return 0;   
  600. }
  601.  
  602. #Received: from dragnet.sjc.ebay.com (dragnet.sjc.ebay.com [10.6.21.14])
  603. #    by bashir.ebay.com (8.10.2/8.10.2) with SMTP id g29JpwB10940
  604. #    for <rod@begbie.com>; Sat, 9 Mar 2002 11:51:58 -0800
  605.  
  606. sub check_for_from_domain_in_received_headers {
  607.   my ($self, $domain, $desired) = @_;
  608.   
  609.   if (exists $self->{from_domain_in_received}) {
  610.       if (exists $self->{from_domain_in_received}->{$domain}) {
  611.       if ($desired eq 'true') {
  612.           # See use of '0e0' below for why we force int() here:
  613.           return int($self->{from_domain_in_received}->{$domain});
  614.       }
  615.       else {
  616.           # And why we deliberately do NOT use integers here:
  617.           return !$self->{from_domain_in_received}->{$domain};
  618.       }
  619.       }
  620.   } else {
  621.       $self->{from_domain_in_received} = {};
  622.   }
  623.  
  624.   my $from = $self->get('From:addr');
  625.   if ($from !~ /\b\Q$domain\E/i) {
  626.       # '0e0' is Perl idiom for "true but zero":
  627.       $self->{from_domain_in_received}->{$domain} = '0e0';
  628.       return 0;
  629.   }
  630.  
  631.   my $rcvd = $self->{relays_trusted_str}."\n".$self->{relays_untrusted_str};
  632.  
  633.   if ($rcvd =~ / rdns=\S*\b${domain} [^\]]*by=\S*\b${domain} /) {
  634.       $self->{from_domain_in_received}->{$domain} = 1;
  635.       return ($desired eq 'true');
  636.   }
  637.  
  638.   $self->{from_domain_in_received}->{$domain} = 0;
  639.   return ($desired ne 'true');   
  640. }
  641.  
  642. # ezmlm has a very bad habit of removing Received: headers! bad ezmlm.
  643. #
  644. sub gated_through_received_hdr_remover {
  645.   my ($self) = @_;
  646.  
  647.   my $txt = $self->get("Mailing-List");
  648.   if (defined $txt && $txt =~ /^contact \S+\@\S+\; run by ezmlm$/) {
  649.     my $dlto = $self->get("Delivered-To");
  650.     my $rcvd = $self->get("Received");
  651.  
  652.     # ensure we have other indicative headers too
  653.     if ($dlto =~ /^mailing list \S+\@\S+/ &&
  654.         $rcvd =~ /qmail \d+ invoked (?:from network|by .{3,20})\); \d+ ... \d+/)
  655.     {
  656.       return 1;
  657.     }
  658.   }
  659.  
  660.   if ($self->get("Received") !~ /\S/) {
  661.     # we have no Received headers!  These tests cannot run in that case
  662.     return 1;
  663.   }
  664.  
  665.   # MSN groups removes Received lines. thanks MSN
  666.   if ($self->get("Received") =~ /from groups\.msn\.com \(\S+\.msn\.com /) {
  667.     return 1;
  668.   }
  669.  
  670.   return 0;
  671. }
  672.  
  673. ###########################################################################
  674.  
  675. # Bug 1133
  676.  
  677. # Some spammers will, through HELO, tell the server that their machine
  678. # name *is* the relay; don't know why. An example:
  679.  
  680. # from mail1.mailwizards.com (m448-mp1.cvx1-b.col.dial.ntli.net
  681. #        [213.107.233.192])
  682. #        by mail1.mailwizards.com
  683.  
  684. # When this occurs for real, the from name and HELO name will be the
  685. # same, unless the "helo" name is localhost, or the from and by hostsnames
  686. # themselves are localhost
  687. sub _check_received_helos {
  688.   my ($self) = @_;
  689.  
  690.   for (my $i = 0; $i < $self->{num_relays_untrusted}; $i++) {
  691.     my $rcvd = $self->{relays_untrusted}->[$i];
  692.  
  693.     # Ignore where IP is in private IP space
  694.     next if ($rcvd->{ip_private});
  695.  
  696.     my $from_host = $rcvd->{rdns};
  697.     my $helo_host = $rcvd->{helo};
  698.     my $by_host = $rcvd->{by};
  699.     my $no_rdns = $rcvd->{no_reverse_dns};
  700.  
  701.     next unless defined($helo_host);
  702.  
  703.     # Check for a faked dotcom HELO, e.g.
  704.     # Received: from mx02.hotmail.com (www.sucasita.com.mx [148.223.251.99])...
  705.     # this can be a stronger spamsign than the normal case, since the
  706.     # big dotcoms don't screw up their rDNS normally ;), so less FPs.
  707.     # Since spammers like sending out their mails from the dotcoms (esp.
  708.     # hotmail and AOL) this will catch those forgeries.
  709.     #
  710.     # allow stuff before the dot-com for both from-name and HELO-name,
  711.     # so HELO="outgoing.aol.com" and from="mx34853495.mx.aol.com" works OK.
  712.     #
  713.     $self->{no_rdns_dotcom_helo} = 0;
  714.     if ($helo_host =~ /(?:\.|^)(lycos\.com|lycos\.co\.uk|hotmail\.com
  715.         |localhost\.com|excite\.com|caramail\.com
  716.         |cs\.com|aol\.com|msn\.com|yahoo\.com|drizzle\.com)$/ix)
  717.     {
  718.       my $dom = $1;
  719.  
  720.       # ok, let's catch the case where there's *no* reverse DNS there either
  721.       if ($no_rdns) {
  722.     dbg("eval: Received: no rDNS for dotcom HELO: from=$from_host HELO=$helo_host");
  723.     $self->{no_rdns_dotcom_helo} = 1;
  724.       }
  725.     }
  726.   }
  727. } # _check_received_helos()
  728.  
  729. sub check_for_no_rdns_dotcom_helo {
  730.   my ($self) = @_;
  731.   if (!exists $self->{no_rdns_dotcom_helo}) { $self->_check_received_helos(@_); }
  732.   return $self->{no_rdns_dotcom_helo};
  733. }
  734.  
  735. ###########################################################################
  736.  
  737. # look for 8-bit and other illegal characters that should be MIME
  738. # encoded, these might want to exempt languages that do not use
  739. # Latin-based alphabets, but only if the user wants it that way
  740. sub check_illegal_chars {
  741.   my ($self, $header, $ratio, $count) = @_;
  742.  
  743.   $header .= ":raw" unless ($header eq "ALL" || $header =~ /:raw$/);
  744.   my $str = $self->get($header);
  745.   return 0 unless $str;
  746.  
  747.   # avoid overlap between tests
  748.   if ($header eq "ALL") {
  749.     # fix continuation lines, then remove Subject and From
  750.     $str =~ s/\n[ \t]+/  /gs;
  751.     $str =~ s/^(?:Subject|From):.*$//gm;
  752.   }
  753.  
  754.   # count illegal substrings (RFC 2045)
  755.   my $illegal = () = ($str =~ /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff]/g);
  756.  
  757.   # minor exemptions for Subject
  758.   if ($header eq "Subject:raw") {
  759.     # only exempt a single cent sign, pound sign, or registered sign
  760.     my $exempt = () = ($str =~ /[\xa2\xa3\xae]/g);
  761.     $illegal-- if $exempt == 1;
  762.   }
  763.  
  764.   return 0 if (length($str) == 0);
  765.   return (($illegal / length($str)) >= $ratio && $illegal >= $count);
  766. }
  767.  
  768. sub are_more_high_bits_set {
  769.   my ($self, $str) = @_;
  770.  
  771.   my $numhis = () = ($str =~ /[\200-\377]/g);
  772.   my $numlos = length($str) - $numhis;
  773.  
  774.   ($numlos <= $numhis && $numhis > 3);
  775. }
  776.  
  777. ###########################################################################
  778.  
  779. sub check_for_missing_to_header {
  780.   my ($self) = @_;
  781.  
  782.   my $hdr = $self->get('To');
  783.   $hdr ||= $self->get('Apparently-To');
  784.   return 1 if ($hdr eq '');
  785.  
  786.   return 0;
  787. }
  788.  
  789. ###########################################################################
  790.  
  791. # Check if the apparent sender (in the last received header) had
  792. # no reverse lookup for it's IP
  793. #
  794. # Look for headers like:
  795. #
  796. #   Received: from mx1.eudoramail.com ([204.32.147.84])
  797. sub check_for_sender_no_reverse {
  798.   my ($self) = @_;
  799.  
  800.   # Sender received header is the last in the sequence
  801.   my $srcvd = $self->{relays_untrusted}->
  802.                 [$self->{num_relays_untrusted} - 1];
  803.  
  804.   return 0 unless (defined $srcvd);
  805.  
  806.   # Ignore if the from host is domainless (has no dot)
  807.   return 0 unless ($srcvd->{rdns} =~ /\./);
  808.  
  809.   # Ignore if the from host is from a private IP range
  810.   return 0 if ($srcvd->{ip_private});
  811.  
  812.   return 1;
  813. } # check_for_sender_no_reverse()
  814.  
  815. ###########################################################################
  816.  
  817. sub check_from_in_list {
  818.   my ($self,$list) = @_;
  819.   my $list_ref = $self->{conf}{$list};
  820.   warn "eval: could not find list $list" unless defined $list_ref;
  821.  
  822.   foreach my $addr (all_from_addrs $self) {
  823.     return 1 if _check_whitelist $self $list_ref, $addr;
  824.   }
  825.  
  826.   return 0;
  827. }
  828.  
  829. ###########################################################################
  830.  
  831. sub check_to_in_list {
  832.   my ($self,$list) = @_;
  833.   my $list_ref = $self->{conf}{$list};
  834.   warn "eval: could not find list $list" unless defined $list_ref;
  835.  
  836.   foreach my $addr (all_to_addrs $self) {
  837.     return 1 if _check_whitelist $self $list_ref, $addr;
  838.   }
  839.  
  840.   return 0;
  841. }
  842.  
  843.  
  844. ###########################################################################
  845.  
  846. sub check_from_in_whitelist {
  847.   my ($self) = @_;
  848.   $self->_check_from_in_whitelist unless exists $self->{from_in_whitelist};
  849.   return ($self->{from_in_whitelist} > 0);
  850. }
  851.  
  852. sub check_forged_in_whitelist {
  853.   my ($self) = @_;
  854.   $self->_check_from_in_whitelist unless exists $self->{from_in_whitelist};
  855.   $self->_check_from_in_default_whitelist unless exists $self->{from_in_default_whitelist};
  856.   return ($self->{from_in_whitelist} < 0) && ($self->{from_in_default_whitelist} == 0);
  857. }
  858.  
  859. sub check_from_in_default_whitelist {
  860.   my ($self) = @_;
  861.   $self->_check_from_in_default_whitelist unless exists $self->{from_in_default_whitelist};
  862.   return ($self->{from_in_default_whitelist} > 0);
  863. }
  864.  
  865. sub check_forged_in_default_whitelist {
  866.   my ($self) = @_;
  867.   $self->_check_from_in_default_whitelist unless exists $self->{from_in_default_whitelist};
  868.   $self->_check_from_in_whitelist unless exists $self->{from_in_whitelist};
  869.   return ($self->{from_in_default_whitelist} < 0) && ($self->{from_in_whitelist} == 0);
  870. }
  871.  
  872. ###########################################################################
  873.  
  874. sub _check_from_in_whitelist {
  875.   my ($self) = @_;
  876.   my $found_match = 0;
  877.   local ($_);
  878.   foreach $_ ($self->all_from_addrs()) {
  879.     if ($self->_check_whitelist ($self->{conf}->{whitelist_from}, $_)) {
  880.       $self->{from_in_whitelist} = 1;
  881.       return;
  882.     }
  883.     my $wh = $self->_check_whitelist_rcvd ($self->{conf}->{whitelist_from_rcvd}, $_);
  884.     if ($wh == 1) {
  885.       $self->{from_in_whitelist} = 1;
  886.       return;
  887.     }
  888.     elsif ($wh == -1) {
  889.       $found_match = -1;
  890.     }
  891.   }
  892.  
  893.   $self->{from_in_whitelist} = $found_match;
  894.   return;
  895. }
  896.  
  897. ###########################################################################
  898.  
  899. sub _check_from_in_default_whitelist {
  900.   my ($self) = @_;
  901.   my $found_match = 0;
  902.   local ($_);
  903.   foreach $_ ($self->all_from_addrs()) {
  904.     my $wh = $self->_check_whitelist_rcvd ($self->{conf}->{def_whitelist_from_rcvd}, $_);
  905.     if ($wh == 1) {
  906.       $self->{from_in_default_whitelist} = 1;
  907.       return;
  908.     }
  909.     elsif ($wh == -1) {
  910.       $found_match = -1;
  911.     }
  912.   }
  913.  
  914.   $self->{from_in_default_whitelist} = $found_match;
  915.   return;
  916. }
  917.  
  918. ###########################################################################
  919.  
  920. # look up $addr and trusted relays in a whitelist with rcvd
  921. # note if it appears to be a forgery and $addr is not in any-relay list
  922. sub _check_whitelist_rcvd {
  923.   my ($self, $list, $addr) = @_;
  924.  
  925.   # we can only match this if we have at least 1 trusted or untrusted header
  926.   my $untrusted = $self->{num_relays_untrusted} || 0;
  927.   my $trusted = $self->{num_relays_trusted} || 0;
  928.   dbg ("rules: no Received: headers -- skipping _check_whitelist_rcvd")
  929.       unless ($untrusted + $trusted > 0);
  930.   return 0 unless ($untrusted + $trusted > 0);
  931.  
  932.   my @relays = ();
  933.   # try the untrusted one first
  934.   if ($self->{num_relays_untrusted} > 0) {
  935.     @relays = $self->{relays_untrusted}->[0];
  936.   }
  937.   # then try the trusted ones; the user could have whitelisted a trusted
  938.   # relay, totally permitted
  939.   # but do not do this if any untrusted relays, to avoid forgery -- bug 4425
  940.   if ($self->{num_relays_trusted} > 0 && !$self->{num_relays_untrusted} ) {
  941.     push (@relays, @{$self->{relays_trusted}});
  942.   }
  943.  
  944.   $addr = lc $addr;
  945.   my $found_forged = 0;
  946.   foreach my $white_addr (keys %{$list}) {
  947.     my $regexp = qr/$list->{$white_addr}{re}/i;
  948.     foreach my $domain (@{$list->{$white_addr}{domain}}) {
  949.       
  950.       if ($addr =~ $regexp) {
  951.         foreach my $lastunt (@relays) {
  952.           my $rdns = $lastunt->{lc_rdns};
  953.           if ($rdns =~ /(?:^|\.)\Q${domain}\E$/i) { 
  954.             dbg("rules: address $addr matches (def_)whitelist_from_rcvd $list->{$white_addr}{re} ${domain}");
  955.             return 1;
  956.           }
  957.       dbg ("rules: domain '$domain' not matched in rdns '$rdns'");
  958.         }
  959.         # found address match but no relay match. note as possible forgery
  960.         $found_forged = -1;
  961.       }
  962.     }
  963.   }
  964.   if ($found_forged) { # might be forgery. check if in list of exempted
  965.     my $wlist = $self->{conf}->{whitelist_allows_relays};
  966.     foreach my $fuzzy_addr (values %{$wlist}) {
  967.       if ($addr =~ /$fuzzy_addr/i) {
  968.         $found_forged = 0;
  969.         last;
  970.       }
  971.     }
  972.   }
  973.   return $found_forged;
  974. }
  975.  
  976. ###########################################################################
  977.  
  978. sub _check_whitelist {
  979.   my ($self, $list, $addr) = @_;
  980.   $addr = lc $addr;
  981.   if (defined ($list->{$addr})) { return 1; }
  982.   study $addr;
  983.   foreach my $regexp (values %{$list}) {
  984.     if ($addr =~ qr/$regexp/i) {
  985.       dbg("rules: address $addr matches whitelist or blacklist regexp: $regexp");
  986.       return 1;
  987.     }
  988.   }
  989.  
  990.   return 0;
  991. }
  992.  
  993. sub all_from_addrs {
  994.   my ($self) = @_;
  995.  
  996.   if (exists $self->{all_from_addrs}) { return @{$self->{all_from_addrs}}; }
  997.  
  998.   my @addrs;
  999.  
  1000.   # Resent- headers take priority, if present. see bug 672
  1001.   # http://www.hughes-family.org/bugzilla/show_bug.cgi?id=672
  1002.   my $resent = $self->get('Resent-From');
  1003.   if (defined $resent && $resent =~ /\S/) {
  1004.     @addrs = $self->{main}->find_all_addrs_in_line ($resent);
  1005.  
  1006.   }
  1007.   else {
  1008.     # bug 2292: Used to use find_all_addrs_in_line() with the same
  1009.     # headers, but the would catch addresses in comments which caused
  1010.     # FNs for things like whitelist_from.  Since all of these are From
  1011.     # headers, there should only be 1 address in each anyway, so use the
  1012.     # :addr code...
  1013.     # bug 3366: some addresses come in as 'foo@bar...', which is invalid.
  1014.     # so deal with the multiple periods.
  1015.     @addrs = grep { defined($_) && length($_) > 0 } map { tr/././s; $_; }
  1016.         ($self->get('From:addr'),        # std
  1017.          $self->get('Envelope-Sender:addr'),    # qmail: new-inject(1)
  1018.          $self->get('Resent-Sender:addr'),    # procmailrc manpage
  1019.          $self->get('X-Envelope-From:addr'),    # procmailrc manpage
  1020.          $self->get('EnvelopeFrom:addr'));    # SMTP envelope
  1021.     # http://www.cs.tut.fi/~jkorpela/headers.html is useful here
  1022.   }
  1023.  
  1024.   # Remove duplicate addresses
  1025.   my %addrs = map { $_ => 1 } @addrs;
  1026.   @addrs = keys %addrs;
  1027.  
  1028.   dbg("eval: all '*From' addrs: " . join(" ", @addrs));
  1029.   $self->{all_from_addrs} = \@addrs;
  1030.   return @addrs;
  1031. }
  1032.  
  1033. sub all_to_addrs {
  1034.   my ($self) = @_;
  1035.  
  1036.   if (exists $self->{all_to_addrs}) { return @{$self->{all_to_addrs}}; }
  1037.  
  1038.   my @addrs;
  1039.  
  1040.   # Resent- headers take priority, if present. see bug 672
  1041.   # http://www.hughes-family.org/bugzilla/show_bug.cgi?id=672
  1042.   my $resent = $self->get('Resent-To') . $self->get('Resent-Cc');
  1043.   if (defined $resent && $resent =~ /\S/) {
  1044.     @addrs = $self->{main}->find_all_addrs_in_line (
  1045.        $self->get('Resent-To') .             # std, rfc822
  1046.        $self->get('Resent-Cc'));             # std, rfc822
  1047.  
  1048.   } else {
  1049.     # OK, a fetchmail trick: try to find the recipient address from
  1050.     # the most recent 3 Received lines.  This is required for sendmail,
  1051.     # since it does not add a helpful header like exim, qmail
  1052.     # or Postfix do.
  1053.     #
  1054.     my $rcvd = $self->get('Received');
  1055.     $rcvd =~ s/\n[ \t]+/ /gs;
  1056.     $rcvd =~ s/\n+/\n/gs;
  1057.  
  1058.     my @rcvdlines = split(/\n/, $rcvd, 4); pop @rcvdlines; # forget last one
  1059.     my @rcvdaddrs = ();
  1060.     foreach my $line (@rcvdlines) {
  1061.       if ($line =~ / for (\S+\@\S+);/) { push (@rcvdaddrs, $1); }
  1062.     }
  1063.  
  1064.     @addrs = $self->{main}->find_all_addrs_in_line (
  1065.      join(" ", @rcvdaddrs)."\n" .
  1066.          $self->get('To') .            # std 
  1067.        $self->get('Apparently-To') .        # sendmail, from envelope
  1068.        $self->get('Delivered-To') .        # Postfix, poss qmail
  1069.        $self->get('Envelope-Recipients') .    # qmail: new-inject(1)
  1070.        $self->get('Apparently-Resent-To') .    # procmailrc manpage
  1071.        $self->get('X-Envelope-To') .        # procmailrc manpage
  1072.        $self->get('Envelope-To') .        # exim
  1073.      $self->get('X-Delivered-To') .        # procmail quick start
  1074.      $self->get('X-Original-To') .        # procmail quick start
  1075.      $self->get('X-Rcpt-To') .        # procmail quick start
  1076.      $self->get('X-Real-To') .        # procmail quick start
  1077.      $self->get('Cc'));            # std
  1078.     # those are taken from various sources; thanks to Nancy McGough, who
  1079.     # noted some in <http://www.ii.com/internet/robots/procmail/qs/#envelope>
  1080.   }
  1081.  
  1082.   dbg("eval: all '*To' addrs: " . join(" ", @addrs));
  1083.   $self->{all_to_addrs} = \@addrs;
  1084.   return @addrs;
  1085.  
  1086. # http://www.cs.tut.fi/~jkorpela/headers.html is useful here, also
  1087. # http://www.exim.org/pipermail/exim-users/Week-of-Mon-20001009/021672.html
  1088. }
  1089.  
  1090. ###########################################################################
  1091.  
  1092. sub check_obfuscated_words {
  1093.   my ($self, $body) = @_;
  1094.   foreach my $line (@$body) {
  1095.       while ($line =~ /[\w$WORD_OBFUSCATION_CHARS]/) {
  1096.         # TODO, it seems ;)
  1097.       }
  1098.   }
  1099. }
  1100.  
  1101. sub check_unique_words {
  1102.   my ($self, $body, $m, $b) = @_;
  1103.  
  1104.   if (!defined $self->{unique_words_repeat}) {
  1105.     $self->_check_unique_words($body);
  1106.   }
  1107.   # y = mx+b where y is number of unique words needed
  1108.   my $unique = $self->{unique_words_unique};
  1109.   my $repeat = $self->{unique_words_repeat};
  1110.   my $y = ($unique + $repeat) * $m + $b;
  1111.   return ($unique > $y);
  1112. }
  1113.  
  1114. sub _check_unique_words {
  1115.   my ($self, $body) = @_;
  1116.  
  1117.   $self->{unique_words_repeat} = 0;
  1118.   $self->{unique_words_unique} = 0;
  1119.   my %count;
  1120.   for (@$body) {
  1121.     # copy to avoid changing @$body
  1122.     my $line = $_;
  1123.     # from tokenize_line in Bayes.pm
  1124.     $line =~ tr/-A-Za-z0-9,\@\*\!_'"\$.\241-\377 / /cs;
  1125.     $line =~ s/(\w)(\.{3,6})(\w)/$1 $2 $3/gs;
  1126.     $line =~ s/(\w)(\-{2,6})(\w)/$1 $2 $3/gs;
  1127.     $line =~ s/(?:^|\.\s+)([A-Z])([^A-Z]+)(?:\s|$)/ ' '.(lc $1).$2.' '/ge;
  1128.     for my $token (split(' ', $line)) {
  1129.       $count{$token}++;
  1130.     }
  1131.   }
  1132.   $self->{unique_words_unique} = scalar grep { $_ == 1 } values(%count);
  1133.   $self->{unique_words_repeat} = scalar keys(%count) - $self->{unique_words_unique};
  1134. }
  1135.  
  1136. ###########################################################################
  1137.  
  1138. sub check_from_in_blacklist {
  1139.   my ($self) = @_;
  1140.   local ($_);
  1141.   foreach $_ ($self->all_from_addrs()) {
  1142.     if ($self->_check_whitelist ($self->{conf}->{blacklist_from}, $_)) {
  1143.       return 1;
  1144.     }
  1145.   }
  1146. }
  1147.  
  1148. sub check_to_in_blacklist {
  1149.   my ($self) = @_;
  1150.   local ($_);
  1151.   foreach $_ ($self->all_to_addrs()) {
  1152.     if ($self->_check_whitelist ($self->{conf}->{blacklist_to}, $_)) {
  1153.       return 1;
  1154.     }
  1155.   }
  1156. }
  1157.  
  1158. ###########################################################################
  1159. # added by DJ
  1160.  
  1161. sub check_to_in_whitelist {
  1162.   my ($self) = @_;
  1163.   local ($_);
  1164.   foreach $_ ($self->all_to_addrs()) {
  1165.     if ($self->_check_whitelist ($self->{conf}->{whitelist_to}, $_)) {
  1166.       return 1;
  1167.     }
  1168.   }
  1169. }
  1170.  
  1171.  
  1172. ###########################################################################
  1173. # added by DJ
  1174.  
  1175. sub check_to_in_more_spam {
  1176.   my ($self) = @_;
  1177.   local ($_);
  1178.   foreach $_ ($self->all_to_addrs()) {
  1179.     if ($self->_check_whitelist ($self->{conf}->{more_spam_to}, $_)) {
  1180.       return 1;
  1181.     }
  1182.   }
  1183. }
  1184.  
  1185.  
  1186. ###########################################################################
  1187. # added by DJ
  1188.  
  1189. sub check_to_in_all_spam {
  1190.   my ($self) = @_;
  1191.   local ($_);
  1192.   foreach $_ ($self->all_to_addrs()) {
  1193.     if ($self->_check_whitelist ($self->{conf}->{all_spam_to}, $_)) {
  1194.       return 1;
  1195.     }
  1196.   }
  1197. }
  1198.  
  1199. ###########################################################################
  1200.  
  1201. sub check_rbl_backend {
  1202.   my ($self, $rule, $set, $rbl_server, $type, $subtest) = @_;
  1203.   local ($_);
  1204.  
  1205.   # First check that DNS is available, if not do not perform this check
  1206.   return 0 if $self->{conf}->{skip_rbl_checks};
  1207.   return 0 unless $self->is_dns_available();
  1208.   $self->load_resolver();
  1209.  
  1210.   if (($rbl_server !~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) &&
  1211.       (index($rbl_server, '.') >= 0) &&
  1212.       ($rbl_server !~ /\.$/)) {
  1213.     $rbl_server .= ".";
  1214.   }
  1215.  
  1216.   dbg("dns: checking RBL $rbl_server, set $set");
  1217.  
  1218.   # ok, make a list of all the IPs in the untrusted set
  1219.   my @fullips = map { $_->{ip} } @{$self->{relays_untrusted}};
  1220.  
  1221.   # now, make a list of all the IPs in the external set, for use in
  1222.   # notfirsthop testing.  this will often be more IPs than found
  1223.   # in @fullips.  It includes the IPs that are trusted, but
  1224.   # not in internal_networks.
  1225.   my @fullexternal = map {
  1226.     (!$_->{internal}) ? ($_->{ip}) : ()
  1227.       } @{$self->{relays_trusted}};
  1228.   push (@fullexternal, @fullips);    # add untrusted set too
  1229.  
  1230.   # Make sure a header significantly improves results before adding here
  1231.   # X-Sender-Ip: could be worth using (very low occurance for me)
  1232.   # X-Sender: has a very low bang-for-buck for me
  1233.   my $IP_ADDRESS = IP_ADDRESS;
  1234.   my @originating = ();
  1235.   for my $header ('X-Originating-IP', 'X-Apparently-From') {
  1236.     my $str = $self->get($header);
  1237.     next unless $str;
  1238.     push (@originating, ($str =~ m/($IP_ADDRESS)/g));
  1239.   }
  1240.  
  1241.   # Let's go ahead and trim away all private ips (KLC)
  1242.   # also uniq the list and strip dups. (jm)
  1243.   my @ips = $self->ip_list_uniq_and_strip_private(@fullips);
  1244.  
  1245.   # if there's no untrusted IPs, it means we trust all the open-internet
  1246.   # relays, so we can return right now.
  1247.   return 0 unless (scalar @ips + scalar @originating > 0);
  1248.  
  1249.   dbg("dns: IPs found: full-external: ".join(", ", @fullexternal).
  1250.     " untrusted: ".join(", ", @ips).
  1251.     " originating: ".join(", ", @originating));
  1252.  
  1253.   my $trusted = $self->{conf}->{trusted_networks};
  1254.  
  1255.   if (scalar @ips + scalar @originating > 0) {
  1256.     # If name is foo-notfirsthop, check all addresses except for
  1257.     # the originating one.  Suitable for use with dialup lists, like the PDL.
  1258.     # note that if there's only 1 IP in the untrusted set, do NOT pop the
  1259.     # list, since it'd remove that one, and a legit user is supposed to
  1260.     # use their SMTP server (ie. have at least 1 more hop)!
  1261.     # If name is foo-lastexternal, check only the Received header just before
  1262.     # it enters our internal networks; we can trust it and it's the one that
  1263.     # passed mail between networks
  1264.     if ($set =~ /-(notfirsthop|lastexternal)$/)
  1265.     {
  1266.       # use the external IP set, instead of the trusted set; the user may have
  1267.       # specified some third-party relays as trusted.  Also, don't use
  1268.       # @originating; those headers are added by a phase of relaying through
  1269.       # a server like Hotmail, which is not going to be in dialup lists anyway.
  1270.       @ips = $self->ip_list_uniq_and_strip_private(@fullexternal);
  1271.       if ($1 eq "lastexternal") {
  1272.         @ips = (defined $ips[0]) ? ($ips[0]) : ();
  1273.       } else {
  1274.     pop @ips if (scalar @ips > 1);
  1275.       }
  1276.     }
  1277.     # If name is foo-firsttrusted, check only the Received header just
  1278.     # after it enters our trusted networks; that's the only one we can
  1279.     # trust the IP address from (since our relay added that header).
  1280.     # And if name is foo-untrusted, check any untrusted IP address.
  1281.     elsif ($set =~ /-(first|un)trusted$/)
  1282.     {
  1283.       my @tips = ();
  1284.       foreach my $ip (@originating) {
  1285.         if ($ip && !$trusted->contains_ip($ip)) {
  1286.           push(@tips, $ip);
  1287.         }
  1288.       }
  1289.       @ips = $self->ip_list_uniq_and_strip_private (@ips, @tips);
  1290.       if ($1 eq "first") {
  1291.         @ips = (defined $ips[0]) ? ($ips[0]) : ();
  1292.       } else {
  1293.         shift @ips;
  1294.       }
  1295.     }
  1296.     else
  1297.     {
  1298.       my @tips = ();
  1299.       foreach my $ip (@originating) {
  1300.         if ($ip && !$trusted->contains_ip($ip)) {
  1301.           push(@tips, $ip);
  1302.         }
  1303.       }
  1304.       # add originating IPs as untrusted IPs (if they are untrusted)
  1305.       @ips = reverse $self->ip_list_uniq_and_strip_private (@ips, @tips);
  1306.  
  1307.       # How many IPs max you check in the received lines
  1308.       my $checklast=$self->{conf}->{num_check_received};
  1309.  
  1310.       if (scalar @ips > $checklast) {
  1311.     splice (@ips, $checklast);    # remove all others
  1312.       }
  1313.     }
  1314.   }
  1315.   dbg("dns: only inspecting the following IPs: ".join(", ", @ips));
  1316.  
  1317.   eval {
  1318.     foreach my $ip (@ips) {
  1319.       next unless ($ip =~ /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/);
  1320.       $self->do_rbl_lookup($rule, $set, $type, $rbl_server,
  1321.                "$4.$3.$2.$1.$rbl_server", $subtest);
  1322.     }
  1323.   };
  1324.  
  1325.   # note that results are not handled here, hits are handled directly
  1326.   # as DNS responses are harvested
  1327.   return 0;
  1328. }
  1329.  
  1330. sub check_rbl {
  1331.   my ($self, $rule, $set, $rbl_server, $subtest) = @_;
  1332.   $self->check_rbl_backend($rule, $set, $rbl_server, 'A', $subtest);
  1333. }
  1334.  
  1335. sub check_rbl_txt {
  1336.   my ($self, $rule, $set, $rbl_server, $subtest) = @_;
  1337.   $self->check_rbl_backend($rule, $set, $rbl_server, 'TXT', $subtest);
  1338. }
  1339.  
  1340. # run for first message 
  1341. sub check_rbl_sub {
  1342.   my ($self, $rule, $set, $subtest) = @_;
  1343.  
  1344.   return 0 if $self->{conf}->{skip_rbl_checks};
  1345.   return 0 unless $self->is_dns_available();
  1346.  
  1347.   $self->register_rbl_subtest($rule, $set, $subtest);
  1348. }
  1349.  
  1350. # backward compatibility
  1351. sub check_rbl_results_for {
  1352.   #warn "dns: check_rbl_results_for() is deprecated, use check_rbl_sub()\n";
  1353.   check_rbl_sub(@_);
  1354. }
  1355.  
  1356. # this only checks the address host name and not the domain name because
  1357. # using the domain name had much worse results for dsn.rfc-ignorant.org
  1358. sub check_rbl_from_host {
  1359.   _check_rbl_addresses(@_, $_[0]->all_from_addrs());
  1360. }
  1361.  
  1362. # this only checks the address host name and not the domain name because
  1363. # using the domain name had much worse results for dsn.rfc-ignorant.org
  1364. sub check_rbl_envfrom {
  1365.   _check_rbl_addresses(@_, $_[0]->get('EnvelopeFrom:addr'));
  1366. }
  1367.  
  1368. sub _check_rbl_addresses {
  1369.   my ($self, $rule, $set, $rbl_server, @addresses) = @_;
  1370.   
  1371.   return 0 if $self->{conf}->{skip_rbl_checks};
  1372.   return 0 unless $self->is_dns_available();
  1373.  
  1374.   my %hosts;
  1375.   for my $address (@addresses) {
  1376.     if ($address =~ m/\@(\S+\.\S+)/) {
  1377.       $hosts{lc($1)} = 1;
  1378.     }
  1379.   }
  1380.   return unless scalar keys %hosts;
  1381.  
  1382.   $self->load_resolver();
  1383.  
  1384.   if (($rbl_server !~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/) &&
  1385.       (index($rbl_server, '.') >= 0) &&
  1386.       ($rbl_server !~ /\.$/)) {
  1387.     $rbl_server .= ".";
  1388.   }
  1389.   dbg("dns: _check_rbl_addresses RBL $rbl_server, set $set");
  1390.  
  1391.   for my $host (keys %hosts) {
  1392.     $self->do_rbl_lookup($rule, $set, 'A', $rbl_server, "$host.$rbl_server");
  1393.   }
  1394. }
  1395.  
  1396. sub check_dns_sender {
  1397.   my ($self, $rule) = @_;
  1398.  
  1399.   my $host;
  1400.   for my $from ($self->get('EnvelopeFrom:addr')) {
  1401.     next unless defined $from;
  1402.  
  1403.     $from =~ tr/././s;        # bug 3366
  1404.     if ($from =~ /\@(\S+\.\S+)/) {
  1405.       $host = lc($1);
  1406.       last;
  1407.     }
  1408.   }
  1409.   return 0 unless defined $host;
  1410.  
  1411.   # First check that DNS is available, if not do not perform this check
  1412.   # TODO: need a way to skip DNS checks as a whole in configuration
  1413.   return 0 unless $self->is_dns_available();
  1414.   $self->load_resolver();
  1415.  
  1416.   if ($host eq 'compiling.spamassassin.taint.org') {
  1417.     # only used when compiling
  1418.     return 0;
  1419.   }
  1420.  
  1421.   dbg("dns: checking A and MX for host $host");
  1422.  
  1423.   $self->do_dns_lookup($rule, 'A', $host);
  1424.   $self->do_dns_lookup($rule, 'MX', $host);
  1425.  
  1426.   # cache name of host for later checking
  1427.   $self->{sender_host} = $host;
  1428.  
  1429.   return 0;
  1430. }
  1431.  
  1432. # interface called by SPF plugin
  1433. sub check_for_from_dns {
  1434.   my ($self) = @_;
  1435.   if (defined $self->{sender_host_fail}) {
  1436.     return ($self->{sender_host_fail} == 2); # both MX and A need to fail
  1437.   }
  1438. }
  1439.  
  1440. sub ip_list_uniq_and_strip_private {
  1441.   my ($self, @origips) = @_;
  1442.   my @ips = ();
  1443.   my %seen = ();
  1444.   my $IP_PRIVATE = IP_PRIVATE;
  1445.   foreach my $ip (@origips) {
  1446.     next unless $ip;
  1447.     next if (exists ($seen{$ip})); $seen{$ip} = 1;
  1448.     next if ($ip =~ /$IP_PRIVATE/o);
  1449.     push(@ips, $ip);
  1450.   }
  1451.   return @ips;
  1452. }
  1453.  
  1454. ###########################################################################
  1455.  
  1456. sub check_for_unique_subject_id {
  1457.   my ($self) = @_;
  1458.   local ($_);
  1459.   $_ = lc $self->get('Subject');
  1460.   study;
  1461.  
  1462.   my $id = 0;
  1463.   if (/[-_\.\s]{7,}([-a-z0-9]{4,})$/
  1464.     || /\s{10,}(?:\S\s)?(\S+)$/
  1465.     || /\s{3,}[-:\#\(\[]+([-a-z0-9]{4,})[\]\)]+$/
  1466.     || /\s{3,}[:\#\(\[]*([a-f0-9]{4,})[\]\)]*$/
  1467.     || /\s{3,}[-:\#]([a-z0-9]{5,})$/
  1468.     || /[\s._]{3,}([^0\s._]\d{3,})$/
  1469.     || /[\s._]{3,}\[(\S+)\]$/
  1470.  
  1471.         # (7217vPhZ0-478TLdy5829qicU9-0@26) and similar
  1472.         || /\(([-\w]{7,}\@\d+)\)$/
  1473.  
  1474.         # Seven or more digits at the end of a subject is almost certainly a id
  1475.         || /\b(\d{7,})\s*$/
  1476.  
  1477.         # stuff at end of line after "!" or "?" is usually an id
  1478.         || /[!\?]\s*(\d{4,}|\w+(-\w+)+)\s*$/
  1479.  
  1480.         # 9095IPZK7-095wsvp8715rJgY8-286-28 and similar
  1481.     # excluding 'Re:', etc and the first word
  1482.         || /(?:\w{2,3}:\s)?\w+\s+(\w{7,}-\w{7,}(-\w+)*)\s*$/
  1483.  
  1484.         # #30D7 and similar
  1485.         || /\s#\s*([a-f0-9]{4,})\s*$/
  1486.      )
  1487.   {
  1488.     $id = $1;
  1489.     # exempt online purchases
  1490.     if ($id =~ /\d{5,}/
  1491.     && /(?:item|invoice|order|number|confirmation).{1,6}\Q$id\E\s*$/)
  1492.     {
  1493.       $id = 0;
  1494.     }
  1495.  
  1496.     # for the "foo-bar-baz" case, otherwise it won't
  1497.     # be found in the dict:
  1498.     $id =~ s/-//;
  1499.   }
  1500.  
  1501.   return ($id && !$self->word_is_in_dictionary($id));
  1502. }
  1503.  
  1504. # word_is_in_dictionary()
  1505. #
  1506. # See if the word looks like an English word, by checking if each triplet
  1507. # of letters it contains is one that can be found in the English language.
  1508. # Does not include triplets only found in proper names, or in the Latin
  1509. # and Greek terms that might be found in a larger dictionary
  1510.  
  1511. my %triplets = ();
  1512. my $triplets_loaded = 0;
  1513.  
  1514. sub word_is_in_dictionary {
  1515.   my ($self, $word) = @_;
  1516.   local ($_);
  1517.   local $/ = "\n";        # Ensure $/ is set appropriately
  1518.  
  1519.   # $word =~ tr/A-Z/a-z/;    # already done by this stage
  1520.   $word =~ s/^\s+//;
  1521.   $word =~ s/\s+$//;
  1522.  
  1523.   # If it contains a digit, dash, etc, it's not a valid word.
  1524.   # Don't reject words like "can't" and "I'll"
  1525.   return 0 if ($word =~ /[^a-z\']/);
  1526.  
  1527.   # handle a few common "blah blah blah (comment)" styles
  1528.   return 1 if ($word eq "ot");    # off-topic
  1529.   return 1 if ($word =~ /(?:linux|nix|bsd)/); # not in most dicts
  1530.   return 1 if ($word =~ /(?:whew|phew|attn|tha?nx)/);  # not in most dicts
  1531.  
  1532.   my $word_len = length($word);
  1533.  
  1534.   # Unique IDs probably aren't going to be only one or two letters long
  1535.   return 1 if ($word_len < 3);
  1536.  
  1537.   if (!$triplets_loaded) {
  1538.     my $filename = $self->{main}->find_rule_support_file('triplets.txt');
  1539.  
  1540.     if (!defined $filename) {
  1541.       dbg("eval: failed to locate the triplets.txt file");
  1542.       return 1;
  1543.     }
  1544.  
  1545.     if (!open (TRIPLETS, "<$filename")) {
  1546.       dbg("eval: failed to open '$filename', cannot check dictionary");
  1547.       return 1;
  1548.     }
  1549.  
  1550.     while(<TRIPLETS>) {
  1551.       chomp;
  1552.       $triplets{$_} = 1;
  1553.     }
  1554.     close(TRIPLETS);
  1555.  
  1556.     $triplets_loaded = 1;
  1557.   } # if (!$triplets_loaded)
  1558.  
  1559.  
  1560.   my $i;
  1561.  
  1562.   for ($i = 0; $i < ($word_len - 2); $i++) {
  1563.     my $triplet = substr($word, $i, 3);
  1564.     if (!$triplets{$triplet}) {
  1565.       dbg("eval: unique ID: letter triplet '$triplet' from word '$word' not valid");
  1566.       return 0;
  1567.     }
  1568.   } # for ($i = 0; $i < ($word_len - 2); $i++)
  1569.  
  1570.   # All letter triplets in word were found to be valid
  1571.   return 1;
  1572. }
  1573.  
  1574. sub get_address_commonality_ratio {
  1575.   my ($self, $addr1, $addr2) = @_;
  1576.  
  1577.  
  1578.   # Ignore "@" and ".".  "@" will always be the same in both, and the
  1579.   # number of "." will almost always be the same
  1580.   $addr1 =~ s/[\@\.]//g;
  1581.   $addr2 =~ s/[\@\.]//g;
  1582.  
  1583.   my %counts1 = ();
  1584.   my %counts2 = ();
  1585.  
  1586.   foreach (split(//, lc $addr1)) {
  1587.     $counts1{$_}++;
  1588.   }
  1589.   foreach (split(//, lc $addr2)) {
  1590.     $counts2{$_}++;
  1591.   }
  1592.  
  1593.   my $different = 0;
  1594.   my $same      = 0;
  1595.   my $unique    = 0;
  1596.   my $char;
  1597.   my @chars     = keys %counts1;
  1598.  
  1599.   # Extract unique characters, and make the two hashes have the same
  1600.   # set of keys
  1601.   foreach $char (@chars) {
  1602.     if (!defined ($counts2{$char})) {
  1603.       $unique += $counts1{$char};
  1604.       delete ($counts1{$char});
  1605.     }
  1606.   }
  1607.  
  1608.   @chars = keys %counts2;
  1609.  
  1610.   foreach $char (@chars) {
  1611.     if (!defined ($counts1{$char})) {
  1612.       $unique += $counts2{$char};
  1613.       delete ($counts2{$char});
  1614.     }
  1615.   }
  1616.  
  1617.   # Hashes now have identical sets of keys; count the differences
  1618.   # between the values.
  1619.   @chars = keys %counts1;
  1620.  
  1621.   foreach $char (@chars) {
  1622.     my $count1 = $counts1{$char} || 0.0;
  1623.     my $count2 = $counts2{$char} || 0.0;
  1624.  
  1625.     if ($count1 == $count2) {
  1626.       $same += $count1;
  1627.     }
  1628.     else {
  1629.       $different += abs($count1 - $count2);
  1630.     }
  1631.   }
  1632.  
  1633.   $different += $unique / 2.0;
  1634.  
  1635.   $same ||= 1.0;
  1636.   my $ratio = $different / $same;
  1637.  
  1638.   #print STDERR "addrcommonality $addr1/$addr2($different<$unique>/$same)"
  1639.   # . " = $ratio\n";
  1640.  
  1641.   return $ratio;
  1642. }
  1643.  
  1644. ###########################################################################
  1645.  
  1646. sub check_for_forged_gw05_received_headers {
  1647.   my ($self) = @_;
  1648.   local ($_);
  1649.  
  1650.   my $rcv = $self->get('Received');
  1651.  
  1652.   # e.g.
  1653.   # Received: from mail3.icytundra.com by gw05 with ESMTP; Thu, 21 Jun 2001 02:28:32 -0400
  1654.   my ($h1, $h2) = ($rcv =~ 
  1655.       m/\nfrom\s(\S+)\sby\s(\S+)\swith\sESMTP\;\s+\S\S\S,\s+\d+\s+\S\S\S\s+
  1656.             \d{4}\s+\d\d:\d\d:\d\d\s+[-+]*\d{4}\n$/xs);
  1657.  
  1658.   if (defined ($h1) && defined ($h2) && $h2 !~ /\./) {
  1659.     return 1;
  1660.   }
  1661.  
  1662.   0;
  1663. }
  1664.  
  1665. ###########################################################################
  1666.  
  1667. sub check_for_faraway_charset {
  1668.   my ($self, $body) = @_;
  1669.  
  1670.   my $type = $self->get('Content-Type');
  1671.  
  1672.   my @locales = $self->get_my_locales();
  1673.  
  1674.   return 0 if grep { $_ eq "all" } @locales;
  1675.  
  1676.   $type = get_charset_from_ct_line ($type);
  1677.  
  1678.   if (defined $type &&
  1679.     !Mail::SpamAssassin::Locales::is_charset_ok_for_locales
  1680.             ($type, @locales))
  1681.   {
  1682.     # sanity check.  Some charsets (e.g. koi8-r) include the ASCII
  1683.     # 7-bit charset as well, so make sure we actually have a high
  1684.     # number of 8-bit chars in the body text first.
  1685.  
  1686.     $body = join("\n", @$body);
  1687.     if ($self->are_more_high_bits_set ($body)) {
  1688.       return 1;
  1689.     }
  1690.   }
  1691.  
  1692.   0;
  1693. }
  1694.  
  1695. sub check_for_faraway_charset_in_headers {
  1696.   my ($self) = @_;
  1697.   my $hdr;
  1698.  
  1699.   my @locales = $self->get_my_locales();
  1700.  
  1701.   return 0 if grep { $_ eq "all" } @locales;
  1702.  
  1703.   for my $h (qw(From Subject)) {
  1704.     my @hdrs = $self->get("$h:raw");
  1705.     if ($#hdrs >= 0) {
  1706.       $hdr = join(" ", @hdrs);
  1707.     } else {
  1708.       $hdr = '';
  1709.     }
  1710.     while ($hdr =~ /=\?(.+?)\?.\?.*?\?=/g) {
  1711.       Mail::SpamAssassin::Locales::is_charset_ok_for_locales($1, @locales)
  1712.       or return 1;
  1713.     }
  1714.   }
  1715.   0;
  1716. }
  1717.  
  1718. sub get_charset_from_ct_line {
  1719.   my $type = shift;
  1720.   if ($type =~ /charset="([^"]+)"/i) { return $1; }
  1721.   if ($type =~ /charset='([^']+)'/i) { return $1; }
  1722.   if ($type =~ /charset=(\S+)/i) { return $1; }
  1723.   return undef;
  1724. }
  1725.  
  1726. sub get_my_locales {
  1727.   my ($self) = @_;
  1728.  
  1729.   my @locales = split(' ', $self->{conf}->{ok_locales});
  1730.   my $lang = $ENV{'LC_ALL'};
  1731.   $lang ||= $ENV{'LANGUAGE'};
  1732.   $lang ||= $ENV{'LC_MESSAGES'};
  1733.   $lang ||= $ENV{'LANG'};
  1734.   push (@locales, $lang) if defined($lang);
  1735.   return @locales;
  1736. }
  1737.  
  1738. ###########################################################################
  1739.  
  1740. sub _check_for_round_the_world_received {
  1741.   my ($self) = @_;
  1742.   my ($relayer, $relayerip, $relay);
  1743.  
  1744.   $self->{round_the_world_revdns} = 0;
  1745.   $self->{round_the_world_helo} = 0;
  1746.   my $rcvd = $self->get('Received');
  1747.   my $IPV4_ADDRESS = IPV4_ADDRESS;
  1748.  
  1749.   # TODO: use new Received header parser
  1750.  
  1751.   # trad sendmail/postfix fmt:
  1752.   # Received: from hitower.parkgroup.ru (unknown [212.107.207.26]) by
  1753.   #     mail.netnoteinc.com (Postfix) with ESMTP id B8CAC11410E for
  1754.   #     <me@netnoteinc.com>; Fri, 30 Nov 2001 02:42:05 +0000 (Eire)
  1755.   # Received: from fmx1.freemail.hu ([212.46.197.200]) by hitower.parkgroup.ru
  1756.   #     (Lotus Domino Release 5.0.8) with ESMTP id 2001113008574773:260 ;
  1757.   #     Fri, 30 Nov 2001 08:57:47 +1000
  1758.   if ($rcvd =~ /
  1759.       \nfrom\b.{0,20}\s(\S+\.${CCTLDS_WITH_LOTS_OF_OPEN_RELAYS})\s\(.{0,200}
  1760.       \nfrom\b.{0,20}\s([-_A-Za-z0-9.]+)\s.{0,30}\[($IPV4_ADDRESS)\]
  1761.   /osix) { $relay = $1; $relayer = $2; $relayerip = $3; goto gotone; }
  1762.  
  1763.   return 0;
  1764.  
  1765. gotone:
  1766.   my $revdns = $self->lookup_ptr ($relayerip);
  1767.   if (!defined $revdns) { $revdns = '(unknown)'; }
  1768.  
  1769.   dbg("eval: round-the-world: mail relayed through $relay by ".    
  1770.       "$relayerip (HELO $relayer, rev DNS says $revdns)");
  1771.  
  1772.   if ($revdns =~ /\.${ROUND_THE_WORLD_RELAYERS}$/oi) {
  1773.     dbg("eval: round-the-world: yep, I think so (from rev dns)");
  1774.     $self->{round_the_world_revdns} = 1;
  1775.     return;
  1776.   }
  1777.  
  1778.   if ($relayer =~ /\.${ROUND_THE_WORLD_RELAYERS}$/oi) {
  1779.     dbg("eval: round-the-world: yep, I think so (from HELO)");
  1780.     $self->{round_the_world_helo} = 1;
  1781.     return;
  1782.   }
  1783.  
  1784.   dbg("eval: round-the-world: probably not");
  1785.   return;
  1786. }
  1787.  
  1788. sub check_for_round_the_world_received_helo {
  1789.   my ($self) = @_;
  1790.   if (!defined $self->{round_the_world_helo}) {
  1791.     $self->_check_for_round_the_world_received();
  1792.   }
  1793.   if ($self->{round_the_world_helo}) { return 1; }
  1794.   return 0;
  1795. }
  1796.  
  1797. sub check_for_round_the_world_received_revdns {
  1798.   my ($self) = @_;
  1799.   if (!defined $self->{round_the_world_revdns}) {
  1800.     $self->_check_for_round_the_world_received();
  1801.   }
  1802.   if ($self->{round_the_world_revdns}) { return 1; }
  1803.   return 0;
  1804. }
  1805.  
  1806. ###########################################################################
  1807.  
  1808. sub check_for_shifted_date {
  1809.   my ($self, $min, $max) = @_;
  1810.  
  1811.   if (!exists $self->{date_diff}) {
  1812.     $self->_check_date_diff();
  1813.   }
  1814.   return (($min eq 'undef' || $self->{date_diff} >= (3600 * $min)) &&
  1815.       ($max eq 'undef' || $self->{date_diff} < (3600 * $max)));
  1816. }
  1817.  
  1818. sub received_within_months {
  1819.   # filters out some false positives in old corpus mail - Allen
  1820.   my ($self,$min,$max) = @_;
  1821.  
  1822.   if (!exists($self->{date_received})) {
  1823.     $self->_check_date_received();
  1824.   }
  1825.   my $diff = time() - $self->{date_received};
  1826.  
  1827.   # 365.2425 * 24 * 60 * 60 = 31556952 = seconds in year (including leap)
  1828.  
  1829.   if (((! defined($min)) || ($min eq 'undef') ||
  1830.        ($diff >= (31556952 * ($min/12)))) &&
  1831.       ((! defined($max)) || ($max eq 'undef') ||
  1832.        ($diff < (31556952 * ($max/12))))) {
  1833.     return 1;
  1834.   } else {
  1835.     return 0;
  1836.   }
  1837. }
  1838.  
  1839. sub _get_date_header_time {
  1840.   my $self = $_[0];
  1841.  
  1842.   my $time;
  1843.   # a Resent-Date: header takes precedence over any Date: header
  1844.   DATE: for my $header ('Resent-Date', 'Date') {
  1845.     my @dates = $self->{msg}->get_header($header);
  1846.     for my $date (@dates) {
  1847.       if (defined($date) && length($date)) {
  1848.         chomp($date);
  1849.         $time = Mail::SpamAssassin::Util::parse_rfc822_date($date);
  1850.       }
  1851.       last DATE if defined($time);
  1852.     }
  1853.   }
  1854.   if (defined($time)) {
  1855.     $self->{date_header_time} = $time;
  1856.   }
  1857.   else {
  1858.     $self->{date_header_time} = undef;
  1859.   }
  1860. }
  1861.  
  1862. sub _get_received_header_times {
  1863.   my $self = $_[0];
  1864.  
  1865.   $self->{received_header_times} = [ () ];
  1866.   $self->{received_fetchmail_time} = undef;
  1867.  
  1868.   my (@received);
  1869.   my $received = $self->get('Received');
  1870.   if (defined($received) && length($received)) {
  1871.     @received = grep {$_ =~ m/\S/} (split(/\n/,$received));
  1872.   }
  1873.   # if we have no Received: headers, chances are we're archived mail
  1874.   # with a limited set of headers
  1875.   if (!scalar(@received)) {
  1876.     return;
  1877.   }
  1878.  
  1879.   # handle fetchmail headers
  1880.   my (@local);
  1881.   if (($received[0] =~
  1882.       m/\bfrom (?:localhost\s|(?:\S+ ){1,2}\S*\b127\.0\.0\.1\b)/) ||
  1883.       ($received[0] =~ m/qmail \d+ invoked by uid \d+/)) {
  1884.     push @local, (shift @received);
  1885.   }
  1886.   if (scalar(@received) &&
  1887.       ($received[0] =~ m/\bby localhost with \w+ \(fetchmail-[\d.]+/)) {
  1888.     push @local, (shift @received);
  1889.   }
  1890.   elsif (scalar(@local)) {
  1891.     unshift @received, (shift @local);
  1892.   }
  1893.  
  1894.   my $rcvd;
  1895.  
  1896.   if (scalar(@local)) {
  1897.     my (@fetchmail_times);
  1898.     foreach $rcvd (@local) {
  1899.       if ($rcvd =~ m/(\s.?\d+ \S\S\S \d+ \d+:\d+:\d+ \S+)/) {
  1900.     my $date = $1;
  1901.     dbg("eval: trying Received fetchmail header date for real time: $date");
  1902.     my $time = Mail::SpamAssassin::Util::parse_rfc822_date($date);
  1903.     if (defined($time) && (time() >= $time)) {
  1904.       dbg("eval: time_t from date=$time, rcvd=$date");
  1905.       push @fetchmail_times, $time;
  1906.     }
  1907.       }
  1908.     }
  1909.     if (scalar(@fetchmail_times) > 1) {
  1910.       $self->{received_fetchmail_time} =
  1911.        (sort {$b <=> $a} (@fetchmail_times))[0];
  1912.     } elsif (scalar(@fetchmail_times)) {
  1913.       $self->{received_fetchmail_time} = $fetchmail_times[0];
  1914.     }
  1915.   }
  1916.  
  1917.   my (@header_times);
  1918.   foreach $rcvd (@received) {
  1919.     if ($rcvd =~ m/(\s.?\d+ \S\S\S \d+ \d+:\d+:\d+ \S+)/) {
  1920.       my $date = $1;
  1921.       dbg("eval: trying Received header date for real time: $date");
  1922.       my $time = Mail::SpamAssassin::Util::parse_rfc822_date($date);
  1923.       if (defined($time)) {
  1924.     dbg("eval: time_t from date=$time, rcvd=$date");
  1925.     push @header_times, $time;
  1926.       }
  1927.     }
  1928.   }
  1929.  
  1930.   if (scalar(@header_times)) {
  1931.     $self->{received_header_times} = [ @header_times ];
  1932.   } else {
  1933.     dbg("eval: no dates found in Received headers");
  1934.   }
  1935. }
  1936.  
  1937. sub _check_date_received {
  1938.   my $self = $_[0];
  1939.  
  1940.   my (@dates_poss);
  1941.  
  1942.   $self->{date_received} = 0;
  1943.  
  1944.   if (!exists($self->{date_header_time})) {
  1945.     $self->_get_date_header_time();
  1946.   }
  1947.  
  1948.   if (defined($self->{date_header_time})) {
  1949.     push @dates_poss, $self->{date_header_time};
  1950.   }
  1951.  
  1952.   if (!exists($self->{received_header_times})) {
  1953.     $self->_get_received_header_times();
  1954.   }
  1955.   my (@received_header_times) = @{ $self->{received_header_times} };
  1956.   if (scalar(@received_header_times)) {
  1957.     push @dates_poss, $received_header_times[0];
  1958.   }
  1959.   if (defined($self->{received_fetchmail_time})) {
  1960.     push @dates_poss, $self->{received_fetchmail_time};
  1961.   }
  1962.  
  1963.   if (defined($self->{date_header_time}) && scalar(@received_header_times)) {
  1964.     if (!exists($self->{date_diff})) {
  1965.       $self->_check_date_diff();
  1966.     }
  1967.     push @dates_poss, $self->{date_header_time} - $self->{date_diff};
  1968.   }
  1969.  
  1970.   if (scalar(@dates_poss)) {    # use median
  1971.     $self->{date_received} = (sort {$b <=> $a}
  1972.                   (@dates_poss))[int($#dates_poss/2)];
  1973.     dbg("eval: date chosen from message: " .
  1974.     scalar(localtime($self->{date_received})));
  1975.   } else {
  1976.     dbg("eval: no dates found in message");
  1977.   }
  1978. }
  1979.  
  1980. sub _check_date_diff {
  1981.   my $self = $_[0];
  1982.  
  1983.   $self->{date_diff} = 0;
  1984.  
  1985.   if (!exists($self->{date_header_time})) {
  1986.     $self->_get_date_header_time();
  1987.   }
  1988.  
  1989.   if (!defined($self->{date_header_time})) {
  1990.     return;            # already have tests for this
  1991.   }
  1992.  
  1993.   if (!exists($self->{received_header_times})) {
  1994.     $self->_get_received_header_times();
  1995.   }
  1996.   my (@header_times) = @{ $self->{received_header_times} };
  1997.  
  1998.   if (!scalar(@header_times)) {
  1999.     return;            # archived mail?
  2000.   }
  2001.  
  2002.   my (@diffs) = map {$self->{date_header_time} - $_} (@header_times);
  2003.  
  2004.   # if the last Received: header has no difference, then we choose to
  2005.   # exclude it
  2006.   if ($#diffs > 0 && $diffs[$#diffs] == 0) {
  2007.     pop(@diffs);
  2008.   }
  2009.  
  2010.   # use the date with the smallest absolute difference
  2011.   # (experimentally, this results in the fewest false positives)
  2012.   @diffs = sort { abs($a) <=> abs($b) } @diffs;
  2013.   $self->{date_diff} = $diffs[0];
  2014. }
  2015.  
  2016. ###########################################################################
  2017.  
  2018. sub subject_is_all_caps {
  2019.    my ($self) = @_;
  2020.    my $subject = $self->get('Subject');
  2021.  
  2022.    $subject =~ s/^\s+//;
  2023.    $subject =~ s/\s+$//;
  2024.    return 0 if $subject !~ /\s/;    # don't match one word subjects
  2025.    return 0 if (length $subject < 10);  # don't match short subjects
  2026.    $subject =~ s/[^a-zA-Z]//g;        # only look at letters
  2027.  
  2028.    # now, check to see if the subject is encoded using a non-ASCII charset.
  2029.    # If so, punt on this test to avoid FPs.  We just list the known charsets
  2030.    # this test will FP on, here.
  2031.    my $subjraw = $self->get('Subject:raw');
  2032.    if ($subjraw =~ /=\?${CHARSETS_LIKELY_TO_FP_AS_CAPS}\?/i) {
  2033.      return 0;
  2034.    }
  2035.  
  2036.    return length($subject) && ($subject eq uc($subject));
  2037. }
  2038.  
  2039. ###########################################################################
  2040.  
  2041. # check an RBL if the message contains an "accreditor assertion,"
  2042. # that is, the message contains the name of a service that will vouch
  2043. # for their practices.
  2044. #
  2045. sub check_rbl_accreditor {
  2046.   my ($self, $rule, $set, $rbl_server, $subtest, $accreditor) = @_;
  2047.  
  2048.   if (!defined $self->{accreditor_tag}) {
  2049.     $self->message_accreditor_tag();
  2050.   }
  2051.   if ($self->{accreditor_tag}->{$accreditor}) {
  2052.     $self->check_rbl_backend($rule, $set, $rbl_server, 'A', $subtest);
  2053.   }
  2054.   return 0;
  2055. }
  2056.  
  2057. # Check for an Accreditor Assertion within the message, that is, the name of
  2058. #    a third-party who will vouch for the sender's practices. The accreditor
  2059. #    can be asserted in the EnvelopeFrom like this:
  2060. #
  2061. #        listowner@a--accreditor.mail.example.com
  2062. #
  2063. #    or in an 'Accreditor" Header field, like this:
  2064. #
  2065. #        Accreditor: accreditor1, parm=value; accreditor2, parm-value
  2066. #
  2067. #    This implementation supports multiple accreditors, but ignores any
  2068. #    parameters in the header field.
  2069. #
  2070. sub message_accreditor_tag {
  2071.   my ($self) = @_;
  2072.   my %acctags;
  2073.  
  2074.   if ($self->get('EnvelopeFrom:addr') =~ /[@.]a--([a-z0-9]{3,})\./i) {
  2075.     (my $tag = $1) =~ tr/A-Z/a-z/;
  2076.     $acctags{$tag} = -1;
  2077.   }
  2078.   my $accreditor_field = $self->get('Accreditor');
  2079.   if (defined($accreditor_field)) {
  2080.     my @accreditors = split(/,/, $accreditor_field);
  2081.     foreach my $accreditor (@accreditors) {
  2082.       my @terms = split(' ', $accreditor);
  2083.       if ($#terms >= 0) {
  2084.       my $tag = $terms[0];
  2085.       $tag =~ tr/A-Z/a-z/;
  2086.       $acctags{$tag} = -1;
  2087.       }
  2088.     }
  2089.   }
  2090.   $self->{accreditor_tag} = \%acctags;
  2091. }
  2092.  
  2093. ###########################################################################
  2094. # BODY TESTS:
  2095. ###########################################################################
  2096.   
  2097. sub body_charset_is_likely_to_fp {
  2098.   my ($self) = @_;
  2099.  
  2100.   # check for charsets where this test will FP -- iso-2022-jp, gb2312,
  2101.   # koi8-r etc.
  2102.   #
  2103.   $self->_check_attachments unless exists $self->{mime_checked_attachments};
  2104.   my @charsets = ();
  2105.   my $type = $self->get('Content-Type');
  2106.   $type = get_charset_from_ct_line ($type);
  2107.   if (defined $type) {
  2108.     push (@charsets, $type);
  2109.   }
  2110.   if (defined $self->{mime_html_charsets}) {
  2111.     push (@charsets, split(' ', $self->{mime_html_charsets}));
  2112.   }
  2113.  
  2114.   foreach my $charset (@charsets) {
  2115.     if ($charset =~ /^${CHARSETS_LIKELY_TO_FP_AS_CAPS}$/) {
  2116.       return 1;
  2117.     }
  2118.   }
  2119.   return 0;
  2120. }
  2121.  
  2122. sub check_for_uppercase {
  2123.   my ($self, $body, $min, $max) = @_;
  2124.   local ($_);
  2125.  
  2126.   if (exists $self->{uppercase}) {
  2127.     return ($self->{uppercase} > $min && $self->{uppercase} <= $max);
  2128.   }
  2129.  
  2130.   if ($self->body_charset_is_likely_to_fp()) {
  2131.     $self->{uppercase} = 0; return 0;
  2132.   }
  2133.  
  2134.   # Dec 20 2002 jm: trade off some speed for low memory footprint, by
  2135.   # iterating over the array computing sums, instead of joining the
  2136.   # array into a giant string and working from that.
  2137.  
  2138.   my $len = 0;
  2139.   my $lower = 0;
  2140.   my $upper = 0;
  2141.   foreach (@{$body}) {
  2142.     # examine lines in the body that have an intermediate space
  2143.     next unless /\S\s+\S/;
  2144.     # strip out lingering base64 (currently possible for forwarded messages)
  2145.     next if /^(?:[A-Za-z0-9+\/=]{60,76} ){2}/;
  2146.  
  2147.     my $line = $_;    # copy so we don't muck up the original
  2148.  
  2149.     # remove shift-JIS charset codes
  2150.     $line =~ s/\x1b\$B.*\x1b\(B//gs;
  2151.  
  2152.     $len += length($line);
  2153.  
  2154.     # count numerals as lower case, otherwise 'date|mail' is spam
  2155.     $lower += ($line =~ tr/a-z0-9//d);
  2156.     $upper += ($line =~ tr/A-Z//);
  2157.   }
  2158.  
  2159.   # report only on mails above a minimum size; otherwise one
  2160.   # or two acronyms can throw it off
  2161.   if ($len < 200) {
  2162.     $self->{uppercase} = 0;
  2163.     return 0;
  2164.   }
  2165.   if (($upper + $lower) == 0) {
  2166.     $self->{uppercase} = 0;
  2167.   } else {
  2168.     $self->{uppercase} = ($upper / ($upper + $lower)) * 100;
  2169.   }
  2170.  
  2171.   return ($self->{uppercase} > $min && $self->{uppercase} <= $max);
  2172. }
  2173.  
  2174. ###########################################################################
  2175. # MIME/uuencode attachment tests
  2176. ###########################################################################
  2177.  
  2178. # generic test version
  2179. sub check_for_mime {
  2180.   my ($self, undef, $test) = @_;
  2181.  
  2182.   $self->_check_attachments unless exists $self->{$test};
  2183.   return $self->{$test};
  2184. }
  2185.  
  2186. # any text/html MIME part
  2187. sub check_for_mime_html {
  2188.   my ($self) = @_;
  2189.  
  2190.   my $ctype = $self->get('Content-Type');
  2191.   return 1 if (defined($ctype) && $ctype =~ m@^text/html@i);
  2192.  
  2193.   $self->_check_attachments unless exists $self->{mime_body_html_count};
  2194.   return ($self->{mime_body_html_count} > 0);
  2195. }
  2196.  
  2197. # HTML without some other type of MIME text part
  2198. sub check_for_mime_html_only {
  2199.   my ($self) = @_;
  2200.  
  2201.   my $ctype = $self->get('Content-Type');
  2202.   return 1 if (defined($ctype) && $ctype =~ m@^text/html@i);
  2203.  
  2204.   $self->_check_attachments unless exists $self->{mime_body_html_count};
  2205.   return ($self->{mime_body_html_count} > 0 &&
  2206.       $self->{mime_body_text_count} == 0);
  2207. }
  2208.  
  2209. sub check_for_mime_excessive_qp {
  2210.   my ($self, undef, $min) = @_;
  2211.  
  2212.   $self->_check_attachments unless exists $self->{mime_qp_ratio};
  2213.  
  2214.   return $self->{mime_qp_ratio} >= $min;
  2215. }
  2216.  
  2217. sub check_mime_multipart_ratio {
  2218.   my ($self, undef, $min, $max) = @_;
  2219.  
  2220.   $self->_check_attachments unless exists $self->{mime_multipart_alternative};
  2221.  
  2222.   return ($self->{mime_multipart_ratio} >= $min &&
  2223.       $self->{mime_multipart_ratio} < $max);
  2224. }
  2225.  
  2226. sub _check_mime_header {
  2227.   my ($self, $ctype, $cte, $cd, $charset, $name) = @_;
  2228.  
  2229.   $charset ||= '';
  2230.  
  2231.   if ($ctype eq 'text/html') {
  2232.     $self->{mime_body_html_count}++;
  2233.   }
  2234.   elsif ($ctype =~ m@^text@i) {
  2235.     $self->{mime_body_text_count}++;
  2236.   }
  2237.  
  2238.   if ($cte =~ /base64/) {
  2239.     $self->{mime_base64_count}++;
  2240.   }
  2241.   elsif ($cte =~ /quoted-printable/) {
  2242.     $self->{mime_qp_count}++;
  2243.   }
  2244.  
  2245.   if ($cd && $cd =~ /attachment/) {
  2246.     $self->{mime_attachment}++;
  2247.   }
  2248.  
  2249.   if ($ctype =~ /^text/ &&
  2250.       $cte =~ /base64/ &&
  2251.       $charset !~ /utf-8/ &&
  2252.       !($cd && $cd =~ /^(?:attachment|inline)/))
  2253.   {
  2254.     $self->{mime_base64_encoded_text} = 1;
  2255.   }
  2256.  
  2257.   if ($cte =~ /base64/ && !$name) {
  2258.     $self->{mime_base64_no_name} = 1;
  2259.   }
  2260.  
  2261.   if ($charset =~ /iso-\S+-\S+\b/i &&
  2262.       $charset !~ /iso-(?:8859-\d{1,2}|2022-(?:jp|kr))\b/)
  2263.   {
  2264.     $self->{mime_bad_iso_charset} = 1;
  2265.   }
  2266.  
  2267.   # MIME_BASE64_LATIN: now a zero-hitter
  2268.   # if (!$name &&
  2269.   # $cte =~ /base64/ &&
  2270.   # $charset =~ /\b(?:us-ascii|iso-8859-(?:[12349]|1[0345])|windows-(?:125[0247]))\b/)
  2271.   # {
  2272.   # $self->{mime_base64_latin} = 1;
  2273.   # }
  2274.  
  2275.   # MIME_QP_NO_CHARSET: now a zero-hitter
  2276.   # if ($cte =~ /quoted-printable/ && $cd =~ /inline/ && !$charset) {
  2277.   # $self->{mime_qp_inline_no_charset} = 1;
  2278.   # }
  2279.  
  2280.   # MIME_HTML_NO_CHARSET: now a zero-hitter
  2281.   # if ($ctype eq 'text/html' &&
  2282.   # !(defined($charset) && $charset) &&
  2283.   # !($cd && $cd =~ /^(?:attachment|inline)/))
  2284.   # {
  2285.   # $self->{mime_html_no_charset} = 1;
  2286.   # }
  2287.  
  2288.   if ($charset =~ /[a-z]/i) {
  2289.     if (defined $self->{mime_html_charsets}) {
  2290.       $self->{mime_html_charsets} .= " ".$charset;
  2291.     } else {
  2292.       $self->{mime_html_charsets} = $charset;
  2293.     }
  2294.  
  2295.     if (! $self->{mime_faraway_charset}) {
  2296.       my @l = $self->get_my_locales();
  2297.  
  2298.       if (!(grep { $_ eq "all" } @l) &&
  2299.       !Mail::SpamAssassin::Locales::is_charset_ok_for_locales($charset, @l))
  2300.       {
  2301.     $self->{mime_faraway_charset} = 1;
  2302.       }
  2303.     }
  2304.   }
  2305. }
  2306.  
  2307. sub _check_attachments {
  2308.   my ($self) = @_;
  2309.  
  2310.   # MIME status
  2311.   my $where = -1;        # -1 = start, 0 = nowhere, 1 = header, 2 = body
  2312.   my $qp_bytes = 0;        # total bytes in QP regions
  2313.   my $qp_count = 0;        # QP-encoded bytes in QP regions
  2314.   my @part_bytes;        # MIME part total bytes
  2315.   my @part_type;        # MIME part types
  2316.  
  2317.   # MIME header information
  2318.   my $part = -1;        # MIME part index
  2319.  
  2320.   # indicate the scan has taken place
  2321.   $self->{mime_checked_attachments} = 1;
  2322.  
  2323.   # results
  2324.   $self->{mime_base64_blanks} = 0;
  2325.   $self->{mime_base64_count} = 0;
  2326.   $self->{mime_base64_encoded_text} = 0;
  2327.   # $self->{mime_base64_illegal} = 0;
  2328.   # $self->{mime_base64_latin} = 0;
  2329.   $self->{mime_base64_no_name} = 0;
  2330.   $self->{mime_body_html_count} = 0;
  2331.   $self->{mime_body_text_count} = 0;
  2332.   $self->{mime_faraway_charset} = 0;
  2333.   # $self->{mime_html_no_charset} = 0;
  2334.   $self->{mime_missing_boundary} = 0;
  2335.   $self->{mime_multipart_alternative} = 0;
  2336.   $self->{mime_multipart_ratio} = 1.0;
  2337.   $self->{mime_qp_count} = 0;
  2338.   # $self->{mime_qp_illegal} = 0;
  2339.   # $self->{mime_qp_inline_no_charset} = 0;
  2340.   $self->{mime_qp_long_line} = 0;
  2341.   $self->{mime_qp_ratio} = 0;
  2342.  
  2343.   # Get all parts ...
  2344.   foreach my $p ($self->{msg}->find_parts(qr/./)) {
  2345.     # message headers
  2346.     my ($ctype, $boundary, $charset, $name) = Mail::SpamAssassin::Util::parse_content_type($p->get_header("content-type"));
  2347.  
  2348.     if ($ctype eq 'multipart/alternative') {
  2349.       $self->{mime_multipart_alternative} = 1;
  2350.     }
  2351.  
  2352.     my $cte = $p->get_header('Content-Transfer-Encoding') || '';
  2353.     chomp($cte = defined($cte) ? lc $cte : "");
  2354.  
  2355.     my $cd = $p->get_header('Content-Disposition') || '';
  2356.     chomp($cd = defined($cd) ? lc $cd : "");
  2357.  
  2358.     $charset = lc $charset if ($charset);
  2359.     $name = lc $name if ($name);
  2360.  
  2361.     $self->_check_mime_header($ctype, $cte, $cd, $charset, $name);
  2362.  
  2363.     # If we're not in a leaf node in the tree, there will be no raw
  2364.     # section, so skip it.
  2365.     if (! $p->is_leaf()) {
  2366.       next;
  2367.     }
  2368.  
  2369.     $part++;
  2370.     $part_type[$part] = $ctype;
  2371.     $part_bytes[$part] = 0 if $cd !~ /attachment/;
  2372.  
  2373.     my $previous = '';
  2374.     foreach (@{$p->raw()}) {
  2375.       if ($cte =~ /base64/i) {
  2376.         if ($previous =~ /^\s*$/ && /^\s*$/) {
  2377.       $self->{mime_base64_blanks} = 1;
  2378.         }
  2379.         # MIME_BASE64_ILLEGAL: now a zero-hitter
  2380.         # if (m@[^A-Za-z0-9+/=\n]@ || /=[^=\s]/) {
  2381.         # $self->{mime_base64_illegal} = 1;
  2382.         # }
  2383.       }
  2384.  
  2385.       # if ($self->{mime_html_no_charset} && $ctype eq 'text/html' && defined $charset) {
  2386.       # $self->{mime_html_no_charset} = 0;
  2387.       # }
  2388.       if ($self->{mime_multipart_alternative} && $cd !~ /attachment/ &&
  2389.           ($ctype eq 'text/plain' || $ctype eq 'text/html')) {
  2390.     $part_bytes[$part] += length;
  2391.       }
  2392.  
  2393.       if ($where != 1 && $cte eq "quoted-printable" && ! /^SPAM: /) {
  2394.         if (length > 77) {
  2395.       $self->{mime_qp_long_line} = 1;
  2396.         }
  2397.         $qp_bytes += length;
  2398.  
  2399.         # MIME_QP_DEFICIENT: zero-hitter now
  2400.  
  2401.         # check for illegal substrings (RFC 2045), hexadecimal values 7F-FF and
  2402.         # control characters other than TAB, or CR and LF as parts of CRLF pairs
  2403.         # if (!$self->{mime_qp_illegal} && /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff]/)
  2404.         # {
  2405.         # $self->{mime_qp_illegal} = 1;
  2406.         # }
  2407.  
  2408.         # count excessive QP bytes
  2409.         if (index($_, '=') != -1) {
  2410.       # whoever wrote this next line is an evil hacker -- jm
  2411.       my $qp = () = m/=(?:09|3[0-9ABCEF]|[2456][0-9A-F]|7[0-9A-E])/g;
  2412.       if ($qp) {
  2413.         $qp_count += $qp;
  2414.         # tabs and spaces at end of encoded line are okay.  Also, multiple
  2415.         # whitespace at the end of a line are OK, like ">=20=20=20=20=20=20".
  2416.         my ($trailing) = m/((?:=09|=20)+)\s*$/g;
  2417.         if ($trailing) {
  2418.           $qp_count -= (length($trailing) / 3);
  2419.         }
  2420.       }
  2421.         }
  2422.       }
  2423.       $previous = $_;
  2424.     }
  2425.   }
  2426.  
  2427.   if ($qp_bytes) {
  2428.     $self->{mime_qp_ratio} = $qp_count / $qp_bytes;
  2429.   }
  2430.  
  2431.   if ($self->{mime_multipart_alternative}) {
  2432.     my $text;
  2433.     my $html;
  2434.     # bug 4207: we want the size of the last parts
  2435.     for (my $i = $part; $i >= 0; $i--) {
  2436.       next if !defined $part_bytes[$i];
  2437.       if (!defined($html) && $part_type[$i] eq 'text/html') {
  2438.     $html = $part_bytes[$i];
  2439.       }
  2440.       elsif (!defined($text) && $part_type[$i] eq 'text/plain') {
  2441.     $text = $part_bytes[$i];
  2442.       }
  2443.       last if (defined($html) && defined($text));
  2444.     }
  2445.     if (defined($text) && defined($html) && $html > 0) {
  2446.       $self->{mime_multipart_ratio} = ($text / $html);
  2447.     }
  2448.   }
  2449.  
  2450.   # Look to see if any multipart boundaries are not "balanced"
  2451.   foreach my $val (values %{$self->{msg}->{mime_boundary_state}}) {
  2452.     if ($val != 0) {
  2453.       $self->{mime_missing_boundary} = 1;
  2454.       last;
  2455.     }
  2456.   }
  2457. }
  2458.  
  2459. ###########################################################################
  2460.  
  2461. sub check_for_fake_aol_relay_in_rcvd {
  2462.   my ($self) = @_;
  2463.   local ($_);
  2464.  
  2465.   $_ = $self->get('Received'); s/\s/ /gs;
  2466.  
  2467.   # this is the hostname format used by AOL for their relays. Spammers love 
  2468.   # forging it.  Don't make it more specific to match aol.com only, though --
  2469.   # there's another set of spammers who generate fake hostnames to go with
  2470.   # it!
  2471.   if (/ rly-[a-z][a-z]\d\d\./i) {
  2472.     return 0 if /\/AOL-\d+\.\d+\.\d+\)/;    # via AOL mail relay
  2473.     return 0 if /ESMTP id (?:RELAY|MAILRELAY|MAILIN)/; # AOLish
  2474.     return 1;
  2475.   }
  2476.  
  2477. # spam: Received: from unknown (HELO mta05bw.bigpond.com) (80.71.176.130) by
  2478. #    rly-xw01.mx.aol.com with QMQP; Sat, 15 Jun 2002 23:37:16 -0000
  2479.  
  2480. # non: Received: from  rly-xj02.mx.aol.com (rly-xj02.mail.aol.com [172.20.116.39]) by
  2481. #    omr-r05.mx.aol.com (v83.35) with ESMTP id RELAYIN7-0501132011; Wed, 01
  2482. #    May 2002 13:20:11 -0400
  2483.  
  2484. # non: Received: from logs-tr.proxy.aol.com (logs-tr.proxy.aol.com [152.163.201.132])
  2485. #    by rly-ip01.mx.aol.com (8.8.8/8.8.8/AOL-5.0.0)
  2486. #    with ESMTP id NAA08955 for <sapient-alumni@yahoogroups.com>;
  2487. #    Thu, 4 Apr 2002 13:11:20 -0500 (EST)
  2488.  
  2489.   return 0;
  2490. }
  2491.  
  2492. ###########################################################################
  2493.  
  2494. sub check_for_to_in_subject {
  2495.   my ($self, $test) = @_;
  2496.  
  2497.   my $full_to = $self->get('To:addr');
  2498.   return 0 unless $full_to;
  2499.  
  2500.   my $subject = $self->get('Subject');
  2501.  
  2502.   if ($test eq "address") {
  2503.     return $subject =~ /\b\Q$full_to\E\b/i;    # "user@domain.com"
  2504.   }
  2505.   elsif ($test eq "user") {
  2506.     my $to = $full_to;
  2507.     $to =~ s/\@.*//;
  2508.     return $subject =~ /^\s*\Q$to\E,\s/i;    # "user,\s" case insensitive
  2509.   }
  2510.   return 0;
  2511. }
  2512.  
  2513. ###########################################################################
  2514.  
  2515. sub check_bayes {
  2516.   my ($self, $fulltext, $min, $max) = @_;
  2517.  
  2518.   return 0 if (!$self->{conf}->{use_bayes_rules});
  2519.  
  2520.   if (!exists ($self->{bayes_score})) {
  2521.     $self->{bayes_score} = $self->{main}->{bayes_scanner}->scan ($self, $self->{msg});
  2522.   }
  2523.  
  2524.   if (defined $self->{bayes_score} &&
  2525.       ($min == 0 || $self->{bayes_score} > $min) &&
  2526.       ($max eq "undef" || $self->{bayes_score} <= $max))
  2527.   {
  2528.       if ($self->{conf}->{detailed_bayes_score}) {
  2529.         $self->test_log(sprintf ("score: %3.4f, hits: %s",
  2530.                                  $self->{bayes_score},
  2531.                                  $self->{bayes_hits}));
  2532.       }
  2533.       else {
  2534.         $self->test_log(sprintf ("score: %3.4f", $self->{bayes_score}));
  2535.       }
  2536.       return 1;
  2537.   }
  2538.   return 0;
  2539.  
  2540. }
  2541.  
  2542. ###########################################################################
  2543.  
  2544. sub check_outlook_message_id {
  2545.   my ($self) = @_;
  2546.   local ($_);
  2547.  
  2548.   my $id = $self->get('MESSAGEID');
  2549.   return 0 if $id !~ /^<[0-9a-f]{4}([0-9a-f]{8})\$[0-9a-f]{8}\$[0-9a-f]{8}\@/;
  2550.  
  2551.   my $timetoken = hex($1);
  2552.   my $x = 0.0023283064365387;
  2553.   my $y = 27111902.8329849;
  2554.  
  2555.   my $fudge = 250;
  2556.  
  2557.   $_ = $self->get('Date');
  2558.   $_ = Mail::SpamAssassin::Util::parse_rfc822_date($_) || 0;
  2559.   my $expected = int (($_ * $x) + $y);
  2560.   my $diff = $timetoken - $expected;
  2561.   return 0 if (abs($diff) < $fudge);
  2562.  
  2563.   $_ = $self->get('Received');
  2564.   /(\s.?\d+ \S\S\S \d+ \d+:\d+:\d+ \S+).*?$/;
  2565.   $_ = Mail::SpamAssassin::Util::parse_rfc822_date($_) || 0;
  2566.   $expected = int(($_ * $x) + $y);
  2567.   $diff = $timetoken - $expected;
  2568.  
  2569.   return (abs($diff) >= $fudge);
  2570. }
  2571.  
  2572. sub check_messageid_not_usable {
  2573.   my ($self) = @_;
  2574.   local ($_);
  2575.  
  2576.   # Lyris eats message-ids.  also some ezmlm, I think :(
  2577.   $_ = $self->get("List-Unsubscribe");
  2578.   return 1 if (/<mailto:(?:leave-\S+|\S+-unsubscribe)\@\S+>$/);
  2579.  
  2580.   # ezmlm again
  2581.   if($self->gated_through_received_hdr_remover()) { return 1; }
  2582.  
  2583.   # Allen notes this as 'Wacky sendmail version?'
  2584.   $_ = $self->get("Received");
  2585.   return 1 if /\/CWT\/DCE\)/;
  2586.  
  2587.   # Apr  2 2003 jm: iPlanet rewrites lots of stuff, including Message-IDs
  2588.   return 1 if /iPlanet Messaging Server/;
  2589.  
  2590.   # too old; older versions of clients used different formats
  2591.   return 1 if ($self->received_within_months('6','undef'));
  2592.  
  2593.   return 0;
  2594. }
  2595.  
  2596. # Return true if the count of $hdr headers are within the given range
  2597. sub check_header_count_range {
  2598.   my ($self, $hdr, $min, $max) = @_;
  2599.   my %uniq = ();
  2600.   my @hdrs = grep(!$uniq{$_}++, $self->{msg}->get_header ($hdr));
  2601.   return (scalar @hdrs >= $min && scalar @hdrs <= $max);
  2602. }
  2603.  
  2604. sub check_blank_line_ratio {
  2605.   my ($self, $fulltext, $min, $max, $minlines) = @_;
  2606.  
  2607.   if (!defined $minlines || $minlines < 1) {
  2608.     $minlines = 1;
  2609.   }
  2610.  
  2611.   if (! exists $self->{blank_line_ratio}->{$minlines}) {
  2612.     $fulltext = $self->get_decoded_body_text_array();
  2613.     my ($blank) = 0;
  2614.     if (scalar @{$fulltext} >= $minlines) {
  2615.       my $consecutive_blanks = 0;
  2616.       foreach my $line (@{$fulltext}) {
  2617.         if ($line =~ /\S/) {
  2618.           $consecutive_blanks = 0;
  2619.           next;
  2620.     }
  2621.         $consecutive_blanks++;
  2622.         $blank++;
  2623.       }
  2624.       # ignore trailing blanks
  2625.       $blank -= $consecutive_blanks;
  2626.       my $total_lines = scalar @{$fulltext} - $consecutive_blanks;
  2627.  
  2628.       $self->{blank_line_ratio}->{$minlines} = $total_lines == 0 ? -1 :
  2629.                                               (100 * $blank / $total_lines);
  2630.     }
  2631.     else {
  2632.       $self->{blank_line_ratio}->{$minlines} = -1; # don't report if it's a blank message ...
  2633.     }
  2634.   }
  2635.  
  2636.   return (($min == 0 && $self->{blank_line_ratio}->{$minlines} <= $max) ||
  2637.       ($self->{blank_line_ratio}->{$minlines} > $min &&
  2638.        $self->{blank_line_ratio}->{$minlines} <= $max));
  2639. }
  2640.  
  2641. sub sent_by_applemail {
  2642.   my ($self) = @_;
  2643.  
  2644.   return 0 unless ($self->get("MIME-Version") =~ /Apple Message framework/);
  2645.   return 0 unless ($self->get("X-Mailer") =~ /^Apple Mail \(\d+\.\d+\)/);
  2646.   return 0 unless ($self->get("Message-Id") =~
  2647.            /^<[A-F0-9]+(?:-[A-F0-9]+){4}\@\S+.\S+>$/);
  2648.   return 1;
  2649. }
  2650.  
  2651. sub check_for_rdns_helo_mismatch {    # T_FAKE_HELO_*
  2652.   my ($self, $rdns, $helo) = @_;
  2653.  
  2654.   # oh for ghod's sake.  Apple's Mail.app HELO's as the right-hand
  2655.   # side of the From address.  So "HELO jmason.org" in my case.
  2656.   # This is (obviously) considered forgery, since it's exactly
  2657.   # what ratware does too.
  2658.   return 0 if $self->sent_by_applemail();
  2659.  
  2660.   # the IETF's list-management system mangles Received headers,
  2661.   # "faking" a HELO, resulting in FPs.  So if we received the
  2662.   # mail from the IETF's outgoing SMTP server, skip it.
  2663.   if ($self->{relays_untrusted_str} =~ /^\[ [^\]]*
  2664.           ip=132\.151\.1\.\S+\s+ rdns=\S*ietf\.org /x)
  2665.   {
  2666.     return 0;
  2667.   }
  2668.  
  2669.   my $firstuntrusted = 1;
  2670.   foreach my $relay (@{$self->{relays_untrusted}}) {
  2671.     my $wasfirst = $firstuntrusted;
  2672.     $firstuntrusted = 0;
  2673.  
  2674.     # did the machine HELO as a \S*something\.com machine?
  2675.     if ($relay->{helo} !~ /(?:\.|^)${helo}$/) { next; }
  2676.  
  2677.     my $claimed = $relay->{rdns};
  2678.     my $claimedmatches = ($claimed =~ /(?:\.|^)${rdns}$/);
  2679.     if ($claimedmatches && $wasfirst) {
  2680.       # the first untrusted Received: hdr is inserted by a trusted MTA.
  2681.       # so if the rDNS pattern matches, we're good, skip it
  2682.       next;
  2683.     }
  2684.  
  2685.     if ($claimedmatches && !$wasfirst) {
  2686.       # it's a possibly-forged rDNS lookup.  Do a verification lookup
  2687.       # to ensure the host really does match what the rDNS lookup
  2688.       # claims it is.
  2689.       if ($self->is_dns_available()) {
  2690.     my $vrdns = $self->lookup_ptr ($relay->{ip});
  2691.     if (defined $vrdns && $vrdns ne $claimed) {
  2692.       dbg("eval: rdns/helo mismatch: helo=$relay->{helo} ".    
  2693.         "claimed-rdns=$claimed true-rdns=$vrdns");
  2694.       return 1;
  2695.       # TODO: instead, we should set a flag and check it later for
  2696.       # another test; but that relies on complicated test ordering
  2697.     }
  2698.       }
  2699.     }
  2700.  
  2701.     if (!$claimedmatches) {
  2702.       if (!$self->is_dns_available()) { 
  2703.     if ($relay->{rdns_not_in_headers}) {
  2704.       # that's OK then; it's just the MTA which picked it up,
  2705.       # is not configured to perform lookups, and we're offline
  2706.       # so we couldn't either.
  2707.       return 0;
  2708.     }
  2709.       }
  2710.  
  2711.       # otherwise there *is* a mismatch
  2712.       dbg("eval: rdns/helo mismatch: helo=$relay->{helo} rdns=$claimed");
  2713.       return 1;
  2714.     }
  2715.   }
  2716.  
  2717.   0;
  2718. }
  2719.  
  2720. # note using IPv4 addresses for now due to empty strings matching IP_ADDRESS
  2721. # due to bug in pure IPv6 address regular expression
  2722. sub helo_ip_mismatch {
  2723.   my ($self) = @_;
  2724.   my $IP_ADDRESS = IPV4_ADDRESS;
  2725.   my $IP_PRIVATE = IP_PRIVATE;
  2726.  
  2727.   for my $relay (@{$self->{relays_untrusted}}) {
  2728.     # is HELO usable?
  2729.     next unless ($relay->{helo} =~ m/^$IP_ADDRESS$/ &&
  2730.          $relay->{helo} !~ /$IP_PRIVATE/);
  2731.     # compare HELO with IP
  2732.     return 1 if ($relay->{ip} =~ m/^$IP_ADDRESS$/ &&
  2733.          $relay->{ip} !~ m/$IP_PRIVATE/ &&
  2734.          $relay->{helo} ne $relay->{ip} &&
  2735.          # different IP is okay if in same /24
  2736.          $relay->{helo} =~ /^(\d+\.\d+\.\d+\.)/ &&
  2737.          index($relay->{ip}, $1) != 0);
  2738.   }
  2739.  
  2740.   0;
  2741. }
  2742.  
  2743. ###########################################################################
  2744.  
  2745. sub check_all_trusted {
  2746.   my ($self) = @_;
  2747.   return $self->{num_relays_trusted}
  2748.         && !$self->{num_relays_untrusted}
  2749.         && !$self->{num_relays_unparseable};
  2750. }
  2751.  
  2752. sub check_no_relays {
  2753.   my ($self) = @_;
  2754.   return !$self->{num_relays_trusted}
  2755.         && !$self->{num_relays_untrusted}
  2756.         && !$self->{num_relays_unparseable};
  2757. }
  2758.  
  2759. sub check_relays_unparseable {
  2760.   my ($self) = @_;
  2761.   return $self->{num_relays_unparseable};
  2762. }
  2763.  
  2764. ###########################################################################
  2765. # HTML parser tests
  2766. ###########################################################################
  2767.  
  2768. sub html_tag_balance {
  2769.   my ($self, undef, $rawtag, $rawexpr) = @_;
  2770.   $rawtag =~ /^([a-zA-Z0-9]+)$/; my $tag = $1;
  2771.   $rawexpr =~ /^([\<\>\=\!\-\+ 0-9]+)$/; my $expr = $1;
  2772.  
  2773.   return 0 unless exists $self->{html}{inside}{$tag};
  2774.  
  2775.   $self->{html}{inside}{$tag} =~ /^([\<\>\=\!\-\+ 0-9]+)$/;
  2776.   my $val = $1;
  2777.   return eval "\$val $expr";
  2778. }
  2779.  
  2780. sub html_image_only {
  2781.   my ($self, undef, $min, $max) = @_;
  2782.  
  2783.   return (exists $self->{html}{inside}{img} &&
  2784.       exists $self->{html}{length} &&
  2785.       $self->{html}{length} > $min &&
  2786.       $self->{html}{length} <= $max);
  2787. }
  2788.  
  2789. sub html_image_ratio {
  2790.   my ($self, undef, $min, $max) = @_;
  2791.  
  2792.   return 0 unless (exists $self->{html}{non_space_len} &&
  2793.            exists $self->{html}{image_area} &&
  2794.            $self->{html}{image_area} > 0);
  2795.   my $ratio = $self->{html}{non_space_len} / $self->{html}{image_area};
  2796.   return ($ratio > $min && $ratio <= $max);
  2797. }
  2798.  
  2799. sub html_charset_faraway {
  2800.   my ($self) = @_;
  2801.  
  2802.   return 0 unless exists $self->{html}{charsets};
  2803.  
  2804.   my @locales = $self->get_my_locales();
  2805.   return 0 if grep { $_ eq "all" } @locales;
  2806.  
  2807.   my $okay = 0;
  2808.   my $bad = 0;
  2809.   for my $c (split(' ', $self->{html}{charsets})) {
  2810.     if (Mail::SpamAssassin::Locales::is_charset_ok_for_locales($c, @locales)) {
  2811.       $okay++;
  2812.     }
  2813.     else {
  2814.       $bad++;
  2815.     }
  2816.   }
  2817.   return ($bad && ($bad >= $okay));
  2818. }
  2819.  
  2820. sub html_tag_exists {
  2821.   my ($self, undef, $tag) = @_;
  2822.   return exists $self->{html}{inside}{$tag};
  2823. }
  2824.  
  2825. sub html_test {
  2826.   my ($self, undef, $test) = @_;
  2827.   return $self->{html}{$test};
  2828. }
  2829.  
  2830. sub html_eval {
  2831.   my ($self, undef, $test, $rawexpr) = @_;
  2832.   $rawexpr =~ /^([\<\>\=\!\-\+ 0-9]+)$/; my $expr = $1;
  2833.  
  2834.   # workaround bug 3320: wierd perl bug where additional, very explicit
  2835.   # untainting into a new var is required.
  2836.   my $tainted = $self->{html}{$test};
  2837.   return unless defined($tainted);
  2838.   $tainted =~ /^(.*)$/; my $val = $1;
  2839.  
  2840.   # just use the value in $val, don't copy it needlessly
  2841.   return eval "\$val $expr";
  2842. }
  2843.  
  2844. sub html_text_match {
  2845.   my ($self, undef, $text, $regexp) = @_;
  2846.   for my $string (@{ $self->{html}{$text} }) {
  2847.     if (defined $string && $string =~ /${regexp}/) {
  2848.       return 1;
  2849.     }
  2850.   }
  2851.   return 0;
  2852. }
  2853.  
  2854. sub html_title_subject_ratio {
  2855.   my ($self, undef, $ratio) = @_;
  2856.  
  2857.   my $subject = $self->get('Subject');
  2858.   if (! $subject) {
  2859.     return 0;
  2860.   }
  2861.   my $max = 0;
  2862.   for my $string (@{ $self->{html}{title} }) {
  2863.     if ($string) {
  2864.       my $ratio = length($string) / length($subject);
  2865.       $max = $ratio if $ratio > $max;
  2866.     }
  2867.   }
  2868.   return $max > $ratio;
  2869. }
  2870.  
  2871. sub html_text_not_match {
  2872.   my ($self, undef, $text, $regexp) = @_;
  2873.   for my $string (@{ $self->{html}{$text} }) {
  2874.     if (defined $string && $string !~ /${regexp}/) {
  2875.       return 1;
  2876.     }
  2877.   }
  2878.   return 0;
  2879. }
  2880.  
  2881. sub html_range {
  2882.   my ($self, undef, $test, $min, $max) = @_;
  2883.  
  2884.   return 0 unless exists $self->{html}{$test};
  2885.  
  2886.   $test = $self->{html}{$test};
  2887.  
  2888.   # not all perls understand what "inf" means, so we need to do
  2889.   # non-numeric tests!  urg!
  2890.   if (!defined $max || $max eq "inf") {
  2891.     return ($test eq "inf") ? 1 : ($test > $min);
  2892.   }
  2893.   elsif ($test eq "inf") {
  2894.     # $max < inf, so $test == inf means $test > $max
  2895.     return 0;
  2896.   }
  2897.   else {
  2898.     # if we get here everything should be a number
  2899.     return ($test > $min && $test <= $max);
  2900.   }
  2901. }
  2902.  
  2903. ###########################################################################
  2904.  
  2905. sub multipart_alternative_difference {
  2906.   my ($self, $fulltext, $min, $max) = @_;
  2907.  
  2908.   $self->_multipart_alternative_difference() unless (exists $self->{madiff});
  2909.  
  2910.   if (($min == 0 || $self->{madiff} > $min) &&
  2911.       ($max eq "undef" || $self->{madiff} <= $max)) {
  2912.       return 1;
  2913.   }
  2914.   return 0;
  2915. }
  2916.  
  2917. sub multipart_alternative_difference_count {
  2918.   my ($self, $fulltext, $ratio, $minhtml) = @_;
  2919.   $self->_multipart_alternative_difference() unless (exists $self->{madiff});
  2920.   return 0 unless $self->{madiff_html} > $minhtml;
  2921.   return(($self->{madiff_text} / $self->{madiff_html}) > $ratio);
  2922. }
  2923.  
  2924. sub _multipart_alternative_difference {
  2925.   my ($self) = @_;
  2926.   $self->{madiff} = 0;
  2927.   $self->{madiff_html} = 0;
  2928.   $self->{madiff_text} = 0;
  2929.  
  2930.   # Find all multipart/alternative parts in the message
  2931.   my @ma = $self->{msg}->find_parts(qr@^multipart/alternative\b@i);
  2932.  
  2933.   # If there are no multipart/alternative sections, skip this test.
  2934.   return if (!@ma);
  2935.  
  2936.   # Figure out what the MIME content of the message looks like
  2937.   my @content = $self->{msg}->content_summary();
  2938.  
  2939.   # Exchange meeting requests come in as m/a text/html text/calendar,
  2940.   # which we want to ignore because of the high FP rate it would cause.
  2941.   # 
  2942.   if (@content == 3 && $content[2] eq 'text/calendar' &&
  2943.       $content[1] eq 'text/html' &&
  2944.       $content[0] eq 'multipart/alternative') {
  2945.     return;
  2946.   }
  2947.  
  2948.   # Go through each of the multipart parts
  2949.   foreach my $part (@ma) {
  2950.     my %html = ();
  2951.     my %text = ();
  2952.  
  2953.     # limit our search to text-based parts
  2954.     my @txt = $part->find_parts(qr@^text\b@i);
  2955.     foreach my $text (@txt) {
  2956.       # we only care about the rendered version of the part
  2957.       my ($type, $rnd) = $text->rendered();
  2958.  
  2959.       # parse the rendered text into tokens.  assume they are whitespace
  2960.       # separated, and ignore anything that doesn't have a word-character
  2961.       # in it (0-9a-zA-Z_) since those are probably things like bullet
  2962.       # points, horizontal lines, etc.  this assumes that punctuation
  2963.       # in one part will be the same in other parts.
  2964.       #
  2965.       if ($type eq 'text/html') {
  2966.         foreach my $w (grep(/\w/, split(/\s+/, $rnd))) {
  2967.       #dbg("eval: HTML: $w");
  2968.           $html{$w}++;
  2969.         }
  2970.  
  2971.     # If there are no words, mark if there's at least 1 image ...
  2972.     if (keys %html == 0 && exists $self->{html}{inside}{img}) {
  2973.       # Use "\n" as the mark since it can't ever occur normally
  2974.       $html{"\n"}=1;
  2975.     }
  2976.       }
  2977.       else {
  2978.         foreach my $w (grep(/\w/, split(/\s+/, $rnd))) {
  2979.       #dbg("eval: TEXT: $w");
  2980.           $text{$w}++;
  2981.         }
  2982.       }
  2983.     }
  2984.  
  2985.     # How many HTML tokens do we have at the start?
  2986.     my $orig = keys %html;
  2987.     next if ($orig == 0);
  2988.  
  2989.     $self->{madiff_html} = $orig;
  2990.     $self->{madiff_text} = keys %text;
  2991.     dbg('eval: text words: ' . $self->{madiff_text} . ', html words: ' . $self->{madiff_html});
  2992.  
  2993.     # If the token appears at least as many times in the text part as
  2994.     # in the html part, remove it from the list of html tokens.
  2995.     while(my ($k,$v) = each %text) {
  2996.       delete $html{$k} if (exists $html{$k} && $html{$k}-$text{$k} < 1);
  2997.     }
  2998.  
  2999.     #map { dbg("eval: LEFT: $_") } keys %html;
  3000.  
  3001.     # In theory, the tokens should be the same in both text and html
  3002.     # parts, so there would be 0 tokens left in the html token list, for
  3003.     # a 0% difference rate.  Calculate it here, and record the difference
  3004.     # if it's been the highest so far in this message.
  3005.     my $diff = scalar(keys %html)/$orig*100;
  3006.     $self->{madiff} = $diff if ($diff > $self->{madiff});
  3007.  
  3008.     dbg("eval: " . sprintf "madiff: left: %d, orig: %d, max-difference: %0.2f%%", scalar(keys %html), $orig, $self->{madiff});
  3009.   }
  3010.  
  3011.   return;
  3012. }
  3013.  
  3014. ###########################################################################
  3015.  
  3016. sub check_domain_ratio {
  3017.   my ($self, $body, $ratio) = @_;
  3018.   my $length = (length(join('', @{$body})) || 1);
  3019.   if (!defined $self->{uri_domain_count}) {
  3020.     $self->get_uri_list();
  3021.   }
  3022.   return 0 if !defined $self->{uri_domain_count};
  3023.   return (($self->{uri_domain_count} / $length) > $ratio);
  3024. }
  3025.  
  3026. ###########################################################################
  3027.  
  3028. sub check_for_http_redirector {
  3029.   my ($self) = @_;
  3030.  
  3031.   foreach ($self->get_uri_list()) {
  3032.     while (s{^https?://([^/:\?]+).+?(https?:/{0,2}?([^/:\?]+).*)$}{$2}) {
  3033.       my ($redir, $dest) = ($1, $3);
  3034.       foreach ($redir, $dest) {
  3035.     $_ = Mail::SpamAssassin::Util::uri_to_domain(lc($_)) || $_;
  3036.       }
  3037.       next if ($redir eq $dest);
  3038.       dbg("eval: redirect: found $redir to $dest, flagging");
  3039.       return 1;
  3040.     }
  3041.   }
  3042.   return 0;
  3043. }
  3044.  
  3045. ###########################################################################
  3046.  
  3047. sub check_for_numeric_helo {
  3048.   my ($self) = @_;
  3049.  
  3050.   my $rcvd = $self->{relays_untrusted_str};
  3051.  
  3052.   if ($rcvd) {
  3053.     my $IP_ADDRESS = IPV4_ADDRESS;
  3054.     my $IP_PRIVATE = IP_PRIVATE;
  3055.     if ($rcvd =~ /helo=($IP_ADDRESS)\b/i && $1 !~ /$IP_PRIVATE/) {
  3056.       return 1;
  3057.     }
  3058.   }
  3059.   return 0;
  3060. }
  3061.  
  3062. sub check_for_illegal_ip {
  3063.   my ($self) = @_;
  3064.  
  3065.   foreach my $rcvd ( @{$self->{relays_untrusted}} ) {
  3066.     # (note this might miss some hits if the Received.pm skips any invalid IPs)
  3067.     foreach my $check ( $rcvd->{ip}, $rcvd->{by} ) {
  3068.       next if ($check eq '127.0.0.1');
  3069.       return 1 if ($check =~ /^
  3070.     (?:[01257]|127|22[3-9]|23[0-9]|24[0-9]|25[0-5])\.\d+\.\d+\.\d+
  3071.     $/x);
  3072.     }
  3073.   }
  3074.   return 0;
  3075. }
  3076.  
  3077. ###########################################################################
  3078.  
  3079. sub check_msg_parse_flags {
  3080.   my($self, $type, $type2) = @_;
  3081.   $type = $type2 if ref($type);
  3082.   return defined $self->{msg}->{$type};
  3083. }
  3084.  
  3085. ###########################################################################
  3086.  
  3087. sub check_unresolved_template {
  3088.   my ($self) = @_;
  3089.  
  3090.   my $all = $self->get('ALL');    # cached access
  3091.   $all =~ s/\n[ \t]+/ /gs;    # fix continuation lines
  3092.   
  3093.   for my $header (split(/\n/, $all)) {
  3094.     # slightly faster to test in this order
  3095.     if ($header =~ /%[A-Z][A-Z_-]/ &&
  3096.     $header !~ /^(?:X-UIDL|X-Face|To|Cc|From|Subject|References|In-Reply-To|(?:X-|Resent-|X-Original-)?Message-Id):/i)
  3097.     {
  3098.       return 1;
  3099.     }
  3100.   }
  3101.   return 0;
  3102. }
  3103.  
  3104. ###########################################################################
  3105.  
  3106. sub check_ratware_name_id {
  3107.   my ($self) = @_;
  3108.  
  3109.   my $mid = $self->get('MESSAGEID');
  3110.   my $from = $self->get('From');
  3111.   if ($mid =~ m/<[A-Z]{28}\.([^>]+?)>/) {
  3112.      if ($from =~ m/\"[^\"]+\"\s*<\Q$1\E>/) {
  3113.        return 1;
  3114.      }
  3115.   }
  3116.   return 0;
  3117. }
  3118.  
  3119. sub check_https_ip_mismatch {
  3120.   my ($self) = @_;
  3121.  
  3122.   while (my($k,$v) = each %{$self->{html}->{uri_detail}}) {
  3123.     next if ($k !~ m%^https?:/*(?:[^\@/]+\@)?\d+\.\d+\.\d+\.\d+%i);
  3124.     foreach (@{$v->{anchor_text}}) {
  3125.       next if (m%^https:/*(?:[^\@/]+\@)?\d+\.\d+\.\d+\.\d+%i);
  3126.       if (m%https:%i) {
  3127.         keys %{$self->{html}->{uri_detail}}; # resets iterator, bug 4829
  3128.         return 1;
  3129.       }
  3130.     }
  3131.   }
  3132.  
  3133.   return 0;
  3134. }
  3135.  
  3136. sub check_iframe_src {
  3137.   my ($self) = @_;
  3138.  
  3139.   foreach my $v ( values %{$self->{html}->{uri_detail}} ) {
  3140.     return 1 if $v->{types}->{iframe};
  3141.   }
  3142.  
  3143.   return 0;
  3144. }
  3145.  
  3146. sub check_ratware_envelope_from {
  3147.   my ($self) = @_;
  3148.  
  3149.   my $to = $self->get('To:addr');
  3150.   my $from = $self->get('EnvelopeFrom');
  3151.  
  3152.   return 0 unless ($to && $from);
  3153.   return 0 if ($from =~ /^SRS\d=/);
  3154.  
  3155.   if ($to =~ /^([^@]+)@(.+)$/) {
  3156.     my($user,$dom) = ($1,$2);
  3157.     $dom = Mail::SpamAssassin::Util::RegistrarBoundaries::trim_domain($dom);
  3158.     return unless
  3159.         (Mail::SpamAssassin::Util::RegistrarBoundaries::is_domain_valid($dom));
  3160.  
  3161.     return 1 if ($from =~ /\b\Q$dom\E.\Q$user\E@/i);
  3162.   }
  3163.  
  3164.   return 0;
  3165. }
  3166.  
  3167. # came up on the users@ list, look for multipart/alternative parts which
  3168. # include non-text parts -- skip certain types which occur normally in ham
  3169. sub check_ma_non_text {
  3170.   my $self = shift;
  3171.  
  3172.   foreach my $map ($self->{msg}->find_parts(qr@^multipart/alternative$@i)) {
  3173.     foreach my $p ($map->find_parts(qr/./, 1, 0)) {
  3174.       next if (lc $p->{'type'} eq 'multipart/related');
  3175.       next if (lc $p->{'type'} eq 'application/rtf');
  3176.       next if ($p->{'type'} =~ m@^text/@i);
  3177.       return 1;
  3178.     }
  3179.   }
  3180.   
  3181.   return 0;
  3182. }
  3183.  
  3184. 1;
  3185.