Public/Test-ReusableWorkflowTrust.ps1

function Test-ReusableWorkflowTrust {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject[]]$WorkflowFiles,

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

        [string[]]$ReusableWorkflowAllowlist = @()
    )

    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    $normalizedAllowlist = [System.Collections.Generic.List[string]]::new()
    $normalizedAllowlist.Add('actions/*')
    $normalizedAllowlist.Add('github/*')

    foreach ($entry in $ReusableWorkflowAllowlist) {
        if ([string]::IsNullOrWhiteSpace($entry)) {
            continue
        }

        $normalizedAllowlist.Add($entry.Trim().ToLowerInvariant())
    }

    foreach ($wf in $WorkflowFiles) {
        $lines = @(($wf.Content -split "`n") | Where-Object { $_ -notmatch '^\s*#' })
        $findings = [System.Collections.Generic.List[string]]::new()

        for ($i = 0; $i -lt $lines.Count; $i++) {
            $line = $lines[$i]
            if ($line -notmatch '(?i)^\s*-?\s*uses\s*:\s*(?<ref>[^\s#]+)') {
                continue
            }

            $reference = $Matches.ref.Trim().Trim("'").Trim('"')
            if ($reference -notmatch '/\.github/workflows/') {
                continue
            }

            if ($reference -notmatch '^(?<repo>[A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/\.github/workflows/.+@(?<version>.+)$') {
                $findings.Add("line $($i + 1): reusable workflow reference is malformed: $reference")
                continue
            }

            $sourceRepo = $Matches.repo.ToLowerInvariant()
            $sourceOwner = ($sourceRepo -split '/')[0]
            $versionRef = $Matches.version

            $isShaPinned = $versionRef -match '^[0-9a-fA-F]{40}$'
            if (-not $isShaPinned) {
                $findings.Add("line $($i + 1): reusable workflow is not SHA pinned: $reference")
            }

            $isAllowedSource = $false
            if ($Owner -and ($sourceOwner -eq $Owner.ToLowerInvariant())) {
                $isAllowedSource = $true
            }
            else {
                foreach ($allowEntry in $normalizedAllowlist) {
                    if ($allowEntry -match '/\*$') {
                        $allowOwner = ($allowEntry -split '/')[0]
                        if ($sourceOwner -eq $allowOwner) {
                            $isAllowedSource = $true
                            break
                        }
                    }
                    elseif ($sourceRepo -eq $allowEntry) {
                        $isAllowedSource = $true
                        break
                    }
                }
            }

            if (-not $isAllowedSource) {
                $findings.Add("line $($i + 1): reusable workflow source is outside trusted allowlist: $sourceRepo")
            }
        }

        if ($findings.Count -gt 0) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'ReusableWorkflowTrust' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource $wf.Path `
                -Detail "Workflow '$($wf.Name)' has reusable workflow trust issues: $((@($findings | Select-Object -Unique)) -join ' | ')." `
                -Remediation 'Pin reusable workflow refs to full 40-character SHAs and limit sources to trusted repositories (same owner, actions/*, github/*, or explicit allowlist entries).' `
                -AttackMapping @('tj-actions-shai-hulud')))
            continue
        }

        $results.Add((Format-FylgyrResult `
            -CheckName 'ReusableWorkflowTrust' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $wf.Path `
            -Detail "Workflow '$($wf.Name)' has no detected reusable-workflow trust issues." `
            -Remediation 'No action needed.'))
    }

    return $results.ToArray()
}