From d7443c5e427f9c852e03cc1362d7d9c65b3fdc62 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 21 May 2026 09:42:55 +0000 Subject: [PATCH 1/3] Handle GitHub token revocation timeouts Co-authored-by: Armen Zambrano G. --- src/github_app.py | 20 +++++++-- src/github_sdk.py | 5 ++- src/sentry_config.py | 3 +- tests/test_github_app.py | 91 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 tests/test_github_app.py diff --git a/src/github_app.py b/src/github_app.py index c18b6a9..0aea9f2 100644 --- a/src/github_app.py +++ b/src/github_app.py @@ -4,12 +4,16 @@ from __future__ import annotations import contextlib +import logging import time from typing import Generator import jwt import requests +GITHUB_API_TIMEOUT = (3.05, 10) +logger = logging.getLogger(__name__) + class GithubAppToken: def __init__(self, private_key, app_id) -> None: @@ -22,6 +26,7 @@ def get_token(self, installation_id: int) -> Generator[str, None, None]: req = requests.post( url=f"https://api.github.com/app/installations/{installation_id}/access_tokens", headers=self.headers, + timeout=GITHUB_API_TIMEOUT, ) req.raise_for_status() resp = req.json() @@ -29,10 +34,17 @@ def get_token(self, installation_id: int) -> Generator[str, None, None]: # This token expires in an hour yield resp["token"] finally: - requests.delete( - "https://api.github.com/installation/token", - headers={"Authorization": f"token {resp['token']}"}, - ) + try: + requests.delete( + "https://api.github.com/installation/token", + headers={"Authorization": f"token {resp['token']}"}, + timeout=GITHUB_API_TIMEOUT, + ).raise_for_status() + except requests.RequestException: + logger.warning( + "Failed to revoke GitHub installation token.", + exc_info=True, + ) def get_jwt_token(self, private_key, app_id): payload = { diff --git a/src/github_sdk.py b/src/github_sdk.py index cf28d01..d5d832f 100644 --- a/src/github_sdk.py +++ b/src/github_sdk.py @@ -11,6 +11,8 @@ from sentry_sdk.envelope import Envelope from sentry_sdk.utils import format_timestamp +HTTP_TIMEOUT = (3.05, 10) + class GithubSentryError(Exception): pass @@ -42,7 +44,7 @@ def __init__(self, token, dsn, dry_run=False) -> None: def _fetch_github(self, url): headers = {"Authorization": f"token {self.token}"} - req = requests.get(url, headers=headers) + req = requests.get(url, headers=headers, timeout=HTTP_TIMEOUT) req.raise_for_status() return req @@ -132,6 +134,7 @@ def _send_envelope(self, trace): self.sentry_project_url, data=body.getvalue(), headers=headers, + timeout=HTTP_TIMEOUT, ) req.raise_for_status() return req diff --git a/src/sentry_config.py b/src/sentry_config.py index 30678da..3587d39 100644 --- a/src/sentry_config.py +++ b/src/sentry_config.py @@ -15,6 +15,7 @@ SENTRY_CONFIG_API_URL = ( "https://api.github.com/repos/{owner}/.sentry/contents/sentry_config.ini" ) +GITHUB_API_TIMEOUT = (3.05, 10) def fetch_dsn_for_github_org(org: str, token: str) -> str: @@ -27,7 +28,7 @@ def fetch_dsn_for_github_org(org: str, token: str) -> str: api_url = SENTRY_CONFIG_API_URL.replace("{owner}", org) # - Get meta about sentry_config.ini file - resp = requests.get(api_url, headers=headers) + resp = requests.get(api_url, headers=headers, timeout=GITHUB_API_TIMEOUT) resp.raise_for_status() meta = resp.json() diff --git a/tests/test_github_app.py b/tests/test_github_app.py new file mode 100644 index 0000000..8e9cdd3 --- /dev/null +++ b/tests/test_github_app.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import logging + +import requests + +from src.github_app import GITHUB_API_TIMEOUT +from src.github_app import GithubAppToken + + +class FakeResponse: + def __init__(self, payload=None, error=None): + self.payload = payload or {} + self.error = error + + def json(self): + return self.payload + + def raise_for_status(self): + if self.error: + raise self.error + + +def test_get_token_revocation_timeout_is_best_effort(monkeypatch, caplog): + delete_calls = [] + + def fake_post(url, headers, timeout): + return FakeResponse({"token": "installation-token"}) + + def fake_delete(url, headers, timeout): + delete_calls.append( + { + "url": url, + "headers": headers, + "timeout": timeout, + } + ) + raise requests.ConnectTimeout() + + monkeypatch.setattr("src.github_app.requests.post", fake_post) + monkeypatch.setattr("src.github_app.requests.delete", fake_delete) + + token_client = GithubAppToken.__new__(GithubAppToken) + token_client.headers = {"Authorization": "Bearer jwt-token"} + + caplog.set_level(logging.WARNING) + with token_client.get_token(123) as token: + assert token == "installation-token" + + assert delete_calls == [ + { + "url": "https://api.github.com/installation/token", + "headers": {"Authorization": "token installation-token"}, + "timeout": GITHUB_API_TIMEOUT, + } + ] + assert "Failed to revoke GitHub installation token." in caplog.text + + +def test_get_token_requests_access_token_with_timeout(monkeypatch): + post_calls = [] + + def fake_post(url, headers, timeout): + post_calls.append( + { + "url": url, + "headers": headers, + "timeout": timeout, + } + ) + return FakeResponse({"token": "installation-token"}) + + monkeypatch.setattr("src.github_app.requests.post", fake_post) + monkeypatch.setattr( + "src.github_app.requests.delete", + lambda url, headers, timeout: FakeResponse(), + ) + + token_client = GithubAppToken.__new__(GithubAppToken) + token_client.headers = {"Authorization": "Bearer jwt-token"} + + with token_client.get_token(123) as token: + assert token == "installation-token" + + assert post_calls == [ + { + "url": "https://api.github.com/app/installations/123/access_tokens", + "headers": {"Authorization": "Bearer jwt-token"}, + "timeout": GITHUB_API_TIMEOUT, + } + ] From 98dc461b8a81e552590eac26070ae51a4f59a7fb Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Thu, 21 May 2026 06:53:18 -0400 Subject: [PATCH 2/3] Potential fix for pull request finding 'CodeQL / Full server-side request forgery' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/github_sdk.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/github_sdk.py b/src/github_sdk.py index d5d832f..f753284 100644 --- a/src/github_sdk.py +++ b/src/github_sdk.py @@ -6,6 +6,7 @@ import logging import uuid from datetime import datetime +from urllib.parse import urlparse import requests from sentry_sdk.envelope import Envelope @@ -31,6 +32,7 @@ def get_uuid_from_string(input_string): class GithubClient: # This transform GH jobs conclusion keywords to Sentry performance status github_status_trace_status = {"success": "ok", "failure": "internal_error"} + allowed_github_hosts = {"api.github.com", "github.com"} def __init__(self, token, dsn, dry_run=False) -> None: self.token = token @@ -41,7 +43,15 @@ def __init__(self, token, dsn, dry_run=False) -> None: # '{BASE_URI}/api/{PROJECT_ID}/{ENDPOINT}/' self.sentry_project_url = f"{base_uri}/api/{project_id}/envelope/" + def _validate_github_url(self, url): + parsed = urlparse(url) + if parsed.scheme != "https": + raise GithubSentryError(f"Blocked non-HTTPS URL: {url}") + if not parsed.hostname or parsed.hostname not in self.allowed_github_hosts: + raise GithubSentryError(f"Blocked non-GitHub URL host: {url}") + def _fetch_github(self, url): + self._validate_github_url(url) headers = {"Authorization": f"token {self.token}"} req = requests.get(url, headers=headers, timeout=HTTP_TIMEOUT) From 94a0be9611a719159948c7590f7e214f4fcf42eb Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Thu, 21 May 2026 06:53:41 -0400 Subject: [PATCH 3/3] Potential fix for pull request finding 'CodeQL / Full server-side request forgery' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/sentry_config.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/sentry_config.py b/src/sentry_config.py index 3587d39..373bd51 100644 --- a/src/sentry_config.py +++ b/src/sentry_config.py @@ -3,6 +3,7 @@ import base64 import logging import os +import re from configparser import ConfigParser from functools import lru_cache @@ -16,6 +17,7 @@ "https://api.github.com/repos/{owner}/.sentry/contents/sentry_config.ini" ) GITHUB_API_TIMEOUT = (3.05, 10) +GITHUB_OWNER_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,39}(? str: @@ -25,7 +27,11 @@ def fetch_dsn_for_github_org(org: str, token: str) -> str: "Authorization": f"token {token}", } try: - api_url = SENTRY_CONFIG_API_URL.replace("{owner}", org) + if not isinstance(org, str) or not GITHUB_OWNER_RE.fullmatch(org): + raise ValueError("Invalid GitHub organization/owner name.") + + safe_org = requests.utils.quote(org, safe="") + api_url = SENTRY_CONFIG_API_URL.replace("{owner}", safe_org) # - Get meta about sentry_config.ini file resp = requests.get(api_url, headers=headers, timeout=GITHUB_API_TIMEOUT)