Public/Drift/Test-RecentProtectionChange.ps1
|
function Test-RecentProtectionChange { [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() $defaultBranch = 'main' try { $repoInfo = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo" -Token $Token if ($repoInfo -and $repoInfo.default_branch) { $defaultBranch = [string]$repoInfo.default_branch } } catch { Write-Debug "Default branch lookup failed for '$target': $($_.Exception.Message)" } $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 '$target': $($_.Exception.Message)" } } else { $auditUsable = $true } if ($auditUsable) { $protectionEvents = @($events | Where-Object { $_.action -match 'protected_branch\.|branch_protection_rule\.|repository_ruleset\.' -and ($_.repo -eq $Repo -or -not $_.repo) }) if ($protectionEvents.Count -gt 0) { foreach ($protectionRecord in $protectionEvents) { $isDefaultBranch = $false if ($protectionRecord.data) { $eventData = ($protectionRecord.data | ConvertTo-Json -Depth 12 -Compress) if ($eventData -match [regex]::Escape($defaultBranch) -or $eventData -match 'default') { $isDefaultBranch = $true } } $severity = if ($isDefaultBranch) { 'High' } else { 'Medium' } $results.Add((Format-FylgyrResult ` -CheckName 'RecentProtectionChange' ` -Status 'Drift' ` -Severity $severity ` -Resource $target ` -Detail "Branch protection/ruleset drift detected from audit log action '$($protectionRecord.action)'." ` -Remediation 'Review branch protection and ruleset history, restore strict protection on default branch, and validate recent merges/tags.' ` -AttackMapping @('trivy-tag-poisoning', 'trivy-force-push-main') ` -Target $target ` -Evidence @{ Source = 'audit-log' ChangedAt = $protectionRecord.created_at ChangedBy = if ($protectionRecord.actor) { $protectionRecord.actor } else { $null } Action = $protectionRecord.action Data = $protectionRecord.data DefaultBranch = $defaultBranch } ` -Mode 'Drift')) } return $results.ToArray() } } $protection = $null $rulesets = @() try { $protection = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/branches/$defaultBranch/protection" -Token $Token } catch { if ($_.Exception.Message -match '404') { $protection = [PSCustomObject]@{ Missing = $true } } else { $results.Add((Format-FylgyrResult ` -CheckName 'RecentProtectionChange' ` -Status 'Error' ` -Severity 'High' ` -Resource $target ` -Detail "Failed to fetch branch protection baseline snapshot: $($_.Exception.Message)" ` -Remediation 'Verify token permissions (Administration:read or repo) and rerun.' ` -Target $target ` -Mode 'Drift')) return $results.ToArray() } } try { $rulesetResponse = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/rulesets" -Token $Token if ($rulesetResponse -is [System.Array]) { $rulesets = @($rulesetResponse) } elseif ($rulesetResponse -and $rulesetResponse.PSObject.Properties['rulesets']) { $rulesets = @($rulesetResponse.rulesets) } elseif ($rulesetResponse) { $rulesets = @($rulesetResponse) } } catch { Write-Debug "Ruleset snapshot unavailable for '$target': $($_.Exception.Message)" } $currentSnapshot = [PSCustomObject]@{ DefaultBranch = $defaultBranch BranchProtection = $protection Rulesets = $rulesets } if (-not $BaselinePath) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentProtectionChange' ` -Status 'Info' ` -Severity 'Info' ` -Resource $target ` -Detail 'Audit log unavailable or no matching events. Captured protection snapshot for baseline drift comparison.' ` -Remediation 'Provide -BaselinePath on subsequent runs to detect weakening via state diff.' ` -Target $target ` -Evidence @{ Source = 'baseline-diff' To = $currentSnapshot Fidelity = 'Baseline diff has no actor attribution; use audit log for who/when context.' StateSnapshot = $currentSnapshot } ` -Mode 'Drift')) return $results.ToArray() } try { $comparison = Compare-FylgyrBaseline -BaselinePath $BaselinePath -CheckName 'RecentProtectionChange' -Resource $target -CurrentSnapshot $currentSnapshot } catch { $results.Add((Format-FylgyrResult ` -CheckName 'RecentProtectionChange' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $target ` -Detail "Failed baseline comparison for branch protection drift: $($_.Exception.Message)" ` -Remediation 'Provide a valid baseline file generated by Invoke-Fylgyr.' ` -Target $target ` -Mode 'Drift')) return $results.ToArray() } if (-not $comparison.HasBaseline -or -not $comparison.IsChanged) { $results.Add((Format-FylgyrResult ` -CheckName 'RecentProtectionChange' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $target ` -Detail 'No branch protection drift 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() } $results.Add((Format-FylgyrResult ` -CheckName 'RecentProtectionChange' ` -Status 'Drift' ` -Severity 'High' ` -Resource $target ` -Detail "Branch protection drift detected by baseline comparison on default branch '$defaultBranch'." ` -Remediation 'Review and restore required reviews, status checks, and force-push/deletion protections on the default branch.' ` -AttackMapping @('trivy-tag-poisoning', 'trivy-force-push-main') ` -Target $target ` -Evidence @{ Source = 'baseline-diff' From = $comparison.BaselineSnapshot To = $currentSnapshot DefaultBranch = $defaultBranch Fidelity = 'Baseline diff has no actor attribution; validate source of change in audit log where available.' StateSnapshot = $currentSnapshot } ` -Mode 'Drift')) return $results.ToArray() } |