mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(github): add organization_default_workflow_permissions_read_only check (#12122)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
co-authored by
Daniel Barranquero
parent
95642fb220
commit
9dc53ffc60
@@ -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`
|
||||
+39
@@ -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": ""
|
||||
}
|
||||
+38
@@ -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
|
||||
|
||||
+132
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user