Skip to main content

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: Match blocks, Match All reset, directive shadowing, and Include glob 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

-V is used for --version so that -v stays free for a future verbosity flag, following the common convention where -v means 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 Match blocks (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 Include paths 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_config these are identical. When auditing a copy of only the main file (without its snippets alongside it), pass --base-dir /etc/ssh to resolve includes the way sshd itself would.
  • Version-aware rules are currently minimal โ€” the --openssh-version flag 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


๐Ÿ“œ 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

sshd_lint-1.4.0.tar.gz (24.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sshd_lint-1.4.0-py3-none-any.whl (23.7 kB view details)

Uploaded Python 3

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

Hashes for sshd_lint-1.4.0.tar.gz
Algorithm Hash digest
SHA256 f2773a6f7d3346b42dccb62d59bdd3083264d00ed3ade5ac80961b260ea5d73f
MD5 5c2d688854b4c052993eff02885d834c
BLAKE2b-256 13db4cfacca5ebee74cb6d2a749f3f21f4f2fed1c7a0fcd58bf960070e76637e

See more details on using hashes here.

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

Hashes for sshd_lint-1.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 be896909f77e333d8449ccd845ed17f850296be70511947137ae362fea0d9549
MD5 ebfc8a590cdc8c2807c7b609bc85921e
BLAKE2b-256 b52af3717da02d986336cb7a61543932e62b41f1e58de53094e01f77464e54a3

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page