microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/security/Test-WorkflowPermissions.ps1
349lines · modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | |
| 5 | #Requires -Version 7.0 |
| 6 | |
| 7 | <# |
| 8 | .SYNOPSIS |
| 9 | Validates that GitHub Actions workflow files include a top-level permissions block. |
| 10 | |
| 11 | .DESCRIPTION |
| 12 | Scans GitHub Actions workflow YAML files for the presence of a top-level |
| 13 | permissions block. Workflows without explicit permissions rely on the |
| 14 | repository's default token permissions, which can cause OpenSSF Scorecard |
| 15 | Token-Permissions failures. |
| 16 | |
| 17 | The script uses a regex-based approach (^permissions:) to detect the |
| 18 | top-level permissions declaration at column 0, ensuring zero dependencies |
| 19 | and zero false positives. |
| 20 | |
| 21 | .PARAMETER Path |
| 22 | Directory containing workflow YAML files. Defaults to '.github/workflows'. |
| 23 | |
| 24 | .PARAMETER Format |
| 25 | Output format: 'json', 'sarif', or 'console'. Defaults to 'json'. |
| 26 | |
| 27 | .PARAMETER OutputPath |
| 28 | Path for result output file. Defaults to 'logs/workflow-permissions-results.json'. |
| 29 | |
| 30 | .PARAMETER FailOnViolation |
| 31 | When set, exits with non-zero code if any workflow is missing permissions. |
| 32 | |
| 33 | .PARAMETER ExcludePaths |
| 34 | Comma-separated list of workflow filenames to exclude from scanning. |
| 35 | Defaults to 'copilot-setup-steps.yml'. |
| 36 | |
| 37 | .EXAMPLE |
| 38 | ./scripts/security/Test-WorkflowPermissions.ps1 |
| 39 | |
| 40 | .EXAMPLE |
| 41 | ./scripts/security/Test-WorkflowPermissions.ps1 -FailOnViolation -Format sarif |
| 42 | |
| 43 | .NOTES |
| 44 | Part of the HVE Core security validation suite. |
| 45 | |
| 46 | .LINK |
| 47 | https://github.com/microsoft/hve-core |
| 48 | #> |
| 49 | |
| 50 | using module ./Modules/SecurityClasses.psm1 |
| 51 | |
| 52 | [CmdletBinding()] |
| 53 | param( |
| 54 | [Parameter(Mandatory = $false)] |
| 55 | [string]$Path = '.github/workflows', |
| 56 | |
| 57 | [Parameter(Mandatory = $false)] |
| 58 | [ValidateSet('json', 'sarif', 'console')] |
| 59 | [string]$Format = 'json', |
| 60 | |
| 61 | [Parameter(Mandatory = $false)] |
| 62 | [string]$OutputPath = 'logs/workflow-permissions-results.json', |
| 63 | |
| 64 | [Parameter(Mandatory = $false)] |
| 65 | [switch]$FailOnViolation, |
| 66 | |
| 67 | [Parameter(Mandatory = $false)] |
| 68 | [string]$ExcludePaths = 'copilot-setup-steps.yml' |
| 69 | ) |
| 70 | |
| 71 | $ErrorActionPreference = 'Stop' |
| 72 | |
| 73 | Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force |
| 74 | Import-Module (Join-Path $PSScriptRoot 'Modules/SecurityHelpers.psm1') -Force |
| 75 | |
| 76 | # region Helper Functions |
| 77 | |
| 78 | function Test-WorkflowPermissions { |
| 79 | <# |
| 80 | .SYNOPSIS |
| 81 | Tests a single workflow file for a top-level permissions block. |
| 82 | #> |
| 83 | [CmdletBinding()] |
| 84 | param( |
| 85 | [Parameter(Mandatory = $true)] |
| 86 | [string]$FilePath |
| 87 | ) |
| 88 | |
| 89 | $content = Get-Content -Path $FilePath -Raw |
| 90 | if ($content -match '(?m)^permissions:') { |
| 91 | return $null |
| 92 | } |
| 93 | |
| 94 | $fileName = [System.IO.Path]::GetFileName($FilePath) |
| 95 | $relativePath = $FilePath |
| 96 | |
| 97 | $violation = [DependencyViolation]::new() |
| 98 | $violation.File = $relativePath |
| 99 | $violation.Line = 0 |
| 100 | $violation.Type = 'workflow-permissions' |
| 101 | $violation.Name = $fileName |
| 102 | $violation.ViolationType = 'MissingPermissions' |
| 103 | $violation.Severity = 'High' |
| 104 | $violation.Description = "Workflow '$fileName' is missing a top-level permissions block" |
| 105 | $violation.Remediation = "Add a top-level 'permissions:' block to restrict default token scope and satisfy OpenSSF Scorecard Token-Permissions" |
| 106 | $violation.Metadata = @{ FullPath = $FilePath } |
| 107 | |
| 108 | return $violation |
| 109 | } |
| 110 | |
| 111 | function ConvertTo-PermissionsSarif { |
| 112 | <# |
| 113 | .SYNOPSIS |
| 114 | Converts violations to SARIF 2.1.0 format. |
| 115 | #> |
| 116 | [CmdletBinding()] |
| 117 | param( |
| 118 | [Parameter(Mandatory = $true)] |
| 119 | [AllowEmptyCollection()] |
| 120 | [DependencyViolation[]]$Violations |
| 121 | ) |
| 122 | |
| 123 | $rules = @( |
| 124 | @{ |
| 125 | id = 'missing-permissions' |
| 126 | name = 'MissingWorkflowPermissions' |
| 127 | shortDescription = @{ text = 'Workflow missing top-level permissions block' } |
| 128 | fullDescription = @{ text = 'GitHub Actions workflows should declare a top-level permissions block to restrict the default GITHUB_TOKEN scope.' } |
| 129 | helpUri = 'https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token' |
| 130 | defaultConfiguration = @{ level = 'error' } |
| 131 | } |
| 132 | ) |
| 133 | |
| 134 | $results = @() |
| 135 | foreach ($v in $Violations) { |
| 136 | $results += @{ |
| 137 | ruleId = 'missing-permissions' |
| 138 | level = 'error' |
| 139 | message = @{ text = $v.Description } |
| 140 | locations = @( |
| 141 | @{ |
| 142 | physicalLocation = @{ |
| 143 | artifactLocation = @{ uri = $v.File } |
| 144 | region = @{ startLine = 1 } |
| 145 | } |
| 146 | } |
| 147 | ) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | $sarif = @{ |
| 152 | version = '2.1.0' |
| 153 | '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' |
| 154 | runs = @( |
| 155 | @{ |
| 156 | tool = @{ |
| 157 | driver = @{ |
| 158 | name = 'Test-WorkflowPermissions' |
| 159 | version = '1.0.0' |
| 160 | informationUri = 'https://github.com/microsoft/hve-core' |
| 161 | rules = $rules |
| 162 | } |
| 163 | } |
| 164 | results = $results |
| 165 | } |
| 166 | ) |
| 167 | } |
| 168 | |
| 169 | return $sarif |
| 170 | } |
| 171 | |
| 172 | function Invoke-WorkflowPermissionsCheck { |
| 173 | <# |
| 174 | .SYNOPSIS |
| 175 | Orchestrates the workflow permissions validation scan. |
| 176 | #> |
| 177 | [OutputType([int])] |
| 178 | [CmdletBinding()] |
| 179 | param( |
| 180 | [Parameter(Mandatory = $false)] |
| 181 | [string]$Path = '.github/workflows', |
| 182 | |
| 183 | [Parameter(Mandatory = $false)] |
| 184 | [ValidateSet('json', 'sarif', 'console')] |
| 185 | [string]$Format = 'json', |
| 186 | |
| 187 | [Parameter(Mandatory = $false)] |
| 188 | [string]$OutputPath = 'logs/workflow-permissions-results.json', |
| 189 | |
| 190 | [Parameter(Mandatory = $false)] |
| 191 | [switch]$FailOnViolation, |
| 192 | |
| 193 | [Parameter(Mandatory = $false)] |
| 194 | [string]$ExcludePaths = 'copilot-setup-steps.yml' |
| 195 | ) |
| 196 | |
| 197 | Write-SecurityLog "Starting workflow permissions validation" -Level Info -CIAnnotation |
| 198 | Write-SecurityLog "Scanning: $Path" -Level Info |
| 199 | |
| 200 | # Resolve scan path |
| 201 | $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop |
| 202 | Write-SecurityLog "Resolved path: $resolvedPath" -Level Info |
| 203 | |
| 204 | # Parse exclusions |
| 205 | $exclusions = @() |
| 206 | if ($ExcludePaths) { |
| 207 | $exclusions = $ExcludePaths -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } |
| 208 | } |
| 209 | if ($exclusions.Count -gt 0) { |
| 210 | Write-SecurityLog "Excluding: $($exclusions -join ', ')" -Level Info |
| 211 | } |
| 212 | |
| 213 | # Discover workflow files |
| 214 | $workflowFiles = Get-ChildItem -Path $resolvedPath -File | Where-Object { $_.Extension -in '.yml', '.yaml' } |
| 215 | $totalFiles = @($workflowFiles).Count |
| 216 | Write-SecurityLog "Found $totalFiles workflow file(s)" -Level Info |
| 217 | |
| 218 | # Apply exclusions |
| 219 | if ($exclusions.Count -gt 0) { |
| 220 | $workflowFiles = $workflowFiles | Where-Object { $exclusions -notcontains $_.Name } |
| 221 | } |
| 222 | $scannedFiles = $workflowFiles.Count |
| 223 | Write-SecurityLog "Scanning $scannedFiles file(s) after exclusions" -Level Info |
| 224 | |
| 225 | # Scan each workflow |
| 226 | $report = [ComplianceReport]::new($Path) |
| 227 | $report.TotalFiles = $totalFiles |
| 228 | $report.ScannedFiles = $scannedFiles |
| 229 | $report.TotalDependencies = $scannedFiles |
| 230 | $report.Metadata['ItemType'] = 'workflow' |
| 231 | $report.Metadata['ItemLabel'] = 'workflows with permissions' |
| 232 | $filesWithPermissions = 0 |
| 233 | |
| 234 | foreach ($file in $workflowFiles) { |
| 235 | $violation = Test-WorkflowPermissions -FilePath $file.FullName |
| 236 | if ($null -eq $violation) { |
| 237 | $filesWithPermissions++ |
| 238 | Write-SecurityLog " PASS: $($file.Name)" -Level Success |
| 239 | } |
| 240 | else { |
| 241 | # Normalize to workspace-relative path |
| 242 | $violation.File = Join-Path $Path $file.Name |
| 243 | $report.AddViolation($violation) |
| 244 | Write-SecurityLog " FAIL: $($file.Name) - missing permissions block" -Level Error -CIAnnotation |
| 245 | Write-CIAnnotation -Message $violation.Description -Level 'Error' -File $violation.File -Line 1 |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | $report.PinnedDependencies = $filesWithPermissions |
| 250 | $report.CalculateScore() |
| 251 | |
| 252 | Write-SecurityLog "Score: $($report.ComplianceScore)% ($filesWithPermissions/$scannedFiles with permissions)" -Level Info |
| 253 | |
| 254 | # Format output |
| 255 | $output = switch ($Format) { |
| 256 | 'console' { |
| 257 | if ($report.Violations.Count -eq 0) { |
| 258 | "All $scannedFiles workflow(s) have a top-level permissions block." |
| 259 | } |
| 260 | else { |
| 261 | $lines = @("Workflow permissions violations found:`n") |
| 262 | foreach ($v in $report.Violations) { |
| 263 | $lines += " - $($v.File): $($v.Description)" |
| 264 | } |
| 265 | $lines += "`nRemediation: $($report.Violations[0].Remediation)" |
| 266 | $lines -join "`n" |
| 267 | } |
| 268 | } |
| 269 | 'sarif' { |
| 270 | (ConvertTo-PermissionsSarif -Violations $report.Violations) | ConvertTo-Json -Depth 10 |
| 271 | } |
| 272 | 'json' { |
| 273 | $report.ToHashtable() | ConvertTo-Json -Depth 10 |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | # Write output file |
| 278 | $outputDir = [System.IO.Path]::GetDirectoryName($OutputPath) |
| 279 | if ($outputDir -and -not (Test-Path $outputDir)) { |
| 280 | New-Item -ItemType Directory -Path $outputDir -Force | Out-Null |
| 281 | } |
| 282 | |
| 283 | $output | Out-File -FilePath $OutputPath -Encoding utf8 -Force |
| 284 | Write-SecurityLog "Results written to: $OutputPath" -Level Info |
| 285 | |
| 286 | # Generate step summary |
| 287 | $summaryLines = @( |
| 288 | "## Workflow Permissions Validation" |
| 289 | "" |
| 290 | "| Metric | Value |" |
| 291 | "|--------|-------|" |
| 292 | "| Total Workflows | $totalFiles |" |
| 293 | "| Scanned | $scannedFiles |" |
| 294 | "| With Permissions | $filesWithPermissions |" |
| 295 | "| Missing Permissions | $($report.Violations.Count) |" |
| 296 | "| Compliance Score | $($report.ComplianceScore)% |" |
| 297 | ) |
| 298 | |
| 299 | if ($report.Violations.Count -gt 0) { |
| 300 | $summaryLines += @( |
| 301 | "" |
| 302 | "### Violations" |
| 303 | "" |
| 304 | "| Workflow | Issue |" |
| 305 | "|----------|-------|" |
| 306 | ) |
| 307 | foreach ($v in $report.Violations) { |
| 308 | $summaryLines += "| ``$($v.File)`` | $($v.Description) |" |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | $summary = $summaryLines -join "`n" |
| 313 | Write-CIStepSummary -Content $summary |
| 314 | |
| 315 | # Display to console |
| 316 | $output | Out-Host |
| 317 | |
| 318 | # Determine exit code |
| 319 | $exitCode = 0 |
| 320 | if ($report.Violations.Count -gt 0) { |
| 321 | if ($FailOnViolation) { |
| 322 | Write-SecurityLog "$($report.Violations.Count) violation(s) found - failing" -Level Error -CIAnnotation |
| 323 | $exitCode = 1 |
| 324 | } |
| 325 | else { |
| 326 | Write-SecurityLog "$($report.Violations.Count) violation(s) found - soft fail mode" -Level Warning -CIAnnotation |
| 327 | } |
| 328 | } |
| 329 | else { |
| 330 | Write-SecurityLog "All workflows have permissions blocks" -Level Success |
| 331 | } |
| 332 | |
| 333 | return $exitCode |
| 334 | } |
| 335 | |
| 336 | # endregion |
| 337 | |
| 338 | # Dot-source guard |
| 339 | if ($MyInvocation.InvocationName -ne '.') { |
| 340 | try { |
| 341 | $exitCode = Invoke-WorkflowPermissionsCheck @PSBoundParameters |
| 342 | exit $exitCode |
| 343 | } |
| 344 | catch { |
| 345 | Write-SecurityLog "Fatal error: $_" -Level Error -CIAnnotation |
| 346 | Write-SecurityLog $_.ScriptStackTrace -Level Error |
| 347 | exit 1 |
| 348 | } |
| 349 | } |
| 350 | |