From d036e0054b39980a3e6c4cc2ad76cd0a35c6414c Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Wed, 21 May 2025 16:18:55 +0200 Subject: [PATCH] feat(repository): add new check `repository_default_branch_requires_codeowners_review` (#7753) Co-authored-by: Sergio Garcia --- prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...h_requires_codeowners_review.metadata.json | 30 +++++ ...fault_branch_requires_codeowners_review.py | 38 ++++++ .../services/repository/repository_service.py | 9 ++ ..._branch_requires_codeowners_review_test.py | 112 ++++++++++++++++++ .../repository/repository_service_test.py | 2 + 7 files changed, 192 insertions(+) create mode 100644 prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/__init__.py create mode 100644 prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json create mode 100644 prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py create mode 100644 tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index fc0028d6a2..3b3f77db43 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add a level for Prowler ThreatScore in the accordion in Dashboard. [(#7739)](https://github.com/prowler-cloud/prowler/pull/7739) - Add CIS 4.0 compliance framework for GCP. [(7785)](https://github.com/prowler-cloud/prowler/pull/7785) - Add `repository_has_codeowners_file` check for GitHub provider. [(#7752)](https://github.com/prowler-cloud/prowler/pull/7752) +- Add `repository_default_branch_requires_codeowners_review` check for GitHub provider. [(#7753)](https://github.com/prowler-cloud/prowler/pull/7753) ### Fixed - Fix `m365_powershell test_credentials` to use sanitized credentials. [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/__init__.py b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json new file mode 100644 index 0000000000..8c19b24818 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_default_branch_requires_codeowners_review", + "CheckTitle": "Check if code owner approval is required for changes to owned code", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Ensure that code owners are required to review and approve any proposed changes that affect their respective areas of ownership in the code base.", + "Risk": "If code owner approval is not required, unauthorized or unqualified individuals may merge changes to sensitive or critical areas of the codebase, increasing the risk of security vulnerabilities, bugs, or malicious modifications.", + "RelatedUrl": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#requiring-code-owner-review", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "To require code owner review, navigate to the repository settings, click on 'Branches', add or edit a branch protection rule, and enable 'Require review from Code Owners'.", + "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-review-from-code-owners" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py new file mode 100644 index 0000000000..11cc3fd05b --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py @@ -0,0 +1,38 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_default_branch_requires_codeowners_review(Check): + """Check if code owner approval is required for changes to owned code + + This class verifies whether each repository requires code owner review for changes to code they own. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository Code Owner Approval Requirement check + + Iterates over all repositories and checks if they require code owner review for changes. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.require_code_owner_reviews is not None: + report = CheckReportGithub( + metadata=self.metadata(), resource=repo, repository=repo.name + ) + if repo.require_code_owner_reviews: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} requires code owner approval for changes to owned code." + else: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not require code owner approval for changes to owned code." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index 719a4ac71c..a3afffccc1 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -60,6 +60,7 @@ class Repository(GithubService): required_linear_history = False allow_force_pushes = True branch_deletion = True + require_code_owner_reviews = False status_checks = False enforce_admins = False conversation_resolution = False @@ -89,6 +90,11 @@ class Repository(GithubService): protection.required_conversation_resolution ) branch_protection = True + require_code_owner_reviews = ( + protection.required_pull_request_reviews.require_code_owner_reviews + if require_pr + else False + ) except Exception as error: # If the branch is not found, it is not protected if "404" in str(error): @@ -103,6 +109,7 @@ class Repository(GithubService): required_linear_history = None allow_force_pushes = None branch_deletion = None + require_code_owner_reviews = None status_checks = None enforce_admins = None conversation_resolution = None @@ -127,6 +134,7 @@ class Repository(GithubService): conversation_resolution=conversation_resolution, default_branch_protection=branch_protection, codeowners_exists=codeowners_exists, + require_code_owner_reviews=require_code_owner_reviews, delete_branch_on_merge=delete_branch_on_merge, ) @@ -155,5 +163,6 @@ class Repo(BaseModel): enforce_admins: Optional[bool] approval_count: Optional[int] codeowners_exists: Optional[bool] + require_code_owner_reviews: Optional[bool] delete_branch_on_merge: Optional[bool] conversation_resolution: Optional[bool] diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py new file mode 100644 index 0000000000..fe64448402 --- /dev/null +++ b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py @@ -0,0 +1,112 @@ +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_default_branch_requires_codeowners_review: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 0 + + def test_one_repository_no_codeowner_approval(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + full_name="account-name/repo1", + default_branch="main", + private=False, + securitymd=True, + require_pull_request=False, + approval_count=0, + require_code_owner_reviews=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not require code owner approval for changes to owned code." + ) + + def test_one_repository_with_codeowner_approval(self): + repository_client = mock.MagicMock + repo_name = "repo2" + repository_client.repositories = { + 2: Repo( + id=2, + name=repo_name, + full_name="account-name/repo2", + default_branch="main", + private=False, + securitymd=True, + require_pull_request=False, + approval_count=0, + require_code_owner_reviews=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} requires code owner approval for changes to owned code." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index 4b9d45b479..82535fad14 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -24,6 +24,7 @@ def mock_list_repositories(_): status_checks=True, approval_count=2, codeowners_exists=True, + require_code_owner_reviews=True, enforce_admins=True, delete_branch_on_merge=True, conversation_resolution=True, @@ -62,6 +63,7 @@ class Test_Repository_Service: assert repository_service.repositories[1].conversation_resolution assert repository_service.repositories[1].approval_count == 2 assert repository_service.repositories[1].codeowners_exists is True + assert repository_service.repositories[1].require_code_owner_reviews is True class Test_Repository_FileExists: