Public/Test-ScriptInjection.ps1

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

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

    $safeExpressions = @(
        '^github\.event\.number$'
        '^github\.sha$'
        '^github\.ref$'
        '^github\.run_id$'
        '^github\.run_number$'
        '^github\.actor$'
        '^github\.repository$'
    )

    $userControlledPatterns = @(
        'github\.event\.issue\.(title|body)'
        'github\.event\.pull_request\.(title|body)'
        'github\.event\.comment\.body'
        'github\.event\.review\.body'
        'github\.event\.discussion\.(body|title)'
        'github\.event\.pages\.[^.]+\.page_name'
        'github\.event\.commits\.[^.]+\.message'
        'github\.event\.head_commit\.message'
        'github\.head_ref'
        'github\.event\.workflow_run\.head_branch'
        'github\.event\.pull_request\.head\.label'
        'github\.event\.pull_request\.head\.repo\.default_branch'
    )

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

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

        foreach ($block in $runBlocks) {
            $expressionMatches = [regex]::Matches($block.Content, '\$\{\{\s*(?<expr>[^}]+?)\s*\}\}')
            foreach ($match in $expressionMatches) {
                $expr = $match.Groups['expr'].Value.Trim().ToLowerInvariant()
                if ([string]::IsNullOrWhiteSpace($expr)) {
                    continue
                }

                $isSafeExpression = $false
                foreach ($safePattern in $safeExpressions) {
                    if ($expr -match $safePattern) {
                        $isSafeExpression = $true
                        break
                    }
                }

                if ($isSafeExpression) {
                    continue
                }

                foreach ($unsafePattern in $userControlledPatterns) {
                    if ($expr -match $unsafePattern) {
                        $riskyExpressions.Add($expr)
                        break
                    }
                }
            }
        }

        if ($riskyExpressions.Count -gt 0) {
            $uniqueExpr = @($riskyExpressions | Sort-Object -Unique)
            $results.Add((Format-FylgyrResult `
                -CheckName 'ScriptInjection' `
                -Status 'Fail' `
                -Severity 'Critical' `
                -Resource $wf.Path `
                -Detail "Workflow '$($wf.Name)' interpolates untrusted GitHub event fields inside run steps: $($uniqueExpr -join ', '). This creates command-injection risk in shell execution context and matches real-world GitHub Actions script-injection campaigns." `
                -Remediation 'Never interpolate untrusted event fields directly in run steps. Move untrusted values into validated inputs or sanitize them before use. Known limitation: this check only evaluates run: blocks and does not currently inspect env: interpolation paths.' `
                -AttackMapping @('github-actions-script-injection')))
            continue
        }

        $results.Add((Format-FylgyrResult `
            -CheckName 'ScriptInjection' `
            -Status 'Pass' `
            -Severity 'Info' `
            -Resource $wf.Path `
            -Detail "Workflow '$($wf.Name)' has no detected untrusted event expression interpolation inside run blocks." `
            -Remediation 'No action needed.'))
    }

    return $results.ToArray()
}