Bash Script to Audit sshd_config Hardening Gaps

– min read

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

  1. Bash 4 or newer (for lowercase expansion and associative arrays)
  2. Read access to the target sshd_config and any Include files
  3. 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 0


Exit 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)
sshd-audit.sh output on a weakened sshd_config showing 8 failed checks
Sandbox run: audit of a deliberately weakened sshd_config (synthetic demo data)


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)
sshd-audit.sh output on a hardened sshd_config showing all 15 checks passing
Sandbox run: audit of a hardened sshd_config (synthetic demo data)


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"
done


If 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.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page