#!/bin/bash # Nginx configuration static analysis test suite. # Usage: bash test.sh [nginx-dir] # Defaults to nginx/nginx relative to the repo root. NGINX_DIR="${1:-nginx/nginx}" PASS=0 FAIL=0 pass() { printf " PASS: %s\n" "$1" ((PASS++)) } fail() { printf " FAIL: %s\n" "$1" ((FAIL++)) } echo "=== nginx config static analysis ===" echo "Directory: $NGINX_DIR" echo "" # ────────────────────────────────────────────────────────────────── # 1. No deprecated TLS versions # ────────────────────────────────────────────────────────────────── echo "--- TLS version check ---" deprecated=$(grep -rnP 'TLSv1(?!\.[23])' "$NGINX_DIR" --include="*.conf" 2>/dev/null || true) if [ -n "$deprecated" ]; then fail "Deprecated TLS versions (TLSv1 or TLSv1.1) found:" printf '%s\n' "$deprecated" | sed 's/^/ /' else pass "No deprecated TLS versions" fi echo "" # ────────────────────────────────────────────────────────────────── # 2. No duplicate literal server_name values across all site configs # ────────────────────────────────────────────────────────────────── echo "--- Duplicate server_name check ---" duplicates=$(grep -rh --include="*.conf" 'server_name' "$NGINX_DIR/sites-available/" \ | grep -v '^\s*#' \ | sed 's/.*server_name\s*//' \ | sed 's/\s*;//' \ | tr ' ' '\n' \ | grep -vP '^\s*$|^_$|^~|^\*\.' \ | sort | uniq -d) if [ -n "$duplicates" ]; then fail "Duplicate server_name values:" printf '%s\n' "$duplicates" | sed 's/^/ /' else pass "No duplicate server_name values" fi echo "" # ────────────────────────────────────────────────────────────────── # 3. Every sites-available/*.conf has a sites-enabled symlink # ────────────────────────────────────────────────────────────────── echo "--- sites-enabled coverage check ---" missing_links=0 for conf in "$NGINX_DIR/sites-available/"*.conf; do name=$(basename "$conf") if [ ! -L "$NGINX_DIR/sites-enabled/$name" ]; then fail "No sites-enabled symlink for: $name" missing_links=1 fi done [ "$missing_links" -eq 0 ] && pass "All sites-available configs have sites-enabled symlinks" echo "" # ────────────────────────────────────────────────────────────────── # 4. No broken symlinks in sites-enabled # ────────────────────────────────────────────────────────────────── echo "--- Broken symlink check ---" broken=0 for link in "$NGINX_DIR/sites-enabled/"*; do [ -L "$link" ] || continue if [ ! -e "$link" ]; then fail "Broken symlink: $(basename "$link")" broken=1 fi done [ "$broken" -eq 0 ] && pass "No broken symlinks in sites-enabled" echo "" # ────────────────────────────────────────────────────────────────── # 5. No orphaned sites-enabled symlinks (no matching sites-available file) # ────────────────────────────────────────────────────────────────── echo "--- Orphaned symlink check ---" orphaned=0 for link in "$NGINX_DIR/sites-enabled/"*.conf; do [ -L "$link" ] || continue name=$(basename "$link") if [ ! -f "$NGINX_DIR/sites-available/$name" ]; then fail "Orphaned sites-enabled symlink: $name" orphaned=1 fi done [ "$orphaned" -eq 0 ] && pass "No orphaned sites-enabled symlinks" echo "" # ────────────────────────────────────────────────────────────────── # 6. No port-80 listeners in any custom server block # (port 80 is blocked at the firewall; all traffic is HTTPS only) # ────────────────────────────────────────────────────────────────── echo "--- Port 80 listener check ---" http_blocks=$(grep -rnP 'listen\s.*\b80\b' "$NGINX_DIR/sites-available/" \ | grep -v 'sites-available/default' \ | grep -v '^\s*#' || true) if [ -n "$http_blocks" ]; then fail "Port 80 listeners found in custom site configs:" printf '%s\n' "$http_blocks" | sed 's/^/ /' else pass "No port 80 listeners in custom server blocks" fi echo "" # ────────────────────────────────────────────────────────────────── # 7. ssl_certificate and ssl_certificate_key counts match per file # ────────────────────────────────────────────────────────────────── echo "--- SSL certificate directive pairing check ---" ssl_errors=0 for conf in "$NGINX_DIR/sites-available/"*.conf; do certs=$(grep -cP 'ssl_certificate\b(?!_key)' "$conf" 2>/dev/null || echo 0) keys=$(grep -c 'ssl_certificate_key' "$conf" 2>/dev/null || echo 0) if [ "$certs" != "$keys" ]; then fail "$(basename "$conf"): $certs ssl_certificate vs $keys ssl_certificate_key (must match)" ssl_errors=1 fi done [ "$ssl_errors" -eq 0 ] && pass "All ssl_certificate directives are correctly paired" echo "" # ────────────────────────────────────────────────────────────────── # 8. All plain-HTTP proxy_pass targets are local # (https:// proxy_pass is permitted for intentional external proxying, # e.g. CDN reverse-proxying to object storage over TLS) # ────────────────────────────────────────────────────────────────── echo "--- proxy_pass locality check ---" external=$(grep -rn 'proxy_pass\s\+http://' "$NGINX_DIR/sites-available/" \ | grep -v '#' \ | grep -vP 'proxy_pass\s+http://(127\.0\.0\.1|localhost|0\.0\.0\.0)' || true) if [ -n "$external" ]; then fail "Plain-HTTP proxy_pass to non-local target found:" printf '%s\n' "$external" | sed 's/^/ /' else pass "All plain-HTTP proxy_pass targets are local" fi echo "" # ────────────────────────────────────────────────────────────────── # 9. All SSL cert paths use /etc/letsencrypt/live/ # ────────────────────────────────────────────────────────────────── echo "--- SSL certificate path convention check ---" nonstandard_certs=$(grep -rn 'ssl_certificate' "$NGINX_DIR/sites-available/" \ | grep -v '#' \ | grep -vP '/etc/letsencrypt/live/' || true) if [ -n "$nonstandard_certs" ]; then fail "SSL certs not under /etc/letsencrypt/live/:" printf '%s\n' "$nonstandard_certs" | sed 's/^/ /' else pass "All SSL certificate paths use /etc/letsencrypt/live/" fi echo "" # ────────────────────────────────────────────────────────────────── # 10. ssl_certificate uses fullchain.pem, ssl_certificate_key uses privkey.pem # ────────────────────────────────────────────────────────────────── echo "--- SSL certificate filename convention check ---" cert_name_errors=0 wrong_certs=$(grep -rnP 'ssl_certificate\b(?!_key)' "$NGINX_DIR/sites-available/" \ | grep -v '#' \ | grep -v 'fullchain\.pem' || true) wrong_keys=$(grep -rn 'ssl_certificate_key' "$NGINX_DIR/sites-available/" \ | grep -v '#' \ | grep -v 'privkey\.pem' || true) if [ -n "$wrong_certs" ]; then fail "ssl_certificate not using fullchain.pem:" printf '%s\n' "$wrong_certs" | sed 's/^/ /' cert_name_errors=1 fi if [ -n "$wrong_keys" ]; then fail "ssl_certificate_key not using privkey.pem:" printf '%s\n' "$wrong_keys" | sed 's/^/ /' cert_name_errors=1 fi [ "$cert_name_errors" -eq 0 ] && pass "All SSL certs use fullchain.pem / privkey.pem" echo "" # ────────────────────────────────────────────────────────────────── # 11. No server_name directives use raw IP addresses # ────────────────────────────────────────────────────────────────── echo "--- IP address as server_name check ---" ip_names=$(grep -rh --include="*.conf" 'server_name' "$NGINX_DIR/sites-available/" \ | grep -v '#' \ | sed 's/.*server_name\s*//' \ | sed 's/\s*;//' \ | tr ' ' '\n' \ | grep -P '^\d{1,3}(\.\d{1,3}){3}$' || true) if [ -n "$ip_names" ]; then fail "server_name uses raw IP addresses:" printf '%s\n' "$ip_names" | sed 's/^/ /' else pass "No server_name directives use raw IP addresses" fi echo "" # ────────────────────────────────────────────────────────────────── # 12. No conf.d files conflict with built-in nginx conf.d conventions # (i.e. no stray default.conf or catch-all templates left over) # ────────────────────────────────────────────────────────────────── echo "--- conf.d stray file check ---" stray=$(find "$NGINX_DIR/conf.d" -name "*.conf" \ | grep -vP '/(logging|tuning|cloudflare_ips)\.conf$' || true) if [ -n "$stray" ]; then fail "Unexpected files in conf.d (only logging.conf, tuning.conf, cloudflare_ips.conf expected):" printf '%s\n' "$stray" | sed 's/^/ /' else pass "conf.d contains only expected files" fi echo "" # ────────────────────────────────────────────────────────────────── # 13. Server blocks within each sites-available file are sorted # alphabetically by server_name (LC_ALL=C; regex/wildcard excluded) # ────────────────────────────────────────────────────────────────── echo "--- Alphabetical server_name order check ---" sort_errors=0 for conf in "$NGINX_DIR/sites-available/"*.conf; do [ "$(basename "$conf")" = "default" ] && continue mapfile -t actual < <(grep -P '^\s*server_name\s' "$conf" \ | grep -v '^\s*#' \ | sed 's/.*server_name\s*//' \ | sed 's/\s*;//' \ | awk '{print $1}' \ | grep -vP '^~|^\*\.|^_$') mapfile -t expected < <(printf '%s\n' "${actual[@]}" | LC_ALL=C sort) for ((i = 0; i < ${#actual[@]}; i++)); do if [ "${actual[$i]}" != "${expected[$i]}" ]; then fail "$(basename "$conf"): not sorted — found '${actual[$i]}', expected '${expected[$i]}'" sort_errors=1 break fi done done [ "$sort_errors" -eq 0 ] && pass "All sites-available files have alphabetically sorted server blocks" echo "" # ────────────────────────────────────────────────────────────────── # Summary # ────────────────────────────────────────────────────────────────── echo "===================================" printf "Results: %d passed, %d failed\n" "$PASS" "$FAIL" echo "===================================" [ "$FAIL" -gt 0 ] && exit 1 exit 0