Public/Test-PublishIntegrity.ps1

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

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

    foreach ($wf in $WorkflowFiles) {
        # Strip YAML comment lines before pattern matching to reduce false positives.
        $lines = ($wf.Content -split "`n") | Where-Object { $_ -notmatch '^\s*#' }
        $normalized = $lines -join "`n"

        $hasIdTokenWrite = $normalized -match '(?im)^\s*id-token\s*:\s*write\s*$'
        $hasNpmTokenEnv = $normalized -match '(?i)\b(NPM_TOKEN|NODE_AUTH_TOKEN)\b'

        $npmPublishLines = @($lines | Where-Object { $_ -match '(?i)\bnpm\s+publish\b' })
        $hasNpmPublish = $npmPublishLines.Count -gt 0
        $hasNpmProvenance = @($npmPublishLines | Where-Object { $_ -match '(?i)\bnpm\s+publish\b.*--provenance\b' }).Count -gt 0

        $hasPypiPublish = $false
        $pypiUsesPassword = $false

        $dockerPushSteps = 0
        $hasReleasePublish = $false

        for ($i = 0; $i -lt $lines.Count; $i++) {
            $line = $lines[$i]

            if ($line -match '(?i)\buses\s*:\s*pypa/gh-action-pypi-publish@') {
                $hasPypiPublish = $true
                for ($j = $i + 1; $j -lt $lines.Count -and $j -le ($i + 18); $j++) {
                    if ($lines[$j] -match '^\s*-\s*uses\s*:') {
                        break
                    }

                    if ($lines[$j] -match '^\s*password\s*:') {
                        $pypiUsesPassword = $true
                        break
                    }
                }
            }

            if ($line -match '(?i)\buses\s*:\s*docker/build-push-action@') {
                $stepPushEnabled = $false
                for ($j = $i + 1; $j -lt $lines.Count -and $j -le ($i + 18); $j++) {
                    if ($lines[$j] -match '^\s*-\s*uses\s*:') {
                        break
                    }

                    if ($lines[$j] -match '(?i)^\s*push\s*:\s*true\s*$') {
                        $stepPushEnabled = $true
                        break
                    }
                }

                if ($stepPushEnabled) {
                    $dockerPushSteps++
                }
            }

            if ($line -match '(?i)\bgh\s+release\s+create\b' -or
                $line -match '(?i)\buses\s*:\s*softprops/action-gh-release@') {
                $hasReleasePublish = $true
            }
        }

        $hasContainerAttestation = $normalized -match '(?i)\bcosign\s+sign\b' -or
                                   $normalized -match '(?i)\buses\s*:\s*actions/attest-build-provenance@'

        $hasReleaseAttestation = $normalized -match '(?i)\bgh\s+attestation\b' -or
                                 $normalized -match '(?i)\buses\s*:\s*actions/attest-build-provenance@'

        $hasPublishStep = $hasNpmPublish -or $hasPypiPublish -or ($dockerPushSteps -gt 0) -or $hasReleasePublish

        $findings = [System.Collections.Generic.List[string]]::new()

        if ($hasNpmPublish -and -not $hasNpmProvenance) {
            if ($hasNpmTokenEnv) {
                $findings.Add('Detected npm publish using token-based auth without --provenance. Long-lived npm tokens are a primary compromise path in incidents like Shai-Hulud and lottie-player.')
            }
            elseif ($hasIdTokenWrite) {
                $findings.Add('Detected npm publish with id-token: write but missing --provenance. Trusted publishing to npm must use npm publish --provenance to emit verifiable provenance.')
            }
            else {
                $findings.Add('Detected npm publish without --provenance and without evidence of trusted publishing. This weakens publish-chain integrity and incident response confidence.')
            }
        }

        if ($hasPypiPublish -and $pypiUsesPassword) {
            $findings.Add('Detected pypa/gh-action-pypi-publish with password input. Prefer PyPI Trusted Publishing (OIDC) with no password field.')
        }

        if ($dockerPushSteps -gt 0 -and -not $hasContainerAttestation) {
            $findings.Add('Detected docker/build-push-action push without cosign signing or actions/attest-build-provenance. Published containers are missing a verifiable integrity signal.')
        }

        if ($hasReleasePublish -and -not $hasReleaseAttestation) {
            $findings.Add('Detected GitHub Release publishing without an attestation step. Release artifacts should include provenance or attestations for downstream verification.')
        }

        if ($findings.Count -gt 0) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'PublishIntegrity' `
                -Status 'Fail' `
                -Severity 'High' `
                -Resource $wf.Path `
                -Detail ($findings -join ' ') `
                -Remediation 'For npm, use npm publish --provenance with OIDC trusted publishing. For PyPI, remove password and use Trusted Publishing. For containers and releases, add cosign signing or actions/attest-build-provenance.' `
                -AttackMapping @('shai-hulud-npm-worm', 'lottie-player-npm-compromise', 'ua-parser-js-npm-compromise', 'bitwarden-cli-2026-04', 'event-stream-hijack')))
            continue
        }

        if (-not $hasPublishStep) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'PublishIntegrity' `
                -Status 'Pass' `
                -Severity 'Info' `
                -Resource $wf.Path `
                -Detail "Workflow '$($wf.Name)' does not appear to publish packages, container images, or releases." `
                -Remediation 'No action needed.'))
            continue
        }

        $crossCheckNote = ''
        if ($hasNpmPublish -and $hasNpmProvenance -and $hasIdTokenWrite) {
            $crossCheckNote = ' npm publish uses OIDC-oriented provenance controls. Also verify OIDC trust hardening on publish jobs: use protected environments with required reviewers and trusted ref restrictions; OIDC without environment gating was exploited in the Bitwarden CLI 2026-04 compromise.'
        }

        $results.Add((Format-FylgyrResult `
            -CheckName 'PublishIntegrity' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $wf.Path `
            -Detail "Publish-related steps in workflow '$($wf.Name)' include provenance/OIDC/signing signals.$crossCheckNote" `
            -Remediation 'No action needed.'))
    }

    return $results.ToArray()
}