Public/Test-OutsideCollaborators.ps1
|
function Test-OutsideCollaborators { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Public check name follows project check contract.')] [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 'OutsideCollaborators' ` -Status 'Info' ` -Severity 'Info' ` -Resource $resource ` -Detail "Owner '$Owner' is a personal account. Outside collaborator policy does not apply." ` -Remediation 'No action needed. Run this check against an organization owner.' ` -Target $resource)) return $results.ToArray() } # Rate-limit strategy: bound repo x collaborator permission lookups. # We cap permission checks to avoid N x M explosion on large organizations. $maxPermissionChecks = 500 $permissionChecks = 0 $limitReached = $false try { $outsideCollaborators = @(Invoke-GitHubApi -Endpoint "orgs/$Owner/outside_collaborators?filter=all&per_page=100" -Token $Token -AllPages) } catch { $msg = $_.Exception.Message if ($msg -match '403') { $results.Add((Format-FylgyrResult ` -CheckName 'OutsideCollaborators' ` -Status 'Info' ` -Severity 'Info' ` -Resource $resource ` -Detail 'Insufficient permissions to enumerate outside collaborators.' ` -Remediation 'Use a fine-grained token with organization Members:read, or a classic token with read:org scope.' ` -Target $resource)) return $results.ToArray() } $results.Add((Format-FylgyrResult ` -CheckName 'OutsideCollaborators' ` -Status 'Error' ` -Severity 'High' ` -Resource $resource ` -Detail "Failed to enumerate outside collaborators: $($_.Exception.Message)" ` -Remediation 'Verify token scope and organization access, then rerun.' ` -Target $resource)) return $results.ToArray() } if (-not $outsideCollaborators -or $outsideCollaborators.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'OutsideCollaborators' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail 'No outside collaborators found for this organization.' ` -Remediation 'No action needed.' ` -Target $resource)) return $results.ToArray() } try { $orgRepos = @(Invoke-GitHubApi -Endpoint "orgs/$Owner/repos?type=all&per_page=100" -Token $Token -AllPages) } catch { $results.Add((Format-FylgyrResult ` -CheckName 'OutsideCollaborators' ` -Status 'Error' ` -Severity 'High' ` -Resource $resource ` -Detail "Failed to enumerate organization repositories: $($_.Exception.Message)" ` -Remediation 'Use a token that can read organization repositories and rerun.' ` -Target $resource)) return $results.ToArray() } $risky = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($collaborator in $outsideCollaborators) { if ($limitReached) { break } $username = [string]$collaborator.login if (-not $username) { continue } foreach ($repo in $orgRepos) { if ($permissionChecks -ge $maxPermissionChecks) { $limitReached = $true break } if (-not $repo -or -not $repo.name) { continue } $repoName = [string]$repo.name $permissionChecks++ try { $perm = Invoke-GitHubApi -Endpoint "repos/$Owner/$repoName/collaborators/$username/permission" -Token $Token } catch { $permMsg = $_.Exception.Message if ($permMsg -match '404') { continue } if ($permMsg -match '403') { $results.Add((Format-FylgyrResult ` -CheckName 'OutsideCollaborators' ` -Status 'Info' ` -Severity 'Info' ` -Resource $resource ` -Detail 'Token cannot read collaborator permission for one or more repositories. Outside collaborator analysis is partial.' ` -Remediation 'Use a fine-grained token with repository Administration:read, or a classic token with repo + read:org scope.' ` -Target $resource)) return $results.ToArray() } continue } if ($perm -and $perm.PSObject.Properties['permission'] -and $perm.permission -in @('write', 'admin')) { $risky.Add([PSCustomObject]@{ User = $username Repo = $repoName Permission = [string]$perm.permission }) } } } if ($risky.Count -eq 0) { $suffix = if ($limitReached) { " Analysis stopped after $maxPermissionChecks permission checks to stay within rate-limit budget." } else { '' } $results.Add((Format-FylgyrResult ` -CheckName 'OutsideCollaborators' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $resource ` -Detail "No outside collaborators with write/admin permission detected.$suffix" ` -Remediation 'No action needed.' ` -Target $resource)) return $results.ToArray() } $sample = @($risky | Select-Object -First 10 | ForEach-Object { "$($_.User) on $Owner/$($_.Repo) ($($_.Permission))" }) -join '; ' $coverageNote = if ($limitReached) { " Analysis hit the $maxPermissionChecks permission-check cap, so findings may be incomplete." } else { '' } $results.Add((Format-FylgyrResult ` -CheckName 'OutsideCollaborators' ` -Status 'Fail' ` -Severity 'High' ` -Resource $resource ` -Detail "$($risky.Count) outside collaborator write/admin grants detected. Examples: $sample.$coverageNote" ` -Remediation 'Remove stale outside-collaborator access, downgrade to read/triage where possible, and use temporary team membership for contractors.' ` -AttackMapping @('uber-credential-leak') ` -Target $resource)) $results.ToArray() } |