Skip to content

Commit

Permalink
Implement AIA chasing
Browse files Browse the repository at this point in the history
  • Loading branch information
ofek committed Feb 3, 2021
1 parent 022e6fb commit 59f636d
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 0 deletions.
21 changes: 21 additions & 0 deletions tls/assets/configuration/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions tls/datadog_checks/tls/data/conf.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:<SERVICE>` to every metric, event, and service check emitted by this integration.
##
Expand Down Expand Up @@ -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.
Expand Down
89 changes: 89 additions & 0 deletions tls/datadog_checks/tls/tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions tls/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
14 changes: 14 additions & 0 deletions tls/tests/test_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 59f636d

Please sign in to comment.