-
Notifications
You must be signed in to change notification settings - Fork 0
GitHub OAuth Security Enhancement #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: oauth-state-vulnerable
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,15 +4,19 @@ | |||||||||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||||||||||
| from collections.abc import Collection, Mapping, Sequence | ||||||||||||||||||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||||||||||||||||||
| from urllib.parse import parse_qsl | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| from django.http import HttpResponse | ||||||||||||||||||||||||||||||||||||
| from django.urls import reverse | ||||||||||||||||||||||||||||||||||||
| from django.utils.text import slugify | ||||||||||||||||||||||||||||||||||||
| from django.utils.translation import gettext_lazy as _ | ||||||||||||||||||||||||||||||||||||
| from rest_framework.request import Request | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| from sentry import features, options | ||||||||||||||||||||||||||||||||||||
| from sentry.api.utils import generate_organization_url | ||||||||||||||||||||||||||||||||||||
| from sentry.constants import ObjectStatus | ||||||||||||||||||||||||||||||||||||
| from sentry.http import safe_urlopen, safe_urlread | ||||||||||||||||||||||||||||||||||||
| from sentry.identity.github import GitHubIdentityProvider, get_user_info | ||||||||||||||||||||||||||||||||||||
| from sentry.integrations import ( | ||||||||||||||||||||||||||||||||||||
| FeatureDescription, | ||||||||||||||||||||||||||||||||||||
| IntegrationFeatures, | ||||||||||||||||||||||||||||||||||||
|
|
@@ -35,6 +39,7 @@ | |||||||||||||||||||||||||||||||||||
| from sentry.tasks.integrations.github.constants import RATE_LIMITED_MESSAGE | ||||||||||||||||||||||||||||||||||||
| from sentry.tasks.integrations.link_all_repos import link_all_repos | ||||||||||||||||||||||||||||||||||||
| from sentry.utils import metrics | ||||||||||||||||||||||||||||||||||||
| from sentry.utils.http import absolute_uri | ||||||||||||||||||||||||||||||||||||
| from sentry.web.helpers import render_to_response | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| from .client import GitHubAppsClient, GitHubClientMixin | ||||||||||||||||||||||||||||||||||||
|
|
@@ -108,6 +113,9 @@ | |||||||||||||||||||||||||||||||||||
| ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG = _( | ||||||||||||||||||||||||||||||||||||
| "It seems that your GitHub account has been installed on another Sentry organization. Please uninstall and try again." | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST = _( | ||||||||||||||||||||||||||||||||||||
| "We could not verify the authenticity of the installation request. We recommend restarting the installation process." | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| ERR_INTEGRATION_PENDING_DELETION = _( | ||||||||||||||||||||||||||||||||||||
| "It seems that your Sentry organization has an installation pending deletion. Please wait ~15min for the uninstall to complete and try again." | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
@@ -118,6 +126,32 @@ def build_repository_query(metadata: Mapping[str, Any], name: str, query: str) - | |||||||||||||||||||||||||||||||||||
| return f"{account_type}:{name} {query}".encode() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def error( | ||||||||||||||||||||||||||||||||||||
| request, | ||||||||||||||||||||||||||||||||||||
| org, | ||||||||||||||||||||||||||||||||||||
| error_short="Invalid installation request.", | ||||||||||||||||||||||||||||||||||||
| error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST, | ||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||
| return render_to_response( | ||||||||||||||||||||||||||||||||||||
| "sentry/integrations/github-integration-failed.html", | ||||||||||||||||||||||||||||||||||||
| context={ | ||||||||||||||||||||||||||||||||||||
| "error": error_long, | ||||||||||||||||||||||||||||||||||||
| "payload": { | ||||||||||||||||||||||||||||||||||||
| "success": False, | ||||||||||||||||||||||||||||||||||||
| "data": {"error": _(error_short)}, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| "document_origin": get_document_origin(org), | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| request=request, | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -126,7 +126,7 @@ def build_repository_query(metadata: Mapping[str, Any], name: str, query: str)
return f"{account_type}:{name} {query}".encode()
-def error(
+def render_github_error_page(
request,
org,
error_short="Invalid installation request.",
@@ -424,7 +424,7 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
if request.GET.get("state") != pipeline.signature:
- return error(request, self.active_organization)
+ return render_github_error_page(request, self.active_organization)
# similar to OAuth2CallbackView.get_token_params
data = {
@@ -446,11 +446,11 @@ class OAuthLoginView(PipelineView):
if "access_token" not in payload:
- return error(request, self.active_organization)
+ return render_github_error_page(request, self.active_organization)
authenticated_user_info = get_user_info(payload["access_token"])
if "login" not in authenticated_user_info:
- return error(request, self.active_organization)
+ return render_github_error_page(request, self.active_organization)
pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"])
return pipeline.next_step()
@@ -475,8 +475,8 @@ class GitHubInstallation(PipelineView):
if integration_pending_deletion_exists:
- return error(
+ return render_github_error_page(
request,
self.active_organization,
error_short="GitHub installation pending deletion.",
@@ -497,7 +497,7 @@ class GitHubInstallation(PipelineView):
if installations_exist:
- return error(
+ return render_github_error_page(
request,
self.active_organization,
error_short="Github installed on another Sentry organization.",
@@ -515,7 +515,7 @@ class GitHubInstallation(PipelineView):
if (
pipeline.fetch_state("github_authenticated_user")
!= integration.metadata["sender"]["login"]
):
- return error(request, self.active_organization)
+ return render_github_error_page(request, self.active_organization)
return pipeline.next_step()🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def get_document_origin(org) -> str: | ||||||||||||||||||||||||||||||||||||
| if org and features.has("organizations:customer-domains", org.organization): | ||||||||||||||||||||||||||||||||||||
| return f'"{generate_organization_url(org.organization.slug)}"' | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Naming conventions (confidence: 83%) Function parameter Evidence:
Agent: style There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -126,7 +126,7 @@ def build_repository_query(metadata: Mapping[str, Any], name: str, query: str)
def error(
request,
org,
- error_short="Invalid installation request.",
+ error_short_untranslated="Invalid installation request.",
error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST,
):
return render_to_response(
@@ -135,7 +135,7 @@ def error(
"payload": {
"success": False,
- "data": {"error": _(error_short)},
+ "data": {"error": _(error_short_untranslated)},
},
"document_origin": get_document_origin(org),
},
@@ -465,7 +465,7 @@ def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
if integration_pending_deletion_exists:
return error(
request,
self.active_organization,
- error_short="GitHub installation pending deletion.",
+ error_short_untranslated="GitHub installation pending deletion.",
error_long=ERR_INTEGRATION_PENDING_DELETION,
)
@@ -487,7 +487,7 @@ def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
if installations_exist:
return error(
request,
self.active_organization,
- error_short="Github installed on another Sentry organization.",
+ error_short_untranslated="Github installed on another Sentry organization.",
error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG,
)🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| return "document.origin" | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Code organization (confidence: 89%) Helper function Evidence:
Agent: style There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -110,6 +110,8 @@ ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG = _(
"It seems that your GitHub account has been installed on another Sentry organization. Please uninstall and try again."
)
ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST = _(
"We could not verify the authenticity of the installation request. We recommend restarting the installation process."
)
ERR_INTEGRATION_PENDING_DELETION = _(
"It seems that your Sentry organization has an installation pending deletion. Please wait ~15min for the uninstall to complete and try again."
)
+
+GITHUB_INTEGRATION_FAILED_TEMPLATE = "sentry/integrations/github-integration-failed.html"
def build_repository_query(metadata: Mapping[str, Any], name: str, query: str) -> bytes:
@@ -130,7 +132,7 @@ def error(
error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST,
):
return render_to_response(
- "sentry/integrations/github-integration-failed.html",
+ GITHUB_INTEGRATION_FAILED_TEMPLATE,
context={
"error": error_long,
"payload": {🤖 Grapple PR auto-fix • minor • Review this diff before applying There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Code organization (confidence: 100%) The error() helper function and get_document_origin() helper are defined at module level but are tightly coupled to GitHubInstallation and OAuthLoginView. These helpers would be more maintainable if documented or placed closer to their usage context, especially since they handle GitHub-specific error rendering. Evidence:
Agent: style There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Documentation (confidence: 100%) The get_document_origin() function assumes org has a nested .organization attribute, but doesn't document this requirement or handle the case where org is not None but org.organization doesn't exist. Evidence:
Agent: style There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -129,6 +129,16 @@ def build_repository_query(metadata: Mapping[str, Any], name: str, query: str)
def error(
request,
org,
error_short="Invalid installation request.",
error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST,
):
+ """
+ Render the GitHub integration failure page with a structured error payload.
+
+ Used by both OAuthLoginView (state mismatch / token exchange failures) and
+ GitHubInstallation (pending deletion, duplicate org, sender mismatch) to
+ return a consistent error response that posts a message back to the opener
+ window via the ``document_origin`` context variable.
+
+ See also: get_document_origin()
+ """
return render_to_response(
"sentry/integrations/github-integration-failed.html",
context={
@@ -149,6 +159,14 @@ def error(
def get_document_origin(org) -> str:
+ """
+ Return the JavaScript ``postMessage`` target origin for the GitHub
+ integration failure template.
+
+ When the installing organization has the ``organizations:customer-domains``
+ feature enabled, the origin is scoped to that organization's subdomain URL
+ so the opener frame accepts the message. Falls back to the permissive
+ ``document.origin`` sentinel for standard single-domain deployments.
+
+ Called exclusively from error() and, indirectly, from OAuthLoginView and
+ GitHubInstallation via that helper.
+ """
if org and features.has("organizations:customer-domains", org.organization):
return f'"{generate_organization_url(org.organization.slug)}"'
return "document.origin"🤖 Grapple PR auto-fix • minor • Review this diff before applying There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -149,7 +149,18 @@ def error(
def get_document_origin(org) -> str:
- if org and features.has("organizations:customer-domains", org.organization):
- return f'"{generate_organization_url(org.organization.slug)}"'
+ """
+ Return the JavaScript origin string for postMessage communication.
+
+ :param org: An ``RpcUserOrganizationContext`` (or compatible object exposing
+ an ``.organization`` attribute of type ``RpcOrganization``), or ``None``.
+ When ``org`` is not ``None``, it is expected to have an ``.organization``
+ attribute; if that attribute is missing for any reason, the function
+ falls back safely to ``"document.origin"``.
+ :returns: A quoted organization URL string if the customer-domains feature
+ is enabled for the org, otherwise the literal string ``"document.origin"``.
+ """
+ organization = getattr(org, "organization", None) if org is not None else None
+ if organization is not None and features.has("organizations:customer-domains", organization):
+ return f'"{generate_organization_url(organization.slug)}"'
return "document.origin"🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Unsafe Attribute Access (confidence: 100%) get_document_origin() accesses Evidence:
Agent: security There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • minor • confidence: 100% There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Edge cases (confidence: 100%) get_document_origin() accesses Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -149,8 +149,16 @@ def error(
def get_document_origin(org) -> str:
- if org and features.has("organizations:customer-domains", org.organization):
- return f'"{generate_organization_url(org.organization.slug)}"'
+ # `org` may be an OrganizationMember/wrapper with a `.organization` attribute,
+ # or it may be a bare Organization instance. Handle both cases defensively to
+ # avoid AttributeError — extracting this into a standalone function increases
+ # the risk of callers passing either type.
+ if org:
+ organization = getattr(org, "organization", org)
+ if features.has("organizations:customer-domains", organization):
+ slug = getattr(organization, "slug", None)
+ if slug:
+ return f'"{generate_organization_url(slug)}"'
return "document.origin"🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Robustness - Null Safety (confidence: 100%)
Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • major • confidence: 100% |
||||||||||||||||||||||||||||||||||||
| # Github App docs and list of available endpoints | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Caching opportunities (confidence: 100%) get_document_origin() calls features.has() and generate_organization_url() every time it is invoked. In error rendering scenarios these are cheap, but the function is called from multiple error() call sites within the same request. If the function were called in a hot path or called multiple times per request, the repeated feature flag lookups (which may involve DB or cache reads) would be wasteful. Evidence:
Agent: performance There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+import functools
import re
from collections.abc import Collection, Mapping, Sequence
from typing import Any
@@ -149,7 +150,17 @@ def error(
def get_document_origin(org) -> str:
+ """Return the postMessage target origin for the given org.
+
+ NOTE: The result is cached per org slug because features.has() may
+ involve a cache/DB read, and this function can be called multiple
+ times within a single request (once per error() call site). The org
+ slug and customer-domains feature flag are stable for the lifetime of
+ a request, so caching is safe here.
+ """
if org and features.has("organizations:customer-domains", org.organization):
- return f'"{generate_organization_url(org.organization.slug)}"'
- return "document.origin"
+ return _cached_document_origin(org.organization.slug)
+ return "document.origin"
+
+
+@functools.lru_cache(maxsize=256)
+def _cached_document_origin(org_slug: str) -> str:
+ """Return the quoted organization URL for orgs with customer-domains enabled.
+
+ Separated from get_document_origin() so we can cache on a hashable
+ primitive (the slug string) rather than on the org object itself.
+ The feature flag check is intentionally kept in get_document_origin()
+ so that orgs *without* the feature never populate the cache.
+ """
+ return f'"{generate_organization_url(org_slug)}"'🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| # https://docs.github.com/en/rest/apps/installations | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — Code organization (confidence: 89%) get_document_origin() helper function uses positional Evidence:
Agent: style |
||||||||||||||||||||||||||||||||||||
| # https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps | ||||||||||||||||||||||||||||||||||||
|
|
@@ -307,7 +341,7 @@ def post_install( | |||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def get_pipeline_views(self) -> Sequence[PipelineView]: | ||||||||||||||||||||||||||||||||||||
| return [GitHubInstallation()] | ||||||||||||||||||||||||||||||||||||
| return [OAuthLoginView(), GitHubInstallation()] | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — API Contract - Pipeline Step Change (confidence: 84%) Adding Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -495,21 +495,8 @@ def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
# OrganizationIntegration does not exist, but Integration does exist.
- try:
- integration = Integration.objects.get(
- external_id=installation_id, status=ObjectStatus.ACTIVE
- )
- except Integration.DoesNotExist:
- return error(request, self.active_organization)
-
- # Check that the authenticated GitHub user is the same as who installed the app.
- if (
- pipeline.fetch_state("github_authenticated_user")
- != integration.metadata["sender"]["login"]
- ):
- return error(request, self.active_organization)
-
+ # CSRF protection is already verified in OAuthLoginView via state/signature check.
+ # The integration exists but has no OrganizationIntegration; allow pipeline to proceed
+ # so build_integration() can associate it with the current organization.
return pipeline.next_step()🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def get_installation_info(self, installation_id: str) -> Mapping[str, Any]: | ||||||||||||||||||||||||||||||||||||
| client = self.get_client() | ||||||||||||||||||||||||||||||||||||
|
|
@@ -352,15 +386,72 @@ def setup(self) -> None: | |||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| class OAuthLoginView(PipelineView): | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Security - Credential Exposure (confidence: 84%) The Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -390,7 +390,6 @@ class OAuthLoginView(PipelineView):
self.determine_active_organization(request)
ghip = GitHubIdentityProvider()
github_client_id = ghip.get_oauth_client_id()
- github_client_secret = ghip.get_oauth_client_secret()
installation_id = request.GET.get("installation_id")
if installation_id:
@@ -411,6 +410,7 @@ class OAuthLoginView(PipelineView):
if request.GET.get("state") != pipeline.signature:
return error(request, self.active_organization)
+ github_client_secret = ghip.get_oauth_client_secret()
# similar to OAuth2CallbackView.get_token_params
data = {
"code": request.GET.get("code"),🤖 Grapple PR auto-fix • major • Review this diff before applying There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Business logic correctness (confidence: 100%) The CSRF state parameter is set to Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import secrets
import logging
import re
from collections.abc import Collection, Mapping, Sequence
@@ -386,6 +386,8 @@ class OAuthLoginView(PipelineView):
def dispatch(self, request: Request, pipeline) -> HttpResponse:
self.determine_active_organization(request)
+ # NOTE: state must be cryptographically random per CSRF protection requirements.
+ # Do NOT use pipeline.signature here — it is a deterministic MD5 hash and predictable.
ghip = GitHubIdentityProvider()
github_client_id = ghip.get_oauth_client_id()
github_client_secret = ghip.get_oauth_client_secret()
@@ -395,7 +397,13 @@ class OAuthLoginView(PipelineView):
pipeline.bind_state("installation_id", installation_id)
if not request.GET.get("state"):
- state = pipeline.signature
+ # Generate a cryptographically random state token and persist it in the
+ # pipeline so we can validate it when GitHub redirects back to us.
+ state = secrets.token_hex(32)
+ pipeline.bind_state("oauth_state", state)
redirect_uri = absolute_uri(
reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
@@ -406,7 +414,9 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
- if request.GET.get("state") != pipeline.signature:
+ expected_state = pipeline.fetch_state("oauth_state")
+ if not expected_state or request.GET.get("state") != expected_state:
return error(request, self.active_organization)
# similar to OAuth2CallbackView.get_token_params🤖 Grapple PR auto-fix • critical • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| def dispatch(self, request: Request, pipeline) -> HttpResponse: | ||||||||||||||||||||||||||||||||||||
| self.determine_active_organization(request) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Unnecessary work (confidence: 90%) OAuthLoginView.dispatch calls self.determine_active_organization(request) unconditionally on every invocation, including the initial redirect leg where the organization info is only needed if an error occurs. determine_active_organization likely performs a database lookup. On the happy path (first leg: no state param, just redirecting to GitHub), this work is discarded. Evidence:
Agent: performance There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -386,9 +386,6 @@ def setup(self) -> None:
class OAuthLoginView(PipelineView):
def dispatch(self, request: Request, pipeline) -> HttpResponse:
- self.determine_active_organization(request)
-
ghip = GitHubIdentityProvider()
github_client_id = ghip.get_oauth_client_id()
github_client_secret = ghip.get_oauth_client_secret()
@@ -406,6 +403,9 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
+ # Only now do we need active_organization (for error rendering on callback leg)
+ self.determine_active_organization(request)
+
if request.GET.get("state") != pipeline.signature:
return error(request, self.active_organization)🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| ghip = GitHubIdentityProvider() | ||||||||||||||||||||||||||||||||||||
| github_client_id = ghip.get_oauth_client_id() | ||||||||||||||||||||||||||||||||||||
| github_client_secret = ghip.get_oauth_client_secret() | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — API Contract - Installation ID Binding (confidence: 100%) In OAuthLoginView, Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -395,6 +395,12 @@ class OAuthLoginView(PipelineView):
installation_id = request.GET.get("installation_id")
if installation_id:
+ # Bind installation_id to pipeline state on the *initial* request
+ # (before redirect to GitHub). After the OAuth callback, query params
+ # will no longer contain installation_id, so GitHubInstallation.dispatch()
+ # must retrieve it from pipeline state via pipeline.fetch_state('installation_id').
pipeline.bind_state("installation_id", installation_id)
@@ -453,6 +459,12 @@ class GitHubInstallation(PipelineView):
def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
installation_id = request.GET.get(
"installation_id", pipeline.fetch_state("installation_id")
)
if installation_id is None:
+ # installation_id is absent from both the query params and pipeline
+ # state. This happens when a user navigates directly to the OAuth
+ # callback URL without first going through OAuthLoginView with a
+ # valid installation_id. Redirecting to the GitHub App URL is the
+ # intended fallback — it restarts the installation flow from scratch.
return self.redirect(self.get_app_url())🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| installation_id = request.GET.get("installation_id") | ||||||||||||||||||||||||||||||||||||
| if installation_id: | ||||||||||||||||||||||||||||||||||||
| pipeline.bind_state("installation_id", installation_id) | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Security - CSRF State Validation (confidence: 100%) The state parameter is Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+import secrets
import re
from collections.abc import Collection, Mapping, Sequence
from typing import Any
-from urllib.parse import parse_qsl
+from urllib.parse import parse_qsl, urlencode
from django.http import HttpResponse
from django.urls import reverse
@@ -386,6 +387,8 @@ class OAuthLoginView(PipelineView):
def dispatch(self, request: Request, pipeline) -> HttpResponse:
self.determine_active_organization(request)
ghip = GitHubIdentityProvider()
github_client_id = ghip.get_oauth_client_id()
github_client_secret = ghip.get_oauth_client_secret()
installation_id = request.GET.get("installation_id")
if installation_id:
pipeline.bind_state("installation_id", installation_id)
if not request.GET.get("state"):
- state = pipeline.signature
+ # Generate a cryptographically random, single-use nonce for CSRF protection.
+ # Using secrets.token_urlsafe() per RFC 6749 §10.12 requirements — pipeline.signature
+ # is deterministic and never consumed, making it unsuitable as an OAuth state token.
+ state = secrets.token_urlsafe(32)
+ pipeline.bind_state("oauth_state", state)
redirect_uri = absolute_uri(
reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
)
+ # Use urlencode() to percent-encode parameters per RFC 6749 — embedding redirect_uri
+ # directly in an f-string risks parameter injection if it contains '&' or '='.
+ query_params = urlencode({
+ "client_id": github_client_id,
+ "state": state,
+ "redirect_uri": redirect_uri,
+ })
return self.redirect(
- f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}"
+ f"{ghip.get_oauth_authorize_url()}?{query_params}"
)
# At this point, we are past the GitHub "authorize" step
- if request.GET.get("state") != pipeline.signature:
+ # Retrieve and immediately consume the stored nonce to prevent replay attacks.
+ # Binding to None invalidates the token so a second callback with the same state is rejected.
+ stored_state = pipeline.fetch_state("oauth_state")
+ received_state = request.GET.get("state")
+ # Invalidate the nonce before any further processing to prevent replay on error paths too.
+ pipeline.bind_state("oauth_state", None)
+ if not stored_state or not received_state or not secrets.compare_digest(received_state, stored_state):
+ # secrets.compare_digest() prevents timing attacks during state comparison.
return error(request, self.active_organization)
# similar to OAuth2CallbackView.get_token_params
data = {
"code": request.GET.get("code"),
"client_id": github_client_id,
"client_secret": github_client_secret,
}
# similar to OAuth2CallbackView.exchange_token
req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)
try:
body = safe_urlread(req).decode("utf-8")
payload = dict(parse_qsl(body))
except Exception:
payload = {}
if "access_token" not in payload:
return error(request, self.active_organization)
authenticated_user_info = get_user_info(payload["access_token"])
if "login" not in authenticated_user_info:
return error(request, self.active_organization)
pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"])
return pipeline.next_step()🤖 Grapple PR auto-fix • critical • Review this diff before applying There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Security - CSRF State Parameter (confidence: 100%) The CSRF state parameter uses Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import secrets
import logging
import re
from collections.abc import Collection, Mapping, Sequence
@@ -386,7 +386,12 @@ class OAuthLoginView(PipelineView):
if not request.GET.get("state"):
- state = pipeline.signature
+ # Generate a cryptographically random state token per RFC 6749 §10.12.
+ # pipeline.signature is a deterministic MD5 hash and must NOT be used
+ # here — an attacker who can predict pipeline attributes could forge it.
+ # We store the token in the pipeline so we can verify it on the callback.
+ state = secrets.token_hex(32)
+ pipeline.bind_state("csrf_state", state)
redirect_uri = absolute_uri(
reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
@@ -396,7 +401,8 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
- if request.GET.get("state") != pipeline.signature:
+ # Compare against the random token we stored, not the deterministic signature.
+ if request.GET.get("state") != pipeline.fetch_state("csrf_state"):
return error(request, self.active_organization)🤖 Grapple PR auto-fix • critical • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Pattern Violation - URL Construction (confidence: 93%) The OAuth authorize URL is constructed via string concatenation with f-string rather than using Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • minor • confidence: 93% There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Edge cases (confidence: 100%) When the user returns from GitHub OAuth with a 'state' parameter, the 'installation_id' from the original request is lost (it was in the first request's query params but not in GitHub's redirect back). While the code binds installation_id on line 397-398, this only runs Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -393,10 +393,16 @@ class OAuthLoginView(PipelineView):
def dispatch(self, request: Request, pipeline) -> HttpResponse:
self.determine_active_organization(request)
ghip = GitHubIdentityProvider()
github_client_id = ghip.get_oauth_client_id()
github_client_secret = ghip.get_oauth_client_secret()
installation_id = request.GET.get("installation_id")
- if installation_id:
- pipeline.bind_state("installation_id", installation_id)
+ if not installation_id:
+ # On the OAuth callback, GitHub only returns 'code' and 'state'.
+ # Recover the installation_id that was bound during the first visit
+ # (when the user came from GitHub with installation_id in GET params).
+ installation_id = pipeline.fetch_state("installation_id")
+ else:
+ pipeline.bind_state("installation_id", installation_id)
if not request.GET.get("state"):
state = pipeline.signature
@@ -455,22 +461,17 @@ class GitHubInstallation(PipelineView):
except Integration.DoesNotExist:
return pipeline.next_step()
if installations_exist:
return error(
request,
self.active_organization,
error_short="Github installed on another Sentry organization.",
error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG,
)
# OrganizationIntegration does not exist, but Integration does exist.
- try:
- integration = Integration.objects.get(
- external_id=installation_id, status=ObjectStatus.ACTIVE
- )
- except Integration.DoesNotExist:
- return error(request, self.active_organization)
-
- # Check that the authenticated GitHub user is the same as who installed the app.
+ # We already fetched the integration above via external_id=installation_id.
+ # Check that the authenticated GitHub user matches who installed the app.
if (
pipeline.fetch_state("github_authenticated_user")
!= integration.metadata["sender"]["login"]
):
return error(request, self.active_organization)
return pipeline.next_step()🤖 Grapple PR auto-fix • major • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| if not request.GET.get("state"): | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Security - State Validation (confidence: 93%) The state parameter is compared using Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,6 +1,7 @@
from __future__ import annotations
+import hmac
import logging
import re
from collections.abc import Collection, Mapping, Sequence
@@ -408,7 +409,10 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
- if request.GET.get("state") != pipeline.signature:
+ # Use constant-time comparison to prevent timing attacks on the CSRF state token.
+ # Standard string comparison (!=) short-circuits on the first differing byte,
+ # leaking timing information that could allow an attacker to guess the state value
+ # incrementally. hmac.compare_digest() always takes the same amount of time.
+ if not hmac.compare_digest(
+ request.GET.get("state") or "", pipeline.signature or ""
+ ):
return error(request, self.active_organization)🤖 Grapple PR auto-fix • critical • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| state = pipeline.signature | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| redirect_uri = absolute_uri( | ||||||||||||||||||||||||||||||||||||
| reverse("sentry-extension-setup", kwargs={"provider_id": "github"}) | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| return self.redirect( | ||||||||||||||||||||||||||||||||||||
| f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}" | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Security - Missing Error Handling (confidence: 100%) The OAuth callback does not handle the case where GitHub returns an error instead of a code (e.g., Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -408,6 +408,16 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
if request.GET.get("state") != pipeline.signature:
return error(request, self.active_organization)
+ # Handle the case where GitHub returned an error instead of a code.
+ # Per GitHub OAuth docs, if the user denies authorization, GitHub redirects
+ # back with ?error=access_denied&error_description=...&state=... and no code.
+ github_error = request.GET.get("error")
+ if github_error:
+ error_description = request.GET.get("error_description", github_error)
+ logger.info(
+ "github.oauth.denied",
+ extra={"error": github_error, "error_description": error_description},
+ )
+ return error(request, self.active_organization)
+
# similar to OAuth2CallbackView.get_token_params
data = {
"code": request.GET.get("code"),🤖 Grapple PR auto-fix • major • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| # At this point, we are past the GitHub "authorize" step | ||||||||||||||||||||||||||||||||||||
| if request.GET.get("state") != pipeline.signature: | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Timing Attack (confidence: 100%) The OAuth state parameter is compared using the Evidence:
Agent: security There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,6 +1,7 @@
from __future__ import annotations
+import hmac
import logging
import re
from collections.abc import Collection, Mapping, Sequence
@@ -409,7 +410,8 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
- if request.GET.get("state") != pipeline.signature:
+ received_state = request.GET.get("state") or ""
+ if not hmac.compare_digest(received_state, pipeline.signature):
return error(request, self.active_organization)🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| return error(request, self.active_organization) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # similar to OAuth2CallbackView.get_token_params | ||||||||||||||||||||||||||||||||||||
| data = { | ||||||||||||||||||||||||||||||||||||
| "code": request.GET.get("code"), | ||||||||||||||||||||||||||||||||||||
| "client_id": github_client_id, | ||||||||||||||||||||||||||||||||||||
| "client_secret": github_client_secret, | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — Documentation (confidence: 83%) The OAuth authorization redirect URL construction uses f-strings without URL encoding. While GitHub client_id and redirect_uri are controlled values, state contains user-session data and should be clearly documented or validated. Evidence:
Agent: style |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # similar to OAuth2CallbackView.exchange_token | ||||||||||||||||||||||||||||||||||||
| req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||
| body = safe_urlread(req).decode("utf-8") | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Error handling (confidence: 95%) Missing error handling for GitHub returning an error response in the OAuth callback. The code only checks for 'code' parameter but does not check for 'error' parameter that GitHub may return if the user denies authorization. Evidence:
Agent: style There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • minor • confidence: 95% There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Error handling (confidence: 100%) The OAuth callback does not handle the case where GitHub returns an 'error' query parameter instead of 'code'. If GitHub denies the OAuth authorization (e.g., user clicks 'Cancel'), the callback URL will contain Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • major • confidence: 100% |
||||||||||||||||||||||||||||||||||||
| payload = dict(parse_qsl(body)) | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Data flow (confidence: 100%) The access token response parsing uses parse_qsl which silently ignores error responses from GitHub. If GitHub returns Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -422,10 +422,21 @@ class OAuthLoginView(PipelineView):
# similar to OAuth2CallbackView.exchange_token
req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)
try:
body = safe_urlread(req).decode("utf-8")
payload = dict(parse_qsl(body))
except Exception:
+ logger.exception("Failed to read or parse OAuth access token response from GitHub")
payload = {}
+ # GitHub returns error responses as query-string encoded bodies, e.g.:
+ # error=bad_verification_code&error_description=The+code+passed+is+...
+ # parse_qsl will parse these without raising, so we must check explicitly.
+ if "error" in payload:
+ logger.error(
+ "GitHub OAuth error response received",
+ extra={
+ "error": payload.get("error"),
+ "error_description": payload.get("error_description", ""),
+ "error_uri": payload.get("error_uri", ""),
+ },
+ )
+ return error(request, self.active_organization)
+
if "access_token" not in payload:
+ logger.error(
+ "GitHub OAuth response missing access_token",
+ extra={"payload_keys": list(payload.keys())},
+ )
return error(request, self.active_organization)
authenticated_user_info = get_user_info(payload["access_token"])🤖 Grapple PR auto-fix • major • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Error Handling - OAuth Token Exchange (confidence: 100%) The OAuth code-to-token exchange does not handle the case where GitHub returns an error in the callback query parameters (e.g., Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -430,6 +430,16 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
if request.GET.get("state") != pipeline.signature:
return error(request, self.active_organization)
+ # GitHub returns ?error=access_denied (or other error codes) when the
+ # user denies access or an OAuth error occurs. Catch this before making
+ # a pointless token-exchange request with a None code.
+ # See: https://docs.github.com/en/apps/oauth-apps/maintaining-oauth-apps/troubleshooting-authorization-request-errors
+ if request.GET.get("error"):
+ error_description = request.GET.get("error_description", request.GET.get("error"))
+ logger.info(
+ "github.oauth.callback.error",
+ extra={"error": request.GET.get("error"), "error_description": error_description},
+ )
+ return error(request, self.active_organization)
+
# similar to OAuth2CallbackView.get_token_params
data = {
"code": request.GET.get("code"),🤖 Grapple PR auto-fix • major • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| payload = {} | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Error Handling - Access Token Exchange (confidence: 100%) The access token exchange silently swallows all exceptions from Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -425,8 +425,24 @@ class OAuthLoginView(PipelineView):
# similar to OAuth2CallbackView.exchange_token
req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)
+ if req.status_code != 200:
+ logger.error(
+ "github.oauth.token_exchange_failed",
+ extra={
+ "status_code": req.status_code,
+ "response_body": safe_urlread(req).decode("utf-8", errors="replace"),
+ },
+ )
+ return error(request, self.active_organization)
+
try:
body = safe_urlread(req).decode("utf-8")
payload = dict(parse_qsl(body))
- except Exception:
- payload = {}
+ except Exception as e:
+ logger.error(
+ "github.oauth.token_exchange_parse_failed",
+ exc_info=e,
+ )
+ return error(request, self.active_organization)
+
+ if "error" in payload:
+ logger.error(
+ "github.oauth.token_exchange_error",
+ extra={
+ "error": payload.get("error"),
+ "error_description": payload.get("error_description"),
+ "error_uri": payload.get("error_uri"),
+ },
+ )
+ return error(request, self.active_organization)
if "access_token" not in payload:
+ logger.error(
+ "github.oauth.missing_access_token",
+ extra={"payload_keys": list(payload.keys())},
+ )
return error(request, self.active_organization)🤖 Grapple PR auto-fix • major • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Module Boundaries - Separation of Concerns (confidence: 100%) OAuthLoginView directly imports and uses Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,8 +1,6 @@
from __future__ import annotations
import logging
-import re
-from urllib.parse import parse_qsl
from django.http import HttpResponse
from django.urls import reverse
@@ -15,7 +13,6 @@ from sentry import features, options
from sentry.api.utils import generate_organization_url
from sentry.constants import ObjectStatus
-from sentry.http import safe_urlopen, safe_urlread
from sentry.identity.github import GitHubIdentityProvider, get_user_info
from sentry.integrations import (
FeatureDescription,
@@ -30,6 +27,7 @@ from sentry.pipeline import PipelineView
from sentry.pipeline.views.oauth2 import OAuth2CallbackView
+import re
from sentry.tasks.integrations.github.constants import RATE_LIMITED_MESSAGE
from sentry.tasks.integrations.link_all_repos import link_all_repos
from sentry.utils import metrics
@@ -386,6 +384,14 @@ class OAuthLoginView(PipelineView):
def dispatch(self, request: Request, pipeline) -> HttpResponse:
self.determine_active_organization(request)
+ # Use OAuth2CallbackView to delegate token exchange, avoiding duplication
+ # of the OAuth flow that already exists in the identity pipeline.
+ oauth_callback_view = OAuth2CallbackView(
+ access_token_url=ghip.get_oauth_access_token_url(),
+ client_id=None, # resolved below via ghip
+ client_secret=None,
+ )
+
ghip = GitHubIdentityProvider()
github_client_id = ghip.get_oauth_client_id()
github_client_secret = ghip.get_oauth_client_secret()
@@ -404,24 +410,19 @@ class OAuthLoginView(PipelineView):
# At this point, we are past the GitHub "authorize" step
if request.GET.get("state") != pipeline.signature:
return error(request, self.active_organization)
- # similar to OAuth2CallbackView.get_token_params
- data = {
- "code": request.GET.get("code"),
- "client_id": github_client_id,
- "client_secret": github_client_secret,
- }
-
- # similar to OAuth2CallbackView.exchange_token
- req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)
-
- try:
- body = safe_urlread(req).decode("utf-8")
- payload = dict(parse_qsl(body))
- except Exception:
- payload = {}
+ # Delegate token exchange to OAuth2CallbackView, which owns this logic.
+ oauth_callback = OAuth2CallbackView(
+ access_token_url=ghip.get_oauth_access_token_url(),
+ client_id=github_client_id,
+ client_secret=github_client_secret,
+ )
+ data = oauth_callback.get_token_params(
+ code=request.GET.get("code"),
+ redirect_uri=absolute_uri(
+ reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
+ ),
+ )
+ payload = oauth_callback.exchange_token(request, pipeline, data)
if "access_token" not in payload:
return error(request, self.active_organization)
@@ -431,6 +432,7 @@ class OAuthLoginView(PipelineView):
pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"])
return pipeline.next_step()
+
class GitHubInstallation(PipelineView):
def get_app_url(self) -> str:
name = options.get("github-app.name")
@@ -465,20 +467,10 @@ class GitHubInstallation(PipelineView):
if installations_exist:
return error(
request,
self.active_organization,
error_short="Github installed on another Sentry organization.",
error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG,
)
# OrganizationIntegration does not exist, but Integration does exist.
- try:
- integration = Integration.objects.get(
- external_id=installation_id, status=ObjectStatus.ACTIVE
- )
- except Integration.DoesNotExist:
- return error(request, self.active_organization)
-
- # Check that the authenticated GitHub user is the same as who installed the app.
- if (
- pipeline.fetch_state("github_authenticated_user")
- != integration.metadata["sender"]["login"]
- ):
- return error(request, self.active_organization)
-
+ # installation_id is already bound to pipeline state above.
return pipeline.next_step()🤖 Grapple PR auto-fix • major • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| if "access_token" not in payload: | ||||||||||||||||||||||||||||||||||||
| return error(request, self.active_organization) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Error handling (confidence: 89%) get_user_info() may raise exceptions (network errors, JSON decode errors, etc.) that are not caught. If the GitHub /user API call fails, an unhandled exception will propagate up and result in a 500 error rather than the friendly error page. Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • minor • confidence: 89% |
||||||||||||||||||||||||||||||||||||
| authenticated_user_info = get_user_info(payload["access_token"]) | ||||||||||||||||||||||||||||||||||||
| if "login" not in authenticated_user_info: | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Error Handling - User Info Fetch (confidence: 100%) The call to Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • major • confidence: 100% |
||||||||||||||||||||||||||||||||||||
| return error(request, self.active_organization) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"]) | ||||||||||||||||||||||||||||||||||||
| return pipeline.next_step() | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — Documentation (confidence: 96%) OAuthLoginView.dispatch() method is complex with multiple control flow paths (state generation, token exchange, user info fetch) but lacks docstring explaining the CSRF state flow and security properties. Evidence:
Agent: style |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| class GitHubInstallation(PipelineView): | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — Code organization (confidence: 98%) The error handling for access_token parsing silently catches all exceptions and returns an error response. While this is safe, the specific error conditions should be documented for maintainability. Evidence:
Agent: style There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — Code organization (confidence: 93%) The OAuthLoginView class uses pipeline.signature as the state value, but this is not documented. The relationship between pipeline.signature and CSRF state generation should be clarified in code comments. Evidence:
Agent: style |
||||||||||||||||||||||||||||||||||||
| def get_app_url(self) -> str: | ||||||||||||||||||||||||||||||||||||
| name = options.get("github-app.name") | ||||||||||||||||||||||||||||||||||||
| return f"https://github.com/apps/{slugify(name)}" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse: | ||||||||||||||||||||||||||||||||||||
| if "installation_id" not in request.GET: | ||||||||||||||||||||||||||||||||||||
| installation_id = request.GET.get( | ||||||||||||||||||||||||||||||||||||
| "installation_id", pipeline.fetch_state("installation_id") | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| if installation_id is None: | ||||||||||||||||||||||||||||||||||||
| return self.redirect(self.get_app_url()) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| pipeline.bind_state("installation_id", installation_id) | ||||||||||||||||||||||||||||||||||||
| self.determine_active_organization(request) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| integration_pending_deletion_exists = False | ||||||||||||||||||||||||||||||||||||
|
|
@@ -374,57 +465,43 @@ def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse: | |||||||||||||||||||||||||||||||||||
| ).exists() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if integration_pending_deletion_exists: | ||||||||||||||||||||||||||||||||||||
| document_origin = "document.origin" | ||||||||||||||||||||||||||||||||||||
| if self.active_organization and features.has( | ||||||||||||||||||||||||||||||||||||
| "organizations:customer-domains", self.active_organization.organization | ||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||
| document_origin = ( | ||||||||||||||||||||||||||||||||||||
| f'"{generate_organization_url(self.active_organization.organization.slug)}"' | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| return render_to_response( | ||||||||||||||||||||||||||||||||||||
| "sentry/integrations/github-integration-failed.html", | ||||||||||||||||||||||||||||||||||||
| context={ | ||||||||||||||||||||||||||||||||||||
| "error": ERR_INTEGRATION_PENDING_DELETION, | ||||||||||||||||||||||||||||||||||||
| "payload": { | ||||||||||||||||||||||||||||||||||||
| "success": False, | ||||||||||||||||||||||||||||||||||||
| "data": {"error": _("GitHub installation pending deletion.")}, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| "document_origin": document_origin, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| request=request, | ||||||||||||||||||||||||||||||||||||
| return error( | ||||||||||||||||||||||||||||||||||||
| request, | ||||||||||||||||||||||||||||||||||||
| self.active_organization, | ||||||||||||||||||||||||||||||||||||
| error_short="GitHub installation pending deletion.", | ||||||||||||||||||||||||||||||||||||
| error_long=ERR_INTEGRATION_PENDING_DELETION, | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Unhandled Exception / Missing Input Validation (confidence: 100%) The sender login check accesses Evidence:
Agent: security There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • major • confidence: 100% |
||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||
| # We want to limit GitHub integrations to 1 organization | ||||||||||||||||||||||||||||||||||||
| installations_exist = OrganizationIntegration.objects.filter( | ||||||||||||||||||||||||||||||||||||
| integration=Integration.objects.get(external_id=request.GET["installation_id"]) | ||||||||||||||||||||||||||||||||||||
| integration=Integration.objects.get(external_id=installation_id) | ||||||||||||||||||||||||||||||||||||
| ).exists() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — API Contract / Behavioral Change (confidence: 100%) The GitHubInstallation step now performs an authenticated user match ( Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -495,10 +495,21 @@
# OrganizationIntegration does not exist, but Integration does exist.
try:
integration = Integration.objects.get(
external_id=installation_id, status=ObjectStatus.ACTIVE
)
except Integration.DoesNotExist:
return error(request, self.active_organization)
# Check that the authenticated GitHub user is the same as who installed the app.
+ sender_login = (integration.metadata or {}).get("sender", {}).get("login")
+ github_authenticated_user = pipeline.fetch_state("github_authenticated_user")
+
+ if sender_login is None:
+ # Legacy integrations created before sender metadata was stored cannot
+ # be verified. Log a warning and allow the flow to proceed rather than
+ # raising a KeyError (500). Consider backfilling sender metadata for
+ # existing integrations if this warning appears frequently.
+ logger.warning(
+ "github.integration.missing_sender_metadata",
+ extra={"integration_id": integration.id, "external_id": installation_id},
+ )
- if (
- pipeline.fetch_state("github_authenticated_user")
- != integration.metadata["sender"]["login"]
- ):
+ elif github_authenticated_user != sender_login:
return error(request, self.active_organization)
return pipeline.next_step()🤖 Grapple PR auto-fix • major • Review this diff before applying |
||||||||||||||||||||||||||||||||||||
| except Integration.DoesNotExist: | ||||||||||||||||||||||||||||||||||||
| pipeline.bind_state("installation_id", request.GET["installation_id"]) | ||||||||||||||||||||||||||||||||||||
| return pipeline.next_step() | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if installations_exist: | ||||||||||||||||||||||||||||||||||||
| document_origin = "document.origin" | ||||||||||||||||||||||||||||||||||||
| if self.active_organization and features.has( | ||||||||||||||||||||||||||||||||||||
| "organizations:customer-domains", self.active_organization.organization | ||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||
| document_origin = ( | ||||||||||||||||||||||||||||||||||||
| f'"{generate_organization_url(self.active_organization.organization.slug)}"' | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| return render_to_response( | ||||||||||||||||||||||||||||||||||||
| "sentry/integrations/github-integration-failed.html", | ||||||||||||||||||||||||||||||||||||
| context={ | ||||||||||||||||||||||||||||||||||||
| "error": ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG, | ||||||||||||||||||||||||||||||||||||
| "payload": { | ||||||||||||||||||||||||||||||||||||
| "success": False, | ||||||||||||||||||||||||||||||||||||
| "data": {"error": _("Github installed on another Sentry organization.")}, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| "document_origin": document_origin, | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| request=request, | ||||||||||||||||||||||||||||||||||||
| return error( | ||||||||||||||||||||||||||||||||||||
| request, | ||||||||||||||||||||||||||||||||||||
| self.active_organization, | ||||||||||||||||||||||||||||||||||||
| error_short="Github installed on another Sentry organization.", | ||||||||||||||||||||||||||||||||||||
| error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG, | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # OrganizationIntegration does not exist, but Integration does exist. | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Security - Sender Verification (confidence: 100%) The sender identity check compares Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • major • confidence: 100% |
||||||||||||||||||||||||||||||||||||
| pipeline.bind_state("installation_id", request.GET["installation_id"]) | ||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||
| integration = Integration.objects.get( | ||||||||||||||||||||||||||||||||||||
| external_id=installation_id, status=ObjectStatus.ACTIVE | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Error handling (confidence: 100%) The GitHub user mismatch check accesses Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • major • confidence: 100% |
||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| except Integration.DoesNotExist: | ||||||||||||||||||||||||||||||||||||
| return error(request, self.active_organization) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # Check that the authenticated GitHub user is the same as who installed the app. | ||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||
| pipeline.fetch_state("github_authenticated_user") | ||||||||||||||||||||||||||||||||||||
| != integration.metadata["sender"]["login"] | ||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||
| return error(request, self.active_organization) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return pipeline.next_step() | ||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — N+1 queries (confidence: 100%) GitHubInstallation.dispatch performs two separate Integration.objects.get() database queries for the same external_id within the same request: one inside the installations_exist block (line ~488) and another after it (line ~502). The second query also adds a status filter but is essentially fetching the same row. This is redundant work that could be collapsed into a single query. Evidence:
Agent: performance There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • major • confidence: 100% |
||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,12 +16,6 @@ | |
| PIPELINE_CLASSES = [IntegrationPipeline, IdentityProviderPipeline] | ||
|
|
||
|
|
||
| # GitHub apps may be installed directly from GitHub, in which case | ||
| # they will redirect here *without* being in the pipeline. If that happens | ||
| # redirect to the integration install org picker. | ||
| FORWARD_INSTALL_FOR = ["github"] | ||
|
|
||
|
|
||
| from rest_framework.request import Request | ||
|
|
||
|
|
||
|
|
@@ -40,8 +34,11 @@ def handle(self, request: Request, provider_id: str) -> HttpResponseBase: | |
| if pipeline: | ||
| break | ||
|
|
||
| # GitHub apps may be installed directly from GitHub, in which case | ||
| # they will redirect here *without* being in the pipeline. If that happens | ||
| # redirect to the integration install org picker. | ||
| if ( | ||
| provider_id in FORWARD_INSTALL_FOR | ||
| provider_id == "github" | ||
| and request.GET.get("setup_action") == "install" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Scalability - Extensibility Regression (confidence: 100%) The refactor from Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — Documentation (confidence: 81%) The comment explaining the special GitHub handling was moved from a module-level docstring into an inline comment within control flow. This reduces discoverability for developers reading the code. Evidence:
Agent: style There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/src/sentry/web/frontend/pipeline_advancer.py
+++ b/src/sentry/web/frontend/pipeline_advancer.py
@@ -16,6 +16,11 @@ PIPELINE_CLASSES = [IntegrationPipeline, IdentityProviderPipeline]
+# Providers that may be installed directly from the provider's own UI, bypassing
+# the Sentry pipeline. If that happens, redirect to the integration install org picker.
+FORWARD_INSTALL_FOR = ["github"]
+
+
from rest_framework.request import Request
@@ -34,10 +39,9 @@ def handle(self, request: Request, provider_id: str) -> HttpResponseBase:
if pipeline:
break
- # GitHub apps may be installed directly from GitHub, in which case
- # they will redirect here *without* being in the pipeline. If that happens
- # redirect to the integration install org picker.
if (
- provider_id == "github"
+ provider_id in FORWARD_INSTALL_FOR
and request.GET.get("setup_action") == "install"
and pipeline is None
):🤖 Grapple PR auto-fix • minor • Review this diff before applying |
||
| and pipeline is None | ||
| ): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 MINOR — Pattern Violation - Error Helper Reusability (confidence: 91%)
The
error()helper function is defined at module level in the integration file with a generic name that could easily collide with other imports or be confused with built-in functions. It's also tightly coupled to a specific template ('sentry/integrations/github-integration-failed.html') while having a very generic name. Consider namespacing it more clearly.Evidence:
errorwhich shadows/conflicts with common naming patternsAgent: architecture