File Coverage

blib/lib/Mail/SpamAssassin/Plugin/Razor2.pm
Criterion Covered Total %
statement 44 193 22.8
branch 5 120 4.1
condition 1 32 3.1
subroutine 9 14 64.2
pod 3 7 42.8
total 62 366 16.9


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             =head1 NAME
19              
20             Mail::SpamAssassin::Plugin::Razor2 - perform Razor check of messages
21              
22             =head1 SYNOPSIS
23              
24             loadplugin Mail::SpamAssassin::Plugin::Razor2
25              
26             =head1 DESCRIPTION
27              
28             Vipul's Razor is a distributed, collaborative, spam detection and
29             filtering network based on user submissions of spam. Detection is done
30             with signatures that efficiently spot mutating spam content and user
31             input is validated through reputation assignments.
32              
33             See http://razor.sourceforge.net/ for more information about Razor.
34              
35             =head1 USER SETTINGS
36              
37             =over 4
38              
39             =cut
40              
41              
42             use Mail::SpamAssassin::Plugin;
43 22     22   162 use Mail::SpamAssassin::Logger;
  22         48  
  22         666  
44 22     22   115 use Mail::SpamAssassin::Timeout;
  22         47  
  22         1230  
45 22     22   1787 use strict;
  22         44  
  22         530  
46 22     22   121 use warnings;
  22         35  
  22         534  
47 22     22   115 # use bytes;
  22         83  
  22         697  
48             use re 'taint';
49 22     22   132  
  22         61  
  22         45070  
50             our @ISA = qw(Mail::SpamAssassin::Plugin);
51              
52             my $class = shift;
53             my $mailsaobject = shift;
54 63     63 1 201  
55 63         119 $class = ref($class) || $class;
56             my $self = $class->SUPER::new($mailsaobject);
57 63   33     388 bless ($self, $class);
58 63         343  
59 63         135 # figure out if razor is even available or not ...
60             $self->{razor2_available} = 0;
61             if ($mailsaobject->{local_tests_only}) {
62 63         240 dbg("razor2: local tests only, skipping Razor");
63 63 100       226 }
64 62         212 else {
65             if (eval { require Razor2::Client::Agent; }) {
66             $self->{razor2_available} = 1;
67 1 50       2 dbg("razor2: razor2 is available, version " . $Razor2::Client::Version::VERSION . "\n");
  1         157  
68 0         0 }
69 0         0 else {
70             dbg("razor2: razor2 is not available");
71             }
72 1         6 }
73              
74             $self->register_eval_rule("check_razor2");
75             $self->register_eval_rule("check_razor2_range");
76 63         300  
77 63         207 $self->set_config($mailsaobject->{conf});
78              
79 63         285 return $self;
80             }
81 63         573  
82             my ($self, $conf) = @_;
83             my @cmds;
84              
85 63     63 0 143 =item use_razor2 (0|1) (default: 1)
86 63         130  
87             Whether to use Razor2, if it is available.
88              
89             =cut
90              
91             push(@cmds, {
92             setting => 'use_razor2',
93             default => 1,
94 63         328 type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
95             });
96              
97             =back
98              
99             =head1 ADMINISTRATOR SETTINGS
100              
101             =over 4
102              
103             =item razor_timeout n (default: 5)
104              
105             How many seconds you wait for Razor to complete before you go on without
106             the results
107              
108             =cut
109              
110             push(@cmds, {
111             setting => 'razor_timeout',
112             is_admin => 1,
113 63         313 default => 5,
114             type => $Mail::SpamAssassin::Conf::CONF_TYPE_DURATION,
115             });
116              
117             =item razor_config filename
118              
119             Define the filename used to store Razor's configuration settings.
120             Currently this is left to Razor to decide.
121              
122             =cut
123              
124             push(@cmds, {
125             setting => 'razor_config',
126             is_admin => 1,
127 63         208 type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
128             });
129              
130             $conf->{parser}->register_commands(\@cmds);
131             }
132              
133 63         313 my ($self, $fulltext, $type, $deadline) = @_;
134             my $timeout = $self->{main}->{conf}->{razor_timeout};
135             my $return = 0;
136             my @results;
137 0     0 0 0  
138 0         0 my $debug = $type eq 'check' ? 'razor2' : 'reporter';
139 0         0  
140 0         0 # razor also debugs to stdout. argh. fix it to stderr...
141             if (would_log('dbg', $debug)) {
142 0 0       0 open(OLDOUT, ">&STDOUT");
143             open(STDOUT, ">&STDERR");
144             }
145 0 0       0  
146 0         0 Mail::SpamAssassin::PerMsgStatus::enter_helper_run_mode($self);
147 0         0  
148             my $rnd = rand(0x7fffffff); # save entropy before Razor clobbers it
149              
150 0         0 my $timer = Mail::SpamAssassin::Timeout->new(
151             { secs => $timeout, deadline => $deadline });
152 0         0 my $err = $timer->run_and_catch(sub {
153              
154 0         0 local ($^W) = 0; # argh, warnings in Razor
155              
156             # everything's in the module!
157             my $rc = Razor2::Client::Agent->new("razor-$type");
158 0     0   0  
159             if ($rc) {
160             $rc->{opt} = {
161 0         0 debug => (would_log('dbg', $debug) > 1),
162             foreground => 1,
163 0 0       0 config => $self->{main}->{conf}->{razor_config}
164             };
165             # no facility prefix on this die
166             $rc->do_conf() or die "$debug: " . $rc->errstr;
167              
168 0         0 # Razor2 requires authentication for reporting
169             my $ident;
170 0 0       0 if ($type ne 'check') {
171             # no facility prefix on this die
172             $ident = $rc->get_ident
173 0         0 or die("$type requires authentication");
174 0 0       0 }
175              
176 0 0       0 my @msg = ($fulltext);
177             # no facility prefix on this die
178             my $objects = $rc->prepare_objects(\@msg)
179             or die "$debug: error in prepare_objects";
180 0         0 unless ($rc->get_server_info()) {
181             my $error = $rc->errprefix("$debug: spamassassin") || "$debug: razor2 had unknown error during get_server_info";
182 0 0       0 die $error;
183             }
184 0 0       0  
185 0   0     0 # let's reset the alarm since get_server_info() calls
186 0         0 # nextserver() which calls discover() which very likely will
187             # reset the alarm for us ... how polite. :(
188             $timer->reset();
189              
190             # no facility prefix on this die
191             my $sigs = $rc->compute_sigs($objects)
192 0         0 or die "$debug: error in compute_sigs";
193              
194             # if mail isn't whitelisted, check it out
195 0 0       0 # see 'man razor-whitelist'
196             if ($type ne 'check' || ! $rc->local_check($objects->[0])) {
197             # provide a better error message when servers are unavailable,
198             # than "Bad file descriptor Died".
199             $rc->connect() or die "$debug: could not connect to any servers\n";
200 0 0 0     0  
201             # Talk to the Razor server and do work
202             if ($type eq 'check') {
203 0 0       0 unless ($rc->check($objects)) {
204             my $error = $rc->errprefix("$debug: spamassassin") || "$debug: razor2 had unknown error during check";
205             die $error;
206 0 0       0 }
207 0 0       0 }
208 0   0     0 else {
209 0         0 unless ($rc->authenticate($ident)) {
210             my $error = $rc->errprefix("$debug: spamassassin") || "$debug: razor2 had unknown error during authenticate";
211             die $error;
212             }
213 0 0       0 unless ($rc->report($objects)) {
214 0   0     0 my $error = $rc->errprefix("$debug: spamassassin") || "$debug: razor2 had unknown error during report";
215 0         0 die $error;
216             }
217 0 0       0 }
218 0   0     0  
219 0         0 unless ($rc->disconnect()) {
220             my $error = $rc->errprefix("$debug: spamassassin") || "$debug: razor2 had unknown error during disconnect";
221             die $error;
222             }
223 0 0       0 }
224 0   0     0  
225 0         0 # Razor 2.14 says that if we get here, we did ok.
226             $return = 1;
227              
228             # figure out if we have a log file we need to close...
229             if (ref($rc->{logref}) && exists $rc->{logref}->{fd}) {
230 0         0 # the fd can be stdout or stderr, so we need to find out if it is
231             # so we don't close them by accident. Note: we can't just
232             # undef the fd here (like the IO::Handle manpage says we can)
233 0 0 0     0 # because it won't actually close, unfortunately. :(
234             my $untie = 1;
235             foreach my $log (*STDOUT{IO}, *STDERR{IO}) {
236             if ($log == $rc->{logref}->{fd}) {
237             $untie = 0;
238 0         0 last;
239 0         0 }
240 0 0       0 }
241 0         0 if ($untie) {
242 0         0 close($rc->{logref}->{fd}) or die "error closing log: $!";
243             }
244             }
245 0 0       0  
246 0 0       0 if ($type eq 'check') {
247             # so $objects->[0] is the first (only) message, and ->{spam} is a general yes/no
248             push(@results, { result => $objects->[0]->{spam} });
249              
250 0 0       0 # great for debugging, but leave this off!
251             #use Data::Dumper;
252 0         0 #print Dumper($objects),"\n";
253              
254             # ->{p} is for each part of the message
255             # so go through each part, taking the highest cf we find
256             # of any part that isn't contested (ct). This helps avoid false
257             # positives. equals logic_method 4.
258             #
259             # razor-agents < 2.14 have a different object format, so we now support both.
260             # $objects->[0]->{resp} vs $objects->[0]->{p}->[part #]->{resp}
261             my $part = 0;
262             my $arrayref = $objects->[0]->{p} || $objects;
263             if (defined $arrayref) {
264             foreach my $cf (@{$arrayref}) {
265 0         0 if (exists $cf->{resp}) {
266 0   0     0 for (my $response=0; $response<@{$cf->{resp}}; $response++) {
267 0 0       0 my $tmp = $cf->{resp}->[$response];
268 0         0 my $tmpcf = $tmp->{cf}; # Part confidence
  0         0  
269 0 0       0 my $tmpct = $tmp->{ct}; # Part contested?
270 0         0 my $engine = $cf->{sent}->[$response]->{e};
  0         0  
271 0         0  
272 0         0 # These should always be set, but just in case ...
273 0         0 $tmpcf = 0 unless defined $tmpcf;
274 0         0 $tmpct = 0 unless defined $tmpct;
275             $engine = 0 unless defined $engine;
276              
277 0 0       0 push(@results,
278 0 0       0 { part => $part, engine => $engine, contested => $tmpct, confidence => $tmpcf });
279 0 0       0 }
280             }
281 0         0 else {
282             push(@results, { part => $part, noresponse => 1 });
283             }
284             $part++;
285             }
286 0         0 }
287             else {
288 0         0 # If we have some new $objects format that isn't close to
289             # the current razor-agents 2.x version, we won't FP but we
290             # should alert in debug.
291             dbg("$debug: it looks like the internal Razor object has changed format!");
292             }
293             }
294             }
295 0         0 else {
296             warn "$debug: undefined Razor2::Client::Agent\n";
297             }
298            
299             });
300 0         0  
301             # OK, that's enough Razor stuff. now, reset all that global
302             # state it futzes with :(
303 0         0 # work around serious brain damage in Razor2 (constant seed)
304             $rnd ^= int(rand(0xffffffff)); # mix old acc with whatever came out of razor
305             srand; # let Perl give it a try ...
306             $rnd ^= int(rand(0xffffffff)); # ... and mix-in that too
307             srand($rnd & 0x7fffffff); # reseed, keep it unsigned 32-bit just in case
308 0         0  
309 0         0 Mail::SpamAssassin::PerMsgStatus::leave_helper_run_mode($self);
310 0         0  
311 0         0 if ($timer->timed_out()) {
312             dbg("$debug: razor2 $type timed out after $timeout seconds");
313 0         0 }
314              
315 0 0       0 if ($err) {
316 0         0 chomp $err;
317             if ($err =~ /(?:could not connect|network is unreachable)/) {
318             # make this a dbg(); SpamAssassin will still continue,
319 0 0       0 # but without Razor checking. otherwise there may be
320 0         0 # DSNs and errors in syslog etc., yuck
321 0 0       0 dbg("$debug: razor2 $type could not connect to any servers");
    0          
322             } elsif ($err =~ /timeout/i) {
323             dbg("$debug: razor2 $type timed out connecting to servers");
324             } else {
325 0         0 warn("$debug: razor2 $type failed: $! $err");
326             }
327 0         0 }
328              
329 0         0 # razor also debugs to stdout. argh. fix it to stderr...
330             if (would_log('dbg', $debug)) {
331             open(STDOUT, ">&OLDOUT");
332             close OLDOUT;
333             }
334 0 0       0  
335 0         0 return wantarray ? ($return, @results) : $return;
336 0         0 }
337              
338             my ($self, $options) = @_;
339 0 0       0  
340             return unless $self->{razor2_available};
341             return if $self->{main}->{local_tests_only};
342             return unless $self->{main}->{conf}->{use_razor2};
343 0     0 1 0 return if $options->{report}->{options}->{dont_report_to_razor};
344              
345 0 0       0 if ($self->razor2_access($options->{text}, 'report', undef)) {
346 0 0       0 $options->{report}->{report_available} = 1;
347 0 0       0 info('reporter: spam reported to Razor');
348 0 0       0 $options->{report}->{report_return} = 1;
349             }
350 0 0       0 else {
351 0         0 info('reporter: could not report spam to Razor');
352 0         0 }
353 0         0 }
354              
355             my ($self, $options) = @_;
356 0         0  
357             return unless $self->{razor2_available};
358             return if $self->{main}->{local_tests_only};
359             return unless $self->{main}->{conf}->{use_razor2};
360             return if $options->{revoke}->{options}->{dont_report_to_razor};
361 0     0 1 0  
362             if ($self->razor2_access($options->{text}, 'revoke', undef)) {
363 0 0       0 $options->{revoke}->{revoke_available} = 1;
364 0 0       0 info('reporter: spam revoked from Razor');
365 0 0       0 $options->{revoke}->{revoke_return} = 1;
366 0 0       0 }
367             else {
368 0 0       0 info('reporter: could not revoke spam from Razor');
369 0         0 }
370 0         0 }
371 0         0  
372             my ($self, $permsgstatus, $full) = @_;
373              
374 0         0 return $permsgstatus->{razor2_result} if (defined $permsgstatus->{razor2_result});
375             $permsgstatus->{razor2_result} = 0;
376             $permsgstatus->{razor2_cf_score} = { '4' => 0, '8' => 0 };
377              
378             return unless $self->{razor2_available};
379 4     4 0 8 return unless $self->{main}->{conf}->{use_razor2};
380              
381 4 50       13 my $timer = $self->{main}->time_method("check_razor2");
382 4         8  
383 4         13 my $return;
384             my @results;
385 4 50       60  
386 0 0         # TODO: check for cache header, set results appropriately
387              
388 0           # do it this way to make it easier to get out the results later from the
389             # netcache plugin
390 0           ($return, @results) =
391             $self->razor2_access($full, 'check', $permsgstatus->{master_deadline});
392             $self->{main}->call_plugins ('process_razor_result',
393             { results => \@results, permsgstatus => $permsgstatus }
394             );
395              
396             foreach my $result (@results) {
397             if (exists $result->{result}) {
398 0           $permsgstatus->{razor2_result} = $result->{result} if $result->{result};
399 0           }
400             elsif ($result->{noresponse}) {
401             dbg('razor2: part=' . $result->{part} . ' noresponse');
402             }
403 0           else {
404 0 0         dbg('razor2: part=' . $result->{part} .
    0          
405 0 0         ' engine=' . $result->{engine} .
406             ' contested=' . $result->{contested} .
407             ' confidence=' . $result->{confidence});
408 0            
409             next if $result->{contested};
410              
411             my $cf = $permsgstatus->{razor2_cf_score}->{$result->{engine}} || 0;
412             if ($result->{confidence} > $cf) {
413             $permsgstatus->{razor2_cf_score}->{$result->{engine}} = $result->{confidence};
414 0           }
415             }
416 0 0         }
417              
418 0   0       dbg("razor2: results: spam? " . $permsgstatus->{razor2_result});
419 0 0         while(my ($engine, $cf) = each %{$permsgstatus->{razor2_cf_score}}) {
420 0           dbg("razor2: results: engine $engine, highest cf score: $cf");
421             }
422              
423             return $permsgstatus->{razor2_result};
424             }
425 0            
426 0           # Check the cf value of a given message and return if it's within the
  0            
427 0           # given range
428             my ($self, $permsgstatus, $body, $engine, $min, $max) = @_;
429              
430 0           # If Razor2 isn't available, or the general test is disabled, don't
431             # continue.
432             return unless $self->{razor2_available};
433             return unless $self->{main}->{conf}->{use_razor2};
434             return unless $self->{main}->{conf}->{scores}->{'RAZOR2_CHECK'};
435              
436 0     0 0   # If Razor2 hasn't been checked yet, go ahead and run it.
437             unless (defined $permsgstatus->{razor2_result}) {
438             $self->check_razor2($permsgstatus, $body);
439             }
440 0 0          
441 0 0         my $cf = 0;
442 0 0         if ($engine) {
443             $cf = $permsgstatus->{razor2_cf_score}->{$engine};
444             return unless defined $cf;
445 0 0         }
446 0           else {
447             # If no specific engine was given to the rule, find the highest cf
448             # determined and use that
449 0           while(my ($engine, $ecf) = each %{$permsgstatus->{razor2_cf_score}}) {
450 0 0         if ($ecf > $cf) {
451 0           $cf = $ecf;
452 0 0         }
453             }
454             }
455              
456             if ($cf >= $min && $cf <= $max) {
457 0           $permsgstatus->test_log(sprintf("cf: %3d", $cf));
  0            
458 0 0         return 1;
459 0           }
460              
461             return;
462             }
463              
464 0 0 0       1;
465 0            
466 0           =back
467              
468             =cut