Public/Test-GitHubAppSecurity.ps1

function Test-GitHubAppSecurity {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Owner,

        [Parameter(Mandatory)]
        [string]$Token
    )

    $resource = "org/$Owner"
    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    $ownerContext = Get-FylgyrOwnerContext -Owner $Owner -Token $Token
    if ($ownerContext.Type -eq 'User') {
        $results.Add((Format-FylgyrResult `
            -CheckName 'GitHubAppSecurity' `
            -Status 'Info' `
            -Severity 'Info' `
            -Resource $resource `
            -Detail "Owner '$Owner' is a personal account. Organization-level GitHub App installation audit does not apply." `
            -Remediation 'No action needed. Run this check against an organization owner.' `
            -Target $resource))
        return $results.ToArray()
    }

    if ($ownerContext.Type -eq 'Unknown') {
        $results.Add((Format-FylgyrResult `
            -CheckName 'GitHubAppSecurity' `
            -Status 'Error' `
            -Severity 'Medium' `
            -Resource $resource `
            -Detail "Could not resolve owner type for '$Owner'." `
            -Remediation 'Verify owner name and token permissions, then rerun.' `
            -Target $resource))
        return $results.ToArray()
    }

    try {
        $installationsResponse = Invoke-GitHubApi -Endpoint "orgs/$Owner/installations" -Token $Token
    }
    catch {
        $msg = $_.Exception.Message
        if ($msg -match '403') {
            $results.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Error' `
                -Severity 'Medium' `
                -Resource $resource `
                -Detail 'Insufficient permissions to read organization GitHub App installations.' `
                -Remediation 'Use a fine-grained token with organization Administration:read, or a classic token with admin:org scope.' `
                -Target $resource))
            return $results.ToArray()
        }

        $results.Add((Format-FylgyrResult `
            -CheckName 'GitHubAppSecurity' `
            -Status 'Error' `
            -Severity 'Medium' `
            -Resource $resource `
            -Detail "Failed to list organization GitHub App installations: $($_.Exception.Message)" `
            -Remediation 'Verify token scope and organization access.' `
            -Target $resource))
        return $results.ToArray()
    }

    $appList = if ($installationsResponse -and $installationsResponse.PSObject.Properties['installations']) {
        @($installationsResponse.installations)
    }
    elseif ($installationsResponse -is [System.Array]) {
        @($installationsResponse)
    }
    else {
        @()
    }

    if ($appList.Count -eq 0) {
        $results.Add((Format-FylgyrResult `
            -CheckName 'GitHubAppSecurity' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $resource `
            -Detail 'No GitHub App installations found for this organization.' `
            -Remediation 'No action needed.' `
            -Target $resource))
        return $results.ToArray()
    }

    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($app in $appList) {
        $appName = if ($app.app_slug) { [string]$app.app_slug } else { "installation-$($app.id)" }
        $permissions = $app.permissions
        if (-not $permissions) {
            continue
        }

        $writePermissions = [System.Collections.Generic.List[string]]::new()
        foreach ($property in $permissions.PSObject.Properties) {
            if ($property.Value -eq 'write') {
                $writePermissions.Add("$($property.Name):write")
            }
        }

        $hasContentsWrite = $permissions.PSObject.Properties['contents'] -and $permissions.contents -eq 'write'
        $hasActionsWrite = $permissions.PSObject.Properties['actions'] -and $permissions.actions -eq 'write'
        $hasWorkflowsWrite = $permissions.PSObject.Properties['workflows'] -and $permissions.workflows -eq 'write'
        $hasMembersWrite = $permissions.PSObject.Properties['members'] -and $permissions.members -eq 'write'
        $hasSecretsWrite = $permissions.PSObject.Properties['secrets'] -and $permissions.secrets -eq 'write'
        $hasRepoAdminWrite = $permissions.PSObject.Properties['administration'] -and $permissions.administration -eq 'write'
        $hasOrgAdminWrite = $permissions.PSObject.Properties['organization_administration'] -and $permissions.organization_administration -eq 'write'

        $isAllRepos = $app.repository_selection -eq 'all'
        $isSelectedRepos = $app.repository_selection -eq 'selected'

        if ($hasOrgAdminWrite) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'Critical' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' has organization_administration:write. A stolen installation token can directly alter organization security posture and drive full org takeover." `
                -Remediation 'Remove organization_administration:write unless strictly required, and scope this app to least privilege.' `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }

        if ($isAllRepos -and $writePermissions.Count -gt 0) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' is installed on all repositories with write permissions ($($writePermissions -join ', ')). Compromise of this app has organization-wide blast radius." `
                -Remediation 'Restrict installation to selected repositories and remove write scopes not required for the app function.' `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }
        elseif ($isSelectedRepos -and $writePermissions.Count -gt 0) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Info' `
                -Severity 'Info' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' is scoped to selected repositories with write permissions ($($writePermissions -join ', ')). This is lower blast radius than all-repos installs but should still be periodically reviewed." `
                -Remediation 'Keep selected-repository scoping and review write permissions during access reviews.' `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }

        if ($isAllRepos -and $hasContentsWrite -and ($hasActionsWrite -or $hasWorkflowsWrite)) {
            $permPairs = [System.Collections.Generic.List[string]]::new()
            $permPairs.Add('contents:write')
            if ($hasActionsWrite) { $permPairs.Add('actions:write') }
            if ($hasWorkflowsWrite) { $permPairs.Add('workflows:write') }

            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'Critical' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' is all-repos with $($permPairs -join ', '). This enables direct workflow injection and execution across the organization." `
                -Remediation 'Remove one of the write capabilities and restrict install scope to selected repositories.' `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }

        if ($hasMembersWrite -and $hasContentsWrite) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' combines members:write with contents:write. This enables identity-plane and code-plane abuse from one compromised integration." `
                -Remediation 'Split this capability across separate apps or remove one of the write scopes.' `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }

        if ($hasSecretsWrite -and $hasActionsWrite) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' combines secrets:write with actions:write. This creates a direct secret-overwrite and workflow-execution abuse path." `
                -Remediation 'Remove one of these write scopes or isolate responsibilities across separate GitHub Apps.' `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }

        if ($hasRepoAdminWrite) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' has repository administration:write. This can weaken branch protections or repository security controls." `
                -Remediation 'Drop administration:write unless required by app design.' `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }

        # Installation activity date fields vary by endpoint payload. If an installation
        # has a parseable timestamp older than 90 days, flag as stale for review.
        $activityDate = $null
        foreach ($candidateField in @('updated_at', 'last_used_at', 'created_at')) {
            if ($app.PSObject.Properties[$candidateField] -and $app.$candidateField) {
                try {
                    $activityDate = [DateTime]::Parse([string]$app.$candidateField)
                    break
                }
                catch {
                    Write-Debug "Could not parse '$candidateField' for app '$appName': $($_.Exception.Message)"
                }
            }
        }

        if ($activityDate) {
            $inactiveDays = [int]([DateTime]::UtcNow - $activityDate.ToUniversalTime()).TotalDays
            if ($inactiveDays -gt 90) {
                $findings.Add((Format-FylgyrResult `
                    -CheckName 'GitHubAppSecurity' `
                    -Status 'Warning' `
                    -Severity 'Medium' `
                    -Resource "$resource (app: $appName)" `
                    -Detail "GitHub App '$appName' appears inactive for $inactiveDays days based on installation metadata. Dormant integrations increase attack surface." `
                    -Remediation 'Review whether this app is still needed. Uninstall stale integrations and rotate any related credentials.' `
                    -AttackMapping @('github-app-token-theft') `
                    -Target $resource))
            }
        }
    }

    if ($findings.Count -eq 0) {
        $results.Add((Format-FylgyrResult `
            -CheckName 'GitHubAppSecurity' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $resource `
            -Detail "All $($appList.Count) GitHub App installation(s) have acceptable scope and permission posture." `
            -Remediation 'No action needed.' `
            -Target $resource))
    }
    else {
        foreach ($finding in $findings) {
            $results.Add($finding)
        }
    }

    $results.ToArray()
}