Public/Test-ForkSecretExposure.ps1
|
function Test-ForkSecretExposure { [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory)] [PSCustomObject[]]$WorkflowFiles, [Parameter(Mandatory)] [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Owner, [Parameter(Mandatory)] [ValidatePattern('^[a-zA-Z0-9._-]+$')] [string]$Repo, [Parameter(Mandatory)] [string]$Token ) $target = "$Owner/$Repo" $results = [System.Collections.Generic.List[PSCustomObject]]::new() # Check workflow files for pull_request_target + secrets references foreach ($wf in $WorkflowFiles) { # Strip YAML comment lines to avoid false positives $strippedLines = ($wf.Content -split "`n") | Where-Object { $_ -notmatch '^\s*#' } $stripped = $strippedLines -join "`n" $hasPRT = $false $prtPatterns = @( '(?m)^\s*pull_request_target\s*:' '(?m)^\s*on\s*:\s*pull_request_target\s*(?:#.*)?$' '(?m)^\s*on\s*:\s*\[[^\]]*\bpull_request_target\b[^\]]*\]' ) foreach ($pattern in $prtPatterns) { if ($stripped -match $pattern) { $hasPRT = $true break } } if (-not $hasPRT) { continue } # Check if secrets are referenced $secretRefs = [System.Collections.Generic.List[string]]::new() $secretPattern = '(?i)\$\{\{\s*secrets\.([a-zA-Z0-9_]+)\s*\}\}' $secretMatches = [regex]::Matches($stripped, $secretPattern) foreach ($m in $secretMatches) { $secretName = $m.Groups[1].Value if ($secretName -ne 'GITHUB_TOKEN' -and -not $secretRefs.Contains($secretName)) { $secretRefs.Add($secretName) } } if ($secretRefs.Count -gt 0) { $secretList = $secretRefs -join ', ' $results.Add((Format-FylgyrResult ` -CheckName 'ForkSecretExposure' ` -Status 'Fail' ` -Severity 'Critical' ` -Resource $wf.Path ` -Detail "Workflow '$($wf.Name)' uses pull_request_target and references secrets ($secretList). These secrets are available to code from fork PRs in this trigger context, enabling exfiltration as demonstrated by prt-scan (475+ malicious PRs) and hackerbot-claw." ` -Remediation 'Move secret-dependent steps to a separate workflow using workflow_run trigger with environment protection. Use environment secrets with required reviewers instead of repository secrets in pull_request_target workflows.' ` -AttackMapping @('prt-scan-ai-automated', 'hackerbot-claw', 'nx-pwn-request', 'azure-karpenter-pwn-request') ` -Target $target)) } } # Check environment protection rules try { $environments = Invoke-GitHubApi -Endpoint "repos/$Owner/$Repo/environments" -Token $Token if ($environments.environments) { foreach ($env in $environments.environments) { $envName = $env.name $hasProtection = $false # Check for required reviewers if ($env.protection_rules) { foreach ($rule in $env.protection_rules) { if ($rule.type -eq 'required_reviewers' -and $rule.reviewers -and $rule.reviewers.Count -gt 0) { $hasProtection = $true } if ($rule.type -eq 'wait_timer' -and $rule.wait_timer -gt 0) { $hasProtection = $true } } } if (-not $hasProtection) { $results.Add((Format-FylgyrResult ` -CheckName 'ForkSecretExposure' ` -Status 'Fail' ` -Severity 'High' ` -Resource "$target (environment: $envName)" ` -Detail "Environment '$envName' has no required reviewers or wait timers. Deployments to this environment can proceed without approval, bypassing human review of potentially malicious code." ` -Remediation "Add required reviewers to the '$envName' environment in Settings > Environments. For production environments, also add a wait timer to allow for review." ` -AttackMapping @('prt-scan-ai-automated', 'hackerbot-claw') ` -Target $target)) } } } } catch { $msg = $_.Exception.Message if ($msg -notmatch '404' -and $msg -notmatch '403') { $results.Add((Format-FylgyrResult ` -CheckName 'ForkSecretExposure' ` -Status 'Error' ` -Severity 'Medium' ` -Resource $target ` -Detail "Failed to check environment protection rules: $($_.Exception.Message)" ` -Remediation 'Verify the token has access to read environment settings.' ` -Target $target)) } } # Check org-level secrets without repository restrictions try { $orgSecrets = Invoke-GitHubApi -Endpoint "orgs/$Owner/actions/secrets" -Token $Token if ($orgSecrets.secrets) { foreach ($secret in $orgSecrets.secrets) { if ($secret.visibility -eq 'all') { $results.Add((Format-FylgyrResult ` -CheckName 'ForkSecretExposure' ` -Status 'Fail' ` -Severity 'High' ` -Resource "$Owner (org-secret: $($secret.name))" ` -Detail "Org-level secret '$($secret.name)' is available to all repositories. Any repository with a pull_request_target workflow can expose this secret to fork PRs." ` -Remediation "Restrict this secret to specific repositories in Settings > Secrets and variables > Actions. Use the 'Selected repositories' visibility option." ` -AttackMapping @('prt-scan-ai-automated', 'hackerbot-claw') ` -Target $Owner)) } } } } catch { Write-Debug "Org secret listing skipped: $($_.Exception.Message)" } if ($results.Count -eq 0) { $results.Add((Format-FylgyrResult ` -CheckName 'ForkSecretExposure' ` -Status 'Pass' ` -Severity 'Info' ` -Resource $target ` -Detail 'No fork secret exposure risks detected in workflow files or environment configuration.' ` -Remediation 'No action needed.' ` -Target $target)) } $results.ToArray() } |