Public/Test-GitHubAppSecurity.ps1

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

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

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

    # Determine whether the owner is an Organization or a User.
    $ownerType = $null
    try {
        $ownerInfo = Invoke-GitHubApi -Endpoint "users/$Owner" -Token $Token
        if ($ownerInfo -and $ownerInfo.PSObject.Properties['type']) {
            $ownerType = $ownerInfo.type
        }
    }
    catch {
        $msg = $_.Exception.Message
        if ($msg -match '404') {
            $results.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Error' `
                -Severity 'Medium' `
                -Resource $resource `
                -Detail "Owner '$Owner' not found." `
                -Remediation 'Verify the owner name.' `
                -Target $resource))
            return $results.ToArray()
        }

        $results.Add((Format-FylgyrResult `
            -CheckName 'GitHubAppSecurity' `
            -Status 'Error' `
            -Severity 'Medium' `
            -Resource $resource `
            -Detail "Failed to resolve owner type: $($_.Exception.Message)" `
            -Remediation 'Re-run with a valid token and verify network access to api.github.com.' `
            -Target $resource))
        return $results.ToArray()
    }

    $installationsResponse = $null
    $auditScope = $null

    if ($ownerType -eq 'Organization') {
        $auditScope = 'organization'
        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 classic token with admin:org scope, or a fine-grained PAT with organization admin access to audit GitHub App installations.' `
                    -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 the token has org admin access.' `
                -Target $resource))
            return $results.ToArray()
        }
    }
    else {
        # User account path. GitHub has no `users/{user}/installations` endpoint;
        # the only way to list a user's installed apps is via `user/installations`,
        # which requires that the supplied token belong to that user.
        $auditScope = 'user'

        $authenticatedLogin = $null
        try {
            $authed = Invoke-GitHubApi -Endpoint 'user' -Token $Token
            if ($authed -and $authed.PSObject.Properties['login']) {
                $authenticatedLogin = $authed.login
            }
        }
        catch {
            Write-Debug "Failed to resolve authenticated user: $($_.Exception.Message)"
        }

        if (-not $authenticatedLogin -or ($authenticatedLogin -ne $Owner)) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Info' `
                -Severity 'Info' `
                -Resource $resource `
                -Detail "Owner '$Owner' is a personal GitHub account. Auditing GitHub App installations on a user account requires a token belonging to that user; the supplied token belongs to '$authenticatedLogin'. Personal account App audit skipped." `
                -Remediation "Re-run with a token owned by '$Owner' (fine-grained PAT or classic token) to audit personal GitHub App installations. For organizations, use a token with admin:org access." `
                -Target $resource))
            return $results.ToArray()
        }

        try {
            $installationsResponse = Invoke-GitHubApi -Endpoint 'user/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 user GitHub App installations.' `
                    -Remediation 'Use a fine-grained PAT with access to the user account, or a classic token with the read:user scope.' `
                    -Target $resource))
                return $results.ToArray()
            }

            $results.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Error' `
                -Severity 'Medium' `
                -Resource $resource `
                -Detail "Failed to list user GitHub App installations: $($_.Exception.Message)" `
                -Remediation 'Re-run with a valid token and verify network access to api.github.com.' `
                -Target $resource))
            return $results.ToArray()
        }
    }

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

    if (-not $appList -or $appList.Count -eq 0) {
        $scopeWord = if ($auditScope -eq 'organization') { 'organization' } else { 'user account' }
        $results.Add((Format-FylgyrResult `
            -CheckName 'GitHubAppSecurity' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $resource `
            -Detail "No GitHub App installations found on this $scopeWord." `
            -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) { $app.app_slug } else { "installation-$($app.id)" }
        $permissions = $app.permissions

        if (-not $permissions) {
            continue
        }

        $hasContentsWrite = $permissions.PSObject.Properties['contents'] -and $permissions.contents -eq 'write'
        $hasActionsWrite = $permissions.PSObject.Properties['actions'] -and $permissions.actions -eq 'write'
        $hasAdminPerm = $permissions.PSObject.Properties['administration'] -and $permissions.administration -in @('write', 'read')

        # Apps installed against "all" repositories have the largest blast radius.
        $isAllRepos = $app.repository_selection -eq 'all'
        $scopeLabel = if ($auditScope -eq 'organization') { 'org-wide' } else { 'across all of your repositories' }

        if ($isAllRepos -and $hasContentsWrite -and $hasActionsWrite) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'Critical' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' is installed $scopeLabel with both contents:write and actions:write permissions. If this app is compromised, an attacker can modify workflow files and trigger them across every repository the app can reach." `
                -Remediation "Restrict this app to specific repositories and audit whether it needs both contents:write and actions:write. Remove unnecessary permissions following the principle of least privilege." `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }
        elseif ($isAllRepos -and ($hasContentsWrite -or $hasActionsWrite)) {
            $writePerm = if ($hasContentsWrite) { 'contents:write' } else { 'actions:write' }
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' is installed $scopeLabel with $writePerm permission. Compromising this app grants write access across every repository the app can reach." `
                -Remediation "Restrict this app to only the repositories that require it. Review whether $writePerm is necessary." `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }
        elseif ($isAllRepos) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Warning' `
                -Severity 'Medium' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' is installed $scopeLabel. Even with read-only permissions, installing against all repositories increases the blast radius if the app is compromised." `
                -Remediation 'Consider restricting this app to specific repositories that need it.' `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }

        if ($hasAdminPerm) {
            $findings.Add((Format-FylgyrResult `
                -CheckName 'GitHubAppSecurity' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource "$resource (app: $appName)" `
                -Detail "GitHub App '$appName' has administration permission. This allows modifying repository settings including branch protection rules." `
                -Remediation "Audit whether this app requires administration permission. Apps with admin access can disable branch protection, enabling direct pushes to protected branches." `
                -AttackMapping @('github-app-token-theft') `
                -Target $resource))
        }
    }

    if ($findings.Count -eq 0) {
        $scopeWord = if ($auditScope -eq 'organization') { 'organization' } else { 'user account' }
        $results.Add((Format-FylgyrResult `
            -CheckName 'GitHubAppSecurity' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $resource `
            -Detail "All $($appList.Count) GitHub App installation(s) on this $scopeWord have appropriate scoping and permissions." `
            -Remediation 'No action needed.' `
            -Target $resource))
    }
    else {
        foreach ($finding in $findings) { $results.Add($finding) }
    }

    $results.ToArray()
}