mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user