From 6e3dc16e3383239bd1476a95c408e23fd51ac5cd Mon Sep 17 00:00:00 2001 From: Kay Date: Sun, 7 Apr 2024 12:13:34 +1000 Subject: [PATCH] Support both Qualys SSLLabs API v4 and v3 (#189) (#199) - Support Qualys SSLLabs API v4 - The tool uses [API v4](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v4.md) if you provide your registered email with Qualys SSLLabs via the `--email` argument. - The tool uses [API v3](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v3.md) if you do not specify the `--email` argument. Note that v3 will be being deprecated in 2024 by Qualys. - Ticket: #189 --- CHANGELOG.md | 8 +++++++ README.md | 25 ++++++++++++++++++--- setup.py | 2 +- ssllabsscan/main.py | 17 ++++++++++++-- ssllabsscan/ssllabs_client.py | 28 +++++++++++++++--------- ssllabsscan/tests/conftest.py | 10 +++++++++ ssllabsscan/tests/test_main.py | 6 +++++ ssllabsscan/tests/test_ssllabs_client.py | 16 +++++++++----- 8 files changed, 90 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdbe09d..f7fc95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Change Log All notable changes to this project will be documented in this file. +3.0.0 - 2024-04-02 +================== + +### Changed +- Support Qualys SSLLabs API v4 (#189) + - The tool uses [API v4](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v4.md) if you provide your registered email with Qualys SSLLabs via the `--email` argument. + - The tool uses [API v3](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v3.md) if you do not specify the `--email` argument. Note that v3 will be being deprecated in 2024 by Qualys. + 2.3.0 - 2024-04-01 ================== diff --git a/README.md b/README.md index df74455..81846ee 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,10 @@ [![SecretsScan](https://github.com/kyhau/ssllabs-scan/actions/workflows/secrets-scan.yml/badge.svg)](https://github.com/kyhau/ssllabs-scan/actions/workflows/secrets-scan.yml) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](http://en.wikipedia.org/wiki/MIT_License) -This tool calls the SSL Labs [API v3](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v3.md) to do SSL testings on the given hosts, and generates csv and html reports. +This tool calls the SSL Labs API to do SSL testings on the given hosts, and generates csv and html reports. +- The tool uses [API v4](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v4.md) if you provide your registered email with Qualys SSLLabs via the `--email` argument. +- The tool uses [API v3](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v3.md) if you do not specify the `--email` argument. Note that v3 will be being deprecated in 2024 by Qualys. + All notable changes to this project will be documented in [CHANGELOG](./CHANGELOG.md). @@ -33,6 +36,12 @@ You can change the report template and styles in these files: - [ssllabsscan/report_template.py](./ssllabsscan/report_template.py) - [ssllabsscan/styles.css](./ssllabsscan/styles.css) +--- +## Important Notes + +ℹ️ Please note that from Qualys SSLLabs API v4, you must use a one-time registration with Qualys SSLLabs. For details see [Introduction of API v4 for Qualys SSLLabs and deprecation of API v3](https://notifications.qualys.com/api/2023/09/28/introduction-of-api-v4-for-qualys-ssllabs-and-deprecation-of-api-v3). +> The API v3 API will be available until the end of 2023 (Dec 31st 2023), and starting from 1st January 2024, we will be deprecating the API v3 support for SSL Labs. Request all customers to move to API v4. + ℹ️ Please note that the SSL Labs Assessment API has access rate limits. You can find more details in the sections "Error Response Status Codes" and "Access Rate and Rate Limiting" in the official [SSL Labs API Documentation](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v3.md). Some common status codes are: - 400 - invocation error (e.g., invalid parameters) - 429 - client request rate too high or too many new assessments too fast @@ -49,9 +58,14 @@ You can change the report template and styles in these files: virtualenv env . env/bin/activate -# Install and run +# Install pip install -e . + +# Run with v3 (v3, which does not required a registered email, will be being deprecated in 2024) ssllabs-scan sample/SampleServerList.txt + +# Run with v4 +ssllabs-scan sample/SampleServerList.txt --email ``` ### Windows @@ -60,9 +74,14 @@ ssllabs-scan sample/SampleServerList.txt virtualenv env env\Scripts\activate -# Install and run +# Install pip install -e . + +# Run with v3 (v3, which does not required a registered email, will be being deprecated in 2024) ssllabs-scan sample\SampleServerList.txt + +# Run with v4 +ssllabs-scan sample\SampleServerList.txt --email ``` ### Docker diff --git a/setup.py b/setup.py index 273ac28..edf023e 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import find_packages, setup __title__ = "ssllabsscan" -__version__ = "2.3.0" +__version__ = "3.0.0" __author__ = "Kay Hau" __email__ = "virtualda@gmail.com" __uri__ = "https://github.com/kyhau/ssllabs-scan" diff --git a/ssllabsscan/main.py b/ssllabsscan/main.py index b12f4fc..fd04197 100644 --- a/ssllabsscan/main.py +++ b/ssllabsscan/main.py @@ -48,6 +48,7 @@ def output_summary_html(input_csv, output_html): def process( server_list_file, + email, check_progress_interval_secs=30, summary_csv=SUMMARY_CSV, summary_html=SUMMARY_HTML @@ -65,7 +66,7 @@ def process( for server in servers: try: print(f"Start analyzing {server}...") - SSLLabsClient(check_progress_interval_secs).analyze(server, summary_csv) + SSLLabsClient(email, check_progress_interval_secs).analyze(server, summary_csv) except Exception as e: traceback.print_exc() ret = 1 @@ -82,6 +83,12 @@ def parse_args(): "inputfile", help="Input file containing list of servers to scan", ) + parser.add_argument( + "-e", + "--email", + dest="email", + help="Registered-email required for Qualys SSLLabs API v4", + ) parser.add_argument( "-o", "--output", @@ -110,7 +117,13 @@ def main(): Entry point of the app. """ args = parse_args() - return process(server_list_file=args.inputfile, check_progress_interval_secs=args.progress, summary_csv=args.summary, summary_html=args.output) + return process( + server_list_file=args.inputfile, + email=args.email, + check_progress_interval_secs=args.progress, + summary_csv=args.summary, + summary_html=args.output, + ) if __name__ == "__main__": diff --git a/ssllabsscan/ssllabs_client.py b/ssllabsscan/ssllabs_client.py index a31a123..2fe0210 100644 --- a/ssllabsscan/ssllabs_client.py +++ b/ssllabsscan/ssllabs_client.py @@ -11,7 +11,9 @@ logging.getLogger().setLevel(logging.INFO) -API_URL = "https://api.ssllabs.com/api/v3/analyze" +API_V4_URL = "https://api.ssllabs.com/api/v4/analyze" +API_V3_URL = "https://api.ssllabs.com/api/v3/analyze" + CHAIN_ISSUES = { "0": "none", @@ -47,7 +49,8 @@ class SSLLabsClient(): - def __init__(self, check_progress_interval_secs=30, max_attempts=100, verify=True): + def __init__(self, email, check_progress_interval_secs=30, max_attempts=100, verify=True): + self.email = email self._check_progress_interval_secs = check_progress_interval_secs self._max_attempts = max_attempts self._verify = verify @@ -66,7 +69,6 @@ def analyze(self, host, summary_csv_file): self.append_summary_csv(summary_csv_file, host, data) def start_new_scan(self, host, publish="off", startNew="off", all="done", ignoreMismatch="on"): - path = API_URL payload = { "host": host, "publish": publish, @@ -75,7 +77,7 @@ def start_new_scan(self, host, publish="off", startNew="off", all="done", ignore "ignoreMismatch": ignoreMismatch } - response = self.request_api(path, payload) + response = self.request_api(payload) results = response.json() payload.pop("startNew") @@ -84,7 +86,7 @@ def start_new_scan(self, host, publish="off", startNew="off", all="done", ignore while response.status_code == 200 and results["status"] not in ["READY", "ERROR"]: self.print_msg(response, "WAIT_FOR_COMPLETE") time.sleep(self._check_progress_interval_secs) - response = self.request_api(path, payload) + response = self.request_api(payload) results = response.json() if response.status_code != 200 or results["status"] == "ERROR": @@ -93,8 +95,8 @@ def start_new_scan(self, host, publish="off", startNew="off", all="done", ignore return results - def request_api(self, url, payload): - response = self.requests_get(url, payload) + def request_api(self, payload): + response = self.requests_get(payload) attempts = 0 # Supported error codes @@ -109,12 +111,18 @@ def request_api(self, url, payload): self.print_msg(response, "WAIT_FOR_RETRY") attempts += 1 time.sleep(self._check_progress_interval_secs) - response = self.requests_get(url, payload) + response = self.requests_get(payload) return response - def requests_get(self, url, payload): - return requests.get(url, params=payload, verify=self._verify) + def requests_get(self, payload): + kargs = {} + url = API_V3_URL + if self.email: + kargs = {"headers": {"email": self.email}} + url = API_V4_URL + + return requests.get(url, params=payload, verify=self._verify, **kargs) @staticmethod def prepare_datetime(epoch_time): diff --git a/ssllabsscan/tests/conftest.py b/ssllabsscan/tests/conftest.py index a70039e..b4831e8 100644 --- a/ssllabsscan/tests/conftest.py +++ b/ssllabsscan/tests/conftest.py @@ -127,6 +127,16 @@ def sample_server_list_file_2(unit_tests_tmp_output_dir): return server_list_file +@pytest.fixture(scope="session") +def email_1(): + return None + + +@pytest.fixture(scope="session") +def email_2(): + return "dummy@example.com" + + @pytest.fixture(scope="session") def output_summary_csv_file(unit_tests_tmp_output_dir): return os.path.join(unit_tests_tmp_output_dir, "test_summary.csv") diff --git a/ssllabsscan/tests/test_main.py b/ssllabsscan/tests/test_main.py index ba730a0..a551da3 100644 --- a/ssllabsscan/tests/test_main.py +++ b/ssllabsscan/tests/test_main.py @@ -13,6 +13,7 @@ def common_tests( sample_dns_response, sample_in_progress_response, sample_ready_response, + email, output_summary_csv_file, output_summary_html_file, output_server_1_json_file @@ -27,6 +28,7 @@ def common_tests( assert 0 == process( sample_input_file, + email, check_progress_interval_secs=1, summary_csv=output_summary_csv_file, summary_html=output_summary_html_file @@ -42,6 +44,7 @@ def test_main_process_1( sample_dns_response, sample_in_progress_response, sample_ready_response, + email_1, output_summary_csv_file, output_summary_html_file, output_server_1_json_file @@ -51,6 +54,7 @@ def test_main_process_1( sample_dns_response, sample_in_progress_response, sample_ready_response, + email_1, output_summary_csv_file, output_summary_html_file, output_server_1_json_file @@ -62,6 +66,7 @@ def test_main_process_2( sample_dns_response, sample_in_progress_response, sample_ready_response, + email_2, output_summary_csv_file, output_summary_html_file, output_server_1_json_file @@ -71,6 +76,7 @@ def test_main_process_2( sample_dns_response, sample_in_progress_response, sample_ready_response, + email_2, output_summary_csv_file, output_summary_html_file, output_server_1_json_file diff --git a/ssllabsscan/tests/test_ssllabs_client.py b/ssllabsscan/tests/test_ssllabs_client.py index 8673704..09d7def 100644 --- a/ssllabsscan/tests/test_ssllabs_client.py +++ b/ssllabsscan/tests/test_ssllabs_client.py @@ -2,6 +2,7 @@ import pytest from mock import Mock + from ssllabsscan.ssllabs_client import SSLLabsClient from .conftest import MockHttpResponse @@ -13,6 +14,7 @@ def test_ssl_labs_client_analyze( sample_dns_response, sample_in_progress_response, sample_ready_response, + email_1, output_summary_csv_file, output_server_1_json_file ): @@ -22,7 +24,7 @@ def test_ssl_labs_client_analyze( MockHttpResponse(200, sample_ready_response) ] - client = SSLLabsClient(check_progress_interval_secs=1) + client = SSLLabsClient(email=email_1, check_progress_interval_secs=1) client.requests_get = Mock(side_effect=mocked_request_ok_response_sequence) client.analyze(host=sample_ready_response["host"], summary_csv_file=output_summary_csv_file) @@ -35,6 +37,7 @@ def test_ssl_labs_client_start_new_scan_valid_url( sample_dns_response, sample_in_progress_response, sample_ready_response, + email_1 ): """Case 1: valid server url""" @@ -44,7 +47,7 @@ def test_ssl_labs_client_start_new_scan_valid_url( MockHttpResponse(200, sample_ready_response) ] - client1 = SSLLabsClient(check_progress_interval_secs=1) + client1 = SSLLabsClient(email=email_1, check_progress_interval_secs=1) client1.requests_get = Mock(side_effect=mocked_request_ok_response_sequence) ret = client1.start_new_scan(host=sample_ready_response["host"]) @@ -53,14 +56,14 @@ def test_ssl_labs_client_start_new_scan_valid_url( assert ret["endpoints"][0]["grade"] -def test_ssl_labs_client_start_new_scan_invalid_url(sample_dns_response): +def test_ssl_labs_client_start_new_scan_invalid_url(sample_dns_response, email_1): """Case 2: unable to resolve domain name""" mocked_request_err_response_sequence = [ MockHttpResponse(200, sample_dns_response), MockHttpResponse(200, SAMPLE_UNABLE_TO_RESOLVE_DOMAIN_RESPONSE) ] - client2 = SSLLabsClient(check_progress_interval_secs=1) + client2 = SSLLabsClient(email=email_1, check_progress_interval_secs=1) client2.requests_get = Mock(side_effect=mocked_request_err_response_sequence) ret = client2.start_new_scan(host="example2.com") @@ -70,7 +73,8 @@ def test_ssl_labs_client_start_new_scan_invalid_url(sample_dns_response): def test_ssl_labs_client_start_new_scan_unexpected_error_code( sample_dns_response, - sample_in_progress_response + sample_in_progress_response, + email_1 ): # Case 3: received error codes other than the supported one mocked_request_err_response_sequence = [ @@ -79,7 +83,7 @@ def test_ssl_labs_client_start_new_scan_unexpected_error_code( MockHttpResponse(441, {"status": "ERROR", "statusMessage": "some error"}) ] - client3 = SSLLabsClient(check_progress_interval_secs=1) + client3 = SSLLabsClient(email=email_1, check_progress_interval_secs=1) client3.requests_get = Mock(side_effect=mocked_request_err_response_sequence) ret = client3.start_new_scan(host="example3.com")