diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index cc320da60f..784635032a 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -240,7 +240,7 @@ class GithubProvider(Provider): ) session_token = env.str("GITHUB_PERSONAL_ACCESS_TOKEN", "") if session_token: - self._auth_method = "Environment Variable for Personal App Token" + self._auth_method = "Environment Variable for Personal Access Token" if not session_token: # OAUTH 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..e5e179c133 --- /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": "Other", + "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..1c76dae69c --- /dev/null +++ b/prowler/providers/github/services/repository/repository_code_changes_multi_approval_requirement/repository_code_changes_multi_approval_requirement.py @@ -0,0 +1,37 @@ +from typing import List + +from prowler.lib.check.models import Check, Check_Report_Github +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[Check_Report_Github]: + """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[Check_Report_Github]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + report = Check_Report_Github(self.metadata()) + report.resource_id = repo.id + report.resource_name = 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 a711a86544..b276285025 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 @@ -8,9 +8,9 @@ "ResourceIdTemplate": "github:user-id:repository/repository-name", "Severity": "low", "ResourceType": "Other", - "Description": "Ensure that public repositories have a SECURITY.md file", + "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://gist.github.com/bayudwiyansatria/3aa6a865aad8d45d69cdb80090bea7c7", + "RelatedUrl": "https://docs.github.com/en/code-security/getting-started/quickstart-for-securing-your-repository", "Remediation": { "Code": { "CLI": "", @@ -20,7 +20,7 @@ }, "Recommendation": { "Text": "Add a SECURITY.md file to the root of the repository. The file should contain information on how to report a security vulnerability, the security policy of the repository, and any other relevant information.", - "Url": "https://github.blog/changelog/2019-05-23-security-policy/" + "Url": "https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository" } }, "Categories": [], diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index f7c2c02760..96ccbbe7ed 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -17,17 +17,46 @@ 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 e: + logger.warning( + f"Could not find SECURITY.md for repo {repo.name}: {e}" + ) + + 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 e: + logger.warning( + f"Could not get branch protection for repo {repo.name}: {e}" + ) + 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 +70,8 @@ class Repo(BaseModel): id: int name: str full_name: str + default_branch: str private: bool securitymd: Optional[bool] = False + require_pull_request: Optional[bool] = False + approval_count: Optional[int] = 0 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..ecb776c602 --- /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,139 @@ +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 87c0b03caa..e4030ff899 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 @@ -32,8 +32,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, ), } @@ -67,8 +70,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 3592cbcb8f..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, ), } @@ -26,7 +29,7 @@ def mock_list_repositories(_): class Test_Repository_Service: def test_get_client(self): repository_service = Repository(set_mocked_github_provider()) - assert repository_service.client.__class__.__name__ == "Github" + assert repository_service.clients[0].__class__.__name__ == "Github" def test_get_service(self): repository_service = Repository(set_mocked_github_provider()) @@ -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