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/__init__.py
ADDED
keywarden/__main__.py
ADDED
keywarden/access.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Access review: who each key lets in, as what, from where, and whether it is still used."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from keywarden.check import Account, Finding, HostObservation, Login, Policy
|
|
10
|
+
from keywarden.parse import KeyRef
|
|
11
|
+
|
|
12
|
+
DAY = 86400
|
|
13
|
+
|
|
14
|
+
# Groups that are root in all but name.
|
|
15
|
+
ROOT_EQUIVALENT_GROUPS = {"docker", "lxd", "lxc", "disk", "incus-admin"}
|
|
16
|
+
# Groups that usually grant sudo with a password.
|
|
17
|
+
SUDO_GROUPS = {"sudo", "wheel", "admin"}
|
|
18
|
+
# Commands that give a shell or write any file when run through sudo.
|
|
19
|
+
# Not exhaustive: a limited rule that is not on this list is still only "limited".
|
|
20
|
+
SHELL_ESCAPES = {
|
|
21
|
+
"ALL", "sh", "bash", "dash", "zsh", "ksh", "fish", "su", "sudo", "sudoedit", "env", "vi", "vim", "nvim",
|
|
22
|
+
"nano", "ed", "emacs", "less", "more", "man", "find", "awk", "gawk", "mawk", "sed", "perl", "python",
|
|
23
|
+
"python2", "python3", "ruby", "lua", "node", "php", "tee", "cp", "mv", "dd", "chmod", "chown", "tar",
|
|
24
|
+
"zip", "rsync", "git", "docker", "podman", "systemctl", "journalctl", "crontab", "install", "ln",
|
|
25
|
+
"nmap", "ssh", "scp", "script", "socat", "nc", "ncat", "xargs", "flock", "taskset", "nice", "time",
|
|
26
|
+
"timeout", "watch", "strace", "gdb", "apt", "apt-get", "yum", "dnf", "pip", "pip3", "npm", "make",
|
|
27
|
+
"mount", "busybox", "openssl", "run-parts", "visudo",
|
|
28
|
+
}
|
|
29
|
+
_ROOT_RUNAS = re.compile(r"^\(\s*(ALL|root)\b", re.IGNORECASE)
|
|
30
|
+
_TAGS = re.compile(r"^((?:NOPASSWD|PASSWD|SETENV|NOSETENV|EXEC|NOEXEC|LOG_INPUT|NOLOG_INPUT|LOG_OUTPUT|NOLOG_OUTPUT|MAIL|NOMAIL|FOLLOW|NOFOLLOW|INTERCEPT|NOINTERCEPT)\s*:\s*)+")
|
|
31
|
+
_ACCEPTED = re.compile(
|
|
32
|
+
r"Accepted publickey for (?P<user>\S+) from (?P<source>\S+) port \d+ \S+: (?P<type>\S+) "
|
|
33
|
+
r"(?P<fp>SHA256:[A-Za-z0-9+/]+)(?:.*? CA \S+ (?P<ca>SHA256:[A-Za-z0-9+/]+))?"
|
|
34
|
+
)
|
|
35
|
+
_SYSLOG_TIME = re.compile(r"^(?P<mon>[A-Z][a-z]{2})\s+(?P<day>\d{1,2})\s+(?P<time>\d{2}:\d{2}:\d{2})(?:\s|$)")
|
|
36
|
+
_MONTHS = {name: index for index, name in enumerate(
|
|
37
|
+
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], start=1)}
|
|
38
|
+
_QUIET_LOG_LEVELS = {"quiet", "fatal", "error"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Reach:
|
|
43
|
+
"""What logging in as this account leads to."""
|
|
44
|
+
|
|
45
|
+
level: str # root, root-nopasswd, root-password, limited, none, unknown
|
|
46
|
+
via: str = ""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def privileged(self) -> bool:
|
|
50
|
+
return self.level in {"root", "root-nopasswd"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _command_name(command: str) -> str:
|
|
54
|
+
first = command.strip().split()[0] if command.strip() else ""
|
|
55
|
+
return first.rsplit("/", 1)[-1]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def classify_sudo(lines: list[str]) -> Reach | None:
|
|
59
|
+
"""Read `sudo -l -U user` output. None when the output says nothing usable."""
|
|
60
|
+
text = "\n".join(lines)
|
|
61
|
+
if "is not allowed to run sudo" in text:
|
|
62
|
+
return Reach("none", "not in sudoers")
|
|
63
|
+
if "may run the following commands" not in text:
|
|
64
|
+
return None
|
|
65
|
+
entries: list[str] = []
|
|
66
|
+
collecting = False
|
|
67
|
+
for line in lines:
|
|
68
|
+
if "may run the following commands" in line:
|
|
69
|
+
collecting = True
|
|
70
|
+
continue
|
|
71
|
+
if collecting and line.strip():
|
|
72
|
+
entries.append(line.strip())
|
|
73
|
+
best = Reach("none", "no sudo rules")
|
|
74
|
+
rank = {"none": 0, "limited": 1, "root-password": 2, "root-nopasswd": 3}
|
|
75
|
+
for entry in entries:
|
|
76
|
+
runas_root = bool(_ROOT_RUNAS.match(entry))
|
|
77
|
+
rest = re.sub(r"^\([^)]*\)\s*", "", entry)
|
|
78
|
+
tags = _TAGS.match(rest)
|
|
79
|
+
tag_text = tags.group(0) if tags else ""
|
|
80
|
+
nopasswd = "NOPASSWD" in tag_text.upper()
|
|
81
|
+
commands = [part.strip() for part in rest[len(tag_text):].split(",") if part.strip()]
|
|
82
|
+
escapes = [command for command in commands if _command_name(command) in SHELL_ESCAPES or command.strip() == "ALL"]
|
|
83
|
+
if runas_root and escapes:
|
|
84
|
+
shown = "ALL" if any(command.strip() == "ALL" for command in escapes) else _command_name(escapes[0])
|
|
85
|
+
level = "root-nopasswd" if nopasswd else "root-password"
|
|
86
|
+
via = f"sudo {shown}" + (" without a password" if nopasswd else " with a password")
|
|
87
|
+
candidate = Reach(level, via)
|
|
88
|
+
else:
|
|
89
|
+
candidate = Reach("limited", "sudo " + ", ".join(_command_name(command) for command in commands[:3]))
|
|
90
|
+
if rank[candidate.level] > rank[best.level]:
|
|
91
|
+
best = candidate
|
|
92
|
+
return best
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def reach_of(account: Account) -> Reach:
|
|
96
|
+
if account.user == "root" or account.uid == 0:
|
|
97
|
+
return Reach("root", "is root" if account.user == "root" else "uid 0")
|
|
98
|
+
groups = set(account.groups or [])
|
|
99
|
+
root_groups = sorted(groups & ROOT_EQUIVALENT_GROUPS)
|
|
100
|
+
sudo = classify_sudo(account.sudo_lines) if account.sudo_state == "ok" else None
|
|
101
|
+
if sudo is not None and sudo.level == "root-nopasswd":
|
|
102
|
+
return sudo
|
|
103
|
+
if root_groups:
|
|
104
|
+
return Reach("root-nopasswd", f"member of {root_groups[0]}, which is root-equivalent")
|
|
105
|
+
if sudo is not None:
|
|
106
|
+
return sudo
|
|
107
|
+
if account.sudo_state == "absent":
|
|
108
|
+
return Reach("none", "sudo is not installed")
|
|
109
|
+
sudo_groups = sorted(groups & SUDO_GROUPS)
|
|
110
|
+
if sudo_groups:
|
|
111
|
+
return Reach("root-password", f"member of {sudo_groups[0]}; sudo rules could not be read")
|
|
112
|
+
return Reach("unknown", "sudo rules could not be read")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_log_time(line: str, now: int | None, offset: str | None) -> int | None:
|
|
116
|
+
"""Timestamp of a journal (short-unix), RFC 3339, or classic syslog line."""
|
|
117
|
+
head = line.split(" ", 1)[0]
|
|
118
|
+
try:
|
|
119
|
+
return int(float(head))
|
|
120
|
+
except ValueError:
|
|
121
|
+
pass
|
|
122
|
+
if len(head) >= 19 and head[4] == "-" and head[10] == "T":
|
|
123
|
+
text = head[:-1] + "+00:00" if head.endswith("Z") else head
|
|
124
|
+
try:
|
|
125
|
+
moment = datetime.fromisoformat(text)
|
|
126
|
+
except ValueError:
|
|
127
|
+
moment = None
|
|
128
|
+
if moment is not None:
|
|
129
|
+
if moment.tzinfo is None:
|
|
130
|
+
moment = moment.replace(tzinfo=_zone(offset))
|
|
131
|
+
return int(moment.timestamp())
|
|
132
|
+
match = _SYSLOG_TIME.match(line)
|
|
133
|
+
if not match or match.group("mon") not in _MONTHS:
|
|
134
|
+
return None
|
|
135
|
+
zone = _zone(offset)
|
|
136
|
+
reference = datetime.fromtimestamp(now, zone) if now else datetime.now(zone)
|
|
137
|
+
hour, minute, second = (int(part) for part in match.group("time").split(":"))
|
|
138
|
+
try:
|
|
139
|
+
moment = datetime(reference.year, _MONTHS[match.group("mon")], int(match.group("day")), hour, minute, second, tzinfo=zone)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
142
|
+
if moment - reference > timedelta(days=1):
|
|
143
|
+
# Classic syslog has no year. A date ahead of the host clock is last year.
|
|
144
|
+
moment = moment.replace(year=moment.year - 1)
|
|
145
|
+
return int(moment.timestamp())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _zone(offset: str | None) -> timezone:
|
|
149
|
+
if offset and re.fullmatch(r"[+-]\d{4}", offset):
|
|
150
|
+
sign = 1 if offset[0] == "+" else -1
|
|
151
|
+
return timezone(sign * timedelta(hours=int(offset[1:3]), minutes=int(offset[3:5])))
|
|
152
|
+
return timezone.utc
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_login_lines(lines: list[str], now: int | None, offset: str | None) -> dict[tuple[str, str], Login]:
|
|
156
|
+
"""Lines are "<count> <log line>": the newest accepted login per account and key, with a count."""
|
|
157
|
+
logins: dict[tuple[str, str], Login] = {}
|
|
158
|
+
for raw in lines:
|
|
159
|
+
count_text, _space, line = raw.partition(" ")
|
|
160
|
+
if not count_text.isdigit():
|
|
161
|
+
continue
|
|
162
|
+
match = _ACCEPTED.search(line)
|
|
163
|
+
if not match:
|
|
164
|
+
continue
|
|
165
|
+
when = parse_log_time(line, now, offset)
|
|
166
|
+
count = int(count_text)
|
|
167
|
+
keys = [(match.group("user"), match.group("fp"))]
|
|
168
|
+
if match.group("ca"):
|
|
169
|
+
keys.append((match.group("user"), "CA:" + match.group("ca")))
|
|
170
|
+
for key in keys:
|
|
171
|
+
known = logins.get(key)
|
|
172
|
+
if known is None:
|
|
173
|
+
logins[key] = Login(when, count, match.group("source"))
|
|
174
|
+
continue
|
|
175
|
+
newer = when is not None and (known.last is None or when > known.last)
|
|
176
|
+
logins[key] = Login(
|
|
177
|
+
when if newer else known.last,
|
|
178
|
+
known.count + count,
|
|
179
|
+
match.group("source") if newer else known.source,
|
|
180
|
+
)
|
|
181
|
+
return logins
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _iso(epoch: int | None) -> str | None:
|
|
185
|
+
if epoch is None:
|
|
186
|
+
return None
|
|
187
|
+
return datetime.fromtimestamp(epoch, timezone.utc).replace(microsecond=0).isoformat()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _login_for(host: HostObservation, user: str, key: KeyRef) -> Login | None:
|
|
191
|
+
if key.cert_authority:
|
|
192
|
+
return host.logins.get((user, "CA:" + key.fingerprint))
|
|
193
|
+
return host.logins.get((user, key.fingerprint))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def logs_usable(host: HostObservation) -> bool:
|
|
197
|
+
return host.log_source in {"journal", "file"} and host.now is not None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def usage_status(host: HostObservation, user: str, key: KeyRef, policy: Policy) -> tuple[str, Login | None]:
|
|
201
|
+
"""fresh, stale, unseen (logs too short to say), unmatched (a certificate line), or unknown."""
|
|
202
|
+
if not logs_usable(host):
|
|
203
|
+
return "unknown", None
|
|
204
|
+
if key.cert is not None and not key.cert_authority:
|
|
205
|
+
# sshd logs the certificate's key, not the line as written; it cannot be matched.
|
|
206
|
+
return "unmatched", None
|
|
207
|
+
login = _login_for(host, user, key)
|
|
208
|
+
now = host.now or 0
|
|
209
|
+
window = policy.stale_days * DAY
|
|
210
|
+
if login is not None and login.last is not None and now - login.last <= window:
|
|
211
|
+
return "fresh", login
|
|
212
|
+
covered = now - host.log_since if host.log_since is not None else 0
|
|
213
|
+
if covered >= window:
|
|
214
|
+
return "stale", login
|
|
215
|
+
return "unseen", login
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def key_rows(host: HostObservation, push: list[KeyRef], policy: Policy) -> list[dict]:
|
|
219
|
+
"""One row per authorized key: the access review table."""
|
|
220
|
+
allowed = {key.fingerprint for key in push}
|
|
221
|
+
rows: list[dict] = []
|
|
222
|
+
for account in host.accounts:
|
|
223
|
+
if account.read_state != "ok":
|
|
224
|
+
continue
|
|
225
|
+
reach = reach_of(account)
|
|
226
|
+
for key in account.keys:
|
|
227
|
+
status, login = usage_status(host, account.user, key, policy)
|
|
228
|
+
row = {
|
|
229
|
+
"host": host.name,
|
|
230
|
+
"account": account.user,
|
|
231
|
+
"uid": account.uid,
|
|
232
|
+
"fingerprint": key.fingerprint,
|
|
233
|
+
"type": key.type,
|
|
234
|
+
"bits": key.bits,
|
|
235
|
+
"comment": key.comment,
|
|
236
|
+
"inAllow": (key.fingerprint in allowed) if push else None,
|
|
237
|
+
"reach": reach.level,
|
|
238
|
+
"reachVia": reach.via,
|
|
239
|
+
"certAuthority": key.cert_authority,
|
|
240
|
+
"options": key.options.public(),
|
|
241
|
+
"usage": status,
|
|
242
|
+
"lastUsed": _iso(login.last) if login else None,
|
|
243
|
+
"uses": login.count if login else 0,
|
|
244
|
+
"lastFrom": login.source if login else None,
|
|
245
|
+
}
|
|
246
|
+
rows.append(row)
|
|
247
|
+
return rows
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _line(account: Account, key: KeyRef, extra: str = "") -> str:
|
|
251
|
+
who = key.comment or "no comment"
|
|
252
|
+
tail = f" ({extra})" if extra else ""
|
|
253
|
+
return f"{account.user} · {key.type} {key.fingerprint} ({who}){tail}"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _on(value: str) -> bool:
|
|
257
|
+
"""sshd forwarding settings: anything but "no" leaves the capability available."""
|
|
258
|
+
return value.lower() not in {"no"}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def access_findings(host: HostObservation, push: list[KeyRef], policy: Policy) -> list[Finding]:
|
|
262
|
+
findings: list[Finding] = []
|
|
263
|
+
readable = [account for account in host.accounts if account.read_state == "ok" and account.keys]
|
|
264
|
+
|
|
265
|
+
# Logging in with this key leads to root without another secret.
|
|
266
|
+
to_root = []
|
|
267
|
+
unknown_reach = []
|
|
268
|
+
for account in readable:
|
|
269
|
+
if account.user == "root" or account.uid == 0:
|
|
270
|
+
continue
|
|
271
|
+
reach = reach_of(account)
|
|
272
|
+
if reach.level == "root-nopasswd":
|
|
273
|
+
to_root.extend(_line(account, key, reach.via) for key in account.keys)
|
|
274
|
+
elif reach.level == "unknown" and account.sudo_state == "unreadable":
|
|
275
|
+
unknown_reach.append(account.user)
|
|
276
|
+
if to_root:
|
|
277
|
+
findings.append(
|
|
278
|
+
Finding(
|
|
279
|
+
"key-to-root",
|
|
280
|
+
"warn",
|
|
281
|
+
"A key leads to root without another password" if len(to_root) == 1 else f"{len(to_root)} keys lead to root without another password",
|
|
282
|
+
"; ".join(to_root) + ". Treat these keys like root keys: restrict them with from= and review who holds them.",
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
elif unknown_reach:
|
|
286
|
+
findings.append(
|
|
287
|
+
Finding(
|
|
288
|
+
"key-to-root",
|
|
289
|
+
"warn",
|
|
290
|
+
"Sudo rights could not be read",
|
|
291
|
+
"Run as root or with passwordless sudo to see what these accounts can do: " + ", ".join(unknown_reach[:10]) + ".",
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
elif any(account.sudo_state != "unknown" or account.groups is not None for account in readable):
|
|
295
|
+
findings.append(Finding("key-to-root", "pass", "No key reaches root without a password", "No account with a key has passwordless root through sudo or a root-equivalent group."))
|
|
296
|
+
|
|
297
|
+
# Privileged keys that can be used from anywhere.
|
|
298
|
+
open_source = []
|
|
299
|
+
for account in readable:
|
|
300
|
+
reach = reach_of(account)
|
|
301
|
+
if not reach.privileged:
|
|
302
|
+
continue
|
|
303
|
+
for key in account.keys:
|
|
304
|
+
if not key.options.source:
|
|
305
|
+
open_source.append(_line(account, key, reach.via))
|
|
306
|
+
if open_source:
|
|
307
|
+
findings.append(
|
|
308
|
+
Finding(
|
|
309
|
+
"key-source",
|
|
310
|
+
"warn",
|
|
311
|
+
"A root-level key works from any address" if len(open_source) == 1 else f"{len(open_source)} root-level keys work from any address",
|
|
312
|
+
"; ".join(open_source) + ". Add from=\"<jump host or admin network>\" so a copied key is useless elsewhere.",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
elif any(reach_of(account).privileged for account in readable):
|
|
316
|
+
findings.append(Finding("key-source", "pass", "Root-level keys are limited by source address", "Every key that reaches root has a from= restriction."))
|
|
317
|
+
|
|
318
|
+
# A forced command does not stop forwarding.
|
|
319
|
+
tcp_on = _on(host.sshd.allow_tcp_forwarding)
|
|
320
|
+
agent_on = _on(host.sshd.allow_agent_forwarding)
|
|
321
|
+
loose_commands = []
|
|
322
|
+
for account in readable:
|
|
323
|
+
for key in account.keys:
|
|
324
|
+
options = key.options
|
|
325
|
+
if not options.forced_command:
|
|
326
|
+
continue
|
|
327
|
+
open_paths = []
|
|
328
|
+
if "port" in options.allows and tcp_on and not options.permit_open:
|
|
329
|
+
open_paths.append("port forwarding")
|
|
330
|
+
if "agent" in options.allows and agent_on:
|
|
331
|
+
open_paths.append("agent forwarding")
|
|
332
|
+
if open_paths:
|
|
333
|
+
loose_commands.append(_line(account, key, " and ".join(open_paths) + " still allowed"))
|
|
334
|
+
if loose_commands:
|
|
335
|
+
findings.append(
|
|
336
|
+
Finding(
|
|
337
|
+
"forced-command-forwarding",
|
|
338
|
+
"warn",
|
|
339
|
+
"A forced-command key can still forward" if len(loose_commands) == 1 else f"{len(loose_commands)} forced-command keys can still forward",
|
|
340
|
+
"; ".join(loose_commands) + ". command= limits the shell, not tunnels. Add restrict (or no-port-forwarding,no-agent-forwarding).",
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# environment= only matters when sshd honors it.
|
|
345
|
+
if host.sshd.permit_user_environment.lower() not in {"no", "unknown"}:
|
|
346
|
+
env_keys = [_line(account, key) for account in readable for key in account.keys if key.options.environment]
|
|
347
|
+
if env_keys:
|
|
348
|
+
findings.append(
|
|
349
|
+
Finding(
|
|
350
|
+
"key-environment",
|
|
351
|
+
"fail",
|
|
352
|
+
"A key sets environment variables sshd will honor",
|
|
353
|
+
"; ".join(env_keys) + f". PermitUserEnvironment is {host.sshd.permit_user_environment}, so a key can set LD_PRELOAD and similar.",
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Lines sshd already refuses are clutter that hides real changes.
|
|
358
|
+
now = host.now
|
|
359
|
+
expired = []
|
|
360
|
+
for account in readable:
|
|
361
|
+
for key in account.keys:
|
|
362
|
+
if key.options.expiry_unreadable:
|
|
363
|
+
expired.append(_line(account, key, "expiry-time is not a date sshd reads"))
|
|
364
|
+
elif key.options.expires is not None and now is not None and key.options.expires < now:
|
|
365
|
+
expired.append(_line(account, key, f"expired {_iso(key.options.expires)}"))
|
|
366
|
+
if expired:
|
|
367
|
+
findings.append(
|
|
368
|
+
Finding(
|
|
369
|
+
"expired-key",
|
|
370
|
+
"warn",
|
|
371
|
+
"A key line has expired" if len(expired) == 1 else f"{len(expired)} key lines have expired",
|
|
372
|
+
"; ".join(expired) + ". sshd refuses them; remove the lines.",
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Logging must be on for anyone to tell which key was used.
|
|
377
|
+
level = host.sshd.log_level.lower()
|
|
378
|
+
if level in _QUIET_LOG_LEVELS:
|
|
379
|
+
findings.append(
|
|
380
|
+
Finding(
|
|
381
|
+
"log-level",
|
|
382
|
+
"fail",
|
|
383
|
+
"sshd does not log logins",
|
|
384
|
+
f"LogLevel is {host.sshd.log_level}. Accepted logins and their key fingerprints are not recorded. Use INFO or VERBOSE.",
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
elif level not in {"unknown", ""}:
|
|
388
|
+
findings.append(Finding("log-level", "pass", "sshd logs which key was used", f"LogLevel is {host.sshd.log_level}."))
|
|
389
|
+
|
|
390
|
+
findings.extend(_usage_findings(host, readable, policy))
|
|
391
|
+
return findings
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _usage_findings(host: HostObservation, readable: list[Account], policy: Policy) -> list[Finding]:
|
|
395
|
+
if host.log_source == "unknown":
|
|
396
|
+
return []
|
|
397
|
+
if not logs_usable(host):
|
|
398
|
+
reasons = {
|
|
399
|
+
"unreadable": "The sshd log could not be read. Run as root, or with passwordless sudo, or in the adm or systemd-journal group.",
|
|
400
|
+
"none": "No sshd log was found in the journal (ssh.service or sshd.service) or in /var/log/auth.log or /var/log/secure.",
|
|
401
|
+
}
|
|
402
|
+
detail = reasons.get(host.log_source, f"The log source was {host.log_source}.")
|
|
403
|
+
if host.log_source == "none" and host.privilege == "none":
|
|
404
|
+
detail = reasons["unreadable"]
|
|
405
|
+
return [Finding("auth-log", "warn", "Key usage could not be checked", detail + " stale-key was skipped, not passed.")]
|
|
406
|
+
findings: list[Finding] = []
|
|
407
|
+
covered_days = int(((host.now or 0) - (host.log_since or host.now or 0)) / DAY)
|
|
408
|
+
source = "the systemd journal (sshd units only)" if host.log_source == "journal" else "syslog files, which any local user can write to"
|
|
409
|
+
findings.append(
|
|
410
|
+
Finding(
|
|
411
|
+
"auth-log",
|
|
412
|
+
"pass",
|
|
413
|
+
"sshd logins were read",
|
|
414
|
+
f"Read {covered_days} day(s) of logins from {source}.",
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
stale = []
|
|
418
|
+
unseen = []
|
|
419
|
+
for account in readable:
|
|
420
|
+
for key in account.keys:
|
|
421
|
+
status, login = usage_status(host, account.user, key, policy)
|
|
422
|
+
if status == "stale":
|
|
423
|
+
last = f"last used {_iso(login.last)}" if login and login.last else "no login in the logs"
|
|
424
|
+
stale.append(_line(account, key, last))
|
|
425
|
+
elif status == "unseen":
|
|
426
|
+
unseen.append(_line(account, key, f"no login in {covered_days} day(s) of logs"))
|
|
427
|
+
if stale:
|
|
428
|
+
findings.append(
|
|
429
|
+
Finding(
|
|
430
|
+
"stale-key",
|
|
431
|
+
"fail",
|
|
432
|
+
f"A key has not been used in {policy.stale_days} days" if len(stale) == 1 else f"{len(stale)} keys have not been used in {policy.stale_days} days",
|
|
433
|
+
"; ".join(stale) + ". Remove keys nobody uses; they are access nobody is watching.",
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
elif unseen:
|
|
437
|
+
findings.append(
|
|
438
|
+
Finding(
|
|
439
|
+
"stale-key",
|
|
440
|
+
"warn",
|
|
441
|
+
"Some keys were not used in the logs available",
|
|
442
|
+
"; ".join(unseen) + f". The logs cover less than {policy.stale_days} days, so these are not called stale yet.",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
elif readable:
|
|
446
|
+
findings.append(Finding("stale-key", "pass", f"Every key was used in the last {policy.stale_days} days", "Each authorized key has a login in the sshd log."))
|
|
447
|
+
return findings
|