sshd_lint
A zero-dependency static analyzer for OpenSSH sshd_config files. Audits configs
offline โ no root, no network, no live server needed.
sshd_lint evaluates your SSH server configuration against industry-standard security baselines, including the CIS Benchmark for Linux, Mozilla OpenSSH Guidelines, and NIST SP 800-53.
๐ก Motivation
This project was born out of a recurring practical need: quickly auditing an SSH server configuration whenever setting up a new test VM or reviewing a production host.
Existing tools either require root on the live system or an open network connection to the server. sshd_lint takes the opposite approach โ it reads the config file and nothing else, which means it works on a copy pulled from a machine you cannot log into, inside a container image, or in a CI pipeline where no SSH server is running at all.
๐ค AI-Assisted Project
This tool was conceptualized and developed with the assistance of Artificial Intelligence.
AI was used for code generation, logic refinement, and edge-case handling (such as cumulative directives and Match block scoping), under human direction and review.
โจ Features
- Zero Dependencies โ Built entirely on the Python standard library. No
pip install. - Context-Aware Parsing โ Understands OpenSSH semantics:
Matchblocks,Match Allreset, directive shadowing, andIncludeglob expansion. - Scoped Match Block Findings โ Distinguishes between global misconfigurations and risks that apply only to specific users, addresses, or groups.
- Duplicate Directive Detection โ Warns when a directive appears more than once globally, since sshd silently uses only the first occurrence.
- Detailed, Actionable Reports โ Explains what is wrong, why it matters, and references the relevant standard.
- CI/CD Ready โ Structured JSON output, and exit codes that separate a security verdict from an operational failure.
- Version-Aware Rules โ Adjusts expectations based on target OpenSSH version.
๐ฆ Requirements
- Python 3.9+
- No external packages
โ๏ธ Installation
Since there are no external dependencies, you can run it directly:
wget https://raw.githubusercontent.com/capitan0n/sshd-lint/main/sshd_lint.py -O sshd_lint
chmod +x sshd_lint
./sshd_lint
Or clone the repository:
git clone https://github.com/capitan0n/sshd-lint.git
cd sshd-lint
python sshd_lint.py
๐ Sample Output
$ sshd_lint /etc/ssh/sshd_config --severity medium --compact
sshd_lint 1.4.0 โ /etc/ssh/sshd_config
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Findings: 5 CRITICAL: 1 HIGH: 1 MEDIUM: 3
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
[CRITICAL] PermitRootLogin (line 2)
Current value : yes
Issue : Root login over SSH is permitted.
[HIGH] PasswordAuthentication (line 3)
Current value : yes
Issue : Password authentication is enabled.
[MEDIUM] MaxAuthTries (line 5)
Current value : 8
Issue : MaxAuthTries is 8 โ recommended โค 4.
[MEDIUM] AllowUsers / AllowGroups
Current value : <not set>
Issue : No user or group allowlist is defined.
[MEDIUM] X11Forwarding (line 4)
Current value : yes
Issue : X11 forwarding is enabled.
Without --compact, each finding also carries a Why it matters explanation and the
standards it references. Colors are enabled automatically when writing to a terminal and
disabled when piping or redirecting.
๐ Usage
Analyze the default system SSH config:
python sshd_lint.py
Analyze a specific file:
python sshd_lint.py /path/to/sshd_config
Filter by severity (only HIGH and above):
python sshd_lint.py --severity high
Compact output (hides explanations โ useful for quick scans):
python sshd_lint.py --compact
JSON output for pipeline integration:
python sshd_lint.py --format json
The JSON output is self-contained โ exit_code and a per-severity summary are included at the top level so consumers don't need to capture $? separately:
{
"exit_code": 2,
"summary": {
"CRITICAL": 1,
"HIGH": 1,
"MEDIUM": 0,
"LOW": 0,
"INFO": 0
},
"findings": [
{
"severity": "HIGH",
"directive": "PasswordAuthentication",
"value": "yes",
"line": 3,
"scope": "global",
"message": "Password authentication is enabled.",
"detail": "Password authentication is vulnerable to brute-force and credential-stuffing attacks. Disable it and use public-key authentication exclusively: 'PasswordAuthentication no'.",
"references": ["CIS Benchmark for Linux", "Mozilla OpenSSH Guidelines"]
}
]
}
Note that exit_code in the JSON only ever carries the findings verdict (0, 1 or 2).
Operational failures โ a bad flag or a missing config file โ produce no JSON at all, so a
document that parses is always a real report.
Filter with jq:
# Only CRITICAL findings
python sshd_lint.py --format json | jq '.findings[] | select(.severity == "CRITICAL")'
# Summary only
python sshd_lint.py --format json | jq '.summary'
# Read exit code from JSON instead of $?
python sshd_lint.py --format json | jq '.exit_code'
Audit a config copied from a remote server, resolving Includes from the live system:
scp user@server:/etc/ssh/sshd_config /tmp/audit/sshd_config
python sshd_lint.py /tmp/audit/sshd_config --base-dir /etc/ssh
๐งฐ CLI Flags
| Flag | Short | Description |
|---|---|---|
config |
โ | Path to sshd_config (default: /etc/ssh/sshd_config) |
--severity |
-s |
Minimum severity: critical, high, medium, low, info (default: info) |
--format |
-f |
Output format: text or json (default: text) |
--compact |
-c |
Hide explanations and references for cleaner output |
--no-color |
โ | Disable ANSI colors |
--openssh-version |
โ | Target OpenSSH version (e.g. 8.9) for version-aware rule adjustments |
--base-dir |
โ | Base directory for Include resolution. Default: same directory as the config file |
--help |
-h |
Show usage and exit |
--version |
-V |
Show version and exit |
-Vis used for--versionso that-vstays free for a future verbosity flag, following the common convention where-vmeans verbose.
๐ฆ Exit Codes
Exit codes fall into two groups. 0โ2 are the security verdict; 64 and 66
signal that the tool could not run at all. Keeping them separate means a typo in a flag
can never be mistaken by a pipeline for a critical finding.
| Code | Meaning |
|---|---|
0 |
No findings at or above the requested severity threshold |
1 |
Findings exist, but none are HIGH or CRITICAL |
2 |
At least one HIGH or CRITICAL finding โ pipeline should fail |
64 |
Usage error โ unrecognized flag or invalid argument (EX_USAGE) |
66 |
Config file not found or unreadable (EX_NOINPUT) |
64 and 66 follow the conventional values from BSD sysexits.h.
When using --format json, the verdict is also embedded in the JSON output as
exit_code, so the report is fully self-contained and readable by downstream tools
without capturing $?.
GitHub Actions
CI systems treat any non-zero exit code as failure, which would collapse the
distinction between 1 and 2. Translate the verdict explicitly:
- name: Lint SSH config
run: |
code=0
python sshd_lint.py /etc/ssh/sshd_config --format json > report.json || code=$?
cat report.json
# Fail only on HIGH/CRITICAL. Exit 1 = minor findings, informational.
if [ "$code" -ge 2 ]; then
echo "::error::HIGH or CRITICAL findings in sshd_config"
exit 1
fi
|| code=$? does two jobs: it captures the exit code, and it stops bash -e (the default
shell for run: steps) from aborting the script the moment the linter returns non-zero.
cat must come after the capture, since $? only holds the status of the most recent
command.
๐งช Rules Evaluated
Parse & File Handling
- Rule 00: Include resolution problems โ unreadable files, an Include glob matching more than 500 files (refused outright), an Include glob matching 0 files, and lines that couldn't be parsed as a directive
Authentication
- Rule 01:
PermitRootLogin - Rule 02:
PasswordAuthentication - Rule 03:
PermitEmptyPasswords - Rule 04:
ChallengeResponseAuthentication - Rule 05:
PubkeyAuthentication - Rule 06:
HostbasedAuthentication/IgnoreRhosts
Access Control
- Rule 10:
LoginGraceTime - Rule 11:
MaxAuthTries - Rule 12:
MaxSessions - Rule 13:
MaxStartups - Rule 14:
AllowUsers/AllowGroups
Forwarding & Tunneling
- Rule 20:
X11Forwarding - Rule 21:
AllowTcpForwarding - Rule 22:
AllowAgentForwarding - Rule 23:
GatewayPorts - Rule 24:
PermitTunnel
Logging & Auditing
- Rule 30:
LogLevel - Rule 31:
PrintLastLog
Cryptography
- Rule 40: Weak or deprecated Ciphers
- Rule 41: Weak or deprecated MACs
- Rule 42: Weak KexAlgorithms (key exchange)
- Rule 43: Deprecated HostKeyAlgorithms
- Rule 44: Deprecated
PubkeyAcceptedAlgorithms
Rules 40-44 understand OpenSSH's +/-/^ default-set syntax (e.g. Ciphers +arcfour
appends to the compiled-in default rather than replacing it) โ a weak algorithm is flagged
whether it fully replaces the list or is merely appended to it.
Miscellaneous
- Rule 50:
Banner - Rule 51:
StrictModes - Rule 52:
Port(default port 22) - Rule 53:
ClientAliveInterval/ idle session timeout - Rule 54:
UseDNS - Rule 55: Insecure directives inside
Matchblocks (scoped risk) - Rule 56: Duplicate global directives (shadowed by sshd)
- Rule 57:
PermitUserEnvironment
๐ How sshd_lint differs from similar tools
| Tool | How it works | Requires root / live system |
|---|---|---|
| Lynis | Runs live on the system, audits many aspects | Yes |
| ssh-audit | Connects to a live SSH server, tests its responses | Yes (network access) |
| sshd_lint | Reads the config file statically, offline | No |
sshd_lint is designed for offline auditing, CI/CD pipelines, and reviewing configs from remote systems without needing access to the live server.
โ ๏ธ Limitations
- Static analysis only โ does not connect to a live server or test actual behaviour.
- Match block conditions are not evaluated โ the condition string (e.g.
User anoncvs,Address 10.0.0.0/8) is recorded and reported, but sshd_lint cannot determine whether it applies to a given connection. - Include resolution requires filesystem access โ unreadable files and Include
globs matching over 500 files are reported as CRITICAL; a glob matching 0 files is
reported as INFO (often benign โ e.g. an empty
sshd_config.d/โ but worth a glance). - Relative
Includepaths resolve against--base-dir/ the config file's own directory, not real sshd's hardcoded/etc/ssh. For the common case of auditing the live/etc/ssh/sshd_configthese are identical. When auditing a copy of only the main file (without its snippets alongside it), pass--base-dir /etc/sshto resolve includes the way sshd itself would. - Version-aware rules are currently minimal โ the
--openssh-versionflag adjusts a small number of known defaults; more version-specific rules may be added in future releases. - Compiled-in defaults are for OpenSSH 8.x โ behaviour on significantly older or newer versions may differ.
Author
- capitan0n - capitan0n
๐ License
MIT License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sshd_lint-1.4.0.tar.gz.
File metadata
- Download URL: sshd_lint-1.4.0.tar.gz
- Upload date:
- Size: 24.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f2773a6f7d3346b42dccb62d59bdd3083264d00ed3ade5ac80961b260ea5d73f
|
|
| MD5 |
5c2d688854b4c052993eff02885d834c
|
|
| BLAKE2b-256 |
13db4cfacca5ebee74cb6d2a749f3f21f4f2fed1c7a0fcd58bf960070e76637e
|
File details
Details for the file sshd_lint-1.4.0-py3-none-any.whl.
File metadata
- Download URL: sshd_lint-1.4.0-py3-none-any.whl
- Upload date:
- Size: 23.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be896909f77e333d8449ccd845ed17f850296be70511947137ae362fea0d9549
|
|
| MD5 |
ebfc8a590cdc8c2807c7b609bc85921e
|
|
| BLAKE2b-256 |
b52af3717da02d986336cb7a61543932e62b41f1e58de53094e01f77464e54a3
|