diff --git a/README.md b/README.md index 5e20971be0..221a29d35e 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ prowler dashboard | GCP | 79 | 13 | 7 | 3 | | Azure | 140 | 18 | 8 | 3 | | Kubernetes | 83 | 7 | 4 | 7 | -| GitHub | 1 | 1 | 1 | 0 | +| GitHub | 2 | 1 | 1 | 0 | | M365 | 44 | 2 | 2 | 0 | | NHN (Unofficial) | 6 | 2 | 1 | 0 | diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d147982f4d..7b1498e2dc 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. - Allow setting cluster name in in-cluster mode in Kubernetes. [(#7695)](https://github.com/prowler-cloud/prowler/pull/7695) - Add Prowler ThreatScore for M365 provider. [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692) - Add GitHub provider. [(#5787)](https://github.com/prowler-cloud/prowler/pull/5787) +- Add `repository_code_changes_multi_approval_requirement` check for GitHub provider. [(#6160)](https://github.com/prowler-cloud/prowler/pull/6160) ### Fixed - Update CIS 4.0 for M365 provider. [(#7699)](https://github.com/prowler-cloud/prowler/pull/7699) diff --git a/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/__init__.py b/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement.metadata.json b/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement.metadata.json new file mode 100644 index 0000000000..8390543480 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_code_changes_multi_approval_requirement", + "CheckTitle": "Check if repositories require at least 2 code changes approvals", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Ensure that repositories require at least 2 code changes approvals before merging a pull request.", + "Risk": "If repositories do not require at least 2 code changes approvals before merging a pull request, it is possible that code changes are not being reviewed by multiple people, which could lead to the introduction of bugs or security vulnerabilities.", + "RelatedUrl": "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/approving-a-pull-request-with-required-reviews", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "To require at least 2 code changes approvals before merging a pull request, navigate to the repository settings, click on 'Branches', and then 'Add rule'.", + "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement.py b/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement.py new file mode 100644 index 0000000000..599b77a6f5 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement.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_code_changes_multi_approval_requirement(Check): + """Check if a repository enforces at least 2 approvals for code changes + + This class verifies whether each repository enforces at least 2 approvals for code changes. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository code changes enforce multi approval requirement check + + Iterates over each repository and checks if the repository enforces at least 2 approvals for code changes. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.approval_count is not None: + report = CheckReportGithub( + metadata=self.metadata(), resource=repo, repository=repo.name + ) + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not enforce at least 2 approvals for code changes." + + if repo.approval_count >= 2: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} does enforce at least 2 approvals for code changes." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.metadata.json b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.metadata.json index 69c9446aa7..4b2cdf24f8 100644 --- a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.metadata.json +++ b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "github:user-id:repository/repository-name", "Severity": "low", - "ResourceType": "Other", + "ResourceType": "GitHubRepository", "Description": "Ensure that public repositories have a SECURITY.md file", "Risk": "Not having a SECURITY.md file in a public repository may lead to security vulnerabilities being overlooked by users and contributors.", "RelatedUrl": "https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository", diff --git a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py index 342b8f5e9f..c1ec7b51e4 100644 --- a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py +++ b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py @@ -22,7 +22,7 @@ class repository_public_has_securitymd_file(Check): """ findings = [] for repo in repository_client.repositories.values(): - if not repo.private: + if not repo.private and repo.securitymd is not None: report = CheckReportGithub( metadata=self.metadata(), resource=repo, repository=repo.name ) diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index f7c2c02760..d2ab5d3658 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -17,17 +17,56 @@ class Repository(GithubService): try: for client in self.clients: for repo in client.get_user().get_repos(): + default_branch = repo.default_branch + securitymd_exists = False try: securitymd_exists = repo.get_contents("SECURITY.md") is not None - except Exception: - securitymd_exists = False + except Exception as error: + if "404" in str(error): + securitymd_exists = False + else: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + securitymd_exists = None + + require_pr = False + approval_cnt = 0 + try: + branch = repo.get_branch(default_branch) + if branch.protected: + protection = branch.get_protection() + if protection: + require_pr = ( + protection.required_pull_request_reviews is not None + ) + approval_cnt = ( + protection.required_pull_request_reviews.required_approving_review_count + if require_pr + else 0 + ) + except Exception as error: + if "404" in str(error): + require_pr = False + approval_cnt = 0 + else: + require_pr = None + approval_cnt = None + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + repos[repo.id] = Repo( id=repo.id, name=repo.name, full_name=repo.full_name, + default_branch=repo.default_branch, private=repo.private, securitymd=securitymd_exists, + require_pull_request=require_pr, + approval_count=approval_cnt, ) + except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -41,5 +80,8 @@ class Repo(BaseModel): id: int name: str full_name: str + default_branch: str private: bool - securitymd: Optional[bool] = False + securitymd: Optional[bool] + require_pull_request: Optional[bool] + approval_count: Optional[int] diff --git a/tests/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement_test.py b/tests/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement_test.py new file mode 100644 index 0000000000..ba0b28e673 --- /dev/null +++ b/tests/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement_test.py @@ -0,0 +1,151 @@ +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_code_changes_multi_approval_requirement: + 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_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement import ( + repository_code_changes_multi_approval_requirement, + ) + + check = repository_code_changes_multi_approval_requirement() + result = check.execute() + assert len(result) == 0 + + def test_repository_no_require_pull_request(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=False, + require_pull_request=False, + approval_count=0, + ), + } + + 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_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement import ( + repository_code_changes_multi_approval_requirement, + ) + + check = repository_code_changes_multi_approval_requirement() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not enforce at least 2 approvals for code changes." + ) + + def test_repository_no_approvals(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="master", + private=False, + securitymd=False, + require_pull_request=True, + approval_count=0, + ), + } + + 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_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement import ( + repository_code_changes_multi_approval_requirement, + ) + + check = repository_code_changes_multi_approval_requirement() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not enforce at least 2 approvals for code changes." + ) + + def test_repository_two_approvals(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="master", + private=False, + securitymd=True, + require_pull_request=True, + approval_count=2, + ), + } + + 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_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_code_changes_multi_approval_requirement.repository_code_changes_multi_approval_requirement import ( + repository_code_changes_multi_approval_requirement, + ) + + check = repository_code_changes_multi_approval_requirement() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} does enforce at least 2 approvals for code changes." + ) diff --git a/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py b/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py index ccbf02c075..776d557252 100644 --- a/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py +++ b/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py @@ -35,8 +35,11 @@ class Test_repository_public_has_securitymd_file_test: id=1, name=repo_name, full_name="account-name/repo1", + default_branch="main", private=False, securitymd=False, + require_pull_request=False, + approval_count=0, ), } @@ -73,8 +76,11 @@ class Test_repository_public_has_securitymd_file_test: id=1, name=repo_name, full_name="account-name/repo1", + default_branch="main", private=False, securitymd=True, + require_pull_request=False, + approval_count=0, ), } diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index a375b5c62c..bc1f709ead 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -13,8 +13,11 @@ def mock_list_repositories(_): id=1, name="repo1", full_name="account-name/repo1", + default_branch="main", private=False, - securitymd=False, + securitymd=True, + require_pull_request=True, + approval_count=2, ), } @@ -38,4 +41,6 @@ class Test_Repository_Service: assert repository_service.repositories[1].name == "repo1" assert repository_service.repositories[1].full_name == "account-name/repo1" assert repository_service.repositories[1].private is False - assert repository_service.repositories[1].securitymd is False + assert repository_service.repositories[1].securitymd + assert repository_service.repositories[1].require_pull_request + assert repository_service.repositories[1].approval_count == 2