microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v3.2.1

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

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
50using module ./Modules/SecurityClasses.psm1
51
52[CmdletBinding()]
53param(
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
73Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
74Import-Module (Join-Path $PSScriptRoot 'Modules/SecurityHelpers.psm1') -Force
75
76# region Helper Functions
77
78function 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
111function 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
172function 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
339if ($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