Skip to content

Commit

Permalink
Add use_cert_from_response option (#15785)
Browse files Browse the repository at this point in the history
  • Loading branch information
ofek authored Sep 8, 2023
1 parent 3567074 commit 24feb58
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 37 deletions.
4 changes: 4 additions & 0 deletions http_check/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

***Added***:

* Add `use_cert_from_response` option ([#15785](https://github.com/DataDog/integrations-core/pull/15785))

## 9.0.1 / 2023-08-18

***Fixed***:
Expand Down
11 changes: 11 additions & 0 deletions http_check/assets/configuration/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,22 @@ files:
value:
type: boolean
example: true
- name: use_cert_from_response
description: |
By default, the check makes a direct TCP connection to the server defined by the URL
when the `check_certificate_expiration` option is enabled. This connection happens
after the HTTP(S) request. When this option is enabled, the check instead uses the
certificate from the original response to check the expiration.
value:
type: boolean
example: true
- name: tls_retrieve_non_validated_cert
description: |
When set to true along with enabling `check_certificate_expiration`, this option allows certificates
to be retrieved from a peer whether or not `tls_verify` is set to true or false. This allows the
certificate to be examined for an expiration date in either case.
This option has no effect if `use_cert_from_response` is enabled.
value:
type: boolean
example: false
Expand Down
5 changes: 5 additions & 0 deletions http_check/datadog_checks/http_check/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
'instance_ca_certs',
'check_hostname',
'stream',
'use_cert_from_response',
],
)

Expand Down Expand Up @@ -68,6 +69,9 @@ def from_instance(instance, default_ca_certs=None):
instance_ca_certs = instance.get('tls_ca_cert', instance.get('ca_certs', default_ca_certs))
check_hostname = is_affirmative(instance.get('check_hostname', True))
stream = is_affirmative(instance.get('stream', False))
use_cert_from_response = is_affirmative(instance.get('use_cert_from_response', False))
if use_cert_from_response:
stream = True

return Config(
url,
Expand All @@ -86,4 +90,5 @@ def from_instance(instance, default_ca_certs=None):
instance_ca_certs,
check_hostname,
stream,
use_cert_from_response,
)
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,9 @@ def instance_tls_verify():
return False


def instance_use_cert_from_response():
return True


def instance_use_legacy_auth_encoding():
return True
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ class InstanceConfig(BaseModel):
tls_validate_hostname: Optional[bool] = None
tls_verify: Optional[bool] = None
url: str
use_cert_from_response: Optional[bool] = None
use_legacy_auth_encoding: Optional[bool] = None
username: Optional[str] = None

Expand Down
10 changes: 10 additions & 0 deletions http_check/datadog_checks/http_check/data/conf.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,20 @@ instances:
#
# check_certificate_expiration: true

## @param use_cert_from_response - boolean - optional - default: true
## By default, the check makes a direct TCP connection to the server defined by the URL
## when the `check_certificate_expiration` option is enabled. This connection happens
## after the HTTP(S) request. When this option is enabled, the check instead uses the
## certificate from the original response to check the expiration.
#
# use_cert_from_response: true

## @param tls_retrieve_non_validated_cert - boolean - optional - default: false
## When set to true along with enabling `check_certificate_expiration`, this option allows certificates
## to be retrieved from a peer whether or not `tls_verify` is set to true or false. This allows the
## certificate to be examined for an expiration date in either case.
##
## This option has no effect if `use_cert_from_response` is enabled.
#
# tls_retrieve_non_validated_cert: false

Expand Down
95 changes: 60 additions & 35 deletions http_check/datadog_checks/http_check/http_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ def __init__(self, name, init_config, instances):
headers.clear()
headers.update(self.instance.get("extra_headers", {}))

if is_affirmative(self.instance.get('use_cert_from_response', False)):
self.HTTP_CONFIG_REMAPPER['disable_ssl_validation']['default'] = False

def check(self, instance):
(
addr,
Expand All @@ -86,6 +89,7 @@ def check(self, instance):
instance_ca_certs,
check_hostname,
stream,
use_cert_from_response,
) = from_instance(instance, self.ca_certs)
timeout = self.http.options["timeout"][0]
start = time.time()
Expand All @@ -109,6 +113,7 @@ def send_status_down(loginfo, down_msg):
service_checks = []
service_checks_tags = self._get_service_checks_tags(instance)
r = None # type: Response
peer_cert = None # type: bytes | None
try:
parsed_uri = urlparse(addr)
self.log.debug("Connecting to %s", addr)
Expand Down Expand Up @@ -180,6 +185,9 @@ def send_status_down(loginfo, down_msg):
raise

else:
if use_cert_from_response:
peer_cert = r.raw.connection.sock.getpeercert(binary_form=True)

# Only add the URL tag if it's not already present
if not any(filter(re.compile("^url:").match, tags_list)):
tags_list.append("url:{}".format(addr))
Expand Down Expand Up @@ -257,7 +265,11 @@ def send_status_down(loginfo, down_msg):
self.gauge("network.http.cant_connect", cant_status, tags=tags_list)

if ssl_expire and parsed_uri.scheme == "https":
status, days_left, seconds_left, msg = self.check_cert_expiration(instance, timeout, instance_ca_certs)
if peer_cert is None:
status, days_left, seconds_left, msg = self.check_cert_expiration(instance, timeout, instance_ca_certs)
else:
status, days_left, seconds_left, msg = self._inspect_cert(peer_cert, instance)

tags_list = list(tags)
tags_list.append("url:{}".format(addr))
tags_list.append("instance:{}".format(instance_name))
Expand Down Expand Up @@ -302,6 +314,34 @@ def report_as_service_check(self, sc_name, status, tags, msg=None):
self.service_check(sc_name, status, tags=tags, message=msg)

def check_cert_expiration(self, instance, timeout, instance_ca_certs):
try:
peer_cert = self._fetch_cert(instance, timeout, instance_ca_certs)
except Exception as e:
msg = repr(e)
if any(word in msg for word in ['expired', 'expiration']):
self.log.debug('error: %s. Cert might be expired.', e)
return AgentCheck.CRITICAL, 0, 0, msg
else:
if 'Hostname mismatch' in msg or "doesn't match" in msg:
self.log.debug(
'The hostname on the SSL certificate does not match the given host: %s',
e,
)
else:
self.log.debug('Unable to connect to site to get cert expiration: %s', e)
return AgentCheck.UNKNOWN, None, None, msg

# To maintain backwards compatability, if we aren't validating tls/certs, do not process
# the returned binary cert unless specifically configured to with tls_retrieve_non_validated_cert
if (
not is_affirmative(instance.get('tls_verify', True))
and not is_affirmative(instance.get('tls_retrieve_non_validated_cert', False))
) or not peer_cert:
return AgentCheck.UNKNOWN, None, None, 'Empty or no certificate found.'
else:
return self._inspect_cert(peer_cert, instance)

def _inspect_cert(self, binary_cert, instance):
# thresholds expressed in seconds take precedence over those expressed in days
seconds_warning = (
int(instance.get("seconds_warning", 0))
Expand All @@ -313,46 +353,13 @@ def check_cert_expiration(self, instance, timeout, instance_ca_certs):
or int(instance.get("days_critical", 0)) * 24 * 3600
or DEFAULT_EXPIRE_CRITICAL
)
url = instance.get("url")

o = urlparse(url)
host = o.hostname
server_name = instance.get("ssl_server_name", o.hostname)
port = o.port or 443

try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(float(timeout))
sock.connect((host, port))

context = self.get_tls_context()
context.load_verify_locations(instance_ca_certs)

ssl_sock = context.wrap_socket(sock, server_hostname=server_name)
binary_cert = ssl_sock.getpeercert(binary_form=True)

# To maintain backwards compatability, if we aren't validating tls/certs, do not process
# the returned binary cert unless specifically configured to with tls_retrieve_non_validated_cert
if (
not is_affirmative(instance.get("tls_verify", True))
and not is_affirmative(instance.get("tls_retrieve_non_validated_cert", False))
) or not binary_cert:
raise Exception("Empty or no certificate found.")

cert = x509.load_der_x509_certificate(binary_cert)
exp_date = cert.not_valid_after
except Exception as e:
msg = repr(e)
if any(word in msg for word in ["expired", "expiration"]):
self.log.debug("error: %s. Cert might be expired.", e)
return AgentCheck.CRITICAL, 0, 0, msg
elif "Hostname mismatch" in msg or "doesn't match" in msg:
self.log.debug(
"The hostname on the SSL certificate does not match the given host: %s",
e,
)
else:
self.log.debug("Unable to connect to site to get cert expiration: %s", e)
self.log.debug('Unable to parse the certificate to get expiration: %s', e)
return AgentCheck.UNKNOWN, None, None, msg

time_left = exp_date - datetime.utcnow()
Expand Down Expand Up @@ -386,6 +393,24 @@ def check_cert_expiration(self, instance, timeout, instance_ca_certs):
"Days left: {}".format(days_left),
)

def _fetch_cert(self, instance, timeout, instance_ca_certs):
url = instance.get('url')

o = urlparse(url)
host = o.hostname
server_name = instance.get('ssl_server_name', o.hostname)
port = o.port or 443

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(float(timeout))
sock.connect((host, port))

context = self.get_tls_context()
context.load_verify_locations(instance_ca_certs)

ssl_sock = context.wrap_socket(sock, server_hostname=server_name)
return ssl_sock.getpeercert(binary_form=True)

@staticmethod
def _include_content(include_content, message, content):
if include_content:
Expand Down
29 changes: 28 additions & 1 deletion http_check/tests/test_http_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def test_cert_expiration_no_cert(http_check):

status, days_left, seconds_left, msg = http_check.check_cert_expiration(instance, 10, cert_path)
assert status == AgentCheck.UNKNOWN
expected_msg = 'Exception(\'Empty or no certificate found.\')'
expected_msg = 'Empty or no certificate found.'
if PY2:
expected_msg = (
'ValueError(\'empty or no certificate, match_hostname needs a SSL socket '
Expand Down Expand Up @@ -240,6 +240,33 @@ def test_check_ssl(aggregator, http_check):
aggregator.assert_service_check(HTTPCheck.SC_SSL_CERT, status=HTTPCheck.UNKNOWN, tags=connection_err_tags, count=1)


@pytest.mark.usefixtures('dd_environment')
def test_check_ssl_use_cert_from_response(aggregator, http_check):
# Run the check for all the instances in the config
for instance in CONFIG_SSL_ONLY['instances']:
instance = instance.copy()
instance['use_cert_from_response'] = True
http_check.check(instance)

good_cert_tags = ['url:https://valid.mock:443', 'instance:good_cert']
aggregator.assert_service_check(HTTPCheck.SC_STATUS, status=HTTPCheck.OK, tags=good_cert_tags, count=1)
aggregator.assert_service_check(HTTPCheck.SC_SSL_CERT, status=HTTPCheck.OK, tags=good_cert_tags, count=1)

expiring_soon_cert_tags = ['url:https://valid.mock', 'instance:cert_exp_soon']
aggregator.assert_service_check(HTTPCheck.SC_STATUS, status=HTTPCheck.OK, tags=expiring_soon_cert_tags, count=1)
aggregator.assert_service_check(
HTTPCheck.SC_SSL_CERT, status=HTTPCheck.WARNING, tags=expiring_soon_cert_tags, count=1
)

critical_cert_tags = ['url:https://valid.mock', 'instance:cert_critical']
aggregator.assert_service_check(HTTPCheck.SC_STATUS, status=HTTPCheck.OK, tags=critical_cert_tags, count=1)
aggregator.assert_service_check(HTTPCheck.SC_SSL_CERT, status=HTTPCheck.CRITICAL, tags=critical_cert_tags, count=1)

connection_err_tags = ['url:https://thereisnosuchlink.com', 'instance:conn_error']
aggregator.assert_service_check(HTTPCheck.SC_STATUS, status=HTTPCheck.CRITICAL, tags=connection_err_tags, count=1)
aggregator.assert_service_check(HTTPCheck.SC_SSL_CERT, status=HTTPCheck.UNKNOWN, tags=connection_err_tags, count=1)


@pytest.mark.usefixtures("dd_environment")
def test_check_tsl_ca_cert(aggregator, dd_run_check):
instance = {
Expand Down
3 changes: 2 additions & 1 deletion http_check/tests/test_unit_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_from_instance():

# defaults
config = from_instance({'url': 'https://example.com', 'name': 'UpService'})
assert len(config) == 16
assert len(config) == 17

# `url` is mandatory
assert config.url == 'https://example.com'
Expand All @@ -45,6 +45,7 @@ def test_from_instance():
assert config.instance_ca_certs != '' # `ca_certs`, it's mocked we don't care
assert config.check_hostname is True
assert config.stream is False
assert config.use_cert_from_response is False

# headers
config = from_instance(
Expand Down

0 comments on commit 24feb58

Please sign in to comment.