Skip to content

Commit

Permalink
Support both Qualys SSLLabs API v4 and v3 (#189) (#199)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
kyhau authored Apr 7, 2024
1 parent 8cb1716 commit 6e3dc16
Show file tree
Hide file tree
Showing 8 changed files with 90 additions and 22 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
==================
Expand Down
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand All @@ -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 <your registered email with Qualys SSLLabs>
```

### Windows
Expand All @@ -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 <your registered email with Qualys SSLLabs>
```

### Docker
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from setuptools import find_packages, setup

__title__ = "ssllabsscan"
__version__ = "2.3.0"
__version__ = "3.0.0"
__author__ = "Kay Hau"
__email__ = "[email protected]"
__uri__ = "https://github.com/kyhau/ssllabs-scan"
Expand Down
17 changes: 15 additions & 2 deletions ssllabsscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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__":
Expand Down
28 changes: 18 additions & 10 deletions ssllabsscan/ssllabs_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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")
Expand All @@ -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":
Expand All @@ -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
Expand All @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions ssllabsscan/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "[email protected]"


@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")
Expand Down
6 changes: 6 additions & 0 deletions ssllabsscan/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
16 changes: 10 additions & 6 deletions ssllabsscan/tests/test_ssllabs_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest
from mock import Mock

from ssllabsscan.ssllabs_client import SSLLabsClient

from .conftest import MockHttpResponse
Expand All @@ -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
):
Expand All @@ -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)
Expand All @@ -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"""

Expand All @@ -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"])
Expand All @@ -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")
Expand All @@ -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 = [
Expand All @@ -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")
Expand Down

0 comments on commit 6e3dc16

Please sign in to comment.