microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1873-devcontainer

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-ActionVersionConsistency.ps1

489lines · modecode

1#!/usr/bin/env pwsh
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4#Requires -Version 7.0
5
6<#
7.SYNOPSIS
8 Validates GitHub Actions version comment consistency across workflows.
9
10.DESCRIPTION
11 Scans workflow files for SHA-pinned actions and validates:
12 - Same SHA has consistent version comments across all workflows
13 - SHA-pinned actions include version comments for traceability
14
15 Version comments follow the Renovate convention: action@sha # vX.Y.Z
16
17.PARAMETER Path
18 Path to scan for workflow files. Defaults to .github/workflows.
19
20.PARAMETER Format
21 Output format: Table, Json, Sarif. Defaults to Table.
22
23.PARAMETER OutputPath
24 Path to write output file when using Json or Sarif format.
25
26.PARAMETER FailOnMismatch
27 Exit with error code 1 if version mismatches are found.
28
29.PARAMETER FailOnMissingComment
30 Exit with error code 1 if missing version comments are found.
31
32.EXAMPLE
33 ./Test-ActionVersionConsistency.ps1
34 Scan workflows and display results in table format.
35
36.EXAMPLE
37 ./Test-ActionVersionConsistency.ps1 -Format Sarif -OutputPath results.sarif
38 Export results in SARIF format for CI integration.
39
40.EXAMPLE
41 ./Test-ActionVersionConsistency.ps1 -FailOnMismatch -FailOnMissingComment
42 Fail the script if any consistency issues are found.
43
44.NOTES
45 Requires:
46 - PowerShell 7.0 or later for cross-platform compatibility
47
48.LINK
49 https://docs.renovatebot.com/modules/manager/github-actions/
50#>
51
52using module ./Modules/SecurityClasses.psm1
53
54[CmdletBinding()]
55param(
56 [Parameter(Mandatory = $false)]
57 [string]$Path = '.github/workflows',
58
59 [Parameter(Mandatory = $false)]
60 [ValidateSet('Table', 'Json', 'Sarif')]
61 [string]$Format = 'Table',
62
63 [Parameter(Mandatory = $false)]
64 [string]$OutputPath,
65
66 [Parameter(Mandatory = $false)]
67 [switch]$FailOnMismatch,
68
69 [Parameter(Mandatory = $false)]
70 [switch]$FailOnMissingComment
71)
72
73$ErrorActionPreference = 'Stop'
74
75# Import CIHelpers for workflow command escaping
76Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
77
78function Write-ConsistencyLog {
79 param(
80 [Parameter(Mandatory = $true)]
81 [string]$Message,
82
83 [Parameter(Mandatory = $false)]
84 [ValidateSet('Info', 'Warning', 'Error', 'Success')]
85 [string]$Level = 'Info'
86 )
87
88 $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
89 $color = switch ($Level) {
90 'Info' { 'Cyan' }
91 'Warning' { 'Yellow' }
92 'Error' { 'Red' }
93 'Success' { 'Green' }
94 }
95
96 Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color
97
98 # Surface warnings and errors as CI annotations so they appear in the Actions/ADO UI
99 if ($Level -eq 'Warning') {
100 Write-CIAnnotation -Message $Message -Level Warning
101 }
102 elseif ($Level -eq 'Error') {
103 Write-CIAnnotation -Message $Message -Level Error
104 }
105}
106
107function Get-ActionVersionViolations {
108 <#
109 .SYNOPSIS
110 Scans workflow files for version consistency violations.
111 #>
112 param(
113 [Parameter(Mandatory)]
114 [string]$WorkflowPath
115 )
116
117 # Enhanced regex to capture action, SHA, and optional version comment
118 $actionPattern = 'uses:\s*(?<action>[^@\s]+)@(?<ref>[a-fA-F0-9]{40})(?:\s*#\s*(?<version>.+))?'
119
120 $shaVersionMap = @{}
121 $violations = [System.Collections.ArrayList]::new()
122 $totalActions = 0
123
124 # Resolve to absolute path
125 $resolvedPath = Resolve-Path -Path $WorkflowPath -ErrorAction SilentlyContinue
126 if (-not $resolvedPath) {
127 Write-ConsistencyLog "Workflow path not found: $WorkflowPath" -Level Warning
128 return @{
129 Violations = @()
130 ShaVersionMap = @{}
131 TotalActions = 0
132 }
133 }
134
135 $workflowFiles = @(Get-ChildItem -Path $resolvedPath -Filter '*.yml' -Recurse -ErrorAction SilentlyContinue)
136 $workflowFiles += @(Get-ChildItem -Path $resolvedPath -Filter '*.yaml' -Recurse -ErrorAction SilentlyContinue)
137
138 foreach ($file in $workflowFiles) {
139 $lines = Get-Content -Path $file.FullName
140 $lineNumber = 0
141
142 foreach ($line in $lines) {
143 $lineNumber++
144
145 if ($line -match $actionPattern) {
146 $totalActions++
147 $action = $Matches['action']
148 $sha = $Matches['ref']
149 $version = if ($Matches['version']) { $Matches['version'].Trim() } else { $null }
150 # Normalize gh-aw provenance suffix (e.g. "v9.0.0 (source v9)") so generated
151 # lock files and generated workflows are treated as the same version comment.
152 $normalizedVersion = if ($version) { ($version -replace '\s*\(source[^)]*\)\s*$', '').Trim() } else { $null }
153 $relativePath = [System.IO.Path]::GetRelativePath((Get-Location).Path, $file.FullName)
154
155 # Initialize SHA entry if not present
156 if (-not $shaVersionMap.ContainsKey($sha)) {
157 $shaVersionMap[$sha] = @{
158 Action = $action
159 Versions = [System.Collections.ArrayList]::new()
160 Sources = [System.Collections.ArrayList]::new()
161 }
162 }
163
164 # Track version and source
165 if ($normalizedVersion -and $normalizedVersion -notin $shaVersionMap[$sha].Versions) {
166 [void]$shaVersionMap[$sha].Versions.Add($normalizedVersion)
167 }
168 [void]$shaVersionMap[$sha].Sources.Add(@{
169 File = $relativePath
170 FullPath = $file.FullName
171 Line = $lineNumber
172 Version = $version
173 LineContent = $line.Trim()
174 })
175
176 # Detect missing version comment
177 if (-not $version) {
178 $violation = [DependencyViolation]::new()
179 $violation.File = $relativePath
180 $violation.Line = $lineNumber
181 $violation.Type = 'github-actions'
182 $violation.Name = $action
183 $violation.Version = $sha.Substring(0, 7)
184 $violation.Severity = 'Medium'
185 $violation.ViolationType = 'MissingVersionComment'
186 $violation.Description = 'SHA-pinned action missing version comment'
187 $violation.Remediation = "Add version comment: $action@$sha # vX.Y.Z"
188 $violation.Metadata = @{
189 FullSha = $sha
190 LineContent = $line.Trim()
191 }
192 [void]$violations.Add($violation)
193 }
194 }
195 }
196 }
197
198 # Detect version mismatches (same SHA, different version comments)
199 foreach ($sha in $shaVersionMap.Keys) {
200 $entry = $shaVersionMap[$sha]
201
202 if ($entry.Versions.Count -gt 1) {
203 # Report one violation per SHA with all affected locations in Metadata
204 $primarySource = $entry.Sources[0]
205 $allLocations = $entry.Sources | ForEach-Object { "$($_.File):$($_.Line)" }
206
207 $violation = [DependencyViolation]::new()
208 $violation.File = $primarySource.File
209 $violation.Line = $primarySource.Line
210 $violation.Type = 'github-actions'
211 $violation.Name = $entry.Action
212 $violation.Version = $sha.Substring(0, 7)
213 $violation.Severity = 'High'
214 $violation.ViolationType = 'VersionMismatch'
215 $violation.Description = "Same SHA has conflicting version comments across $($entry.Sources.Count) files: $($entry.Versions -join ' vs ')"
216 $violation.Remediation = 'Standardize version comment across all workflows'
217 $violation.Metadata = @{
218 FullSha = $sha
219 ConflictingVersions = $entry.Versions -join ', '
220 AffectedLocations = $allLocations
221 LineContent = $primarySource.LineContent
222 }
223 [void]$violations.Add($violation)
224 }
225 }
226
227 return @{
228 Violations = $violations
229 ShaVersionMap = $shaVersionMap
230 TotalActions = $totalActions
231 }
232}
233
234function Export-ConsistencyReport {
235 <#
236 .SYNOPSIS
237 Exports consistency report in the specified format.
238 #>
239 param(
240 [Parameter(Mandatory)]
241 [AllowEmptyCollection()]
242 [object[]]$Violations,
243
244 [Parameter(Mandatory)]
245 [string]$Format,
246
247 [Parameter()]
248 [string]$OutputPath,
249
250 [Parameter()]
251 [int]$TotalActions
252 )
253
254 $reportData = @{
255 Timestamp = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
256 TotalActions = $TotalActions
257 MismatchCount = @($Violations | Where-Object { $_.ViolationType -eq 'VersionMismatch' }).Count
258 MissingComments = @($Violations | Where-Object { $_.ViolationType -eq 'MissingVersionComment' }).Count
259 Violations = $Violations
260 }
261
262 switch ($Format) {
263 'Table' {
264 if ($Violations.Count -eq 0) {
265 Write-ConsistencyLog 'No version consistency violations found.' -Level Success
266 }
267 else {
268 $Violations | Format-Table -Property @(
269 @{ Label = 'File'; Expression = { $_.File } }
270 @{ Label = 'Line'; Expression = { $_.Line } }
271 @{ Label = 'Type'; Expression = { $_.ViolationType } }
272 @{ Label = 'Action'; Expression = { $_.Name } }
273 @{ Label = 'Severity'; Expression = { $_.Severity } }
274 @{ Label = 'Description'; Expression = { $_.Description } }
275 ) -AutoSize -Wrap
276 }
277
278 if ($OutputPath) {
279 $Violations | Format-Table -Property File, Line, ViolationType, Name, Severity, Description -AutoSize |
280 Out-File -FilePath $OutputPath -Encoding UTF8 -Width 200
281 }
282 }
283
284 'Json' {
285 $json = $reportData | ConvertTo-Json -Depth 10
286
287 if ($OutputPath) {
288 $json | Out-File -FilePath $OutputPath -Encoding UTF8
289 Write-ConsistencyLog "Report exported to: $OutputPath" -Level Success
290 }
291 else {
292 Write-Output $json
293 }
294 }
295
296 'Sarif' {
297 $sarif = @{
298 version = '2.1.0'
299 '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json'
300 runs = @(@{
301 tool = @{
302 driver = @{
303 name = 'action-version-consistency'
304 version = '1.0.0'
305 informationUri = 'https://github.com/microsoft/hve-core'
306 rules = @(
307 @{
308 id = 'version-mismatch'
309 name = 'VersionMismatch'
310 shortDescription = @{ text = 'Same SHA has conflicting version comments' }
311 defaultConfiguration = @{ level = 'error' }
312 }
313 @{
314 id = 'missing-version-comment'
315 name = 'MissingVersionComment'
316 shortDescription = @{ text = 'SHA-pinned action missing version comment' }
317 defaultConfiguration = @{ level = 'warning' }
318 }
319 )
320 }
321 }
322 results = @($Violations | ForEach-Object {
323 $ruleId = switch ($_.ViolationType) {
324 'VersionMismatch' { 'version-mismatch' }
325 'MissingVersionComment' { 'missing-version-comment' }
326 default { 'unknown' }
327 }
328 $level = switch ($_.Severity) {
329 'High' { 'error' }
330 'Medium' { 'warning' }
331 default { 'note' }
332 }
333 @{
334 ruleId = $ruleId
335 level = $level
336 message = @{ text = $_.Description }
337 locations = @(@{
338 physicalLocation = @{
339 artifactLocation = @{ uri = $_.File }
340 region = @{ startLine = $_.Line }
341 }
342 })
343 properties = @{
344 actionName = $_.Name
345 sha = $_.Version
346 remediation = $_.Remediation
347 }
348 }
349 })
350 })
351 }
352
353 $json = $sarif | ConvertTo-Json -Depth 15
354
355 if ($OutputPath) {
356 $json | Out-File -FilePath $OutputPath -Encoding UTF8
357 Write-ConsistencyLog "SARIF report exported to: $OutputPath" -Level Success
358 }
359 else {
360 Write-Output $json
361 }
362 }
363 }
364}
365
366function Invoke-ActionVersionConsistency {
367 <#
368 .SYNOPSIS
369 Orchestrates the version consistency analysis.
370 #>
371 [OutputType([int])]
372 [CmdletBinding()]
373 param(
374 [Parameter(Mandatory = $false)]
375 [string]$Path = '.github/workflows',
376
377 [Parameter(Mandatory = $false)]
378 [ValidateSet('Table', 'Json', 'Sarif')]
379 [string]$Format = 'Table',
380
381 [Parameter(Mandatory = $false)]
382 [string]$OutputPath,
383
384 [Parameter(Mandatory = $false)]
385 [switch]$FailOnMismatch,
386
387 [Parameter(Mandatory = $false)]
388 [switch]$FailOnMissingComment
389 )
390
391 Write-ConsistencyLog 'Starting GitHub Actions version consistency analysis...' -Level Info
392 Write-ConsistencyLog "Scanning path: $Path" -Level Info
393
394 # Scan for violations
395 $result = Get-ActionVersionViolations -WorkflowPath $Path
396
397 $violations = $result.Violations
398 $mismatchCount = @($violations | Where-Object { $_.ViolationType -eq 'VersionMismatch' }).Count
399 $missingCount = @($violations | Where-Object { $_.ViolationType -eq 'MissingVersionComment' }).Count
400
401 Write-ConsistencyLog "Scanned $($result.TotalActions) SHA-pinned actions" -Level Info
402 Write-ConsistencyLog "Found $mismatchCount version mismatches" -Level $(if ($mismatchCount -gt 0) { 'Warning' } else { 'Info' })
403 Write-ConsistencyLog "Found $missingCount missing version comments" -Level $(if ($missingCount -gt 0) { 'Warning' } else { 'Info' })
404
405 # Emit CI annotations per violation
406 foreach ($violation in $violations) {
407 $annotationLevel = switch ($violation.Severity) {
408 'High' { 'Error' }
409 'Medium' { 'Warning' }
410 default { 'Notice' }
411 }
412 Write-CIAnnotation `
413 -Message "$($violation.ViolationType): $($violation.Description)" `
414 -Level $annotationLevel `
415 -File $violation.File `
416 -Line $violation.Line
417 }
418
419 # Export report (pipe to Out-Host to prevent pipeline pollution of return value)
420 Export-ConsistencyReport -Violations $violations -Format $Format -OutputPath $OutputPath -TotalActions $result.TotalActions | Out-Host
421
422 # Emit CI step summary
423 if ($violations.Count -eq 0) {
424 Write-CIStepSummary -Content @"
425## Action Version Consistency
426
427:white_check_mark: **Status**: Passed
428
429All $($result.TotalActions) SHA-pinned actions have consistent version comments.
430"@
431 }
432 else {
433 $summaryLines = [System.Collections.ArrayList]::new()
434 [void]$summaryLines.Add(@"
435## Action Version Consistency
436
437:x: **Status**: Failed
438
439| Metric | Count |
440|--------|-------|
441| SHA-Pinned Actions | $($result.TotalActions) |
442| Version Mismatches | $mismatchCount |
443| Missing Comments | $missingCount |
444
445### Violations
446
447| File | Line | Type | Action | Severity | Description |
448|------|------|------|--------|----------|-------------|
449"@)
450 foreach ($v in $violations) {
451 [void]$summaryLines.Add("| ``$($v.File)`` | $($v.Line) | $($v.ViolationType) | ``$($v.Name)`` | $($v.Severity) | $($v.Description) |")
452 }
453
454 Write-CIStepSummary -Content ($summaryLines -join "`n")
455 }
456
457 # Determine exit code
458 $exitCode = 0
459
460 if ($FailOnMismatch -and $mismatchCount -gt 0) {
461 Write-ConsistencyLog "Failing due to $mismatchCount version mismatch(es) (-FailOnMismatch enabled)" -Level Error
462 $exitCode = 1
463 }
464
465 if ($FailOnMissingComment -and $missingCount -gt 0) {
466 Write-ConsistencyLog "Failing due to $missingCount missing version comment(s) (-FailOnMissingComment enabled)" -Level Error
467 $exitCode = 1
468 }
469
470 if ($exitCode -eq 0 -and $violations.Count -eq 0) {
471 Write-ConsistencyLog 'All SHA-pinned actions have consistent version comments!' -Level Success
472 }
473
474 return $exitCode
475}
476
477#region Main Execution
478if ($MyInvocation.InvocationName -ne '.') {
479 try {
480 $exitCode = Invoke-ActionVersionConsistency @PSBoundParameters
481 exit $exitCode
482 }
483 catch {
484 Write-Error -ErrorAction Continue "Test-ActionVersionConsistency failed: $($_.Exception.Message)"
485 Write-CIAnnotation -Message $_.Exception.Message -Level Error
486 exit 1
487 }
488}
489#endregion Main Execution
490