Public/Test-BranchProtection.ps1

function Test-BranchProtection {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [string]$Owner,

        [Parameter(Mandatory)]
        [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: $_" `
            -Remediation 'Verify the repository exists and the token has contents:read access.' `
            -Target $target))
        return $results.ToArray()
    }

    $resource = "$target (branch: $defaultBranch)"

    try {
        $protection = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/branches/$defaultBranch/protection" -Token $Token
    }
    catch {
        $msg = $_.ToString()

        if ($msg -match '404') {
            $results.Add((Format-FylgyrResult `
                -CheckName 'BranchProtection' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource $resource `
                -Detail "Branch '$defaultBranch' has no protection rules configured." `
                -Remediation 'Enable branch protection in repository Settings → Branches. Require pull request reviews and status checks.' `
                -AttackMapping @('trivy-force-push-main', 'codecov-bash-uploader') `
                -Target $target))
            return $results.ToArray()
        }

        if ($msg -match '403') {
            $results.Add((Format-FylgyrResult `
                -CheckName 'BranchProtection' `
                -Status 'Error' `
                -Severity 'High' `
                -Resource $resource `
                -Detail "Insufficient permissions to read branch protection. Requires admin access or a fine-grained token with administration:read." `
                -Remediation 'Use a token with admin access to the repository, or grant the token the administration:read permission.' `
                -Target $target))
            return $results.ToArray()
        }

        $results.Add((Format-FylgyrResult `
            -CheckName 'BranchProtection' `
            -Status 'Error' `
            -Severity 'High' `
            -Resource $resource `
            -Detail "Unexpected error reading branch protection: $_" `
            -Remediation 'Re-run with a valid token and verify network access to api.github.com.' `
            -Target $target))
        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()
}