351 lines
9.8 KiB
Perl
351 lines
9.8 KiB
Perl
#!perl -w
|
|
|
|
=head1 NAME
|
|
|
|
clamdscan
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
A qpsmtpd plugin for virus scanning using the ClamAV scan daemon, clamd.
|
|
|
|
=head1 RESTRICTIONS
|
|
|
|
If connecting to clamd via TCP/IP host:port, then ignore this restriction.
|
|
|
|
The ClamAV scan daemon, clamd, must have at least execute access to the qpsmtpd
|
|
spool directory in order to sucessfully scan the messages. You can ensure this
|
|
by running clamd as the same user as qpsmtpd does, or by doing the following:
|
|
|
|
=over 4
|
|
|
|
=item * Change the group ownership of the spool directory to be a group
|
|
of which clamav is a member or add clamav to the same group as the qpsmtpd
|
|
user.
|
|
|
|
=item * Enable the "AllowSupplementaryGroups" option in clamd.conf.
|
|
|
|
=item * Add group-execute permissions to the qpsmtpd spool directory.
|
|
|
|
=item * Make sure that all directories above the spool directory (to the
|
|
root) are g+x so that the group has directory traversal rights; it is not
|
|
necessary for the group to have any read rights.
|
|
|
|
=back
|
|
|
|
It may be helpful to temporary grant the clamav user a shell and test to
|
|
make sure you can cd into the spool directory and read files located there.
|
|
Remember to remove the shell from the clamav user when you are done
|
|
testing.
|
|
|
|
=head1 INSTALL AND CONFIG
|
|
|
|
Place this plugin in the plugin/virus directory beneath the standard
|
|
qpsmtpd installation. If you installed clamd with the default path, you
|
|
can use this plugin with default options (nothing specified):
|
|
|
|
You must have the ClamAV::Client module installed to use the plugin.
|
|
|
|
=over 4
|
|
|
|
=item B<clamd_socket>
|
|
|
|
Full path to the clamd socket, if different from the ClamAV::Client defaults.
|
|
|
|
=item B<clamd_host>
|
|
|
|
IP address where clamd is listening.
|
|
|
|
Default: localhost
|
|
|
|
=item B<clamd_port>
|
|
|
|
The TCP port where the clamd service is running, typically 3310.
|
|
|
|
Default: disabled. When present, overrides clamd_socket.
|
|
|
|
=item B<deny_viruses>
|
|
|
|
Whether the scanner will automatically delete messages which have viruses.
|
|
Takes either 'yes' or 'no'. If set to 'no', adds a header with the virus name.
|
|
|
|
Default: yes
|
|
|
|
=item B<defer_on_error>
|
|
|
|
Whether to defer the mail (with a soft-failure error, which will incur a retry)
|
|
if an unrecoverable error occurs during the scan. The default is to accept
|
|
the mail under these conditions. This can permit viruses to be accepted when
|
|
the clamd daemon is malfunctioning or unreadable, but will not allow mail to
|
|
backlog or be lost if the condition persists.
|
|
|
|
=item B<max_size>
|
|
|
|
The maximum size, in kilobytes, of messages to scan.
|
|
|
|
Default: 1024 (1 MB)
|
|
|
|
=item B<scan_all>
|
|
|
|
Scan all messages, even if there are no attachments
|
|
|
|
=back
|
|
|
|
=head1 REQUIREMENTS
|
|
|
|
This module requires the ClamAV::Client module, found on CPAN here:
|
|
|
|
L<http://search.cpan.org/dist/ClamAV-Client/>
|
|
|
|
=head1 AUTHOR
|
|
|
|
Originally written for the Clamd module by John Peacock <jpeacock@cpan.org>;
|
|
adjusted for ClamAV::Client by Devin Carraway <qpsmtpd/@/devin.com>.
|
|
|
|
=head1 COPYRIGHT AND LICENSE
|
|
|
|
Copyright (c) 2005 John Peacock,
|
|
Copyright (c) 2007 Devin Carraway
|
|
Copyright (c) 2013 Matt Simerson
|
|
|
|
Based heavily on the clamav plugin
|
|
|
|
This plugin is licensed under the same terms as the qpsmtpd package itself.
|
|
Please see the LICENSE file included with qpsmtpd for details.
|
|
|
|
=cut
|
|
|
|
use strict;
|
|
use warnings;
|
|
|
|
#use ClamAV::Client; # eval'ed in $self->register
|
|
use Socket qw(:DEFAULT :crlf);
|
|
|
|
use Qpsmtpd::Constants;
|
|
|
|
sub register {
|
|
my $self = shift;
|
|
my $qp = shift;
|
|
|
|
$self->log(LOGERROR, "Bad parameters for the clamdscan plugin") if @_ % 2;
|
|
$self->{'_args'} = {@_};
|
|
|
|
eval 'use ClamAV::Client'; ## no critic (Stringy)
|
|
if ($@) {
|
|
$self->log(LOGERROR, "unable to load ClamAV::Client");
|
|
return;
|
|
}
|
|
|
|
# Set sensible defaults
|
|
$self->{_args}{max_size} ||= 1024;
|
|
if ( ! defined $self->{_args}{deny_viruses} ) {
|
|
$self->{_args}{deny_viruses} = 'yes';
|
|
}
|
|
if ( ! defined $self->{_args}{scan_all} ) {
|
|
$self->{_args}{scan_all} = 1;
|
|
}
|
|
for my $setting (qw( deny_viruses defer_on_error scan_all )) {
|
|
next unless $self->{'_args'}{$setting};
|
|
if (lc $self->{'_args'}{$setting} eq 'no') {
|
|
$self->{'_args'}{$setting} = 0;
|
|
}
|
|
}
|
|
|
|
$self->register_hook('data_post', 'data_post_handler');
|
|
}
|
|
|
|
sub data_post_handler {
|
|
my ($self, $transaction) = @_;
|
|
|
|
|
|
if ($self->connection->notes('naughty')) {
|
|
$self->log(LOGINFO, "skip, naughty");
|
|
return DECLINED;
|
|
}
|
|
return DECLINED if ! $self->should_scan($transaction);
|
|
|
|
my $clamd = $self->get_clamd()
|
|
or return $self->err_and_return("Cannot instantiate ClamAV::Client");
|
|
|
|
unless (eval { $clamd->ping() }) {
|
|
return $self->err_and_return("Cannot ping clamd server: $@");
|
|
}
|
|
|
|
my ($version) = split(/\//, $clamd->version);
|
|
$version ||= 'ClamAV';
|
|
|
|
my ($path, $found);
|
|
if ( $self->{_args}{clamd_port} ) {
|
|
my $message = $self->assemble_message($transaction);
|
|
$found = eval { $clamd->scan_scalar(\$message) }; # pass scalar ref
|
|
# $found = eval { $clamd->scan_stream() }; # pass IO handle
|
|
}
|
|
else {
|
|
my $filename = $self->get_filename($transaction) or return DECLINED;
|
|
$self->set_permission($filename) or return DECLINED;
|
|
($path, $found) = eval { $clamd->scan_path($filename) };
|
|
};
|
|
|
|
if ($@) {
|
|
return $self->err_and_return("Error scanning mail: $@");
|
|
}
|
|
|
|
if ($found) {
|
|
$self->log(LOGNOTICE, "fail, found virus $found");
|
|
|
|
$self->is_naughty(1); # see plugins/naughty
|
|
$self->adjust_karma(-1);
|
|
|
|
if ($self->{_args}{deny_viruses}) {
|
|
return DENY, "Virus found: $found";
|
|
}
|
|
|
|
$transaction->header->add('X-Virus-Found', 'Yes', 0);
|
|
$transaction->header->add('X-Virus-Details', $found, 0);
|
|
return DECLINED;
|
|
}
|
|
|
|
$self->log(LOGINFO, "pass, clean");
|
|
$transaction->header->add('X-Virus-Found', 'No', 0);
|
|
$transaction->header->add('X-Virus-Checked',
|
|
"by $version on " . $self->qp->config('me'), 0);
|
|
return DECLINED;
|
|
}
|
|
|
|
sub assemble_message {
|
|
my ($self, $transaction) = @_;
|
|
$transaction->body_resetpos;
|
|
my $message = $transaction->header->as_string . "\n\n";
|
|
while (my $line = $transaction->body_getline) { $message .= $line; }
|
|
$message = join(CRLF, split /\n/, $message);
|
|
return $message . CRLF;
|
|
}
|
|
|
|
sub err_and_return {
|
|
my $self = shift;
|
|
my $message = shift;
|
|
if ($message) {
|
|
$self->log(LOGERROR, $message);
|
|
}
|
|
return DENYSOFT, "Unable to scan for viruses"
|
|
if $self->{_args}{defer_on_error};
|
|
return DECLINED, "skip";
|
|
}
|
|
|
|
sub get_filename {
|
|
my $self = shift;
|
|
my $transaction = shift || $self->qp->transaction;
|
|
|
|
my $filename = $transaction->body_filename;
|
|
|
|
if (!$filename) {
|
|
$self->log(LOGWARN, "Cannot process due to lack of filename");
|
|
return;
|
|
}
|
|
|
|
if (!-f $filename) {
|
|
if ($transaction->data_size) {
|
|
$self->log(LOGERROR, "spool file missing! Attempting to respool");
|
|
$transaction->body_spool;
|
|
$filename = $transaction->body_filename;
|
|
};
|
|
if (!-f $filename) {
|
|
$self->log(LOGERROR, "skip: failed spool to $filename! Giving up");
|
|
return;
|
|
}
|
|
my $size = (stat($filename))[7];
|
|
$self->log(LOGDEBUG, "Spooled $size bytes to $filename");
|
|
}
|
|
|
|
return $filename;
|
|
}
|
|
|
|
sub set_permission {
|
|
my ($self, $filename) = @_;
|
|
|
|
# the spool directory must be readable and executable by the scanner;
|
|
# this generally means either group or world exec; if
|
|
# neither of these is set, issue a warning but try to proceed anyway
|
|
my $dir_mode = (stat($self->spool_dir()))[2];
|
|
$self->log(LOGDEBUG, "spool dir mode: $dir_mode");
|
|
|
|
if ($dir_mode & oct('0010') || $dir_mode & oct('0001')) {
|
|
|
|
# match the spool file mode with the mode of the directory -- add
|
|
# the read bit for group, world, or both, depending on what the
|
|
# spool dir had, and strip all other bits, especially the sticky bit
|
|
my $fmode =
|
|
($dir_mode & oct('0044')) | ($dir_mode & oct('0010') ? oct('0040') : 0) |
|
|
($dir_mode & oct('0001') ? oct('0004') : 0);
|
|
|
|
unless (chmod $fmode, $filename) {
|
|
$self->log(LOGERROR, "chmod: $filename: $!");
|
|
return;
|
|
}
|
|
return 1;
|
|
}
|
|
$self->log(LOGWARN,
|
|
"spool directory permissions do not permit scanner access");
|
|
return 1;
|
|
}
|
|
|
|
sub get_clamd {
|
|
my $self = shift;
|
|
|
|
my $port = $self->{'_args'}{'clamd_port'};
|
|
my $host = $self->{'_args'}{'clamd_host'} || 'localhost';
|
|
|
|
if ($port && $port =~ /^(\d+)/) {
|
|
return new ClamAV::Client(socket_host => $host, socket_port => $1);
|
|
}
|
|
|
|
my $socket = $self->{'_args'}{'clamd_socket'};
|
|
if ($socket) {
|
|
if ($socket =~ /([\w\/.]+)/) {
|
|
return new ClamAV::Client(socket_name => $1);
|
|
}
|
|
$self->log(LOGERROR, "invalid characters in socket name");
|
|
}
|
|
|
|
return new ClamAV::Client;
|
|
}
|
|
|
|
sub is_too_big {
|
|
my $self = shift;
|
|
my $transaction = shift || $self->qp->transaction;
|
|
|
|
my $size = $transaction->data_size;
|
|
if ($size > $self->{_args}{max_size} * 1024) {
|
|
$self->log(LOGINFO, "skip, too big ($size)");
|
|
return 1;
|
|
}
|
|
|
|
$self->log(LOGDEBUG, "data_size, $size");
|
|
return 0;
|
|
}
|
|
|
|
sub is_multipart {
|
|
my $self = shift;
|
|
my $transaction = shift || $self->qp->transaction;
|
|
|
|
return 0 if !$transaction->header;
|
|
|
|
# Ignore non-multipart emails
|
|
my $content_type = $transaction->header->get('Content-Type') or return 0;
|
|
$content_type =~ s/\s/ /g;
|
|
if ($content_type !~ m!\bmultipart/.*\bboundary="?([^"]+)!i) {
|
|
$self->log(LOGNOTICE, "skip, not multipart");
|
|
return 0;
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
sub should_scan {
|
|
my $self = shift;
|
|
my $tran = shift;
|
|
return 0 if $self->is_too_big($tran);
|
|
return 1 if $self->{_args}{scan_all};
|
|
return 0 if ! $self->is_multipart($tran);
|
|
return 1;
|
|
}
|