From 1cf6eaa0b7562615661e4ab109cb777e218da00f Mon Sep 17 00:00:00 2001 From: "shria :))" <96020379+shalkoda@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:22:36 -0500 Subject: [PATCH] feat(github): add organization_repository_deletion_limited check (#10185) Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> --- prowler/CHANGELOG.md | 1 + prowler/compliance/github/cis_1.0_github.json | 4 +- prowler/providers/common/provider.py | 19 +- prowler/providers/github/github_provider.py | 16 +- .../__init__.py | 0 ..._repository_deletion_limited.metadata.json | 36 ++++ ...rganization_repository_deletion_limited.py | 31 +++ .../organization/organization_service.py | 6 + ...zation_repository_deletion_limited_test.py | 186 ++++++++++++++++++ 9 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 prowler/providers/github/services/organization/organization_repository_deletion_limited/__init__.py create mode 100644 prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.metadata.json create mode 100644 prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.py create mode 100644 tests/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index df49d1fa25..3e7efa203a 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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 diff --git a/prowler/compliance/github/cis_1.0_github.json b/prowler/compliance/github/cis_1.0_github.json index e675470169..e9dbccc7f6 100644 --- a/prowler/compliance/github/cis_1.0_github.json +++ b/prowler/compliance/github/cis_1.0_github.json @@ -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", diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 0f028106bb..c47c268566 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -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( diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index 9088b79c6f..16d13f7434 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -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, diff --git a/prowler/providers/github/services/organization/organization_repository_deletion_limited/__init__.py b/prowler/providers/github/services/organization/organization_repository_deletion_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.metadata.json b/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.metadata.json new file mode 100644 index 0000000000..851848ce5a --- /dev/null +++ b/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.metadata.json @@ -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" + ] +} diff --git a/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.py b/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.py new file mode 100644 index 0000000000..5d94ad5af6 --- /dev/null +++ b/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.py @@ -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 diff --git a/prowler/providers/github/services/organization/organization_service.py b/prowler/providers/github/services/organization/organization_service.py index 187da72f68..0aceca423e 100644 --- a/prowler/providers/github/services/organization/organization_service.py +++ b/prowler/providers/github/services/organization/organization_service.py @@ -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 diff --git a/tests/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited_test.py b/tests/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited_test.py new file mode 100644 index 0000000000..a9991ab2d1 --- /dev/null +++ b/tests/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited_test.py @@ -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