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