Public/Test-OidcTrust.ps1

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

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

    foreach ($wf in $WorkflowFiles) {
        $sanitizedContent = (($wf.Content -split "`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n"

        $jobBlocks = @(Get-WorkflowJobBlock -Content $sanitizedContent)
        $hasWorkflowLevelIdToken = $false

        $lines = $sanitizedContent -split "`n"
        for ($i = 0; $i -lt $lines.Count; $i++) {
            $line = $lines[$i]

            if ($line -match '^permissions\s*:\s*\{[^}]*id-token\s*:\s*write') {
                $hasWorkflowLevelIdToken = $true
                break
            }

            if ($line -notmatch '^permissions\s*:\s*$') {
                continue
            }

            $j = $i + 1
            while ($j -lt $lines.Count) {
                $next = $lines[$j]
                if ($next -match '^\s*$') {
                    $j++
                    continue
                }

                if ($next -match '^\S') {
                    break
                }

                if ($next -match '^\s+id-token\s*:\s*write\s*$') {
                    $hasWorkflowLevelIdToken = $true
                    break
                }

                $j++
            }

            if ($hasWorkflowLevelIdToken) {
                break
            }
        }

        $findings = [System.Collections.Generic.List[PSCustomObject]]::new()
        $anyIdToken = $hasWorkflowLevelIdToken

        foreach ($job in $jobBlocks) {
            $jobText = $job.Content
            $jobHasIdToken = $hasWorkflowLevelIdToken -or ($jobText -match '(?im)^\s*id-token\s*:\s*write\s*$')
            if (-not $jobHasIdToken) {
                continue
            }

            $anyIdToken = $true
            $jobHasEnvironment = $jobText -match '(?im)^\s*environment\s*:'

            if ($jobHasEnvironment) {
                continue
            }

            $hasDockerPush = $jobText -match '(?im)^\s*-\s*uses\s*:\s*docker/build-push-action@' -and $jobText -match '(?im)^\s*push\s*:\s*true\s*$'
            $isPublishAdjacent = ($jobText -match '(?i)\bnpm\s+publish\b') -or
                                 ($jobText -match '(?i)\bpypa/gh-action-pypi-publish@') -or
                                 ($jobText -match '(?i)\bgh\s+release\s+create\b') -or
                                 ($jobText -match '(?i)\bsoftprops/action-gh-release@') -or
                                 $hasDockerPush

            $findings.Add([PSCustomObject]@{
                    JobName           = $job.Name
                    IsPublishAdjacent = $isPublishAdjacent
                })
        }

        if (-not $anyIdToken) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'OidcTrust' `
                -Status 'Pass' `
                -Severity 'Info' `
                -Resource $wf.Path `
                -Detail "Workflow '$($wf.Name)' does not request OIDC tokens (id-token: write)." `
                -Remediation 'No action needed.'))
            continue
        }

        if ($findings.Count -eq 0) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'OidcTrust' `
                -Status 'Pass' `
                -Severity 'Info' `
                -Resource $wf.Path `
                -Detail "Workflow '$($wf.Name)' requests OIDC tokens and declares environment scoping for analyzed jobs." `
                -Remediation 'No action needed. Keep cloud IAM trust policies scoped to expected repos/refs/environments.'))
            continue
        }

        $publishAdjacentFindings = @($findings | Where-Object { $_.IsPublishAdjacent })
        $affectedJobs = @($findings | ForEach-Object { $_.JobName } | Sort-Object -Unique)

        if ($publishAdjacentFindings.Count -gt 0) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'OidcTrust' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource $wf.Path `
                -Detail "Workflow '$($wf.Name)' requests OIDC tokens without environment scoping in publish-adjacent job(s): $($affectedJobs -join ', '). This matches the Bitwarden CLI 2026-04 primitive (OIDC trusted publishing without environment gating). Cross-check this job with PublishIntegrity controls to ensure both provenance and trust gating are enforced." `
                -Remediation 'Workflow requests OIDC id-token without environment scoping. Verify cloud IAM trust policies are properly restricted. If this job publishes packages, add environment: with required reviewers - OIDC trusted publishing without environment gating is the primitive exploited in the Bitwarden CLI 2026-04 compromise.' `
                -AttackMapping @('oidc-trust-abuse', 'bitwarden-cli-2026-04')))
            continue
        }

        $results.Add((Format-FylgyrResult `
            -CheckName 'OidcTrust' `
            -Status 'Warning' `
            -Severity 'Medium' `
            -Resource $wf.Path `
            -Detail "Workflow '$($wf.Name)' requests OIDC tokens without environment scoping in job(s): $($affectedJobs -join ', '). Cloud-side trust policies cannot be verified from workflow YAML alone, so this should be reviewed manually." `
            -Remediation 'Workflow requests OIDC id-token without environment scoping. Verify cloud IAM trust policies are properly restricted. Add environment protection with required reviewers for sensitive jobs.' `
            -AttackMapping @('oidc-trust-abuse')))
    }

    return $results.ToArray()
}