feat(github): add repository_default_workflow_permissions_read_only check (#12143)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Chethas Dileep
2026-08-27 09:00:42 +02:00
committed by GitHub
co-authored by Daniel Barranquero
parent 654d2c9f17
commit 2f11b16299
8 changed files with 494 additions and 15 deletions
@@ -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`
@@ -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": ""
}
@@ -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
@@ -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()