#!/usr/bin/perl -w
# Kasperski-AV plugin.
 
=head1 NAME

kavscanner - plugin for qpsmtpd which calls the Kasperski anti virus scanner

=head1 DESCRIPTION

Check a mail with the B<kavscanner> and deny if it matches a configured virus
list.

=head1 VERSION

this is B<kavscanner> version 1.2

=head1 CONFIGURATION

Add (perl-)regexps to the F<kav_deny> configuration file, one per line for the
virii you want to block, e.g.:

  I-Worm\.Sober\..*
  I-Worm\.NetSky\..*

If this list does not match the virus found in the mail, you may set 
I<bcc_virusadmin viradm@your.company.com> in the plugin config to send a 
B<Bcc:> to the given mail address, i.e. the line 

  kavscanner bcc_virusadmin viradm@your.company.com 

in the F<config/plugin> file instead of just

  kavscanner

Set the location of the binary with 

  kavscanner kavscanner_bin /path/to/kavscanner

=head1 NOTES

This started as a merge of the clam_av plugin for qpsmtpd and 
qmail-scanner-queue.pl L<http://qmail-scanner.sourceforge.net/> with my 
own improvements. By now, nothing of the qmail-scanner code is used anymore.

Only tested with kavscanner 4.0.x, and bcc_virusadmin untested, as we have no
use for it currently. I wait for an official change in Qpsmtpd::Transaction
(reset/set the RCPT TO list) to activate and test the currently disabled 
B<to_virusadmin> option.

=cut

use File::Temp qw(:mktemp);
use Mail::Address;
 
sub register {
  my ($self, $qp, @args) = @_;
  $self->register_hook("data_post", "kav_scan");

  if (@args % 2) {
    warn "kavscanner: Wrong number of arguments";
    $self->{_kavscanner_bin} = "/opt/AVP/kavscanner";
  } else {
    my %args = @args;
    foreach my $key (keys %args) {
      my $arg = $key;
      $key =~ s/^/_/;
      $self->{$key} = $args{$arg};
    }
    # Untaint scanner location
    if (exists $self->{_kavscanner_bin}) {
      if ($self->{_kavscanner_bin} =~ /^(\/[\/\-\_\.a-z0-9A-Z]*)$/) {
        $self->{_kavscanner_bin} = $1;
      } else {
        $self->log(1, "FATAL ERROR: Unexpected characters in kavscanner argument");
        exit 3;
      }
    } else {
      $self->{_kavscanner_bin} = "/opt/AVP/kavscanner";
    }
  } 
}
 
sub kav_scan {
  my ($self, $transaction) = @_;
 
  if (defined $transaction->notes('relay_client')
        and $transaction->notes('relay_client') eq 'Yes') {
    $self->log(2, "Not checking for virus: it's a relay client...");
    return(DECLINED);
  }

  my $tmpfh_template = 'qpkavXXXXXXXX';
  my $tmpfh_dir      = '/tmp';
  my ($temp_fh, $filename) = mkstemp($tmpfh_dir.'/'.$tmpfh_template);

  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->{_kavscanner_bin}." -Y -P -B -MP -MD -* $filename 2>&1";
  $self->log(1, "Running: $cmd");
  my @output = `$cmd`;
  my $exit   = $?;
  my $signal = ($? & 127);
 
  unlink $filename;
  close $temp_fh;

  if ($signal) {
    $self->log(1, "kavscanner exited with signal: $signal");
    return (DECLINED);
  }
  # kavscanner/kavdaemon exit codes from the docs:
  # 0 - No viruses were found
  # 1 - Virus scan was not complete
  # 3 - Suspicious objects were found
  # 4 - Known viruses were detected
  # 5 - All detected viruses have been disifected
  # 6 - All detected viruses have been deleted
  # 7 - File kavdaemon is corrupted
  # 8 - Corrupted objects were found
  
  # sometimes it's really ugly to have no 'case' or 'switch' statement ;->
  my $result = ($exit >> 8);
  if ($result == 0) {
    $self->log(1, "No viruses were found");
    $transaction->header->add('X-Virus-Checked', 
                                'Checked by '.$self->qp->config("me"), 0);
    return (DECLINED);
  } 

  elsif ($result == 1) {
    $self->log(1, "Virus scan was not complete");
    return (DECLINED);
  } 

  elsif ($result == 2) {
    $self->log(1, "Warning issued by kavscanner");
  }

  elsif ($result == 3) {
    $self->log(1, "Suspicious objects were found");
  }

  elsif ($result == 4) {
    $self->log(1, "Known viruses were detected");
  }

  elsif ($result == 5) {
    $self->log(1, "Ooops, all detected viruses have been disinfected");
    # oops... but we didn't tell kavscanner to do so..., 
    # no '--' cmd line switch used
    return (DECLINED);
  }

  elsif ($result == 6) {
    $self->log(1, "Ooops, all detected viruses have been deleted");
    # oops... but we didn't tell kavscanner to do so...
    # no '-E' cmd line switch used
    return (DECLINED);
  }

  elsif ($result == 7) {
    $self->log(1, "File kavdaemon is corrupted");
    # err... who cares about a daemon, when we don't use it :)
    # ...this is kavdaemon specific, so it should never happen
    return (DECLINED);
  }

  elsif ($result == 8) {
    $self->log(1, "Corrupted objects were found");
  } 

  ### untested, this is from the sample daemon clients code:
  # elsif (($exit & 0x80) != 0) {
  #   $self->log(1, "kavscanner internal error: Integrity failed.");
  #   return (DECLINED);
  # } 
  #
  # elsif (($exit & 0x40) != 0) {
  #   $self->log(1, "kavscanner internal error: Bases not found.");
  #   return (DECLINED);
  # } 
  #
  # elsif (($exit & 0x10) != 0) {
  #   $self->log(1, "Key-file not found or key-file expired.");
  #   return (DECLINED);
  # } 

  else {
    $self->log(1, "Unknown exit code for kavscanner...");
    return (DECLINED);
  }

  my @infected    = ();
  my @suspicious  = ();
  # yes, I know the examples are from oooooold virii, but the archive 
  # good for testing :)
  
  # ...the @output is very strange, most lines start with a ^M, 
  # it looks like they used LF+CR instead of CR+LF in the output :)
  chomp(@output);
  foreach (@output) {
    if (/^\cM?.* infected:\s+(\S+)\s*$/) {
      # vol1.zip/fharry.zip/HARRY.EXE infected: Win95.Harry.a
      push @infected, $1;
    } 
    elsif (/^\cM?.* suspicion:\s+(\S+)\s*$/) {
      # INVADE.ZIP/INVADE.COM suspicion: Type_BootComExeTSR
      push @suspicious, $1;
    }
    elsif (/^\cM?.* warning:\s+(\S+)\s*$/) {
      # ICE-1.ZIP/ICE-1.EXE warning: Trojan.Kermit.a 
      push @infected, $1;
    }
  }
  my $description = "infected by: ".join(", ",@infected)."; "
                ."suspicions: ".join(", ", @suspicious);
  # else we may get a veeeery long X-Virus-Details: line or log entry
  #  max 60 chars for the description 
  #    + 17 chars for 'X-Virus-Details: '
  #    +  1 newline
  # -------------------
  #  max 78 chars for the header... should be ok
  $description = substr($description,0,60); 

  $self->log(1, "Found a virus: $description");
  # ok, this works now :-)
  if ($self->qp->config("kav_deny")) {
    foreach my $d ($self->qp->config("kav_deny")) {
      foreach my $v (@infected) {
        if ($v =~ /^$d$/i) {
          $self->log(1, "Denying mail with virus '$v'");
          return(DENY, "Virus found: $description");
        }
      }
      foreach my $s (@suspicious) {
        if ($s =~ /^$d$/i) {
          $self->log(1, "Denying a suspicious content: $s");
          return(DENY, "Virus found: $description");
        }
      }
    }
  }
  $transaction->header->add('X-Virus-Found', 'Yes', 0);
  $transaction->header->add('X-Virus-Details', $description, 0);
  ### maybe the spamassassin plugin can skip this mail if a virus
  ### was found (and $transaction->notes('virus_flag') exists :))
  ### ...ok, works with our spamassassin plugin version 
  ###   -- hah
  $transaction->notes('virus', $description);
  $transaction->notes('virus_flag', 'Yes');

  #### requires modification of Qpsmtpd/Transaction.pm:
  # if ($self->{_to_virusadmin}) {
  #   my @addrs = ();
  #   foreach (@{$transaction->recipients}) {
  #     push @addr, $_->address;
  #   }
  #   $transaction->header->add('X-Virus-Orig-RcptTo', join(", ", @addrs), 0);
  #   $transaction->set_recipients(@{ Mail::Address->parse($self->{_to_virusadmin}) });
  # } elsif ($self->{_bcc_virusadmin}) {
  if ($self->{_bcc_virusadmin}) {
    foreach ( @{ Mail::Address->parse($self->{_bcc_virusadmin}) } ) {
      $transaction->add_recipient($_->address);
    }
  }
  $self->log(1, "results: $description");
 
  $transaction->header->add('X-Virus-Checked', 'Checked by '.$self->qp->config("me"), 0);
  return (DECLINED);
} 

# vim: ts=2 sw=2 expandtab
