feat(github): support repository rulesets in default branch protection checks (#11723)

This commit is contained in:
Daniel Barranquero
2026-06-30 14:53:32 +02:00
committed by GitHub
parent aba43440ca
commit 2abcb05e22
32 changed files with 1821 additions and 47 deletions
+1
View File
@@ -26,6 +26,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Compliance frameworks contributed by several external packages under the same provider are now merged instead of overwritten, so every entry-point directory a provider contributes is discovered [(#11578)](https://github.com/prowler-cloud/prowler/pull/11578)
- Azure PostgreSQL flexible server collection no longer drops the remaining servers in a subscription when one server fails to collect; the `connection_throttle.enable` parameter (removed in PostgreSQL 16+) is treated as absent only when the Azure SDK reports it as not found, so unexpected lookup failures are not silently reported as throttling disabled [(#11595)](https://github.com/prowler-cloud/prowler/pull/11595)
- Azure `keyvault_logging_enabled` now accepts Key Vault diagnostic settings that enable the explicit `AuditEvent` category, avoiding false failures when Azure returns category-based logs without category groups [(#11660)](https://github.com/prowler-cloud/prowler/pull/11660)
- GitHub default branch protection checks now evaluate repository rulesets in addition to classic branch protection, avoiding false positives for repositories that enforce protection through rulesets [(#11723)](https://github.com/prowler-cloud/prowler/pull/11723)
- Okta, Alibaba Cloud and OpenStack scan-config sections are now validated against a registered schema instead of being silently accepted, so their configurable thresholds (session/idle timeouts, retention days, image-sharing and secret-scanning settings) log a warning and fall back to the built-in default whenever a value is out of range [(#11725)](https://github.com/prowler-cloud/prowler/pull/11725)
---
@@ -28,6 +28,8 @@ class repository_default_branch_deletion_disabled(Check):
report.status_extended = (
f"Repository {repo.name} does allow default branch deletion."
)
if repo.default_branch.branch_deletion_source == "ruleset_not_active":
report.status_extended = f"Repository {repo.name} has default branch deletion disabled in a ruleset, but the ruleset is not active."
if not repo.default_branch.branch_deletion:
report.status = "PASS"
@@ -9,12 +9,13 @@
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** blocks **force pushes** through branch protection.\n\nEvaluates whether the default branch permits force pushes.",
"Description": "**GitHub repository default branch** blocks **force pushes** through branch protection.\n\nEvaluates whether the default branch permits force pushes. This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Allowing **force pushes on the default branch** erodes **integrity** and **auditability** by enabling history rewrites and deletion of commits. Attackers or insiders can inject unreviewed code, bypass reviews and status checks, and corrupt PRs, risking supply-chain compromise and reduced **availability**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#allow-force-pushes"
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#allow-force-pushes",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -26,6 +26,11 @@ class repository_default_branch_disallows_force_push(Check):
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does allow force pushes on default branch ({repo.default_branch.name})."
if (
repo.default_branch.allow_force_pushes_source
== "ruleset_not_active"
):
report.status_extended = f"Repository {repo.name} has force pushes disallowed in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active."
if not repo.default_branch.allow_force_pushes:
report.status = "PASS"
@@ -9,12 +9,13 @@
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** applies **branch protection rules** to **administrators** via `enforce_admins`, holding admin pushes to the same requirements as other contributors (reviews, status checks, and push restrictions).",
"Description": "**GitHub repository default branch** applies **branch protection rules** to **administrators** via `enforce_admins`, holding admin pushes to the same requirements as other contributors (reviews, status checks, and push restrictions). This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Without admin enforcement, privileged users can bypass reviews and checks, enabling **unauthorized code changes**. A compromised admin token can inject backdoors, alter dependencies, or disable safeguards, undermining **integrity**, exposing secrets (**confidentiality**), and causing outages (**availability**).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#do-not-allow-bypassing-the-above-settings"
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#do-not-allow-bypassing-the-above-settings",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -26,6 +26,8 @@ class repository_default_branch_protection_applies_to_admins(Check):
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not enforce administrators to be subject to the same branch protection rules as other users."
if repo.default_branch.enforce_admins_source == "ruleset_not_active":
report.status_extended = f"Repository {repo.name} has a ruleset that would apply to administrators, but the ruleset is not active."
if repo.default_branch.enforce_admins:
report.status = "PASS"
@@ -9,12 +9,13 @@
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** has **branch protection rules** enabled to restrict direct changes and require reviewed, validated merges. The evaluation determines whether the default branch enforces such rules.",
"Description": "**GitHub repository default branch** has **branch protection rules** enabled to restrict direct changes and require reviewed, validated merges. The evaluation determines whether the default branch enforces such rules. This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Without default-branch protection, changes can bypass reviews and checks, enabling:\n- Unauthorized direct pushes/force pushes\n- Malicious code injection and workflow tampering\n- Accidental deletions or unstable releases\nThis undermines code **integrity** and service **availability**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches"
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -26,6 +26,8 @@ class repository_default_branch_protection_enabled(Check):
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch.name})."
if repo.default_branch.protected_source == "ruleset_not_active":
report.status_extended = f"Repository {repo.name} has a ruleset configured on default branch ({repo.default_branch.name}), but the ruleset is not active."
if repo.default_branch.protected:
report.status = "PASS"
@@ -9,12 +9,13 @@
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** requires **Code Owners** approval for pull requests that modify paths declared in `CODEOWNERS`",
"Description": "**GitHub repository default branch** requires **Code Owners** approval for pull requests that modify paths declared in `CODEOWNERS`. This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Without required **Code Owners** review, non-owners can merge changes to sensitive code, undermining **integrity**.\nThis increases the chance of **malicious code injection**, hidden backdoors, or fragile changes that enable **data exfiltration** or cause outages, impacting confidentiality and availability.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-review-from-code-owners",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule"
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -30,6 +30,11 @@ class repository_default_branch_requires_codeowners_review(Check):
else:
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not require code owner approval for changes to owned code."
if (
repo.default_branch.require_code_owner_reviews_source
== "ruleset_not_active"
):
report.status_extended = f"Repository {repo.name} has code owner approval configured in a ruleset, but the ruleset is not active."
findings.append(report)
@@ -9,12 +9,13 @@
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** uses **branch protection** to require **conversation resolution** on pull requests (`Require conversation resolution before merging`).",
"Description": "**GitHub repository default branch** uses **branch protection** to require **conversation resolution** on pull requests (`Require conversation resolution before merging`). This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Unresolved threads let code with known concerns reach default, weakening **integrity** and **confidentiality**. Insecure changes or secrets may ship, enabling injection, auth bypass, or data exposure. **Availability** can suffer from regressions; review accountability is reduced.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches"
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -26,6 +26,11 @@ class repository_default_branch_requires_conversation_resolution(Check):
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not require conversation resolution on default branch ({repo.default_branch.name})."
if (
repo.default_branch.conversation_resolution_source
== "ruleset_not_active"
):
report.status_extended = f"Repository {repo.name} has conversation resolution configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active."
if repo.default_branch.conversation_resolution:
report.status = "PASS"
@@ -9,12 +9,13 @@
"Severity": "low",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** enforces `Require linear history`, blocking merge commits and allowing only `squash` or `rebase` merges",
"Description": "**GitHub repository default branch** enforces `Require linear history`, blocking merge commits and allowing only `squash` or `rebase` merges. This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Without a **linear history**, commit provenance is harder to verify, weakening **integrity** and **accountability**.\n\nMerge commits can obscure diffs entering the default branch, hindering audits and rollbacks, enabling unnoticed **malicious or unreviewed code**, and delaying incident response.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-linear-history",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule"
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -26,6 +26,11 @@ class repository_default_branch_requires_linear_history(Check):
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch.name})."
if (
repo.default_branch.required_linear_history_source
== "ruleset_not_active"
):
report.status_extended = f"Repository {repo.name} has linear history configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active."
if repo.default_branch.required_linear_history:
report.status = "PASS"
@@ -9,12 +9,13 @@
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** enforces **required reviews** with a minimum of `2` approving reviews before a pull request can be merged.\n\nAssesses whether an approval threshold of at least `2` is configured for code changes targeting the default branch.",
"Description": "**GitHub repository default branch** enforces **required reviews** with a minimum of `2` approving reviews before a pull request can be merged.\n\nAssesses whether an approval threshold of at least `2` is configured for code changes targeting the default branch. This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Without multi-review approval on the default branch, a single actor can merge changes, degrading **integrity** and **accountability**. This enables:\n- supply-chain tampering or backdoors\n- introduction of exploitable bugs\n- bypass of change control via compromised accounts",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/approving-a-pull-request-with-required-reviews",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging"
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-pull-request-reviews-before-merging",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -26,6 +26,8 @@ class repository_default_branch_requires_multiple_approvals(Check):
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not enforce at least 2 approvals for code changes."
if repo.default_branch.approval_count_source == "ruleset_not_active":
report.status_extended = f"Repository {repo.name} has at least 2 approvals configured in a ruleset, but the ruleset is not active."
if repo.default_branch.approval_count >= 2:
report.status = "PASS"
@@ -9,12 +9,13 @@
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** enforces **signed and verified commits** (`Require signed commits`), allowing only commits with valid cryptographic signatures to be pushed or merged.",
"Description": "**GitHub repository default branch** enforces **signed and verified commits** (`Require signed commits`), allowing only commits with valid cryptographic signatures to be pushed or merged. This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Without required signing, commit authorship can be spoofed and unverified changes added, impacting integrity.\n- Backdoor injection\n- History tampering and forged identities\n- Release pipeline abuse and supply-chain compromise",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-signed-commits",
"https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification"
"https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -26,6 +26,11 @@ class repository_default_branch_requires_signed_commits(Check):
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
report.status = "FAIL"
report.status_extended = f"Repository {repo.name} does not require signed commits on default branch ({repo.default_branch.name})."
if (
repo.default_branch.require_signed_commits_source
== "ruleset_not_active"
):
report.status_extended = f"Repository {repo.name} has signed commits configured in a ruleset on default branch ({repo.default_branch.name}), but the ruleset is not active."
if repo.default_branch.require_signed_commits:
report.status = "PASS"
@@ -9,13 +9,14 @@
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "devops",
"Description": "**GitHub repository default branch** uses **required status checks**, indicating whether merges are gated by successful check results on pull requests",
"Description": "**GitHub repository default branch** uses **required status checks**, indicating whether merges are gated by successful check results on pull requests. This protection can be enforced through classic **branch protection** or repository **rulesets**.",
"Risk": "Without required checks, unvetted commits can be merged, degrading code **integrity** and **availability**. Skipped or failing validations may introduce vulnerable dependencies, break builds, or allow malicious code, enabling supply-chain compromise and rapid propagation to production.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging",
"https://docs.gearset.com/en/articles/2437757-managing-status-check-rules-in-github"
"https://docs.gearset.com/en/articles/2437757-managing-status-check-rules-in-github",
"https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets"
],
"Remediation": {
"Code": {
@@ -28,6 +28,8 @@ class repository_default_branch_status_checks_required(Check):
report.status_extended = (
f"Repository {repo.name} does not enforce status checks."
)
if repo.default_branch.status_checks_source == "ruleset_not_active":
report.status_extended = f"Repository {repo.name} has status checks configured in a ruleset, but the ruleset is not active."
if repo.default_branch.status_checks:
report.status = "PASS"
@@ -202,15 +202,40 @@ class Repository(GithubService):
return None
def _get_dismiss_stale_reviews_from_rulesets(
def _evaluate_default_branch_rulesets(
self, repo, default_branch: str
) -> tuple[Optional[bool], Optional[str]]:
"""Evaluate dismiss-stale-review coverage from repository and parent rulesets."""
rulesets = self._get_repository_rulesets(repo)
if rulesets is None:
return None, None
) -> dict[str, tuple[Optional[int], str]]:
"""Evaluate default-branch protection coverage provided by rulesets.
has_inactive_ruleset = False
Fetches the repository (and parent) rulesets once and walks every rule that
targets the default branch, mapping each ruleset rule to its equivalent classic
branch-protection attribute.
Returns:
dict mapping a ``Branch`` attribute name to a ``(value, source)`` tuple where
``source`` is ``"ruleset"`` (enforced by an active ruleset), ``"ruleset_not_active"``
(configured by a ruleset that is not active) or absent when no ruleset addresses
the attribute. ``value`` is only meaningful for ``approval_count`` (the enforced
review count); for boolean attributes it is ``None`` and the caller derives the
value from ``source`` and the attribute polarity.
"""
result: dict[str, tuple[Optional[int], str]] = {}
rulesets = self._get_repository_rulesets(repo)
if not rulesets:
return result
active: set[str] = set()
inactive: set[str] = set()
active_approval_count: Optional[int] = None
inactive_approval_count: Optional[int] = None
any_active_ruleset = False
any_inactive_ruleset = False
# A ruleset with no bypass actors applies to everyone, including administrators
# (the rulesets equivalent of "enforce admins"). A ruleset that has bypass actors
# would not apply to admins even if activated, so it must not drive the
# enforce-admins finding in either the active or the inactive case.
admins_enforced_active = False
admins_configured_inactive = False
for ruleset in rulesets:
if ruleset.get("target") != "branch":
@@ -219,26 +244,119 @@ class Repository(GithubService):
if not self._ruleset_targets_default_branch(ruleset, default_branch):
continue
enforcement = ruleset.get("enforcement")
is_active = enforcement in {"active", "enabled"}
is_inactive = enforcement in {"disabled", "evaluate"}
if not (is_active or is_inactive):
continue
has_no_bypass_actors = not (ruleset.get("bypass_actors") or [])
if is_active:
any_active_ruleset = True
if has_no_bypass_actors:
admins_enforced_active = True
else:
any_inactive_ruleset = True
if has_no_bypass_actors:
admins_configured_inactive = True
bucket = active if is_active else inactive
for rule in ruleset.get("rules") or []:
if rule.get("type") != "pull_request":
continue
rule_type = rule.get("type")
params = rule.get("parameters") or {}
dismiss_stale_reviews = (rule.get("parameters") or {}).get(
"dismiss_stale_reviews_on_push"
)
if dismiss_stale_reviews is not True:
continue
if rule_type == "required_linear_history":
bucket.add("required_linear_history")
elif rule_type == "required_signatures":
bucket.add("require_signed_commits")
elif rule_type == "required_status_checks":
# Only enforced when at least one status check is configured;
# an empty list (or just strict policy) requires nothing.
if params.get("required_status_checks"):
bucket.add("status_checks")
elif rule_type == "non_fast_forward":
# Presence of the rule disallows force pushes.
bucket.add("allow_force_pushes")
elif rule_type == "deletion":
# Presence of the rule disallows branch deletion.
bucket.add("branch_deletion")
elif rule_type == "pull_request":
bucket.add("require_pull_request")
if params.get("require_code_owner_review") is True:
bucket.add("require_code_owner_reviews")
if params.get("required_review_thread_resolution") is True:
bucket.add("conversation_resolution")
if params.get("dismiss_stale_reviews_on_push") is True:
bucket.add("dismiss_stale_reviews")
count = params.get("required_approving_review_count")
if isinstance(count, int):
if is_active:
active_approval_count = max(
active_approval_count or 0, count
)
else:
inactive_approval_count = max(
inactive_approval_count or 0, count
)
enforcement = ruleset.get("enforcement")
if enforcement in {"active", "enabled"}:
return True, "ruleset"
if enforcement in {"disabled", "evaluate"}:
has_inactive_ruleset = True
for concept in (
"required_linear_history",
"require_signed_commits",
"status_checks",
"allow_force_pushes",
"branch_deletion",
"require_pull_request",
"require_code_owner_reviews",
"conversation_resolution",
"dismiss_stale_reviews",
):
if concept in active:
result[concept] = (None, "ruleset")
elif concept in inactive:
result[concept] = (None, "ruleset_not_active")
if has_inactive_ruleset:
return False, "ruleset_not_active"
if active_approval_count is not None:
result["approval_count"] = (active_approval_count, "ruleset")
elif inactive_approval_count is not None:
result["approval_count"] = (inactive_approval_count, "ruleset_not_active")
return None, None
if any_active_ruleset:
result["protected"] = (None, "ruleset")
elif any_inactive_ruleset:
result["protected"] = (None, "ruleset_not_active")
if admins_enforced_active:
result["enforce_admins"] = (None, "ruleset")
elif admins_configured_inactive:
result["enforce_admins"] = (None, "ruleset_not_active")
return result
@staticmethod
def _merge_ruleset_bool(
classic_value: Optional[bool], ruleset_source: Optional[str], good: bool = True
) -> tuple[Optional[bool], Optional[str]]:
"""Merge a boolean branch-protection attribute with its ruleset evaluation.
Args:
classic_value: The value resolved from classic branch protection.
ruleset_source: ``"ruleset"``, ``"ruleset_not_active"`` or ``None``.
good: The compliant value for the attribute (``True`` for positive attributes,
``False`` for inverted ones such as ``allow_force_pushes``).
Returns:
A ``(value, source)`` tuple. Classic protection wins when it already satisfies
the control; otherwise an active ruleset enforces the compliant value, and an
inactive ruleset surfaces the non-compliant value with a ``"ruleset_not_active"``
source so checks can explain the gap.
"""
classic_pass = classic_value == good
if ruleset_source == "ruleset":
return good, "ruleset"
if ruleset_source == "ruleset_not_active" and not classic_pass:
return (not good), "ruleset_not_active"
return classic_value, ("classic" if classic_pass else None)
def _list_repositories(self):
"""
@@ -398,6 +516,17 @@ class Repository(GithubService):
conversation_resolution = False
dismiss_stale_reviews = False
dismiss_stale_reviews_source = None
protected_source = None
require_pull_request_source = None
approval_count_source = None
required_linear_history_source = None
allow_force_pushes_source = None
branch_deletion_source = None
require_code_owner_reviews_source = None
require_signed_commits_source = None
status_checks_source = None
enforce_admins_source = None
conversation_resolution_source = None
try:
branch = repo.get_branch(default_branch)
if branch.protected:
@@ -458,20 +587,98 @@ class Repository(GithubService):
f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
if dismiss_stale_reviews is not None:
# Branch protection enforced through rulesets is equivalent to classic branch
# protection, so merge any ruleset coverage before reporting findings to avoid
# false positives for repositories that have migrated to rulesets.
if branch_protection is not None:
ruleset_eval = self._evaluate_default_branch_rulesets(
repo, default_branch
)
branch_protection, protected_source = self._merge_ruleset_bool(
branch_protection, ruleset_eval.get("protected", (None, None))[1]
)
require_pr, require_pull_request_source = self._merge_ruleset_bool(
require_pr,
ruleset_eval.get("require_pull_request", (None, None))[1],
)
(
ruleset_dismiss_stale_reviews,
ruleset_source,
) = self._get_dismiss_stale_reviews_from_rulesets(repo, default_branch)
if ruleset_dismiss_stale_reviews:
required_linear_history,
required_linear_history_source,
) = self._merge_ruleset_bool(
required_linear_history,
ruleset_eval.get("required_linear_history", (None, None))[1],
)
(
require_signed_commits,
require_signed_commits_source,
) = self._merge_ruleset_bool(
require_signed_commits,
ruleset_eval.get("require_signed_commits", (None, None))[1],
)
status_checks, status_checks_source = self._merge_ruleset_bool(
status_checks, ruleset_eval.get("status_checks", (None, None))[1]
)
(
require_code_owner_reviews,
require_code_owner_reviews_source,
) = self._merge_ruleset_bool(
require_code_owner_reviews,
ruleset_eval.get("require_code_owner_reviews", (None, None))[1],
)
(
conversation_resolution,
conversation_resolution_source,
) = self._merge_ruleset_bool(
conversation_resolution,
ruleset_eval.get("conversation_resolution", (None, None))[1],
)
enforce_admins, enforce_admins_source = self._merge_ruleset_bool(
enforce_admins,
ruleset_eval.get("enforce_admins", (None, None))[1],
)
allow_force_pushes, allow_force_pushes_source = (
self._merge_ruleset_bool(
allow_force_pushes,
ruleset_eval.get("allow_force_pushes", (None, None))[1],
good=False,
)
)
branch_deletion, branch_deletion_source = self._merge_ruleset_bool(
branch_deletion,
ruleset_eval.get("branch_deletion", (None, None))[1],
good=False,
)
# Dismiss stale reviews keeps its dedicated handling so the classic source
# set above (when enabled) is preserved.
_, dismiss_source = ruleset_eval.get(
"dismiss_stale_reviews", (None, None)
)
if dismiss_source == "ruleset":
dismiss_stale_reviews = True
dismiss_stale_reviews_source = "ruleset"
elif (
ruleset_source == "ruleset_not_active" and not dismiss_stale_reviews
dismiss_source == "ruleset_not_active" and not dismiss_stale_reviews
):
dismiss_stale_reviews = False
dismiss_stale_reviews_source = "ruleset_not_active"
# Approval count takes the strongest requirement between classic and rulesets.
approval_value, approval_source = ruleset_eval.get(
"approval_count", (None, None)
)
if approval_source == "ruleset" and approval_value is not None:
if approval_value > approval_cnt:
approval_cnt = approval_value
approval_count_source = "ruleset"
elif (
approval_source == "ruleset_not_active"
and (approval_value or 0) >= 2
and approval_cnt < 2
):
approval_count_source = "ruleset_not_active"
secret_scanning_enabled = False
dependabot_alerts_enabled = False
try:
@@ -517,17 +724,28 @@ class Repository(GithubService):
default_branch=Branch(
name=default_branch,
protected=branch_protection,
protected_source=protected_source,
default_branch=True,
require_pull_request=require_pr,
require_pull_request_source=require_pull_request_source,
approval_count=approval_cnt,
approval_count_source=approval_count_source,
required_linear_history=required_linear_history,
required_linear_history_source=required_linear_history_source,
allow_force_pushes=allow_force_pushes,
allow_force_pushes_source=allow_force_pushes_source,
branch_deletion=branch_deletion,
branch_deletion_source=branch_deletion_source,
status_checks=status_checks,
status_checks_source=status_checks_source,
enforce_admins=enforce_admins,
enforce_admins_source=enforce_admins_source,
conversation_resolution=conversation_resolution,
conversation_resolution_source=conversation_resolution_source,
require_code_owner_reviews=require_code_owner_reviews,
require_code_owner_reviews_source=require_code_owner_reviews_source,
require_signed_commits=require_signed_commits,
require_signed_commits_source=require_signed_commits_source,
dismiss_stale_reviews=dismiss_stale_reviews,
dismiss_stale_reviews_source=dismiss_stale_reviews_source,
),
@@ -599,17 +817,28 @@ class Branch(BaseModel):
name: str
protected: Optional[bool]
protected_source: Optional[str] = None
default_branch: bool
require_pull_request: Optional[bool]
require_pull_request_source: Optional[str] = None
approval_count: Optional[int]
approval_count_source: Optional[str] = None
required_linear_history: Optional[bool]
required_linear_history_source: Optional[str] = None
allow_force_pushes: Optional[bool]
allow_force_pushes_source: Optional[str] = None
branch_deletion: Optional[bool]
branch_deletion_source: Optional[str] = None
status_checks: Optional[bool]
status_checks_source: Optional[str] = None
enforce_admins: Optional[bool]
enforce_admins_source: Optional[str] = None
require_code_owner_reviews: Optional[bool]
require_code_owner_reviews_source: Optional[str] = None
require_signed_commits: Optional[bool]
require_signed_commits_source: Optional[str] = None
conversation_resolution: Optional[bool]
conversation_resolution_source: Optional[str] = None
dismiss_stale_reviews: Optional[bool]
dismiss_stale_reviews_source: Optional[str] = None
@@ -155,3 +155,123 @@ class Test_repository_default_branch_deletion_disabled_test:
result[0].status_extended
== f"Repository {repo_name} does deny default branch deletion."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=False,
branch_deletion_source="ruleset",
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
)
now = datetime.now(timezone.utc)
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=default_branch,
private=False,
archived=False,
pushed_at=now,
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_deletion_disabled.repository_default_branch_deletion_disabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled import (
repository_default_branch_deletion_disabled,
)
check = repository_default_branch_deletion_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
branch_deletion_source="ruleset_not_active",
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
)
now = datetime.now(timezone.utc)
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=default_branch,
private=False,
archived=False,
pushed_at=now,
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_deletion_disabled.repository_default_branch_deletion_disabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_deletion_disabled.repository_default_branch_deletion_disabled import (
repository_default_branch_deletion_disabled,
)
check = repository_default_branch_deletion_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has default branch deletion disabled in a ruleset, but the ruleset is not active."
)
@@ -149,3 +149,119 @@ class Test_repository_default_branch_disallows_force_push_test:
result[0].status_extended
== f"Repository {repo_name} does deny force pushes on default branch ({default_branch})."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
allow_force_pushes=False,
allow_force_pushes_source="ruleset",
branch_deletion=False,
status_checks=True,
enforce_admins=True,
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
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=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_default_branch_disallows_force_push.repository_default_branch_disallows_force_push.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push import (
repository_default_branch_disallows_force_push,
)
check = repository_default_branch_disallows_force_push()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
allow_force_pushes_source="ruleset_not_active",
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_disallows_force_push.repository_default_branch_disallows_force_push.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_disallows_force_push.repository_default_branch_disallows_force_push import (
repository_default_branch_disallows_force_push,
)
check = repository_default_branch_disallows_force_push()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has force pushes disallowed in a ruleset on default branch ({default_branch}), but the ruleset is not active."
)
@@ -149,3 +149,119 @@ class Test_repository_default_branch_protection_applies_to_admins_test:
result[0].status_extended
== f"Repository {repo_name} does enforce administrators to be subject to the same branch protection rules as other users."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
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,
enforce_admins_source="ruleset",
require_code_owner_reviews=True,
require_signed_commits=True,
conversation_resolution=True,
),
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=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_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins import (
repository_default_branch_protection_applies_to_admins,
)
check = repository_default_branch_protection_applies_to_admins()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
enforce_admins_source="ruleset_not_active",
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_protection_applies_to_admins.repository_default_branch_protection_applies_to_admins import (
repository_default_branch_protection_applies_to_admins,
)
check = repository_default_branch_protection_applies_to_admins()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has a ruleset that would apply to administrators, but the ruleset is not active."
)
@@ -149,3 +149,119 @@ class Test_repository_default_branch_protection_enabled_test:
result[0].status_extended
== f"Repository {repo_name} does enforce branch protection on default branch ({default_branch})."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=True,
protected_source="ruleset",
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,
),
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=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_default_branch_protection_enabled.repository_default_branch_protection_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled import (
repository_default_branch_protection_enabled,
)
check = repository_default_branch_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=False,
protected_source="ruleset_not_active",
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_protection_enabled.repository_default_branch_protection_enabled.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_protection_enabled.repository_default_branch_protection_enabled import (
repository_default_branch_protection_enabled,
)
check = repository_default_branch_protection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has a ruleset configured on default branch ({default_branch}), but the ruleset is not active."
)
@@ -147,3 +147,117 @@ class Test_repository_default_branch_requires_codeowners_review:
result[0].status_extended
== f"Repository {repo_name} requires code owner approval for changes to owned code."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
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_code_owner_reviews_source="ruleset",
require_signed_commits=True,
conversation_resolution=True,
),
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=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_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import (
repository_default_branch_requires_codeowners_review,
)
check = repository_default_branch_requires_codeowners_review()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_code_owner_reviews_source="ruleset_not_active",
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import (
repository_default_branch_requires_codeowners_review,
)
check = repository_default_branch_requires_codeowners_review()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has code owner approval configured in a ruleset, but the ruleset is not active."
)
@@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_conversation_resolution_test:
result[0].status_extended
== f"Repository {repo_name} does require conversation resolution on default branch ({default_branch})."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
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,
conversation_resolution_source="ruleset",
),
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=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_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution import (
repository_default_branch_requires_conversation_resolution,
)
check = repository_default_branch_requires_conversation_resolution()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
conversation_resolution_source="ruleset_not_active",
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_conversation_resolution.repository_default_branch_requires_conversation_resolution import (
repository_default_branch_requires_conversation_resolution,
)
check = repository_default_branch_requires_conversation_resolution()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has conversation resolution configured in a ruleset on default branch ({default_branch}), but the ruleset is not active."
)
@@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_linear_history_test:
result[0].status_extended
== f"Repository {repo_name} does require linear history on default branch ({default_branch})."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=1,
required_linear_history=True,
required_linear_history_source="ruleset",
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,
),
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=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_default_branch_requires_linear_history.repository_default_branch_requires_linear_history.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history import (
repository_default_branch_requires_linear_history,
)
check = repository_default_branch_requires_linear_history()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
required_linear_history_source="ruleset_not_active",
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_requires_linear_history.repository_default_branch_requires_linear_history.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_linear_history.repository_default_branch_requires_linear_history import (
repository_default_branch_requires_linear_history,
)
check = repository_default_branch_requires_linear_history()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has linear history configured in a ruleset on default branch ({default_branch}), but the ruleset is not active."
)
@@ -207,3 +207,117 @@ class Test_repository_default_branch_requires_multiple_approvals:
result[0].status_extended
== f"Repository {repo_name} does enforce at least 2 approvals for code changes."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name="main",
protected=True,
default_branch=True,
require_pull_request=True,
approval_count=2,
approval_count_source="ruleset",
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals import (
repository_default_branch_requires_multiple_approvals,
)
check = repository_default_branch_requires_multiple_approvals()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name="main",
protected=False,
default_branch=True,
require_pull_request=True,
approval_count=0,
approval_count_source="ruleset_not_active",
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_multiple_approvals.repository_default_branch_requires_multiple_approvals import (
repository_default_branch_requires_multiple_approvals,
)
check = repository_default_branch_requires_multiple_approvals()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has at least 2 approvals configured in a ruleset, but the ruleset is not active."
)
@@ -149,3 +149,119 @@ class Test_repository_default_branch_requires_signed_commits:
result[0].status_extended
== f"Repository {repo_name} does require signed commits on default branch ({default_branch})."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
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,
require_signed_commits_source="ruleset",
conversation_resolution=True,
),
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=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_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import (
repository_default_branch_requires_signed_commits,
)
check = repository_default_branch_requires_signed_commits()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
require_signed_commits_source="ruleset_not_active",
conversation_resolution=False,
),
private=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import (
repository_default_branch_requires_signed_commits,
)
check = repository_default_branch_requires_signed_commits()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has signed commits configured in a ruleset on default branch ({default_branch}), but the ruleset is not active."
)
@@ -151,3 +151,121 @@ class Test_repository_default_branch_status_checks_required_test:
result[0].status_extended
== f"Repository {repo_name} does enforce status checks."
)
def test_active_ruleset_passes(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
private=False,
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=True,
status_checks_source="ruleset",
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
status_checks=True,
archived=False,
pushed_at=datetime.now(timezone.utc),
securitymd=True,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_status_checks_required.repository_default_branch_status_checks_required.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required import (
repository_default_branch_status_checks_required,
)
check = repository_default_branch_status_checks_required()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_inactive_ruleset_fails(self):
repository_client = mock.MagicMock
repo_name = "repo1"
default_branch = "main"
repository_client.repositories = {
1: Repo(
id=1,
name=repo_name,
owner="account-name",
full_name="account-name/repo1",
default_branch=Branch(
name=default_branch,
protected=False,
default_branch=True,
require_pull_request=False,
approval_count=0,
required_linear_history=False,
allow_force_pushes=True,
branch_deletion=True,
status_checks=False,
status_checks_source="ruleset_not_active",
enforce_admins=False,
require_code_owner_reviews=False,
require_signed_commits=False,
conversation_resolution=False,
),
status_checks=False,
archived=False,
pushed_at=datetime.now(timezone.utc),
private=False,
securitymd=False,
codeowners_exists=False,
secret_scanning_enabled=False,
dependabot_alerts_enabled=False,
delete_branch_on_merge=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_default_branch_status_checks_required.repository_default_branch_status_checks_required.repository_client",
new=repository_client,
),
):
from prowler.providers.github.services.repository.repository_default_branch_status_checks_required.repository_default_branch_status_checks_required import (
repository_default_branch_status_checks_required,
)
check = repository_default_branch_status_checks_required()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Repository {repo_name} has status checks configured in a ruleset, but the ruleset is not active."
)
@@ -464,7 +464,7 @@ class Test_Repository_ErrorHandling:
assert "Rate limit exceeded" in str(mock_logger.error.call_args)
class Test_Repository_DismissStaleReviewsRulesets:
class Test_Repository_BranchProtectionRulesets:
def setup_method(self):
self.repository_service = Repository.__new__(Repository)
self.repository_service.provider = set_mocked_github_provider()
@@ -550,6 +550,27 @@ class Test_Repository_DismissStaleReviewsRulesets:
],
}
def _build_ruleset(
self,
*,
enforcement,
include,
rules,
bypass_actors=None,
ruleset_id=201,
):
return {
"id": ruleset_id,
"name": "Branch protection ruleset",
"target": "branch",
"source_type": "Repository",
"source": "owner1/repo1",
"enforcement": enforcement,
"bypass_actors": bypass_actors or [],
"conditions": {"ref_name": {"include": include, "exclude": []}},
"rules": rules,
}
def test_process_repository_uses_classic_branch_protection(self):
repo = self._build_repo(branch_protected=True, dismiss_stale_reviews=True)
repos = {}
@@ -606,3 +627,320 @@ class Test_Repository_DismissStaleReviewsRulesets:
assert (
repos[1].default_branch.dismiss_stale_reviews_source == "ruleset_not_active"
)
def test_ruleset_non_fast_forward_disallows_force_push(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "non_fast_forward"}],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.allow_force_pushes is False
assert repos[1].default_branch.allow_force_pushes_source == "ruleset"
def test_ruleset_non_fast_forward_inactive_keeps_force_push_allowed(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="disabled",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "non_fast_forward"}],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.allow_force_pushes is True
assert repos[1].default_branch.allow_force_pushes_source == "ruleset_not_active"
def test_classic_protection_takes_precedence_over_inactive_ruleset(self):
# Classic protection already disallows force pushes, so an inactive ruleset
# must not downgrade the result.
repo = self._build_repo(
branch_protected=True,
ruleset_details=[
self._build_ruleset(
enforcement="disabled",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "non_fast_forward"}],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.allow_force_pushes is False
assert repos[1].default_branch.allow_force_pushes_source == "classic"
def test_ruleset_required_signatures_requires_signed_commits(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "required_signatures"}],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.require_signed_commits is True
assert repos[1].default_branch.require_signed_commits_source == "ruleset"
def test_ruleset_required_linear_history(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "required_linear_history"}],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.required_linear_history is True
assert repos[1].default_branch.required_linear_history_source == "ruleset"
def test_ruleset_required_status_checks(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[
{
"type": "required_status_checks",
"parameters": {
"required_status_checks": [{"context": "ci/build"}],
"strict_required_status_checks_policy": True,
},
}
],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.status_checks is True
assert repos[1].default_branch.status_checks_source == "ruleset"
def test_ruleset_required_status_checks_without_configured_checks(self):
# A required_status_checks rule with an empty list enforces nothing, so it
# must not be treated as a passing status-checks requirement.
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[
{
"type": "required_status_checks",
"parameters": {
"required_status_checks": [],
"strict_required_status_checks_policy": True,
},
}
],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.status_checks is False
assert repos[1].default_branch.status_checks_source is None
def test_ruleset_deletion_disables_branch_deletion(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "deletion"}],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.branch_deletion is False
assert repos[1].default_branch.branch_deletion_source == "ruleset"
def test_active_ruleset_marks_default_branch_protected(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "non_fast_forward"}],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.protected is True
assert repos[1].default_branch.protected_source == "ruleset"
def test_ruleset_pull_request_parameters_map_to_attributes(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[
{
"type": "pull_request",
"parameters": {
"dismiss_stale_reviews_on_push": False,
"require_code_owner_review": True,
"require_last_push_approval": False,
"required_approving_review_count": 2,
"required_review_thread_resolution": True,
},
}
],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
branch = repos[1].default_branch
assert branch.require_pull_request is True
assert branch.require_pull_request_source == "ruleset"
assert branch.require_code_owner_reviews is True
assert branch.require_code_owner_reviews_source == "ruleset"
assert branch.conversation_resolution is True
assert branch.conversation_resolution_source == "ruleset"
assert branch.approval_count == 2
assert branch.approval_count_source == "ruleset"
def test_inactive_ruleset_approval_count_is_fail_signal(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="disabled",
include=["~DEFAULT_BRANCH"],
rules=[
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 2,
},
}
],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.approval_count == 0
assert repos[1].default_branch.approval_count_source == "ruleset_not_active"
def test_active_ruleset_without_bypass_actors_applies_to_admins(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "non_fast_forward"}],
bypass_actors=[],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.enforce_admins is True
assert repos[1].default_branch.enforce_admins_source == "ruleset"
def test_active_ruleset_with_bypass_actors_does_not_apply_to_admins(self):
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="active",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "non_fast_forward"}],
bypass_actors=[
{
"actor_id": 1,
"actor_type": "RepositoryRole",
"bypass_mode": "always",
}
],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
# Admins can bypass, so the rulesets do not enforce protection for them.
assert repos[1].default_branch.enforce_admins is False
assert repos[1].default_branch.enforce_admins_source is None
def test_inactive_ruleset_with_bypass_actors_is_not_admin_fail_signal(self):
# A disabled ruleset that has bypass actors would not apply to admins even if
# activated, so it must not raise the enforce-admins ruleset_not_active signal.
repo = self._build_repo(
branch_protected=False,
ruleset_details=[
self._build_ruleset(
enforcement="disabled",
include=["~DEFAULT_BRANCH"],
rules=[{"type": "non_fast_forward"}],
bypass_actors=[
{
"actor_id": 1,
"actor_type": "RepositoryRole",
"bypass_mode": "always",
}
],
)
],
)
repos = {}
self.repository_service._process_repository(repo, repos)
assert repos[1].default_branch.enforce_admins is False
assert repos[1].default_branch.enforce_admins_source is None
# The branch is still reported as protected-but-inactive regardless of bypass.
assert repos[1].default_branch.protected_source == "ruleset_not_active"