From 548415ea244fd2d8f5f6a151f9d3b2600b0a6dba Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Wed, 13 Mar 2013 02:26:25 -0400 Subject: [PATCH 01/27] headers: added section # to RFC citation --- plugins/headers | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/headers b/plugins/headers index ae7accb..959be55 100644 --- a/plugins/headers +++ b/plugins/headers @@ -126,7 +126,7 @@ sub hook_data_post { }; my $header = $transaction->header or do { - return $self->get_reject( "missing headers", "missing headers" ); + return $self->get_reject( "Headers are missing", "missing headers" ); }; return (DECLINED, "immune") if $self->is_immune(); @@ -140,7 +140,7 @@ sub hook_data_post { next if ! $header->get($h); # doesn't exist my @qty = $header->get($h); next if @qty == 1; # only 1 header - return $self->get_reject("Only one $h header allowed. See RFC 5322", "too many $h headers"); + return $self->get_reject("Only one $h header allowed. See RFC 5322, Section 3.6", "too many $h headers"); }; my $err_msg = $self->invalid_date_range(); From 57a2f68564f598e4044c42fadcf6553decc27a21 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Wed, 13 Mar 2013 03:19:48 -0400 Subject: [PATCH 02/27] karma: general improvements skip earlytalker checks for positive senders limit negative karma senders to 1 concurrent connection (hosts_allow) added karma::hook_pre_connection, to make hosts_allow change possible added karma score to log entries --- plugins/earlytalker | 3 ++ plugins/hosts_allow | 11 +++++++ plugins/karma | 70 +++++++++++++++++++++++++++-------------- plugins/virus/clamdscan | 5 +-- 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/plugins/earlytalker b/plugins/earlytalker index bcbad95..cb31010 100644 --- a/plugins/earlytalker +++ b/plugins/earlytalker @@ -163,6 +163,9 @@ sub connect_handler { return DECLINED unless $self->{_args}{'check-at'}{CONNECT}; return DECLINED if $self->is_immune(); + my $karma = $self->connection->notes('karma_history'); + return DECLINED if (defined $karma && $karma > 5); + $in->add(\*STDIN) or return DECLINED; if (! $in->can_read($self->{_args}{'wait'})) { return $self->log_and_pass(); diff --git a/plugins/hosts_allow b/plugins/hosts_allow index 6661ec1..d226578 100644 --- a/plugins/hosts_allow +++ b/plugins/hosts_allow @@ -68,6 +68,7 @@ sub hook_pre_connection { my $remote = $args{remote_ip}; my $max = $args{max_conn_ip}; + my $karma = $self->connection->notes('karma_history'); if ( $max ) { my $num_conn = 1; # seed with current value @@ -75,6 +76,7 @@ sub hook_pre_connection { foreach my $rip (@{$args{child_addrs}}) { ++$num_conn if (defined $rip && $rip eq $raddr); } + $max = $self->karma_bump( $karma, $max ) if defined $karma; if ($num_conn > $max ) { my $err_mess = "too many connections from $remote"; $self->log(LOGINFO, "fail: $err_mess ($num_conn > $max)"); @@ -113,3 +115,12 @@ sub in_hosts_allow { return; }; + +sub karma_bump { + my ($self, $karma, $max) = @_; + if ( $karma <= 0 ) { + $self->log(LOGINFO, "limiting max connects to 1 for negative karma ($karma)"); + return 1; + }; + return $max; +}; diff --git a/plugins/karma b/plugins/karma index b5a3a33..723d17c 100644 --- a/plugins/karma +++ b/plugins/karma @@ -6,7 +6,7 @@ karma - reward nice and penalize naughty mail senders =head1 SYNOPSIS -Karma tracks sender history, providing the ability to deliver differing levels +Karma tracks sender history, allowing us to provide differing levels of service to naughty, nice, and unknown senders. =head1 DESCRIPTION @@ -14,7 +14,7 @@ of service to naughty, nice, and unknown senders. Karma records the number of nice, naughty, and total connections from mail senders. After sending a naughty message, if a sender has more naughty than nice connections, they are penalized for I. Connections -from senders in the penalty box are tersely disconnected. +from senders in the penalty box are rejected per the settings in I. Karma provides other plugins with a karma value they can use to be more lenient, strict, or skip processing entirely. @@ -24,10 +24,9 @@ custom connection policies such as these two examples: =over 4 -Hi there, well behaved sender. Please help yourself to TLS, AUTH, greater -concurrency, multiple recipients, no delays, and other privileges. +Hi there, well behaved sender. Please help yourself to greater concurrency, multiple recipients, no delays, and other privileges. -Hi there, naughty sender. Enjoy this poke in the eye with a sharp stick. Bye. +Hi there, naughty sender. You get a max concurrency of 1, and SMTP delays. =back @@ -114,13 +113,7 @@ run before B for that to work. No attempt is made by this plugin to determine what karma is. It is up to other plugins to make that determination and communicate it to this plugin by incrementing or decrementing the transaction note B. Raise it for good -karma and lower it for bad karma. This is best done like so: - - # only if karma plugin loaded - if ( defined $connection->notes('karma') ) { - $connection->notes('karma', $connection->notes('karma') - 1); # bad - $connection->notes('karma', $connection->notes('karma') + 1); # good - }; +karma and lower it for bad karma. See B. After the connection ends, B will record the result. Mail servers whose naughty connections exceed nice ones are sent to the penalty box. Servers in @@ -133,7 +126,7 @@ an example connection from an IP in the penalty box: 73122 (connect) earlytalker: pass: 64.185.226.65 said nothing spontaneous 73122 (connect) relay: skip: no match 73122 (connect) karma: fail - 73122 550 You were naughty. You are penalized for 0.99 more days. + 73122 550 You were naughty. You are cannot connect for 0.99 more days. 73122 click, disconnecting 73122 (post-connection) connection_time: 1.048 s. @@ -211,12 +204,11 @@ karma_tool script. =head1 BUGS & LIMITATIONS -This plugin is reactionary. Like the FBI, it doesn't punish until -after a crime has been committed. It an "abuse me once, shame on you, -abuse me twice, shame on me" policy. +This plugin is reactionary. Like the FBI, it doesn't do anything until +after a crime has been committed. There is little to be gained by listing servers that are already on DNS -blacklists, send to non-existent users, earlytalkers, etc. Those already have +blacklists, send to invalid users, earlytalkers, etc. Those already have very lightweight tests. =head1 AUTHOR @@ -255,6 +247,32 @@ sub register { $self->register_hook('disconnect', 'disconnect_handler'); } +sub hook_pre_connection { + my ($self,$transaction,%args) = @_; + + $self->connection->notes('karma_history', 0); + + my $remote_ip = $args{remote_ip}; + #my $max_conn = $args{max_conn_ip}; + + my $db = $self->get_db_location(); + my $lock = $self->get_db_lock( $db ) or return DECLINED; + my $tied = $self->get_db_tie( $db, $lock ) or return DECLINED; + my $key = $self->get_db_key( $remote_ip ) or do { + $self->log( LOGINFO, "skip, unable to get DB key" ); + return DECLINED; + }; + + if ( ! $tied->{$key} ) { + $self->log(LOGINFO, "pass, no record"); + return $self->cleanup_and_return($tied, $lock ); + }; + + my ($penalty_start_ts, $naughty, $nice, $connects) = $self->parse_value( $tied->{$key} ); + $self->calc_karma($naughty, $nice); + return $self->cleanup_and_return($tied, $lock ); +}; + sub connect_handler { my $self = shift; @@ -294,7 +312,7 @@ sub connect_handler { $self->cleanup_and_return($tied, $lock ); my $left = sprintf "%.2f", $self->{_args}{penalty_days} - $days_old; - my $mess = "You were naughty. You are penalized for $left more days."; + my $mess = "You were naughty. You cannot connect for $left more days."; return $self->get_reject( $mess, $karma ); } @@ -313,11 +331,11 @@ sub disconnect_handler { my $key = $self->get_db_key(); my ($penalty_start_ts, $naughty, $nice, $connects) = $self->parse_value( $tied->{$key} ); + my $history = ($nice || 0) - $naughty; if ( $karma < 0 ) { - $naughty++; + $history--; my $negative_limit = 0 - $self->{_args}{negative}; - my $history = ($nice || 0) - $naughty; if ( $history <= $negative_limit ) { if ( $nice == 0 && $history < -5 ) { $self->log(LOGINFO, "penalty box bonus!"); @@ -326,15 +344,15 @@ sub disconnect_handler { else { $penalty_start_ts = sprintf "%s", time; }; - $self->log(LOGINFO, "negative, sent to penalty box ($history)"); + $self->log(LOGINFO, "negative, sent to penalty box (k: $karma, h: $history)"); } else { - $self->log(LOGINFO, "negative"); + $self->log(LOGINFO, "negative (k: $karma, h: $history)"); }; } elsif ($karma > 1) { $nice++; - $self->log(LOGINFO, "positive"); + $self->log(LOGINFO, "positive (k: $karma, h: $history)"); } $tied->{$key} = join(':', $penalty_start_ts, $naughty, $nice, ++$connects); @@ -375,7 +393,11 @@ sub cleanup_and_return { sub get_db_key { my $self = shift; - my $nip = Net::IP->new( $self->qp->connection->remote_ip ) or return; + my $ip = shift || $self->qp->connection->remote_ip; + my $nip = Net::IP->new( $ip ) or do { + $self->log(LOGERROR, "skip, unable to determine remote IP"); + return; + }; return $nip->intip; # convert IP to an int }; diff --git a/plugins/virus/clamdscan b/plugins/virus/clamdscan index ab35ab0..4148bd8 100644 --- a/plugins/virus/clamdscan +++ b/plugins/virus/clamdscan @@ -168,10 +168,7 @@ sub data_post_handler { $self->log( LOGNOTICE, "fail, found virus $found" ); $self->connection->notes('naughty', 1); # see plugins/naughty - - if ( defined $self->connection->notes('karma') ) { - $self->connection->notes('karma', ($self->connection->notes('karma') - 1)); - }; + $self->adjust_karma( -1 ); if ( $self->{_args}{deny_viruses} ) { return ( DENY, "Virus found: $found" ); From f198157e92709961122f8689b64eb13e036dcfc0 Mon Sep 17 00:00:00 2001 From: Markus Ullmann Date: Wed, 20 Mar 2013 01:16:09 +0100 Subject: [PATCH 03/27] Sanitize spamd_sock path for perl taint mode --- plugins/spamassassin | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/spamassassin b/plugins/spamassassin index 082f44d..be5c2ef 100644 --- a/plugins/spamassassin +++ b/plugins/spamassassin @@ -314,6 +314,10 @@ sub connect_to_spamd_socket { return; }; + # Sanitize for use with taint mode + $socket =~ /^([\w\/.-]+)$/; + $socket = $1; + socket(my $SPAMD, PF_UNIX, SOCK_STREAM, 0) or do { $self->log(LOGERROR, "Could not open socket: $!"); return; From 5f9aed1162aef9570a8e29fa87e9cfc899d8f3a2 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 00:51:00 -0400 Subject: [PATCH 04/27] adjust_karma now increments properly --- lib/Qpsmtpd/Plugin.pm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Qpsmtpd/Plugin.pm b/lib/Qpsmtpd/Plugin.pm index 3bb4b73..6d8e1c1 100644 --- a/lib/Qpsmtpd/Plugin.pm +++ b/lib/Qpsmtpd/Plugin.pm @@ -287,7 +287,8 @@ sub adjust_karma { my $karma = $self->connection->notes('karma') || 0; $karma += $value; - $self->connection->notes('karma', $value); + $self->log(LOGDEBUG, "karma adjust: $value ($karma)"); + $self->connection->notes('karma', $karma); return $value; }; From 170fdc93f8f605eb6f85668f78e00486d58a70d5 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 00:52:06 -0400 Subject: [PATCH 05/27] log/watch: raise default # of log lines to parse --- log/watch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/log/watch b/log/watch index 0514a3d..427f58f 100755 --- a/log/watch +++ b/log/watch @@ -9,7 +9,7 @@ use File::Tail; my $dir = get_qp_dir() or die "unable to find QP home dir"; my $file = "$dir/log/main/current"; -my $fh = File::Tail->new(name=>$file, interval=>1, maxinterval=>1, debug =>1, tail =>100 ); +my $fh = File::Tail->new(name=>$file, interval=>1, maxinterval=>1, debug =>1, tail =>300 ); while ( defined (my $line = $fh->read) ) { my (undef, $line) = split /\s/, $line, 2; # strip off tai timestamps From 03641b32aea5e1e793d044a919de42e44b513f19 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 00:54:16 -0400 Subject: [PATCH 06/27] logs: suppress perl errors in summary output --- log/summarize | 1 + 1 file changed, 1 insertion(+) diff --git a/log/summarize b/log/summarize index acf3c94..2956221 100755 --- a/log/summarize +++ b/log/summarize @@ -182,6 +182,7 @@ sub parse_line { return ( 'info', $pid, undef, undef, $message ) if substr($message, 0, 24) eq 'Permissions on spool_dir'; return ( 'info', $pid, undef, undef, $message ) if substr($message, 0, 13) eq 'Listening on '; + return ( 'err', $pid, undef, undef, $message ) if $line =~ /at [\S]+ line \d/; # generic perl error print "UNKNOWN LINE: $line\n"; return ( 'unknown', $pid, undef, undef, $message ); }; From 7b804c70c93a5f1696df1ecc2cd5480c0c2f4ff8 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:01:13 -0400 Subject: [PATCH 07/27] karma_tool: optimized for speedy IP search, IPv6 fixed one IPv6 issue --- plugins/karma_tool | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/plugins/karma_tool b/plugins/karma_tool index bc841ee..627725c 100755 --- a/plugins/karma_tool +++ b/plugins/karma_tool @@ -26,6 +26,9 @@ elsif ( $command eq 'release' ) { elsif ( $command eq 'prune' ) { $self->prune_db( $ARGV[1] || 7 ); } +elsif ( $command eq 'search' && is_ip( $ARGV[1] ) ) { + $self->show_ip( $ARGV[1] ); +} elsif ( $command eq 'list' | $command eq 'search' ) { $self->main(); }; @@ -76,10 +79,7 @@ sub capture { sub release { my $self = shift; my $ip = shift or return; - is_ip( $ip ) or do { - warn "not an IP: $ip\n"; - return; - }; + is_ip( $ip ) or do { warn "not an IP: $ip\n"; return; }; my $db = $self->get_db_location(); my $lock = $self->get_db_lock( $db ) or return; @@ -92,6 +92,27 @@ sub release { return $self->cleanup_and_return( $tied, $lock ); }; +sub show_ip { + my $self = shift; + my $ip = shift or return; + my $db = $self->get_db_location(); + my $lock = $self->get_db_lock( $db ) or return; + my $tied = $self->get_db_tie( $db, $lock ) or return; + my $key = $self->get_db_key( $ip ); + + my ($penalty_start_ts, $naughty, $nice, $connects) = split /:/, $tied->{$key}; + $naughty ||= 0; + $nice ||= 0; + $connects ||= 0; + my $time_human = ''; + if ( $penalty_start_ts ) { + $time_human = strftime "%a %b %e %H:%M", localtime $penalty_start_ts; + }; + my $hostname = `dig +short -x $ip` || ''; chomp $hostname; + print " IP Address Penalty Naughty Nice Connects Hostname\n"; + printf(" %-18s %24s %3s %3s %3s %-30s\n", $ip, $time_human, $naughty, $nice, $connects, $hostname); +}; + sub main { my $self = shift; @@ -140,8 +161,8 @@ sub main { sub is_ip { my $ip = shift || $ARGV[0]; - return 1 if $ip =~ /^(\d{1,3}\.){3}\d{1,3}$/; - return; + new Net::IP( $ip ) or return; + return 1; }; sub cleanup_and_return { @@ -152,7 +173,7 @@ sub cleanup_and_return { sub get_db_key { my $self = shift; - my $nip = Net::IP->new( shift ); + my $nip = Net::IP->new( shift ) or return; return $nip->intip; # convert IP to an int }; From 79a5c3d7ae3365ac6e6bec0d948115968e990e19 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:03:28 -0400 Subject: [PATCH 08/27] geoip: added too_far option --- plugins/ident/geoip | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/ident/geoip b/plugins/ident/geoip index 2f6b635..9964457 100644 --- a/plugins/ident/geoip +++ b/plugins/ident/geoip @@ -58,6 +58,12 @@ IP of your mail server. Default: none. (no distance calculations) +=head2 too_far + +Assign negative karma to connections further than this many km. + +Default: none + =head2 db_dir The path to the GeoIP database directory. @@ -159,7 +165,12 @@ sub connect_handler { push @msg_parts, $c_code if $c_code; #push @msg_parts, $c_name if $c_name; push @msg_parts, $city if $city; - push @msg_parts, "\t$distance km" if $distance; + if ( $distance ) { + push @msg_parts, "\t$distance km"; + if ( $self->{_args}{too_far} && $distance > $self->{_args}{too_far} ) { + $self->adjust_karma( -1 ); + }; + }; $self->log(LOGINFO, join( ", ", @msg_parts) ); return DECLINED; From 3b71f066824b8b064c8625d06c609540a568c088 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:04:03 -0400 Subject: [PATCH 09/27] badrcptto: smite matches with -2 karma useful for (reject=>naughty) + spam filter training --- plugins/badrcptto | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/badrcptto b/plugins/badrcptto index 8787974..3d15776 100644 --- a/plugins/badrcptto +++ b/plugins/badrcptto @@ -64,6 +64,7 @@ sub hook_rcpt { my ($bad, $reason) = split /\s+/, $line, 2; next if ! $bad; if ( $self->is_match( $to, lc($bad), $host ) ) { + $self->adjust_karma( -2 ); if ( $reason ) { return (DENY, "mail to $bad not accepted here"); } From d427f43f54a02b851346e4c5c096f0f3cf6019ee Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:05:15 -0400 Subject: [PATCH 10/27] dnsbl: smite blacklisted IPs with -1 karma --- plugins/dnsbl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/dnsbl b/plugins/dnsbl index 7c869ee..4a055fc 100644 --- a/plugins/dnsbl +++ b/plugins/dnsbl @@ -191,6 +191,8 @@ sub hook_connect { next if ! $result; + $self->adjust_karma( -1 ); + if ( ! $dnsbl ) { ($dnsbl) = ($result =~ m/(?:\d+\.){4}(.*)/); }; if ( ! $dnsbl ) { $dnsbl = $result; }; From d37f875992112f673f09afc716cdac1f8a89473e Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:07:01 -0400 Subject: [PATCH 11/27] dspam: be more conservative with karma awards previous settings were reasonable for a well trained dspam. After starting with a fresh dspam, the settings were not optimal for the amount of naive that a default dspam is. --- plugins/dspam | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/dspam b/plugins/dspam index 6812451..9d8ec43 100644 --- a/plugins/dspam +++ b/plugins/dspam @@ -491,7 +491,7 @@ sub reject_agree { if ( $d->{class} eq 'Innocent' ) { if ( $sa->{is_spam} eq 'No' ) { if ( $d->{confidence} > .9 ) { - $self->adjust_karma( 2 ); + $self->adjust_karma( 1 ); }; $self->log(LOGINFO, "pass, agree, $status"); return DECLINED; @@ -632,14 +632,14 @@ sub autolearn_karma { my $karma = $self->connection->notes('karma'); return if ! defined $karma; - if ( $karma <= -1 && $response->{result} eq 'Innocent' ) { - $self->log(LOGINFO, "training bad karma FN as spam"); + if ( $karma < -1 && $response->{result} eq 'Innocent' ) { + $self->log(LOGINFO, "training bad karma ($karma) FN as spam"); $self->train_error_as_spam( $transaction ); return 1; }; - if ( $karma >= 1 && $response->{result} eq 'Spam' ) { - $self->log(LOGINFO, "training good karma FP as ham"); + if ( $karma > 1 && $response->{result} eq 'Spam' ) { + $self->log(LOGINFO, "training good karma ($karma) FP as ham"); $self->train_error_as_ham( $transaction ); return 1; }; From c4fc2ecea31569c0bb33dd2608c98f0677083293 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:24:02 -0400 Subject: [PATCH 12/27] spamassassin: assign karma for autolearn message also removed 'use lib', to be consistent with most other plugins and improved grammar --- plugins/spamassassin | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugins/spamassassin b/plugins/spamassassin index be5c2ef..1279681 100644 --- a/plugins/spamassassin +++ b/plugins/spamassassin @@ -134,7 +134,7 @@ Make the "subject munge string" configurable * added support for per-user SpamAssassin preferences * updated get_spam_results so that score=N.N works (as well as hits=N.N) * rewrote the X-Spam-* header additions so that SA generated headers are - not discarded. Admin can alter SA headers with add_header in their SA + preserved. Admins can alter SA headers with add_header in their SA config. Subverting their changes there is unexpected. Making them read code to figure out why is an unnecessary hurdle. * added assemble_message, so we can calc content size which spamd wants @@ -144,7 +144,6 @@ Make the "subject munge string" configurable use strict; use warnings; -use lib 'lib'; use Qpsmtpd::Constants; use Qpsmtpd::DSN; @@ -398,6 +397,8 @@ sub reject { my $status = "$ham_or_spam, $score"; my $learn = ''; if ( $sa_results->{autolearn} ) { + $self->adjust_karma( 1 ) if $ham_or_spam eq 'Ham'; + $self->adjust_karma( -1 ) if $ham_or_spam eq 'Spam'; $learn = "learn=". $sa_results->{autolearn}; }; @@ -417,8 +418,6 @@ sub reject { } } - $self->adjust_karma( -1 ); -# default of media_unsupported is DENY, so just change the message $self->log(LOGINFO, "fail, $status, > $reject, $learn"); return ($self->get_reject_type(), "spam score exceeded threshold"); } @@ -477,7 +476,7 @@ sub parse_spam_header { } $r{is_spam} = $is_spam; - # backwards compatibility for SA versions < 3 + # compatibility for SA versions < 3 if ( defined $r{hits} && ! defined $r{score} ) { $r{score} = delete $r{hits}; }; From 279a43f26ad5ba3ef878b7af55a70634566f414f Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:38:47 -0400 Subject: [PATCH 13/27] earlytalker: if we skip for +karma, log it and remove IP from log (not IPv6 optimal) --- plugins/earlytalker | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/earlytalker b/plugins/earlytalker index cb31010..33cbf19 100644 --- a/plugins/earlytalker +++ b/plugins/earlytalker @@ -163,8 +163,12 @@ sub connect_handler { return DECLINED unless $self->{_args}{'check-at'}{CONNECT}; return DECLINED if $self->is_immune(); + # senders with good karma skip the delay my $karma = $self->connection->notes('karma_history'); - return DECLINED if (defined $karma && $karma > 5); + if (defined $karma && $karma > 5) { + $self->log(LOGINFO, "skip, karma $karma"); + return DECLINED; + }; $in->add(\*STDIN) or return DECLINED; if (! $in->can_read($self->{_args}{'wait'})) { @@ -198,7 +202,7 @@ sub data_handler { sub log_and_pass { my $self = shift; my $ip = $self->qp->connection->remote_ip || 'remote host'; - $self->log(LOGINFO, "pass, $ip said nothing spontaneous"); + $self->log(LOGINFO, "pass, not spontaneous"); return DECLINED; } @@ -210,7 +214,7 @@ sub log_and_deny { $self->connection->notes('earlytalker', 1); $self->adjust_karma( -1 ); - my $log_mess = "$ip started talking before we said hello"; + my $log_mess = "remote started talking before we said hello"; my $smtp_msg = 'Connecting host started transmitting before SMTP greeting'; return $self->get_reject( $smtp_msg, $log_mess ); From e01843f6f939b10c8e3fa8211a3e31de882f77e2 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:42:10 -0400 Subject: [PATCH 14/27] headers: smite poorly behaved senders with -karma --- plugins/headers | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/headers b/plugins/headers index 959be55..deb5b70 100644 --- a/plugins/headers +++ b/plugins/headers @@ -133,6 +133,7 @@ sub hook_data_post { foreach my $h ( @required_headers ) { next if $header->get($h); + $self->adjust_karma( -1 ); return $self->get_reject( "We require a valid $h header", "no $h header"); }; @@ -140,11 +141,18 @@ sub hook_data_post { next if ! $header->get($h); # doesn't exist my @qty = $header->get($h); next if @qty == 1; # only 1 header - return $self->get_reject("Only one $h header allowed. See RFC 5322, Section 3.6", "too many $h headers"); + $self->adjust_karma( -1 ); + return $self->get_reject( + "Only one $h header allowed. See RFC 5322, Section 3.6", + "too many $h headers", + ); }; my $err_msg = $self->invalid_date_range(); - return $self->get_reject($err_msg, $err_msg) if $err_msg; + if ( $err_msg ) { + $self->adjust_karma( -1 ); + return $self->get_reject($err_msg, $err_msg); + }; $self->log( LOGINFO, 'pass' ); return (DECLINED); From 6a41d1ea0de58120aa3f1129e253006b0295d923 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:43:32 -0400 Subject: [PATCH 15/27] helo: smite senders that fail the selected tests and made log entries more terse --- plugins/helo | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/helo b/plugins/helo index ef06dcc..1299d78 100644 --- a/plugins/helo +++ b/plugins/helo @@ -256,7 +256,10 @@ sub helo_handler { foreach my $test ( @{ $self->{_helo_tests} } ) { my @err = $self->$test( $host ); - return $self->get_reject( @err ) if scalar @err; + if ( scalar @err ) { + $self->adjust_karma( -1 ); + return $self->get_reject( @err ); + }; }; $self->log(LOGINFO, "pass"); @@ -388,6 +391,8 @@ sub is_not_fqdn { sub no_forward_dns { my ( $self, $host ) = @_; + return if $self->is_address_literal( $host ); + my $res = $self->init_resolver(); $host = "$host." if $host !~ /\.$/; # fully qualify name @@ -395,7 +400,7 @@ sub no_forward_dns { if (! $query) { if ( $res->errorstring eq 'NXDOMAIN' ) { - return ("HELO hostname does not exist", "HELO hostname does not exist"); + return ("HELO hostname does not exist", "no such host"); } $self->log(LOGERROR, "skip, query failed (", $res->errorstring, ")" ); return; @@ -410,7 +415,7 @@ sub no_forward_dns { $self->log(LOGDEBUG, "pass, forward DNS") if $hits; return; }; - return ("helo hostname did not resolve", "fail, HELO forward DNS"); + return ("HELO hostname did not resolve", "no forward DNS"); }; sub no_reverse_dns { @@ -447,7 +452,7 @@ sub no_matching_dns { if ( $self->connection->notes('helo_forward_match') && $self->connection->notes('helo_reverse_match') ) { $self->log( LOGDEBUG, "foward and reverse match" ); - $self->adjust_karma( 1 ); # whoppee, a match! + $self->adjust_karma( 1 ); # a perfect match return; }; @@ -461,7 +466,7 @@ sub no_matching_dns { }; $self->log( LOGINFO, "fail, no forward or reverse DNS match" ); - return ("That HELO hostname fails forward and reverse DNS checks", "no matching DNS"); + return ("That HELO hostname fails FCrDNS", "no matching DNS"); }; sub check_ip_match { From 1dfa55c230508219d34af902d2a124f106de8ce5 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:47:34 -0400 Subject: [PATCH 16/27] hosts_allow: allow +karma senders +3 concurrents this is really useful if you set max-per-ip to <= 3. --- plugins/hosts_allow | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/hosts_allow b/plugins/hosts_allow index d226578..2e3be5f 100644 --- a/plugins/hosts_allow +++ b/plugins/hosts_allow @@ -80,14 +80,14 @@ sub hook_pre_connection { if ($num_conn > $max ) { my $err_mess = "too many connections from $remote"; $self->log(LOGINFO, "fail: $err_mess ($num_conn > $max)"); - return (DENYSOFT, "Sorry, $err_mess, try again later"); + return (DENYSOFT, "$err_mess, try again later"); } } my @r = $self->in_hosts_allow( $remote ); return @r if scalar @r; - $self->log( LOGDEBUG, "pass" ); + $self->log(LOGDEBUG, "pass" ); return (DECLINED); } @@ -118,8 +118,13 @@ sub in_hosts_allow { sub karma_bump { my ($self, $karma, $max) = @_; + + if ( $karma > 5 ) { + $self->log(LOGDEBUG, "increasing max connects for positive karma"); + return $max + 3; + }; if ( $karma <= 0 ) { - $self->log(LOGINFO, "limiting max connects to 1 for negative karma ($karma)"); + $self->log(LOGINFO, "limiting max connects to 1 (karma $karma)"); return 1; }; return $max; From aaa2241cb84ac447b212336a69346e584b3be042 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 01:56:49 -0400 Subject: [PATCH 17/27] p0f: added smite_os, assign -karma by OS --- plugins/ident/p0f | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/ident/p0f b/plugins/ident/p0f index 0493e77..d3a1c2b 100644 --- a/plugins/ident/p0f +++ b/plugins/ident/p0f @@ -99,6 +99,14 @@ Example entry specifying p0f version 2 ident/p0f /tmp/.p0f_socket version 2 +=head2 smite_os + +Assign -1 karma to senders whose OS match the regex pattern supplied. I only recommend using with this p0f 3, as it's OS database is far more reliable than p0f v2. + +Example entry: + + ident/p0f /tmp/.p0f_socket smite_os windows + =head1 Environment requirements p0f v3 requires only the remote IP. @@ -119,7 +127,7 @@ Version 2 code heavily based upon the p0fq.pl included with the p0f distribution 2010 - Matt Simerson - added local_ip option -2012 - Matt Simerson - refactored, v3 support +2012 - Matt Simerson - refactored, added v3 support =cut @@ -284,7 +292,7 @@ sub test_v2_response { return; } elsif ($type == 2) { - $self->log(LOGWARN, "skip, this connection is no longer in the cache"); + $self->log(LOGWARN, "skip, connection not in the cache"); return; } return 1; @@ -358,6 +366,10 @@ sub store_v3_results { $r{uptime} = $r{uptime_min} if $r{uptime_min}; }; + if ( $r{genre} && $self->{_args}{smite_os} ) { + my $sos = $self->{_args}{smite_os}; + $self->adjust_karma( -1 ) if $r{genre} =~ /$sos/i; + }; $self->connection->notes('p0f', \%r); $self->log(LOGINFO, "$r{os_name} $r{os_flavor}"); $self->log(LOGDEBUG, join(' ', @values )); From 309fdbe4b4519202740b7bcd72d6f52a7374f02f Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 02:06:57 -0400 Subject: [PATCH 18/27] relay: give +2 karma boost to relay IPs --- plugins/relay | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/relay b/plugins/relay index 7cba450..979ef94 100644 --- a/plugins/relay +++ b/plugins/relay @@ -241,6 +241,7 @@ sub hook_connect { # 95586 (connect) relay: pass, octet match in relayclients (127.0.0.) if ( $self->is_in_cidr_block() || $self->is_octet_match() ) { + $self->adjust_karma( 2 ); # big karma boost! $self->qp->connection->relay_client(1); return (DECLINED); }; From a639fc794a6b7692d1f9990c8a162e2a118fb1ce Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 02:12:06 -0400 Subject: [PATCH 19/27] whitelist: add +5 karma to whitelisted IPs --- plugins/whitelist | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/whitelist b/plugins/whitelist index 549dea1..76797ce 100644 --- a/plugins/whitelist +++ b/plugins/whitelist @@ -1,3 +1,4 @@ +#!perl -w =head1 NAME @@ -97,7 +98,6 @@ automatically allow relaying from that IP. use strict; use warnings; -use lib 'lib'; use Qpsmtpd::Constants; my $VERSION = 0.02; @@ -138,7 +138,8 @@ sub check_host { # From tcpserver if (exists $ENV{WHITELISTCLIENT}) { $self->qp->connection->notes('whitelistclient', 1); - $self->log(2, "pass, host $ip is a whitelisted client"); + $self->log(2, "pass, is whitelisted client"); + $self->adjust_karma( 5 ); return OK; } @@ -146,7 +147,8 @@ sub check_host { for my $h ($self->qp->config('whitelisthosts', $config_arg)) { if ($h eq $ip or $ip =~ /^\Q$h\E/) { $self->qp->connection->notes('whitelisthost', 1); - $self->log(2, "pass, host $ip is a whitelisted host"); + $self->log(2, "pass, is a whitelisted host"); + $self->adjust_karma( 5 ); return OK; } } From a5b3cc33aeccaf34f5ece24c561645a42685c132 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 02:15:24 -0400 Subject: [PATCH 20/27] karma: be a bit more conservative require at least -2 karma before smiting also, add +1 karma to senders with karma_history > 10 --- plugins/karma | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/plugins/karma b/plugins/karma index 723d17c..6dce939 100644 --- a/plugins/karma +++ b/plugins/karma @@ -98,7 +98,7 @@ Karma reduces the resources wasted by naughty mailers. When used with I, naughty senders are disconnected in about 0.1 seconds. The biggest gains to be had are by having heavy plugins (spamassassin, dspam, -virus filters) set the B transaction note (see KARMA) when they encounter +virus filters) set the B connection note (see KARMA) when they encounter naughty senders. Reasons to send servers to the penalty box could include sending a virus, early talking, or sending messages with a very high spam score. @@ -110,10 +110,9 @@ run before B for that to work. =head1 KARMA -No attempt is made by this plugin to determine what karma is. It is up to -other plugins to make that determination and communicate it to this plugin by -incrementing or decrementing the transaction note B. Raise it for good -karma and lower it for bad karma. See B. +No attempt is made by this plugin to determine karma. It is up to other +plugins to reward well behaved senders with positive karma and smite poorly +behaved senders with negative karma. See B After the connection ends, B will record the result. Mail servers whose naughty connections exceed nice ones are sent to the penalty box. Servers in @@ -141,11 +140,11 @@ the time if we are careful to also set positive karma. Karma maintains a history for each IP. When a senders history has decreased below -5 and they have never sent a good message, they get a karma bonus. The bonus tacks on an extra day of blocking for every naughty message they -sent us. +send. Example: an unknown sender delivers a spam. They get a one day penalty_box. After 5 days, 5 spams, 5 penalties, and 0 nice messages, they get a six day -penalty. The next offence gets a 7 day penalty, and so on. +penalty. The next offense gets a 7 day penalty, and so on. =head1 USING KARMA @@ -164,7 +163,7 @@ ident plugins. 88798 cleaning up after 89011 Unlike RBLs, B only penalizes IPs that have sent us spam, and only when -those senders haven't sent us any ham. As such, it's much safer to use. +those senders have sent us more spam than ham. =head1 USING KARMA IN OTHER PLUGINS @@ -196,8 +195,8 @@ seems to be a very big win. =head1 DATABASE -Connection summaries are stored in a database. The database key is the int -form of the remote IP. The value is a : delimited list containing a penalty +Connection summaries are stored in a database. The database key is the integer +value of the remote IP. The DB value is a : delimited list containing a penalty box start time (if the server is/was on timeout) and the count of naughty, nice, and total connections. The database can be listed and searched with the karma_tool script. @@ -264,7 +263,7 @@ sub hook_pre_connection { }; if ( ! $tied->{$key} ) { - $self->log(LOGINFO, "pass, no record"); + $self->log(LOGDEBUG, "pass, no record"); return $self->cleanup_and_return($tied, $lock ); }; @@ -332,28 +331,33 @@ sub disconnect_handler { my ($penalty_start_ts, $naughty, $nice, $connects) = $self->parse_value( $tied->{$key} ); my $history = ($nice || 0) - $naughty; + my $log_mess = ''; - if ( $karma < 0 ) { + if ( $karma < -1 ) { # they achieved at least 2 strikes $history--; my $negative_limit = 0 - $self->{_args}{negative}; if ( $history <= $negative_limit ) { if ( $nice == 0 && $history < -5 ) { - $self->log(LOGINFO, "penalty box bonus!"); + $log_mess = ", penalty box bonus!"; $penalty_start_ts = sprintf "%s", time + abs($history) * 86400; } else { $penalty_start_ts = sprintf "%s", time; }; - $self->log(LOGINFO, "negative, sent to penalty box (k: $karma, h: $history)"); + $log_mess = "negative, sent to penalty box" . $log_mess; } else { - $self->log(LOGINFO, "negative (k: $karma, h: $history)"); + $log_mess = "negative"; }; } elsif ($karma > 1) { $nice++; - $self->log(LOGINFO, "positive (k: $karma, h: $history)"); + $log_mess = "positive"; } + else { + $log_mess = "neutral"; + } + $self->log(LOGINFO, $log_mess . ", (msg: $karma, his: $history)" ); $tied->{$key} = join(':', $penalty_start_ts, $naughty, $nice, ++$connects); return $self->cleanup_and_return($tied, $lock ); @@ -379,6 +383,7 @@ sub calc_karma { my $karma = ( $nice || 0 ) - ( $naughty || 0 ); $self->connection->notes('karma_history', $karma ); + $self->adjust_karma( 1 ) if $karma > 10; return $karma; }; From 4e3b33870a02f49506ef78e4937160af3f7fc4a9 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 02:16:49 -0400 Subject: [PATCH 21/27] naughty: improve POD --- plugins/naughty | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/naughty b/plugins/naughty index f8ea233..491bb8a 100644 --- a/plugins/naughty +++ b/plugins/naughty @@ -30,8 +30,8 @@ For efficiency, other plugins should skip processing naughty connections. Plugins like SpamAssassin and DSPAM can benefit from using naughty connections to train their filters. -Since so many connections are from blacklisted IPs, naughty significantly -reduces the resources required to disposing of them. Over 80% of my +Since many connections are from blacklisted IPs, naughty significantly +reduces the resources required to dispose of them. Over 80% of my connections are disposed of after after a few DNS queries (B or one DB query (B) and 0.01s of compute time. @@ -56,7 +56,7 @@ deployment models. When a user authenticates, the naughty flag on their connection is cleared. This is to allow users to send email from IPs that fail connection tests such -as B. Keep in mind that if I is set, connections will +as B. Note that if I is set, connections will not get the chance to authenticate. To allow clients a chance to authenticate, I works well. @@ -86,7 +86,7 @@ Solutions are to make sure B is listed before rcpt_ok in config/plugins or set naughty to run in a phase after the one you wish to complete. In this case, use data instead of rcpt to disconnect after rcpt_ok. The latter is particularly useful if your rcpt plugins skip naughty testing. In that case, -any recipient is accepted for naughty connections, which prevents spammers +any recipient is accepted for naughty connections, which inhibits spammers from detecting address validity. =head2 reject_type [ temp | perm | disconnect ] From a5856d2e4a528ae8fa44314216ab7bf43d210037 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 02:17:45 -0400 Subject: [PATCH 22/27] qm_deliverable: added reject option, karma smite award senders -1 karma to senders to invalid addresses --- plugins/qmail_deliverable | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/plugins/qmail_deliverable b/plugins/qmail_deliverable index 91f6813..ec45024 100755 --- a/plugins/qmail_deliverable +++ b/plugins/qmail_deliverable @@ -45,6 +45,19 @@ option must be enabled in order for user-ext@example.org addresses to work. Default: 0 +=item reject + +karma reject [ 0 | 1 | connect | naughty ] + +I<0> will not reject any connections. + +I<1> will reject naughty senders. + +I is the most efficient setting. + +To reject at any other connection hook, use the I setting and the +B plugin. + =back =head1 CAVEATS @@ -155,6 +168,9 @@ sub register { if ( $args{vpopmail_ext} ) { $Qmail::Deliverable::VPOPMAIL_EXT = $args{vpopmail_ext}; }; + if ( $args{reject} ) { + $self->{_args}{reject} = $args{reject}; + }; } $self->register_hook("rcpt", "rcpt_handler"); } @@ -206,7 +222,8 @@ sub rcpt_handler { return DECLINED; }; - return (DENY, "Sorry, no mailbox by that name. qd (#5.1.1)" ); + $self->adjust_karma( -1 ); + return $self->get_reject( "Sorry, no mailbox by that name. qd (#5.1.1)" ); } sub _smtproute { From 12f1de22be030bf43b4d40b65c114fe48a4c3b4d Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Sat, 23 Mar 2013 02:22:20 -0400 Subject: [PATCH 23/27] fcrdns: new plugin for Forward Confirmed rDNS --- log/summarize | 1 + plugins/fcrdns | 280 +++++++++++++++++++++++++++++++++++++++++++ plugins/registry.txt | 1 + 3 files changed, 282 insertions(+) create mode 100644 plugins/fcrdns diff --git a/log/summarize b/log/summarize index 2956221..d658f55 100755 --- a/log/summarize +++ b/log/summarize @@ -33,6 +33,7 @@ my %formats = ( rhsbl => "%-3.3s", relay => "%-3.3s", karma => "%-3.3s", + fcrdns => "%-3.3s", earlytalker => "%-3.3s", check_earlytalker => "%-3.3s", helo => "%-3.3s", diff --git a/plugins/fcrdns b/plugins/fcrdns new file mode 100644 index 0000000..388f57b --- /dev/null +++ b/plugins/fcrdns @@ -0,0 +1,280 @@ +#!perl -w + +=head1 NAME + +Forward Confirmed RDNS - http://en.wikipedia.org/wiki/FCrDNS + +=head1 DESCRIPTION + +Determine if the SMTP sender has matching forward and reverse DNS. + +Sets the connection note fcrdns. + +=head1 WHY IT WORKS + +The reverse DNS of zombie PCs is out of the spam operators control. Their +only way to pass this test is to limit themselves to hosts with matching +forward and reverse DNS. At present, this presents a significant hurdle. + +=head1 VALIDATION TESTS + +=over 4 + +=item has_reverse_dns + +Determine if the senders IP address resolves to a hostname. + +=item has_forward_dns + +If the remote IP has a PTR hostname(s), see if that host has an A or AAAA. If +so, see if any of the host IPs (A or AAAA records) match the remote IP. + +Since the dawn of SMTP, having matching DNS has been a standard expected and +oft required of mail servers. While requiring matching DNS is prudent, +requiring an exact match will reject valid email. This often hinders the +use of FcRDNS. While testing this plugin, I noticed that mx0.slc.paypal.com +sends mail from an IP that reverses to mx1.slc.paypal.com. While that's +technically an error, so too would rejecting that connection. + +To avoid false positives, matches are extended to the first 3 octets of the +IP and the last two labels of the FQDN. The following are considered a match: + + 192.0.1.2, 192.0.1.3 + + foo.example.com, bar.example.com + +This allows FcRDNS to be used without rejecting mail from orgs with +pools of servers where the HELO name and IP don't exactly match. This list +includes Yahoo, Gmail, PayPal, cheaptickets.com, exchange.microsoft.com, etc. + +=back + +=head1 CONFIGURATION + +=head2 timeout [seconds] + +Default: 5 + +The number of seconds before DNS queries timeout. + +=head2 reject [ 0 | 1 | naughty ] + +Default: 1 + +0: do not reject + +1: reject + +naughty: naughty plugin handles rejection + +=head2 reject_type [ temp | perm | disconnect ] + +Default: disconnect + +What type of rejection should be sent? See docs/config.pod + +=head2 loglevel + +Adjust the quantity of logging for this plugin. See docs/logging.pod + + +=head1 RFC 1912, RFC 5451 + +From Wikipedia summary: + +1. First a reverse DNS lookup (PTR query) is performed on the IP address, which returns a list of zero or more PTR records. (has_reverse_dns) + +2. For each domain name returned in the PTR query results, a regular 'forward' DNS lookup (type A or AAAA query) is then performed on that domain name. (has_forward_dns) + +3. Any A or AAAA record returned by the second query is then compared against the original IP address (check_ip_match), and if there is a match, then the FCrDNS check passes. + + +=head1 AUTHOR + +2013 - Matt Simerson + +=cut + +use strict; +use warnings; + +use Qpsmtpd::Constants; + +use Net::DNS; + +sub register { + my ($self, $qp) = (shift, shift); + $self->{_args} = { @_ }; + $self->{_args}{reject_type} = 'temp'; + $self->{_args}{timeout} ||= 5; + $self->{_args}{ptr_hosts} = {}; + + if ( ! defined $self->{_args}{reject} ) { + $self->{_args}{reject} = 0; + }; + + $self->init_resolver(); + + $self->register_hook('connect', 'connect_handler'); + $self->register_hook('data_post', 'data_post_handler'); +}; + +sub connect_handler { + my ($self) = @_; + + return DECLINED if $self->is_immune(); + + # run a couple cheap tests before the more expensive DNS tests + foreach my $test ( qw/ invalid_localhost is_not_fqdn / ) { + $self->$test() or return DECLINED; + }; + + $self->has_reverse_dns() or return DECLINED; + $self->has_forward_dns() or return DECLINED; + + $self->log(LOGINFO, "pass"); + return DECLINED; +} + +sub data_post_handler { + my ($self, $transaction) = @_; + + my $match = $self->connection->notes('fcrdns_match') || 0; + $transaction->header->add('X-Fcrdns', $match ? 'Yes' : 'No', 0 ); + return (DECLINED); +}; + +sub init_resolver { + my $self = shift; + return $self->{_resolver} if $self->{_resolver}; + $self->log( LOGDEBUG, "initializing Net::DNS::Resolver"); + $self->{_resolver} = Net::DNS::Resolver->new(dnsrch => 0); + my $timeout = $self->{_args}{timeout} || 5; + $self->{_resolver}->tcp_timeout($timeout); + $self->{_resolver}->udp_timeout($timeout); + return $self->{_resolver}; +}; + +sub invalid_localhost { + my ( $self ) = @_; + return 1 if lc $self->qp->connection->remote_host ne 'localhost'; + if ( $self->qp->connection->remote_ip ne '127.0.0.1' + && $self->qp->connection->remote_ip ne '::1' ) { + $self->adjust_karma( -1 ); + $self->log( LOGINFO, "fail, not localhost" ); + return; + }; + $self->adjust_karma( 1 ); + $self->log( LOGDEBUG, "pass, is localhost" ); + return 1; +}; + +sub is_not_fqdn { + my ($self) = @_; + my $host = $self->qp->connection->remote_host or return 1; + return 1 if $host eq 'Unknown'; # QP assigns this to a "no DNS result" + + # Since QP looked it up, perform some quick validation + if ( $host !~ /\./ ) { # has no dots + $self->adjust_karma( -1 ); + $self->log(LOGINFO, "fail, not FQDN"); + return; + }; + if ( $host =~ /[^a-zA-Z0-9\-\.]/ ) { + $self->adjust_karma( -1 ); + $self->log(LOGINFO, "fail, invalid FQDN chars"); + return; + }; + return 1; +}; + +sub has_reverse_dns { + my ( $self ) = @_; + + my $res = $self->init_resolver(); + my $ip = $self->qp->connection->remote_ip; + + my $query = $res->query( $ip ) or do { + if ( $res->errorstring eq 'NXDOMAIN' ) { + $self->adjust_karma( -1 ); + $self->log( LOGINFO, "fail, no rDNS: ".$res->errorstring ); + return; + }; + $self->log( LOGINFO, "fail, error getting rDNS: ".$res->errorstring ); + return; + }; + + my $hits = 0; + $self->{_args}{ptr_hosts} = {}; # reset hash + for my $rr ($query->answer) { + next if $rr->type ne 'PTR'; + $hits++; + $self->{_args}{ptr_hosts}{ $rr->ptrdname } = 1; + $self->log(LOGDEBUG, "PTR: " . $rr->ptrdname ); + }; + if ( ! $hits ) { + $self->adjust_karma( -1 ); + $self->log( LOGINFO, "fail, no PTR records"); + return; + }; + + $self->log(LOGDEBUG, "has rDNS"); + return 1; +}; + +sub has_forward_dns { + my ( $self ) = @_; + + my $res = $self->init_resolver(); + + foreach my $host ( keys %{ $self->{_args}{ptr_hosts} } ) { + + $host .= '.' if '.' ne substr( $host, -1, 1); # fully qualify name + my $query = $res->search($host) or do { + if ( $res->errorstring eq 'NXDOMAIN' ) { + $self->log(LOGDEBUG, "host $host does not exist" ); + next; + } + $self->log(LOGDEBUG, "query for $host failed (", $res->errorstring, ")" ); + next; + }; + + my $hits = 0; + foreach my $rr ($query->answer) { + next unless $rr->type =~ /^(?:A|AAAA)$/; + $hits++; + $self->check_ip_match( $rr->address ) and return 1; + } + if ( $hits ) { + $self->log(LOGDEBUG, "PTR host has forward DNS") if $hits; + return 1; + }; + }; + $self->adjust_karma( -1 ); + $self->log(LOGINFO, "fail, no PTR hosts have forward DNS"); + return; +}; + +sub check_ip_match { + my $self = shift; + my $ip = shift or return; + + if ( $ip eq $self->qp->connection->remote_ip ) { + $self->log( LOGDEBUG, "forward ip match" ); + $self->connection->notes('fcrdns_match', 1); + $self->adjust_karma( 1 ); + return 1; + }; + +# TODO: make this IPv6 compatible + my $dns_net = join('.', (split(/\./, $ip))[0,1,2] ); + my $rem_net = join('.', (split(/\./, $self->qp->connection->remote_ip))[0,1,2] ); + + if ( $dns_net eq $rem_net ) { + $self->log( LOGNOTICE, "forward network match" ); + $self->connection->notes('fcrdns_match', 1); + return 1; + }; + return; +}; + diff --git a/plugins/registry.txt b/plugins/registry.txt index 8d6f1ae..a276584 100644 --- a/plugins/registry.txt +++ b/plugins/registry.txt @@ -10,6 +10,7 @@ 5 karma krm karma 6 dnsbl dbl dnsbl 7 relay rly relay check_relay,check_norelay,relay_only +8 fcrdns dns fcrdn 9 earlytalker ear early check_earlytalker 15 helo hlo helo check_spamhelo 16 tls tls tls From 31609e36435428fff70dbd7b401f210c79e20b4a Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Mon, 25 Mar 2013 01:46:34 -0400 Subject: [PATCH 24/27] badmailfrom: fix reject message typo --- plugins/badmailfrom | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/badmailfrom b/plugins/badmailfrom index 1d1f36f..4aea3fe 100644 --- a/plugins/badmailfrom +++ b/plugins/badmailfrom @@ -44,7 +44,7 @@ is a Perl pattern expression. Don't forget to anchor the pattern anywhere in the string. ^streamsendbouncer@.*\.mailengine1\.com$ Your right-hand side VERP doesn't fool me - ^return.*@.*\.pidplate\.biz$ I don' want it regardless of subdomain + ^return.*@.*\.pidplate\.biz$ I don't want it regardless of subdomain ^admin.*\.ppoonn400\.com$ From b0ebb75be4688b85d29d0ee2b2c448971b2036e8 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Mon, 25 Mar 2013 01:48:40 -0400 Subject: [PATCH 25/27] added karma awards for SPF pass/fail --- plugins/sender_permitted_from | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/plugins/sender_permitted_from b/plugins/sender_permitted_from index 05044d8..dcefe99 100644 --- a/plugins/sender_permitted_from +++ b/plugins/sender_permitted_from @@ -144,10 +144,16 @@ sub mail_handler { # SPF result codes: pass fail softfail neutral none error permerror temperror return $self->handle_code_none($reject, $why) if $code eq 'none'; - return $self->handle_code_fail($reject, $why) if $code eq 'fail'; - return $self->handle_code_softfail($reject, $why) if $code eq 'softfail'; - - if ( $code eq 'pass' ) { + if ( $code eq 'fail' ) { + $self->adjust_karma( -1 ); + return $self->handle_code_fail($reject, $why); + } + elsif ( $code eq 'softfail' ) { + $self->adjust_karma( -1 ); + return $self->handle_code_softfail($reject, $why); + } + elsif ( $code eq 'pass' ) { + $self->adjust_karma( 1 ); $self->log(LOGINFO, "pass, $code: $why" ); return (DECLINED); } @@ -158,12 +164,12 @@ sub mail_handler { elsif ( $code eq 'error' ) { $self->log(LOGINFO, "fail, $code, $why" ); return (DENY, "SPF - $code: $why") if $reject >= 6; - return (DENYSOFT, "SPF - $code: $why") if $reject >= 2; + return (DENYSOFT, "SPF - $code: $why") if $reject > 3; } elsif ( $code eq 'permerror' ) { $self->log(LOGINFO, "fail, $code, $why" ); return (DENY, "SPF - $code: $why") if $reject >= 6; - return (DENYSOFT, "SPF - $code: $why") if $reject >= 3; + return (DENYSOFT, "SPF - $code: $why") if $reject > 3; } elsif ( $code eq 'temperror' ) { $self->log(LOGINFO, "fail, $code, $why" ); From 37f4c95175d6b7e0dd2b1623c63356fa4bbcbacf Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Mon, 25 Mar 2013 01:53:16 -0400 Subject: [PATCH 26/27] spamassassin: karma scoring is dependent on the sessage learn status, not SA (global) autolearn setting. So, karma learning follows SA learning rules. --- plugins/spamassassin | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/spamassassin b/plugins/spamassassin index 1279681..6455d8f 100644 --- a/plugins/spamassassin +++ b/plugins/spamassassin @@ -396,10 +396,11 @@ sub reject { my $ham_or_spam = $sa_results->{is_spam} eq 'Yes' ? 'Spam' : 'Ham'; my $status = "$ham_or_spam, $score"; my $learn = ''; - if ( $sa_results->{autolearn} ) { - $self->adjust_karma( 1 ) if $ham_or_spam eq 'Ham'; - $self->adjust_karma( -1 ) if $ham_or_spam eq 'Spam'; - $learn = "learn=". $sa_results->{autolearn}; + my $al = $sa_results->{autolearn}; + if ( $al ) { + $self->adjust_karma( 1 ) if $al eq 'ham'; + $self->adjust_karma( -1 ) if $al eq 'spam'; + $learn = "learn=". $al; }; my $reject = $self->{_args}{reject} or do { From 278107f7fc7d470e7216563e53f276bba203428c Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Tue, 26 Mar 2013 17:52:41 -0400 Subject: [PATCH 27/27] resolvable_fromhost: added karma smites --- plugins/resolvable_fromhost | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/plugins/resolvable_fromhost b/plugins/resolvable_fromhost index 3181470..56ca10c 100644 --- a/plugins/resolvable_fromhost +++ b/plugins/resolvable_fromhost @@ -109,21 +109,26 @@ sub hook_mail { return DECLINED if $resolved; # success, no need to continue #return DECLINED if $sender->host; # reject later - if ( ! $self->{_args}{reject} ) {; - $self->log(LOGINFO, 'skip, reject disabled' ); - return DECLINED; - }; - my $result = $transaction->notes('resolvable_fromhost') or do { - $self->log(LOGINFO, 'error, missing result' ); - return Qpsmtpd::DSN->temp_resolver_failed( $self->get_reject_type(), '' ); + if ( $self->{_args}{reject} ) {; + $self->log(LOGINFO, 'error, missing result' ); + return Qpsmtpd::DSN->temp_resolver_failed( $self->get_reject_type(), '' ); + }; + $self->log(LOGINFO, 'error, missing result, reject disabled' ); + return DECLINED; }; return DECLINED if $result =~ /^(?:a|ip|mx)$/; # success return DECLINED if $result =~ /^(?:whitelist|null|naughty)$/; # immunity - $self->log(LOGINFO, "fail, $result" ); # log error + $self->adjust_karma( -1 ); + if ( ! $self->{_args}{reject} ) {; + $self->log(LOGINFO, "fail, reject disabled, $result" ); + return DECLINED; + }; + + $self->log(LOGINFO, "fail, $result" ); # log error return Qpsmtpd::DSN->addr_bad_from_system( $self->get_reject_type(), "FQDN required in the envelope sender"); } @@ -134,6 +139,7 @@ sub check_dns { # we can't even parse a hostname out of the address if ( ! $host ) { $transaction->notes('resolvable_fromhost', 'unparsable host'); + $self->adjust_karma( -1 ); return; }; @@ -142,6 +148,7 @@ sub check_dns { if ( $host =~ m/^\[(\d{1,3}\.){3}\d{1,3}\]$/ ) { $self->log(LOGINFO, "skip, $host is an IP"); $transaction->notes('resolvable_fromhost', 'ip'); + $self->adjust_karma( -1 ); return 1; }; @@ -150,8 +157,9 @@ sub check_dns { $res->udp_timeout(30); my $has_mx = $self->get_and_validate_mx( $res, $host, $transaction ); - return 1 if $has_mx == 1; # success! + return 1 if $has_mx == 1; # success, has MX! return if $has_mx == -1; # has invalid MX records + # at this point, no MX for fh is resolvable my @host_answers = $self->get_host_records( $res, $host, $transaction ); foreach my $rr (@host_answers) { @@ -189,6 +197,7 @@ sub get_and_validate_mx { my @mx = mx($res, $host); if ( ! scalar @mx ) { # no mx records + $self->adjust_karma( -1 ); $self->log(LOGINFO, "$host has no MX"); return 0; }; @@ -203,8 +212,9 @@ sub get_and_validate_mx { } # if there are MX records, and we got here, none are valid - $self->log(LOGINFO, "fail, invalid MX for $host"); + #$self->log(LOGINFO, "fail, invalid MX for $host"); $transaction->notes('resolvable_fromhost', "invalid MX for $host"); + $self->adjust_karma( -1 ); return -1; };