# MTX plugin for SpamAssassin # (c) Darxus@ChaosReigns.com, released under the GPL. # http://www.chaosreigns.com/mtx/ # # 2010-02-10 Initial release. # 2010-02-12 Implemented blacklisting, switched to SA's DnsResolver # 2010-02-12-01 Fixed failure to handle IP CNAME caused in previous. # Reduce chances of exploiting flaws by more defaulting to # "fail". # 2010-02-12-01 Switched from last external to last untrusted relay. # 2010-02-13 Rename of above. # 2010-02-13-01 Don't "fail" on "last untrusted relay unavailable". # 2010-02-14 Rename of above. # 2010-02-14-01 Implemented policy record without delegation. # 2010-02-14-02 Implemented policy record delegation. # 2010-02-14-03 Fixed whining about "implicit split to @_". # 2010-02-15 Rename of above. # 2010-02-15-01 Don't check Policy of None has already been determined. # 2010-02-15-02 Removed unnecessary variable $arraydepth. # 2010-02-15-03 Minor tidying. # 2010-02-15-04 Removed unnecessary variable $hostname. # 2010-02-15-05 Further minor tidying. # 2010-02-16 Rename of above. # 2010-02-15-01 Switched back to Net::DNS::Resolver for SpamAssassin v3.3.0 compatability. # All releases pass harness testing on SA v3.2.5 + perl # v5.8.8 and v3.3.0 + perl v5.10.0 starting here. # 2010-10-19 Throw a freaking error if the DNS lookup fails. Thanks to # Patrick Domack for reporting. # 2010-10-24 If DNS lookup on sending IP returns something but it # contains nothing, also set mtx_none so check_polciy # doesn't get run. Thanks to Patrick Domack for reporting. # 2011-05-29 IPv6 support by Andreas Schulze. # # TODO # * Switch to Mail::SpamAssassin::DnsResolver::bgsend ? =head1 NAME MTX - MTX =head1 SYNOPSIS # http://www.chaosreigns.com/mtx/ loadplugin Mail::SpamAssassin::Plugin::MTX header MTX_PASS eval:check_mtx_pass() score MTX_PASS -5 describe MTX_PASS MTX: Passed: http://www.chaosreigns.com/mtx/ header MTX_FAIL eval:check_mtx_fail() score MTX_FAIL 1 describe MTX_FAIL MTX: Failed: http://www.chaosreigns.com/mtx/ header MTX_BLACKLIST eval:check_mtx_blacklist() # Do not define a score, it's defined with mtx_blacklist. describe MTX_BLACKLIST MTX: On your blacklist. # Blacklist by the host name (PTR) of the sending IP (last untrusted relay). # Second argument is the score to assign, use whatever you want. mtx_blacklist *.example.com 5 # Known to send spam *and* nonspam, nullify PASS score. mtx_blacklist *.example2.com 100 # Only sends spam, big penalty. =head1 DESCRIPTION Write the above lines in the synopsis to C. =cut use strict; use warnings; package Mail::SpamAssassin::Plugin::MTX; use Mail::SpamAssassin::Plugin; use Mail::SpamAssassin::Logger; # dbg() # REVIEW: does including Net::IP introduce performance # or massiv memory usage changes? # REVIEW: may this trigger problems outside this plugin? use Net::IP; use vars qw(@ISA); @ISA = qw(Mail::SpamAssassin::Plugin); #my $res = Mail::SpamAssassin::DnsResolver->new; my $res = Net::DNS::Resolver->new; sub new { my $class = shift; my $mailsaobject = shift; $class = ref($class) || $class; my $self = $class->SUPER::new($mailsaobject); bless ($self, $class); $self->register_eval_rule ("check_mtx_pass"); $self->register_eval_rule ("check_mtx_fail"); $self->register_eval_rule ("check_mtx_none"); $self->register_eval_rule ("check_mtx_neutral"); $self->register_eval_rule ("check_mtx_softfail"); $self->register_eval_rule ("check_mtx_hardfail"); $self->register_eval_rule ("check_mtx_blacklist"); $self->set_config($mailsaobject->{conf}); return $self; } sub check_mtx_pass { my ($self, $scanner) = @_; &check_mtx unless $scanner->{mtx_checked}; return $scanner->{mtx_pass}; } sub check_mtx_fail { my ($self, $scanner) = @_; &check_mtx unless $scanner->{mtx_checked}; return $scanner->{mtx_fail}; } sub check_mtx_none { my ($self, $scanner) = @_; &check_mtx unless $scanner->{mtx_checked}; return 0 if ( $scanner->{mtx_hardfail} ); &check_policy if ( $scanner->{mtx_fail} and ! $scanner->{policy_checked} and ! $scanner->{mtx_none} ); return $scanner->{mtx_none}; } sub check_mtx_neutral { my ($self, $scanner) = @_; &check_mtx unless $scanner->{mtx_checked}; return 0 if ( $scanner->{mtx_hardfail} ); &check_policy if ( $scanner->{mtx_fail} and ! $scanner->{policy_checked} and ! $scanner->{mtx_none} ); return $scanner->{mtx_neutral}; } sub check_mtx_softfail { my ($self, $scanner) = @_; &check_mtx unless $scanner->{mtx_checked}; return 0 if ( $scanner->{mtx_hardfail} ); &check_policy if ( $scanner->{mtx_fail} and ! $scanner->{policy_checked} and ! $scanner->{mtx_none} ); return $scanner->{mtx_softfail}; } sub check_mtx_hardfail { my ($self, $scanner) = @_; &check_mtx unless $scanner->{mtx_checked}; return 1 if ( $scanner->{mtx_hardfail} ); &check_policy if ( $scanner->{mtx_fail} and ! $scanner->{policy_checked} and ! $scanner->{mtx_none} ); return $scanner->{mtx_hardfail}; } sub check_policy { my ($self,$scanner) = @_; $scanner->{policy_checked} = 1; my $participant = 0; dbg("mtx: Checking MTX Policy."); use Mail::SpamAssassin::Util::RegistrarBoundaries; my $domain = Mail::SpamAssassin::Util::RegistrarBoundaries::trim_domain($scanner->{hostname}); my @hostname = split('\.', $scanner->{hostname}); my $policy_mindepth = scalar( @{[split('\.', $domain)]} ); my $policy_maxdepth = scalar( @hostname ); $policy_maxdepth = 20 if ($policy_maxdepth > 20); dbg ("mtx: Policy mindepth: $policy_mindepth, maxdepth: $policy_maxdepth" ); for my $policy_depth ($policy_mindepth .. $policy_maxdepth) { my $delegate = 0; my $policyfound = 0; $domain = join('.',reverse((reverse(@hostname))[0 .. $policy_depth -1])); my $policy = "policy.mtx.$domain"; dbg("mtx: MTX Policy record name: $policy, depth: $policy_depth"); my $packet = $res->send($policy, 'A'); unless (defined $packet) { dbg('mtx: DNS "A" record lookup failed. You appear to have a DNS problem: ', $res->errorstring); return; } my @answer = $packet->answer; unless (@answer) { dbg("mtx: Failed to get policy record $policy."); $scanner->{mtx_none}=1; return; } for my $rr (@answer) { if (${$rr}{type} eq 'A') { my $address = ${$rr}{address}; unless (defined $address) { dbg("mtx: A record exists but has no value. I don't think this is possible."); $scanner->{mtx_none}=1; return; } dbg("mtx: MTX Policy record value: $address."); if ($address =~ m#^127\.\d{1,3}\.(0|1)\.(0|1|2)$#) { ## $delegate = $1; $participant = $2; $policyfound = 1; if ($delegate == 1) { dbg("mtx: Delegated."); } else { dbg("mtx: Not delegated."); } if ($participant == 0) { dbg("mtx: Found Neutral."); $scanner->{mtx_neutral}=1; $scanner->{mtx_softfail}=0; $scanner->{mtx_hardfail}=0; } elsif ($participant == 1) { dbg("mtx: Found SoftFail."); $scanner->{mtx_neutral}=0; $scanner->{mtx_softfail}=1; $scanner->{mtx_hardfail}=0; } elsif ($participant == 2) { dbg("mtx: Found HardFail."); $scanner->{mtx_neutral}=0; $scanner->{mtx_softfail}=0; $scanner->{mtx_hardfail}=1; } } else { dbg("mtx: Unknown policy record found, wildcard DNS record, or new version of MTX? Ignoring."); } last; # Protocol says only check first. } } if ($policyfound != 1) { dbg("mtx: No policy found at this depth."); unless ( $scanner->{mtx_neutral} or $scanner->{mtx_softfail} or $scanner->{mtx_hardfail} ) { $scanner->{mtx_none}=1; } return; } last unless ($delegate == 1); } } sub check_mtx { my ($self,$scanner) = @_; # Sane defaults. Reduce chance of exploitable flaws. $scanner->{mtx_fail}=1; $scanner->{mtx_pass}=0; dbg("mtx: Doing the necessary DNS lookups."); $scanner->{mtx_checked}=1; $scanner->{lasthop} = $scanner->{relays_untrusted}->[0]; if (!defined $scanner->{lasthop}) { dbg("mtx: Last untrusted relay not available, fix your SA config, skipping MTX."); # The *only* failure that doesn't result in a "fail", because it's # due to SA misconfiguration. Or all hops being trusted, or something. $scanner->{mtx_fail}=0; return; } my $ip = $scanner->{lasthop}->{ip}; dbg("mtx: Testing IP: $ip (last untrusted relay)."); my $mtx = ''; { my $packet = $res->send($ip, 'PTR'); unless (defined $packet) { dbg('mtx: DNS "PTR" record lookup failed. You appear to have a DNS problem: ', $res->errorstring); return; } my @answer = $packet->answer; unless (@answer) { dbg("mtx: Failed to get PTR record for $ip."); $scanner->{mtx_fail}=1; $scanner->{mtx_none}=1; return; } # Can't just use the first record because it could be a CNAME with # a PTR in there somewhere. for my $rr (@answer) { if (${$rr}{type} eq 'PTR') { $scanner->{hostname} = ${$rr}{ptrdname}; dbg("mtx: Host name ('PTR' record) is ". $scanner->{hostname} ."."); my $netip = new Net::IP ($ip); my $reverseip = $netip->reverse_ip(); $reverseip =~ s/\.(in-addr|ip6)\.arpa\.//i; unless (defined $reverseip and $reverseip =~ /\./) { info("mtx: failed to reverse $ip"); # REVIEW: maybe $scanner->{mtx_foo} should be set ????? return; } $mtx = $reverseip . '.mtx.' . $scanner->{hostname}; $scanner->{mtx_record}=$mtx; dbg("mtx: Relevant MTX record is: $mtx"); last; # Protocol says use the first one. } } if ($mtx eq '') { dbg("mtx: Looking up DNS PTR record for sender returned a vailue which did not contain the answer."); # Looking up the DNS record for the delivering IP returned an # answer, but it contained nothing. That's pretty freaky. # Need to call it a "fail" anyway so spammers don't explot it. $scanner->{mtx_fail}=1; $scanner->{mtx_none}=1; return; } dbg("mtx: Checking blacklist."); foreach my $black_addr (keys %{$scanner->{conf}->{mtx_blacklist}}) { my $re = qr/$scanner->{conf}->{mtx_blacklist}->{$black_addr}{re}/i; if ($mtx =~ $re) { # How can I do this without an array? my $bl_score = (@{$scanner->{conf}->{mtx_blacklist}->{$black_addr}{domain}})[0]; dbg("mtx: Blacklisted with score $bl_score and regex $re"); $scanner->{blacklist_score}=$bl_score; last; # Use first matching blacklist entry. } } } { my $packet = $res->send($mtx, 'A'); unless (defined $packet) { dbg('mtx: DNS "A" record lookup failed. You appear to have a DNS problem: ', $res->errorstring); return; } my @answer = $packet->answer; unless (@answer) { dbg("mtx: Failed to get A record for $mtx."); $scanner->{mtx_fail}=1; return; } for my $rr (@answer) { if (${$rr}{type} eq 'A') { my $address = ${$rr}{address}; unless (defined $address) { dbg("mtx: A record exists but has no value. I don't think this is possible."); # Make sure it doesn't get exploited, just in case. $scanner->{mtx_fail}=1; return; } dbg("mtx: MTX record value: $address."); if ($address =~ m#^127\.\d{1,3}\.\d{1,3}\.(0|1)$#) { ## my $mtxvalue = $1; if ($mtxvalue == 1) { dbg("mtx: MTX record value indicates legit server. That's the only pass."); $scanner->{mtx_pass}=1; $scanner->{mtx_fail}=0; return; } elsif ($mtxvalue == 0) { dbg("mtx: MTX record value indicates non-legit server. That's a fail."); $scanner->{mtx_fail}=1; $scanner->{mtx_hardfail}=1; return; } else { dbg("mtx: Somebody introduced a bug."); die "mtx: Somebody introduced a bug."; } } else { dbg("mtx: Unknown MTX record value found. Wildcard DNS record or new version of MTX? Ignoring."); } last; # Protocol says only check first. } } dbg("mtx: No known MTX record value found, fail."); $scanner->{mtx_fail}=1; return; } } sub set_config { my ($self, $conf) = @_; my @cmds; push (@cmds, { setting => 'mtx_blacklist', code => sub { my ($self, $key, $value, $line) = @_; local ($1,$2); unless (defined $value and $value !~ /^$/) { return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE; } # It is important to not accept negative scores on the blacklist, # because these hostnames can effortlessly beforged by the IP owner. unless (defined $value and $value =~ /^(\S+)\s+([\d\.]+)$/) { return $Mail::SpamAssassin::Conf::INVALID_VALUE; } my $address = $1; my $score = $2; $self->{parser}->add_to_addrlist_rcvd('mtx_blacklist', $address, $score); } }); return($conf->{parser}->register_commands(\@cmds)); } sub check_mtx_blacklist { my ($self, $scanner) = @_; &check_mtx unless $scanner->{mtx_checked}; my @cmds; my $score = $scanner->{blacklist_score}; if($score) { my $description = $scanner->{conf}->{descriptions}->{MTX_BLACKLIST}; $description .= " Score $score."; $scanner->{conf}->{descriptions}->{MTX_BLACKLIST} = $description; # Set the score. $scanner->got_hit("MTX_BLACKLIST", "", score => $score); for my $set (0..3) { $scanner->{conf}->{scoreset}->[$set]->{"MTX_BLACKLIST"} = sprintf("%0.3f", $score); } } return 0; } 1;