Private/Write-FylgyrConsole.ps1
|
function Write-FylgyrConsole { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Console output requires Write-Host for colored formatting')] [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory)] [PSCustomObject[]]$Results, [string]$Target = '', [int]$ScannedRepoCount = -1 ) Write-Host '' Write-Host " Fylgyr Supply-Chain Audit: $Target" -ForegroundColor Cyan Write-Host " $('-' * 60)" -ForegroundColor DarkGray # Separate repos with no workflows from repos with actual check results $noWorkflowResults = $Results | Where-Object { $_.CheckName -eq 'WorkflowFileFetch' -and $_.Status -eq 'Warning' } $checkResults = $Results | Where-Object { -not ($_.CheckName -eq 'WorkflowFileFetch' -and $_.Status -eq 'Warning') } # Build a consolidated recommendation set so users get actionable next steps # in addition to per-finding remediation text. $recommendationItems = [System.Collections.Generic.List[PSCustomObject]]::new() $recommendationKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $addRecommendation = { param( [int]$Priority, [string]$Key, [string]$Text ) if (-not $recommendationKeys.Contains($Key)) { $recommendationKeys.Add($Key) | Out-Null $recommendationItems.Add([PSCustomObject]@{ Priority = $Priority Text = $Text }) } } $nonPassResults = @($checkResults | Where-Object { $_.Status -in @('Fail', 'Warning', 'Error') }) if (@($nonPassResults | Where-Object { $_.Status -eq 'Error' }).Count -gt 0) { & $addRecommendation 0 'errors' 'P0 Resolve API/token scope errors first so coverage is complete.' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'OrgMfaPolicy' -and $_.Status -eq 'Fail' }).Count -gt 0) { & $addRecommendation 0 'org-mfa' 'P0 Enforce organization-wide MFA immediately to reduce account takeover risk from stolen passwords.' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'OrgActionRestrictions' -and $_.Status -eq 'Fail' }).Count -gt 0) { & $addRecommendation 1 'org-actions' "P1 Restrict organization actions to 'selected' and maintain an explicit allowlist of trusted sources." } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'ActionPinning' -and $_.Status -eq 'Fail' }).Count -gt 0) { & $addRecommendation 1 'action-pinning' 'P1 Pin third-party actions to full 40-character commit SHAs to prevent mutable-tag supply chain attacks.' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'Rulesets' -and $_.Detail -match 'tag protection' }).Count -gt 0) { & $addRecommendation 1 'tag-protection' 'P1 Protect release tags with a tag ruleset (for example v*) to prevent mutable tag poisoning.' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'BranchProtection' -and $_.Detail -match '0 approving reviews' }).Count -gt 0) { & $addRecommendation 1 'approvals' 'P1 Set at least 1 required approval on the default branch, or explicitly document solo-maintainer exception with compensating controls.' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'BranchProtection' -and $_.Detail -match 'status checks|pull requests|non-fast-forward|deletion' }).Count -gt 0) { & $addRecommendation 1 'branch-baseline' 'P1 Keep default branch baseline: PR-required, strict status checks, force-push blocked, and deletion blocked.' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'BranchProtection' -and $_.Detail -match 'no classic branch protection|no active branch ruleset targeting it' }).Count -gt 0) { & $addRecommendation 1 'branch-protection' 'P1 Add active branch protection for the default branch (classic branch protection or branch-target ruleset).' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'SignedCommit' }).Count -gt 0) { & $addRecommendation 2 'signed-commits' 'P2 Require signed commits on the default branch to reduce maintainer impersonation risk.' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'EgressControl' }).Count -gt 0) { & $addRecommendation 2 'egress' 'P2 Add CI egress controls to limit outbound traffic from compromised actions or injected workflow code.' } if (@($nonPassResults | Where-Object { $_.CheckName -eq 'CodeOwner' }).Count -gt 0) { & $addRecommendation 3 'codeowner' 'P3 Add a trusted co-owner in CODEOWNERS (or migrate to an organization team) to reduce single-maintainer risk.' } # Group by Target (Owner/Repo) if ($checkResults.Count -gt 0) { $repoGroups = $checkResults | Group-Object -Property Target foreach ($repoGroup in $repoGroups) { Write-Host '' Write-Host " [$($repoGroup.Name)]" -ForegroundColor Cyan $checkGroups = @($repoGroup.Group | Group-Object -Property CheckName) for ($i = 0; $i -lt $checkGroups.Count; $i++) { $checkGroup = $checkGroups[$i] $passes = @($checkGroup.Group | Where-Object { $_.Status -eq 'Pass' }) $failures = @($checkGroup.Group | Where-Object { $_.Status -ne 'Pass' }) if ($i -gt 0) { Write-Host '' } Write-Host " > $($checkGroup.Name) " -ForegroundColor White -NoNewline if ($failures.Count -eq 0) { $passDetail = $passes[0].Detail if ($passes.Count -gt 1) { Write-Host "[PASS]" -ForegroundColor Green Write-Host " $passDetail ($($passes.Count) files)" -ForegroundColor DarkGray } else { Write-Host "[PASS]" -ForegroundColor Green Write-Host " $passDetail" -ForegroundColor DarkGray } } else { $onlyInfo = @($failures | Where-Object { $_.Status -ne 'Info' }).Count -eq 0 if ($passes.Count -gt 0) { Write-Host "[$($passes.Count) passed, $($failures.Count) finding(s)]" -ForegroundColor Yellow } elseif ($onlyInfo) { Write-Host "[$($failures.Count) info]" -ForegroundColor Cyan } else { Write-Host "[$($failures.Count) finding(s)]" -ForegroundColor Red } foreach ($r in $failures) { $icon = switch ($r.Status) { 'Fail' { '[FAIL]' } 'Warning' { '[WARN]' } 'Error' { '[ERR]' } 'Info' { '[INFO]' } default { '[?]' } } $color = switch ($r.Status) { 'Fail' { 'Red' } 'Warning' { 'Yellow' } 'Error' { 'Magenta' } 'Info' { 'Cyan' } default { 'Gray' } } Write-Host " $icon " -ForegroundColor $color -NoNewline Write-Host "$($r.Detail)" -ForegroundColor $color Write-Host " Resource: $($r.Resource)" -ForegroundColor DarkGray Write-Host " Severity: $($r.Severity)" -ForegroundColor DarkGray Write-Host " Remediation: $($r.Remediation)" -ForegroundColor DarkGray if ($r.AttackMapping.Count -gt 0) { Write-Host " Attacks: $($r.AttackMapping -join ', ')" -ForegroundColor DarkGray } } } } } } # Show repos with no workflows as a compact list at the end if ($noWorkflowResults.Count -gt 0) { Write-Host '' Write-Host " Repos with no workflow files ($($noWorkflowResults.Count)):" -ForegroundColor DarkGray foreach ($nw in $noWorkflowResults) { Write-Host " - $($nw.Target)" -ForegroundColor DarkGray } } # Summary - prefer explicit scan count from the orchestrator; fall back to # grouping by Target only when the caller did not provide one. $totalRepos = if ($ScannedRepoCount -ge 0) { $ScannedRepoCount } else { @($Results.Target | Where-Object { $_ } | Sort-Object -Unique).Count } $passCount = ($Results | Where-Object Status -EQ 'Pass').Count $failCount = ($Results | Where-Object Status -EQ 'Fail').Count $warnCount = ($Results | Where-Object Status -EQ 'Warning').Count $errorCount = ($Results | Where-Object Status -EQ 'Error').Count Write-Host '' Write-Host " $('-' * 60)" -ForegroundColor DarkGray Write-Host " $totalRepos repo(s) scanned | " -ForegroundColor White -NoNewline Write-Host "$passCount passed" -ForegroundColor Green -NoNewline Write-Host ', ' -NoNewline Write-Host "$failCount failed" -ForegroundColor Red -NoNewline Write-Host ', ' -NoNewline Write-Host "$warnCount warnings" -ForegroundColor Yellow -NoNewline Write-Host ', ' -NoNewline Write-Host "$errorCount errors" -ForegroundColor Magenta if ($recommendationItems.Count -gt 0) { Write-Host '' Write-Host ' Prioritized Recommendations:' -ForegroundColor White foreach ($recommendation in @($recommendationItems | Sort-Object -Property Priority, Text)) { Write-Host " - $($recommendation.Text)" -ForegroundColor DarkGray } } Write-Host '' } |