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/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")
|