diff --git a/sslyze/__main__.py b/sslyze/__main__.py index 217dca68..6836acc0 100644 --- a/sslyze/__main__.py +++ b/sslyze/__main__.py @@ -1,5 +1,5 @@ import sys -from datetime import UTC, datetime +from datetime import datetime, timezone from typing import Optional, TextIO from sslyze.cli.console_output import ObserverToGenerateConsoleOutput @@ -23,7 +23,7 @@ def main() -> None: # Parse the supplied command line - date_scans_started = datetime.now(UTC) + date_scans_started = datetime.now(timezone.utc) sslyze_parser = CommandLineParser(__version__) try: parsed_command_line = sslyze_parser.parse_command_line() @@ -82,7 +82,7 @@ def main() -> None: for bad_server in parsed_command_line.invalid_servers ], date_scans_started=date_scans_started, - date_scans_completed=datetime.now(UTC), + date_scans_completed=datetime.now(timezone.utc), ) json_output_as_str = json_output.model_dump_json(indent=2) json_file_out.write(json_output_as_str) diff --git a/sslyze/connection_helpers/opportunistic_tls_helpers.py b/sslyze/connection_helpers/opportunistic_tls_helpers.py index 064200af..430cfe78 100644 --- a/sslyze/connection_helpers/opportunistic_tls_helpers.py +++ b/sslyze/connection_helpers/opportunistic_tls_helpers.py @@ -2,6 +2,7 @@ import struct from abc import abstractmethod, ABC from enum import Enum +from smtplib import SMTP, SMTPException from typing import ClassVar, Optional @@ -61,20 +62,46 @@ def prepare_socket_for_tls_handshake(self, sock: socket.socket) -> None: class _SmtpHelper(_OpportunisticTlsHelper): """Perform an SMTP StartTLS negotiation.""" + def __init__(self, smtp_ehlo_hostname: str): + self._smtp_ehlo_hostname = smtp_ehlo_hostname + def prepare_socket_for_tls_handshake(self, sock: socket.socket) -> None: - # Get the SMTP banner - sock.recv(2048) + # SMTP parsing has some complicated areas and some unusual but legal + # server behavior - this code uses Python's smtplib to handle the protocol. + smtp = SMTP(local_hostname=self._smtp_ehlo_hostname) + smtp.sock = sock - # Send a EHLO and wait for the 250 status - sock.send(b"EHLO sslyze.scan\r\n") - data = sock.recv(2048) - if b"250 " not in data: - raise OpportunisticTlsError(f"SMTP EHLO was rejected: {repr(data)}") + try: + code, server_reply_as_bytes = smtp.getreply() + except SMTPException as exc: + raise OpportunisticTlsError(f"Unexpected error while performing the SMTP EHLO handshake: {str(exc)}") + + if code != 220: + server_reply_as_str = server_reply_as_bytes.decode() + raise OpportunisticTlsError( + f"Server did not send a '220 service ready' SMTP message: {server_reply_as_str}" + ) + + try: + code, server_reply_as_bytes = smtp.ehlo() + except SMTPException as exc: + raise OpportunisticTlsError(f"Unexpected error while performing the SMTP EHLO handshake: {str(exc)}") + + if code != 250: + server_reply_as_str = server_reply_as_bytes.decode() + raise OpportunisticTlsError(f"SMTP EHLO was rejected: {server_reply_as_str}") - # Send a STARTTLS - sock.send(b"STARTTLS\r\n") - if b"220" not in sock.recv(2048): - raise OpportunisticTlsError("SMTP STARTTLS not supported") + if not smtp.has_extn("starttls"): + raise OpportunisticTlsError("Server does not support STARTTLS with SMTP") + + try: + code, server_reply_as_bytes = smtp.docmd("STARTTLS") + except SMTPException as exc: + raise OpportunisticTlsError(f"Unexpected error while performing the SMTP EHLO handshake: {str(exc)}") + + if code != 220: + server_reply_as_str = server_reply_as_bytes.decode() + raise OpportunisticTlsError(f"SMTP STARTTLS rejected: {server_reply_as_str}") class _XmppHelper(_OpportunisticTlsHelper): @@ -220,14 +247,16 @@ class _PostgresHelper(_GenericOpportunisticTlsHelper): def get_opportunistic_tls_helper( - protocol: ProtocolWithOpportunisticTlsEnum, xmpp_to_hostname: Optional[str] + protocol: ProtocolWithOpportunisticTlsEnum, xmpp_to_hostname: Optional[str], smtp_ehlo_hostname: Optional[str] ) -> _OpportunisticTlsHelper: helper_cls = _START_TLS_HELPER_CLASSES[protocol] - if protocol not in [ProtocolWithOpportunisticTlsEnum.XMPP, ProtocolWithOpportunisticTlsEnum.XMPP_SERVER]: - opportunistic_tls_helper = helper_cls() - else: + if protocol in [ProtocolWithOpportunisticTlsEnum.XMPP, ProtocolWithOpportunisticTlsEnum.XMPP_SERVER]: if xmpp_to_hostname is None: raise ValueError("Received None for xmpp_to_hostname") opportunistic_tls_helper = helper_cls(xmpp_to=xmpp_to_hostname) + elif protocol == ProtocolWithOpportunisticTlsEnum.SMTP: + opportunistic_tls_helper = helper_cls(smtp_ehlo_hostname=smtp_ehlo_hostname) + else: + opportunistic_tls_helper = helper_cls() return opportunistic_tls_helper diff --git a/sslyze/connection_helpers/tls_connection.py b/sslyze/connection_helpers/tls_connection.py index 49631dec..e86ea211 100644 --- a/sslyze/connection_helpers/tls_connection.py +++ b/sslyze/connection_helpers/tls_connection.py @@ -233,7 +233,9 @@ def _do_pre_handshake(self) -> None: # Do the Opportunistic/StartTLS negotiation if needed if self._network_configuration.tls_opportunistic_encryption: opportunistic_tls_helper = get_opportunistic_tls_helper( - self._network_configuration.tls_opportunistic_encryption, self._network_configuration.xmpp_to_hostname + self._network_configuration.tls_opportunistic_encryption, + self._network_configuration.xmpp_to_hostname, + self._network_configuration.smtp_ehlo_hostname, ) try: opportunistic_tls_helper.prepare_socket_for_tls_handshake(sock) diff --git a/sslyze/scanner/models.py b/sslyze/scanner/models.py index 08ca7946..8ae23497 100644 --- a/sslyze/scanner/models.py +++ b/sslyze/scanner/models.py @@ -151,9 +151,14 @@ class AllScanCommandsAttempts: http_headers: HttpHeadersScanAttempt +_SCAN_CMD_FIELD_NAME_TO_CLS: dict[str, Type[ScanCommandAttempt]] = { + cls_field.name: cls_field.type # type: ignore + for cls_field in fields(AllScanCommandsAttempts) +} + + def get_scan_command_attempt_cls(scan_command: ScanCommand) -> Type[ScanCommandAttempt]: - field_name_to_cls = {cls_field.name: cls_field.type for cls_field in fields(AllScanCommandsAttempts)} - return field_name_to_cls[scan_command.value] + return _SCAN_CMD_FIELD_NAME_TO_CLS[scan_command.value] class ServerConnectivityStatusEnum(str, Enum): diff --git a/sslyze/server_setting.py b/sslyze/server_setting.py index c30a4397..881e8c00 100644 --- a/sslyze/server_setting.py +++ b/sslyze/server_setting.py @@ -163,7 +163,7 @@ class ServerNetworkConfiguration: Attributes: tls_server_name_indication: The hostname to set within the Server Name Indication TLS extension. - tls_wrapped_protocol: The protocol wrapped in TLS that the server expects. It allows SSLyze to figure out + tls_opportunistic_encryption: The protocol wrapped in TLS that the server expects. It allows SSLyze to figure out how to establish a (Start)TLS connection to the server and what kind of "hello" message (SMTP, XMPP, etc.) to send to the server after the handshake was completed. If not supplied, standard TLS will be used. @@ -171,10 +171,12 @@ class ServerNetworkConfiguration: with the server. If not supplied, SSLyze will attempt to connect to the server without performing client authentication. xmpp_to_hostname: The hostname to set within the `to` attribute of the XMPP stream. If not supplied, the - server's hostname will be used. Should only be set if the supplied `tls_wrapped_protocol` is an + server's hostname will be used. Should only be set if the supplied `tls_opportunistic_encryption` is an XMPP protocol. http_user_agent: The User-Agent to send in HTTP requests. If not supplied, a default Chrome-like is used that includes SSLyze's version. + smtp_ehlo_hostname: The hostname to set in the SMTP EHLO. If not supplied, the default of "sslyze.scan" + will be used. Should only be set if the supplied `tls_opportunistic_encryption` is SMTP. network_timeout: The timeout (in seconds) to be used when attempting to establish a connection to the server. network_max_retries: The number of retries SSLyze will perform when attempting to establish a connection @@ -186,6 +188,7 @@ class ServerNetworkConfiguration: tls_client_auth_credentials: Optional[ClientAuthenticationCredentials] = None xmpp_to_hostname: Optional[str] = None + smtp_ehlo_hostname: Optional[str] = None http_user_agent: Optional[str] = None network_timeout: int = 5 @@ -204,6 +207,17 @@ def __post_init__(self) -> None: if self.xmpp_to_hostname: raise InvalidServerNetworkConfigurationError("Can only specify xmpp_to for the XMPP StartTLS protocol.") + if self.tls_opportunistic_encryption in [ + ProtocolWithOpportunisticTlsEnum.SMTP, + ]: + if not self.smtp_ehlo_hostname: + object.__setattr__(self, "smtp_ehlo_hostname", "sslyze.scan") + else: + if self.smtp_ehlo_hostname: + raise InvalidServerNetworkConfigurationError( + "Can only specify smtp_ehlo_hostname for the SMTP StartTLS protocol." + ) + if self.tls_opportunistic_encryption and self.http_user_agent: raise InvalidServerNetworkConfigurationError( "Cannot specify both tls_opportunistic_encryption and http_user_agent"