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