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/__init__.py +3 -0
- keywarden/__main__.py +6 -0
- keywarden/access.py +447 -0
- keywarden/check.py +690 -0
- keywarden/cli.py +624 -0
- keywarden/collect.py +967 -0
- keywarden/controls.py +133 -0
- keywarden/evidence.py +207 -0
- keywarden/inventory.py +199 -0
- keywarden/parse.py +443 -0
- keywarden/term.py +67 -0
- keywarden/ui.py +348 -0
- keywarden/ui_lab.py +173 -0
- keywarden/ui_page.py +665 -0
- keywarden/waivers.py +171 -0
- keywarden_ssh-0.3.0.dist-info/METADATA +280 -0
- keywarden_ssh-0.3.0.dist-info/RECORD +21 -0
- keywarden_ssh-0.3.0.dist-info/WHEEL +5 -0
- keywarden_ssh-0.3.0.dist-info/entry_points.txt +2 -0
- keywarden_ssh-0.3.0.dist-info/licenses/LICENSE +21 -0
- keywarden_ssh-0.3.0.dist-info/top_level.txt +1 -0
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
|