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.
- audit_ssh_keys-0.1.0/.gitignore +25 -0
- audit_ssh_keys-0.1.0/CHANGELOG.md +21 -0
- audit_ssh_keys-0.1.0/LICENSE +21 -0
- audit_ssh_keys-0.1.0/PKG-INFO +147 -0
- audit_ssh_keys-0.1.0/README.md +119 -0
- audit_ssh_keys-0.1.0/docs/development.md +130 -0
- audit_ssh_keys-0.1.0/docs/findings.md +90 -0
- audit_ssh_keys-0.1.0/docs/how-it-works.md +41 -0
- audit_ssh_keys-0.1.0/docs/usage.md +111 -0
- audit_ssh_keys-0.1.0/pyproject.toml +64 -0
- audit_ssh_keys-0.1.0/src/audit_ssh_keys/__init__.py +3 -0
- audit_ssh_keys-0.1.0/src/audit_ssh_keys/__main__.py +5 -0
- audit_ssh_keys-0.1.0/src/audit_ssh_keys/audit.py +3303 -0
- audit_ssh_keys-0.1.0/tests/__init__.py +0 -0
- audit_ssh_keys-0.1.0/tests/conftest.py +105 -0
- audit_ssh_keys-0.1.0/tests/test_audit.py +4863 -0
- audit_ssh_keys-0.1.0/tests/test_config.py +997 -0
- audit_ssh_keys-0.1.0/tests/test_parsing.py +454 -0
|
@@ -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
|
+
[](https://github.com/seanthegeek/audit-ssh-keys/actions/workflows/ci.yml)
|
|
32
|
+
[](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
|
+
[](https://github.com/seanthegeek/audit-ssh-keys/actions/workflows/ci.yml)
|
|
4
|
+
[](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 |
|