Public/Drift/Test-RecentCollaboratorChange.ps1

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

        [Parameter(Mandatory)]
        [ValidatePattern('^[a-zA-Z0-9._-]+$')]
        [string]$Repo,

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

        [ValidateRange(1, 720)]
        [int]$SinceHours = 168,

        [string]$BaselinePath
    )

    $target = "$Owner/$Repo"
    $results = [System.Collections.Generic.List[PSCustomObject]]::new()
    $since = [datetime]::UtcNow.AddHours(-1 * $SinceHours)

    $events = @()
    try {
        $events = @(Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/events?per_page=100" -Token $Token)
    }
    catch {
        Write-Debug "Event fetch failed for '$target': $($_.Exception.Message)"
    }

    $memberEvents = @($events | Where-Object {
        $_.type -eq 'MemberEvent' -and $_.created_at -and ([datetime]$_.created_at) -ge $since
    })

    if ($memberEvents.Count -gt 0) {
        foreach ($memberRecord in $memberEvents) {
            $action = if ($memberRecord.payload -and $memberRecord.payload.action) { [string]$memberRecord.payload.action } else { 'changed' }
            $member = if ($memberRecord.payload -and $memberRecord.payload.member -and $memberRecord.payload.member.login) { [string]$memberRecord.payload.member.login } else { 'unknown' }
            $permission = if ($memberRecord.payload -and $memberRecord.payload.member -and $memberRecord.payload.member.permissions) {
                if ($memberRecord.payload.member.permissions.push -eq $true -or $memberRecord.payload.member.permissions.admin -eq $true) { 'write' } else { 'read' }
            }
            else {
                'unknown'
            }

            $severity = 'Low'
            if ($action -eq 'added' -and $permission -eq 'write') { $severity = 'Medium' }
            elseif ($action -eq 'removed') { $severity = 'Info' }

            $results.Add((Format-FylgyrResult `
                -CheckName 'RecentCollaboratorChange' `
                -Status 'Drift' `
                -Severity $severity `
                -Resource $target `
                -Detail "Collaborator drift detected: '$member' was $action (permission: $permission)." `
                -Remediation 'Validate change intent, enforce least privilege, and remove unexpected collaborator access immediately.' `
                -AttackMapping @('uber-credential-leak') `
                -Target $target `
                -Evidence @{
                    Source = 'events-api'
                    ChangedAt = $memberRecord.created_at
                    ChangedBy = if ($memberRecord.actor) { $memberRecord.actor.login } else { $null }
                    Action = $action
                    Collaborator = $member
                    Permission = $permission
                    EventId = $memberRecord.id
                } `
                -Mode 'Drift'))
        }

        return $results.ToArray()
    }

    $currentCollaborators = @()
    try {
        $collabResponse = @(Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/collaborators?per_page=100&affiliation=all" -Token $Token -AllPages)
        $currentCollaborators = @($collabResponse | ForEach-Object {
            [PSCustomObject]@{
                Login = [string]$_.login
                Permission = if ($_.permissions -and ($_.permissions.push -eq $true -or $_.permissions.admin -eq $true)) { 'write' } else { 'read' }
            }
        } | Sort-Object -Property Login)
    }
    catch {
        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentCollaboratorChange' `
            -Status 'Error' `
            -Severity 'Medium' `
            -Resource $target `
            -Detail "Failed to collect collaborators for baseline diff: $($_.Exception.Message)" `
            -Remediation 'Verify token permission to read collaborators and rerun.' `
            -Target $target `
            -Mode 'Drift'))
        return $results.ToArray()
    }

    if (-not $BaselinePath) {
        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentCollaboratorChange' `
            -Status 'Info' `
            -Severity 'Info' `
            -Resource $target `
            -Detail 'No recent collaborator events found and no baseline provided. Captured current collaborator snapshot for a future diff.' `
            -Remediation 'Provide -BaselinePath from a previous scan to enable baseline-diff fallback.' `
            -Target $target `
            -Evidence @{
                Source = 'baseline-diff'
                To = @{ Collaborators = $currentCollaborators }
                Fidelity = 'Baseline diff has no actor attribution; this run establishes state only.'
                StateSnapshot = @{ Collaborators = $currentCollaborators }
            } `
            -Mode 'Drift'))
        return $results.ToArray()
    }

    try {
        $comparison = Compare-FylgyrBaseline -BaselinePath $BaselinePath -CheckName 'RecentCollaboratorChange' -Resource $target -CurrentSnapshot @{ Collaborators = $currentCollaborators }
    }
    catch {
        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentCollaboratorChange' `
            -Status 'Error' `
            -Severity 'Medium' `
            -Resource $target `
            -Detail "Failed baseline comparison for collaborator drift: $($_.Exception.Message)" `
            -Remediation 'Provide a valid baseline file generated by Invoke-Fylgyr.' `
            -Target $target `
            -Mode 'Drift'))
        return $results.ToArray()
    }

    $baselineCollaborators = @()
    if ($comparison.BaselineSnapshot -and $comparison.BaselineSnapshot.PSObject.Properties['Collaborators']) {
        $baselineCollaborators = @($comparison.BaselineSnapshot.Collaborators)
    }

    $baselineSet = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($entry in $baselineCollaborators) {
        if ($entry.Login) {
            $baselineSet[[string]$entry.Login] = [string]$entry.Permission
        }
    }

    $currentSet = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($entry in $currentCollaborators) {
        $currentSet[[string]$entry.Login] = [string]$entry.Permission
    }

    foreach ($login in $currentSet.Keys) {
        if (-not $baselineSet.ContainsKey($login)) {
            $severity = if ($currentSet[$login] -eq 'write') { 'Medium' } else { 'Low' }
            $results.Add((Format-FylgyrResult `
                -CheckName 'RecentCollaboratorChange' `
                -Status 'Drift' `
                -Severity $severity `
                -Resource $target `
                -Detail "Collaborator drift detected by baseline diff: '$login' was added with $($currentSet[$login]) access." `
                -Remediation 'Validate onboarding/change request and remove unexpected collaborator access.' `
                -AttackMapping @('uber-credential-leak') `
                -Target $target `
                -Evidence @{
                    Source = 'baseline-diff'
                    From = @{ Collaborators = $baselineCollaborators }
                    To = @{ Collaborators = $currentCollaborators }
                    Collaborator = $login
                    Permission = $currentSet[$login]
                    Fidelity = 'Baseline diff has no actor attribution; validate in GitHub audit logs.'
                    StateSnapshot = @{ Collaborators = $currentCollaborators }
                } `
                -Mode 'Drift'))
        }
    }

    foreach ($login in $baselineSet.Keys) {
        if (-not $currentSet.ContainsKey($login)) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'RecentCollaboratorChange' `
                -Status 'Drift' `
                -Severity 'Info' `
                -Resource $target `
                -Detail "Collaborator drift detected by baseline diff: '$login' was removed." `
                -Remediation 'No action needed if this removal is expected; investigate if unexpected account churn occurred.' `
                -AttackMapping @('uber-credential-leak') `
                -Target $target `
                -Evidence @{
                    Source = 'baseline-diff'
                    From = @{ Collaborators = $baselineCollaborators }
                    To = @{ Collaborators = $currentCollaborators }
                    Collaborator = $login
                    Fidelity = 'Baseline diff has no actor attribution; validate in GitHub audit logs.'
                    StateSnapshot = @{ Collaborators = $currentCollaborators }
                } `
                -Mode 'Drift'))
        }
    }

    if ($results.Count -eq 0) {
        $results.Add((Format-FylgyrResult `
            -CheckName 'RecentCollaboratorChange' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $target `
            -Detail 'No collaborator drift detected.' `
            -Remediation 'No action needed.' `
            -Target $target `
            -Evidence @{
                Source = 'baseline-diff'
                From = @{ Collaborators = $baselineCollaborators }
                To = @{ Collaborators = $currentCollaborators }
                StateSnapshot = @{ Collaborators = $currentCollaborators }
            } `
            -Mode 'Drift'))
    }

    return $results.ToArray()
}