microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v3.3.41

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

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