ssh-audit
ssh-audit
reads what an SSH endpoint actually negotiates —
its key-exchange, host-key, cipher and MAC algorithms —
and grades each as info / warn / fail,
alongside non-crypto checks (the DHEat DoS and Terrapin).
It only reports.
This skill covers running it, reading it,
and turning a finding into a concrete sshd_config directive —
but it stays out of how you deploy that directive.
Whether the fix lands via a config-management role, a hand edit,
or anything else
is the host project's decision, not this skill's.
When in doubt, ask the operator
If a scan result is ambiguous, a tool is missing, or a fix would trade away compatibility you can't confirm is safe — stop and ask the operator instead of guessing. Locking yourself out of a host you can only reach over SSH is expensive; one question is cheaper. This applies to every step below.
Authorization
A scan is active traffic against a live service —
run it only against hosts you own or are explicitly cleared to test.
A default scan already includes a DHEat rate-test —
a burst of dozens of connections
(pass --skip-rate-test to suppress it).
And --dheat is a real, sustained DoS attack against the target
(--conn-rate-test floods connections to measure DHEat susceptibility) —
never point either at a host you don't control.
Model
The scan runs from your machine against a remote endpoint —
nothing is installed on the target.
An SSH server exposes one global crypto config, not a per-interface one,
so reaching it by public IP or over a VPN / overlay address
hits the same sshd and yields the same verdict.
Install
Check for it first — command -v ssh-audit.
If it's missing, install it with the mise skill;
pipx:ssh-audit is a mise tool spec (mise's pipx backend), not a shell command.
With the mise binary but no mise skill, add and pin it yourself:
mise use -g "pipx:ssh-audit@<version>" # resolve <version> via `mise latest`
ssh-audit is pure-stdlib Python,
so pipx gives it an isolated venv with zero dependencies
and puts nothing in system site-packages.
Never pip install it into site-packages, and never brew / curl-pipe it.
If there's no mise at all, say so and stop — don't reach for another installer.
Running a scan
Server (the common case):
ssh-audit <host> # default port 22
ssh-audit <host> -p 2222 # non-standard port
The default scan runs the DHEat rate-test — a burst of dozens of connections.
On a host you depend on SSH access to, or one running fail2ban / sshguard,
that burst can get your source IP banned (a self-lockout):
scan such hosts with --skip-rate-test, or allowlist your IP first.
Keep the default (with the rate-test) for the first scan when it's safe —
it's what produces the DHEat verdict; only re-scans routinely skip it.
Client audit — ssh-audit listens (default port 2222) and grades the next SSH client that connects to it:
ssh-audit -c # listens on :2222
ssh -p 2222 user@<this-machine> # from the client you want graded
The client connection won't complete —
ssh-audit only needs the handshake to read what the client offers.
(-c opens a transient listener on :2222 on this machine.)
Flags worth knowing:
-t N— connection/read timeout, default 5s. Raise it when an outbound firewall makes the first connection slow.-n— no colours; use it whenever you pipe or capture output.--skip-rate-test— skip the connection-flood DHEat probe (faster, and see Re-scanning below).-l fail/-l warn— raise the minimum level shown. Don't set-l failfor a real audit: it hides the very warnings this skill wants you to judge. (Lower-case-l; the upper-case-Llists policies — a different flag.)--lookup <alg,…>— explain specific algorithm names without scanning.-p N,--socks5 host:port— port and SOCKS proxy (scanning through--socks5implies--skip-rate-test, so no DHEat verdict).
Capture the full report to a file —
tee it to a mktemp -d path —
rather than … | tail, which throws away sections you'll need.
(If the tee and mktemp-d skills are present, follow them.)
Reading the report
A trimmed scan looks like this — banner, graded lines, then a rec block:
(gen) banner: SSH-2.0-OpenSSH_9.7p1
(kex) sntrup761x25519-sha512@openssh.com -- [info] hybrid post-quantum kex
(kex) ecdh-sha2-nistp256 -- [fail] using weak elliptic curves
(enc) aes256-ctr -- [info]
(mac) hmac-sha1 -- [fail] using broken SHA-1 hash
(rec) -ecdh-sha2-nistp256 -- kex algorithm to remove
(rec) -hmac-sha1 -- mac algorithm to remove
The body is grouped —
(kex) key exchange,
(key) host-key,
(enc) ciphers,
(mac) MACs —
each line tagged [info], [warn], or [fail],
followed by a (rec) block of suggested add/remove changes
and (nfo) notes for the non-crypto findings.
Exit code is the CI gate: 0 is clean, non-zero means the run needs a look.
ssh-audit documents 1 as a connection error, 2 as warnings-only,
and 3 as at least one failure —
so wire non-zero as the gate rather than a specific number,
but don't read every non-zero as a crypto finding:
1 means the scan itself didn't run
(an unreachable host, or the rate-limit stall on a re-scan),
so confirm the run actually connected before chasing a "crypto failure".
Treat [fail] lines as must-fix —
broken or backdoor-suspect primitives:
NIST-curve kex, ECDSA host keys, SHA-1 MACs, ssh-rsa, CBC ciphers
(some grade [warn] in some versions — trust the tag on the line, not this list).
Read warnings with judgement — they are often intentional.
A server hardened to the vendor guide still warns on non-PQ algorithms
(curve25519-sha256, the classical DH groups)
because the guide keeps them as fallback for older clients,
while the post-quantum kex (sntrup761x25519-…) is offered first
and used when both peers support it.
Chasing exit 0 means removing those fallbacks —
a real compatibility trade-off, not a free win (see below).
For the non-crypto notes:
kex-strict-…-v00@openssh.com in the kex list
means the target is protected against Terrapin —
but the note reminds you the client must support it too.
A DHEat note means connection throttling is weak (see fixes).
From findings to fixes
Do not hand-write algorithm lists.
ssh-audit ships canonical, vendor-matched sshd_config snippets:
ssh-audit --list-hardening-guides # the exact platform names
ssh-audit --get-hardening-guide "<platform>" # one of those names, verbatim
--get-hardening-guide runs offline — no scan, no network —
and prints ready KexAlgorithms / Ciphers / MACs / HostKeyAlgorithms /
PubkeyAcceptedAlgorithms / RequiredRSASize lines,
plus host-key and moduli steps.
Match the platform to the target's OS + OpenSSH.
The banner shows the OpenSSH version;
if it doesn't name the distro (many show only OpenSSH_9.7),
take the OS from the host you administer and pick the closest guide.
Prior guide revisions: --get-hardening-guide "<platform> (version N)"
(N is the guide's revision number, not an OpenSSH version).
Reading the guide correctly:
It is compatibility-preserving, not PQ-only. It clears failures but deliberately keeps some non-PQ algorithms, so a guide-hardened server still scans as warnings (exit
2), by design. Going PQ-only (leaving onlysntrup761x25519-…) reaches exit0but locks out any client without post-quantum kex — decide that trade-off deliberately, don't chase the green number.Weak host key (e.g. an ECDSA NIST key): restricting
HostKeyAlgorithmsto the good types stops the server offering it — ssh-audit then stops flagging it — without touching the key file. Keep at least one strong type you actually hold a key for (anssh_host_ed25519_key/ssh_host_rsa_keyon disk) — restrict to a type with no key file and sshd has nothing to offer.sshd -tdoes not catch this, so verify by hand:sudo sshd -T | grep -i hostkeymust list a type whose key file exists. Prefer this to regenerating host keys: regeneration changes the host fingerprint, which breaks every client'sknown_hostsand trips host-key-changed warnings.GEX (group exchange) / moduli: if you keep
diffie-hellman-group-exchange-sha256,/etc/ssh/modulion the target must contain only strong primes (--get-hardening-guideemits this step too). Editing that file is destructive — back up, guard against an empty result, and run it as root in one shell (a plainsudo awk … > fileopens the redirect as your user and fails):cp /etc/ssh/moduli /etc/ssh/moduli.bak awk '$5 >= 3071' /etc/ssh/moduli > /etc/ssh/moduli.strong [ -s /etc/ssh/moduli.strong ] && mv /etc/ssh/moduli.strong /etc/ssh/moduliUse the guide's threshold verbatim — don't recompute it: the size field is the bit length minus one (that's why
3071keeps 3072-bit primes), so a naive>= 4096would mis-filter and could empty the file. And OpenSSH has a hard-coded 2048-bit GEX fallback that config alone can't close, so dropping GEX entirely is the cleaner fix when you don't need it.DHEat (CVE-2002-20001): throttle new connections at the firewall —
ufw limiton the SSH port, or an iptablesrecent-module rule — rather thanPerSourceMaxStartups 1, which ssh-audit itself warns breaks clients on connection bursts and false-positives its own rate test.ufw limitcaps ~6 connections / 30 s per source IP, so clients behind one shared NAT / CI egress share that budget — size the rule for them.
Some of these fixes are an sshd_config directive,
others a firewall rule or a moduli edit —
this skill gives you the fix and the reason.
Note the shift: the scan is remote and read-only,
but applying and verifying a fix needs an administrative (usually root)
shell on the target — if you were only cleared to scan it,
stop at the findings and hand them off.
How you roll it out —
config-management, a manual edit, a drop-in file —
is the host project's call; this skill doesn't prescribe the mechanism.
Verify before you apply (don't lock yourself out)
Restricting SSH crypto on a host you reach only over SSH is a lockout risk.
Close it before deploying, not after —
and mind where each command runs:
sshd -t / sshd -T and the moduli edit run on the target, as root
(SSH in first — they read the target's own OpenSSH build and host keys,
not your laptop's; sshd lives in /usr/sbin, and a non-root run fails
in a way that looks like a rejected directive but is a permission error),
while ssh -Q runs on the client you connect from.
On the target (root) — dry-run the exact directives, writing nothing. Each guide line
Directive valuebecomes one-o 'Directive=value':sudo sshd -t -o 'KexAlgorithms=curve25519-sha256,…' \ -o 'Ciphers=…' -o 'MACs=…' -o 'PubkeyAcceptedAlgorithms=…'A non-zero exit names a directive that build rejects — e.g.
gss-*algorithms need a GSSAPI-enabled build. A pass on your laptop's sshd proves nothing about a Linux server, and this can't see whether the client set still intersects — check that next.On every client that must keep access — confirm it can still negotiate.
ssh -Qreports only the local OpenSSH binary (not itsssh_configrestrictions, and nothing about other clients):ssh -Q kex; ssh -Q cipher; ssh -Q mac; ssh -Q key ssh -Q PubkeyAcceptedAlgorithms # older clients: ssh -Q key / ssh -Q sigCheck the intersection with each new server allow-list is non-empty on every axis — for every client population, not just your laptop: other admins' keys (an RSA-only holder), automation / CI behind a shared egress IP, PuTTY, mobile apps, non-OpenSSH libraries. Your own session staying up won't reveal that you locked them out. The silent-lockout axis is
PubkeyAcceptedAlgorithms— each auth key's type must be in it: an ed25519 key needsssh-ed25519, an RSA keyrsa-sha2-*, and a certificate needs the matching…-cert-v01@openssh.com.Apply with
reload, notrestart.systemctl reload ssh(the unit issshon Debian/Ubuntu,sshdelsewhere) sends SIGHUP — sshd re-reads the config and rotates listeners without ever signalling existing connections, so your session is safe; on many distros it also runssshd -tfirst, so a broken config fails the reload and the old good listener stays up. A fullrestartusually spares established sessions on modern systemd hosts, but not always (minimal / container images can drop them) — don't lean on it as your net. Either way: hold a second session open, keep the serial / web console ready, and test a fresh connection (not a reused multiplexed master) first.On the target — know what's actually in effect.
sshd -Tdumps the merged config from the files — it confirms your drop-in isn't shadowed (drop-ins insshd_config.d/*.confare first-match-wins in lexical order), but it is not proof the daemon reloaded; the live check is reload + re-scan. On socket-activated hosts (systemctl is-active ssh.socket) each connection spawns a fresh sshd, soreloadcan be a no-op — there the fresh-connection test / re-scan is the confirmation.
Order of operations on a remote host:
- baseline —
sudo sshd -Ton the target; - write the change (drop-in / firewall rule), backing up anything you edit;
- validate on the target:
sudo sshd -t(the written file), andsudo sshd -t -o …to pre-check a set before writing — syntax + build; ssh -Q …per client population — intersection non-empty on every axis;- open a second session; confirm console access;
sudo systemctl reload ssh(orsshd— the unit name from the note above);- test a fresh connection from each client population — if any fails, revert in your still-open session and reload again, then diagnose;
- re-scan (with
--skip-rate-testif you added a throttle in step 2).
Re-scanning after hardening
Re-scan to confirm failures are gone.
One catch:
once you've rate-limited the SSH port for DHEat,
ssh-audit's own connection flood trips that limit,
so a full re-scan may stall or show connection errors.
Use --skip-rate-test on re-scans —
the crypto verdict is unaffected.
The trade-off: skipping it means you don't re-verify the DHEat throttle
you just added, so confirm that rule independently —
check the firewall rule and its hit counters,
or a rapid-connect probe from a throwaway source you can afford to get banned.
Policy gate (CI)
Beyond the pass/fail exit code, ssh-audit can gate against a policy:
ssh-audit -L # list built-in policies
ssh-audit -M baseline.txt <host> # snapshot this host as the baseline
ssh-audit -P baseline.txt <host> # later: assert a host still matches
ssh-audit -P "Built-In Policy Name" <host>
-j / -b give JSON / batch output for a CI job to parse.
A failing policy check exits non-zero — wire that as the gate.
Gotchas
- Outbound firewall (Little Snitch, LuLu, …) cuts a fresh CLI
the first time it connects —
the pipx venv's Python is a new binary, so allow it,
and raise
-tif the prompt pushes the first connection past the timeout. Offline commands (--get-hardening-guide,--list-hardening-guides,--lookup) need no network and won't prompt. - Piped output loses colour meaning — pass
-nand read the level tags. - Overlay vs public IP hit the same
sshd(config is global), so scan from whichever path reaches the SSH port.
Reporting
After a scan, summarise: the target and its banner (OS + OpenSSH version), the failures (must-fix) and which fixes clear them, the warnings that are intentional (kept for compatibility) versus any that are actionable, the recommended directives and the guide they came from, and the exit code.