Public/Test-BranchProtection.ps1
|
function Test-BranchProtection { [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory)] [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Owner, [Parameter(Mandatory)] [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Repo, [Parameter(Mandatory)] [string]$Token ) $target = "$Owner/$Repo" $results = [System.Collections.Generic.List[PSCustomObject]]::new() # Determine default branch $defaultBranch = 'main' try { $repoInfo = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo" -Token $Token $defaultBranch = $repoInfo.default_branch } catch { $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Error' ` -Severity 'High' ` -Resource $target ` -Detail "Failed to retrieve repository info: $($_.Exception.Message)" ` -Remediation 'Verify the repository exists and the token has contents:read access.' ` -Target $target)) return $results.ToArray() } $resource = "$target (branch: $defaultBranch)" $isActiveRuleset = { param([object]$Ruleset) if (-not $Ruleset) { return $false } if (-not $Ruleset.PSObject.Properties['enforcement'] -or -not $Ruleset.enforcement) { return $true } return $Ruleset.enforcement -in @('active', 'evaluate') } $targetsDefaultBranch = { param([object]$Ruleset, [string]$BranchName) if (-not $Ruleset -or -not $Ruleset.PSObject.Properties['conditions'] -or -not $Ruleset.conditions) { return $true } $conditionsJson = $Ruleset.conditions | ConvertTo-Json -Depth 8 if ($conditionsJson -match 'DEFAULT_BRANCH' -or $conditionsJson -match [regex]::Escape("refs/heads/$BranchName") -or $conditionsJson -match [regex]::Escape($BranchName)) { return $true } return $false } $protection = $null $classicProtectionState = 'Unknown' try { $protection = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/branches/$defaultBranch/protection" -Token $Token $classicProtectionState = 'Available' } catch { $msg = $_.Exception.Message if ($msg -match '404') { $classicProtectionState = 'NotFound' } elseif ($msg -match '403') { $classicProtectionState = 'Forbidden' } else { $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Error' ` -Severity 'High' ` -Resource $resource ` -Detail "Unexpected error reading classic branch protection: $($_.Exception.Message)" ` -Remediation 'Re-run with a valid token and verify network access to api.github.com.' ` -Target $target)) return $results.ToArray() } } if ($classicProtectionState -ne 'Available') { $rulesetsResponse = $null try { $rulesetsResponse = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/rulesets" -Token $Token } catch { $rulesetMessage = $_.Exception.Message if ($rulesetMessage -match '403') { $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Error' ` -Severity 'High' ` -Resource $resource ` -Detail 'Insufficient permissions to read classic branch protection and branch rulesets.' ` -Remediation 'Use a fine-grained token with Administration:read permission, or a classic token with repo scope.' ` -Target $target)) return $results.ToArray() } if ($rulesetMessage -match '404') { $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'High' ` -Resource $resource ` -Detail "Branch '$defaultBranch' has no classic branch protection and no accessible branch rulesets." ` -Remediation 'Enable branch protection in Settings → Branches or add an active branch ruleset in Settings → Rules → Rulesets.' ` -AttackMapping @('trivy-force-push-main', 'codecov-bash-uploader') ` -Target $target)) return $results.ToArray() } $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Error' ` -Severity 'High' ` -Resource $resource ` -Detail "Unexpected error reading branch rulesets: $rulesetMessage" ` -Remediation 'Re-run with a valid token and verify network access to api.github.com.' ` -Target $target)) return $results.ToArray() } $rulesets = if ($rulesetsResponse -is [System.Array]) { @($rulesetsResponse) } elseif ($rulesetsResponse -and $rulesetsResponse.PSObject.Properties['rulesets']) { @($rulesetsResponse.rulesets) } elseif ($rulesetsResponse) { @($rulesetsResponse) } else { @() } $activeBranchRulesets = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($ruleset in $rulesets) { if ($ruleset.target -eq 'branch' -and (& $isActiveRuleset $ruleset) -and (& $targetsDefaultBranch $ruleset $defaultBranch)) { $activeBranchRulesets.Add($ruleset) } } if ($classicProtectionState -eq 'Forbidden') { $rulesetContext = if ($activeBranchRulesets.Count -gt 0) { "Detected $($activeBranchRulesets.Count) active branch ruleset(s) for '$defaultBranch', but classic branch protection could not be read." } else { "No active branch rulesets targeting '$defaultBranch' were readable." } $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Error' ` -Severity 'High' ` -Resource $resource ` -Detail "Insufficient permissions to fully evaluate branch protection (classic branch protection endpoint returned 403). $rulesetContext" ` -Remediation 'Use a fine-grained token with Administration:read permission, or a classic token with repo scope.' ` -Target $target)) return $results.ToArray() } if ($activeBranchRulesets.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'High' ` -Resource $resource ` -Detail "Branch '$defaultBranch' has no classic branch protection and no active branch ruleset targeting it." ` -Remediation 'Enable branch protection in Settings → Branches or add an active branch ruleset targeting the default branch in Settings → Rules → Rulesets.' ` -AttackMapping @('trivy-force-push-main', 'codecov-bash-uploader') ` -Target $target)) return $results.ToArray() } $resolvedBranchRulesets = [System.Collections.Generic.List[PSCustomObject]]::new() $unresolvedRulesetCount = 0 foreach ($ruleset in $activeBranchRulesets) { $resolvedRuleset = $ruleset $hasRulesOnListResponse = $ruleset.PSObject.Properties['rules'] -and $ruleset.rules -and @($ruleset.rules).Count -gt 0 if (-not $hasRulesOnListResponse -and $ruleset.PSObject.Properties['id'] -and $ruleset.id) { try { $rulesetId = [string]$ruleset.id $rulesetDetail = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/rulesets/$rulesetId" -Token $Token if ($rulesetDetail -and $rulesetDetail.PSObject.Properties['rules'] -and $rulesetDetail.rules -and @($rulesetDetail.rules).Count -gt 0) { $resolvedRuleset = $rulesetDetail } } catch { Write-Debug "Unable to fetch ruleset details for id '$($ruleset.id)' on '$target': $($_.Exception.Message)" } } $hasResolvedRules = $resolvedRuleset.PSObject.Properties['rules'] -and $resolvedRuleset.rules -and @($resolvedRuleset.rules).Count -gt 0 if (-not $hasResolvedRules) { $unresolvedRulesetCount++ } $resolvedBranchRulesets.Add($resolvedRuleset) } $findings = [System.Collections.Generic.List[PSCustomObject]]::new() $ruleTypes = [System.Collections.Generic.List[string]]::new() $pullRequestRules = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($ruleset in $resolvedBranchRulesets) { if (-not $ruleset.PSObject.Properties['rules'] -or $null -eq $ruleset.rules) { continue } foreach ($rule in @($ruleset.rules)) { if (-not $rule -or -not $rule.PSObject.Properties['type']) { continue } $ruleTypes.Add([string]$rule.type) if ($rule.type -eq 'pull_request') { $pullRequestRules.Add($rule) } } } if ($ruleTypes.Count -eq 0 -and $unresolvedRulesetCount -gt 0) { $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Error' ` -Severity 'High' ` -Resource $resource ` -Detail "Active branch ruleset for '$defaultBranch' was found, but its rule definitions could not be retrieved for evaluation." ` -Remediation 'Ensure the token has Administration:read on the repository and rerun the scan.' ` -Target $target)) return $results.ToArray() } $hasNonFastForwardRule = $ruleTypes -contains 'non_fast_forward' if (-not $hasNonFastForwardRule) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'High' ` -Resource $resource ` -Detail "Active branch ruleset for '$defaultBranch' does not block non-fast-forward updates (force-push equivalent)." ` -Remediation 'Add the non-fast-forward rule to the active branch ruleset.' ` -AttackMapping @('trivy-force-push-main', 'codecov-bash-uploader') ` -Target $target)) } $hasDeletionRule = $ruleTypes -contains 'deletion' if (-not $hasDeletionRule) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Active branch ruleset for '$defaultBranch' does not block branch deletion." ` -Remediation 'Add the deletion rule to the active branch ruleset.' ` -AttackMapping @('trivy-force-push-main') ` -Target $target)) } if ($pullRequestRules.Count -eq 0) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'High' ` -Resource $resource ` -Detail "Active branch ruleset for '$defaultBranch' does not require pull requests before merging." ` -Remediation 'Add a pull_request rule that requires pull request review before merge.' ` -AttackMapping @('trivy-force-push-main', 'codecov-bash-uploader') ` -Target $target)) } else { $requiresApprover = $false $dismissesStaleReviews = $false foreach ($pullRequestRule in $pullRequestRules) { if (-not $pullRequestRule.PSObject.Properties['parameters'] -or -not $pullRequestRule.parameters) { continue } $pullRequestParameters = $pullRequestRule.parameters if ($pullRequestParameters.PSObject.Properties['required_approving_review_count']) { $requiredApprovals = [int]$pullRequestParameters.required_approving_review_count if ($requiredApprovals -ge 1) { $requiresApprover = $true } } if ($pullRequestParameters.PSObject.Properties['dismiss_stale_reviews_on_push'] -and $pullRequestParameters.dismiss_stale_reviews_on_push -eq $true) { $dismissesStaleReviews = $true } } if (-not $requiresApprover) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Active branch ruleset for '$defaultBranch' allows 0 approving reviews." ` -Remediation 'Set required approving review count to at least 1 in the pull_request ruleset configuration.' ` -AttackMapping @('trivy-force-push-main') ` -Target $target)) } if (-not $dismissesStaleReviews) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Active branch ruleset for '$defaultBranch' does not dismiss stale pull request reviews when new commits are pushed." ` -Remediation "Enable stale review dismissal (dismiss_stale_reviews_on_push) in the pull_request ruleset." ` -AttackMapping @('trivy-force-push-main') ` -Target $target)) } } $hasStatusChecksRule = $ruleTypes -contains 'required_status_checks' if (-not $hasStatusChecksRule) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Active branch ruleset for '$defaultBranch' does not require status checks before merge." ` -Remediation 'Add a required_status_checks rule with CI contexts.' ` -AttackMapping @('codecov-bash-uploader') ` -Target $target)) } if ($findings.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail "Branch '$defaultBranch' is protected via active branch ruleset controls." ` -Remediation 'No action needed.' ` -Target $target)) } else { foreach ($finding in $findings) { $results.Add($finding) } } return $results.ToArray() } $findings = [System.Collections.Generic.List[PSCustomObject]]::new() # Force push allowed if (-not $protection.PSObject.Properties['allow_force_pushes'] -or $null -eq $protection.allow_force_pushes) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Error' ` -Severity 'High' ` -Resource $resource ` -Detail "Branch '$defaultBranch' force-push setting could not be evaluated (property missing from API response)." ` -Remediation 'Verify the branch protection rule exposes the force-push setting and that the token has sufficient access to read it.' ` -Target $target)) } elseif ($protection.allow_force_pushes.enabled -eq $true) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'High' ` -Resource $resource ` -Detail "Branch '$defaultBranch' allows force pushes." ` -Remediation "Disable force pushes in Settings → Branches → Branch protection rules." ` -AttackMapping @('trivy-force-push-main', 'codecov-bash-uploader') ` -Target $target)) } # Deletions allowed if ($protection.PSObject.Properties['allow_deletions'] -and $protection.allow_deletions.enabled -eq $true) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Branch '$defaultBranch' allows deletion." ` -Remediation "Disable branch deletion in Settings → Branches → Branch protection rules." ` -AttackMapping @('trivy-force-push-main') ` -Target $target)) } # No required PR reviews if (-not $protection.PSObject.Properties['required_pull_request_reviews'] -or $null -eq $protection.required_pull_request_reviews) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'High' ` -Resource $resource ` -Detail "Branch '$defaultBranch' does not require pull request reviews before merging." ` -Remediation "Enable required pull request reviews with at least 1 approver in Settings → Branches." ` -AttackMapping @('trivy-force-push-main', 'codecov-bash-uploader') ` -Target $target)) } else { $prReviews = $protection.required_pull_request_reviews if ($prReviews.required_approving_review_count -eq 0) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Branch '$defaultBranch' requires pull request reviews but allows 0 approvers." ` -Remediation "Set required approving review count to at least 1 in Settings → Branches." ` -AttackMapping @('trivy-force-push-main') ` -Target $target)) } if (-not $prReviews.dismiss_stale_reviews) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Branch '$defaultBranch' does not dismiss stale pull request reviews when new commits are pushed." ` -Remediation "Enable 'Dismiss stale pull request approvals when new commits are pushed' in Settings → Branches." ` -AttackMapping @('trivy-force-push-main') ` -Target $target)) } } # No required status checks if (-not $protection.PSObject.Properties['required_status_checks'] -or $null -eq $protection.required_status_checks) { $findings.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Fail' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Branch '$defaultBranch' does not require status checks to pass before merging." ` -Remediation "Enable required status checks (e.g., CI) in Settings → Branches." ` -AttackMapping @('codecov-bash-uploader') ` -Target $target)) } if ($findings.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'BranchProtection' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail "Branch '$defaultBranch' has adequate protection rules configured." ` -Remediation 'No action needed.' ` -Target $target)) } else { foreach ($finding in $findings) { $results.Add($finding) } } $results.ToArray() } |