sensu-plugins/sensu-plugins-ssl

View on GitHub
bin/check-ssl-host.rb

Summary

Maintainability
A
2 hrs
Test Coverage
#!/usr/bin/env ruby
# frozen_string_literal: false

#  check-ssl-host.rb
#
# DESCRIPTION:
#   SSL certificate checker
#   Connects to a HTTPS (or other SSL) server and performs several checks on
#   the certificate:
#     - Is the hostname valid for the host we're requesting
#     - If any certificate chain is presented, is it valid (i.e. is each
#       certificate signed by the next)
#     - Is the certificate about to expire
#   Currently no checks are performed to make sure the certificate is signed
#   by a trusted authority.
#
# DEPENDENCIES:
#   gem: sensu-plugin
#
# USAGE:
#   # Basic usage
#   check-ssl-host.rb -h <hostname>
#   # Specify specific days before cert expiry to alert on
#   check-ssl-host.rb -h <hostmame> -c <critical_days> -w <warning_days>
#   # Use -p to specify an alternate port
#   check-ssl-host.rb -h <hostname> -p 8443
#   # Use --skip-hostname-verification and/or --skip-chain-verification to
#   # disable some of the checks made.
#   check-ssl-host.rb -h <hostname> --skip-chain-verification
#
# LICENSE:
#   Copyright 2014 Chef Software, Inc.
#   Released under the same terms as Sensu (the MIT license); see LICENSE for
#   details.
#

require 'sensu-plugin/check/cli'
require 'date'
require 'openssl'
require 'socket'

#
# Check SSL Host
#
class CheckSSLHost < Sensu::Plugin::Check::CLI
  STARTTLS_PROTOS = %w[smtp imap].freeze

  check_name 'check_ssl_host'

  option :critical,
         description: 'Return critical this many days before cert expiry',
         short: '-c',
         long: '--critical DAYS',
         proc: proc(&:to_i),
         default: 7

  option :warning,
         description: 'Return warning this many days before cert expiry',
         short: '-w',
         long: '--warning DAYS',
         required: true,
         proc: proc(&:to_i),
         default: 14

  option :host,
         description: 'Hostname of the server certificate to check, by default used as the server address if none ' \
                      'is given',
         short: '-h',
         long: '--host HOST',
         required: true

  option :port,
         description: 'Port on server to check',
         short: '-p',
         long: '--port PORT',
         default: 443

  option :address,
         description: 'Address of server to check. This is used instead of the host argument for the TCP connection, ' \
                      'however the server hostname is still used for the TLS/SSL context.',
         short: '-a',
         long: '--address ADDRESS'

  option :client_cert,
         description: 'Path to the client certificate in DER/PEM format',
         long: '--client-cert CERT'

  option :client_key,
         description: 'Path to the client RSA key in DER/PEM format',
         long: '--client-key KEY'

  option :skip_hostname_verification,
         description: 'Disables hostname verification',
         long: '--skip-hostname-verification',
         boolean: true

  option :skip_chain_verification,
         description: 'Disables certificate chain verification',
         long: '--skip-chain-verification',
         boolean: true

  option :starttls,
         description: 'use STARTTLS negotiation for the given protocol '\
                      "(#{STARTTLS_PROTOS.join(', ')})",
         long: '--starttls PROTO'

  def get_cert_chain(host, port, address, client_cert, client_key)
    tcp_client = TCPSocket.new(address ? address : host, port) # rubocop:disable Style/RedundantCondition
    handle_starttls(config[:starttls], tcp_client) if config[:starttls]
    ssl_context = OpenSSL::SSL::SSLContext.new
    ssl_context.cert = OpenSSL::X509::Certificate.new File.read(client_cert) if client_cert
    ssl_context.key = OpenSSL::PKey::RSA.new File.read(client_key) if client_key
    ssl_client = OpenSSL::SSL::SSLSocket.new(tcp_client, ssl_context)

    # If the OpenSSL version in use supports Server Name Indication (SNI, RFC 3546), then we set the hostname we
    # received to request that certificate.
    ssl_client.hostname = host if ssl_client.respond_to? :hostname=

    ssl_client.connect
    certs = ssl_client.peer_cert_chain
    ssl_client.close
    certs
  end

  def handle_starttls(proto, socket)
    if STARTTLS_PROTOS.include?(proto) # rubocop:disable Style/GuardClause
      send("starttls_#{proto}", socket)
    else
      raise ArgumentError, "STARTTLS supported only for #{STARTTLS_PROTOS.join(', ')}"
    end
  end

  def starttls_smtp(socket)
    status = socket.readline
    unless /^220 / =~ status
      critical "#{config[:host]} - did not receive initial SMTP 220"
      # no fall-through
    end
    socket.puts 'STARTTLS'

    status = socket.readline
    return if /^220 / =~ status

    critical "#{config[:host]} - did not receive SMTP 220 in response to STARTTLS"
  end

  def starttls_imap(socket)
    status = socket.readline
    unless /^* OK / =~ status
      critical "#{config[:host]} - did not receive initial * OK"
    end
    socket.puts 'a001 STARTTLS'

    status = socket.readline
    return if /^a001 OK Begin TLS negotiation now/ =~ status

    critical "#{config[:host]} - did not receive OK Begin TLS negotiation now"
  end

  def verify_expiry(cert)
    # Expiry check
    days = (cert.not_after.to_date - Date.today).to_i
    message = "#{config[:host]} - #{days} days until expiry"
    critical "#{config[:host]} - Expired #{days} days ago" if days < 0 # rubocop:disable Style/NumericPredicate
    critical message if days < config[:critical]
    warning message if days < config[:warning]
    ok message
  end

  def verify_certificate_chain(certs)
    # Validates that a chain of certs are each signed by the next
    # NOTE: doesn't validate that the top of the chain is signed by a trusted
    # CA.
    valid = true
    parent = nil
    certs.reverse_each do |c|
      if parent
        valid &= c.verify(parent.public_key)
      end
      parent = c
    end
    critical "#{config[:host]} - Invalid certificate chain" unless valid
  end

  def verify_hostname(cert)
    unless OpenSSL::SSL.verify_certificate_identity(cert, config[:host]) # rubocop:disable Style/GuardClause
      critical "#{config[:host]} hostname mismatch (#{cert.subject})"
    end
  end

  def run
    chain = get_cert_chain(config[:host], config[:port], config[:address], config[:client_cert], config[:client_key])
    verify_hostname(chain[0]) unless config[:skip_hostname_verification]
    verify_certificate_chain(chain) unless config[:skip_chain_verification]
    verify_expiry(chain[0])
  rescue Errno::ECONNRESET => e
    critical "#{e.class} - #{e.message}"
  end
end