Gday all,
I've been tinkering with qpsmtpd for about a month now, making little tweaks
here and there in some of the plugin files. Today, I decided to rework the
clamav plugin and thought I'd post it here to get some feedback and
guidance. As a rookie plugin developer, I'll gladly accept any suggestions
or criticisms of this that you might have.
You can also find it here: http://www.qogo.com/qpsmtpd/clamav
I used the stock 0.27.1 spamassassin plugin as the basis, so the structure
might look somewhat familiar. I've added config options that allow you to
specify what to do with a virus-positive message -- accept, reject,
blackhole, quarantine. Quarantine-mode also requires a directory to place
the messages in to. Documentation is in the plugin.
This has only been tested on my test server, not on a production system, so
if you're silly enough to run this on a real box without further testing,
don't yell at me!
Cheers,
Burt
--
Burt Heymanson
=head1 NAME
clamav - Clam AntiVirus integration for qpsmtpd
=head1 DESCRIPTION
Plugin that checks if the mail contains a virus by running either
clamscan or clamdscan from the Clam AntiVirus package. F<http://www.clamav.net>
This plugin was tested with Clam AntiVirus 0.68
=head1 CONFIGURATION
Configured in the plugins file without any parameters, the
clamav plugin will add relevant headers if a virus is identified by
clamscan/clamdscan (X-Virus-Found, X-Virus-Details) and continue
processing the message..
The format of the entry in the config/plugins file should be:
clamav option value [option value]
Options being those listed below and the values being parameters to
the options.
=over 4
=item B<clamscan_location /path/to/clamscan/binary>
The full path to either the clamscan or clamdscan binary. If you choose
to use clamdscan, you must ensure that clamd is running for scanning to
occur.
The default is /usr/local/bin/clamscan
=item B<action [ accept | reject | blackhole ]>
The action to take when a message is found to contain a virus. The
valid actions that may be specified are:
accept: The message, with the X-Virus-Found and X-Virus-Details
headers added, is accepted and continues being
processed.
reject: The message is rejected -- the server sends a 55x
response to the sending mail server. The message does
not continue being processed by additinal plugins. The
address in the "From:" header will receive a bounce
email indicating that their message contained a virus
and that it was not delivered.
blackhole: The message is accepted, but does not continue being
processed. It is simply dropped and not queued. The
address in the "From:" header will NOT receive a
notification that the message contained a virus and was
not delivered. As most emails that are detected
containing viruses have spoofed "From:" addresses, this
is probably a preferred approach over the "reject"
action if you don't wish to deliver the email to the
listed recipient.
quarantine: The message is accepted, but does not continue being
processed. It is saved to a unique file in the
quarantine directory, with a quarantine summary
envelope appended to the end of the file. The address
in the "From:" header will NOT receive a notification
that the message contained a virus and was not
delivered. As most emails that are detected containing
viruses have spoofed "From:" addresses, this is
probably a preferred approach over the "reject" action
if you don't wish to deliver the email to the listed
recipient.
The default is "accept".
=item B<quarantine_dir /quarantine/directory>
(Only used when "action" is set to "quarantine".)
The directory where messages that are found to contain a virus are to be
quarantined. This directory must be writable by the user that qpsmtpd/tcpserver
is running as.
If no quarantine directory is specified, the behavior is basically the same as
the "blackhole" action.
=back
With both of the global options specified, the configuration line will look like
the following:
clamav clamscan_location /usr/local/bin/clamdscan action blackhole
If "quarantine" is the desired action, the configuration line will look like the
following:
clamav clamscan_location /usr/local/bin/clamdscan action quarantine quarantine_dir
/var/spool/quarantine
=head1 TODO
Quarantining a message simply places a copy of it
=cut
use File::Temp qw(tempfile);
use File::Basename qw(basename);
use POSIX qw:strftime:;
sub register {
my ($self, $qp, @args) = @_;
$self->register_hook("data_post", "clam_scan");
$self->log(0, "Bad parameters for the clamav plugin")
if @_ % 2;
%{$self->{_args}} = @args;
if ($self->{_args}->{clamscan_location} ) {
$self->{_clamscan_loc} = $self->{_args}->{clamscan_location};
# Untaint scanner location
if ($self->{_clamscan_loc} =~ /^(\/[\/\-\_\.a-z0-9A-Z]*)$/) {
$self->{_clamscan_loc} = $1;
} else {
$self->log(1, "FATAL ERROR: Unexpected characters in clamscan_location
argument");
exit 3;
}
} else {
$self->{_clamscan_loc} = "/usr/local/bin/clamscan";
}
if (basename($self->{_clamscan_loc}) eq "clamdscan") {
$self->{_clamscan_opts} = "--stdout --disable-summary";
} else {
$self->{_clamscan_opts} = "--mbox --stdout -i --max-recursion=50
--disable-summary";
}
$self->register_hook("data_post", "clam_scan_handler")
if $self->{_args}->{action};
}
sub clam_scan {
my ($self, $transaction) = @_;
my ($temp_fh, $filename) = tempfile();
print $temp_fh "From " . $transaction->sender->format . " " . gmtime(time) . "\n";
print $temp_fh $transaction->header->as_string;
print $temp_fh "\n";
$transaction->body_resetpos;
while (my $line = $transaction->body_getline) {
print $temp_fh $line;
}
seek($temp_fh, 0, 0);
# Now do the actual scanning!
my $cmd = $self->{_clamscan_loc} . " " . $self->{_clamscan_opts} . " $filename 2>&1";
$self->log(1, "Running: $cmd");
my $output = `$cmd`;
my $result = ($? >> 8);
my $signal = ($? & 127);
unlink($filename);
chomp($output);
$output =~ s/^.* (.*) FOUND$/$1 /mg;
$self->log(1, "clamscan results: $output");
if ($signal) {
$self->log(1, "clamscan exited with signal: $signal");
return (DECLINED);
}
if ($result == 1) {
$self->log(1, "Virus(es) found");
$transaction->header->delete("X-Virus-Found");
$transaction->header->delete("X-Virus-Details");
$transaction->header->delete("X-Virus-Checked");
$transaction->header->add('X-Virus-Found', 'Yes');
$transaction->header->add('X-Virus-Details', $output);
}
elsif ($result) {
$self->log(1, "ClamAV error: $result\n");
}
# Added the if ($result < 2) check, so that the header isn't added if clamd is down,
or clamscan fails.
if ($result < 2) {
$transaction->header->add('X-Virus-Checked', 'Checked with ClamAV on ' .
$self->qp->config('me'));
}
return (DECLINED);
}
sub clam_scan_handler {
my ($self, $transaction) = @_;
$self->log(6, "clam_scan_handler: action=" . $self->{_args}->{action});
if (my $result = $self->get_clam_scan_result($transaction)) {
my $details = $self->get_clam_scan_details($transaction);
$self->log(6, "clam_scan_handler: result=$result details=$details");
if (uc $self->{_args}->{action} eq "ACCEPT") {
$self->log(1, "clam_scan_handler: Virus found and accepted for queueing");
return DECLINED;
}
elsif (uc $self->{_args}->{action} eq "REJECT") {
$self->log(1, "clam_scan_handler: Virus found and rejected");
return (DENY, "Virus Found: $details");
}
elsif (uc $self->{_args}->{action} eq "BLACKHOLE") {
$self->log(1, "clam_scan_handler: Virus found and blackholed");
$self->qp->respond(250, "Virus received and blackholed. Thanks for nothing!");
return DONE;
}
elsif (uc $self->{_args}->{action} eq "QUARANTINE") {
if ($self->{_args}->{quarantine_dir}) {
$self->log(1, "clam_scan_handler: Virus found and quarantined")
if $self->quarantine_message($transaction);
} else {
$self->log(1, "clam_scan_handler: Quarantine action requested, but no
quarantine directory specified");
}
$self->qp->respond(250, "Virus received and quarantined. Thanks for nothing!");
return DONE;
}
}
return DECLINED;
}
sub get_clam_scan_result {
my ($self, $transaction) = @_;
my $virusfound = $transaction->header->get('X-Virus-Found') or return;
return $virusfound;
}
sub get_clam_scan_details {
my ($self, $transaction) = @_;
my $virusdetails = $transaction->header->get('X-Virus-Details');
return $virusdetails;
}
sub quarantine_message {
my ($self, $transaction) = @_;
$self->log(6, "quarantine_message: quarantine_dir=" .
$self->{_args}->{quarantine_dir});
# as a decent default, log on a per-day-basis
my $quarantinedir = $self->{_args}->{quarantine_dir};
my $filedate = strftime("%Y%m%d%H%M%S",localtime(time));
my $quarantinefile = $self->{_args}->{quarantine_dir} . "/quarantine." . $filedate .
"." . $$;
unless ( $quarantinefile =~ m/^([\w\/.]+)$/ ) {
$self->log(1, "quarantine_message: quarantine path or filename contains invalid
characters.");
return;
}
$quarantinefile = $1;
$self->log(8, "quarantine_message: quarantinefile=" . $quarantinefile);
open(my $out,">>$quarantinefile")
or $self->log(1, "quarantine_message: Could not open quarantine file. Message not
quarantined but not queued.") and return;
#print $out "From " . $transaction->sender->format . " " . gmtime(time) . "\n";
$transaction->header->print($out);
$transaction->body_resetpos;
while (my $line = $transaction->body_getline) {
print $out $line;
}
print $out "\n";
print $out "*** qsmptd clamav plugin Quarantine Envelope Details Begin ***\n";
print $out "Sender: \"" . $transaction->sender->format . "\"\n";
foreach my $recip ($transaction->recipients) {
print $out "Recipient: \"" . $recip->format . "\"\n";
}
my $result = $self->get_clam_scan_result($transaction);
my $details = $self->get_clam_scan_details($transaction);
print $out "X-Virus-Found: $result";
print $out "X-Virus-Details: $details";
print $out "*** qsmptd clamav plugin Quarantine Envelope Details End ***\n";
print $out "\n";
close $out;
}