Skip to content

Instantly share code, notes, and snippets.

@dex4er
Last active December 27, 2015 06:09
Show Gist options
  • Select an option

  • Save dex4er/7279409 to your computer and use it in GitHub Desktop.

Select an option

Save dex4er/7279409 to your computer and use it in GitHub Desktop.
exifilter - Pass-through filter which scans mail for viruses
#!/usr/bin/perl
## exifilter
##
## (c) 2003-2004 Piotr Roszatycki <dexter@debian.org>, GPL
##
## $Id: exifilter.pl 161 2004-09-01 11:04:11Z dexter $
=head1 NAME
exifilter - Pass-through filter which scans mail for viruses.
=head1 SYNOPSIS
B<exifilter> S<B<-h>>
B<exifilter>
S<[B<--no-cleanup>]>
S<[B<--debug-stderr>]>
S<[B<--debug-logfile>]>
S<[B<--test>]>
S<[B<--tmpdir> I<directory>]>
S<[B<--logfile> I<path>]>
S<[B<--scanner> B<none>|B<clamav>|B<clamavd>]>
S<[B<--timeout-scan> I<seconds>]>
S<[B<--pipe> I<command>]>
I<primary_hostname>
I<message_id>
=cut
use 5.006;
use strict;
##############################################################################
## Default values for constant variables
##
## Program name
my $NAME = "exifilter";
## Program version
my $VERSION = 0.2;
## Path for temporary files
my $TMPDIR = "/tmp";
## Path for log files
my $LOGFILE = "/var/log/exifilter/%s.log";
## Default scanner
my $SCANNER = "clamavd";
## Default timeout time for scanner
my $TIMEOUT_SCAN = 180;
## RLIMIT_* variables, see BSD::Resource(3)
## undef means default value
##
## file size (bytes), causes kill with SIGXFSZ
my $RLIMIT_FSIZE = 100*1024*1024;
## number of open files, causes i/o function error EMFILE
my $RLIMIT_OPEN_MAX = 20;
## virtual address space (bytes)
my $RLIMIT_AS = 100*1024*1024;
## CPU time (seconds), causes kill with SIGXCPU, after 10 sec. kill with SIGKILL
my $RLIMIT_CPU = 60;
##############################################################################
## Default file names
##
## Time stamp file
my $stampfile = "timestamp";
## Original mail from STDIN
my $mailfile = "mail";
## Definitions for antivirus scanners
##
my %scanners = (
'testok' => {
cmd =>
'ls -l <TMPDIR>/<FILE>',
found_regexp => '^-',
status => {
0 => "OK",
1 => "Unknown error" } },
'testvirus' => {
cmd =>
'ls -l <TMPDIR>/<FILE>',
found_regexp => '^-',
status => {
0 => "VIRUS",
1 => "Unknown error" } },
'clamav' => {
cmd =>
'clamscan --stdout --recursive --infected --no-summary --mbox '
. '--max-files=500 --max-space=20m --max-recursion=5 '
. '<FILE>',
found_regexp => 'mail: (.*\sFOUND)$',
status => {
0 => "OK",
1 => "VIRUS",
40 => "Unknown option passed.",
50 => "The database can't be initialized.",
52 => "Not supported file type.",
53 => "Can't open directory.",
54 => "Can't open file. (ofm)",
55 => "Error reading file. (ofm)",
56 => "Can't stat input file / directory.",
57 => "Can't get absolute pathname of current working directory.",
58 => "I/O error, check your filesystem.",
59 => "Can't get information about current user from /etc/passwd.",
60 => "Can't get information about user 'clamav' (default name) from /etc/passwd.",
61 => "Can't fork.",
63 => "Can't create temporary files/directories (check permissions).",
64 => "Can't write to temporary directory, please specify another one.",
70 => "Can't allocate and clear memory (calloc).",
71 => "Can't allocate memory (malloc)." } },
'clamavd' => {
cmd =>
'clamdscan --stdout --disable-summary <FILE>',
found_regexp => 'mail: (.*\sFOUND)$',
status => {
0 => "OK",
1 => "VIRUS",
2 => "An error occured." } },
);
## Signals to handle
##
my @signals = qw( HUP INT QUIT TERM SEGV PIPE XCPU XFSZ );
## Explains of system errors
##
my %explains = (
'???' => "The message couldn't be scanned. The scanning process stopped\n"
."unexpectedly. It could be a system error or message contained\n"
. "too big attachments or it was causedby DoS attack on mail server.",
'1' => "The message or its attachment contained virus code.",
'2' => "The message couldn't be scanned. It could be a system error or\n"
. "message contained too big attachments or it was causedby DoS attack\n"
. "on mail server.",
'SIGXCPU' => "Scanning process was too exhausting.",
'SIGXFSZ' => "The message contained too big attachments.",
);
##############################################################################
## Internal handlers and global variables
##
## Getopt::Long handler
my %opt = (
'tmpdir' => $TMPDIR,
'logfile' => $LOGFILE,
'scanner' => $SCANNER,
'timeout-scan' => $TIMEOUT_SCAN,
);
## Temporary directory
my $tmpdir;
## PID for fork function
my $pid;
## Flag if mail is already dumped to file
my $is_mail_dumped;
## Primary hostname used in headers
my $primary_hostname;
## Message id used in logging and archiving
my $message_id;
##############################################################################
## date_rfc822()
##
## Return date in rfc822 format.
## Based on 822-date from dpkg package.
##
sub date_rfc822() {
my $curtime = time;
my @localtm = localtime($curtime);
my $localtms = localtime($curtime);
my @gmttm = gmtime($curtime);
my $gmttms = gmtime($curtime);
if ($localtm[0] != $gmttm[0]) {
die(sprintf("local timezone differs from GMT by a non-minute interval\n"
. "local time: %s\n"
. "GMT time: %s\n", $localtms, $gmttms));
}
my $localmin = $localtm[1] + $localtm[2] * 60;
my $gmtmin = $gmttm[1] + $gmttm[2] * 60;
if ((($gmttm[6] + 1) % 7) == $localtm[6]) {
$localmin += 1440;
} elsif ((($gmttm[6] - 1) % 7) == $localtm[6]) {
$localmin -= 1440;
} elsif ($gmttm[6] == $localtm[6]) {
1;
} else {
die "822-date: local time offset greater than or equal to 24 hours\n";
}
my $offset = $localmin - $gmtmin;
my $offhour = $offset / 60;
my $offmin = abs($offset % 60);
if (abs($offhour) >= 24) {
die "822-date: local time offset greater than or equal to 24 hours\n";
}
return sprintf(
"%s, %2d %s %d %02d:%02d:%02d %s%02d%02d",
qw(Sun Mon Tue Wed Thu Fri Sat)[$localtm[6]], # day of week
$localtm[3], # day of month
qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec)[$localtm[4]], # month
$localtm[5]+1900, # year
$localtm[2], # hour
$localtm[1], # minute
$localtm[0], # sec
($offset >= 0) ? '+' : '-',# TZ offset direction
abs ($offhour), # TZ offset hour
$offmin, # TZ offset minute
);
}
## debug($msg, ...)
##
## Dumps message if debug mode is turned on.
##
sub debug(@) {
if (${opt{'debug-logfile'}}) {
open LOG, sprintf ">>" . $opt{'logfile'}, "debug" and
print LOG scalar localtime() . " " . $NAME . "[" . $$ . "]: " . join('',@_) . "\n";
close LOG;
}
if (${opt{'debug-stderr'}}) {
print STDERR scalar localtime() . " " . $NAME . "[" . $$ . "]: " . join('',@_) . "\n";
}
}
## error($msg, ...)
##
## Pass through and let to die.
##
sub error(@) {
open LOG, sprintf ">>" . $opt{'logfile'}, "error" and
print LOG scalar localtime() . " " . $NAME . "[" . $$ . "]: " . join('',@_) . "\n";
close LOG;
if (${opt{'debug-stderr'}}) {
print STDERR scalar localtime() . " " . $NAME . "[" . $$ . "]: " . join('',@_) . "\n";
}
if ($opt{pipe}) {
debug("open pipe $opt{pipe}");
open PIPE, "|$opt{pipe}"
or die "Can not open pipe `$opt{pipe}': $!\n";
open OUT, ">&PIPE";
} else {
open OUT, ">&STDOUT";
}
if (not $is_mail_dumped) {
while ($_ = <STDIN>) {
print OUT or die "Can not write to output: $!\n";
}
} else {
open MAIL, "$tmpdir/$mailfile" or die "Could not open file $tmpdir/$mailfile: $!\n";
while ($_ = <MAIL>) {
print OUT or die "Can not write to output: $!\n";
}
close MAIL;
}
if ($opt{pipe}) {
debug("close pipe $opt{pipe}");
close PIPE;
}
cleanup();
exit 0;
}
## usage()
##
## Prints usage message.
##
sub usage() {
eval 'use Pod::Usage;';
pod2usage(2);
}
## help()
##
## Prints help message.
##
sub help() {
eval 'use Pod::Usage;';
pod2usage(-verbose=>1, -message=>"$NAME $VERSION\n");
}
## $path = replace_XX($template)
##
## Replace "X" characters with randomize chars. Used by mktempdir() function.
##
sub replace_XX($) {
my ($path) = @_;
my @CHARS = (qw/ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
a b c d e f g h i j k l m n o p q r s t u v w x y z
0 1 2 3 4 5 6 7 8 9 _
/);
$path =~ s/X(?=X*\z)/$CHARS[ int( rand( $#CHARS ) ) ]/ge;
return $path;
}
## $path = mktempdir($template)
##
## Create temporary directory.
##
sub mktempdir($) {
my ($template) = @_;
my $MAX_TRIES = 50;
my $OPENFLAGS = 194; # O_CREAT | O_EXCL | O_RDWR
for (my $i = 0; $i < $MAX_TRIES; $i++) {
my $fh;
my $path = replace_XX($template);
## Store callers umask
my $umask = umask();
## Set a known umask
umask(026);
# Open the temp directory
if (mkdir($path, 0750)) {
# created okay
# Reset umask
umask($umask) if defined $umask;
return $path;
} else {
# Reset umask
umask($umask) if defined $umask;
# Abort with error if the reason for failure was anything
# except EEXIST
unless ($!{EEXIST}) {
error("Could not create directory $path: $!");
}
# Loop round for another try
}
}
}
## $status = rmsubtree($path, $dh)
##
## Remove directory tree with named directory handler.
## Used by rmtree() function.
##
sub rmsubtree($$) {
my ($path, $dh) = @_;
$dh++;
no strict 'refs';
opendir $dh, $path or return 0;
while ($_ = readdir $dh) {
if ($_ ne "." and $_ ne "..") {
if (-d "$path/$_") {
chdir $_;
rmsubtree("$path/$_", $dh) or return 0;
} else {
unlink "$path/$_" or return 0;
}
}
}
closedir $dh;
rmdir $path or return 0;
return 1;
}
## $status = rmtree($path)
##
## Remove directory tree.
##
sub rmtree($) {
my ($path) = @_;
return rmsubtree($path, "DIR000");
}
## cleanup($path)
##
## Cleanup before die.
##
sub cleanup() {
return if $opt{'no-cleanup'};
debug("rmtree $tmpdir");
rmtree($tmpdir) if $tmpdir;
}
## die_main($msg, ...)
##
## Dump the message and really die.
##
sub die_main(@) {
cleanup();
debug(@_);
die @_;
}
## signal_main($sig)
##
## Handler for signals which cleans up temporary directory
##
sub signal_main($) {
my ($sig) = @_;
die "Caught a SIG$sig -- shutting down\n";
}
## scan()
##
## Spawn scanner process
##
sub scan() {
return 1 if $opt{scanner} eq "none";
## die handler
$SIG{__DIE__} = sub { die @_; };
my $scanpid;
use Data::Dumper;
debug $opt{'scanner'};
my $cmd = $scanners{$opt{'scanner'}}->{cmd};
$cmd =~ s|<FILE>|$mailfile|g;
$cmd =~ s|<DIRECTORY>|.|g;
$cmd =~ s|<TMPDIR>|$tmpdir|g;
chdir $tmpdir or die "Can not chdir to $tmpdir: $!\n";
debug("running $cmd");
## Fork for exec
if (! defined($scanpid = fork)) {
error("Could not fork: $!");
} elsif ($scanpid == 0) {
## child
exec $cmd or die "Can not run command `$cmd': $!\n";
}
## parent
eval {
local $SIG{ALRM} = sub { die "timeout-scan\n"; };
alarm $opt{'timeout-scan'};
wait;
alarm 0;
};
if ($@ eq "timeout-scan\n") {
## timeouted
kill 9, $scanpid;
die "Scanner command was timeouted\n";
}
## Check status
$_ = $? & 0xffff;
if ($_ & 0x00ff) {
die "Can not run command `$cmd': $!\n";
} else {
$_ >>= 8;
my $status = (defined($scanners{$opt{scanner}}->{status}->{$_})
? $scanners{$opt{scanner}}->{status}->{$_}
: "Unknown");
if ($status eq "OK") {
return 1;
} elsif ($status eq "VIRUS") {
return 0;
} else {
die sprintf "Error on command `$cmd': $_ (%s)\n", $status;
}
}
}
## output(@msg)
##
## Output the mail with additional headers.
##
sub output(@) {
my @msg = @_;
if ($opt{pipe}) {
&debug("open pipe $opt{pipe}");
open PIPE, "|$opt{pipe}"
or die "Can not open pipe `$opt{pipe}': $!\n";
open OUT, ">&PIPE";
} else {
open OUT, ">&STDOUT";
}
open MAIL, "$tmpdir/$mailfile" or die "Could not open file $tmpdir/$mailfile: $!\n";
while ($_ = <MAIL>) {
last if $_ =~ /^$/;
next if $_ =~ /^X-Exifilter[:-]/;
print OUT or die "Can not write to output: $!\n";
}
print OUT "X-Exifilter: $NAME $VERSION on $primary_hostname\n" or die "Can not write to output: $!\n";
if (@msg) {
print OUT "X-Exifilter-Virus: YES\n" or die "Can not write to output: $!\n";
}
my $regexp = $scanners{$opt{scanner}}{found_regexp};
foreach (@msg) {
/$regexp/ or next;
$_ = $1 if $1;
print OUT "X-Exifilter-Detail: $_\n" or die "Can not write to output: $!\n";
}
print OUT or die "Can not write to output: $!\n";
while (<MAIL>) {
print OUT or die "Can not write to output: $!\n";
}
if ($opt{pipe}) {
&debug("close pipe $opt{pipe}");
close PIPE;
}
close MAIL;
}
##############################################################################
## Main subroutine
##
## die handler
$SIG{__DIE__} = \&die_main;
## clean exit for signals
foreach my $sig (@signals) {
$SIG{$sig} = \&signal_main;
}
## Parse command line arguments
while (@ARGV) {
my $arg = $ARGV[0];
if ($arg =~ /^--(.*)$/) {
my $opt = $1;
if ($opt eq 'test') {
$opt{'no-cleanup'} = 1;
$opt{'debug-stderr'} = 1;
} elsif ($opt =~ /^(tmpdir|scanner|timeout-scan|pipe)$/) {
usage() unless @ARGV;
shift;
$arg = $ARGV[0]
} elsif ($opt =~ /^(no-cleanup|debug-stderr|debug-logfile)$/) {
$arg = 1;
} elsif ($opt eq 'help') {
help();
} else {
usage();
}
$opt{$opt} = $arg;
} else {
help() if $arg eq '-h';
last;
}
shift @ARGV;
}
usage() if @ARGV > 2;
if (@ARGV < 1) {
if ($opt{'test'}) {
$primary_hostname = '$primary_hostname';
} else {
eval 'use Sys::Hostname;';
$primary_hostname = hostname();
}
} else {
$primary_hostname = $ARGV[0];
}
if (@ARGV < 2) {
if ($opt{'test'}) {
$message_id = '$message_id';
} else {
$message_id = sprintf "%10d-%05d", time, $$;
}
} else {
$message_id = $ARGV[1];
}
## Read permission for group. Scanner daemon should read the file.
umask(0027);
## Create tmpdir
$tmpdir = mktempdir("$opt{tmpdir}/${NAME}XXXXXXXXX");
debug("mktempdir $tmpdir");
## Test the writting to filesystem
open STAMP, ">$tmpdir/$stampfile" or error("Could not create file $tmpdir/$stampfile: $!");
print STAMP scalar localtime or error("Could not write file $tmpdir/$stampfile: $!\n");
close STAMP or error("Could not close file $tmpdir/$stampfile: $!");
## Dump the mail and die if it is not possible
open MAIL, ">$tmpdir/$mailfile" or error("Could not create file $tmpdir/$mailfile: $!");
$is_mail_dumped = 1;
while (<STDIN>) {
print MAIL or die "Could not write file $tmpdir/$mailfile: $!\n";
}
close MAIL or die "Could not close file $tmpdir/$mailfile: $!\n";
## Fork with unnamed pipe
pipe(PARENT_RDR, CHILD_WTR);
if (! defined($pid = fork)) {
error("Could not fork: $!");
} elsif ($pid == 0) {
## child
close PARENT_RDR;
open STDOUT, ">&CHILD_WTR" or die "Can not redirect stdout: $!\n";
open STDERR, ">&CHILD_WTR" or die "Can not redirect stderr: $!\n";
eval {
$_ = scan();
};
## 0 = ok, 1 = virus, 2 = error
if ($@ || ($_ > 1 && $_ < 0)) {
debug($@);
debug("exit 2");
exit 2;
} elsif ($_ == 0) {
debug("exit 1");
exit 1;
} else {
debug("exit 0");
exit 0;
}
}
## parent
my @lines;
close CHILD_WTR;
while ($_ = <PARENT_RDR>) {
chomp;
push @lines, $_;
}
close PARENT_RDR;
wait;
if ($?>>8 == 1) {
debug("Virus found in message $message_id");
output(@lines);
} elsif ($?>>8 == 0) {
debug("Clean message $message_id");
output();
} else {
error("Error in scanning process for message $message_id");
}
## Clean up tmpdir
cleanup();
## Clean exit
exit 0;
__END__
=head1 DESCRIPTION
Exifilter is a pass-through filter (like i.e. SpamAssassin) which
includes own headers into the mail. It means the mail is taken from
stdin and given back to stdout.
If virus was detected, the "X-Exifilter-Virus: YES" header is set. The
detailed output of scanning process is included in "X-Exifilter-Detail:"
headers.
If scanner process is failed because of unexpected error, the mail is
copied from stdin to stdout without modifications.
Can be used as transport filter with Exim MTA, i.e.:
transport_filter = /usr/bin/exifilter --scanner clamavd \
--pipe "spamc -u nobody" --debug-logfile \
$primary_hostname $message_id
or:
# Main section
trusted_users = mail:www-data:exifilter
# Exifilter transport
exifilter_pipe:
driver = pipe
bsmtp = all
command = /usr/sbin/exim -oMr exifilter-bsmtp -bS
transport_filter = /usr/bin/exifilter --debug-logfile \
$primary_hostname $message_id
home_directory = "/tmp"
current_directory = "/tmp"
# must use a privileged user to set $received_protocol on the way back in!
user = exifilter
group = exifilter
prefix =
suffix =
log_output = true
return_output = false
return_fail_output = true
return_path_add = false
# Exifilter director
exifilter_incoming:
driver = smartuser
verify = false
expn = false
condition = "${if !eq {$received_protocol}{exifilter-bsmtp}\
{1}{0}\
}"
transport = exifilter_pipe
# Exifilter router
exifilter_outgoing:
driver = domainlist
route_list = "*"
verify = false
expn = false
condition = "${if !eq {$received_protocol}{exifilter-bsmtp}\
{1}{0}\
}"
transport = exifilter_pipe
The integration with exim3conf is even simpler. In the
F</etc/exim3conf/conf.d/local> file:
EXIFILTER_FEATURE = true
EXIFILTER_DEBUG_LOGFILE = true
More sofisticated condition is also possible:
EXIFILTER_INCOMING_LOCAL_CONDITION = eq {$domain}{just.for.this.example.com}
If you want to filter out the virused messages, you can use system filter:
The F<exim.conf> file:
message_filter = /path/to/system_filter
message_filter_directory_transport = address_directory
And the F<system_filter> file:
# Exim filter
if $h_X-Exifilter-Virus is "YES"
then
logfile /var/log/exim/filterlog 0640
logwrite "$tod_log - filter: *** Exifilter *** - sender: $sender_address -
subject: $h_subject: - id: $message_id - detail: $h_X-Exifilter-Detail"
if $h_X-Exifilter-Detail does not match "(Worm|Trojan)"
then
save /var/spool/exifilter/ 0640
endif
seen finish
endif
=head1 SECURITY
Exifilter should be started with its own privileges on separated user
and group. It requires log directory so you should create it with
command:
# adduser --system --group exifilter
# install -d -m0750 -o exifilter -g adm /var/log/exifilter
Exim runs system filter transport with its own privileges. In Debian
distribution it is B<mail> user and group. If you want to save mails
contained viruses, create it with command:
# install -d -m0750 -o mail -g adm /var/spool/exifilter
Exifilter temporary files are readable by group. It allows to permit the
antivirus scanner daemon to read this files after adding the daemon user
to the exifilter group.
# adduser clamav exifilter
=head1 OPTIONS
=over 8
=item B<--no-cleanup>
Don't clean up temporary directory.
=item B<--debug-stderr>
Turn on debug mode on stderr.
=item B<--debug-logfile>
Turn on debug mode on log file.
=item B<--test>
Alias for --no-cleanup --debug-stderr '$primary_hostname' '$message_id'
=item S<B<--tmpdir> I<directory>>
Localization of temporary directories.
Default is F</tmp>.
=item S<B<--logfile> I<path>>
The path to log file in printf(3) format. The default is
F</var/log/exifilter/%s.log>.
=item S<B<--scanner> B<testok>|B<testvirus>|B<clamav>|B<clamavd>>
Scanner used to scan for viruses. If set to B<testok>, the scanner is
not used and no virus status is returned. If set to B<testvirus>, the
scanner is not used and virus found status is returned.
=item S<B<--timeout-scan> I<seconds>>
Timeout for scan process.
=item S<B<--pipe> I<command>>
Output mail to I<command> through pipe than stdout. The arguments of commands
are separated by space.
=item B<-h>|B<--help>
This help.
=item I<primary_hostname>
This specifies the name of the current host. The same as
$primary_hostname variable in Exim MTA. If missing, the gethostname(2)
function is used.
=item I<message_id>
The unique message id used for logging and archiving purposes. The same
as $message_id variable in Exim MTA. If missing, the message id is
generated based on unix time and actual PID.
=back
=head1 PROBLEMS
If Exim reports "delivery filter process failed (2)", check if B<exifilter>
is called with properly arguments (C<transport_filter> configuration option in
F<exim.conf>).
If Exim reports "delivery filter process failed (69)", check if path to
B<exifilter> is correctly in C<transport_filter> configuration option.
If Exim reports "defer (-25): Filter process failure", it means serious internal
error, i.e. SIGSEGV.
=head1 SEE ALSO
B<exim>(8), B<clamscan>(1), B<clamdscan>(1)
=head1 AUTHOR
(c) 2003-2004 Piotr Roszatycki E<lt>dexter@debian.orgE<gt>
All rights reserved. This program is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public License, the
latest version.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment