#!/usr/bin/env bash ##################################################################################### #### certificate-smoke-tests.sh — Verify TLS certificates are healthy #### #### Checks expiry, chain, OCSP, TLS version, ciphers, SAN, on-disk certs. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: TARGETS="example.com:443" ./certificate-smoke-tests.sh #### #### CERT_FILES="/etc/ssl/certs/app.pem" ./certificate-smoke-tests.sh #### #### #### #### See --help for all options. #### ##################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── TARGETS="${TARGETS:-}" WARN_DAYS="${WARN_DAYS:-30}" CRITICAL_DAYS="${CRITICAL_DAYS:-7}" CERT_FILES="${CERT_FILES:-}" CERT_FILE="${CERT_FILE:-}" KEY_FILE="${KEY_FILE:-}" CHECK_OCSP="${CHECK_OCSP:-true}" CHECK_TLS_VERSION="${CHECK_TLS_VERSION:-true}" CHECK_HSTS="${CHECK_HSTS:-true}" REJECT_SELF_SIGNED="${REJECT_SELF_SIGNED:-false}" SKIP_CHAIN="${SKIP_CHAIN:-false}" SKIP_OCSP="${SKIP_OCSP:-false}" SKIP_TLS_VERSION="${SKIP_TLS_VERSION:-false}" CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-10}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" COLOR="${COLOR:-auto}" VERBOSE="${VERBOSE:-false}" # ── State ───────────────────────────────────────────────────────────── PASS=0; FAIL=0; SKIP=0; TOTAL=0 RESULTS=() START_TIME="" CERT_TMP="" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" detail="${2:-}" ((PASS++)) || true; ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}${detail:+ (${detail})}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}"; fi } record_fail() { local name="$1" detail="${2:-}" ((FAIL++)) || true; ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" else echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}"; fi } record_skip() { local name="$1" reason="${2:-}" ((SKIP++)) || true; ((TOTAL++)) || true RESULTS+=("SKIP|${name}|${reason}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${reason}" else echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}"; fi } # ── Helpers ─────────────────────────────────────────────────────────── has_cmd() { command -v "$1" >/dev/null 2>&1; } section() { if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo ""; echo -e "${BOLD}$1${RESET}"; fi } # ── Cleanup ─────────────────────────────────────────────────────────── # shellcheck disable=SC2317 cleanup() { [[ -n "${CERT_TMP}" && -d "${CERT_TMP}" ]] && rm -rf "${CERT_TMP}" } trap cleanup EXIT # ══════════════════════════════════════════════════════════════════════ # HELPER FUNCTIONS # ══════════════════════════════════════════════════════════════════════ # Fetch certificate from a remote host:port, store in temp file # Returns path to PEM file on stdout, empty on failure fetch_cert() { local host="$1" port="$2" pem_file pem_file="${CERT_TMP}/${host}_${port}.pem" verbose "Fetching certificate from ${host}:${port}" if timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" \ -servername "${host}" \ -showcerts < /dev/null 2>/dev/null \ | openssl x509 -outform PEM > "${pem_file}" 2>/dev/null; then if [[ -s "${pem_file}" ]]; then echo "${pem_file}" return 0 fi fi return 1 } # Fetch full chain from remote host:port fetch_chain() { local host="$1" port="$2" chain_file chain_file="${CERT_TMP}/${host}_${port}_chain.pem" timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" \ -servername "${host}" \ -showcerts < /dev/null 2>"${CERT_TMP}/s_client_err.txt" \ > "${chain_file}" || true if [[ -s "${chain_file}" ]]; then echo "${chain_file}" fi } # Get days until certificate expires # Args: path to PEM file # Returns: integer days (negative = already expired) cert_days_remaining() { local pem_file="$1" local end_date epoch_end epoch_now end_date=$(openssl x509 -in "${pem_file}" -noout -enddate 2>/dev/null | sed 's/notAfter=//') || return 1 epoch_end=$(date -d "${end_date}" +%s 2>/dev/null) || return 1 epoch_now=$(date +%s) echo $(( (epoch_end - epoch_now) / 86400 )) } # ══════════════════════════════════════════════════════════════════════ # TEST FUNCTIONS # ══════════════════════════════════════════════════════════════════════ # ── Certificate Expiry ─────────────────────────────────────────────── test_cert_expiry() { local host="$1" port="$2" pem_file pem_file=$(fetch_cert "$host" "$port") || { record_fail "Certificate expiry (${host}:${port})" "could not connect" return } local days days=$(cert_days_remaining "$pem_file") || { record_fail "Certificate expiry (${host}:${port})" "could not parse expiry date" return } if [[ $days -lt 0 ]]; then record_fail "Certificate expiry (${host}:${port})" "EXPIRED ${days#-} days ago" elif [[ $days -lt $CRITICAL_DAYS ]]; then record_fail "Certificate expiry (${host}:${port})" "expires in ${days}d (critical < ${CRITICAL_DAYS}d)" elif [[ $days -lt $WARN_DAYS ]]; then record_pass "Certificate expiry (${host}:${port})" "expires in ${days}d (warning < ${WARN_DAYS}d)" else record_pass "Certificate expiry (${host}:${port})" "expires in ${days}d" fi } # ── Subject / SAN Match ───────────────────────────────────────────── test_san_match() { local host="$1" port="$2" pem_file pem_file="${CERT_TMP}/${host}_${port}.pem" [[ ! -s "$pem_file" ]] && { record_skip "SAN match (${host}:${port})" "no certificate fetched"; return; } local san_output cn_output matched=false san_output=$(openssl x509 -in "${pem_file}" -noout -ext subjectAltName 2>/dev/null) || true cn_output=$(openssl x509 -in "${pem_file}" -noout -subject 2>/dev/null | grep -oP 'CN\s*=\s*\K[^/,]+') || true if echo "$san_output" | grep -qi "DNS:${host}"; then matched=true elif echo "$san_output" | grep -qi "DNS:\*.$(echo "$host" | sed 's/^[^.]*\.//')"; then matched=true elif [[ "${cn_output}" == "${host}" ]]; then matched=true fi if $matched; then record_pass "SAN match (${host}:${port})" "hostname matches certificate" else record_fail "SAN match (${host}:${port})" "hostname not in CN or SAN" fi } # ── Chain Validation ───────────────────────────────────────────────── test_chain_valid() { local host="$1" port="$2" if [[ "$SKIP_CHAIN" == "true" ]]; then record_skip "Chain valid (${host}:${port})" "SKIP_CHAIN=true" return fi local verify_output verify_output=$(echo | timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" \ -servername "${host}" \ -verify_return_error &1) || true if echo "$verify_output" | grep -q "Verify return code: 0"; then record_pass "Chain valid (${host}:${port})" "full chain verified" elif echo "$verify_output" | grep -q "Verify return code: 18\|Verify return code: 19"; then if [[ "$REJECT_SELF_SIGNED" == "true" ]]; then record_fail "Chain valid (${host}:${port})" "self-signed certificate" else record_pass "Chain valid (${host}:${port})" "self-signed (allowed)" fi else local code code=$(echo "$verify_output" | grep -oP 'Verify return code: \K[0-9]+' | head -1) || code="unknown" record_fail "Chain valid (${host}:${port})" "verify failed (code ${code})" fi } # ── Self-signed Detection ──────────────────────────────────────────── test_self_signed() { local host="$1" port="$2" pem_file pem_file="${CERT_TMP}/${host}_${port}.pem" [[ ! -s "$pem_file" ]] && { record_skip "Self-signed check (${host}:${port})" "no certificate fetched"; return; } local issuer subject issuer=$(openssl x509 -in "${pem_file}" -noout -issuer 2>/dev/null) || true subject=$(openssl x509 -in "${pem_file}" -noout -subject 2>/dev/null) || true if [[ "$issuer" == "$subject" ]]; then if [[ "$REJECT_SELF_SIGNED" == "true" ]]; then record_fail "Self-signed check (${host}:${port})" "certificate is self-signed" else record_pass "Self-signed check (${host}:${port})" "self-signed (allowed)" fi else record_pass "Self-signed check (${host}:${port})" "CA-signed" fi } # ── OCSP Stapling ──────────────────────────────────────────────────── test_ocsp_stapling() { local host="$1" port="$2" if [[ "$SKIP_OCSP" == "true" ]]; then record_skip "OCSP stapling (${host}:${port})" "SKIP_OCSP=true" return fi local ocsp_output ocsp_output=$(echo | timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" \ -servername "${host}" \ -status &1) || true if echo "$ocsp_output" | grep -q "OCSP Response Status: successful"; then record_pass "OCSP stapling (${host}:${port})" "stapled response present" elif echo "$ocsp_output" | grep -q "OCSP response: no response sent"; then record_pass "OCSP stapling (${host}:${port})" "not configured (optional)" else record_pass "OCSP stapling (${host}:${port})" "status unknown (non-critical)" fi } # ── OCSP Responder Reachable ───────────────────────────────────────── test_ocsp_responder() { local host="$1" port="$2" pem_file if [[ "$SKIP_OCSP" == "true" ]]; then record_skip "OCSP responder (${host}:${port})" "SKIP_OCSP=true" return fi pem_file="${CERT_TMP}/${host}_${port}.pem" [[ ! -s "$pem_file" ]] && { record_skip "OCSP responder (${host}:${port})" "no certificate fetched"; return; } local ocsp_uri ocsp_uri=$(openssl x509 -in "${pem_file}" -noout -ocsp_uri 2>/dev/null) || true if [[ -z "$ocsp_uri" ]]; then record_skip "OCSP responder (${host}:${port})" "no OCSP URI in certificate" return fi verbose "OCSP URI: ${ocsp_uri}" local ocsp_host ocsp_host=$(echo "$ocsp_uri" | sed 's|https\?://||' | cut -d/ -f1) if has_cmd curl; then if curl -sf --max-time 5 -o /dev/null "${ocsp_uri}" 2>/dev/null; then record_pass "OCSP responder (${host}:${port})" "${ocsp_host} reachable" else record_fail "OCSP responder (${host}:${port})" "${ocsp_host} unreachable" fi elif ping -c1 -W3 "$ocsp_host" >/dev/null 2>&1; then record_pass "OCSP responder (${host}:${port})" "${ocsp_host} reachable (ping)" else record_fail "OCSP responder (${host}:${port})" "${ocsp_host} unreachable" fi } # ── TLS Version Check ──────────────────────────────────────────────── test_tls_version() { local host="$1" port="$2" if [[ "$SKIP_TLS_VERSION" == "true" ]]; then record_skip "TLS version (${host}:${port})" "SKIP_TLS_VERSION=true" return fi # Check TLS 1.2 supported local tls12_ok=false tls13_ok=false if echo | timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" -servername "${host}" \ -tls1_2 &1 | grep -q "Protocol.*TLSv1.2"; then tls12_ok=true fi # Check TLS 1.3 supported if echo | timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" -servername "${host}" \ -tls1_3 &1 | grep -q "Protocol.*TLSv1.3"; then tls13_ok=true fi if $tls13_ok; then record_pass "TLS version (${host}:${port})" "TLS 1.3 supported" elif $tls12_ok; then record_pass "TLS version (${host}:${port})" "TLS 1.2 supported" else record_fail "TLS version (${host}:${port})" "neither TLS 1.2 nor 1.3 supported" fi # Check TLS 1.0 rejected local tls10_output tls10_output=$(echo | timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" -servername "${host}" \ -tls1 &1) || true if echo "$tls10_output" | grep -q "Protocol.*TLSv1$\|Protocol.*TLSv1.0"; then record_fail "TLS 1.0 rejected (${host}:${port})" "TLS 1.0 still accepted" else record_pass "TLS 1.0 rejected (${host}:${port})" "correctly refused" fi } # ── Cipher Strength ────────────────────────────────────────────────── test_cipher_strength() { local host="$1" port="$2" local cipher_output negotiated cipher_output=$(echo | timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" -servername "${host}" \ &1) || true negotiated=$(echo "$cipher_output" | grep -oP 'Cipher\s+:\s+\K\S+' | head -1) || true if [[ -z "$negotiated" ]]; then record_skip "Cipher strength (${host}:${port})" "could not determine cipher" return fi local weak_ciphers="RC4|DES|3DES|NULL|EXPORT|MD5|anon" if echo "$negotiated" | grep -qiE "$weak_ciphers"; then record_fail "Cipher strength (${host}:${port})" "weak cipher: ${negotiated}" else record_pass "Cipher strength (${host}:${port})" "${negotiated}" fi } # ── HSTS Header ────────────────────────────────────────────────────── test_hsts() { local host="$1" port="$2" if [[ "$CHECK_HSTS" != "true" ]]; then record_skip "HSTS header (${host}:${port})" "CHECK_HSTS=false" return fi if ! has_cmd curl; then record_skip "HSTS header (${host}:${port})" "curl not installed" return fi if [[ "$port" != "443" ]]; then record_skip "HSTS header (${host}:${port})" "not HTTPS port" return fi local headers headers=$(curl -sI --max-time 5 -k "https://${host}/" 2>/dev/null) || true if echo "$headers" | grep -qi "Strict-Transport-Security"; then local max_age max_age=$(echo "$headers" | grep -oi 'max-age=[0-9]*' | head -1 | cut -d= -f2) || true record_pass "HSTS header (${host}:${port})" "max-age=${max_age:-unknown}" else record_fail "HSTS header (${host}:${port})" "header not present" fi } # ── Certificate SCT ───────────────────────────────────────────────── test_sct() { local host="$1" port="$2" local sct_output sct_output=$(echo | timeout "${CONNECT_TIMEOUT}" openssl s_client \ -connect "${host}:${port}" -servername "${host}" \ -ct &1) || true if echo "$sct_output" | grep -qi "SCT validation status\|Signed Certificate Timestamp"; then record_pass "Certificate transparency (${host}:${port})" "SCT present" elif echo "$sct_output" | grep -qi "unknown option\|unrecognized option"; then record_skip "Certificate transparency (${host}:${port})" "openssl does not support -ct" else record_pass "Certificate transparency (${host}:${port})" "SCT status unknown (non-critical)" fi } # ── On-disk Certificate File Expiry ────────────────────────────────── test_cert_file_expiry() { local cert_path="$1" local filename filename=$(basename "$cert_path") if [[ ! -f "$cert_path" ]]; then record_fail "File expiry (${filename})" "file not found: ${cert_path}" return fi local days days=$(cert_days_remaining "$cert_path") || { record_fail "File expiry (${filename})" "could not parse certificate" return } if [[ $days -lt 0 ]]; then record_fail "File expiry (${filename})" "EXPIRED ${days#-} days ago" elif [[ $days -lt $CRITICAL_DAYS ]]; then record_fail "File expiry (${filename})" "expires in ${days}d (critical < ${CRITICAL_DAYS}d)" elif [[ $days -lt $WARN_DAYS ]]; then record_pass "File expiry (${filename})" "expires in ${days}d (warning < ${WARN_DAYS}d)" else record_pass "File expiry (${filename})" "expires in ${days}d" fi } # ── Key / Cert Match ──────────────────────────────────────────────── test_key_cert_match() { if [[ -z "$CERT_FILE" || -z "$KEY_FILE" ]]; then record_skip "Key/cert match" "CERT_FILE or KEY_FILE not set" return fi if [[ ! -f "$CERT_FILE" ]]; then record_fail "Key/cert match" "cert file not found: ${CERT_FILE}" return fi if [[ ! -f "$KEY_FILE" ]]; then record_fail "Key/cert match" "key file not found: ${KEY_FILE}" return fi local cert_mod key_mod cert_mod=$(openssl x509 -in "${CERT_FILE}" -noout -modulus 2>/dev/null | md5sum | awk '{print $1}') || true key_mod=$(openssl rsa -in "${KEY_FILE}" -noout -modulus 2>/dev/null | md5sum | awk '{print $1}') || { key_mod=$(openssl ec -in "${KEY_FILE}" -noout -text 2>/dev/null | md5sum | awk '{print $1}') || true } if [[ -n "$cert_mod" && "$cert_mod" == "$key_mod" ]]; then record_pass "Key/cert match" "modulus matches" elif [[ -z "$cert_mod" || -z "$key_mod" ]]; then record_skip "Key/cert match" "could not extract modulus" else record_fail "Key/cert match" "cert and key do not match" fi } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ print_tap_header() { echo "TAP version 13"; } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# skip ${SKIP}" } print_summary() { local end_time; end_time=$(date +%s) local duration=$(( end_time - START_TIME )) echo "" echo -e "${BOLD}────────────────────────────────────────${RESET}" echo -e "${BOLD}Summary${RESET} Certificate Smoke Tests" echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)" echo -e "${BOLD}────────────────────────────────────────${RESET}" if [[ $FAIL -eq 0 ]]; then echo -e "${GREEN}${BOLD}All tests passed.${RESET}" else echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}"; fi } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat <