Public/Drift/Test-RecentAppAuthorization.ps1
|
function Test-RecentAppAuthorization { [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory)] [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Owner, [Parameter(Mandatory)] [string]$Token, [ValidateRange(1, 720)] [int]$SinceHours = 168, [string]$BaselinePath, [PSCustomObject[]]$AuditEvents = @() ) $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 'RecentAppAuthorization' ` -Status 'Info' ` -Severity 'Info' ` -Resource $resource ` -Detail "Owner '$Owner' is a personal account. Organization app authorization drift is not applicable." ` -Remediation 'No action needed.' ` -Target $resource ` -Mode 'Drift')) return $results.ToArray() } $auditLogUsable = $false $events = @($AuditEvents) if ($events.Count -eq 0) { try { $events = @(Get-OrgAuditLog -Owner $Owner -Token $Token -SinceHours $SinceHours) $auditLogUsable = $true } catch { Write-Debug "Org audit log unavailable for '$resource': $($_.Exception.Message)" } } else { $auditLogUsable = $true } if ($auditLogUsable) { $appGrantEvents = @($events | Where-Object { $_.action -match 'org_credential_authorization\.grant|oauth_authorization\.create|integration_installation\.create|integration_installation\.add_repository' }) if ($appGrantEvents.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentAppAuthorization' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail "No recent app authorization grants detected in the last $SinceHours hour(s)." ` -Remediation 'No action needed.' ` -Target $resource ` -Evidence @{ Source = 'audit-log' EventCount = 0 } ` -Mode 'Drift')) return $results.ToArray() } foreach ($appGrantRecord in $appGrantEvents) { $scopeText = '' if ($appGrantRecord.data) { $scopeText = ($appGrantRecord.data | ConvertTo-Json -Depth 10 -Compress) } $hasWriteScope = $scopeText -match '"(write|admin|maintain|all)"|:write|_write' $severity = if ($hasWriteScope) { 'High' } else { 'Medium' } $appName = if ($appGrantRecord.programmatic_access_type) { [string]$appGrantRecord.programmatic_access_type } elseif ($appGrantRecord.user) { [string]$appGrantRecord.user } else { 'application' } $results.Add((Format-FylgyrResult ` -CheckName 'RecentAppAuthorization' ` -Status 'Drift' ` -Severity $severity ` -Resource $resource ` -Detail "New app authorization drift detected: '$appName' via action '$($appGrantRecord.action)'." ` -Remediation 'Review app permission scopes, revoke unexpected grants, and restrict app authorization policy to approved apps only.' ` -AttackMapping @('github-device-code-phishing', 'github-app-token-theft') ` -Target $resource ` -Evidence @{ Source = 'audit-log' ChangedAt = $appGrantRecord.created_at ChangedBy = if ($appGrantRecord.actor) { $appGrantRecord.actor } else { $null } Action = $appGrantRecord.action App = $appName HasWriteScope = $hasWriteScope RawData = $appGrantRecord.data } ` -Mode 'Drift')) } return $results.ToArray() } $currentInstallations = @() try { $response = Invoke-GitHubApi -Endpoint "orgs/$Owner/installations?per_page=100" -Token $Token if ($response -and $response.PSObject.Properties['installations']) { $currentInstallations = @($response.installations | ForEach-Object { [PSCustomObject]@{ Id = $_.id AppSlug = [string]$_.app_slug TargetType = [string]$_.target_type } } | Sort-Object -Property Id) } } catch { $results.Add((Format-FylgyrResult ` -CheckName 'RecentAppAuthorization' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Audit log unavailable and fallback app installation inventory failed: $($_.Exception.Message)" ` -Remediation 'Grant admin:org scope for audit log access or provide a baseline with org installation state.' ` -Target $resource ` -Mode 'Drift')) return $results.ToArray() } if (-not $BaselinePath) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentAppAuthorization' ` -Status 'Info' ` -Severity 'Info' ` -Resource $resource ` -Detail 'Audit log unavailable. Captured current app installation snapshot for baseline diff fallback.' ` -Remediation 'Provide -BaselinePath on subsequent runs to detect newly authorized apps without audit log coverage.' ` -Target $resource ` -Evidence @{ Source = 'baseline-diff' To = @{ Installations = $currentInstallations } Fidelity = 'No actor attribution in baseline mode; enable audit log for identity context.' StateSnapshot = @{ Installations = $currentInstallations } } ` -Mode 'Drift')) return $results.ToArray() } try { $comparison = Compare-FylgyrBaseline -BaselinePath $BaselinePath -CheckName 'RecentAppAuthorization' -Resource $resource -CurrentSnapshot @{ Installations = $currentInstallations } } catch { $results.Add((Format-FylgyrResult ` -CheckName 'RecentAppAuthorization' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Failed baseline comparison for app authorization drift: $($_.Exception.Message)" ` -Remediation 'Provide a valid baseline file generated by Invoke-Fylgyr.' ` -Target $resource ` -Mode 'Drift')) return $results.ToArray() } $previousInstallations = @() if ($comparison.BaselineSnapshot -and $comparison.BaselineSnapshot.PSObject.Properties['Installations']) { $previousInstallations = @($comparison.BaselineSnapshot.Installations) } $previousIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($item in $previousInstallations) { if ($item -and $item.PSObject.Properties['Id']) { $previousIds.Add([string]$item.Id) | Out-Null } } $newInstalls = @($currentInstallations | Where-Object { -not $previousIds.Contains([string]$_.Id) }) if ($newInstalls.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentAppAuthorization' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail 'No app authorization drift detected in baseline fallback mode.' ` -Remediation 'No action needed.' ` -Target $resource ` -Evidence @{ Source = 'baseline-diff' From = @{ Installations = $previousInstallations } To = @{ Installations = $currentInstallations } StateSnapshot = @{ Installations = $currentInstallations } } ` -Mode 'Drift')) return $results.ToArray() } foreach ($install in $newInstalls) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentAppAuthorization' ` -Status 'Drift' ` -Severity 'Medium' ` -Resource $resource ` -Detail "New organization app installation detected by baseline diff: '$($install.AppSlug)'." ` -Remediation 'Validate the installation request and app permissions. Revoke unauthorized installations.' ` -AttackMapping @('github-device-code-phishing', 'github-app-token-theft') ` -Target $resource ` -Evidence @{ Source = 'baseline-diff' From = @{ Installations = $previousInstallations } To = @{ Installations = $currentInstallations } AppSlug = $install.AppSlug InstallationId = $install.Id Fidelity = 'Baseline diff has no actor attribution; use audit log when available.' StateSnapshot = @{ Installations = $currentInstallations } } ` -Mode 'Drift')) } return $results.ToArray() } |