From 2721d425947823839f8583151e6bb4127e1753e5 Mon Sep 17 00:00:00 2001 From: Chethas Dileep <97353024+Edneam@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:25:32 +0400 Subject: [PATCH] feat(github): add organization_actions_pull_request_approval_disabled check (#12394) Co-authored-by: Daniel Barranquero --- .../providers/github/authentication.mdx | 8 +- ...ion-actions-pull-request-approval.added.md | 1 + .../__init__.py | 0 ...ll_request_approval_disabled.metadata.json | 39 +++++++++ ..._actions_pull_request_approval_disabled.py | 38 +++++++++ .../organization/organization_service.py | 36 +++++++-- ...ons_pull_request_approval_disabled_test.py | 79 +++++++++++++++++++ .../organization/organization_service_test.py | 61 ++++++++++++-- 8 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 prowler/changelog.d/organization-actions-pull-request-approval.added.md create mode 100644 prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/__init__.py create mode 100644 prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.metadata.json create mode 100644 prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.py create mode 100644 tests/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled_test.py diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index 928f1a0ea7..2a5834077d 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -63,7 +63,7 @@ Required for scanning organization-level security settings: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only` | +| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` | | **Members** | Read | Member access reviews | Organization membership auditing | #### Account Permissions (Fine-Grained PAT only) @@ -84,7 +84,7 @@ With the **Read-only permissions** listed above, Prowler can run: |----------------|----------|-------| | Branch protection checks (12 checks) | ✅ Full | Signed commits, status checks, PR reviews, etc. | | Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS | -| Organization checks (4 checks) | ✅ Full | MFA, repo creation policies, default permissions, 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) | @@ -171,7 +171,7 @@ Use OAuth App Tokens when building applications that need delegated user permiss - `repo`: Full control of repositories - `read:org`: Read organization and team membership -- `admin:org`: Required by `organization_default_workflow_permissions_read_only` to read the organization Actions workflow permissions +- `admin:org`: Required by `organization_default_workflow_permissions_read_only` and `organization_actions_pull_request_approval_disabled` to read the organization Actions workflow permissions - `read:user`: Read user profile data **Create an OAuth App:** @@ -224,7 +224,7 @@ If a GitHub App is required: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only` | +| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` | | **Members** | Read | Member access reviews | Organization membership auditing | **Create a GitHub App:** diff --git a/prowler/changelog.d/organization-actions-pull-request-approval.added.md b/prowler/changelog.d/organization-actions-pull-request-approval.added.md new file mode 100644 index 0000000000..aba0be0820 --- /dev/null +++ b/prowler/changelog.d/organization-actions-pull-request-approval.added.md @@ -0,0 +1 @@ +`organization_actions_pull_request_approval_disabled` check for GitHub provider, verifying that organizations prevent GitHub Actions from creating and approving pull requests diff --git a/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/__init__.py b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.metadata.json b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.metadata.json new file mode 100644 index 0000000000..6a6af964eb --- /dev/null +++ b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "github", + "CheckID": "organization_actions_pull_request_approval_disabled", + "CheckTitle": "Organization prevents GitHub Actions from approving pull requests", + "CheckType": [], + "ServiceName": "organization", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "GitHub Actions organization settings define whether workflows are allowed to create and approve pull requests.\n\nThe evaluation determines whether that permission is disabled, so that approving a pull request always requires a human reviewer.", + "Risk": "When workflows can approve pull requests, automation can satisfy the review requirement it was meant to be checked by, leading to:\n- Required reviews being cleared without any human inspecting the change\n- Self-approving pull requests opened by a workflow\n- Merge gates that appear enforced while providing no real oversight", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#preventing-github-actions-from-creating-or-approving-pull-requests", + "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 /orgs//actions/permissions/workflow -F can_approve_pull_request_reviews=false", + "NativeIaC": "", + "Other": "1. Sign in to GitHub as an organization owner\n2. Go to your organization > Settings\n3. In the left sidebar, click Actions > General\n4. Under Workflow permissions, clear Allow GitHub Actions to create and approve pull requests\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **Allow GitHub Actions to create and approve pull requests** so that pull request approvals always come from a human reviewer.\n\nWhere automation genuinely needs to open pull requests, let it create them without granting approval rights, and keep the approval step with a reviewer who is not the author.", + "Url": "https://hub.prowler.com/check/organization_actions_pull_request_approval_disabled" + } + }, + "Categories": [ + "ci-cd", + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.py b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.py new file mode 100644 index 0000000000..92d53cf22b --- /dev/null +++ b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.py @@ -0,0 +1,38 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.organization.organization_client import ( + organization_client, +) + + +class organization_actions_pull_request_approval_disabled(Check): + """Check if GitHub Actions workflows are prevented from approving pull requests. + + This class verifies whether each organization disallows workflows from creating and + approving pull requests, so that required reviews cannot be satisfied by automation. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Organization Actions Pull Request Approval Disabled check. + + Iterates over all organizations and checks whether GitHub Actions workflows are + allowed to approve pull requests. + + Returns: + List[CheckReportGithub]: A list of reports for each organization + """ + findings = [] + for org in organization_client.organizations.values(): + if org.can_approve_pull_request_reviews is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=org) + report.status = "PASS" + report.status_extended = f"Organization {org.name} does not allow GitHub Actions to approve pull requests." + + if org.can_approve_pull_request_reviews: + report.status = "FAIL" + report.status_extended = f"Organization {org.name} allows GitHub Actions to approve pull requests." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/organization/organization_service.py b/prowler/providers/github/services/organization/organization_service.py index 2f475cc74c..e137db29db 100644 --- a/prowler/providers/github/services/organization/organization_service.py +++ b/prowler/providers/github/services/organization/organization_service.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Tuple import github from pydantic.v1 import BaseModel @@ -205,7 +205,10 @@ class Organization(GithubService): else None ) is_verified = _extract_flag("is_verified", bool) - default_workflow_permissions = self._get_default_workflow_permissions(org) + ( + default_workflow_permissions, + can_approve_pull_request_reviews, + ) = self._get_actions_workflow_permissions(org) organizations[org.id] = Org( id=org.id, name=org.login, @@ -229,20 +232,33 @@ class Organization(GithubService): base_permission=base_permission, is_verified=is_verified, default_workflow_permissions=default_workflow_permissions, + can_approve_pull_request_reviews=can_approve_pull_request_reviews, ) - def _get_default_workflow_permissions(self, org) -> Optional[str]: - """Fetch the default GITHUB_TOKEN permissions granted to workflows in the organization. + def _get_actions_workflow_permissions( + self, org + ) -> Tuple[Optional[str], Optional[bool]]: + """Fetch the Actions workflow permissions settings of the organization. + + The API returns a response in the format: + { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + } Args: org: PyGithub Organization object. Returns: - Optional[str]: "read" or "write", or None when the setting cannot be read. + Tuple[Optional[str], Optional[bool]]: The default GITHUB_TOKEN permissions + ("read" or "write") and whether workflows can approve pull requests. + Each value is None when it cannot be read. Raises: github.RateLimitExceededException: When API rate limits are exceeded """ + default_workflow_permissions = None + can_approve_pull_request_reviews = None try: _, response = org._requester.requestJsonAndCheck( # type: ignore[attr-defined] "GET", @@ -254,8 +270,11 @@ class Organization(GithubService): ) if isinstance(response, dict): permissions = response.get("default_workflow_permissions") - if isinstance(permissions, str): - return permissions + if permissions in ("read", "write"): + default_workflow_permissions = permissions + can_approve = response.get("can_approve_pull_request_reviews") + if isinstance(can_approve, bool): + can_approve_pull_request_reviews = can_approve except github.RateLimitExceededException as error: logger.error(f"GitHub API rate limit exceeded: {error}") raise # Re-raise rate limit errors as they need special handling @@ -277,7 +296,7 @@ class Organization(GithubService): logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - return None + return default_workflow_permissions, can_approve_pull_request_reviews class Org(BaseModel): @@ -295,3 +314,4 @@ class Org(BaseModel): base_permission: Optional[str] = None is_verified: Optional[bool] = None default_workflow_permissions: Optional[str] = None + can_approve_pull_request_reviews: Optional[bool] = None diff --git a/tests/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled_test.py b/tests/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled_test.py new file mode 100644 index 0000000000..c188fe2c18 --- /dev/null +++ b/tests/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled_test.py @@ -0,0 +1,79 @@ +from unittest import mock + +from prowler.providers.github.services.organization.organization_service import Org +from tests.providers.github.github_fixtures import set_mocked_github_provider + +CHECK_CLIENT_PATH = "prowler.providers.github.services.organization.organization_actions_pull_request_approval_disabled.organization_actions_pull_request_approval_disabled.organization_client" + + +class Test_organization_actions_pull_request_approval_disabled: + """Unit tests for the organization_actions_pull_request_approval_disabled check.""" + + def _run_check(self, organizations): + """Execute the check against the provided organizations.""" + organization_client = mock.MagicMock + organization_client.organizations = organizations + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch(CHECK_CLIENT_PATH, new=organization_client), + ): + from prowler.providers.github.services.organization.organization_actions_pull_request_approval_disabled.organization_actions_pull_request_approval_disabled import ( + organization_actions_pull_request_approval_disabled, + ) + + check = organization_actions_pull_request_approval_disabled() + return check.execute() + + def test_no_organizations(self): + """Test that no findings are reported when there are no organizations.""" + assert len(self._run_check({})) == 0 + + def test_pull_request_approval_unknown(self): + """Test that no finding is reported when the setting could not be read.""" + organizations = { + 1: Org( + id=1, name="test-organization", can_approve_pull_request_reviews=None + ) + } + + assert len(self._run_check(organizations)) == 0 + + def test_pull_request_approval_disabled(self): + """Test that an organization not allowing workflow approvals passes the check.""" + organizations = { + 1: Org( + id=1, name="test-organization", can_approve_pull_request_reviews=False + ) + } + result = self._run_check(organizations) + + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "test-organization" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Organization test-organization does not allow GitHub Actions to approve pull requests." + ) + + def test_pull_request_approval_enabled(self): + """Test that an organization allowing workflow approvals fails the check.""" + organizations = { + 1: Org( + id=1, name="test-organization", can_approve_pull_request_reviews=True + ) + } + result = self._run_check(organizations) + + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "test-organization" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Organization test-organization allows GitHub Actions to approve pull requests." + ) diff --git a/tests/providers/github/services/organization/organization_service_test.py b/tests/providers/github/services/organization/organization_service_test.py index 46f7d98e5b..43e8e4ae8e 100644 --- a/tests/providers/github/services/organization/organization_service_test.py +++ b/tests/providers/github/services/organization/organization_service_test.py @@ -514,7 +514,8 @@ class Test_Organization_Default_Workflow_Permissions: ) assert ( - organization_service._get_default_workflow_permissions(mock_org) == "read" + organization_service._get_actions_workflow_permissions(mock_org)[0] + == "read" ), "The organization default GITHUB_TOKEN permissions should be read from the Actions permissions endpoint" mock_org._requester.requestJsonAndCheck.assert_called_once_with( "GET", @@ -525,6 +526,27 @@ class Test_Organization_Default_Workflow_Permissions: }, ) + def test_can_approve_pull_request_reviews(self): + """Test that the pull request approval setting is read from the same API response""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.return_value = ( + {}, + { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + ) + + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + "write", + True, + ), "Both Actions workflow permissions settings should come from a single request" + assert ( + mock_org._requester.requestJsonAndCheck.call_count == 1 + ), "Both settings should be read without issuing a second request" + def test_default_workflow_permissions_missing_field(self): """Test that a response without the permissions field yields no value""" organization_service = self._get_service() @@ -532,10 +554,31 @@ class Test_Organization_Default_Workflow_Permissions: mock_org.login = "test-org" mock_org._requester.requestJsonAndCheck.return_value = ({}, {}) - assert ( - organization_service._get_default_workflow_permissions(mock_org) is None + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + None, + 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 + + The endpoint documents only "read" and "write". Accepting any string meant a + value such as "READ" or "none" reached the check and produced a FAIL finding, + reporting a write-capable default the organization may not actually have. + """ + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.return_value = ( + {}, + {"default_workflow_permissions": "none"}, + ) + + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + None, + None, + ), "An unsupported permissions value should be left unknown instead of reported as write" + def test_default_workflow_permissions_not_available(self): """Test that accounts without Actions permissions settings yield no value""" organization_service = self._get_service() @@ -545,8 +588,9 @@ class Test_Organization_Default_Workflow_Permissions: 404, "Not Found", None ) - assert ( - organization_service._get_default_workflow_permissions(mock_org) is None + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + None, + None, ), "Accounts without Actions permissions settings should not produce a value" def test_default_workflow_permissions_access_denied(self): @@ -561,8 +605,9 @@ class Test_Organization_Default_Workflow_Permissions: with patch( "prowler.providers.github.services.organization.organization_service.logger" ) as mock_logger: - assert ( - organization_service._get_default_workflow_permissions(mock_org) is None + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + None, + None, ), "Insufficient permissions should not produce a value" mock_logger.warning.assert_called() @@ -576,4 +621,4 @@ class Test_Organization_Default_Workflow_Permissions: ) with raises(RateLimitExceededException): - organization_service._get_default_workflow_permissions(mock_org) + organization_service._get_actions_workflow_permissions(mock_org)