What this script does

tls-audit runs a read-only audit against a deployed HTTPS endpoint and reports on each item from the Nginx TLS 2026 guide and the nginx-tls config generator:

  • Protocol support — TLS 1.0 and 1.1 should be rejected; TLS 1.2 and 1.3 should be accepted.
  • Certificate — covers the hostname, not expired (and not expiring in the next 21 days), modern key algorithm, complete chain with at least one intermediate, not self-signed.
  • OCSP stapling — the server should be stapling an OCSP response.
  • HSTSStrict-Transport-Security header present, max-age of at least one year, includeSubDomains directive set, preload flagged if present (informational, not a recommendation).
  • Security headersX-Content-Type-Options: nosniff and Referrer-Policy present; a Server header that leaks a version number is flagged.

It uses only openssl s_client, curl, and standard text tools — no external scanner like testssl.sh required. Designed to run from a machine other than the host being audited, with no privilege.

Exit codes are designed for unattended use:

Code Meaning
0 every check PASS
1 at least one FAIL
2 no FAIL but at least one WARN
3 could not connect

Usage

 1# Download (audit the source first — see "The script" below).
 2curl -fsSL https://stackharden.com/scripts/tls-audit.sh -o tls-audit
 3chmod +x tls-audit
 4
 5# Standard HTTPS audit (port 443 inferred):
 6./tls-audit example.com
 7
 8# Custom port:
 9./tls-audit example.com 8443
10./tls-audit example.com:8443

The script

The source rendered below is the same file served at /scripts/tls-audit.sh — Hugo reads the downloadable directly at build time, so what you read here is what you would run.

  1#!/usr/bin/env bash
  2#
  3# tls-audit — Read-only TLS posture audit for an HTTPS endpoint.
  4#
  5# Purpose:    Reports on each recommendation from
  6#             https://stackharden.com/guides/nginx-tls-2026/
  7# Companion:  /guides/nginx-tls-2026/
  8# Tested on:  Ubuntu 24.04 LTS / AlmaLinux 9.x, OpenSSL 3.x, curl 8.x
  9# Author:     StackHarden — https://stackharden.com
 10# Date:       2026-05-18
 11# Licence:    MIT
 12#
 13# Usage:
 14#   ./tls-audit example.com
 15#   ./tls-audit example.com 443
 16#   ./tls-audit example.com:443
 17#
 18# Issues only outbound HTTPS connections — does not modify the target.
 19# Designed to run from a machine other than the host it's auditing.
 20#
 21# Exit codes:
 22#   0 — every check PASS
 23#   1 — at least one FAIL
 24#   2 — no FAIL but at least one WARN
 25#   3 — could not connect
 26
 27set -euo pipefail
 28
 29# ---------- Arguments ----------
 30if [ $# -lt 1 ]; then
 31  printf 'Usage: %s <host[:port]> [port]\n' "$0" >&2
 32  exit 2
 33fi
 34
 35# Allow either "host:port" or separate args.
 36INPUT=$1
 37if [[ "$INPUT" == *:* ]]; then
 38  HOST=${INPUT%:*}
 39  PORT=${INPUT#*:}
 40else
 41  HOST=$INPUT
 42  PORT=${2:-443}
 43fi
 44
 45# ---------- Colour output ----------
 46if [ -t 1 ]; then
 47  C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_GRN=$'\033[32m'; C_OFF=$'\033[0m'
 48else
 49  C_RED=''; C_YEL=''; C_GRN=''; C_OFF=''
 50fi
 51
 52FAILS=0; WARNS=0; PASSES=0
 53pass() { PASSES=$((PASSES+1)); printf '  [%sPASS%s] %s\n' "$C_GRN" "$C_OFF" "$1"; }
 54warn() { WARNS=$((WARNS+1));  printf '  [%sWARN%s] %s\n' "$C_YEL" "$C_OFF" "$1"; }
 55fail() { FAILS=$((FAILS+1));  printf '  [%sFAIL%s] %s\n' "$C_RED" "$C_OFF" "$1"; }
 56info() {                       printf '  [INFO] %s\n' "$1"; }
 57hdr()  { printf '\n%s\n' "$1"; }
 58
 59# ---------- OpenSSL helpers ----------
 60
 61# Run `openssl s_client` against host:port with given args. Returns
 62# 0 if the handshake completed (regardless of protocol version), non-zero
 63# if it failed.
 64oc() {
 65  echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" \
 66       "$@" </dev/null 2>/dev/null
 67}
 68
 69# Get cert PEM from an `openssl s_client` invocation.
 70cert_pem() {
 71  oc "$@" | sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' | head -100
 72}
 73
 74printf 'TLS audit — %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
 75printf 'Target: https://%s:%s\n' "$HOST" "$PORT"
 76printf 'See https://stackharden.com/guides/nginx-tls-2026/ for context.\n'
 77
 78# ---------- Connectivity ----------
 79PEM=$(cert_pem)
 80if [ -z "$PEM" ]; then
 81  printf '\nERROR: could not retrieve a certificate from %s:%s\n' "$HOST" "$PORT" >&2
 82  exit 3
 83fi
 84
 85VER_LINE=$(echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" 2>/dev/null \
 86           | grep -E '^[[:space:]]*Protocol[[:space:]]*:' | head -1)
 87printf '\nNegotiated default protocol:%s\n' "${VER_LINE#*:}"
 88
 89# ---------- Protocol support ----------
 90hdr "Protocol support"
 91
 92# Test each protocol explicitly. -ign_eof prevents s_client from waiting
 93# for stdin EOF on a successful handshake.
 94test_proto() {
 95  local flag=$1 name=$2 expect=$3
 96  if oc "-$flag" -ign_eof >/dev/null 2>&1; then
 97    accepted="yes"
 98  else
 99    accepted="no"
100  fi
101  case "$expect:$accepted" in
102    accept:yes) pass "$name accepted" ;;
103    accept:no)  warn "$name not accepted — clients on older platforms may fail to connect" ;;
104    reject:no)  pass "$name rejected" ;;
105    reject:yes) fail "$name accepted — disable" ;;
106  esac
107}
108
109test_proto "tls1"    "TLS 1.0" reject
110test_proto "tls1_1"  "TLS 1.1" reject
111test_proto "tls1_2"  "TLS 1.2" accept
112test_proto "tls1_3"  "TLS 1.3" accept
113
114# ---------- Certificate ----------
115hdr "Certificate"
116
117SUBJECT=$(echo "$PEM" | openssl x509 -noout -subject 2>/dev/null | sed 's/^subject=//')
118ISSUER=$( echo "$PEM" | openssl x509 -noout -issuer  2>/dev/null | sed 's/^issuer=//')
119NOTAFTER=$(echo "$PEM" | openssl x509 -noout -enddate 2>/dev/null | sed 's/^notAfter=//')
120KEY_ALG=$( echo "$PEM" | openssl x509 -noout -text 2>/dev/null | awk -F: '/Public Key Algorithm/{print $2; exit}' | xargs)
121SAN=$(echo "$PEM" | openssl x509 -noout -ext subjectAltName 2>/dev/null | tail -n +2 | xargs)
122
123info "Subject: $SUBJECT"
124info "Issuer:  $ISSUER"
125info "Key alg: ${KEY_ALG:-unknown}"
126[ -n "$SAN" ] && info "SAN:     $SAN"
127
128# Hostname match
129if echo "$SAN $SUBJECT" | grep -qE "(^|[, ])(DNS:)?$HOST([, ]|\$)"; then
130  pass "Certificate covers $HOST"
131elif echo "$SAN $SUBJECT" | grep -qE "(^|[, ])(DNS:)?\\*\\.[^,]+"; then
132  warn "Hostname match relies on a wildcard cert — verify manually"
133else
134  fail "Certificate does not appear to cover $HOST"
135fi
136
137# Expiry — compare in seconds
138NOTAFTER_EPOCH=$(date -u -d "$NOTAFTER" +%s 2>/dev/null || echo 0)
139NOW_EPOCH=$(date -u +%s)
140DAYS_LEFT=$(( (NOTAFTER_EPOCH - NOW_EPOCH) / 86400 ))
141if [ "$DAYS_LEFT" -le 0 ]; then
142  fail "Certificate EXPIRED ($NOTAFTER)"
143elif [ "$DAYS_LEFT" -lt 21 ]; then
144  warn "Certificate expires in $DAYS_LEFT days — renew imminent"
145else
146  pass "Certificate valid for $DAYS_LEFT more days"
147fi
148
149# Key algorithm preference
150case "$KEY_ALG" in
151  *id-ecPublicKey*|*ECDSA*|*ed25519*) pass "Public key algorithm: $KEY_ALG (modern)" ;;
152  *rsaEncryption*|*RSA*)              warn "Public key algorithm: RSA — ECDSA preferred for new certs" ;;
153  *)                                  info "Public key algorithm: $KEY_ALG" ;;
154esac
155
156# Self-signed?
157if [ "$SUBJECT" = "$ISSUER" ]; then
158  fail "Certificate is self-signed (subject == issuer)"
159fi
160
161# Chain length — count BEGIN CERTIFICATE markers in the full chain.
162CHAIN_COUNT=$(oc -showcerts | grep -c -- '-----BEGIN CERTIFICATE-----' || true)
163if [ "$CHAIN_COUNT" -ge 2 ]; then
164  pass "Certificate chain has $CHAIN_COUNT certs (intermediate present)"
165else
166  warn "Certificate chain has $CHAIN_COUNT certs — clients may fail trust validation"
167fi
168
169# ---------- OCSP stapling ----------
170hdr "OCSP stapling"
171OCSP=$(echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" \
172       -status </dev/null 2>/dev/null | grep -A 1 'OCSP response:' | head -3 || true)
173if echo "$OCSP" | grep -q "no response sent"; then
174  warn "OCSP stapling not configured (no response sent)"
175elif echo "$OCSP" | grep -qE 'OCSP Response Status: successful'; then
176  pass "OCSP stapling response present (successful)"
177else
178  warn "OCSP stapling status unclear — verify manually"
179fi
180
181# ---------- HTTP response headers ----------
182hdr "HTTP security headers"
183HEADERS=$(curl -sIk -A 'StackHarden-tls-audit/1' --max-time 10 \
184          "https://$HOST:$PORT/" 2>/dev/null || true)
185
186if [ -z "$HEADERS" ]; then
187  warn "Could not fetch HTTP headers — the site may not respond on /"
188else
189  # `|| true` on every grep-pipeline-to-var: a missing header is a
190  # normal outcome (we WARN below), not a script-abort error. Without
191  # this, `set -o pipefail` + `set -e` would kill the script on the
192  # first header that's not present.
193  HSTS=$(echo "$HEADERS" | grep -i '^strict-transport-security:' | head -1 | tr -d '\r' || true)
194  if [ -z "$HSTS" ]; then
195    fail "Strict-Transport-Security header missing"
196  else
197    info "$HSTS"
198    MAX_AGE=$(echo "$HSTS" | grep -oE 'max-age=[0-9]+' | head -1 | cut -d= -f2 || true)
199    if [ -n "$MAX_AGE" ] && [ "$MAX_AGE" -ge 31536000 ]; then
200      pass "HSTS max-age = $MAX_AGE (>= 1 year)"
201    elif [ -n "$MAX_AGE" ]; then
202      warn "HSTS max-age = $MAX_AGE — recommend at least 31536000 (1 year), 63072000 (2 years) is the common default"
203    fi
204    if echo "$HSTS" | grep -qi 'includeSubDomains'; then
205      pass "HSTS includeSubDomains directive present"
206    else
207      warn "HSTS includeSubDomains not present — fine only if subdomains are intentionally HTTP-capable"
208    fi
209    if echo "$HSTS" | grep -qi 'preload'; then
210      info "HSTS preload directive present — domain must also be submitted to hstspreload.org"
211    fi
212  fi
213
214  for h_pair in \
215      'X-Content-Type-Options:nosniff' \
216      'Referrer-Policy:any'; do
217    name=${h_pair%%:*}
218    expect=${h_pair##*:}
219    line=$(echo "$HEADERS" | grep -i "^${name}:" | head -1 | tr -d '\r' || true)
220    if [ -z "$line" ]; then
221      warn "$name header missing"
222    elif [ "$expect" = "any" ]; then
223      pass "$name set ($line)"
224    elif echo "$line" | grep -qi "$expect"; then
225      pass "$name: $expect"
226    else
227      warn "$name present but value unexpected: $line"
228    fi
229  done
230
231  SERVER_LINE=$(echo "$HEADERS" | grep -i '^server:' | head -1 | tr -d '\r' || true)
232  if [ -n "$SERVER_LINE" ] && echo "$SERVER_LINE" | grep -qE '[0-9]+\.[0-9]'; then
233    warn "$SERVER_LINE — reveals software version; consider stripping"
234  elif [ -n "$SERVER_LINE" ]; then
235    info "$SERVER_LINE"
236  fi
237fi
238
239# ---------- Summary ----------
240printf '\nSummary: %sPASS %d%s · %sWARN %d%s · %sFAIL %d%s\n' \
241  "$C_GRN" "$PASSES" "$C_OFF" \
242  "$C_YEL" "$WARNS"  "$C_OFF" \
243  "$C_RED" "$FAILS"  "$C_OFF"
244
245if   [ "$FAILS" -gt 0 ]; then exit 1
246elif [ "$WARNS" -gt 0 ]; then exit 2
247else                          exit 0
248fi

Download tls-audit.sh · Companion guide

Limitations

  • Single hostname, single port. The script audits one endpoint per invocation. For a fleet, drive it from a wrapper loop.
  • No deep cipher enumeration. TLS 1.0/1.1 are tested for rejection and TLS 1.2/1.3 for acceptance, but individual ciphersuites are not enumerated. For that, run nmap --script ssl-enum-ciphers -p 443 <host> or testssl.sh <host> alongside this script.
  • Does not check Diffie-Hellman parameters explicitly. Modern cipher suites use ECDHE which is robust by default; classic DHE parameter strength is a separate test.
  • Wildcard certs flagged as WARN, not PASS. A wildcard cert is technically valid for the hostname but is more lenient than a SAN cert — operators should confirm the wildcard is intentional.

What this script deliberately does not cover

  • Origin-side TLS configuration audit — for inspecting nginx -T output or Caddy’s loaded config, that’s a different tool with different permissions; this one is the external client-side view.
  • CT log monitoring / cert transparency — out of scope; use a CT monitoring service.
  • Rate-limited or geo-blocked endpoints — the script’s curl call may be challenged. Run from an allow-listed network if needed.