mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(github): add immutable releases check (#9162)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+33
@@ -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": ""
|
||||
}
|
||||
|
||||
+41
@@ -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
|
||||
|
||||
+141
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user