mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(github): add repository_default_workflow_permissions_read_only check (#12143)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
co-authored by
Daniel Barranquero
parent
654d2c9f17
commit
2f11b16299
@@ -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` |
|
||||
|
||||
@@ -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`
|
||||
+39
@@ -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/<owner>/<repository>/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": ""
|
||||
}
|
||||
+38
@@ -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
|
||||
@@ -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
|
||||
|
||||
+98
@@ -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."
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user