Skip to content

Commit 59f8dfe

Browse files
Sakeeb91andoniaf
andauthored
feat(github): add immutable releases check (#9162)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
1 parent 7e0c554 commit 59f8dfe

File tree

7 files changed

+270
-0
lines changed

7 files changed

+270
-0
lines changed

prowler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
66

77
### Added
88
- `cloudstorage_uses_vpc_service_controls` check for GCP provider [(#9256)](https://github.com/prowler-cloud/prowler/pull/9256)
9+
- `repository_immutable_releases_enabled` check for GitHub provider [(#9162)](https://github.com/prowler-cloud/prowler/pull/9162)
910

1011
---
1112

prowler/providers/github/services/repository/repository_immutable_releases_enabled/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"Provider": "github",
3+
"CheckID": "repository_immutable_releases_enabled",
4+
"CheckTitle": "Repository has immutable releases enabled",
5+
"CheckType": [],
6+
"ServiceName": "repository",
7+
"SubServiceName": "",
8+
"ResourceIdTemplate": "github:user-id:repository/repository-name",
9+
"Severity": "high",
10+
"ResourceType": "GitHubRepository",
11+
"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.",
12+
"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.",
13+
"RelatedUrl": "https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#preventing-changes-to-releases",
14+
"Remediation": {
15+
"Code": {
16+
"CLI": "",
17+
"NativeIaC": "",
18+
"Other": "",
19+
"Terraform": ""
20+
},
21+
"Recommendation": {
22+
"Text": "Enable immutable releases in the repository settings so release artifacts cannot be altered once published.",
23+
"Url": "https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases"
24+
}
25+
},
26+
"Categories": [
27+
"software-supply-chain"
28+
],
29+
"DependsOn": [],
30+
"RelatedTo": [],
31+
"Notes": ""
32+
}
33+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import List
2+
3+
from prowler.lib.check.models import Check, CheckReportGithub
4+
from prowler.providers.github.services.repository.repository_client import (
5+
repository_client,
6+
)
7+
8+
9+
class repository_immutable_releases_enabled(Check):
10+
"""Ensure immutable releases are enabled for GitHub repositories.
11+
12+
Immutable releases prevent post-publication tampering of binaries and release assets.
13+
"""
14+
15+
def execute(self) -> List[CheckReportGithub]:
16+
"""Run the immutable releases verification for each discovered repository.
17+
18+
Returns:
19+
List[CheckReportGithub]: Collection of check reports describing the immutable releases status.
20+
"""
21+
findings: List[CheckReportGithub] = []
22+
for repo in repository_client.repositories.values():
23+
if repo.immutable_releases_enabled is None:
24+
continue
25+
26+
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
27+
28+
if repo.immutable_releases_enabled:
29+
report.status = "PASS"
30+
report.status_extended = (
31+
f"Repository {repo.name} has immutable releases enabled."
32+
)
33+
else:
34+
report.status = "FAIL"
35+
report.status_extended = (
36+
f"Repository {repo.name} does not have immutable releases enabled."
37+
)
38+
39+
findings.append(report)
40+
41+
return findings

prowler/providers/github/services/repository/repository_service.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ def _process_repository(self, repo, repos):
341341
name=repo.name,
342342
owner=repo.owner.login,
343343
full_name=repo.full_name,
344+
immutable_releases_enabled=self._get_repository_immutable_releases_status(
345+
repo
346+
),
344347
default_branch=Branch(
345348
name=default_branch,
346349
protected=branch_protection,
@@ -370,6 +373,54 @@ def _process_repository(self, repo, repos):
370373
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
371374
)
372375

376+
def _get_repository_immutable_releases_status(self, repo) -> Optional[bool]:
377+
"""Retrieve the immutable releases status for the provided repository.
378+
379+
The API returns a response in the format:
380+
{
381+
"enabled": true,
382+
"enforced_by_owner": false
383+
}
384+
385+
Args:
386+
repo: The PyGithub repository instance to query.
387+
388+
Returns:
389+
Optional[bool]: True when immutable releases are enabled, False when they are disabled, and None when the status cannot be determined.
390+
"""
391+
try:
392+
_, response = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined]
393+
"GET",
394+
f"/repos/{repo.full_name}/immutable-releases",
395+
headers={
396+
"Accept": "application/vnd.github+json",
397+
"X-GitHub-Api-Version": "2022-11-28",
398+
},
399+
)
400+
if isinstance(response, dict) and "enabled" in response:
401+
return response.get("enabled")
402+
return None
403+
except github.GithubException as error:
404+
status_code = getattr(error, "status", None)
405+
if status_code == 404:
406+
logger.info(
407+
f"{repo.full_name}: immutable releases endpoint not available for this repository."
408+
)
409+
return None
410+
if status_code == 403:
411+
logger.warning(
412+
f"{repo.full_name}: insufficient permissions to query immutable releases endpoint."
413+
)
414+
return None
415+
self._handle_github_api_error(
416+
error, "fetching immutable releases status", repo.full_name
417+
)
418+
except Exception as error:
419+
logger.error(
420+
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
421+
)
422+
return None
423+
373424

374425
class Branch(BaseModel):
375426
"""Model for Github Branch"""
@@ -396,6 +447,7 @@ class Repo(BaseModel):
396447
name: str
397448
owner: str
398449
full_name: str
450+
immutable_releases_enabled: Optional[bool] = None
399451
default_branch: Branch
400452
private: bool
401453
archived: bool
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from datetime import datetime, timezone
2+
from unittest import mock
3+
4+
from prowler.providers.github.services.repository.repository_service import Branch, Repo
5+
from tests.providers.github.github_fixtures import set_mocked_github_provider
6+
7+
8+
class Test_repository_immutable_releases_enabled:
9+
"""Unit tests for the repository_immutable_releases_enabled check."""
10+
11+
def _build_repo(self, immutable_releases_enabled):
12+
"""Create a Repo instance with the provided immutable releases state."""
13+
default_branch = Branch(
14+
name="main",
15+
protected=True,
16+
default_branch=True,
17+
require_pull_request=True,
18+
approval_count=1,
19+
required_linear_history=True,
20+
allow_force_pushes=False,
21+
branch_deletion=False,
22+
status_checks=True,
23+
enforce_admins=True,
24+
require_code_owner_reviews=True,
25+
require_signed_commits=True,
26+
conversation_resolution=True,
27+
)
28+
return Repo(
29+
id=1,
30+
name="repo1",
31+
owner="account-name",
32+
full_name="account-name/repo1",
33+
immutable_releases_enabled=immutable_releases_enabled,
34+
default_branch=default_branch,
35+
private=False,
36+
archived=False,
37+
pushed_at=datetime.now(timezone.utc),
38+
securitymd=True,
39+
codeowners_exists=True,
40+
secret_scanning_enabled=True,
41+
dependabot_alerts_enabled=True,
42+
delete_branch_on_merge=False,
43+
)
44+
45+
def test_no_repositories(self):
46+
repository_client = mock.MagicMock
47+
repository_client.repositories = {}
48+
49+
with (
50+
mock.patch(
51+
"prowler.providers.common.provider.Provider.get_global_provider",
52+
return_value=set_mocked_github_provider(),
53+
),
54+
mock.patch(
55+
"prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client",
56+
new=repository_client,
57+
),
58+
):
59+
from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import (
60+
repository_immutable_releases_enabled,
61+
)
62+
63+
check = repository_immutable_releases_enabled()
64+
result = check.execute()
65+
assert len(result) == 0
66+
67+
def test_immutable_releases_enabled(self):
68+
repository_client = mock.MagicMock
69+
repository_client.repositories = {1: self._build_repo(True)}
70+
71+
with (
72+
mock.patch(
73+
"prowler.providers.common.provider.Provider.get_global_provider",
74+
return_value=set_mocked_github_provider(),
75+
),
76+
mock.patch(
77+
"prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client",
78+
new=repository_client,
79+
),
80+
):
81+
from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import (
82+
repository_immutable_releases_enabled,
83+
)
84+
85+
check = repository_immutable_releases_enabled()
86+
result = check.execute()
87+
assert len(result) == 1
88+
assert result[0].status == "PASS"
89+
assert (
90+
result[0].status_extended
91+
== "Repository repo1 has immutable releases enabled."
92+
)
93+
94+
def test_immutable_releases_disabled(self):
95+
repository_client = mock.MagicMock
96+
repository_client.repositories = {1: self._build_repo(False)}
97+
98+
with (
99+
mock.patch(
100+
"prowler.providers.common.provider.Provider.get_global_provider",
101+
return_value=set_mocked_github_provider(),
102+
),
103+
mock.patch(
104+
"prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client",
105+
new=repository_client,
106+
),
107+
):
108+
from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import (
109+
repository_immutable_releases_enabled,
110+
)
111+
112+
check = repository_immutable_releases_enabled()
113+
result = check.execute()
114+
assert len(result) == 1
115+
assert result[0].status == "FAIL"
116+
assert (
117+
result[0].status_extended
118+
== "Repository repo1 does not have immutable releases enabled."
119+
)
120+
121+
def test_immutable_releases_unknown(self):
122+
repository_client = mock.MagicMock
123+
repository_client.repositories = {1: self._build_repo(None)}
124+
125+
with (
126+
mock.patch(
127+
"prowler.providers.common.provider.Provider.get_global_provider",
128+
return_value=set_mocked_github_provider(),
129+
),
130+
mock.patch(
131+
"prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled.repository_client",
132+
new=repository_client,
133+
),
134+
):
135+
from prowler.providers.github.services.repository.repository_immutable_releases_enabled.repository_immutable_releases_enabled import (
136+
repository_immutable_releases_enabled,
137+
)
138+
139+
check = repository_immutable_releases_enabled()
140+
result = check.execute()
141+
assert len(result) == 0

tests/providers/github/services/repository/repository_service_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def mock_list_repositories(_):
1919
name="repo1",
2020
owner="account-name",
2121
full_name="account-name/repo1",
22+
immutable_releases_enabled=True,
2223
default_branch=Branch(
2324
name="main",
2425
protected=True,
@@ -88,6 +89,7 @@ def test_list_repositories(self):
8889
)
8990
assert repository_service.repositories[1].archived is False
9091
assert repository_service.repositories[1].pushed_at is not None
92+
assert repository_service.repositories[1].immutable_releases_enabled is True
9193

9294

9395
class Test_Repository_FileExists:

0 commit comments

Comments
 (0)