1#!perl -w
2
3=head1 NAME
4
5clamav -- ClamAV antivirus plugin for qpsmtpd
6
7=head1 DESCRIPTION
8
9This plugin scans incoming mail with the clamav A/V scanner, and can at your
10option reject or flag infected messages.
11
12=head1 CONFIGURATION
13
14Arguments to clamav should be specified in the form of name=value pairs,
15separated by whitespace.  For sake of backwards compatibility, a single
16leading argument containing only alphanumerics, -, _, . and slashes will
17be tolerated, and interpreted as the path to clamscan/clamdscan.  All
18new installations should use the name=value form as follows:
19
20=over 4
21
22=item clamscan_path=I<path> (e.g. I<clamscan_path=/usr/bin/clamdscan>)
23
24Path to the clamav commandline scanner.  Mail will be passed to the clamav
25scanner in Berkeley mbox format (that is, with a "From " line).  See the
26discussion below on which commandline scanner to use.
27
28=item clamd_conf=I<path> (e.g. I<clamd_conf=/etc/sysconfig/clamd.conf>)
29
30Path to the clamd configuration file. Passed as an argument to the
31command-line scanner (--config-file=I<path>).
32
33The default value is '/etc/clamd.conf'.
34
35=item action=E<lt>I<add-header> | I<reject>E<gt> (e.g. I<action=reject>)
36
37Selects an action to take when an inbound message is found to be infected.
38Valid arguments are 'add-header' and 'reject'.  All rejections are hard
395xx-code rejects; the SMTP error will contain an explanation of the virus
40found in the mail (for example, '552 Virus Found: Worm.SomeFool.P').
41
42The default action is 'add-header'.
43
44=item max_size=I<bytes> (e.g. I<max_size=1048576>)
45
46Specifies the maximum size, in bytes, for mail to be scanned.  Any mail
47exceeding this size will be left alone.  This is recommended, as large mail
48can take an exceedingly long time to scan.  The default is 524288, or 512k.
49
50=item tmp_dir=I<path> (e.g. I<tmp_dir=/tmp>)
51
52Specify an alternate temporary directory.  If not specified, the qpsmtpd
53I<spool_dir> will be used.  If neither is available, I<~/tmp/> will be tried,
54and if that that fails the plugin will gracefully fail.
55
56=item back_compat
57
58If you are using a version of ClamAV prior to 0.80, you need to set this
59variable to include a couple of now deprecated options.
60
61=back
62
63=head2 CLAMAV COMMAND LINE SCANNER
64
65You can use either clamscan or clamdscan, but the latter is recommended for
66sake of performance.  However, in this case, the user executing clamd
67requires access to the qpsmtpd spool directory, which usually means either
68running clamd as the same user as qpsmtpd does (by far the easiest method)
69or by doing the following:
70
71=over 4
72
73=item * Change the group ownership of the spool directory to be a group
74of which clamav is a member or add clamav to the same group as the qpsmtpd
75user.
76
77=item * Enable the "AllowSupplementaryGroups" option in clamd.conf.
78
79=item * Change the permissions of the qpsmtpd spool directory to 0750 (this
80will emit a warning when the qpsmtpd service starts up, but can be safely
81ignored).
82
83=item * Make sure that all directories above the spool directory (to the
84root) are g+x so that the group has directory traversal rights; it is not
85necessary for the group to have any read rights except to the spool
86directory itself.
87
88=back
89
90It may be helpful to temporary grant the clamav user a shell and test to
91make sure you can cd into the spool directory and read files located there.
92Remember to remove the shell from the clamav user when you are done
93testing.
94
95
96=head2 CLAMAV CONFIGURATION
97
98At the least, you should have 'ScanMail' supplied in your clamav.conf file.
99It is recommended that you also have sane limits on ArchiveMaxRecursion and
100StreamMaxLength also.
101
102=head1 LICENSE
103
104This plugin is licensed under the same terms as the qpsmtpd package itself.
105Please see the LICENSE file included with qpsmtpd for details.
106
107=cut
108
109use strict;
110use warnings;
111
112use Qpsmtpd::Constants;
113
114sub register {
115    my ($self, $qp, @args) = @_;
116    my %args;
117
118    if ($args[0] && $args[0] =~ /^(\/[\/\-\_\.a-z0-9A-Z]*)$/ && -x $1) {
119        $self->{_clamscan_loc} = $1;
120        shift @args;
121    }
122
123    for (@args) {
124        if (/^max_size=(\d+)$/) {
125            $self->{_max_size} = $1;
126        }
127        elsif (/^clamscan_path=(\/[\/\-\_\.a-z0-9A-Z]*)$/) {
128            $self->{_clamscan_loc} = $1;
129        }
130        elsif (/^clamd_conf=(\/[\/\-\_\.a-z0-9A-Z]*)$/) {
131            $self->{_clamd_conf} = "$1";
132        }
133        elsif (/^tmp_dir=(\/[\/\-\_\.a-z0-9A-Z]*)$/) {
134            $self->{_spool_dir} = $1;
135        }
136        elsif (/^action=(add-header|reject)$/) {
137            $self->{_action} = $1;
138        }
139        elsif (/back_compat/) {
140            $self->{_back_compat} = '-i --max-recursion=50';
141        }
142        elsif (/declined_on_fail/) {
143            $self->{_declined_on_fail} = 1;
144        }
145        else {
146            $self->log(LOGERROR, "Unrecognized argument '$_' to clamav plugin");
147            return undef;
148        }
149    }
150
151    $self->{_max_size}    ||= 512 * 1024;
152    $self->{_spool_dir}   ||= $self->spool_dir();
153    $self->{_back_compat} ||= '';                   # make sure something is set
154    $self->{_clamd_conf}  ||= '/etc/clamd.conf';    # make sure something is set
155    $self->{_declined_on_fail} ||= 0;    # decline the message on clamav failure
156
157    unless ($self->{_spool_dir}) {
158        $self->log(LOGERROR, "No spool dir configuration found");
159        return undef;
160    }
161    unless (-d $self->{_spool_dir}) {
162        $self->log(LOGERROR, "Spool dir $self->{_spool_dir} does not exist");
163        return undef;
164    }
165
166}
167
168sub hook_data_post {
169    my ($self, $transaction) = @_;
170
171    if ($transaction->data_size > $self->{_max_size}) {
172        $self->log(LOGWARN,
173                       'Mail too large to scan ('
174                     . $transaction->data_size
175                     . " vs $self->{_max_size})"
176                  );
177        return (DECLINED);
178    }
179
180    my $filename = $transaction->body_filename;
181    unless (defined $filename) {
182        $self->log(LOGWARN, "didn't get a filename");
183        return DECLINED;
184    }
185    my $mode = (stat($self->{_spool_dir}))[2];
186    if ($mode & 07077) {    # must be sharing spool directory with external app
187        $self->log(LOGWARN,
188                   "Changing permissions on file to permit scanner access");
189        chmod $mode, $filename;
190    }
191
192    # Now do the actual scanning!
193    my $cmd =
194        $self->{_clamscan_loc}
195      . " --stdout "
196      . $self->{_back_compat}
197      . " --config-file="
198      . $self->{_clamd_conf}
199      . " --no-summary $filename 2>&1";
200    $self->log(LOGDEBUG, "Running: $cmd");
201    my $output = `$cmd`;
202
203    my $result = ($? >> 8);
204    my $signal = ($? & 127);
205
206    chomp($output);
207
208    $output =~ s/^.* (.*) FOUND$/$1 /mg;
209
210    $self->log(LOGINFO, "clamscan results: $output");
211
212    if ($signal) {
213        $self->log(LOGINFO, "clamscan exited with signal: $signal");
214        return (DENYSOFT) if (!$self->{_declined_on_fail});
215        return (DECLINED);
216    }
217    if ($result == 1) {
218        $self->log(LOGINFO, "Virus(es) found: $output");
219        if ($self->{_action} eq 'add-header') {
220            $transaction->header->add('X-Virus-Found',   'Yes');
221            $transaction->header->add('X-Virus-Details', $output);
222        }
223        else {
224            return (DENY, "Virus Found: $output");
225        }
226    }
227    elsif ($result) {
228        $self->log(LOGERROR, "ClamAV error: $cmd: $result\n");
229        return (DENYSOFT) if (!$self->{_declined_on_fail});
230    }
231    else {
232        $transaction->header->add('X-Virus-Checked',
233                             "Checked by ClamAV on " . $self->qp->config("me"));
234    }
235    return (DECLINED);
236}
237
238