Public/Test-RunnerHygiene.ps1

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

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

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

        [string]$Token
    )

    $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
        $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*(.+)$') {
                $runsOnValues.Add($Matches[1].Trim())
            }
            elseif ($line -match '(?i)^\s*runs-on:\s*$') {
                $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 - cannot 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', 'praetorian-runner-pivot') `
                    -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, as demonstrated in the Praetorian lateral movement attack." `
                    -Remediation 'Move this job to a GitHub-hosted runner, or ensure the workflow never checks out untrusted code on a self-hosted runner. Use ephemeral runners and restrict runner groups to specific repositories.' `
                    -AttackMapping @('github-actions-cryptomining', 'nx-pwn-request', 'praetorian-runner-pivot') `
                    -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. Consider ephemeral runners to limit persistence." `
                    -AttackMapping @('github-actions-cryptomining', 'praetorian-runner-pivot') `
                    -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', 'praetorian-runner-pivot') `
                    -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))
        }
    }

    # Org-level runner checks (require API access).
    # These hit `orgs/{Owner}/...` and must not re-fire once per repository on an org-wide scan.
    # Invoke-Fylgyr resets $script:FylgyrOwnerRunnerGroupsChecked in its begin block;
    # we suppress the org block here if this owner was already checked in the current run.
    $orgAlreadyChecked = $false
    if ($script:FylgyrOwnerRunnerGroupsChecked -is [hashtable] -and $Owner) {
        if ($script:FylgyrOwnerRunnerGroupsChecked.ContainsKey($Owner)) {
            $orgAlreadyChecked = $true
        }
        else {
            $script:FylgyrOwnerRunnerGroupsChecked[$Owner] = $true
        }
    }

    if ($Owner -and $Token -and -not $orgAlreadyChecked) {
        $target = if ($Repo) { "$Owner/$Repo" } else { $Owner }

        # Check org-wide runner groups
        try {
            $runnerGroups = Invoke-GitHubApi -Endpoint "orgs/$Owner/actions/runner-groups" -Token $Token
            if ($runnerGroups.runner_groups) {
                foreach ($group in $runnerGroups.runner_groups) {
                    # Flag runner groups available to all repos
                    if ($group.visibility -eq 'all' -or $group.allows_public_repositories -eq $true) {
                        $detail = "Runner group '$($group.name)' is available to all repositories in the organization."
                        if ($group.allows_public_repositories -eq $true) {
                            $detail += ' Public repositories can use these runners, enabling fork PR abuse.'
                        }

                        $results.Add((Format-FylgyrResult `
                            -CheckName 'RunnerHygiene' `
                            -Status 'Fail' `
                            -Severity 'High' `
                            -Resource "$target (runner-group: $($group.name))" `
                            -Detail $detail `
                            -Remediation "Restrict this runner group to specific repositories in Settings > Actions > Runner groups. Disable 'Allow public repositories' to prevent fork PR abuse. This was the exact attack path in the Praetorian lateral movement demonstration." `
                            -AttackMapping @('praetorian-runner-pivot', 'github-actions-cryptomining') `
                            -Target $target))
                    }
                }
            }
        }
        catch {
            $msg = $_.Exception.Message
            if ($msg -notmatch '404' -and $msg -notmatch '403') {
                $results.Add((Format-FylgyrResult `
                    -CheckName 'RunnerHygiene' `
                    -Status 'Error' `
                    -Severity 'Medium' `
                    -Resource $target `
                    -Detail "Failed to check org runner groups: $($_.Exception.Message)" `
                    -Remediation 'Verify the token has org admin access to read runner group configuration.' `
                    -Target $target))
            }
        }

        # Check for non-ephemeral self-hosted runners at org level
        try {
            $orgRunners = Invoke-GitHubApi -Endpoint "orgs/$Owner/actions/runners" -Token $Token
            if ($orgRunners.runners) {
                foreach ($runner in $orgRunners.runners) {
                    # Check if runner is not ephemeral (persistent runners = persistence for attackers)
                    if ($runner.PSObject.Properties['ephemeral'] -and $runner.ephemeral -eq $false) {
                        $results.Add((Format-FylgyrResult `
                            -CheckName 'RunnerHygiene' `
                            -Status 'Warning' `
                            -Severity 'Medium' `
                            -Resource "$target (runner: $($runner.name))" `
                            -Detail "Self-hosted runner '$($runner.name)' is not configured as ephemeral. Persistent runners allow attackers to maintain access across workflow runs." `
                            -Remediation 'Configure runners with --ephemeral flag so they are automatically de-registered after each job. This limits persistence for attackers who gain runner access.' `
                            -AttackMapping @('praetorian-runner-pivot', 'github-actions-cryptomining') `
                            -Target $target))
                    }
                }
            }
        }
        catch {
            Write-Debug "Org runner listing skipped: $($_.Exception.Message)"
        }
    }

    # Repo-level runner listing always runs per-repo (not cached at owner level).
    if ($Owner -and $Token -and $Repo) {
        $target = "$Owner/$Repo"
        try {
            $repoRunners = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/actions/runners" -Token $Token
            if ($repoRunners.runners -and $repoRunners.runners.Count -gt 0) {
                $isPublic = $false
                try {
                    $repoInfo = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo" -Token $Token
                    $isPublic = -not $repoInfo.private
                }
                catch {
                    Write-Debug "Repo info lookup skipped: $($_.Exception.Message)"
                }

                if ($isPublic) {
                    $results.Add((Format-FylgyrResult `
                        -CheckName 'RunnerHygiene' `
                        -Status 'Fail' `
                        -Severity 'Critical' `
                        -Resource "$target" `
                        -Detail "$($repoRunners.runners.Count) self-hosted runner(s) registered on a public repository. Anyone who forks this repo can potentially execute code on your runners." `
                        -Remediation 'Remove self-hosted runners from public repositories or switch to GitHub-hosted runners. If self-hosted runners are required, use ephemeral runners with strict network isolation.' `
                        -AttackMapping @('github-actions-cryptomining', 'praetorian-runner-pivot') `
                        -Target $target))
                }

                foreach ($runner in $repoRunners.runners) {
                    if ($runner.PSObject.Properties['ephemeral'] -and $runner.ephemeral -eq $false) {
                        $results.Add((Format-FylgyrResult `
                            -CheckName 'RunnerHygiene' `
                            -Status 'Warning' `
                            -Severity 'Medium' `
                            -Resource "$target (runner: $($runner.name))" `
                            -Detail "Self-hosted runner '$($runner.name)' is not ephemeral. Persistent runners allow attackers to maintain access across workflow runs." `
                            -Remediation 'Configure runners with --ephemeral flag. This limits persistence for attackers.' `
                            -AttackMapping @('praetorian-runner-pivot') `
                            -Target $target))
                    }
                }
            }
        }
        catch {
            Write-Debug "Repo runner listing skipped: $($_.Exception.Message)"
        }
    }

    $results.ToArray()
}