blob: fd3a26e26ac5090d18507e36a3ae71d2ee950927 [file] [log] [blame]
=head1 NAME
Mail::SpamAssassin::Plugin::SIQ version: 20060305
=head1 SYNOPSIS
loadplugin Mail::SpamAssassin::Plugin::SIQ [/path/to/SIQ.pm]
siq_server db.outboundindex.net:6264
siq_server_ttl db.outboundindex.net:6264 300
siq_oi_workaround 0
siq_query_timeout 5
siq_skip_domain example.com
siq_skip_ip 1.2.3.4
header SIQ_OI_00 eval:siq_score('db.outboundindex.net',0,0)
score SIQ_OI_00 1.5
describe SIQ_OI_00 Outbound Index Reputation: http://outboundindex.org/
tflags SIQ_OI_00 net
priority SIQ_OI_00 900
header SIQ_OI_IP_01 eval:siq_ip_score('db.outboundindex.net',1,1)
score SIQ_OI_IP_01 1.0
describe SIQ_OI_IP_01 Outbound Index IP Reputation: http://outboundindex.org/
tflags SIQ_OI_IP_01 net
priority SIQ_OI_IP_01 900
header SIQ_OI_DOM_50 eval:siq_domain_score('db.outboundindex.net',50,59)
score SIQ_OI_DOM_50 0.1
describe SIQ_OI_DOM_50 Outbound Index Domain Reputation: http://outboundindex.org/
tflags SIQ_OI_DOM_50 net
priority SIQ_OI_DOM_50 900
header SIQ_OI_REL_01 eval:siq_relative_score('db.outboundindex.net',1,1)
score SIQ_OI_REL_01 1.0
describe SIQ_OI_REL_01 Outbound Index Relative Reputation: http://outboundindex.org/
tflags SIQ_OI_REL_01 net
priority SIQ_OI_REL_01 900
header SIQ_OI_CONF_01 eval:siq_confidence('db.outboundindex.net',1,1)
score SIQ_OI_CONF_01 1.0
describe SIQ_OI_CONF_01 Outbound Index Confidence: http://outboundindex.org/
tflags SIQ_OI_CONF_01 net
priority SIQ_OI_CONF_01 900
header SIQ_OI_STAB_1 db.outboundindex.net:6264 =~ /stability=1\./
score SIQ_OI_STAB_1 0.5
describe SIQ_OI_STAB_1 Outbound Index stability value of 1
tflags SIQ_OI_STAB_1 net
priority SIQ_OI_STAB_1 901
=head1 DESCRIPTION
This plugin queries for reputation data, based on domain & IP pairs, from a
reputation service provider using the IETF ASRG draft SIQ protocol:
http://www.ietf.org/internet-drafts/draft-irtf-asrg-iar-howe-siq-02.txt
A number of eval functions are provided for writing eval-type rules against
the reputation data returned by the reputation service queried.
A pseudo-header is also provided for testing of the optional text area in an
SIQ response.
=head1 AUTHOR
Daryl C. W. O'Shea, DOS Technologies <spamassassin@dostech.ca>
=head1 COPYRIGHT
Copyright (c) 2006 Daryl C. W. O'Shea, DOS Technologies. All rights reserved.
Licensed 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.
=head1 NOTICE
Built-in caching is used, so queries against the same domain and IP pair will
not incur the expense (both time and reputation service provider charges) of
an additional query. Note that each SpamAssassin child process maintains its
own idependent cache which is not shared with other children and lasts only
for the lifetime of the current child. The cache life time is configurable.
=head1 PRIVACY CONCERNS
As with any third-party data service used to classify email, use of services
utilizing the SIQ protocol has inherent privacy implications. Many/most
reputation services use aggregated data from their query logs as a part of
their reputation calculations. With the data provided (domain and IP pairs)
by a query client, such as this plugin, a reputation service provider could
estimate your email volume, a breakdown of email domains sending mail to your
systems, and etc.
Depending on your DNS setup, use of services using the SIQ protocol might not
impose privacy concerns greater than those already imposed by the use of DNS
based IP and/or URI blacklists (or whitelists).
=cut
package Mail::SpamAssassin::Plugin::SIQ;
use Mail::SpamAssassin::Plugin;
use Mail::SpamAssassin::Logger;
use strict;
use warnings;
use bytes;
use Socket;
use IO::Socket;
use Fcntl qw(F_GETFL F_SETFL O_NONBLOCK);
our @ISA = qw(Mail::SpamAssassin::Plugin);
sub new {
my $class = shift;
my $mailsaobject = shift;
$class = ref($class) || $class;
my $self = $class->SUPER::new($mailsaobject);
bless ($self, $class);
if ($mailsaobject->{local_tests_only}) {
$self->{disabled} = 1;
} else {
$self->{disabled} = 0;
}
$self->register_eval_rule("siq_score");
$self->register_eval_rule("siq_ip_score");
$self->register_eval_rule("siq_domain_score");
$self->register_eval_rule("siq_relative_score");
$self->register_eval_rule("siq_confidence");
$self->set_config($mailsaobject->{conf});
return $self;
}
sub set_config {
my($self, $conf) = @_;
my @cmds = ();
=head1 USER PREFERENCES
=over 4
=item siq_skip_domain example.com (default: none)
A list of domain name patterns to exclude from SIQ queries. Normal shell
wild cards may be used, similar to those used in <C>whilelist_from entries.
Multiple domain name patterns per line are permitted, as are multiple lines.
Example:
siq_skip_domain example.com *.example.com
siq_skip_domain *.apache.org
=cut
push (@cmds, {
setting => 'siq_skip_domain',
default => {},
code => sub {
my ($self, $key, $value, $line) = @_;
if ($value =~ /^$/) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
if ($value !~ /^[-.*?\w\s]+$/) {
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
foreach my $domain (split(/\s+/, $value)) {
my $pattern = $domain;
$domain =~ s/\./\\\./g;
$domain =~ s/\?/\./g;
$domain =~ s/\*/\.\*/g;
$self->{siq_skip_domain}->{lc $domain} = $pattern;
}
}
});
=item siq_skip_ip 192.168.123.* (default: none)
A list of ip patterns to exclude from SIQ queries. Normal shell wild cards
may be used, similar to those used in <C>whilelist_from entries.
Multiple ip patterns per line are permitted, as are multiple lines.
Example:
siq_skip_ip 192.168.123.* 127.*
siq_skip_ip 10.1.*
<b>Note: Currently only file-glob style wildcards are supported. CIDR
notation, nor any other format, is <b> NOT supported.
=cut
push (@cmds, {
setting => 'siq_skip_ip',
default => {},
code => sub {
my ($self, $key, $value, $line) = @_;
if ($value =~ /^$/) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
if ($value !~ /^[\.\*\?0-9\s]+$/) {
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
foreach my $ip (split(/\s+/, $value)) {
my $pattern = $ip;
$ip =~ s/\./\\\./g;
$ip =~ s/\?/\./g;
$ip =~ s/\*/\.\*/g;
$self->{siq_skip_ip}->{$ip} = $pattern;
}
}
});
=back
=head1 RULE DEFINITIONS AND PRIVILEGED SETTINGS
There are no privileged settings provided.
=head1 ADMINISTRATOR SETTINGS
These settings differ from the ones above, in that they are considered 'more
privileged' -- even more than the ones in the B<PRIVILEGED SETTINGS> section.
No matter what C<allow_user_rules> is set to, these can never be set from a
user's C<user_prefs> file when spamc/spamd is being used. However, all
settings can be used by local programs run directly by the user.
=over 4
=item siq_server db.example.net:6264
An SIQ server hostname to query. An optional :port number may be included. If
no port is specified, port 6264 will be used by default. Multiple servers per
line are permitted, as are multiple lines.
Examples:
siq_server db.example.net:6264
siq_server db.example.org
siq_server db.example.com db.example.org:1234
siq_server db.example.net:6264 db.example.org:1234
=cut
push (@cmds, {
setting => 'siq_server',
default => {},
is_admin => 1,
code => sub {
my ($self, $key, $value, $line) = @_;
if ($value =~ /^$/) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
if ($value !~ /^[-.\w\d]+(?::\d{1,5})?(?:\s+[-.\w\d]+(?::\d{1,5})?)*$/) {
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
foreach my $server (split(/\s+/, $value)) {
$server =~ /^(.*?)(?::(.*))?$/;
my $host = lc $1;
my $port = (defined $2 ? $2 : "6264");
$self->{siq_servers}->{lc $1}->{$port} = 1;
dbg("config: added SIQ server host: $1 port: $port");
}
}
});
=item siq_server_ttl db.example.net:6264 300
The amount of time in seconds to keep cached SIQ query responses from a
particular server. Note that domain and IP pairs may be cached more often
than this value as caches are not shared between children and expire when a
child expires (after 200 messages by default).
This option overrides the TTL returned in an SIQ response by the specified SIQ
server hostname. An optional :port number may be included. If no port is
specified, port 6264 will be used by default. Only one server, with optional
port, and TTL value per line is permitted. Multiple lines are permitted.
Examples:
siq_server_ttl db.example.net:6264 300
siq_server_ttl db.example.org 500
Note: To prevent abuse of services, the longer of the TTL provided with this
option and the TTL provided in the SIQ response will be used.
=cut
push (@cmds, {
setting => 'siq_server_ttl',
default => {},
is_admin => 1,
code => sub {
my ($self, $key, $value, $line) = @_;
if ($value =~ /^$/) {
return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
}
if ($value !~ /^([-.\w\d]+)(?::(\d{1,5}))?\s+(\d+)$/) {
return $Mail::SpamAssassin::Conf::INVALID_VALUE;
}
my $host = lc $1;
my $port = (defined $2 ? $2 : "6264");
my $ttl = $3;
$self->{siq_server_ttls}->{lc $1}->{$port} = $3;
dbg("config: added SIQ response TTL: $3 for server host: $1 port: $port");
}
});
=item siq_oi_workaround (0|1) (default: 0)
As of March 5, 2006, Oubtbound Index does not yet include octets 8-11 (TTL,
Confidence and Extra-Length values) as specified by the draft in their
responses.
Outbound Index plans on updating their software to include these octets in the
near future. Set this option to 1 to enable correct parsing of Outbound Index
responses in the interim.
<B>Note: Enabling this option will affect parsing of ALL SIQ servers'
responses. Therefore you cannot use Outbound Index and another service
together until Outbound Index updates their service so that this option
is not required. This shouldn't be a problem since there aren't any other
public services using SIQ yet.
=cut
push(@cmds, {
setting => 'siq_oi_workaround',
default => 0,
is_admin => 1,
type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOL
});
=item siq_query_timeout n (default: 5)
The amount of time in seconds to wait for an SIQ query to complete.
=cut
push(@cmds, {
setting => 'siq_query_timeout',
default => 5,
is_admin => 1,
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC
});
$conf->{parser}->register_commands(\@cmds);
}
=item eval:siq_score('host:port',min,max)
This eval function is provided for writing eval-type rules against the
reputation score returned by the reputation service queried.
<i>min and <i>max define a range of scores to match against.
Example:
header SIQ_OI_00 eval:siq_score('db.outboundindex.net',0,0)
score SIQ_OI_00 1.5
describe SIQ_OI_00 Outbound Index Reputation: http://outboundindex.org/
tflags SIQ_OI_00 net
priority SIQ_OI_00 900
Note: The priority value gives SIQ responses more time to arrive before
SpamAssassin pauses to wait for responses for the amount of time specified by
<I>siq_query_timeout. Changing the priority value is not recommended. Adjust
the <I>siq_query_timeout value to shorten or lengthen the time SpamAssassin
will wait for SIQ responses. Set <I>siq_query_timeout to <I>0 if you do not
want SpamAssassin to wait at all for SIQ responses.
=cut
sub siq_score {
my ($self, $pms, $server, $min, $max) = @_;
return 0 if $self->{disabled};
return 0 unless $pms->{siq_checking};
my $rule_name = $pms->get_current_eval_rule_name();
my ($config_ok, $host, $port)
= $self->_parse_eval_call($pms, "siq_score", $rule_name, $server, $min, $max);
return 0 if (!$config_ok);
# this comes after $self->_parse_eval_call to avoid being called earlier
# than necessary by an incorrect eval call
$self->_get_results($pms) unless $pms->{siq_got_results};
# log the hit (if any) and return 0, otherwise hits will appear twice
my @results = $self->_get_results_from_cache($pms->{siq_time}, $host,
$port, $pms->{siq_domain}, $pms->{siq_ip});
if (defined $results[1] &&
$min <= $results[1] && $results[1] <= $max) {
$self->_log_hit($pms, $rule_name, "SIQ: score: $results[1] queried: ".
"$pms->{siq_domain}/$pms->{siq_ip}");
}
return 0;
}
=item eval:siq_ip_score('host:port',min,max)
This eval function is provided for writing eval-type rules against the
IP reputation score returned by the reputation service queried.
<i>min and <i>max define a range of scores to match against.
Example:
header SIQ_OI_IP_01 eval:siq_ip_score('db.outboundindex.net',1,1)
score SIQ_OI_IP_01 1.0
describe SIQ_OI_IP_01 Outbound Index IP Reputation: http://outboundindex.org/
tflags SIQ_OI_IP_01 net
priority SIQ_OI_IP_01 900
Note: See the note above for <I>eval:siq_score regarding the priority value.
=cut
sub siq_ip_score {
my ($self, $pms, $server, $min, $max) = @_;
return 0 if $self->{disabled};
return 0 unless $pms->{siq_checking};
my $rule_name = $pms->get_current_eval_rule_name();
my ($config_ok, $host, $port)
= $self->_parse_eval_call($pms, "siq_ip_score", $rule_name, $server, $min, $max);
return 0 if (!$config_ok);
# this comes after $self->_parse_eval_call to avoid being called earlier
# than necessary by an incorrect eval call
$self->_get_results($pms) unless $pms->{siq_got_results};
# log the hit (if any) and return 0, otherwise hits will appear twice
my @results = $self->_get_results_from_cache($pms->{siq_time}, $host,
$port, $pms->{siq_domain}, $pms->{siq_ip});
if (defined $results[3] &&
$min <= $results[3] && $results[3] <= $max) {
$self->_log_hit($pms, $rule_name, "SIQ: score: $results[3] queried: ".
"$pms->{siq_domain}/$pms->{siq_ip}");
}
return 0;
}
=item eval:siq_domain_score('host:port',min,max)
This eval function is provided for writing eval-type rules against the
domain reputation score returned by the reputation service queried.
<i>min and <i>max define a range of scores to match against.
Example:
header SIQ_OI_DOM_50 eval:siq_domain_score('db.outboundindex.net',50,59)
score SIQ_OI_DOM_50 0.1
describe SIQ_OI_DOM_50 Outbound Index Domain Reputation: http://outboundindex.org/
tflags SIQ_OI_DOM_50 net
priority SIQ_OI_DOM_50 900
Note: See the note above for <I>eval:siq_score regarding the priority value.
=cut
sub siq_domain_score {
my ($self, $pms, $server, $min, $max) = @_;
return 0 if $self->{disabled};
return 0 unless $pms->{siq_checking};
my $rule_name = $pms->get_current_eval_rule_name();
my ($config_ok, $host, $port)
= $self->_parse_eval_call($pms, "siq_domain_score", $rule_name, $server, $min, $max);
return 0 if (!$config_ok);
# this comes after $self->_parse_eval_call to avoid being called earlier
# than necessary by an incorrect eval call
$self->_get_results($pms) unless $pms->{siq_got_results};
# log the hit (if any) and return 0, otherwise hits will appear twice
my @results = $self->_get_results_from_cache($pms->{siq_time}, $host,
$port, $pms->{siq_domain}, $pms->{siq_ip});
if (defined $results[4] &&
$min <= $results[4] && $results[4] <= $max) {
$self->_log_hit($pms, $rule_name, "SIQ: score: $results[4] queried: ".
"$pms->{siq_domain}/$pms->{siq_ip}");
}
return 0;
}
=item eval:siq_relative_score('host:port',min,max)
This eval function is provided for writing eval-type rules against the
relative reputation score returned by the reputation service queried.
<i>min and <i>max define a range of scores to match against.
Example:
header SIQ_OI_REL_01 eval:siq_relative_score('db.outboundindex.net',1,1)
score SIQ_OI_REL_01 1.0
describe SIQ_OI_REL_01 Outbound Index Relative Reputation: http://outboundindex.org/
tflags SIQ_OI_REL_01 net
priority SIQ_OI_REL_01 900
Note: See the note above for <I>eval:siq_score regarding the priority value.
=cut
sub siq_relative_score {
my ($self, $pms, $server, $min, $max) = @_;
return 0 if $self->{disabled};
return 0 unless $pms->{siq_checking};
my $rule_name = $pms->get_current_eval_rule_name();
my ($config_ok, $host, $port)
= $self->_parse_eval_call($pms, "siq_relative_score", $rule_name, $server, $min, $max);
return 0 if (!$config_ok);
# this comes after $self->_parse_eval_call to avoid being called earlier
# than necessary by an incorrect eval call
$self->_get_results($pms) unless $pms->{siq_got_results};
# log the hit (if any) and return 0, otherwise hits will appear twice
my @results = $self->_get_results_from_cache($pms->{siq_time}, $host,
$port, $pms->{siq_domain}, $pms->{siq_ip});
if (defined $results[5] &&
$min <= $results[5] && $results[5] <= $max) {
$self->_log_hit($pms, $rule_name, "SIQ: score: $results[5] queried: ".
"$pms->{siq_domain}/$pms->{siq_ip}");
}
return 0;
}
=item eval:siq_confidence('host:port',min,max)
This eval function is provided for writing eval-type rules against the
confidence value returned by the reputation service queried.
<i>min and <i>max define a range of values to match against.
Example:
header SIQ_OI_CONF_01 eval:siq_confidence('db.outboundindex.net',1,1)
score SIQ_OI_CONF_01 1.0
describe SIQ_OI_CONF_01 Outbound Index Confidence: http://outboundindex.org/
tflags SIQ_OI_CONF_01 net
priority SIQ_OI_CONF_01 900
Note: See the note above for <I>eval:siq_score regarding the priority value.
=cut
sub siq_confidence {
my ($self, $pms, $server, $min, $max) = @_;
return 0 if $self->{disabled};
return 0 unless $pms->{siq_checking};
my $rule_name = $pms->get_current_eval_rule_name();
my ($config_ok, $host, $port)
= $self->_parse_eval_call($pms, "siq_confidence", $rule_name, $server, $min, $max);
return 0 if (!$config_ok);
# this comes after $self->_parse_eval_call to avoid being called earlier
# than necessary by an incorrect eval call
$self->_get_results($pms) unless $pms->{siq_got_results};
# log the hit (if any) and return 0, otherwise hits will appear twice
my @results = $self->_get_results_from_cache($pms->{siq_time}, $host,
$port, $pms->{siq_domain}, $pms->{siq_ip});
if (defined $results[8] &&
$min <= $results[8] && $results[8] <= $max) {
$self->_log_hit($pms, $rule_name, "SIQ: value: $results[8] queried: ".
"$pms->{siq_domain}/$pms->{siq_ip}");
}
return 0;
}
=item header siqhost[:port] =~ /pattern/modifiers
A pseudo-header containing the text portion of the SIQ result is
provided for each SIQ server that you have called at least one of
the above eval tests on one of more times.
Example:
header SIQ_OI_STAB_1 db.outboundindex.net =~ /stability=1\./
score SIQ_OI_STAB_1 0.5
describe SIQ_OI_STAB_1 Outbound Index stability value of 1
tflags SIQ_OI_STAB_1 net
priority SIQ_OI_STAB_1 901
header SIQ_EX_STAB_20 db.example.org:1234 =~ /stability=2[0-9]\./
score SIQ_EX_STAB_20 0.1
describe SIQ_EX_STAB_20 Example Service stability value of 20 to 29
tflags SIQ_EX_STAB_20 net
priority SIQ_EX_STAB_20 9
Notes:
You <b>MUST call at least one of the above eval tests on each of the servers
that you want to test the text portion of the response, otherwise the
pseudo-header will not be present.
You <B>MUST include the port number in the psuedo-header if the default port
6264 is not used, otherwise it is optional.
You <B>MUST include a priority for the rule that is greater in value than the
priority of the required pre-requisite eval test. The pseudo-header will not
yet be present if this rule's priority is less than (higher) than the above
eval tests.
=back
=cut
sub _parse_eval_call {
my ($self, $pms, $eval_name, $rule_name, $server, $min, $max) = @_;
my ($host, $port);
# validate the eval call and complain if it was done wrong
unless (defined $server && $server =~ /^([-.\w\d]+)(?::(\d{1,5}))?$/) {
warn("siq: eval rule: $rule_name ".
"requires an SIQ server parameter (host with optional :port) ".
"as the first parameter");
dbg("config: eval rule: $rule_name ".
"requires an SIQ server parameter such as: header $rule_name ".
"eval:$eval_name\('db.example.com:6264',20,30\)");
$pms->{rule_errors}++; # flag to --lint that there was an error ...
return 0;
} else {
$host = $1;
$port = (defined $2 ? $2 : "6264");
}
unless (exists $pms->{conf}->{siq_servers}->{$host}->{$port}) {
warn("siq: the SIQ server specified in eval rule: $rule_name ".
"has not been added to the list of SIQ servers to query");
dbg("config: you must add \'siq_server $host:$port\' to your configuration ".
"if you want to be able to test SIQ results from this server");
$pms->{rule_errors}++; # flag to --lint that there was an error ...
return 0;
}
unless (defined $min && $min =~ /^-?\d+(?:\.\d+)?$/ &&
defined $max && $max =~ /^-?\d+(?:\.\d+)?$/) {
warn("siq: eval rule: $rule_name requires a minimum and maximum value");
dbg("config: eval rule: $rule_name ".
"requires minimum and maximum parameters such as: header ".
"$rule_name eval:$eval_name\('db.example.com',20,30\)");
$pms->{rule_errors}++; # flag to --lint that there was an error ...
return 0;
}
return (1, $host, $port);
}
sub _log_hit {
my ($self, $pms, $rulename, $text) = @_;
$pms->test_log ($text);
$pms->got_hit ($rulename, "");
}
sub parsed_metadata {
my ($self, $opts) = @_;
my $pms = $opts->{permsgstatus};
return if $self->{disabled};
$pms->{siq_queries_remaining} = 0;
$pms->{siq_queries_sent} = 0;
$pms->{siq_time} = time;
$pms->{siq_got_results} = 0;
$pms->{siq_checking} = 0;
# get an appropriate relay to test against
my $lasthop = $self->_get_relay($pms);
if (!defined $lasthop) {
dbg("siq: no suitable relay for siq use found, skipping SIQ query");
return;
}
$pms->{siq_ip} = $lasthop->{ip};
$pms->{siq_domain} = $self->_get_sender($pms);
# we already dbg'd if we couldn't get a sender, just return
return unless (defined $pms->{siq_domain});
$pms->{siq_domain} =~ s/^.*\@//;
# check to see if the domain is in the list of domains to skip
my $skip_it = 0;
while (my ($regexp, $simple) = each (%{$pms->{conf}->{siq_skip_domain}})) {
if ($pms->{siq_domain} =~ /^$regexp$/) { # both already lc
dbg("siq: domain: $pms->{siq_domain} matches skip pattern: $simple");
$skip_it = 1;
}
}
return if $skip_it;
# check to see if the domain is in the list of domains to skip
while (my ($regexp, $simple) = each (%{$pms->{conf}->{siq_skip_ip}})) {
if ($pms->{siq_ip} =~ /^$regexp$/) { # both already lc
dbg("siq: ip: $pms->{siq_ip} matches skip pattern: $simple");
$skip_it = 1;
}
}
return if $skip_it;
# signal to the evals that we're doing checks this time around
$pms->{siq_checking} = 1;
# do queries
foreach my $host (keys %{$pms->{conf}->{siq_servers}}) {
foreach my $port (keys %{$pms->{conf}->{siq_servers}->{$host}}) {
next if $self->_check_for_cached_results($pms, $pms->{siq_time}, $host,
$port, $pms->{siq_domain}, $pms->{siq_ip});
dbg("siq: querying $host:$port");
$self->_send_siq_query($pms, $pms->{siq_domain}, $pms->{siq_ip},
$host, $port);
}
}
return;
}
sub _cache_results {
my ($self, $time, $host, $port, $domain, $ip, @results) = @_;
# set cache item expiry time
# don't allow TTLs shorter than the TTL specified in the response
if (exists $self->{main}->{conf}->{siq_server_ttls}->{$host}->{$port} &&
$self->{main}->{conf}->{siq_server_ttls}->{$host}->{$port} > $results[7]) {
$time += $self->{main}->{conf}->{siq_server_ttls}->{$host}->{$port};
} else {
$time += $results[7] if ($results[7] > 0); # unknown OI TTL is set to -999
}
$self->{siq_cache} = {} unless (exists $self->{siq_cache});
$self->{siq_cache}->{$host} = {}
unless (exists $self->{siq_cache}->{$host});
$self->{siq_cache}->{$host}->{$port} = {}
unless (exists $self->{siq_cache}->{$host}->{$port});
$self->{siq_cache}->{$host}->{$port}->{$domain} = {}
unless (exists $self->{siq_cache}->{$host}->{$port}->{$domain});
$self->{siq_cache}->{$host}->{$port}->{$domain}->{$ip} = [$time, @results];
dbg("siq: saved results to cache: $host:$port/$domain/$ip");
return;
}
sub _check_for_cached_results {
my ($self, $pms, $time, $host, $port, $domain, $ip) = @_;
if (exists $self->{siq_cache}->{$host}->{$port}->{$domain}->{$ip}) {
if ($self->{siq_cache}->{$host}->{$port}->{$domain}->{$ip}->[0] > $time) {
dbg("siq: found results in cache: $host:$port/$domain/$ip");
# make the cached text portion available for testing
# the port number is optional if the default 6264 is used
$pms->{msg}->put_metadata("$host:$port",
$self->{siq_cache}->{$host}->{$port}->{$domain}->{$ip}->[10]);
if ($port == 6264) {
$pms->{msg}->put_metadata($host,
$self->{siq_cache}->{$host}->{$port}->{$domain}->{$ip}->[10]);
}
return 1;
} else {
dbg("siq: found expired result in cache, doing new query");
}
} else {
dbg("siq: no results found in cache for $host:$port");
}
return 0;
}
sub _get_results_from_cache {
my ($self, $time, $host, $port, $domain, $ip) = @_;
if (exists $self->{siq_cache}->{$host}->{$port}->{$domain}->{$ip}) {
if ($self->{siq_cache}->{$host}->{$port}->{$domain}->{$ip}->[0] > $time) {
my @results = @{$self->{siq_cache}->{$host}->{$port}->{$domain}->{$ip}};
shift @results;
return @results;
}
}
return undef;
}
sub _generate_query_id {
return int(rand(65535));
}
sub _send_siq_query {
my ($self, $pms, $domain, $ip, $host, $port) = @_;
unless (defined $ip &&
$ip =~ /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) {
warn("siq: invalid (non-IPv4) IP passed to _send_siq_query\n");
return 0;
}
unless (defined $domain) {
warn("siq: missing domain in call to _send_siq_query\n");
return 0;
}
# the query ID identifies the query and is used in the response packet
# since we send multiple queries out on the same socket, we have to
# create a new packet with a random query ID for each query
my $query_id = $self->_generate_query_id();
$pms->{siq_query_ids}->{$host}->{$port} = $query_id;
# build request packet
my $payload = pack("B8", "00000001"); # version
$payload .= pack("B8"); # QT
$payload .= substr(pack("N", $query_id), 2); # ID
$payload .= pack("B96"); # IPv6 zero-padding
$payload .= inet_aton($ip); # IPv4 in IPv6
$payload .= substr(pack("N", length($domain)), 3); # QD-length in octets
$payload .= pack("B8"); # extra length (octets)
$payload .= $domain; # domain
# save socket handle on $pms, we'll check for a response later
unless (exists $pms->{siq_handle} && $pms->{siq_handle}) {
dbg("siq: opening socket for SIQ queries");
unless ($pms->{siq_handle} = IO::Socket::INET->new(Proto => 'udp')) {
dbg("siq: socket creation failed: $@");
return 0;
} else {
# try to prevent unwanted blocking
my $flags = fcntl($pms->{siq_handle}, F_GETFL, 0)
or warn "siq: Can't get flags for the socket: $!\n";
if ($flags) {
fcntl($pms->{siq_handle}, F_SETFL, $flags | O_NONBLOCK)
or warn "siq: Can't set flags for the socket: $!\n";
}
}
} else {
dbg("siq: using existing socket for SIQ queries");
}
my $ipaddr = inet_aton($host);
my $portaddr = sockaddr_in($port, $ipaddr);
unless (send($pms->{siq_handle}, $payload, 0, $portaddr)
== length($payload)) {
dbg("siq: cannot send query: $!");
return 0;
} else {
dbg("siq: sent query ID $query_id to $host:$port");
$pms->{siq_queries_remaining}++;
$pms->{siq_queries_sent}++;
}
return 1;
}
sub _get_results {
my ($self, $pms) = @_;
$pms->{siq_got_results} = 1;
$self->_harvest_siq_responses($pms);
dbg("siq: sent ". $pms->{siq_queries_sent} ." queries, received ".
($pms->{siq_queries_sent} - $pms->{siq_queries_remaining})
." responses");
return;
}
sub _harvest_siq_responses {
my ($self, $pms) = @_;
return unless $pms->{siq_queries_sent};
my $rout;
my $rin = '';
vec($rin,fileno($pms->{siq_handle}),1) = 1;
my $timeout = $pms->{conf}->{siq_query_timeout};
my $nfound = 0;
my $wait_time = 0.05;
while ($timeout > 0) {
$nfound = select($rout=$rin, undef, undef, $wait_time);
if (!defined $nfound || $nfound < 1) {
$timeout -= $wait_time;
} else {
# read results
READRESULT: for (my $i = 0; $i < $nfound; $i++) {
my ($response, $portaddr);
unless ($portaddr = recv($pms->{siq_handle}, $response, 512, 0)) {
dbg ("siq: recv failed: $!");
return 0;
}
# parse response
# returns: ($version, $score, $id, $ipscore, $dscore, $rscore, $textlen, $ttl,
# $confidence, $text)
my (@results) = $self->_parse_response($response, $pms->{conf}->{siq_oi_workaround});
next READRESULT unless @results;
foreach my $host (keys %{$pms->{siq_query_ids}}) {
foreach my $port (keys %{$pms->{siq_query_ids}->{$host}}) {
my $query_id = $pms->{siq_query_ids}->{$host}->{$port};
if ($results[2] == $query_id) {
dbg("siq: response ID $query_id matches query to $host:$port");
$pms->{siq_queries_remaining}--;
dbg("siq: response: ". join("/", @results));
$self->_cache_results($pms->{siq_time}, $host, $port,
$pms->{siq_domain}, $pms->{siq_ip}, @results);
# we store the text section as metadata so people can write
# rules against it
# the port number is optional if the default 6264 is used
$pms->{msg}->put_metadata("$host:$port", $results[9]);
if ($port == 6264) {
$pms->{msg}->put_metadata($host, $results[9]);
}
unless ($pms->{siq_queries_remaining}) {
dbg("siq: received responses to all queries after waiting ".
(sprintf "%.2f", ($pms->{conf}->{siq_query_timeout} - $timeout))
." seconds, closing socket");
close $pms->{siq_handle};
return 1;
}
next READRESULT;
}
}
}
dbg("siq: response ID $results[2] does not match any queries sent ".
"for this message, discarding");
}
dbg("siq: waiting up to $timeout seconds for more responses");
}
}
dbg("siq: query response timeout, closing socket");
close $pms->{siq_handle};
return 0;
}
sub _parse_response {
my ($self, $response, $enable_oi_workaround) = @_;
my $min_response_length = 12;
if ($enable_oi_workaround) {
dbg("siq: using Outbound Index response missing octets workaround");
$min_response_length = 8;
}
if (length($response) < $min_response_length) {
dbg("siq: packet shorter than minimum response length, ignoring packet");
return;
}
my $version = unpack("c8", substr($response, 0, 1));
my $score = unpack("c8", substr($response, 1, 1));
my $id = unpack("N", pack("x2B16", unpack("B16", substr($response, 2, 2))));
my $ipscore = unpack("c8", substr($response, 4, 1));
my $dscore = unpack("c8", substr($response, 5, 1));
my $rscore = unpack("c8", substr($response, 6, 1));
my $textlen = unpack("c8", substr($response, 7, 1));
# ensure we've got the entire packet
if (length($response) < ($min_response_length + $textlen)) {
dbg("siq: packet length shorter than minimum length plus reported TEXT ".
"section length, ignoring packet");
return;
}
# workaround Outbound Index not using the current draft yet
# (they don't include draft response octets 8-11)
my ($ttl, $confidence, $text);
if ($enable_oi_workaround) {
$ttl = -999;
$confidence = -999;
$text = unpack("A*", substr($response, 8, $textlen));
} else {
$ttl = unpack("N", pack("x2B16", unpack("B16", substr($response, 8, 2))));
$confidence = unpack("c8", substr($response, 10, 1));
my $xtralen = unpack("c8", substr($response, 11, 1));
$text = unpack("A*", substr($response, 12, $textlen));
# the 'EXTRA' data is server/client dependent, we can't reasonably parse
# ever implementations EXTRA section, so don't parse any -- they can
# always use the TEXT section which we do support
dbg("siq: plugin does not support parsing of the $xtralen octets of ".
"'EXTRA' data provided in the SIQ response, not using 'EXTRA' data")
if $xtralen;
}
return ($version, $score, $id, $ipscore, $dscore, $rscore, $textlen, $ttl,
$confidence, $text);
}
# dos: copied (with s/SPF/SIQ/) from my patch for SA bug 4661 -- the current
# SPF code does it wrong for non-trivial cases
# http://issues.apache.org/SpamAssassin/attachment.cgi?id=3241&action=view
# this really needs to get into Received.pm itself
sub _get_relay {
my ($self, $scanner) = @_;
# return relay if already determined
return $scanner->{siq_relay} if exists $scanner->{siq_relay};
# DOS: For SIQ checks we want to use the relay that passed the message to
# the internal network. This relay can be any of the trusted relays or the
# first untrusted relay. No matter which it is, the next (newer) relay has
# to be an internal relay. If there are no trusted relays, the first
# untrusted relay is the one we want. If internal_networks aren't set we
# have to assume all trusted relays are internal.
my $relay = undef;
my $relays_trusted = $scanner->{relays_trusted};
# no trusted relays, use first untrusted
if (scalar @{$relays_trusted} == 0) {
$relay = $scanner->{relays_untrusted}->[0];
dbg("siq: no trusted relays found, using first (untrusted) relay (if present) for SIQ checks");
}
# last trusted relay is internal (or internal_networks not set), use first untrusted
elsif ($relays_trusted->[-1]->{internal} || !($scanner->{conf}->{internal_networks}->get_num_nets() > 0)) {
$relay = $scanner->{relays_untrusted}->[0];
dbg("siq: last trusted relay is internal, using first untrusted relay (parsed relay #". (scalar @{$relays_trusted}+1) ." if present) for SIQ checks");
}
# find external relay that passed the message to the last internal relay
else {
# found an internal relay?
my $found = 0;
# start at the end; don't check for an internal relay before the first one
for (my $i = scalar @{$relays_trusted} - 1; $i > 0 && !$found; $i--) {
# if the next relay is internal, we can use the current external one
if ($relays_trusted->[$i-1]->{internal}) {
$relay = $relays_trusted->[$i];
$found = 1;
dbg("siq: using first external trusted relay (parsed relay #". ($i+1) .") for SIQ checks");
}
}
# if none of the trusted relays were internal, internal_networks isn't set
# correctly -- dbg about it
if (!$found) {
dbg("siq: none of the trusted relays are internal, please check your internal_networks configuration");
}
}
$scanner->{siq_relay} = $relay;
return $relay;
}
# copied with modifications from patched (bug 4661) SPF.pm
# this also needs to get into Received.pm or elsewhere
sub _get_sender {
my ($self, $scanner) = @_;
my $sender;
my $relay = $self->_get_relay($scanner);
if (defined $relay) {
$sender = $relay->{envfrom};
}
if ($sender) {
dbg("siq: found Envelope-From in first external Received header");
}
else {
# We cannot use the env-from data, since it went through 1 or more relays
# since the untrusted sender and they may have rewritten it.
if ($scanner->{num_relays_trusted} > 0 && !$scanner->{conf}->{always_trust_envelope_sender}) {
dbg("siq: relayed through one or more trusted relays, cannot use header-based Envelope-From, skipping");
return;
}
# we can (apparently) use whatever the current Envelope-From was,
# from the Return-Path, X-Envelope-From, or whatever header.
# it's better to get it from Received though, as that is updated
# hop-by-hop.
$sender = $scanner->get ("EnvelopeFrom");
}
if (!$sender) {
dbg("siq: cannot get Envelope-From, cannot use SIQ");
return; # avoid setting $scanner->{sender} to undef
}
return lc $sender;
}
1;