microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/987-enable-json-log-lint-version-consistency

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

702lines · 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 Updates GitHub Actions workflows to use SHA-pinned action references for supply chain security.
9
10.DESCRIPTION
11 This script scans GitHub Actions workflows and replaces mutable tag references with immutable SHA commits.
12 This prevents supply chain attacks through compromised action repositories by ensuring reproducible builds.
13
14 With -UpdateStale, the script will fetch the latest commit SHAs from GitHub and update already-pinned actions.
15
16.PARAMETER WorkflowPath
17 Path to the .github/workflows directory. Defaults to current repository structure.
18
19.PARAMETER OutputReport
20 Generate detailed report of changes and pinning status.
21
22.EXAMPLE
23 ./Update-ActionSHAPinning.ps1 -OutputReport -WhatIf
24 Preview SHA pinning changes and generate report without modifying files.
25
26.EXAMPLE
27 ./Update-ActionSHAPinning.ps1
28 Apply SHA pinning to all workflows and update files.
29
30.EXAMPLE
31 ./Update-ActionSHAPinning.ps1 -UpdateStale
32 Update already-pinned-but-stale GitHub Actions to their latest commit SHAs.
33#>
34
35[CmdletBinding(SupportsShouldProcess)]
36param(
37 [Parameter()]
38 [string]$WorkflowPath = ".github/workflows",
39
40 [Parameter()]
41 [switch]$OutputReport,
42
43 [Parameter()]
44 [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
45 [string]$OutputFormat = "console",
46
47 [Parameter()]
48 [switch]$UpdateStale
49)
50
51$ErrorActionPreference = 'Stop'
52
53# Import shared modules
54Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
55Import-Module (Join-Path $PSScriptRoot 'Modules/SecurityHelpers.psm1') -Force
56
57# Explicit parameter usage to satisfy static analyzer
58Write-Debug "Parameters: WorkflowPath=$WorkflowPath, OutputReport=$OutputReport, OutputFormat=$OutputFormat, UpdateStale=$UpdateStale"
59
60# GitHub Actions SHA references matching current workflow usage
61$ActionSHAMap = @{
62 # Core setup and checkout
63 "actions/checkout@v4" = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v4.2.2
64 "actions/setup-node@v6" = "actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f" # v6.3.0
65 "actions/setup-python@v6" = "actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405" # v6.2.0
66
67 # Artifact management
68 "actions/upload-artifact@v4" = "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v4.4.3
69 "actions/download-artifact@v8" = "actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3" # v8.0.0
70
71 # GitHub Pages
72 "actions/configure-pages@v5" = "actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b" # v5.0.0
73 "actions/upload-pages-artifact@v4" = "actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b" # v4.0.0
74 "actions/deploy-pages@v4" = "actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e" # v4.0.5
75
76 # Attestation and provenance
77 "actions/attest@v4" = "actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26" # v4.1.0
78 "actions/attest-build-provenance@v4" = "actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" # v4.1.0
79
80 # Security and code analysis
81 "actions/dependency-review-action@v4" = "actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803" # v4.3.4
82 "github/codeql-action/init@v3" = "github/codeql-action/init@ce729e4d353d580e6cacd6a8cf2921b72e5e310a" # v3.27.0
83 "github/codeql-action/autobuild@v3" = "github/codeql-action/autobuild@ce729e4d353d580e6cacd6a8cf2921b72e5e310a" # v3.27.0
84 "github/codeql-action/analyze@v3" = "github/codeql-action/analyze@ce729e4d353d580e6cacd6a8cf2921b72e5e310a" # v3.27.0
85 "github/codeql-action/upload-sarif@v3" = "github/codeql-action/upload-sarif@ce729e4d353d580e6cacd6a8cf2921b72e5e310a" # v3.27.0
86 "ossf/scorecard-action@v2" = "ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a" # v2.4.3
87
88 # Azure
89 "azure/login@v2" = "azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5" # v2.3.0
90
91 # Third-party
92 "actions/create-github-app-token@v2" = "actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf" # v2.0.0
93 "codecov/codecov-action@v5" = "codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de" # v5.5.2
94 "googleapis/release-please-action@v4" = "googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38" # v4.4.0
95 "anchore/sbom-action@v0" = "anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11" # v0.23.0
96}
97
98# Initialize security issues collection
99$SecurityIssues = [System.Collections.Generic.List[PSCustomObject]]::new()
100
101function Write-SecurityOutput {
102 <#
103 .SYNOPSIS
104 Formats and emits security scan results in the requested CI or local format.
105 #>
106 [CmdletBinding()]
107 param(
108 [Parameter(Mandatory)]
109 [ValidateSet('json', 'azdo', 'github', 'console', 'BuildWarning', 'Summary')]
110 [string]$OutputFormat,
111
112 [Parameter()]
113 [array]$Results = @(),
114
115 [Parameter()]
116 [string]$Summary = '',
117
118 [Parameter()]
119 [string]$OutputPath
120 )
121
122 switch ($OutputFormat) {
123 'json' {
124 Write-SecurityReport -Results $Results -Summary $Summary -OutputFormat json -OutputPath $OutputPath
125 return
126 }
127 'console' {
128 Write-SecurityReport -Results $Results -Summary $Summary -OutputFormat console
129 return
130 }
131 'BuildWarning' {
132 if (@($Results).Count -eq 0) {
133 Write-Output '##[section]No GitHub Actions security issues found'
134 return
135 }
136 Write-Output '##[section]GitHub Actions Security Issues Found:'
137 foreach ($issue in $Results) {
138 $message = "$($issue.Title) - $($issue.Description)"
139 if ($issue.File) { $message += " (File: $($issue.File))" }
140 if ($issue.Recommendation) { $message += " Recommendation: $($issue.Recommendation)" }
141 Write-Output "##[warning]$message"
142 }
143 return
144 }
145 'github' {
146 if (@($Results).Count -eq 0) {
147 Write-CIAnnotation -Message 'No GitHub Actions security issues found' -Level Notice
148 return
149 }
150 foreach ($issue in $Results) {
151 $message = "[$($issue.Severity)] $($issue.Title) - $($issue.Description)"
152 $file = if ($issue.File) { $issue.File -replace '\\', '/' } else { $null }
153 Write-CIAnnotation -Message $message -Level Warning -File $file
154 }
155 return
156 }
157 'azdo' {
158 if (@($Results).Count -eq 0) {
159 Write-CIAnnotation -Message 'No GitHub Actions security issues found' -Level Notice
160 return
161 }
162 foreach ($issue in $Results) {
163 $message = "[$($issue.Severity)] $($issue.Title) - $($issue.Description)"
164 $file = if ($issue.File) { $issue.File } else { $null }
165 Write-CIAnnotation -Message $message -Level Warning -File $file
166 }
167 Set-CITaskResult -Result SucceededWithIssues
168 return
169 }
170 'Summary' {
171 if (@($Results).Count -eq 0) {
172 Write-SecurityLog -Message 'No security issues found' -Level Success
173 return
174 }
175 $Results | Group-Object -Property Type | ForEach-Object {
176 Write-Output "=== $($_.Name) ==="
177 foreach ($issue in $_.Group) {
178 Write-Output " [$($issue.Severity)] $($issue.Title): $($issue.Description)"
179 }
180 }
181 return
182 }
183 }
184}
185
186function Get-ActionReference {
187 param(
188 [Parameter(Mandatory)]
189 [string]$WorkflowContent
190 )
191
192 # Match GitHub Actions usage patterns with uses: keyword
193 $actionPattern = '(?m)^\s*uses:\s*([^\s@]+@[^\s]+)'
194 $actionMatches = [regex]::Matches($WorkflowContent, $actionPattern)
195
196 $actions = @()
197 foreach ($match in $actionMatches) {
198 $actionRef = $match.Groups[1].Value.Trim()
199 # Skip local actions (starting with ./)
200 if (-not $actionRef.StartsWith('./')) {
201 $actions += @{
202 OriginalRef = $actionRef
203 LineNumber = ($WorkflowContent.Substring(0, $match.Index).Split("`n").Count)
204 StartIndex = $match.Groups[1].Index
205 Length = $match.Groups[1].Length
206 }
207 }
208 }
209
210 return $actions
211}
212
213function Get-LatestCommitSHA {
214 param(
215 [Parameter(Mandatory)]
216 [string]$Owner,
217
218 [Parameter(Mandatory)]
219 [string]$Repo,
220
221 [Parameter()]
222 [string]$Branch
223 )
224
225 try {
226 $headers = @{
227 'Accept' = 'application/vnd.github+json'
228 'User-Agent' = 'hve-core-sha-pinning-updater'
229 }
230
231 # Check GitHub token and validate it
232 $githubToken = $env:GITHUB_TOKEN
233 if ($githubToken) {
234 $tokenStatus = Test-GitHubToken -Token $githubToken
235 if ($tokenStatus.Valid) {
236 $headers['Authorization'] = "Bearer $githubToken"
237 }
238 else {
239 Write-SecurityLog "Token validation failed, proceeding without authentication" -Level Warning
240 Write-SecurityLog "CAUSE: Invalid or expired GitHub token" -Level Warning
241 Write-SecurityLog "SOLUTION: Generate new token at https://github.com/settings/tokens" -Level Warning
242 }
243 }
244
245 $apiBase = Get-GitHubApiBase
246
247 # If no branch specified, detect the repository's default branch
248 if (-not $Branch) {
249 $repoApiUrl = "$apiBase/repos/$Owner/$Repo"
250 $repoInfo = Invoke-GitHubAPIWithRetry -Uri $repoApiUrl -Method GET -Headers $headers
251 if ($null -eq $repoInfo) { throw "GitHub API returned no response for $repoApiUrl" }
252 $Branch = $repoInfo.default_branch
253 Write-SecurityLog "Detected default branch for $Owner/$Repo : $Branch" -Level 'Info'
254 }
255
256 $apiUrl = "$apiBase/repos/$Owner/$Repo/commits/$Branch"
257 $response = Invoke-GitHubAPIWithRetry -Uri $apiUrl -Method GET -Headers $headers
258 if ($null -eq $response) { throw "GitHub API returned no response for $apiUrl" }
259 return $response.sha
260 }
261 catch {
262 $statusCode = $null
263 if ($_.Exception.PSObject.Properties.Name -contains 'Response') {
264 $response = $_.Exception.Response
265 if ($response -and $response.PSObject.Properties.Name -contains 'StatusCode') {
266 $statusCode = [int]$response.StatusCode
267 }
268 }
269
270 if ($statusCode -eq 404) {
271 Write-SecurityLog "Failed to fetch latest SHA for $Owner/$Repo : Repository or branch not found" -Level 'Warning'
272 Write-SecurityLog "CAUSE: Repository does not exist, is private, or branch name is incorrect" -Level 'Warning'
273 Write-SecurityLog "SOLUTION: Verify repository exists and branch name is correct" -Level 'Warning'
274 }
275 else {
276 Write-SecurityLog "Failed to fetch latest SHA for $Owner/$Repo : $($_.Exception.Message)" -Level 'Warning'
277 Write-SecurityLog "CAUSE: Network connectivity issue or GitHub API unavailable" -Level 'Warning'
278 }
279 return $null
280 }
281}
282
283function Get-SHAForAction {
284 param(
285 [Parameter(Mandatory)]
286 [string]$ActionRef
287 )
288
289 # Check if already SHA-pinned (40-character hex string)
290 if ($ActionRef -match '@[a-fA-F0-9]{40}$') {
291 # If UpdateStale is enabled, fetch the latest SHA and compare
292 if ($UpdateStale) {
293 # Extract owner/repo from action reference (supports subpaths)
294 if ($ActionRef -match '^([^@]+)@([a-fA-F0-9]{40})$') {
295 $actionPath = $matches[1]
296 $currentSHA = $matches[2]
297
298 # Handle actions with subpaths (e.g., github/codeql-action/init)
299 $parts = $actionPath -split '/'
300
301 # Validate action reference format
302 if ($parts.Count -lt 2) {
303 Write-SecurityLog "Invalid action reference format: $ActionRef - must be 'owner/repo' or 'owner/repo/path'" -Level 'Warning'
304 Write-SecurityLog "CAUSE: Malformed action path missing owner or repository name" -Level 'Warning'
305 Write-SecurityLog "SOLUTION: Verify action reference follows GitHub Actions format (e.g., actions/checkout@v4)" -Level 'Warning'
306 return $null
307 }
308
309 $owner = $parts[0]
310 $repo = $parts[1]
311
312 Write-SecurityLog "Checking for updates: $actionPath (current: $($currentSHA.Substring(0,8))...)" -Level 'Info'
313
314 # Fetch latest SHA from GitHub
315 $latestSHA = Get-LatestCommitSHA -Owner $owner -Repo $repo
316
317 if ($latestSHA -and $latestSHA -ne $currentSHA) {
318 Write-SecurityLog "Update available: $actionPath ($($currentSHA.Substring(0,8))... -> $($latestSHA.Substring(0,8))...)" -Level 'Success'
319 return "$actionPath@$latestSHA"
320 }
321 elseif ($latestSHA -eq $currentSHA) {
322 Write-SecurityLog "Already up-to-date: $actionPath" -Level 'Info'
323 }
324 elseif (-not $latestSHA) {
325 Write-SecurityLog "Failed to fetch latest SHA for $actionPath - keeping current SHA (likely rate limited)" -Level 'Warning'
326 }
327
328 return $ActionRef
329 }
330 }
331
332 Write-SecurityLog "Action already SHA-pinned: $ActionRef" -Level 'Info'
333 return $ActionRef
334 }
335
336 # Look up in pre-defined SHA map
337 if ($ActionSHAMap.ContainsKey($ActionRef)) {
338 $pinnedRef = $ActionSHAMap[$ActionRef]
339
340 # If UpdateStale is enabled, check if we should fetch the latest SHA instead
341 if ($UpdateStale) {
342 # Extract owner/repo from the pinned reference
343 if ($pinnedRef -match '^([^/]+/[^/@]+)@([a-fA-F0-9]{40})$') {
344 $actionPath = $matches[1]
345 $mappedSHA = $matches[2]
346
347 $parts = $actionPath -split '/'
348 $owner = $parts[0]
349 $repo = $parts[1]
350
351 Write-SecurityLog "Checking ActionSHAMap entry for updates: $ActionRef (mapped: $($mappedSHA.Substring(0,8))...)" -Level 'Info'
352
353 # Fetch latest SHA from GitHub
354 $latestSHA = Get-LatestCommitSHA -Owner $owner -Repo $repo
355
356 if ($latestSHA -and $latestSHA -ne $mappedSHA) {
357 Write-SecurityLog "Update available for mapping: $ActionRef ($($mappedSHA.Substring(0,8))... -> $($latestSHA.Substring(0,8))...)" -Level 'Success' | Out-Null
358 return "$actionPath@$latestSHA"
359 }
360 elseif ($latestSHA -eq $mappedSHA) {
361 Write-SecurityLog "ActionSHAMap entry up-to-date: $ActionRef" -Level 'Info' | Out-Null
362 }
363 elseif (-not $latestSHA) {
364 Write-SecurityLog "Failed to fetch latest SHA for $ActionRef mapping - keeping mapped SHA (likely rate limited)" -Level 'Warning' | Out-Null
365 }
366 }
367 }
368
369 Write-SecurityLog "Found SHA mapping: $ActionRef -> $pinnedRef" -Level 'Success'
370 return $pinnedRef
371 }
372
373 # For unmapped actions, suggest manual review
374 Write-SecurityLog "No SHA mapping found for: $ActionRef - requires manual review" -Level 'Warning'
375 return $null
376}
377
378function Update-WorkflowFile {
379 [CmdletBinding(SupportsShouldProcess)]
380 [OutputType([PSCustomObject])]
381 param(
382 [Parameter(Mandatory)]
383 [string]$FilePath
384 )
385
386 Write-SecurityLog "Processing workflow: $FilePath" -Level 'Info'
387
388 try {
389 $content = Get-Content -Path $FilePath -Raw
390 $originalContent = $content
391 $actions = Get-ActionReference -WorkflowContent $content
392
393 if (@($actions).Count -eq 0) {
394 Write-SecurityLog "No GitHub Actions found in $FilePath" -Level 'Info'
395 return [PSCustomObject]@{
396 FilePath = $FilePath
397 ActionsProcessed = 0
398 ActionsPinned = 0
399 ActionsSkipped = 0
400 Changes = @()
401 }
402 }
403
404 $changes = @()
405 $actionsPinned = 0
406 $actionsSkipped = 0
407
408 # Sort by StartIndex in descending order to avoid offset issues
409 $sortedActions = $actions | Sort-Object StartIndex -Descending
410
411 foreach ($action in $sortedActions) {
412 $originalRef = $action.OriginalRef
413 $pinnedRef = Get-SHAForAction -ActionRef $originalRef
414
415 if ($pinnedRef -and $pinnedRef -ne $originalRef) {
416 # Replace the action reference
417 $content = $content.Substring(0, $action.StartIndex) + $pinnedRef + $content.Substring($action.StartIndex + $action.Length)
418
419 $changes += @{
420 LineNumber = $action.LineNumber
421 Original = $originalRef
422 Pinned = $pinnedRef
423 ChangeType = 'SHA-Pinned'
424 }
425 $actionsPinned++
426 Write-SecurityLog "Pinned: $originalRef -> $pinnedRef" -Level 'Success' | Out-Null
427 }
428 elseif ($pinnedRef -eq $originalRef) {
429 $changes += @{
430 LineNumber = $action.LineNumber
431 Original = $originalRef
432 Pinned = $originalRef
433 ChangeType = 'Already-Pinned'
434 }
435 }
436 else {
437 $changes += @{
438 LineNumber = $action.LineNumber
439 Original = $originalRef
440 Pinned = $null
441 ChangeType = 'Requires-Manual-Review'
442 }
443 $actionsSkipped++
444 }
445 }
446
447 # Write updated content if changes were made and not in WhatIf mode
448 if ($content -ne $originalContent) {
449 if ($PSCmdlet.ShouldProcess($FilePath, "Update SHA pinning")) {
450 Set-ContentPreservePermission -Path $FilePath -Value $content -NoNewline
451 Write-SecurityLog "Updated workflow file: $FilePath" -Level 'Success'
452 }
453 }
454
455 return [PSCustomObject]@{
456 FilePath = $FilePath
457 ActionsProcessed = @($actions).Count
458 ActionsPinned = $actionsPinned
459 ActionsSkipped = $actionsSkipped
460 Changes = $changes
461 ContentChanged = ($content -ne $originalContent)
462 }
463 }
464 catch {
465 Write-SecurityLog "Error processing $FilePath : $($_.Exception.Message)" -Level 'Error'
466 return [PSCustomObject]@{
467 FilePath = $FilePath
468 ActionsProcessed = 0
469 ActionsPinned = 0
470 ActionsSkipped = 0
471 Changes = @()
472 ContentChanged = $false
473 Error = $_.Exception.Message
474 }
475 }
476}
477
478function Export-SecurityReport {
479 param(
480 [Parameter(Mandatory)]
481 [array]$Results
482 )
483
484 $reportPath = "scripts/security/sha-pinning-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
485
486 $sumActionsProcessed = 0
487 $sumActionsPinned = 0
488 $sumActionsSkipped = 0
489 foreach ($result in $Results) {
490 if ($result -is [hashtable]) {
491 if ($result.ContainsKey('ActionsProcessed') -and $null -ne $result['ActionsProcessed']) {
492 $sumActionsProcessed += [int]$result['ActionsProcessed']
493 }
494 if ($result.ContainsKey('ActionsPinned') -and $null -ne $result['ActionsPinned']) {
495 $sumActionsPinned += [int]$result['ActionsPinned']
496 }
497 if ($result.ContainsKey('ActionsSkipped') -and $null -ne $result['ActionsSkipped']) {
498 $sumActionsSkipped += [int]$result['ActionsSkipped']
499 }
500 }
501 else {
502 if ($result.PSObject.Properties.Name -contains 'ActionsProcessed' -and $null -ne $result.ActionsProcessed) {
503 $sumActionsProcessed += [int]$result.ActionsProcessed
504 }
505 if ($result.PSObject.Properties.Name -contains 'ActionsPinned' -and $null -ne $result.ActionsPinned) {
506 $sumActionsPinned += [int]$result.ActionsPinned
507 }
508 if ($result.PSObject.Properties.Name -contains 'ActionsSkipped' -and $null -ne $result.ActionsSkipped) {
509 $sumActionsSkipped += [int]$result.ActionsSkipped
510 }
511 }
512 }
513
514 $report = @{
515 GeneratedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC"
516 Summary = @{
517 TotalWorkflows = @($Results).Count
518 WorkflowsChanged = @($Results | Where-Object { $_.PSObject.Properties.Name -contains 'ContentChanged' -and $_.ContentChanged }).Count
519 TotalActions = $sumActionsProcessed
520 ActionsPinned = $sumActionsPinned
521 ActionsSkipped = $sumActionsSkipped
522 }
523 WorkflowResults = $Results
524 SHAMappings = $ActionSHAMap
525 }
526
527 $report | ConvertTo-Json -Depth 10 | Set-Content -Path $reportPath
528 Write-SecurityLog "Security report exported to: $reportPath" -Level 'Success'
529
530 return $reportPath
531}
532
533# Add Set-ContentPreservePermission function for cross-platform compatibility
534function Set-ContentPreservePermission {
535 [CmdletBinding(SupportsShouldProcess)]
536 param(
537 [Parameter(Mandatory = $true)]
538 [string]$Path,
539
540 [Parameter(Mandatory = $true)]
541 [string]$Value,
542
543 [Parameter(Mandatory = $false)]
544 [switch]$NoNewline
545 )
546
547 # Get original file permissions before writing
548 $OriginalMode = $null
549 if (Test-Path $Path) {
550 try {
551 # Get file mode using Get-Item (cross-platform)
552 $item = Get-Item -Path $Path -ErrorAction SilentlyContinue
553 if ($item -and $item.Mode) {
554 $OriginalMode = $item.Mode
555 }
556 }
557 catch {
558 Write-SecurityLog "Warning: Could not determine original file permissions for $Path" -Level 'Warning'
559 }
560 }
561
562 # Write content
563 if ($NoNewline) {
564 Set-Content -Path $Path -Value $Value -NoNewline
565 }
566 else {
567 Set-Content -Path $Path -Value $Value
568 }
569
570 # Restore original permissions if they were executable
571 if ($OriginalMode -and $OriginalMode -match '^-rwxr-xr-x') {
572 try {
573 & chmod +x $Path 2>$null
574 if ($LASTEXITCODE -eq 0) {
575 Write-SecurityLog "Restored execute permissions for $Path" -Level 'Info'
576 }
577 }
578 catch {
579 Write-SecurityLog "Warning: Could not restore execute permissions for $Path" -Level 'Warning'
580 }
581 }
582}
583
584#region Main Execution
585
586function Invoke-ActionSHAPinningUpdate {
587 [CmdletBinding(SupportsShouldProcess)]
588 [OutputType([void])]
589 param(
590 [Parameter()]
591 [string]$WorkflowPath = ".github/workflows",
592
593 [Parameter()]
594 [switch]$OutputReport,
595
596 [Parameter()]
597 [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
598 [string]$OutputFormat = "console",
599
600 [Parameter()]
601 [switch]$UpdateStale
602 )
603
604 Set-StrictMode -Version Latest
605
606 if ($UpdateStale) {
607 Write-SecurityLog "Starting GitHub Actions SHA update process (updating stale pins)..." -Level 'Info'
608 }
609 else {
610 Write-SecurityLog "Starting GitHub Actions SHA pinning process..." -Level 'Info'
611 }
612
613 if (-not (Test-Path -Path $WorkflowPath)) {
614 throw "Workflow path not found: $WorkflowPath"
615 }
616
617 $workflowFiles = Get-ChildItem -Path $WorkflowPath -Filter "*.yml" -File
618
619 if (@($workflowFiles).Count -eq 0) {
620 Write-SecurityLog "No YAML workflow files found in $WorkflowPath" -Level 'Warning'
621 return
622 }
623
624 Write-SecurityLog "Found $(@($workflowFiles).Count) workflow files" -Level 'Info'
625
626 $results = @()
627 foreach ($workflowFile in $workflowFiles) {
628 $result = Update-WorkflowFile -FilePath $workflowFile.FullName
629 $results += $result
630 }
631
632 $totalActions = ($results | Measure-Object ActionsProcessed -Sum).Sum
633 $totalPinned = ($results | Measure-Object ActionsPinned -Sum).Sum
634 $totalSkipped = ($results | Measure-Object ActionsSkipped -Sum).Sum
635 $workflowsChanged = @($results | Where-Object { $_.PSObject.Properties.Name -contains 'ContentChanged' -and $_.ContentChanged }).Count
636
637 Write-SecurityLog "" -Level 'Info'
638 Write-SecurityLog "=== SHA Pinning Summary ===" -Level 'Info'
639 Write-SecurityLog "Workflows processed: $(@($workflowFiles).Count)" -Level 'Info'
640 Write-SecurityLog "Workflows changed: $workflowsChanged" -Level 'Success'
641 Write-SecurityLog "Total actions found: $totalActions" -Level 'Info'
642 Write-SecurityLog "Actions SHA-pinned: $totalPinned" -Level 'Success'
643 Write-SecurityLog "Actions requiring manual review: $totalSkipped" -Level 'Warning'
644
645 if ($OutputReport) {
646 $reportPath = Export-SecurityReport -Results $results
647 Write-SecurityLog "Detailed report available at: $reportPath" -Level 'Info'
648 }
649
650 $manualReviewActions = @()
651 foreach ($result in $results) {
652 if ($result.PSObject.Properties.Name -contains 'Changes') {
653 foreach ($change in $result.Changes) {
654 if ($change.ChangeType -eq 'Requires-Manual-Review') {
655 $manualReviewActions += @{
656 Original = $change.Original
657 WorkflowFile = $result.FilePath
658 LineNumber = $change.LineNumber
659 }
660 }
661 }
662 }
663 }
664
665 if ($manualReviewActions) {
666 Write-SecurityLog "" -Level 'Info'
667 Write-SecurityLog "=== Actions Requiring Manual SHA Pinning ===" -Level 'Warning'
668 foreach ($action in $manualReviewActions) {
669 Write-SecurityLog " - $($action.Original)" -Level 'Warning'
670
671 $SecurityIssues.Add((New-SecurityIssue -Type "GitHub Actions Security" `
672 -Severity "Medium" `
673 -Title "Unpinned GitHub Action" `
674 -Description "Action '$($action.Original)' requires manual SHA pinning for supply chain security" `
675 -File $action.WorkflowFile `
676 -Recommendation "Research the action's repository and add SHA mapping to ActionSHAMap"))
677 }
678 Write-SecurityLog "Please research and add SHA mappings for these actions manually." -Level 'Warning'
679 }
680
681 $summaryText = "Processed $(@($workflowFiles).Count) workflows, pinned $totalPinned actions, $totalSkipped require manual review"
682 Write-SecurityOutput -OutputFormat $OutputFormat -Results $SecurityIssues -Summary $summaryText
683
684 if ($WhatIfPreference) {
685 Write-SecurityLog "" -Level 'Info'
686 Write-SecurityLog "WhatIf mode: No files were modified. Run without -WhatIf to apply changes." -Level 'Info'
687 }
688}
689
690if ($MyInvocation.InvocationName -ne '.') {
691 try {
692 Invoke-ActionSHAPinningUpdate -WorkflowPath $WorkflowPath -OutputReport:$OutputReport -OutputFormat $OutputFormat -UpdateStale:$UpdateStale
693 exit 0
694 }
695 catch {
696 Write-Error -ErrorAction Continue "Update-ActionSHAPinning failed: $($_.Exception.Message)"
697 Write-CIAnnotation -Message $_.Exception.Message -Level Error
698 exit 1
699 }
700}
701
702#endregion Main Execution
703