diff --git a/bin/check_https b/bin/check_https new file mode 100755 index 0000000..ecbc497 --- /dev/null +++ b/bin/check_https @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +$:.unshift 'lib' +require 'logging' +require 'cryptcheck' + +name = ARGV[0] || 'index' +file = ::File.join 'output', "#{name}.yml" + +if ::File.exist? file + ::CryptCheck::Tls::Https.analyze_from_file "output/#{name}.yml", "output/#{name}.html" +else + ::Logging.logger.root.appenders = ::Logging.appenders.stdout + ::Logging.logger.root.level = :warn + + server = ::CryptCheck::Tls::Https::Server.new(ARGV[0], ARGV[1] || 443) + p grade = ::CryptCheck::Tls::Https::Grade.new(server) +end + + diff --git a/bin/check_https_alexa b/bin/check_https_alexa new file mode 100755 index 0000000..94b5f90 --- /dev/null +++ b/bin/check_https_alexa @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +$:.unshift 'lib' +require 'logging' +require 'cryptcheck' + +::Logging.logger.root.appenders = ::Logging.appenders.stdout +::Logging.logger.root.level = :error + +hosts = [] +::File.open('top-1m.csv', 'r') do |file| + i = 0 + while line = file.gets + hosts << ['Top 100 Alexa', line.strip.split(',')[1]] + i += 1 + break if i == 100 + end +end + +::CryptCheck::Tls::Https.analyze hosts, 'output/alexa.html' diff --git a/lib/cryptcheck.rb b/lib/cryptcheck.rb new file mode 100644 index 0000000..8789784 --- /dev/null +++ b/lib/cryptcheck.rb @@ -0,0 +1,12 @@ +module CryptCheck + module Tls + autoload :Server, 'cryptcheck/tls/server' + autoload :Grade, 'cryptcheck/tls/grade' + autoload :Https, 'cryptcheck/tls/https' + + module Https + autoload :Server, 'cryptcheck/tls/https/server' + autoload :Grade, 'cryptcheck/tls/https/grade' + end + end +end diff --git a/lib/cryptcheck/tls/grade.rb b/lib/cryptcheck/tls/grade.rb new file mode 100644 index 0000000..fdedf25 --- /dev/null +++ b/lib/cryptcheck/tls/grade.rb @@ -0,0 +1,111 @@ +module CryptCheck + module Tls + class TlsNotSupportedGrade + attr_reader :server, :score, :grade + def initialize(server) + @server, @score, @grade = server, -1, 'X' + end + end + + class Grade + attr_reader :server, :score, :grade, :warning, :success + + def initialize(server) + @server = server + protocol_score + key_exchange_score + cipher_strengths_score + @score = @protocol_score*0.3 + @key_exchange_score*0.3 + @cipher_strengths_score*0.4 + calculate_grade + warning + success + perfect + end + + private + def calculate_grade + @grade = case @score + when 0...20 then 'F' + when 20...35 then 'E' + when 35...50 then 'D' + when 50...65 then 'C' + when 65...80 then 'B' + else 'A' + end + + @grade = [@grade, 'B'].max if !@server.tlsv1_2? or @server.key_size < 2048 + @grade = [@grade, 'C'].max if @server.des3? + @grade = [@grade, 'E'].max if @server.rc4? or @server.des? + @grade = [@grade, 'F'].max if @server.ssl? or @server.key_size < 1024 + + @grade = 'M' unless @server.cert_valid + @grade = 'T' unless @server.cert_trusted + end + + def warning + @warning = [] + + @warning << :md5_sig if @server.md5_sig? + @warning << :sha1_sig if @server.sha1_sig? + + @warning << :md5 if @server.md5? + #@warning << :sha1 if @server.sha1? + + @warning << :rc4 if @server.rc4? + @warning << :des if @server.des? + @warning << :des3 if @server.des3? + end + + def success + @success = [] + @success << :pfs if @server.pfs_only? + end + + ALL_WARNING = %i(md5_sig md5 rc4 des) + def all_warning + ALL_WARNING + end + ALL_SUCCESS = %i(pfs hsts hsts_long) + def all_success + ALL_SUCCESS + end + + def perfect + @grade = 'A+' if @grade == 'A' and (all_warning & @warning).empty? and (all_success & @success) == all_success + end + + METHODS_SCORES = { SSLv2: 0, SSLv3: 80, TLSv1: 90, TLSv1_1: 95, TLSv1_2: 100 } + def protocol_score + methods = @server.supported_methods + worst, best = methods[:worst], methods[:best] + @protocol_score = (METHODS_SCORES[worst] + METHODS_SCORES[best]) / 2 + end + + def key_exchange_score + @key_exchange_score = case @server.key_size + when 0 then 0 + when 0...512 then 20 + when 512...1024 then 40 + when 1024...2048 then 80 + when 2048...4096 then 90 + else 100 + end + end + + def cipher_strength_score(cipher_strength) + case cipher_strength + when 0 then 0 + when 0...128 then 20 + when 128...256 then 80 + else 100 + end + end + + def cipher_strengths_score + strength = @server.cipher_size + worst, best = strength[:min], strength[:max] + @cipher_strengths_score = (cipher_strength_score(worst) + cipher_strength_score(best)) / 2 + end + end + end +end diff --git a/lib/cryptcheck/tls/https.rb b/lib/cryptcheck/tls/https.rb new file mode 100644 index 0000000..dd42cf2 --- /dev/null +++ b/lib/cryptcheck/tls/https.rb @@ -0,0 +1,73 @@ +require 'erb' +require 'logging' +require 'parallel' + +module CryptCheck + module Tls + module Https + MAX_ANALYSIS_DURATION = 600 + PARALLEL_ANALYSIS = 10 + @@log = ::Logging.logger[Https] + + def self.grade(hostname, port=443) + timeout MAX_ANALYSIS_DURATION do + Grade.new Server.new hostname, port + end + rescue ::Exception => e + @@log.error { "Error during #{hostname}:#{port} analysis : #{e}" } + TlsNotSupportedGrade.new TlsNotSupportedServer.new hostname, port + end + + def self.analyze(hosts, output, groups = nil) + results = {} + semaphore = ::Mutex.new + ::Parallel.each hosts, progress: 'Analysing', in_threads: PARALLEL_ANALYSIS, + finish: lambda { |item, _, _| puts item[1] } do |description, host| + result = grade host.strip + semaphore.synchronize do + if results.include? description + results[description] << result + else + results[description] = [result] + end + end + end + + results = ::Hash[groups.collect { |g| [g, results[g]] }] if groups + + results.each do |d, _| + results[d].sort! do |a, b| + cmp = score(a) <=> score(b) + if cmp == 0 + cmp = b.score <=> a.score + if cmp == 0 + cmp = a.server.hostname <=> b.server.hostname + end + end + cmp + end + end + + ::File.write output, ::ERB.new(::File.read('output/https.erb')).result(binding) + end + + def self.analyze_from_file(file, output) + config = ::YAML.load_file file + hosts = [] + groups = [] + config.each do |c| + d, hs = c['description'], c['hostnames'] + groups << d + hs.each { |host| hosts << [d, host] } + end + self.analyze hosts, output, groups + end + + private + SCORES = %w(A+ A A- B C D E F T M X) + def self.score(a) + SCORES.index a.grade + end + end + end +end diff --git a/lib/cryptcheck/tls/https/grade.rb b/lib/cryptcheck/tls/https/grade.rb new file mode 100644 index 0000000..465db1a --- /dev/null +++ b/lib/cryptcheck/tls/https/grade.rb @@ -0,0 +1,18 @@ +module CryptCheck + module Tls + module Https + class Grade < Tls::Grade + private + def success + super + @success << :hsts if @server.hsts? + @success << :hsts_long if @server.hsts_long? + end + + def all_success + super + %i(hsts hsts_long) + end + end + end + end +end diff --git a/lib/cryptcheck/tls/https/server.rb b/lib/cryptcheck/tls/https/server.rb new file mode 100644 index 0000000..d1aaff7 --- /dev/null +++ b/lib/cryptcheck/tls/https/server.rb @@ -0,0 +1,54 @@ +require 'socket' +require 'openssl' +require 'httparty' + +module CryptCheck + module Tls + module Https + class Server < Tls::Server + attr_reader :hsts + + def initialize(hostname, port=443, methods: EXISTING_METHODS) + super + fetch_hsts + end + + def fetch_hsts + port = @port == 443 ? '' : ":#{@port}" + + response = nil + @methods.each do |method| + begin + next unless SUPPORTED_METHODS.include? method + @log.debug { "Check HSTS with #{method}" } + response = ::HTTParty.head "https://#{@hostname}#{port}/", { follow_redirects: false, verify: false, ssl_version: method, timeout: SSL_TIMEOUT } + break + rescue Exception => e + @log.debug { "#{method} not supported : #{e}" } + end + end + + if response and header = response.headers['strict-transport-security'] + name, value = header.split '=' + if name == 'max-age' + @hsts = value.to_i + @log.info { "HSTS : #{@hsts}" } + return + end + end + + @log.info { 'No HSTS' } + @hsts = nil + end + + def hsts? + !@hsts.nil? + end + + def hsts_long? + hsts? and @hsts >= 6*30*24*60*60 + end + end + end + end +end diff --git a/lib/cryptcheck/tls/server.rb b/lib/cryptcheck/tls/server.rb new file mode 100644 index 0000000..e53e05c --- /dev/null +++ b/lib/cryptcheck/tls/server.rb @@ -0,0 +1,309 @@ +require 'socket' +require 'openssl' +require 'httparty' + +module CryptCheck + module Tls + class TlsNotSupportedServer + attr_reader :hostname, :port + + def initialize(hostname, port) + @hostname, @port = hostname, port + end + end + + class Server + TCP_TIMEOUT = 10 + SSL_TIMEOUT = 2*TCP_TIMEOUT + EXISTING_METHODS = %i(TLSv1_2 TLSv1_1 TLSv1 SSLv3 SSLv2) + SUPPORTED_METHODS = ::OpenSSL::SSL::SSLContext::METHODS + class TLSException < ::Exception + end + class TLSNotAvailableException < TLSException + end + class CipherNotAvailable < TLSException + end + class Timeout < TLSException + end + class TLSTimeout < TLSException + end + class ConnectionError < TLSException + end + + attr_reader :hostname, :port, :prefered_ciphers, :cert, :cert_valid, :cert_trusted + + def initialize(hostname, port, methods: EXISTING_METHODS) + @log = Logging.logger[hostname] + @hostname = hostname + @port = port + @methods = methods + @log.error { "Begin analysis" } + extract_cert + fetch_prefered_ciphers + check_supported_cipher + fetch_hsts + @log.error { "End analysis" } + end + + def supported_methods + worst = EXISTING_METHODS.find { |method| !@prefered_ciphers[method].nil? } + best = EXISTING_METHODS.reverse.find { |method| !@prefered_ciphers[method].nil? } + { worst: worst, best: best } + end + + def key + key = @cert.public_key + case key + when ::OpenSSL::PKey::RSA then + [:rsa, key.n.num_bits] + when ::OpenSSL::PKey::DSA then + [:dsa, key.p.num_bits] + when ::OpenSSL::PKey::EC then + [:ecc, key.group.degree] + end + end + + def key_size + type, size = self.key + if type == :ecc + size = case size + when 160 then + 1024 + when 224 then + 2048 + when 256 then + 3072 + when 384 then + 7680 + when 521 then + 15360 + end + end + size + end + + def cipher_size + cipher_strengths = supported_ciphers.collect { |c| c[2] }.uniq.sort + worst, best = cipher_strengths.first, cipher_strengths.last + { worst: worst, best: best } + end + + EXISTING_METHODS.each do |method| + class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + def #{method.to_s.downcase}? + !prefered_ciphers[:#{method}].nil? + end + RUBY_EVAL + end + + { + md2: %w(md2WithRSAEncryption), + md5: %w(md5WithRSAEncryption md5WithRSA), + sha1: %w(sha1WithRSAEncryption sha1WithRSA dsaWithSHA1 dsaWithSHA1_2 ecdsa_with_SHA1) + }.each do |name, signature| + class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + def #{name}_sig? + #{signature}.include? @cert.signature_algorithm + end + RUBY_EVAL + end + + { + md5: %w(MD5), + sha1: %w(SHA), + + rc4: %w(RC4), + des3: %w(3DES DES-CBC3), + des: %w(DES-CBC) + }.each do |name, ciphers| + class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + def #{name}? + supported_ciphers.any? { |supported| #{ciphers}.any? { |available| /(^|-)#\{available\}(-|$)/ =~ supported[0] } } + end + RUBY_EVAL + end + + def ssl? + sslv2? or sslv3? + end + + def tls? + tlsv1? or tlsv1_1? or tlsv1_2? + end + + def tls_only? + tls? and !ssl? + end + + PFS_CIPHERS = [/^DHE-RSA-/, /^DHE-DSS-/, /^ECDHE-RSA-/, /^ECDHE-ECDSA-/] + + def pfs? + supported_ciphers.any? { |cipher| PFS_CIPHERS.any? { |pc| pc =~ cipher[0] } } + end + + def pfs_only? + supported_ciphers.all? { |cipher| PFS_CIPHERS.any? { |pc| pc =~ cipher[0] } } + end + + def supported_ciphers + @supported_ciphers.values.flatten(1).uniq + end + + def supported_ciphers_by_method + @supported_ciphers + end + + private + def connect(family, host, port, &block) + socket = ::Socket.new family, ::Socket::SOCK_STREAM + sockaddr = ::Socket.sockaddr_in port, host + @log.debug { "Connecting to #{host}:#{port}" } + begin + status = socket.connect_nonblock sockaddr + @log.debug { "Connecting to #{host}:#{port} status : #{status}" } + raise ConnectionError, status unless status == 0 + @log.debug { "Connected to #{host}:#{port}" } + block_given? ? block.call(socket) : nil + rescue ::IO::WaitReadable + @log.debug { "Waiting for read to #{host}:#{port}" } + raise Timeout unless IO.select [socket], nil, nil, TCP_TIMEOUT + retry + rescue ::IO::WaitWritable + @log.debug { "Waiting for write to #{host}:#{port}" } + raise Timeout unless IO.select nil, [socket], nil, TCP_TIMEOUT + retry + ensure + socket.close + end + end + + def ssl_connect(socket, context, method, &block) + ssl_socket = ::OpenSSL::SSL::SSLSocket.new socket, context + ssl_socket.hostname = @hostname unless method == :SSLv2 + @log.debug { "SSL connecting to #{@hostname}:#{@port}" } + begin + ssl_socket.connect_nonblock + @log.debug { "SSL connected to #{@hostname}:#{@port}" } + return block_given? ? block.call(ssl_socket) : nil + rescue ::IO::WaitReadable + @log.debug { "Waiting for SSL read to #{@hostname}:#{@port}" } + raise TLSTimeout unless IO.select [socket], nil, nil, SSL_TIMEOUT + retry + rescue ::IO::WaitWritable + @log.debug { "Waiting for SSL write to #{@hostname}:#{@port}" } + raise TLSTimeout unless IO.select nil, [socket], nil, SSL_TIMEOUT + retry + rescue ::OpenSSL::SSL::SSLError => e + raise TLSException, e + ensure + ssl_socket.close + end + end + + def ssl_client(method, ciphers = nil, &block) + ssl_context = ::OpenSSL::SSL::SSLContext.new method + ssl_context.ciphers = ciphers if ciphers + @log.debug { "Try #{method} connection with #{ciphers}" } + + [::Socket::AF_INET, ::Socket::AF_INET6].each do |family| + @log.debug { "Try connection for family #{family}" } + addrs = begin + ::Socket.getaddrinfo @hostname, nil, family, :STREAM + rescue ::SocketError => e + @log.debug { "Unable to resolv #{@hostname} : #{e}" } + next + end + + addrs.each do |addr| + connect family, addr[3], @port do |socket| + ssl_connect socket, ssl_context, method do |ssl_socket| + return block_given? ? block.call(ssl_socket) : nil + end + end + end + end + + @log.debug { "No SSL available on #{@hostname}" } + raise CipherNotAvailable + end + + def extract_cert + @methods.each do |method| + next unless SUPPORTED_METHODS.include? method + begin + @cert, @chain = ssl_client(method) { |s| [s.peer_cert, s.peer_cert_chain] } + @log.warn { "Certificate #{@cert.subject}" } + break + rescue TLSException => e + @log.info { "Method #{method} not supported : #{e}" } + end + end + raise TLSNotAvailableException unless @cert + @cert_valid = ::OpenSSL::SSL.verify_certificate_identity @cert, @hostname + @cert_trusted = verify_trust @chain, @cert + end + + def prefered_cipher(method) + cipher = ssl_client(method, %w(ALL:COMPLEMENTOFALL)) { |s| s.cipher } + @log.warn { "Prefered cipher for #{method} : #{cipher[0]}" } + cipher + rescue Exception => e + @log.info { "Method #{method} not supported : #{e}" } + nil + end + + def fetch_prefered_ciphers + @prefered_ciphers = {} + @methods.each do |method| + next unless SUPPORTED_METHODS.include? method + @prefered_ciphers[method] = prefered_cipher method + end + raise TLSNotAvailableException.new unless @prefered_ciphers.any? { |_, c| !c.nil? } + end + + def available_ciphers(method) + ::OpenSSL::SSL::SSLContext.new(method).ciphers + end + + def supported_cipher?(method, cipher) + ssl_client method, [cipher] + @log.warn { "Verify #{method} / #{cipher[0]} : OK" } + true + rescue TLSException => e + @log.info { "Verify #{method} / #{cipher[0]} : NOK (#{e})" } + false + end + + def check_supported_cipher + @supported_ciphers = {} + @methods.each do |method| + next unless SUPPORTED_METHODS.include? method and @prefered_ciphers[method] + @supported_ciphers[method] = available_ciphers(method).select { |cipher| supported_cipher? method, cipher } + end + end + + def verify_trust(chain, cert) + store = ::OpenSSL::X509::Store.new + store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT + %w(mozilla cacert).each do |directory| + ::Dir.glob(::File.join '/usr/share/ca-certificates', directory, '*').each do |file| + ::File.open file, 'r' do |file| + cert = ::OpenSSL::X509::Certificate.new file.read + begin + store.add_cert cert + rescue ::OpenSSL::X509::StoreError + end + end + end + end + chain.each do |cert| + begin + store.add_cert cert + rescue ::OpenSSL::X509::StoreError + end + end + store.verify cert + end + end + end +end diff --git a/lib/sslcheck.rb b/lib/sslcheck.rb deleted file mode 100644 index 1897251..0000000 --- a/lib/sslcheck.rb +++ /dev/null @@ -1,77 +0,0 @@ -require 'erb' -require 'logging' -require 'parallel' -require 'thread' -require 'yaml' - -module SSLCheck - module SSLLabs - autoload :API, 'sslcheck/ssllabs/api' - end - autoload :Server, 'sslcheck/server' - autoload :Grade, 'sslcheck/grade' - - PARALLEL_ANALYSIS = 20 - SYN_TIMEOUT = 600 - @@log = Logging.logger[SSLCheck] - - def self.grade(hostname, port=443) - timeout SYN_TIMEOUT do - Grade.new Server.new hostname, port - end - rescue Exception => e - @@log.error { "Error during #{hostname}:#{port} analysis : #{e}" } - NoSslTlsGrade.new NoSslTlsServer.new hostname, port - end - - def self.analyze(hosts, output, groups = nil) - results = {} - semaphore = Mutex.new - Parallel.each hosts, progress: 'Analysing', in_threads: PARALLEL_ANALYSIS, - finish: lambda { |item, _, _| puts item[1] } do |description, host| - result = SSLCheck.grade host.strip - semaphore.synchronize do - if results.include? description - results[description] << result - else - results[description] = [result] - end - end - end - - results = Hash[groups.collect { |g| [g, results[g]] }] if groups - - results.each do |d, _| - results[d].sort! do |a, b| - cmp = score(a) <=> score(b) - if cmp == 0 - cmp = a.score <=> b.score - if cmp == 0 - cmp = a.server.hostname <=> b.server.hostname - end - end - cmp - end - end - - File.write output, ERB.new(File.read('output/sslcheck.erb')).result(binding) - end - - def self.analyze_from_file(file, output) - config = YAML.load_file file - hosts = [] - groups = [] - config.each do |c| - d, hs = c['description'], c['hostnames'] - groups << d - hs.each { |host| hosts << [d, host] } - end - self.analyze hosts, output, groups - end - - private - SCORES = %w(A+ A A- B C D E F T M X) - def self.score(a) - SCORES.index a.grade - end -end diff --git a/lib/sslcheck/grade.rb b/lib/sslcheck/grade.rb deleted file mode 100644 index a0bd8bb..0000000 --- a/lib/sslcheck/grade.rb +++ /dev/null @@ -1,107 +0,0 @@ -require 'timeout' - -module SSLCheck - class NoSslTlsGrade - attr_reader :server, :score, :grade - - def initialize(server) - @server, @score, @grade = server, -1, 'X' - end - end - - class Grade - attr_reader :server, :score, :grade, :warning, :success - - def initialize(server) - @server = server - protocol_score - key_exchange_score - cipher_strengths_score - @score = @protocol_score*0.3 + @key_exchange_score*0.3 + @cipher_strengths_score*0.4 - calculate_grade - warning - success - perfect - end - - private - def calculate_grade - @grade = case @score - when 0...20 then 'F' - when 20...35 then 'E' - when 35...50 then 'D' - when 50...65 then 'C' - when 65...80 then 'B' - else 'A' - end - - @grade = [@grade, 'B'].max if !@server.tlsv1_2? or @server.key_size < 2048 - @grade = [@grade, 'C'].max if @server.des3? - @grade = [@grade, 'E'].max if @server.rc4? or @server.des? - @grade = [@grade, 'F'].max if @server.ssl? or @server.key_size < 1024 - - @grade = 'M' unless @server.cert_valid - @grade = 'T' unless @server.cert_trusted - end - - def warning - @warning = [] - - @warning << :md5_sig if @server.md5_sig? - @warning << :sha1_sig if @server.sha1_sig? - - @warning << :md5 if @server.md5? - #@warning << :sha1 if @server.sha1? - - @warning << :rc4 if @server.rc4? - @warning << :des if @server.des? - @warning << :des3 if @server.des3? - end - - def success - @success = [] - @success << :pfs if @server.pfs_only? - @success << :hsts if @server.hsts? - @success << :hsts_long if @server.hsts_long? - end - - ALL_WARNING = %i(md5_sig md5 rc4 des) - ALL_SUCCESS = %i(pfs hsts hsts_long) - def perfect - @grade = 'A+' if @grade == 'A' and (ALL_WARNING & @warning).empty? and (ALL_SUCCESS & @success) == ALL_SUCCESS - end - - METHODS_SCORES = { SSLv2: 0, SSLv3: 80, TLSv1: 90, TLSv1_1: 95, TLSv1_2: 100 } - def protocol_score - methods = @server.supported_methods - worst, best = methods[:worst], methods[:best] - @protocol_score = (METHODS_SCORES[worst] + METHODS_SCORES[best]) / 2 - end - - def key_exchange_score - @key_exchange_score = case @server.key_size - when 0 then 0 - when 0...512 then 20 - when 512...1024 then 40 - when 1024...2048 then 80 - when 2048...4096 then 90 - else 100 - end - end - - def cipher_strength_score(cipher_strength) - case cipher_strength - when 0 then 0 - when 0...128 then 20 - when 128...256 then 80 - else 100 - end - end - - def cipher_strengths_score - strength = @server.cipher_size - worst, best = strength[:min], strength[:max] - @cipher_strengths_score = (cipher_strength_score(worst) + cipher_strength_score(best)) / 2 - end - end -end diff --git a/lib/sslcheck/server.rb b/lib/sslcheck/server.rb deleted file mode 100644 index ac8d6a2..0000000 --- a/lib/sslcheck/server.rb +++ /dev/null @@ -1,334 +0,0 @@ -require 'socket' -require 'openssl' -require 'httparty' -require 'parallel' -require 'tcp_timeout' - -module SSLCheck - class NoSslTlsServer - attr_reader :hostname, :port - - def initialize(hostname, port=443) - @hostname, @port = hostname, port - end - end - - class Server - TCP_TIMEOUT = 10 - SSL_TIMEOUT = 2*TCP_TIMEOUT - EXISTING_METHODS = %i(TLSv1_2 TLSv1_1 TLSv1 SSLv3 SSLv2) - SUPPORTED_METHODS = OpenSSL::SSL::SSLContext::METHODS - class TLSException < Exception; end - class TLSNotAvailableException < TLSException; end - class CipherNotAvailable < TLSException; end - class Timeout < TLSException; end - class TLSTimeout < TLSException; end - class ConnectionError < TLSException; end - - attr_reader :hostname, :port, :prefered_ciphers, :cert, :hsts, :cert_valid, :cert_trusted - - def initialize(hostname, port=443, methods: EXISTING_METHODS) - @log = Logging.logger[hostname] - @hostname = hostname - @port = port - @methods = methods - @log.error { "Begin analysis" } - extract_cert - fetch_prefered_ciphers - check_supported_cipher - fetch_hsts - @log.error { "End analysis" } - end - - def supported_methods - worst = EXISTING_METHODS.find { |method| !@prefered_ciphers[method].nil? } - best = EXISTING_METHODS.reverse.find { |method| !@prefered_ciphers[method].nil? } - {worst: worst, best: best} - end - - def key - key = @cert.public_key - case key - when OpenSSL::PKey::RSA then - [:rsa, key.n.num_bits] - when OpenSSL::PKey::DSA then - [:dsa, key.p.num_bits] - when OpenSSL::PKey::EC then - [:ecc, key.group.degree] - end - end - - def key_size - type, size = self.key - if type == :ecc - size = case size - when 160 then 1024 - when 224 then 2048 - when 256 then 3072 - when 384 then 7680 - when 521 then 15360 - end - end - size - end - - def cipher_size - cipher_strengths = supported_ciphers.collect { |c| c[2] }.uniq.sort - worst, best = cipher_strengths.first, cipher_strengths.last - {worst: worst, best: best} - end - - EXISTING_METHODS.each do |method| - class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 - def #{method.to_s.downcase}? - !prefered_ciphers[:#{method}].nil? - end - RUBY_EVAL - end - - { - md2: %w(md2WithRSAEncryption), - md5: %w(md5WithRSAEncryption md5WithRSA), - sha1: %w(sha1WithRSAEncryption sha1WithRSA dsaWithSHA1 dsaWithSHA1_2 ecdsa_with_SHA1) - }.each do |name, signature| - class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 - def #{name}_sig? - #{signature}.include? @cert.signature_algorithm - end - RUBY_EVAL - end - - { - md5: %w(MD5), - sha1: %w(SHA), - - rc4: %w(RC4), - des3: %w(3DES DES-CBC3), - des: %w(DES-CBC) - }.each do |name, ciphers| - class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 - def #{name}? - supported_ciphers.any? { |supported| #{ciphers}.any? { |available| /(^|-)#\{available\}(-|$)/ =~ supported[0] } } - end - RUBY_EVAL - end - - def ssl? - sslv2? or sslv3? - end - - def tls? - tlsv1? or tlsv1_1? or tlsv1_2? - end - - def tls_only? - tls? and !ssl? - end - - PFS_CIPHERS = [/^DHE-RSA-/, /^DHE-DSS-/, /^ECDHE-RSA-/, /^ECDHE-ECDSA-/] - - def pfs? - supported_ciphers.any? { |cipher| PFS_CIPHERS.any? { |pc| pc =~ cipher[0] } } - end - - def pfs_only? - supported_ciphers.all? { |cipher| PFS_CIPHERS.any? { |pc| pc =~ cipher[0] } } - end - - def supported_ciphers - @supported_ciphers.values.flatten(1).uniq - end - - def supported_ciphers_by_method - @supported_ciphers - end - - def hsts? - !@hsts.nil? - end - - def hsts_long? - hsts? and @hsts >= 6*30*24*60*60 - end - - private - def connect(family, host, port, &block) - socket = Socket.new family, Socket::SOCK_STREAM - sockaddr = Socket.sockaddr_in port, host - @log.debug { "Connecting to #{host}:#{port}" } - begin - status = socket.connect_nonblock sockaddr - @log.debug { "Connecting to #{host}:#{port} status : #{status}" } - raise ConnectionError.new status unless status == 0 - @log.debug { "Connected to #{host}:#{port}" } - block_given? ? block.call(socket) : nil - rescue IO::WaitReadable - @log.debug { "Waiting for read to #{host}:#{port}" } - raise Timeout.new unless IO.select [socket], nil, nil, TCP_TIMEOUT - retry - rescue IO::WaitWritable - @log.debug { "Waiting for write to #{host}:#{port}" } - raise Timeout.new unless IO.select nil, [socket], nil, TCP_TIMEOUT - retry - ensure - socket.close - end - end - - def ssl_connect(socket, context, method, &block) - ssl_socket = OpenSSL::SSL::SSLSocket.new socket, context - ssl_socket.hostname = @hostname unless method == :SSLv2 - @log.debug { "SSL connecting to #{@hostname}:#{@port}" } - begin - ssl_socket.connect_nonblock - @log.debug { "SSL connected to #{@hostname}:#{@port}" } - return block_given? ? block.call(ssl_socket) : nil - rescue IO::WaitReadable - @log.debug { "Waiting for SSL read to #{@hostname}:#{@port}" } - raise TLSTimeout.new unless IO.select [socket], nil, nil, SSL_TIMEOUT - retry - rescue IO::WaitWritable - @log.debug { "Waiting for SSL write to #{@hostname}:#{@port}" } - raise TLSTimeout.new unless IO.select nil, [socket], nil, SSL_TIMEOUT - retry - rescue OpenSSL::SSL::SSLError => e - raise TLSException, e - ensure - ssl_socket.close - end - end - - def ssl_client(method, ciphers = nil, &block) - ssl_context = OpenSSL::SSL::SSLContext.new method - ssl_context.ciphers = ciphers if ciphers - @log.debug { "Try #{method} connection with #{ciphers}" } - - [Socket::AF_INET, Socket::AF_INET6].each do |family| - @log.debug { "Try connection for family #{family}" } - addrs = begin - Socket.getaddrinfo @hostname, nil, family, :STREAM - rescue SocketError => e - @log.debug { "Unable to resolv #{@hostname} : #{e}" } - next - end - - addrs.each do |addr| - connect family, addr[3], @port do |socket| - ssl_connect socket, ssl_context, method do |ssl_socket| - return block_given? ? block.call(ssl_socket) : nil - end - end - end - end - - @log.debug { "No SSL available on #{@hostname}" } - raise CipherNotAvailable.new - end - - def extract_cert - @methods.each do |method| - next unless SUPPORTED_METHODS.include? method - begin - @cert, @chain = ssl_client(method) { |s| [s.peer_cert, s.peer_cert_chain] } - @log.warn { "Certificate #{@cert.subject}" } - break - rescue TLSException => e - @log.info { "Method #{method} not supported : #{e}" } - end - end - raise TLSNotAvailableException.new unless @cert - @cert_valid = OpenSSL::SSL.verify_certificate_identity @cert, @hostname - @cert_trusted = verify_trust @chain, @cert - end - - def prefered_cipher(method) - cipher = ssl_client(method, %w(ALL:COMPLEMENTOFALL)) { |s| s.cipher } - @log.warn { "Prefered cipher for #{method} : #{cipher[0]}" } - cipher - rescue Exception => e - @log.info { "Method #{method} not supported : #{e}" } - nil - end - - def fetch_prefered_ciphers - @prefered_ciphers = {} - @methods.each do |method| - next unless SUPPORTED_METHODS.include? method - @prefered_ciphers[method] = prefered_cipher method - end - raise TLSNotAvailableException.new unless @prefered_ciphers.any? { |_, c| !c.nil? } - end - - def available_ciphers(method) - OpenSSL::SSL::SSLContext.new(method).ciphers - end - - def supported_cipher?(method, cipher) - ssl_client method, [cipher] - @log.warn { "Verify #{method} / #{cipher[0]} : OK" } - true - rescue TLSException => e - @log.info { "Verify #{method} / #{cipher[0]} : NOK (#{e})" } - false - end - - def check_supported_cipher - @supported_ciphers = {} - @methods.each do |method| - next unless SUPPORTED_METHODS.include? method and @prefered_ciphers[method] - @supported_ciphers[method] = available_ciphers(method).select { |cipher| supported_cipher? method, cipher } - end - end - - def fetch_hsts - port = @port == 443 ? '' : ":#{@port}" - - response = nil - @methods.each do |method| - begin - next unless SUPPORTED_METHODS.include? method - @log.debug { "Check HSTS with #{method}" } - response = HTTParty.head "https://#{@hostname}#{port}/", {follow_redirects: false, verify: false, ssl_version: method, timeout: SSL_TIMEOUT} - break - rescue Exception => e - @log.debug { "#{method} not supported : #{e}" } - end - end - - if response and header = response.headers['strict-transport-security'] - name, value = header.split '=' - if name == 'max-age' - @hsts = value.to_i - @log.info { "HSTS : #{@hsts}" } - return - end - end - - @log.info { 'No HSTS' } - @hsts = nil - end - - def verify_trust(chain, cert) - store = OpenSSL::X509::Store.new - #store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT - %w(mozilla cacert).each do |directory| - Dir.glob(File.join '/usr/share/ca-certificates', directory, '*').each do |file| - File.open file, 'r' do |file| - cert = OpenSSL::X509::Certificate.new file.read - begin - store.add_cert cert - rescue OpenSSL::X509::StoreError - end - end - end - end - chain.each do |cert| - begin - store.add_cert cert - rescue OpenSSL::X509::StoreError - end - end - store.verify cert - end - end -end diff --git a/lib/sslcheck/ssllabs/api.rb b/lib/sslcheck/ssllabs/api.rb deleted file mode 100644 index 1d5957d..0000000 --- a/lib/sslcheck/ssllabs/api.rb +++ /dev/null @@ -1,148 +0,0 @@ -require 'httparty' -require 'nokogiri' - -module SSLCheck - module SSLLabs - class Error < StandardError; end - class WaitingError < Error; end - class ServerError < Error; end - class NoEncryptionError < Error; end - - class API - include HTTParty - #debug_output $stdout - base_uri 'https://www.ssllabs.com/ssltest' - - attr_reader :hostname, :ip, :rank, :ssl, :tls, :bits, :rc4, :pfs, :hsts - - def initialize(hostname, debug: false) - @debug = debug - @hostname = hostname - @ip = nil - html = content hostname - @rank = html.css('#rating div')[1].text.strip - parse_configuration html - end - - private - def content(hostname, ip=nil) - #puts "host: #{hostname}, ip: #{ip}" - options = {query: {d: hostname}} - options[:query][:s] = ip unless ip.nil? - - html = nil - loop do - response = self.class.get '/analyze.html', options - raise ServerError, response.code unless response.success? - html = Nokogiri::HTML response.body - File.write File.join('html', hostname), html if @debug - break if not resolving_domain? html - end - waiting? html - - html = resolve_multiple_servers html - encrypted? html - - @hostname = html.at_css('.url').content.strip - ip = html.at_css '.ip' - unless ip.nil? - @ip = ip.content.strip.gsub /[()]/, '' - else - @ip = '' - end - html - end - - def waiting?(html) - warning = html.at_css '#warningBox' - raise WaitingError if not warning.nil? and warning.content.include? 'Please wait...' - end - - def encrypted?(html) - warning = html.at_css '#warningBox' - raise NoEncryptionError if not warning.nil? and \ - warning.content.include? 'Assessment failed: Unable to connect to server' - end - - def resolving_domain?(html) - warning = html.at_css('#warningBox') - not warning.nil? and warning.content.strip == 'Please wait... (Resolving domain names)' - end - - def resolve_multiple_servers(html) - servers = html.at_css('#multiTable') - return html if servers.nil? - servers.css('tr').each do |server| - td = server.css('td')[4] - next if td.nil? - rank = td.content.strip - unless rank == '-' - ip = server.at_css('.ip').content - html = content hostname, ip - waiting? html - return html - end - end - raise NoEncryptionError - end - - def parse_configuration(html) - configuration = html.css('.reportSection')[2] - parse_protocols configuration - parse_handshakes configuration - parse_details configuration - end - - def parse_protocols(configuration) - protocols = configuration.css('.reportTable')[0].css('tr.tableRow') - @tls = true - @ssl = false - protocols.each do |row| - cells = row.css 'td' - next unless cells.size >= 2 - name = cells[0].content.strip - value = cells[1].content.strip - case name - when /^TLS 1.2/ then - @tls = value == 'Yes' - when /^SSL 2/ then - @ssl |= value != 'No' - when /^SSL 3/ then - @ssl |= value != 'No' - end - end - end - - def parse_handshakes(configuration) - @bits = nil - handshakes = configuration.css('.reportTable')[2].css('td.tableRight') - handshakes.each do |cell| - value = cell.content.strip - begin - i = Integer value - @bits = @bits.nil? ? i : [@bits, i].min - rescue - end - end - end - - def parse_details(configuration) - @rc4 = @pfs = @hsts = nil - details = configuration.css('.reportTable')[3].css('tr.tableRow') - details.each do |row| - cells = row.css 'td' - name = cells[0].content.strip - value = cells[1].content.strip - case name - when 'RC4' then - @rc4 = value != 'No' - when 'Forward Secrecy' then - @pfs = value == 'Yes (with most browsers) ROBUST (more info)' - when 'Strict Transport Security (HSTS)' then - @hsts = value.start_with? 'Yes' - end - end - end - end - end -end diff --git a/output/sslcheck.erb b/output/https.erb similarity index 98% rename from output/sslcheck.erb rename to output/https.erb index 5e9f1ce..27b7957 100644 --- a/output/sslcheck.erb +++ b/output/https.erb @@ -84,7 +84,7 @@ <%= s.hostname %> - <% if s.is_a? SSLCheck::NoSslTlsServer %> + <% if s.is_a? SSLCheck::TlsNotSupportedServer %>
- | |||||||
---|---|---|---|---|---|---|---|
<%= r[0] %> | -|||||||
Site (IP) | -Rang | -Sécurité (bits) | -SSL 2/3 (obsolète) | -TLS 1.2 (actuel) | -RC4 | -PFS | -HSTS | -
- - <%= s.hostname %> (<%= s.ip %>) - - | -- <%= s.rank %> - | -- <%= s.bits %> - (<%= s.bits < 128 ? '☹' : '☺' %>) - | -- <%= s.ssl ? '✓' : '✗' %> - (<%= s.ssl ? '☹' : '☺' %>) - | -- <%= s.tls ? '✓' : '✗' %> - (<%= s.tls ? '☺' : '☹' %>) - | -- <%= s.rc4 ? '✓' : '✗' %> - (<%= s.rc4 ? '☹' : '☺' %>) - | -- <%= s.pfs ? '✓' : '✗' %> - (<%= s.pfs ? '☺' : '☹' %>) - | -- <%= s.hsts ? '✓' : '✗' %> - (<%= s.hsts ? '☺' : '☹' %>) - | -
Site (IP) | -Rang | -Sécurité (bits) | -SSL 2/3 (obsolète) | -TLS 1.2 (actuel) | -RC4 | -PFS | -HSTS | -