feat(github): add organization base repository permission strict check (CIS GitHub 1.3.8) (#8785)

Co-authored-by: akorshak-afg <alex.korshak@afg.org>
Co-authored-by: Sergio Garcia <sergargar1@gmail.com>
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
This commit is contained in:
Alex K
2025-10-27 03:45:50 -05:00
committed by GitHub
parent b8dab5e0ed
commit ff4a186df6
9 changed files with 344 additions and 12 deletions

6
.gitignore vendored
View File

@@ -39,6 +39,12 @@ secrets-*/
# JUnit Reports
junit-reports/
# Test and coverage artifacts
*_coverage.xml
pytest_*.xml
.coverage
htmlcov/
# VSCode files
.vscode/

View File

@@ -2,6 +2,11 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [v5.14.0] (Prowler UNRELEASED)
### Added
- GitHub provider check `organization_default_repository_permission_strict` [(#8785)](https://github.com/prowler-cloud/prowler/pull/8785)
## [v5.13.0] (Prowler v5.13.0)
### Added

View File

@@ -753,7 +753,9 @@
{
"Id": "1.3.8",
"Description": "Base permissions define the permission level automatically granted to all organization members. Define strict base access permissions for all of the repositories in the organization, including new ones.",
"Checks": [],
"Checks": [
"organization_default_repository_permission_strict"
],
"Attributes": [
{
"Section": "1 Source Code",

View File

@@ -0,0 +1,32 @@
{
"Provider": "github",
"CheckID": "organization_default_repository_permission_strict",
"CheckTitle": "Ensure strict base repository permissions are set for the organization",
"CheckType": [],
"ServiceName": "organization",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "GitHubOrganization",
"Description": "Ensure the organization's base repository permission for members is set to 'read' or 'none' to minimize risk.",
"Risk": "If base repository permissions allow 'write' or 'admin' by default, organization members may unintentionally gain excessive privileges across repositories, increasing the risk of unauthorized changes or accidental modifications.",
"RelatedUrl": "https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/setting-base-permissions-for-an-organization",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "Set the organization's base repository permission to 'read' or 'none' for members, unless stricter requirements are needed.",
"Url": "https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/setting-base-permissions-for-an-organization"
}
},
"AdditionalURLs": [],
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}

View File

@@ -0,0 +1,36 @@
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_repository_permission_strict(Check):
"""Check if an organization's base repository permission is set to a strict level.
PASS: base permission is "read" or "none"
FAIL: base permission is "write" or "admin" (or any other non-strict value)
"""
def execute(self) -> List[CheckReportGithub]:
findings = []
for org in organization_client.organizations.values():
base_perm = getattr(org, "base_permission", None)
if base_perm is None:
# Unknown / no permission to read → skip producing a finding
continue
p = str(base_perm).lower()
report = CheckReportGithub(metadata=self.metadata(), resource=org)
if p in ("read", "none"):
report.status = "PASS"
report.status_extended = f"Organization {org.name} base repository permission is '{p}', which is strict."
else:
report.status = "FAIL"
report.status_extended = f"Organization {org.name} base repository permission is '{p}', which is not strict."
findings.append(report)
return findings

View File

@@ -5,7 +5,7 @@ from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.github.lib.service.service import GithubService
from prowler.providers.github.models import GithubAppIdentityInfo, GithubIdentityInfo
from prowler.providers.github.models import GithubAppIdentityInfo
class Organization(GithubService):
@@ -38,13 +38,15 @@ class Organization(GithubService):
org_names_to_check = set()
try:
for client in self.clients:
if self.provider.organizations:
for client in getattr(self, "clients", []) or []:
if getattr(self.provider, "organizations", None):
org_names_to_check.update(self.provider.organizations)
# If repositories are specified without organizations, don't perform organization checks
# Only add repository owners to organization checks if organizations are also specified
if self.provider.repositories and self.provider.organizations:
if getattr(self.provider, "repositories", None) and getattr(
self.provider, "organizations", None
):
for repo_name in self.provider.repositories:
if "/" in repo_name:
owner_name = repo_name.split("/")[0]
@@ -111,18 +113,19 @@ class Organization(GithubService):
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
elif not self.provider.repositories:
elif not getattr(self.provider, "repositories", None):
# Default behavior: get all organizations the user is a member of
# Only when no repositories are specified
if isinstance(self.provider.identity, GithubIdentityInfo):
if isinstance(self.provider.identity, GithubAppIdentityInfo):
orgs = client.get_organizations()
if getattr(orgs, "totalCount", 0) > 0:
for org in orgs:
self._process_organization(org, organizations)
else:
# Default (personal access/OAuth): use user organizations
orgs = client.get_user().get_orgs()
for org in orgs:
self._process_organization(org, organizations)
elif isinstance(self.provider.identity, GithubAppIdentityInfo):
orgs = client.get_organizations()
if orgs.totalCount > 0:
for org in orgs:
self._process_organization(org, organizations)
except github.RateLimitExceededException as error:
logger.error(f"GitHub API rate limit exceeded: {error}")
@@ -144,10 +147,22 @@ class Organization(GithubService):
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
# Base permission (default repository permission for members)
base_perm: Optional[str] = None
try:
base_perm = getattr(org, "default_repository_permission", None)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
base_perm = None
organizations[org.id] = Org(
id=org.id,
name=org.login,
mfa_required=require_mfa,
base_permission=base_perm,
)
@@ -157,3 +172,4 @@ class Org(BaseModel):
id: int
name: str
mfa_required: Optional[bool] = False
base_permission: Optional[str] = None

View File

@@ -0,0 +1,201 @@
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_repository_permission_strict:
def test_no_organizations(self):
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_repository_permission_strict.organization_default_repository_permission_strict.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_default_repository_permission_strict.organization_default_repository_permission_strict import (
organization_default_repository_permission_strict,
)
check = organization_default_repository_permission_strict()
result = check.execute()
assert len(result) == 0
def test_permission_read(self):
organization_client = mock.MagicMock
org_name = "test-organization"
organization_client.organizations = {
1: Org(
id=1,
name=org_name,
base_permission="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_repository_permission_strict.organization_default_repository_permission_strict.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_default_repository_permission_strict.organization_default_repository_permission_strict import (
organization_default_repository_permission_strict,
)
check = organization_default_repository_permission_strict()
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} base repository permission is 'read', which is strict."
)
def test_permission_none(self):
organization_client = mock.MagicMock
org_name = "test-organization"
organization_client.organizations = {
1: Org(
id=1,
name=org_name,
base_permission="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_repository_permission_strict.organization_default_repository_permission_strict.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_default_repository_permission_strict.organization_default_repository_permission_strict import (
organization_default_repository_permission_strict,
)
check = organization_default_repository_permission_strict()
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} base repository permission is 'none', which is strict."
)
def test_permission_write(self):
organization_client = mock.MagicMock
org_name = "test-organization"
organization_client.organizations = {
1: Org(
id=1,
name=org_name,
base_permission="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_repository_permission_strict.organization_default_repository_permission_strict.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_default_repository_permission_strict.organization_default_repository_permission_strict import (
organization_default_repository_permission_strict,
)
check = organization_default_repository_permission_strict()
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} base repository permission is 'write', which is not strict."
)
def test_permission_admin(self):
organization_client = mock.MagicMock
org_name = "test-organization"
organization_client.organizations = {
1: Org(
id=1,
name=org_name,
base_permission="admin",
),
}
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_repository_permission_strict.organization_default_repository_permission_strict.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_default_repository_permission_strict.organization_default_repository_permission_strict import (
organization_default_repository_permission_strict,
)
check = organization_default_repository_permission_strict()
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} base repository permission is 'admin', which is not strict."
)
def test_permission_unknown_none_skipped(self):
organization_client = mock.MagicMock
org_name = "test-organization"
organization_client.organizations = {
1: Org(
id=1,
name=org_name,
base_permission=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_repository_permission_strict.organization_default_repository_permission_strict.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_default_repository_permission_strict.organization_default_repository_permission_strict import (
organization_default_repository_permission_strict,
)
check = organization_default_repository_permission_strict()
result = check.execute()
assert len(result) == 0

View File

@@ -45,11 +45,13 @@ class Test_Organization_Scoping:
self.mock_org1.id = 1
self.mock_org1.login = "test-org1"
self.mock_org1.two_factor_requirement_enabled = True
self.mock_org1.default_repository_permission = None
self.mock_org2 = MagicMock()
self.mock_org2.id = 2
self.mock_org2.login = "test-org2"
self.mock_org2.two_factor_requirement_enabled = False
self.mock_org2.default_repository_permission = None
self.mock_user = MagicMock()
self.mock_user.id = 100
@@ -175,6 +177,35 @@ class Test_Organization_Scoping:
assert len(orgs) == 0
def test_base_permission_extraction(self):
"""Test that base_permission is populated from organization's default_repository_permission"""
provider = set_mocked_github_provider()
provider.repositories = []
provider.organizations = ["test-org1"]
mock_client = MagicMock()
# Organization with default_repository_permission set to "read"
org_with_perm = MagicMock()
org_with_perm.id = 1
org_with_perm.login = "test-org1"
org_with_perm.two_factor_requirement_enabled = True
org_with_perm.default_repository_permission = "read"
mock_client.get_organization.return_value = org_with_perm
with patch(
"prowler.providers.github.services.organization.organization_service.GithubService.__init__"
):
organization_service = Organization(provider)
organization_service.clients = [mock_client]
organization_service.provider = provider
orgs = organization_service._list_organizations()
assert len(orgs) == 1
assert 1 in orgs
assert orgs[1].name == "test-org1"
assert orgs[1].base_permission == "read"
def test_specific_organization_scoping(self):
"""Test that only specified organizations are returned"""
provider = set_mocked_github_provider()
@@ -287,11 +318,13 @@ class Test_Organization_Scoping:
mock_owner_org.id = 1
mock_owner_org.login = "owner1"
mock_owner_org.two_factor_requirement_enabled = True
mock_owner_org.default_repository_permission = None
mock_specific_org = MagicMock()
mock_specific_org.id = 2
mock_specific_org.login = "specific-org"
mock_specific_org.two_factor_requirement_enabled = False
mock_specific_org.default_repository_permission = None
mock_client.get_organization.side_effect = [
mock_owner_org,
@@ -393,6 +426,7 @@ class Test_Organization_ErrorHandling:
self.mock_org1.id = 1
self.mock_org1.login = "test-org1"
self.mock_org1.two_factor_requirement_enabled = True
self.mock_org1.default_repository_permission = None
def test_github_api_error_handling(self):
"""Test that GitHub API errors are handled properly"""