File Coverage

blib/lib/Mail/SpamAssassin/Plugin/FromNameSpoof.pm
Criterion Covered Total %
statement 42 177 23.7
branch 0 48 0.0
condition 1 14 7.1
subroutine 8 24 33.3
pod 3 12 25.0
total 54 275 19.6


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             FromNameSpoof - perform various tests to detect spoof attempts using the From header name section
21              
22             =head1 SYNOPSIS
23              
24             loadplugin Mail::SpamAssassin::Plugin::FromNameSpoof
25              
26             # Does the From:name look like it contains an email address
27             header __PLUGIN_FROMNAME_EMAIL eval:check_fromname_contains_email()
28              
29             # Is the From:name different to the From:addr header
30             header __PLUGIN_FROMNAME_DIFFERENT eval:check_fromname_different()
31              
32             # From:name and From:addr owners differ
33             header __PLUGIN_FROMNAME_OWNERS_DIFFER eval:check_fromname_owners_differ()
34              
35             # From:name domain differs to from header
36             header __PLUGIN_FROMNAME_DOMAIN_DIFFER eval:check_fromname_domain_differ()
37              
38             # From:name and From:address don't match and owners differ
39             header __PLUGIN_FROMNAME_SPOOF eval:check_fromname_spoof()
40            
41             # From:name address matches To:address
42             header __PLUGIN_FROMNAME_EQUALS_TO eval:check_fromname_equals_to()
43              
44             =head1 DESCRIPTION
45              
46             Perform various tests against From:name header to detect spoofing. Steps in place to
47             ensure minimal FPs.
48              
49             =head1 CONFIGURATION
50              
51             The plugin allows you to skip emails that have been DKIM signed by specific senders:
52              
53             fns_ignore_dkim googlegroups.com
54              
55             FromNameSpoof allows for a configurable closeness when matching the From:addr and From:name,
56             the closeness can be adjusted with:
57              
58             fns_extrachars 50
59              
60             B<Note> that FromNameSpoof detects the "owner" of a domain by the following search:
61              
62             <owner>.<tld>
63              
64             By default FromNameSpoof will ignore the TLD when testing if From:addr is spoofed.
65             Default 1
66              
67             fns_check 1
68              
69             Check levels:
70              
71             0 - Strict checking of From:name != From:addr
72             1 - Allow for different tlds
73             2 - Allow for different aliases but same domain
74              
75             =head1 TAGS
76              
77             The following tags are added to the set if a spoof is detected. They are available for
78             use in reports, header fields, other plugins, etc.:
79              
80             _FNSFNAMEADDR_
81             Detected spoof address from From:name header
82              
83             _FNSFNAMEDOMAIN_
84             Detected spoof domain from From:name header
85              
86             _FNSFNAMEOWNER_
87             Detected spoof owner from From:name header
88              
89             _FNSFADDRADDR_
90             Actual From:addr address
91              
92             _FNSFADDRDOMAIN_
93             Actual From:addr domain
94              
95             _FNSFADDROWNER_
96             Actual From:addr detected owner
97              
98             =head1 EXAMPLE
99              
100             header __PLUGIN_FROMNAME_SPOOF eval:check_fromname_spoof()
101             header __PLUGIN_FROMNAME_EQUALS_TO eval:check_fromname_equals_to()
102              
103             meta FROMNAME_SPOOF_EQUALS_TO (__PLUGIN_FROMNAME_SPOOF && __PLUGIN_FROMNAME_EQUALS_TO)
104             describe FROMNAME_SPOOF_EQUALS_TO From:name is spoof to look like To: address
105             score FROMNAME_SPOOF_EQUALS_TO 1.2
106              
107             =cut
108              
109 20     20   138 use strict;
  20         43  
  20         931  
110              
111             my $VERSION = 0.9;
112              
113             use Mail::SpamAssassin::Plugin;
114 20     20   121 use List::Util ();
  20         41  
  20         477  
115 20     20   127 use Mail::SpamAssassin::Util;
  20         43  
  20         401  
116 20     20   100  
  20         40  
  20         903  
117             use vars qw(@ISA);
118 20     20   116 @ISA = qw(Mail::SpamAssassin::Plugin);
  20         37  
  20         41074  
119              
120              
121 0     0 1 0 my ($self, $domain) = @_;
122              
123             return unless defined $domain;
124 0     0 0 0  
125             if ($Mail::SpamAssassin::VERSION <= 3.004000) {
126 0 0       0 Mail::SpamAssassin::Util::uri_to_domain($domain);
127             } else {
128 0 0       0 $self->{main}->{registryboundaries}->uri_to_domain($domain);
129 0         0 }
130             }
131 0         0  
132             # constructor: register the eval rule
133             {
134             my $class = shift;
135             my $mailsaobject = shift;
136              
137             # some boilerplate...
138 61     61 1 211 $class = ref($class) || $class;
139 61         146 my $self = $class->SUPER::new($mailsaobject);
140             bless ($self, $class);
141              
142 61   33     378 $self->set_config($mailsaobject->{conf});
143 61         320  
144 61         154 # the important bit!
145             $self->register_eval_rule("check_fromname_spoof");
146 61         326 $self->register_eval_rule("check_fromname_different");
147             $self->register_eval_rule("check_fromname_domain_differ");
148             $self->register_eval_rule("check_fromname_contains_email");
149 61         352 $self->register_eval_rule("check_fromname_equals_to");
150 61         188 $self->register_eval_rule("check_fromname_owners_differ");
151 61         223 $self->register_eval_rule("check_fromname_equals_replyto");
152 61         272 return $self;
153 61         209 }
154 61         211  
155 61         172 my ($self, $conf) = @_;
156 61         563 my @cmds = ();
157              
158             push (@cmds, {
159             setting => 'fns_add_addrlist',
160 61     61 0 196 type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
161 61         143 code => sub {
162             my($self, $key, $value, $line) = @_;
163             local($1,$2);
164             if ($value !~ /^ \( (.*?) \) \s+ (.*) \z/sx) {
165             return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
166             }
167 0     0   0 my $listname = "FNS_$1";
168 0         0 $value = $2;
169 0 0       0 $self->{parser}->add_to_addrlist ($listname, split(/\s+/, lc($value)));
170 0         0 $self->{fns_addrlists}{$listname} = 1;
171             }
172 0         0 });
173 0         0  
174 0         0 push (@cmds, {
175 0         0 setting => 'fns_remove_addrlist',
176             type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
177 61         599 code => sub {
178             my($self, $key, $value, $line) = @_;
179             local($1,$2);
180             if ($value !~ /^ \( (.*?) \) \s+ (.*) \z/sx) {
181             return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
182             }
183 0     0   0 my $listname = "FNS_$1";
184 0         0 $value = $2;
185 0 0       0 $self->{parser}->remove_from_addrlist ($listname, split (/\s+/, $value));
186 0         0 }
187             });
188 0         0  
189 0         0 push(@cmds, {
190 0         0 setting => 'fns_extrachars',
191             default => 50,
192 61         428 type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
193             });
194 61         278  
195             push (@cmds, {
196             setting => 'fns_ignore_dkim',
197             default => {},
198             type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
199             code => sub {
200             my ($self, $key, $value, $line) = @_;
201             if ($value eq '') {
202             return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
203             }
204             $self->{fns_ignore_dkim}->{$_} = 1 foreach (split(/\s+/, lc($value)));
205 0     0   0 }
206 0 0       0 });
207 0         0  
208             push (@cmds, {
209 0         0 setting => 'fns_ignore_headers',
210             default => {},
211 61         505 type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
212             code => sub {
213             my ($self, $key, $value, $line) = @_;
214             if ($value eq '') {
215             return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
216             }
217             $self->{fns_ignore_header}->{$_} = 1 foreach (split(/\s+/, $value));
218 0     0   0 }
219 0 0       0 });
220 0         0  
221             push(@cmds, {
222 0         0 setting => 'fns_check',
223             default => 1,
224 61         470 type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
225             });
226 61         263  
227             $conf->{parser}->register_commands(\@cmds);
228             }
229              
230             my ($self, $opts) = @_;
231             my $pms = $opts->{permsgstatus};
232 61         296 $pms->action_depends_on_tags('DKIMDOMAIN',
233             sub { my($pms,@args) = @_;
234             $self->_check_fromnamespoof($pms);
235             }
236 81     81 1 179 );
237 81         165 1;
238             }
239 0     0   0  
240 0         0 {
241             my ($self, $pms) = @_;
242 81         605 $self->_check_fromnamespoof($pms);
243 81         183 return $pms->{fromname_address_different};
244             }
245              
246             {
247             my ($self, $pms) = @_;
248 0     0 0   $self->_check_fromnamespoof($pms);
249 0           return $pms->{fromname_domain_different};
250 0           }
251              
252             {
253             my ($self, $pms, $check_lvl) = @_;
254             $self->_check_fromnamespoof($pms);
255 0     0 0    
256 0           if ( not defined $check_lvl ) {
257 0           $check_lvl = $pms->{conf}->{fns_check};
258             }
259              
260             my @array = (
261             ($pms->{fromname_address_different}) ,
262 0     0 0   ($pms->{fromname_address_different} && $pms->{fromname_owner_different}) ,
263 0           ($pms->{fromname_address_different} && $pms->{fromname_domain_different})
264             );
265 0 0          
266 0           return $array[$check_lvl];
267              
268             }
269              
270             {
271             my ($self, $pms) = @_;
272             $self->_check_fromnamespoof($pms);
273 0   0       return $pms->{fromname_contains_email};
      0        
274             }
275 0            
276             {
277             my ($self, $pms) = @_;
278             $self->_check_fromnamespoof($pms);
279             return $pms->{fromname_equals_replyto};
280             }
281 0     0 0    
282 0           {
283 0           my ($self, $pms) = @_;
284             $self->_check_fromnamespoof($pms);
285             return $pms->{fromname_equals_to_addr};
286             }
287              
288 0     0 0   {
289 0           my ($self, $pms) = @_;
290 0           $self->_check_fromnamespoof($pms);
291             return $pms->{fromname_owner_different};
292             }
293              
294             {
295 0     0 0   my ($self, $pms) = @_;
296 0            
297 0           return if (defined $pms->{fromname_contains_email});
298              
299             my $conf = $pms->{conf};
300              
301             $pms->{fromname_contains_email} = 0;
302 0     0 0   $pms->{fromname_address_different} = 0;
303 0           $pms->{fromname_equals_to_addr} = 0;
304 0           $pms->{fromname_domain_different} = 0;
305             $pms->{fromname_owner_different} = 0;
306             $pms->{fromname_equals_replyto} = 0;
307              
308             foreach my $addr (split / /, $pms->get_tag('DKIMDOMAIN') || '') {
309 0     0     if ($conf->{fns_ignore_dkim}->{lc($addr)}) {
310             dbg("ignoring, DKIM signed: $addr");
311 0 0         return 0;
312             }
313 0           }
314              
315 0           foreach my $iheader (keys %{$conf->{fns_ignore_header}}) {
316 0           if ($pms->get($iheader)) {
317 0           dbg("ignoring, header $iheader found");
318 0           return 0 if ($pms->get($iheader));
319 0           }
320 0           }
321              
322 0   0       my $list_refs = {};
323 0 0          
324 0           if ($conf->{fns_addrlists}) {
325 0           my @lists = keys %{$conf->{fns_addrlists}};
326             foreach my $list (@lists) {
327             $list_refs->{$list} = $conf->{$list};
328             }
329 0           s/^FNS_// foreach (@lists);
  0            
330 0 0         dbg("using addrlists: ".join(', ', @lists));
331 0           }
332 0 0          
333             my %fnd = ();
334             my %fad = ();
335             my %tod = ();
336 0            
337             $fnd{'addr'} = $pms->get("From:name");
338 0 0          
339 0           if ($fnd{'addr'} =~ /\b((?>[\w\.\!\#\$\%\&\'\*\+\/\=\?\^\_\`\{\|\}\~\-]+@[\w\-\.]+\.[\w\-\.]+))\b/i) {
  0            
340 0           my $nochar = ($fnd{'addr'} =~ y/A-Za-z0-9//c);
341 0           $nochar -= ($1 =~ y/A-Za-z0-9//c);
342              
343 0           return 0 unless ((length($fnd{'addr'})+$nochar) - length($1) <= $conf->{'fns_extrachars'});
344 0            
345             $fnd{'addr'} = lc $1;
346             } else {
347 0           return 0;
348 0           }
349 0            
350             my $replyto = lc $pms->get("Reply-To:addr");
351 0            
352             $fad{'addr'} = lc $pms->get("From:addr");
353 0 0         my @toaddrs = $pms->all_to_addrs();
354 0           return 0 unless @toaddrs;
355 0            
356             $tod{'addr'} = lc $toaddrs[0];
357 0 0          
358             $fnd{'domain'} = $self->uri_to_domain($fnd{'addr'});
359 0           $fad{'domain'} = $self->uri_to_domain($fad{'addr'});
360             $tod{'domain'} = $self->uri_to_domain($tod{'addr'});
361 0            
362             return 0 unless (defined $fnd{'domain'} && defined $fad{'domain'});
363              
364 0           $pms->{fromname_contains_email} = 1;
365              
366 0           $fnd{'owner'} = $self->_find_address_owner($fnd{'addr'}, $list_refs);
367 0            
368 0 0         $fad{'owner'} = $self->_find_address_owner($fad{'addr'}, $list_refs);
369              
370 0           $tod{'owner'} = $self->_find_address_owner($tod{'addr'}, $list_refs);
371              
372 0           $pms->{fromname_address_different} = 1 if ($fnd{'addr'} ne $fad{'addr'});
373 0            
374 0           $pms->{fromname_domain_different} = 1 if ($fnd{'domain'} ne $fad{'domain'});
375              
376 0 0 0       $pms->{fromname_equals_to_addr} = 1 if ($fnd{'addr'} eq $tod{addr});
377              
378 0           $pms->{fromname_equals_replyto} = 1 if ($fnd{'addr'} eq $replyto);
379              
380 0           if ($fnd{'owner'} ne $fad{'owner'}) {
381             $pms->{fromname_owner_different} = 1;
382 0           }
383              
384 0           if ($pms->{fromname_address_different}) {
385             $pms->set_tag("FNSFNAMEADDR", $fnd{'addr'});
386 0 0         $pms->set_tag("FNSFADDRADDR", $fad{'addr'});
387             $pms->set_tag("FNSFNAMEOWNER", $fnd{'owner'});
388 0 0         $pms->set_tag("FNSFADDROWNER", $fad{'owner'});
389             $pms->set_tag("FNSFNAMEDOMAIN", $fnd{'domain'});
390 0 0         $pms->set_tag("FNSFADDRDOMAIN", $fad{'domain'});
391              
392 0 0         dbg("From name spoof: $fnd{addr} $fnd{domain} $fnd{owner}");
393             dbg("Actual From: $fad{addr} $fad{domain} $fad{owner}");
394 0 0         dbg("To Address: $tod{addr} $tod{domain} $tod{owner}");
395 0           }
396             }
397              
398 0 0         {
399 0           my ($self, $check, $list_refs) = @_;
400 0           foreach my $owner (keys %{$list_refs}) {
401 0           foreach my $white_addr (keys %{$list_refs->{$owner}}) {
402 0           my $regexp = qr/$list_refs->{$owner}{$white_addr}/i;
403 0           if ($check =~ /$regexp/) {
404 0           $owner =~ s/^FNS_//i;
405             return lc $owner;
406 0           }
407 0           }
408 0           }
409              
410             my $owner = $self->uri_to_domain($check);
411              
412             $check =~ /^([^\@]+)\@(.*)$/;
413              
414 0     0     if ($owner ne $2) {
415 0           return $self->_find_address_owner("$1\@$owner", $list_refs);
  0            
416 0           }
  0            
417 0            
418 0 0         $owner =~ /^([^\.]+)\./;
419 0           return lc $1;
420 0           }
421              
422             1;