File: //usr/share/perl5/vendor_perl/Amavis/SpamControl/SpamdClient.pm
# SPDX-License-Identifier: GPL-2.0-or-later
package Amavis::SpamControl::SpamdClient;
use strict;
use re 'taint';
use warnings;
use warnings FATAL => qw(utf8 void);
no warnings 'uninitialized';
# use warnings 'extra'; no warnings 'experimental::re_strict'; use re 'strict';
BEGIN {
require Exporter;
use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
$VERSION = '2.412';
@ISA = qw(Exporter);
}
use Errno qw(ENOENT EACCES);
use Amavis::Conf qw(:platform :confvars :sa c cr ca);
use Amavis::rfc2821_2822_Tools qw(qquote_rfc2821_local);
use Amavis::Timing qw(section_time);
use Amavis::Util qw(ll do_log sanitize_str min max minmax get_deadline);
sub new {
my($class, $scanner_name,$module,@args) = @_;
my(%options) = @args;
bless { scanner_name => $scanner_name, options => \%options }, $class;
}
# needs spamd running, could be started like this:
# spamd -H /var/amavis/home -r /var/amavis/home/spamd.pid -s stderr \
# -u vscan -g vscan -x -P --allow-tell --min-children=2 --max-children=2
sub check {
my($self,$msginfo) = @_;
my($which_section, $spam_level, $sa_tests, $size_limit, %attr);
my $scanner_name = $self->{scanner_name};
my $mbsl = $self->{options}->{'mail_body_size_limit'};
$mbsl = c('sa_mail_body_size_limit') if !defined $mbsl;
if (defined $mbsl) {
$size_limit = min(64*1024, $msginfo->orig_header_size) + 1 +
min($mbsl, $msginfo->orig_body_size);
# don't bother if slightly oversized, it's faster without size checks
undef $size_limit if $msginfo->msg_size < $size_limit + 5*1024;
}
my $hdr_edits = $msginfo->header_edits;
# fake a local delivery agent by inserting Return-Path
$which_section = 'prepare pseudo header section';
my $hdr_prefix = '';
$hdr_prefix .= sprintf("Return-Path: %s\n", $msginfo->sender_smtp);
$hdr_prefix .= sprintf("X-Envelope-To: %s\n",
join(",\n ",qquote_rfc2821_local(@{$msginfo->recips})));
my $os_fp = $msginfo->client_os_fingerprint;
$hdr_prefix .= sprintf("X-Amavis-OS-Fingerprint: %s\n",
sanitize_str($os_fp)) if defined($os_fp) && $os_fp ne '';
my(@av_tests);
my $per_recip_data = $msginfo->per_recip_data;
$per_recip_data = [] if !$per_recip_data;
for my $r (@$per_recip_data) {
my $spam_tests = $r->spam_tests;
push(@av_tests, grep(/^AV\..+=/,
split(/,/, join(',',map($$_,@$spam_tests))))) if $spam_tests;
}
$hdr_prefix .= sprintf("X-Amavis-AV-Status: %s\n",
sanitize_str(join(',',@av_tests))) if @av_tests;
$hdr_prefix .= sprintf("X-Amavis-PolicyBank: %s\n", c('policy_bank_path'));
$hdr_prefix .= sprintf("X-Amavis-MessageSize: %d%s\n", $msginfo->msg_size,
!defined $size_limit ? '' : ", TRUNCATED to $size_limit");
my($remaining_time, $deadline) = get_deadline('spamd check', 1, 5);
my $msg = $msginfo->mail_text;
my $msg_str_ref = $msginfo->mail_text_str; # have an in-memory copy?
$msg = $msg_str_ref if ref $msg_str_ref;
eval {
$which_section = 'spamd_connect'; do_log(3,"connecting to spamd");
my $spamd_handle = Amavis::IO::RW->new(
[ '127.0.0.1:783', '[::1]:783' ], Eol => "\015\012", Timeout => 10);
defined $spamd_handle or die "Can't connect to spamd, $@ ($!)";
$spamd_handle->timeout(max(3, $deadline - Time::HiRes::time));
section_time($which_section);
$which_section = 'spamd_tx'; do_log(4,"sending to spamd");
$hdr_prefix =~ s{\n}{\015\012}gs;
my $file_position = $msginfo->skip_bytes;
my $msgsize = length($hdr_prefix); # prepended lines...
$msgsize += $msginfo->msg_size; # size as defined by RFC 1870
$msgsize -= $file_position; # TODO: adjust for CRLF (alright for 0)
ll(5) && do_log(5, "spamc: message size: %d + %d - %d = %s",
length($hdr_prefix), $msginfo->msg_size, $file_position,
defined $size_limit && $msgsize > $size_limit
? "LIM:$size_limit" : $msgsize);
if (defined $size_limit && $msgsize > $size_limit) {
# consider $size_limit in the RFC 1870 sense for simplicity
$msgsize = $size_limit;
}
$spamd_handle->print("SYMBOLS SPAMC/1.3\015\012"); # HEADERS
$spamd_handle->print("Content-length: " . $msgsize . "\015\012");
$spamd_handle->print("\015\012");
$spamd_handle->print($hdr_prefix);
my $bytes_written = length($hdr_prefix);
if (!defined $msg) {
# empty mail
} elsif (ref $msg eq 'SCALAR') {
# do it in chunks, saves memory, cache friendly
my $done;
while ($file_position < length($$msg)) {
my $buff = substr($$msg,$file_position,16384);
$file_position += length($buff);
$buff =~ s{\n}{\015\012}gs;
if (defined $size_limit &&
$bytes_written + length($buff) >= $size_limit) {
substr($buff, $size_limit - $bytes_written) = ''; # truncate
# spamd reads line-by-line and hangs if not terminated by a NL
substr($buff,-1,1) = "\012";
do_log(5,"spamc: reached size limit %d bytes, ".
"%d = %d (sent) + %d (still to go)",
$size_limit, $bytes_written+length($buff),
$bytes_written, length($buff));
$done = 1;
}
$spamd_handle->print($buff);
$bytes_written += length($buff);
last if $done;
}
} elsif ($msg->isa('MIME::Entity')) { # TODO - content length won't match!
do_log(3,"spamc: message is MIME::Entity, size won't match");
$msg->print_body($spamd_handle);
} else {
$msg->seek($file_position,0) or die "Can't rewind mail file: $!";
my($nbytes,$buff,$done);
while ( $nbytes=$msg->sysread($buff,16384) ) {
$file_position += $nbytes;
$buff =~ s{\n}{\015\012}gs;
if (defined $size_limit &&
$bytes_written + length($buff) >= $size_limit) {
substr($buff, $size_limit - $bytes_written) = ''; # truncate
# spamd reads line-by-line and hangs if not terminated by a NL
substr($buff,-1,1) = "\012";
do_log(5,"spamc: reached size limit %d bytes, ".
"%d = %d (sent) + %d (still to go)",
$size_limit, $bytes_written+length($buff),
$bytes_written, length($buff));
$done = 1;
}
$spamd_handle->print($buff);
$bytes_written += length($buff);
last if $done;
}
defined $nbytes or die "Error reading: $!";
}
$spamd_handle->flush;
$hdr_prefix = undef;
section_time($which_section);
$which_section = 'spamd_rx'; do_log(4,"receiving from spamd");
my($version, $resp_code, $resp_msg);
local($1,$2,$3); my($ln,$error,$first); $first = 1;
while (defined($ln = $spamd_handle->get_response_line)) {
do_log(4,"from spamd - resp.hdr: %s", $ln);
if ($ln eq "\015\012") {
last;
} elsif ($first) {
$first = 0; $ln =~ s/\015\012\z//;
($version,$resp_code,$resp_msg) = split(/[ \t]+/,$ln,3);
} elsif ($ln =~ /^([^:]*?)[ \t]*:[ \t]*(.*)\015\012\z/i) {
$attr{lc($1)} = $2;
} else { $error = $ln }
}
if ($first) { do_log(-1,"Empty spamd response") }
elsif (defined $error) { do_log(-1,"Error in spamd resp: %s",$error) }
elsif ($resp_code !~ /^\d+\z/ || $resp_code != 0) {
do_log(-1,"Failure reported by spamd: %s %s %s",
$version,$resp_code,$resp_msg);
} else {
my $reply_len = 0;
while (defined($ln = $spamd_handle->get_response_line)) {
do_log(5,"from spamd: %s", $ln);
$reply_len += length($ln); $ln =~ s/\015\012\z//; $sa_tests = $ln;
}
do_log(-1,"Reply from spamd size mismatch: %d %s",
$reply_len, $attr{'content-length'}
) if $reply_len != $attr{'content-length'};
}
$spamd_handle->close; # terminate the session, ignoring status
undef $spamd_handle;
$spam_level = $2 if $attr{'spam'} =~ m{(\S+) ; (\S+) / (\S+)};
1;
} or do {
my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat;
do_log(-1,"%s client failed: %s", $scanner_name, $eval_stat);
};
section_time($which_section);
my $score_factor = $self->{options}->{'score_factor'};
if (defined $spam_level && defined $score_factor) {
$spam_level *= $score_factor;
}
do_log(2,"%s spamd score=%s, tests=%s",
$scanner_name, $spam_level, $sa_tests);
$msginfo->supplementary_info('SCORE-'.$scanner_name, $spam_level);
$msginfo->supplementary_info('VERDICT-'.$scanner_name,
$attr{'spam'} =~ /^True/ ? 'Spam'
: $attr{'spam'} =~ /^False/ ? 'Ham' : 'Unknown');
for my $r (@$per_recip_data) {
$r->spam_level( ($r->spam_level || 0) + $spam_level );
if (!$r->spam_tests) {
$r->spam_tests([ \$sa_tests ]);
} else {
push(@{$r->spam_tests}, \$sa_tests);
}
}
}
1;