a1a17e81a1
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
651 lines
27 KiB
Bash
Executable File
651 lines
27 KiB
Bash
Executable File
#!/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 echo | 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"
|
|
echo | timeout "${CONNECT_TIMEOUT}" openssl s_client \
|
|
-connect "${host}:${port}" \
|
|
-servername "${host}" \
|
|
-showcerts </dev/null 2>"${CERT_TMP}/s_client_err.txt" \
|
|
> "${chain_file}" 2>/dev/null || 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 </dev/null 2>&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 </dev/null 2>&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 </dev/null 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 </dev/null 2>&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 </dev/null 2>&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}" \
|
|
</dev/null 2>&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 </dev/null 2>&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 <<EOF
|
|
Usage: $(basename "$0") [OPTIONS]
|
|
|
|
Smoke-test TLS/SSL certificates. Checks expiry, chain, OCSP, TLS version, ciphers.
|
|
|
|
Environment variables:
|
|
TARGETS Comma-separated host:port list (e.g. "example.com:443,mail.example.com:993")
|
|
WARN_DAYS Days before expiry to warn (default: 30)
|
|
CRITICAL_DAYS Days before expiry to fail (default: 7)
|
|
CERT_FILES Comma-separated on-disk cert paths to check
|
|
CERT_FILE Single cert file for key match
|
|
KEY_FILE Private key file for key match
|
|
CHECK_OCSP Check OCSP stapling (default: true)
|
|
CHECK_TLS_VERSION Check TLS version support (default: true)
|
|
CHECK_HSTS Check HSTS header (default: true)
|
|
REJECT_SELF_SIGNED Fail on self-signed certs (default: false)
|
|
CONNECT_TIMEOUT TLS connect timeout in seconds (default: 10)
|
|
OUTPUT_FORMAT text or tap (default: text)
|
|
COLOR auto, always, never (default: auto)
|
|
VERBOSE Show debug output (default: false)
|
|
|
|
Options:
|
|
--skip-chain Skip chain validation
|
|
--skip-ocsp Skip OCSP checks
|
|
--skip-tls-version Skip TLS version checks
|
|
--format FORMAT Output format: text (default), tap
|
|
--verbose Show debug output
|
|
--no-color Disable colored output
|
|
--help Show this help
|
|
|
|
Examples:
|
|
TARGETS="example.com:443" ./$(basename "$0")
|
|
TARGETS="web.example.com:443,mail.example.com:993" WARN_DAYS=14 ./$(basename "$0")
|
|
CERT_FILES="/etc/ssl/certs/app.pem,/etc/ssl/certs/api.pem" ./$(basename "$0")
|
|
CERT_FILE=/etc/ssl/app.pem KEY_FILE=/etc/ssl/app.key ./$(basename "$0")
|
|
TARGETS="example.com:443" ./$(basename "$0") --format tap
|
|
EOF
|
|
}
|
|
|
|
main() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--skip-chain) SKIP_CHAIN=true ;;
|
|
--skip-ocsp) SKIP_OCSP=true ;;
|
|
--skip-tls-version) SKIP_TLS_VERSION=true ;;
|
|
--format) OUTPUT_FORMAT="$2"; shift ;;
|
|
--verbose) VERBOSE=true ;;
|
|
--no-color) COLOR=never ;;
|
|
--help|-h) usage; exit 0 ;;
|
|
*) err "Unknown option: $1"; usage; exit 1 ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
setup_colors
|
|
|
|
if ! has_cmd openssl; then
|
|
err "openssl not found in PATH"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "${TARGETS}" && -z "${CERT_FILES}" && -z "${CERT_FILE}" ]]; then
|
|
err "At least one of TARGETS, CERT_FILES, or CERT_FILE must be set"
|
|
echo ""; usage; exit 1
|
|
fi
|
|
|
|
CERT_TMP=$(mktemp -d /tmp/cert-smoke-XXXXXX)
|
|
START_TIME=$(date +%s)
|
|
|
|
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
|
|
print_tap_header
|
|
else
|
|
echo ""
|
|
echo -e "${BOLD}Certificate Smoke Tests${RESET}"
|
|
echo -e "Targets: ${TARGETS:-none} Warn: ${WARN_DAYS}d Critical: ${CRITICAL_DAYS}d"
|
|
echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
fi
|
|
|
|
# ── Remote target checks ──
|
|
if [[ -n "${TARGETS}" ]]; then
|
|
IFS=',' read -ra target_list <<< "${TARGETS}"
|
|
for target in "${target_list[@]}"; do
|
|
local host port
|
|
host="${target%%:*}"
|
|
port="${target##*:}"
|
|
[[ "$host" == "$port" ]] && port=443
|
|
|
|
section "Target: ${host}:${port}"
|
|
test_cert_expiry "$host" "$port"
|
|
test_san_match "$host" "$port"
|
|
test_chain_valid "$host" "$port"
|
|
test_self_signed "$host" "$port"
|
|
test_ocsp_stapling "$host" "$port"
|
|
test_ocsp_responder "$host" "$port"
|
|
test_tls_version "$host" "$port"
|
|
test_cipher_strength "$host" "$port"
|
|
test_hsts "$host" "$port"
|
|
test_sct "$host" "$port"
|
|
done
|
|
fi
|
|
|
|
# ── On-disk certificate file checks ──
|
|
if [[ -n "${CERT_FILES}" ]]; then
|
|
section "On-Disk Certificates"
|
|
IFS=',' read -ra file_list <<< "${CERT_FILES}"
|
|
for cert_path in "${file_list[@]}"; do
|
|
test_cert_file_expiry "$cert_path"
|
|
done
|
|
fi
|
|
|
|
# ── Key/cert match ──
|
|
if [[ -n "${CERT_FILE}" || -n "${KEY_FILE}" ]]; then
|
|
section "Key/Certificate Match"
|
|
test_key_cert_match
|
|
fi
|
|
|
|
# ── Results ──
|
|
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
|
|
print_tap_footer
|
|
else
|
|
print_summary
|
|
fi
|
|
|
|
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
|
|
}
|
|
|
|
main "$@"
|