feat(github): add immutable releases check (#9162)

Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
This commit is contained in:
Shafkat Rahman
2025-11-27 07:40:15 -05:00
committed by GitHub
parent 7e0c5540bb
commit 59f8dfe5ae
7 changed files with 270 additions and 0 deletions
+1
View File
@@ -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)
---
@@ -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": ""
}
@@ -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
@@ -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
@@ -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
@@ -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: