From 5081a443f6722bf56550636dbcf3128310f856d7 Mon Sep 17 00:00:00 2001 From: Steve Kemp Date: Wed, 18 Feb 2015 11:41:59 +0000 Subject: Added SSL checking magic, from Patrick. This is a clone of the code that we're already using for SSL checking of domains. The biggest excpetion is that I've disabled the SSL v2/v3 checking because that is causing alerts on https://google.com/ This closes #9563. --- lib/custodian/protocoltest/ssl.rb | 348 +++++++++++++++++++++++++++++++++++++- 1 file changed, 344 insertions(+), 4 deletions(-) (limited to 'lib/custodian') diff --git a/lib/custodian/protocoltest/ssl.rb b/lib/custodian/protocoltest/ssl.rb index 56e9c3f..a979fcb 100644 --- a/lib/custodian/protocoltest/ssl.rb +++ b/lib/custodian/protocoltest/ssl.rb @@ -1,5 +1,327 @@ require 'custodian/testfactory' +require 'openssl' +require 'socket' +require 'uri' +require 'timeout' + + + +# +# This is the class which tests the SSL certificate associated with a +# given URL. +# +class SSLCheck + + ALL_TESTS = [:signature, :valid_from, :valid_to, :subject, :sslv3_disabled] + + attr_reader :errors + + # This is a helper for console-debugging. + def verbose( msg ) + # nop + end + + # + # Takes one parameter -- the URL. + # + def initialize(uri) + raise ArgumentError, "URI must be a string" unless uri.is_a?(String) + @uri = URI.parse(uri) + + @domain = @uri.host + @key = nil + + @certificate = nil + @certificate_store = nil + + @tests = ALL_TESTS + + @errors = [] + end + + # + # Returns the URI + # + def uri + @uri + end + + alias :url :uri + + # + # Returns the domain. This is initially set to the "host" part of the URI. + # + def domain + @domain + end + + # + # Allows the domain to be set manually. + # + def domain=(d) + raise ArgumentError, "domain must be a String" unless d.is_a?(String) + @domain=d + end + + # + # Returns the tests to be carried out for this URI + # + def tests + @tests + end + + # + # Allows the tests to be set. Should an array of strings or symbols. Only + # ones from ALL_TESTS are taken. Anything else is ignored. + # + def tests=(ts) + raise ArgumentError, "tests must be an Array" unless ts.is_a?(Array) + @tests = ts.collect{|t| t.to_sym}.select{|t| ALL_TESTS.include?(t)} + + @tests + end + + # + # Returns the SSL key (if any) + # + def key + @key + end + + # + # Allows an SSL RSA key to be set. Used for self-signed cert verification. + # Probably not much use here. + # + def key=(k) + raise ArgumentError, "key must be a String" unless k.is_a?(String) + if k =~ /-----BEGIN/ + @key = OpenSSL::PKey::RSA.new(k) + else + @key = OpenSSL::PKey::RSA.new(File.read(k)) + end + end + + # + # This allows a bundle to be set. This is useful if a site is known to be + # serving a good cert+bundle, but for some reason openssl isn't validating it + # properly. + # + # This method is also used to include any peer_cert_chain from the SSL socket. + # + def bundle=(b) + if b.is_a?(String) + if b =~ /-----BEGIN CERT/ + self.certificate_store.add_cert(OpenSSL::X509::Certificate.new(b)) + else + self.certificate_store.add_file(b) + end + elsif b.is_a?(Array) + b.each do |c| + begin + self.certificate_store.add_cert(c) + rescue OpenSSL::X509::StoreError + # do nothing .. + end + end + elsif b.is_a?(OpenSSL::X509::Certificate) + self.certificate_store.add_cert(b) + else + raise ArgumentError, "bundle must be a String, an Array, or an OpenSSL::X509::Certificate" + end + b + end + + # + # This returns the certificate store used for validating certs. + # + def certificate_store + return @certificate_store if @certificate_store.is_a?(OpenSSL::X509::Store) + + @certificate_store = OpenSSL::X509::Store.new + @certificate_store.set_default_paths + @certificate_store.add_path("/etc/ssl/certs") + @certificate_store + end + + # + # This connects to a host, and fetches its certificate and bundle + # + def certificate + return @certificate if @certificate.is_a?(OpenSSL::X509::Certificate) + + s = nil + ctx = OpenSSL::SSL::SSLContext.new(:TLSv1_client) + retried = false + begin + Timeout::timeout(10) do + s = TCPSocket.open(uri.host, uri.port) + s = OpenSSL::SSL::SSLSocket.new(s, ctx) + s.sync_close = true + s.hostname = uri.host # SNI + s.connect + @certificate = s.peer_cert + self.bundle = s.peer_cert_chain + s.close + end + rescue OpenSSL::SSL::SSLError => err + unless retried + # retry with a different context + # + ctx = OpenSSL::SSL::SSLContext.new(:SSLv3_client) + retry + end + self.errors << verbose("*Caught #{err.class}* (#{err.to_s}) when connecting to #{uri.host}:#{uri.port}") + + rescue StandardError, Timeout::Error => err + self.errors << verbose("*Caught #{err.class}* (#{err.to_s}) when connecting to #{uri.host}:#{uri.port}") + ensure + s.close if s.respond_to?(:close) and !s.closed? + end + + return @certificate + end + + # + # This performs the verification tests. + # + def verify + if self.tests.empty? + verbose "All tests have been disabled for #{self.domain}" + return true + elsif self.certificate.nil? + self.errors << verbose("Failed to fetch certificate for #{self.domain}") + return nil + else + return ![ verify_subject, verify_valid_from, verify_valid_to, verify_signature].any?{|r| false == r} + end + end + + def verify_sslv3_disabled + unless self.tests.include?(:sslv3_disabled) + verbose "Skipping SSLv3 test for #{self.domain}" + return true + end + + s = nil + begin + Timeout::timeout(10) do + s = TCPSocket.open(uri.host, uri.port) + s = OpenSSL::SSL::SSLSocket.new(s, OpenSSL::SSL::SSLContext.new(:SSLv3_client)) + s.sync_close = true + s.connect + s.close + end + self.errors << verbose("*SSLv2 or SSLv3 enabled* on #{uri.host}:#{uri.port}") + return false + rescue OpenSSL::SSL::SSLError => err + # + # OK good :) + # + return true + rescue StandardError, Timeout::Error => err + self.errors << verbose("*Caught #{err.class}* (#{err.to_s}) when connecting to #{uri.host}:#{uri.port} using SSLv3") + ensure + s.close if s.respond_to?(:close) and !s.closed? + end + + return false + end + + def verify_subject + unless self.tests.include?(:subject) + verbose "Skipping subject verification for #{self.domain}" + return true + end + + # + # Firstly check that the certificate is valid for the domain or one of its aliases. + # + if OpenSSL::SSL.verify_certificate_identity(self.certificate, self.domain) + verbose "The certificate subject is valid for #{self.domain}" + return true + else + self.errors << verbose("The certificate subject is *not valid* for this domain #{self.domain}.") + return false + end + end + + def verify_valid_from + unless self.tests.include?(:valid_from) + verbose "Skipping certificate end date validation for #{self.domain}" + return true + end + + # + # Check that the certificate is current + # + if self.certificate.not_before < Time.now + verbose "The certificate for #{self.domain} is valid from #{self.certificate.not_before}." + return true + else + self.errors << verbose("The certificate for #{self.domain} *is not valid yet*.") + return false + end + end + + def verify_valid_to + unless self.tests.include?(:valid_to) + verbose "Skipping certificate start date validation for #{self.domain}" + return true + end + + days_until_expiry = (self.certificate.not_after.to_i - Time.now.to_i)/(24.0*3600).floor.to_i + + if days_until_expiry > 14 + verbose "The certificate for #{self.domain} is valid until #{self.certificate.not_after}." + return true + else + if days_until_expiry > 0 + self.errors << verbose("The certificate for #{self.domain} *will expire in #{days_until_expiry} days*.") + else + self.errors << verbose("The certificate for #{self.domain} *has expired*.") + end + return false + end + end + + def verify_signature + unless self.tests.include?(:signature) + verbose "Skipping certificate signature validation for #{self.domain}" + return true + end + + # + # Now check the signature. + # + # First see if we can verify it using our own private key, i.e. the + # certificate is self-signed. + # + if self.key.is_a?(OpenSSL::PKey) and self.certificate.verify(self.key) + verbose "Using a self-signed certificate for #{self.domain}." + return true + + # + # Otherwise see if we can verify it using the certificate store, + # including any bundle that has been uploaded. + # + elsif self.certificate_store.is_a?(OpenSSL::X509::Store) and self.certificate_store.verify(self.certificate) + verbose "Certificate signed by #{self.certificate.issuer.to_s}" + return true + + # + # If we can't verify -- raise an error. + # + else + self.errors << verbose("Certificate *signature does not verify* for #{self.domain} -- maybe a bundle is missing?") + return false + end + end + +end + + + # # The SSL-expiry test. @@ -68,12 +390,30 @@ module Custodian return true end - # - # NOP - validate here. + # Double-check we've got an SSL host # - puts( "NOP - Not running SSL-Verification of #{@host}" ) - return true + if ( ! @host =~ /^https:\/\// ) + puts( "Not an SSL URL" ) + return true + end + + s = SSLCheck.new(@host) + result = s.verify + + if true == result + puts( "SSL Verification succeeded for #{@host}" ) + return true + elsif result.nil? + puts( "SSL Verification returned no result (timeout?) #{@host}" ) + return true + else + puts( "SSL certificate check for #{@host} has failed." ) + @error = "SSL certificate check for #{@host} failed: "; + @error += s.errors.join("\n") + return false + end + end -- cgit v1.2.1