Public/Drift/Test-RecentTokenExposure.ps1
|
function Test-RecentTokenExposure { [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory)] [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Owner, [Parameter(Mandatory)] [string]$Token, [ValidateRange(1, 168)] [int]$CorrelationWindowHours = 24, [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 'RecentTokenExposure' ` -Status 'Info' ` -Severity 'Info' ` -Resource $resource ` -Detail "Owner '$Owner' is a personal account. Organization token exposure correlation is not applicable." ` -Remediation 'No action needed.' ` -Target $resource ` -Mode 'Drift')) return $results.ToArray() } $events = @($AuditEvents) $auditUsable = $false if ($events.Count -eq 0) { try { $events = @(Get-OrgAuditLog -Owner $Owner -Token $Token -SinceHours $SinceHours) $auditUsable = $true } catch { Write-Debug "Audit log unavailable for token exposure check: $($_.Exception.Message)" } } else { $auditUsable = $true } if ($auditUsable) { $tokenRiskEvents = @($events | Where-Object { $_.action -match 'org_credential_authorization\.|oauth_authorization\.|token\.|pat\.|integration_installation\.' }) $repoAccessEvents = @($events | Where-Object { $_.action -match 'repo\.access|repo\.download|git\.clone|git\.archive|repo\.export|repo\.transfer' }) if ($tokenRiskEvents.Count -eq 0 -and $repoAccessEvents.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentTokenExposure' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail "No token-risk or correlated repository-access burst signals detected in the last $SinceHours hour(s)." ` -Remediation 'No action needed.' ` -Target $resource ` -Evidence @{ Source = 'audit-log' TokenEventCount = 0 RepoAccessBurstCount = 0 CorrelationWindowHours = $CorrelationWindowHours } ` -Mode 'Drift')) return $results.ToArray() } $riskByActor = [System.Collections.Generic.Dictionary[string, int]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($riskRecord in $tokenRiskEvents) { $actor = if ($riskRecord.actor) { [string]$riskRecord.actor } else { 'unknown' } if (-not $riskByActor.ContainsKey($actor)) { $riskByActor[$actor] = 0 } $riskByActor[$actor]++ } $burstByActor = [System.Collections.Generic.Dictionary[string, int]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($accessRecord in $repoAccessEvents) { $actor = if ($accessRecord.actor) { [string]$accessRecord.actor } else { 'unknown' } if (-not $burstByActor.ContainsKey($actor)) { $burstByActor[$actor] = 0 } $burstByActor[$actor]++ } $allActors = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($actor in $riskByActor.Keys) { $allActors.Add($actor) | Out-Null } foreach ($actor in $burstByActor.Keys) { $allActors.Add($actor) | Out-Null } foreach ($actor in $allActors) { $tokenCount = if ($riskByActor.ContainsKey($actor)) { $riskByActor[$actor] } else { 0 } $burstCount = if ($burstByActor.ContainsKey($actor)) { $burstByActor[$actor] } else { 0 } $severity = 'Medium' if ($tokenCount -gt 0 -and $burstCount -ge 5) { $severity = 'Critical' } elseif ($tokenCount -gt 0 -and $burstCount -gt 0) { $severity = 'High' } elseif ($tokenCount -ge 2) { $severity = 'High' } if ($tokenCount -eq 0 -and $burstCount -lt 8) { continue } $detail = if ($tokenCount -gt 0 -and $burstCount -gt 0) { "Token exposure drift chain detected for actor '$actor': $tokenCount token-risk event(s) and $burstCount repository-access event(s)." } elseif ($tokenCount -gt 0) { "Token-risk drift detected for actor '$actor': $tokenCount token-related event(s) without confirmed access burst." } else { "Repository access burst detected for actor '$actor' ($burstCount events). Correlated token-risk events were not observed in the same window." } $results.Add((Format-FylgyrResult ` -CheckName 'RecentTokenExposure' ` -Status 'Drift' ` -Severity $severity ` -Resource $resource ` -Detail $detail ` -Remediation 'Investigate actor session history, revoke suspicious credentials, and rotate sensitive secrets/tokens across impacted repositories.' ` -AttackMapping @('uber-credential-leak', 'github-device-code-phishing', 'committed-credentials-exposure') ` -Target $resource ` -Evidence @{ Source = 'audit-log' Actor = $actor TokenEventCount = $tokenCount RepoAccessBurstCount = $burstCount CorrelationWindowHours = $CorrelationWindowHours } ` -Mode 'Drift')) } if ($results.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentTokenExposure' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail 'Token-related events observed, but no correlation pattern met drift thresholds.' ` -Remediation 'Continue monitoring and tune thresholds for your environment.' ` -Target $resource ` -Evidence @{ Source = 'audit-log' TokenEventCount = $tokenRiskEvents.Count RepoAccessBurstCount = $repoAccessEvents.Count CorrelationWindowHours = $CorrelationWindowHours } ` -Mode 'Drift')) } return $results.ToArray() } $patPolicy = $null $oauthPolicy = $null try { $patPolicy = Invoke-GitHubApi -Endpoint "orgs/$Owner/personal-access-token-requests" -Token $Token } catch { Write-Debug "PAT policy fallback endpoint unavailable: $($_.Exception.Message)" } try { $oauthPolicy = Invoke-GitHubApi -Endpoint "orgs/$Owner/settings/billing/actions" -Token $Token } catch { Write-Debug "OAuth policy fallback endpoint unavailable: $($_.Exception.Message)" } $currentSnapshot = [PSCustomObject]@{ PatPolicy = $patPolicy OAuthPolicy = $oauthPolicy } if (-not $BaselinePath) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentTokenExposure' ` -Status 'Info' ` -Severity 'Info' ` -Resource $resource ` -Detail 'Audit log unavailable. Captured current token-governance posture for baseline fallback comparison.' ` -Remediation 'Provide -BaselinePath and enable org audit log access for full token exposure chain detection.' ` -Target $resource ` -Evidence @{ Source = 'baseline-diff' To = $currentSnapshot Fidelity = 'Baseline fallback detects governance posture drift, not token usage events.' StateSnapshot = $currentSnapshot } ` -Mode 'Drift')) return $results.ToArray() } try { $comparison = Compare-FylgyrBaseline -BaselinePath $BaselinePath -CheckName 'RecentTokenExposure' -Resource $resource -CurrentSnapshot $currentSnapshot } catch { $results.Add((Format-FylgyrResult ` -CheckName 'RecentTokenExposure' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $resource ` -Detail "Failed baseline comparison for token-governance drift: $($_.Exception.Message)" ` -Remediation 'Provide a valid baseline file generated by Invoke-Fylgyr.' ` -Target $resource ` -Mode 'Drift')) return $results.ToArray() } if (-not $comparison.HasBaseline -or -not $comparison.IsChanged) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentTokenExposure' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail 'No token-governance drift detected in baseline fallback mode.' ` -Remediation 'No action needed.' ` -Target $resource ` -Evidence @{ Source = 'baseline-diff' From = $comparison.BaselineSnapshot To = $currentSnapshot CorrelationWindowHours = $CorrelationWindowHours StateSnapshot = $currentSnapshot } ` -Mode 'Drift')) return $results.ToArray() } $results.Add((Format-FylgyrResult ` -CheckName 'RecentTokenExposure' ` -Status 'Drift' ` -Severity 'Medium' ` -Resource $resource ` -Detail 'Token-governance drift detected via baseline fallback (policy/configuration changed). Audit log correlation is unavailable.' ` -Remediation 'Review PAT/OAuth policy changes, re-enable restrictive defaults, and enable audit log access for actor-level correlation.' ` -AttackMapping @('uber-credential-leak', 'github-device-code-phishing', 'committed-credentials-exposure') ` -Target $resource ` -Evidence @{ Source = 'baseline-diff' From = $comparison.BaselineSnapshot To = $currentSnapshot TokenEventCount = 0 RepoAccessBurstCount = 0 CorrelationWindowHours = $CorrelationWindowHours Fidelity = 'No actor attribution or usage burst correlation without audit log.' StateSnapshot = $currentSnapshot } ` -Mode 'Drift')) return $results.ToArray() } |