From 59f636d424e715c0123b27d5892f24b9b3317f4c Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Tue, 2 Feb 2021 21:41:59 -0500 Subject: [PATCH] Implement AIA chasing --- tls/assets/configuration/spec.yaml | 21 +++++ tls/datadog_checks/tls/data/conf.yaml.example | 18 ++++ tls/datadog_checks/tls/tls.py | 89 +++++++++++++++++++ tls/tests/conftest.py | 5 ++ tls/tests/test_remote.py | 14 +++ 5 files changed, 147 insertions(+) diff --git a/tls/assets/configuration/spec.yaml b/tls/assets/configuration/spec.yaml index b2622b7fda1673..d2837083d9e5d5 100644 --- a/tls/assets/configuration/spec.yaml +++ b/tls/assets/configuration/spec.yaml @@ -16,6 +16,12 @@ files: example: - 'TLSv1.2' - 'TLSv1.3' + - name: fetch_intermediate_certs + description: | + Whether or not to perform AIA chasing in order to load the full certificate chain. + value: + example: false + type: boolean - template: init_config/default - template: instances options: @@ -72,6 +78,21 @@ files: example: - 'TLSv1.2' - 'TLSv1.3' + - name: fetch_intermediate_certs + description: | + Whether or not to perform AIA chasing in order to load the full certificate chain. + + This overrides `fetch_intermediate_certs` defined in `init_config`. + value: + example: false + type: boolean + - name: intermediate_cert_refresh_interval + description: | + When `fetch_intermediate_certs` is set to `true`, this indicates how often to + refresh the intermediate certificate cache in minutes. + value: + example: 60 + type: number - name: days_warning description: | Number of days before certificate expiration from which the service check diff --git a/tls/datadog_checks/tls/data/conf.yaml.example b/tls/datadog_checks/tls/data/conf.yaml.example index f4f89918e479e1..70ee7f2cb69051 100644 --- a/tls/datadog_checks/tls/data/conf.yaml.example +++ b/tls/datadog_checks/tls/data/conf.yaml.example @@ -11,6 +11,11 @@ init_config: # - TLSv1.2 # - TLSv1.3 + ## @param fetch_intermediate_certs - boolean - optional - default: false + ## Whether or not to perform AIA chasing in order to load the full certificate chain. + # + # fetch_intermediate_certs: false + ## @param service - string - optional ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. ## @@ -71,6 +76,19 @@ instances: # - TLSv1.2 # - TLSv1.3 + ## @param fetch_intermediate_certs - boolean - optional - default: false + ## Whether or not to perform AIA chasing in order to load the full certificate chain. + ## + ## This overrides `fetch_intermediate_certs` defined in `init_config`. + # + # fetch_intermediate_certs: false + + ## @param intermediate_cert_refresh_interval - number - optional - default: 60 + ## When `fetch_intermediate_certs` is set to `true`, this indicates how often to + ## refresh the intermediate certificate cache in minutes. + # + # intermediate_cert_refresh_interval: 60 + ## @param days_warning - number - optional - default: 14.0 ## Number of days before certificate expiration from which the service check ## `tls.cert_expiration` begins emitting WARNING. diff --git a/tls/datadog_checks/tls/tls.py b/tls/datadog_checks/tls/tls.py index 7748652bc0293b..0ce57245bde308 100644 --- a/tls/datadog_checks/tls/tls.py +++ b/tls/datadog_checks/tls/tls.py @@ -4,14 +4,18 @@ import socket import ssl from datetime import datetime +from hashlib import sha256 import service_identity from cryptography.hazmat.backends import default_backend from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate +from cryptography.x509.extensions import ExtensionNotFound +from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID from six import text_type from six.moves.urllib.parse import urlparse from datadog_checks.base import AgentCheck, ConfigurationError, is_affirmative +from datadog_checks.base.utils.time import get_timestamp from .utils import closing, days_to_seconds, get_protocol_versions, is_ip_address, seconds_to_days @@ -77,6 +81,15 @@ def __init__(self, name, init_config, instances): if not self._tls_validate_hostname or not self._validate_hostname: self._tls_validate_hostname = False + self._fetch_intermediate_certs = is_affirmative( + self.instance.get('fetch_intermediate_certs', self.init_config.get('fetch_intermediate_certs', False)) + ) + self._intermediate_cert_refresh_interval = ( + # Convert minutes to seconds + float(self.instance.get('intermediate_cert_refresh_interval', 60)) + * 60 + ) + # Thresholds expressed in seconds take precedence over those expressed in days self._seconds_warning = ( int(self.instance.get('seconds_warning', 0)) @@ -116,10 +129,19 @@ def __init__(self, name, init_config, instances): self._validation_data = None self._tls_context = None + # Only fetch intermediate certs from the indicated URIs occasionally + self._intermediate_cert_uri_cache = {} + + # Only load intermediate certs once + self._intermediate_cert_id_cache = set() + def check_remote(self, _): if not self._server: raise ConfigurationError('You must specify `server` in your configuration file.') + if self._fetch_intermediate_certs: + self.load_intermediate_certs() + try: self.log.debug('Checking that TLS service check can connect') sock = self.create_connection() @@ -315,6 +337,73 @@ def create_connection(self): raise + def load_intermediate_certs(self): + # https://tools.ietf.org/html/rfc3280#section-4.2.2.1 + # https://tools.ietf.org/html/rfc5280#section-5.2.7 + # + # TODO: prefer stdlib implementation when available, see https://bugs.python.org/issue18617 + try: + sock = self.create_connection() + except Exception as e: + self.log.error('Error occurred while connecting to socket to discover intermediate certificates: %s', e) + return + + with closing(sock): + try: + context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS) + context.verify_mode = ssl.CERT_NONE + + with closing(context.wrap_socket(sock, server_hostname=self._server_hostname)) as secure_sock: + der_cert = secure_sock.getpeercert(binary_form=True) + except Exception as e: + self.log.error('Error occurred while getting cert to discover intermediate certificates: %s', e) + return + + try: + cert = load_der_x509_certificate(der_cert, default_backend()) + except Exception as e: + self.log.error('Error while deserializing peer certificate to discover intermediate certificates: %s', e) + return + + try: + authority_information_access = cert.extensions.get_extension_for_oid( + ExtensionOID.AUTHORITY_INFORMATION_ACCESS + ) + except ExtensionNotFound: + self.log.debug( + 'No Authority Information Access extension found, skipping discovery of intermediate certificates' + ) + return + + for access_description in authority_information_access.value: + if access_description.access_method != AuthorityInformationAccessOID.CA_ISSUERS: + continue + + uri = access_description.access_location.value + if ( + uri in self._intermediate_cert_uri_cache + and get_timestamp() - self._intermediate_cert_uri_cache[uri] < self._intermediate_cert_refresh_interval + ): + continue + + # Assume HTTP for now + try: + response = self.http.get(uri) # SKIP_HTTP_VALIDATION + response.raise_for_status() + except Exception as e: + self.log.error('Error fetching intermediate certificate from `%s`: %s', uri, e) + continue + else: + access_time = get_timestamp() + + intermediate_cert = response.content + cert_id = sha256(intermediate_cert).digest() + if cert_id not in self._intermediate_cert_id_cache: + self.get_tls_context().load_verify_locations(cadata=intermediate_cert) + self._intermediate_cert_id_cache.add(cert_id) + + self._intermediate_cert_uri_cache[uri] = access_time + @property def validation_data(self): if self._validation_data is None: diff --git a/tls/tests/conftest.py b/tls/tests/conftest.py index 69e16a5c6de71c..473163b2579297 100644 --- a/tls/tests/conftest.py +++ b/tls/tests/conftest.py @@ -209,6 +209,11 @@ def instance_remote_cert_expired(): return {'server': 'https://expired.mock', 'tls_ca_cert': CA_CERT} +@pytest.fixture +def instance_remote_fetch_intermediate_certs(): + return {'server': 'incomplete-chain.badssl.com', 'fetch_intermediate_certs': True} + + @pytest.fixture def instance_remote_cert_critical_days(): return {'server': 'https://valid.mock', 'days_critical': 200, 'tls_ca_cert': CA_CERT} diff --git a/tls/tests/test_remote.py b/tls/tests/test_remote.py index 097d523b3ff2a5..5086ed516f00c7 100644 --- a/tls/tests/test_remote.py +++ b/tls/tests/test_remote.py @@ -224,6 +224,20 @@ def test_cert_expired(aggregator, instance_remote_cert_expired): aggregator.assert_all_metrics_covered() +def test_fetch_intermediate_certs(aggregator, instance_remote_fetch_intermediate_certs): + c = TLSCheck('tls', {}, [instance_remote_fetch_intermediate_certs]) + c.check(None) + + aggregator.assert_service_check(c.SERVICE_CHECK_CAN_CONNECT, status=c.OK, tags=c._tags, count=1) + aggregator.assert_service_check(c.SERVICE_CHECK_VERSION, status=c.OK, tags=c._tags, count=1) + aggregator.assert_service_check(c.SERVICE_CHECK_VALIDATION, status=c.OK, tags=c._tags, count=1) + aggregator.assert_service_check(c.SERVICE_CHECK_EXPIRATION, status=c.OK, tags=c._tags, count=1) + + aggregator.assert_metric('tls.days_left', count=1) + aggregator.assert_metric('tls.seconds_left', count=1) + aggregator.assert_all_metrics_covered() + + def test_cert_critical_days(aggregator, instance_remote_cert_critical_days): c = TLSCheck('tls', {}, [instance_remote_cert_critical_days]) c.check(None)