Public/Drift/Test-RecentRunnerRegistration.ps1
|
function Test-RecentRunnerRegistration { [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, [PSCustomObject[]]$AuditEvents = @() ) $target = "$Owner/$Repo" $results = [System.Collections.Generic.List[PSCustomObject]]::new() $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 runner registration drift: $($_.Exception.Message)" } } else { $auditUsable = $true } if ($auditUsable) { $runnerEvents = @($events | Where-Object { $_.action -match 'runner\.|self_hosted_runner\.|actions_runner\.' -and ($_.repo -eq $Repo -or -not $_.repo) }) foreach ($runnerRecord in $runnerEvents) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentRunnerRegistration' ` -Status 'Drift' ` -Severity 'High' ` -Resource $target ` -Detail "Runner registration drift detected via audit log action '$($runnerRecord.action)'." ` -Remediation 'Validate runner registration ownership, enforce ephemeral runners, and restrict runner groups to trusted repositories only.' ` -AttackMapping @('shai-hulud-runner-backdoor', 'praetorian-runner-pivot', 'github-actions-cryptomining') ` -Target $target ` -Evidence @{ Source = 'audit-log' ChangedAt = $runnerRecord.created_at ChangedBy = if ($runnerRecord.actor) { $runnerRecord.actor } else { $null } Action = $runnerRecord.action Data = $runnerRecord.data } ` -Mode 'Drift')) } if ($results.Count -gt 0) { return $results.ToArray() } } $repoRunners = @() $orgRunners = @() try { $repoRunnerResponse = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/actions/runners" -Token $Token if ($repoRunnerResponse -and $repoRunnerResponse.PSObject.Properties['runners']) { $repoRunners = @($repoRunnerResponse.runners | ForEach-Object { [PSCustomObject]@{ Id = $_.id Name = [string]$_.name Ephemeral = if ($_.PSObject.Properties['ephemeral']) { [bool]$_.ephemeral } else { $null } Scope = 'repo' } } | Sort-Object -Property Id) } } catch { Write-Debug "Repo runner snapshot unavailable for '$target': $($_.Exception.Message)" } try { $orgRunnerResponse = Invoke-GitHubApi -Endpoint "orgs/$Owner/actions/runners" -Token $Token if ($orgRunnerResponse -and $orgRunnerResponse.PSObject.Properties['runners']) { $orgRunners = @($orgRunnerResponse.runners | ForEach-Object { [PSCustomObject]@{ Id = $_.id Name = [string]$_.name Ephemeral = if ($_.PSObject.Properties['ephemeral']) { [bool]$_.ephemeral } else { $null } Scope = 'org' } } | Sort-Object -Property Id) } } catch { Write-Debug "Org runner snapshot unavailable for '$target': $($_.Exception.Message)" } $currentSnapshot = [PSCustomObject]@{ RepoRunners = $repoRunners OrgRunners = $orgRunners } if (-not $BaselinePath) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentRunnerRegistration' ` -Status 'Info' ` -Severity 'Info' ` -Resource $target ` -Detail 'Captured runner inventory for baseline drift comparison. Provide -BaselinePath to detect newly registered runners.' ` -Remediation 'Run periodic drift scans with a baseline to detect runner additions promptly.' ` -AttackMapping @('shai-hulud-runner-backdoor', 'praetorian-runner-pivot', 'github-actions-cryptomining') ` -Target $target ` -Evidence @{ Source = 'baseline-diff' To = $currentSnapshot Fidelity = 'Baseline diff has no actor attribution; rely on audit log where available.' StateSnapshot = $currentSnapshot } ` -Mode 'Drift')) return $results.ToArray() } try { $comparison = Compare-FylgyrBaseline -BaselinePath $BaselinePath -CheckName 'RecentRunnerRegistration' -Resource $target -CurrentSnapshot $currentSnapshot } catch { $results.Add((Format-FylgyrResult ` -CheckName 'RecentRunnerRegistration' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $target ` -Detail "Failed baseline comparison for runner registration drift: $($_.Exception.Message)" ` -Remediation 'Provide a valid baseline file generated by Invoke-Fylgyr.' ` -Target $target ` -Mode 'Drift')) return $results.ToArray() } $previousRepo = @() $previousOrg = @() if ($comparison.BaselineSnapshot) { if ($comparison.BaselineSnapshot.PSObject.Properties['RepoRunners']) { $previousRepo = @($comparison.BaselineSnapshot.RepoRunners) } if ($comparison.BaselineSnapshot.PSObject.Properties['OrgRunners']) { $previousOrg = @($comparison.BaselineSnapshot.OrgRunners) } } $previousIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($runner in @($previousRepo + $previousOrg)) { if ($runner -and $runner.PSObject.Properties['Id']) { $previousIds.Add([string]$runner.Id) | Out-Null } } $newRunners = @($repoRunners + $orgRunners | Where-Object { -not $previousIds.Contains([string]$_.Id) }) if ($newRunners.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentRunnerRegistration' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $target ` -Detail 'No new runner registrations detected in baseline fallback mode.' ` -Remediation 'No action needed.' ` -Target $target ` -Evidence @{ Source = 'baseline-diff' From = $comparison.BaselineSnapshot To = $currentSnapshot StateSnapshot = $currentSnapshot } ` -Mode 'Drift')) return $results.ToArray() } foreach ($runner in $newRunners) { $isEphemeral = $runner.Ephemeral -eq $true $severity = if ($isEphemeral) { 'Medium' } else { 'High' } $ephemeralNote = if ($isEphemeral) { 'Runner is ephemeral.' } else { 'Runner is persistent (non-ephemeral).' } $results.Add((Format-FylgyrResult ` -CheckName 'RecentRunnerRegistration' ` -Status 'Drift' ` -Severity $severity ` -Resource $target ` -Detail "New $($runner.Scope)-scope runner detected: '$($runner.Name)'. $ephemeralNote" ` -Remediation 'Validate runner registration request, isolate network egress, and enforce short-lived ephemeral runner strategy.' ` -AttackMapping @('shai-hulud-runner-backdoor', 'praetorian-runner-pivot', 'github-actions-cryptomining') ` -Target $target ` -Evidence @{ Source = 'baseline-diff' From = $comparison.BaselineSnapshot To = $currentSnapshot RunnerId = $runner.Id RunnerName = $runner.Name RunnerScope = $runner.Scope Ephemeral = $runner.Ephemeral Fidelity = 'Baseline diff has no actor attribution; use audit log for identity context.' StateSnapshot = $currentSnapshot } ` -Mode 'Drift')) } return $results.ToArray() } |