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