From 59f8dfe5aeebed861e68c26c4d29c39566ef5eb1 Mon Sep 17 00:00:00 2001 From: Shafkat Rahman Date: Thu, 27 Nov 2025 07:40:15 -0500 Subject: [PATCH] feat(github): add immutable releases check (#9162) Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> --- prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...y_immutable_releases_enabled.metadata.json | 33 ++++ .../repository_immutable_releases_enabled.py | 41 +++++ .../services/repository/repository_service.py | 52 +++++++ ...ository_immutable_releases_enabled_test.py | 141 ++++++++++++++++++ .../repository/repository_service_test.py | 2 + 7 files changed, 270 insertions(+) create mode 100644 prowler/providers/github/services/repository/repository_immutable_releases_enabled/__init__.py create mode 100644 prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.metadata.json create mode 100644 prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.py create mode 100644 tests/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a1667df041..d9ad0508a1 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Added - `cloudstorage_uses_vpc_service_controls` check for GCP provider [(#9256)](https://github.com/prowler-cloud/prowler/pull/9256) +- `repository_immutable_releases_enabled` check for GitHub provider [(#9162)](https://github.com/prowler-cloud/prowler/pull/9162) --- diff --git a/prowler/providers/github/services/repository/repository_immutable_releases_enabled/__init__.py b/prowler/providers/github/services/repository/repository_immutable_releases_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.metadata.json b/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.metadata.json new file mode 100644 index 0000000000..a0723af8d9 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.metadata.json @@ -0,0 +1,33 @@ +{ + "Provider": "github", + "CheckID": "repository_immutable_releases_enabled", + "CheckTitle": "Repository has immutable releases enabled", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Immutable releases prevent modification or replacement of published artifacts after publication. When enabled, release assets and binaries become tamper-proof, ensuring artifact integrity throughout the software supply chain.", + "Risk": "If immutable releases are disabled, release assets can be tampered with after publication, allowing attackers to substitute malicious binaries and undermining supply chain integrity.", + "RelatedUrl": "https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#preventing-changes-to-releases", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable immutable releases in the repository settings so release artifacts cannot be altered once published.", + "Url": "https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases" + } + }, + "Categories": [ + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} + diff --git a/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.py b/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.py new file mode 100644 index 0000000000..21f7108718 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.py @@ -0,0 +1,41 @@ +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_immutable_releases_enabled(Check): + """Ensure immutable releases are enabled for GitHub repositories. + + Immutable releases prevent post-publication tampering of binaries and release assets. + """ + + def execute(self) -> List[CheckReportGithub]: + """Run the immutable releases verification for each discovered repository. + + Returns: + List[CheckReportGithub]: Collection of check reports describing the immutable releases status. + """ + findings: List[CheckReportGithub] = [] + for repo in repository_client.repositories.values(): + if repo.immutable_releases_enabled is None: + continue + + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + + if repo.immutable_releases_enabled: + report.status = "PASS" + report.status_extended = ( + f"Repository {repo.name} has immutable releases enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Repository {repo.name} does not have immutable releases enabled." + ) + + 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 4ca18813c5..244de81e08 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -341,6 +341,9 @@ class Repository(GithubService): name=repo.name, owner=repo.owner.login, full_name=repo.full_name, + immutable_releases_enabled=self._get_repository_immutable_releases_status( + repo + ), default_branch=Branch( name=default_branch, protected=branch_protection, @@ -370,6 +373,54 @@ class Repository(GithubService): f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_repository_immutable_releases_status(self, repo) -> Optional[bool]: + """Retrieve the immutable releases status for the provided repository. + + The API returns a response in the format: + { + "enabled": true, + "enforced_by_owner": false + } + + Args: + repo: The PyGithub repository instance to query. + + Returns: + Optional[bool]: True when immutable releases are enabled, False when they are disabled, and None when the status cannot be determined. + """ + try: + _, response = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/repos/{repo.full_name}/immutable-releases", + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + if isinstance(response, dict) and "enabled" in response: + return response.get("enabled") + return None + except github.GithubException as error: + status_code = getattr(error, "status", None) + if status_code == 404: + logger.info( + f"{repo.full_name}: immutable releases endpoint not available for this repository." + ) + return None + if status_code == 403: + logger.warning( + f"{repo.full_name}: insufficient permissions to query immutable releases endpoint." + ) + return None + self._handle_github_api_error( + error, "fetching immutable releases status", repo.full_name + ) + except Exception as error: + logger.error( + f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + class Branch(BaseModel): """Model for Github Branch""" @@ -396,6 +447,7 @@ class Repo(BaseModel): name: str owner: str full_name: str + immutable_releases_enabled: Optional[bool] = None default_branch: Branch private: bool archived: bool diff --git a/tests/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled_test.py b/tests/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled_test.py new file mode 100644 index 0000000000..eaf2da4dbf --- /dev/null +++ b/tests/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled_test.py @@ -0,0 +1,141 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_immutable_releases_enabled: + """Unit tests for the repository_immutable_releases_enabled check.""" + + def _build_repo(self, immutable_releases_enabled): + """Create a Repo instance with the provided immutable releases state.""" + default_branch = Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ) + return Repo( + id=1, + name="repo1", + owner="account-name", + full_name="account-name/repo1", + immutable_releases_enabled=immutable_releases_enabled, + default_branch=default_branch, + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=False, + ) + + 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_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import ( + repository_immutable_releases_enabled, + ) + + check = repository_immutable_releases_enabled() + result = check.execute() + assert len(result) == 0 + + def test_immutable_releases_enabled(self): + repository_client = mock.MagicMock + repository_client.repositories = {1: self._build_repo(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_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import ( + repository_immutable_releases_enabled, + ) + + check = repository_immutable_releases_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Repository repo1 has immutable releases enabled." + ) + + def test_immutable_releases_disabled(self): + repository_client = mock.MagicMock + repository_client.repositories = {1: self._build_repo(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_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import ( + repository_immutable_releases_enabled, + ) + + check = repository_immutable_releases_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Repository repo1 does not have immutable releases enabled." + ) + + def test_immutable_releases_unknown(self): + repository_client = mock.MagicMock + repository_client.repositories = {1: self._build_repo(None)} + + 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_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import ( + repository_immutable_releases_enabled, + ) + + check = repository_immutable_releases_enabled() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index 20e5887d54..e005e3a05b 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -19,6 +19,7 @@ def mock_list_repositories(_): name="repo1", owner="account-name", full_name="account-name/repo1", + immutable_releases_enabled=True, default_branch=Branch( name="main", protected=True, @@ -88,6 +89,7 @@ class Test_Repository_Service: ) assert repository_service.repositories[1].archived is False assert repository_service.repositories[1].pushed_at is not None + assert repository_service.repositories[1].immutable_releases_enabled is True class Test_Repository_FileExists: