From 9dc53ffc60ceefd8cd7aec1d0ff3b98e2fd473e4 Mon Sep 17 00:00:00 2001 From: Chethas Dileep <97353024+Edneam@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:21:49 +0400 Subject: [PATCH] feat(github): add organization_default_workflow_permissions_read_only check (#12122) Co-authored-by: Daniel Barranquero --- .../providers/github/authentication.mdx | 7 +- ...tion-default-workflow-permissions.added.md | 1 + .../__init__.py | 0 ...rkflow_permissions_read_only.metadata.json | 39 ++++++ ..._default_workflow_permissions_read_only.py | 38 +++++ .../organization/organization_service.py | 55 ++++++++ ...ult_workflow_permissions_read_only_test.py | 132 ++++++++++++++++++ .../organization/organization_service_test.py | 97 ++++++++++++- 8 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 prowler/changelog.d/organization-default-workflow-permissions.added.md create mode 100644 prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/__init__.py create mode 100644 prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.metadata.json create mode 100644 prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.py create mode 100644 tests/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only_test.py diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index 1546bf284a..928f1a0ea7 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 | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` | +| **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` | | **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 (3 checks) | ✅ Full | MFA, repo creation policies, default permissions | +| Organization checks (4 checks) | ✅ Full | MFA, repo creation policies, default permissions, default workflow permissions | | Compliance frameworks | ✅ Full | CIS GitHub Benchmark and others | | Merge settings (`delete_branch_on_merge`) | ⚠️ MANUAL | Requires write permission (see below) | @@ -171,6 +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 - `read:user`: Read user profile data **Create an OAuth App:** @@ -223,7 +224,7 @@ If a GitHub App is required: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` | +| **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` | | **Members** | Read | Member access reviews | Organization membership auditing | **Create a GitHub App:** diff --git a/prowler/changelog.d/organization-default-workflow-permissions.added.md b/prowler/changelog.d/organization-default-workflow-permissions.added.md new file mode 100644 index 0000000000..efdcb7e921 --- /dev/null +++ b/prowler/changelog.d/organization-default-workflow-permissions.added.md @@ -0,0 +1 @@ +`organization_default_workflow_permissions_read_only` check for GitHub provider, verifying that organizations grant GitHub Actions workflows a read-only default `GITHUB_TOKEN` diff --git a/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/__init__.py b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.metadata.json b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.metadata.json new file mode 100644 index 0000000000..af2ef2ffa2 --- /dev/null +++ b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "github", + "CheckID": "organization_default_workflow_permissions_read_only", + "CheckTitle": "Organization grants workflows a read-only default GITHUB_TOKEN", + "CheckType": [], + "ServiceName": "organization", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "GitHub Actions organization settings define the default **GITHUB_TOKEN** permissions granted to every workflow run.\n\nThe evaluation determines whether the organization 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 organization, 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/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#setting-the-permissions-of-the-github_token-for-your-organization", + "https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication", + "https://docs.github.com/en/actions/reference/security/secure-use#using-the-github_token" + ], + "Remediation": { + "Code": { + "CLI": "gh api --method PUT /orgs//actions/permissions/workflow -f default_workflow_permissions=read", + "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, select Read repository contents and packages permissions\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the organization default **GITHUB_TOKEN** permissions to read-only and grant write scopes per workflow with an explicit `permissions` block, following **least privilege**.\n\nAlso prevent Actions from approving pull requests, and prefer short-lived **GitHub App** tokens over broad credentials when a workflow genuinely needs write access.", + "Url": "https://hub.prowler.com/check/organization_default_workflow_permissions_read_only" + } + }, + "Categories": [ + "ci-cd", + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.py b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.py new file mode 100644 index 0000000000..364bbc9333 --- /dev/null +++ b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_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.organization.organization_client import ( + organization_client, +) + + +class organization_default_workflow_permissions_read_only(Check): + """Check if the default GITHUB_TOKEN permissions granted to workflows are read-only. + + This class verifies whether each organization grants workflows a read-only GITHUB_TOKEN + by default, instead of a token with write access to the repository contents. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Organization Default Workflow Permissions Read Only check. + + Iterates over all organizations and checks the default GITHUB_TOKEN permissions + granted to GitHub Actions workflows. + + Returns: + List[CheckReportGithub]: A list of reports for each organization + """ + findings = [] + for org in organization_client.organizations.values(): + if org.default_workflow_permissions is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=org) + report.status = "FAIL" + report.status_extended = f"Organization {org.name} grants workflows a default GITHUB_TOKEN with {org.default_workflow_permissions} permissions." + + if org.default_workflow_permissions == "read": + report.status = "PASS" + report.status_extended = f"Organization {org.name} grants workflows a read-only default GITHUB_TOKEN." + + 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 0aceca423e..2f475cc74c 100644 --- a/prowler/providers/github/services/organization/organization_service.py +++ b/prowler/providers/github/services/organization/organization_service.py @@ -63,6 +63,10 @@ class Organization(GithubService): try: org = client.get_organization(org_name) self._process_organization(org, organizations) + except github.RateLimitExceededException: + # Rate limits are transient and must not be mistaken + # for a missing organization or a permissions gap + raise except github.GithubException as org_error: # If organization fails, try as a user (personal account) if "404" in str(org_error): @@ -201,6 +205,7 @@ class Organization(GithubService): else None ) is_verified = _extract_flag("is_verified", bool) + default_workflow_permissions = self._get_default_workflow_permissions(org) organizations[org.id] = Org( id=org.id, name=org.login, @@ -223,8 +228,57 @@ class Organization(GithubService): ], base_permission=base_permission, is_verified=is_verified, + default_workflow_permissions=default_workflow_permissions, ) + def _get_default_workflow_permissions(self, org) -> Optional[str]: + """Fetch the default GITHUB_TOKEN permissions granted to workflows in the organization. + + Args: + org: PyGithub Organization object. + + Returns: + Optional[str]: "read" or "write", or None when the setting cannot be read. + + Raises: + github.RateLimitExceededException: When API rate limits are exceeded + """ + try: + _, response = org._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/orgs/{org.login}/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 isinstance(permissions, str): + return permissions + 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 + except github.GithubException as error: + status_code = getattr(error, "status", None) + if status_code == 404: + logger.info( + f"'{org.login}': Actions workflow permissions endpoint not available for this account." + ) + elif status_code == 403: + logger.warning( + f"Access denied reading Actions workflow permissions for '{org.login}' - insufficient permissions" + ) + else: + logger.error( + f"GitHub API error reading Actions workflow permissions for '{org.login}': {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + class Org(BaseModel): """Model for Github Organization""" @@ -240,3 +294,4 @@ class Org(BaseModel): members_allowed_repository_creation_type: Optional[str] = None base_permission: Optional[str] = None is_verified: Optional[bool] = None + default_workflow_permissions: Optional[str] = None diff --git a/tests/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only_test.py b/tests/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only_test.py new file mode 100644 index 0000000000..ddface4a73 --- /dev/null +++ b/tests/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only_test.py @@ -0,0 +1,132 @@ +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 + + +class Test_organization_default_workflow_permissions_read_only: + def test_no_organizations(self): + """Test that no findings are reported when there are no organizations""" + organization_client = mock.MagicMock + organization_client.organizations = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only import ( + organization_default_workflow_permissions_read_only, + ) + + check = organization_default_workflow_permissions_read_only() + result = check.execute() + assert len(result) == 0 + + def test_organization_default_workflow_permissions_unknown(self): + """Test that no finding is reported when the setting could not be read""" + organization_client = mock.MagicMock + organization_client.organizations = { + 1: Org( + id=1, + name="test-organization", + default_workflow_permissions=None, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only import ( + organization_default_workflow_permissions_read_only, + ) + + check = organization_default_workflow_permissions_read_only() + result = check.execute() + assert len(result) == 0 + + def test_organization_default_workflow_permissions_write(self): + """Test that a write-capable default GITHUB_TOKEN fails the check""" + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + default_workflow_permissions="write", + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only import ( + organization_default_workflow_permissions_read_only, + ) + + check = organization_default_workflow_permissions_read_only() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Organization {org_name} grants workflows a default GITHUB_TOKEN with write permissions." + ) + + def test_organization_default_workflow_permissions_read(self): + """Test that a read-only default GITHUB_TOKEN passes the check""" + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + default_workflow_permissions="read", + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only import ( + organization_default_workflow_permissions_read_only, + ) + + check = organization_default_workflow_permissions_read_only() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Organization {org_name} grants workflows a read-only default GITHUB_TOKEN." + ) diff --git a/tests/providers/github/services/organization/organization_service_test.py b/tests/providers/github/services/organization/organization_service_test.py index 22b65e3267..46f7d98e5b 100644 --- a/tests/providers/github/services/organization/organization_service_test.py +++ b/tests/providers/github/services/organization/organization_service_test.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch from github import GithubException, RateLimitExceededException +from pytest import raises from prowler.providers.github.services.organization.organization_service import ( Org, @@ -464,7 +465,7 @@ class Test_Organization_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 in the scoped-organization path are propagated""" provider = set_mocked_github_provider() provider.repositories = [] provider.organizations = ["test-org1"] @@ -484,11 +485,95 @@ class Test_Organization_ErrorHandling: with patch( "prowler.providers.github.services.organization.organization_service.logger" ) as mock_logger: - # Rate limit errors should be caught and logged at the outer level - orgs = organization_service._list_organizations() + # Rate limit errors are logged and propagated instead of silently + # dropping the scoped organization + with raises(RateLimitExceededException): + organization_service._list_organizations() - # Should be empty due to rate limit error - assert len(orgs) == 0 - # Should log rate limit error mock_logger.error.assert_called() assert "Rate limit exceeded" in str(mock_logger.error.call_args) + mock_client.get_user.assert_not_called() + + +class Test_Organization_Default_Workflow_Permissions: + def _get_service(self): + """Build an Organization service without contacting GitHub""" + with patch( + "prowler.providers.github.services.organization.organization_service.GithubService.__init__" + ): + return Organization(set_mocked_github_provider()) + + def test_default_workflow_permissions_read(self): + """Test that the default workflow permissions value is read from the API response""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.return_value = ( + {}, + {"default_workflow_permissions": "read"}, + ) + + assert ( + organization_service._get_default_workflow_permissions(mock_org) == "read" + ), "The organization default GITHUB_TOKEN permissions should be read from the Actions permissions endpoint" + mock_org._requester.requestJsonAndCheck.assert_called_once_with( + "GET", + "/orgs/test-org/actions/permissions/workflow", + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + + def test_default_workflow_permissions_missing_field(self): + """Test that a response without the permissions field yields no value""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.return_value = ({}, {}) + + assert ( + organization_service._get_default_workflow_permissions(mock_org) is None + ), "An unexpected response payload should not be reported as a permissions value" + + def test_default_workflow_permissions_not_available(self): + """Test that accounts without Actions permissions settings yield no value""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-user" + mock_org._requester.requestJsonAndCheck.side_effect = GithubException( + 404, "Not Found", None + ) + + assert ( + organization_service._get_default_workflow_permissions(mock_org) is None + ), "Accounts without Actions permissions settings should not produce a value" + + def test_default_workflow_permissions_access_denied(self): + """Test that a token without organization administration access yields no value""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.side_effect = GithubException( + 403, "Forbidden", None + ) + + with patch( + "prowler.providers.github.services.organization.organization_service.logger" + ) as mock_logger: + assert ( + organization_service._get_default_workflow_permissions(mock_org) 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""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.side_effect = ( + RateLimitExceededException(403, "Rate limit exceeded", None) + ) + + with raises(RateLimitExceededException): + organization_service._get_default_workflow_permissions(mock_org)