1#!/usr/bin/env python
2#
3# This file is a part of DNSViz, a tool suite for DNS/DNSSEC monitoring,
4# analysis, and visualization.
5# Created by Casey Deccio (casey@deccio.net)
6#
7# Copyright 2014-2016 VeriSign, Inc.
8#
9# Copyright 2016-2021 Casey Deccio
10#
11# DNSViz is free software; you can redistribute it and/or modify
12# it under the terms of the GNU General Public License as published by
13# the Free Software Foundation; either version 2 of the License, or
14# (at your option) any later version.
15#
16# DNSViz is distributed in the hope that it will be useful,
17# but WITHOUT ANY WARRANTY; without even the implied warranty of
18# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19# GNU General Public License for more details.
20#
21# You should have received a copy of the GNU General Public License along
22# with DNSViz.  If not, see <http://www.gnu.org/licenses/>.
23#
24
25from __future__ import unicode_literals
26
27import argparse
28import codecs
29import io
30import json
31import logging
32import os
33import re
34import sys
35
36# minimal support for python2.6
37try:
38    from collections import OrderedDict
39except ImportError:
40    from ordereddict import OrderedDict
41
42import dns.exception, dns.name
43
44from dnsviz.analysis import OfflineDomainNameAnalysis, DNS_RAW_VERSION
45from dnsviz.format import latin1_binary_to_string as lb2s
46from dnsviz.util import get_trusted_keys
47
48# If the import of DNSAuthGraph fails because of the lack of pygraphviz, it
49# will be reported later
50try:
51    from dnsviz.viz.dnssec import DNSAuthGraph
52except ImportError:
53    try:
54        import pygraphviz
55    except ImportError:
56        pass
57    else:
58        raise
59
60logging.basicConfig(level=logging.WARNING, format='%(message)s')
61logger = logging.getLogger()
62
63class AnalysisInputError(Exception):
64    pass
65
66TERM_COLOR_MAP = {
67    'BOLD': '\033[1m',
68    'RESET': '\033[0m',
69    'SECURE': '\033[36m',
70    'BOGUS': '\033[31m',
71    'INSECURE': '\033[37m',
72    'NOERROR': '\033[37m',
73    'NXDOMAIN': '\033[37m',
74    'INDETERMINATE': '\033[31m',
75    'NON_EXISTENT': '\033[37m',
76    'VALID': '\033[36m',
77    'INDETERMINATE': '\033[37m',
78    'INDETERMINATE_NO_DNSKEY': '\033[37m',
79    'INDETERMINATE_MATCH_PRE_REVOKE': '\033[37m',
80    'INDETERMINATE_UNKNOWN_ALGORITHM': '\033[33m',
81    'ALGORITHM_IGNORED': '\033[37m',
82    'EXPIRED': '\033[35m',
83    'PREMATURE': '\033[35m',
84    'INVALID_SIG': '\033[31m',
85    'INVALID': '\033[31m',
86    'INVALID_DIGEST': '\033[31m',
87    'INCOMPLETE': '\033[33m',
88    'LAME': '\033[33m',
89    'INVALID_TARGET': '\033[31m',
90    'ERROR': '\033[31m',
91    'WARNING': '\033[33m',
92}
93
94KEY_RE = re.compile(r'^((?P<indent>\s+)")(.+)(": )')
95ERRORS_RE = re.compile(r'^((?P<indent>\s+)")((?P<level>warning|error)s?)(": \[)$')
96ERRORS_CLOSE_RE = re.compile(r'^(?P<indent>\s+)],?$')
97DESCRIPTION_CODE_RE = re.compile(r'^((?P<indent>\s+)")(?P<name>description|code)(": ")(.+)(",?)$')
98STATUS_RE = re.compile(r'^(?P<indent>\s+)("status": ")(?P<status>.+)(",?)')
99
100
101def color_json(s):
102    error = None
103    s1 = ''
104
105    for line in s.split('\n'):
106        if error is None:
107            # not in an error object; look for a start
108            error = ERRORS_RE.search(line)
109            if error is not None:
110                # found an error start
111                line = ERRORS_RE.sub(r'\1%s%s\3%s\5' % (TERM_COLOR_MAP['BOLD'], TERM_COLOR_MAP[error.group('level').upper()], TERM_COLOR_MAP['RESET']), line)
112                s1 += line + '\n'
113                continue
114
115        if error is None:
116            # not in an error object
117            m = STATUS_RE.search(line)
118            if m is not None:
119                line = STATUS_RE.sub(r'\1\2%s\3%s\4' % (TERM_COLOR_MAP[m.group('status').upper()], TERM_COLOR_MAP['RESET']), line)
120            line = KEY_RE.sub(r'\1%s\3%s\4' % (TERM_COLOR_MAP['BOLD'], TERM_COLOR_MAP['RESET']), line)
121            s1 += line + '\n'
122            continue
123
124        # in an error object
125        m = ERRORS_CLOSE_RE.search(line)
126        if m is not None and len(m.group('indent')) == len(error.group('indent')):
127            error = None
128            s1 += line + '\n'
129            continue
130
131        line = DESCRIPTION_CODE_RE.sub(r'\1\3\4%s\5%s\6' % (TERM_COLOR_MAP[error.group('level').upper()], TERM_COLOR_MAP['RESET']), line)
132        line = KEY_RE.sub(r'\1%s\3%s\4' % (TERM_COLOR_MAP['BOLD'], TERM_COLOR_MAP['RESET']), line)
133        s1 += line + '\n'
134
135    return s1.rstrip()
136
137def test_pygraphviz():
138    try:
139        from pygraphviz import release
140        try:
141            major, minor = release.version.split('.')[:2]
142            major = int(major)
143            minor = int(re.sub(r'(\d+)[^\d].*', r'\1', minor))
144            if (major, minor) < (1,3):
145                logger.error('''pygraphviz version >= 1.3 is required, but version %s is installed.''' % release.version)
146                sys.exit(2)
147        except ValueError:
148            logger.error('''pygraphviz version >= 1.3 is required, but version %s is installed.''' % release.version)
149            sys.exit(2)
150    except ImportError:
151        logger.error('''pygraphviz is required, but not installed.''')
152        sys.exit(2)
153
154class GrokArgHelper:
155
156    def __init__(self, logger):
157        self.parser = None
158
159        self.trusted_keys = None
160        self.names = None
161        self.analysis_structured = None
162        self.log_level = None
163
164        self.args = None
165        self._arg_mapping = None
166
167        self._logger = logger
168
169    def build_parser(self, prog):
170        self.parser = argparse.ArgumentParser(description='Assess diagnostic DNS queries', prog=prog)
171
172        # python3/python2 dual compatibility
173        stdin_buffer = io.open(sys.stdin.fileno(), 'rb', closefd=False)
174        stdout_buffer = io.open(sys.stdout.fileno(), 'wb', closefd=False)
175
176        try:
177            self.parser.add_argument('-f', '--names-file',
178                    type=argparse.FileType('r', encoding='UTF-8'),
179                    action='store', metavar='<filename>',
180                    help='Read names from a file')
181        except TypeError:
182            # this try/except is for
183            # python3/python2 dual compatibility
184            self.parser.add_argument('-f', '--names-file',
185                    type=argparse.FileType('r'),
186                    action='store', metavar='<filename>',
187                    help='Read names from a file')
188        #self.parser.add_argument('-s', '--silent',
189        #        const=True, default=False,
190        #        action='store_const',
191        #        help='Suppress error messages')
192        try:
193            self.parser.add_argument('-r', '--input-file',
194                    type=argparse.FileType('r', encoding='UTF-8'), default=stdin_buffer,
195                    action='store', metavar='<filename>',
196                    help='Read diagnostic queries from a file')
197        except TypeError:
198            # this try/except is for
199            # python3/python2 dual compatibility
200            self.parser.add_argument('-r', '--input-file',
201                    type=argparse.FileType('r'), default=stdin_buffer,
202                    action='store', metavar='<filename>',
203                    help='Read diagnostic queries from a file')
204        try:
205            self.parser.add_argument('-t', '--trusted-keys-file',
206                    type=argparse.FileType('r', encoding='UTF-8'),
207                    action='append', metavar='<filename>',
208                    help='Use trusted keys from the designated file')
209        except TypeError:
210            # this try/except is for
211            # python3/python2 dual compatibility
212            self.parser.add_argument('-t', '--trusted-keys-file',
213                    type=argparse.FileType('r'),
214                    action='append', metavar='<filename>',
215                    help='Use trusted keys from the designated file')
216        self.parser.add_argument('-a', '--algorithms',
217                type=self.comma_separated_ints_set,
218                action='store', metavar='<alg>,[<alg>...]',
219                help='Support only the specified DNSSEC algorithm(s)')
220        self.parser.add_argument('-d', '--digest-algorithms',
221                type=self.comma_separated_ints_set,
222                action='store', metavar='<digest_alg>,[<digest_alg>...]',
223                help='Support only the specified DNSSEC digest algorithm(s)')
224        self.parser.add_argument('-b', '--validate-prohibited-algs',
225                const=True, default=False,
226                action='store_const',
227                help='Validate algorithms for which validation is otherwise prohibited')
228        self.parser.add_argument('-C', '--enforce-cookies',
229                const=True, default=False,
230                action='store_const',
231                help='Enforce DNS cookies strictly')
232        self.parser.add_argument('-P', '--allow-private',
233                const=True, default=False,
234                action='store_const',
235                help='Allow private IP addresses for authoritative DNS servers')
236        self.parser.add_argument('-o', '--output-file',
237                type=argparse.FileType('wb'), default=stdout_buffer,
238                action='store', metavar='<filename>',
239                help='Save the output to the specified file')
240        self.parser.add_argument('-c', '--minimize-output',
241                const=True, default=False,
242                action='store_const',
243                help='Format JSON output minimally, instead of "pretty"')
244        self.parser.add_argument('-l', '--log-level',
245                type=str, choices=('error', 'warning', 'info', 'debug'), default='debug',
246                action='store', metavar='<loglevel>',
247                help='Save the output to the specified file')
248        self.parser.add_argument('domain_name',
249                type=self.valid_domain_name,
250                action='store', nargs='*', metavar='<domain_name>',
251                help='Domain names')
252
253        self._arg_mapping = dict([(a.dest, '/'.join(a.option_strings)) for a in self.parser._actions])
254
255    def parse_args(self, args):
256        self.args = self.parser.parse_args(args)
257
258    @classmethod
259    def comma_separated_ints_set(cls, arg):
260        return set(cls.comma_separated_ints(arg))
261
262    @classmethod
263    def comma_separated_ints(cls, arg):
264        ints = []
265        arg = arg.strip()
266        if not arg:
267            return ints
268        for i in arg.split(','):
269            try:
270                ints.append(int(i.strip()))
271            except ValueError:
272                raise argparse.ArgumentTypeError('Invalid integer: %s' % (i))
273        return ints
274
275    @classmethod
276    def valid_domain_name(cls, arg):
277        try:
278            return dns.name.from_text(arg)
279        except dns.exception.DNSException:
280            raise argparse.ArgumentTypeError('Invalid domain name: "%s"' % arg)
281
282    def check_args(self):
283        if self.args.names_file and self.args.domain_name:
284            raise argparse.ArgumentTypeError('If %(names_file)s is used, then domain names may not supplied as command line arguments.' % \
285                    self._arg_mapping)
286
287    def set_kwargs(self):
288        if self.args.log_level == 'error':
289            self.log_level = logging.ERROR
290        elif self.args.log_level == 'warning':
291            self.log_level = logging.WARNING
292        elif self.args.log_level == 'info':
293            self.log_level = logging.INFO
294        else: # self.args.log_level == 'debug':
295            self.log_level = logging.DEBUG
296
297    def set_buffers(self):
298        # This entire method is for
299        # python3/python2 dual compatibility
300        if self.args.input_file is not None:
301            if self.args.input_file.fileno() == sys.stdin.fileno():
302                filename = self.args.input_file.fileno()
303            else:
304                filename = self.args.input_file.name
305                self.args.input_file.close()
306            self.args.input_file = io.open(filename, 'r', encoding='utf-8')
307        if self.args.names_file is not None:
308            if self.args.names_file.fileno() == sys.stdin.fileno():
309                filename = self.args.names_file.fileno()
310            else:
311                filename = self.args.names_file.name
312                self.args.names_file.close()
313            self.args.names_file = io.open(filename, 'r', encoding='utf-8')
314        if self.args.trusted_keys_file is not None:
315            trusted_keys_files = []
316            for tk_file in self.args.trusted_keys_file:
317                if tk_file.fileno() == sys.stdin.fileno():
318                    filename = tk_file.fileno()
319                else:
320                    filename = tk_file.name
321                    tk_file.close()
322                trusted_keys_files.append(io.open(filename, 'r', encoding='utf-8'))
323            self.args.trusted_keys_file = trusted_keys_files
324        if self.args.output_file is not None:
325            if self.args.output_file.fileno() == sys.stdout.fileno():
326                filename = self.args.output_file.fileno()
327            else:
328                filename = self.args.output_file.name
329                self.args.output_file.close()
330            self.args.output_file = io.open(filename, 'wb')
331
332    def aggregate_trusted_key_info(self):
333        if not self.args.trusted_keys_file:
334            return
335
336        self.trusted_keys = []
337        for fh in self.args.trusted_keys_file:
338            tk_str = fh.read()
339            try:
340                self.trusted_keys.extend(get_trusted_keys(tk_str))
341            except dns.exception.DNSException:
342                raise argparse.ArgumentTypeError('There was an error parsing the trusted keys file: "%s"' % \
343                        self._arg_mapping)
344
345    def update_trusted_key_info(self):
346        if self.args.trusted_keys_file is None:
347            self.trusted_keys = []
348
349    def ingest_input(self):
350        analysis_str = self.args.input_file.read()
351        if not analysis_str:
352            if self.args.input_file.fileno() != sys.stdin.fileno():
353                raise AnalysisInputError('No input')
354            else:
355                raise AnalysisInputError()
356        try:
357            self.analysis_structured = json.loads(analysis_str)
358        except ValueError:
359            raise AnalysisInputError('There was an error parsing the JSON input: "%s"' % self.args.input_file.name)
360
361        # check version
362        if '_meta._dnsviz.' not in self.analysis_structured or 'version' not in self.analysis_structured['_meta._dnsviz.']:
363            raise AnalysisInputError('No version information in JSON input: "%s"' % self.args.input_file.name)
364        try:
365            major_vers, minor_vers = [int(x) for x in str(self.analysis_structured['_meta._dnsviz.']['version']).split('.', 1)]
366        except ValueError:
367            raise AnalysisInputError('Version of JSON input is invalid: %s' % self.analysis_structured['_meta._dnsviz.']['version'])
368        # ensure major version is a match and minor version is no greater
369        # than the current minor version
370        curr_major_vers, curr_minor_vers = [int(x) for x in str(DNS_RAW_VERSION).split('.', 1)]
371        if major_vers != curr_major_vers or minor_vers > curr_minor_vers:
372            raise AnalysisInputError('Version %d.%d of JSON input is incompatible with this software.' % (major_vers, minor_vers))
373
374    def ingest_names(self):
375        self.names = OrderedDict()
376
377        if self.args.domain_name:
378            for name in self.args.domain_name:
379                if name not in self.names:
380                    self.names[name] = None
381            return
382
383        if self.args.names_file:
384            args = self.args.names_file
385        else:
386            try:
387                args = self.analysis_structured['_meta._dnsviz.']['names']
388            except KeyError:
389                raise AnalysisInputError('No names found in JSON input!')
390
391        for arg in args:
392            name = arg.strip()
393
394            # python3/python2 dual compatibility
395            if hasattr(name, 'decode'):
396                name = name.decode('utf-8')
397
398            try:
399                name = dns.name.from_text(name)
400            except UnicodeDecodeError as e:
401                self._logger.error('%s: "%s"' % (e, name))
402            except dns.exception.DNSException:
403                self._logger.error('The domain name was invalid: "%s"' % name)
404            else:
405                if name not in self.names:
406                    self.names[name] = None
407
408def build_helper(logger, cmd, subcmd):
409    arghelper = GrokArgHelper(logger)
410    arghelper.build_parser('%s %s' % (cmd, subcmd))
411    return arghelper
412
413def main(argv):
414    try:
415
416        arghelper = build_helper(logger, sys.argv[0], argv[0])
417        arghelper.parse_args(argv[1:])
418        logger.setLevel(logging.WARNING)
419
420        try:
421            arghelper.check_args()
422            arghelper.set_kwargs()
423            arghelper.set_buffers()
424            arghelper.aggregate_trusted_key_info()
425            arghelper.ingest_input()
426            arghelper.ingest_names()
427        except argparse.ArgumentTypeError as e:
428            arghelper.parser.error(str(e))
429        except AnalysisInputError as e:
430            s = str(e)
431            if s:
432                logger.error(s)
433            sys.exit(3)
434
435        if arghelper.args.minimize_output:
436            kwargs = {}
437        else:
438            kwargs = { 'indent': 4, 'separators': (',', ': ') }
439
440        # if trusted keys were supplied, check that pygraphviz is installed
441        if arghelper.trusted_keys:
442            test_pygraphviz()
443
444        name_objs = []
445        cache = {}
446        for name in arghelper.names:
447            name_str = lb2s(name.canonicalize().to_text())
448            if name_str not in arghelper.analysis_structured or arghelper.analysis_structured[name_str].get('stub', True):
449                logger.error('The analysis of "%s" was not found in the input.' % lb2s(name.to_text()))
450                continue
451            name_obj = OfflineDomainNameAnalysis.deserialize(name, arghelper.analysis_structured, cache, strict_cookies=arghelper.args.enforce_cookies, allow_private=arghelper.args.allow_private)
452            name_objs.append(name_obj)
453
454        if not name_objs:
455            sys.exit(4)
456
457        arghelper.update_trusted_key_info()
458
459        d = OrderedDict()
460        for name_obj in name_objs:
461            name_obj.populate_status(arghelper.trusted_keys, supported_algs=arghelper.args.algorithms, supported_digest_algs=arghelper.args.digest_algorithms, validate_prohibited_algs=arghelper.args.validate_prohibited_algs)
462
463            if arghelper.trusted_keys:
464                G = DNSAuthGraph()
465                for qname, rdtype in name_obj.queries:
466                    if name_obj.is_zone() and rdtype in (dns.rdatatype.DNSKEY, dns.rdatatype.DS, dns.rdatatype.DLV):
467                        continue
468                    G.graph_rrset_auth(name_obj, qname, rdtype)
469                for target, mx_obj in name_obj.mx_targets.items():
470                    if mx_obj is not None:
471                        G.graph_rrset_auth(mx_obj, target, dns.rdatatype.A)
472                        G.graph_rrset_auth(mx_obj, target, dns.rdatatype.AAAA)
473                for target, ns_obj in name_obj.ns_dependencies.items():
474                    if ns_obj is not None:
475                        G.graph_rrset_auth(ns_obj, target, dns.rdatatype.A)
476                        G.graph_rrset_auth(ns_obj, target, dns.rdatatype.AAAA)
477                G.add_trust(arghelper.trusted_keys, supported_algs=arghelper.args.algorithms)
478                name_obj.populate_response_component_status(G)
479
480            name_obj.serialize_status(d, loglevel=arghelper.log_level)
481
482        if d:
483            s = json.dumps(d, ensure_ascii=False, **kwargs)
484            if not arghelper.args.minimize_output and arghelper.args.output_file.isatty() and os.environ.get('TERM', 'dumb') != 'dumb':
485                s = color_json(s)
486            arghelper.args.output_file.write(s.encode('utf-8'))
487
488    except KeyboardInterrupt:
489        logger.error('Interrupted.')
490        sys.exit(4)
491
492if __name__ == "__main__":
493    main(sys.argv)
494