diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 6e8306b905..e63bec1d1b 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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) --- diff --git a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py index bf615a21b6..1f05035723 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json index f774f204d0..2ef741d253 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py index 16eb7fa794..de61256080 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py +++ b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json index 70668adbc1..75c84dc752 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py index 0a70cf0aea..142f0a5b7b 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json index 8f173e868f..dbefae6179 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py index 1348018ee1..b305f5019a 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json index 465e61aad9..53f849271a 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py index 4c5b75010a..3b8a749961 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py @@ -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) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json index beaff24afb..c304ef26c1 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py index da33754cf5..9c25f8a14f 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json index 215ab496aa..5ecb27ffc6 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py index 29a0e51b51..86f944738c 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json index dda4f25ea0..c7f07b1101 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py index 6312b98d02..5ddf37e853 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json index d3d9c91806..9b045fc4e9 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py index 4b6aa3ae4c..110a7d4a1e 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json index 6d450fde33..d0799962ce 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.metadata.json @@ -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": { diff --git a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py index e67b9def2c..f96d5b058d 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py +++ b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py @@ -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" diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index 0201d199e4..fd8aa28662 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -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 diff --git a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py index b4d385fdf1..c57393b314 100644 --- a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py index b35393764e..81ab6bf0cc 100644 --- a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py index 296c3d78e5..fbc7e22d68 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py index 67bce91575..5fce4e7553 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py index ab3d579b75..fc52676845 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py index 8f5a82bbd8..d283fd1b26 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py index b072396804..99c6abcd29 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py index ddb9e89df6..72fb5470a8 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py index 5785dd2a14..0f6f0112a7 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py index 1ec8acc3e0..da36b36865 100644 --- a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py @@ -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." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index 97924cbbd8..d0f3573e49 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -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"