keywarden-ssh 0.3.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.
Files changed (29) hide show
  1. keywarden_ssh-0.3.0/LICENSE +21 -0
  2. keywarden_ssh-0.3.0/PKG-INFO +280 -0
  3. keywarden_ssh-0.3.0/README.md +259 -0
  4. keywarden_ssh-0.3.0/pyproject.toml +34 -0
  5. keywarden_ssh-0.3.0/setup.cfg +4 -0
  6. keywarden_ssh-0.3.0/src/keywarden/__init__.py +3 -0
  7. keywarden_ssh-0.3.0/src/keywarden/__main__.py +6 -0
  8. keywarden_ssh-0.3.0/src/keywarden/access.py +447 -0
  9. keywarden_ssh-0.3.0/src/keywarden/check.py +690 -0
  10. keywarden_ssh-0.3.0/src/keywarden/cli.py +624 -0
  11. keywarden_ssh-0.3.0/src/keywarden/collect.py +967 -0
  12. keywarden_ssh-0.3.0/src/keywarden/controls.py +133 -0
  13. keywarden_ssh-0.3.0/src/keywarden/evidence.py +207 -0
  14. keywarden_ssh-0.3.0/src/keywarden/inventory.py +199 -0
  15. keywarden_ssh-0.3.0/src/keywarden/parse.py +443 -0
  16. keywarden_ssh-0.3.0/src/keywarden/term.py +67 -0
  17. keywarden_ssh-0.3.0/src/keywarden/ui.py +348 -0
  18. keywarden_ssh-0.3.0/src/keywarden/ui_lab.py +173 -0
  19. keywarden_ssh-0.3.0/src/keywarden/ui_page.py +665 -0
  20. keywarden_ssh-0.3.0/src/keywarden/waivers.py +171 -0
  21. keywarden_ssh-0.3.0/src/keywarden_ssh.egg-info/PKG-INFO +280 -0
  22. keywarden_ssh-0.3.0/src/keywarden_ssh.egg-info/SOURCES.txt +27 -0
  23. keywarden_ssh-0.3.0/src/keywarden_ssh.egg-info/dependency_links.txt +1 -0
  24. keywarden_ssh-0.3.0/src/keywarden_ssh.egg-info/entry_points.txt +2 -0
  25. keywarden_ssh-0.3.0/src/keywarden_ssh.egg-info/top_level.txt +1 -0
  26. keywarden_ssh-0.3.0/tests/test_access.py +340 -0
  27. keywarden_ssh-0.3.0/tests/test_evidence.py +133 -0
  28. keywarden_ssh-0.3.0/tests/test_keywarden.py +740 -0
  29. keywarden_ssh-0.3.0/tests/test_ui.py +94 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Waxman
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,280 @@
1
+ Metadata-Version: 2.4
2
+ Name: keywarden-ssh
3
+ Version: 0.3.0
4
+ Summary: Check SSH posture around the public keys you push. Never stores them.
5
+ Author: Michael Waxman
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/generalwax2018/keywarden
8
+ Project-URL: Repository, https://github.com/generalwax2018/keywarden
9
+ Project-URL: Issues, https://github.com/generalwax2018/keywarden/issues
10
+ Project-URL: Changelog, https://github.com/generalwax2018/keywarden/blob/main/CHANGELOG.md
11
+ Keywords: ssh,security,authorized_keys
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: POSIX
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Security
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # Keywarden
23
+
24
+ Check SSH posture around the public keys you already push. Keywarden does not push keys, and it does not store them.
25
+
26
+ You keep using whatever already places `authorized_keys` on a machine (Ansible, a script, by hand). Keywarden reads the effective daemon config and the keys that are actually authorized, fingerprints those keys, and drops the bodies before it writes a report.
27
+
28
+ One command checks this machine, one host, or every host in an Ansible inventory. It is a point-in-time check: there is no daemon, scheduler, or stored state.
29
+
30
+ ![keywarden ui: run checks on six lab hosts, review keys that reach root or have gone stale, and find every place a key still works](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/demo.gif)
31
+
32
+ `keywarden ui` on the built-in lab hosts. Screenshots: [findings, light](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-light.webp) · [findings, dark](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-dark.webp) · [access review](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/access-review.webp).
33
+
34
+ ## Install
35
+
36
+ Python 3.9 or newer. No third-party packages. `--local` needs Linux or another POSIX system; the browser UI, `--host`, and `--inventory` also run on Windows using its built-in `ssh`.
37
+
38
+ ```sh
39
+ python3 -m pip install keywarden-ssh
40
+ ```
41
+
42
+ Or straight from a tag:
43
+
44
+ ```sh
45
+ python3 -m pip install "git+https://github.com/generalwax2018/keywarden.git@v0.3.0"
46
+ ```
47
+
48
+ The distribution name is `keywarden-ssh`. The `keywarden` package on PyPI is an unrelated project; do not install it expecting this tool.
49
+
50
+ Or clone this repository and run it from the tree:
51
+
52
+ ```sh
53
+ git clone https://github.com/generalwax2018/keywarden.git
54
+ cd keywarden
55
+ PYTHONPATH=src python3 -m keywarden --version
56
+ ```
57
+
58
+ ## Use it in a browser
59
+
60
+ ```sh
61
+ keywarden ui
62
+ ```
63
+
64
+ This opens Keywarden in a browser tab, served from your machine. Paste the public keys you push, choose what to check (this machine, one host, an Ansible inventory, or collector output you paste), and run. Results show failing hosts first, with the access review table and a "find a key" search beside them. Export the findings as JSON or the key table as CSV. Press **Fill lab keys** and **Run checks** to try it on six made-up hosts first.
65
+
66
+ The server listens on `127.0.0.1` only, on a random port, and the link it prints carries a one-time token. It refuses requests without that token, from another web page, or with a Host header other than localhost, so a site you visit cannot drive it. The page loads nothing from the internet. Key text is fingerprinted on arrival and cleared from the box; the tab holds fingerprints only, and nothing is written to disk unless you export. Use `--port` to pick the port and `--no-browser` to print the link without opening it.
67
+
68
+ ## Check this machine
69
+
70
+ ```sh
71
+ keywarden check --local --allow team.pub
72
+ ```
73
+
74
+ `team.pub` is the public keys you intend to be authorized, one OpenSSH line each. Keywarden fingerprints them for the comparison and does not copy them into the report.
75
+
76
+ A failing check exits 1. A clean or warning-only check exits 0. Bad input, including a private key, exits 2 and writes no report.
77
+
78
+ The terminal shows a summary line, then failures and warnings in colour. Passing checks are counted but hidden; `--verbose` lists them. Colour is off when the output is not a terminal or `NO_COLOR` is set, and `--color always|never` overrides it.
79
+
80
+ ```sh
81
+ keywarden check --local --allow team.pub --output keywarden-report.json
82
+ ```
83
+
84
+ The JSON file has fingerprints, key types, bit lengths, comments, daemon settings, and findings. It does not have key bodies. Do not point `--allow` at a private key. If one is present, Keywarden stops and keeps nothing.
85
+
86
+ ## Check one other host
87
+
88
+ ```sh
89
+ keywarden check --host deploy@app-web-03.example --allow team.pub --json
90
+ ```
91
+
92
+ This is `ssh` in batch mode (no password prompt). The remote command prints `sshd -T` and `authorized_keys` back to this process. Fingerprints are computed here. Nothing is written on the remote host.
93
+
94
+ A host key that is missing or changed fails the connection. Keywarden does not accept it. `--accept-new` trusts a key you have not seen before and is off unless you pass it. Use the `known_hosts` file your SSH client already has.
95
+
96
+ Reading root, and reading other accounts, needs to be allowed: run as root, or have passwordless `sudo`. With general passwordless sudo the collector uses it for `sshd -T`, `stat`, `test`, `ls`, `grep` of the sshd config, and `cat` of `authorized_keys`. With sudo limited to `sshd -T` and `cat`, those two still work, and file modes behind an unreadable home are reported as unknown. If root's file cannot be read, the root-key check is a **warning, not a pass**. A hidden root key must not look clean, and an account whose home cannot be read is listed as not checked instead of being skipped.
97
+
98
+ Every line read from an `authorized_keys` file is prefixed before it crosses the wire, and the parser refuses output that is out of order, repeated, or truncated. A user who writes collector markers into their own key file cannot change what the report says about the daemon or other accounts; the host fails with `collector-output` instead.
99
+
100
+ `--account` adds a specific user. Repeat it. Accounts that already have an `authorized_keys` file are included when the file is visible.
101
+
102
+ ## Check an Ansible inventory
103
+
104
+ ```sh
105
+ keywarden check --inventory hosts.ini --allow team.pub
106
+ ```
107
+
108
+ `hosts.ini` is the INI inventory you already use. Keywarden reads `ansible_user`, `ansible_host`, `ansible_port`, and `ansible_ssh_private_key_file` (and their `ansible_ssh_*` spellings), from host lines, `[group:vars]`, and parent groups through `[group:children]`. A nearer group overrides a parent, and a host line overrides both, as in Ansible. A private-key path is handed to `ssh -i`; Keywarden never opens the file. Passwords, become passwords, and every other variable are dropped as the file is read. YAML inventories are not supported yet and are refused with a message. Each host is one SSH, in order. A host that cannot be reached is a failure, and the rest are still checked.
109
+
110
+ The terminal shows failures only. A check that names a key is printed once per fingerprint, with every host where that fingerprint failed. Other failures stay on their own line: hostname and check. Passes are not printed. Exit 1 if any host failed. `--output` writes the JSON report, still without key bodies. The `fingerprints` array is the unique set. `--prior` is for one host, not an inventory.
111
+
112
+ ```ini
113
+ [web]
114
+ app-web-03 ansible_user=deploy
115
+ bastion ansible_host=10.0.0.9 ansible_user=ops ansible_port=2222
116
+ ```
117
+
118
+ There is no scheduler. The next run is the next time you start the command.
119
+
120
+ ## What is checked
121
+
122
+ | Code | Fails when |
123
+ | --- | --- |
124
+ | `permit-root` | `PermitRootLogin` is anything but `no`. `prohibit-password` and `without-password` still allow a root key, so they fail. |
125
+ | `password-auth` | `PasswordAuthentication` is on |
126
+ | `keyboard-interactive` | `KbdInteractiveAuthentication` is on |
127
+ | `empty-passwords` | `PermitEmptyPasswords` is on |
128
+ | `pubkey-auth` | `PubkeyAuthentication` is off |
129
+ | `root-authorized-key` | root has a key. Unreadable is a warning. |
130
+ | `root-account` | another account has uid 0, root is missing, or root's password field is empty |
131
+ | `permissions` | `.ssh` or `authorized_keys` is group- or world-accessible |
132
+ | `weak-algorithm` | DSA, or RSA under 3072 bits |
133
+ | `outside-push` | a non-root key is not in `--allow` |
134
+ | `cert-authority` | a line is marked `cert-authority` |
135
+ | `ssh-certificate` | a certificate is expired, not yet valid, a host certificate, or unreadable |
136
+ | `accepted-algorithms` | `PubkeyAcceptedAlgorithms` still allows `ssh-rsa`, `ssh-dss`, or SHA-1 |
137
+ | `ca-algorithms` | `CASignatureAlgorithms` still allows those |
138
+ | `trusted-user-ca` | `TrustedUserCAKeys` is set, so a signed key can bypass the push |
139
+ | `revoked-keys` | a user CA is trusted and `RevokedKeys` is none (warning) |
140
+ | `authorized-keys-command` | a command can inject keys that are not in the file |
141
+ | `authorized-keys-file` | `sshd` also reads a path other than `.ssh/authorized_keys` |
142
+ | `hostbased-auth` | host-based authentication is on |
143
+ | `auth-methods` | `AuthenticationMethods` still allows a password |
144
+ | `sshd-config` | `sshd -T` could not run, so daemon checks were skipped (warning) |
145
+ | `match-blocks` | `sshd_config` has `Match` blocks (warning; see below) |
146
+ | `collector-output` | the collector output was malformed or tampered with |
147
+ | `unreachable` | an inventory host could not be reached |
148
+ | `key-to-root` | a key opens an account with passwordless root: `NOPASSWD` sudo to a shell or `ALL`, or a root-equivalent group such as `docker` (warning) |
149
+ | `key-source` | a root-level key has no `from=` restriction (warning) |
150
+ | `forced-command-forwarding` | a `command=` key still allows port or agent forwarding (warning) |
151
+ | `key-environment` | a key sets `environment=` and `PermitUserEnvironment` is on |
152
+ | `expired-key` | a line's `expiry-time` has passed, or cannot be read (warning) |
153
+ | `log-level` | `LogLevel` is `QUIET`, `FATAL`, or `ERROR`, so logins are not recorded |
154
+ | `auth-log` | the sshd log could not be read, so key usage was not checked (warning) |
155
+ | `stale-key` | an authorized key has no login in `--stale-days` (default 90). A warning when the logs are shorter than that. |
156
+
157
+ Fields that are absent from the observation are skipped, not treated as a pass.
158
+
159
+ `sshd -T` prints the default settings, before any `Match` block applies. When the config has `Match` blocks, Keywarden also runs `sshd -T -C user=root,...` and fails `permit-root`, `password-auth`, or `keyboard-interactive` if a block makes root worse than the default. A `Match` block can only make a result worse, never better. Blocks for other users, groups, or source addresses are not evaluated; `match-blocks` warns so you know to read them.
160
+
161
+ Key text is fingerprinted in memory and then dropped. A private key, including a PuTTY private key, stops the run and is not written. The report never contains a key body. `--allow` is not deleted; it is your file.
162
+
163
+ `forced-commands-only` is a warning. Pass `--no-weak` or `--no-outside` to skip those two comparisons.
164
+
165
+ Key comments are often an email address or a machine name. Pass `--redact-comments` to leave them out of the terminal output and the report.
166
+
167
+ ## Access review
168
+
169
+ Every check also builds a table with one row per authorized key: host, account, fingerprint, type, comment, whether it is in `--allow`, what logging in leads to (`reach`), its `from=` and other options, and when it was last used.
170
+
171
+ ```sh
172
+ keywarden check --local --allow team.pub --keys
173
+ keywarden check --inventory hosts.ini --allow team.pub --csv access-review.csv
174
+ ```
175
+
176
+ `--keys` prints the table. `--csv` writes it for a spreadsheet or a ticket. The JSON report has it under `keys`. Cells that start with `=`, `+`, `-`, or `@` are prefixed with `'` so a key comment cannot run as a spreadsheet formula. A `command=` value is never printed, only that one is set, because forced commands sometimes carry tokens.
177
+
178
+ **Reach.** For each account with a key, Keywarden reads `sudo -l -U <account>` and the account's groups:
179
+
180
+ | Reach | Meaning |
181
+ | --- | --- |
182
+ | `root` | the account is root, or uid 0 |
183
+ | `root-nopasswd` | `NOPASSWD` sudo to `ALL`, a shell, or a command with a known shell escape (`vim`, `less`, `find`, `tee`, `systemctl`, and others), or membership in `docker`, `lxd`, `lxc`, `incus-admin`, or `disk` |
184
+ | `root-password` | the same sudo rules, but with a password; or membership in `sudo`, `wheel`, or `admin` when the rules cannot be read |
185
+ | `limited` | sudo to specific commands that are not on the shell-escape list. The list is not exhaustive; review these rules by hand. |
186
+ | `none` | not in sudoers, or sudo is not installed |
187
+ | `unknown` | sudo rules could not be read (run as root or with passwordless sudo) |
188
+
189
+ Each `sudo -l -U` call is written to the host's auth log by sudo itself.
190
+
191
+ **Last used.** Keywarden reads `Accepted publickey for <account> ... SHA256:<fingerprint>` lines. sshd writes these at the default `LogLevel INFO`. It prefers the systemd journal, read only for `ssh.service` and `sshd.service` entries logged as uid 0, which a local user cannot forge. Without a journal it reads `/var/log/auth.log*` or `/var/log/secure*`, including rotated and gzipped files. A local user can write fake lines to those files with `logger`, so the report names which source it used (`authLog`). The log tells you how far back it reaches; a key is only called stale when the logs cover the whole `--stale-days` window. A login through a certificate counts toward the `cert-authority` line that trusted it. A certificate pasted directly into `authorized_keys` is marked `unmatched`, because sshd logs the key inside it rather than the line.
192
+
193
+ Only the newest login line and a count per account and key leave the host. Pass `--redact-addresses` to drop the last login address from the output.
194
+
195
+ ## Evidence for an assessor
196
+
197
+ ```sh
198
+ keywarden check --inventory hosts.ini --allow team.pub --waivers keywarden.toml --evidence ssh-evidence.html
199
+ ```
200
+
201
+ `--evidence` (or **Evidence** in `keywarden ui`) writes one self-contained HTML file: who ran the check and with what visibility, the hosts in scope, every finding with the controls it bears on, the access review table, accepted risks, and a SHA-256 of the embedded JSON report so an assessor can tell it was not edited. A control coverage table groups the results under CIS Controls v8, NIST SP 800-53 Rev. 5, PCI DSS v4.0, and NERC CIP. Every finding in the JSON report also carries its `controls`.
202
+
203
+ The mapping is in [docs/compliance.md](docs/compliance.md). It supports an assessment and does not replace one: a passing check is evidence toward a control on the hosts checked, not proof the control is met.
204
+
205
+ ## Find a key
206
+
207
+ Offboarding someone, or a laptop was stolen: find every place their key gets in.
208
+
209
+ ```sh
210
+ keywarden where --key alice.pub --inventory hosts.ini
211
+ keywarden where SHA256:CU0yV+3RbcXlukC+M6dq938Ou+kt105Ipe/qaP4n4LU --host deploy@app-web-03.example
212
+ ```
213
+
214
+ Each hit names the host, the account, whether the key is in `authorized_keys` or trusted as a `cert-authority`, its reach, its `from=` limit, and its last login. A key that logged in recently but is not in any readable `authorized_keys` is listed too: it may come from `AuthorizedKeysCommand`, another key file, or a file Keywarden could not read. Hosts and accounts that could not be read are printed on stderr, because "not found" only covers what was visible.
215
+
216
+ Like `grep`, `where` exits 0 when it finds the key, 1 when it does not, and 2 on bad input. A private key passed to `--key` is refused.
217
+
218
+ ## Waivers
219
+
220
+ Some findings are deliberate: a break-glass key on root, an admin account with passwordless sudo. Record them instead of turning the check off:
221
+
222
+ ```toml
223
+ # keywarden.toml (Python 3.11+), or the same fields as JSON: {"waivers": [ {...} ]}
224
+ [[waiver]]
225
+ code = "root-authorized-key"
226
+ host = "bastion-*" # optional glob; default every host
227
+ fingerprint = "SHA256:..." # optional; default every key in the finding
228
+ reason = "break-glass key, sealed in the vault"
229
+ owner = "secops" # owner or ticket is required
230
+ ticket = "SEC-1234"
231
+ expires = "2027-03-01" # required
232
+ ```
233
+
234
+ ```sh
235
+ keywarden check --inventory hosts.ini --allow team.pub --waivers keywarden.toml
236
+ ```
237
+
238
+ A waived finding is reported as `accepted`, with the reason, ticket, and end date, and does not fail the run. When a finding names several keys, it is accepted only if every key is waived; otherwise it keeps its severity and says which keys were waived. An expired waiver stops working and the finding says so. A waiver that matched nothing is printed on stderr so the file does not collect dead entries.
239
+
240
+ ## Drift
241
+
242
+ Keep the previous report (it has no key bodies) and pass it next time:
243
+
244
+ ```sh
245
+ keywarden check --local --allow team.pub --prior keywarden-report.json
246
+ ```
247
+
248
+ A check that was a pass and is now a fail is marked `regressed`. A new failure is `new`. A failure that cleared is `cleared`. A check that was already failing and now names a fingerprint it did not name before is `new-keys`, with the added fingerprints in `newFingerprints`. Each finding that names keys carries a `fingerprints` array.
249
+
250
+ ## Offline observation
251
+
252
+ If you would rather not have Keywarden open SSH itself, run the collector on the host and pipe it. Do not save that stream: it contains public keys. The report does not. Collector output from 0.1.0 (protocol 1) is refused; run the current collector.
253
+
254
+ ```sh
255
+ python3 -c 'from keywarden.collect import REMOTE_SCRIPT; print(REMOTE_SCRIPT)' | sh | keywarden check --observation - --allow team.pub
256
+ ```
257
+
258
+ ## What this release does not do
259
+
260
+ - It does not install, rotate, or delete keys.
261
+ - It does not remember a push, a host list, or a report unless you choose `--output`.
262
+ - It does not run on a schedule or keep evidence for you. Run it from cron or CI and keep the reports you want.
263
+ - It checks inventory hosts one at a time, up to 256 per run.
264
+ - It reads `/etc/passwd` and `~/.ssh/authorized_keys`. Accounts that exist only in LDAP or SSSD, and keys served by `AuthorizedKeysCommand`, are not listed (the command itself is flagged). Their logins still show up in `where`.
265
+
266
+ ## Tests
267
+
268
+ ```sh
269
+ python3 -m unittest discover -s tests -v
270
+ ```
271
+
272
+ Fingerprint and certificate tests run when `ssh-keygen` is installed and are skipped otherwise. CI also runs the collector against a real OpenSSH server, including an `authorized_keys` file that tries to rewrite the report.
273
+
274
+ ## Contributing
275
+
276
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Report security issues privately; see [SECURITY.md](SECURITY.md).
277
+
278
+ ## License
279
+
280
+ [MIT](LICENSE).
@@ -0,0 +1,259 @@
1
+ # Keywarden
2
+
3
+ Check SSH posture around the public keys you already push. Keywarden does not push keys, and it does not store them.
4
+
5
+ You keep using whatever already places `authorized_keys` on a machine (Ansible, a script, by hand). Keywarden reads the effective daemon config and the keys that are actually authorized, fingerprints those keys, and drops the bodies before it writes a report.
6
+
7
+ One command checks this machine, one host, or every host in an Ansible inventory. It is a point-in-time check: there is no daemon, scheduler, or stored state.
8
+
9
+ ![keywarden ui: run checks on six lab hosts, review keys that reach root or have gone stale, and find every place a key still works](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/demo.gif)
10
+
11
+ `keywarden ui` on the built-in lab hosts. Screenshots: [findings, light](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-light.webp) · [findings, dark](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/findings-dark.webp) · [access review](https://raw.githubusercontent.com/generalwax2018/keywarden/main/docs/screenshots/access-review.webp).
12
+
13
+ ## Install
14
+
15
+ Python 3.9 or newer. No third-party packages. `--local` needs Linux or another POSIX system; the browser UI, `--host`, and `--inventory` also run on Windows using its built-in `ssh`.
16
+
17
+ ```sh
18
+ python3 -m pip install keywarden-ssh
19
+ ```
20
+
21
+ Or straight from a tag:
22
+
23
+ ```sh
24
+ python3 -m pip install "git+https://github.com/generalwax2018/keywarden.git@v0.3.0"
25
+ ```
26
+
27
+ The distribution name is `keywarden-ssh`. The `keywarden` package on PyPI is an unrelated project; do not install it expecting this tool.
28
+
29
+ Or clone this repository and run it from the tree:
30
+
31
+ ```sh
32
+ git clone https://github.com/generalwax2018/keywarden.git
33
+ cd keywarden
34
+ PYTHONPATH=src python3 -m keywarden --version
35
+ ```
36
+
37
+ ## Use it in a browser
38
+
39
+ ```sh
40
+ keywarden ui
41
+ ```
42
+
43
+ This opens Keywarden in a browser tab, served from your machine. Paste the public keys you push, choose what to check (this machine, one host, an Ansible inventory, or collector output you paste), and run. Results show failing hosts first, with the access review table and a "find a key" search beside them. Export the findings as JSON or the key table as CSV. Press **Fill lab keys** and **Run checks** to try it on six made-up hosts first.
44
+
45
+ The server listens on `127.0.0.1` only, on a random port, and the link it prints carries a one-time token. It refuses requests without that token, from another web page, or with a Host header other than localhost, so a site you visit cannot drive it. The page loads nothing from the internet. Key text is fingerprinted on arrival and cleared from the box; the tab holds fingerprints only, and nothing is written to disk unless you export. Use `--port` to pick the port and `--no-browser` to print the link without opening it.
46
+
47
+ ## Check this machine
48
+
49
+ ```sh
50
+ keywarden check --local --allow team.pub
51
+ ```
52
+
53
+ `team.pub` is the public keys you intend to be authorized, one OpenSSH line each. Keywarden fingerprints them for the comparison and does not copy them into the report.
54
+
55
+ A failing check exits 1. A clean or warning-only check exits 0. Bad input, including a private key, exits 2 and writes no report.
56
+
57
+ The terminal shows a summary line, then failures and warnings in colour. Passing checks are counted but hidden; `--verbose` lists them. Colour is off when the output is not a terminal or `NO_COLOR` is set, and `--color always|never` overrides it.
58
+
59
+ ```sh
60
+ keywarden check --local --allow team.pub --output keywarden-report.json
61
+ ```
62
+
63
+ The JSON file has fingerprints, key types, bit lengths, comments, daemon settings, and findings. It does not have key bodies. Do not point `--allow` at a private key. If one is present, Keywarden stops and keeps nothing.
64
+
65
+ ## Check one other host
66
+
67
+ ```sh
68
+ keywarden check --host deploy@app-web-03.example --allow team.pub --json
69
+ ```
70
+
71
+ This is `ssh` in batch mode (no password prompt). The remote command prints `sshd -T` and `authorized_keys` back to this process. Fingerprints are computed here. Nothing is written on the remote host.
72
+
73
+ A host key that is missing or changed fails the connection. Keywarden does not accept it. `--accept-new` trusts a key you have not seen before and is off unless you pass it. Use the `known_hosts` file your SSH client already has.
74
+
75
+ Reading root, and reading other accounts, needs to be allowed: run as root, or have passwordless `sudo`. With general passwordless sudo the collector uses it for `sshd -T`, `stat`, `test`, `ls`, `grep` of the sshd config, and `cat` of `authorized_keys`. With sudo limited to `sshd -T` and `cat`, those two still work, and file modes behind an unreadable home are reported as unknown. If root's file cannot be read, the root-key check is a **warning, not a pass**. A hidden root key must not look clean, and an account whose home cannot be read is listed as not checked instead of being skipped.
76
+
77
+ Every line read from an `authorized_keys` file is prefixed before it crosses the wire, and the parser refuses output that is out of order, repeated, or truncated. A user who writes collector markers into their own key file cannot change what the report says about the daemon or other accounts; the host fails with `collector-output` instead.
78
+
79
+ `--account` adds a specific user. Repeat it. Accounts that already have an `authorized_keys` file are included when the file is visible.
80
+
81
+ ## Check an Ansible inventory
82
+
83
+ ```sh
84
+ keywarden check --inventory hosts.ini --allow team.pub
85
+ ```
86
+
87
+ `hosts.ini` is the INI inventory you already use. Keywarden reads `ansible_user`, `ansible_host`, `ansible_port`, and `ansible_ssh_private_key_file` (and their `ansible_ssh_*` spellings), from host lines, `[group:vars]`, and parent groups through `[group:children]`. A nearer group overrides a parent, and a host line overrides both, as in Ansible. A private-key path is handed to `ssh -i`; Keywarden never opens the file. Passwords, become passwords, and every other variable are dropped as the file is read. YAML inventories are not supported yet and are refused with a message. Each host is one SSH, in order. A host that cannot be reached is a failure, and the rest are still checked.
88
+
89
+ The terminal shows failures only. A check that names a key is printed once per fingerprint, with every host where that fingerprint failed. Other failures stay on their own line: hostname and check. Passes are not printed. Exit 1 if any host failed. `--output` writes the JSON report, still without key bodies. The `fingerprints` array is the unique set. `--prior` is for one host, not an inventory.
90
+
91
+ ```ini
92
+ [web]
93
+ app-web-03 ansible_user=deploy
94
+ bastion ansible_host=10.0.0.9 ansible_user=ops ansible_port=2222
95
+ ```
96
+
97
+ There is no scheduler. The next run is the next time you start the command.
98
+
99
+ ## What is checked
100
+
101
+ | Code | Fails when |
102
+ | --- | --- |
103
+ | `permit-root` | `PermitRootLogin` is anything but `no`. `prohibit-password` and `without-password` still allow a root key, so they fail. |
104
+ | `password-auth` | `PasswordAuthentication` is on |
105
+ | `keyboard-interactive` | `KbdInteractiveAuthentication` is on |
106
+ | `empty-passwords` | `PermitEmptyPasswords` is on |
107
+ | `pubkey-auth` | `PubkeyAuthentication` is off |
108
+ | `root-authorized-key` | root has a key. Unreadable is a warning. |
109
+ | `root-account` | another account has uid 0, root is missing, or root's password field is empty |
110
+ | `permissions` | `.ssh` or `authorized_keys` is group- or world-accessible |
111
+ | `weak-algorithm` | DSA, or RSA under 3072 bits |
112
+ | `outside-push` | a non-root key is not in `--allow` |
113
+ | `cert-authority` | a line is marked `cert-authority` |
114
+ | `ssh-certificate` | a certificate is expired, not yet valid, a host certificate, or unreadable |
115
+ | `accepted-algorithms` | `PubkeyAcceptedAlgorithms` still allows `ssh-rsa`, `ssh-dss`, or SHA-1 |
116
+ | `ca-algorithms` | `CASignatureAlgorithms` still allows those |
117
+ | `trusted-user-ca` | `TrustedUserCAKeys` is set, so a signed key can bypass the push |
118
+ | `revoked-keys` | a user CA is trusted and `RevokedKeys` is none (warning) |
119
+ | `authorized-keys-command` | a command can inject keys that are not in the file |
120
+ | `authorized-keys-file` | `sshd` also reads a path other than `.ssh/authorized_keys` |
121
+ | `hostbased-auth` | host-based authentication is on |
122
+ | `auth-methods` | `AuthenticationMethods` still allows a password |
123
+ | `sshd-config` | `sshd -T` could not run, so daemon checks were skipped (warning) |
124
+ | `match-blocks` | `sshd_config` has `Match` blocks (warning; see below) |
125
+ | `collector-output` | the collector output was malformed or tampered with |
126
+ | `unreachable` | an inventory host could not be reached |
127
+ | `key-to-root` | a key opens an account with passwordless root: `NOPASSWD` sudo to a shell or `ALL`, or a root-equivalent group such as `docker` (warning) |
128
+ | `key-source` | a root-level key has no `from=` restriction (warning) |
129
+ | `forced-command-forwarding` | a `command=` key still allows port or agent forwarding (warning) |
130
+ | `key-environment` | a key sets `environment=` and `PermitUserEnvironment` is on |
131
+ | `expired-key` | a line's `expiry-time` has passed, or cannot be read (warning) |
132
+ | `log-level` | `LogLevel` is `QUIET`, `FATAL`, or `ERROR`, so logins are not recorded |
133
+ | `auth-log` | the sshd log could not be read, so key usage was not checked (warning) |
134
+ | `stale-key` | an authorized key has no login in `--stale-days` (default 90). A warning when the logs are shorter than that. |
135
+
136
+ Fields that are absent from the observation are skipped, not treated as a pass.
137
+
138
+ `sshd -T` prints the default settings, before any `Match` block applies. When the config has `Match` blocks, Keywarden also runs `sshd -T -C user=root,...` and fails `permit-root`, `password-auth`, or `keyboard-interactive` if a block makes root worse than the default. A `Match` block can only make a result worse, never better. Blocks for other users, groups, or source addresses are not evaluated; `match-blocks` warns so you know to read them.
139
+
140
+ Key text is fingerprinted in memory and then dropped. A private key, including a PuTTY private key, stops the run and is not written. The report never contains a key body. `--allow` is not deleted; it is your file.
141
+
142
+ `forced-commands-only` is a warning. Pass `--no-weak` or `--no-outside` to skip those two comparisons.
143
+
144
+ Key comments are often an email address or a machine name. Pass `--redact-comments` to leave them out of the terminal output and the report.
145
+
146
+ ## Access review
147
+
148
+ Every check also builds a table with one row per authorized key: host, account, fingerprint, type, comment, whether it is in `--allow`, what logging in leads to (`reach`), its `from=` and other options, and when it was last used.
149
+
150
+ ```sh
151
+ keywarden check --local --allow team.pub --keys
152
+ keywarden check --inventory hosts.ini --allow team.pub --csv access-review.csv
153
+ ```
154
+
155
+ `--keys` prints the table. `--csv` writes it for a spreadsheet or a ticket. The JSON report has it under `keys`. Cells that start with `=`, `+`, `-`, or `@` are prefixed with `'` so a key comment cannot run as a spreadsheet formula. A `command=` value is never printed, only that one is set, because forced commands sometimes carry tokens.
156
+
157
+ **Reach.** For each account with a key, Keywarden reads `sudo -l -U <account>` and the account's groups:
158
+
159
+ | Reach | Meaning |
160
+ | --- | --- |
161
+ | `root` | the account is root, or uid 0 |
162
+ | `root-nopasswd` | `NOPASSWD` sudo to `ALL`, a shell, or a command with a known shell escape (`vim`, `less`, `find`, `tee`, `systemctl`, and others), or membership in `docker`, `lxd`, `lxc`, `incus-admin`, or `disk` |
163
+ | `root-password` | the same sudo rules, but with a password; or membership in `sudo`, `wheel`, or `admin` when the rules cannot be read |
164
+ | `limited` | sudo to specific commands that are not on the shell-escape list. The list is not exhaustive; review these rules by hand. |
165
+ | `none` | not in sudoers, or sudo is not installed |
166
+ | `unknown` | sudo rules could not be read (run as root or with passwordless sudo) |
167
+
168
+ Each `sudo -l -U` call is written to the host's auth log by sudo itself.
169
+
170
+ **Last used.** Keywarden reads `Accepted publickey for <account> ... SHA256:<fingerprint>` lines. sshd writes these at the default `LogLevel INFO`. It prefers the systemd journal, read only for `ssh.service` and `sshd.service` entries logged as uid 0, which a local user cannot forge. Without a journal it reads `/var/log/auth.log*` or `/var/log/secure*`, including rotated and gzipped files. A local user can write fake lines to those files with `logger`, so the report names which source it used (`authLog`). The log tells you how far back it reaches; a key is only called stale when the logs cover the whole `--stale-days` window. A login through a certificate counts toward the `cert-authority` line that trusted it. A certificate pasted directly into `authorized_keys` is marked `unmatched`, because sshd logs the key inside it rather than the line.
171
+
172
+ Only the newest login line and a count per account and key leave the host. Pass `--redact-addresses` to drop the last login address from the output.
173
+
174
+ ## Evidence for an assessor
175
+
176
+ ```sh
177
+ keywarden check --inventory hosts.ini --allow team.pub --waivers keywarden.toml --evidence ssh-evidence.html
178
+ ```
179
+
180
+ `--evidence` (or **Evidence** in `keywarden ui`) writes one self-contained HTML file: who ran the check and with what visibility, the hosts in scope, every finding with the controls it bears on, the access review table, accepted risks, and a SHA-256 of the embedded JSON report so an assessor can tell it was not edited. A control coverage table groups the results under CIS Controls v8, NIST SP 800-53 Rev. 5, PCI DSS v4.0, and NERC CIP. Every finding in the JSON report also carries its `controls`.
181
+
182
+ The mapping is in [docs/compliance.md](docs/compliance.md). It supports an assessment and does not replace one: a passing check is evidence toward a control on the hosts checked, not proof the control is met.
183
+
184
+ ## Find a key
185
+
186
+ Offboarding someone, or a laptop was stolen: find every place their key gets in.
187
+
188
+ ```sh
189
+ keywarden where --key alice.pub --inventory hosts.ini
190
+ keywarden where SHA256:CU0yV+3RbcXlukC+M6dq938Ou+kt105Ipe/qaP4n4LU --host deploy@app-web-03.example
191
+ ```
192
+
193
+ Each hit names the host, the account, whether the key is in `authorized_keys` or trusted as a `cert-authority`, its reach, its `from=` limit, and its last login. A key that logged in recently but is not in any readable `authorized_keys` is listed too: it may come from `AuthorizedKeysCommand`, another key file, or a file Keywarden could not read. Hosts and accounts that could not be read are printed on stderr, because "not found" only covers what was visible.
194
+
195
+ Like `grep`, `where` exits 0 when it finds the key, 1 when it does not, and 2 on bad input. A private key passed to `--key` is refused.
196
+
197
+ ## Waivers
198
+
199
+ Some findings are deliberate: a break-glass key on root, an admin account with passwordless sudo. Record them instead of turning the check off:
200
+
201
+ ```toml
202
+ # keywarden.toml (Python 3.11+), or the same fields as JSON: {"waivers": [ {...} ]}
203
+ [[waiver]]
204
+ code = "root-authorized-key"
205
+ host = "bastion-*" # optional glob; default every host
206
+ fingerprint = "SHA256:..." # optional; default every key in the finding
207
+ reason = "break-glass key, sealed in the vault"
208
+ owner = "secops" # owner or ticket is required
209
+ ticket = "SEC-1234"
210
+ expires = "2027-03-01" # required
211
+ ```
212
+
213
+ ```sh
214
+ keywarden check --inventory hosts.ini --allow team.pub --waivers keywarden.toml
215
+ ```
216
+
217
+ A waived finding is reported as `accepted`, with the reason, ticket, and end date, and does not fail the run. When a finding names several keys, it is accepted only if every key is waived; otherwise it keeps its severity and says which keys were waived. An expired waiver stops working and the finding says so. A waiver that matched nothing is printed on stderr so the file does not collect dead entries.
218
+
219
+ ## Drift
220
+
221
+ Keep the previous report (it has no key bodies) and pass it next time:
222
+
223
+ ```sh
224
+ keywarden check --local --allow team.pub --prior keywarden-report.json
225
+ ```
226
+
227
+ A check that was a pass and is now a fail is marked `regressed`. A new failure is `new`. A failure that cleared is `cleared`. A check that was already failing and now names a fingerprint it did not name before is `new-keys`, with the added fingerprints in `newFingerprints`. Each finding that names keys carries a `fingerprints` array.
228
+
229
+ ## Offline observation
230
+
231
+ If you would rather not have Keywarden open SSH itself, run the collector on the host and pipe it. Do not save that stream: it contains public keys. The report does not. Collector output from 0.1.0 (protocol 1) is refused; run the current collector.
232
+
233
+ ```sh
234
+ python3 -c 'from keywarden.collect import REMOTE_SCRIPT; print(REMOTE_SCRIPT)' | sh | keywarden check --observation - --allow team.pub
235
+ ```
236
+
237
+ ## What this release does not do
238
+
239
+ - It does not install, rotate, or delete keys.
240
+ - It does not remember a push, a host list, or a report unless you choose `--output`.
241
+ - It does not run on a schedule or keep evidence for you. Run it from cron or CI and keep the reports you want.
242
+ - It checks inventory hosts one at a time, up to 256 per run.
243
+ - It reads `/etc/passwd` and `~/.ssh/authorized_keys`. Accounts that exist only in LDAP or SSSD, and keys served by `AuthorizedKeysCommand`, are not listed (the command itself is flagged). Their logins still show up in `where`.
244
+
245
+ ## Tests
246
+
247
+ ```sh
248
+ python3 -m unittest discover -s tests -v
249
+ ```
250
+
251
+ Fingerprint and certificate tests run when `ssh-keygen` is installed and are skipped otherwise. CI also runs the collector against a real OpenSSH server, including an `authorized_keys` file that tries to rewrite the report.
252
+
253
+ ## Contributing
254
+
255
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Report security issues privately; see [SECURITY.md](SECURITY.md).
256
+
257
+ ## License
258
+
259
+ [MIT](LICENSE).
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "keywarden-ssh"
3
+ version = "0.3.0"
4
+ description = "Check SSH posture around the public keys you push. Never stores them."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.9"
9
+ authors = [{ name = "Michael Waxman" }]
10
+ keywords = ["ssh", "security", "authorized_keys"]
11
+ classifiers = [
12
+ "Environment :: Console",
13
+ "Operating System :: POSIX",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Security",
17
+ ]
18
+ dependencies = []
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/generalwax2018/keywarden"
22
+ Repository = "https://github.com/generalwax2018/keywarden"
23
+ Issues = "https://github.com/generalwax2018/keywarden/issues"
24
+ Changelog = "https://github.com/generalwax2018/keywarden/blob/main/CHANGELOG.md"
25
+
26
+ [project.scripts]
27
+ keywarden = "keywarden.cli:main"
28
+
29
+ [build-system]
30
+ requires = ["setuptools>=77"]
31
+ build-backend = "setuptools.build_meta"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Check SSH posture around public keys you push. Never stores the keys."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from keywarden.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())