audit-ssh-keys 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ # Generated test keys — tests create these with ssh-keygen; never commit them
2
+ id_*
3
+ ssh_host_*
4
+ *.pem
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ .coverage.*
13
+ coverage.xml
14
+ htmlcov/
15
+ dist/
16
+ build/
17
+ *.egg-info/
18
+
19
+ # Environments
20
+ .venv/
21
+ venv/
22
+ # Tool-manager lockfile: the tool has no runtime dependencies, and the dev
23
+ # workflow is `pip install -e ".[dev]"`, so nothing reads this file. Revisit
24
+ # if CI adopts uv.
25
+ uv.lock
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-09-12
9
+
10
+ ### Added
11
+
12
+ - Initial release: audits `sshd` host keys, every account's `authorized_keys` (resolved from the effective `AuthorizedKeysFile`), and private keys in `~/.ssh`
13
+ - Server-configuration checks for weak accepted signature algorithms, `StrictModes`, `PermitRootLogin`, and password authentication
14
+ - Coverage warnings when keys come from `AuthorizedKeysCommand` or `TrustedUserCAKeys`, when `HostKeyAgent` holds the private host keys, or when `AuthorizedKeysFile` mixes `none` with real paths (which OpenSSH's current development code rejects, so a future upgrade may stop `sshd` starting)
15
+ - `--json` output for fleet rollups; `-v/--verbose` to list every key including clean ones; `--skip-host`, `--skip-authorized`, `--skip-private`, `--min-rsa-bits`, `--debug`, `--version`; and the opt-in modification-time thresholds `--authorized-keys-unchanged-for`, `--authorized-keys-changed-within` and `--host-keys-changed-within`
16
+ - The date each key file was last modified, on each host key, `authorized_keys` file, key entry and private key heading in the text report, and as `last_modified` (`file_last_modified` on an `authorized_keys` entry) in the JSON
17
+ - Test suite and docs
18
+ - Every release attaches `audit-ssh-keys.py`, a copy of the tool's single source file that runs on any host with `python3` and `ssh-keygen` without installing anything and reports its version with `--version`
19
+ - Published on PyPI as `audit-ssh-keys`; the release workflow uploads the wheel and sdist through a PyPI trusted publisher, and attaches the wheel, sdist, and standalone script to the GitHub release
20
+
21
+ [0.1.0]: https://github.com/seanthegeek/audit-ssh-keys/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.5
2
+ Name: audit-ssh-keys
3
+ Version: 0.1.0
4
+ Summary: Audit sshd host keys, every account's authorized_keys, and ~/.ssh private keys on a Linux server
5
+ Project-URL: Homepage, https://github.com/seanthegeek/audit-ssh-keys
6
+ Project-URL: Issues, https://github.com/seanthegeek/audit-ssh-keys/issues
7
+ Project-URL: Changelog, https://github.com/seanthegeek/audit-ssh-keys/blob/main/CHANGELOG.md
8
+ Author: Sean Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: audit,authorized_keys,host-keys,openssh,security,ssh
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: System :: Systems Administration
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: dev
23
+ Requires-Dist: pyright; extra == 'dev'
24
+ Requires-Dist: pytest-cov; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # audit-ssh-keys
30
+
31
+ [![CI](https://github.com/seanthegeek/audit-ssh-keys/actions/workflows/ci.yml/badge.svg)](https://github.com/seanthegeek/audit-ssh-keys/actions/workflows/ci.yml)
32
+ [![codecov](https://codecov.io/gh/seanthegeek/audit-ssh-keys/graph/badge.svg)](https://codecov.io/gh/seanthegeek/audit-ssh-keys)
33
+
34
+ Audit every SSH key on a Linux server in one pass: the server's own host keys,
35
+ every account's `authorized_keys`, and any private keys sitting in `~/.ssh`.
36
+
37
+ It reads the *effective* `sshd` configuration (via `sshd -T`) so it checks the
38
+ files `sshd` will actually consult — including custom `AuthorizedKeysFile`
39
+ locations and home directories outside `/home` — and tells you when `sshd` is
40
+ sourcing keys from somewhere a file audit cannot see.
41
+
42
+ ```text
43
+ $ sudo python3 audit-ssh-keys.py
44
+ sshd config source: sshd -T
45
+
46
+ === Server configuration ===
47
+ [MEDIUM] PubkeyAcceptedAlgorithms accepts ssh-rsa (RSA with SHA-1 signatures)
48
+
49
+ === Host keys (3) ===
50
+
51
+ /etc/ssh/ssh_host_rsa_key (last modified 2023-11-04)
52
+ RSA 2048-bit SHA256:/EqCt23YD1l/qoc+0D2kBP6H6jCo3An+wBUshL+LFPY
53
+ [MEDIUM] RSA 2048-bit is below policy minimum of 3072
54
+ ...
55
+ === authorized_keys (3 file(s), 6 key(s)) ===
56
+
57
+ svc-backup: /var/lib/svc-backup/.ssh/authorized_keys (2 key(s), last modified 2026-02-17)
58
+ [HIGH] /var/lib/svc-backup/.ssh/authorized_keys is owned by mallory, not svc-backup or root
59
+ ...
60
+ Totals: CRITICAL: 1 HIGH: 3 MEDIUM: 5 LOW: 1 INFO: 2
61
+ ```
62
+
63
+ ## Run it
64
+
65
+ The tool is one Python file. It needs nothing but Python 3.10+ and
66
+ `ssh-keygen` (package `openssh-client` / `openssh-clients`) — no third-party
67
+ dependencies, and no package to install. Copy it to the server and run it as
68
+ root. No package is installed and no key file or configuration is touched;
69
+ the only thing a run writes is a temporary directory holding a single symlink,
70
+ created while fingerprinting a legacy PEM or PKCS#8 private key and removed
71
+ immediately afterwards.
72
+
73
+ ```bash
74
+ curl -fsSLO https://github.com/seanthegeek/audit-ssh-keys/releases/latest/download/audit-ssh-keys.py
75
+ scp audit-ssh-keys.py server:
76
+ ssh server sudo python3 audit-ssh-keys.py
77
+ ```
78
+
79
+ `releases/latest/download/` always fetches the newest release that is not a
80
+ prerelease (the workflow marks any tag containing a letter as a prerelease, so
81
+ `latest` never hands out one of those). To pin a specific version, use
82
+ `https://github.com/seanthegeek/audit-ssh-keys/releases/download/vX.Y.Z/audit-ssh-keys.py`
83
+ instead. Either way, `python3 audit-ssh-keys.py --version` tells you which
84
+ version you have — worth recording alongside any report you keep.
85
+
86
+ Run it as root — other accounts' key files and the host private keys are not
87
+ readable otherwise, and `sshd -T` needs root.
88
+
89
+ ```bash
90
+ sudo python3 audit-ssh-keys.py # human-readable report
91
+ sudo python3 audit-ssh-keys.py -v # list every key, not just those with findings
92
+ sudo python3 audit-ssh-keys.py --json # machine-readable, for pipelines and fleet rollups
93
+ sudo python3 audit-ssh-keys.py --authorized-keys-changed-within 7 # flag authorized_keys files modified in the last 7 days
94
+ ```
95
+
96
+ ## Install
97
+
98
+ For a machine you administer, rather than one you're investigating, installing
99
+ from PyPI gets you an `audit-ssh-keys` command that takes exactly the same
100
+ options.
101
+
102
+ ```bash
103
+ pipx install audit-ssh-keys
104
+ # or, from a checkout
105
+ pipx install .
106
+ ```
107
+
108
+ Then run it as `audit-ssh-keys`, with the same options:
109
+
110
+ ```bash
111
+ sudo audit-ssh-keys
112
+ ```
113
+
114
+ ## What it checks
115
+
116
+ | Section | Checks |
117
+ | ------- | ------ |
118
+ | Server configuration | Weak signature algorithms still accepted (`ssh-dss`, SHA-1 `ssh-rsa`, and their certificate forms); `StrictModes no`; `PermitRootLogin yes`; password auth enabled |
119
+ | Host keys | Algorithm and size; ownership and mode (`sshd` refuses a root-owned key with group/other permission bits; a key owned by anyone else loads regardless, which is its own problem); a `.pub` file next to the key checked against the key itself; configured-but-missing keys; passphrase-protected keys; missing Ed25519 key; optionally, keys whose file changed inside a window you name (`--host-keys-changed-within`) |
120
+ | `authorized_keys` | Every account, every configured path; algorithm and size; what `StrictModes` would reject (the file and every directory above it, up to `$HOME` for a file inside the home directory and otherwise up to `/`, must be owned by the user or root and not group/world-writable); the same key reused across accounts; unrestricted keys on uid-0 accounts; malformed lines; optionally, files changed inside, or untouched for longer than, a window you name (`--authorized-keys-changed-within`, `--authorized-keys-unchanged-for`) |
121
+ | Private keys in `~/.ssh` | Algorithm and size; ownership and mode; whether a passphrase is set; a `.pub` file next to the key checked against the key itself |
122
+
123
+ Every host key, `authorized_keys` file and private key is also reported with
124
+ the date it was last modified. The three `DAYS` options in the table turn
125
+ that date into a finding; they are off unless you ask for them.
126
+
127
+ Every finding has a severity (CRITICAL, HIGH, MEDIUM, LOW, INFO). See
128
+ [docs/findings.md](docs/findings.md) for what each one means and why it has the
129
+ severity it does.
130
+
131
+ ## Documentation
132
+
133
+ - [Usage](docs/usage.md) — options, exit codes, JSON schema, running without installing, fleet usage
134
+ - [Findings reference](docs/findings.md) — every finding, its severity, and remediation
135
+ - [How it works](docs/how-it-works.md) — what is read, what is not, known gaps
136
+ - [Development](docs/development.md) — running tests, linting, releasing
137
+
138
+ ## Disclaimer
139
+
140
+ This tool was developed with the assistance of AI coding agents. All code has
141
+ been reviewed and tested by a human, but you should review it yourself before
142
+ running it on systems you care about. It only reads files and runs `ssh-keygen`
143
+ and `sshd -T`; it never modifies keys or configuration.
144
+
145
+ ## License
146
+
147
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,119 @@
1
+ # audit-ssh-keys
2
+
3
+ [![CI](https://github.com/seanthegeek/audit-ssh-keys/actions/workflows/ci.yml/badge.svg)](https://github.com/seanthegeek/audit-ssh-keys/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/seanthegeek/audit-ssh-keys/graph/badge.svg)](https://codecov.io/gh/seanthegeek/audit-ssh-keys)
5
+
6
+ Audit every SSH key on a Linux server in one pass: the server's own host keys,
7
+ every account's `authorized_keys`, and any private keys sitting in `~/.ssh`.
8
+
9
+ It reads the *effective* `sshd` configuration (via `sshd -T`) so it checks the
10
+ files `sshd` will actually consult — including custom `AuthorizedKeysFile`
11
+ locations and home directories outside `/home` — and tells you when `sshd` is
12
+ sourcing keys from somewhere a file audit cannot see.
13
+
14
+ ```text
15
+ $ sudo python3 audit-ssh-keys.py
16
+ sshd config source: sshd -T
17
+
18
+ === Server configuration ===
19
+ [MEDIUM] PubkeyAcceptedAlgorithms accepts ssh-rsa (RSA with SHA-1 signatures)
20
+
21
+ === Host keys (3) ===
22
+
23
+ /etc/ssh/ssh_host_rsa_key (last modified 2023-11-04)
24
+ RSA 2048-bit SHA256:/EqCt23YD1l/qoc+0D2kBP6H6jCo3An+wBUshL+LFPY
25
+ [MEDIUM] RSA 2048-bit is below policy minimum of 3072
26
+ ...
27
+ === authorized_keys (3 file(s), 6 key(s)) ===
28
+
29
+ svc-backup: /var/lib/svc-backup/.ssh/authorized_keys (2 key(s), last modified 2026-02-17)
30
+ [HIGH] /var/lib/svc-backup/.ssh/authorized_keys is owned by mallory, not svc-backup or root
31
+ ...
32
+ Totals: CRITICAL: 1 HIGH: 3 MEDIUM: 5 LOW: 1 INFO: 2
33
+ ```
34
+
35
+ ## Run it
36
+
37
+ The tool is one Python file. It needs nothing but Python 3.10+ and
38
+ `ssh-keygen` (package `openssh-client` / `openssh-clients`) — no third-party
39
+ dependencies, and no package to install. Copy it to the server and run it as
40
+ root. No package is installed and no key file or configuration is touched;
41
+ the only thing a run writes is a temporary directory holding a single symlink,
42
+ created while fingerprinting a legacy PEM or PKCS#8 private key and removed
43
+ immediately afterwards.
44
+
45
+ ```bash
46
+ curl -fsSLO https://github.com/seanthegeek/audit-ssh-keys/releases/latest/download/audit-ssh-keys.py
47
+ scp audit-ssh-keys.py server:
48
+ ssh server sudo python3 audit-ssh-keys.py
49
+ ```
50
+
51
+ `releases/latest/download/` always fetches the newest release that is not a
52
+ prerelease (the workflow marks any tag containing a letter as a prerelease, so
53
+ `latest` never hands out one of those). To pin a specific version, use
54
+ `https://github.com/seanthegeek/audit-ssh-keys/releases/download/vX.Y.Z/audit-ssh-keys.py`
55
+ instead. Either way, `python3 audit-ssh-keys.py --version` tells you which
56
+ version you have — worth recording alongside any report you keep.
57
+
58
+ Run it as root — other accounts' key files and the host private keys are not
59
+ readable otherwise, and `sshd -T` needs root.
60
+
61
+ ```bash
62
+ sudo python3 audit-ssh-keys.py # human-readable report
63
+ sudo python3 audit-ssh-keys.py -v # list every key, not just those with findings
64
+ sudo python3 audit-ssh-keys.py --json # machine-readable, for pipelines and fleet rollups
65
+ sudo python3 audit-ssh-keys.py --authorized-keys-changed-within 7 # flag authorized_keys files modified in the last 7 days
66
+ ```
67
+
68
+ ## Install
69
+
70
+ For a machine you administer, rather than one you're investigating, installing
71
+ from PyPI gets you an `audit-ssh-keys` command that takes exactly the same
72
+ options.
73
+
74
+ ```bash
75
+ pipx install audit-ssh-keys
76
+ # or, from a checkout
77
+ pipx install .
78
+ ```
79
+
80
+ Then run it as `audit-ssh-keys`, with the same options:
81
+
82
+ ```bash
83
+ sudo audit-ssh-keys
84
+ ```
85
+
86
+ ## What it checks
87
+
88
+ | Section | Checks |
89
+ | ------- | ------ |
90
+ | Server configuration | Weak signature algorithms still accepted (`ssh-dss`, SHA-1 `ssh-rsa`, and their certificate forms); `StrictModes no`; `PermitRootLogin yes`; password auth enabled |
91
+ | Host keys | Algorithm and size; ownership and mode (`sshd` refuses a root-owned key with group/other permission bits; a key owned by anyone else loads regardless, which is its own problem); a `.pub` file next to the key checked against the key itself; configured-but-missing keys; passphrase-protected keys; missing Ed25519 key; optionally, keys whose file changed inside a window you name (`--host-keys-changed-within`) |
92
+ | `authorized_keys` | Every account, every configured path; algorithm and size; what `StrictModes` would reject (the file and every directory above it, up to `$HOME` for a file inside the home directory and otherwise up to `/`, must be owned by the user or root and not group/world-writable); the same key reused across accounts; unrestricted keys on uid-0 accounts; malformed lines; optionally, files changed inside, or untouched for longer than, a window you name (`--authorized-keys-changed-within`, `--authorized-keys-unchanged-for`) |
93
+ | Private keys in `~/.ssh` | Algorithm and size; ownership and mode; whether a passphrase is set; a `.pub` file next to the key checked against the key itself |
94
+
95
+ Every host key, `authorized_keys` file and private key is also reported with
96
+ the date it was last modified. The three `DAYS` options in the table turn
97
+ that date into a finding; they are off unless you ask for them.
98
+
99
+ Every finding has a severity (CRITICAL, HIGH, MEDIUM, LOW, INFO). See
100
+ [docs/findings.md](docs/findings.md) for what each one means and why it has the
101
+ severity it does.
102
+
103
+ ## Documentation
104
+
105
+ - [Usage](docs/usage.md) — options, exit codes, JSON schema, running without installing, fleet usage
106
+ - [Findings reference](docs/findings.md) — every finding, its severity, and remediation
107
+ - [How it works](docs/how-it-works.md) — what is read, what is not, known gaps
108
+ - [Development](docs/development.md) — running tests, linting, releasing
109
+
110
+ ## Disclaimer
111
+
112
+ This tool was developed with the assistance of AI coding agents. All code has
113
+ been reviewed and tested by a human, but you should review it yourself before
114
+ running it on systems you care about. It only reads files and runs `ssh-keygen`
115
+ and `sshd -T`; it never modifies keys or configuration.
116
+
117
+ ## License
118
+
119
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,130 @@
1
+ # Development
2
+
3
+ ```bash
4
+ git clone https://github.com/seanthegeek/audit-ssh-keys
5
+ cd audit-ssh-keys
6
+ python3 -m venv .venv && . .venv/bin/activate
7
+ pip install -e ".[dev]"
8
+ ```
9
+
10
+ ## Checks
11
+
12
+ Run exactly these, from the repo root, before opening a PR:
13
+
14
+ ```bash
15
+ ruff check .
16
+ ruff format --check .
17
+ pyright
18
+ pytest
19
+ ```
20
+
21
+ `ruff format .` fixes formatting in place.
22
+
23
+ CI (`.github/workflows/ci.yml`) runs the first three commands unchanged, and
24
+ `pytest` with coverage (`pytest --cov=audit_ssh_keys --cov-report=term-missing
25
+ --cov-report=xml`) on Python 3.10 through 3.14, on every pull request and every
26
+ push to `main`. It also lints Markdown and runs the root-only tests under
27
+ `sudo` in a separate job. The release workflow runs the four commands exactly
28
+ as written above.
29
+
30
+ ## Tests
31
+
32
+ - Tests generate real keys with `ssh-keygen` into `tmp_path`; nothing key-shaped is committed. Tests that need `ssh-keygen` are skipped if it is not installed.
33
+ - Audit functions take an explicit list of `pwd.struct_passwd` entries (`users=`) and explicit config paths / `sshd` binary, so tests build fake accounts under `tmp_path` and never touch the real system.
34
+ - A few ownership tests need root (`chown`) and are skipped otherwise; run them locally with `sudo -E pytest` if you touch ownership logic.
35
+ - The test uid helper `USER_UID` is `os.getuid()` or `1000` when running as root, because files the suite creates are owned by the real uid and `sshd` accepts root-owned files for any account.
36
+
37
+ ## Layout
38
+
39
+ ```text
40
+ src/audit_ssh_keys/
41
+ __init__.py re-exports __version__ from audit.py
42
+ __main__.py python -m entry point
43
+ audit.py everything else, including __version__, in sections: config,
44
+ ssh-keygen helpers, parsing, grading, the three audit
45
+ sections, output, CLI
46
+ tests/
47
+ conftest.py key-generation and fake-passwd fixtures
48
+ test_parsing.py pure functions (no filesystem, no ssh-keygen)
49
+ test_config.py sshd config reading and server-setting grading
50
+ test_audit.py permission checks and the three audit sections
51
+ docs/
52
+ ```
53
+
54
+ ## Releasing
55
+
56
+ 1. Add a `## [X.Y.Z] - YYYY-MM-DD` heading to `CHANGELOG.md` above the
57
+ previous release's, brackets included — that is Keep a Changelog's form, and
58
+ the release workflow greps for exactly `## [X.Y.Z]` and stops if it is
59
+ missing — and list the changes under it. Add the matching
60
+ `[X.Y.Z]: https://github.com/seanthegeek/audit-ssh-keys/releases/tag/vX.Y.Z`
61
+ link definition at the end of the file.
62
+ 2. Bump `__version__` in `src/audit_ssh_keys/audit.py`, just after the imports.
63
+ 3. Commit, then tag and push:
64
+
65
+ ```bash
66
+ git tag vX.Y.Z
67
+ git push origin vX.Y.Z
68
+ ```
69
+
70
+ Pushing the tag runs the release workflow
71
+ (`.github/workflows/release.yml`), which checks the tag against
72
+ `__version__` and the changelog heading, runs the checks, builds the wheel and
73
+ sdist, checks that `dist/` holds exactly one of each, uploads both to PyPI,
74
+ copies `audit.py` into `dist/` as `audit-ssh-keys.py`, checks that copy is
75
+ byte-identical to the module and that `python -S dist/audit-ssh-keys.py
76
+ --version` reports the tag's version, and creates the GitHub release titled
77
+ without the `v` with all three files attached.
78
+
79
+ ### If the workflow fails after the PyPI upload
80
+
81
+ Only the steps after the upload can be redone by hand, and the section below
82
+ says how to tell which side of it a failure landed on. A failure before or
83
+ during the upload is fixed by fixing the cause and re-running the job — there
84
+ is no manual upload path.
85
+
86
+ Everything after the upload is three steps: copying the standalone script,
87
+ checking that copy, and creating the GitHub release. By hand you need only the
88
+ first and last — the check exists to catch a copy that the workflow made
89
+ wrongly, and a `cp` you run yourself from the same checkout cannot fail that
90
+ way. Build the wheel and sdist locally:
91
+
92
+ ```bash
93
+ uvx hatch build
94
+ ```
95
+
96
+ (or `python -m build`). Both land in `dist/`. Then copy the standalone script
97
+ alongside them and create the release, attaching all three files:
98
+
99
+ ```bash
100
+ cp src/audit_ssh_keys/audit.py dist/audit-ssh-keys.py
101
+ gh release create vX.Y.Z --title X.Y.Z --generate-notes dist/*
102
+ ```
103
+
104
+ The title has no `v` prefix, per this repo's release rules, even though the tag does.
105
+
106
+ ### How PyPI publishing works
107
+
108
+ PyPI is published through a trusted publisher, so no API token is stored
109
+ anywhere: the project is registered on pypi.org against this repository and the
110
+ `release.yml` workflow file, and the job's OIDC token is what proves who it is.
111
+ That registration names no environment, so the release job must not declare one
112
+ — PyPI rejects the token if the two disagree.
113
+
114
+ The upload runs before the GitHub release is created, because PyPI never
115
+ accepts the same version twice, and a GitHub release can be made by hand while
116
+ an upload cannot.
117
+
118
+ If the upload fails, fix the cause and re-run the failed job from the Actions
119
+ tab, leaving the tag where it is. The wheel and the sdist go up as two separate
120
+ files, so a failure part way through can leave one of them on PyPI; the step
121
+ runs with `skip-existing`, so the re-run uploads whichever file is still
122
+ missing instead of stopping at the one already there. Check the project's
123
+ release page on PyPI afterwards to confirm both files are present. If the
124
+ upload succeeded and a later step failed, PyPI already has the files and only
125
+ the standalone script copy and the GitHub release still need doing, by hand as
126
+ above.
127
+
128
+ There is no manual upload path — that is why the by-hand steps above stop at
129
+ the GitHub release. A release whose files have to change gets a new version
130
+ number.
@@ -0,0 +1,90 @@
1
+ # Findings reference
2
+
3
+ Every finding has a severity. The scale is about *what an attacker gets* or *what silently stops working*, not about how hard the fix is.
4
+
5
+ | Severity | Meaning |
6
+ | -------- | ------- |
7
+ | CRITICAL | Broken cryptography or key material readable by anyone on the box |
8
+ | HIGH | Something `sshd` itself refuses, or that lets another local account inject or use a key |
9
+ | MEDIUM | Below current policy but not broken today |
10
+ | LOW | Hygiene, or something the tool could not fully check |
11
+ | INFO | Worth knowing; no action required by default |
12
+
13
+ ## Server configuration
14
+
15
+ | Finding | Severity | Why | Fix |
16
+ | ------- | -------- | --- | --- |
17
+ | `*Algorithms accepts ssh-dss` | MEDIUM | DSA signatures; 1024-bit only, removed from OpenSSH defaults | Remove `ssh-dss` from `HostKeyAlgorithms` / `PubkeyAcceptedAlgorithms` / `CASignatureAlgorithms` |
18
+ | `*Algorithms accepts ssh-rsa` | MEDIUM | `ssh-rsa` is RSA with SHA-1 signatures (the key itself is fine; the signature scheme is not). Disabled by default since OpenSSH 8.8 | Remove `ssh-rsa`; RSA keys keep working via `rsa-sha2-256`/`rsa-sha2-512` |
19
+ | `StrictModes is 'no'` | MEDIUM | `sshd` will read key files another account can write to | Set `StrictModes yes` and fix whatever permission problem prompted turning it off |
20
+ | `PermitRootLogin is 'yes'` | MEDIUM | Permits password-based login as root when `PasswordAuthentication` is enabled | `PermitRootLogin prohibit-password` (or `no`) |
21
+ | `PasswordAuthentication is 'yes'` | INFO | Keys are not the only way in | Consider `PasswordAuthentication no` once all users have keys |
22
+
23
+ Algorithm checks only run when `sshd -T` succeeds, because only it prints the fully expanded effective lists.
24
+
25
+ ## Host keys
26
+
27
+ | Finding | Severity | Why | Fix |
28
+ | ------- | -------- | --- | --- |
29
+ | `RSA N-bit is below 2048` | CRITICAL | Factoring risk | `ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key`; clients will see a host-key change |
30
+ | `DSA key` | HIGH | Deprecated; modern clients will not accept it | Delete the key and its `HostKey` line |
31
+ | `RSA N-bit is below policy minimum` | MEDIUM | Below `--min-rsa-bits` | Regenerate at 3072+ or 4096 |
32
+ | `world-accessible private key` | CRITICAL | Any local user can read the server's identity key and impersonate the host. When root owns the file `sshd` refuses to load it as well ("UNPROTECTED PRIVATE KEY FILE") | `chmod 600` |
33
+ | `group-accessible private key` | HIGH | The group can read the server's identity key, and replace it if the directory allows. When root owns the file `sshd` refuses to load it too; when another account owns it, `sshd` loads it whatever its mode says, because `sshkey_perm_ok()` only looks at the mode of a file owned by the account `sshd` runs as | `chmod 600` |
34
+ | `group/other bits set but not readable by them` | LOW | The mode has a group or other write or execute bit but no read bit, so nobody else can read the key; when root owns the file `sshd` still refuses to load it, because `sshkey_perm_ok()` refuses any group/other bit, not just a read one, and a write bit lets that group or anyone replace the file with one `sshd` refuses just the same | `chmod 600` |
35
+ | `modified within the last N days` | MEDIUM | Only reported when `--host-keys-changed-within N` is given. The file a `HostKey` line names changed inside that window — the private key `sshd` loads, or a public key or certificate file a `HostKey` line names by mistake (each already has a finding of its own above) — which is either a rotation somebody carried out or the first sign that somebody else replaced the server's identity — the audit cannot tell those apart, so it asks. Clients that have the old key saved in `known_hosts` normally refuse to connect until it is updated, so a genuine rotation is rarely silent. The window is measured against the file's own modification time, so anything that rewrites the file — a configuration-management run, a restore from backup, `cp` without `-p` — starts it again even when the key inside is the same one | Find out who changed the file and why; compare the fingerprint `ssh-keygen -lf <key>` prints now against the one recorded for this host before the change |
36
+ | `owned by X, expected root` | HIGH | `sshd` runs as root, so it loads the key anyway — the problem is that account X can read the host's identity key and replace it with one of its own | `chown root:root` |
37
+ | `public key file; the private half is held by HostKeyAgent` | INFO | The `HostKey` names a public key file and `HostKeyAgent` is set, which is how `sshd` is told which agent-held key to serve. It is graded like any other host key, but the private half is not on disk, so its permissions, its passphrase, and any `.pub` file beside it cannot be checked here | Nothing to fix on the host; audit the agent's own key store |
38
+ | `HostKey names a public key file and no HostKeyAgent is set` | LOW | `sshd` can load no private key from the file, so the entry grants the host nothing: it logs `Unable to load host key` and, with no other key configured, exits with `no hostkeys available`. The key's type is not counted among the ones the host can offer, and its algorithm and size are not graded either — `sshd` never offers this key to anyone, so nothing about it is reachable | Point `HostKey` at the private key file (drop the `.pub`), or set `HostKeyAgent` |
39
+ | `HostKey names a certificate file` | LOW | A certificate is not a host key `sshd` can load, so the entry grants the host nothing. `sshd` only falls back to the agent for a plain key type, so this fails even with `HostKeyAgent` set and the agent holding both the key and its certificate — verified against OpenSSH 10.2, which logs `Unable to load host key` and exits with `no hostkeys available`. As with a public key file and no agent, the key's type is not counted among the ones the host can offer and its algorithm and size are not graded | Point `HostKey` at the private key file and name the certificate on a `HostCertificate` line instead |
40
+ | `HostKey is set to none and no other HostKey is configured` | LOW | `none` is a sentinel `sshd` keeps as an empty host-key entry rather than the name of a file (`sshd -T` prints it as `hostkey (null)`), so there is no file to audit — and with nothing else configured `sshd` has no key to offer at all: it exits with `no hostkeys available -- exiting`, verified against OpenSSH 10.2 | Remove the `HostKey none` line, or configure a real one (`ssh-keygen -A`) |
41
+ | `no host key exists at any default path` | LOW | No `HostKey` is configured and none of the default files exist, so `sshd` has nothing to offer and exits with `no hostkeys available -- exiting` (verified against OpenSSH 10.2). The audit only says this when it could stat every default path | `ssh-keygen -A` |
42
+ | `configured HostKey does not exist` | LOW | `sshd` logs an error at start; harmless if another key is present | Remove the line or `ssh-keygen -A` |
43
+ | `could not stat host key` | LOW | The tool could not even tell whether the file is there — for example, permission denied on a directory above it, which happens when the tool is not running as root. Different from a configured key that is confirmed absent, which has its own row above | Run as root, or fix the directory permissions |
44
+ | `host key is passphrase-protected` | LOW | `sshd` cannot load it at boot | Regenerate without a passphrase |
45
+ | `<name>.pub does not match this private key` | LOW | The public file next to the key is stale or belongs to a different key, so anything copied out of it — into an `authorized_keys` file, a config-management repo, a `known_hosts` entry — authorises the wrong key | Regenerate it: `ssh-keygen -y -f <key> > <key>.pub` |
46
+ | `no Ed25519 host key present` | LOW | Ed25519 is the current best-practice host key. The note only appears when every configured host key that is there could be fingerprinted: a key the audit could not stat or could not read has an unknown type, and the host may well already have the Ed25519 key this note would tell you to generate — which is what a non-root run meets on every root-owned host key | `ssh-keygen -A` |
47
+ | `could not fingerprint host key` | LOW | The key could not be fingerprinted: a passphrase-protected key in the old PEM format with no `.pub` file, a key file the tool cannot read at all (a root-owned host key on a non-root run, where the `.pub` file beside it is not used either, since nothing says it describes this key), an empty file or one holding something that is not a private key at all (for either of those two the `.pub` file beside it is not used either, for the same reason), a private key file bigger than 1 MiB, which the audit stops at (the limit is on reading a private key file, so a `HostKey` naming a public key file is fingerprinted whatever its size; and the limit is this tool's own and not one `ssh` applies: `sshd` loads such a file perfectly well, so the finding says only that the audit did not read it, and the `.pub` file beside it gets no say for such a file either), a corrupt or truncated key body in the current OpenSSH format (reported this way even when a `.pub` file is present, since a corrupt file says nothing about which key it was), or a current-format key whose private half `ssh`'s own loader cannot load — mismatched check integers, private fields that do not deserialize, bad padding, or a body mangled in transit (CRLF line endings, indentation). Its algorithm and size were not graded. Its ownership and permissions are still checked and reported normally, because those come from `stat` and do not need the file's contents; whether a passphrase is set is only known when the file could be read, so nothing is reported about that for a file the tool could not read | Run the audit as root if it could not read the file; write the public file (`ssh-keygen -y -f <key> > <key>.pub`) or convert the key to the current format with `ssh-keygen -p -o -f <key>`; for a current-format key `ssh` itself cannot load, regenerate the key (delete the file and run `ssh-keygen -A`) and expect every client to see a changed host key; for a file over 1 MiB, look at what it actually holds, because a real host key is a few kilobytes |
48
+
49
+ ## authorized_keys — file level
50
+
51
+ | Finding | Severity | Why | Fix |
52
+ | ------- | -------- | --- | --- |
53
+ | `<file/dir/home> is group/world-writable` | HIGH | With `StrictModes yes` (default) `sshd` ignores the file and the account's keys silently stop working. With `StrictModes no` another account can add keys | `chmod g-w,o-w` on the offending path |
54
+ | `<path> is owned by X, not <user> or root` | HIGH | Same as above; `sshd` requires the user or root to own the whole path | `chown` |
55
+ | `line N: unparseable entry` | LOW | `sshd` reads no key from the line and skips it. Usually a corrupted paste. Also a line copied out of a `known_hosts` file, where a host name, a host pattern, or an `@cert-authority` or `@revoked` marker sits where the key type belongs: `ssh-keygen -l` accepts `known_hosts` lines and `sshd` does not, so the key material is held to `sshd`'s own test before it is fingerprinted. Most `known_hosts` lines are reported by the row below instead, because the host field parses as an option name and is then turned down as an option `sshd` does not know — `somehost <key>`, `*.example.com <key>`, `192.0.2.1,host <key>`, a hashed `\|1\|salt\|hash <key>` and a bare `@cert-authority <key>` or `@revoked <key>` with no host pattern all land there. Two shapes land here instead: a marker line, `@cert-authority <pattern> <key>` or `@revoked <pattern> <key>`, where the marker is read as the options and the host pattern is then where the key type should be; and a `known_hosts` line with anything at all in front of it that reads as options, such as `no-pty somehost <key>` or `bogusopt somehost <key>`. Neither gets an options finding, and neither does `sshd`: `auth_check_authkey_line()` (`auth2-pubkeyfile.c`) jumps to the end of the line when the second `sshkey_read()` fails, without calling `sshauthopt_parse()` at all | Remove or re-add the key |
56
+ | `line N: bad key options (...)` | LOW | `sshd` refuses the whole line when its options do not parse, so the key on it grants nothing while the file still reads as if it authorised someone. Usually a typo in an option name, or a `known_hosts` line whose leading host field is read as one (see the row above for which shapes go where). One shape is reported here that `sshd` says nothing about: a line that is a single unknown word and no key, such as `garbage`. `sshd` ignores it silently, because `sshkey_advance_past_options()` fails only on an unterminated quote, so the options on such a line are never parsed. Naming the unknown option is more useful to whoever has to fix the file, and both answers say the line grants nothing | Correct the option; the `AUTHORIZED_KEYS FILE FORMAT` section of `sshd(8)` lists every option `sshd` accepts |
57
+ | `line N: longer than 65536 characters, so it was not read` | LOW | The audit reads a line only as far as 65536 characters and winds past the rest of it. `sshd` applies no such limit — `auth_check_authkeys_file()` (`auth2-pubkeyfile.c`) reads the file with `getline()`, which grows its buffer to whatever the line needs — so this is a deliberate divergence, and one of the few places the report is less complete than `sshd` rather than more cautious than it: the line may hold a key that works, and it is neither graded nor entered in the check for keys reused across accounts. The limit is there because `sshd` reads one account's file in a process serving one connection while this tool reads every account's files in a single root process, and a file that is one enormous line costs the account that owns it a handful of disk blocks (`truncate -s 3G ~/.ssh/authorized_keys`), which used to end the audit for every account after that one. The rest of the file is still read, and every line number in the report is still the one an editor shows. A line over the limit does not stop the file getting `not modified in N days`, unlike a read that failed: the file was read from end to end, one line of it was skipped rather than lost, and that line has this finding of its own sitting beside it. Nothing written on purpose comes close to the limit: a 16384-bit RSA key is about 2.8 KB of base64 | Look at the line; if it is a real key it will fit, so shorten or remove whatever is there |
58
+ | `modified within the last N days` | MEDIUM | Only reported when `--authorized-keys-changed-within N` is given. The file changed inside that window: a key added or removed, or the whole file rewritten. That is either an edit somebody made on purpose or exactly what the option was switched on to catch. It is reported whether or not the file still holds any keys, because emptying a file out is a change like any other, and it is reported for a file the tool cannot read as well, since the window is decided from the file's modification time alone. Anything that rewrites the file — a configuration-management run, a restore from backup, `cp` without `-p` — starts the window again even when the lines in it are unchanged | Find out who changed the file and why, and check the keys in it against the people who should have access |
59
+ | `not modified in N days` | LOW | Only reported when `--authorized-keys-unchanged-for N` is given, and only for a file holding at least one key line `sshd` would parse — a file of comments, a file holding nothing but certificate lines (which authorise nobody), a file the tool could not read, and a file the tool could not read to the end (a read that failed partway, or a close that failed after it) all say nothing about whose access is old — for the last of those, the keys the audit did read say nothing about the ones it did not. Whether a line's `expiry-time=` has passed is not evaluated, the same as in every other check here. The finding is about the file: it has gone that long without a change, so nothing in it was added more recently unless its modification time was set by hand, which is the limit of any check built on that time. It says how long the file has gone unchanged, not whether anybody has looked at it: a review that changes nothing leaves the modification time where it was | Check each key in the file against the people and systems that should still have access, and remove the ones that should not |
60
+ | `could not resolve` / `could not stat` / `could not read` / `could not close` | LOW | Tool could not check it. This is what a non-root run reports for another account's files it cannot even stat, rather than silently treating them as absent. `could not read` also covers a read that failed partway through a file — a failing disk, a network filesystem going away — which leaves the lines read before it reported and the rest of the file unread; `could not close` is that same failure arriving as the file is closed, after every line has been read. Neither ends the audit of the accounts that follow | Run as root; a read that fails partway, or a close that fails at all, points at the filesystem rather than at permissions |
61
+
62
+ The tool follows any symbolic links, then checks the file and every directory above it, stopping once it has checked the home directory if the file is inside it and otherwise carrying on up to `/`. That is the same walk `sshd`'s `safe_path()` does, which is why an `authorized_keys` file placed outside the home under a world-writable directory such as `/tmp` is rejected outright. Only *write* bits for group/other matter: mode 644 on `authorized_keys` or 755 on `~/.ssh` is accepted by `sshd` and is not flagged.
63
+
64
+ ## authorized_keys — key level
65
+
66
+ | Finding | Severity | Why | Fix |
67
+ | ------- | -------- | --- | --- |
68
+ | `RSA N-bit is below 2048` | CRITICAL | Factoring risk | Replace with Ed25519 or RSA 4096 |
69
+ | `DSA key` | HIGH | Deprecated; cannot log in on modern `sshd` anyway | Remove |
70
+ | `SSH-1 RSA key` | CRITICAL | Protocol 1, long removed | Remove |
71
+ | `RSA N-bit is below policy minimum` | MEDIUM | Below `--min-rsa-bits` | Rotate |
72
+ | `same key also authorized at: ...` | MEDIUM | One compromised private key opens every listed account; also a sign of shared or copied keys | One key per (person, purpose); remove the extras |
73
+ | `same key also listed for this account at: ...` | LOW | The same key twice for one account grants no extra access, but an admin who deletes one entry believes the key is revoked while the other one still admits it | Keep a single entry per key |
74
+ | `certificate listed in authorized_keys` | INFO | `sshd` matches a plain key against the line itself and a certificate against a `cert-authority` line holding the CA's plain key; a certificate blob matches neither, so the line is dead weight that looks like an authorisation. It is also left out of the check for keys reused across accounts, because `ssh-keygen -l` reports a certificate under the fingerprint of the key it certifies, which would otherwise make that key look reused | Remove it; to trust the CA, add its plain public key with the `cert-authority` option |
75
+ | `uid-0 account key has no from= or command= restriction` | INFO | Unrestricted root key. Common, but a `from=` or `command=` option limits blast radius | Add options where the use case allows |
76
+
77
+ ## Private keys in ~/.ssh
78
+
79
+ | Finding | Severity | Why | Fix |
80
+ | ------- | -------- | --- | --- |
81
+ | `world-accessible private key` | CRITICAL | Any local user can copy it | `chmod 600` |
82
+ | `group-accessible private key` | HIGH | Group members can copy it | `chmod 600` |
83
+ | `group/other bits set but not readable by them` | LOW | Nobody else can read the key, but `ssh` refuses to load it for its owner ("UNPROTECTED PRIVATE KEY FILE") because `sshkey_perm_ok()` rejects any group/other bit | `chmod 600` |
84
+ | `owned by X, expected <user> or root` | HIGH | Someone else's key in this account's directory, or a copied home | Investigate |
85
+ | `private key has no passphrase` (root) | HIGH | A root-owned key that is one file read away from use elsewhere | Add a passphrase (`ssh-keygen -p`) or move to an agent / hardware key |
86
+ | `private key has no passphrase` (other) | MEDIUM | Same, smaller blast radius. Automation keys are often legitimately passphrase-less; pair them with `from=`/`command=` on the receiving side | Same |
87
+ | `RSA N-bit ...`, `DSA key` | as above | | Rotate |
88
+ | `could not determine whether the key is passphrase-protected` | LOW | Unrecognised file format, an OpenSSH-format key cut short before its closing line, which leaves no way to trust anything read out of it, a key file bigger than the 1 MiB the audit reads (`ssh` loads a far larger file than that, so this says the audit did not look, not that the key is unusable), or a key file holding a byte that is not ASCII (a private-key file is ASCII from end to end, so such a file is corrupt) | Inspect manually |
89
+ | `<name>.pub does not match this private key` | LOW | The public file next to the key is stale or belongs to a different key, so anything copied out of it — for example into an `authorized_keys` file — authorises the wrong key | Regenerate it: `ssh-keygen -y -f <key> > <key>.pub` |
90
+ | `could not fingerprint private key` | LOW | The key could not be fingerprinted: a passphrase-protected key in the old PEM format with no `.pub` file, a PEM key with no `.pub` that is owned by the account running the tool with any group or other permission bits set (`ssh-keygen` refuses to open such a key), a file bigger than 1 MiB, which the audit stops at (that limit is the audit's own and not one `ssh` applies: `ssh` loads such a file without trouble, so the finding says only that the audit did not read it), a corrupt or truncated key body in the current OpenSSH format (reported this way even when a `.pub` file is present, since a corrupt file says nothing about which key it was), or a current-format key whose private half `ssh`'s own loader cannot load — mismatched check integers, private fields that do not deserialize, bad padding, or a body mangled in transit (CRLF line endings, indentation). Its algorithm and size were not graded | Fix the permissions if that is the cause; otherwise write the public file (`ssh-keygen -y -f <key> > <key>.pub`) or convert the key to the current format with `ssh-keygen -p -o -f <key>`; for a current-format key `ssh` itself cannot load, regenerate the key and replace it everywhere it is authorised; for a file over 1 MiB, look at what it actually holds, because a real private key is a few kilobytes |