feat(github): add organization_default_workflow_permissions_read_only check (#12122)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Chethas Dileep
2026-08-26 12:21:49 +02:00
committed by GitHub
co-authored by Daniel Barranquero
parent 95642fb220
commit 9dc53ffc60
8 changed files with 360 additions and 9 deletions
@@ -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:**
@@ -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`
@@ -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/<organization>/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": ""
}
@@ -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
@@ -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
@@ -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."
)
@@ -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)