1#!perl -w
2
3=head1 NAME
4
5clamdscan
6
7=head1 DESCRIPTION
8
9A qpsmtpd plugin for virus scanning using the ClamAV scan daemon, clamd.
10
11=head1 RESTRICTIONS
12
13The ClamAV scan daemon, clamd, must have at least execute access to the qpsmtpd
14spool directory in order to sucessfully scan the messages.  You can ensure this
15by running clamd as the same user as qpsmtpd does, or by doing the following:
16
17=over 4
18
19=item * Change the group ownership of the spool directory to be a group
20of which clamav is a member or add clamav to the same group as the qpsmtpd
21user.
22
23=item * Enable the "AllowSupplementaryGroups" option in clamd.conf.
24
25=item * Add group-execute permissions to the qpsmtpd spool directory.
26
27=item * Make sure that all directories above the spool directory (to the
28root) are g+x so that the group has directory traversal rights; it is not
29necessary for the group to have any read rights.
30
31=back
32
33It may be helpful to temporary grant the clamav user a shell and test to
34make sure you can cd into the spool directory and read files located there.
35Remember to remove the shell from the clamav user when you are done
36testing.
37
38=head1 INSTALL AND CONFIG
39
40Place this plugin in the plugin/virus directory beneath the standard
41qpsmtpd installation.  If you installed clamd with the default path, you
42can use this plugin with default options (nothing specified):
43
44You must have the ClamAV::Client module installed to use the plugin.
45
46=over 4
47
48=item B<clamd_socket>
49
50Full path to the clamd socket (the recommended mode), if different from the
51ClamAV::Client defaults.
52
53=item B<clamd_port>
54
55If present, must be the TCP port where the clamd service is running,
56typically 3310; default disabled.  If present, overrides the clamd_socket.
57
58=item B<deny_viruses>
59
60Whether the scanner will automatically delete messages which have viruses.
61Takes either 'yes' or 'no' (defaults to 'yes').  If set to 'no' it will add
62a header to the message with the virus results.
63
64=item B<defer_on_error>
65
66Whether to defer the mail (with a soft-failure error, which will incur a retry)
67if an unrecoverable error occurs during the scan.   The default is to accept
68the mail under these conditions.  This can permit viruses to be accepted when
69the clamd daemon is malfunctioning or unreadable, but will not allow mail to
70backlog or be lost if the condition persists.
71
72=item B<max_size>
73
74The maximum size, in kilobytes, of messages to scan; defaults to 128k.
75
76=item B<scan_all>
77
78Scan all messages, even if there are no attachments
79
80=back
81
82=head1 REQUIREMENTS
83
84This module requires the ClamAV::Client module, found on CPAN here:
85
86L<http://search.cpan.org/dist/ClamAV-Client/>
87
88=head1 AUTHOR
89
90Originally written for the Clamd module by John Peacock <jpeacock@cpan.org>;
91adjusted for ClamAV::Client by Devin Carraway <qpsmtpd/@/devin.com>.
92
93=head1 COPYRIGHT AND LICENSE
94
95 Copyright (c) 2005 John Peacock,
96 Copyright (c) 2007 Devin Carraway
97
98Based heavily on the clamav plugin
99
100This plugin is licensed under the same terms as the qpsmtpd package itself.
101Please see the LICENSE file included with qpsmtpd for details.
102
103=cut
104
105use strict;
106use warnings;
107
108#use ClamAV::Client;  # eval'ed in $self->register
109use Qpsmtpd::Constants;
110
111sub register {
112    my ($self, $qp) = shift, shift;
113
114    $self->log(LOGERROR, "Bad parameters for the clamdscan plugin") if @_ % 2;
115    $self->{'_args'} = {@_};
116
117    eval 'use ClamAV::Client';
118    if ($@) {
119        warn "unable to load ClamAV::Client\n";
120        $self->log(LOGERROR, "unable to load ClamAV::Client");
121        return;
122    }
123
124    # Set some sensible defaults
125    $self->{'_args'}{'deny_viruses'} ||= 'yes';
126    $self->{'_args'}{'max_size'}     ||= 1024;
127    $self->{'_args'}{'scan_all'}     ||= 1;
128    for my $setting ('deny_viruses', 'defer_on_error') {
129        next unless $self->{'_args'}{$setting};
130        if (lc $self->{'_args'}{$setting} eq 'no') {
131            $self->{'_args'}{$setting} = 0;
132        }
133    }
134
135    $self->register_hook('data_post', 'data_post_handler');
136}
137
138sub data_post_handler {
139    my ($self, $transaction) = @_;
140
141    my $filename = $self->get_filename($transaction) or return DECLINED;
142
143    if ($self->connection->notes('naughty')) {
144        $self->log(LOGINFO, "skip, naughty");
145        return (DECLINED);
146    }
147    return (DECLINED) if $self->is_too_big($transaction);
148    return (DECLINED) if $self->is_not_multipart($transaction);
149
150    $self->set_permission($filename) or return DECLINED;
151
152    my $clamd = $self->get_clamd()
153      or return $self->err_and_return("Cannot instantiate ClamAV::Client");
154
155    unless (eval { $clamd->ping() }) {
156        return $self->err_and_return("Cannot ping clamd server: $@");
157    }
158
159    my ($version) = split(/\//, $clamd->version);
160    $version ||= 'ClamAV';
161
162    my ($path, $found) = eval { $clamd->scan_path($filename) };
163    if ($@) {
164        return $self->err_and_return("Error scanning mail: $@");
165    }
166
167    if ($found) {
168        $self->log(LOGNOTICE, "fail, found virus $found");
169
170        $self->is_naughty(1);       # see plugins/naughty
171        $self->adjust_karma(-1);
172
173        if ($self->{_args}{deny_viruses}) {
174            return (DENY, "Virus found: $found");
175        }
176
177        $transaction->header->add('X-Virus-Found',   'Yes',  0);
178        $transaction->header->add('X-Virus-Details', $found, 0);
179        return (DECLINED);
180    }
181
182    $self->log(LOGINFO, "pass, clean");
183    $transaction->header->add('X-Virus-Found', 'No', 0);
184    $transaction->header->add('X-Virus-Checked',
185                              "by $version on " . $self->qp->config('me'), 0);
186    return (DECLINED);
187}
188
189sub err_and_return {
190    my $self    = shift;
191    my $message = shift;
192    if ($message) {
193        $self->log(LOGERROR, $message);
194    }
195    return (DENYSOFT, "Unable to scan for viruses")
196      if $self->{_args}{defer_on_error};
197    return (DECLINED, "skip");
198}
199
200sub get_filename {
201    my $self = shift;
202    my $transaction = shift || $self->qp->transaction;
203
204    my $filename = $transaction->body_filename;
205
206    if (!$filename) {
207        $self->log(LOGWARN, "Cannot process due to lack of filename");
208        return;
209    }
210
211    if (!-f $filename) {
212        $self->log(LOGERROR, "spool file missing! Attempting to respool");
213        $transaction->body_spool;
214        $filename = $transaction->body_filename;
215        if (!-f $filename) {
216            $self->log(LOGERROR, "skip: failed spool to $filename! Giving up");
217            return;
218        }
219        my $size = (stat($filename))[7];
220        $self->log(LOGDEBUG, "Spooled $size bytes to $filename");
221    }
222
223    return $filename;
224}
225
226sub set_permission {
227    my ($self, $filename) = @_;
228
229    # the spool directory must be readable and executable by the scanner;
230    # this generally means either group or world exec; if
231    # neither of these is set, issue a warning but try to proceed anyway
232    my $dir_mode = (stat($self->spool_dir()))[2];
233    $self->log(LOGDEBUG, "spool dir mode: $dir_mode");
234
235    if ($dir_mode & 0010 || $dir_mode & 0001) {
236
237        # match the spool file mode with the mode of the directory -- add
238        # the read bit for group, world, or both, depending on what the
239        # spool dir had, and strip all other bits, especially the sticky bit
240        my $fmode =
241          ($dir_mode & 0044) | ($dir_mode & 0010 ? 0040 : 0) |
242          ($dir_mode & 0001 ? 0004 : 0);
243
244        unless (chmod $fmode, $filename) {
245            $self->log(LOGERROR, "chmod: $filename: $!");
246            return;
247        }
248        return 1;
249    }
250    $self->log(LOGWARN,
251               "spool directory permissions do not permit scanner access");
252    return 1;
253}
254
255sub get_clamd {
256    my $self = shift;
257
258    my $port = $self->{'_args'}{'clamd_port'};
259    my $host = $self->{'_args'}{'clamd_host'} || 'localhost';
260
261    if ($port && $port =~ /^(\d+)/) {
262        return new ClamAV::Client(socket_host => $host, socket_port => $1);
263    }
264
265    my $socket = $self->{'_args'}{'clamd_socket'};
266    if ($socket) {
267        if ($socket =~ /([\w\/.]+)/) {
268            return new ClamAV::Client(socket_name => $1);
269        }
270        $self->log(LOGERROR, "invalid characters in socket name");
271    }
272
273    return new ClamAV::Client;
274}
275
276sub is_too_big {
277    my $self = shift;
278    my $transaction = shift || $self->qp->transaction;
279
280    my $size = $transaction->data_size;
281    if ($size > $self->{_args}{max_size} * 1024) {
282        $self->log(LOGINFO, "skip, too big ($size)");
283        return 1;
284    }
285
286    $self->log(LOGDEBUG, "data_size, $size");
287    return;
288}
289
290sub is_not_multipart {
291    my $self = shift;
292    my $transaction = shift || $self->qp->transaction;
293
294    return if $self->{'_args'}{'scan_all'};
295
296    return 1 if !$transaction->header;
297
298    # Ignore non-multipart emails
299    my $content_type = $transaction->header->get('Content-Type') or return 1;
300    $content_type =~ s/\s/ /g;
301    if ($content_type !~ m!\bmultipart/.*\bboundary="?([^"]+)!i) {
302        $self->log(LOGNOTICE, "skip, not multipart");
303        return 1;
304    }
305
306    return;
307}
308