lupaxa-github-token-validator 0.1.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.
- lupaxa/github_token_validator/__init__.py +7 -0
- lupaxa/github_token_validator/__main__.py +8 -0
- lupaxa/github_token_validator/classify.py +52 -0
- lupaxa/github_token_validator/cli.py +150 -0
- lupaxa/github_token_validator/client.py +131 -0
- lupaxa/github_token_validator/constants.py +37 -0
- lupaxa/github_token_validator/probes.py +478 -0
- lupaxa/github_token_validator/report.py +221 -0
- lupaxa/github_token_validator/scopes.py +98 -0
- lupaxa_github_token_validator-0.1.0.dist-info/METADATA +206 -0
- lupaxa_github_token_validator-0.1.0.dist-info/RECORD +15 -0
- lupaxa_github_token_validator-0.1.0.dist-info/WHEEL +5 -0
- lupaxa_github_token_validator-0.1.0.dist-info/entry_points.txt +3 -0
- lupaxa_github_token_validator-0.1.0.dist-info/licenses/LICENCE +21 -0
- lupaxa_github_token_validator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Read-only probe catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from lupaxa.github_token_validator.classify import TokenKind
|
|
11
|
+
from lupaxa.github_token_validator.client import (
|
|
12
|
+
GitHubClient,
|
|
13
|
+
GitHubRateLimitError,
|
|
14
|
+
GitHubResponse,
|
|
15
|
+
GitHubTransportError,
|
|
16
|
+
GitHubUnauthorizedError,
|
|
17
|
+
)
|
|
18
|
+
from lupaxa.github_token_validator.constants import (
|
|
19
|
+
EXIT_FAILURE,
|
|
20
|
+
EXIT_SUCCESS,
|
|
21
|
+
EXIT_USAGE,
|
|
22
|
+
)
|
|
23
|
+
from lupaxa.github_token_validator.scopes import (
|
|
24
|
+
accepted_permission_rows,
|
|
25
|
+
allows_text,
|
|
26
|
+
scope_names,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Probe:
|
|
32
|
+
"""One catalog row."""
|
|
33
|
+
|
|
34
|
+
check: str
|
|
35
|
+
endpoint: str
|
|
36
|
+
meaning: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def status_result(status_code: int) -> str:
|
|
40
|
+
"""Map an HTTP status to a row result."""
|
|
41
|
+
if status_code in (200, 204):
|
|
42
|
+
return "granted"
|
|
43
|
+
if status_code == 403:
|
|
44
|
+
return "denied"
|
|
45
|
+
if status_code == 404:
|
|
46
|
+
return "not applicable"
|
|
47
|
+
return f"error {status_code}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def request_path(endpoint: str) -> str:
|
|
51
|
+
"""Return the path and query from a ``GET`` endpoint label."""
|
|
52
|
+
method, _, path = endpoint.partition(" ")
|
|
53
|
+
if method != "GET" or not path.startswith("/"):
|
|
54
|
+
raise ValueError(endpoint)
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bind_endpoint(
|
|
59
|
+
endpoint: str,
|
|
60
|
+
*,
|
|
61
|
+
org: str | None = None,
|
|
62
|
+
owner: str | None = None,
|
|
63
|
+
repo: str | None = None,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Fill placeholders whose arguments were passed."""
|
|
66
|
+
text = endpoint
|
|
67
|
+
if org is not None:
|
|
68
|
+
text = text.replace("{org}", quote(org, safe=""))
|
|
69
|
+
if owner is not None:
|
|
70
|
+
text = text.replace("{owner}", quote(owner, safe=""))
|
|
71
|
+
if repo is not None:
|
|
72
|
+
text = text.replace("{repo}", quote(repo, safe=""))
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def header_value(headers: Mapping[str, str], name: str) -> str | None:
|
|
77
|
+
"""Return a header value, ignoring case and blank text."""
|
|
78
|
+
wanted = name.lower()
|
|
79
|
+
for key, value in headers.items():
|
|
80
|
+
if key.lower() == wanted and value.strip() != "":
|
|
81
|
+
return value
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def rate_limit_fields(headers: Mapping[str, str]) -> tuple[str, str, str, str]:
|
|
86
|
+
"""Return limit, used, remaining, and UTC reset."""
|
|
87
|
+
limit = header_value(headers, "X-RateLimit-Limit") or "unknown"
|
|
88
|
+
used = header_value(headers, "X-RateLimit-Used") or "unknown"
|
|
89
|
+
remaining = header_value(headers, "X-RateLimit-Remaining") or "unknown"
|
|
90
|
+
raw_reset = header_value(headers, "X-RateLimit-Reset")
|
|
91
|
+
if raw_reset is None or not raw_reset.isdigit():
|
|
92
|
+
return (limit, used, remaining, "unknown")
|
|
93
|
+
reset = datetime.fromtimestamp(int(raw_reset), tz=UTC).strftime("%Y-%m-%d %H:%M:%S")
|
|
94
|
+
return (limit, used, remaining, reset)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
ACCOUNT_PROBES: tuple[Probe, ...] = (
|
|
98
|
+
Probe("Profile", "GET /user", "Read the authenticated user profile"),
|
|
99
|
+
Probe(
|
|
100
|
+
"Emails", "GET /user/emails", "Read the authenticated user's email addresses"
|
|
101
|
+
),
|
|
102
|
+
Probe(
|
|
103
|
+
"Following",
|
|
104
|
+
"GET /user/following?per_page=1",
|
|
105
|
+
"Read the users this account follows",
|
|
106
|
+
),
|
|
107
|
+
Probe(
|
|
108
|
+
"Organisations", "GET /user/orgs?per_page=1", "Read organisation memberships"
|
|
109
|
+
),
|
|
110
|
+
Probe(
|
|
111
|
+
"Org membership roles",
|
|
112
|
+
"GET /user/memberships/orgs?per_page=1",
|
|
113
|
+
"Read organisation membership roles",
|
|
114
|
+
),
|
|
115
|
+
Probe("Gists", "GET /gists?per_page=1", "Read gists"),
|
|
116
|
+
Probe("Notifications", "GET /notifications?per_page=1", "Read notifications"),
|
|
117
|
+
Probe("Public keys", "GET /user/keys?per_page=1", "Read public keys"),
|
|
118
|
+
Probe("GPG keys", "GET /user/gpg_keys?per_page=1", "Read GPG keys"),
|
|
119
|
+
Probe(
|
|
120
|
+
"SSH signing keys",
|
|
121
|
+
"GET /user/ssh_signing_keys?per_page=1",
|
|
122
|
+
"Read SSH signing keys",
|
|
123
|
+
),
|
|
124
|
+
Probe(
|
|
125
|
+
"Packages",
|
|
126
|
+
"GET /user/packages?package_type=npm&per_page=1",
|
|
127
|
+
"Read GitHub Packages",
|
|
128
|
+
),
|
|
129
|
+
Probe(
|
|
130
|
+
"Repository list",
|
|
131
|
+
"GET /user/repos?per_page=1",
|
|
132
|
+
"Read whether the repository list is allowed. Do not print repository names",
|
|
133
|
+
),
|
|
134
|
+
Probe("Codespaces", "GET /user/codespaces?per_page=1", "Read codespaces"),
|
|
135
|
+
Probe(
|
|
136
|
+
"Installations",
|
|
137
|
+
"GET /user/installations?per_page=1",
|
|
138
|
+
"Read GitHub App installations",
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
ORGANISATION_PROBES: tuple[Probe, ...] = (
|
|
143
|
+
Probe("Organisation", "GET /orgs/{org}", "Read the organisation profile"),
|
|
144
|
+
Probe(
|
|
145
|
+
"Members",
|
|
146
|
+
"GET /orgs/{org}/members?per_page=1",
|
|
147
|
+
"Read organisation members. Do not print logins",
|
|
148
|
+
),
|
|
149
|
+
Probe("Hooks", "GET /orgs/{org}/hooks?per_page=1", "Read organisation hooks"),
|
|
150
|
+
Probe(
|
|
151
|
+
"Audit log",
|
|
152
|
+
"GET /orgs/{org}/audit-log?per_page=1",
|
|
153
|
+
"Read the organisation audit log",
|
|
154
|
+
),
|
|
155
|
+
Probe(
|
|
156
|
+
"Actions permissions",
|
|
157
|
+
"GET /orgs/{org}/actions/permissions",
|
|
158
|
+
"Read organisation Actions permissions",
|
|
159
|
+
),
|
|
160
|
+
Probe(
|
|
161
|
+
"Packages",
|
|
162
|
+
"GET /orgs/{org}/packages?package_type=npm&per_page=1",
|
|
163
|
+
"Read organisation packages",
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
REPOSITORY_PROBES: tuple[Probe, ...] = (
|
|
168
|
+
Probe("Repository", "GET /repos/{owner}/{repo}", "Read repository metadata"),
|
|
169
|
+
Probe(
|
|
170
|
+
"Contents",
|
|
171
|
+
"GET /repos/{owner}/{repo}/contents",
|
|
172
|
+
"Read repository contents. Do not print paths",
|
|
173
|
+
),
|
|
174
|
+
Probe(
|
|
175
|
+
"Hooks", "GET /repos/{owner}/{repo}/hooks?per_page=1", "Read repository hooks"
|
|
176
|
+
),
|
|
177
|
+
Probe(
|
|
178
|
+
"Workflows",
|
|
179
|
+
"GET /repos/{owner}/{repo}/actions/workflows?per_page=1",
|
|
180
|
+
"Read Actions workflows. Do not print workflow names",
|
|
181
|
+
),
|
|
182
|
+
Probe(
|
|
183
|
+
"Deployments",
|
|
184
|
+
"GET /repos/{owner}/{repo}/deployments?per_page=1",
|
|
185
|
+
"Read deployments",
|
|
186
|
+
),
|
|
187
|
+
Probe(
|
|
188
|
+
"Invitations",
|
|
189
|
+
"GET /repos/{owner}/{repo}/invitations?per_page=1",
|
|
190
|
+
"Read repository invitations",
|
|
191
|
+
),
|
|
192
|
+
Probe(
|
|
193
|
+
"Code scanning",
|
|
194
|
+
"GET /repos/{owner}/{repo}/code-scanning/alerts?per_page=1",
|
|
195
|
+
"Read code-scanning alerts",
|
|
196
|
+
),
|
|
197
|
+
Probe(
|
|
198
|
+
"Commits",
|
|
199
|
+
"GET /repos/{owner}/{repo}/commits?per_page=1",
|
|
200
|
+
"Read commits. Do not print commit messages",
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class ProbeRow:
|
|
207
|
+
"""One rendered permission check."""
|
|
208
|
+
|
|
209
|
+
target: str
|
|
210
|
+
check: str
|
|
211
|
+
endpoint: str
|
|
212
|
+
result: str
|
|
213
|
+
meaning: str
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class RunReport:
|
|
218
|
+
"""Tables and the process exit code."""
|
|
219
|
+
|
|
220
|
+
kind_label: str
|
|
221
|
+
login: str
|
|
222
|
+
scopes_raw: str
|
|
223
|
+
scope_rows: tuple[tuple[str, str], ...]
|
|
224
|
+
rate_limit: str
|
|
225
|
+
rate_used: str
|
|
226
|
+
rate_remaining: str
|
|
227
|
+
rate_reset: str
|
|
228
|
+
account_rows: tuple[ProbeRow, ...]
|
|
229
|
+
target_rows: tuple[ProbeRow, ...]
|
|
230
|
+
error: str | None
|
|
231
|
+
exit_code: int
|
|
232
|
+
show_summary: bool
|
|
233
|
+
show_scopes: bool
|
|
234
|
+
show_account: bool
|
|
235
|
+
show_target: bool
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True, slots=True)
|
|
239
|
+
class _ProbeStep:
|
|
240
|
+
"""A completed row or a row awaiting one GET."""
|
|
241
|
+
|
|
242
|
+
row: ProbeRow
|
|
243
|
+
path: str | None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def run_validation(
|
|
247
|
+
*,
|
|
248
|
+
kind: TokenKind,
|
|
249
|
+
org: str | None,
|
|
250
|
+
repo: tuple[str, str] | None,
|
|
251
|
+
client: GitHubClient | None,
|
|
252
|
+
) -> RunReport:
|
|
253
|
+
"""Probe the token, or return the refresh-token summary."""
|
|
254
|
+
if not kind.calls_api:
|
|
255
|
+
return _not_requested(kind.label)
|
|
256
|
+
if client is None:
|
|
257
|
+
raise ValueError("client is required")
|
|
258
|
+
try:
|
|
259
|
+
user = client.get("/user", read_login=True)
|
|
260
|
+
except (GitHubUnauthorizedError, GitHubRateLimitError, GitHubTransportError) as exc:
|
|
261
|
+
return _hidden_failure(kind.label, str(exc))
|
|
262
|
+
report = _report_from_user(kind, user)
|
|
263
|
+
if kind.checks_account:
|
|
264
|
+
account_rows, error = _collect(client, _account_steps(user))
|
|
265
|
+
if error is not None:
|
|
266
|
+
return _aborted(report, account_rows, (), error)
|
|
267
|
+
else:
|
|
268
|
+
account_rows = ()
|
|
269
|
+
target_rows, error = _collect(client, _target_steps(org, repo))
|
|
270
|
+
if error is not None:
|
|
271
|
+
return _aborted(report, account_rows, target_rows, error)
|
|
272
|
+
return replace(
|
|
273
|
+
report,
|
|
274
|
+
account_rows=account_rows,
|
|
275
|
+
target_rows=target_rows,
|
|
276
|
+
exit_code=EXIT_SUCCESS,
|
|
277
|
+
show_account=bool(account_rows),
|
|
278
|
+
show_target=bool(target_rows),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _not_requested(kind_label: str) -> RunReport:
|
|
283
|
+
"""Return the summary for a token that cannot call the API."""
|
|
284
|
+
return RunReport(
|
|
285
|
+
kind_label=kind_label,
|
|
286
|
+
login="not requested",
|
|
287
|
+
scopes_raw="none",
|
|
288
|
+
scope_rows=(),
|
|
289
|
+
rate_limit="not requested",
|
|
290
|
+
rate_used="not requested",
|
|
291
|
+
rate_remaining="not requested",
|
|
292
|
+
rate_reset="not requested",
|
|
293
|
+
account_rows=(),
|
|
294
|
+
target_rows=(),
|
|
295
|
+
error=None,
|
|
296
|
+
exit_code=EXIT_USAGE,
|
|
297
|
+
show_summary=True,
|
|
298
|
+
show_scopes=False,
|
|
299
|
+
show_account=False,
|
|
300
|
+
show_target=False,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _report_from_user(kind: TokenKind, user: GitHubResponse) -> RunReport:
|
|
305
|
+
"""Build the initial visible report from GET /user."""
|
|
306
|
+
scopes_raw, scope_rows = _permission_fields(kind, user.headers)
|
|
307
|
+
limit, used, remaining, reset = rate_limit_fields(user.headers)
|
|
308
|
+
return RunReport(
|
|
309
|
+
kind_label=kind.label,
|
|
310
|
+
login=user.login or "unknown",
|
|
311
|
+
scopes_raw=scopes_raw,
|
|
312
|
+
scope_rows=scope_rows,
|
|
313
|
+
rate_limit=limit,
|
|
314
|
+
rate_used=used,
|
|
315
|
+
rate_remaining=remaining,
|
|
316
|
+
rate_reset=reset,
|
|
317
|
+
account_rows=(),
|
|
318
|
+
target_rows=(),
|
|
319
|
+
error=None,
|
|
320
|
+
exit_code=EXIT_SUCCESS,
|
|
321
|
+
show_summary=True,
|
|
322
|
+
show_scopes=True,
|
|
323
|
+
show_account=True,
|
|
324
|
+
show_target=True,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _account_steps(user: GitHubResponse) -> tuple[_ProbeStep, ...]:
|
|
329
|
+
"""Return the completed Profile step followed by pending account steps."""
|
|
330
|
+
profile = ACCOUNT_PROBES[0]
|
|
331
|
+
completed = _ProbeStep(
|
|
332
|
+
_row("", profile, profile.endpoint, status_result(user.status_code)),
|
|
333
|
+
None,
|
|
334
|
+
)
|
|
335
|
+
pending = tuple(
|
|
336
|
+
_ProbeStep(_row("", probe, probe.endpoint, ""), request_path(probe.endpoint))
|
|
337
|
+
for probe in ACCOUNT_PROBES[1:]
|
|
338
|
+
)
|
|
339
|
+
return (completed, *pending)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _target_steps(
|
|
343
|
+
org: str | None,
|
|
344
|
+
repo: tuple[str, str] | None,
|
|
345
|
+
) -> tuple[_ProbeStep, ...]:
|
|
346
|
+
"""Return organisation steps followed by repository steps."""
|
|
347
|
+
organisation = _bound_steps(
|
|
348
|
+
target="organisation",
|
|
349
|
+
probes=ORGANISATION_PROBES,
|
|
350
|
+
enabled=org is not None,
|
|
351
|
+
org=org,
|
|
352
|
+
)
|
|
353
|
+
owner, name = repo if repo is not None else (None, None)
|
|
354
|
+
repository = _bound_steps(
|
|
355
|
+
target="repository",
|
|
356
|
+
probes=REPOSITORY_PROBES,
|
|
357
|
+
enabled=repo is not None,
|
|
358
|
+
owner=owner,
|
|
359
|
+
repo=name,
|
|
360
|
+
)
|
|
361
|
+
return (*organisation, *repository)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _bound_steps(
|
|
365
|
+
*,
|
|
366
|
+
target: str,
|
|
367
|
+
probes: tuple[Probe, ...],
|
|
368
|
+
enabled: bool,
|
|
369
|
+
org: str | None = None,
|
|
370
|
+
owner: str | None = None,
|
|
371
|
+
repo: str | None = None,
|
|
372
|
+
) -> tuple[_ProbeStep, ...]:
|
|
373
|
+
"""Bind one target's endpoints when that target was requested."""
|
|
374
|
+
if not enabled:
|
|
375
|
+
return ()
|
|
376
|
+
return tuple(
|
|
377
|
+
_pending_bound_step(target, probe, org=org, owner=owner, repo=repo)
|
|
378
|
+
for probe in probes
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _pending_bound_step(
|
|
383
|
+
target: str,
|
|
384
|
+
probe: Probe,
|
|
385
|
+
*,
|
|
386
|
+
org: str | None,
|
|
387
|
+
owner: str | None,
|
|
388
|
+
repo: str | None,
|
|
389
|
+
) -> _ProbeStep:
|
|
390
|
+
"""Create a pending step with a bound endpoint."""
|
|
391
|
+
endpoint = bind_endpoint(probe.endpoint, org=org, owner=owner, repo=repo)
|
|
392
|
+
return _ProbeStep(_row(target, probe, endpoint, ""), request_path(endpoint))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _row(target: str, probe: Probe, endpoint: str, result: str) -> ProbeRow:
|
|
396
|
+
"""Build one result row."""
|
|
397
|
+
return ProbeRow(target, probe.check, endpoint, result, probe.meaning)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _collect(
|
|
401
|
+
client: GitHubClient,
|
|
402
|
+
steps: tuple[_ProbeStep, ...],
|
|
403
|
+
) -> tuple[tuple[ProbeRow, ...], str | None]:
|
|
404
|
+
"""Complete steps until the first authentication or transport failure."""
|
|
405
|
+
rows: list[ProbeRow] = []
|
|
406
|
+
for step in steps:
|
|
407
|
+
if step.path is None:
|
|
408
|
+
rows.append(step.row)
|
|
409
|
+
continue
|
|
410
|
+
result = _request(client, step.path)
|
|
411
|
+
if isinstance(result, str):
|
|
412
|
+
return (tuple(rows), result)
|
|
413
|
+
rows.append(replace(step.row, result=status_result(result.status_code)))
|
|
414
|
+
return (tuple(rows), None)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _request(client: GitHubClient, path: str) -> GitHubResponse | str:
|
|
418
|
+
"""Return one response, or the client failure text."""
|
|
419
|
+
try:
|
|
420
|
+
return client.get(path)
|
|
421
|
+
except (GitHubUnauthorizedError, GitHubRateLimitError, GitHubTransportError) as exc:
|
|
422
|
+
return str(exc)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _permission_fields(
|
|
426
|
+
kind: TokenKind,
|
|
427
|
+
headers: Mapping[str, str],
|
|
428
|
+
) -> tuple[str, tuple[tuple[str, str], ...]]:
|
|
429
|
+
"""Choose the scope or accepted-permission rows for this token kind."""
|
|
430
|
+
if kind.permission_source == "accepted":
|
|
431
|
+
header = header_value(headers, "X-Accepted-GitHub-Permissions")
|
|
432
|
+
return header or "none", accepted_permission_rows(header)
|
|
433
|
+
scopes_header = header_value(headers, "X-OAuth-Scopes")
|
|
434
|
+
if kind.permission_source == "fine_grained" and scopes_header is None:
|
|
435
|
+
text = "Fine-grained tokens do not return OAuth scopes."
|
|
436
|
+
return "none", (("none", text),)
|
|
437
|
+
names = scope_names(scopes_header)
|
|
438
|
+
return scopes_header or "none", tuple((name, allows_text(name)) for name in names)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _hidden_failure(kind_label: str, error: str) -> RunReport:
|
|
442
|
+
"""Return an initial failure with every table hidden."""
|
|
443
|
+
return RunReport(
|
|
444
|
+
kind_label=kind_label,
|
|
445
|
+
login="unknown",
|
|
446
|
+
scopes_raw="none",
|
|
447
|
+
scope_rows=(),
|
|
448
|
+
rate_limit="unknown",
|
|
449
|
+
rate_used="unknown",
|
|
450
|
+
rate_remaining="unknown",
|
|
451
|
+
rate_reset="unknown",
|
|
452
|
+
account_rows=(),
|
|
453
|
+
target_rows=(),
|
|
454
|
+
error=error,
|
|
455
|
+
exit_code=EXIT_FAILURE,
|
|
456
|
+
show_summary=False,
|
|
457
|
+
show_scopes=False,
|
|
458
|
+
show_account=False,
|
|
459
|
+
show_target=False,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _aborted(
|
|
464
|
+
report: RunReport,
|
|
465
|
+
account_rows: tuple[ProbeRow, ...],
|
|
466
|
+
target_rows: tuple[ProbeRow, ...],
|
|
467
|
+
error: str,
|
|
468
|
+
) -> RunReport:
|
|
469
|
+
"""Keep completed rows and hide empty result tables after a failure."""
|
|
470
|
+
return replace(
|
|
471
|
+
report,
|
|
472
|
+
account_rows=account_rows,
|
|
473
|
+
target_rows=target_rows,
|
|
474
|
+
error=error,
|
|
475
|
+
exit_code=EXIT_FAILURE,
|
|
476
|
+
show_account=bool(account_rows),
|
|
477
|
+
show_target=bool(target_rows),
|
|
478
|
+
)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Render a run as Rich tables of one shared width and first column."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from colored import fore, style, stylize
|
|
9
|
+
from rich.cells import cell_len
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from lupaxa.github_token_validator.probes import ProbeRow, RunReport
|
|
15
|
+
|
|
16
|
+
_RESULT_COLORS = {
|
|
17
|
+
"granted": "green",
|
|
18
|
+
"denied": "red",
|
|
19
|
+
"not applicable": "yellow",
|
|
20
|
+
"not checked": "dark_gray",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_report(report: RunReport, console: Console | None = None) -> None:
|
|
25
|
+
"""Print the tables selected by the report flags."""
|
|
26
|
+
output = _default_console() if console is None else console
|
|
27
|
+
first_width = _first_column_width(report)
|
|
28
|
+
if report.show_summary:
|
|
29
|
+
_summary(output, report, first_width)
|
|
30
|
+
if report.show_scopes:
|
|
31
|
+
_scopes(output, report, first_width)
|
|
32
|
+
if report.show_account:
|
|
33
|
+
_account(output, report, first_width)
|
|
34
|
+
if report.show_target:
|
|
35
|
+
_targets(output, report, first_width)
|
|
36
|
+
if report.error is not None:
|
|
37
|
+
output.print(_paint(report.error, "red", bold=True))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _default_console() -> Console:
|
|
41
|
+
"""Use a wide layout when stdout is not an interactive terminal."""
|
|
42
|
+
if sys.stdout.isatty():
|
|
43
|
+
return Console()
|
|
44
|
+
columns = shutil.get_terminal_size(fallback=(200, 24)).columns
|
|
45
|
+
return Console(width=max(200, columns), _environ={})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _summary(console: Console, report: RunReport, first_width: int) -> None:
|
|
49
|
+
table = _table(console, "Summary")
|
|
50
|
+
_columns(table, ("Field", "Value"), first_width)
|
|
51
|
+
rows = (
|
|
52
|
+
("Token kind", report.kind_label),
|
|
53
|
+
("Login", report.login),
|
|
54
|
+
("Scopes", report.scopes_raw),
|
|
55
|
+
("Rate limit", report.rate_limit),
|
|
56
|
+
("Used", report.rate_used),
|
|
57
|
+
("Remaining", report.rate_remaining),
|
|
58
|
+
("Reset", report.rate_reset),
|
|
59
|
+
)
|
|
60
|
+
for field, value in rows:
|
|
61
|
+
table.add_row(_plain(field), _plain(value))
|
|
62
|
+
console.print(table)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _scopes(console: Console, report: RunReport, first_width: int) -> None:
|
|
66
|
+
table = _table(console, "Scopes")
|
|
67
|
+
_columns(table, ("Scope", "Allows"), first_width)
|
|
68
|
+
for scope, allows in report.scope_rows:
|
|
69
|
+
table.add_row(_plain(scope), _plain(allows))
|
|
70
|
+
console.print(table)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _account(console: Console, report: RunReport, first_width: int) -> None:
|
|
74
|
+
table = _table(console, "Account permissions")
|
|
75
|
+
endpoint_width = _endpoint_width(report.account_rows)
|
|
76
|
+
_permission_columns(
|
|
77
|
+
table,
|
|
78
|
+
("Check", "Endpoint", "Result", "Meaning"),
|
|
79
|
+
first_width,
|
|
80
|
+
endpoint_width,
|
|
81
|
+
)
|
|
82
|
+
for row in report.account_rows:
|
|
83
|
+
_add_permission(table, row, include_target=False)
|
|
84
|
+
console.print(table)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _targets(console: Console, report: RunReport, first_width: int) -> None:
|
|
88
|
+
table = _table(console, "Target permissions")
|
|
89
|
+
endpoint_width = _endpoint_width(report.target_rows)
|
|
90
|
+
_permission_columns(
|
|
91
|
+
table,
|
|
92
|
+
("Target", "Check", "Endpoint", "Result", "Meaning"),
|
|
93
|
+
first_width,
|
|
94
|
+
endpoint_width,
|
|
95
|
+
)
|
|
96
|
+
for row in report.target_rows:
|
|
97
|
+
_add_permission(table, row, include_target=True)
|
|
98
|
+
console.print(table)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _first_column_width(report: RunReport) -> int:
|
|
102
|
+
"""Return one content width for the first column of every visible table."""
|
|
103
|
+
cells = _first_column_cells(report)
|
|
104
|
+
if not cells:
|
|
105
|
+
return 1
|
|
106
|
+
return max(cell_len(cell) for cell in cells)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _first_column_cells(report: RunReport) -> tuple[str, ...]:
|
|
110
|
+
cells: list[str] = []
|
|
111
|
+
if report.show_summary:
|
|
112
|
+
cells.extend(
|
|
113
|
+
(
|
|
114
|
+
"Field",
|
|
115
|
+
"Token kind",
|
|
116
|
+
"Login",
|
|
117
|
+
"Scopes",
|
|
118
|
+
"Rate limit",
|
|
119
|
+
"Used",
|
|
120
|
+
"Remaining",
|
|
121
|
+
"Reset",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
if report.show_scopes:
|
|
125
|
+
cells.append("Scope")
|
|
126
|
+
cells.extend(scope for scope, _allows in report.scope_rows)
|
|
127
|
+
if report.show_account:
|
|
128
|
+
cells.append("Check")
|
|
129
|
+
cells.extend(row.check for row in report.account_rows)
|
|
130
|
+
if report.show_target:
|
|
131
|
+
cells.append("Target")
|
|
132
|
+
cells.extend(row.target for row in report.target_rows)
|
|
133
|
+
return tuple(cells)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _columns(table: Table, headings: tuple[str, ...], first_width: int) -> None:
|
|
137
|
+
"""Pin the first column and let the remaining columns share the rest."""
|
|
138
|
+
table.add_column(
|
|
139
|
+
_heading(headings[0]),
|
|
140
|
+
width=first_width,
|
|
141
|
+
no_wrap=True,
|
|
142
|
+
overflow="ellipsis",
|
|
143
|
+
)
|
|
144
|
+
for heading in headings[1:]:
|
|
145
|
+
table.add_column(_heading(heading), ratio=1)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _endpoint_width(rows: tuple[ProbeRow, ...]) -> int:
|
|
149
|
+
if not rows:
|
|
150
|
+
return 1
|
|
151
|
+
return max(cell_len(row.endpoint) for row in rows)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _permission_columns(
|
|
155
|
+
table: Table,
|
|
156
|
+
headings: tuple[str, ...],
|
|
157
|
+
first_width: int,
|
|
158
|
+
endpoint_width: int,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Like ``_columns``, but keep probe endpoints on one line when possible."""
|
|
161
|
+
table.add_column(
|
|
162
|
+
_heading(headings[0]),
|
|
163
|
+
width=first_width,
|
|
164
|
+
no_wrap=True,
|
|
165
|
+
overflow="ellipsis",
|
|
166
|
+
)
|
|
167
|
+
for heading in headings[1:]:
|
|
168
|
+
if heading == "Endpoint":
|
|
169
|
+
table.add_column(
|
|
170
|
+
_heading(heading),
|
|
171
|
+
min_width=endpoint_width,
|
|
172
|
+
no_wrap=True,
|
|
173
|
+
ratio=1,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
table.add_column(_heading(heading), ratio=1)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _table(console: Console, title: str) -> Table:
|
|
180
|
+
"""Build a table that fills the console width."""
|
|
181
|
+
return Table(
|
|
182
|
+
title=_paint(title, "cyan", bold=True),
|
|
183
|
+
width=console.width,
|
|
184
|
+
expand=True,
|
|
185
|
+
header_style="none",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _add_permission(table: Table, row: ProbeRow, *, include_target: bool) -> None:
|
|
190
|
+
cells: list[Text] = [
|
|
191
|
+
_plain(row.check),
|
|
192
|
+
_plain(row.endpoint),
|
|
193
|
+
_result(row.result),
|
|
194
|
+
_plain(row.meaning),
|
|
195
|
+
]
|
|
196
|
+
if include_target:
|
|
197
|
+
cells.insert(0, _plain(row.target))
|
|
198
|
+
table.add_row(*cells)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _heading(text: str) -> Text:
|
|
202
|
+
return _paint(text, "light_blue", bold=True)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _plain(text: str) -> Text:
|
|
206
|
+
return Text(text)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _result(result: str) -> Text:
|
|
210
|
+
color = _RESULT_COLORS.get(result)
|
|
211
|
+
if color is None and result.startswith("error"):
|
|
212
|
+
color = "light_red"
|
|
213
|
+
if color is None:
|
|
214
|
+
return _plain(result)
|
|
215
|
+
return _paint(result, color, bold=True)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _paint(text: str, color: str, *, bold: bool = False) -> Text:
|
|
219
|
+
"""Color ``text`` with the colored library and hand Rich a styled span."""
|
|
220
|
+
formatting = f"{style('bold')}{fore(color)}" if bold else str(fore(color))
|
|
221
|
+
return Text.from_ansi(str(stylize(text, formatting)))
|