#
# SpamAssassin SNF4SA Plugin for SNFServer.
#
# This plugin implements a SpamAssassin rule to use SNFServer to test
# whether an email is spam.
#
# Copyright (C) 2009 ARM Research Labs, LLC.
#
# snf4sa.pm
#
# The plugin implements a single evaluation rule, which passes the
# email message through SNFServer. The communication with SNFServer
# is through XCI and a temporary file on disk which contains the email
# message truncated to the frist 64K bytes.
#
package Snf4sa;
use strict;
use Mail::SpamAssassin;
use Mail::SpamAssassin::Plugin;
use Mail::SpamAssassin::PerMsgStatus;
use IO::Socket;
use IO::File;
use File::Temp qw/ tempfile tempdir /;
our @ISA = qw(Mail::SpamAssassin::Plugin);
# Convenience variables and pseudo-constants
my $CRLF = "\x0d\x0a";
my $DefaultMaxTempFileSize = 64 * 1024;
# translation table for SNF rule codes
my $rule_code_xlat = {
0 => 'Standard White Rules',
20 => 'GBUdb Truncate (superblack)',
40 => 'GBUdb Caution (suspicious)',
47 => 'Travel',
48 => 'Insurance',
49 => 'Antivirus Push',
50 => 'Media Theft',
51 => 'Spamware',
52 => 'Snake Oil',
53 => 'Scam Patterns',
54 => 'Porn/Adult',
55 => 'Malware & Scumware Greetings',
56 => 'Ink & Toner',
57 => 'Get Rich',
58 => 'Debt & Credit',
59 => 'Casinos & Gambling',
60 => 'Ungrouped Black Rules',
61 => 'Experimental Abstract',
62 => 'Obfuscation Techniques',
63 => 'Experimental Received [ip]',
};
sub new {
my ($class, $mailsa) = @_;
$class = ref($class) || $class;
my $self = $class->SUPER::new($mailsa);
bless ($self, $class);
# Name of evaluation rule.
$self->register_eval_rule ("snf4sa_sacheck");
# Use localhost.
$self->{SNF_Host} = "localhost";
# Use default port.
$self->{SNF_Port} = 9001;
# Timeout.
$self->{SNF_Timeout} = 1;
# Directory for files containing emails read by SNFServer.
$self->{Temp_Dir} = '/tmp/snf4sa';
# Maximum email message size (including headers).
$self->{SNF_MaxTempFileSize} = $DefaultMaxTempFileSize;
return $self;
}
sub snf4sa_sacheck {
my ($self, $permsgstatus, $fulltext) = @_;
my $testscore = 0;
my $response ='';
my $exitvalue;
# Make sure we have a temp dir
unless(-d $self->{Temp_Dir}) {
mkdir($self->{Temp_Dir});
chmod(0777, $self->{Temp_Dir});
};
# Truncate the message.
my $mailtext = substr( ${$fulltext}, 0, $self->{SNF_MaxTempFileSize});
# create our temp file, $filename will contain the full path
my ($fh, $filename) = tempfile( DIR => $self->{Temp_Dir} );
# spew our mail into the temp file
my $SNF_fh = IO::File->new( $filename, "w" ) ||
die(__PACKAGE__ . ": Unable to create temporary file '" . $filename . "'");
$SNF_fh->print($mailtext) ||
$self->cleanup_die($filename,
__PACKAGE__ . ": Unable to write to temporary file '" .
$filename . "'");
$SNF_fh->close ||
$self->cleanup_die($filename,
__PACKAGE__ . ": Unable to close temporary file '" .
$filename . "'");
# Change permissions.
my $cnt = chmod(0666, $filename) ||
$self->cleanup_die($filename, __PACKAGE__ .
": Unable to change permissions of temporary file '" .
$filename . "'");
# xci_scan connects to SNFServer with XCI to scan the message
my $SNF_XCI_Return = $self->xci_scan( $filename );
# Remove the temp file, we are done with it.
unlink($filename);
# Check response from SNFServer.
if (! $SNF_XCI_Return ) {
die(__PACKAGE__ . ": Internal error");
}
# Check for success.
if (! $SNF_XCI_Return->{"success"}) {
die(__PACKAGE__ . ": Error from SNFServer: " .
$SNF_XCI_Return->{"message"});
}
# get the return code and translation
my ( $rc, $rcx ) = ( $SNF_XCI_Return->{"code"},
$rule_code_xlat->{ $SNF_XCI_Return->{"code"} } );
$rc = -1 unless defined $rc; # default values
$rcx = 'Unknown' unless $rcx;
my $rch = $SNF_XCI_Return->{"header"}; # the SNF header(s)
# Result code of 0 indicates non-spam. Any other value indicates
# spam.
if ($rc >= 1) {
$testscore=1;
} else {
$testscore=0;
}
# Add the header.
$permsgstatus->set_tag("SNFRESULTTAG", "$rc ($rcx)");
#all SA cares about is whether a 0 or 1 comes back. 0 = good 1=spam.
return $testscore;
}
sub abort
{
my ( $self, $message ) = @_;
}
# xci_scan( $file )
# returns hashref:
# success : true/false
# code : response code from SNF
# message : scalar message (if any)
sub xci_scan
{
my ( $self, $file ) = @_;
return undef unless $self and $file;
my $ret_hash = {
success => undef,
code => undef,
message => undef,
header => undef,
xml => undef
};
my $xci = $self->connect_socket( $self->{SNF_Host}, $self->{SNF_Port} )
or return $self->err_hash("cannot connect to socket ($!)");
$xci->print("\n");
my $rc = $ret_hash->{xml} = $self->socket_response($xci, $file);
$xci->close;
if ( $rc =~ /^/ ) {
$ret_hash->{success} = 1;
$ret_hash->{code} = $1;
$rc =~ /(.*)<\/xhdr>/s and $ret_hash->{header} = $1;
} elsif ( $rc =~ /^{message} = $1;
} else {
$ret_hash->{message} = "unknown XCI response: $rc";
}
return $ret_hash;
}
# connect_socket( $host, $port )
# returns IO::Socket handle
sub connect_socket
{
my ( $self, $host, $port ) = @_;
return undef unless $self and $host and $port;
my $protoname = 'tcp'; # Proto should default to tcp but it's not expensive to specify
$self->{XCI_Socket} = IO::Socket::INET->new(
PeerAddr => $host,
PeerPort => $port,
Proto => $protoname,
Timeout => $self->{SNF_Timeout} ) or return undef;
$self->{XCI_Socket}->autoflush(1); # make sure autoflush is on -- legacy
return $self->{XCI_Socket}; # return the socket handle
}
# socket_response( $socket_handle )
# returns scalar string
sub socket_response
{
my ( $self, $rs, $file ) = @_;
my $buf = ''; # buffer for response
# blocking timeout for servers who accept but don't answer
eval {
local $SIG{ALRM} = sub { die "timeout\n" }; # set up the interrupt
alarm $self->{SNF_Timeout}; # set up the alarm
while (<$rs>) { # read the socket
$buf .= $_;
}
alarm 0; # reset the alarm
};
# report a blocking timeout
if ( $@ eq "timeout\n" ) {
$self->cleanup_die($file,
__PACKAGE__ . ": Timeout waiting for response from SNFServer");
} elsif ( $@ =~ /alarm.*unimplemented/ ) { # no signals on Win32
while (<$rs>) { # get whatever's left
# in the socket.
$buf .= $_;
}
}
return $buf;
}
# return an error message for xci_scan
sub err_hash
{
my ( $self, $message ) = @_;
return {
success => undef,
code => undef,
message => $message
};
}
sub cleanup_die
{
my ( $self, $file, $message ) = @_;
unlink($file);
die($message);
}
1;