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)
|
- `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)
|
- 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)
|
- `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
|
### 🔄 Changed
|
||||||
|
|
||||||
|
|||||||
@@ -499,7 +499,9 @@
|
|||||||
{
|
{
|
||||||
"Id": "1.2.3",
|
"Id": "1.2.3",
|
||||||
"Description": "Ensure only a limited number of trusted users can delete repositories.",
|
"Description": "Ensure only a limited number of trusted users can delete repositories.",
|
||||||
"Checks": [],
|
"Checks": [
|
||||||
|
"organization_repository_deletion_limited"
|
||||||
|
],
|
||||||
"Attributes": [
|
"Attributes": [
|
||||||
{
|
{
|
||||||
"Section": "1 Source Code",
|
"Section": "1 Source Code",
|
||||||
|
|||||||
@@ -238,6 +238,21 @@ class Provider(ABC):
|
|||||||
fixer_config=fixer_config,
|
fixer_config=fixer_config,
|
||||||
)
|
)
|
||||||
elif "github" in provider_class_name.lower():
|
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(
|
provider_class(
|
||||||
personal_access_token=arguments.personal_access_token,
|
personal_access_token=arguments.personal_access_token,
|
||||||
oauth_app_token=arguments.oauth_app_token,
|
oauth_app_token=arguments.oauth_app_token,
|
||||||
@@ -245,8 +260,8 @@ class Provider(ABC):
|
|||||||
github_app_id=arguments.github_app_id,
|
github_app_id=arguments.github_app_id,
|
||||||
mutelist_path=arguments.mutelist_file,
|
mutelist_path=arguments.mutelist_file,
|
||||||
config_path=arguments.config_file,
|
config_path=arguments.config_file,
|
||||||
repositories=arguments.repository,
|
repositories=repos,
|
||||||
organizations=arguments.organization,
|
organizations=orgs,
|
||||||
)
|
)
|
||||||
elif "googleworkspace" in provider_class_name.lower():
|
elif "googleworkspace" in provider_class_name.lower():
|
||||||
provider_class(
|
provider_class(
|
||||||
|
|||||||
@@ -139,8 +139,20 @@ class GithubProvider(Provider):
|
|||||||
logging.getLogger("github.GithubRetry").setLevel(logging.CRITICAL)
|
logging.getLogger("github.GithubRetry").setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
# Set repositories and organizations for scoping
|
# Set repositories and organizations for scoping
|
||||||
self._repositories = repositories or []
|
# Normalize single strings into lists (argparse sometimes passes str for singular flags)
|
||||||
self._organizations = organizations or []
|
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(
|
self._session = GithubProvider.setup_session(
|
||||||
personal_access_token,
|
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,
|
id=user.id,
|
||||||
name=user.login,
|
name=user.login,
|
||||||
mfa_required=None, # Users don't have MFA requirements like orgs
|
mfa_required=None, # Users don't have MFA requirements like orgs
|
||||||
|
members_can_delete_repositories=None,
|
||||||
is_verified=None,
|
is_verified=None,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -189,6 +190,9 @@ class Organization(GithubService):
|
|||||||
repo_creation_settings["members_allowed_repository_creation_type"] = (
|
repo_creation_settings["members_allowed_repository_creation_type"] = (
|
||||||
_extract_flag("members_allowed_repository_creation_type", str)
|
_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_raw = _extract_flag("default_repository_permission", str)
|
||||||
base_permission = (
|
base_permission = (
|
||||||
@@ -201,6 +205,7 @@ class Organization(GithubService):
|
|||||||
id=org.id,
|
id=org.id,
|
||||||
name=org.login,
|
name=org.login,
|
||||||
mfa_required=require_mfa,
|
mfa_required=require_mfa,
|
||||||
|
members_can_delete_repositories=members_can_delete_repositories,
|
||||||
members_can_create_repositories=repo_creation_settings[
|
members_can_create_repositories=repo_creation_settings[
|
||||||
"members_can_create_repositories"
|
"members_can_create_repositories"
|
||||||
],
|
],
|
||||||
@@ -227,6 +232,7 @@ class Org(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
mfa_required: Optional[bool] = False
|
mfa_required: Optional[bool] = False
|
||||||
|
members_can_delete_repositories: Optional[bool] = None
|
||||||
members_can_create_repositories: Optional[bool] = None
|
members_can_create_repositories: Optional[bool] = None
|
||||||
members_can_create_public_repositories: Optional[bool] = None
|
members_can_create_public_repositories: Optional[bool] = None
|
||||||
members_can_create_private_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