Public/Test-DangerousTrigger.ps1
|
function Test-DangerousTrigger { [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory)] [PSCustomObject[]]$WorkflowFiles, [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Owner, [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Repo, [string]$Token ) $results = [System.Collections.Generic.List[PSCustomObject]]::new() $dangerousTriggers = @('pull_request_target', 'workflow_run') # Patterns that indicate checkout of untrusted PR code $untrustedCheckoutPatterns = @( 'github\.event\.pull_request\.head\.sha' 'github\.event\.pull_request\.head\.ref' '\$\{\{\s*github\.head_ref\s*\}\}' ) # Patterns that indicate actor-restricted workflows $actorRestrictionPatterns = @( 'github\.actor\s*[!=]' 'github\.triggering_actor\s*[!=]' 'contains\s*\([^)]*github\.actor' 'github\.event\.pull_request\.author_association' ) # Resolve fork PR contributor approval policy via the real API when possible. # Docs: https://docs.github.com/en/rest/actions/permissions#get-fork-pr-contributor-approval-permissions-for-a-repository # Valid approval_policy values (any of these means a gate is configured): # first_time_contributors_new_to_github # first_time_contributors # all_external_contributors $hasApprovalGate = $null if ($Owner -and $Repo -and $Token) { try { $forkApproval = Invoke-GitHubApi ` -Endpoint "repos/$Owner/$Repo/actions/permissions/fork-pr-contributor-approval" ` -Token $Token if ($forkApproval -and $forkApproval.PSObject.Properties['approval_policy']) { $hasApprovalGate = $forkApproval.approval_policy -in @( 'first_time_contributors_new_to_github', 'first_time_contributors', 'all_external_contributors' ) } else { $hasApprovalGate = $false } } catch { $msg = $_.Exception.Message if ($msg -match '404') { # Repo has no explicit policy - GitHub default applies; treat as absent gate. $hasApprovalGate = $false } else { # 403 or other error: leave as $null so we do not emit a misleading advisory. $hasApprovalGate = $null } } } foreach ($wf in $WorkflowFiles) { # Strip YAML comment lines to avoid false positives $content = (($wf.Content -split "`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n" $foundTriggers = @() foreach ($trigger in $dangerousTriggers) { $escaped = [regex]::Escape($trigger) $triggerPatterns = @( "(?m)^\s*$escaped\s*:" "(?m)^\s*on\s*:\s*$escaped\s*(?:#.*)?$" "(?m)^\s*on\s*:\s*\[[^\]]*\b$escaped\b[^\]]*\]" ) foreach ($tp in $triggerPatterns) { if ($content -match $tp) { $foundTriggers += $trigger break } } } if ($foundTriggers.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'DangerousTrigger' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $wf.Path ` -Detail 'No dangerous trigger patterns found.' ` -Remediation 'None.')) continue } # Check if the workflow checks out untrusted code $checksOutUntrusted = $false foreach ($pattern in $untrustedCheckoutPatterns) { if ($content -match $pattern) { $checksOutUntrusted = $true break } } # Check if the workflow has actor restrictions $hasActorRestriction = $false foreach ($pattern in $actorRestrictionPatterns) { if ($content -match $pattern) { $hasActorRestriction = $true break } } # Check if secrets are referenced in a pull_request_target workflow $referencesSecrets = $content -match 'secrets\.' $triggerList = $foundTriggers -join ', ' $hasPRT = $foundTriggers -contains 'pull_request_target' if ($checksOutUntrusted) { $attackMappings = @('nx-pwn-request', 'prt-scan-ai-automated', 'trivy-supply-chain-2026', 'azure-karpenter-pwn-request') if ($foundTriggers -contains 'workflow_run') { $attackMappings += 'hackerbot-claw' } $detail = "Uses $triggerList and checks out untrusted PR code. This allows attacker-controlled code to run with elevated permissions, as exploited in the prt-scan campaign (475+ malicious PRs, ~10% success rate) and the Trivy supply chain worm." if (-not $hasActorRestriction) { $detail += ' No actor-restriction conditions detected to limit who can trigger this workflow.' } $results.Add((Format-FylgyrResult ` -CheckName 'DangerousTrigger' ` -Status 'Fail' ` -Severity 'Critical' ` -Resource $wf.Path ` -Detail $detail ` -Remediation 'Do not checkout the PR head ref in pull_request_target workflows. Use pull_request trigger instead, or run untrusted code in a separate unprivileged workflow. Add actor-restriction conditions (e.g., check github.event.pull_request.author_association) to limit execution to trusted contributors.' ` -AttackMapping $attackMappings)) } elseif ($hasPRT -and $referencesSecrets) { $results.Add((Format-FylgyrResult ` -CheckName 'DangerousTrigger' ` -Status 'Fail' ` -Severity 'High' ` -Resource $wf.Path ` -Detail "Uses $triggerList and references secrets. Secrets are available to pull_request_target workflows even for fork PRs, enabling exfiltration if the workflow processes untrusted input." ` -Remediation 'Move secret-dependent steps to a separate workflow triggered by workflow_run with appropriate isolation. Use environment protection rules with required reviewers for deployments.' ` -AttackMapping @('prt-scan-ai-automated', 'hackerbot-claw', 'nx-pwn-request'))) } else { $detail = "Uses $triggerList without apparent checkout of untrusted code. The workflow may still run with elevated permissions." if (-not $hasActorRestriction) { $detail += ' No actor-restriction conditions detected.' } $results.Add((Format-FylgyrResult ` -CheckName 'DangerousTrigger' ` -Status 'Warning' ` -Severity 'Medium' ` -Resource $wf.Path ` -Detail $detail ` -Remediation 'Verify this workflow does not process untrusted input. Consider narrowing permissions, adding actor-restriction conditions, or switching to pull_request trigger.' ` -AttackMapping @('nx-pwn-request', 'prt-scan-ai-automated'))) } # Advisory: check if first-time contributor approval is missing if ($hasPRT -and $hasApprovalGate -eq $false) { $results.Add((Format-FylgyrResult ` -CheckName 'DangerousTrigger' ` -Status 'Info' ` -Severity 'Medium' ` -Resource $wf.Path ` -Detail 'Consider enabling first-time contributor approval gates for fork PRs. Repos that required approval (Sentry, NixOS, OpenSearch) successfully blocked the prt-scan campaign.' ` -Remediation 'In Settings > Actions > General, set "Fork pull request workflows from outside collaborators" to "Require approval for first-time contributors" or stricter.' ` -AttackMapping @('prt-scan-ai-automated'))) } } $results.ToArray() } |