Server IP : 85.214.239.14 / Your IP : 3.146.178.220 Web Server : Apache/2.4.62 (Debian) System : Linux h2886529.stratoserver.net 4.9.0 #1 SMP Tue Jan 9 19:45:01 MSK 2024 x86_64 User : www-data ( 33) PHP Version : 7.4.18 Disable Function : pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare, MySQL : OFF | cURL : OFF | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /proc/self/root/usr/share/perl5/Mail/SpamAssassin/Plugin/ |
Upload File : |
# <@LICENSE> # Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. # The ASF licenses this file to you under the Apache License, Version 2.0 # (the "License"); you may not use this file except in compliance with # the License. You may obtain a copy of the License at: # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # </@LICENSE> # Changes since SpamAssassin 3.3.2: # support for DCC learning. See dcc_learn_score. # deal with orphan dccifd sockets # use `cdcc -q` to not stall waiting to find a DCC server when deciding # whether DCC checks are enabled # use dccproc -Q or dccifd query if a pre-existing X-DCC header shows # the message has already been reported # dccproc now uses -w /var/dcc/whiteclnt so it acts more like dccifd # warn about the use of ancient versions of dccproc and dccifd # turn off dccifd greylisting # query instead of reporting mail messages that contain X-DCC headers # and so has probably already been reported # try harder to find dccproc and cdcc when not explicitly configured # Rhyolite Software DCC 2.3.140-1.4 $Revision$ =head1 NAME Mail::SpamAssassin::Plugin::DCC - perform DCC check of messages =head1 SYNOPSIS loadplugin Mail::SpamAssassin::Plugin::DCC full DCC_CHECK eval:check_dcc() full DCC_CHECK_50_79 eval:check_dcc_reputation_range('50','79') =head1 DESCRIPTION The DCC or Distributed Checksum Clearinghouse is a system of servers collecting and counting checksums of millions of mail messages. The counts can be used by SpamAssassin to detect and filter spam. See https://www.dcc-servers.net/dcc/ for more information about DCC. Note that DCC is disabled by default in C<v310.pre> because its use requires software that is not distributed with SpamAssassin and that has license restrictions for certain commercial uses. See the DCC license at https://www.dcc-servers.net/dcc/LICENSE for details. Enable it by uncommenting the "loadplugin Mail::SpamAssassin::Plugin::DCC" confdir/v310.pre or by adding this line to your local.pre. It might also be necessary to install a DCC package, port, rpm, or equivalent from your operating system distributor or a tarball from the primary DCC source at https://www.dcc-servers.net/dcc/#download See also https://www.dcc-servers.net/dcc/INSTALL.html =head1 TAGS The following tags are added to the set, available for use in reports, header fields, other plugins, etc.: _DCCB_ DCC server ID in X-DCC-*-Metrics header field name _DCCR_ X-DCC-*-Metrics header field body _DCCREP_ DCC Reputation or percent bulk mail (0..100) from commercial DCC software =cut package Mail::SpamAssassin::Plugin::DCC; use strict; use warnings; # use bytes; use re 'taint'; use Mail::SpamAssassin::Plugin; use Mail::SpamAssassin::Logger; use Mail::SpamAssassin::Timeout; use Mail::SpamAssassin::Util qw(untaint_var untaint_file_path proc_status_ok exit_status_str); use Errno qw(ENOENT EACCES); use IO::Socket; use IO::Select; our @ISA = qw(Mail::SpamAssassin::Plugin); our $io_socket_module_name; BEGIN { if (eval { require IO::Socket::IP }) { $io_socket_module_name = 'IO::Socket::IP'; } elsif (eval { require IO::Socket::INET6 }) { $io_socket_module_name = 'IO::Socket::INET6'; } elsif (eval { require IO::Socket::INET }) { $io_socket_module_name = 'IO::Socket::INET'; } } sub new { my $class = shift; my $mailsaobject = shift; $class = ref($class) || $class; my $self = $class->SUPER::new($mailsaobject); bless ($self, $class); # are network tests enabled? if ($mailsaobject->{local_tests_only}) { $self->{dcc_disabled} = 1; dbg("dcc: local tests only, disabling DCC"); } $self->register_eval_rule("check_dcc", $Mail::SpamAssassin::Conf::TYPE_FULL_EVALS); $self->register_eval_rule("check_dcc_reputation_range", $Mail::SpamAssassin::Conf::TYPE_FULL_EVALS); $self->set_config($mailsaobject->{conf}); return $self; } sub set_config { my($self, $conf) = @_; my @cmds; =head1 USER OPTIONS =over 4 =item use_dcc (0|1) (default: 1) Whether to use DCC, if it is available. =cut push(@cmds, { setting => 'use_dcc', default => 1, is_admin => 1, type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOL, }); =item use_dcc_rep (0|1) (default: 1) Whether to use the commercial DCC Reputation feature, if it is available. Note that reputation data is free for all starting from DCC 2.x version, where it's automatically used. =cut push(@cmds, { setting => 'use_dcc_rep', default => 1, is_admin => 1, type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOL, }); =item dcc_body_max NUMBER =item dcc_fuz1_max NUMBER =item dcc_fuz2_max NUMBER Sets how often a message's body/fuz1/fuz2 checksum must have been reported to the DCC server before SpamAssassin will consider the DCC check hit. C<999999> is DCC's MANY count. The default is C<999999> for all these options. =item dcc_rep_percent NUMBER Only the commercial DCC software provides DCC Reputations (but starting from DCC 2.x version it is available for all). A DCC Reputation is the percentage of bulk mail received from the last untrusted relay in the path taken by a mail message as measured by all commercial DCC installations. See http://www.rhyolite.com/dcc/reputations.html You C<must> whitelist your trusted relays or MX servers with MX or MXDCC lines in /var/dcc/whiteclnt as described in the main DCC man page to avoid seeing your own MX servers as sources of bulk mail. See https://www.dcc-servers.net/dcc/dcc-tree/dcc.html#White-and-Blacklists The default is C<90>. =cut push (@cmds, { setting => 'dcc_body_max', is_admin => 1, default => 999999, type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC }, { setting => 'dcc_fuz1_max', is_admin => 1, default => 999999, type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC }, { setting => 'dcc_fuz2_max', is_admin => 1, default => 999999, type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC }, { setting => 'dcc_rep_percent', is_admin => 1, default => 90, type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC }); =back =head1 ADMINISTRATOR OPTIONS =over 4 =item dcc_timeout n (default: 5) How many seconds you wait for DCC to complete, before scanning continues without the DCC results. A numeric value is optionally suffixed by a time unit (s, m, h, d, w, indicating seconds (default), minutes, hours, days, weeks). =cut push (@cmds, { setting => 'dcc_timeout', is_admin => 1, default => 5, type => $Mail::SpamAssassin::Conf::CONF_TYPE_DURATION, }); =item dcc_home STRING This option tells SpamAssassin where to find the dcc homedir. If not specified, try to use the locally configured directory from the C<cdcc homedir> command. Try /var/dcc if that command fails. =cut push (@cmds, { setting => 'dcc_home', is_admin => 1, type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, code => sub { my ($self, $key, $value, $line) = @_; if (!defined $value || $value eq '') { return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE; } $value = untaint_file_path($value); my $stat_errn = stat($value) ? 0 : 0+$!; if ($stat_errn != 0 || !-d _) { my $msg = $stat_errn == ENOENT ? "does not exist" : !-d _ ? "is not a directory" : "not accessible: $!"; info("config: dcc_home \"$value\" $msg"); return $Mail::SpamAssassin::Conf::INVALID_VALUE; } $self->{dcc_home} = $value; } }); =item dcc_dccifd_path STRING This option tells SpamAssassin where to find the dccifd socket instead of a local Unix socket named C<dccifd> in the C<dcc_home> directory. If a socket is specified or found, use it instead of C<dccproc>. If specified, C<dcc_dccifd_path> is the absolute path of local Unix socket or an INET socket specified as C<[Host]:Port> or C<Host:Port>. Host can be an IPv4 or IPv6 address or a host name Port is a TCP port number. The brackets are required for an IPv6 address. The default is C<undef>. =cut push (@cmds, { setting => 'dcc_dccifd_path', is_admin => 1, type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, code => sub { my ($self, $key, $value, $line) = @_; if (!defined $value || $value eq '') { return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE; } local($1,$2,$3); if ($value =~ m{^ (?: \[ ([^\]]*) \] | ([^:]*) ) : ([^:]*) \z}sx) { my $host = untaint_var(defined $1 ? $1 : $2); my $port = untaint_var($3); if (!$host) { info("config: missing or bad host name in dcc_dccifd_path '$value'"); return $Mail::SpamAssassin::Conf::INVALID_VALUE; } if (!$port || $port !~ /^\d+\z/ || $port < 1 || $port > 65535) { info("config: bad TCP port number in dcc_dccifd_path '$value'"); return $Mail::SpamAssassin::Conf::INVALID_VALUE; } $self->{dcc_dccifd_host} = $host; $self->{dcc_dccifd_port} = $port; dbg("config: dcc_dccifd_path set to [%s]:%s", $host, $port); } else { # assume a unix socket if ($value !~ m{^/}) { info("config: dcc_dccifd_path '$value' is not an absolute path"); # return $Mail::SpamAssassin::Conf::INVALID_VALUE; # abort or accept? } $value = untaint_file_path($value); $self->{dcc_dccifd_socket} = $value; dbg("config: dcc_dccifd_path set to local socket %s", $value); dbg("dcc: dcc_dccifd_path set to local socket %s", $value); } $self->{dcc_dccifd_path_raw} = $value; } }); =item dcc_path STRING Where to find the C<dccproc> client program instead of relying on SpamAssassin to find it in the current PATH or C<dcc_home/bin>. This must often be set, because the current PATH is cleared by I<taint mode> in the Perl interpreter, If a C<dccifd> socket is found in C<dcc_home> or specified explicitly with C<dcc_dccifd_path>, use the C<dccifd(8)> interface instead of C<dccproc>. The default is C<undef>. =cut push (@cmds, { setting => 'dcc_path', is_admin => 1, default => undef, type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, code => sub { my ($self, $key, $value, $line) = @_; if (!defined $value || $value eq '') { return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE; } $value = untaint_file_path($value); if (!-x $value) { info("config: dcc_path '$value' is not executable"); return $Mail::SpamAssassin::Conf::INVALID_VALUE; } $self->{dcc_path} = $value; } }); =item dcc_options options Specify additional options to the dccproc(8) command. Only characters in the range [0-9A-Za-z ,._/-] are allowed for security reasons. The default is C<undef>. =cut push (@cmds, { setting => 'dcc_options', is_admin => 1, default => undef, type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, code => sub { my ($self, $key, $value, $line) = @_; if ($value !~ m{^([0-9A-Za-z ,._/-]+)$}) { info("config: dcc_options '$value' contains impermissible characters"); return $Mail::SpamAssassin::Conf::INVALID_VALUE; } $self->{dcc_options} = $1; } }); =item dccifd_options options Specify additional options to send to the dccifd daemon with the ASCII protocol described on the dccifd(8) man page. Only characters in the range [0-9A-Za-z ,._/-] are allowed for security reasons. The default is C<undef>. =cut push (@cmds, { setting => 'dccifd_options', is_admin => 1, default => undef, type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, code => sub { my ($self, $key, $value, $line) = @_; if ($value !~ m{^([0-9A-Za-z ,._/-]+)$}) { info("config: dccifd_options '$value' contains impermissible characters"); return $Mail::SpamAssassin::Conf::INVALID_VALUE; } $self->{dccifd_options} = $1; } }); =item dcc_learn_score n (default: undef) Report messages with total scores this much larger than the SpamAssassin spam threshold to DCC as spam. =back =cut push (@cmds, { setting => 'dcc_learn_score', is_admin => 1, default => undef, type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC, }); $conf->{parser}->register_commands(\@cmds); } sub ck_dir { my ($self, $dir, $tgt, $src) = @_; $dir = untaint_file_path($dir); if (!stat($dir)) { my $dir_errno = 0+$!; if ($dir_errno == ENOENT) { dbg("dcc: $tgt $dir from $src does not exist"); } else { dbg("dcc: $tgt $dir from $src is not accessible: $!"); } return; } if (!-d _) { dbg("dcc: $tgt $dir from $src is not a directory"); return; } $self->{main}->{conf}->{$tgt} = $dir; dbg("dcc: use '$tgt $dir' from $src"); } sub find_dcc_home { my ($self) = @_; # just once return if defined $self->{dcc_version}; $self->{dcc_version} = '?'; my $conf = $self->{main}->{conf}; # Get the DCC software version for talking to dccifd and formatting the # dccifd options and the built-in DCC homedir. Use -q to prevent delays. my $cdcc_home; my $cdcc = $self->dcc_pgm_path('cdcc'); my $cmd = '-qV homedir libexecdir'; if ($cdcc && open(CDCC, "$cdcc $cmd 2>&1 |")) { my $cdcc_output = do { local $/ = undef; <CDCC> }; close CDCC; $cdcc_output =~ s/\s+/ /gs; # everything in 1 line for debugging $cdcc_output =~ s/\s+$//; dbg("dcc: `%s %s` reports '%s'", $cdcc, $cmd, $cdcc_output); $self->{dcc_version} = ($cdcc_output =~ /^(\d+\.\d+\.\d+)/) ? $1 : ''; $cdcc_home = ($cdcc_output =~ /\s+homedir=(\S+)/) ? $1 : ''; if ($cdcc_output =~ /\s+libexecdir=(\S+)/) { $self->ck_dir($1, 'dcc_libexec', 'cdcc'); } } # without a home, try the homedir from cdcc if (!$conf->{dcc_home} && $cdcc_home) { $self->ck_dir($cdcc_home, 'dcc_home', 'cdcc'); } # finally fall back to /var/dcc if (!$conf->{dcc_home}) { $self->ck_dir($conf->{dcc_home} = '/var/dcc', 'dcc_home', 'default') } # fall back to $conf->{dcc_home}/libexec or /var/dcc/libexec for dccsight if (!$conf->{dcc_libexec}) { $self->ck_dir($conf->{dcc_home} . '/libexec', 'dcc_libexec', 'dcc_home'); } if (!$conf->{dcc_libexec}) { $self->ck_dir('/var/dcc/libexec', 'dcc_libexec', 'dcc_home'); } # format options for dccifd my $opts = ($conf->{dccifd_options} || '') . "\n"; if ($self->{dcc_version} =~ /\d+\.(\d+)\.(\d+)$/ && ($1 < 3 || ($1 == 3 && $2 < 123))) { if ($1 < 3 || ($1 == 3 && $2 < 50)) { info("dcc: DCC version $self->{dcc_version} is years old, ". "obsolete, and likely to cause problems. ". "See https://www.dcc-servers.net/dcc/old-versions.html"); } $self->{dccifd_lookup_options} = "header " . $opts; $self->{dccifd_report_options} = "header spam " . $opts; } else { # dccifd after version 1.2.123 understands "cksums" and "no-grey" $self->{dccifd_lookup_options} = "cksums grey-off " . $opts; $self->{dccifd_report_options} = "header spam grey-off " . $opts; } } sub dcc_pgm_path { my ($self, $pgm) = @_; my $pgmpath; my $conf = $self->{main}->{conf}; $pgmpath = $conf->{dcc_path}; if (defined $pgmpath && $pgmpath ne '') { # accept explicit setting for dccproc return $pgmpath if $pgm eq 'dccproc'; # try adapting it for cdcc and everything else if ($pgmpath =~ s{[^/]+\z}{$pgm}s) { $pgmpath = untaint_file_path($pgmpath); if (-x $pgmpath) { dbg("dcc: dcc_pgm_path, found %s in dcc_path: %s", $pgm,$pgmpath); return $pgmpath; } } } $pgmpath = Mail::SpamAssassin::Util::find_executable_in_env_path($pgm); if (defined $pgmpath) { dbg("dcc: dcc_pgm_path, found %s in env.path: %s", $pgm,$pgmpath); return $pgmpath; } # try dcc_home/bin, dcc_libexec, and some desperate last attempts foreach my $dir (!defined $conf->{dcc_home} ? () : $conf->{dcc_home}.'/bin', $conf->{dcc_libexec}, '/usr/local/bin', '/usr/local/dcc', '/var/dcc') { next unless defined $dir; $pgmpath = $dir . '/' . $pgm; if (-x $pgmpath) { dbg("dcc: dcc_pgm_path, found %s in %s: %s", $pgm,$dir,$pgmpath); return $pgmpath; } } return; } sub is_dccifd_available { my ($self) = @_; # dccifd remains available until it breaks return $self->{dccifd_available} if $self->{dccifd_available}; $self->find_dcc_home(); my $conf = $self->{main}->{conf}; # deal with configured INET or INET6 socket if (defined $conf->{dcc_dccifd_host}) { dbg("dcc: dccifd is available via socket [%s]:%s", $conf->{dcc_dccifd_host}, $conf->{dcc_dccifd_port}); return ($self->{dccifd_available} = 1); } # the first time here, compute a default local socket based on DCC home # from self->find_dcc_home() called elsewhere my $sockpath = $conf->{dcc_dccifd_socket}; if (!$sockpath) { if ($conf->{dcc_dccifd_path_raw}) { $sockpath = $conf->{dcc_dccifd_path_raw}; } else { $sockpath = "$conf->{dcc_home}/dccifd"; } $conf->{dcc_dccifd_socket} = $sockpath; } # check the socket every time because it can appear and disappear return ($self->{dccifd_available} = 1) if (-S $sockpath && -w _ && -r _); dbg("dcc: dccifd is not available; no r/w socket at %s", $sockpath); return ($self->{dccifd_available} = 0); } sub is_dccproc_available { my ($self) = @_; my $conf = $self->{main}->{conf}; # dccproc remains (un)available so check only once return $self->{dccproc_available} if defined $self->{dccproc_available}; $self->find_dcc_home(); my $dccproc = $conf->{dcc_path}; if (!defined $dccproc || $dccproc eq '') { $dccproc = $self->dcc_pgm_path('dccproc'); $conf->{dcc_path} = $dccproc; if (!$dccproc || ! -x $dccproc) { dbg("dcc: dccproc is not available: no dccproc executable found"); return ($self->{dccproc_available} = 0); } } dbg("dcc: %s is available", $conf->{dcc_path}); return ($self->{dccproc_available} = 1); } sub dccifd_connect { my($self, $tag) = @_; my $conf = $self->{main}->{conf}; my $sockpath = $conf->{dcc_dccifd_socket}; my $sock; if (defined $sockpath) { dbg("$tag connecting to local socket $sockpath"); $sock = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $sockpath); info("$tag failed to connect to local socket $sockpath") if !$sock; } else { # must be TCP/IP my $host = $conf->{dcc_dccifd_host}; my $port = $conf->{dcc_dccifd_port}; dbg("$tag connecting to [%s]:%s using %s", $host, $port, $io_socket_module_name); $sock = $io_socket_module_name->new( Proto => 'tcp', PeerAddr => $host, PeerPort => $port); info("$tag failed to connect to [%s]:%s using %s: %s", $host, $port, $io_socket_module_name, $!) if !$sock; } $self->{dccifd_available} = 0 if !$sock; return $sock; } # check for dccifd every time in case enough uses of dccproc starts dccifd sub get_dcc_interface { my ($self) = @_; if (!$self->is_dccifd_available() && !$self->is_dccproc_available()) { dbg("dcc: dccifd or dccproc is not available"); return 0; } return 1; } sub check_tick { my ($self, $opts) = @_; $self->_check_async($opts, 0); my $pms = $opts->{permsgstatus}; # Finish callbacks if ($pms->{dcc_range_callbacks}) { while (@{$pms->{dcc_range_callbacks}}) { my $cb_args = shift @{$pms->{dcc_range_callbacks}}; $self->check_dcc_reputation_range($pms, @$cb_args); } } } sub check_cleanup { my ($self, $opts) = @_; $self->_check_async($opts, 1); my $pms = $opts->{permsgstatus}; # Finish callbacks if ($pms->{dcc_range_callbacks}) { while (@{$pms->{dcc_range_callbacks}}) { my $cb_args = shift @{$pms->{dcc_range_callbacks}}; $self->check_dcc_reputation_range($pms, @$cb_args); } } } sub _check_async { my ($self, $opts, $timeout) = @_; my $pms = $opts->{permsgstatus}; return if !$pms->{dcc_sock}; my $timer = $self->{main}->time_method("check_dcc"); $pms->{dcc_abort} = $pms->{dcc_abort} || $pms->{deadline_exceeded} || $pms->{shortcircuited}; if ($pms->{dcc_abort}) { $timeout = 0; } elsif ($timeout) { # Calculate how much time left from original timeout $timeout = $self->{main}->{conf}->{dcc_timeout} - (time - $pms->{dcc_async_start}); $timeout = 1 if $timeout < 1; $timeout = 20 if $timeout > 20; # hard sanity check dbg("dcc: final wait for dccifd, timeout in $timeout sec"); } if (IO::Select->new($pms->{dcc_sock})->can_read($timeout)) { dbg("dcc: reading dccifd response"); my @resp; # if DCC is ready, should never block? timeout 1s just in case my $timer = Mail::SpamAssassin::Timeout->new({ secs => 1 }); my $err = $timer->run_and_catch(sub { local $SIG{PIPE} = sub { die "__brokenpipe__ignore__\n" }; @resp = $pms->{dcc_sock}->getlines(); }); delete $pms->{dcc_sock}; if ($timer->timed_out()) { info("dcc: dccifd read failed"); } elsif ($err) { chomp $err; info("dcc: dccifd read failed: $err"); } else { shift @resp; shift @resp; # ignore status/multistatus line if (@resp) { dbg("dcc: dccifd raw response: ".join("", @resp)); ($pms->{dcc_x_result}, $pms->{dcc_cksums}) = $self->parse_dcc_response(\@resp, 'dccifd'); if ($pms->{dcc_x_result}) { dbg("dcc: dccifd parsed response: $pms->{dcc_x_result}"); ($pms->{dcc_result}, $pms->{dcc_rep}) = $self->check_dcc_result($pms, $pms->{dcc_x_result}); if ($pms->{dcc_result}) { foreach (@{$pms->{conf}->{eval_to_rule}->{check_dcc}}) { $pms->got_hit($_, "", ruletype => 'eval'); } } else { foreach (@{$pms->{conf}->{eval_to_rule}->{check_dcc}}) { $pms->rule_ready($_); } } } } else { info("dcc: empty response from dccifd?"); } } } elsif ($pms->{dcc_abort}) { dbg("dcc: bailing out due to deadline/shortcircuit"); delete $pms->{dcc_sock}; delete $pms->{dcc_range_callbacks}; } elsif ($timeout) { dbg("dcc: no response from dccifd, timed out"); delete $pms->{dcc_sock}; delete $pms->{dcc_range_callbacks}; } else { dbg("dcc: still waiting for dccifd response"); } } sub check_dnsbl { my($self, $opts) = @_; return 0 if $self->{dcc_disabled}; return 0 if !$self->{main}->{conf}->{use_dcc}; my $pms = $opts->{permsgstatus}; # Check that rules are active return 0 if !grep {$pms->{conf}->{scores}->{$_}} ( @{$pms->{conf}->{eval_to_rule}->{check_dcc}}, @{$pms->{conf}->{eval_to_rule}->{check_dcc_reputation_range}} ); # Launch async only if dccifd found if ($self->is_dccifd_available()) { $self->_launch_dcc($pms); } } sub _launch_dcc { my ($self, $pms) = @_; return if $pms->{dcc_running}; $pms->{dcc_running} = 1; my $timer = $self->{main}->time_method("check_dcc"); # initialize valid tags $pms->{tag_data}->{DCCB} = ''; $pms->{tag_data}->{DCCR} = ''; $pms->{tag_data}->{DCCREP} = ''; my $fulltext = $pms->{msg}->get_pristine(); if ($fulltext eq '') { dbg("dcc: empty message; skipping dcc check"); $pms->{dcc_result} = 0; $pms->{dcc_abort} = 1; return; } if (!$self->get_dcc_interface()) { $pms->{dcc_result} = 0; $pms->{dcc_abort} = 1; return; } if ($pms->get('ALL-TRUSTED') =~ /^(X-DCC-[^:]*?-Metrics: .*)$/m) { # short-circuit if there is already a X-DCC header with value of # "bulk" from an upstream DCC check # require "bulk" because then at least one body checksum will be "many" # and so we know the X-DCC header is not forged by spammers #if ($1 =~ / bulk /) { # return $self->check_dcc_result($pms, $1); #} } my $envelope = $pms->{relays_external}->[0]; ($pms->{dcc_x_result}, $pms->{dcc_cksums}) = $self->ask_dcc('dcc:', $pms, \$fulltext, $envelope); return; } sub check_dcc { my ($self, $pms) = @_; return 0 if $self->{dcc_disabled}; return 0 if !$pms->{conf}->{use_dcc}; return 0 if $pms->{dcc_abort}; # async already handling? if ($pms->{dcc_async_start}) { return; # return undef for async status } return $pms->{dcc_result} if defined $pms->{dcc_result}; $self->_launch_dcc($pms); return if $pms->{dcc_async_start}; # return undef for async status if (!defined $pms->{dcc_x_result}) { $pms->{dcc_abort} = 1; return 0; } ($pms->{dcc_result}, $pms->{dcc_rep}) = $self->check_dcc_result($pms, $pms->{dcc_x_result}); return $pms->{dcc_result}; } sub check_dcc_reputation_range { my ($self, $pms, undef, $min, $max, $cb_rulename) = @_; return 0 if $self->{dcc_disabled}; return 0 if !$pms->{conf}->{use_dcc}; return 0 if !$pms->{conf}->{use_dcc_rep}; return 0 if $pms->{dcc_abort}; my $timer = $self->{main}->time_method("check_dcc"); if (exists $pms->{dcc_rep}) { my $result; # Process result if ($pms->{dcc_rep} < 0) { # Not used or missing reputation $result = 0; } else { # cover the entire range of reputations if not told otherwise $min = 0 if !defined $min; $max = 100 if !defined $max; $result = $pms->{dcc_rep} >= $min && $pms->{dcc_rep} <= $max ? 1 : 0; dbg("dcc: dcc_rep %s, min %s, max %s => result=%s", $pms->{dcc_rep}, $min, $max, $result ? 'YES' : 'no'); } if (defined $cb_rulename) { # If callback, use got_hit() if ($result) { $pms->got_hit($cb_rulename, "", ruletype => 'eval'); } else { $pms->rule_ready($cb_rulename); } return 0; } else { return $result; } } else { # Install callback if waiting for async result if (!defined $cb_rulename) { my $rulename = $pms->get_current_eval_rule_name(); # array matches check_dcc_reputation_range() argument order push @{$pms->{dcc_range_callbacks}}, [undef, $min, $max, $rulename]; return; # return undef for async status } } return 0; } sub check_dcc_result { my ($self, $pms, $x_dcc) = @_; my $dcc_result = 0; my $dcc_rep = -1; if (!defined $x_dcc || $x_dcc eq '') { return ($dcc_result, $dcc_rep); } my $conf = $pms->{conf}; if ($x_dcc =~ /^X-DCC-([^:]*?)-Metrics: (.*)$/) { $pms->set_tag('DCCB', $1); $pms->set_tag('DCCR', $2); } $x_dcc =~ s/many/999999/ig; $x_dcc =~ s/ok\d?/0/ig; my %count = (body => 0, fuz1 => 0, fuz2 => 0, rep => 0); if ($x_dcc =~ /\bBody=(\d+)/) { $count{body} = $1+0; } if ($x_dcc =~ /\bFuz1=(\d+)/) { $count{fuz1} = $1+0; } if ($x_dcc =~ /\bFuz2=(\d+)/) { $count{fuz2} = $1+0; } if ($pms->{conf}->{use_dcc_rep} && $x_dcc =~ /\brep=(\d+)/) { $count{rep} = $1+0; $dcc_rep = $count{rep}; $pms->set_tag('DCCREP', $dcc_rep); } if ($count{body} >= $conf->{dcc_body_max} || $count{fuz1} >= $conf->{dcc_fuz1_max} || $count{fuz2} >= $conf->{dcc_fuz2_max} || $count{rep} >= $conf->{dcc_rep_percent}) { dbg(sprintf("dcc: listed: BODY=%s/%s FUZ1=%s/%s FUZ2=%s/%s REP=%s/%s", map { defined $_ ? $_ : 'undef' } ( $count{body}, $conf->{dcc_body_max}, $count{fuz1}, $conf->{dcc_fuz1_max}, $count{fuz2}, $conf->{dcc_fuz2_max}, $count{rep}, $conf->{dcc_rep_percent}) )); $dcc_result = 1; } return ($dcc_result, $dcc_rep); } # get the X-DCC header line and save the checksums from dccifd or dccproc sub parse_dcc_response { my ($self, $resp, $pgm) = @_; my ($raw_x_dcc, $cksums); # The first line is the header we want. It uses SMTP folded whitespace # if it is long. The folded whitespace is always a single \t. chomp($raw_x_dcc = shift @$resp); my $v; while (($v = shift @$resp) && $v =~ s/^\t(.+)\s*\n/ $1/) { $raw_x_dcc .= $v; } # skip the "reported:" line between the X-DCC header and any checksums # remove ':' to avoid a bug in versions 1.3.115 - 1.3.122 in dccsight # with the length of "Message-ID:" $cksums = ''; while (($v = shift @$resp) && $v =~ s/^([^:]*):/$1/) { $cksums .= $v; } if (!defined $raw_x_dcc || $raw_x_dcc !~ /^X-DCC/) { info("dcc: instead of X-DCC header, $pgm returned '%s'", $raw_x_dcc||''); } return ($raw_x_dcc, $cksums); } sub ask_dcc { my ($self, $tag, $pms, $fulltext, $envelope) = @_; my $conf = $pms->{conf}; my $timeout = $conf->{dcc_timeout}; if ($self->is_dccifd_available()) { my @resp; my $timer = Mail::SpamAssassin::Timeout->new( { secs => $timeout, deadline => $pms->{master_deadline} }); my $err = $timer->run_and_catch(sub { local $SIG{PIPE} = sub { die "__brokenpipe__ignore__\n" }; $pms->{dcc_sock} = $self->dccifd_connect($tag); if (!$pms->{dcc_sock}) { $self->{dccifd_available} = 0; # fall back on dccproc if the socket is an orphan from # a killed dccifd daemon or some other obvious (no timeout) problem dbg("$tag dccifd failed: trying dccproc as fallback"); return; } # send the options and other parameters to the daemon my $client = $envelope->{ip}; my $clientname = $envelope->{rdns}; if (!defined $client) { $client = ''; } else { $client .= ("\r" . $clientname) if defined $clientname; } my $helo = $envelope->{helo} || ''; my $opts; if ($tag eq 'dcc:') { $opts = $self->{dccifd_lookup_options}; if (defined $pms->{dcc_x_result}) { # only query if there is an X-DCC header $opts =~ s/grey-off/grey-off query/; } } else { $opts = $self->{dccifd_report_options}; } $pms->{dcc_sock}->print($opts) or die "failed write options\n"; $pms->{dcc_sock}->print("$client\n") or die "failed write SMTP client\n"; $pms->{dcc_sock}->print("$helo\n") or die "failed write HELO value\n"; $pms->{dcc_sock}->print("\n") or die "failed write sender\n"; $pms->{dcc_sock}->print("unknown\n\n") or die "failed write 1 recipient\n"; $pms->{dcc_sock}->print($$fulltext) or die "failed write mail message\n"; $pms->{dcc_sock}->shutdown(1) or die "failed socket shutdown: $!"; # don't async report and learn if ($tag ne 'dcc:') { @resp = $pms->{dcc_sock}->getlines(); delete $pms->{dcc_sock}; shift @resp; shift @resp; # ignore status/multistatus line if (!@resp) { die("no response"); } } else { $pms->{dcc_async_start} = time; } }); if ($timer->timed_out()) { delete $pms->{dcc_sock}; dbg("$tag dccifd timed out after $timeout seconds"); return (undef, undef); } elsif ($err) { delete $pms->{dcc_sock}; chomp $err; info("$tag dccifd failed: $err"); return (undef, undef); } # report, learn if ($tag ne 'dcc:') { my ($raw_x_dcc, $cksums) = $self->parse_dcc_response(\@resp, 'dccifd'); if ($raw_x_dcc) { dbg("$tag dccifd responded with '$raw_x_dcc'"); return ($raw_x_dcc, $cksums); } else { return (undef, undef); } } # async lookup return ('async', undef) if $pms->{dcc_async_start}; # or falling back to dccproc.. } if ($self->is_dccproc_available()) { $pms->enter_helper_run_mode(); my $pid; my @resp; my $timer = Mail::SpamAssassin::Timeout->new( { secs => $timeout, deadline => $pms->{master_deadline} }); my $err = $timer->run_and_catch(sub { local $SIG{PIPE} = sub { die "__brokenpipe__ignore__\n" }; # use a temp file -- open2() is unreliable, buffering-wise, under spamd my $tmpf = $pms->create_fulltext_tmpfile(); my @opts = split(/\s+/, $conf->{dcc_options} || ''); untaint_var(\@opts); unshift(@opts, '-w', 'whiteclnt'); my $client = $envelope->{ip}; if ($client) { unshift(@opts, '-a', untaint_var($client)); } else { # get external relay IP address from Received: header if not available unshift(@opts, '-R'); } if ($tag eq 'dcc:') { # query instead of report if there is an X-DCC header from upstream unshift(@opts, '-Q') if defined $pms->{dcc_x_result}; } else { # learn or report spam unshift(@opts, '-t', 'many'); } if ($conf->{dcc_home}) { # set home directory explicitly unshift(@opts, '-h', $conf->{dcc_home}); } dbg("$tag opening pipe to " . join(' ', $conf->{dcc_path}, "-C", "-x", "0", @opts, "<$tmpf")); $pid = Mail::SpamAssassin::Util::helper_app_pipe_open(*DCC, $tmpf, 1, $conf->{dcc_path}, "-C", "-x", "0", @opts); $pid or die "DCC: $!\n"; # read+split avoids a Perl I/O bug (Bug 5985) my($inbuf, $nread); my $resp = ''; while ($nread = read(DCC, $inbuf, 8192)) { $resp .= $inbuf } defined $nread or die "error reading from pipe: $!"; @resp = split(/^/m, $resp, -1); my $errno = 0; close DCC or $errno = $!; proc_status_ok($?,$errno) or info("$tag [%s] finished: %s", $pid, exit_status_str($?,$errno)); die "failed to read X-DCC header from dccproc\n" if !@resp; }); if (defined(fileno(*DCC))) { # still open if ($pid) { if (kill('TERM', $pid)) { dbg("$tag killed stale dccproc process [$pid]") } else { dbg("$tag killing dccproc process [$pid] failed: $!") } } my $errno = 0; close(DCC) or $errno = $!; proc_status_ok($?,$errno) or info("$tag [%s] dccproc terminated: %s", $pid, exit_status_str($?,$errno)); } $pms->leave_helper_run_mode(); if ($timer->timed_out()) { dbg("$tag dccproc timed out after $timeout seconds"); return (undef, undef); } elsif ($err) { chomp $err; info("$tag dccproc failed: $err"); return (undef, undef); } my ($raw_x_dcc, $cksums) = $self->parse_dcc_response(\@resp, 'dccproc'); if ($raw_x_dcc) { dbg("$tag dccproc responded with '$raw_x_dcc'"); return ($raw_x_dcc, $cksums); } else { info("$tag instead of X-DCC header, dccproc returned '$raw_x_dcc'"); return (undef, undef); } } return (undef, undef); } # tell DCC server that the message is spam according to SpamAssassin sub check_post_learn { my ($self, $opts) = @_; return if $self->{dcc_disabled}; return if !$self->{main}->{conf}->{use_dcc}; my $pms = $opts->{permsgstatus}; return if $pms->{dcc_abort}; # learn only if allowed my $conf = $self->{main}->{conf}; my $learn_score = $conf->{dcc_learn_score}; if (!defined $learn_score || $learn_score eq '') { dbg("dcc: DCC learning not enabled by dcc_learn_score"); $self->{learn_disabled} = 1; return; } # and if SpamAssassin concluded that the message is spam # worse than our threshold if ($pms->is_spam()) { my $score = $pms->get_score(); my $required_score = $pms->get_required_score(); if ($score < $required_score + $learn_score) { dbg("dcc: score=%d required_score=%d dcc_learn_score=%d", $score, $required_score, $learn_score); return; } } # and if we checked the message return if (!defined $pms->{dcc_x_result}); # and if the DCC server thinks it was not spam if ($pms->{dcc_x_result} !~ /\b(Body|Fuz1|Fuz2)=\d/) { dbg("dcc: already known as spam; no need to learn: $pms->{dcc_x_result}"); return; } my $timer = $self->{main}->time_method("dcc_learn"); # dccsight is faster than dccifd or dccproc if we have checksums, # which we do not have with dccifd before 1.3.123 my $old_cksums = $pms->{dcc_cksums}; return if ($old_cksums && $self->dccsight_learn($pms, $old_cksums)); # Fall back on dccifd or dccproc without saved checksums or dccsight. # get_dcc_interface() was called when the message was checked my $fulltext = $pms->{msg}->get_pristine(); my $envelope = $pms->{relays_external}->[0]; my ($raw_x_dcc, undef) = $self->ask_dcc('dcc: learn:', $pms, \$fulltext, $envelope); dbg("dcc: learned as spam") if defined $raw_x_dcc; } sub dccsight_learn { my ($self, $pms, $old_cksums) = @_; return 0 if !$old_cksums; my $dccsight = $self->dcc_pgm_path('dccsight'); if (!$dccsight) { info("dcc: cannot find dccsight") if $dccsight eq ''; return 0; } $pms->enter_helper_run_mode(); # use a temp file here -- open2() is unreliable, buffering-wise, under spamd my $tmpf = $pms->create_fulltext_tmpfile(\$old_cksums); my ($raw_x_dcc, $new_cksums); my $pid; my $timeout = $self->{main}->{conf}->{dcc_timeout}; my $timer = Mail::SpamAssassin::Timeout->new( { secs => $timeout, deadline => $pms->{master_deadline} }); my $err = $timer->run_and_catch(sub { local $SIG{PIPE} = sub { die "__brokenpipe__ignore__\n" }; dbg("dcc: opening pipe to %s", join(' ', $dccsight, "-t", "many", "<$tmpf")); $pid = Mail::SpamAssassin::Util::helper_app_pipe_open(*DCC, $tmpf, 1, $dccsight, "-t", "many"); $pid or die "$!\n"; # read+split avoids a Perl I/O bug (Bug 5985) my($inbuf, $nread); my $resp = ''; while ($nread = read(DCC, $inbuf, 8192)) { $resp .= $inbuf } defined $nread or die "error reading from pipe: $!"; my @resp = split(/^/m, $resp, -1); my $errno = 0; close DCC or $errno = $!; proc_status_ok($?,$errno) or info("dcc: [%s] finished: %s", $pid, exit_status_str($?,$errno)); die "dcc: failed to read learning response\n" if !@resp; ($raw_x_dcc, $new_cksums) = $self->parse_dcc_response(\@resp, 'dccsight'); }); if (defined(fileno(*DCC))) { # still open if ($pid) { if (kill('TERM', $pid)) { dbg("dcc: killed stale dccsight process [$pid]"); } else { dbg("dcc: killing stale dccsight process [$pid] failed: $!"); } } my $errno = 0; close(DCC) or $errno = $!; proc_status_ok($?,$errno) or info("dcc: dccsight [%s] terminated: %s", $pid, exit_status_str($?,$errno)); } $pms->delete_fulltext_tmpfile($tmpf); $pms->leave_helper_run_mode(); if ($timer->timed_out()) { dbg("dcc: dccsight timed out after $timeout seconds"); return 0; } elsif ($err) { chomp $err; info("dcc: dccsight failed: $err\n"); return 0; } if ($raw_x_dcc ne '') { #TODO check if working dbg("dcc: learned response: $raw_x_dcc"); return 1; } return 0; } sub plugin_report { my ($self, $opts) = @_; return if $self->{dcc_disabled}; return if !$self->{main}->{conf}->{use_dcc}; return if $opts->{report}->{options}->{dont_report_to_dcc}; return if !$self->get_dcc_interface(); my $report = $opts->{report}; my $timer = $self->{main}->time_method("dcc_report"); # get the metadata from the message so we can report the external relay $opts->{msg}->extract_message_metadata($report->{main}); my $envelope = $opts->{msg}->{metadata}->{relays_external}->[0]; my ($raw_x_dcc, undef) = $self->ask_dcc('reporter:', $report, $opts->{text}, $envelope); if (defined $raw_x_dcc) { $report->{report_available} = $report->{report_return} = 1; info("reporter: spam reported to DCC"); } else { info("reporter: could not report spam to DCC"); } } 1;