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,52 @@
|
|
|
1
|
+
"""Classify a GitHub token from its prefix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
PermissionSource = Literal["oauth", "accepted", "fine_grained"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class TokenKind:
|
|
13
|
+
"""Prefix classification decided before any API call."""
|
|
14
|
+
|
|
15
|
+
label: str
|
|
16
|
+
calls_api: bool
|
|
17
|
+
requires_target: bool
|
|
18
|
+
checks_account: bool
|
|
19
|
+
permission_source: PermissionSource
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_UNKNOWN = TokenKind("Unknown", True, True, True, "oauth")
|
|
23
|
+
|
|
24
|
+
_PREFIXES: tuple[tuple[str, TokenKind], ...] = (
|
|
25
|
+
(
|
|
26
|
+
"github_pat_",
|
|
27
|
+
TokenKind(
|
|
28
|
+
"Fine-grained personal access token", True, True, True, "fine_grained"
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
("ghp_", TokenKind("Classic personal access token", True, False, True, "oauth")),
|
|
32
|
+
("gho_", TokenKind("OAuth access token", True, False, True, "oauth")),
|
|
33
|
+
(
|
|
34
|
+
"ghu_",
|
|
35
|
+
TokenKind("GitHub App user-to-server token", True, False, True, "oauth"),
|
|
36
|
+
),
|
|
37
|
+
(
|
|
38
|
+
"ghs_",
|
|
39
|
+
TokenKind(
|
|
40
|
+
"GitHub App installation or Actions token", True, True, False, "accepted"
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
("ghr_", TokenKind("Refresh token", False, False, False, "oauth")),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify_token(token: str) -> TokenKind:
|
|
48
|
+
"""Return the kind for ``token`` from the first matching prefix."""
|
|
49
|
+
for prefix, kind in _PREFIXES:
|
|
50
|
+
if token.startswith(prefix):
|
|
51
|
+
return kind
|
|
52
|
+
return _UNKNOWN
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from lupaxa.github_token_validator.classify import classify_token
|
|
12
|
+
from lupaxa.github_token_validator.client import GitHubClient, RequestsGitHubClient
|
|
13
|
+
from lupaxa.github_token_validator.constants import (
|
|
14
|
+
DEFAULT_TIMEOUT,
|
|
15
|
+
EXIT_INTERRUPTED,
|
|
16
|
+
EXIT_USAGE,
|
|
17
|
+
PROGRAM_NAME,
|
|
18
|
+
PROGRAM_VERSION_STRING,
|
|
19
|
+
)
|
|
20
|
+
from lupaxa.github_token_validator.probes import run_validation
|
|
21
|
+
from lupaxa.github_token_validator.report import render_report
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_client_factory(token: str, timeout: int) -> GitHubClient:
|
|
25
|
+
"""Build the requests client."""
|
|
26
|
+
return RequestsGitHubClient(token, timeout)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(
|
|
30
|
+
argv: Sequence[str] | None = None,
|
|
31
|
+
*,
|
|
32
|
+
client_factory: Callable[[str, int], GitHubClient] = default_client_factory,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Parse arguments, run probes, and exit."""
|
|
35
|
+
try:
|
|
36
|
+
code = execute(argv, client_factory, os.environ)
|
|
37
|
+
except KeyboardInterrupt:
|
|
38
|
+
print("Interrupted.", file=sys.stderr)
|
|
39
|
+
code = EXIT_INTERRUPTED
|
|
40
|
+
raise SystemExit(code)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def execute(
|
|
44
|
+
argv: Sequence[str] | None,
|
|
45
|
+
client_factory: Callable[[str, int], GitHubClient],
|
|
46
|
+
environ: Mapping[str, str],
|
|
47
|
+
) -> int:
|
|
48
|
+
"""Return the process exit code."""
|
|
49
|
+
args = _parser().parse_args(None if argv is None else list(argv))
|
|
50
|
+
token = _token(args.token, environ)
|
|
51
|
+
if token is None:
|
|
52
|
+
print(
|
|
53
|
+
"A token is required. Pass --token or set GITHUB_TOKEN.",
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
return EXIT_USAGE
|
|
57
|
+
org = None if args.org is None else args.org.strip()
|
|
58
|
+
if args.org is not None and org == "":
|
|
59
|
+
print("--org must not be blank.", file=sys.stderr)
|
|
60
|
+
return EXIT_USAGE
|
|
61
|
+
if org is not None and not _single_name(org):
|
|
62
|
+
print("--org must be a single name.", file=sys.stderr)
|
|
63
|
+
return EXIT_USAGE
|
|
64
|
+
kind = classify_token(token)
|
|
65
|
+
if kind.requires_target and org is None and args.repo is None:
|
|
66
|
+
print(f"{kind.label} requires --org or --repo.", file=sys.stderr)
|
|
67
|
+
return EXIT_USAGE
|
|
68
|
+
client = client_factory(token, args.timeout) if kind.calls_api else None
|
|
69
|
+
try:
|
|
70
|
+
report = run_validation(kind=kind, org=org, repo=args.repo, client=client)
|
|
71
|
+
render_report(report)
|
|
72
|
+
return report.exit_code
|
|
73
|
+
finally:
|
|
74
|
+
_close(client)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Parser(argparse.ArgumentParser):
|
|
78
|
+
"""Argument errors never echo the values the user passed."""
|
|
79
|
+
|
|
80
|
+
def error(self, message: str) -> NoReturn:
|
|
81
|
+
"""Print a usage error and exit 2."""
|
|
82
|
+
if message.startswith("unrecognized arguments:"):
|
|
83
|
+
message = "unrecognized arguments."
|
|
84
|
+
self.print_usage(sys.stderr)
|
|
85
|
+
print(f"{self.prog}: error: {message}", file=sys.stderr)
|
|
86
|
+
self.exit(2)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parser() -> argparse.ArgumentParser:
|
|
90
|
+
parser = _Parser(
|
|
91
|
+
prog=PROGRAM_NAME,
|
|
92
|
+
description="Report what a GitHub token is allowed to do.",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"-V", "--version", action="version", version=PROGRAM_VERSION_STRING
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument("-t", "--token", default=None)
|
|
98
|
+
parser.add_argument("-T", "--timeout", type=_positive_int, default=DEFAULT_TIMEOUT)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--org",
|
|
101
|
+
default=None,
|
|
102
|
+
help=(
|
|
103
|
+
"Organisation to check. Required for fine-grained, installation, "
|
|
104
|
+
"and unknown tokens unless --repo is set"
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--repo",
|
|
109
|
+
type=_repo,
|
|
110
|
+
default=None,
|
|
111
|
+
help=(
|
|
112
|
+
"Repository to check as OWNER/NAME. Required for fine-grained, "
|
|
113
|
+
"installation, and unknown tokens unless --org is set"
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
return parser
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _positive_int(value: str) -> int:
|
|
120
|
+
if not value.isdigit() or int(value) <= 0:
|
|
121
|
+
raise argparse.ArgumentTypeError("--timeout must be an integer greater than 0.")
|
|
122
|
+
return int(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _single_name(value: str) -> bool:
|
|
126
|
+
return "/" not in value and not any(character.isspace() for character in value)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _repo(value: str) -> tuple[str, str]:
|
|
130
|
+
owner, separator, name = value.partition("/")
|
|
131
|
+
invalid = separator != "/" or "/" in name or owner == "" or name == ""
|
|
132
|
+
if invalid or any(character.isspace() for character in owner + name):
|
|
133
|
+
raise argparse.ArgumentTypeError("--repo must be OWNER/NAME.")
|
|
134
|
+
return owner, name
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _close(client: GitHubClient | None) -> None:
|
|
138
|
+
close = getattr(client, "close", None)
|
|
139
|
+
if callable(close):
|
|
140
|
+
close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _token(flag: str | None, environ: Mapping[str, str]) -> str | None:
|
|
144
|
+
raw = flag if flag is not None else environ.get("GITHUB_TOKEN")
|
|
145
|
+
if raw is None:
|
|
146
|
+
return None
|
|
147
|
+
token = raw.strip()
|
|
148
|
+
if token == "":
|
|
149
|
+
return None
|
|
150
|
+
return token
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Read-only GitHub GET client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from requests import Session
|
|
11
|
+
|
|
12
|
+
from lupaxa.github_token_validator.constants import (
|
|
13
|
+
API_BASE_URL,
|
|
14
|
+
API_VERSION,
|
|
15
|
+
USER_AGENT,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitHubUnauthorizedError(Exception):
|
|
20
|
+
"""GitHub returned HTTP 401."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitHubRateLimitError(Exception):
|
|
24
|
+
"""GitHub returned HTTP 429."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitHubTransportError(Exception):
|
|
28
|
+
"""The request timed out or the connection failed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class GitHubResponse:
|
|
33
|
+
"""Status, headers, and the optional login from GET /user."""
|
|
34
|
+
|
|
35
|
+
status_code: int
|
|
36
|
+
headers: dict[str, str]
|
|
37
|
+
login: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitHubClient(Protocol):
|
|
41
|
+
"""Injectable GET client used by the probe runner."""
|
|
42
|
+
|
|
43
|
+
def get(self, path: str, *, read_login: bool = False) -> GitHubResponse:
|
|
44
|
+
"""Perform one GET and return status data."""
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RequestsGitHubClient:
|
|
49
|
+
"""requests-backed client. The token is sent only as a Bearer header."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self, token: str, timeout: int, session: Session | None = None
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Store the token, timeout, and session."""
|
|
55
|
+
self._token = token
|
|
56
|
+
self._timeout = timeout
|
|
57
|
+
self._owns_session = session is None
|
|
58
|
+
self._session = requests.Session() if session is None else session
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
"""Close the session this client created."""
|
|
62
|
+
if self._owns_session:
|
|
63
|
+
self._session.close()
|
|
64
|
+
|
|
65
|
+
def get(self, path: str, *, read_login: bool = False) -> GitHubResponse:
|
|
66
|
+
"""GET ``path`` and discard the body unless ``read_login`` is set."""
|
|
67
|
+
try:
|
|
68
|
+
response = self._session.get(
|
|
69
|
+
f"{API_BASE_URL}{path}",
|
|
70
|
+
headers={
|
|
71
|
+
"Authorization": f"Bearer {self._token}",
|
|
72
|
+
"Accept": "application/vnd.github+json",
|
|
73
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
74
|
+
"User-Agent": USER_AGENT,
|
|
75
|
+
},
|
|
76
|
+
timeout=self._timeout,
|
|
77
|
+
)
|
|
78
|
+
except requests.Timeout:
|
|
79
|
+
raise GitHubTransportError("GitHub request timed out.") from None
|
|
80
|
+
except requests.ConnectionError:
|
|
81
|
+
raise GitHubTransportError(
|
|
82
|
+
"GitHub request failed: connection error."
|
|
83
|
+
) from None
|
|
84
|
+
except requests.RequestException:
|
|
85
|
+
raise GitHubTransportError("GitHub request failed.") from None
|
|
86
|
+
if response.status_code == 401:
|
|
87
|
+
raise GitHubUnauthorizedError("GitHub rejected the token (HTTP 401).")
|
|
88
|
+
if _rate_limited(response.status_code, response.headers):
|
|
89
|
+
raise GitHubRateLimitError(
|
|
90
|
+
f"GitHub rate limit exceeded (HTTP {response.status_code})."
|
|
91
|
+
)
|
|
92
|
+
return GitHubResponse(
|
|
93
|
+
response.status_code,
|
|
94
|
+
{str(key): str(value) for key, value in response.headers.items()},
|
|
95
|
+
_login(response) if read_login else None,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _rate_limited(status_code: int, headers: Mapping[str, str]) -> bool:
|
|
100
|
+
"""Return whether this response is a primary or secondary rate limit."""
|
|
101
|
+
if status_code == 429:
|
|
102
|
+
return True
|
|
103
|
+
if status_code != 403:
|
|
104
|
+
return False
|
|
105
|
+
remaining = _header(headers, "X-RateLimit-Remaining")
|
|
106
|
+
if remaining == "0":
|
|
107
|
+
return True
|
|
108
|
+
return _header(headers, "Retry-After") is not None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
112
|
+
"""Return one header, ignoring case and blank text."""
|
|
113
|
+
wanted = name.lower()
|
|
114
|
+
for key, value in headers.items():
|
|
115
|
+
text = str(value).strip()
|
|
116
|
+
if key.lower() == wanted and text != "":
|
|
117
|
+
return text
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _login(response: requests.Response) -> str | None:
|
|
122
|
+
try:
|
|
123
|
+
payload: object = response.json()
|
|
124
|
+
except ValueError:
|
|
125
|
+
return None
|
|
126
|
+
if not isinstance(payload, dict):
|
|
127
|
+
return None
|
|
128
|
+
value = payload.get("login")
|
|
129
|
+
if isinstance(value, str) and value.strip() != "":
|
|
130
|
+
return value
|
|
131
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Stable names, version, API constants, and exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"API_BASE_URL",
|
|
9
|
+
"API_VERSION",
|
|
10
|
+
"DEFAULT_TIMEOUT",
|
|
11
|
+
"EXIT_FAILURE",
|
|
12
|
+
"EXIT_INTERRUPTED",
|
|
13
|
+
"EXIT_SUCCESS",
|
|
14
|
+
"EXIT_USAGE",
|
|
15
|
+
"PROGRAM_ALIAS",
|
|
16
|
+
"PROGRAM_NAME",
|
|
17
|
+
"PROGRAM_VERSION",
|
|
18
|
+
"PROGRAM_VERSION_STRING",
|
|
19
|
+
"PROJECT_NAME",
|
|
20
|
+
"USER_AGENT",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
PROGRAM_NAME: Final[str] = "github-token-validator"
|
|
24
|
+
PROGRAM_ALIAS: Final[str] = "gtv"
|
|
25
|
+
PROJECT_NAME: Final[str] = "lupaxa-github-token-validator"
|
|
26
|
+
PROGRAM_VERSION: Final[str] = "0.1.0"
|
|
27
|
+
PROGRAM_VERSION_STRING: Final[str] = f"{PROGRAM_NAME} v{PROGRAM_VERSION}"
|
|
28
|
+
|
|
29
|
+
API_BASE_URL: Final[str] = "https://api.github.com"
|
|
30
|
+
API_VERSION: Final[str] = "2022-11-28"
|
|
31
|
+
USER_AGENT: Final[str] = "lupaxa-github-token-validator"
|
|
32
|
+
DEFAULT_TIMEOUT: Final[int] = 10
|
|
33
|
+
|
|
34
|
+
EXIT_SUCCESS: Final[int] = 0
|
|
35
|
+
EXIT_FAILURE: Final[int] = 1
|
|
36
|
+
EXIT_USAGE: Final[int] = 2
|
|
37
|
+
EXIT_INTERRUPTED: Final[int] = 130
|