From 6290344cae2ccaff8f5bd4e2358ac0cda8450a4f Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Thu, 27 Aug 2026 20:46:54 -0700 Subject: [PATCH 1/6] BED-9508: support GHES REST and GraphQL endpoints --- README.md | 13 + src/openhound_github/resources/enterprise.py | 40 +- .../resources/organization.py | 56 ++- src/openhound_github/source.py | 155 +++++-- tests/test_source_endpoints.py | 389 ++++++++++++++++++ 5 files changed, 594 insertions(+), 59 deletions(-) create mode 100644 tests/test_source_endpoints.py diff --git a/README.md b/README.md index afb265c..3eb50b5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,19 @@ Enterprise GitHub App credentials accept either `client_id` or `app_id` as the JWT issuer. When both are configured, `client_id` is preferred. At least one identifier must be supplied together with `key_path` and `enterprise_name`. +### GitHub Enterprise Server endpoints + +GitHub.com is the default deployment and does not require endpoint configuration. +To collect from GitHub Enterprise Server, set both API endpoints in `config.toml`: + +```toml +[sources.github] +rest_api_url = "https://ghe.example/api/v3" +graphql_url = "https://ghe.example/api/graphql" +``` + +Both values must be provided together when overriding the GitHub.com defaults. + ### Enterprise SCIM and hybrid correlations A token with enterprise SCIM access is used to collect both `/scim/v2/enterprises/{enterprise}/Users` and `/scim/v2/enterprises/{enterprise}/Groups`. The collector emits normalized `SCIM_Organization`, `SCIM_User`, and `SCIM_Group` nodes plus `SCIM_Contains`, `SCIM_MemberOf`, and `SCIM_Provisioned` relationships. Install the BloodHound SCIM extension alongside this extension to register the shared SCIM kinds. diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 389ca47..c38edda 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -53,7 +53,9 @@ class SourceContext: """Shared context for GitHub API access.""" client: RESTClient + graphql_client: RESTClient | None = None sso_client: RESTClient | None = None + sso_graphql_client: RESTClient | None = None org_name: str | None = None enterprise_name: str | None = None emit_legacy_scim_correlations: bool = False @@ -61,6 +63,18 @@ class SourceContext: github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN +def _graphql_client(ctx: SourceContext) -> tuple[RESTClient, str]: + if ctx.graphql_client: + return ctx.graphql_client, "" + return ctx.client, "/graphql" + + +def _sso_graphql_client(ctx: SourceContext) -> tuple[RESTClient | None, str]: + if ctx.sso_graphql_client: + return ctx.sso_graphql_client, "" + return ctx.sso_client, "/graphql" + + def iter_enterprise_scim_resources( client: RESTClient, enterprise_slug: str, @@ -111,8 +125,9 @@ def enterprise(ctx: SourceContext): "variables": {"slug": ctx.enterprise_name, "after": None}, } + client, graphql_path = _graphql_client(ctx) try: - response = ctx.client.post("/graphql", json=data).json() + response = client.post(graphql_path, json=data).json() page_enterprise = (response.get("data") or {}).get("enterprise") if page_enterprise: yield page_enterprise @@ -139,9 +154,10 @@ def enterprise_organizations(enterprise_data: Enterprise, ctx: SourceContext): "variables": {"slug": ctx.enterprise_name, "after": None}, } + client, graphql_path = _graphql_client(ctx) try: - for page_data in ctx.client.paginate( - "/graphql", + for page_data in client.paginate( + graphql_path, method="POST", json=data, paginator=paginator, @@ -263,8 +279,9 @@ def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext): "query": ENTERPRISE_MEMBERS_QUERY, "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, } - for page_data in ctx.client.paginate( - "/graphql", + client, graphql_path = _graphql_client(ctx) + for page_data in client.paginate( + graphql_path, method="POST", json=data, paginator=paginator, @@ -635,8 +652,9 @@ def enterprise_admins(enterprise_data: Enterprise, ctx: SourceContext): "query": ENTERPRISE_ADMINS_QUERY, "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, } - for page_data in ctx.client.paginate( - "/graphql", + client, graphql_path = _graphql_client(ctx) + for page_data in client.paginate( + graphql_path, method="POST", json=data, paginator=paginator, @@ -665,7 +683,7 @@ def enterprise_admins(enterprise_data: Enterprise, ctx: SourceContext): parallelized=True ) def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): - client = ctx.sso_client + client, graphql_path = _sso_graphql_client(ctx) if not client: logger.info( "Skipping enterprise_saml_provider for enterprise '%s': no SSO client configured", @@ -679,7 +697,7 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): } try: - response = client.post("/graphql", json=data).json() + response = client.post(graphql_path, json=data).json() except Exception as e: logger.error( f"Error in resource 'enterprise_saml_provider' processing enterprise '{ctx.enterprise_name}': {e}", @@ -779,7 +797,7 @@ def enterprise_saml_issuer(saml_provider: SamlProvider, ctx: SourceContext): def enterprise_external_identity( saml_provider: SamlProvider, ctx: SourceContext ): - client = ctx.sso_client + client, graphql_path = _sso_graphql_client(ctx) if not client: logger.info( "Skipping enterprise_external_identity for enterprise '%s': no SSO client configured", @@ -801,7 +819,7 @@ def enterprise_external_identity( try: for page_data in client.paginate( - "/graphql", + graphql_path, method="POST", json=data, paginator=paginator, diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index 931eb86..d124428 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -83,11 +83,14 @@ logger = logging.getLogger(__name__) +DEFAULT_GITHUB_REST_API_URL = "https://api.github.com" + @dataclass class OrgContext: client: RESTClient org_name: str + graphql_client: RESTClient | None = None enterprise_name: str | None = None github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN @@ -97,6 +100,7 @@ class OrgContext: class SourceContext: client: RESTClient organizations: list[OrgContext] = field(default_factory=list) + graphql_client: RESTClient | None = None enterprise_name: str | None = None github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN @@ -132,6 +136,24 @@ def _client_for_org(ctx: SourceContext, org_login: str) -> RESTClient: return ctx.client +def _graphql_client_for_org( + ctx: SourceContext, org_login: str +) -> tuple[RESTClient, str]: + for org in ctx.organizations: + if org.org_name == org_login: + if org.graphql_client: + return org.graphql_client, "" + return org.client, "/graphql" + if ctx.graphql_client: + return ctx.graphql_client, "" + return ctx.client, "/graphql" + + +def _rest_api_url(client: RESTClient) -> str: + base_url = getattr(client, "base_url", DEFAULT_GITHUB_REST_API_URL) + return str(base_url).strip().rstrip("/") or DEFAULT_GITHUB_REST_API_URL + + def _encode_path_segment(value: str) -> str: return quote(value, safe="") @@ -452,7 +474,7 @@ def users(ctx: SourceContext) -> Iterator[dict[str, Any]]: for org in ctx.organizations: org_name = org.org_name - client = org.client + client, graphql_path = _graphql_client_for_org(ctx, org_name) try: paginator = GraphQLCursorPaginator( page_info_path="data.organization.membersWithRole.pageInfo", @@ -466,7 +488,7 @@ def users(ctx: SourceContext) -> Iterator[dict[str, Any]]: } for page_data in client.paginate( - "/graphql", + graphql_path, method="POST", json=data, paginator=paginator, @@ -503,7 +525,7 @@ def teams(ctx: SourceContext): for org in ctx.organizations: org_name = org.org_name - client = org.client + client, graphql_path = _graphql_client_for_org(ctx, org_name) try: paginator = GraphQLCursorPaginator( page_info_path="data.organization.teams.pageInfo", @@ -516,7 +538,7 @@ def teams(ctx: SourceContext): "variables": {"login": org_name, "count": 100, "after": None}, } for page_data in client.paginate( - "/graphql", + graphql_path, method="POST", json=data, paginator=paginator, @@ -614,7 +636,7 @@ def team_members(team: Team, ctx: SourceContext): raise RuntimeError( f"GitHub team {team.org_login}/{team.slug} has more members but no endCursor" ) - client = _client_for_org(ctx, team.org_login) + client, graphql_path = _graphql_client_for_org(ctx, team.org_login) data = { "query": TEAM_MEMBERS_OVERFLOW_QUERY, "variables": { @@ -625,7 +647,7 @@ def team_members(team: Team, ctx: SourceContext): }, } for page_data in client.paginate( - "/graphql", + graphql_path, method="POST", json=data, paginator=paginator, @@ -915,7 +937,7 @@ def repositories_graphql(ctx: SourceContext): """ for org in ctx.organizations: org_name = org.org_name - client = org.client + client, graphql_path = _graphql_client_for_org(ctx, org_name) repository_cursor: str | None = None emitted_repositories = 0 try: @@ -931,7 +953,7 @@ def repositories_graphql(ctx: SourceContext): } for page_data in client.paginate( - "/graphql", + graphql_path, method="POST", json=data, paginator=paginator, @@ -1003,7 +1025,7 @@ def branches(repository: RepositoryQL, ctx: SourceContext): } if repository.refs.page_info.has_next_page: - client = _client_for_org(ctx, repository.org_login) + client, graphql_path = _graphql_client_for_org(ctx, repository.org_login) data = { "query": REF_OVERFLOW_QUERY, "variables": { @@ -1015,7 +1037,7 @@ def branches(repository: RepositoryQL, ctx: SourceContext): } for page_data in client.paginate( - "/graphql", + graphql_path, method="POST", json=data, paginator=paginator, @@ -1057,12 +1079,12 @@ def branch_protection_rules(repository: RepositoryQL, ctx: SourceContext): rule_ids_seen.add(rule_id) rule_ids_list = list(rule_ids_seen) - client = _client_for_org(ctx, repository.org_login) + client, graphql_path = _graphql_client_for_org(ctx, repository.org_login) for i in range(0, len(rule_ids_list), 100): rules_chunk = rule_ids_list[i : i + 100] if rules_chunk: data = {"query": PROTECTION_RULES_QUERY, "variables": {"ids": rules_chunk}} - response = client.post("/graphql", json=data).json() + response = client.post(graphql_path, json=data).json() for rule in response["data"].get("nodes", []): # GitHub can return null actors for deleted or inaccessible allowance actors. for allowance_key in ("bypassPullRequestAllowances", "pushAllowances"): @@ -1646,7 +1668,7 @@ def secret_scanning_alerts(ctx: SourceContext): ): try: resp = requests.get( - "https://api.github.com/user", + f"{_rest_api_url(client)}/user", headers={"Authorization": f"Bearer {secret}"}, timeout=10, ) @@ -1782,14 +1804,14 @@ def saml_provider(ctx: SourceContext): """ for org in ctx.organizations: org_name = org.org_name - client = org.client + client, graphql_path = _graphql_client_for_org(ctx, org_name) try: data = { "query": SAML_QUERY, "variables": {"login": org_name, "count": 100, "after": None}, } - response = client.post("/graphql", json=data).json() + response = client.post(graphql_path, json=data).json() response_data = response.get("data", {}) org_data = response_data.get("organization", {}) if response_data and org_data: @@ -1826,7 +1848,7 @@ def external_identities(ctx: SourceContext): """ for org in ctx.organizations: org_name = org.org_name - client = org.client + client, graphql_path = _graphql_client_for_org(ctx, org_name) github_deployment_id = org.github_deployment_id try: paginator = GraphQLCursorPaginator( @@ -1842,7 +1864,7 @@ def external_identities(ctx: SourceContext): } for page_data in client.paginate( - "/graphql", + graphql_path, method="POST", json=data, paginator=paginator, diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 0999be1..9d8f748 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -2,6 +2,7 @@ from dataclasses import dataclass, field from threading import Lock from typing import Any, Union +from urllib.parse import urlparse import dlt from dlt.common.configuration import configspec @@ -33,11 +34,73 @@ logger = logging.getLogger(__name__) +DEFAULT_GITHUB_REST_API_URL = "https://api.github.com" +DEFAULT_GITHUB_GRAPHQL_URL = "https://api.github.com/graphql" + + +@dataclass(frozen=True) +class GithubEndpoints: + rest_api_url: str + graphql_url: str + + +def _normalize_endpoint_url(url: str, setting_name: str) -> str: + normalized_url = url.strip().rstrip("/") + parsed = urlparse(normalized_url) + if ( + not normalized_url + or parsed.scheme not in {"http", "https"} + or not parsed.hostname + ): + raise ValueError(f"{setting_name} must be an absolute HTTP(S) URL") + if ( + parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise ValueError( + f"{setting_name} must not contain user-info, query strings, or fragments" + ) + return normalized_url + + +def resolve_github_endpoints( + *, + host: str = DEFAULT_GITHUB_REST_API_URL, + rest_api_url: str | None = None, + graphql_url: str | None = None, +) -> GithubEndpoints: + """Resolve GitHub REST and GraphQL endpoints from new and legacy settings.""" + if (rest_api_url is None) != (graphql_url is None): + raise ValueError( + "Both rest_api_url and graphql_url must be set when overriding GitHub endpoints" + ) + + if rest_api_url is not None and graphql_url is not None: + return GithubEndpoints( + rest_api_url=_normalize_endpoint_url(rest_api_url, "rest_api_url"), + graphql_url=_normalize_endpoint_url(graphql_url, "graphql_url"), + ) + + legacy_rest_api_url = _normalize_endpoint_url(host, "host") + if legacy_rest_api_url == DEFAULT_GITHUB_REST_API_URL: + return GithubEndpoints( + rest_api_url=DEFAULT_GITHUB_REST_API_URL, + graphql_url=DEFAULT_GITHUB_GRAPHQL_URL, + ) + + return GithubEndpoints( + rest_api_url=legacy_rest_api_url, + graphql_url=f"{legacy_rest_api_url}/graphql", + ) + @dataclass class OrgContext: client: RESTClient org_name: str + graphql_client: RESTClient | None = None enterprise_name: str | None = None github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN @@ -47,7 +110,9 @@ class OrgContext: class SourceContext: organizations: list[OrgContext] | None = field(default_factory=list) client: RESTClient | None = None + graphql_client: RESTClient | None = None sso_client: RESTClient | None = None + sso_graphql_client: RESTClient | None = None enterprise_name: str | None = None emit_legacy_scim_correlations: bool = False github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID @@ -79,7 +144,7 @@ class GithubEnterpriseAppCredentials(CredentialsConfiguration): key_path: str = None enterprise_name: str = None pat_token: str | None = None - api_uri: str = "https://api.github.com" + api_uri: str = DEFAULT_GITHUB_REST_API_URL @property def auth(self) -> str: @@ -92,7 +157,7 @@ class GithubOrgAppCredentials(CredentialsConfiguration): install_id: str = None key_path: str = None org_name: str = None - api_uri: str = "https://api.github.com" + api_uri: str = DEFAULT_GITHUB_REST_API_URL @property def auth(self) -> str: @@ -117,20 +182,32 @@ def source( credentials: Union[ GithubEnterpriseAppCredentials, GithubOrgAppCredentials, GithubTokenCredentials ] = dlt.secrets.value, - host: str = "https://api.github.com", + host: str = DEFAULT_GITHUB_REST_API_URL, emit_legacy_scim_correlations: bool | None = dlt.config.value, + rest_api_url: str | None = None, + graphql_url: str | None = None, ): """DLT source, defines GitHub collection resources and transformers. Args: credentials (Union[GithubEnterpriseAppCredentials, GithubOrgAppCredentials, GithubTokenCredentials]): The GitHub credentials. - host (str): The base GitHub API URL used for API calls. + host (str): Legacy base GitHub REST API URL used for API calls. + emit_legacy_scim_correlations (bool | None): Whether to emit legacy SCIM correlation relationships. + rest_api_url (str): The GitHub REST API base URL. + graphql_url (str): The GitHub GraphQL endpoint URL. """ - github_deployment_id, github_web_origin = github_deployment_context(host) - - def client(auth: AuthConfigBase) -> RESTClient: + endpoints = resolve_github_endpoints( + host=host, + rest_api_url=rest_api_url, + graphql_url=graphql_url, + ) + github_deployment_id, github_web_origin = github_deployment_context( + endpoints.rest_api_url + ) + + def api_client(base_url: str, auth: AuthConfigBase) -> RESTClient: return RESTClient( - base_url=host, + base_url=base_url, headers={ "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", @@ -143,8 +220,14 @@ def client(auth: AuthConfigBase) -> RESTClient: ).session, ) - def token_client(token: str) -> RESTClient: - return client(BearerTokenAuth(token=token)) + def clients(auth: AuthConfigBase) -> tuple[RESTClient, RESTClient]: + return ( + api_client(endpoints.rest_api_url, auth), + api_client(endpoints.graphql_url, auth), + ) + + def token_clients(token: str) -> tuple[RESTClient, RESTClient]: + return clients(BearerTokenAuth(token=token)) if credentials.auth == "enterprise_app": jwt_issuer = resolve_github_app_jwt_issuer( @@ -158,11 +241,13 @@ def token_client(token: str) -> RESTClient: github_web_origin=github_web_origin, ) if credentials.pat_token: - ctx.sso_client = token_client(credentials.pat_token) + ctx.sso_client, ctx.sso_graphql_client = token_clients( + credentials.pat_token + ) github_app_session = GithubApp( jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, - api_uri=host, + api_uri=endpoints.rest_api_url, ) for installation in github_app_session.installations: if installation.target_type == "Organization": @@ -170,17 +255,19 @@ def token_client(token: str) -> RESTClient: installation_id=installation.id, jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, - api_uri=host, + api_uri=endpoints.rest_api_url, + ) + org_client, org_graphql_client = clients( + GitHubAppInstallationAuth( + installation=org_installation, + api_uri=endpoints.rest_api_url, + ) ) ctx.organizations.append( OrgContext( org_name=installation.account.login, - client=client( - GitHubAppInstallationAuth( - installation=org_installation, - api_uri=host, - ) - ), + client=org_client, + graphql_client=org_graphql_client, enterprise_name=credentials.enterprise_name, github_deployment_id=github_deployment_id, github_web_origin=github_web_origin, @@ -191,12 +278,12 @@ def token_client(token: str) -> RESTClient: installation_id=installation.id, jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, - api_uri=host, + api_uri=endpoints.rest_api_url, ) - ctx.client = client( + ctx.client, ctx.graphql_client = clients( GitHubAppInstallationAuth( installation=es_installation, - api_uri=host, + api_uri=endpoints.rest_api_url, ) ) @@ -212,17 +299,19 @@ def token_client(token: str) -> RESTClient: installation_id=credentials.install_id, jwt_issuer=credentials.client_id, private_key_path=credentials.key_path, - api_uri=host, + api_uri=endpoints.rest_api_url, + ) + org_client, org_graphql_client = clients( + GitHubAppInstallationAuth( + installation=org_installation, + api_uri=endpoints.rest_api_url, + ) ) ctx.organizations.append( OrgContext( org_name=credentials.org_name, - client=client( - GitHubAppInstallationAuth( - installation=org_installation, - api_uri=host, - ) - ), + client=org_client, + graphql_client=org_graphql_client, github_deployment_id=github_deployment_id, github_web_origin=github_web_origin, ) @@ -232,10 +321,12 @@ def token_client(token: str) -> RESTClient: else: if credentials.enterprise_name: - token_api_client = token_client(credentials.token) + token_api_client, token_graphql_client = token_clients(credentials.token) ctx = SourceContext( client=token_api_client, + graphql_client=token_graphql_client, sso_client=token_api_client, + sso_graphql_client=token_graphql_client, enterprise_name=credentials.enterprise_name, emit_legacy_scim_correlations=bool(emit_legacy_scim_correlations), github_deployment_id=github_deployment_id, @@ -243,6 +334,7 @@ def token_client(token: str) -> RESTClient: ) return enterprise_resources(ctx) + token_api_client, token_graphql_client = token_clients(credentials.token) ctx = SourceContext( github_deployment_id=github_deployment_id, github_web_origin=github_web_origin, @@ -250,7 +342,8 @@ def token_client(token: str) -> RESTClient: ctx.organizations.append( OrgContext( org_name=credentials.org_name, - client=token_client(credentials.token), + client=token_api_client, + graphql_client=token_graphql_client, github_deployment_id=github_deployment_id, github_web_origin=github_web_origin, ) diff --git a/tests/test_source_endpoints.py b/tests/test_source_endpoints.py new file mode 100644 index 0000000..767a24f --- /dev/null +++ b/tests/test_source_endpoints.py @@ -0,0 +1,389 @@ +import importlib +import inspect +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from openhound_github.resources.enterprise import ( + SourceContext as EnterpriseResourceContext, + enterprise, +) +from openhound_github.resources.organization import ( + OrgContext as OrganizationOrgContext, + SourceContext as OrganizationResourceContext, + secret_scanning_alerts, + users, +) +from openhound_github.source import ( + DEFAULT_GITHUB_GRAPHQL_URL, + DEFAULT_GITHUB_REST_API_URL, + GithubEndpoints, + GithubEnterpriseAppCredentials, + GithubOrgAppCredentials, + GithubTokenCredentials, + resolve_github_endpoints, +) + + +def test_resolve_github_endpoints_uses_dotcom_defaults() -> None: + assert resolve_github_endpoints() == GithubEndpoints( + rest_api_url=DEFAULT_GITHUB_REST_API_URL, + graphql_url=DEFAULT_GITHUB_GRAPHQL_URL, + ) + + +def test_resolve_github_endpoints_uses_explicit_endpoint_pair() -> None: + assert resolve_github_endpoints( + rest_api_url="https://ghe.example/api/v3/", + graphql_url="https://ghe.example/api/graphql/", + ) == GithubEndpoints( + rest_api_url="https://ghe.example/api/v3", + graphql_url="https://ghe.example/api/graphql", + ) + + +@pytest.mark.parametrize( + ("rest_api_url", "graphql_url"), + ( + ("https://ghe.example/api/v3", None), + (None, "https://ghe.example/api/graphql"), + ), +) +def test_resolve_github_endpoints_requires_explicit_pair( + rest_api_url: str | None, + graphql_url: str | None, +) -> None: + with pytest.raises( + ValueError, + match="Both rest_api_url and graphql_url must be set", + ): + resolve_github_endpoints( + rest_api_url=rest_api_url, + graphql_url=graphql_url, + ) + + +def test_resolve_github_endpoints_preserves_legacy_host_behavior() -> None: + assert resolve_github_endpoints(host="https://ghe.example/api/v3/") == GithubEndpoints( + rest_api_url="https://ghe.example/api/v3", + graphql_url="https://ghe.example/api/v3/graphql", + ) + + +@pytest.mark.parametrize( + ("setting_name", "kwargs"), + ( + ("host", {"host": "ghe.example/api/v3"}), + ( + "rest_api_url", + { + "rest_api_url": "https://ghe.example/api/v3?token=secret", + "graphql_url": "https://ghe.example/api/graphql", + }, + ), + ( + "graphql_url", + { + "rest_api_url": "https://ghe.example/api/v3", + "graphql_url": "https://ghe.example/api/graphql#fragment", + }, + ), + ), +) +def test_resolve_github_endpoints_rejects_invalid_urls( + setting_name: str, + kwargs: dict[str, str], +) -> None: + with pytest.raises(ValueError, match=setting_name): + resolve_github_endpoints(**kwargs) + + +def test_org_source_context_carries_rest_and_graphql_clients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_module = importlib.import_module("openhound_github.source") + captured_ctx = None + + class FakeRESTClient: + def __init__(self, **kwargs) -> None: + self.base_url = kwargs["base_url"] + + def capture_context(ctx): + nonlocal captured_ctx + captured_ctx = ctx + return () + + monkeypatch.setattr(source_module, "RESTClient", FakeRESTClient) + monkeypatch.setattr(source_module, "organization_resources", capture_context) + + resources = source_module.source.__wrapped__( + credentials=GithubTokenCredentials(token="token", org_name="acme"), + emit_legacy_scim_correlations=False, + rest_api_url="https://ghe.example/api/v3", + graphql_url="https://ghe.example/api/graphql", + ) + + assert resources == () + assert captured_ctx is not None + assert captured_ctx.organizations[0].client.base_url == "https://ghe.example/api/v3" + assert ( + captured_ctx.organizations[0].graphql_client.base_url + == "https://ghe.example/api/graphql" + ) + + +def test_enterprise_source_context_carries_rest_and_graphql_clients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_module = importlib.import_module("openhound_github.source") + captured_ctx = None + + class FakeRESTClient: + def __init__(self, **kwargs) -> None: + self.base_url = kwargs["base_url"] + + def capture_context(ctx): + nonlocal captured_ctx + captured_ctx = ctx + return () + + monkeypatch.setattr(source_module, "RESTClient", FakeRESTClient) + monkeypatch.setattr(source_module, "enterprise_resources", capture_context) + + resources = source_module.source.__wrapped__( + credentials=GithubTokenCredentials( + token="token", + enterprise_name="acme-enterprise", + ), + emit_legacy_scim_correlations=False, + rest_api_url="https://ghe.example/api/v3", + graphql_url="https://ghe.example/api/graphql", + ) + + assert resources == () + assert captured_ctx is not None + assert captured_ctx.client.base_url == "https://ghe.example/api/v3" + assert captured_ctx.graphql_client.base_url == "https://ghe.example/api/graphql" + assert captured_ctx.sso_client.base_url == "https://ghe.example/api/v3" + assert captured_ctx.sso_graphql_client.base_url == "https://ghe.example/api/graphql" + + +def test_org_graphql_resource_uses_dedicated_graphql_client() -> None: + rest_client = MagicMock() + graphql_client = MagicMock() + graphql_client.paginate.return_value = [ + [{"organization": {"membersWithRole": {"edges": []}}}] + ] + ctx = OrganizationResourceContext( + client=rest_client, + organizations=[ + OrganizationOrgContext( + client=rest_client, + graphql_client=graphql_client, + org_name="acme", + ) + ], + ) + + rows = list(inspect.unwrap(users._pipe.gen)(ctx)) + + assert rows == [] + graphql_client.paginate.assert_called_once() + assert graphql_client.paginate.call_args.args[0] == "" + rest_client.paginate.assert_not_called() + + +def test_enterprise_graphql_resource_uses_dedicated_graphql_client() -> None: + rest_client = MagicMock() + graphql_client = MagicMock() + graphql_client.post.return_value.json.return_value = { + "data": {"enterprise": {"id": "E_1", "slug": "acme"}} + } + ctx = EnterpriseResourceContext( + client=rest_client, + graphql_client=graphql_client, + enterprise_name="acme", + ) + + rows = list(inspect.unwrap(enterprise._pipe.gen)(ctx)) + + assert len(rows) == 1 + graphql_client.post.assert_called_once() + assert graphql_client.post.call_args.args[0] == "" + rest_client.post.assert_not_called() + + +def test_secret_scanning_pat_validation_uses_configured_rest_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + organization_module = importlib.import_module("openhound_github.resources.organization") + rest_client = MagicMock() + rest_client.base_url = "https://ghe.example/api/v3/" + rest_client.paginate.return_value = [ + [ + { + "state": "open", + "secret_type": "github_personal_access_token", + "secret": "ghp_example", + } + ] + ] + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"node_id": "U_1"} + get = MagicMock(return_value=response) + monkeypatch.setattr(organization_module.requests, "get", get) + ctx = OrganizationResourceContext( + client=rest_client, + organizations=[OrganizationOrgContext(client=rest_client, org_name="acme")], + ) + + rows = list(secret_scanning_alerts.__wrapped__(ctx)) + + assert rows[0]["valid_token_user_node_id"] == "U_1" + get.assert_called_once_with( + "https://ghe.example/api/v3/user", + headers={"Authorization": "Bearer ghp_example"}, + timeout=10, + ) + + +def test_org_app_source_uses_rest_endpoint_for_auth_and_both_api_clients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_module = importlib.import_module("openhound_github.source") + captured_api_uris: list[str] = [] + captured_ctx = None + + class FakeGithubInstallation: + def __init__( + self, + installation_id: str, + jwt_issuer: str, + private_key_path: str, + api_uri: str, + ) -> None: + captured_api_uris.append(api_uri) + + class FakeRESTClient: + def __init__(self, **kwargs) -> None: + self.base_url = kwargs["base_url"] + + def fake_auth(*, installation, api_uri: str): + captured_api_uris.append(api_uri) + return object() + + def capture_context(ctx): + nonlocal captured_ctx + captured_ctx = ctx + return () + + monkeypatch.setattr(source_module, "GithubInstallation", FakeGithubInstallation) + monkeypatch.setattr(source_module, "GitHubAppInstallationAuth", fake_auth) + monkeypatch.setattr(source_module, "RESTClient", FakeRESTClient) + monkeypatch.setattr(source_module, "organization_resources", capture_context) + + resources = source_module.source.__wrapped__( + credentials=GithubOrgAppCredentials( + client_id="Iv1.example", + install_id="123", + key_path="/tmp/github-app.pem", + org_name="acme", + ), + emit_legacy_scim_correlations=False, + rest_api_url="https://ghe.example/api/v3", + graphql_url="https://ghe.example/api/graphql", + ) + + assert resources == () + assert captured_api_uris == [ + "https://ghe.example/api/v3", + "https://ghe.example/api/v3", + ] + assert captured_ctx.organizations[0].client.base_url == "https://ghe.example/api/v3" + assert ( + captured_ctx.organizations[0].graphql_client.base_url + == "https://ghe.example/api/graphql" + ) + + +def test_enterprise_app_source_uses_rest_endpoint_for_auth_and_both_api_clients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_module = importlib.import_module("openhound_github.source") + captured_api_uris: list[str] = [] + captured_ctxs: list[object] = [] + + class FakeGithubApp: + def __init__( + self, jwt_issuer: str, private_key_path: str, api_uri: str + ) -> None: + captured_api_uris.append(api_uri) + self.installations = ( + SimpleNamespace( + id=11, + target_type="Organization", + account=SimpleNamespace(login="acme"), + ), + SimpleNamespace( + id=12, + target_type="Enterprise", + account=SimpleNamespace(slug="acme-enterprise"), + ), + ) + + class FakeGithubInstallation: + def __init__( + self, + installation_id: int, + jwt_issuer: str, + private_key_path: str, + api_uri: str, + ) -> None: + captured_api_uris.append(api_uri) + + class FakeRESTClient: + def __init__(self, **kwargs) -> None: + self.base_url = kwargs["base_url"] + + def fake_auth(*, installation, api_uri: str): + captured_api_uris.append(api_uri) + return object() + + def capture_context(ctx): + captured_ctxs.append(ctx) + return () + + monkeypatch.setattr(source_module, "GithubApp", FakeGithubApp) + monkeypatch.setattr(source_module, "GithubInstallation", FakeGithubInstallation) + monkeypatch.setattr(source_module, "GitHubAppInstallationAuth", fake_auth) + monkeypatch.setattr(source_module, "RESTClient", FakeRESTClient) + monkeypatch.setattr(source_module, "enterprise_resources", capture_context) + monkeypatch.setattr(source_module, "organization_resources", capture_context) + + resources = source_module.source.__wrapped__( + credentials=GithubEnterpriseAppCredentials( + app_id="123456", + key_path="/tmp/github-app.pem", + enterprise_name="acme-enterprise", + ), + emit_legacy_scim_correlations=False, + rest_api_url="https://ghe.example/api/v3", + graphql_url="https://ghe.example/api/graphql", + ) + + assert resources == () + assert captured_api_uris == [ + "https://ghe.example/api/v3", + "https://ghe.example/api/v3", + "https://ghe.example/api/v3", + "https://ghe.example/api/v3", + "https://ghe.example/api/v3", + ] + assert len(captured_ctxs) == 2 + ctx = captured_ctxs[0] + assert ctx.client.base_url == "https://ghe.example/api/v3" + assert ctx.graphql_client.base_url == "https://ghe.example/api/graphql" + assert ctx.organizations[0].client.base_url == "https://ghe.example/api/v3" + assert ctx.organizations[0].graphql_client.base_url == "https://ghe.example/api/graphql" From efee23675531be536e94575c40353863ddf90018 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Fri, 28 Aug 2026 11:59:22 -0700 Subject: [PATCH 2/6] BED-9508: centralize GraphQL client routing --- src/openhound_github/helpers.py | 30 ++++++++++++++++++- src/openhound_github/resources/enterprise.py | 14 ++++----- .../resources/organization.py | 18 +++++------ src/openhound_github/source.py | 10 +++---- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/openhound_github/helpers.py b/src/openhound_github/helpers.py index aed2b99..d3955ea 100644 --- a/src/openhound_github/helpers.py +++ b/src/openhound_github/helpers.py @@ -1,11 +1,12 @@ import logging import time -from typing import Optional +from typing import Optional, overload from urllib.parse import urlparse from dlt.common import jsonpath from dlt.sources.helpers import requests from dlt.sources.helpers.rest_client.auth import AuthConfigBase +from dlt.sources.helpers.rest_client.client import RESTClient from dlt.sources.helpers.rest_client.paginators import ( JSONResponseCursorPaginator, ) @@ -15,11 +16,38 @@ logger = logging.getLogger(__name__) +DEFAULT_GITHUB_REST_API_URL = "https://api.github.com" +DEFAULT_GITHUB_GRAPHQL_URL = "https://api.github.com/graphql" + class GraphQLPaginationError(RuntimeError): pass +@overload +def graphql_client_and_path( + rest_client: RESTClient, + graphql_client: RESTClient | None, +) -> tuple[RESTClient, str]: ... + + +@overload +def graphql_client_and_path( + rest_client: RESTClient | None, + graphql_client: RESTClient | None, +) -> tuple[RESTClient | None, str]: ... + + +def graphql_client_and_path( + rest_client: RESTClient | None, + graphql_client: RESTClient | None, +) -> tuple[RESTClient | None, str]: + """Return the configured GraphQL client and its request path.""" + if graphql_client: + return graphql_client, "" + return rest_client, "/graphql" + + def scim_skip_reason(exception: BaseException) -> str | None: """Return a user-facing reason for expected SCIM API unavailability.""" if not isinstance(exception, requests.HTTPError) or exception.response is None: diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index c38edda..3eadfb6 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -11,7 +11,11 @@ ENTERPRISE_QUERY, ENTERPRISE_SAML_QUERY, ) -from openhound_github.helpers import GraphQLCursorPaginator, scim_skip_reason +from openhound_github.helpers import ( + GraphQLCursorPaginator, + graphql_client_and_path, + scim_skip_reason, +) from openhound_github.main import app from openhound_github.models import ( BaseUser, @@ -64,15 +68,11 @@ class SourceContext: def _graphql_client(ctx: SourceContext) -> tuple[RESTClient, str]: - if ctx.graphql_client: - return ctx.graphql_client, "" - return ctx.client, "/graphql" + return graphql_client_and_path(ctx.client, ctx.graphql_client) def _sso_graphql_client(ctx: SourceContext) -> tuple[RESTClient | None, str]: - if ctx.sso_graphql_client: - return ctx.sso_graphql_client, "" - return ctx.sso_client, "/graphql" + return graphql_client_and_path(ctx.sso_client, ctx.sso_graphql_client) def iter_enterprise_scim_resources( diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index d124428..2045240 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -22,7 +22,12 @@ TEAM_MEMBERS_OVERFLOW_QUERY, TEAMS_QUERY, ) -from openhound_github.helpers import GraphQLCursorPaginator, scim_skip_reason +from openhound_github.helpers import ( + DEFAULT_GITHUB_REST_API_URL, + GraphQLCursorPaginator, + graphql_client_and_path, + scim_skip_reason, +) from openhound_github.main import app from openhound_github.models import ( ActionPermission, @@ -83,9 +88,6 @@ logger = logging.getLogger(__name__) -DEFAULT_GITHUB_REST_API_URL = "https://api.github.com" - - @dataclass class OrgContext: client: RESTClient @@ -141,12 +143,8 @@ def _graphql_client_for_org( ) -> tuple[RESTClient, str]: for org in ctx.organizations: if org.org_name == org_login: - if org.graphql_client: - return org.graphql_client, "" - return org.client, "/graphql" - if ctx.graphql_client: - return ctx.graphql_client, "" - return ctx.client, "/graphql" + return graphql_client_and_path(org.client, org.graphql_client) + return graphql_client_and_path(ctx.client, ctx.graphql_client) def _rest_api_url(client: RESTClient) -> str: diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 9d8f748..690855e 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -21,7 +21,11 @@ GithubInstallation, resolve_github_app_jwt_issuer, ) -from openhound_github.helpers import github_retry_policy +from openhound_github.helpers import ( + DEFAULT_GITHUB_GRAPHQL_URL, + DEFAULT_GITHUB_REST_API_URL, + github_retry_policy, +) from openhound_github.main import app from openhound_github.models.saml_helpers import ( DEFAULT_GITHUB_DEPLOYMENT_ID, @@ -34,10 +38,6 @@ logger = logging.getLogger(__name__) -DEFAULT_GITHUB_REST_API_URL = "https://api.github.com" -DEFAULT_GITHUB_GRAPHQL_URL = "https://api.github.com/graphql" - - @dataclass(frozen=True) class GithubEndpoints: rest_api_url: str From 769d0f1e22c4e85569ae2f95d5a9d66635914ed7 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Fri, 28 Aug 2026 12:13:24 -0700 Subject: [PATCH 3/6] BED-9508: preserve app auth api uri overrides --- src/openhound_github/source.py | 28 +++++++++++++++++-------- tests/test_source_endpoints.py | 38 ++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 690855e..579173d 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -144,7 +144,7 @@ class GithubEnterpriseAppCredentials(CredentialsConfiguration): key_path: str = None enterprise_name: str = None pat_token: str | None = None - api_uri: str = DEFAULT_GITHUB_REST_API_URL + api_uri: str | None = None @property def auth(self) -> str: @@ -157,7 +157,7 @@ class GithubOrgAppCredentials(CredentialsConfiguration): install_id: str = None key_path: str = None org_name: str = None - api_uri: str = DEFAULT_GITHUB_REST_API_URL + api_uri: str | None = None @property def auth(self) -> str: @@ -230,6 +230,11 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: return clients(BearerTokenAuth(token=token)) if credentials.auth == "enterprise_app": + auth_api_uri = ( + credentials.api_uri + if credentials.api_uri is not None + else endpoints.rest_api_url + ) jwt_issuer = resolve_github_app_jwt_issuer( client_id=credentials.client_id, app_id=credentials.app_id, @@ -247,7 +252,7 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: github_app_session = GithubApp( jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, - api_uri=endpoints.rest_api_url, + api_uri=auth_api_uri, ) for installation in github_app_session.installations: if installation.target_type == "Organization": @@ -255,12 +260,12 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: installation_id=installation.id, jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, - api_uri=endpoints.rest_api_url, + api_uri=auth_api_uri, ) org_client, org_graphql_client = clients( GitHubAppInstallationAuth( installation=org_installation, - api_uri=endpoints.rest_api_url, + api_uri=auth_api_uri, ) ) ctx.organizations.append( @@ -278,18 +283,23 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: installation_id=installation.id, jwt_issuer=jwt_issuer, private_key_path=credentials.key_path, - api_uri=endpoints.rest_api_url, + api_uri=auth_api_uri, ) ctx.client, ctx.graphql_client = clients( GitHubAppInstallationAuth( installation=es_installation, - api_uri=endpoints.rest_api_url, + api_uri=auth_api_uri, ) ) return (*enterprise_resources(ctx), *organization_resources(ctx)) elif credentials.auth == "org_app": + auth_api_uri = ( + credentials.api_uri + if credentials.api_uri is not None + else endpoints.rest_api_url + ) ctx = SourceContext( enterprise_name=None, github_deployment_id=github_deployment_id, @@ -299,12 +309,12 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: installation_id=credentials.install_id, jwt_issuer=credentials.client_id, private_key_path=credentials.key_path, - api_uri=endpoints.rest_api_url, + api_uri=auth_api_uri, ) org_client, org_graphql_client = clients( GitHubAppInstallationAuth( installation=org_installation, - api_uri=endpoints.rest_api_url, + api_uri=auth_api_uri, ) ) ctx.organizations.append( diff --git a/tests/test_source_endpoints.py b/tests/test_source_endpoints.py index 767a24f..bc68697 100644 --- a/tests/test_source_endpoints.py +++ b/tests/test_source_endpoints.py @@ -249,8 +249,17 @@ def test_secret_scanning_pat_validation_uses_configured_rest_endpoint( ) -def test_org_app_source_uses_rest_endpoint_for_auth_and_both_api_clients( +@pytest.mark.parametrize( + ("credential_api_uri", "expected_auth_api_uri"), + ( + (None, "https://ghe.example/api/v3"), + ("https://ghe.example/custom/api/v3", "https://ghe.example/custom/api/v3"), + ), +) +def test_org_app_source_uses_selected_auth_endpoint_and_both_api_clients( monkeypatch: pytest.MonkeyPatch, + credential_api_uri: str | None, + expected_auth_api_uri: str, ) -> None: source_module = importlib.import_module("openhound_github.source") captured_api_uris: list[str] = [] @@ -290,6 +299,7 @@ def capture_context(ctx): install_id="123", key_path="/tmp/github-app.pem", org_name="acme", + api_uri=credential_api_uri, ), emit_legacy_scim_correlations=False, rest_api_url="https://ghe.example/api/v3", @@ -298,8 +308,8 @@ def capture_context(ctx): assert resources == () assert captured_api_uris == [ - "https://ghe.example/api/v3", - "https://ghe.example/api/v3", + expected_auth_api_uri, + expected_auth_api_uri, ] assert captured_ctx.organizations[0].client.base_url == "https://ghe.example/api/v3" assert ( @@ -308,8 +318,17 @@ def capture_context(ctx): ) -def test_enterprise_app_source_uses_rest_endpoint_for_auth_and_both_api_clients( +@pytest.mark.parametrize( + ("credential_api_uri", "expected_auth_api_uri"), + ( + (None, "https://ghe.example/api/v3"), + ("https://ghe.example/custom/api/v3", "https://ghe.example/custom/api/v3"), + ), +) +def test_enterprise_app_source_uses_selected_auth_endpoint_and_both_api_clients( monkeypatch: pytest.MonkeyPatch, + credential_api_uri: str | None, + expected_auth_api_uri: str, ) -> None: source_module = importlib.import_module("openhound_github.source") captured_api_uris: list[str] = [] @@ -367,6 +386,7 @@ def capture_context(ctx): app_id="123456", key_path="/tmp/github-app.pem", enterprise_name="acme-enterprise", + api_uri=credential_api_uri, ), emit_legacy_scim_correlations=False, rest_api_url="https://ghe.example/api/v3", @@ -375,11 +395,11 @@ def capture_context(ctx): assert resources == () assert captured_api_uris == [ - "https://ghe.example/api/v3", - "https://ghe.example/api/v3", - "https://ghe.example/api/v3", - "https://ghe.example/api/v3", - "https://ghe.example/api/v3", + expected_auth_api_uri, + expected_auth_api_uri, + expected_auth_api_uri, + expected_auth_api_uri, + expected_auth_api_uri, ] assert len(captured_ctxs) == 2 ctx = captured_ctxs[0] From 6834e47736c908a2081856ac302035dc1fc6f6c3 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Fri, 28 Aug 2026 13:27:30 -0700 Subject: [PATCH 4/6] BED-9508: validate app auth api uri origin --- src/openhound_github/source.py | 37 ++++++++++++++++++++++++++-------- tests/test_source_endpoints.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index ae486e0..beb2b7d 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -65,6 +65,29 @@ def _normalize_endpoint_url(url: str, setting_name: str) -> str: return normalized_url +def _endpoint_origin(url: str) -> tuple[str, str, int | None]: + parsed = urlparse(url) + port = parsed.port + if (parsed.scheme.lower(), port) in {("http", 80), ("https", 443)}: + port = None + return parsed.scheme.lower(), parsed.hostname.lower(), port + + +def _resolve_app_auth_api_uri( + credentials_api_uri: str | None, + rest_api_url: str, +) -> str: + if credentials_api_uri is None: + return rest_api_url + + auth_api_uri = _normalize_endpoint_url(credentials_api_uri, "credentials.api_uri") + if _endpoint_origin(auth_api_uri) != _endpoint_origin(rest_api_url): + raise ValueError( + "credentials.api_uri origin must match rest_api_url origin for GitHub App authentication" + ) + return auth_api_uri + + def resolve_github_endpoints( *, host: str = DEFAULT_GITHUB_REST_API_URL, @@ -256,10 +279,9 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: return clients(BearerTokenAuth(token=token)) if credentials.auth == "enterprise_app": - auth_api_uri = ( - credentials.api_uri - if credentials.api_uri is not None - else endpoints.rest_api_url + auth_api_uri = _resolve_app_auth_api_uri( + credentials.api_uri, + endpoints.rest_api_url, ) jwt_issuer = resolve_github_app_jwt_issuer( client_id=credentials.client_id, @@ -321,10 +343,9 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: return (*enterprise_resources(ctx), *organization_resources(ctx)) elif credentials.auth == "org_app": - auth_api_uri = ( - credentials.api_uri - if credentials.api_uri is not None - else endpoints.rest_api_url + auth_api_uri = _resolve_app_auth_api_uri( + credentials.api_uri, + endpoints.rest_api_url, ) ctx = SourceContext( enterprise_name=None, diff --git a/tests/test_source_endpoints.py b/tests/test_source_endpoints.py index bc68697..21ddf3b 100644 --- a/tests/test_source_endpoints.py +++ b/tests/test_source_endpoints.py @@ -99,6 +99,38 @@ def test_resolve_github_endpoints_rejects_invalid_urls( resolve_github_endpoints(**kwargs) +@pytest.mark.parametrize( + "credentials", + ( + GithubOrgAppCredentials( + client_id="Iv1.example", + install_id="123", + key_path="/tmp/github-app.pem", + org_name="acme", + api_uri="https://api.github.com", + ), + GithubEnterpriseAppCredentials( + app_id="123456", + key_path="/tmp/github-app.pem", + enterprise_name="acme-enterprise", + api_uri="https://api.github.com", + ), + ), +) +def test_app_source_rejects_auth_api_uri_on_different_rest_origin(credentials) -> None: + with pytest.raises( + ValueError, + match="credentials.api_uri origin must match rest_api_url origin", + ): + source_module = importlib.import_module("openhound_github.source") + source_module.source.__wrapped__( + credentials=credentials, + emit_legacy_scim_correlations=False, + rest_api_url="https://ghe.example/api/v3", + graphql_url="https://ghe.example/api/graphql", + ) + + def test_org_source_context_carries_rest_and_graphql_clients( monkeypatch: pytest.MonkeyPatch, ) -> None: From 8df476941849eaa229c30d483406d2a361bd05be Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Fri, 28 Aug 2026 14:07:57 -0700 Subject: [PATCH 5/6] BED-9508: clean up endpoint mismatch test fixtures --- tests/test_source_endpoints.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_source_endpoints.py b/tests/test_source_endpoints.py index 21ddf3b..82b715d 100644 --- a/tests/test_source_endpoints.py +++ b/tests/test_source_endpoints.py @@ -105,13 +105,13 @@ def test_resolve_github_endpoints_rejects_invalid_urls( GithubOrgAppCredentials( client_id="Iv1.example", install_id="123", - key_path="/tmp/github-app.pem", + key_path="unused-github-app.pem", org_name="acme", api_uri="https://api.github.com", ), GithubEnterpriseAppCredentials( app_id="123456", - key_path="/tmp/github-app.pem", + key_path="unused-github-app.pem", enterprise_name="acme-enterprise", api_uri="https://api.github.com", ), @@ -120,7 +120,7 @@ def test_resolve_github_endpoints_rejects_invalid_urls( def test_app_source_rejects_auth_api_uri_on_different_rest_origin(credentials) -> None: with pytest.raises( ValueError, - match="credentials.api_uri origin must match rest_api_url origin", + match=r"credentials\.api_uri origin must match rest_api_url origin", ): source_module = importlib.import_module("openhound_github.source") source_module.source.__wrapped__( From 9d82a96f91efe81ea7d015426b1c474d5e5be7e8 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Fri, 28 Aug 2026 20:59:06 -0700 Subject: [PATCH 6/6] BED-9508: harden endpoint origin handling --- README.md | 2 ++ src/openhound_github/auth.py | 10 ++++++++++ src/openhound_github/source.py | 21 ++++++++++++++++---- tests/test_github_app_retry.py | 12 ++++++++++++ tests/test_source_endpoints.py | 35 +++++++++++++++++++++++++++++++++- 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3eb50b5..237baf0 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ graphql_url = "https://ghe.example/api/graphql" ``` Both values must be provided together when overriding the GitHub.com defaults. +Endpoint URLs must use HTTPS and share the same origin. GitHub App +`credentials.api_uri` may override only the path on that same origin. ### Enterprise SCIM and hybrid correlations diff --git a/src/openhound_github/auth.py b/src/openhound_github/auth.py index df51819..2c94a43 100644 --- a/src/openhound_github/auth.py +++ b/src/openhound_github/auth.py @@ -293,6 +293,16 @@ def refresh_request(self, request: requests.PreparedRequest) -> bool: return True def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: + try: + request_origin = _normalized_http_origin( + request.url or "", allow_query=True + ) + except ValueError: + return request + + if request_origin != self._api_origin: + return request + request.headers["Authorization"] = f"Bearer {self.token()}" return request diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index beb2b7d..989e6d0 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -49,10 +49,10 @@ def _normalize_endpoint_url(url: str, setting_name: str) -> str: parsed = urlparse(normalized_url) if ( not normalized_url - or parsed.scheme not in {"http", "https"} + or parsed.scheme != "https" or not parsed.hostname ): - raise ValueError(f"{setting_name} must be an absolute HTTP(S) URL") + raise ValueError(f"{setting_name} must be an absolute HTTPS URL") if ( parsed.username is not None or parsed.password is not None @@ -88,6 +88,12 @@ def _resolve_app_auth_api_uri( return auth_api_uri +def _legacy_graphql_url(rest_api_url: str) -> str: + if rest_api_url.endswith("/api/v3"): + return f"{rest_api_url.removesuffix('/v3')}/graphql" + return f"{rest_api_url}/graphql" + + def resolve_github_endpoints( *, host: str = DEFAULT_GITHUB_REST_API_URL, @@ -101,10 +107,17 @@ def resolve_github_endpoints( ) if rest_api_url is not None and graphql_url is not None: - return GithubEndpoints( + resolved_endpoints = GithubEndpoints( rest_api_url=_normalize_endpoint_url(rest_api_url, "rest_api_url"), graphql_url=_normalize_endpoint_url(graphql_url, "graphql_url"), ) + if _endpoint_origin(resolved_endpoints.rest_api_url) != _endpoint_origin( + resolved_endpoints.graphql_url + ): + raise ValueError( + "rest_api_url origin must match graphql_url origin" + ) + return resolved_endpoints legacy_rest_api_url = _normalize_endpoint_url(host, "host") if legacy_rest_api_url == DEFAULT_GITHUB_REST_API_URL: @@ -115,7 +128,7 @@ def resolve_github_endpoints( return GithubEndpoints( rest_api_url=legacy_rest_api_url, - graphql_url=f"{legacy_rest_api_url}/graphql", + graphql_url=_legacy_graphql_url(legacy_rest_api_url), ) diff --git a/tests/test_github_app_retry.py b/tests/test_github_app_retry.py index a9e6dca..5b9b1fd 100644 --- a/tests/test_github_app_retry.py +++ b/tests/test_github_app_retry.py @@ -156,6 +156,18 @@ def test_refresh_request_does_not_restore_authorization_on_cross_origin_request( assert installation.token_calls == 0 +def test_auth_does_not_attach_token_to_cross_origin_request() -> None: + installation = FakeInstallation("new-token") + auth = GitHubAppInstallationAuth(installation=installation) + request = prepared_request(None, url="https://attacker.example/redirected") + + authenticated_request = auth(request) + + assert authenticated_request is request + assert "Authorization" not in request.headers + assert installation.token_calls == 0 + + def test_retry_policy_repairs_bad_credentials_for_github_app_auth() -> None: installation = FakeInstallation("new-token") auth = GitHubAppInstallationAuth(installation=installation) diff --git a/tests/test_source_endpoints.py b/tests/test_source_endpoints.py index 82b715d..58dea2e 100644 --- a/tests/test_source_endpoints.py +++ b/tests/test_source_endpoints.py @@ -64,10 +64,28 @@ def test_resolve_github_endpoints_requires_explicit_pair( ) +@pytest.mark.parametrize( + "graphql_url", + ( + "https://graphql.ghe.example/api/graphql", + "https://ghe.example:8443/api/graphql", + ), +) +def test_resolve_github_endpoints_requires_matching_origins(graphql_url: str) -> None: + with pytest.raises( + ValueError, + match=r"rest_api_url origin must match graphql_url origin", + ): + resolve_github_endpoints( + rest_api_url="https://ghe.example/api/v3", + graphql_url=graphql_url, + ) + + def test_resolve_github_endpoints_preserves_legacy_host_behavior() -> None: assert resolve_github_endpoints(host="https://ghe.example/api/v3/") == GithubEndpoints( rest_api_url="https://ghe.example/api/v3", - graphql_url="https://ghe.example/api/v3/graphql", + graphql_url="https://ghe.example/api/graphql", ) @@ -75,6 +93,7 @@ def test_resolve_github_endpoints_preserves_legacy_host_behavior() -> None: ("setting_name", "kwargs"), ( ("host", {"host": "ghe.example/api/v3"}), + ("host", {"host": "http://ghe.example/api/v3"}), ( "rest_api_url", { @@ -82,6 +101,13 @@ def test_resolve_github_endpoints_preserves_legacy_host_behavior() -> None: "graphql_url": "https://ghe.example/api/graphql", }, ), + ( + "rest_api_url", + { + "rest_api_url": "http://ghe.example/api/v3", + "graphql_url": "https://ghe.example/api/graphql", + }, + ), ( "graphql_url", { @@ -89,6 +115,13 @@ def test_resolve_github_endpoints_preserves_legacy_host_behavior() -> None: "graphql_url": "https://ghe.example/api/graphql#fragment", }, ), + ( + "graphql_url", + { + "rest_api_url": "https://ghe.example/api/v3", + "graphql_url": "http://ghe.example/api/graphql", + }, + ), ), ) def test_resolve_github_endpoints_rejects_invalid_urls(