feat(github): add organization_repository_deletion_limited check (#10185)

Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
This commit is contained in:
shria :))
2026-03-16 10:22:36 -05:00
committed by GitHub
parent b311456160
commit 1cf6eaa0b7
9 changed files with 294 additions and 5 deletions

View File

@@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `entra_conditional_access_policy_device_code_flow_blocked` check for M365 provider [(#10218)](https://github.com/prowler-cloud/prowler/pull/10218)
- CheckMetadata Pydantic validators [(#8584)](https://github.com/prowler-cloud/prowler/pull/8583)
- `entra_conditional_access_policy_require_mfa_for_admin_portals` check for Azure provider and update CIS compliance [(#10330)](https://github.com/prowler-cloud/prowler/pull/10330)
- `organization_repository_deletion_limited` check for GitHub provider [(#10185)](https://github.com/prowler-cloud/prowler/pull/10185)
### 🔄 Changed

View File

@@ -499,7 +499,9 @@
{
"Id": "1.2.3",
"Description": "Ensure only a limited number of trusted users can delete repositories.",
"Checks": [],
"Checks": [
"organization_repository_deletion_limited"
],
"Attributes": [
{
"Section": "1 Source Code",

View File

@@ -238,6 +238,21 @@ class Provider(ABC):
fixer_config=fixer_config,
)
elif "github" in provider_class_name.lower():
orgs = []
repos = []
if getattr(arguments, "organization", None):
orgs.extend(arguments.organization)
if getattr(arguments, "organizations", None):
orgs.extend(arguments.organizations)
if getattr(arguments, "repository", None):
repos.extend(arguments.repository)
if getattr(arguments, "repositories", None):
repos.extend(arguments.repositories)
orgs = list(dict.fromkeys(orgs))
repos = list(dict.fromkeys(repos))
provider_class(
personal_access_token=arguments.personal_access_token,
oauth_app_token=arguments.oauth_app_token,
@@ -245,8 +260,8 @@ class Provider(ABC):
github_app_id=arguments.github_app_id,
mutelist_path=arguments.mutelist_file,
config_path=arguments.config_file,
repositories=arguments.repository,
organizations=arguments.organization,
repositories=repos,
organizations=orgs,
)
elif "googleworkspace" in provider_class_name.lower():
provider_class(

View File

@@ -139,8 +139,20 @@ class GithubProvider(Provider):
logging.getLogger("github.GithubRetry").setLevel(logging.CRITICAL)
# Set repositories and organizations for scoping
self._repositories = repositories or []
self._organizations = organizations or []
# Normalize single strings into lists (argparse sometimes passes str for singular flags)
if repositories is None:
self._repositories = []
elif isinstance(repositories, str):
self._repositories = [repositories]
else:
self._repositories = list(repositories)
if organizations is None:
self._organizations = []
elif isinstance(organizations, str):
self._organizations = [organizations]
else:
self._organizations = list(organizations)
self._session = GithubProvider.setup_session(
personal_access_token,

View File

@@ -0,0 +1,36 @@
{
"Provider": "github",
"CheckID": "organization_repository_deletion_limited",
"CheckTitle": "Organization repository deletion and transfer is restricted to owners",
"CheckType": [],
"ServiceName": "organization",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "GitHubOrganization",
"ResourceGroup": "governance",
"Description": "Ensure repository deletion/transfer is restricted so only trusted organization users (owners) can delete or transfer repositories.",
"Risk": "If members can delete or transfer repositories, accidental or malicious deletions can cause irreversible data loss, service disruption, and increased blast radius from compromised accounts.",
"RelatedUrl": "",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to GitHub as an organization owner\n2. Go to Organization > Settings\n3. Under \"Access\", click \"Member privileges\"\n4. Disable \"Allow members to delete or transfer repositories\"\n5. Save changes",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable member repository deletion/transfer privileges so only organization owners can delete or transfer repositories.",
"Url": "https://hub.prowler.com/check/organization_repository_deletion_limited"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "",
"AdditionalURLs": [
"https://docs.github.com/en/organizations/managing-organization-settings/setting-permissions-for-deleting-or-transferring-repositories"
]
}

View File

@@ -0,0 +1,31 @@
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_repository_deletion_limited(Check):
"""Check if repository deletion/transfer is limited to trusted organization users."""
def execute(self) -> List[CheckReportGithub]:
findings = []
for org in organization_client.organizations.values():
members_can_delete = org.members_can_delete_repositories
if members_can_delete is None:
continue
report = CheckReportGithub(metadata=self.metadata(), resource=org)
if members_can_delete is False:
report.status = "PASS"
report.status_extended = f"Organization {org.name} restricts repository deletion/transfer to trusted users."
else:
report.status = "FAIL"
report.status_extended = f"Organization {org.name} allows members to delete/transfer repositories."
findings.append(report)
return findings

View File

@@ -76,6 +76,7 @@ class Organization(GithubService):
id=user.id,
name=user.login,
mfa_required=None, # Users don't have MFA requirements like orgs
members_can_delete_repositories=None,
is_verified=None,
)
logger.info(
@@ -189,6 +190,9 @@ class Organization(GithubService):
repo_creation_settings["members_allowed_repository_creation_type"] = (
_extract_flag("members_allowed_repository_creation_type", str)
)
members_can_delete_repositories = _extract_flag(
"members_can_delete_repositories", bool
)
base_permission_raw = _extract_flag("default_repository_permission", str)
base_permission = (
@@ -201,6 +205,7 @@ class Organization(GithubService):
id=org.id,
name=org.login,
mfa_required=require_mfa,
members_can_delete_repositories=members_can_delete_repositories,
members_can_create_repositories=repo_creation_settings[
"members_can_create_repositories"
],
@@ -227,6 +232,7 @@ class Org(BaseModel):
id: int
name: str
mfa_required: Optional[bool] = False
members_can_delete_repositories: Optional[bool] = None
members_can_create_repositories: Optional[bool] = None
members_can_create_public_repositories: Optional[bool] = None
members_can_create_private_repositories: Optional[bool] = None

View File

@@ -0,0 +1,186 @@
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_repository_deletion_limited:
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_repository_deletion_limited.organization_repository_deletion_limited.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import (
organization_repository_deletion_limited,
)
check = organization_repository_deletion_limited()
result = check.execute()
assert len(result) == 0
def test_repository_deletion_disabled(self):
organization_client = mock.MagicMock
org_name = "test-organization"
organization_client.organizations = {
1: Org(
id=1,
name=org_name,
mfa_required=None,
members_can_delete_repositories=False,
),
}
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_repository_deletion_limited.organization_repository_deletion_limited.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import (
organization_repository_deletion_limited,
)
check = organization_repository_deletion_limited()
result = check.execute()
assert len(result) == 1
assert result[0].resource_name == org_name
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Organization {org_name} restricts repository deletion/transfer to trusted users."
)
def test_repository_deletion_enabled(self):
organization_client = mock.MagicMock
org_name = "test-organization"
organization_client.organizations = {
1: Org(
id=1,
name=org_name,
mfa_required=None,
members_can_delete_repositories=True,
),
}
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_repository_deletion_limited.organization_repository_deletion_limited.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import (
organization_repository_deletion_limited,
)
check = organization_repository_deletion_limited()
result = check.execute()
assert len(result) == 1
assert result[0].resource_name == org_name
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Organization {org_name} allows members to delete/transfer repositories."
)
def test_repository_deletion_setting_not_available(self):
organization_client = mock.MagicMock
org_name = "test-organization"
organization_client.organizations = {
1: Org(
id=1,
name=org_name,
mfa_required=None,
members_can_delete_repositories=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_repository_deletion_limited.organization_repository_deletion_limited.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import (
organization_repository_deletion_limited,
)
check = organization_repository_deletion_limited()
result = check.execute()
assert len(result) == 0
def test_multiple_organizations_mixed_settings(self):
organization_client = mock.MagicMock
org_name_1 = "test-organization-1"
org_name_2 = "test-organization-2"
org_name_3 = "test-organization-3"
organization_client.organizations = {
1: Org(
id=1,
name=org_name_1,
mfa_required=None,
members_can_delete_repositories=False,
),
2: Org(
id=2,
name=org_name_2,
mfa_required=None,
members_can_delete_repositories=True,
),
3: Org(
id=3,
name=org_name_3,
mfa_required=None,
members_can_delete_repositories=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_repository_deletion_limited.organization_repository_deletion_limited.organization_client",
new=organization_client,
),
):
from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import (
organization_repository_deletion_limited,
)
check = organization_repository_deletion_limited()
result = check.execute()
assert len(result) == 2
# Find results by organization name
results_by_name = {r.resource_name: r for r in result}
assert org_name_1 in results_by_name
assert results_by_name[org_name_1].status == "PASS"
assert org_name_2 in results_by_name
assert results_by_name[org_name_2].status == "FAIL"
# org_name_3 should not be in results because setting is None
assert org_name_3 not in results_by_name