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;
}

Reply via email to