=head1 NAME
DictionaryCheckDB - provides eval test using info from a anti-dictionary-attack database
=head1 SYNOPSIS
loadplugin Mail::SpamAssassin::Plugin::DictionaryCheckDB /usr/local/etc/mail/spamassassin/plugins/DictionaryCheckDB.pm
dictionarycheckdb_sql_dsn DBI:mysql:dbname:localhost
dictionarycheckdb_sql_username user
dictionarycheckdb_sql_password pass
dictionarycheckdb_min_window 60
header __DCDB_500C_24H eval:dictionarycheckdb_count(500,'60*24')
header __DCDB_100C_24H eval:dictionarycheckdb_count(100,'60*24')
header __DCDB_050C_24H eval:dictionarycheckdb_count(50,'60*24')
header __DCDB_050C_10M eval:dictionarycheckdb_count(50,10)
header __DCDB_NOTNONE eval:dictionarycheckdb_count(1)
header __DCDB_20P eval:dictionarycheckdb_calc(0.2)
header __DCDB_10P eval:dictionarycheckdb_calc(0.1)
header __DCDB_05P eval:dictionarycheckdb_calc(0.05)
meta DCDB_WORST __DCDB_500C_24H || __DCDB_050C_10M || __DCDB_20P
meta DCDB_WORSE !DCDB_WORST && (__DCDB_100C_24H || __DCDB_10P)
meta DCDB_BAD !(DCDB_WORSE || DCDB_WORST) && (__DCDB_050C_24H || __DCDB_05P)
meta DCDB_NOTE !(DCDB_WORSE || DCDB_WORST || DCDB_BAD) && __DCDB_NOTNONE
score DCDB_NOTE 0.01
score DCDB_BAD 0.5
score DCDB_WORSE 1.5
score DCDB_WORST 3.0
describe DCDB_NOTE One or more dcdb-entries
describe DCDB_BAD Impolite relay
describe DCDB_WORSE Abusive relay
describe DCDB_WORST Very abusive relay
=head1 DESCRIPTION
This module provides eval tests that checks a database containins entries
for connections that could be parts of dictionary attacks.
=head1 REQUIREMENT
This plugin uses the database used by the MIMEDefang filter at
http://whatever.frukt.org.
=head1 CONFIGURATION
=head2 Eval test
=over
=item dictionarycheckdb_count(count,window)
True if the number of entries for the first untrusted host is count or higher.
Window specifies the number of minutes (backwards from now) entries are checked for.
If not specified, a bunch of different windows are used.
=item dictionarycheckdb_calc(value,window)
True if the number of entries divided by the time from the first to the last is
value or higher.
Window specifies the number of minutes (backwards from now) entries are checked for.
If not specified, all entries for the host is counted.
=back
=head2 Options
=over
=item dictionarycheckdb_sql_dsn
Wich database driver and database to use.
=item dictionarycheckdb_sql_username
User name for the database connection.
=item dictionarycheckdb_sql_password
Password for the database connection.
=item dictionarycheckdb_min_window
Minimum time window used in calculation.
=back
=cut
package Mail::SpamAssassin::Plugin::DictionaryCheckDB;
# $Id: DictionaryCheckDB.pm,v 1.8 2009/06/26 11:52:16 jonas Exp $
use strict;
use base 'Mail::SpamAssassin::Plugin';
use DBI;
sub dbg {
my $msg = shift;
Mail::SpamAssassin::Plugin::dbg(sprintf("dictionarycheckdb: $msg",@_));
}
sub new {
my ($class,$mailsa) = @_;
$class = ref($class) || $class;
my $self = $class->SUPER::new($mailsa);
bless($self,$class);
$self->{sqldb} = undef;
$self->register_eval_rule('dictionarycheckdb_count');
$self->register_eval_rule('dictionarycheckdb_calc');
$self->{main}->{conf}->{dictionarycheckdb_sql_dsn} = 'DBI:mysql:mdf:localhost';
$self->{main}->{conf}->{dictionarycheckdb_sql_username} = 'sa';
$self->{main}->{conf}->{dictionarycheckdb_sql_password} = 'pwd';
$self->{main}->{conf}->{dictionarycheckdb_min_window} = 60;
#dbg('registered');
return $self;
}
sub parse_config {
my ($self,$pars) = @_;
return 0 if ($pars->{user_config});
return 0 unless ($pars->{key} =~ /^dictionarycheckdb_(sql_dsn|sql_username|sql_password|min_window)$/);
my $key = $1;
my $val = $pars->{value};
$pars->{value} = eval($pars->{value}) if ($pars->{value} && $key eq 'min_window');
$val = '' if ($key =~ /(username|password)/);
$val = " = $val" if ($val);
dbg('config %s%s',$key,$val);
$self->{main}->{conf}->{$pars->{key}} = $pars->{value};
$self->inhibit_further_callbacks();
return 1;
}
sub _sql_connect {
my ($self) = @_;
return 1 if ($self->{sqldb});
#dbg('sql connect');
$self->{sqldb} = DBI->connect_cached(
$self->{main}->{conf}->{dictionarycheckdb_sql_dsn},
$self->{main}->{conf}->{dictionarycheckdb_sql_username},
$self->{main}->{conf}->{dictionarycheckdb_sql_password},
{RaiseError=>0}
);
return 1 if ($self->{sqldb});
dbg('sql connect failed');
return 0;
}
sub _sql_disconnect {
my ($self) = @_;
if ($self->{sqldb}) {
#dbg('sql disconnect');
$self->{sqldb}->disconnect();
}
$self->{sqldb} = undef;
}
sub _sql_select {
my ($self,$cmd,@par) = @_;
my $st;
dbg('sql %s; %s',$cmd,join(', ',@par));
$st = $self->{sqldb}->prepare($cmd);
unless ($st) {
dbg('sql prepare failed');
return undef;
}
$st->execute(@par);
my @res = $st->fetchrow_array;
$st->finish;
dbg('sql %u',scalar @res);
return undef unless (@res);
for (my $i=0;$i<@res;$i++) { $res[$i] = 0 unless (defined($res[$i])); }
dbg('sql %s',join(',',@res));
return \@res;
}
sub _get_values {
my ($self,$pms,$now,$ip,$window) = @_;
return $pms->{"dictionarycheckdb.c.$window.$ip"} if ($pms->{"dictionarycheckdb.c.$window.$ip"});
dbg('sql %s %u',$ip,$window);
my $res;
return [0,0,0] unless ($self->_sql_connect());
$res = $window ?
$self->_sql_select('SELECT COUNT(dc_stamp),MIN(dc_stamp),MAX(dc_stamp) FROM dictionary WHERE dc_host=? AND dc_stamp>?',$ip,$now-$window) :
$self->_sql_select('SELECT COUNT(dc_stamp),MIN(dc_stamp),MAX(dc_stamp) FROM dictionary WHERE dc_host=?',$ip);
return [0,0,0] unless ($res);
$pms->{"dictionarycheckdb.c.$window.$ip"} = $res;
return $res;
}
sub dictionarycheckdb_count {
my ($self,$pms,$limit,$window) = @_;
my $msg = $pms->get_message();
return 0 unless ($msg && $msg->{metadata} && $msg->{metadata}->{relays_untrusted} && @{$msg->{metadata}->{relays_untrusted}});
my $ip = $msg->{metadata}->{relays_untrusted}->[0]->{ip};
return 0 unless ($ip);
$limit = eval($limit) if ($limit);
$window = eval($window) if ($window);
$limit = 1 unless ($limit && $limit>0);
$window = 0 unless ($window && $window>0);
dbg('eval cnt ? %u %u %s',$limit,$window,$ip);
my $res = $self->_get_values($pms,time(),$ip,$window*60);
$self->_sql_disconnect();
return 0 unless ($res && @{$res});
dbg('eval cnt = %u',$res->[0]);
return ($res->[0] > $limit) ? 1 : 0;
}
sub dictionarycheckdb_calc {
my ($self,$pms,$limit,$window) = @_;
my $msg = $pms->get_message();
return 0 unless ($msg && $msg->{metadata} && $msg->{metadata}->{relays_untrusted} && @{$msg->{metadata}->{relays_untrusted}});
my $ip = $msg->{metadata}->{relays_untrusted}->[0]->{ip};
return 0 unless ($ip);
$limit = eval($limit) if ($limit);
$limit = 0.1 unless ($limit && $limit>0);
my @windows = ();
if (defined($window)) {
$window = eval($window) if ($window);
$window = 0 unless ($window && $window>0);
@windows = ($window * 60);
} else {
@windows = (0,24*60*60,12*60*60,6*60*60,3*60*60,60*60,30*60,15*60,7*60);
}
my ($lth,$ltl,$ltt);
my $now = time();
my $lp = 0;
foreach $window (@windows) {
if ($lp && $window>0) {
next unless ($window < $ltt);
my $win = $now - $window;
last if ($win > $lth);
next if ($win < $ltl);
}
dbg('eval clc ? %u %u %s',$limit,$window,$ip);
my $res = $self->_get_values($pms,$now,$ip,$window);
last unless ($res && @{$res} && $res->[0] && $res->[1] && $res->[2]);
my $tim = $res->[2] - $res->[1];
next unless ($tim > $self->{main}->{conf}->{dictionarycheckdb_min_window});
my $val = $tim ? $res->[0]/$tim : $res->[0];
dbg('eval clc = %i / %i : %f',$res->[0],$tim,$val);
if ($val > $limit) {
$self->_sql_disconnect();
return 1;
}
$ltl = $res->[1];
$lth = $res->[2];
$ltt = $now - $res->[1];
$lp ++;
}
$self->_sql_disconnect();
return 0;
}
1;
(2008-01-11)