diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e1adba..10d4179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,3 +8,6 @@ # v1.2.0 - Added some type annotations and more consistent error for the main function + +# v1.3.0 +- Added the ability to scan by IP \ No newline at end of file diff --git a/README.md b/README.md index cfcc68a..8c7f495 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,15 @@ Deployment - As part of the above setup, you will be able to deploy to Azure usi Invoke the function on the command line using curl: -``` curl https://.azurewebsite.net/api/{scan:alpha}/{view:alpha}/{name}/{port}``` +``` curl https://.azurewebsite.net/api/{scan:alpha}/{view:alpha}/{target}/{port}``` -There are four parts to pass to the URI: scan, view, name and port. +There are four parts to pass to the URI: scan, view, target, and port. -"scan" is the type of scan: policy or full. Currently, the default policy prohibits using SSL 2.0/3.0 and TLS 1.0, so the policy scan will identify which unsupported ciphers are in use, if any. A full scan will report back all supported ciphers. In a future release I will make this configurable. +"scan" is the type of scan: policy or full. Currently, the default policy prohibits using SSL 2.0/3.0 and TLS 1.0/1.1, so the policy scan will identify which unsupported ciphers are in use, if any. A full scan will report back all supported ciphers. In a future release I will make this configurable. Since corporations often use [split-view DNS](https://en.wikipedia.org/wiki/Split-horizon_DNS), "view" in this context is the network viewpoint you want to scan, either internal or external. This is accomplished by specifying a valid DNS server to use for name resolution. The default value for external will use OpenDNS (e.g. 208.67.222.222). The default for internal will be 0.0.0.0 and will result in an error if a scan is attempted and no internal DNS server is specified. Please modify the config.ini file to use an internal DNS server. -"name" should be the DNS domain name you would like to scan (i.e., github.com). +"target" should be the DNS domain name or IP that you would like to scan (i.e., github.com or 140.82.113.4). "port" is optional and if omitted will default to TCP 443. diff --git a/SSLChecker/SSLChecker/function.json b/SSLChecker/SSLChecker/function.json index 3f8021a..7109509 100644 --- a/SSLChecker/SSLChecker/function.json +++ b/SSLChecker/SSLChecker/function.json @@ -7,7 +7,7 @@ "type": "httpTrigger", "direction": "in", "name": "req", - "route": "{scan:alpha}/{view:alpha?}/{name?}/{port?}", + "route": "{scan:alpha}/{view:alpha?}/{target?}/{port?}", "methods": [ "get" ] diff --git a/SSLChecker/SSLChecker/main.py b/SSLChecker/SSLChecker/main.py index 84f3edc..8bbd178 100644 --- a/SSLChecker/SSLChecker/main.py +++ b/SSLChecker/SSLChecker/main.py @@ -1,3 +1,4 @@ +import ipaddress import json import logging from typing import Tuple, Mapping @@ -12,10 +13,10 @@ from ..sharedcode.errors import (InvalidRequest, DNSError, InvalidConfig, ConnectionError, InvalidFQDN) -external_dns, internal_dns = shared_dns.get_dns_options() +EXTERNAL_DNS, INTERNAL_DNS = shared_dns.get_dns_options() -dnsview = {"external": external_dns, - "internal": internal_dns} +DNSVIEW = {"external": EXTERNAL_DNS, + "internal": INTERNAL_DNS} # Valid scan types VALID_SCAN_TYPES = ['policy', 'full'] @@ -23,8 +24,8 @@ ERROR_MSG_MISSING_PARAMETERS = \ ("Please pass three parameters in the URI: " "valid scan type: policy or full, valid DNS view: internal or external, " - "and a valid DNS domain name. For example: " - "https://.azurewebsite.net/api/full/external/www.google.com") + "and a valid DNS domain name or IP. For example: " + "https://.azurewebsite.net/api/full/external/github.com") ERROR_MSG_INVALID_SCANNER_TYPE = \ "Please pass a valid scan type: 'policy' or 'full'" @@ -41,7 +42,8 @@ ERROR_MSG_INVALID_PORT = \ "Please pass a valid port in range 1-65535" -def verify_port(port:str) -> int: + +def verify_port(port: str) -> int: """ raises InvalidRequest """ @@ -53,7 +55,7 @@ def verify_port(port:str) -> int: return _port -def verify_scan_type(scan_type:str) -> str: +def verify_scan_type(scan_type: str) -> str: """ verify scan type is valid """ @@ -66,7 +68,7 @@ def verify_scan_type(scan_type:str) -> str: def pre_scan_check(req: func.HttpRequest) -> Tuple[str, str, str, int, str]: """ - return scan_type, view, name, and port, ip as a tuple if the request is + return scan_type, view, target, port, and ip as a tuple if the request is valid. raises @@ -77,7 +79,7 @@ def pre_scan_check(req: func.HttpRequest) -> Tuple[str, str, str, int, str]: """ scan_type = req.route_params.get('scan') view = req.route_params.get('view') - name = req.route_params.get('name') + target = req.route_params.get('target') port = req.route_params.get('port', '443') port = verify_port(port) @@ -86,41 +88,47 @@ def pre_scan_check(req: func.HttpRequest) -> Tuple[str, str, str, int, str]: the Azure Function worker supplies a 404 if you do not supply all three routes in the URI. I made routes optional, this way we can handle errors gracefully """ - if scan_type is None or view is None or name is None: + if scan_type is None or view is None or target is None: raise InvalidRequest("Missing Parameter(s)", ERROR_MSG_MISSING_PARAMETERS) scan_type = verify_scan_type(scan_type) # Check to ensure a valid DNS view was passed view = view.lower() - if view not in dnsview: + if view not in DNSVIEW: raise InvalidRequest(f"Invalid View '{view}'", ERROR_MSG_INVALID_VIEW) # this maybe best handled as part of loading the app instead of checking it # here - if dnsview.get(view) == '0.0.0.0': + if DNSVIEW.get(view) == '0.0.0.0': raise InvalidConfig('Missing DNS Server in config', ERROR_MSG_MISSING_DNS_SERVER) - # Parse the name parameter to ensure it is a valid DNS name - # and does not contain http(s) - name = shared_dns.parse_name(name) + # See if a valid IP was passed, else check if it was a valid DNS name + try: + if ipaddress.IPv4Address(target): + ip = target + except: + # Parse the target parameter to ensure it is a valid FQDN + # and does not contain http(s) + target = shared_dns.parse_name(target) + + """ Try to resolve the DNS name to an IP to ensure it exists. + We use the IP in the scan so that we can record which one we tested + which can be useful. """ + ip = shared_dns.resolve_dns(DNSVIEW.get(view), target) - """ Try to resolve the DNS name to an IP to ensure it exists. - We use the IP in the scan so that we can record which one we tested - which can be useful. """ - ip = shared_dns.resolve_dns(dnsview.get(view), name) + return scan_type, view, target, port, ip - return scan_type, view, name, port, ip def path_params_scanner(req: func.HttpRequest) -> str: """ perform actual scan for path based parameters """ try: - scan_type, view, name, port, ip = pre_scan_check(req) + scan_type, view, target, port, ip = pre_scan_check(req) # Run the scan - return json.dumps(scanner.scan(name, ip, port, view, scan_type)) + return json.dumps(scanner.scan(target, ip, port, view, scan_type)) except (InvalidRequest, InvalidConfig, DNSError, ConnectionError, InvalidFQDN) as err: return json.dumps(results.set_error(err.args[0], err.args[1])) @@ -128,28 +136,29 @@ def path_params_scanner(req: func.HttpRequest) -> str: return json.dumps(results.set_error("Unexpected Error", str(err))) -VALID_QUERY_PARAMS = ('host', 'nameserver', 'port', 'scan_type') +VALID_QUERY_PARAMS = ('target', 'nameserver', 'port', 'scan_type') ERROR_MSG_QUERY_EXAMPLE = ( - "Example: https://sslchecker.metlife.com/api/ssl?" - "host=www.yahoo.com&port=8443") + "Example: https://.azurewebsite.net/api/ssl?" + "target=www.yahoo.com&port=8443") -ERROR_MSG_INVALID_QUERY_PARAMS = ( "Valid params are: " +ERROR_MSG_INVALID_QUERY_PARAMS = ("Valid params are: " f"{', '.join(VALID_QUERY_PARAMS)}") -ERROR_MSG_INVALID_QUERY_URL = ( "Valid URL path must be 'ssl' or 'tls'. " +ERROR_MSG_INVALID_QUERY_URL = ("Valid URL path must be 'ssl' or 'tls'. " f"{ERROR_MSG_QUERY_EXAMPLE}") -ERROR_MSG_INVALID_QUERY_MISSING_PARAMS = ( "'host' parameters is required. " +ERROR_MSG_INVALID_QUERY_MISSING_PARAMS = ("'target' parameters is required. " f"{ERROR_MSG_QUERY_EXAMPLE}") -def query_scanner_precheck(url:str, - params:Mapping[str, str] + +def query_scanner_precheck(url: str, + params: Mapping[str, str] ) -> Tuple[str, str, int, str, str, str]: """ check to ensure the url path as well as the query parameters are valid returns ------- - scan_type, host, port, ip, nameserver, view + scan_type, target, port, ip, nameserver, view raises ------ @@ -159,10 +168,11 @@ def query_scanner_precheck(url:str, if url.lower() not in VALID_QUERY_API_URL: raise InvalidRequest(f"Invalid URL Path '{url}'", ERROR_MSG_INVALID_QUERY_URL) - if 'host' not in params: + if 'target' not in params: raise InvalidRequest(f'Missing required parameter', ERROR_MSG_INVALID_QUERY_MISSING_PARAMS) - host = shared_dns.parse_name(params['host']) + + target = params['target'] scan_type = 'full' port = '443' nameserver = None @@ -180,29 +190,36 @@ def query_scanner_precheck(url:str, view = 'custom' if nameserver is None: - if external_dns: + if EXTERNAL_DNS: view = 'external' - nameserver = external_dns + nameserver = EXTERNAL_DNS else: view = 'internal' - nameserver = internal_dns + nameserver = INTERNAL_DNS - ip = shared_dns.resolve_dns(nameserver, host) + # See if a valid IP was passed, else check if it was a valid DNS name + try: + if ipaddress.IPv4Address(target): + ip = target + except: + target = shared_dns.parse_name(target) + ip = shared_dns.resolve_dns(nameserver, target) - return scan_type, host, port, ip, nameserver, view + return scan_type, target, port, ip, nameserver, view VALID_QUERY_API_URL = ('ssl', 'tls') -def query_params_scanner(url:str, params:Mapping[str, str]) -> str: + +def query_params_scanner(url: str, params: Mapping[str, str]) -> str: """ new function behavior to handle query based scanner, it would default to external DNS view before using the Internal """ try: - scan_type, host, port, ip, nameserver, view = \ + scan_type, target, port, ip, nameserver, view = \ query_scanner_precheck(url, params) - return json.dumps(scanner.scan(host, ip, port, view, scan_type)) + return json.dumps(scanner.scan(target, ip, port, view, scan_type)) except (InvalidRequest, InvalidConfig, DNSError, ConnectionError, InvalidFQDN) as err: return json.dumps(results.set_error(err.args[0], err.args[1])) @@ -211,7 +228,7 @@ def query_params_scanner(url:str, params:Mapping[str, str]) -> str: def main(req: func.HttpRequest) -> str: - logging.info( 'Python HTTP trigger function processed a request ' + logging.info('Python HTTP trigger function processed a request ' f'for url: {req.url}.') starttime = process_time() url_parsed = urlparse(req.url) @@ -222,6 +239,7 @@ def main(req: func.HttpRequest) -> str: else: resp = query_params_scanner(url_path[0], req.params) - logging.info(f'Processed time for URL {req.url} took {process_time() - starttime}') + logging.info(f'Processing time for URL {req.url} took' + f' {process_time() - starttime}') return resp diff --git a/SSLChecker/sharedcode/errors.py b/SSLChecker/sharedcode/errors.py index 889e6a3..ec94c80 100644 --- a/SSLChecker/sharedcode/errors.py +++ b/SSLChecker/sharedcode/errors.py @@ -1,20 +1,27 @@ """ Custom Exception for the app """ + + class InvalidConfig(Exception): pass + class InvalidFQDN(Exception): pass + class UnknownError(Exception): pass + class InvalidRequest(Exception): pass + class DNSError(Exception): pass + class ConnectionError(Exception): pass diff --git a/SSLChecker/sharedcode/results.py b/SSLChecker/sharedcode/results.py index f4e95ef..2bfad23 100644 --- a/SSLChecker/sharedcode/results.py +++ b/SSLChecker/sharedcode/results.py @@ -7,7 +7,7 @@ def set_error(error_type, message): def new(): return { - 'Hostname': None, + 'Target': None, 'IP': None, 'MD5': None, 'View': None, diff --git a/SSLChecker/sharedcode/scanner.py b/SSLChecker/sharedcode/scanner.py index 2dd3df5..35909c2 100644 --- a/SSLChecker/sharedcode/scanner.py +++ b/SSLChecker/sharedcode/scanner.py @@ -29,13 +29,13 @@ 'TCP connection to {}:{} encountered unknown error'.format -def scan(name, ip, port, view, suite): +def scan(target, ip, port, view, suite): """ Five inputs: web site name, ip, port split-dns view, and cipher suite """ try: server_tester = ServerConnectivityTester( - hostname=name, + hostname=target, ip_address=ip, port=port, tls_wrapped_protocol=TlsWrappedProtocolEnum.HTTPS @@ -46,18 +46,18 @@ def scan(name, ip, port, view, suite): # Could not establish an SSL connection to the server except ConnectionToServerTimedOut: raise ConnectionError('Connection Timeout', - ERROR_MSG_CONNECTION_TIMEOUT(name, port)) + ERROR_MSG_CONNECTION_TIMEOUT(target, port)) except ServerConnectivityError: raise ConnectionError('Unknow Connection Error', - ERROR_MSG_UNKNOWN_CONNECTION(name, port)) + ERROR_MSG_UNKNOWN_CONNECTION(target, port)) # Create a new results dictionary scan_output = results.new() # I hash the combination of hostname and ip for tracking - key = md5((f'{name}' + ip).encode("utf-8")).hexdigest() + key = md5((f'{target}' + ip).encode("utf-8")).hexdigest() results.set_result(scan_output, "MD5", key) - results.set_result(scan_output, "Hostname", f'{name}') + results.set_result(scan_output, "Target", f'{target}') results.set_result(scan_output, "IP", ip) results.set_result(scan_output, "View", view) diff --git a/tests/test_SSLChecker.py b/tests/test_SSLChecker.py index 15ea0ff..076927e 100644 --- a/tests/test_SSLChecker.py +++ b/tests/test_SSLChecker.py @@ -15,7 +15,7 @@ def test_policy_external_no_violations(): url='/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': 'microsoft.com'} + 'target': 'microsoft.com'} ) # Call the function @@ -36,7 +36,7 @@ def test_full_external(): url='/api/', route_params={'scan': 'full', 'view': 'external', - 'name': 'github.com'} + 'target': 'github.com'} ) # Call the function @@ -57,7 +57,7 @@ def test_policy_external_violations(): url='/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': 'espn.com'} + 'target': 'espn.com'} ) # Call the function. @@ -78,7 +78,7 @@ def test_external_dns_name_not_resolved(): url='/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': 'joegatt.com'} + 'target': 'joegatt.com'} ) # Call the function. @@ -99,7 +99,7 @@ def test_external_dns_name_not_exist(): url='/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': 'jeogatt.com'} + 'target': 'jeogatt.com'} ) # Call the function. @@ -120,7 +120,7 @@ def test_external_sslyze_timeout(): url='/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': name} + 'target': name} ) # Call the function @@ -141,7 +141,7 @@ def test_external_missing_dns_name(): url='/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': None} + 'target': None} ) # Call the function. @@ -164,7 +164,7 @@ def test_bad_dns_view_input(): url=f'/api/', route_params={'scan': 'policy', 'view': view_name, - 'name': 'microsoft.com'} + 'target': 'microsoft.com'} ) # Call the function. @@ -187,7 +187,7 @@ def test_bad_policy_input(): url=f'/api/', route_params={'scan': policy_type, 'view': 'external', - 'name': 'microsoft.com'} + 'target': 'microsoft.com'} ) # Call the function. @@ -209,7 +209,7 @@ def test_missing_dns_view(): url='/api/', route_params={'scan': 'policy', 'view': None, - 'name': None} + 'target': None} ) # Call the function. @@ -232,7 +232,7 @@ def test_bad_dns_name(): url=f'/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': dns_name} + 'target': dns_name} ) # Call the function. @@ -254,7 +254,7 @@ def test_missing_policy_view_dns_name(): url='/api/', route_params={'scan': None, 'view': None, - 'name': None} + 'target': None} ) # Call the function. @@ -278,7 +278,7 @@ def test_external_bad_port(): url=f'/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': dns_name, + 'target': dns_name, 'port': port} ) @@ -300,10 +300,10 @@ def test_external_port_timeout(): req = func.HttpRequest( method='GET', body=None, - url=f'/api/', + url='/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': dns_name, + 'target': dns_name, 'port': '8443'} ) @@ -324,10 +324,10 @@ def test_external_port_not_in_range(): req = func.HttpRequest( method='GET', body=None, - url=f'/api/policy/external/espn.com/{port}', + url='/api/', route_params={'scan': 'policy', 'view': 'external', - 'name': 'espn.com', + 'target': 'espn.com', 'port': port} ) @@ -347,7 +347,18 @@ def test_query_api(): method='GET', body=None, url=f'/api/tls', - params={'host': 'www.google.com', 'nameserver': '8.8.8.8'} + params={'target': 'www.google.com', 'nameserver': '8.8.8.8'} + ) + resp = main(req) + assert 'Results' in resp + + +def test_query_api_by_ip(): + req = func.HttpRequest( + method='GET', + body=None, + url='/api/tls', + params={'target': '140.82.113.4', 'nameserver': '8.8.8.8'} ) resp = main(req) assert 'Results' in resp @@ -357,9 +368,28 @@ def test_query_api_error_handling(): req = func.HttpRequest( method='GET', body=None, - url=f'/api/tls', + url='/api/tls', params={'nameserver': '8.8.8.8'} ) resp = main(req) results = json.loads(resp) assert results['Error Type'] == "Missing required parameter" + + +def test_policy_external_by_ip_no_violations(): + req = func.HttpRequest( + method='GET', + body=None, + url='/api/', + route_params={'scan': 'policy', + 'view': 'external', + 'target': '140.82.113.4'} + ) + # Call the function. + resp = main(req) + + # Convert resp string to dict + results = json.loads(resp) + + # Check the output to ensure there are violations + assert results["Results"] == 'No Policy Violations'