Public/Test-RunnerHygiene.ps1

function Test-RunnerHygiene {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [System.Object[]]$WorkflowFiles
    )

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

    $hostedPattern = '^(ubuntu|windows|macos)-'
    $selfHostedPattern = '(?i)(self.hosted|self_hosted)'

    foreach ($wf in $WorkflowFiles) {
        $content = $wf.Content
        $name = $wf.Name
        $path = $wf.Path

        # Strip comment lines to avoid false positives
        $strippedLines = ($content -split "`n") | Where-Object { $_ -notmatch '^\s*#' }
        $stripped = $strippedLines -join "`n"

        $hasPullRequestTarget = $stripped -match '(?m)pull_request_target'
        $hasPullRequest = $stripped -match '(?m)(^|\s)pull_request(\s|:|$)'
        $hasWorkflowRun = $stripped -match '(?m)workflow_run'

        # Find all runs-on values — handles both inline and multi-line list forms:
        # runs-on: self-hosted
        # runs-on:
        # - self-hosted
        # - linux
        # Multi-line label lists represent a single runner spec; collect as one joined string.
        $runsOnValues = [System.Collections.Generic.List[string]]::new()

        $lines = $stripped -split "`n"
        for ($i = 0; $i -lt $lines.Count; $i++) {
            $line = $lines[$i]
            if ($line -match '(?i)^\s*runs-on:\s*(.+)$') {
                # Inline value
                $runsOnValues.Add($Matches[1].Trim())
            }
            elseif ($line -match '(?i)^\s*runs-on:\s*$') {
                # Multi-line list — collect all label items as one joined spec
                $labels = [System.Collections.Generic.List[string]]::new()
                $j = $i + 1
                while ($j -lt $lines.Count -and $lines[$j] -match '^\s+-\s+(.+)$') {
                    $labels.Add($Matches[1].Trim())
                    $j++
                }
                if ($labels.Count -gt 0) {
                    $runsOnValues.Add($labels -join ', ')
                }
            }
        }

        $foundSelfHosted = $false

        foreach ($runsOnValue in $runsOnValues) {
            # Skip GitHub-hosted runners
            if ($runsOnValue -match $hostedPattern) {
                continue
            }

            # Matrix/expression — can't determine at analysis time; warn conservatively
            if ($runsOnValue -match '^\$\{\{') {
                $foundSelfHosted = $true
                $results.Add((Format-FylgyrResult `
                    -CheckName 'RunnerHygiene' `
                    -Status 'Warning' `
                    -Severity 'Low' `
                    -Resource "$path" `
                    -Detail "Workflow '$name' uses a dynamic runner expression ('$runsOnValue'). If this resolves to a self-hosted runner, review the security hardening guidance." `
                    -Remediation 'Verify the expression never resolves to a self-hosted runner in untrusted contexts. See: https://docs.github.com/actions/security-guides/security-hardening-for-github-actions#hardening-for-self-hosted-runners' `
                    -AttackMapping @('github-actions-cryptomining') `
                    -Target $null))
                continue
            }

            # Check for self-hosted label or non-standard runner
            $isSelfHosted = $runsOnValue -match $selfHostedPattern -or
                            $runsOnValue -notmatch $hostedPattern

            if (-not $isSelfHosted) {
                continue
            }

            $foundSelfHosted = $true

            if ($hasPullRequestTarget -or $hasWorkflowRun) {
                $results.Add((Format-FylgyrResult `
                    -CheckName 'RunnerHygiene' `
                    -Status 'Fail' `
                    -Severity 'High' `
                    -Resource "$path" `
                    -Detail "Workflow '$name' uses a self-hosted runner ('$runsOnValue') with a dangerous trigger (pull_request_target or workflow_run). Attacker-controlled code from a fork could execute on your runner." `
                    -Remediation 'Move this job to a GitHub-hosted runner, or ensure the workflow never checks out untrusted code on a self-hosted runner. Consider using ephemeral runners.' `
                    -AttackMapping @('github-actions-cryptomining', 'nx-pwn-request') `
                    -Target $null))
            }
            elseif ($hasPullRequest) {
                $results.Add((Format-FylgyrResult `
                    -CheckName 'RunnerHygiene' `
                    -Status 'Warning' `
                    -Severity 'Medium' `
                    -Resource "$path" `
                    -Detail "Workflow '$name' uses a self-hosted runner ('$runsOnValue') with a pull_request trigger. Fork PRs can run arbitrary code on your self-hosted runner." `
                    -Remediation "If this repository is public, switch to GitHub-hosted runners for PR workflows. If private, verify fork PR access is restricted." `
                    -AttackMapping @('github-actions-cryptomining') `
                    -Target $null))
            }
            else {
                $results.Add((Format-FylgyrResult `
                    -CheckName 'RunnerHygiene' `
                    -Status 'Warning' `
                    -Severity 'Low' `
                    -Resource "$path" `
                    -Detail "Workflow '$name' uses a self-hosted runner ('$runsOnValue'). Self-hosted runners require careful hardening and access control." `
                    -Remediation 'Ensure self-hosted runners are ephemeral, run in isolated environments, and are not exposed to untrusted input. See: https://docs.github.com/actions/security-guides/security-hardening-for-github-actions#hardening-for-self-hosted-runners' `
                    -AttackMapping @('github-actions-cryptomining') `
                    -Target $null))
            }
        }

        if (-not $foundSelfHosted) {
            $results.Add((Format-FylgyrResult `
                -CheckName 'RunnerHygiene' `
                -Status 'Pass' `
                -Severity 'Info' `
                -Resource "$path" `
                -Detail "Workflow '$name' uses only GitHub-hosted runners." `
                -Remediation 'No action needed.' `
                -Target $null))
        }
    }

    $results.ToArray()
}