File Coverage

blib/lib/Mail/SpamAssassin/Plugin/AskDNS.pm
Criterion Covered Total %
statement 39 205 19.0
branch 2 158 1.2
condition 1 38 2.6
subroutine 10 17 58.8
pod 2 7 28.5
total 54 425 12.7


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             AskDNS - form a DNS query using tag values, and look up the DNSxL lists
21              
22             =head1 SYNOPSIS
23              
24             loadplugin Mail::SpamAssassin::Plugin::AskDNS
25             askdns D_IN_DWL _DKIMDOMAIN_._vouch.dwl.spamhaus.org TXT /\b(transaction|list|all)\b/
26              
27             =head1 DESCRIPTION
28              
29             Using a DNS query template as specified in a parameter of a askdns rule,
30             the plugin replaces tag names as found in the template with their values
31             and launches DNS queries as soon as tag values become available. When DNS
32             responses trickle in, filters them according to the requested DNS resource
33             record type and optional subrule filtering expression, yielding a rule hit
34             if a response meets filtering conditions.
35              
36             =head1 USER SETTINGS
37              
38             =over 4
39              
40             =item rbl_timeout t [t_min] [zone] (default: 15 3)
41              
42             The rbl_timeout setting is common to all DNS querying rules (as implemented
43             by other plugins). It can specify a DNS query timeout globally, or individually
44             for each zone. When the zone parameter is specified, the settings affects DNS
45             queries when their query domain equals the specified zone, or is its subdomain.
46             See the C<Mail::SpamAssassin::Conf> POD for details on C<rbl_timeout>.
47              
48             =back
49              
50             =head1 RULE DEFINITIONS
51              
52             =over 4
53              
54             =item askdns NAME_OF_RULE query_template [rr_type [subqueryfilter]]
55              
56             A query template is a string which will be expanded to produce a domain name
57             to be used in a DNS query. The template may include SpamAssassin tag names,
58             which will be replaced by their values to form a final query domain.
59             The final query domain must adhere to rules governing DNS domains, i.e.
60             must consist of fields each up to 63 characters long, delimited by dots.
61             There may be a trailing dot at the end, but it is redundant / carries
62             no semantics, because SpamAssassin uses a Net::DSN::Resolver::send method
63             for querying DNS, which ignores any 'search' or 'domain' DNS resolver options.
64             Domain names in DNS queries are case-insensitive.
65              
66             A tag name is a string of capital letters, preceded and followed by an
67             underscore character. This syntax mirrors the add_header setting, except that
68             tags cannot have parameters in parenthesis when used in askdns templates.
69             Tag names may appear anywhere in the template - each queried DNS zone
70             prescribes how a query should be formed.
71              
72             A query template may contain any number of tag names including none,
73             although in the most common anticipated scenario exactly one tag name would
74             appear in each askdns rule. Specified tag names are considered dependencies.
75             Askdns rules with dependencies on the same set of tags are grouped, and all
76             queries in a group are launched as soon as all their dependencies are met,
77             i.e. when the last of the awaited tag values becomes available by a call
78             to set_tag() from some other plugin or elsewhere in the SpamAssassin code.
79              
80             Launched queries from all askdns rules are grouped too according to a pair
81             of: query type and an expanded query domain name. Even if there are multiple
82             rules producing the same type/domain pair, only one DNS query is launched,
83             and a reply to such query contributes to all the constituent rules.
84              
85             A tag may produce none, one or multiple values. Askdns rules awaiting for
86             a tag which never receives its value never result in a DNS query. Tags which
87             produce multiple values will result in multiple queries launched, each with
88             an expanded template using one of the tag values. An example is a DKIMDOMAIN
89             tag which yields a list of signing domains, one for each valid signature in
90             a signed message.
91              
92             When more than one distinct tag name appears in a template, each potentially
93             resulting in multiple values, a Cartesian product is formed, and each tuple
94             results in a launch of one DNS query (duplicates excluded). For example,
95             a query template _A_._B_.example._A_.com where tag A is a list (11,22)
96             and B is (xx,yy,zz), will result in queries: 11.xx.example.11.com,
97             22.xx.example.22.com, 11.yy.example.11.com, 22.yy.example.22.com,
98             11.zz.example.11.com, 22.zz.example.22.com .
99              
100             A parameter rr_type following the query template is a comma-separated list
101             of expected DNS resource record (RR) types. Missing rr_type parameter implies
102             an 'A'. A DNS result may bring resource records of multiple types, but only
103             resource records of a type found in the rr_type parameter list are considered,
104             other resource records found in the answer section of a DNS reply are ignored
105             for this rule. A value ANY in the rr_type parameter list matches any resource
106             record type. An empty DNS answer section does not match ANY.
107              
108             The rr_type parameter not only provides a filter for RR types found in
109             the DNS answer, but also determines the DNS query type. If only a single
110             RR type is specified in the parameter (e.g. TXT), than this is also the RR
111             type of a query. When more than one RR type is specified (e.g. A, AAAA, TXT)
112             or if ANY is specified, then the DNS query type will be ANY and the rr_type
113             parameter will only act as a filter on a result.
114              
115             Currently recognized RR types in the rr_type parameter are: ANY, A, AAAA,
116             MX, TXT, PTR, NAPTR, NS, SOA, CERT, CNAME, DNAME, DHCID, HINFO, MINFO,
117             RP, HIP, IPSECKEY, KX, LOC, SRV, SSHFP, SPF.
118              
119             https://www.iana.org/assignments/dns-parameters/dns-parameters.xml
120              
121             The last optional parameter of a rule is a filtering expression, a.k.a. a
122             subrule. Its function is much like the subrule in URIDNSBL plugin rules,
123             or in the check_rbl eval rules. The main difference is that with askdns
124             rules there is no need to manually group rules according to their queried
125             zone, as the grouping is automatic and duplicate queries are implicitly
126             eliminated.
127              
128             The subrule filtering parameter can be: a plain string, a regular expression,
129             a single numerical value or a pair of numerical values, or a list of rcodes
130             (DNS status codes of a response). Absence of the filtering parameter implies
131             no filtering, i.e. any positive DNS response (rcode=NOERROR) of the requested
132             RR type will result in a rule hit, regardless of the RR value returned with
133             the response.
134              
135             When a plain string is used as a filter, it must be enclosed in single or
136             double quotes. For the rule to hit, the response must match the filtering
137             string exactly, and a RR type of a response must match the query type.
138             Typical use is an exact text string for TXT queries, or an exact quad-dotted
139             IPv4 address. In case of a TXT or SPF resource record which can return
140             multiple character-strings (as defined in Section 3.3 of [RFC1035]), these
141             strings are concatenated with no delimiters before comparing the result
142             to the filtering string. This follows requirements of several documents,
143             such as RFC 5518, RFC 7208, RFC 4871, RFC 5617. Examples of a plain text
144             filtering parameter: "127.0.0.1", "transaction", 'list' .
145              
146             A regular expression follows a familiar perl syntax like /.../ or m{...}
147             optionally followed by regexp flags (such as 'i' for case-insensitivity).
148             If a DNS response matches the requested RR type and the regular expression,
149             the rule hits. Examples: /^127\.0\.0\.\d+$/, m{\bdial up\b}i .
150              
151             A single numerical value can be a decimal number, or a hexadecimal number
152             prefixed by 0x. Such numeric filtering expression is typically used with
153             RR type-A DNS queries. The returned value (an IPv4 address) is masked
154             with a specified filtering value and tested to fall within a 127.0.0.0/8
155             network range - the rule hits if the result is nonzero:
156             ((r & n) != 0) && ((r & 0xff000000) == 0x7f000000). An example: 0x10 .
157              
158             A pair of numerical values (each a decimal, hexadecimal or quad-dotted)
159             delimited by a '-' specifies an IPv4 address range, and a pair of values
160             delimited by a '/' specifies an IPv4 address followed by a bitmask. Again,
161             this type of filtering expression is primarily intended with RR type-A
162             DNS queries. The rule hits if the RR type matches, and the returned IP
163             address falls within the specified range: (r >= n1 && r <= n2), or
164             masked with a bitmask matches the specified value: (r & m) == (n & m) .
165              
166             As a shorthand notation, a single quad-dotted value is equivalent to
167             a n-n form, i.e. it must match the returned value exactly with all its bits.
168              
169             Some typical examples of a numeric filtering parameter are: 127.0.1.2,
170             127.0.1.20-127.0.1.39, 127.0.1.0/255.255.255.0, 0.0.0.16/0.0.0.16,
171             0x10/0x10, 16, 0x10 .
172              
173             Lastly, the filtering parameter can be a comma-separated list of DNS status
174             codes (rcode), enclosed in square brackets. Rcodes can be represented either
175             by their numeric decimal values (0=NOERROR, 3=NXDOMAIN, ...), or their names.
176             See https://www.iana.org/assignments/dns-parameters for the list of names. When
177             testing for a rcode where rcode is nonzero, a RR type parameter is ignored
178             as a filter, as there is typically no answer section in a DNS reply when
179             rcode indicates an error. Example: [NXDOMAIN], or [FormErr,ServFail,4,5] .
180              
181             =back
182              
183             =cut
184              
185             package Mail::SpamAssassin::Plugin::AskDNS;
186              
187 21     21   156 use strict;
  21         53  
  21         670  
188 21     21   150 use warnings;
  21         54  
  21         632  
189 21     21   128 use re 'taint';
  21         46  
  21         736  
190              
191 21     21   2387 use Mail::SpamAssassin::Plugin;
  21         70  
  21         595  
192 21     21   132 use Mail::SpamAssassin::Util qw(decode_dns_question_entry);
  21         48  
  21         1133  
193 21     21   146 use Mail::SpamAssassin::Logger;
  21         46  
  21         1432  
194 21     21   9927 use version 0.77;
  21         39271  
  21         157  
195              
196             our @ISA = qw(Mail::SpamAssassin::Plugin);
197              
198             our %rcode_value = ( # https://www.iana.org/assignments/dns-parameters, RFC 6195
199             NOERROR => 0, FORMERR => 1, SERVFAIL => 2, NXDOMAIN => 3, NOTIMP => 4,
200             REFUSED => 5, YXDOMAIN => 6, YXRRSET => 7, NXRRSET => 8, NOTAUTH => 9,
201             NOTZONE => 10, BADVERS => 16, BADSIG => 16, BADKEY => 17, BADTIME => 18,
202             BADMODE => 19, BADNAME => 20, BADALG => 21, BADTRUNC => 22,
203             );
204              
205             our $txtdata_can_provide_a_list;
206              
207             sub new {
208 62     62 1 299 my($class,$sa_main) = @_;
209              
210 62   33     641 $class = ref($class) || $class;
211 62         400 my $self = $class->SUPER::new($sa_main);
212 62         192 bless($self, $class);
213              
214 62         391 $self->set_config($sa_main->{conf});
215              
216             #$txtdata_can_provide_a_list = Net::DNS->VERSION >= 0.69;
217             #more robust version check from Damyan Ivanov - Bug 7095
218 62         3337 $txtdata_can_provide_a_list = version->parse(Net::DNS->VERSION) >= version->parse('0.69');
219              
220 62         898 return $self;
221             }
222              
223             # ---------------------------------------------------------------------------
224              
225             # Accepts argument as a string in single or double quotes, or as a regular
226             # expression in // or m{} notation, or as a numerical value or a pair of
227             # numerical values, or as a bracketed and comma-separated list of DNS rcode
228             # names or their numerical codes. Recognized numerical forms are: m, n1-n2,
229             # or n/m, where n,n1,n2,m can be any of: decimal digits, 0x followed by
230             # up to 8 hexadecimal digits, or an IPv4 address in quad-dotted notation.
231             # The argument is checked for syntax, undef is returned on syntax errors.
232             # A string that looks like a regular expression is converted to a compiled
233             # Regexp object and returned as a result. Otherwise, numeric components of
234             # the remaining three forms are converted as follows: hex or decimal numeric
235             # strings are converted to a number and a quad-dot is converted to a number,
236             # then components are reassembled into a string delimited by '-' or '/'.
237             # As a special backward compatibility measure, a single quad-dot (with no
238             # second number) is converted into n-n, to distinguish it from a traditional
239             # mask-only form. A list or rcodes is returned as a hashref, where keys
240             # represent specified numerical rcodes.
241             #
242             # Arguments like the following are anticipated:
243             # "127.0.0.1", "some text", 'some "more" text',
244             # /regexp/flags, m{regexp}flags,
245             # 127.0.1.2 (same as 127.0.1.2-127.0.1.2 or 127.0.1.2/255.255.255.255)
246             # 127.0.1.20-127.0.1.39 (= 0x7f000114-0x7f000127 or 2130706708-2130706727)
247             # 0.0.0.16/0.0.0.16 (same as 0x10/0x10 or 16/0x10 or 16/16)
248             # 16 (traditional style mask-only, same as 0x10)
249             # [NXDOMAIN], [FormErr,ServFail,4,5]
250             #
251             sub parse_and_canonicalize_subtest {
252 0     0 0 0 my($subtest) = @_;
253 0         0 my $result;
254              
255 0         0 local($1,$2,$3);
256             # modifiers /a, /d, /l, /u in suffix form were added with perl 5.13.10 (5.14)
257             # currently known modifiers are [msixoadlu], but let's not be too picky here
258 0 0       0 if ( $subtest =~ m{^ / (.+) / ([a-z]*) \z}xs) {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
259 0 0       0 $result = $2 ne '' ? qr{(?$2)$1} : qr{$1};
260             } elsif ($subtest =~ m{^ m \s* \( (.+) \) ([a-z]*) \z}xs) {
261 0 0       0 $result = $2 ne '' ? qr{(?$2)$1} : qr{$1};
262             } elsif ($subtest =~ m{^ m \s* \[ (.+) \] ([a-z]*) \z}xs) {
263 0 0       0 $result = $2 ne '' ? qr{(?$2)$1} : qr{$1};
264             } elsif ($subtest =~ m{^ m \s* \{ (.+) \} ([a-z]*) \z}xs) {
265 0 0       0 $result = $2 ne '' ? qr{(?$2)$1} : qr{$1};
266             } elsif ($subtest =~ m{^ m \s* < (.+) > ([a-z]*) \z}xs) {
267 0 0       0 $result = $2 ne '' ? qr{(?$2)$1} : qr{$1};
268             } elsif ($subtest =~ m{^ m \s* (\S) (.+) \1 ([a-z]*) \z}xs) {
269 0 0       0 $result = $2 ne '' ? qr{(?$2)$1} : qr{$1};
270             } elsif ($subtest =~ m{^ (["']) (.*) \1 \z}xs) { # quoted string
271 0         0 $result = $2;
272             } elsif ($subtest =~ m{^ \[ ( (?:[A-Z]+|\d+)
273             (?: \s* , \s* (?:[A-Z]+|\d+) )* ) \] \z}xis) {
274             # a comma-separated list of rcode names or their decimal values
275 0         0 my @rcodes = split(/\s*,\s*/, uc $1);
276 0 0       0 for (@rcodes) { $_ = $rcode_value{$_} if exists $rcode_value{$_} }
  0         0  
277 0 0       0 return if grep(!/^\d+\z/, @rcodes);
278             # a hashref indicates a list of DNS rcodes (stored as hash keys)
279 0         0 $result = { map( ($_,1), @rcodes) };
280             } elsif ($subtest =~ m{^ ([^/-]+) (?: ([/-]) (.+) )? \z}xs) {
281 0         0 my($n1,$delim,$n2) = ($1,$2,$3);
282 0         0 my $any_quad_dot;
283 0         0 for ($n1,$n2) {
284 0 0       0 if (!defined $_) {
    0          
    0          
    0          
285             # ok, $n2 may not exist
286             } elsif (/^\d{1,10}\z/) {
287 0         0 $_ = 0 + $_; # decimal string -> number
288             } elsif (/^0x[0-9a-zA-Z]{1,8}\z/) {
289 0         0 $_ = hex($_); # hex string -> number
290             } elsif (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/) {
291 0         0 $_ = Mail::SpamAssassin::Util::my_inet_aton($_); # quad-dot -> number
292 0         0 $any_quad_dot = 1;
293             } else {
294 0         0 return;
295             }
296             }
297 0 0       0 $result = defined $n2 ? $n1.$delim.$n2
    0          
298             : $any_quad_dot ? $n1.'-'.$n1 : "$n1";
299             }
300 0         0 return $result;
301             }
302              
303             sub set_config {
304 62     62 0 190 my($self, $conf) = @_;
305 62         165 my @cmds;
306              
307             push(@cmds, {
308             setting => 'askdns',
309             is_admin => 1,
310             type => $Mail::SpamAssassin::Conf::CONF_TYPE_HASH_KEY_VALUE,
311             code => sub {
312 0     0   0 my($self, $key, $value, $line) = @_;
313 0         0 local($1,$2,$3,$4);
314 0 0 0     0 if (!defined $value || $value =~ /^$/) {
    0          
315 0         0 return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE;
316             } elsif ($value !~ /^ (\S+) \s+ (\S+)
317             (?: \s+ ([A-Za-z0-9,]+)
318             (?: \s+ (.*?) )? )? \s* $/xs) {
319 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
320             } else {
321 0         0 my($rulename,$query_template,$query_type,$subtest) = ($1,$2,$3,$4);
322 0 0       0 $query_type = 'A' if !defined $query_type;
323 0         0 $query_type = uc $query_type;
324 0         0 my @answer_types = split(/,/, $query_type);
325             # https://www.iana.org/assignments/dns-parameters/dns-parameters.xml
326 0 0       0 if (grep(!/^(?:ANY|A|AAAA|MX|TXT|PTR|NAPTR|NS|SOA|CERT|CNAME|DNAME|
327             DHCID|HINFO|MINFO|RP|HIP|IPSECKEY|KX|LOC|SRV|
328             SSHFP|SPF)\z/x, @answer_types)) {
329 0         0 return $Mail::SpamAssassin::Conf::INVALID_VALUE;
330             }
331 0 0 0     0 $query_type = 'ANY' if @answer_types > 1 || $answer_types[0] eq 'ANY';
332 0 0       0 if (defined $subtest) {
333 0         0 $subtest = parse_and_canonicalize_subtest($subtest);
334 0 0       0 defined $subtest or return $Mail::SpamAssassin::Conf::INVALID_VALUE;
335             }
336             # collect tag names as used in each query template
337 0         0 my @tags = $query_template =~ /_([A-Z][A-Z0-9]*)_/g;
338 0         0 my %seen; @tags = grep(!$seen{$_}++, @tags); # filter out duplicates
  0         0  
339              
340             # group rules by tag names used in them (to be used as a hash key)
341 0 0       0 my $depends_on_tags = !@tags ? '' : join(',',@tags);
342              
343             # subgroup rules by a DNS RR type and a nonexpanded query template
344 0         0 my $query_template_key = $query_type . ':' . $query_template;
345              
346 0 0 0     0 $self->{askdns}{$depends_on_tags}{$query_template_key} ||=
      0        
347             { query => $query_template, rules => {}, q_type => $query_type,
348             a_types => # optimization: undef means "same as q_type"
349             @answer_types == 1 && $answer_types[0] eq $query_type ? undef
350             : \@answer_types };
351 0         0 $self->{askdns}{$depends_on_tags}{$query_template_key}{rules}{$rulename}
352             = $subtest;
353             # dbg("askdns: rule: %s, config dep: %s, domkey: %s, subtest: %s",
354             # $rulename, $depends_on_tags, $query_template_key, $subtest);
355              
356             # just define the test so that scores and lint works
357 0         0 $self->{parser}->add_test($rulename, undef,
358             $Mail::SpamAssassin::Conf::TYPE_EMPTY_TESTS);
359             }
360             }
361 62         800 });
362              
363 62         381 $conf->{parser}->register_commands(\@cmds);
364             }
365              
366             # run as early as possible, launching DNS queries as soon as their
367             # dependencies are fulfilled
368             #
369             sub parsed_metadata {
370 81     81 1 262 my($self, $opts) = @_;
371 81         230 my $pms = $opts->{permsgstatus};
372 81         191 my $conf = $pms->{conf};
373              
374 81 100       267 return if !$pms->is_dns_available;
375 4         18 $pms->{askdns_map_dnskey_to_rules} = {};
376              
377             # walk through all collected askdns rules, obtain tag values whenever
378             # they may become available, and launch DNS queries right after
379             #
380 4         11 for my $depends_on_tags (keys %{$conf->{askdns}}) {
  4         29  
381 0           my @tags;
382 0 0         @tags = split(/,/, $depends_on_tags) if $depends_on_tags ne '';
383              
384 0 0         if (would_log("dbg","askdns")) {
385 0           while ( my($query_template_key, $struct) =
386 0           each %{$conf->{askdns}{$depends_on_tags}} ) {
387             my($query_template, $query_type, $answer_types_ref, $rules) =
388 0           @$struct{qw(query q_type a_types rules)};
389 0           dbg("askdns: depend on tags %s, rules: %s ",
390             $depends_on_tags, join(', ', keys %$rules));
391             }
392             }
393              
394 0 0         if (!@tags) {
395             # no dependencies on tags, just call directly
396 0           $self->launch_queries($pms,$depends_on_tags);
397             } else {
398             # enqueue callback for tags needed
399             $pms->action_depends_on_tags(@tags == 1 ? $tags[0] : \@tags,
400 0     0     sub { my($pms,@args) = @_;
401 0           $self->launch_queries($pms,$depends_on_tags) }
402 0 0         );
403             }
404             }
405             }
406              
407             # generate DNS queries - called for each set of rules
408             # when their tag dependencies are met
409             #
410             sub launch_queries {
411 0     0 0   my($self, $pms, $depends_on_tags) = @_;
412 0           my $conf = $pms->{conf};
413              
414 0           my %tags;
415             # obtain tag/value pairs of tags we depend upon in this set of rules
416 0 0         if ($depends_on_tags ne '') {
417 0           %tags = map( ($_,$pms->get_tag($_)), split(/,/,$depends_on_tags) );
418             }
419             dbg("askdns: preparing queries which depend on tags: %s",
420 0           join(', ', map($_.' => '.$tags{$_}, keys %tags)));
421              
422             # replace tag names in a query template with actual tag values
423             # and launch DNS queries
424 0           while ( my($query_template_key, $struct) =
425 0           each %{$conf->{askdns}{$depends_on_tags}} ) {
426             my($query_template, $query_type, $answer_types_ref, $rules) =
427 0           @$struct{qw(query q_type a_types rules)};
428              
429 0           my @rulenames = keys %$rules;
430 0 0         if (grep($conf->{scores}->{$_}, @rulenames)) {
431 0 0         dbg("askdns: query template %s, type %s, rules: %s",
432             $query_template,
433             !$answer_types_ref ? $query_type
434             : $query_type.'/'.join(',',@$answer_types_ref),
435             join(', ', @rulenames));
436             } else {
437 0           dbg("askdns: query template %s, type %s, all rules disabled: %s",
438             $query_template, $query_type, join(', ', @rulenames));
439 0           next;
440             }
441              
442             # collect all tag names from a template, each may occur more than once
443 0           my @templ_tags = $query_template =~ /_([A-Z][A-Z0-9]*)_/gs;
444              
445             # filter out duplicate tag names, and tags with undefined or empty value
446 0           my %seen;
447 0   0       @templ_tags = grep(!$seen{$_}++ && defined $tags{$_} && $tags{$_} ne '',
448             @templ_tags);
449              
450 0           my %templ_vals; # values that each tag takes
451 0           for my $t (@templ_tags) {
452 0           my %seen;
453             # a tag value may be a space-separated list,
454             # store it as an arrayref, removing duplicate values
455 0           $templ_vals{$t} = [ grep(!$seen{$_}++, split(' ',$tags{$t})) ];
456             }
457              
458             # count through all tag value tuples
459 0           my @digit = (0) x @templ_tags; # counting accumulator
460             OUTER:
461 0           for (;;) {
462 0           my %current_tag_val; # maps a tag name to its current iteration value
463 0           for my $j (0 .. $#templ_tags) {
464 0           my $t = $templ_tags[$j];
465 0           $current_tag_val{$t} = $templ_vals{$t}[$digit[$j]];
466             }
467 0           local $1;
468 0           my $query_domain = $query_template;
469 0 0         $query_domain =~ s{_([A-Z][A-Z0-9]*)_}
  0            
470             { defined $current_tag_val{$1} ? $current_tag_val{$1}
471             : '' }ge;
472              
473 0           # the $dnskey identifies this query in AsyncLoop's pending_lookups
474 0           my $dnskey = join(':', 'askdns', $query_type, $query_domain);
475             dbg("askdns: expanded query %s, dns key %s", $query_domain, $dnskey);
476 0 0          
477             if ($query_domain eq '') {
478             # ignore, just in case
479 0 0         } else {
480 0           if (!exists $pms->{askdns_map_dnskey_to_rules}{$dnskey}) {
481             $pms->{askdns_map_dnskey_to_rules}{$dnskey} =
482             [ [$query_type, $answer_types_ref, $rules] ];
483 0           } else {
  0            
484             push(@{$pms->{askdns_map_dnskey_to_rules}{$dnskey}},
485             [$query_type, $answer_types_ref, $rules] );
486             }
487             # launch a new DNS query for $query_type and $query_domain
488             my $ent = $pms->{async}->bgsend_and_start_lookup(
489             $query_domain, $query_type, undef,
490 0     0     { key => $dnskey, zone => $query_domain },
491 0           sub { my ($ent2,$pkt) = @_;
492 0           $self->process_response_packet($pms, $ent2, $pkt, $dnskey) },
493             master_deadline => $pms->{master_deadline} );
494             # these rules are now underway; unless the rule hits, these will
495 0 0         # not be considered "finished" until harvest_dnsbl_queries() completes
496             $pms->register_async_rule_start($dnskey) if $ent;
497             }
498 0 0          
499             last if !@templ_tags;
500 0           # increment accumulator, little-endian
501 0 0         for (my $j = 0; ; $j++) {
  0            
502 0           last if ++$digit[$j] <= $#{$templ_vals{$templ_tags[$j]}};
503 0 0         $digit[$j] = 0; # and carry
504             last OUTER if $j >= $#templ_tags;
505             }
506             }
507             }
508             }
509              
510 0     0 0   sub process_response_packet {
511             my($self, $pms, $ent, $pkt, $dnskey) = @_;
512 0            
513 0           my $conf = $pms->{conf};
514             my %rulenames_hit;
515              
516 0           # map a dnskey back to info on queries which caused this DNS lookup
517             my $queries_ref = $pms->{askdns_map_dnskey_to_rules}{$dnskey};
518 0            
519             my($header, @question, @answer, $qtype, $rcode);
520 0 0         # NOTE: $pkt will be undef if the DNS query was aborted (e.g. timed out)
521 0           if ($pkt) {
522 0           @answer = $pkt->answer;
523 0           $header = $pkt->header;
524 0 0         @question = $pkt->question;
525 0 0         $qtype = uc $question[0]->qtype if @question;
526             $rcode = uc $header->rcode if $header; # 'NOERROR', 'NXDOMAIN', ...
527              
528 0           # NOTE: qname is encoded in RFC 1035 zone format, decode it
529             dbg("askdns: answer received, rcode %s, query %s, answer has %d records",
530             $rcode,
531             join(', ', map(join('/', decode_dns_question_entry($_)), @question)),
532             scalar @answer);
533 0 0 0        
534             if (defined $rcode && exists $rcode_value{$rcode}) {
535             # Net::DNS return a rcode name for codes it knows about,
536 0 0         # and returns a number for the rest; we deal with numbers from here on
537             $rcode = $rcode_value{$rcode} if exists $rcode_value{$rcode};
538             }
539 0 0         }
540             if (!@answer) {
541             # a trick to make the following loop run at least once, so that we can
542 0           # evaluate also rules which only care for rcode status
543             @answer = ( undef );
544             }
545              
546             # NOTE: $rr->rdstring returns the result encoded in a DNS zone file
547             # format, i.e. enclosed in double quotes if a result contains whitespace
548             # (or other funny characters), and may use \DDD encoding or \X quoting as
549             # per RFC 1035. Using $rr->txtdata instead avoids this unnecessary encoding
550             # step and a need for decoding by a caller, returning an unmodified string.
551             # Caveat: in case of multiple RDATA <character-string> fields contained
552             # in a resource record (TXT, SPF, HINFO), starting with Net::DNS 0.69
553             # the $rr->txtdata in a list context returns these strings as a list.
554             # The $rr->txtdata in a scalar context always returns a single string
555             # with <character-string> fields joined by a single space character as
556             # a separator. The $rr->txtdata in Net::DNS 0.68 and older returned
557             # such joined space-separated string even in a list context.
558              
559             # RFC 5518: If the RDATA in a TXT record contains multiple
560             # character-strings (as defined in Section 3.3 of [RFC1035]),
561             # the code handling such reply from DNS MUST assemble all of these
562             # marshaled text blocks into a single one before any syntactical
563             # verification takes place.
564             # The same goes for RFC 4408 (SPF), RFC 4871 (DKIM), RFC 5617 (ADSP),
565             # draft-kucherawy-dmarc-base (DMARC), ...
566 0            
567 0           for my $rr (@answer) {
568 0 0         my($rr_rdatastr, $rdatanum, $rr_type);
569             if (!$rr) {
570             # special case, no answer records, only rcode can be tested
571 0           } else {
572 0 0         $rr_type = uc $rr->type;
    0          
573             if ($rr_type eq 'A') {
574 0 0         # Net::DNS::RR::A::address() is available since Net::DNS 0.69
575             $rr_rdatastr = $rr->UNIVERSAL::can('address') ? $rr->address
576 0 0         : $rr->rdatastr;
577 0           if ($rr_rdatastr =~ m/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/) {
578             $rdatanum = Mail::SpamAssassin::Util::my_inet_aton($rr_rdatastr);
579             }
580              
581             } elsif ($rr->UNIVERSAL::can('txtdata')) {
582 0 0 0       # TXT, SPF: join with no intervening spaces, as per RFC 5518
583 0           if ($txtdata_can_provide_a_list || $rr_type ne 'TXT') {
584             $rr_rdatastr = join('', $rr->txtdata); # txtdata() in list context!
585 0           } else { # char_str_list() is only available for TXT records
586             $rr_rdatastr = join('', $rr->char_str_list); # historical
587             }
588             } else {
589 0 0         # rdatastr() is historical, use rdstring() since Net::DNS 0.69
590             $rr_rdatastr = $rr->UNIVERSAL::can('rdstring') ? $rr->rdstring
591 0 0         : $rr->rdatastr;
592             utf8::encode($rr_rdatastr) if utf8::is_utf8($rr_rdatastr);
593             }
594             # dbg("askdns: received rr type %s, data: %s", $rr_type, $rr_rdatastr);
595             }
596 0            
597 0 0         my $j = 0;
598 0 0         for my $q_tuple (!ref $queries_ref ? () : @$queries_ref) {
599 0           next if !$q_tuple;
600             my($query_type, $answer_types_ref, $rules) = @$q_tuple;
601 0 0 0        
602 0 0         next if !defined $qtype || $query_type ne $qtype;
603             $answer_types_ref = [$query_type] if !defined $answer_types_ref;
604              
605 0           # mark rule as done
606             $pms->{askdns_map_dnskey_to_rules}{$dnskey}[$j++] = undef;
607 0            
608 0           while (my($rulename,$subtest) = each %$rules) {
609 0           my $match;
610 0 0 0       local($1,$2,$3);
    0 0        
    0          
    0          
    0          
    0          
    0          
611 0 0         if (ref $subtest eq 'HASH') { # a list of DNS rcodes (as hash keys)
612             $match = 1 if $subtest->{$rcode};
613             } elsif ($rcode != 0) {
614             # skip remaining tests on DNS error
615             } elsif (!defined($rr_type) ||
616             !grep($_ eq 'ANY' || $_ eq $rr_type, @$answer_types_ref) ) {
617             # skip remaining tests on wrong RR type
618 0           } elsif (!defined $subtest) {
619             $match = 1; # any valid response of the requested RR type matches
620 0 0         } elsif (ref $subtest eq 'Regexp') { # a regular expression
621             $match = 1 if $rr_rdatastr =~ $subtest;
622 0           } elsif ($rr_rdatastr eq $subtest) { # exact equality
623             $match = 1;
624             } elsif (defined $rdatanum &&
625 0           $subtest =~ m{^ (\d+) (?: ([/-]) (\d+) )? \z}x) {
626 0 0 0       my($n1,$delim,$n2) = ($1,$2,$3);
    0 0        
    0          
627             $match =
628             !defined $n2 ? ($rdatanum & $n1) && # mask only
629             (($rdatanum & 0xff000000) == 0x7f000000) # 127/8
630             : $delim eq '-' ? $rdatanum >= $n1 && $rdatanum <= $n2 # range
631             : $delim eq '/' ? ($rdatanum & $n2) == (int($n1) & $n2) # value/mask
632             : 0; # notice int($n1) to fix perl ~5.14 taint bug (Bug 7725)
633 0 0         }
634 0           if ($match) {
635             $self->askdns_hit($pms, $ent->{query_domain}, $qtype,
636 0           $rr_rdatastr, $rulename);
637             $rulenames_hit{$rulename} = 1;
638             }
639             }
640             }
641             }
642 0           # these rules have completed (since they got at least 1 hit)
643             $pms->register_async_rule_finish($_) for keys %rulenames_hit;
644             }
645              
646 0     0 0   sub askdns_hit {
647             my($self, $pms, $query_domain, $qtype, $rr_rdatastr, $rulename) = @_;
648 0 0          
649 0           $rr_rdatastr = '' if !defined $rr_rdatastr; # e.g. with rules testing rcode
650             dbg('askdns: domain "%s" listed (%s): %s',
651             $query_domain, $rulename, $rr_rdatastr);
652              
653             # only the first hit will show in the test log report, even if
654 0           # an answer section matches more than once - got_hit() handles this
655 0           $pms->clear_test_state;
656 0           $pms->test_log(sprintf("%s %s:%s", $query_domain,$qtype,$rr_rdatastr));
657             $pms->got_hit($rulename, 'ASKDNS: ', ruletype => 'askdns'); # score=>$score
658             }
659              
660             1;