##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'active_support/inflector'
require 'json'
require 'active_support/core_ext/hash'

class MetasploitModule < Msf::Auxiliary
  class InvocationError < StandardError; end
  class RequestRateTooHigh < StandardError; end
  class InternalError < StandardError; end
  class ServiceNotAvailable < StandardError; end
  class ServiceOverloaded < StandardError; end

  class Api
    attr_reader :max_assessments, :current_assessments

    def initialize
      @max_assessments = 0
      @current_assessments = 0
    end

    def request(name, params = {})
      api_host = "api.ssllabs.com"
      api_port = "443"
      api_path = "/api/v2/"
      user_agent = "Msf_ssllabs_scan"

      name = name.to_s.camelize(:lower)
      uri = api_path + name
      cli = Rex::Proto::Http::Client.new(api_host, api_port, {}, true, 'TLS')
      cli.connect
      req = cli.request_cgi({
          'uri' => uri,
          'agent' => user_agent,
          'method' => 'GET',
          'vars_get' => params
      })
      res = cli.send_recv(req)
      cli.close

      if res && res.code.to_i == 200
        @max_assessments = res.headers['X-Max-Assessments']
        @current_assessments = res.headers['X-Current-Assessments']
        r = JSON.load(res.body)
        fail InvocationError, "API returned: #{r['errors']}" if r.key?('errors')
        return r
      end

      case res.code.to_i
      when 400
        fail InvocationError
      when 429
        fail RequestRateTooHigh
      when 500
        fail InternalError
      when 503
        fail ServiceNotAvailable
      when 529
        fail ServiceOverloaded
      else
        fail StandardError, "HTTP error code #{r.code}", caller
      end
    end

    def info
      Info.load request(:info)
    end

    def analyse(params = {})
      Host.load request(:analyze, params)
    end

    def get_endpoint_data(params = {})
      Endpoint.load request(:get_endpoint_data, params)
    end

    def get_status_codes
      StatusCodes.load request(:get_status_codes)
    end
  end

  class ApiObject

    class << self;
      attr_accessor :all_attributes
      attr_accessor :fields
      attr_accessor :lists
      attr_accessor :refs
    end

    def self.inherited(base)
      base.all_attributes = []
      base.fields = []
      base.lists = {}
      base.refs = {}
    end

    def self.to_api_name(name)
      name.to_s.gsub(/\?$/, '').camelize(:lower)
    end

    def self.to_attr_name(name)
      name.to_s.gsub(/\?$/, '').underscore
    end

    def self.field_methods(name)
      is_bool = name.to_s.end_with?('?')
      attr_name = to_attr_name(name)
      api_name = to_api_name(name)
      class_eval <<-EOF, __FILE__, __LINE__
        def #{attr_name}#{'?' if is_bool}
          @#{api_name}
        end
        def #{attr_name}=(value)
          @#{api_name} = value
        end
      EOF
    end

    def self.has_fields(*names)
      names.each do |name|
        @all_attributes << to_api_name(name)
        @fields << to_api_name(name)
        field_methods(name)
      end
    end

    def self.has_objects_list(name, klass)
      @all_attributes << to_api_name(name)
      @lists[to_api_name(name)] = klass
      field_methods(name)
    end

    def self.has_object_ref(name, klass)
      @all_attributes << to_api_name(name)
      @refs[to_api_name(name)] = klass
      field_methods(name)
    end

    def self.load(attributes = {})
      obj = self.new
      attributes.each do |name, value|
        if @fields.include?(name)
          obj.instance_variable_set("@#{name}", value)
        elsif @lists.key?(name)
          obj.instance_variable_set("@#{name}", value.map { |v| @lists[name].load(v) }) unless value.nil?
        elsif @refs.key?(name)
          obj.instance_variable_set("@#{name}", @refs[name].load(value)) unless value.nil?
        else
          fail ArgumentError, "#{name} is not an attribute of object #{self.name}"
        end
      end
      obj
    end

    def to_json(opts = {})
      obj = {}
      self.class.all_attributes.each do |api_name|
        v = instance_variable_get("@#{api_name}")
        obj[api_name] = v
      end
      obj.to_json
    end
  end

  class Cert < ApiObject
    has_fields :subject,
               :commonNames,
               :altNames,
               :notBefore,
               :notAfter,
               :issuerSubject,
               :sigAlg,
               :issuerLabel,
               :revocationInfo,
               :crlURIs,
               :ocspURIs,
               :revocationStatus,
               :crlRevocationStatus,
               :ocspRevocationStatus,
               :sgc?,
               :validationType,
               :issues,
               :sct?

    def valid?
      issues == 0
    end

    def invalid?
      !valid?
    end
  end

  class ChainCert < ApiObject
    has_fields :subject,
               :label,
               :notBefore,
               :notAfter,
               :issuerSubject,
               :issuerLabel,
               :sigAlg,
               :issues,
               :keyAlg,
               :keySize,
               :keyStrength,
               :revocationStatus,
               :crlRevocationStatus,
               :ocspRevocationStatus,
               :raw

    def valid?
      issues == 0
    end

    def invalid?
      !valid?
    end
  end

  class Chain < ApiObject
    has_objects_list :certs, ChainCert
    has_fields :issues

    def valid?
      issues == 0
    end

    def invalid?
      !valid?
    end
  end

  class Key < ApiObject
    has_fields :size,
               :strength,
               :alg,
               :debianFlaw?,
               :q

    def insecure?
      debian_flaw? || q == 0
    end

    def secure?
      !insecure?
    end
  end

  class Protocol < ApiObject
    has_fields :id,
               :name,
               :version,
               :v2SuitesDisabled?,
               :q

    def insecure?
      q == 0
    end

    def secure?
      !insecure?
    end

  end

  class Info < ApiObject
    has_fields :engineVersion,
               :criteriaVersion,
               :clientMaxAssessments,
               :maxAssessments,
               :currentAssessments,
               :messages
  end

  class SimClient < ApiObject
    has_fields :id,
               :name,
               :platform,
               :version,
               :isReference?
  end

  class Simulation < ApiObject
    has_object_ref :client, SimClient
    has_fields :errorCode,
               :attempts,
               :protocolId,
               :suiteId

    def success?
      error_code == 0
    end

    def error?
      !success?
    end
  end

  class SimDetails < ApiObject
    has_objects_list :results, Simulation
  end

  class StatusCodes < ApiObject
    has_fields :statusDetails

    def [](name)
      status_details[name]
    end
  end

  class Suite < ApiObject
    has_fields :id,
               :name,
               :cipherStrength,
               :dhStrength,
               :dhP,
               :dhG,
               :dhYs,
               :ecdhBits,
               :ecdhStrength,
               :q

    def insecure?
      q == 0
    end

    def secure?
      !insecure?
    end
  end

  class Suites < ApiObject
    has_objects_list :list, Suite
    has_fields :preference?
  end

  class EndpointDetails < ApiObject
    has_fields :hostStartTime
    has_object_ref :key, Key
    has_object_ref :cert, Cert
    has_object_ref :chain, Chain
    has_objects_list :protocols, Protocol
    has_object_ref :suites, Suites
    has_fields :serverSignature,
               :prefixDelegation?,
               :nonPrefixDelegation?,
               :vulnBeast?,
               :renegSupport,
               :stsResponseHeader,
               :stsMaxAge,
               :stsSubdomains?,
               :pkpResponseHeader,
               :sessionResumption,
               :compressionMethods,
               :supportsNpn?,
               :npnProtocols,
               :sessionTickets,
               :ocspStapling?,
               :staplingRevocationStatus,
               :staplingRevocationErrorMessage,
               :sniRequired?,
               :httpStatusCode,
               :httpForwarding,
               :supportsRc4?,
               :forwardSecrecy,
               :rc4WithModern?
    has_object_ref :sims, SimDetails
    has_fields :heartbleed?,
               :heartbeat?,
               :openSslCcs,
               :poodle?,
               :poodleTls,
               :fallbackScsv?,
               :freak?,
               :hasSct
  end

  class Endpoint < ApiObject
    has_fields :ipAddress,
               :serverName,
               :statusMessage,
               :statusDetails,
               :statusDetailsMessage,
               :grade,
               :gradeTrustIgnored,
               :hasWarnings?,
               :isExceptional?,
               :progress,
               :duration,
               :eta,
               :delegation
    has_object_ref :details, EndpointDetails
  end

  class Host < ApiObject
    has_fields :host,
               :port,
               :protocol,
               :isPublic?,
               :status,
               :statusMessage,
               :startTime,
               :testTime,
               :engineVersion,
               :criteriaVersion,
               :cacheExpiryTime
    has_objects_list :endpoints, Endpoint
    has_fields :certHostnames
  end

  def initialize(info = {})
    super(update_info(info,
        'Name'          => 'SSL Labs API Client',
        'Description'   => %q{
          This module is a simple client for the SSL Labs APIs, designed for
          SSL/TLS assessment during a penetration test.
        },
        'License'       => MSF_LICENSE,
        'Author'        =>
          [
            'Denis Kolegov <dnkolegov[at]gmail.com>',
            'Francois Chagnon' # ssllab.rb author (https://github.com/Shopify/ssllabs.rb)
           ],
        'DefaultOptions' =>
          {
            'RPORT'      => 443,
            'SSL'        => true,
          }
    ))
    register_options(
      [
        OptString.new('HOSTNAME', [true, 'The target hostname']),
        OptInt.new('DELAY', [true, 'The delay in seconds between  API requests', 5]),
        OptBool.new('USECACHE', [true, 'Use cached results (if available), else force live scan', true]),
        OptBool.new('GRADE', [true, 'Output only the hostname: grade', false]),
        OptBool.new('IGNOREMISMATCH', [true, 'Proceed with assessments even when the server certificate doesn\'t match the assessment hostname', true])
      ])
  end

  def report_good(line)
    print_good line
  end

  def report_warning(line)
    print_warning line
  end

  def report_bad(line)
    print_warning line
  end

  def report_status(line)
    print_status line
  end

  def output_endpoint_data(r)
    ssl_protocols = [
      { id: 771, name: "TLS", version: "1.2", secure: true, active: false },
      { id: 770, name: "TLS", version: "1.1", secure: true, active: false },
      { id: 769, name: "TLS", version: "1.0", secure: true, active: false },
      { id: 768, name: "SSL", version: "3.0", secure: false, active: false },
      { id: 2, name: "SSL", version: "2.0", secure: false, active: false }
    ]

    report_status "-----------------------------------------------------------------"
    report_status "Report for #{r.server_name} (#{r.ip_address})"
    report_status "-----------------------------------------------------------------"

    case r.grade.to_s
    when "A+", "A", "A-"
      report_good "Overall rating: #{r.grade}"
    when "B"
      report_warning "Overall rating: #{r.grade}"
    when "C", "D", "E", "F"
      report_bad "Overall rating: #{r.grade}"
    when "M"
      report_bad "Overall rating: #{r.grade} - Certificate name mismatch"
    when "T"
      report_bad "Overall rating: #{r.grade} - Server's certificate is not trusted"
    end

    report_warning "Grade is #{r.grade_trust_ignored}, if trust issues are ignored)" if r.grade.to_s != r.grade_trust_ignored.to_s

    # Supported protocols
    r.details.protocols.each do |i|
      p = ssl_protocols.detect { |x| x[:id] == i.id }
      p.store(:active, true) if p
    end

    ssl_protocols.each do |proto|
      if proto[:active]
        if proto[:secure]
          report_good "#{proto[:name]} #{proto[:version]} - Yes"
        else
          report_bad "#{proto[:name]} #{proto[:version]} - Yes"
        end
      else
        report_good "#{proto[:name]} #{proto[:version]} - No"
      end
    end

    # Renegotioation
    case
    when r.details.reneg_support == 0
      report_warning "Secure renegotiation is not supported"
    when r.details.reneg_support[0] == 1
      report_bad "Insecure client-initiated renegotiation is supported"
    when r.details.reneg_support[1] == 1
      report_good "Secure renegotiation is supported"
    when r.details.reneg_support[2] == 1
      report_warning "Secure client-initiated renegotiation is supported"
    when r.details.reneg_support[3] == 1
      report_warning "Server requires secure renegotiation support"
    end

    # BEAST
    if r.details.vuln_beast?
      report_bad "BEAST attack - Yes"
    else
      report_good "BEAST attack - No"
    end

    # POODLE (SSLv3)
    if r.details.poodle?
      report_bad "POODLE SSLv3 - Vulnerable"
    else
      report_good "POODLE SSLv3 - Not vulnerable"
    end

    # POODLE TLS
    case r.details.poodle_tls
    when -1
      report_warning "POODLE TLS - Test failed"
    when 0
      report_warning "POODLE TLS - Unknown"
    when 1
      report_good "POODLE TLS - Not vulnerable"
    when 2
      report_bad "POODLE TLS - Vulnerable"
    end

    # Downgrade attack prevention
    if r.details.fallback_scsv?
      report_good "Downgrade attack prevention - Yes, TLS_FALLBACK_SCSV supported"
    else
      report_bad "Downgrade attack prevention - No, TLS_FALLBACK_SCSV not supported"
    end

    # Freak
    if r.details.freak?
      report_bad "Freak - Vulnerable"
    else
      report_good "Freak - Not vulnerable"
    end

    # RC4
    if r.details.supports_rc4?
      report_warning "RC4 - Server supports at least one RC4 suite"
    else
      report_good "RC4 - No"
    end

    # RC4 with modern browsers
    report_warning "RC4 is used with modern clients" if r.details.rc4_with_modern?

    # Heartbeat
    if r.details.heartbeat?
      report_status "Heartbeat (extension) - Yes"
    else
      report_status "Heartbeat (extension) - No"
    end

    # Heartbleed
    if r.details.heartbleed?
      report_bad "Heartbleed (vulnerability) - Yes"
    else
      report_good "Heartbleed (vulnerability) - No"
    end

    # OpenSSL CCS
    case r.details.open_ssl_ccs
    when -1
      report_warning "OpenSSL CCS vulnerability (CVE-2014-0224) - Test failed"
    when 0
      report_warning "OpenSSL CCS vulnerability (CVE-2014-0224) - Unknown"
    when 1
      report_good "OpenSSL CCS vulnerability (CVE-2014-0224) - No"
    when 2
      report_bad "OpenSSL CCS vulnerability (CVE-2014-0224) - Possibly vulnerable, but not exploitable"
    when 3
      report_bad "OpenSSL CCS vulnerability (CVE-2014-0224) - Vulnerable and exploitable"
    end

    # Forward Secrecy
    case
    when r.details.forward_secrecy == 0
      report_bad "Forward Secrecy - No"
    when r.details.forward_secrecy[0] == 1
      report_bad "Forward Secrecy - With some browsers"
    when r.details.forward_secrecy[1] == 1
      report_good "Forward Secrecy - With modern browsers"
    when r.details.forward_secrecy[2] == 1
      report_good "Forward Secrecy - Yes (with most browsers)"
    end

    # HSTS
    if r.details.sts_response_header
      str = "Strict Transport Security (HSTS) - Yes"
      if r.details.sts_max_age && r.details.sts_max_age != -1
        str += ":max-age=#{r.details.sts_max_age}"
      end
      str += ":includeSubdomains" if r.details.sts_subdomains?
      report_good str
    else
      report_bad "Strict Transport Security (HSTS) - No"
    end

    # HPKP
    if r.details.pkp_response_header
      report_good "Public Key Pinning (HPKP) - Yes"
    else
      report_warning "Public Key Pinning (HPKP) - No"
    end

    # Compression
    if r.details.compression_methods == 0
      report_good "Compression - No"
    elsif (r.details.session_tickets & 1) != 0
      report_warning "Compression - Yes (Deflate)"
    end

    # Session Resumption
    case r.details.session_resumption
    when 0
      print_status "Session resumption - No"
    when 1
      report_warning "Session resumption - No (IDs assigned but not accepted)"
    when 2
      print_status "Session resumption - Yes"
    end

    # Session Tickets
    case
    when r.details.session_tickets == 0
      print_status "Session tickets - No"
    when r.details.session_tickets[0] == 1
      print_status "Session tickets - Yes"
    when r.details.session_tickets[1] == 1
      report_good "Session tickets - Implementation is faulty"
    when r.details.session_tickets[2] == 1
      report_warning "Session tickets - Server is intolerant to the extension"
    end

    # OCSP stapling
    if r.details.ocsp_stapling?
      print_status "OCSP Stapling - Yes"
    else
      print_status "OCSP Stapling - No"
    end

    # NPN
    if r.details.supports_npn?
      print_status "Next Protocol Negotiation (NPN) - Yes (#{r.details.npn_protocols})"
    else
      print_status "Next Protocol Negotiation (NPN) - No"
    end

    # SNI
    print_status "SNI Required - Yes" if r.details.sni_required?
  end

  def output_grades_only(r)
    r.endpoints.each do |e|
      if e.status_message == "Ready"
        print_status "Server: #{e.server_name} (#{e.ip_address}) - Grade:#{e.grade}"
      else
        print_status "Server: #{e.server_name} (#{e.ip_address} - Status:#{e.status_message}"
      end
    end
  end

  def output_common_info(r)
    return unless r
    print_status "Host: #{r.host}"

    r.endpoints.each do |e|
      print_status "\t  #{e.ip_address}\n"
    end
  end

  def output_result(r, grade)
    return unless r
    output_common_info(r)
    if grade
      output_grades_only(r)
    else
      r.endpoints.each do |e|
        if e.status_message == "Ready"
          output_endpoint_data(e)
        else
          print_status "#{e.status_message}"
        end
      end
    end
  end

  def output_testing_details(r)
    return unless r.status == "IN_PROGRESS"

    if r.endpoints.length == 1
      print_status "#{r.host} (#{r.endpoints[0].ip_address}) - Progress #{[r.endpoints[0].progress, 0].max}% (#{r.endpoints[0].status_details_message})"
    elsif r.endpoints.length > 1
      in_progress_srv_num = 0
      ready_srv_num = 0
      pending_srv_num = 0
      r.endpoints.each do |e|
        case e.status_message.to_s
        when "In progress"
          in_progress_srv_num += 1
          print_status "Scanned host: #{e.ip_address} (#{e.server_name})- #{[e.progress, 0].max}% complete (#{e.status_details_message})"
        when "Pending"
          pending_srv_num += 1
        when "Ready"
          ready_srv_num += 1
        end
      end
      progress = ((ready_srv_num.to_f / (pending_srv_num + in_progress_srv_num + ready_srv_num)) * 100.0).round(0)
      print_status "Ready: #{ready_srv_num}, In progress: #{in_progress_srv_num}, Pending: #{pending_srv_num}"
      print_status "#{r.host} - Progress #{progress}%"
    end
  end

  def valid_hostname?(hostname)
    hostname =~ /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/
  end

  def run
    delay = datastore['DELAY']
    hostname = datastore['HOSTNAME']
    unless valid_hostname?(hostname)
      print_status "Invalid hostname"
      return
    end

    usecache = datastore['USECACHE']
    grade = datastore['GRADE']

    # Use cached results
    if usecache
      from_cache = 'on'
      start_new = 'off'
    else
      from_cache = 'off'
      start_new = 'on'
    end

    # Ignore mismatch
    ignore_mismatch = datastore['IGNOREMISMATCH'] ? 'on' : 'off'

    api = Api.new
    info = api.info
    print_status "SSL Labs API info"
    print_status "API version: #{info.engine_version}"
    print_status "Evaluation criteria: #{info.criteria_version}"
    print_status "Running assessments: #{info.current_assessments} (max #{info.max_assessments})"

    if api.current_assessments >= api.max_assessments
      print_status "Too many active assessments"
      return
    end

    if usecache
      r = api.analyse(host: hostname, fromCache: from_cache, ignoreMismatch: ignore_mismatch, all: 'done')
    else
      r = api.analyse(host: hostname, startNew: start_new, ignoreMismatch: ignore_mismatch, all: 'done')
    end

    loop do
      case r.status
      when "DNS"
        print_status "Server: #{r.host} - #{r.status_message}"
      when "IN_PROGRESS"
        output_testing_details(r)
      when "READY"
        output_result(r, grade)
        return
      when "ERROR"
        print_error "#{r.status_message}"
        return
      else
        print_error "Unknown assessment status"
        return
      end
      sleep delay
      r = api.analyse(host: hostname, all: 'done')
    end

    rescue
      print_error "Invalid parameters"
    rescue RequestRateTooHigh
      print_error "Request rate is too high, please slow down"
    rescue InternalError
      print_error "Service encountered an error, sleep 5 minutes"
    rescue ServiceNotAvailable
      print_error "Service is not available, sleep 15 minutes"
    rescue ServiceOverloaded
      print_error "Service is overloaded, sleep 30 minutes"
  end
end
