=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 where is a comma-separated list of suffixes to check e.g. check exe,zip A corresponding 'signature_' 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 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 , 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