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.
@@ -0,0 +1,7 @@
1
+ """Report what a GitHub token is allowed to do."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lupaxa.github_token_validator.cli import main
6
+
7
+ __all__ = ["main"]
@@ -0,0 +1,8 @@
1
+ """python -m lupaxa.github_token_validator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lupaxa.github_token_validator.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -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