Script to show TLS and HTTP(S) details

I'm pleased with the following script. It evolved over months of checking our websites for security and technical details. Let's start with the output, which looks like this:

$ tlsdetails google.ca
Using OpenSSL:  /usr/bin/openssl
Expiry Date:    Oct 27 17:27:07 2019 GMT (78 days)
Issuer:         Google Trust Services, CN
TLS Versions:   tls1_3 tls1_2 tls1_1 tls1  (tried but unavailable: ssl3 ssl2 )
HTTP Version:   2

(I'm amused that Google thinks we should think it's okay for them to issue certificates that trust themselves ... but then, all certificate authorities do that.)

If you need extensive details about a site's SSL/TLS support, use a site like SSL Labs Qualys Test. (There are others - this is my preferred one at the moment.)

Here's the script itself - look below the script for the explanation of each of the pieces.

#!/bin/bash
# Purpose:
#     Check TLS certificate and HTTPS details.
# Source:
#     https://www.gilesorr.com/blog/tls-https-details.html

######################################################################
#                       Version Check
######################################################################
# Apple's own 'openssl' was incredibly old (0.9.8) until ~2019-06 when they
# changed to LibreSSL which is better, but doesn't support TLS 1.3.  Brew
# respects Apple's version, so we have to go on a version hunt.

OPENSSL=""

# Put most desirable/least-likely last:
for opensslbinary in /usr/local/opt/openssl@1.1/bin/openssl /usr/local/opt/openssl/bin/openssl /usr/bin/openssl
do
    # Every version of OpenSSL I've checked (several across multiple
    # OpenSSL versions and Apple's LibreSSL) respond to 'openssl version'
    # with '<brand> <version-no> ...' - there may or may not be stuff after
    # those two items (usually a build date, but not with Libre).
    #
    if [ -f "${opensslbinary}" ]
    then
        output="$("${opensslbinary}" version)"
        brand="$(  echo "${output}" | awk '{ print $1 }')"
        version="$(echo "${output}" | awk '{ print $2 }')"
        if [[ "OpenSSL" == "${brand}" ]]
        then
            if [[ "${version}" == "1.1"* ]]
            then
                OPENSSL="${opensslbinary}"
            fi
        fi
    fi
done
if [ "${OPENSSL}x" == "x" ]
then
    echo "This script requires OpenSSL v1.1, which was not found."
    echo "If on Mac, please 'brew install openssl@1.1'"
    exit 1
fi
echo "Using OpenSSL:  ${OPENSSL}"

######################################################################
#                            Help
######################################################################

function help() {
    echo "Usage:"
    echo "    $(basename "${0}") [-h]|<domain-name>"
    echo ""
    echo "Show HTTP(s) and certificate details."
    echo "Do not include the 'http(s)://' leader on the domain name."
    echo ""
    echo "-h            show this help and exit"
}

######################################################################
#                    is hostname valid
######################################################################

function ishostnamevalid() {
    # given a hostname, try to find the IP and use that to determine if the
    # hostname is actually valid.  Return 0 for a valid hostname, 1 for an
    # invalid hostname.

    IP="$( dig +short "${1}")"
    if [ "${IP}x" == "x" ]
    then
        # hostname isn't valid - no IP returned
        echo 1
    else
        # IP returned, valid hostname
        echo 0
    fi
}

######################################################################
#                       Utility Functions
######################################################################

function expiry_date() {
    echo "${1}" | ${OPENSSL} x509 -noout -dates | grep notAfter | awk 'BEGIN { FS="=" } { print $2 }'
}

function days_to_expiry() {
    expiry_date="$(echo "${1}" | ${OPENSSL} x509 -noout -dates | grep notAfter | awk 'BEGIN { FS="=" } { print $2 }')"
    if [[ "$(date --version 2>/dev/null)" == *"GNU"* ]]
    then
        # Linux (or at least GNU)
        expiry_epoch_seconds=$(date --date="${expiry_date}" "+%s")
    else
        # Assuming the Mac version:
        expiry_epoch_seconds=$(date -jf '%b %e %H:%M:%S %Y %Z' "${expiry_date}" "+%s")
    fi
    # and we convert NOW to seconds from the Unix Epoch ...
    now_epoch_seconds=$(date "+%s")
    seconds_to_expiry=$(( expiry_epoch_seconds - now_epoch_seconds ))
    echo "$(( seconds_to_expiry / 60 / 60 / 24 ))"
}

function issuer() {
    echo "${1}" | ${OPENSSL} x509 -noout -issuer | awk -F "=" '{ print $4 }' | sed -e 's@/.*@@'
}

function tlsversions() {
    successful=""
    failed=""
    for tlsversion in ssl2 ssl3 tls1 tls1_1 tls1_2 tls1_3
    do
        success=$(echo | ${OPENSSL} s_client -connect "${1}":443 -${tlsversion} > /dev/null 2> /dev/null ; echo $?)
        if [ ${success} -eq 0 ]
        then
            successful="${tlsversion} ${successful}"
        else
            failed="${tlsversion} ${failed}"
        fi
    done
    echo "${successful} (tried but unavailable: ${failed})"
}

function httpversion() {
    # This 'curl' command returns nothing but a number: '1.1' for most
    # connections, but '2' for HTTP2 sites - and '0' for https:// requests
    # on an unencrypted site.
    unEncNum=$(curl -sI "${1}"         -o/dev/null -w '%{http_version}')
    EncNum=$(curl -sI   "https://${1}" -o/dev/null -w '%{http_version}')
    if [ "${EncNum}" -eq "0" ]
    then
        echo "${unEncNum}"
    else
        echo "${EncNum}"
    fi
}

######################################################################
#                    Process the command line
######################################################################

if [ $# -lt 1 ]
then
    help
    exit 1
fi

# http://wiki.bash-hackers.org/howto/getopts_tutorial
while getopts ":h" opt
do
    case ${opt} in
        h)
            help
            exit 0
            ;;

        \?)
            echo "invalid option: -${OPTARG}" >&2
            help
            exit 1
            ;;

        :)
            echo "option -${OPTARG} requires an argument." >&2
            help
            exit 1
            ;;
    esac
done

domain_name="${1}"

if [ "$(ishostnamevalid "${domain_name}")" -eq "0" ]
then
    sclient_out="$(echo | ${OPENSSL} s_client -connect "${domain_name}:443" -servername "${domain_name}" 2>/dev/null)"
    if [ "${sclient_out}x" == "x" ]
    then
        echo "No certificate returned."
    else
        echo "Expiry Date:    $(expiry_date "${sclient_out}") ($(days_to_expiry "${sclient_out}") days)"
        echo "Issuer:        $(issuer "${sclient_out}")"
        echo "TLS Versions:   $(tlsversions "${domain_name}")"
        echo "HTTP Version:   $(httpversion "${domain_name}")"
    fi
else
    echo "'${1}' appears to be an invalid domain."
fi

(cloc says that's 126 lines of code to generate five lines of output ...)

I wrote this script to run on both Linux and Mac. This is significantly complicated by Apple's weird history with OpenSSL (not entirely Apple's fault as OpenSSL is a complex mess). Up until fairly recently (it changed in the last three or four months), OS X included the spectacularly old version 0.9.8 of OpenSSL. This was ... not very useful, as there are a number of things it couldn't do. So most of us who have to use openssl instead do brew install openssl@1.1. But because there's already an existing OS X binary, brew doesn't link their openssl binary onto your path so you have to go through various machinations to use it. A very recent version of OS X (10.14.5? I'm not entirely sure when it changed) now includes the LibreSSL openssl binary. This is an improvement, but this new binary doesn't support TLS 1.3 ... I get the frustrations with OpenSSL, but that's a huge lack in the LibreSSL binary. So my script insists on an OpenSSL binary, and a version of at least 1.1 (1.0.x doesn't support TLS 1.3).

After the OpenSSL version check, it's probably best to start reading the script from the "Process the command line" section. We use getopts to parse the command line and then proceed only if the provided host name is considered valid. This is tested by the ishostnamevalid() function using dig +short host.name to see if an IP address is returned - if it is, the host name is good.

The next step is to grab the output of the openssl s_client ... command for the host name. The output is large and technically complex, and includes a lot more information than I want. It even includes the TLS protocol used, but since we want to determine all the TLS protocols available, I didn't use that information from this source.

The expiry_date() function uses some text mangling to pull the wanted date. The days_to_expiry() function uses the date command (different versions on Mac or Linux, so annoying) to convert the expiry date of the certificate into seconds since the Unix Epoch (that's a huge subject by itself, but once you understand it - as stupid as it initially seems - it's often the best way to handle dates on a Unix system). Then we convert the date NOW to Unix Epoch time, get the difference between the two, and convert those seconds back into a number of days until the certificate expires.

(The expiry date of our certificates was actually the original inspiration for this script: we had a couple expire without anyone noticing, which is very embarrassing. Running this weekly against a long list of host names and noting how soon expiries are coming up has helped.)

issuer() uses openssl and text mangling to get the name authority for the certificate.

tlsversions() uses openssl to try to connect to the domain using each of the current TLS and SSL protocols, and then prints success or failure for each. It's a GOOD THING (TM) that 'ssl3' and 'ssl2' fail: they're incredibly outdated and broken. I disagree with Google's decision to still allow TLS 1.0 - our web stats show that very few people are using it, and it's extremely weak. But enough people are still using old web clients that supporting TLS 1.1 is still - sadly - recommended.

Finally, httpversion() uses curl to determine what version of HTTP is available from the site. I didn't bother listing all versions available from the host name because ALL sites support 1.1. So this test is essentially a binary question, "do they support HTTP2 or not?" If you look closely, you'll see that the host name has "https://" prefixed to it before testing for HTTP2: in theory, HTTP2 can be implemented without encryption, but nobody has done that.