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/collect.py ADDED
@@ -0,0 +1,967 @@
1
+ """Read sshd and authorized_keys. Key text stays in this process only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import glob
7
+ import gzip
8
+ import os
9
+ import time
10
+ import re
11
+ import shutil
12
+ import socket
13
+ import subprocess
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ from keywarden.check import Account, HostObservation, SshdFacts, parse_sshd
18
+ from keywarden.parse import parse_public_keys
19
+
20
+ PROTOCOL_VERSION = 3
21
+ SUPPORTED_PROTOCOLS = {2, 3}
22
+
23
+ # Address and user that sshd -T -C uses to evaluate Match blocks for root.
24
+ # 192.0.2.1 is TEST-NET-1 and keywarden.invalid cannot resolve, so only
25
+ # "Match User root" style blocks apply, not address or host blocks.
26
+ ROOT_MATCH_SPEC = "user=root,host=keywarden.invalid,addr=192.0.2.1"
27
+ SSHD_CANDIDATES = ("sshd", "/usr/sbin/sshd", "/usr/local/sbin/sshd", "/sbin/sshd")
28
+ SSHD_CONFIG_GLOBS = ("/etc/ssh/sshd_config", "/etc/ssh/sshd_config.d/*.conf")
29
+ _MATCH_LINE = re.compile(r"^\s*Match\s", re.IGNORECASE | re.MULTILINE)
30
+ _ROOT_FIELDS = (
31
+ "permitrootlogin",
32
+ "passwordauthentication",
33
+ "kbdinteractiveauthentication",
34
+ "authenticationmethods",
35
+ "pubkeyauthentication",
36
+ )
37
+
38
+ # Fixed script. Usernames arrive as arguments, never interpolated.
39
+ # Every line read from an authorized_keys file is prefixed with "K " so a user
40
+ # who controls their own file cannot write protocol markers into the stream.
41
+ REMOTE_SCRIPT = r"""
42
+ set -u
43
+ echo KEYWARDEN 3
44
+ echo "HOST $(hostname 2>/dev/null || echo unknown)"
45
+ echo "NOW $(date +%s 2>/dev/null || echo unknown) $(date +%z 2>/dev/null || echo +0000)"
46
+ P=""
47
+ if [ "$(id -u 2>/dev/null)" = "0" ]; then
48
+ echo PRIV root
49
+ elif sudo -n true </dev/null >/dev/null 2>&1; then
50
+ P=sudo
51
+ echo PRIV sudo
52
+ else
53
+ echo PRIV none
54
+ fi
55
+ p() {
56
+ if [ -n "$P" ]; then
57
+ sudo -n "$@" </dev/null
58
+ else
59
+ "$@" </dev/null
60
+ fi
61
+ }
62
+ SSHD_BIN=""
63
+ for candidate in sshd /usr/sbin/sshd /usr/local/sbin/sshd /sbin/sshd; do
64
+ if command -v "$candidate" >/dev/null 2>&1; then
65
+ SSHD_BIN=$(command -v "$candidate")
66
+ break
67
+ fi
68
+ done
69
+ sshd_state=missing
70
+ sshd_out=""
71
+ if [ -n "$SSHD_BIN" ]; then
72
+ if sshd_out=$(p "$SSHD_BIN" -T 2>/dev/null); then
73
+ sshd_state=ok
74
+ elif sshd_out=$(sudo -n "$SSHD_BIN" -T </dev/null 2>/dev/null); then
75
+ sshd_state=ok
76
+ else
77
+ sshd_state=failed
78
+ sshd_out=""
79
+ fi
80
+ fi
81
+ echo "SSHDRUN $sshd_state"
82
+ echo SSHD
83
+ if [ -n "$sshd_out" ]; then
84
+ printf '%s\n' "$sshd_out"
85
+ fi
86
+ echo ENDSSHD
87
+ cfgs=/etc/ssh/sshd_config
88
+ for extra in /etc/ssh/sshd_config.d/*.conf; do
89
+ if [ -e "$extra" ]; then
90
+ cfgs="$cfgs $extra"
91
+ fi
92
+ done
93
+ match=unknown
94
+ if p grep -Eqs '^[[:space:]]*[Mm][Aa][Tt][Cc][Hh][[:space:]]' $cfgs; then
95
+ match=yes
96
+ else
97
+ status=$?
98
+ if [ "$status" -eq 1 ]; then
99
+ match=no
100
+ fi
101
+ fi
102
+ echo "MATCH $match"
103
+ echo SSHDROOT
104
+ if [ "$sshd_state" = "ok" ] && [ "$match" = "yes" ]; then
105
+ root_out=""
106
+ if root_out=$(p "$SSHD_BIN" -T -C user=root,host=keywarden.invalid,addr=192.0.2.1 2>/dev/null); then
107
+ :
108
+ else
109
+ root_out=$(sudo -n "$SSHD_BIN" -T -C user=root,host=keywarden.invalid,addr=192.0.2.1 </dev/null 2>/dev/null || true)
110
+ fi
111
+ if [ -n "$root_out" ]; then
112
+ printf '%s\n' "$root_out" | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|pubkeyauthentication) ' || true
113
+ fi
114
+ fi
115
+ echo ENDSSHDROOT
116
+ # Successful public-key logins: newest line and a count per account and key.
117
+ # The journal is read only for sshd's own units as root (_UID=0), which a local
118
+ # user cannot forge with logger. Syslog files can be forged; the report says which was used.
119
+ LOGAWK='
120
+ NR == 1 { print "AUTHFIRST " $1 " " $2 " " $3 }
121
+ /Accepted publickey for / {
122
+ user = ""
123
+ for (i = 1; i < NF; i++) if ($i == "publickey" && $(i + 1) == "for") { user = $(i + 2); break }
124
+ if (user == "" || !match($0, /SHA256:[A-Za-z0-9+\/]+/)) next
125
+ key = user " " substr($0, RSTART, RLENGTH)
126
+ count[key]++
127
+ last[key] = $0
128
+ }
129
+ END { for (key in count) print "L " count[key] " " last[key] }
130
+ '
131
+ log_source=none
132
+ log_out=""
133
+ if command -v journalctl >/dev/null 2>&1; then
134
+ log_out=$(p journalctl -q --no-pager -o short-unix _UID=0 _SYSTEMD_UNIT=ssh.service _SYSTEMD_UNIT=sshd.service 2>/dev/null | awk "$LOGAWK")
135
+ if [ -n "$log_out" ]; then
136
+ log_source=journal
137
+ fi
138
+ fi
139
+ if [ "$log_source" = "none" ]; then
140
+ log_files=""
141
+ for base in /var/log/auth.log /var/log/secure; do
142
+ rotated=$(ls -1 "$base".[0-9]* 2>/dev/null | sed 's/^\(.*\.\)\([0-9][0-9]*\)\(\.gz\)\{0,1\}$/\2 &/' | sort -k1,1nr | cut -d' ' -f2)
143
+ dated=$(ls -1 "$base"-[0-9]* 2>/dev/null | sort)
144
+ for candidate in $rotated $dated "$base"; do
145
+ if [ -e "$candidate" ]; then
146
+ log_files="$log_files $candidate"
147
+ fi
148
+ done
149
+ done
150
+ if [ -n "$log_files" ]; then
151
+ log_source=file
152
+ for candidate in $log_files; do
153
+ if ! p test -r "$candidate"; then
154
+ log_source=unreadable
155
+ fi
156
+ done
157
+ if [ "$log_source" = "file" ]; then
158
+ log_out=$(for candidate in $log_files; do p gzip -cdf "$candidate" 2>/dev/null; done | awk "$LOGAWK")
159
+ else
160
+ log_out=""
161
+ fi
162
+ fi
163
+ fi
164
+ echo "AUTHLOG $log_source"
165
+ echo LOGINS
166
+ if [ -n "$log_out" ]; then
167
+ printf '%s\n' "$log_out"
168
+ fi
169
+ echo ENDLOGINS
170
+ emit_sudo() {
171
+ echo "GROUPS $(id -nG "$1" 2>/dev/null)"
172
+ if ! command -v sudo >/dev/null 2>&1; then
173
+ echo "SUDOSTATE absent"
174
+ return
175
+ fi
176
+ sudo_out=$(p sudo -n -l -U "$1" 2>&1)
177
+ case $sudo_out in
178
+ *"may run the following"*|*"not allowed to run sudo"*)
179
+ echo "SUDOSTATE ok"
180
+ echo SUDO
181
+ printf '%s\n' "$sudo_out" | awk '{ print "S " $0 }'
182
+ echo ENDSUDO
183
+ ;;
184
+ *) echo "SUDOSTATE unreadable" ;;
185
+ esac
186
+ }
187
+ mode_of() {
188
+ target=$1
189
+ if [ "$(access_of "$target")" = "absent" ]; then
190
+ echo absent
191
+ return
192
+ fi
193
+ if p stat -c %a "$target" >/dev/null 2>&1; then
194
+ p stat -c %a "$target" 2>/dev/null || echo unknown
195
+ return
196
+ fi
197
+ p stat -f %OLp "$target" 2>/dev/null || echo unknown
198
+ }
199
+ access_of() {
200
+ target=$1
201
+ if p test -e "$target" || p test -L "$target"; then
202
+ echo present
203
+ return
204
+ fi
205
+ err=$(p ls -ld "$target" 2>&1 >/dev/null)
206
+ case $err in
207
+ *[Pp]ermission*denied*|*[Nn]ot\ permitted*) echo unreadable ;;
208
+ *) echo absent ;;
209
+ esac
210
+ }
211
+ emit_keys() {
212
+ keyfile=$1
213
+ if [ "$(access_of "$keyfile")" = "absent" ]; then
214
+ echo STATE absent
215
+ echo KEYS
216
+ echo ENDKEYS
217
+ return
218
+ fi
219
+ if p test -r "$keyfile" && p cat "$keyfile" >/dev/null 2>&1; then
220
+ echo STATE ok
221
+ echo KEYS
222
+ p cat "$keyfile" 2>/dev/null | awk '{ print "K " $0 }'
223
+ echo ENDKEYS
224
+ return
225
+ fi
226
+ if [ -z "$P" ] && sudo -n cat "$keyfile" </dev/null >/dev/null 2>&1; then
227
+ echo STATE ok
228
+ echo KEYS
229
+ sudo -n cat "$keyfile" </dev/null 2>/dev/null | awk '{ print "K " $0 }'
230
+ echo ENDKEYS
231
+ return
232
+ fi
233
+ echo STATE unreadable
234
+ echo KEYS
235
+ echo ENDKEYS
236
+ }
237
+ emit_account() {
238
+ user=$1
239
+ home=$2
240
+ uid=$3
241
+ locked=$4
242
+ echo "ACCOUNT $user"
243
+ if [ -n "$uid" ]; then
244
+ echo "UID $uid"
245
+ fi
246
+ if [ -n "$locked" ]; then
247
+ echo "LOCKED $locked"
248
+ fi
249
+ dir_access=$(access_of "$home/.ssh")
250
+ if [ "$dir_access" = "absent" ]; then
251
+ echo DIR absent
252
+ echo FILE absent
253
+ echo STATE absent
254
+ echo KEYS
255
+ echo ENDKEYS
256
+ return
257
+ fi
258
+ emit_sudo "$user"
259
+ if [ "$dir_access" = "present" ]; then
260
+ echo "DIR $(mode_of "$home/.ssh")"
261
+ echo "FILE $(mode_of "$home/.ssh/authorized_keys")"
262
+ else
263
+ echo DIR unknown
264
+ echo FILE unknown
265
+ fi
266
+ emit_keys "$home/.ssh/authorized_keys"
267
+ }
268
+ seen_users=" "
269
+ mark() { seen_users="$seen_users$1 "; }
270
+ already() {
271
+ case $seen_users in
272
+ *" $1 "*) return 0 ;;
273
+ *) return 1 ;;
274
+ esac
275
+ }
276
+ locked_of() {
277
+ case $1 in
278
+ "") echo no ;;
279
+ x) echo unknown ;;
280
+ *) echo yes ;;
281
+ esac
282
+ }
283
+ for user in "$@"; do
284
+ [ -n "$user" ] || continue
285
+ already "$user" && continue
286
+ entry=$(getent passwd "$user" 2>/dev/null | head -n 1)
287
+ home=$(printf '%s' "$entry" | awk -F: 'NR==1 { print $6 }')
288
+ uid=$(printf '%s' "$entry" | awk -F: 'NR==1 { print $3 }')
289
+ pass=$(printf '%s' "$entry" | awk -F: 'NR==1 { print $2 }')
290
+ if [ -z "${home:-}" ]; then
291
+ echo "ACCOUNT $user"
292
+ echo DIR unknown
293
+ echo FILE unknown
294
+ echo STATE unreadable
295
+ echo KEYS
296
+ echo ENDKEYS
297
+ mark "$user"
298
+ continue
299
+ fi
300
+ emit_account "$user" "$home" "$uid" "$(locked_of "$pass")"
301
+ mark "$user"
302
+ done
303
+ if [ -r /etc/passwd ]; then
304
+ echo "PASSWD ok"
305
+ while IFS=: read -r user pass uid _gid _gecos home _shell; do
306
+ [ -n "${user:-}" ] || continue
307
+ already "$user" && continue
308
+ if [ -z "${home:-}" ]; then
309
+ if [ "${uid:-}" = "0" ]; then
310
+ echo "ACCOUNT $user"
311
+ echo "UID $uid"
312
+ echo "LOCKED $(locked_of "$pass")"
313
+ echo DIR absent
314
+ echo FILE absent
315
+ echo STATE absent
316
+ echo KEYS
317
+ echo ENDKEYS
318
+ mark "$user"
319
+ fi
320
+ continue
321
+ fi
322
+ want=0
323
+ if [ "${uid:-}" = "0" ]; then
324
+ want=1
325
+ elif [ "$(access_of "$home/.ssh/authorized_keys")" != "absent" ]; then
326
+ want=1
327
+ fi
328
+ [ "$want" -eq 1 ] || continue
329
+ emit_account "$user" "$home" "$uid" "$(locked_of "$pass")"
330
+ mark "$user"
331
+ done < /etc/passwd
332
+ else
333
+ echo "PASSWD unreadable"
334
+ fi
335
+ echo END
336
+ """
337
+
338
+ FINGERPRINT_TEXT = re.compile(r"SHA256:[A-Za-z0-9+/]+")
339
+ _DESTINATION = re.compile(r"^[A-Za-z0-9._:-]+@[A-Za-z0-9._:-]+$|^[A-Za-z0-9._:-]+$")
340
+ _USERNAME = re.compile(r"^[a-z_][-a-z0-9_.]*\$?$", re.IGNORECASE)
341
+
342
+
343
+ def now_iso() -> str:
344
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
345
+
346
+
347
+ def file_mode(path: Path) -> str:
348
+ try:
349
+ mode = path.lstat().st_mode & 0o7777
350
+ except FileNotFoundError:
351
+ return "absent"
352
+ except OSError:
353
+ return "unknown"
354
+ return format(mode, "04o") if mode > 0o777 else format(mode, "03o")
355
+
356
+
357
+ def _access(path: Path) -> str:
358
+ """present, absent, or unreadable. Permission errors are not treated as missing."""
359
+ try:
360
+ path.lstat()
361
+ except FileNotFoundError:
362
+ return "absent"
363
+ except NotADirectoryError:
364
+ return "absent"
365
+ except OSError:
366
+ return "unreadable"
367
+ return "present"
368
+
369
+
370
+ def inspect_account(user: str, home: Path) -> Account:
371
+ home_state = _access(home)
372
+ if home_state == "absent":
373
+ return Account(user, "absent", "absent", [], "absent")
374
+ if home_state == "unreadable":
375
+ return Account(user, "unknown", "unknown", [], "unreadable")
376
+ ssh_dir = home / ".ssh"
377
+ dir_state = _access(ssh_dir)
378
+ if dir_state == "absent":
379
+ return Account(user, "absent", "absent", [], "absent")
380
+ if dir_state == "unreadable":
381
+ return Account(user, "unknown", "unknown", [], "unreadable")
382
+ key_file = ssh_dir / "authorized_keys"
383
+ file_state = _access(key_file)
384
+ if file_state == "absent":
385
+ return Account(user, file_mode(ssh_dir), "absent", [], "absent")
386
+ if file_state == "unreadable":
387
+ return Account(user, file_mode(ssh_dir), "unknown", [], "unreadable")
388
+ try:
389
+ text = key_file.read_text(encoding="utf-8", errors="replace")
390
+ except OSError:
391
+ return Account(user, file_mode(ssh_dir), file_mode(key_file), [], "unreadable")
392
+ try:
393
+ keys, _issues = parse_public_keys(text, user)
394
+ finally:
395
+ del text
396
+ return Account(user, file_mode(ssh_dir), file_mode(key_file), keys, "ok")
397
+
398
+
399
+ def _password_locked(field: str) -> bool | None:
400
+ if field == "":
401
+ return False
402
+ if field == "x":
403
+ return None
404
+ return True
405
+
406
+
407
+ def _passwd_records() -> tuple[dict[str, tuple[Path | None, int | None, bool | None]], str]:
408
+ records: dict[str, tuple[Path | None, int | None, bool | None]] = {}
409
+ try:
410
+ text = Path("/etc/passwd").read_text(encoding="utf-8", errors="replace")
411
+ except OSError:
412
+ return records, "unreadable"
413
+ for line in text.splitlines():
414
+ parts = line.split(":")
415
+ if len(parts) < 6 or not parts[0]:
416
+ continue
417
+ uid = int(parts[2]) if parts[2].isdigit() else None
418
+ home = Path(parts[5]) if parts[5] else None
419
+ if home is None and uid != 0:
420
+ continue
421
+ records[parts[0]] = (home, uid, _password_locked(parts[1]))
422
+ return records, "ok"
423
+
424
+
425
+ def _sshd_binary() -> str | None:
426
+ for candidate in SSHD_CANDIDATES:
427
+ found = shutil.which(candidate)
428
+ if found:
429
+ return found
430
+ return None
431
+
432
+
433
+ def _run(command: list[str]) -> subprocess.CompletedProcess | None:
434
+ try:
435
+ return subprocess.run(
436
+ command,
437
+ capture_output=True,
438
+ text=True,
439
+ timeout=10,
440
+ check=False,
441
+ stdin=subprocess.DEVNULL,
442
+ )
443
+ except (OSError, subprocess.TimeoutExpired):
444
+ return None
445
+
446
+
447
+ def _sshd_output(binary: str, extra: list[str]) -> str | None:
448
+ attempts = [[binary, "-T", *extra]]
449
+ if os.geteuid() != 0 and shutil.which("sudo"):
450
+ attempts.append(["sudo", "-n", binary, "-T", *extra])
451
+ for command in attempts:
452
+ completed = _run(command)
453
+ if completed is not None and completed.returncode == 0 and completed.stdout:
454
+ return completed.stdout
455
+ return None
456
+
457
+
458
+ def _match_state() -> str:
459
+ paths: list[str] = []
460
+ for pattern in SSHD_CONFIG_GLOBS:
461
+ paths.extend(sorted(glob.glob(pattern)))
462
+ if not paths:
463
+ return "unknown"
464
+ unreadable = False
465
+ for path in paths:
466
+ try:
467
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
468
+ except OSError:
469
+ unreadable = True
470
+ continue
471
+ if _MATCH_LINE.search(text):
472
+ return "yes"
473
+ return "unknown" if unreadable else "no"
474
+
475
+
476
+ def _root_lines(text: str) -> str:
477
+ return "\n".join(line for line in text.splitlines() if line.split(" ", 1)[0].lower() in _ROOT_FIELDS)
478
+
479
+
480
+ def _local_sshd() -> tuple[SshdFacts, str, str, SshdFacts | None]:
481
+ binary = _sshd_binary()
482
+ match = _match_state()
483
+ if binary is None:
484
+ return parse_sshd(""), "missing", match, None
485
+ text = _sshd_output(binary, [])
486
+ if text is None:
487
+ return parse_sshd(""), "failed", match, None
488
+ root = None
489
+ if match == "yes":
490
+ root_text = _sshd_output(binary, ["-C", ROOT_MATCH_SPEC])
491
+ if root_text:
492
+ root = parse_sshd(_root_lines(root_text))
493
+ return parse_sshd(text), "ok", match, root
494
+
495
+
496
+ def _ordered_users(extra: list[str], records: dict[str, tuple[Path | None, int | None, bool | None]]) -> list[str]:
497
+ wanted: list[str] = []
498
+
499
+ def add(user: str) -> None:
500
+ if user and user not in wanted:
501
+ wanted.append(user)
502
+
503
+ add("root")
504
+ add(getpass.getuser())
505
+ for user in extra:
506
+ if not _USERNAME.fullmatch(user):
507
+ raise ValueError(f"not a username: {user}")
508
+ add(user)
509
+ for user, (home, uid, _locked) in records.items():
510
+ if uid == 0:
511
+ add(user)
512
+ elif home is not None and _access(home / ".ssh" / "authorized_keys") != "absent":
513
+ # Present, or hidden behind a permission error. A hidden file is
514
+ # reported as unreadable, never skipped.
515
+ add(user)
516
+ return wanted
517
+
518
+
519
+ _LOGIN_UNITS = ["_UID=0", "_SYSTEMD_UNIT=ssh.service", "_SYSTEMD_UNIT=sshd.service"]
520
+ _ACCEPTED_MARK = "Accepted publickey for "
521
+
522
+
523
+ def _group_names(user: str) -> list[str] | None:
524
+ completed = _run(["id", "-nG", user])
525
+ if completed is None or completed.returncode != 0:
526
+ return None
527
+ return completed.stdout.split()
528
+
529
+
530
+ def _sudo_rules(user: str) -> tuple[str, list[str]]:
531
+ if not shutil.which("sudo"):
532
+ return "absent", []
533
+ attempts = [["sudo", "-n", "-l", "-U", user]]
534
+ for command in attempts:
535
+ completed = _run(command)
536
+ if completed is None:
537
+ continue
538
+ text = (completed.stdout or "") + (completed.stderr or "")
539
+ if "may run the following" in text or "not allowed to run sudo" in text:
540
+ return "ok", text.splitlines()
541
+ return "unreadable", []
542
+
543
+
544
+ def _aggregate_logins(lines) -> tuple[str | None, list[str]]:
545
+ """Same reduction the remote awk does: newest line and a count per account and key."""
546
+ first: str | None = None
547
+ newest: dict[tuple[str, str], str] = {}
548
+ counts: dict[tuple[str, str], int] = {}
549
+ for line in lines:
550
+ line = line.rstrip("\n")
551
+ if first is None:
552
+ first = " ".join(line.split()[:3])
553
+ position = line.find(_ACCEPTED_MARK)
554
+ if position == -1:
555
+ continue
556
+ rest = line[position + len(_ACCEPTED_MARK):].split(" ", 1)
557
+ found = FINGERPRINT_TEXT.search(line)
558
+ if not rest or not found:
559
+ continue
560
+ key = (rest[0], found.group(0))
561
+ counts[key] = counts.get(key, 0) + 1
562
+ newest[key] = line
563
+ return first, [f"{counts[key]} {newest[key]}" for key in counts]
564
+
565
+
566
+ def _log_files() -> list[str]:
567
+ ordered: list[str] = []
568
+ for base in ("/var/log/auth.log", "/var/log/secure"):
569
+ rotated = []
570
+ for path in glob.glob(base + ".[0-9]*"):
571
+ suffix = path[len(base) + 1 :].split(".", 1)[0]
572
+ if suffix.isdigit():
573
+ rotated.append((int(suffix), path))
574
+ ordered.extend(path for _number, path in sorted(rotated, reverse=True))
575
+ ordered.extend(sorted(glob.glob(base + "-[0-9]*")))
576
+ if os.path.exists(base):
577
+ ordered.append(base)
578
+ return ordered
579
+
580
+
581
+ def _read_log_file(path: str):
582
+ opener = gzip.open if path.endswith(".gz") else open
583
+ with opener(path, "rt", encoding="utf-8", errors="replace") as handle:
584
+ yield from handle
585
+
586
+
587
+ def _local_logins(now: int, offset: str):
588
+ from keywarden.access import parse_log_time, parse_login_lines
589
+
590
+ if shutil.which("journalctl"):
591
+ completed = _run_long(["journalctl", "-q", "--no-pager", "-o", "short-unix", *_LOGIN_UNITS])
592
+ if completed is not None and completed.returncode == 0 and completed.stdout.strip():
593
+ first, rows = _aggregate_logins(completed.stdout.splitlines())
594
+ since = parse_log_time(first, now, offset) if first else None
595
+ return "journal", since, parse_login_lines(rows, now, offset)
596
+ files = _log_files()
597
+ if not files:
598
+ return "none", None, {}
599
+ if any(not os.access(path, os.R_OK) for path in files):
600
+ return "unreadable", None, {}
601
+
602
+ def stream():
603
+ for path in files:
604
+ try:
605
+ yield from _read_log_file(path)
606
+ except OSError:
607
+ continue
608
+
609
+ first, rows = _aggregate_logins(stream())
610
+ since = parse_log_time(first, now, offset) if first else None
611
+ return "file", since, parse_login_lines(rows, now, offset)
612
+
613
+
614
+ def _run_long(command: list[str]) -> subprocess.CompletedProcess | None:
615
+ try:
616
+ return subprocess.run(command, capture_output=True, text=True, timeout=60, check=False, stdin=subprocess.DEVNULL)
617
+ except (OSError, subprocess.TimeoutExpired):
618
+ return None
619
+
620
+
621
+ def collect_local(extra_users: list[str] | None = None) -> HostObservation:
622
+ records, passwd_state = _passwd_records()
623
+ accounts = []
624
+ for user in _ordered_users(extra_users or [], records):
625
+ record = records.get(user)
626
+ if record is None:
627
+ accounts.append(Account(user, "unknown", "unknown", [], "unreadable"))
628
+ continue
629
+ home, uid, locked = record
630
+ if home is None:
631
+ account = Account(user, "absent", "absent", [], "absent")
632
+ else:
633
+ account = inspect_account(user, home)
634
+ account.uid = uid
635
+ account.password_locked = locked
636
+ if account.read_state != "absent":
637
+ account.groups = _group_names(user)
638
+ account.sudo_state, account.sudo_lines = _sudo_rules(user)
639
+ accounts.append(account)
640
+ sshd, sshd_state, match, root = _local_sshd()
641
+ now = int(time.time())
642
+ offset = time.strftime("%z")
643
+ log_source, log_since, logins = _local_logins(now, offset)
644
+ return HostObservation(
645
+ name=socket.gethostname(),
646
+ address="local",
647
+ role="local",
648
+ observed_at=now_iso(),
649
+ sshd=sshd,
650
+ accounts=accounts,
651
+ passwd_state=passwd_state,
652
+ sshd_state=sshd_state,
653
+ match_blocks=match,
654
+ sshd_root=root,
655
+ privilege="root" if os.geteuid() == 0 else "none",
656
+ now=now,
657
+ log_source=log_source,
658
+ log_since=log_since,
659
+ logins=logins,
660
+ )
661
+
662
+
663
+ class ProtocolError(ValueError):
664
+ """Collector output was not well formed. Nothing from it is trusted."""
665
+
666
+
667
+ def _malformed(index: int, why: str) -> ProtocolError:
668
+ return ProtocolError(f"observation is malformed at line {index + 1}: {why}")
669
+
670
+
671
+ _ACCOUNT_FIELDS = ("DIR ", "FILE ", "STATE ", "UID ", "LOCKED ", "GROUPS ", "SUDOSTATE ")
672
+
673
+
674
+ def parse_protocol(text: str, address: str = "observation") -> HostObservation:
675
+ """Parse collector output strictly.
676
+
677
+ Each section may appear once, key lines must carry the "K " prefix, and the
678
+ stream must end with END. Anything else is refused rather than guessed at,
679
+ so text inside an authorized_keys file cannot rewrite the observation.
680
+ """
681
+ lines = text.splitlines()
682
+ if not lines:
683
+ raise ProtocolError("observation is empty")
684
+ header = lines[0].strip()
685
+ if header == "KEYWARDEN 1":
686
+ raise ProtocolError("observation is from an older collector (protocol 1); run the current collector again")
687
+ version_text = header[len("KEYWARDEN "):] if header.startswith("KEYWARDEN ") else ""
688
+ if not version_text.isdigit() or int(version_text) not in SUPPORTED_PROTOCOLS:
689
+ raise ProtocolError("observation is not keywarden collector output this version can read")
690
+
691
+ name = "unknown"
692
+ privilege = "unknown"
693
+ sshd_state = "unknown"
694
+ match = "unknown"
695
+ sshd_lines: list[str] | None = None
696
+ root_lines: list[str] | None = None
697
+ passwd_state = "unknown"
698
+ now: int | None = None
699
+ offset: str | None = None
700
+ log_source = "unknown"
701
+ login_lines: list[str] | None = None
702
+ accounts: list[Account] = []
703
+ seen_users: set[str] = set()
704
+ seen: set[str] = set()
705
+ ended = False
706
+
707
+ def once(tag: str, index: int) -> None:
708
+ if tag in seen:
709
+ raise _malformed(index, f"{tag} appears twice")
710
+ seen.add(tag)
711
+
712
+ def block(index: int, end: str) -> tuple[list[str], int]:
713
+ body: list[str] = []
714
+ index += 1
715
+ while index < len(lines) and lines[index] != end:
716
+ body.append(lines[index])
717
+ index += 1
718
+ if index >= len(lines):
719
+ raise _malformed(index - 1, f"missing {end}")
720
+ return body, index + 1
721
+
722
+ index = 1
723
+ while index < len(lines):
724
+ line = lines[index]
725
+ if ended:
726
+ if line.strip():
727
+ raise _malformed(index, "text after END")
728
+ index += 1
729
+ continue
730
+ if not line.strip():
731
+ index += 1
732
+ continue
733
+ if line.startswith("HOST "):
734
+ once("HOST", index)
735
+ name = line[5:].strip() or "unknown"
736
+ index += 1
737
+ elif line.startswith("PRIV "):
738
+ once("PRIV", index)
739
+ privilege = line[5:].strip() or "unknown"
740
+ index += 1
741
+ elif line.startswith("SSHDRUN "):
742
+ once("SSHDRUN", index)
743
+ sshd_state = line[8:].strip() or "unknown"
744
+ index += 1
745
+ elif line.startswith("MATCH "):
746
+ once("MATCH", index)
747
+ match = line[6:].strip() or "unknown"
748
+ index += 1
749
+ elif line.startswith("NOW "):
750
+ once("NOW", index)
751
+ parts = line[4:].split()
752
+ now = int(parts[0]) if parts and parts[0].isdigit() else None
753
+ offset = parts[1] if len(parts) > 1 else None
754
+ index += 1
755
+ elif line.startswith("AUTHLOG "):
756
+ once("AUTHLOG", index)
757
+ log_source = line[8:].strip() or "unknown"
758
+ index += 1
759
+ elif line == "LOGINS":
760
+ once("LOGINS", index)
761
+ login_lines, index = block(index, "ENDLOGINS")
762
+ elif line.startswith("PASSWD "):
763
+ once("PASSWD", index)
764
+ passwd_state = line[7:].strip() or "unknown"
765
+ index += 1
766
+ elif line == "SSHD":
767
+ once("SSHD", index)
768
+ sshd_lines, index = block(index, "ENDSSHD")
769
+ elif line == "SSHDROOT":
770
+ once("SSHDROOT", index)
771
+ root_lines, index = block(index, "ENDSSHDROOT")
772
+ elif line.startswith("ACCOUNT "):
773
+ account, index = _parse_account(lines, index)
774
+ if account.user in seen_users:
775
+ raise _malformed(index - 1, f"account {account.user} appears twice")
776
+ seen_users.add(account.user)
777
+ accounts.append(account)
778
+ elif line == "END":
779
+ ended = True
780
+ index += 1
781
+ else:
782
+ raise _malformed(index, "unexpected line")
783
+ if not ended:
784
+ raise ProtocolError("observation is incomplete: END is missing")
785
+ root = parse_sshd(_root_lines("\n".join(root_lines))) if root_lines else None
786
+ log_since, logins = _read_logins(login_lines or [], now, offset, start=0)
787
+ return HostObservation(
788
+ name=name,
789
+ address=address,
790
+ role="observed",
791
+ observed_at=now_iso(),
792
+ sshd=parse_sshd("\n".join(sshd_lines or [])),
793
+ accounts=accounts,
794
+ passwd_state=passwd_state,
795
+ sshd_state=sshd_state,
796
+ match_blocks=match,
797
+ sshd_root=root,
798
+ privilege=privilege,
799
+ now=now,
800
+ log_source=log_source,
801
+ log_since=log_since,
802
+ logins=logins,
803
+ )
804
+
805
+
806
+ def _read_logins(lines: list[str], now: int | None, offset: str | None, start: int):
807
+ """LOGINS holds one AUTHFIRST line and "L <count> <log line>" rows. Nothing else."""
808
+ from keywarden.access import parse_log_time, parse_login_lines
809
+
810
+ since: int | None = None
811
+ rows: list[str] = []
812
+ for position, line in enumerate(lines):
813
+ if line.startswith("AUTHFIRST "):
814
+ since = parse_log_time(line[10:], now, offset)
815
+ elif line.startswith("L "):
816
+ rows.append(line[2:])
817
+ elif line.strip():
818
+ raise ProtocolError(f"observation is malformed in LOGINS (row {position + 1}): unexpected line")
819
+ return since, parse_login_lines(rows, now, offset)
820
+
821
+
822
+ def _parse_account(lines: list[str], index: int) -> tuple[Account, int]:
823
+ user = lines[index][8:].strip()
824
+ if not _USERNAME.fullmatch(user):
825
+ raise _malformed(index, "account name is not a username")
826
+ index += 1
827
+ fields: dict[str, str] = {}
828
+ key_lines: list[str] | None = None
829
+ sudo_lines: list[str] | None = None
830
+ while index < len(lines):
831
+ current = lines[index]
832
+ if current == "SUDO":
833
+ if sudo_lines is not None:
834
+ raise _malformed(index, "SUDO appears twice for one account")
835
+ sudo_lines = []
836
+ index += 1
837
+ while index < len(lines) and lines[index] != "ENDSUDO":
838
+ entry = lines[index]
839
+ if entry == "S":
840
+ sudo_lines.append("")
841
+ elif entry.startswith("S "):
842
+ sudo_lines.append(entry[2:])
843
+ else:
844
+ raise _malformed(index, "sudo line without the S prefix")
845
+ index += 1
846
+ if index >= len(lines):
847
+ raise _malformed(index - 1, "missing ENDSUDO")
848
+ index += 1
849
+ continue
850
+ if current == "KEYS":
851
+ if key_lines is not None:
852
+ raise _malformed(index, "KEYS appears twice for one account")
853
+ key_lines = []
854
+ index += 1
855
+ while index < len(lines) and lines[index] != "ENDKEYS":
856
+ entry = lines[index]
857
+ if entry == "K":
858
+ key_lines.append("")
859
+ elif entry.startswith("K "):
860
+ key_lines.append(entry[2:])
861
+ else:
862
+ raise _malformed(index, "key line without the K prefix")
863
+ index += 1
864
+ if index >= len(lines):
865
+ raise _malformed(index - 1, "missing ENDKEYS")
866
+ index += 1
867
+ break
868
+ tag = next((prefix for prefix in _ACCOUNT_FIELDS if current.startswith(prefix)), None)
869
+ if tag is None:
870
+ break
871
+ if tag in fields:
872
+ raise _malformed(index, f"{tag.strip()} appears twice for one account")
873
+ fields[tag] = current[len(tag) :].strip()
874
+ index += 1
875
+ state = fields.get("STATE ", "unreadable")
876
+ uid_text = fields.get("UID ")
877
+ uid = int(uid_text) if uid_text and uid_text.isdigit() else None
878
+ locked = {"yes": True, "no": False}.get(fields.get("LOCKED ", "").lower())
879
+ if state == "ok":
880
+ joined = "\n".join(key_lines or [])
881
+ try:
882
+ keys, _issues = parse_public_keys(joined, user)
883
+ finally:
884
+ del joined
885
+ else:
886
+ keys = []
887
+ if key_lines is not None:
888
+ key_lines.clear()
889
+ account = Account(user, fields.get("DIR ", "unknown"), fields.get("FILE ", "unknown"), keys, state, uid, locked)
890
+ if "GROUPS " in fields:
891
+ account.groups = fields["GROUPS "].split()
892
+ account.sudo_state = fields.get("SUDOSTATE ", "unknown")
893
+ account.sudo_lines = sudo_lines or []
894
+ return account, index
895
+
896
+
897
+ def collect_remote(
898
+ destination: str,
899
+ extra_users: list[str] | None = None,
900
+ port: int | None = None,
901
+ *,
902
+ accept_new: bool = False,
903
+ identity_file: str | None = None,
904
+ ) -> HostObservation:
905
+ if not _DESTINATION.fullmatch(destination) or destination.startswith("-"):
906
+ raise ValueError("host must look like user@hostname")
907
+ users = ["root"]
908
+ if "@" in destination:
909
+ login = destination.split("@", 1)[0]
910
+ if login not in users:
911
+ users.append(login)
912
+ for user in extra_users or []:
913
+ if not _USERNAME.fullmatch(user):
914
+ raise ValueError(f"not a username: {user}")
915
+ if user not in users:
916
+ users.append(user)
917
+ options = [
918
+ "-o",
919
+ "BatchMode=yes",
920
+ "-o",
921
+ "ConnectTimeout=8",
922
+ "-o",
923
+ "StrictHostKeyChecking=accept-new" if accept_new else "StrictHostKeyChecking=yes",
924
+ ]
925
+ if port is not None:
926
+ if not isinstance(port, int) or not 1 <= port <= 65535:
927
+ raise ValueError("port is out of range")
928
+ options[0:0] = ["-p", str(port)]
929
+ if identity_file is not None:
930
+ if not identity_file or identity_file.startswith("-") or "\n" in identity_file:
931
+ raise ValueError("identity file path is not usable")
932
+ # The path is handed to ssh. Keywarden never opens it.
933
+ options.extend(["-i", os.path.expanduser(identity_file), "-o", "IdentitiesOnly=yes"])
934
+ command = ["ssh", *options, "--", destination, "sh", "-s", "--", *users]
935
+ try:
936
+ completed = subprocess.run(
937
+ command,
938
+ input=REMOTE_SCRIPT,
939
+ capture_output=True,
940
+ text=True,
941
+ timeout=60,
942
+ check=False,
943
+ )
944
+ except subprocess.TimeoutExpired as exc:
945
+ raise RuntimeError("ssh timed out before a check could finish") from exc
946
+ except OSError as exc:
947
+ raise RuntimeError("ssh is not available on this machine") from exc
948
+ if completed.returncode != 0:
949
+ tail = ""
950
+ for line in reversed((completed.stderr or "").splitlines()):
951
+ if line.strip() and "PRIVATE KEY" not in line and "ssh-" not in line:
952
+ tail = line.strip()[:180]
953
+ break
954
+ raise RuntimeError(tail or f"ssh exited {completed.returncode}")
955
+ output = completed.stdout
956
+ try:
957
+ return parse_protocol(output, address=destination)
958
+ finally:
959
+ del output
960
+
961
+
962
+ def read_observation(path: str) -> str:
963
+ if path == "-":
964
+ import sys
965
+
966
+ return sys.stdin.read()
967
+ return Path(path).read_text(encoding="utf-8", errors="replace")