A client handed me an sshd_config that had been patched by four different admins over six years. Root login was back on, the ciphers list had drifted to CBC, and nobody could say when. `sshd -t` only checks syntax, so none of it ever failed a check. I wrote a small audit script that reads the effective config the way sshd actually parses it and prints a pass/fail table.
Official docs: man sshd_config | OpenSSH site
Source: openssh/openssh-portable on GitHub
Why a script and not a grep
Three things break naive checks. First, Include lines pull in drop-in files, so the real values live across several files. Second, anything after a Match block is conditional, so grepping the file top to bottom picks up settings that only apply to some clients. Third, sshd keywords are case-insensitive but configs mix cases freely, MACs today, macs tomorrow. The script resolves all three before it checks anything.
Requirements
- Bash 4 or newer (for lowercase expansion and associative arrays)
- Read access to the target sshd_config and any Include files
- No root needed. Pass the file path as the only argument
The script
Save it as sshd-audit.sh. It walks the Include tree first, then parses with last-match-wins semantics, stops at the first Match block, and lowercases every keyword before storing it.
#!/usr/bin/env bash
# sshd-audit.sh - check an sshd_config against common hardening recommendations
# Usage: bash sshd-audit.sh [path-to-sshd_config]
# Defaults to /etc/ssh/sshd_config when no path is given.
set -u
CFG="${1:-/etc/ssh/sshd_config}"
if [[ ! -r "$CFG" ]]; then
echo "ERROR: cannot read $CFG" >&2
exit 2
fi
# ── build effective config ──────────────────────────────────
# Expand Include lines first (globs, like the sshd parser does), keeping
# the rest of the ordering intact. Track visited files so circular
# includes cannot loop forever.
declare -a FILES=()
declare -A SEEN=()
collect() {
local f="$1"
[[ -n "${SEEN[$f]:-}" ]] && return
SEEN["$f"]=1
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}" # strip comments
[[ -z "${line// }" ]] && continue # skip blanks
if [[ "$line" =~ ^[[:space:]]*Include[[:space:]]+(.*)$ ]]; then
for inc in ${BASH_REMATCH[1]}; do
# shellcheck disable=SC2086
collect $inc # recursive for nested includes
done
fi
done < "$f"
FILES+=("$f")
}
collect "$CFG"
# Parse an option: last match wins, and we stop at the first Match block
# because directives inside it are conditional, not global.
# Keys are lowercased: sshd_config keywords are case-insensitive and configs
# in the wild mix cases (MACs vs Macs).
declare -A KV=()
in_match=0
for f in "${FILES[@]}"; do
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
[[ -z "${line// }" ]] && continue
if [[ "$line" =~ ^[[:space:]]*(Match|Host)[[:space:]] ]]; then
[[ "${BASH_REMATCH[1]}" == "Match" ]] && in_match=1
continue
fi
[[ "$in_match" == 1 ]] && continue
if [[ "$line" =~ ^[[:space:]]*([A-Za-z][A-Za-z0-9]*)[[:space:]]+(.*)$ ]]; then
key="${BASH_REMATCH[1],,}" # lowercase the keyword
KV["$key"]="${BASH_REMATCH[2]}"
fi
done < "$f"
done
get() { echo "${KV[$1]:-}"; }
# ── checks ──────────────────────────────────────────────────
PASS=0; WARN=0; FAIL=0
report() { # severity label check value expected
local sev="$1" label="$2" val="$3" exp="$4"
case "$sev" in
PASS) ((PASS++)); mark="[ OK ]";;
WARN) ((WARN++)); mark="[WARN]";;
FAIL) ((FAIL++)); mark="[FAIL]";;
esac
printf '%-6s %-36s %-18s (expected: %s)\n' "$mark" "$label" "${val:--}" "$exp"
}
echo "=== sshd-audit.sh: $CFG ==="
# account-level
report "$( [[ "$(get permitrootlogin)" == "no" ]] && echo PASS || echo FAIL )" \
"PermitRootLogin" "$(get permitrootlogin)" "no"
report "$( [[ "$(get passwordauthentication)" == "no" ]] && echo PASS || echo FAIL )" \
"PasswordAuthentication" "$(get passwordauthentication)" "no"
report "$( [[ "$(get permitemptypasswords)" == "no" ]] && echo PASS || echo FAIL )" \
"PermitEmptyPasswords" "$(get permitemptypasswords)" "no"
report "$( [[ "$(get permitrootlogin)" == "yes" ]] && echo FAIL || echo PASS )" \
"RootLogin via key only" "$(get permitrootlogin)" "no / prohibit-password"
# brute force / session hygiene
mt="$(get maxauthtries)"
report "$( [[ -n "$mt" && "$mt" -le 3 ]] && echo PASS || echo WARN )" \
"MaxAuthTries" "$mt" "<= 3"
lgt="$(get logingracetime)"
report "$( [[ "$lgt" =~ ^[0-9]+$ ]] && [[ "$lgt" -le 60 ]] && echo PASS || echo WARN )" \
"LoginGraceTime" "$lgt" "<= 60"
# forwardings / exposure
report "$( [[ "$(get x11forwarding)" == "no" ]] && echo PASS || echo FAIL )" \
"X11Forwarding" "$(get x11forwarding)" "no"
report "$( [[ "$(get allowtcpforwarding)" == "no" ]] && echo PASS || echo WARN )" \
"AllowTcpForwarding" "$(get allowtcpforwarding)" "no"
report "$( [[ "$(get gatewayports)" == "no" ]] && echo PASS || echo WARN )" \
"GatewayPorts" "$(get gatewayports)" "no"
# algorithms: blacklist known-weak ones (any hit = FAIL).
# "sha1" catches diffie-hellman-group*-sha1 and hmac-sha1, both deprecated.
WK="3des-cbc|aes128-cbc|aes192-cbc|aes256-cbc|arcfour|blowfish|cast128|rmd160|md5|sha1"
ciph="$(get ciphers)"; macs="$(get macs)"; kex="$(get kexalgorithms)"
if [[ -z "$ciph" ]]; then
report WARN "Ciphers" "(default)" "explicitly set"
report WARN "Macs" "(default)" "explicitly set"
report WARN "KexAlgorithms" "(default)" "explicitly set"
else
if echo "$ciph" | grep -qiE "$WK"; then report FAIL "Ciphers" "$ciph" "no weak ciphers";
else report PASS "Ciphers" "$ciph" "no weak ciphers"; fi
if echo "$macs" | grep -qiE "$WK"; then report FAIL "Macs" "$macs" "no weak macs";
else report PASS "Macs" "$macs" "no weak macs"; fi
if echo "$kex" | grep -qiE "$WK"; then report FAIL "KexAlgorithms" "$kex" "no weak kex";
else report PASS "KexAlgorithms" "$kex" "no weak kex"; fi
fi
# basics that should always exist
report "$( [[ -n "$(get loglevel)" ]] && echo PASS || echo WARN )" \
"LogLevel" "$(get loglevel)" "set (INFO/VERBOSE)"
report "$( [[ "$(get usedns)" == "no" ]] && echo PASS || echo WARN )" \
"UseDNS" "$(get usedns)" "no"
report "$( [[ "$(get permituserenvironment)" == "no" ]] && echo PASS || echo WARN )" \
"PermitUserEnvironment" "$(get permituserenvironment)" "no"
echo "-------------------------------------------"
total=$((PASS + WARN + FAIL))
printf 'PASS %d | WARN %d | FAIL %d (of %d checks)\n' "$PASS" "$WARN" "$FAIL" "$total"
if (( FAIL > 0 )); then exit 2; fi
if (( WARN > 0 )); then exit 1; fi
exit 0Exit codes are cron-friendly: 0 all clean, 1 warnings only, 2 at least one failure.
Demo: the config that drifted
This demo config is synthetic (fake IPs from the RFC 5737 test ranges), but it is the shape of every sshd_config I have inherited. Note the Include line and the Match block at the bottom: a naive grep would read MaxAuthTries 20 as global even though it only applies to one subnet.
# /etc/ssh/sshd_config - it drifted. This is the mess I found.
Port 22
PermitRootLogin yes
PasswordAuthentication yes
PermitEmptyPasswords yes
X11Forwarding yes
MaxAuthTries 6
LoginGraceTime 120
Ciphers aes128-cbc,aes256-cbc,[email protected]
MACs hmac-md5,hmac-sha2-256
KexAlgorithms diffie-hellman-group14-sha1,curve25519-sha256
Include /etc/ssh/sshd_config.d/*.conf
UseDNS yes
PermitUserEnvironment yes
# internal jump host staff get extra tries. Match = conditional, not global.
Match Address 203.0.113.0/24
MaxAuthTries 20
PermitRootLogin yes=== sshd-audit.sh: sshd_config.lazy ===
[FAIL] PermitRootLogin yes (expected: no)
[FAIL] PasswordAuthentication yes (expected: no)
[FAIL] PermitEmptyPasswords yes (expected: no)
[FAIL] RootLogin via key only yes (expected: no / prohibit-password)
[WARN] MaxAuthTries 6 (expected: <= 3)
[WARN] LoginGraceTime 120 (expected: <= 60)
[FAIL] X11Forwarding yes (expected: no)
[WARN] AllowTcpForwarding - (expected: no)
[ OK ] GatewayPorts no (expected: no)
[FAIL] Ciphers aes128-cbc,aes256-cbc,[email protected] (expected: no weak ciphers)
[FAIL] Macs hmac-md5,hmac-sha2-256 (expected: no weak macs)
[FAIL] KexAlgorithms diffie-hellman-group14-sha1,curve25519-sha256 (expected: no weak kex)
[WARN] LogLevel - (expected: set (INFO/VERBOSE))
[WARN] UseDNS yes (expected: no)
[WARN] PermitUserEnvironment yes (expected: no)
-------------------------------------------
PASS 1 | WARN 6 | FAIL 8 (of 15 checks)
Demo: the fixed config
Same script, hardened config. Every value explicit, so nothing relies on OpenSSH defaults that change between versions.
# /etc/ssh/sshd_config - after the ansible run
Port 22
PermitRootLogin no
PasswordAuthentication no
PermitEmptyPasswords no
PubkeyAuthentication yes
X11Forwarding no
AllowTcpForwarding no
GatewayPorts no
MaxAuthTries 3
LoginGraceTime 30
LogLevel VERBOSE
Ciphers [email protected],[email protected],[email protected]
MACs [email protected],[email protected]
KexAlgorithms curve25519-sha256,[email protected],diffie-hellman-group16-sha512
UseDNS no
PermitUserEnvironment no
Match Address 198.51.100.0/24
PasswordAuthentication no=== sshd-audit.sh: sshd_config.hardened ===
[ OK ] PermitRootLogin no (expected: no)
[ OK ] PasswordAuthentication no (expected: no)
[ OK ] PermitEmptyPasswords no (expected: no)
[ OK ] RootLogin via key only no (expected: no / prohibit-password)
[ OK ] MaxAuthTries 3 (expected: <= 3)
[ OK ] LoginGraceTime 30 (expected: <= 60)
[ OK ] X11Forwarding no (expected: no)
[ OK ] AllowTcpForwarding no (expected: no)
[ OK ] GatewayPorts no (expected: no)
[ OK ] Ciphers [email protected],[email protected],[email protected] (expected: no weak ciphers)
[ OK ] Macs [email protected],[email protected] (expected: no weak macs)
[ OK ] KexAlgorithms curve25519-sha256,[email protected],diffie-hellman-group16-sha512 (expected: no weak kex)
[ OK ] LogLevel VERBOSE (expected: set (INFO/VERBOSE))
[ OK ] UseDNS no (expected: no)
[ OK ] PermitUserEnvironment no (expected: no)
-------------------------------------------
PASS 15 | WARN 0 | FAIL 0 (of 15 checks)
Bugs I hit while writing it
First version stored keywords as written and looked them up as macs. The config said MACs hmac-md5,hmac-sha2-256, so the lookup missed, the weak-MACs row printed as a dash, and the check silently passed. That is the whole reason for lowercasing keys. The other one was dumber: my report function only counted PASS, WARN and FAIL, but one check returned OK, so the final tally said 14 of 15 checks while 15 rows printed. It printed the right answer and counted the wrong one. Both fixed now, and both worth knowing about if you write your own: case is a trap, and so is returning a status string your table does not recognize.
Using it in cron
Weekly check on a server list is just a loop. Exit 2 sends mail because the list prints to stderr.
for host in web-01 db-01 cache-01; do
ssh root@$host "bash -s" < sshd-audit.sh /etc/ssh/sshd_config \
|| echo "$host needs attention"
doneIf you want to lock SSH down before running this, my IP-restriction walkthrough covers firewall-side control, and the certificate expiry checker is the sibling script I run next to this one. Both are the same shape: read the real state, print a table, exit nonzero when something is wrong.