summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorSteve Kemp <steve@steve.org.uk>2015-02-18 11:41:59 +0000
committerSteve Kemp <steve@steve.org.uk>2015-02-18 11:41:59 +0000
commit5081a443f6722bf56550636dbcf3128310f856d7 (patch)
tree70672917ac20d9bdd098e8a57ca5926d3274dcf3 /lib
parentae6d7535e825807a559877c3fc301a17f031d9d3 (diff)
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.
Diffstat (limited to 'lib')
-rw-r--r--lib/custodian/protocoltest/ssl.rb348
1 files changed, 344 insertions, 4 deletions
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