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.
- HSTS —
Strict-Transport-Securityheader present,max-ageof at least one year,includeSubDomainsdirective set,preloadflagged if present (informational, not a recommendation). - Security headers —
X-Content-Type-Options: nosniffandReferrer-Policypresent; aServerheader 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>ortestssl.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 -Toutput 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.