File Coverage

blib/lib/Mail/SpamAssassin/Plugin/FromNameSpoof.pm
Criterion Covered Total %
statement 42 176 23.8
branch 0 46 0.0
condition 1 17 5.8
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 5
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 19     19   153 use strict;
  19         50  
  19         1033  
110              
111             package Mail::SpamAssassin::Plugin::FromNameSpoof;
112             my $VERSION = 0.9;
113              
114 19     19   136 use Mail::SpamAssassin::Plugin;
  19         48  
  19         453  
115 19     19   175 use List::Util ();
  19         50  
  19         413  
116 19     19   138 use Mail::SpamAssassin::Util;
  19         47  
  19         839  
117              
118 19     19   157 use vars qw(@ISA);
  19         41  
  19         43937  
119             @ISA = qw(Mail::SpamAssassin::Plugin);
120              
121 0     0 1 0 sub dbg { Mail::SpamAssassin::Plugin::dbg ("FromNameSpoof: @_"); }
122              
123             sub uri_to_domain {
124 0     0 0 0 my ($self, $domain) = @_;
125              
126 0 0       0 return unless defined $domain;
127              
128 0 0       0 if ($Mail::SpamAssassin::VERSION <= 3.004000) {
129 0         0 Mail::SpamAssassin::Util::uri_to_domain($domain);
130             } else {
131 0         0 $self->{main}->{registryboundaries}->uri_to_domain($domain);
132             }
133             }
134              
135             # constructor: register the eval rule
136             sub new
137             {
138 60     60 1 218 my $class = shift;
139 60         147 my $mailsaobject = shift;
140              
141             # some boilerplate...
142 60   33     415 $class = ref($class) || $class;
143 60         370 my $self = $class->SUPER::new($mailsaobject);
144 60         224 bless ($self, $class);
145              
146 60         397 $self->set_config($mailsaobject->{conf});
147              
148             # the important bit!
149 60         416 $self->register_eval_rule("check_fromname_spoof");
150 60         225 $self->register_eval_rule("check_fromname_different");
151 60         264 $self->register_eval_rule("check_fromname_domain_differ");
152 60         254 $self->register_eval_rule("check_fromname_contains_email");
153 60         259 $self->register_eval_rule("check_fromname_equals_to");
154 60         280 $self->register_eval_rule("check_fromname_owners_differ");
155 60         211 $self->register_eval_rule("check_fromname_equals_replyto");
156 60         594 return $self;
157             }
158              
159             sub set_config {
160 60     60 0 206 my ($self, $conf) = @_;
161 60         189 my @cmds = ();
162              
163             push (@cmds, {
164             setting => 'fns_add_addrlist',
165             type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
166             code => sub {
167 0     0   0 my($self, $key, $value, $line) = @_;
168 0         0 local($1,$2);
169 0 0       0 if ($value !~ /^ \( (.*?) \) \s+ (.*) \z/sx) {
170 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
171             }
172 0         0 my $listname = "FNS_$1";
173 0         0 $value = $2;
174 0         0 $self->{parser}->add_to_addrlist ($listname, split(/\s+/, lc($value)));
175 0         0 $self->{fns_addrlists}{$listname} = 1;
176             }
177 60         680 });
178              
179             push (@cmds, {
180             setting => 'fns_remove_addrlist',
181             type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST,
182             code => sub {
183 0     0   0 my($self, $key, $value, $line) = @_;
184 0         0 local($1,$2);
185 0 0       0 if ($value !~ /^ \( (.*?) \) \s+ (.*) \z/sx) {
186 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
187             }
188 0         0 my $listname = "FNS_$1";
189 0         0 $value = $2;
190 0         0 $self->{parser}->remove_from_addrlist ($listname, split (/\s+/, $value));
191             }
192 60         610 });
193              
194 60         279 push(@cmds, {
195             setting => 'fns_extrachars',
196             default => 5,
197             type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
198             });
199              
200             push (@cmds, {
201             setting => 'fns_ignore_dkim',
202             default => {},
203             type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
204             code => sub {
205 0     0   0 my ($self, $key, $value, $line) = @_;
206 0 0       0 if ($value eq '') {
207 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
208             }
209 0         0 $self->{fns_ignore_dkim}->{$_} = 1 foreach (split(/\s+/, lc($value)));
210             }
211 60         518 });
212              
213             push (@cmds, {
214             setting => 'fns_ignore_headers',
215             default => {},
216             type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
217             code => sub {
218 0     0   0 my ($self, $key, $value, $line) = @_;
219 0 0       0 if ($value eq '') {
220 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
221             }
222 0         0 $self->{fns_ignore_header}->{$_} = 1 foreach (split(/\s+/, $value));
223             }
224 60         583 });
225              
226 60         314 push(@cmds, {
227             setting => 'fns_check',
228             default => 1,
229             type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
230             });
231              
232 60         318 $conf->{parser}->register_commands(\@cmds);
233             }
234              
235             sub parsed_metadata {
236 81     81 1 219 my ($self, $opts) = @_;
237 81         239 my $pms = $opts->{permsgstatus};
238             $pms->action_depends_on_tags('DKIMDOMAIN',
239 0     0   0 sub { my($pms,@args) = @_;
240 0         0 $self->_check_fromnamespoof($pms);
241             }
242 81         768 );
243 81         205 1;
244             }
245              
246             sub check_fromname_different
247             {
248 0     0 0   my ($self, $pms) = @_;
249 0           $self->_check_fromnamespoof($pms);
250 0           return $pms->{fromname_address_different};
251             }
252              
253             sub check_fromname_domain_differ
254             {
255 0     0 0   my ($self, $pms) = @_;
256 0           $self->_check_fromnamespoof($pms);
257 0           return $pms->{fromname_domain_different};
258             }
259              
260             sub check_fromname_spoof
261             {
262 0     0 0   my ($self, $pms, $check_lvl) = @_;
263 0           $self->_check_fromnamespoof($pms);
264              
265 0   0       $check_lvl //= $pms->{conf}->{fns_check};
266              
267             my @array = (
268             ($pms->{fromname_address_different}) ,
269             ($pms->{fromname_address_different} && $pms->{fromname_owner_different}) ,
270             ($pms->{fromname_address_different} && $pms->{fromname_domain_different})
271 0   0       );
      0        
272              
273 0           return $array[$check_lvl];
274              
275             }
276              
277             sub check_fromname_contains_email
278             {
279 0     0 0   my ($self, $pms) = @_;
280 0           $self->_check_fromnamespoof($pms);
281 0           return $pms->{fromname_contains_email};
282             }
283              
284             sub check_fromname_equals_replyto
285             {
286 0     0 0   my ($self, $pms) = @_;
287 0           $self->_check_fromnamespoof($pms);
288 0           return $pms->{fromname_equals_replyto};
289             }
290              
291             sub check_fromname_equals_to
292             {
293 0     0 0   my ($self, $pms) = @_;
294 0           $self->_check_fromnamespoof($pms);
295 0           return $pms->{fromname_equals_to_addr};
296             }
297              
298             sub check_fromname_owners_differ
299             {
300 0     0 0   my ($self, $pms) = @_;
301 0           $self->_check_fromnamespoof($pms);
302 0           return $pms->{fromname_owner_different};
303             }
304              
305             sub _check_fromnamespoof
306             {
307 0     0     my ($self, $pms) = @_;
308              
309 0 0         return if (defined $pms->{fromname_contains_email});
310              
311 0           my $conf = $pms->{conf};
312              
313 0           $pms->{fromname_contains_email} = 0;
314 0           $pms->{fromname_address_different} = 0;
315 0           $pms->{fromname_equals_to_addr} = 0;
316 0           $pms->{fromname_domain_different} = 0;
317 0           $pms->{fromname_owner_different} = 0;
318 0           $pms->{fromname_equals_replyto} = 0;
319              
320 0   0       foreach my $addr (split / /, $pms->get_tag('DKIMDOMAIN') || '') {
321 0 0         if ($conf->{fns_ignore_dkim}->{lc($addr)}) {
322 0           dbg("ignoring, DKIM signed: $addr");
323 0           return 0;
324             }
325             }
326              
327 0           foreach my $iheader (keys %{$conf->{fns_ignore_header}}) {
  0            
328 0 0         if ($pms->get($iheader)) {
329 0           dbg("ignoring, header $iheader found");
330 0 0         return 0 if ($pms->get($iheader));
331             }
332             }
333              
334 0           my $list_refs = {};
335              
336 0 0         if ($conf->{fns_addrlists}) {
337 0           my @lists = keys %{$conf->{fns_addrlists}};
  0            
338 0           foreach my $list (@lists) {
339 0           $list_refs->{$list} = $conf->{$list};
340             }
341 0           s/^FNS_// foreach (@lists);
342 0           dbg("using addrlists: ".join(', ', @lists));
343             }
344              
345 0           my %fnd = ();
346 0           my %fad = ();
347 0           my %tod = ();
348              
349 0           $fnd{'addr'} = $pms->get("From:name");
350              
351 0 0         if ($fnd{'addr'} =~ /\b([\w\.\!\#\$\%\&\'\*\+\/\=\?\^\_\`\{\|\}\~\-]+@[\w\-\.]+\.[\w\-\.]++)\b/i) {
352 0           my $nochar = ($fnd{'addr'} =~ y/A-Za-z0-9//c);
353 0           $nochar -= ($1 =~ y/A-Za-z0-9//c);
354              
355 0 0         return 0 unless ((length($fnd{'addr'})+$nochar) - length($1) <= $conf->{'fns_extrachars'});
356              
357 0           $fnd{'addr'} = lc $1;
358             } else {
359 0           return 0;
360             }
361              
362 0           my $replyto = lc $pms->get("Reply-To:addr");
363              
364 0           $fad{'addr'} = lc $pms->get("From:addr");
365 0           my @toaddrs = $pms->all_to_addrs();
366 0 0         return 0 unless @toaddrs;
367              
368 0           $tod{'addr'} = lc $toaddrs[0];
369              
370 0           $fnd{'domain'} = $self->uri_to_domain($fnd{'addr'});
371 0           $fad{'domain'} = $self->uri_to_domain($fad{'addr'});
372 0           $tod{'domain'} = $self->uri_to_domain($tod{'addr'});
373              
374 0 0 0       return 0 unless (defined $fnd{'domain'} && defined $fad{'domain'});
375              
376 0           $pms->{fromname_contains_email} = 1;
377              
378 0           $fnd{'owner'} = $self->_find_address_owner($fnd{'addr'}, $list_refs);
379              
380 0           $fad{'owner'} = $self->_find_address_owner($fad{'addr'}, $list_refs);
381              
382 0           $tod{'owner'} = $self->_find_address_owner($tod{'addr'}, $list_refs);
383              
384 0 0         $pms->{fromname_address_different} = 1 if ($fnd{'addr'} ne $fad{'addr'});
385              
386 0 0         $pms->{fromname_domain_different} = 1 if ($fnd{'domain'} ne $fad{'domain'});
387              
388 0 0         $pms->{fromname_equals_to_addr} = 1 if ($fnd{'addr'} eq $tod{addr});
389              
390 0 0         $pms->{fromname_equals_replyto} = 1 if ($fnd{'addr'} eq $replyto);
391              
392 0 0         if ($fnd{'owner'} ne $fad{'owner'}) {
393 0           $pms->{fromname_owner_different} = 1;
394             }
395              
396 0 0         if ($pms->{fromname_address_different}) {
397 0           $pms->set_tag("FNSFNAMEADDR", $fnd{'addr'});
398 0           $pms->set_tag("FNSFADDRADDR", $fad{'addr'});
399 0           $pms->set_tag("FNSFNAMEOWNER", $fnd{'owner'});
400 0           $pms->set_tag("FNSFADDROWNER", $fad{'owner'});
401 0           $pms->set_tag("FNSFNAMEDOMAIN", $fnd{'domain'});
402 0           $pms->set_tag("FNSFADDRDOMAIN", $fad{'domain'});
403              
404 0           dbg("From name spoof: $fnd{addr} $fnd{domain} $fnd{owner}");
405 0           dbg("Actual From: $fad{addr} $fad{domain} $fad{owner}");
406 0           dbg("To Address: $tod{addr} $tod{domain} $tod{owner}");
407             }
408             }
409              
410             sub _find_address_owner
411             {
412 0     0     my ($self, $check, $list_refs) = @_;
413 0           foreach my $owner (keys %{$list_refs}) {
  0            
414 0           foreach my $white_addr (keys %{$list_refs->{$owner}}) {
  0            
415 0           my $regexp = qr/$list_refs->{$owner}{$white_addr}/i;
416 0 0         if ($check =~ /$regexp/) {
417 0           $owner =~ s/^FNS_//i;
418 0           return lc $owner;
419             }
420             }
421             }
422              
423 0           my $owner = $self->uri_to_domain($check);
424              
425 0           $check =~ /^([^\@]+)\@(.*)$/;
426              
427 0 0         if ($owner ne $2) {
428 0           return $self->_find_address_owner("$1\@$owner", $list_refs);
429             }
430              
431 0           $owner =~ /^([^\.]+)\./;
432 0           return lc $1;
433             }
434              
435             1;