diff --git a/.github/security-audit-requirements.txt b/.github/security-audit-requirements.txt index d542924487..2f4a8c1fe8 100644 --- a/.github/security-audit-requirements.txt +++ b/.github/security-audit-requirements.txt @@ -374,9 +374,9 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -opentelemetry-api==1.45.0 \ - --hash=sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7 \ - --hash=sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3 +opentelemetry-api==1.45.1 \ + --hash=sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75 \ + --hash=sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb # via mcp packaging==26.3 \ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 9e0ff21449..f7f2e02749 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -59,6 +59,7 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba | Scheme | Header | Use for | |---|---|---| | `bearer` | `Authorization: Bearer ` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens | +| `github-cli` | `Authorization: Bearer ` | Credentials acquired on demand via `gh auth token` | **Example — PAT via environment variable:** @@ -71,6 +72,51 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba } ``` +**Example — GitHub CLI:** + +```json +{ + "providers": [ + { + "hosts": [ + "github.com", + "api.github.com", + "raw.githubusercontent.com", + "codeload.github.com" + ], + "provider": "github", + "auth": "github-cli" + } + ] +} +``` + +Install [GitHub CLI](https://cli.github.com/) and run `gh auth login` before +using this mode. Specify runs the fixed command +`gh auth token --hostname github.com` without a shell, caches the result in +memory for the current process, and never writes the token to disk. +Surrounding whitespace is trimmed; tokens containing non-ASCII characters, +embedded carriage returns, or newlines are rejected without including the +token in the error. +Credential acquisition failures fail the affected request without trying another +credential or an unauthenticated request. They use the existing URL-error +category, so callers retain their normal error handling (for example, warning +and skipping an unavailable catalog). +Release checks and upgrades preserve the authentication diagnostic rather than +reporting a network outage. Online bundle catalog fetching and reference +validation treat authentication failures as errors, not as availability +fallbacks to packaged snapshots or unchecked references. + +For `github-cli` only, the **first hostname in `hosts`** is passed to +`gh auth token --hostname`. Put `github.com` or your enterprise instance's +GitHub CLI login hostname first. It must be an exact hostname, not a wildcard. +All listed hosts remain request-matching destinations authorized to receive +that credential; later entries may use supported wildcards. Specify does not +infer the credential hostname from service prefixes or the requested URL. +The in-memory cache is keyed by the first hostname. Use separate provider +entries for hosts requiring different credentials. Host order has no effect +on existing authentication modes. + ### GitHub Enterprise Server (GHES) To use a private catalog or extension hosted on a GitHub Enterprise Server @@ -98,6 +144,28 @@ and `codeload.` subdomains your catalog/extension URLs use. A `*.ghes.example.com` wildcard matches subdomains but **not** the bare host, so always include the bare host explicitly. +With `auth: "github-cli"`, list your enterprise instance's GitHub CLI login +hostname first, followed by any request hosts or wildcard patterns that should +receive the same credential: + +```json +{ + "hosts": [ + "ghes.example.com", + "raw.ghes.example.com", + "codeload.ghes.example.com" + ], + "provider": "github", + "auth": "github-cli" +} +``` + +Specify runs `gh auth token --hostname ghes.example.com`, using the first +hostname exactly as configured. Use separate provider entries for separate +GitHub instances. The selected host must already be +authenticated in GitHub CLI; failures suggest `gh auth login --hostname ` +without including GitHub CLI command output. + ### GitHub Enterprise Cloud with data residency (GHE.com) GHE.com tenants use separate web and REST API hostnames. List both hosts so @@ -264,10 +332,13 @@ You can configure multiple entries for different hosts or organizations: 1. For each outbound HTTP request, the URL hostname is matched against the `hosts` patterns in `auth.json`. 2. If a match is found, the corresponding provider resolves the token - and attaches the appropriate `Authorization` header. -3. If the request receives a 401 or 403, the next matching entry is tried. -4. After all matching entries are exhausted, an unauthenticated request - is attempted as a final fallback. + and attaches the appropriate `Authorization` header. A `github-cli` + acquisition failure ends the request immediately, without trying later + matching entries or making an unauthenticated request. +3. After successful token acquisition, if the request receives a 401 or 403, + the next matching entry is tried. +4. After all matching entries are exhausted without an acquisition failure, + an unauthenticated request is attempted as a final fallback. 5. On redirects, the `Authorization` header is stripped if the redirect target leaves the entry's declared hosts — preventing credential leakage to CDNs or third-party services. diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 93b4c6a618..c8fcad7c7e 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -37,6 +37,7 @@ "rate limited (configure ~/.specify/auth.json with a GitHub token)" ) _RESOLUTION_FAILURE_HTTP_PREFIX = "HTTP " +_RESOLUTION_FAILURE_AUTH_PREFIX = "authentication error: " _FAILURE_INSTALLER_MISSING = "installer-missing" _FAILURE_INSTALLER_INVALID = "installer-invalid" _FAILURE_TARGET_TAG_UNPARSEABLE = "target-tag-unparseable" @@ -108,11 +109,12 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: """Return (tag, failure_category). Exactly one outbound call, 5 s timeout. On success: (tag_name, None). - On a documented network/HTTP failure (added in T029/T030): (None, category). + On a documented authentication/network/HTTP failure: (None, category). On anything else — including a malformed response body — the exception propagates; there is no catch-all (research D-006). """ from .authentication.http import open_url + from .authentication.github import GitHubCliAuthError try: with open_url( @@ -131,6 +133,8 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: if not isinstance(tag, str) or not tag: raise ValueError("GitHub API response missing valid tag_name") return tag, None + except GitHubCliAuthError as exc: + return None, f"{_RESOLUTION_FAILURE_AUTH_PREFIX}{exc.reason}" except urllib.error.HTTPError as e: # Order matters: HTTPError is a subclass of URLError. # 403 (primary rate limit / abuse detection) and 429 (Too Many Requests / @@ -1021,7 +1025,10 @@ def _emit_failure( """Render user-facing output for resolver, installer, or verification failures.""" if ( category in _RESOLUTION_FAILURE_CATEGORIES - or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX) + or category.startswith(( + _RESOLUTION_FAILURE_HTTP_PREFIX, + _RESOLUTION_FAILURE_AUTH_PREFIX, + )) ): console.print(f"Upgrade aborted: {category}", soft_wrap=True) return diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index 3797d82926..655063e33c 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -2,23 +2,144 @@ from __future__ import annotations +import os +import shutil +import subprocess +from typing import TYPE_CHECKING +from urllib.error import URLError + from .base import AuthProvider +if TYPE_CHECKING: + from .config import AuthConfigEntry -class GitHubAuth(AuthProvider): - """GitHub authentication provider. - Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs, - OAuth tokens, and GitHub App installation tokens. - """ +_GITHUB_CLI_TIMEOUT_SECONDS = 10 + + +def _is_safe_cli_hostname(hostname: str) -> bool: + """Return whether *hostname* is a shell-safe DNS hostname.""" + if not hostname or len(hostname) > 253 or not hostname.isascii(): + return False + labels = hostname.split(".") + return all( + 0 < len(label) <= 63 + and label[0].isalnum() + and label[-1].isalnum() + and all(char.isalnum() or char == "-" for char in label) + for label in labels + ) + + +class GitHubCliAuthError(URLError): + """Raised when GitHub CLI cannot provide a configured credential.""" + + +class GitHubAuth(AuthProvider): + """GitHub authentication provider.""" key = "github" - supported_auth_schemes = ("bearer",) + supported_auth_schemes = ("bearer", "github-cli") + + def __init__(self) -> None: + self._github_cli_cache: dict[str, tuple[bool, str]] = {} def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: - """Return ``Authorization: Bearer ``.""" - if auth_scheme != "bearer": + """Return a bearer authorization header.""" + if auth_scheme not in self.supported_auth_schemes: raise ValueError( f"GitHubAuth does not support auth scheme {auth_scheme!r}" ) - return {"Authorization": f"Bearer {token}"} + return {"Authorization": "Bearer " + token} + + def resolve_token(self, entry: AuthConfigEntry) -> str | None: + """Resolve a configured token or acquire one from GitHub CLI.""" + if entry.auth != "github-cli": + return super().resolve_token(entry) + + credential_host = self._credential_hostname(entry) + cached = self._github_cli_cache.get(credential_host) + if cached is not None: + succeeded, value = cached + if succeeded: + return value + raise GitHubCliAuthError(value) + + try: + token = self._acquire_via_gh_cli(credential_host) + except GitHubCliAuthError as exc: + message = str(exc.reason) + self._github_cli_cache[credential_host] = (False, message) + raise GitHubCliAuthError(message) from None + + self._github_cli_cache[credential_host] = (True, token) + return token + + @staticmethod + def _credential_hostname(entry: AuthConfigEntry) -> str: + """Use the first configured host for GitHub CLI credential acquisition.""" + hostname = entry.hosts[0] + if "*" in hostname: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + "the first hostname in a github-cli entry must be an exact " + "hostname, not a wildcard." + ) + return hostname + + @staticmethod + def _acquire_via_gh_cli(hostname: str) -> str: + """Run the fixed GitHub CLI token command for *hostname*.""" + if not _is_safe_cli_hostname(hostname): + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + "the configured hostname is not valid for GitHub CLI authentication." + ) + + resolved = shutil.which("gh") + if not resolved or not os.path.isabs(resolved): + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + "GitHub CLI ('gh') was not found." + ) + + try: + result = subprocess.run( # noqa: S603 + [resolved, "auth", "token", "--hostname", hostname], + capture_output=True, + text=True, + timeout=_GITHUB_CLI_TIMEOUT_SECONDS, + check=False, + shell=False, + ) + except subprocess.TimeoutExpired: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI timed out while requesting a token for {hostname!r}." + ) from None + except (OSError, UnicodeDecodeError): + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI could not provide a token for {hostname!r}." + ) from None + + if result.returncode != 0: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI could not provide a token for {hostname!r}. " + f"Run 'gh auth login --hostname {hostname}'." + ) + + token = result.stdout.strip() + if not token: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI returned an empty token for {hostname!r}. " + f"Run 'gh auth login --hostname {hostname}'." + ) + if not token.isascii() or "\r" in token or "\n" in token: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI returned a malformed token for {hostname!r}." + ) + return token diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py index 7dabc752aa..795b491e1e 100644 --- a/src/specify_cli/authentication/http.py +++ b/src/specify_cli/authentication/http.py @@ -133,6 +133,8 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll Uses the first matching entry from ``auth.json`` whose token resolves. Returns a plain request when no entry matches or the file doesn't exist. + A ``github-cli`` acquisition failure raises immediately, without trying + later matching entries or returning an unauthenticated request. """ headers: dict[str, str] = {} if extra_headers: @@ -174,9 +176,12 @@ def open_url( """Open *url* with config-driven auth, redirect stripping, and fallthrough. 1. Find ``auth.json`` entries whose hosts match the URL. - 2. For each entry, resolve the token and try the request. - 3. On 401/403 move to the next matching entry. - 4. After all entries exhausted (or none matched), try unauthenticated. + 2. For each entry, resolve the token and try the request. A ``github-cli`` + acquisition failure raises immediately, with no credential or + unauthenticated fallback. + 3. On HTTP 401/403 after successful acquisition, try the next matching entry. + 4. After all entries exhausted without an acquisition failure (or none + matched), try unauthenticated. 5. Non-auth errors (404, 500, network) raise immediately. *extra_headers* (e.g. ``Accept``) are merged into every attempt. diff --git a/src/specify_cli/bundles/adapters.py b/src/specify_cli/bundles/adapters.py index 46433cb005..fb4c6571c4 100644 --- a/src/specify_cli/bundles/adapters.py +++ b/src/specify_cli/bundles/adapters.py @@ -228,6 +228,7 @@ def _http_get_json(source_id: str, url: str) -> dict: HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end rather than only on the initial URL. """ + from ..authentication.github import GitHubCliAuthError from ..authentication.http import RedirectPolicyError, open_url def _validate_redirect(_old_url: str, new_url: str) -> None: @@ -258,9 +259,9 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: raise BundlerError( f"Failed to fetch catalog from {url}: {exc}" ) from exc - except RedirectPolicyError as exc: - # Unsafe/malformed redirects are security failures and must surface as - # a hard error instead of being downgraded to catalog unavailability. + except (GitHubCliAuthError, RedirectPolicyError) as exc: + # Authentication and redirect-policy failures are definitive, not + # transient catalog unavailability. raise BundlerError( f"Failed to fetch catalog from {url}: {exc}" ) from exc diff --git a/src/specify_cli/bundles/component_catalog.py b/src/specify_cli/bundles/component_catalog.py index d4b7a107c7..aef547e6ac 100644 --- a/src/specify_cli/bundles/component_catalog.py +++ b/src/specify_cli/bundles/component_catalog.py @@ -6,6 +6,7 @@ from http.client import HTTPException from urllib.error import HTTPError, URLError +from ..authentication.github import GitHubCliAuthError from ..authentication.http import RedirectPolicyError from . import BundlerError from .manifest import ComponentRef @@ -19,7 +20,7 @@ def _is_unavailable(exc: BaseException) -> bool: seen: set[int] = set() while exc is not None and id(exc) not in seen: seen.add(id(exc)) - if isinstance(exc, RedirectPolicyError): + if isinstance(exc, (GitHubCliAuthError, RedirectPolicyError)): return False if isinstance(exc, HTTPError): return exc.code in (408, 429) or 500 <= exc.code <= 599 diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 45c0a4b833..31c94f2ad1 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -30,7 +30,7 @@ find_entries_for_url, load_auth_config, ) -from specify_cli.authentication.github import GitHubAuth +from specify_cli.authentication.github import GitHubAuth, GitHubCliAuthError # --------------------------------------------------------------------------- @@ -49,6 +49,22 @@ def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> Auth ) +def _github_cli_entry( + hosts: tuple[str, ...] = ( + "github.com", + "api.github.com", + "raw.githubusercontent.com", + "codeload.github.com", + ), +) -> AuthConfigEntry: + """Build a GitHub CLI config entry.""" + return AuthConfigEntry( + hosts=hosts, + provider="github", + auth="github-cli", + ) + + def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry: """Build an ADO basic-pat config entry.""" return AuthConfigEntry( @@ -94,6 +110,23 @@ def test_valid_github_config(self, tmp_path): assert entries[0].auth == "bearer" assert entries[0].token_env == "GH_TOKEN" + def test_valid_github_cli_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com", "api.github.com"], + "provider": "github", + "auth": "github-cli", + }] + })) + + entries = load_auth_config(cfg) + + assert len(entries) == 1 + assert entries[0].auth == "github-cli" + assert entries[0].token is None + assert entries[0].token_env is None + def test_padded_token_env_is_normalized_and_resolves(self, tmp_path, monkeypatch): # token_env is validated on its stripped form but was stored raw, so a # padded env-var name passed validation yet broke the verbatim @@ -589,6 +622,272 @@ class TestGitHubAuth: def test_bearer_headers(self): assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"} + def test_github_cli_headers(self): + assert GitHubAuth().auth_headers("my-token", "github-cli") == { + "Authorization": "Bearer my-token" + } + + def test_github_cli_resolves_token_with_fixed_command(self): + from unittest.mock import MagicMock, patch + + result = MagicMock(returncode=0, stdout=" cli-token\n") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + token = GitHubAuth().resolve_token(_github_cli_entry()) + + assert token == "cli-token" + run.assert_called_once_with( + [executable, "auth", "token", "--hostname", "github.com"], + capture_output=True, + text=True, + timeout=10, + check=False, + shell=False, + ) + + def test_github_cli_uses_bare_enterprise_host(self): + from unittest.mock import MagicMock, patch + + entry = _github_cli_entry(( + "ghes.example.com", + "raw.ghes.example.com", + "codeload.ghes.example.com", + )) + result = MagicMock(returncode=0, stdout="enterprise-token") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + assert GitHubAuth().resolve_token(entry) == "enterprise-token" + + assert run.call_args.args[0][-2:] == ["--hostname", "ghes.example.com"] + + @pytest.mark.parametrize("hosts", [ + ("api.company.example",), + ("raw.company.example", "api.raw.company.example"), + ("api.github.com", "github.com"), + ("ghes.example.com", "*.ghes.example.com"), + ("first.example.com", "second.example.com"), + ]) + def test_github_cli_uses_first_hostname(self, hosts): + from unittest.mock import patch + + with patch.object( + GitHubAuth, "_acquire_via_gh_cli", return_value="cli-token", + ) as acquire: + assert GitHubAuth().resolve_token(_github_cli_entry(hosts)) == "cli-token" + + acquire.assert_called_once_with(hosts[0]) + + def test_github_cli_rejects_wildcard_first_hostname(self): + from unittest.mock import patch + + entry = _github_cli_entry(("*.ghes.example.com", "ghes.example.com")) + with patch( + "specify_cli.authentication.github.shutil.which", + ) as which, patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, pytest.raises(GitHubCliAuthError, match="first hostname"): + GitHubAuth().resolve_token(entry) + + which.assert_not_called() + run.assert_not_called() + + def test_github_cli_caches_by_first_hostname(self): + from unittest.mock import patch + + provider = GitHubAuth() + with patch.object( + GitHubAuth, "_acquire_via_gh_cli", side_effect=["first-token", "second-token"], + ) as acquire: + assert provider.resolve_token(_github_cli_entry(( + "first.example.com", "api.first.example.com", + ))) == "first-token" + assert provider.resolve_token(_github_cli_entry(( + "first.example.com", "*.first.example.com", + ))) == "first-token" + assert provider.resolve_token(_github_cli_entry(( + "second.example.com", "first.example.com", + ))) == "second-token" + + assert [call.args for call in acquire.call_args_list] == [ + ("first.example.com",), ("second.example.com",), + ] + + def test_github_cli_caches_token_per_process(self): + from unittest.mock import MagicMock, patch + + provider = GitHubAuth() + result = MagicMock(returncode=0, stdout="cached-token") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + assert provider.resolve_token(_github_cli_entry()) == "cached-token" + assert provider.resolve_token(_github_cli_entry()) == "cached-token" + + run.assert_called_once() + + @pytest.mark.parametrize("executable", [None, r".\gh.exe", "gh.exe", "./gh"]) + def test_github_cli_rejects_missing_or_relative_executable(self, executable): + from unittest.mock import patch + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, pytest.raises(GitHubCliAuthError, match="was not found"): + GitHubAuth().resolve_token(_github_cli_entry()) + + run.assert_not_called() + + def test_github_cli_nonzero_suggests_login_without_command_output(self): + from unittest.mock import MagicMock, patch + + secret = "ghp_must_not_leak" + result = MagicMock(returncode=1, stdout=secret, stderr=secret) + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ), pytest.raises(GitHubCliAuthError) as exc_info: + GitHubAuth().resolve_token(_github_cli_entry()) + + message = str(exc_info.value) + assert "gh auth login --hostname github.com" in message + assert secret not in message + + def test_github_cli_empty_output_raises(self): + from unittest.mock import MagicMock, patch + + result = MagicMock(returncode=0, stdout=" \n", stderr="") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ), pytest.raises(GitHubCliAuthError, match="empty token"): + GitHubAuth().resolve_token(_github_cli_entry()) + + @pytest.mark.parametrize("invalid_text", ["\r", "\n", "\r\n", "\u00e9", "\u2603"]) + def test_github_cli_caches_sanitized_malformed_token_failure(self, invalid_text): + from unittest.mock import MagicMock, patch + + secret = "synthetic_credential" + result = MagicMock(returncode=0, stdout=f" {secret}{invalid_text}extra\n") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + provider = GitHubAuth() + messages = [] + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + for _ in range(2): + with pytest.raises(GitHubCliAuthError, match="malformed token") as exc_info: + provider.resolve_token(_github_cli_entry()) + messages.append(str(exc_info.value)) + assert secret not in messages[-1] + assert "extra" not in messages[-1] + + assert messages[0] == messages[1] + assert provider._github_cli_cache == { + "github.com": (False, exc_info.value.reason), + } + run.assert_called_once() + + def test_github_cli_timeout_raises(self): + import subprocess + from unittest.mock import patch + + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + side_effect=subprocess.TimeoutExpired(["gh"], 10), + ), pytest.raises(GitHubCliAuthError, match="timed out"): + GitHubAuth().resolve_token(_github_cli_entry()) + + @pytest.mark.parametrize("error", [ + None, + PermissionError("secret"), + UnicodeDecodeError("utf-8", b"secret\xff", 6, 7, "secret"), + ], ids=["nonzero-exit", "permission-error", "decode-error"]) + def test_github_cli_caches_sanitized_failure(self, error): + from unittest.mock import MagicMock, patch + + provider = GitHubAuth() + result = MagicMock(returncode=1, stdout="secret", stderr="secret") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + messages = [] + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + side_effect=error, + ) as run: + for _ in range(2): + with pytest.raises(GitHubCliAuthError) as exc_info: + provider.resolve_token(_github_cli_entry()) + assert "secret" not in str(exc_info.value) + messages.append(str(exc_info.value)) + + assert messages[0] == messages[1] + assert "provider 'github'" in messages[0] + assert "could not provide a token" in messages[0] + run.assert_called_once() + + def test_github_cli_rejects_shell_unsafe_host_without_echoing_it(self): + from unittest.mock import patch + + unsafe_host = "a&whoami&b.ghes.example.com" + entry = _github_cli_entry((unsafe_host,)) + + with patch( + "specify_cli.authentication.github.shutil.which", + ) as which, patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, pytest.raises(GitHubCliAuthError) as exc_info: + GitHubAuth().resolve_token(entry) + + assert unsafe_host not in str(exc_info.value) + which.assert_not_called() + run.assert_not_called() + def test_unsupported_scheme_raises(self): with pytest.raises(ValueError, match="basic-pat"): GitHubAuth().auth_headers("tok", "basic-pat") @@ -616,7 +915,7 @@ def test_key(self): assert GitHubAuth.key == "github" def test_supported_schemes(self): - assert GitHubAuth.supported_auth_schemes == ("bearer",) + assert GitHubAuth.supported_auth_schemes == ("bearer", "github-cli") # --------------------------------------------------------------------------- @@ -1234,6 +1533,261 @@ def test_timeout_propagates(self, monkeypatch): open_url("https://example.com/file") +# --------------------------------------------------------------------------- +# GitHub CLI — registered provider HTTP wiring +# --------------------------------------------------------------------------- + + +class TestGitHubCliAuthenticatedHttp: + @pytest.fixture(autouse=True) + def isolate_config_and_cache(self, monkeypatch): + from specify_cli.authentication import http + + provider = get_provider("github") + assert isinstance(provider, GitHubAuth) + monkeypatch.setattr(provider, "_github_cli_cache", {}) + monkeypatch.setattr(http, "_config_override", [_github_cli_entry()]) + + @pytest.mark.parametrize("helper", ["build_request", "open_url"]) + def test_resolved_token_becomes_bearer_header(self, helper): + from unittest.mock import MagicMock, patch + from specify_cli.authentication import http + + result = MagicMock(returncode=0, stdout=" cli-token\n") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run, patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + if helper == "build_request": + request = http.build_request("https://api.github.com/repos/org/repo") + build_opener.assert_not_called() + else: + response = http.open_url("https://api.github.com/repos/org/repo") + opener = build_opener.return_value + assert response is opener.open.return_value + opener.open.assert_called_once() + request = opener.open.call_args.args[0] + + assert request.get_header("Authorization") == "Bearer cli-token" + run.assert_called_once_with( + [executable, "auth", "token", "--hostname", "github.com"], + capture_output=True, + text=True, + timeout=10, + check=False, + shell=False, + ) + + @pytest.mark.parametrize("helper", ["build_request", "open_url"]) + def test_acquisition_failure_stops_credential_and_anonymous_fallback( + self, monkeypatch, helper, + ): + from unittest.mock import patch + from specify_cli.authentication import http + + entry = _github_cli_entry() + monkeypatch.setattr(http, "_config_override", [ + entry, _github_entry(token="backup-token"), + ]) + provider = get_provider("github") + assert isinstance(provider, GitHubAuth) + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=None, + ) as which, patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, patch.object( + provider, "resolve_token", wraps=provider.resolve_token, + ) as resolve, patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener, pytest.raises(GitHubCliAuthError, match="was not found"): + getattr(http, helper)("https://api.github.com/repos/org/repo") + + resolve.assert_called_once_with(entry) + which.assert_called_once_with("gh") + run.assert_not_called() + build_opener.assert_not_called() + + @pytest.mark.parametrize("target,expected_auth", [ + ("https://codeload.github.com/org/repo/zip", "Bearer cli-token"), + ("https://unlisted.example.com/archive.zip", None), + ]) + def test_open_url_wires_declared_redirect_hosts(self, target, expected_auth): + from unittest.mock import MagicMock, patch + from specify_cli.authentication import http + + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=MagicMock(returncode=0, stdout="cli-token"), + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + http.open_url("https://github.com/org/repo/archive.zip") + + handler = build_opener.call_args.args[0] + request = build_opener.return_value.open.call_args.args[0] + redirected = handler.redirect_request( + request, io.BytesIO(b""), 302, "Found", {}, target, + ) + assert redirected is not None + assert redirected.get_header("Authorization") == expected_auth + + def test_failed_cli_catalog_does_not_abort_other_catalogs( + self, tmp_path, monkeypatch, capsys, + ): + from unittest.mock import MagicMock, patch + from specify_cli.integrations import IntegrationCatalog, IntegrationCatalogEntry + + catalog = IntegrationCatalog(tmp_path) + entries = [ + IntegrationCatalogEntry( + url="https://github.com/org/private/catalog.json", + name="private", priority=1, install_allowed=True, + ), + IntegrationCatalogEntry( + url="https://catalog.example.com/catalog.json", + name="public", priority=2, install_allowed=True, + ), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: entries) + response = MagicMock() + response.__enter__.return_value = response + response.geturl.return_value = entries[1].url + response.read.side_effect = io.BytesIO(json.dumps({ + "schema_version": "1.0", + "integrations": {"example": {"name": "Example"}}, + }).encode()).read + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=None, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + build_opener.return_value.open.return_value = response + results = catalog.search() + + assert [item["id"] for item in results] == ["example"] + warning = capsys.readouterr().err + assert "Could not fetch catalog 'private'" in warning + assert "GitHub CLI ('gh') was not found" in warning + build_opener.return_value.open.assert_called_once() + + def test_release_check_preserves_cli_failure_reason(self): + from unittest.mock import patch + from specify_cli._version import _fetch_latest_release_tag + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=None, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + tag, reason = _fetch_latest_release_tag() + + assert tag is None + assert reason is not None + assert "GitHub CLI ('gh') was not found" in reason + assert "offline or timeout" not in reason + + build_opener.assert_not_called() + + @pytest.mark.parametrize("detail", [ + "GitHub CLI ('gh') was not found.", + "Run 'gh auth login --hostname github.com'.", + ]) + @pytest.mark.parametrize("command,exit_code", [("check", 0), ("upgrade", 1)]) + def test_release_commands_preserve_cli_failure(self, detail, command, exit_code): + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app, _version + + error = GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + detail + ) + with patch.object( + GitHubAuth, "_acquire_via_gh_cli", side_effect=error, + ), patch.object( + _version, "_detect_install_method", return_value=_version._InstallMethod.UV_TOOL, + ), patch.object( + _version, "_get_installed_version", return_value="1.0.0", + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener, patch.object(_version.subprocess, "run") as run: + result = CliRunner().invoke(app, ["self", command]) + + assert result.exit_code == exit_code, result.output + output = " ".join(result.output.split()) + assert detail in output + assert "offline or timeout" not in output + build_opener.assert_not_called() + run.assert_not_called() + + @pytest.mark.parametrize("url", [ + "builtin://default", + "https://github.com/org/repo/catalog.json", + ]) + def test_bundle_catalog_auth_failure_does_not_use_snapshot(self, url): + from unittest.mock import patch + from specify_cli.bundles import BundlerError, adapters + from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy + + source = CatalogSource( + id="test", url=url, priority=1, + install_policy=InstallPolicy.INSTALL_ALLOWED, + ) + with patch( + "specify_cli.authentication.github.shutil.which", return_value=None, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener, patch.object( + adapters, "_load_packaged_catalog", + ) as snapshot, pytest.raises(BundlerError, match="was not found") as exc_info: + adapters.make_catalog_fetcher(allow_network=True)(source) + + assert not isinstance(exc_info.value, adapters._CatalogUnavailable) + snapshot.assert_not_called() + build_opener.assert_not_called() + + def test_bundle_reference_auth_failure_is_not_left_unchecked(self, tmp_path): + from types import SimpleNamespace + from unittest.mock import patch + from specify_cli.bundles.manifest import ComponentRef + from specify_cli.bundles.references import make_reference_checker + from specify_cli.extensions import ExtensionCatalog + + source = SimpleNamespace( + name="private", install_allowed=True, + url="https://github.com/org/repo/catalog.json", + ) + warnings = [] + with patch.object( + ExtensionCatalog, "get_active_catalogs", return_value=[source], + ), patch( + "specify_cli.authentication.github.shutil.which", return_value=None, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + problem = check(ComponentRef( + kind="extensions", id="private-extension", version="1.0.0", source="private", + )) + + assert problem is not None + assert "GitHub CLI ('gh') was not found" in problem + assert warnings == [] + build_opener.assert_not_called() + + # --------------------------------------------------------------------------- # _load_config caching # ---------------------------------------------------------------------------