=head1 NAME

exe_filter

=head1 DESCRIPTION

exe_filter blocks executable (and other) attachments by matching the
first body line of each MIME part in a message against a set of known
signatures. If a match is found, the email is denied.

Signatures are stored one per line in signature files in the qpsmtpd
config directory. exe_filter currently supports 'signature_exe' and 
'signature_zip' files.

This version uses Simon Cozen's Email::MIME module, rather than 
reimplementing the MIME wheel.

=head1 CONFIG

The following parameters can be passed to exe_filter, or set in a
'exe_filter' config file.

=over 4

=item check <suffixes>

where <suffixes> is a comma-separated list of suffixes to check e.g.

  check exe,zip

A corresponding 'signature_<suffix>' file should exist for each supplied
suffix.

Default: 'check exe'.

Note: this argument used to be called 'deny', which is now deprecated but 
still functional.

=item action <action>

The action to take when a signature match is found. Valid values are 'deny'
(the default), to DENY the mail, and 'note', to record a transaction note 
for some later plugin (and then DECLINE). If action is 'note', the default 
note name is 'virus_score', with a default value of 1. These defaults can 
be modified using an extended note syntax - 'note:NAME=VALUE' e.g.

  action note:virus_score=1                # default settings
  action note:exe_filter=virus_found       # random example

Numeric note values are accumulated, not replaced.

Default: 'action deny'.

=back

The following parameter can be passed to exe_filter in config/plugins (but 
not set via a config file):

=over 4

=item per_recipient 1

Allow per-recipient configs to be used (using the per_user_config plugin).
Default: 0.

=back


=head1 BUGS AND LIMITATIONS

exe_filter is a simple mime part filter - it does not unpack and scan
archives for executables like a full-blown virus scanner. Likewise, zip
filtering blocks *all* zip files, not just those that contain a virus. You
should use a proper virus scanner if that's what you need.

exe_filter slurps the entire email into memory and uses Email::MIME to do
the mime parsing, so it's reasonably memory hungry. You may find you need
to increase your memory softlimits if running under tcpserver.

Because exe_filter is a post_data plugin, it cannot handle different
configurations in per_recipient mode. This means that if you want to use
per_recipient configurations, you should also enforce that only compatible
recipients occur in a single mail (e.g. using a plugin like
denysoft_multi_rcpt).


=head1 AUTHOR

Written by Gavin Carr <gavin@openfusion.com.au>, inspired by Russ Nelson's 
viruscan patch to qmail-smtpd 
(http://www.qmail.org/qmail-smtpd-viruscan-1.2.patch).

=cut

use Email::MIME;

my $VERSION = 0.04;
my %DEFAULTS = ( deny => 'exe', action => 'deny', per_recipient => 0 );

sub register {
  my ($self, $qp, %arg) = @_;
  $self->{_config_defaults} = { %DEFAULTS, %arg };
  $self->register_hook("rcpt", "setup_config") if $arg{per_recipient};
  $self->register_hook("data_post", "filter_exe");
}

sub setup_config {
  my ($self, $transaction, $rcpt) = @_;

  # Setup only once
  return DECLINED if $self->{_config};
  return DECLINED 
    unless ref $self->{_config_defaults} eq 'HASH';

  # Setup config from defaults and per_recipient exe_filter config
  my @config = $self->qp->config('exe_filter', { rcpt => $rcpt });
  $self->{_config} = { 
    %{$self->{_config_defaults}},
    rcpt => $rcpt,
    @config ? map { split /\s+/, $_, 2 } @config : ()
  };
  return DECLINED;
}

sub check_exe {
  my ($self, $mail, $sig) = @_;
  my @parts = $mail->parts;

  # Check line1 of body
  my $body = $mail->body_raw;
  if (defined $body) {
    my ($line1) = ($body =~ m/^(.*?)\n/);
    $self->log(8,"checking line1: $line1");
    for my $suffix (sort keys %$sig) {
      for my $s (@{$sig->{$suffix}}) {
        next unless $s;
        if ($line1 =~ m/^\Q$s/) {
          # Match - deny!
          $self->log(6, "the following line matched $suffix sig '$s':\n$line1");
          return (DENY, "\U$suffix\E attachments are not accepted here.");
        }
      }
    }
  }

  # Check parts
  if (@parts > 1 || $parts[0] ne $mail) {
    for (@parts) {
      my ($status, $message) = $self->check_exe($_, $sig);
      return ($status, $message) unless $status == DECLINED;
    }
  }

  return DECLINED;
}

sub filter_exe {
  my ($self, $transaction) = @_;

  # Setup config parameters if not already done
  my $config = $self->{_config};
  unless ($config) {
    my @config = $self->qp->config('exe_filter'); 
    $config = {
      %{$self->{_config_defaults}},
      @config ? map { split /\s+/, $_, 2 } @config : ()
    };
  };
  $config->{check} ||= $config->{deny};
  return DECLINED unless $config->{check};

  # Load signatures
  my %sig = ();
  my $config_arg = $config->{rcpt} ? { rcpt => $config->{rcpt} } : {};
  for my $suffix (split /\s*,\s*/, $config->{check}) {
    my @sig = $self->qp->config("signatures_$suffix", $config_arg);
    $self->log(3, "warning - no signatures_$suffix loaded") unless @sig;
    $sig{$suffix} = \@sig if @sig;
  }
  return DECLINED unless keys %sig;

  # Reassemble the email for Email::MIME
  my $em;
  {
    my $mail = $transaction->header->as_string;
    $transaction->body_resetpos;
    $mail .= $_ while $_ = $transaction->body_getline;
    $em = Email::MIME->new($mail);
  }
  unless ($em) {
    $self->log(LOGERROR, "failed to instantiate Email::MIME object");
    return DECLINED;
  }

  # Parse mail and check all MIME parts
  my ($status,$msg) = $self->check_exe($em, \%sig);
  return ($status, $msg) 
    unless $config->{action} eq 'deny' and $config->{action} =~ m/^note/;

  # Set transaction note and decline
  my ($name,$value) = ($config->{action} =~ m/note(?::(\w+)(?:=(.+))?)?/);
  $name ||= 'virus_score';
  $value = 1 unless defined $value;
  if ($value + 0) {      # Increment if value is numeric
    $transaction->notes($name, $value + ($transaction->notes($name)||0));
  } else {
    $transaction->notes($name, $value);
  }
  return DECLINED; 
}

# arch-tag: 3fc272f2-9d52-42d4-893b-032b529ec71d
