diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index 2a5834077d..36daf3f7c6 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -42,7 +42,7 @@ Required for scanning repository security settings: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Branch protection, security settings | All branch protection checks, secret scanning status | +| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, secret scanning status, `repository_default_workflow_permissions_read_only` | | **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` | | **Metadata** | Read | Basic repository information | All checks (automatically granted) | | **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` | @@ -83,7 +83,7 @@ With the **Read-only permissions** listed above, Prowler can run: | Check Category | Coverage | Notes | |----------------|----------|-------| | Branch protection checks (12 checks) | ✅ Full | Signed commits, status checks, PR reviews, etc. | -| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS | +| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS, default workflow permissions | | Organization checks (5 checks) | ✅ Full | MFA, repo creation policies, default permissions, Actions workflow permissions | | Compliance frameworks | ✅ Full | CIS GitHub Benchmark and others | | Merge settings (`delete_branch_on_merge`) | ⚠️ MANUAL | Requires write permission (see below) | @@ -215,7 +215,7 @@ If a GitHub App is required: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Branch protection, security settings | All branch protection checks, `repository_secret_scanning_enabled` | +| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, `repository_secret_scanning_enabled`, `repository_default_workflow_permissions_read_only` | | **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` | | **Metadata** | Read | Basic repository information | All checks (automatically granted) | | **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` | diff --git a/prowler/changelog.d/repository-default-workflow-permissions.added.md b/prowler/changelog.d/repository-default-workflow-permissions.added.md new file mode 100644 index 0000000000..0df7ab6dc4 --- /dev/null +++ b/prowler/changelog.d/repository-default-workflow-permissions.added.md @@ -0,0 +1 @@ +`repository_default_workflow_permissions_read_only` check for GitHub provider, verifying that repositories grant GitHub Actions workflows a read-only default `GITHUB_TOKEN` diff --git a/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/__init__.py b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.metadata.json b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.metadata.json new file mode 100644 index 0000000000..dac80aeeb4 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "github", + "CheckID": "repository_default_workflow_permissions_read_only", + "CheckTitle": "Repository grants workflows a read-only default GITHUB_TOKEN", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "devops", + "Description": "GitHub Actions repository settings define the default **GITHUB_TOKEN** permissions granted to every workflow run in the repository.\n\nThe evaluation determines whether the repository default is read-only, so that workflows have to opt in to write access through an explicit `permissions` block.", + "Risk": "A write-capable default **GITHUB_TOKEN** is available to every workflow in the repository, so any compromised step or dependency inherits it, leading to:\n- Code integrity loss from pushed commits, moved tags, or altered releases\n- Supply chain compromise when the token publishes packages or artifacts\n- Weakened review controls when the token acts on pull requests and issues", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#setting-the-permissions-of-the-github_token-for-your-repository", + "https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication", + "https://docs.github.com/en/rest/actions/permissions" + ], + "Remediation": { + "Code": { + "CLI": "gh api --method PUT /repos///actions/permissions/workflow -f default_workflow_permissions=read", + "NativeIaC": "", + "Other": "1. Sign in to GitHub as a repository administrator\n2. Go to the repository > Settings\n3. In the left sidebar, click Actions > General\n4. Under Workflow permissions, select Read repository contents and packages permissions\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the repository default **GITHUB_TOKEN** permissions to read-only and grant write scopes per workflow with an explicit `permissions` block, following **least privilege**.\n\nA repository can override an organization default, so verify this setting per repository even when the organization already defaults to read-only. Prefer short-lived **GitHub App** tokens over broad credentials when a workflow genuinely needs write access.", + "Url": "https://hub.prowler.com/check/repository_default_workflow_permissions_read_only" + } + }, + "Categories": [ + "ci-cd", + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.py b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.py new file mode 100644 index 0000000000..0a0e3a9eb4 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.py @@ -0,0 +1,38 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_default_workflow_permissions_read_only(Check): + """Ensure repositories grant workflows a read-only default GITHUB_TOKEN. + + A read-only default forces workflows to request write access explicitly, instead of + every workflow run starting with a token that can write to the repository. + """ + + def execute(self) -> List[CheckReportGithub]: + """Run the default workflow permissions verification for each discovered repository. + + Returns: + List[CheckReportGithub]: Collection of check reports describing the default GITHUB_TOKEN permissions. + """ + findings: List[CheckReportGithub] = [] + for repo in repository_client.repositories.values(): + if repo.default_workflow_permissions is None: + continue + + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + + if repo.default_workflow_permissions == "read": + report.status = "PASS" + report.status_extended = f"Repository {repo.name} grants workflows a read-only default GITHUB_TOKEN." + else: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} grants workflows a default GITHUB_TOKEN with {repo.default_workflow_permissions} permissions." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index fd8aa28662..dc41df2ea5 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -398,6 +398,10 @@ class Repository(GithubService): try: repo = client.get_repo(repo_name) self._process_repository(repo, repos) + except github.RateLimitExceededException: + # Rate limits are transient and must not leave the + # scan running with an incomplete repository set + raise except Exception as error: self._handle_github_api_error( error, "accessing repository", repo_name @@ -414,6 +418,10 @@ class Repository(GithubService): ) for repo in repos_list: self._process_repository(repo, repos) + except github.RateLimitExceededException: + # Rate limits are transient and must not leave the + # scan running with an incomplete repository set + raise except Exception as error: self._handle_github_api_error( error, "processing organization", org_name @@ -433,6 +441,10 @@ class Repository(GithubService): ) for repo in repos_list: self._process_repository(repo, repos) + except github.RateLimitExceededException: + # Rate limits are transient and must not leave the + # scan running with an incomplete repository set + raise except Exception as error: self._handle_github_api_error( error, "processing organization", org_name @@ -455,6 +467,10 @@ class Repository(GithubService): f"Processing repository found via GraphQL: {repo.full_name}" ) self._process_repository(repo, repos) + except github.RateLimitExceededException: + # Rate limits are transient and must not leave the + # scan running with an incomplete repository set + raise except Exception as error: if hasattr(self, "_handle_github_api_error"): self._handle_github_api_error( @@ -721,6 +737,9 @@ class Repository(GithubService): immutable_releases_enabled=self._get_repository_immutable_releases_status( repo ), + default_workflow_permissions=self._get_default_workflow_permissions( + repo + ), default_branch=Branch( name=default_branch, protected=branch_protection, @@ -758,6 +777,8 @@ class Repository(GithubService): dependabot_alerts_enabled=dependabot_alerts_enabled, delete_branch_on_merge=delete_branch_on_merge, ) + except github.RateLimitExceededException: + raise # Re-raise rate limit errors so the listing flow can handle them except Exception as error: logger.error( f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -811,6 +832,66 @@ class Repository(GithubService): ) return None + def _get_default_workflow_permissions(self, repo) -> Optional[str]: + """Retrieve the default GITHUB_TOKEN permissions granted to workflows in the repository. + + The API returns a response in the format: + { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + } + + Args: + repo: The PyGithub repository instance to query. + + Returns: + Optional[str]: "read" or "write", and None when the setting cannot be determined. + + Raises: + github.RateLimitExceededException: When API rate limits are exceeded + """ + try: + _, response = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/repos/{repo.full_name}/actions/permissions/workflow", + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + if isinstance(response, dict): + permissions = response.get("default_workflow_permissions") + if permissions in ("read", "write"): + return permissions + return None + except github.RateLimitExceededException as error: + self._handle_github_api_error( + error, + "fetching default workflow permissions", + repo.full_name, + reraise_rate_limit=True, + ) + except github.GithubException as error: + status_code = getattr(error, "status", None) + if status_code == 404: + logger.info( + f"{repo.full_name}: Actions workflow permissions endpoint not available for this repository." + ) + return None + if status_code == 403: + logger.warning( + f"{repo.full_name}: insufficient permissions to query Actions workflow permissions." + ) + return None + self._handle_github_api_error( + error, "fetching default workflow permissions", repo.full_name + ) + except Exception as error: + logger.error( + f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + class Branch(BaseModel): """Model for Github Branch""" @@ -851,6 +932,7 @@ class Repo(BaseModel): owner: str full_name: str immutable_releases_enabled: Optional[bool] = None + default_workflow_permissions: Optional[str] = None default_branch: Branch private: bool archived: bool diff --git a/tests/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only_test.py b/tests/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only_test.py new file mode 100644 index 0000000000..1c64813df1 --- /dev/null +++ b/tests/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only_test.py @@ -0,0 +1,98 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + +CHECK_CLIENT_PATH = "prowler.providers.github.services.repository.repository_default_workflow_permissions_read_only.repository_default_workflow_permissions_read_only.repository_client" + + +class Test_repository_default_workflow_permissions_read_only: + """Unit tests for the repository_default_workflow_permissions_read_only check.""" + + def _build_repo(self, default_workflow_permissions): + """Create a Repo instance with the provided default workflow permissions state.""" + default_branch = Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ) + return Repo( + id=1, + name="repo1", + owner="account-name", + full_name="account-name/repo1", + default_workflow_permissions=default_workflow_permissions, + default_branch=default_branch, + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=False, + ) + + def _run_check(self, repositories): + """Execute the check against the provided repositories.""" + repository_client = mock.MagicMock + repository_client.repositories = repositories + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch(CHECK_CLIENT_PATH, new=repository_client), + ): + from prowler.providers.github.services.repository.repository_default_workflow_permissions_read_only.repository_default_workflow_permissions_read_only import ( + repository_default_workflow_permissions_read_only, + ) + + check = repository_default_workflow_permissions_read_only() + return check.execute() + + def test_no_repositories(self): + """Test that no findings are reported when there are no repositories.""" + assert len(self._run_check({})) == 0 + + def test_default_workflow_permissions_unknown(self): + """Test that no finding is reported when the setting could not be read.""" + assert len(self._run_check({1: self._build_repo(None)})) == 0 + + def test_default_workflow_permissions_read(self): + """Test that a read-only default GITHUB_TOKEN passes the check.""" + result = self._run_check({1: self._build_repo("read")}) + + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Repository repo1 grants workflows a read-only default GITHUB_TOKEN." + ) + + def test_default_workflow_permissions_write(self): + """Test that a write-capable default GITHUB_TOKEN fails the check.""" + result = self._run_check({1: self._build_repo("write")}) + + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Repository repo1 grants workflows a default GITHUB_TOKEN with write permissions." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index d0f3573e49..4b77567e32 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -1,9 +1,12 @@ from datetime import datetime, timezone from unittest.mock import MagicMock, patch +import pytest import requests from github import GithubException, RateLimitExceededException +from pytest import raises +from prowler.providers.github.models import GithubAppIdentityInfo from prowler.providers.github.services.repository.repository_service import ( Branch, Repo, @@ -434,7 +437,7 @@ class Test_Repository_ErrorHandling: assert any("Access denied" in msg for msg in log_messages) def test_rate_limit_error_handling(self): - """Test that rate limit errors are logged appropriately""" + """Test that rate limit errors propagate out of _list_repositories""" provider = set_mocked_github_provider() provider.repositories = ["owner/repo1"] provider.organizations = [] @@ -451,17 +454,10 @@ class Test_Repository_ErrorHandling: repository_service.clients = [mock_client] repository_service.provider = provider - with patch( - "prowler.providers.github.lib.service.service.logger" - ) as mock_logger: - # Rate limit errors should be caught and logged at the outer level - repos = repository_service._list_repositories() - - # Should be empty due to rate limit error - assert len(repos) == 0 - # Should log rate limit error - mock_logger.error.assert_called() - assert "Rate limit exceeded" in str(mock_logger.error.call_args) + # Rate limits are transient: the scan must abort rather than + # continue with an incomplete repository set + with pytest.raises(RateLimitExceededException): + repository_service._list_repositories() class Test_Repository_BranchProtectionRulesets: @@ -944,3 +940,228 @@ class Test_Repository_BranchProtectionRulesets: assert repos[1].default_branch.enforce_admins_source is None # The branch is still reported as protected-but-inactive regardless of bypass. assert repos[1].default_branch.protected_source == "ruleset_not_active" + + +class Test_Repository_Default_Workflow_Permissions: + def setup_method(self): + """Build a Repository service instance without contacting GitHub.""" + self.repository_service = Repository.__new__(Repository) + self.repository_service.provider = set_mocked_github_provider() + + def _mock_repo(self): + """Create a mocked PyGithub repository.""" + repo = MagicMock() + repo.full_name = "account-name/repo1" + return repo + + def test_default_workflow_permissions_read(self): + """Test that the default workflow permissions value is read from the API response.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.return_value = ( + {}, + {"default_workflow_permissions": "read"}, + ) + + assert ( + self.repository_service._get_default_workflow_permissions(repo) == "read" + ), "The repository default GITHUB_TOKEN permissions should be read from the Actions permissions endpoint" + + def test_default_workflow_permissions_missing_field(self): + """Test that a response without the permissions field yields no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.return_value = ({}, {}) + + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "An unexpected response payload should not be reported as a permissions value" + + def test_default_workflow_permissions_unsupported_value(self): + """Test that a value outside read/write is treated as unknown rather than a failure.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.return_value = ( + {}, + {"default_workflow_permissions": "none"}, + ) + + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "An unsupported permissions value should be left unknown instead of reported as write" + + def test_default_workflow_permissions_not_available(self): + """Test that repositories without the setting yield no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = GithubException( + 404, "Not Found", None + ) + + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "A repository without Actions permissions settings should not produce a value" + + def test_default_workflow_permissions_access_denied(self): + """Test that a token without repository administration access yields no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = GithubException( + 403, "Forbidden", None + ) + + with patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger: + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "Insufficient permissions should not produce a value" + mock_logger.warning.assert_called() + + def test_default_workflow_permissions_rate_limit(self): + """Test that rate limit errors are propagated instead of being reported as unavailable.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = RateLimitExceededException( + 403, "Rate limit exceeded", None + ) + + with raises(RateLimitExceededException): + self.repository_service._get_default_workflow_permissions(repo) + + def test_default_workflow_permissions_unexpected_api_error(self): + """Test that an unexpected GitHub API error is logged and yields no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = GithubException( + 500, "Internal Server Error", None + ) + + with patch( + "prowler.providers.github.lib.service.service.logger" + ) as mock_logger: + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "An unexpected API error should not produce a value" + mock_logger.error.assert_called() + assert "Internal Server Error" in str(mock_logger.error.call_args) + + def test_default_workflow_permissions_unexpected_error(self): + """Test that an unexpected non-GitHub error is logged and yields no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = Exception("unexpected") + + with patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger: + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "An unexpected error should not produce a value" + mock_logger.error.assert_called() + + def test_process_repository_propagates_rate_limit(self): + """Test that a rate limit while reading the setting aborts repository processing.""" + self.repository_service.clients = [] + self.repository_service.audit_config = None + self.repository_service.fixer_config = None + + repo = MagicMock() + repo.id = 1 + repo.name = "repo1" + repo.owner.login = "account-name" + repo.full_name = "account-name/repo1" + repo.default_branch = "main" + repo.private = False + repo.archived = False + repo.pushed_at = datetime.now(timezone.utc) + repo.delete_branch_on_merge = False + repo.security_and_analysis = None + repo.get_contents.side_effect = [None, None, None, None] + repo.get_dependabot_alerts.side_effect = Exception("403 Forbidden") + repo._requester.requestJsonAndCheck.side_effect = RateLimitExceededException( + 403, "Rate limit exceeded", None + ) + + repos = {} + with raises(RateLimitExceededException): + self.repository_service._process_repository(repo, repos) + + assert repos == {}, "A rate limited repository should not be partially recorded" + + +class Test_Repository_List_Rate_Limit_Propagation: + """Rate limits must abort _list_repositories instead of being swallowed.""" + + def _service_with(self, provider, mock_client): + with patch.object(Repository, "__init__", lambda *_: None): + repository_service = Repository(provider) + repository_service.clients = [mock_client] + repository_service.provider = provider + return repository_service + + def test_direct_repositories_branch_propagates_rate_limit(self): + """A rate limit on a directly requested repository aborts the listing.""" + provider = set_mocked_github_provider() + provider.repositories = ["owner/repo1"] + provider.organizations = [] + + mock_client = MagicMock() + mock_client.get_repo.side_effect = RateLimitExceededException( + 429, "Rate limit exceeded", None + ) + + repository_service = self._service_with(provider, mock_client) + + with raises(RateLimitExceededException): + repository_service._list_repositories() + + def test_organization_branch_propagates_rate_limit(self): + """A rate limit while listing an organization's repositories aborts the listing.""" + provider = set_mocked_github_provider() + provider.repositories = [] + provider.organizations = ["org1"] + + mock_client = MagicMock() + with patch.object( + Repository, + "_get_repositories_from_owner", + side_effect=RateLimitExceededException(429, "Rate limit exceeded", None), + ): + repository_service = self._service_with(provider, mock_client) + + with raises(RateLimitExceededException): + repository_service._list_repositories() + + def test_installations_branch_propagates_rate_limit(self): + """A rate limit while listing a GitHub App installation's repositories aborts the listing.""" + provider = set_mocked_github_provider() + provider.repositories = [] + provider.organizations = [] + provider.identity = GithubAppIdentityInfo( + app_id="1", app_name="app", installations=["installed-org"] + ) + + mock_client = MagicMock() + with patch.object( + Repository, + "_get_repositories_from_owner", + side_effect=RateLimitExceededException(429, "Rate limit exceeded", None), + ): + repository_service = self._service_with(provider, mock_client) + + with raises(RateLimitExceededException): + repository_service._list_repositories() + + def test_graphql_branch_propagates_rate_limit(self): + """A rate limit on a repository discovered via GraphQL aborts the listing.""" + provider = set_mocked_github_provider() + provider.repositories = [] + provider.organizations = [] + + mock_client = MagicMock() + mock_client.get_repo.side_effect = RateLimitExceededException( + 429, "Rate limit exceeded", None + ) + + repository_service = self._service_with(provider, mock_client) + + with patch.object( + repository_service, + "_get_accessible_repos_graphql", + return_value=["owner1/repo1"], + ): + with raises(RateLimitExceededException): + repository_service._list_repositories()