microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/security/Test-ActionVersionConsistency.ps1
486lines · 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 | |
| 52 | using module ./Modules/SecurityClasses.psm1 |
| 53 | |
| 54 | [CmdletBinding()] |
| 55 | param( |
| 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 |
| 76 | Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force |
| 77 | |
| 78 | function 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 | |
| 107 | function 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 | $relativePath = [System.IO.Path]::GetRelativePath((Get-Location).Path, $file.FullName) |
| 151 | |
| 152 | # Initialize SHA entry if not present |
| 153 | if (-not $shaVersionMap.ContainsKey($sha)) { |
| 154 | $shaVersionMap[$sha] = @{ |
| 155 | Action = $action |
| 156 | Versions = [System.Collections.ArrayList]::new() |
| 157 | Sources = [System.Collections.ArrayList]::new() |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | # Track version and source |
| 162 | if ($version -and $version -notin $shaVersionMap[$sha].Versions) { |
| 163 | [void]$shaVersionMap[$sha].Versions.Add($version) |
| 164 | } |
| 165 | [void]$shaVersionMap[$sha].Sources.Add(@{ |
| 166 | File = $relativePath |
| 167 | FullPath = $file.FullName |
| 168 | Line = $lineNumber |
| 169 | Version = $version |
| 170 | LineContent = $line.Trim() |
| 171 | }) |
| 172 | |
| 173 | # Detect missing version comment |
| 174 | if (-not $version) { |
| 175 | $violation = [DependencyViolation]::new() |
| 176 | $violation.File = $relativePath |
| 177 | $violation.Line = $lineNumber |
| 178 | $violation.Type = 'github-actions' |
| 179 | $violation.Name = $action |
| 180 | $violation.Version = $sha.Substring(0, 7) |
| 181 | $violation.Severity = 'Medium' |
| 182 | $violation.ViolationType = 'MissingVersionComment' |
| 183 | $violation.Description = 'SHA-pinned action missing version comment' |
| 184 | $violation.Remediation = "Add version comment: $action@$sha # vX.Y.Z" |
| 185 | $violation.Metadata = @{ |
| 186 | FullSha = $sha |
| 187 | LineContent = $line.Trim() |
| 188 | } |
| 189 | [void]$violations.Add($violation) |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | # Detect version mismatches (same SHA, different version comments) |
| 196 | foreach ($sha in $shaVersionMap.Keys) { |
| 197 | $entry = $shaVersionMap[$sha] |
| 198 | |
| 199 | if ($entry.Versions.Count -gt 1) { |
| 200 | # Report one violation per SHA with all affected locations in Metadata |
| 201 | $primarySource = $entry.Sources[0] |
| 202 | $allLocations = $entry.Sources | ForEach-Object { "$($_.File):$($_.Line)" } |
| 203 | |
| 204 | $violation = [DependencyViolation]::new() |
| 205 | $violation.File = $primarySource.File |
| 206 | $violation.Line = $primarySource.Line |
| 207 | $violation.Type = 'github-actions' |
| 208 | $violation.Name = $entry.Action |
| 209 | $violation.Version = $sha.Substring(0, 7) |
| 210 | $violation.Severity = 'High' |
| 211 | $violation.ViolationType = 'VersionMismatch' |
| 212 | $violation.Description = "Same SHA has conflicting version comments across $($entry.Sources.Count) files: $($entry.Versions -join ' vs ')" |
| 213 | $violation.Remediation = 'Standardize version comment across all workflows' |
| 214 | $violation.Metadata = @{ |
| 215 | FullSha = $sha |
| 216 | ConflictingVersions = $entry.Versions -join ', ' |
| 217 | AffectedLocations = $allLocations |
| 218 | LineContent = $primarySource.LineContent |
| 219 | } |
| 220 | [void]$violations.Add($violation) |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | return @{ |
| 225 | Violations = $violations |
| 226 | ShaVersionMap = $shaVersionMap |
| 227 | TotalActions = $totalActions |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | function Export-ConsistencyReport { |
| 232 | <# |
| 233 | .SYNOPSIS |
| 234 | Exports consistency report in the specified format. |
| 235 | #> |
| 236 | param( |
| 237 | [Parameter(Mandatory)] |
| 238 | [AllowEmptyCollection()] |
| 239 | [object[]]$Violations, |
| 240 | |
| 241 | [Parameter(Mandatory)] |
| 242 | [string]$Format, |
| 243 | |
| 244 | [Parameter()] |
| 245 | [string]$OutputPath, |
| 246 | |
| 247 | [Parameter()] |
| 248 | [int]$TotalActions |
| 249 | ) |
| 250 | |
| 251 | $reportData = @{ |
| 252 | Timestamp = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') |
| 253 | TotalActions = $TotalActions |
| 254 | MismatchCount = @($Violations | Where-Object { $_.ViolationType -eq 'VersionMismatch' }).Count |
| 255 | MissingComments = @($Violations | Where-Object { $_.ViolationType -eq 'MissingVersionComment' }).Count |
| 256 | Violations = $Violations |
| 257 | } |
| 258 | |
| 259 | switch ($Format) { |
| 260 | 'Table' { |
| 261 | if ($Violations.Count -eq 0) { |
| 262 | Write-ConsistencyLog 'No version consistency violations found.' -Level Success |
| 263 | } |
| 264 | else { |
| 265 | $Violations | Format-Table -Property @( |
| 266 | @{ Label = 'File'; Expression = { $_.File } } |
| 267 | @{ Label = 'Line'; Expression = { $_.Line } } |
| 268 | @{ Label = 'Type'; Expression = { $_.ViolationType } } |
| 269 | @{ Label = 'Action'; Expression = { $_.Name } } |
| 270 | @{ Label = 'Severity'; Expression = { $_.Severity } } |
| 271 | @{ Label = 'Description'; Expression = { $_.Description } } |
| 272 | ) -AutoSize -Wrap |
| 273 | } |
| 274 | |
| 275 | if ($OutputPath) { |
| 276 | $Violations | Format-Table -Property File, Line, ViolationType, Name, Severity, Description -AutoSize | |
| 277 | Out-File -FilePath $OutputPath -Encoding UTF8 -Width 200 |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | 'Json' { |
| 282 | $json = $reportData | ConvertTo-Json -Depth 10 |
| 283 | |
| 284 | if ($OutputPath) { |
| 285 | $json | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 286 | Write-ConsistencyLog "Report exported to: $OutputPath" -Level Success |
| 287 | } |
| 288 | else { |
| 289 | Write-Output $json |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | 'Sarif' { |
| 294 | $sarif = @{ |
| 295 | version = '2.1.0' |
| 296 | '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' |
| 297 | runs = @(@{ |
| 298 | tool = @{ |
| 299 | driver = @{ |
| 300 | name = 'action-version-consistency' |
| 301 | version = '1.0.0' |
| 302 | informationUri = 'https://github.com/microsoft/hve-core' |
| 303 | rules = @( |
| 304 | @{ |
| 305 | id = 'version-mismatch' |
| 306 | name = 'VersionMismatch' |
| 307 | shortDescription = @{ text = 'Same SHA has conflicting version comments' } |
| 308 | defaultConfiguration = @{ level = 'error' } |
| 309 | } |
| 310 | @{ |
| 311 | id = 'missing-version-comment' |
| 312 | name = 'MissingVersionComment' |
| 313 | shortDescription = @{ text = 'SHA-pinned action missing version comment' } |
| 314 | defaultConfiguration = @{ level = 'warning' } |
| 315 | } |
| 316 | ) |
| 317 | } |
| 318 | } |
| 319 | results = @($Violations | ForEach-Object { |
| 320 | $ruleId = switch ($_.ViolationType) { |
| 321 | 'VersionMismatch' { 'version-mismatch' } |
| 322 | 'MissingVersionComment' { 'missing-version-comment' } |
| 323 | default { 'unknown' } |
| 324 | } |
| 325 | $level = switch ($_.Severity) { |
| 326 | 'High' { 'error' } |
| 327 | 'Medium' { 'warning' } |
| 328 | default { 'note' } |
| 329 | } |
| 330 | @{ |
| 331 | ruleId = $ruleId |
| 332 | level = $level |
| 333 | message = @{ text = $_.Description } |
| 334 | locations = @(@{ |
| 335 | physicalLocation = @{ |
| 336 | artifactLocation = @{ uri = $_.File } |
| 337 | region = @{ startLine = $_.Line } |
| 338 | } |
| 339 | }) |
| 340 | properties = @{ |
| 341 | actionName = $_.Name |
| 342 | sha = $_.Version |
| 343 | remediation = $_.Remediation |
| 344 | } |
| 345 | } |
| 346 | }) |
| 347 | }) |
| 348 | } |
| 349 | |
| 350 | $json = $sarif | ConvertTo-Json -Depth 15 |
| 351 | |
| 352 | if ($OutputPath) { |
| 353 | $json | Out-File -FilePath $OutputPath -Encoding UTF8 |
| 354 | Write-ConsistencyLog "SARIF report exported to: $OutputPath" -Level Success |
| 355 | } |
| 356 | else { |
| 357 | Write-Output $json |
| 358 | } |
| 359 | } |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | function Invoke-ActionVersionConsistency { |
| 364 | <# |
| 365 | .SYNOPSIS |
| 366 | Orchestrates the version consistency analysis. |
| 367 | #> |
| 368 | [OutputType([int])] |
| 369 | [CmdletBinding()] |
| 370 | param( |
| 371 | [Parameter(Mandatory = $false)] |
| 372 | [string]$Path = '.github/workflows', |
| 373 | |
| 374 | [Parameter(Mandatory = $false)] |
| 375 | [ValidateSet('Table', 'Json', 'Sarif')] |
| 376 | [string]$Format = 'Table', |
| 377 | |
| 378 | [Parameter(Mandatory = $false)] |
| 379 | [string]$OutputPath, |
| 380 | |
| 381 | [Parameter(Mandatory = $false)] |
| 382 | [switch]$FailOnMismatch, |
| 383 | |
| 384 | [Parameter(Mandatory = $false)] |
| 385 | [switch]$FailOnMissingComment |
| 386 | ) |
| 387 | |
| 388 | Write-ConsistencyLog 'Starting GitHub Actions version consistency analysis...' -Level Info |
| 389 | Write-ConsistencyLog "Scanning path: $Path" -Level Info |
| 390 | |
| 391 | # Scan for violations |
| 392 | $result = Get-ActionVersionViolations -WorkflowPath $Path |
| 393 | |
| 394 | $violations = $result.Violations |
| 395 | $mismatchCount = @($violations | Where-Object { $_.ViolationType -eq 'VersionMismatch' }).Count |
| 396 | $missingCount = @($violations | Where-Object { $_.ViolationType -eq 'MissingVersionComment' }).Count |
| 397 | |
| 398 | Write-ConsistencyLog "Scanned $($result.TotalActions) SHA-pinned actions" -Level Info |
| 399 | Write-ConsistencyLog "Found $mismatchCount version mismatches" -Level $(if ($mismatchCount -gt 0) { 'Warning' } else { 'Info' }) |
| 400 | Write-ConsistencyLog "Found $missingCount missing version comments" -Level $(if ($missingCount -gt 0) { 'Warning' } else { 'Info' }) |
| 401 | |
| 402 | # Emit CI annotations per violation |
| 403 | foreach ($violation in $violations) { |
| 404 | $annotationLevel = switch ($violation.Severity) { |
| 405 | 'High' { 'Error' } |
| 406 | 'Medium' { 'Warning' } |
| 407 | default { 'Notice' } |
| 408 | } |
| 409 | Write-CIAnnotation ` |
| 410 | -Message "$($violation.ViolationType): $($violation.Description)" ` |
| 411 | -Level $annotationLevel ` |
| 412 | -File $violation.File ` |
| 413 | -Line $violation.Line |
| 414 | } |
| 415 | |
| 416 | # Export report (pipe to Out-Host to prevent pipeline pollution of return value) |
| 417 | Export-ConsistencyReport -Violations $violations -Format $Format -OutputPath $OutputPath -TotalActions $result.TotalActions | Out-Host |
| 418 | |
| 419 | # Emit CI step summary |
| 420 | if ($violations.Count -eq 0) { |
| 421 | Write-CIStepSummary -Content @" |
| 422 | ## Action Version Consistency |
| 423 | |
| 424 | :white_check_mark: **Status**: Passed |
| 425 | |
| 426 | All $($result.TotalActions) SHA-pinned actions have consistent version comments. |
| 427 | "@ |
| 428 | } |
| 429 | else { |
| 430 | $summaryLines = [System.Collections.ArrayList]::new() |
| 431 | [void]$summaryLines.Add(@" |
| 432 | ## Action Version Consistency |
| 433 | |
| 434 | :x: **Status**: Failed |
| 435 | |
| 436 | | Metric | Count | |
| 437 | |--------|-------| |
| 438 | | SHA-Pinned Actions | $($result.TotalActions) | |
| 439 | | Version Mismatches | $mismatchCount | |
| 440 | | Missing Comments | $missingCount | |
| 441 | |
| 442 | ### Violations |
| 443 | |
| 444 | | File | Line | Type | Action | Severity | Description | |
| 445 | |------|------|------|--------|----------|-------------| |
| 446 | "@) |
| 447 | foreach ($v in $violations) { |
| 448 | [void]$summaryLines.Add("| ``$($v.File)`` | $($v.Line) | $($v.ViolationType) | ``$($v.Name)`` | $($v.Severity) | $($v.Description) |") |
| 449 | } |
| 450 | |
| 451 | Write-CIStepSummary -Content ($summaryLines -join "`n") |
| 452 | } |
| 453 | |
| 454 | # Determine exit code |
| 455 | $exitCode = 0 |
| 456 | |
| 457 | if ($FailOnMismatch -and $mismatchCount -gt 0) { |
| 458 | Write-ConsistencyLog "Failing due to $mismatchCount version mismatch(es) (-FailOnMismatch enabled)" -Level Error |
| 459 | $exitCode = 1 |
| 460 | } |
| 461 | |
| 462 | if ($FailOnMissingComment -and $missingCount -gt 0) { |
| 463 | Write-ConsistencyLog "Failing due to $missingCount missing version comment(s) (-FailOnMissingComment enabled)" -Level Error |
| 464 | $exitCode = 1 |
| 465 | } |
| 466 | |
| 467 | if ($exitCode -eq 0 -and $violations.Count -eq 0) { |
| 468 | Write-ConsistencyLog 'All SHA-pinned actions have consistent version comments!' -Level Success |
| 469 | } |
| 470 | |
| 471 | return $exitCode |
| 472 | } |
| 473 | |
| 474 | #region Main Execution |
| 475 | if ($MyInvocation.InvocationName -ne '.') { |
| 476 | try { |
| 477 | $exitCode = Invoke-ActionVersionConsistency @PSBoundParameters |
| 478 | exit $exitCode |
| 479 | } |
| 480 | catch { |
| 481 | Write-Error -ErrorAction Continue "Test-ActionVersionConsistency failed: $($_.Exception.Message)" |
| 482 | Write-CIAnnotation -Message $_.Exception.Message -Level Error |
| 483 | exit 1 |
| 484 | } |
| 485 | } |
| 486 | #endregion Main Execution |