diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index f3a117ba4d..fc0028d6a2 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add new check `admincenter_external_calendar_sharing_disabled` for M365 provider. [(#7733)](https://github.com/prowler-cloud/prowler/pull/7733) - 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) ### 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_has_codeowners_file/__init__.py b/prowler/providers/github/services/repository/repository_has_codeowners_file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json new file mode 100644 index 0000000000..58cb6ce8d2 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_has_codeowners_file", + "CheckTitle": "Check if repositories have a CODEOWNERS file", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Ensure that repositories have a CODEOWNERS file.", + "Risk": "Not having a CODEOWNERS file in a repository may lead to unclear code ownership and review responsibilities, increasing the risk of unreviewed or unauthorized changes.", + "RelatedUrl": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add a CODEOWNERS file to the root, .github/, or docs/ directory of the repository. The file should specify code owners for files and directories as appropriate for your organization.", + "Url": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py new file mode 100644 index 0000000000..a158f63880 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py @@ -0,0 +1,42 @@ +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_has_codeowners_file(Check): + """Check if a repository has a CODEOWNERS file + + This class verifies whether each repository has a CODEOWNERS file. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository CODEOWNERS file existence check + + Iterates over all repositories and checks if they have a CODEOWNERS file. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.codeowners_exists is not None: + report = CheckReportGithub( + metadata=self.metadata(), resource=repo, repository=repo.name + ) + if repo.codeowners_exists: + report.status = "PASS" + report.status_extended = ( + f"Repository {repo.name} does have a CODEOWNERS file." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Repository {repo.name} does not have a CODEOWNERS file." + ) + + 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 571dda3429..719a4ac71c 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -11,6 +11,19 @@ class Repository(GithubService): super().__init__(__class__.__name__, provider) self.repositories = self._list_repositories() + def _file_exists(self, repo, filename): + """Check if a file exists in the repository. Returns True if exists, False if not, None if error.""" + try: + return repo.get_contents(filename) is not None + except Exception as error: + if "404" in str(error): + return False + else: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + def _list_repositories(self): logger.info("Repository - Listing Repositories...") repos = {} @@ -18,22 +31,28 @@ class Repository(GithubService): for client in self.clients: for repo in client.get_user().get_repos(): default_branch = repo.default_branch + securitymd_exists = self._file_exists(repo, "SECURITY.md") + # CODEOWNERS file can be in .github/, root, or docs/ + # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location + codeowners_paths = [ + ".github/CODEOWNERS", + "CODEOWNERS", + "docs/CODEOWNERS", + ] + codeowners_files = [ + self._file_exists(repo, path) for path in codeowners_paths + ] + if True in codeowners_files: + codeowners_exists = True + elif all(file is None for file in codeowners_files): + codeowners_exists = None + else: + codeowners_exists = False delete_branch_on_merge = ( repo.delete_branch_on_merge if repo.delete_branch_on_merge is not None else False ) - securitymd_exists = False - try: - securitymd_exists = repo.get_contents("SECURITY.md") is not None - 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 @@ -107,6 +126,7 @@ class Repository(GithubService): enforce_admins=enforce_admins, conversation_resolution=conversation_resolution, default_branch_protection=branch_protection, + codeowners_exists=codeowners_exists, delete_branch_on_merge=delete_branch_on_merge, ) @@ -134,5 +154,6 @@ class Repo(BaseModel): status_checks: Optional[bool] enforce_admins: Optional[bool] approval_count: Optional[int] + codeowners_exists: Optional[bool] delete_branch_on_merge: Optional[bool] conversation_resolution: Optional[bool] diff --git a/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py new file mode 100644 index 0000000000..57cb5a53e7 --- /dev/null +++ b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_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_has_codeowners_file: + 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_has_codeowners_file.repository_has_codeowners_file.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import ( + repository_has_codeowners_file, + ) + + check = repository_has_codeowners_file() + result = check.execute() + assert len(result) == 0 + + def test_one_repository_no_codeowners(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, + codeowners_exists=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_has_codeowners_file.repository_has_codeowners_file.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import ( + repository_has_codeowners_file, + ) + + check = repository_has_codeowners_file() + 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 have a CODEOWNERS file." + ) + + def test_one_repository_with_codeowners(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, + codeowners_exists=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_has_codeowners_file.repository_has_codeowners_file.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import ( + repository_has_codeowners_file, + ) + + check = repository_has_codeowners_file() + 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} does have a CODEOWNERS file." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index bcc3b19791..4b9d45b479 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.github.services.repository.repository_service import ( Repo, @@ -23,6 +23,7 @@ def mock_list_repositories(_): default_branch_deletion=True, status_checks=True, approval_count=2, + codeowners_exists=True, enforce_admins=True, delete_branch_on_merge=True, conversation_resolution=True, @@ -60,3 +61,26 @@ class Test_Repository_Service: assert repository_service.repositories[1].delete_branch_on_merge assert repository_service.repositories[1].conversation_resolution assert repository_service.repositories[1].approval_count == 2 + assert repository_service.repositories[1].codeowners_exists is True + + +class Test_Repository_FileExists: + def setup_method(self): + self.repository = Repository(set_mocked_github_provider()) + self.mock_repo = MagicMock() + + def test_file_exists_returns_true(self): + self.mock_repo.get_contents.return_value = object() + assert self.repository._file_exists(self.mock_repo, "somefile.txt") is True + + def test_file_not_found_returns_false(self): + self.mock_repo.get_contents.side_effect = Exception("404 Not Found") + assert self.repository._file_exists(self.mock_repo, "nofile.txt") is False + + def test_other_error_returns_none_and_logs(self): + self.mock_repo.get_contents.side_effect = Exception("Some other error") + with patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger: + assert self.repository._file_exists(self.mock_repo, "errorfile.txt") is None + assert mock_logger.error.called