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/parse.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Turn public-key text into fingerprints. The key blob is not kept."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
KEY_TYPES = (
|
|
12
|
+
"sk-ssh-ed25519-cert-v01@openssh.com",
|
|
13
|
+
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
14
|
+
"sk-ssh-ed25519@openssh.com",
|
|
15
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
16
|
+
"ssh-ed25519-cert-v01@openssh.com",
|
|
17
|
+
"ssh-rsa-cert-v01@openssh.com",
|
|
18
|
+
"ssh-dss-cert-v01@openssh.com",
|
|
19
|
+
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
20
|
+
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
|
21
|
+
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
|
|
22
|
+
"ecdsa-sha2-nistp256",
|
|
23
|
+
"ecdsa-sha2-nistp384",
|
|
24
|
+
"ecdsa-sha2-nistp521",
|
|
25
|
+
"ssh-ed25519",
|
|
26
|
+
"ssh-rsa",
|
|
27
|
+
"ssh-dss",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PrivateKeyError(Exception):
|
|
32
|
+
"""The input contained a private key. Nothing from it was retained."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def contains_private_key(text: str) -> bool:
|
|
36
|
+
return "PRIVATE KEY" in text or "-----BEGIN" in text or "PuTTY-User-Key-File" in text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CertInfo:
|
|
41
|
+
cert_type: str
|
|
42
|
+
valid_after: int
|
|
43
|
+
valid_before: int | None
|
|
44
|
+
key_id: str
|
|
45
|
+
ca_fingerprint: str
|
|
46
|
+
unreadable: bool = False
|
|
47
|
+
|
|
48
|
+
def public(self) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"certType": self.cert_type,
|
|
51
|
+
"validAfter": self.valid_after,
|
|
52
|
+
"validBefore": self.valid_before,
|
|
53
|
+
"keyId": self.key_id,
|
|
54
|
+
"caFingerprint": self.ca_fingerprint,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Options that turn a capability back on after "restrict".
|
|
59
|
+
_REENABLE = {
|
|
60
|
+
"port-forwarding": "port",
|
|
61
|
+
"agent-forwarding": "agent",
|
|
62
|
+
"x11-forwarding": "x11",
|
|
63
|
+
"pty": "pty",
|
|
64
|
+
"user-rc": "user-rc",
|
|
65
|
+
}
|
|
66
|
+
_DISABLE = {
|
|
67
|
+
"no-port-forwarding": "port",
|
|
68
|
+
"no-agent-forwarding": "agent",
|
|
69
|
+
"no-x11-forwarding": "x11",
|
|
70
|
+
"no-pty": "pty",
|
|
71
|
+
"no-user-rc": "user-rc",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_options(text: str) -> tuple[tuple[str, str | None], ...]:
|
|
76
|
+
"""Split an authorized_keys options field. Values may be quoted and contain commas."""
|
|
77
|
+
options: list[tuple[str, str | None]] = []
|
|
78
|
+
index = 0
|
|
79
|
+
length = len(text)
|
|
80
|
+
while index < length:
|
|
81
|
+
while index < length and text[index] in ", \t":
|
|
82
|
+
index += 1
|
|
83
|
+
start = index
|
|
84
|
+
while index < length and text[index] not in ",=":
|
|
85
|
+
index += 1
|
|
86
|
+
name = text[start:index].strip().lower()
|
|
87
|
+
value: str | None = None
|
|
88
|
+
if index < length and text[index] == "=":
|
|
89
|
+
index += 1
|
|
90
|
+
if index < length and text[index] == '"':
|
|
91
|
+
index += 1
|
|
92
|
+
chars: list[str] = []
|
|
93
|
+
while index < length and text[index] != '"':
|
|
94
|
+
if text[index] == "\\" and index + 1 < length and text[index + 1] == '"':
|
|
95
|
+
index += 1
|
|
96
|
+
chars.append(text[index])
|
|
97
|
+
index += 1
|
|
98
|
+
index += 1
|
|
99
|
+
value = "".join(chars)
|
|
100
|
+
else:
|
|
101
|
+
start = index
|
|
102
|
+
while index < length and text[index] != ",":
|
|
103
|
+
index += 1
|
|
104
|
+
value = text[start:index]
|
|
105
|
+
if name:
|
|
106
|
+
options.append((name, value))
|
|
107
|
+
return tuple(options)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class KeyOptions:
|
|
112
|
+
"""What an authorized_keys line allows, before sshd's own settings apply."""
|
|
113
|
+
|
|
114
|
+
source: tuple[str, ...] = ()
|
|
115
|
+
forced_command: bool = False
|
|
116
|
+
restricted: bool = False
|
|
117
|
+
allows: frozenset[str] = frozenset({"port", "agent", "x11", "pty", "user-rc"})
|
|
118
|
+
permit_open: bool = False
|
|
119
|
+
permit_listen: bool = False
|
|
120
|
+
environment: bool = False
|
|
121
|
+
expires: int | None = None
|
|
122
|
+
expiry_unreadable: bool = False
|
|
123
|
+
principals: bool = False
|
|
124
|
+
from_unparsed: bool = False
|
|
125
|
+
names: tuple[str, ...] = field(default_factory=tuple)
|
|
126
|
+
|
|
127
|
+
def public(self) -> dict:
|
|
128
|
+
data: dict = {"restrict": self.restricted, "allows": sorted(self.allows)}
|
|
129
|
+
if self.source:
|
|
130
|
+
data["from"] = list(self.source)
|
|
131
|
+
if self.forced_command:
|
|
132
|
+
data["command"] = True
|
|
133
|
+
if self.permit_open:
|
|
134
|
+
data["permitopen"] = True
|
|
135
|
+
if self.permit_listen:
|
|
136
|
+
data["permitlisten"] = True
|
|
137
|
+
if self.environment:
|
|
138
|
+
data["environment"] = True
|
|
139
|
+
if self.expires is not None:
|
|
140
|
+
data["expiryTime"] = self.expires
|
|
141
|
+
if self.principals:
|
|
142
|
+
data["principals"] = True
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _expiry(value: str) -> int | None:
|
|
147
|
+
value = value.strip()
|
|
148
|
+
zone = timezone.utc if value.endswith("Z") else None
|
|
149
|
+
digits = value.rstrip("Z")
|
|
150
|
+
for pattern in ("%Y%m%d%H%M%S", "%Y%m%d%H%M", "%Y%m%d"):
|
|
151
|
+
try:
|
|
152
|
+
moment = datetime.strptime(digits, pattern)
|
|
153
|
+
except ValueError:
|
|
154
|
+
continue
|
|
155
|
+
if zone is None:
|
|
156
|
+
# sshd reads an expiry without Z in the server's local time.
|
|
157
|
+
return int(moment.timestamp())
|
|
158
|
+
return int(moment.replace(tzinfo=zone).timestamp())
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def summarize_options(options: tuple[tuple[str, str | None], ...]) -> KeyOptions:
|
|
163
|
+
names = tuple(name for name, _value in options)
|
|
164
|
+
restricted = "restrict" in names
|
|
165
|
+
allows = set() if restricted else {"port", "agent", "x11", "pty", "user-rc"}
|
|
166
|
+
source: list[str] = []
|
|
167
|
+
expires: int | None = None
|
|
168
|
+
expiry_unreadable = False
|
|
169
|
+
for name, value in options:
|
|
170
|
+
if name in _REENABLE:
|
|
171
|
+
allows.add(_REENABLE[name])
|
|
172
|
+
elif name in _DISABLE:
|
|
173
|
+
allows.discard(_DISABLE[name])
|
|
174
|
+
elif name == "from" and value:
|
|
175
|
+
source.extend(part.strip() for part in value.split(",") if part.strip())
|
|
176
|
+
elif name == "expiry-time" and value:
|
|
177
|
+
expires = _expiry(value)
|
|
178
|
+
expiry_unreadable = expires is None
|
|
179
|
+
return KeyOptions(
|
|
180
|
+
source=tuple(source),
|
|
181
|
+
forced_command="command" in names,
|
|
182
|
+
restricted=restricted,
|
|
183
|
+
allows=frozenset(allows),
|
|
184
|
+
permit_open="permitopen" in names,
|
|
185
|
+
permit_listen="permitlisten" in names,
|
|
186
|
+
environment="environment" in names,
|
|
187
|
+
expires=expires,
|
|
188
|
+
expiry_unreadable=expiry_unreadable,
|
|
189
|
+
principals="principals" in names,
|
|
190
|
+
from_unparsed="from" in names and not source,
|
|
191
|
+
names=names,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class KeyRef:
|
|
197
|
+
fingerprint: str
|
|
198
|
+
type: str
|
|
199
|
+
kind: str
|
|
200
|
+
bits: int | None
|
|
201
|
+
comment: str
|
|
202
|
+
user: str | None = None
|
|
203
|
+
cert_authority: bool = False
|
|
204
|
+
cert: CertInfo | None = None
|
|
205
|
+
options: KeyOptions = field(default_factory=KeyOptions)
|
|
206
|
+
|
|
207
|
+
def public(self) -> dict:
|
|
208
|
+
data = {
|
|
209
|
+
"fingerprint": self.fingerprint,
|
|
210
|
+
"type": self.type,
|
|
211
|
+
"bits": self.bits,
|
|
212
|
+
"comment": self.comment,
|
|
213
|
+
"certAuthority": self.cert_authority,
|
|
214
|
+
}
|
|
215
|
+
if self.cert and not self.cert.unreadable:
|
|
216
|
+
data["cert"] = self.cert.public()
|
|
217
|
+
return data
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True)
|
|
221
|
+
class ParseIssue:
|
|
222
|
+
line: int
|
|
223
|
+
text: str
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def fingerprint_bytes(raw: bytes) -> str:
|
|
227
|
+
digest = hashlib.sha256(raw).digest()
|
|
228
|
+
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _b64_to_bytes(blob: str) -> bytes | None:
|
|
232
|
+
padded = blob + "=" * ((4 - len(blob) % 4) % 4)
|
|
233
|
+
try:
|
|
234
|
+
return base64.b64decode(padded, validate=True)
|
|
235
|
+
except Exception:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _read_string(buf: bytes, offset: int) -> tuple[bytes, int] | None:
|
|
240
|
+
if offset + 4 > len(buf):
|
|
241
|
+
return None
|
|
242
|
+
length = int.from_bytes(buf[offset : offset + 4], "big")
|
|
243
|
+
start = offset + 4
|
|
244
|
+
if start + length > len(buf):
|
|
245
|
+
return None
|
|
246
|
+
return buf[start : start + length], start + length
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _mpint_bits(number: bytes) -> int:
|
|
250
|
+
index = 0
|
|
251
|
+
while index < len(number) - 1 and number[index] == 0:
|
|
252
|
+
index += 1
|
|
253
|
+
top = 0
|
|
254
|
+
for bit in range(7, -1, -1):
|
|
255
|
+
if (number[index] >> bit) & 1:
|
|
256
|
+
top = bit + 1
|
|
257
|
+
break
|
|
258
|
+
return (len(number) - index - 1) * 8 + top
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def rsa_bits(raw: bytes) -> int | None:
|
|
262
|
+
type_part = _read_string(raw, 0)
|
|
263
|
+
if not type_part:
|
|
264
|
+
return None
|
|
265
|
+
exponent = _read_string(raw, type_part[1])
|
|
266
|
+
if not exponent:
|
|
267
|
+
return None
|
|
268
|
+
modulus = _read_string(raw, exponent[1])
|
|
269
|
+
if not modulus:
|
|
270
|
+
return None
|
|
271
|
+
return _mpint_bits(modulus[0])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def kind_of(key_type: str) -> str:
|
|
275
|
+
base = key_type.replace("-cert-v01@openssh.com", "")
|
|
276
|
+
if base == "ssh-ed25519":
|
|
277
|
+
return "ed25519"
|
|
278
|
+
if base == "ssh-rsa":
|
|
279
|
+
return "rsa"
|
|
280
|
+
if base == "ssh-dss":
|
|
281
|
+
return "dsa"
|
|
282
|
+
if base.startswith("ecdsa-"):
|
|
283
|
+
return "ecdsa"
|
|
284
|
+
if base.startswith("sk-"):
|
|
285
|
+
return "security-key"
|
|
286
|
+
return "unknown"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _read_u32(buf: bytes, offset: int) -> tuple[int, int] | None:
|
|
290
|
+
if offset + 4 > len(buf):
|
|
291
|
+
return None
|
|
292
|
+
return int.from_bytes(buf[offset : offset + 4], "big"), offset + 4
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _read_u64(buf: bytes, offset: int) -> tuple[int | None, int] | None:
|
|
296
|
+
hi = _read_u32(buf, offset)
|
|
297
|
+
lo = _read_u32(buf, offset + 4)
|
|
298
|
+
if not hi or not lo:
|
|
299
|
+
return None
|
|
300
|
+
if hi[0] == 0xFFFFFFFF:
|
|
301
|
+
return None, offset + 8
|
|
302
|
+
return hi[0] * 4294967296 + lo[0], offset + 8
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _cert_lead(key_type: str) -> int | None:
|
|
306
|
+
if key_type == "ssh-ed25519-cert-v01@openssh.com":
|
|
307
|
+
return 3
|
|
308
|
+
if key_type == "ssh-rsa-cert-v01@openssh.com":
|
|
309
|
+
return 4
|
|
310
|
+
if key_type == "ssh-dss-cert-v01@openssh.com":
|
|
311
|
+
return 6
|
|
312
|
+
if key_type.startswith("ecdsa-") and "-cert-" in key_type:
|
|
313
|
+
return 4
|
|
314
|
+
if key_type == "sk-ssh-ed25519-cert-v01@openssh.com":
|
|
315
|
+
return 4
|
|
316
|
+
if key_type.startswith("sk-ecdsa-") and "-cert-" in key_type:
|
|
317
|
+
return 5
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def read_certificate(key_type: str, raw: bytes) -> CertInfo | None:
|
|
322
|
+
lead = _cert_lead(key_type)
|
|
323
|
+
if lead is None:
|
|
324
|
+
return None
|
|
325
|
+
offset = 0
|
|
326
|
+
for _ in range(lead):
|
|
327
|
+
part = _read_string(raw, offset)
|
|
328
|
+
if not part:
|
|
329
|
+
return None
|
|
330
|
+
offset = part[1]
|
|
331
|
+
serial = _read_u64(raw, offset)
|
|
332
|
+
if not serial:
|
|
333
|
+
return None
|
|
334
|
+
offset = serial[1]
|
|
335
|
+
cert_type = _read_u32(raw, offset)
|
|
336
|
+
if not cert_type:
|
|
337
|
+
return None
|
|
338
|
+
offset = cert_type[1]
|
|
339
|
+
key_id = _read_string(raw, offset)
|
|
340
|
+
if not key_id:
|
|
341
|
+
return None
|
|
342
|
+
offset = key_id[1]
|
|
343
|
+
principals = _read_string(raw, offset)
|
|
344
|
+
if not principals:
|
|
345
|
+
return None
|
|
346
|
+
offset = principals[1]
|
|
347
|
+
valid_after = _read_u64(raw, offset)
|
|
348
|
+
if not valid_after or valid_after[0] is None:
|
|
349
|
+
return None
|
|
350
|
+
offset = valid_after[1]
|
|
351
|
+
valid_before = _read_u64(raw, offset)
|
|
352
|
+
if not valid_before:
|
|
353
|
+
return None
|
|
354
|
+
offset = valid_before[1]
|
|
355
|
+
for _ in range(3):
|
|
356
|
+
skipped = _read_string(raw, offset)
|
|
357
|
+
if not skipped:
|
|
358
|
+
return None
|
|
359
|
+
offset = skipped[1]
|
|
360
|
+
ca = _read_string(raw, offset)
|
|
361
|
+
if not ca:
|
|
362
|
+
return None
|
|
363
|
+
label = {1: "user", 2: "host"}.get(cert_type[0], "unknown")
|
|
364
|
+
ident = key_id[0].decode("utf-8", errors="replace").replace("\x00", "").strip()[:80]
|
|
365
|
+
return CertInfo(label, valid_after[0], valid_before[0], ident, fingerprint_bytes(ca[0]), False)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _cert_authority(options: tuple[tuple[str, str | None], ...]) -> bool:
|
|
369
|
+
return any(name == "cert-authority" for name, _value in options)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _split_line(line: str) -> tuple[str, str, str, str] | None:
|
|
373
|
+
for key_type in KEY_TYPES:
|
|
374
|
+
token = f"{key_type} "
|
|
375
|
+
start = 0
|
|
376
|
+
while start < len(line):
|
|
377
|
+
index = line.find(token, start)
|
|
378
|
+
if index == -1:
|
|
379
|
+
break
|
|
380
|
+
if index == 0 or line[index - 1].isspace():
|
|
381
|
+
rest = line[index + len(token) :].strip()
|
|
382
|
+
space = re.search(r"\s", rest)
|
|
383
|
+
blob = rest if space is None else rest[: space.start()]
|
|
384
|
+
comment = "" if space is None else rest[space.end() :].strip()
|
|
385
|
+
if re.fullmatch(r"[A-Za-z0-9+/]+=*", blob) and len(blob) >= 16:
|
|
386
|
+
return key_type, blob, comment, line[:index].strip()
|
|
387
|
+
start = index + 1
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def parse_public_keys(text: str, user: str | None = None) -> tuple[list[KeyRef], list[ParseIssue]]:
|
|
392
|
+
if contains_private_key(text):
|
|
393
|
+
raise PrivateKeyError("private key material was present and was not recorded")
|
|
394
|
+
keys: list[KeyRef] = []
|
|
395
|
+
issues: list[ParseIssue] = []
|
|
396
|
+
seen: set[str] = set()
|
|
397
|
+
for index, raw_line in enumerate(text.splitlines()):
|
|
398
|
+
line = raw_line.strip()
|
|
399
|
+
if not line or line.startswith("#"):
|
|
400
|
+
continue
|
|
401
|
+
parts = _split_line(line)
|
|
402
|
+
if not parts:
|
|
403
|
+
issues.append(ParseIssue(index + 1, "Not a recognized public key"))
|
|
404
|
+
continue
|
|
405
|
+
raw = _b64_to_bytes(parts[1])
|
|
406
|
+
if raw is None:
|
|
407
|
+
issues.append(ParseIssue(index + 1, "Key blob is not valid base64"))
|
|
408
|
+
continue
|
|
409
|
+
fingerprint = fingerprint_bytes(raw)
|
|
410
|
+
if fingerprint in seen:
|
|
411
|
+
continue
|
|
412
|
+
seen.add(fingerprint)
|
|
413
|
+
is_cert = "-cert-" in parts[0]
|
|
414
|
+
cert = read_certificate(parts[0], raw) if is_cert else None
|
|
415
|
+
if parts[0] == "ssh-rsa":
|
|
416
|
+
bits: int | None = rsa_bits(raw)
|
|
417
|
+
elif parts[0] == "ssh-dss" or parts[0].startswith("ssh-dss-cert-"):
|
|
418
|
+
bits = 1024
|
|
419
|
+
elif parts[0].startswith("ssh-rsa-cert-"):
|
|
420
|
+
lead = _read_string(raw, 0)
|
|
421
|
+
nonce = _read_string(raw, lead[1]) if lead else None
|
|
422
|
+
exponent = _read_string(raw, nonce[1]) if nonce else None
|
|
423
|
+
modulus = _read_string(raw, exponent[1]) if exponent else None
|
|
424
|
+
bits = _mpint_bits(modulus[0]) if modulus else None
|
|
425
|
+
else:
|
|
426
|
+
bits = None
|
|
427
|
+
if is_cert and cert is None:
|
|
428
|
+
cert = CertInfo("unknown", 0, 0, "", "", True)
|
|
429
|
+
options = parse_options(parts[3])
|
|
430
|
+
keys.append(
|
|
431
|
+
KeyRef(
|
|
432
|
+
fingerprint,
|
|
433
|
+
parts[0],
|
|
434
|
+
kind_of(parts[0]),
|
|
435
|
+
bits,
|
|
436
|
+
parts[2],
|
|
437
|
+
user,
|
|
438
|
+
_cert_authority(options),
|
|
439
|
+
cert,
|
|
440
|
+
summarize_options(options),
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
return keys, issues
|
keywarden/term.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Terminal colour. Off when output is not a terminal, when NO_COLOR is set, or when asked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_CODES = {
|
|
9
|
+
"fail": "31;1",
|
|
10
|
+
"warn": "33;1",
|
|
11
|
+
"pass": "32",
|
|
12
|
+
"accepted": "34;1",
|
|
13
|
+
"dim": "2",
|
|
14
|
+
"bold": "1",
|
|
15
|
+
"fp": "36",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _enable_windows_vt() -> bool:
|
|
20
|
+
"""Windows 10 and later understand ANSI once virtual terminal processing is switched on."""
|
|
21
|
+
try:
|
|
22
|
+
import ctypes
|
|
23
|
+
|
|
24
|
+
kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
25
|
+
handle = kernel.GetStdHandle(-11)
|
|
26
|
+
mode = ctypes.c_uint32()
|
|
27
|
+
if not kernel.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
28
|
+
return False
|
|
29
|
+
return bool(kernel.SetConsoleMode(handle, mode.value | 0x0004))
|
|
30
|
+
except Exception:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def use_colour(choice: str = "auto", stream=None) -> bool:
|
|
35
|
+
stream = stream or sys.stdout
|
|
36
|
+
if choice == "always":
|
|
37
|
+
if os.name == "nt":
|
|
38
|
+
_enable_windows_vt()
|
|
39
|
+
return True
|
|
40
|
+
if choice == "never" or os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
|
|
41
|
+
return False
|
|
42
|
+
if not hasattr(stream, "isatty") or not stream.isatty():
|
|
43
|
+
return False
|
|
44
|
+
if os.name == "nt":
|
|
45
|
+
return _enable_windows_vt()
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Paint:
|
|
50
|
+
def __init__(self, enabled: bool):
|
|
51
|
+
self.enabled = enabled
|
|
52
|
+
|
|
53
|
+
def __call__(self, style: str, text: str) -> str:
|
|
54
|
+
if not self.enabled or style not in _CODES:
|
|
55
|
+
return text
|
|
56
|
+
return f"\033[{_CODES[style]}m{text}\033[0m"
|
|
57
|
+
|
|
58
|
+
def severity(self, severity: str) -> str:
|
|
59
|
+
label = {"fail": "FAIL", "warn": "WARN", "pass": "PASS", "accepted": "ACPT"}.get(severity, severity.upper())
|
|
60
|
+
return self(severity, f"{label:4}")
|
|
61
|
+
|
|
62
|
+
def fingerprints(self, text: str) -> str:
|
|
63
|
+
if not self.enabled:
|
|
64
|
+
return text
|
|
65
|
+
from keywarden.check import FINGERPRINT
|
|
66
|
+
|
|
67
|
+
return FINGERPRINT.sub(lambda match: self("fp", match.group(0)), text)
|