File Coverage

blib/lib/Mail/SpamAssassin/Plugin/DCC.pm
Criterion Covered Total %
statement 185 487 37.9
branch 53 286 18.5
condition 15 104 14.4
subroutine 23 34 67.6
pod 3 17 17.6
total 279 928 30.0


line stmt bran cond sub pod time code
1             # <@LICENSE>
2             # Licensed to the Apache Software Foundation (ASF) under one or more
3             # contributor license agreements. See the NOTICE file distributed with
4             # this work for additional information regarding copyright ownership.
5             # The ASF licenses this file to you under the Apache License, Version 2.0
6             # (the "License"); you may not use this file except in compliance with
7             # the License. You may obtain a copy of the License at:
8             #
9             # http://www.apache.org/licenses/LICENSE-2.0
10             #
11             # Unless required by applicable law or agreed to in writing, software
12             # distributed under the License is distributed on an "AS IS" BASIS,
13             # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14             # See the License for the specific language governing permissions and
15             # limitations under the License.
16             # </@LICENSE>
17              
18             # Changes since SpamAssassin 3.3.2:
19             # support for DCC learning. See dcc_learn_score.
20             # deal with orphan dccifd sockets
21             # use `cdcc -q` to not stall waiting to find a DCC server when deciding
22             # whether DCC checks are enabled
23             # use dccproc -Q or dccifd query if a pre-existing X-DCC header shows
24             # the message has already been reported
25             # dccproc now uses -w /var/dcc/whiteclnt so it acts more like dccifd
26             # warn about the use of ancient versions of dccproc and dccifd
27             # turn off dccifd greylisting
28             # query instead of reporting mail messages that contain X-DCC headers
29             # and so has probably already been reported
30             # try harder to find dccproc and cdcc when not explicitly configured
31             # Rhyolite Software DCC 2.3.140-1.4 $Revision$
32              
33             =head1 NAME
34              
35             Mail::SpamAssassin::Plugin::DCC - perform DCC check of messages
36              
37             =head1 SYNOPSIS
38              
39             loadplugin Mail::SpamAssassin::Plugin::DCC
40              
41             full DCC_CHECK eval:check_dcc()
42             full DCC_CHECK_50_79 eval:check_dcc_reputation_range('50','79')
43              
44             =head1 DESCRIPTION
45              
46             The DCC or Distributed Checksum Clearinghouse is a system of servers
47             collecting and counting checksums of millions of mail messages.
48             The counts can be used by SpamAssassin to detect and filter spam.
49              
50             See https://www.dcc-servers.net/dcc/ for more information about DCC.
51              
52             Note that DCC is disabled by default in C<v310.pre> because its use requires
53             software that is not distributed with SpamAssassin and that has license
54             restrictions for certain commercial uses.
55             See the DCC license at https://www.dcc-servers.net/dcc/LICENSE for details.
56              
57             Enable it by uncommenting the "loadplugin Mail::SpamAssassin::Plugin::DCC"
58             confdir/v310.pre or by adding this line to your local.pre. It might also
59             be necessary to install a DCC package, port, rpm, or equivalent from your
60             operating system distributor or a tarball from the primary DCC source
61             at https://www.dcc-servers.net/dcc/#download
62             See also https://www.dcc-servers.net/dcc/INSTALL.html
63              
64             =head1 TAGS
65              
66             The following tags are added to the set, available for use in reports,
67             header fields, other plugins, etc.:
68              
69             _DCCB_ DCC server ID in X-DCC-*-Metrics header field name
70             _DCCR_ X-DCC-*-Metrics header field body
71             _DCCREP_ DCC Reputation or percent bulk mail (0..100) from
72             commercial DCC software
73              
74             =cut
75              
76             package Mail::SpamAssassin::Plugin::DCC;
77              
78 19     19   141 use strict;
  19         42  
  19         707  
79 19     19   143 use warnings;
  19         55  
  19         669  
80             # use bytes;
81 19     19   136 use re 'taint';
  19         63  
  19         689  
82              
83 19     19   147 use Mail::SpamAssassin::Plugin;
  19         40  
  19         546  
84 19     19   108 use Mail::SpamAssassin::Logger;
  19         42  
  19         1229  
85 19     19   139 use Mail::SpamAssassin::Timeout;
  19         42  
  19         684  
86 19         1386 use Mail::SpamAssassin::Util qw(untaint_var untaint_file_path
87 19     19   145 proc_status_ok exit_status_str);
  19         48  
88 19     19   141 use Errno qw(ENOENT EACCES);
  19         43  
  19         1093  
89 19     19   147 use IO::Socket;
  19         54  
  19         217  
90              
91             our @ISA = qw(Mail::SpamAssassin::Plugin);
92              
93             our $io_socket_module_name;
94             BEGIN {
95 19 50   19   15847 if (eval { require IO::Socket::IP }) {
  19 0       6475  
    0          
96 19         110341 $io_socket_module_name = 'IO::Socket::IP';
97 0         0 } elsif (eval { require IO::Socket::INET6 }) {
98 0         0 $io_socket_module_name = 'IO::Socket::INET6';
99 0         0 } elsif (eval { require IO::Socket::INET }) {
100 0         0 $io_socket_module_name = 'IO::Socket::INET';
101             }
102             }
103              
104             sub new {
105 60     60 1 255 my $class = shift;
106 60         188 my $mailsaobject = shift;
107              
108 60   33     472 $class = ref($class) || $class;
109 60         411 my $self = $class->SUPER::new($mailsaobject);
110 60         261 bless ($self, $class);
111              
112             # are network tests enabled?
113 60 100       345 if ($mailsaobject->{local_tests_only}) {
114 59         254 $self->{use_dcc} = 0;
115 59         231 dbg("dcc: local tests only, disabling DCC");
116             }
117             else {
118 1         5 dbg("dcc: network tests on, registering DCC");
119             }
120              
121 60         320 $self->register_eval_rule("check_dcc");
122 60         268 $self->register_eval_rule("check_dcc_reputation_range");
123              
124 60         369 $self->set_config($mailsaobject->{conf});
125              
126 60         665 return $self;
127             }
128              
129             sub set_config {
130 60     60 0 177 my($self, $conf) = @_;
131 60         139 my @cmds;
132              
133             =head1 USER OPTIONS
134              
135             =over 4
136              
137             =item use_dcc (0|1) (default: 1)
138              
139             Whether to use DCC, if it is available.
140              
141             =cut
142              
143 60         339 push(@cmds, {
144             setting => 'use_dcc',
145             default => 1,
146             type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOL,
147             });
148              
149             =item dcc_body_max NUMBER
150              
151             =item dcc_fuz1_max NUMBER
152              
153             =item dcc_fuz2_max NUMBER
154              
155             Sets how often a message's body/fuz1/fuz2 checksum must have been reported
156             to the DCC server before SpamAssassin will consider the DCC check hit.
157             C<999999> is DCC's MANY count.
158              
159             The default is C<999999> for all these options.
160              
161             =item dcc_rep_percent NUMBER
162              
163             Only the commercial DCC software provides DCC Reputations. A DCC Reputation
164             is the percentage of bulk mail received from the last untrusted relay in the
165             path taken by a mail message as measured by all commercial DCC installations.
166             See http://www.rhyolite.com/dcc/reputations.html
167             You C<must> whitelist your trusted relays or MX servers with MX or
168             MXDCC lines in /var/dcc/whiteclnt as described in the main DCC man page
169             to avoid seeing your own MX servers as sources of bulk mail.
170             See https://www.dcc-servers.net/dcc/dcc-tree/dcc.html#White-and-Blacklists
171             The default is C<90>.
172              
173             =cut
174              
175 60         599 push (@cmds, {
176             setting => 'dcc_body_max',
177             default => 999999,
178             type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC
179             },
180             {
181             setting => 'dcc_fuz1_max',
182             default => 999999,
183             type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC
184             },
185             {
186             setting => 'dcc_fuz2_max',
187             default => 999999,
188             type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC
189             },
190             {
191             setting => 'dcc_rep_percent',
192             default => 90,
193             type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC
194             });
195              
196             =back
197              
198             =head1 ADMINISTRATOR OPTIONS
199              
200             =over 4
201              
202             =item dcc_timeout n (default: 8)
203              
204             How many seconds you wait for DCC to complete, before scanning continues
205             without the DCC results. A numeric value is optionally suffixed by a
206             time unit (s, m, h, d, w, indicating seconds (default), minutes, hours,
207             days, weeks).
208              
209             =cut
210              
211 60         299 push (@cmds, {
212             setting => 'dcc_timeout',
213             is_admin => 1,
214             default => 8,
215             type => $Mail::SpamAssassin::Conf::CONF_TYPE_DURATION,
216             });
217              
218             =item dcc_home STRING
219              
220             This option tells SpamAssassin where to find the dcc homedir.
221             If not specified, try to use the locally configured directory
222             from the C<cdcc homedir> command.
223             Try /var/dcc if that command fails.
224              
225             =cut
226              
227             push (@cmds, {
228             setting => 'dcc_home',
229             is_admin => 1,
230             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
231             code => sub {
232 0     0   0 my ($self, $key, $value, $line) = @_;
233 0 0 0     0 if (!defined $value || $value eq '') {
234 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
235             }
236 0         0 $value = untaint_file_path($value);
237 0 0       0 my $stat_errn = stat($value) ? 0 : 0+$!;
238 0 0 0     0 if ($stat_errn != 0 || !-d _) {
239 0 0       0 my $msg = $stat_errn == ENOENT ? "does not exist"
    0          
240             : !-d _ ? "is not a directory" : "not accessible: $!";
241 0         0 info("config: dcc_home \"$value\" $msg");
242 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
243             }
244              
245 0         0 $self->{dcc_home} = $value;
246             }
247 60         679 });
248              
249             =item dcc_dccifd_path STRING
250              
251             This option tells SpamAssassin where to find the dccifd socket instead
252             of a local Unix socket named C<dccifd> in the C<dcc_home> directory.
253             If a socket is specified or found, use it instead of C<dccproc>.
254              
255             If specified, C<dcc_dccifd_path> is the absolute path of local Unix socket
256             or an INET socket specified as C<[Host]:Port> or C<Host:Port>.
257             Host can be an IPv4 or IPv6 address or a host name
258             Port is a TCP port number. The brackets are required for an IPv6 address.
259              
260             The default is C<undef>.
261              
262             =cut
263              
264             push (@cmds, {
265             setting => 'dcc_dccifd_path',
266             is_admin => 1,
267             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
268             code => sub {
269 0     0   0 my ($self, $key, $value, $line) = @_;
270              
271 0 0 0     0 if (!defined $value || $value eq '') {
272 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
273             }
274              
275 0         0 local($1,$2,$3);
276 0 0       0 if ($value =~ m{^ (?: \[ ([^\]]*) \] | ([^:]*) ) : ([^:]*) \z}sx) {
277 0 0       0 my $host = untaint_var(defined $1 ? $1 : $2);
278 0         0 my $port = untaint_var($3);
279 0 0       0 if (!$host) {
280 0         0 info("config: missing or bad host name in dcc_dccifd_path '$value'");
281 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
282             }
283 0 0 0     0 if (!$port || $port !~ /^\d+\z/ || $port < 1 || $port > 65535) {
      0        
      0        
284 0         0 info("config: bad TCP port number in dcc_dccifd_path '$value'");
285 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
286             }
287              
288 0         0 $self->{dcc_dccifd_host} = $host;
289 0         0 $self->{dcc_dccifd_port} = $port;
290 0         0 dbg("config: dcc_dccifd_path set to [%s]:%s", $host, $port);
291              
292             } else {
293             # assume a unix socket
294 0 0       0 if ($value !~ m{^/}) {
295 0         0 info("config: dcc_dccifd_path '$value' is not an absolute path");
296             # return $Mail::SpamAssassin::Conf::INVALID_VALUE; # abort or accept?
297             }
298 0         0 $value = untaint_file_path($value);
299              
300 0         0 $self->{dcc_dccifd_socket} = $value;
301 0         0 dbg("config: dcc_dccifd_path set to local socket %s", $value);
302 0         0 dbg("dcc: dcc_dccifd_path set to local socket %s", $value);
303             }
304              
305 0         0 $self->{dcc_dccifd_path_raw} = $value;
306             }
307 60         539 });
308              
309             =item dcc_path STRING
310              
311             Where to find the C<dccproc> client program instead of relying on SpamAssassin
312             to find it in the current PATH or C<dcc_home/bin>. This must often be set,
313             because the current PATH is cleared by I<taint mode> in the Perl interpreter,
314              
315             If a C<dccifd> socket is found in C<dcc_home> or specified explicitly
316             with C<dcc_dccifd_path>, use the C<dccifd(8)> interface instead of C<dccproc>.
317              
318             The default is C<undef>.
319              
320              
321             =cut
322              
323             push (@cmds, {
324             setting => 'dcc_path',
325             is_admin => 1,
326             default => undef,
327             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
328             code => sub {
329 0     0   0 my ($self, $key, $value, $line) = @_;
330 0 0 0     0 if (!defined $value || $value eq '') {
331 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
332             }
333 0         0 $value = untaint_file_path($value);
334 0 0       0 if (!-x $value) {
335 0         0 info("config: dcc_path '$value' is not executable");
336 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
337             }
338              
339 0         0 $self->{dcc_path} = $value;
340             }
341 60         576 });
342              
343             =item dcc_options options
344              
345             Specify additional options to the dccproc(8) command. Only
346             characters in the range [0-9A-Za-z ,._/-] are allowed for security reasons.
347              
348             The default is C<undef>.
349              
350             =cut
351              
352             push (@cmds, {
353             setting => 'dcc_options',
354             is_admin => 1,
355             default => undef,
356             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
357             code => sub {
358 0     0   0 my ($self, $key, $value, $line) = @_;
359 0 0       0 if ($value !~ m{^([0-9A-Za-z ,._/-]+)$}) {
360 0         0 info("config: dcc_options '$value' contains impermissible characters");
361 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
362             }
363 0         0 $self->{dcc_options} = $1;
364             }
365 60         860 });
366              
367             =item dccifd_options options
368              
369             Specify additional options to send to the dccifd daemon with
370             the ASCII protocol described on the dccifd(8) man page.
371             Only characters in the range [0-9A-Za-z ,._/-] are allowed for security reasons.
372              
373             The default is C<undef>.
374              
375             =cut
376              
377             push (@cmds, {
378             setting => 'dccifd_options',
379             is_admin => 1,
380             default => undef,
381             type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
382             code => sub {
383 0     0   0 my ($self, $key, $value, $line) = @_;
384 0 0       0 if ($value !~ m{^([0-9A-Za-z ,._/-]+)$}) {
385 0         0 info("config: dccifd_options '$value' contains impermissible characters");
386 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
387             }
388 0         0 $self->{dccifd_options} = $1;
389             }
390 60         493 });
391              
392             =item dcc_learn_score n (default: undef)
393              
394             Report messages with total scores this much larger than the
395             SpamAssassin spam threshold to DCC as spam.
396              
397             =back
398              
399             =cut
400              
401 60         280 push (@cmds, {
402             setting => 'dcc_learn_score',
403             is_admin => 1,
404             default => undef,
405             type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
406             });
407              
408 60         323 $conf->{parser}->register_commands(\@cmds);
409             }
410              
411              
412              
413              
414             sub ck_dir {
415 3     3 0 9 my ($self, $dir, $tgt, $src) = @_;
416              
417 3         10 $dir = untaint_file_path($dir);
418 3 50       37 if (!stat($dir)) {
419 3         24 my $dir_errno = 0+$!;
420 3 50       9 if ($dir_errno == ENOENT) {
421 3         16 dbg("dcc: $tgt $dir from $src does not exist");
422             } else {
423 0         0 dbg("dcc: $tgt $dir from $src is not accessible: $!");
424             }
425 3         12 return;
426             }
427 0 0       0 if (!-d _) {
428 0         0 dbg("dcc: $tgt $dir from $src is not a directory");
429 0         0 return;
430             }
431              
432 0         0 $self->{main}->{conf}->{$tgt} = $dir;
433 0         0 dbg("dcc: use '$tgt $dir' from $src");
434             }
435              
436             sub find_dcc_home {
437 4     4 0 12 my ($self) = @_;
438              
439             # just once
440 4 100       17 return if defined $self->{dcc_version};
441 1         3 $self->{dcc_version} = '?';
442              
443 1         3 my $conf = $self->{main}->{conf};
444              
445              
446             # Get the DCC software version for talking to dccifd and formatting the
447             # dccifd options and the built-in DCC homedir. Use -q to prevent delays.
448 1         2 my $cdcc_home;
449 1         6 my $cdcc = $self->dcc_pgm_path('cdcc');
450 1         6 my $cmd = '-qV homedir libexecdir';
451 1 50 33     4 if ($cdcc && open(CDCC, "$cdcc $cmd 2>&1 |")) {
452 0         0 my $cdcc_output = do { local $/ = undef; <CDCC> };
  0         0  
  0         0  
453 0         0 close CDCC;
454              
455 0         0 $cdcc_output =~ s/\n/ /g; # everything in 1 line for debugging
456 0         0 dbg("dcc: `%s %s` reports '%s'", $cdcc, $cmd, $cdcc_output);
457 0 0       0 $self->{dcc_version} = ($cdcc_output =~ /^(\d+\.\d+\.\d+)/) ? $1 : '';
458 0 0       0 $cdcc_home = ($cdcc_output =~ /\s+homedir=(\S+)/) ? $1 : '';
459 0 0       0 if ($cdcc_output =~ /\s+libexecdir=(\S+)/) {
460 0         0 $self->ck_dir($1, 'dcc_libexec', 'cdcc');
461             }
462             }
463              
464             # without a home, try the homedir from cdcc
465 1 50 33     7 if (!$conf->{dcc_home} && $cdcc_home) {
466 0         0 $self->ck_dir($cdcc_home, 'dcc_home', 'cdcc');
467             }
468             # finally fall back to /var/dcc
469 1 50       8 if (!$conf->{dcc_home}) {
470 1         8 $self->ck_dir($conf->{dcc_home} = '/var/dcc', 'dcc_home', 'default')
471             }
472              
473             # fall back to $conf->{dcc_home}/libexec or /var/dcc/libexec for dccsight
474 1 50       5 if (!$conf->{dcc_libexec}) {
475 1         5 $self->ck_dir($conf->{dcc_home} . '/libexec', 'dcc_libexec', 'dcc_home');
476             }
477 1 50       7 if (!$conf->{dcc_libexec}) {
478 1         5 $self->ck_dir('/var/dcc/libexec', 'dcc_libexec', 'dcc_home');
479             }
480              
481             # format options for dccifd
482 1   50     9 my $opts = ($conf->{dccifd_options} || '') . "\n";
483 1 50 0     5 if ($self->{dcc_version} =~ /\d+\.(\d+)\.(\d+)$/ &&
      33        
484             ($1 < 3 || ($1 == 3 && $2 < 123))) {
485 0 0 0     0 if ($1 < 3 || ($1 == 3 && $2 < 50)) {
      0        
486 0         0 info("dcc: DCC version $self->{dcc_version} is years old, ".
487             "obsolete, and likely to cause problems. ".
488             "See https://www.dcc-servers.net/dcc/old-versions.html");
489             }
490 0         0 $self->{dccifd_lookup_options} = "header " . $opts;
491 0         0 $self->{dccifd_report_options} = "header spam " . $opts;
492             } else {
493             # dccifd after version 1.2.123 understands "cksums" and "no-grey"
494 1         5 $self->{dccifd_lookup_options} = "cksums grey-off " . $opts;
495 1         5 $self->{dccifd_report_options} = "header spam grey-off " . $opts;
496             }
497             }
498              
499             sub dcc_pgm_path {
500 2     2 0 7 my ($self, $pgm) = @_;
501 2         14 my $pgmpath;
502 2         6 my $conf = $self->{main}->{conf};
503              
504 2         5 $pgmpath = $conf->{dcc_path};
505 2 50 33     10 if (defined $pgmpath && $pgmpath ne '') {
506             # accept explicit setting for dccproc
507 0 0       0 return $pgmpath if $pgm eq 'dccproc';
508             # try adapting it for cdcc and everything else
509 0 0       0 if ($pgmpath =~ s{[^/]+\z}{$pgm}s) {
510 0         0 $pgmpath = untaint_file_path($pgmpath);
511 0 0       0 if (-x $pgmpath) {
512 0         0 dbg("dcc: dcc_pgm_path, found %s in dcc_path: %s", $pgm,$pgmpath);
513 0         0 return $pgmpath;
514             }
515             }
516             }
517              
518 2         20 $pgmpath = Mail::SpamAssassin::Util::find_executable_in_env_path($pgm);
519 2 50       6 if (defined $pgmpath) {
520 0         0 dbg("dcc: dcc_pgm_path, found %s in env.path: %s", $pgm,$pgmpath);
521 0         0 return $pgmpath;
522             }
523              
524             # try dcc_home/bin, dcc_libexec, and some desperate last attempts
525 2 100       12 foreach my $dir (!defined $conf->{dcc_home} ? () : $conf->{dcc_home}.'/bin',
526             $conf->{dcc_libexec},
527             '/usr/local/bin', '/usr/local/dcc', '/var/dcc') {
528 9 100       22 next unless defined $dir;
529 7         14 $pgmpath = $dir . '/' . $pgm;
530 7 50       64 if (-x $pgmpath) {
531 0         0 dbg("dcc: dcc_pgm_path, found %s in %s: %s", $pgm,$dir,$pgmpath);
532 0         0 return $pgmpath;
533             }
534             }
535              
536 2         9 return;
537             }
538              
539             sub is_dccifd_available {
540 4     4 0 10 my ($self) = @_;
541 4         10 my $conf = $self->{main}->{conf};
542              
543             # dccifd remains available until it breaks
544 4 50       22 return $self->{dccifd_available} if $self->{dccifd_available};
545              
546             # deal with configured INET or INET6 socket
547 4 50       13 if (defined $conf->{dcc_dccifd_host}) {
548             dbg("dcc: dccifd is available via socket [%s]:%s",
549 0         0 $conf->{dcc_dccifd_host}, $conf->{dcc_dccifd_port});
550 0         0 return ($self->{dccifd_available} = 1);
551             }
552              
553             # the first time here, compute a default local socket based on DCC home
554             # from self->find_dcc_home() called elsewhere
555 4         14 my $sockpath = $conf->{dcc_dccifd_socket};
556 4 100       20 if (!$sockpath) {
557 1 50       6 if ($conf->{dcc_dccifd_path_raw}) {
558 0         0 $sockpath = $conf->{dcc_dccifd_path_raw};
559             } else {
560 1         4 $sockpath = "$conf->{dcc_home}/dccifd";
561             }
562 1         4 $conf->{dcc_dccifd_socket} = $sockpath;
563             }
564              
565             # check the socket every time because it can appear and disappear
566 4 0 33     95 return ($self->{dccifd_available} = 1) if (-S $sockpath && -w _ && -r _);
      33        
567              
568 4         25 dbg("dcc: dccifd is not available; no r/w socket at %s", $sockpath);
569 4         43 return ($self->{dccifd_available} = 0);
570             }
571              
572             sub is_dccproc_available {
573 4     4 0 14 my ($self) = @_;
574 4         12 my $conf = $self->{main}->{conf};
575              
576             # dccproc remains (un)available so check only once
577 4 100       29 return $self->{dccproc_available} if defined $self->{dccproc_available};
578              
579 1         4 my $dccproc = $conf->{dcc_path};
580 1 50 33     6 if (!defined $dccproc || $dccproc eq '') {
581 1         12 $dccproc = $self->dcc_pgm_path('dccproc');
582 1         9 $conf->{dcc_path} = $dccproc;
583 1 50 33     6 if (!$dccproc || ! -x $dccproc) {
584 1         6 dbg("dcc: dccproc is not available: no dccproc executable found");
585 1         14 return ($self->{dccproc_available} = 0);
586             }
587             }
588              
589 0         0 dbg("dcc: %s is available", $conf->{dcc_path});
590 0         0 return ($self->{dccproc_available} = 1);
591             }
592              
593             sub dccifd_connect {
594 0     0 0 0 my($self, $tag) = @_;
595 0         0 my $conf = $self->{main}->{conf};
596 0         0 my $sockpath = $conf->{dcc_dccifd_socket};
597 0         0 my $sock;
598              
599 0 0       0 if (defined $sockpath) {
600 0         0 dbg("$tag connecting to local socket $sockpath");
601 0         0 $sock = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $sockpath);
602 0 0       0 info("$tag failed to connect to local socket $sockpath") if !$sock;
603              
604             } else { # must be TCP/IP
605 0         0 my $host = $conf->{dcc_dccifd_host};
606 0         0 my $port = $conf->{dcc_dccifd_port};
607 0         0 dbg("$tag connecting to [%s]:%s using %s",
608             $host, $port, $io_socket_module_name);
609 0         0 $sock = $io_socket_module_name->new(
610             Proto => 'tcp', PeerAddr => $host, PeerPort => $port);
611 0 0       0 info("$tag failed to connect to [%s]:%s using %s: %s",
612             $host, $port, $io_socket_module_name, $!) if !$sock;
613             }
614              
615 0 0       0 $self->{dccifd_available} = 0 if !$sock;
616 0         0 return $sock;
617             }
618              
619             # check for dccifd every time in case enough uses of dccproc starts dccifd
620             sub get_dcc_interface {
621 4     4 0 12 my ($self) = @_;
622 4         11 my $conf = $self->{main}->{conf};
623              
624 4 50       16 if (!$conf->{use_dcc}) {
625 0         0 $self->{dcc_disabled} = 1;
626 0         0 return;
627             }
628              
629 4         20 $self->find_dcc_home();
630 4 50 33     17 if (!$self->is_dccifd_available() && !$self->is_dccproc_available()) {
631 4         17 dbg("dcc: dccifd and dccproc are not available");
632 4         20 $self->{dcc_disabled} = 1;
633             }
634              
635 4         14 $self->{dcc_disabled} = 0;
636             }
637              
638             sub dcc_query {
639 4     4 0 12 my ($self, $permsgstatus, $fulltext) = @_;
640              
641 4         12 $permsgstatus->{dcc_checked} = 1;
642              
643 4 50       17 if (!$self->{main}->{conf}->{use_dcc}) {
644 0         0 dbg("dcc: DCC is not available: use_dcc is 0");
645 0         0 return;
646             }
647              
648             # initialize valid tags
649 4         13 $permsgstatus->{tag_data}->{DCCB} = "";
650 4         11 $permsgstatus->{tag_data}->{DCCR} = "";
651 4         10 $permsgstatus->{tag_data}->{DCCREP} = "";
652              
653 4 50       18 if ($$fulltext eq '') {
654 0         0 dbg("dcc: empty message; skipping dcc check");
655 0         0 return;
656             }
657              
658 4 50       14 if ($permsgstatus->get('ALL') =~ /^(X-DCC-.*-Metrics:.*)$/m) {
659 0         0 $permsgstatus->{dcc_raw_x_dcc} = $1;
660             # short-circuit if there is already a X-DCC header with value of
661             # "bulk" from an upstream DCC check
662             # require "bulk" because then at least one body checksum will be "many"
663             # and so we know the X-DCC header is not forged by spammers
664 0 0       0 return if $permsgstatus->{dcc_raw_x_dcc} =~ / bulk /;
665             }
666              
667 4         26 my $timer = $self->{main}->time_method("check_dcc");
668              
669 4         27 $self->get_dcc_interface();
670 4 50       16 return if $self->{dcc_disabled};
671              
672 4         15 my $envelope = $permsgstatus->{relays_external}->[0];
673             ($permsgstatus->{dcc_raw_x_dcc},
674 4         25 $permsgstatus->{dcc_cksums}) = $self->ask_dcc("dcc:", $permsgstatus,
675             $fulltext, $envelope);
676             }
677              
678             sub check_dcc {
679 4     4 0 17 my ($self, $permsgstatus, $full) = @_;
680 4         14 my $conf = $self->{main}->{conf};
681              
682 4 50       33 $self->dcc_query($permsgstatus, $full) if !$permsgstatus->{dcc_checked};
683              
684 4         13 my $x_dcc = $permsgstatus->{dcc_raw_x_dcc};
685 4 50 33     120 return 0 if !defined $x_dcc || $x_dcc eq '';
686              
687 0 0       0 if ($x_dcc =~ /^X-DCC-(.*)-Metrics: (.*)$/) {
688 0         0 $permsgstatus->set_tag('DCCB', $1);
689 0         0 $permsgstatus->set_tag('DCCR', $2);
690             }
691 0         0 $x_dcc =~ s/many/999999/ig;
692 0         0 $x_dcc =~ s/ok\d?/0/ig;
693              
694 0         0 my %count = (body => 0, fuz1 => 0, fuz2 => 0, rep => 0);
695 0 0       0 if ($x_dcc =~ /\bBody=(\d+)/) {
696 0         0 $count{body} = $1+0;
697             }
698 0 0       0 if ($x_dcc =~ /\bFuz1=(\d+)/) {
699 0         0 $count{fuz1} = $1+0;
700             }
701 0 0       0 if ($x_dcc =~ /\bFuz2=(\d+)/) {
702 0         0 $count{fuz2} = $1+0;
703             }
704 0 0       0 if ($x_dcc =~ /\brep=(\d+)/) {
705 0         0 $count{rep} = $1+0;
706             }
707 0 0 0     0 if ($count{body} >= $conf->{dcc_body_max} ||
      0        
      0        
708             $count{fuz1} >= $conf->{dcc_fuz1_max} ||
709             $count{fuz2} >= $conf->{dcc_fuz2_max} ||
710             $count{rep} >= $conf->{dcc_rep_percent})
711             {
712             dbg(sprintf("dcc: listed: BODY=%s/%s FUZ1=%s/%s FUZ2=%s/%s REP=%s/%s",
713 0 0       0 map { defined $_ ? $_ : 'undef' } (
714             $count{body}, $conf->{dcc_body_max},
715             $count{fuz1}, $conf->{dcc_fuz1_max},
716             $count{fuz2}, $conf->{dcc_fuz2_max},
717             $count{rep}, $conf->{dcc_rep_percent})
718 0         0 ));
719 0         0 return 1;
720             }
721 0         0 return 0;
722             }
723              
724             sub check_dcc_reputation_range {
725 0     0 0 0 my ($self, $permsgstatus, $fulltext, $min, $max) = @_;
726              
727             # this is called several times per message, so parse the X-DCC header once
728 0         0 my $dcc_rep = $permsgstatus->{dcc_rep};
729 0 0       0 if (!defined $dcc_rep) {
730 0 0       0 $self->dcc_query($permsgstatus, $fulltext) if !$permsgstatus->{dcc_checked};
731 0         0 my $x_dcc = $permsgstatus->{dcc_raw_x_dcc};
732 0 0 0     0 if (defined $x_dcc && $x_dcc =~ /\brep=(\d+)/) {
733 0         0 $dcc_rep = $1+0;
734 0         0 $permsgstatus->set_tag('DCCREP', $dcc_rep);
735             } else {
736 0         0 $dcc_rep = -1;
737             }
738 0         0 $permsgstatus->{dcc_rep} = $dcc_rep;
739             }
740              
741             # no X-DCC header or no reputation in the X-DCC header, perhaps for lack
742             # of data in the DCC Reputation server
743 0 0       0 return 0 if $dcc_rep < 0;
744              
745             # cover the entire range of reputations if not told otherwise
746 0 0       0 $min = 0 if !defined $min;
747 0 0       0 $max = 100 if !defined $max;
748              
749 0 0 0     0 my $result = $dcc_rep >= $min && $dcc_rep <= $max ? 1 : 0;
750 0 0       0 dbg("dcc: dcc_rep %s, min %s, max %s => result=%s",
751             $dcc_rep, $min, $max, $result?'YES':'no');
752 0         0 return $result;
753             }
754              
755             # get the X-DCC header line and save the checksums from dccifd or dccproc
756             sub parse_dcc_response {
757 0     0 0 0 my ($self, $resp) = @_;
758 0         0 my ($raw_x_dcc, $cksums);
759              
760             # The first line is the header we want. It uses SMTP folded whitespace
761             # if it is long. The folded whitespace is always a single \t.
762 0         0 chomp($raw_x_dcc = shift @$resp);
763 0         0 my $v;
764 0   0     0 while (($v = shift @$resp) && $v =~ s/^\t(.+)\s*\n/ $1/) {
765 0         0 $raw_x_dcc .= $v;
766             }
767              
768             # skip the "reported:" line between the X-DCC header and any checksums
769             # remove ':' to avoid a bug in versions 1.3.115 - 1.3.122 in dccsight
770             # with the length of "Message-ID:"
771 0         0 $cksums = '';
772 0   0     0 while (($v = shift @$resp) && $v =~ s/^([^:]*):/$1/) {
773 0         0 $cksums .= $v;
774             }
775              
776 0         0 return ($raw_x_dcc, $cksums);
777             }
778              
779             sub ask_dcc {
780 4     4 0 19 my ($self, $tag, $permsgstatus, $fulltext, $envelope) = @_;
781 4         11 my $conf = $self->{main}->{conf};
782 4         13 my ($pgm, $err, $sock, $pid, @resp);
783 4         0 my ($client, $clientname, $helo, $opts);
784              
785 4         21 $permsgstatus->enter_helper_run_mode();
786              
787 4         19 my $timeout = $conf->{dcc_timeout};
788             my $timer = Mail::SpamAssassin::Timeout->new(
789 4         35 { secs => $timeout, deadline => $permsgstatus->{master_deadline} });
790              
791             $err = $timer->run_and_catch(sub {
792 4     4   78 local $SIG{PIPE} = sub { die "__brokenpipe__ignore__\n" };
  0         0  
793              
794             # prefer dccifd to dccproc
795 4 50       25 if ($self->{dccifd_available}) {
796 0         0 $pgm = 'dccifd';
797              
798 0         0 $sock = $self->dccifd_connect($tag);
799 0 0       0 if (!$sock) {
800 0         0 $self->{dccifd_available} = 0;
801 0 0       0 die("dccproc not available") if (!$self->is_dccproc_available());
802              
803             # fall back on dccproc if the socket is an orphan from
804             # a killed dccifd daemon or some other obvious (no timeout) problem
805 0         0 dbg("$tag fall back on dccproc");
806             }
807             }
808              
809 4 50       16 if ($self->{dccifd_available}) {
810              
811             # send the options and other parameters to the daemon
812 0         0 $client = $envelope->{ip};
813 0         0 $clientname = $envelope->{rdns};
814 0 0       0 if (!defined $client) {
815 0         0 $client = '';
816             } else {
817 0 0       0 $client .= ("\r" . $clientname) if defined $clientname;
818             }
819 0   0     0 $helo = $envelope->{helo} || '';
820 0 0       0 if ($tag ne "dcc:") {
821             $opts = $self->{dccifd_report_options}
822 0         0 } else {
823 0         0 $opts = $self->{dccifd_lookup_options};
824 0 0       0 if (defined $permsgstatus->{dcc_raw_x_dcc}) {
825             # only query if there is an X-DCC header
826 0         0 $opts =~ s/grey-off/grey-off query/;
827             }
828             }
829              
830 0 0       0 $sock->print($opts) or die "failed write options\n";
831 0 0       0 $sock->print($client . "\n") or die "failed write SMTP client\n";
832 0 0       0 $sock->print($helo . "\n") or die "failed write HELO value\n";
833 0 0       0 $sock->print("\n") or die "failed write sender\n";
834 0 0       0 $sock->print("unknown\n\n") or die "failed write 1 recipient\n";
835 0 0       0 $sock->print($$fulltext) or die "failed write mail message\n";
836 0 0       0 $sock->shutdown(1) or die "failed socket shutdown: $!";
837              
838 0 0       0 $sock->getline() or die "failed read status\n";
839 0 0       0 $sock->getline() or die "failed read multistatus\n";
840              
841 0         0 @resp = $sock->getlines();
842 0 0       0 die "failed to read dccifd response\n" if !@resp;
843              
844             } else {
845 4         13 $pgm = 'dccproc';
846             # use a temp file -- open2() is unreliable, buffering-wise, under spamd
847             # first ensure that we do not hit a stray file from some other filter.
848 4         29 $permsgstatus->delete_fulltext_tmpfile();
849 4         19 my $tmpf = $permsgstatus->create_fulltext_tmpfile($fulltext);
850              
851 4         16 my $path = $conf->{dcc_path};
852 4         11 $opts = $conf->{dcc_options};
853 4 50       22 my @opts = !defined $opts ? () : split(' ',$opts);
854 4         27 untaint_var(\@opts);
855 4         16 unshift(@opts, '-w', 'whiteclnt');
856 4         10 $client = $envelope->{ip};
857 4 50       16 if ($client) {
858 0         0 unshift(@opts, '-a', untaint_var($client));
859             } else {
860             # get external relay IP address from Received: header if not available
861 4         11 unshift(@opts, '-R');
862             }
863 4 50       13 if ($tag eq "dcc:") {
864             # query instead of report if there is an X-DCC header from upstream
865 4 50       13 unshift(@opts, '-Q') if defined $permsgstatus->{dcc_raw_x_dcc};
866             } else {
867             # learn or report spam
868 0         0 unshift(@opts, '-t', 'many');
869             }
870 4 50       20 if ($conf->{dcc_home}) {
871             # set home directory explicitly
872 4         18 unshift(@opts, '-h', $conf->{dcc_home});
873             };
874              
875 4 50       175 defined $path or die "no dcc_path found\n";
876 0         0 dbg("$tag opening pipe to " .
877             join(' ', $path, "-C", "-x", "0", @opts, "<$tmpf"));
878              
879 0         0 $pid = Mail::SpamAssassin::Util::helper_app_pipe_open(*DCC,
880             $tmpf, 1, $path, "-C", "-x", "0", @opts);
881 0 0       0 $pid or die "DCC: $!\n";
882              
883             # read+split avoids a Perl I/O bug (Bug 5985)
884 0         0 my($inbuf,$nread,$resp); $resp = '';
  0         0  
885 0         0 while ( $nread=read(DCC,$inbuf,8192) ) { $resp .= $inbuf }
  0         0  
886 0 0       0 defined $nread or die "error reading from pipe: $!";
887 0         0 @resp = split(/^/m, $resp, -1); undef $resp;
  0         0  
888              
889 0 0       0 my $errno = 0; close DCC or $errno = $!;
  0         0  
890 0 0       0 proc_status_ok($?,$errno)
891             or info("$tag [%s] finished: %s", $pid, exit_status_str($?,$errno));
892              
893 0 0       0 die "failed to read X-DCC header from dccproc\n" if !@resp;
894             }
895 4         86 });
896              
897 4 50 33     152 if (defined $pgm && $pgm eq 'dccproc') {
898 4 50       22 if (defined(fileno(*DCC))) { # still open
899 0 0       0 if ($pid) {
900 0 0       0 if (kill('TERM',$pid)) {
901 0         0 dbg("$tag killed stale dccproc process [$pid]")
902             } else {
903 0         0 dbg("$tag killing dccproc process [$pid] failed: $!")
904             }
905             }
906 0 0       0 my $errno = 0; close(DCC) or $errno = $!;
  0         0  
907 0 0       0 proc_status_ok($?,$errno) or info("$tag [%s] dccproc terminated: %s",
908             $pid, exit_status_str($?,$errno));
909             }
910             }
911              
912 4         22 $permsgstatus->leave_helper_run_mode();
913              
914 4 50       21 if ($timer->timed_out()) {
915 0   0     0 dbg("$tag %s timed out after %d seconds", $pgm||'', $timeout);
916 0         0 return (undef, undef);
917             }
918              
919 4 50       15 if ($err) {
920 4         11 chomp $err;
921 4   50     43 info("$tag %s failed: %s", $pgm||'', $err);
922 4         39 return (undef, undef);
923             }
924              
925 0         0 my ($raw_x_dcc, $cksums) = $self->parse_dcc_response(\@resp);
926 0 0 0     0 if (!defined $raw_x_dcc || $raw_x_dcc !~ /^X-DCC/) {
927 0         0 info("$tag instead of X-DCC header, $pgm returned '$raw_x_dcc'");
928 0         0 return (undef, undef);
929             }
930 0         0 dbg("$tag $pgm responded with '$raw_x_dcc'");
931 0         0 return ($raw_x_dcc, $cksums);
932             }
933              
934             # tell DCC server that the message is spam according to SpamAssassin
935             sub check_post_learn {
936 81     81 1 247 my ($self, $options) = @_;
937              
938             # learn only if allowed
939 81 100       381 return if $self->{learn_disabled};
940 40         124 my $conf = $self->{main}->{conf};
941 40 50       147 if (!$conf->{use_dcc}) {
942 0         0 $self->{learn_disabled} = 1;
943 0         0 return;
944             }
945 40         119 my $learn_score = $conf->{dcc_learn_score};
946 40 50 33     177 if (!defined $learn_score || $learn_score eq '') {
947 40         170 dbg("dcc: DCC learning not enabled by dcc_learn_score");
948 40         127 $self->{learn_disabled} = 1;
949 40         134 return;
950             }
951              
952             # and if SpamAssassin concluded that the message is spam
953             # worse than our threshold
954 0           my $permsgstatus = $options->{permsgstatus};
955 0 0         if ($permsgstatus->is_spam()) {
956 0           my $score = $permsgstatus->get_score();
957 0           my $required_score = $permsgstatus->get_required_score();
958 0 0         if ($score < $required_score + $learn_score) {
959 0           dbg("dcc: score=%d required_score=%d dcc_learn_score=%d",
960             $score, $required_score, $learn_score);
961 0           return;
962             }
963             }
964              
965             # and if we checked the message
966 0 0         return if (!defined $permsgstatus->{dcc_raw_x_dcc});
967              
968             # and if the DCC server thinks it was not spam
969 0 0         if ($permsgstatus->{dcc_raw_x_dcc} !~ /\b(Body|Fuz1|Fuz2)=\d/) {
970 0           dbg("dcc: already known as spam; no need to learn");
971 0           return;
972             }
973              
974             # dccsight is faster than dccifd or dccproc if we have checksums,
975             # which we do not have with dccifd before 1.3.123
976 0           my $old_cksums = $permsgstatus->{dcc_cksums};
977 0 0 0       return if ($old_cksums && $self->dccsight_learn($permsgstatus, $old_cksums));
978              
979             # Fall back on dccifd or dccproc without saved checksums or dccsight.
980             # get_dcc_interface() was called when the message was checked
981              
982 0           my $fulltext = $permsgstatus->{msg}->get_pristine();
983 0           my $envelope = $permsgstatus->{relays_external}->[0];
984 0           my ($raw_x_dcc, $cksums) = $self->ask_dcc("dcc: learn:", $permsgstatus,
985             \$fulltext, $envelope);
986 0 0         dbg("dcc: learned as spam") if defined $raw_x_dcc;
987             }
988              
989             sub dccsight_learn {
990 0     0 0   my ($self, $permsgstatus, $old_cksums) = @_;
991 0           my ($raw_x_dcc, $new_cksums);
992              
993 0 0         return 0 if !$old_cksums;
994              
995 0           my $dccsight = $self->dcc_pgm_path('dccsight');
996 0 0         if (!$dccsight) {
997 0 0         info("dcc: cannot find dccsight") if $dccsight eq '';
998 0           return 0;
999             }
1000              
1001 0           $permsgstatus->enter_helper_run_mode();
1002              
1003             # use a temp file here -- open2() is unreliable, buffering-wise, under spamd
1004             # ensure that we do not hit a stray file from some other filter.
1005 0           $permsgstatus->delete_fulltext_tmpfile();
1006 0           my $tmpf = $permsgstatus->create_fulltext_tmpfile(\$old_cksums);
1007 0           my $pid;
1008              
1009 0           my $timeout = $self->{main}->{conf}->{dcc_timeout};
1010             my $timer = Mail::SpamAssassin::Timeout->new(
1011 0           { secs => $timeout, deadline => $permsgstatus->{master_deadline} });
1012             my $err = $timer->run_and_catch(sub {
1013 0     0     local $SIG{PIPE} = sub { die "__brokenpipe__ignore__\n" };
  0            
1014              
1015 0           dbg("dcc: opening pipe to %s",
1016             join(' ', $dccsight, "-t", "many", "<$tmpf"));
1017              
1018 0           $pid = Mail::SpamAssassin::Util::helper_app_pipe_open(*DCC,
1019             $tmpf, 1, $dccsight, "-t", "many");
1020 0 0         $pid or die "$!\n";
1021              
1022             # read+split avoids a Perl I/O bug (Bug 5985)
1023 0           my($inbuf,$nread,$resp); $resp = '';
  0            
1024 0           while ( $nread=read(DCC,$inbuf,8192) ) { $resp .= $inbuf }
  0            
1025 0 0         defined $nread or die "error reading from pipe: $!";
1026 0           my @resp = split(/^/m, $resp, -1); undef $resp;
  0            
1027              
1028 0 0         my $errno = 0; close DCC or $errno = $!;
  0            
1029 0 0         proc_status_ok($?,$errno)
1030             or info("dcc: [%s] finished: %s", $pid, exit_status_str($?,$errno));
1031              
1032 0 0         die "dcc: failed to read learning response\n" if !@resp;
1033              
1034 0           ($raw_x_dcc, $new_cksums) = $self->parse_dcc_response(\@resp);
1035 0           });
1036              
1037 0 0         if (defined(fileno(*DCC))) { # still open
1038 0 0         if ($pid) {
1039 0 0         if (kill('TERM',$pid)) {
1040 0           dbg("dcc: killed stale dccsight process [$pid]")
1041             } else {
1042 0           dbg("dcc: killing stale dccsight process [$pid] failed: $!") }
1043             }
1044 0 0         my $errno = 0; close(DCC) or $errno = $!;
  0            
1045 0 0         proc_status_ok($?,$errno) or info("dcc: dccsight [%s] terminated: %s",
1046             $pid, exit_status_str($?,$errno));
1047             }
1048 0           $permsgstatus->delete_fulltext_tmpfile();
1049 0           $permsgstatus->leave_helper_run_mode();
1050              
1051 0 0         if ($timer->timed_out()) {
1052 0           dbg("dcc: dccsight timed out after $timeout seconds");
1053 0           return 0;
1054             }
1055              
1056 0 0         if ($err) {
1057 0           chomp $err;
1058 0           info("dcc: dccsight failed: $err\n");
1059 0           return 0;
1060             }
1061              
1062 0 0         if ($raw_x_dcc) {
1063 0           dbg("dcc: learned response: %s", $raw_x_dcc);
1064 0           return 1;
1065             }
1066              
1067 0           return 0;
1068             }
1069              
1070             sub plugin_report {
1071 0     0 1   my ($self, $options) = @_;
1072              
1073 0 0         return if $options->{report}->{options}->{dont_report_to_dcc};
1074 0           $self->get_dcc_interface();
1075 0 0         return if $self->{dcc_disabled};
1076              
1077             # get the metadata from the message so we can report the external relay
1078 0           $options->{msg}->extract_message_metadata($options->{report}->{main});
1079 0           my $envelope = $options->{msg}->{metadata}->{relays_external}->[0];
1080             my ($raw_x_dcc, $cksums) = $self->ask_dcc("reporter:", $options->{report},
1081 0           $options->{text}, $envelope);
1082              
1083 0 0         if (defined $raw_x_dcc) {
1084 0           $options->{report}->{report_available} = 1;
1085 0           info("reporter: spam reported to DCC");
1086 0           $options->{report}->{report_return} = 1;
1087             } else {
1088 0           info("reporter: could not report spam to DCC");
1089             }
1090             }
1091              
1092             1;