keywarden-ssh 0.3.0__py3-none-any.whl

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.
keywarden/check.py ADDED
@@ -0,0 +1,690 @@
1
+ """Posture rules. Findings name fingerprints, never key bodies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass, field
9
+
10
+ from keywarden.parse import KeyRef
11
+
12
+ WEAK_RSA_BITS = 3072
13
+ FINGERPRINT = re.compile(r"SHA256:[A-Za-z0-9+/]+")
14
+ _RANK = {"pass": 0, "warn": 1, "fail": 2}
15
+
16
+
17
+ @dataclass
18
+ class SshdFacts:
19
+ permit_root_login: str = "unknown"
20
+ password_authentication: str = "unknown"
21
+ kbd_interactive_authentication: str = "unknown"
22
+ pubkey_authentication: str = "unknown"
23
+ permit_empty_passwords: str = "unknown"
24
+ authentication_methods: str = "unknown"
25
+ hostbased_authentication: str = "unknown"
26
+ pubkey_accepted_algorithms: str = "unknown"
27
+ ca_signature_algorithms: str = "unknown"
28
+ trusted_user_ca_keys: str = "unknown"
29
+ revoked_keys: str = "unknown"
30
+ authorized_keys_command: str = "unknown"
31
+ authorized_keys_file: str = "unknown"
32
+ log_level: str = "unknown"
33
+ allow_tcp_forwarding: str = "unknown"
34
+ allow_agent_forwarding: str = "unknown"
35
+ x11_forwarding: str = "unknown"
36
+ permit_user_environment: str = "unknown"
37
+
38
+ def as_dict(self) -> dict:
39
+ return {
40
+ "permitRootLogin": self.permit_root_login,
41
+ "passwordAuthentication": self.password_authentication,
42
+ "kbdInteractiveAuthentication": self.kbd_interactive_authentication,
43
+ "pubkeyAuthentication": self.pubkey_authentication,
44
+ "permitEmptyPasswords": self.permit_empty_passwords,
45
+ "authenticationMethods": self.authentication_methods,
46
+ "hostbasedAuthentication": self.hostbased_authentication,
47
+ "pubkeyAcceptedAlgorithms": self.pubkey_accepted_algorithms,
48
+ "caSignatureAlgorithms": self.ca_signature_algorithms,
49
+ "trustedUserCaKeys": self.trusted_user_ca_keys,
50
+ "revokedKeys": self.revoked_keys,
51
+ "authorizedKeysCommand": self.authorized_keys_command,
52
+ "authorizedKeysFile": self.authorized_keys_file,
53
+ "logLevel": self.log_level,
54
+ "allowTcpForwarding": self.allow_tcp_forwarding,
55
+ "allowAgentForwarding": self.allow_agent_forwarding,
56
+ "x11Forwarding": self.x11_forwarding,
57
+ "permitUserEnvironment": self.permit_user_environment,
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class Account:
63
+ user: str
64
+ dir_mode: str
65
+ file_mode: str
66
+ keys: list[KeyRef]
67
+ read_state: str # ok, absent, unreadable
68
+ uid: int | None = None
69
+ password_locked: bool | None = None
70
+ groups: list[str] | None = None
71
+ sudo_state: str = "unknown" # ok, unreadable, absent (no sudo installed), unknown
72
+ sudo_lines: list[str] = field(default_factory=list)
73
+
74
+
75
+ @dataclass
76
+ class Login:
77
+ """Successful public-key logins for one account and fingerprint, from sshd's own log."""
78
+
79
+ last: int | None
80
+ count: int
81
+ source: str = ""
82
+
83
+
84
+ @dataclass
85
+ class HostObservation:
86
+ name: str
87
+ address: str
88
+ role: str
89
+ observed_at: str
90
+ sshd: SshdFacts
91
+ accounts: list[Account]
92
+ passwd_state: str = "unknown"
93
+ sshd_state: str = "unknown" # ok, failed, missing, unknown (hand-made observation)
94
+ match_blocks: str = "unknown" # yes, no, unknown
95
+ sshd_root: SshdFacts | None = None # sshd -T -C user=root, when Match blocks exist
96
+ privilege: str = "unknown" # root, sudo, none
97
+ now: int | None = None # the host's clock when it was observed
98
+ log_source: str = "unknown" # journal, file, none, unreadable, unknown
99
+ log_since: int | None = None # earliest log line the collector saw
100
+ logins: dict[tuple[str, str], Login] = field(default_factory=dict)
101
+
102
+
103
+ @dataclass
104
+ class Finding:
105
+ code: str
106
+ severity: str
107
+ title: str
108
+ detail: str
109
+ drift: str | None = None
110
+ new_fingerprints: list[str] = field(default_factory=list)
111
+ waivers: list[dict] = field(default_factory=list)
112
+ was: str | None = None # the severity before a waiver accepted it
113
+
114
+ @property
115
+ def fingerprints(self) -> list[str]:
116
+ return list(dict.fromkeys(FINGERPRINT.findall(self.detail or "")))
117
+
118
+ def as_dict(self) -> dict:
119
+ data = {
120
+ "code": self.code,
121
+ "severity": self.severity,
122
+ "title": self.title,
123
+ "detail": self.detail,
124
+ }
125
+ prints = self.fingerprints
126
+ if prints:
127
+ data["fingerprints"] = prints
128
+ if self.drift:
129
+ data["drift"] = self.drift
130
+ if self.new_fingerprints:
131
+ data["newFingerprints"] = list(self.new_fingerprints)
132
+ if self.waivers:
133
+ data["waivers"] = list(self.waivers)
134
+ if self.was:
135
+ data["was"] = self.was
136
+ from keywarden.controls import controls_for
137
+
138
+ controls = controls_for(self.code)
139
+ if controls:
140
+ data["controls"] = controls
141
+ return data
142
+
143
+
144
+ @dataclass
145
+ class Policy:
146
+ flag_outside_push: bool = True
147
+ flag_weak: bool = True
148
+ stale_days: int = 90
149
+
150
+
151
+ def parse_sshd(text: str) -> SshdFacts:
152
+ mapped: dict[str, str] = {}
153
+ for line in text.splitlines():
154
+ trimmed = line.strip()
155
+ if not trimmed or trimmed.startswith("#"):
156
+ continue
157
+ parts = trimmed.split(None, 1)
158
+ if len(parts) != 2 or not parts[0][:1].isalpha():
159
+ continue
160
+ mapped[parts[0].lower()] = parts[1].strip()
161
+
162
+ def pick(key: str) -> str:
163
+ return mapped.get(key, "unknown")
164
+
165
+ return SshdFacts(
166
+ permit_root_login=pick("permitrootlogin"),
167
+ password_authentication=pick("passwordauthentication"),
168
+ kbd_interactive_authentication=pick("kbdinteractiveauthentication"),
169
+ pubkey_authentication=pick("pubkeyauthentication"),
170
+ permit_empty_passwords=pick("permitemptypasswords"),
171
+ authentication_methods=pick("authenticationmethods"),
172
+ hostbased_authentication=pick("hostbasedauthentication"),
173
+ pubkey_accepted_algorithms=pick("pubkeyacceptedalgorithms")
174
+ if pick("pubkeyacceptedalgorithms") != "unknown"
175
+ else pick("pubkeyacceptedkeytypes"),
176
+ ca_signature_algorithms=pick("casignaturealgorithms"),
177
+ trusted_user_ca_keys=pick("trustedusercakeys"),
178
+ revoked_keys=pick("revokedkeys"),
179
+ authorized_keys_command=pick("authorizedkeyscommand"),
180
+ authorized_keys_file=pick("authorizedkeysfile"),
181
+ log_level=pick("loglevel"),
182
+ allow_tcp_forwarding=pick("allowtcpforwarding"),
183
+ allow_agent_forwarding=pick("allowagentforwarding"),
184
+ x11_forwarding=pick("x11forwarding"),
185
+ permit_user_environment=pick("permituserenvironment"),
186
+ )
187
+
188
+
189
+ def _mode_too_open(mode: str) -> bool | None:
190
+ if not mode or mode in {"absent", "unknown"}:
191
+ return None
192
+ if len(mode) not in (3, 4) or any(char not in "01234567" for char in mode):
193
+ return None
194
+ last = mode[-3:]
195
+ return last[1] != "0" or last[2] != "0"
196
+
197
+
198
+ def _describe(keys: list[KeyRef]) -> str:
199
+ grouped: dict[str, list[KeyRef]] = {}
200
+ order: list[str] = []
201
+ for key in keys:
202
+ if key.fingerprint not in grouped:
203
+ grouped[key.fingerprint] = []
204
+ order.append(key.fingerprint)
205
+ grouped[key.fingerprint].append(key)
206
+ parts = []
207
+ for fingerprint in order:
208
+ items = grouped[fingerprint]
209
+ key = items[0]
210
+ users: list[str] = []
211
+ for item in items:
212
+ if item.user and item.user not in users:
213
+ users.append(item.user)
214
+ where = f"{', '.join(users)} · " if users else ""
215
+ bits = f" {key.bits}-bit" if key.bits else ""
216
+ who = key.comment or "no comment"
217
+ parts.append(f"{where}{key.type}{bits} {fingerprint} ({who})")
218
+ return "; ".join(parts)
219
+
220
+
221
+ def _names(accounts: list[Account], limit: int = 10) -> str:
222
+ names = [account.user for account in accounts]
223
+ shown = ", ".join(names[:limit])
224
+ return shown + (f", and {len(names) - limit} more" if len(names) > limit else "")
225
+
226
+
227
+ def _unique_count(keys: list[KeyRef]) -> int:
228
+ return len({key.fingerprint for key in keys})
229
+
230
+
231
+ def _root_account(host: HostObservation) -> Finding | None:
232
+ """Fail when passwd shows a second uid 0 account, no root, or an empty root password."""
233
+ if host.passwd_state == "unknown":
234
+ return None
235
+ if host.passwd_state != "ok":
236
+ return Finding(
237
+ "root-account",
238
+ "warn",
239
+ "The root account could not be listed",
240
+ "passwd was not readable, so another uid 0 account would be invisible.",
241
+ )
242
+ uid0 = [account for account in host.accounts if account.uid == 0]
243
+ aliases = [account.user for account in uid0 if account.user != "root"]
244
+ root = next((account for account in host.accounts if account.user == "root"), None)
245
+ problems: list[str] = []
246
+ if aliases:
247
+ problems.append(f"{', '.join(aliases)} has uid 0")
248
+ if root is None:
249
+ problems.append("passwd has no root user")
250
+ elif root.password_locked is False:
251
+ problems.append("root's password field is empty")
252
+ if problems:
253
+ title = "An account other than root has uid 0" if aliases else "The root account is not safe"
254
+ return Finding("root-account", "fail", title, ". ".join(problems) + ".")
255
+ return Finding(
256
+ "root-account",
257
+ "pass",
258
+ "The only uid 0 account is root",
259
+ "No other account has uid 0, and the root password field is not empty.",
260
+ )
261
+
262
+
263
+ def _with_user(key: KeyRef, user: str) -> KeyRef:
264
+ return dataclasses.replace(key, user=user)
265
+
266
+
267
+ def _permit_root(value: str, raw: str) -> Finding | None:
268
+ if value == "no":
269
+ return Finding("permit-root", "pass", "Root login is disabled", "PermitRootLogin is no. Named accounts must log in, then escalate.")
270
+ if value in {"prohibit-password", "without-password"}:
271
+ return Finding(
272
+ "permit-root",
273
+ "fail",
274
+ "Root can still log in with a key",
275
+ f"PermitRootLogin is {raw}. Password is blocked, but a root public key still opens a root shell. Set it to no.",
276
+ )
277
+ if value == "forced-commands-only":
278
+ return Finding(
279
+ "permit-root",
280
+ "warn",
281
+ "Root login is forced-commands-only",
282
+ "Root SSH is limited to a command pinned on the key. Confirm that command is the only one you intend.",
283
+ )
284
+ if value in {"unknown", ""}:
285
+ return Finding("permit-root", "warn", "PermitRootLogin was not in the observation", "sshd -T did not include PermitRootLogin.")
286
+ return Finding("permit-root", "fail", "Root login is enabled", f"PermitRootLogin is {raw}. Set it to no.")
287
+
288
+
289
+ def _password_auth(value: str, raw: str) -> Finding | None:
290
+ if value == "no":
291
+ return Finding("password-auth", "pass", "Password authentication is off", "PasswordAuthentication is no.")
292
+ if value in {"unknown", ""}:
293
+ return Finding("password-auth", "warn", "Password authentication was not observed", "sshd -T did not include PasswordAuthentication.")
294
+ return Finding(
295
+ "password-auth",
296
+ "fail",
297
+ "Password authentication is on",
298
+ f"PasswordAuthentication is {raw}. Keys do not help if a password still works.",
299
+ )
300
+
301
+
302
+ def _kbd_interactive(value: str, raw: str) -> Finding | None:
303
+ if value == "yes":
304
+ return Finding(
305
+ "keyboard-interactive",
306
+ "fail",
307
+ "Keyboard-interactive authentication is on",
308
+ "KbdInteractiveAuthentication yes is another password path. Turn it off.",
309
+ )
310
+ if value == "no":
311
+ return Finding("keyboard-interactive", "pass", "Keyboard-interactive authentication is off", "KbdInteractiveAuthentication is no.")
312
+ return None
313
+
314
+
315
+ def _with_root_match(host: HostObservation, attr: str, rule, daemon_unread: bool) -> Finding | None:
316
+ """Evaluate a daemon setting, then let a Match block for root make it worse, never better."""
317
+ raw = getattr(host.sshd, attr)
318
+ value = raw.lower()
319
+ if daemon_unread and value in {"unknown", ""}:
320
+ return None
321
+ base = rule(value, raw)
322
+ if host.sshd_root is None:
323
+ return base
324
+ root_raw = getattr(host.sshd_root, attr)
325
+ root_value = root_raw.lower()
326
+ if root_value in {"unknown", ""} or root_value == value:
327
+ return base
328
+ for_root = rule(root_value, root_raw)
329
+ if for_root is None or (base is not None and _RANK[for_root.severity] <= _RANK[base.severity]):
330
+ return base
331
+ return Finding(
332
+ for_root.code,
333
+ for_root.severity,
334
+ for_root.title + " (Match block for root)",
335
+ f"A Match block changes this for root. Default: {raw}. For root: {root_raw}. " + for_root.detail,
336
+ )
337
+
338
+
339
+ def evaluate(host: HostObservation, push: list[KeyRef], policy: Policy) -> list[Finding]:
340
+ findings: list[Finding] = []
341
+ daemon_unread = host.sshd_state in {"failed", "missing"}
342
+ if daemon_unread:
343
+ reason = (
344
+ "sshd was not found on PATH or in /usr/sbin, /usr/local/sbin, or /sbin."
345
+ if host.sshd_state == "missing"
346
+ else "sshd -T exited with an error. It usually needs root, or passwordless sudo, to read the host keys."
347
+ )
348
+ findings.append(
349
+ Finding(
350
+ "sshd-config",
351
+ "warn",
352
+ "The effective sshd configuration could not be read",
353
+ reason + " Daemon checks were skipped, not passed.",
354
+ )
355
+ )
356
+
357
+ for finding in (
358
+ _with_root_match(host, "permit_root_login", _permit_root, daemon_unread),
359
+ _with_root_match(host, "password_authentication", _password_auth, daemon_unread),
360
+ _with_root_match(host, "kbd_interactive_authentication", _kbd_interactive, daemon_unread),
361
+ ):
362
+ if finding is not None:
363
+ findings.append(finding)
364
+
365
+ if host.match_blocks == "yes":
366
+ evaluated = "Root was evaluated with its own Match context. " if host.sshd_root is not None else ""
367
+ findings.append(
368
+ Finding(
369
+ "match-blocks",
370
+ "warn",
371
+ "sshd_config has Match blocks",
372
+ "The daemon checks use the default settings. "
373
+ + evaluated
374
+ + "Match blocks for other users, groups, or addresses can override them and were not evaluated.",
375
+ )
376
+ )
377
+
378
+ empty = host.sshd.permit_empty_passwords.lower()
379
+ if empty == "yes":
380
+ findings.append(Finding("empty-passwords", "fail", "Empty passwords are permitted", "PermitEmptyPasswords is yes."))
381
+ elif empty == "no":
382
+ findings.append(Finding("empty-passwords", "pass", "Empty passwords are refused", "PermitEmptyPasswords is no."))
383
+
384
+ pubkey = host.sshd.pubkey_authentication.lower()
385
+ if pubkey == "no":
386
+ findings.append(
387
+ Finding(
388
+ "pubkey-auth",
389
+ "fail",
390
+ "Public-key authentication is disabled",
391
+ "PubkeyAuthentication is no, so the keys you push cannot be what logs people in.",
392
+ )
393
+ )
394
+ elif pubkey == "yes":
395
+ findings.append(Finding("pubkey-auth", "pass", "Public-key authentication is on", "PubkeyAuthentication is yes."))
396
+
397
+ root_accounts = [account for account in host.accounts if account.user == "root"]
398
+ if not root_accounts or any(account.read_state == "unreadable" for account in root_accounts):
399
+ findings.append(
400
+ Finding(
401
+ "root-authorized-key",
402
+ "warn",
403
+ "Root's authorized_keys could not be read",
404
+ "No pass is given for an empty root key unless the file was readable or absent. Re-run as root, or with passwordless sudo, so a hidden root key cannot hide.",
405
+ )
406
+ )
407
+ else:
408
+ root_keys = [
409
+ _with_user(key, "root")
410
+ for account in root_accounts
411
+ if account.read_state == "ok"
412
+ for key in account.keys
413
+ ]
414
+ if not root_keys:
415
+ findings.append(
416
+ Finding(
417
+ "root-authorized-key",
418
+ "pass",
419
+ "No authorized key on root",
420
+ "root's authorized_keys is empty or absent.",
421
+ )
422
+ )
423
+ else:
424
+ title = "A key is authorized for root" if _unique_count(root_keys) == 1 else f"{_unique_count(root_keys)} keys are authorized for root"
425
+ findings.append(Finding("root-authorized-key", "fail", title, _describe(root_keys)))
426
+
427
+ loose = []
428
+ unseen = []
429
+ for account in host.accounts:
430
+ if account.read_state == "unreadable" or (
431
+ account.read_state == "ok" and "unknown" in {account.dir_mode, account.file_mode}
432
+ ):
433
+ unseen.append(account)
434
+ continue
435
+ dir_open = _mode_too_open(account.dir_mode)
436
+ file_open = _mode_too_open(account.file_mode)
437
+ if dir_open or file_open:
438
+ loose.append(account)
439
+ if loose:
440
+ detail = "; ".join(f"{account.user}: .ssh {account.dir_mode}, authorized_keys {account.file_mode}" for account in loose)
441
+ findings.append(Finding("permissions", "fail", "SSH files are group- or world-readable", detail))
442
+ elif unseen:
443
+ findings.append(
444
+ Finding(
445
+ "permissions",
446
+ "warn",
447
+ "Some SSH files could not be read",
448
+ "Modes were checked only where they were visible. Not checked: " + _names(unseen) + ".",
449
+ )
450
+ )
451
+ else:
452
+ findings.append(
453
+ Finding(
454
+ "permissions",
455
+ "pass",
456
+ "SSH directory permissions are tight",
457
+ "Each readable .ssh directory is 700 and authorized_keys is 600, or the file is absent.",
458
+ )
459
+ )
460
+
461
+ if policy.flag_weak:
462
+ weak = [
463
+ _with_user(key, account.user)
464
+ for account in host.accounts
465
+ if account.user != "root" and account.read_state == "ok"
466
+ for key in account.keys
467
+ if key.kind == "dsa" or (key.kind == "rsa" and (key.bits is None or key.bits < WEAK_RSA_BITS))
468
+ ]
469
+ if not weak:
470
+ findings.append(
471
+ Finding(
472
+ "weak-algorithm",
473
+ "pass",
474
+ "No weak public keys",
475
+ "No DSA keys and no RSA keys under 3072 bits.",
476
+ )
477
+ )
478
+ else:
479
+ title = "A weak public key is authorized" if _unique_count(weak) == 1 else f"{_unique_count(weak)} weak public keys are authorized"
480
+ findings.append(Finding("weak-algorithm", "fail", title, _describe(weak)))
481
+
482
+ push_set = {key.fingerprint for key in push}
483
+ if not push:
484
+ findings.append(
485
+ Finding(
486
+ "outside-push",
487
+ "warn",
488
+ "No keys were supplied for this run",
489
+ "Daemon, root, and permission checks still ran. Pass --allow with the public keys you push to compare what the host accepts.",
490
+ )
491
+ )
492
+ elif policy.flag_outside_push:
493
+ outside = [
494
+ _with_user(key, account.user)
495
+ for account in host.accounts
496
+ if account.user != "root" and account.read_state == "ok"
497
+ for key in account.keys
498
+ if key.fingerprint not in push_set
499
+ ]
500
+ if not outside:
501
+ findings.append(
502
+ Finding(
503
+ "outside-push",
504
+ "pass",
505
+ "Authorized keys match the push",
506
+ "Every non-root key on this host is in the set you supplied. The key bodies were not kept.",
507
+ )
508
+ )
509
+ else:
510
+ title = "A key is not in the push" if _unique_count(outside) == 1 else f"{_unique_count(outside)} keys are not in the push"
511
+ findings.append(Finding("outside-push", "fail", title, _describe(outside)))
512
+
513
+ all_keys = [_with_user(key, account.user) for account in host.accounts for key in account.keys]
514
+ authorities = [key for key in all_keys if key.cert_authority]
515
+ if not authorities:
516
+ findings.append(
517
+ Finding(
518
+ "cert-authority",
519
+ "pass",
520
+ "No key is a certificate authority",
521
+ "Nothing in authorized_keys is marked cert-authority, so one line cannot sign in extra keys.",
522
+ )
523
+ )
524
+ else:
525
+ title = "A line trusts a certificate authority" if _unique_count(authorities) == 1 else f"{_unique_count(authorities)} lines trust a certificate authority"
526
+ findings.append(
527
+ Finding(
528
+ "cert-authority",
529
+ "fail",
530
+ title,
531
+ _describe(authorities) + ". A cert-authority line lets any certificate that CA signs log in, including keys you did not push.",
532
+ )
533
+ )
534
+
535
+ now = int(time.time())
536
+ by_fingerprint: dict[str, list[KeyRef]] = {}
537
+ for key in all_keys:
538
+ if not key.cert:
539
+ continue
540
+ by_fingerprint.setdefault(key.fingerprint, []).append(key)
541
+ problems = []
542
+ for fingerprint, items in by_fingerprint.items():
543
+ key = items[0]
544
+ cert = key.cert
545
+ users: list[str] = []
546
+ for item in items:
547
+ if item.user and item.user not in users:
548
+ users.append(item.user)
549
+ who = f"{', '.join(users)} · {fingerprint}" if users else fingerprint
550
+ if cert and cert.unreadable:
551
+ problems.append(f"{who} could not be read")
552
+ elif cert and cert.cert_type == "host":
553
+ problems.append(f"{who} is a host certificate")
554
+ elif cert and cert.valid_after > now:
555
+ problems.append(f"{who} is not valid yet")
556
+ elif cert and cert.valid_before is not None and cert.valid_before < now:
557
+ problems.append(f"{who} has expired")
558
+ if problems:
559
+ title = "An SSH certificate is not acceptable" if len(problems) == 1 else f"{len(problems)} SSH certificates are not acceptable"
560
+ findings.append(Finding("ssh-certificate", "fail", title, "; ".join(problems)))
561
+ elif any(key.cert for key in all_keys):
562
+ findings.append(
563
+ Finding(
564
+ "ssh-certificate",
565
+ "pass",
566
+ "SSH certificates are inside their validity window",
567
+ "Each certificate is a user certificate, currently valid. The certificate body was not kept.",
568
+ )
569
+ )
570
+ else:
571
+ findings.append(
572
+ Finding(
573
+ "ssh-certificate",
574
+ "pass",
575
+ "No SSH certificates are authorized",
576
+ "authorized_keys has no certificate. A trusted user CA is checked separately when sshd -T includes it.",
577
+ )
578
+ )
579
+
580
+ def seen(value: str) -> bool:
581
+ return bool(value) and value.lower() != "unknown"
582
+
583
+ def weak_tokens(value: str) -> list[str]:
584
+ return [token for token in value.lower().replace(",", " ").split() if token in {"ssh-rsa", "ssh-dss"} or "sha1" in token]
585
+
586
+ if seen(host.sshd.pubkey_accepted_algorithms):
587
+ weak = weak_tokens(host.sshd.pubkey_accepted_algorithms)
588
+ if not weak:
589
+ findings.append(Finding("accepted-algorithms", "pass", "sshd rejects weak public-key algorithms", "PubkeyAcceptedAlgorithms does not include ssh-rsa, ssh-dss, or SHA-1."))
590
+ else:
591
+ findings.append(Finding("accepted-algorithms", "fail", "sshd still accepts a weak public-key algorithm", f"PubkeyAcceptedAlgorithms includes {', '.join(weak)}."))
592
+
593
+ if seen(host.sshd.ca_signature_algorithms):
594
+ weak = weak_tokens(host.sshd.ca_signature_algorithms)
595
+ if not weak:
596
+ findings.append(Finding("ca-algorithms", "pass", "CA signatures do not use SHA-1", "CASignatureAlgorithms does not include ssh-rsa, ssh-dss, or SHA-1."))
597
+ else:
598
+ findings.append(Finding("ca-algorithms", "fail", "A CA may still sign with a weak algorithm", f"CASignatureAlgorithms includes {', '.join(weak)}."))
599
+
600
+ if seen(host.sshd.trusted_user_ca_keys):
601
+ trusted = host.sshd.trusted_user_ca_keys.strip().lower() not in {"", "none"}
602
+ if trusted:
603
+ findings.append(Finding("trusted-user-ca", "fail", "A user CA can authorize keys that were not pushed", f"TrustedUserCAKeys is {host.sshd.trusted_user_ca_keys}."))
604
+ else:
605
+ findings.append(Finding("trusted-user-ca", "pass", "No user CA is trusted", "TrustedUserCAKeys is none."))
606
+ if trusted and seen(host.sshd.revoked_keys):
607
+ if host.sshd.revoked_keys.strip().lower() in {"", "none"}:
608
+ findings.append(Finding("revoked-keys", "warn", "The trusted CA has no revocation list", "RevokedKeys is none."))
609
+ else:
610
+ findings.append(Finding("revoked-keys", "pass", "A revocation list is configured", f"RevokedKeys is {host.sshd.revoked_keys}."))
611
+
612
+ if seen(host.sshd.authorized_keys_command):
613
+ if host.sshd.authorized_keys_command.strip().lower() in {"", "none"}:
614
+ findings.append(Finding("authorized-keys-command", "pass", "No command injects authorized keys", "AuthorizedKeysCommand is none."))
615
+ else:
616
+ findings.append(Finding("authorized-keys-command", "fail", "A command can add keys that are not on disk", f"AuthorizedKeysCommand is {host.sshd.authorized_keys_command}."))
617
+
618
+ if seen(host.sshd.authorized_keys_file):
619
+ standard = {".ssh/authorized_keys", ".ssh/authorized_keys2"}
620
+ files = [part for part in host.sshd.authorized_keys_file.replace(",", " ").split() if part]
621
+ extra = [part for part in files if part not in standard]
622
+ if not extra:
623
+ findings.append(Finding("authorized-keys-file", "pass", "Authorized keys are read from the standard files", " ".join(files) or ".ssh/authorized_keys"))
624
+ else:
625
+ findings.append(Finding("authorized-keys-file", "warn", "sshd also reads authorized keys from another path", f"AuthorizedKeysFile includes {', '.join(extra)}."))
626
+
627
+ if seen(host.sshd.hostbased_authentication):
628
+ if host.sshd.hostbased_authentication.lower() == "no":
629
+ findings.append(Finding("hostbased-auth", "pass", "Host-based authentication is off", "HostbasedAuthentication is no."))
630
+ else:
631
+ findings.append(Finding("hostbased-auth", "fail", "Host-based authentication is on", "HostbasedAuthentication is yes."))
632
+
633
+ if seen(host.sshd.authentication_methods):
634
+ methods = host.sshd.authentication_methods.lower().replace(",", " ")
635
+ tokens = methods.split()
636
+ if methods.strip() == "any" or "password" in tokens or "keyboard-interactive" in tokens:
637
+ findings.append(Finding("auth-methods", "fail", "Authentication methods still allow a password", f"AuthenticationMethods is {host.sshd.authentication_methods}."))
638
+ else:
639
+ findings.append(Finding("auth-methods", "pass", "Authentication methods do not include a password", f"AuthenticationMethods is {host.sshd.authentication_methods}."))
640
+
641
+ root_account = _root_account(host)
642
+ if root_account is not None:
643
+ findings.append(root_account)
644
+
645
+ from keywarden.access import access_findings
646
+
647
+ findings.extend(access_findings(host, push, policy))
648
+ return findings
649
+
650
+
651
+ def worst(findings: list[Finding]) -> str:
652
+ """accepted (a waived finding) counts as a pass."""
653
+ if any(item.severity == "fail" for item in findings):
654
+ return "fail"
655
+ if any(item.severity == "warn" for item in findings):
656
+ return "warn"
657
+ return "pass"
658
+
659
+
660
+ PriorFindings = dict[str, tuple[str, set[str]]]
661
+
662
+
663
+ def apply_drift(findings: list[Finding], prior: PriorFindings | None) -> list[Finding]:
664
+ """Mark changes against an earlier report.
665
+
666
+ regressed: was pass, now fail. new: no earlier result, now fail.
667
+ new-keys: was already failing, and now names a fingerprint it did not name before.
668
+ cleared: was fail, now pass.
669
+ """
670
+ if not prior:
671
+ return findings
672
+ adjusted: list[Finding] = []
673
+ for item in findings:
674
+ before = prior.get(item.code)
675
+ drift = None
676
+ added: list[str] = []
677
+ if before is None:
678
+ drift = "new" if item.severity == "fail" else None
679
+ else:
680
+ severity, prints = before
681
+ if severity == "pass" and item.severity == "fail":
682
+ drift = "regressed"
683
+ elif severity == "fail" and item.severity == "pass":
684
+ drift = "cleared"
685
+ elif severity == "fail" and item.severity == "fail":
686
+ added = [fingerprint for fingerprint in item.fingerprints if fingerprint not in prints]
687
+ if added:
688
+ drift = "new-keys"
689
+ adjusted.append(Finding(item.code, item.severity, item.title, item.detail, drift, added))
690
+ return adjusted