microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3a3a0fdf923d96a9e8a9ac734c73f24433b525e8

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

923lines · 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
60function Test-GitHubToken {
61 <#
62 .SYNOPSIS
63 Validates GitHub token and checks API rate limits.
64
65 .DESCRIPTION
66 Tests if the provided GitHub token is valid and checks remaining rate limit.
67 Returns detailed status including authentication, rate limit info, and actionable messages.
68
69 .PARAMETER Token
70 GitHub personal access token or GITHUB_TOKEN to validate
71
72 .OUTPUTS
73 Hashtable with keys: Valid, Authenticated, RateLimit, Remaining, ResetAt, User, Message
74 #>
75 param(
76 [Parameter()]
77 [string]$Token
78 )
79
80 $result = @{
81 Valid = $false
82 Authenticated = $false
83 RateLimit = 0
84 Remaining = 0
85 ResetAt = $null
86 User = $null
87 Message = ""
88 }
89
90 try {
91 $headers = @{
92 "User-Agent" = "GitHub-Actions-Security-Scanner"
93 }
94
95 if ($Token) {
96 $headers["Authorization"] = "Bearer $Token"
97 }
98
99 # Use GraphQL to check authentication and rate limits
100 $query = @{
101 query = "query { viewer { login } rateLimit { limit remaining resetAt } }"
102 } | ConvertTo-Json
103
104 $response = Invoke-RestMethod -Uri "https://api.github.com/graphql" -Method POST -Headers $headers -Body $query -ErrorAction Stop
105
106 $data = $null
107 if ($response -is [hashtable]) {
108 $data = $response['data']
109 }
110 elseif ($response.PSObject.Properties.Name -contains 'data') {
111 $data = $response.data
112 }
113
114 $viewer = $null
115 $rateLimit = $null
116 if ($data) {
117 if ($data -is [hashtable]) {
118 $viewer = $data['viewer']
119 $rateLimit = $data['rateLimit']
120 }
121 else {
122 if ($data.PSObject.Properties.Name -contains 'viewer') {
123 $viewer = $data.viewer
124 }
125 if ($data.PSObject.Properties.Name -contains 'rateLimit') {
126 $rateLimit = $data.rateLimit
127 }
128 }
129 }
130
131 if ($viewer) {
132 $result.Valid = $true
133 $result.Authenticated = $true
134 if ($viewer -is [hashtable]) {
135 $result.User = $viewer['login']
136 }
137 elseif ($viewer.PSObject.Properties.Name -contains 'login') {
138 $result.User = $viewer.login
139 }
140 $result.Message = "Authenticated as $($result.User)"
141 }
142 elseif ($rateLimit) {
143 $result.Valid = $true
144 $result.Authenticated = $false
145 $result.Message = "Unauthenticated access - limited rate limits"
146 }
147
148 if ($rateLimit) {
149 if ($rateLimit -is [hashtable]) {
150 $result.RateLimit = $rateLimit['limit']
151 $result.Remaining = $rateLimit['remaining']
152 $result.ResetAt = $rateLimit['resetAt']
153 }
154 else {
155 if ($rateLimit.PSObject.Properties.Name -contains 'limit') {
156 $result.RateLimit = $rateLimit.limit
157 }
158 if ($rateLimit.PSObject.Properties.Name -contains 'remaining') {
159 $result.Remaining = $rateLimit.remaining
160 }
161 if ($rateLimit.PSObject.Properties.Name -contains 'resetAt') {
162 $result.ResetAt = $rateLimit.resetAt
163 }
164 }
165 }
166
167 if ($result.Remaining -lt 100) {
168 $result.Message += " | WARNING: Only $($result.Remaining) API calls remaining (resets at $($result.ResetAt))"
169 }
170
171 if (-not $result.Authenticated) {
172 Write-Warning "SOLUTION: Set GITHUB_TOKEN environment variable for higher rate limits (5,000 vs 60 points/hour)"
173 Write-Warning "CAUSE: Unauthenticated GitHub GraphQL API requests are heavily rate limited"
174 }
175 }
176 catch {
177 $result.Message = "Token validation failed: $($_.Exception.Message)"
178 Write-Warning $result.Message
179 }
180
181 return $result
182}
183
184function Invoke-GitHubAPIWithRetry {
185 <#
186 .SYNOPSIS
187 Invokes GitHub API with exponential backoff retry for rate limits.
188
189 .DESCRIPTION
190 Wraps Invoke-RestMethod with intelligent retry logic for rate-limited API calls.
191 Implements exponential backoff when encountering 403/429 responses.
192
193 .PARAMETER Uri
194 GitHub API URI to call
195
196 .PARAMETER Method
197 HTTP method (GET, POST, etc.)
198
199 .PARAMETER Headers
200 HTTP headers hashtable
201
202 .PARAMETER Body
203 Request body (optional)
204
205 .PARAMETER MaxRetries
206 Maximum number of retry attempts (default: 3)
207
208 .PARAMETER InitialDelaySeconds
209 Initial delay in seconds before first retry (default: 5)
210
211 .OUTPUTS
212 API response object
213 #>
214 param(
215 [Parameter(Mandatory)]
216 [string]$Uri,
217
218 [Parameter(Mandatory)]
219 [string]$Method,
220
221 [Parameter(Mandatory)]
222 [hashtable]$Headers,
223
224 [Parameter()]
225 [string]$Body,
226
227 [Parameter()]
228 [int]$MaxRetries = 3,
229
230 [Parameter()]
231 [int]$InitialDelaySeconds = 5
232 )
233
234 $attempt = 0
235 $delay = $InitialDelaySeconds
236
237 while ($attempt -lt $MaxRetries) {
238 try {
239 $params = @{
240 Uri = $Uri
241 Method = $Method
242 Headers = $Headers
243 }
244
245 if ($Body) {
246 $params['Body'] = $Body
247 $params['ContentType'] = "application/json"
248 }
249
250 $response = Invoke-RestMethod @params -ErrorAction Stop
251 return $response
252 }
253 catch {
254 $statusCode = $null
255 if ($_.Exception.PSObject.Properties.Name -contains 'Response') {
256 $response = $_.Exception.Response
257 if ($response -and $response.PSObject.Properties.Name -contains 'StatusCode') {
258 $statusCode = [int]$response.StatusCode
259 }
260 }
261
262 # Check if rate limited (403 or 429)
263 if ($statusCode -in 403, 429) {
264 $attempt++
265 if ($attempt -ge $MaxRetries) {
266 Write-Warning "CAUSE: Too many API requests in a short time period"
267 Write-Warning "SOLUTION: Wait for rate limit to reset or provide a GitHub token with higher limits"
268 throw
269 }
270
271 Write-Warning "Rate limited (HTTP $statusCode). Retrying in $delay seconds... (Attempt $attempt/$MaxRetries)"
272 Start-Sleep -Seconds $delay
273 $delay = $delay * 2 # Exponential backoff
274 }
275 else {
276 # Non-rate-limit error, don't retry
277 throw
278 }
279 }
280 }
281
282 throw "Max retries exceeded for API call to $Uri"
283}
284
285# GitHub Actions SHA references matching current workflow usage
286$ActionSHAMap = @{
287 # Core setup and checkout
288 "actions/checkout@v4" = "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v4.2.2
289 "actions/setup-node@v6" = "actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f" # v6.3.0
290 "actions/setup-python@v6" = "actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405" # v6.2.0
291
292 # Artifact management
293 "actions/upload-artifact@v4" = "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v4.4.3
294 "actions/download-artifact@v8" = "actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3" # v8.0.0
295
296 # GitHub Pages
297 "actions/configure-pages@v5" = "actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b" # v5.0.0
298 "actions/upload-pages-artifact@v4" = "actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b" # v4.0.0
299 "actions/deploy-pages@v4" = "actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e" # v4.0.5
300
301 # Attestation and provenance
302 "actions/attest@v4" = "actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26" # v4.1.0
303 "actions/attest-build-provenance@v4" = "actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" # v4.1.0
304
305 # Security and code analysis
306 "actions/dependency-review-action@v4" = "actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803" # v4.3.4
307 "github/codeql-action/init@v3" = "github/codeql-action/init@ce729e4d353d580e6cacd6a8cf2921b72e5e310a" # v3.27.0
308 "github/codeql-action/autobuild@v3" = "github/codeql-action/autobuild@ce729e4d353d580e6cacd6a8cf2921b72e5e310a" # v3.27.0
309 "github/codeql-action/analyze@v3" = "github/codeql-action/analyze@ce729e4d353d580e6cacd6a8cf2921b72e5e310a" # v3.27.0
310 "github/codeql-action/upload-sarif@v3" = "github/codeql-action/upload-sarif@ce729e4d353d580e6cacd6a8cf2921b72e5e310a" # v3.27.0
311 "ossf/scorecard-action@v2" = "ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a" # v2.4.3
312
313 # Azure
314 "azure/login@v2" = "azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5" # v2.3.0
315
316 # Third-party
317 "actions/create-github-app-token@v2" = "actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf" # v2.0.0
318 "codecov/codecov-action@v5" = "codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de" # v5.5.2
319 "googleapis/release-please-action@v4" = "googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38" # v4.4.0
320 "anchore/sbom-action@v0" = "anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11" # v0.23.0
321}
322
323# Initialize security issues collection
324$SecurityIssues = [System.Collections.Generic.List[PSCustomObject]]::new()
325
326function Write-SecurityOutput {
327 <#
328 .SYNOPSIS
329 Formats and emits security scan results in the requested CI or local format.
330 #>
331 [CmdletBinding()]
332 param(
333 [Parameter(Mandatory)]
334 [ValidateSet('json', 'azdo', 'github', 'console', 'BuildWarning', 'Summary')]
335 [string]$OutputFormat,
336
337 [Parameter()]
338 [array]$Results = @(),
339
340 [Parameter()]
341 [string]$Summary = '',
342
343 [Parameter()]
344 [string]$OutputPath
345 )
346
347 switch ($OutputFormat) {
348 'json' {
349 Write-SecurityReport -Results $Results -Summary $Summary -OutputFormat json -OutputPath $OutputPath
350 return
351 }
352 'console' {
353 Write-SecurityReport -Results $Results -Summary $Summary -OutputFormat console
354 return
355 }
356 'BuildWarning' {
357 if (@($Results).Count -eq 0) {
358 Write-Output '##[section]No GitHub Actions security issues found'
359 return
360 }
361 Write-Output '##[section]GitHub Actions Security Issues Found:'
362 foreach ($issue in $Results) {
363 $message = "$($issue.Title) - $($issue.Description)"
364 if ($issue.File) { $message += " (File: $($issue.File))" }
365 if ($issue.Recommendation) { $message += " Recommendation: $($issue.Recommendation)" }
366 Write-Output "##[warning]$message"
367 }
368 return
369 }
370 'github' {
371 if (@($Results).Count -eq 0) {
372 Write-CIAnnotation -Message 'No GitHub Actions security issues found' -Level Notice
373 return
374 }
375 foreach ($issue in $Results) {
376 $message = "[$($issue.Severity)] $($issue.Title) - $($issue.Description)"
377 $file = if ($issue.File) { $issue.File -replace '\\', '/' } else { $null }
378 Write-CIAnnotation -Message $message -Level Warning -File $file
379 }
380 return
381 }
382 'azdo' {
383 if (@($Results).Count -eq 0) {
384 Write-CIAnnotation -Message 'No GitHub Actions security issues found' -Level Notice
385 return
386 }
387 foreach ($issue in $Results) {
388 $message = "[$($issue.Severity)] $($issue.Title) - $($issue.Description)"
389 $file = if ($issue.File) { $issue.File } else { $null }
390 Write-CIAnnotation -Message $message -Level Warning -File $file
391 }
392 Set-CITaskResult -Result SucceededWithIssues
393 return
394 }
395 'Summary' {
396 if (@($Results).Count -eq 0) {
397 Write-SecurityLog -Message 'No security issues found' -Level Success
398 return
399 }
400 $Results | Group-Object -Property Type | ForEach-Object {
401 Write-Output "=== $($_.Name) ==="
402 foreach ($issue in $_.Group) {
403 Write-Output " [$($issue.Severity)] $($issue.Title): $($issue.Description)"
404 }
405 }
406 return
407 }
408 }
409}
410
411function Get-ActionReference {
412 param(
413 [Parameter(Mandatory)]
414 [string]$WorkflowContent
415 )
416
417 # Match GitHub Actions usage patterns with uses: keyword
418 $actionPattern = '(?m)^\s*uses:\s*([^\s@]+@[^\s]+)'
419 $actionMatches = [regex]::Matches($WorkflowContent, $actionPattern)
420
421 $actions = @()
422 foreach ($match in $actionMatches) {
423 $actionRef = $match.Groups[1].Value.Trim()
424 # Skip local actions (starting with ./)
425 if (-not $actionRef.StartsWith('./')) {
426 $actions += @{
427 OriginalRef = $actionRef
428 LineNumber = ($WorkflowContent.Substring(0, $match.Index).Split("`n").Count)
429 StartIndex = $match.Groups[1].Index
430 Length = $match.Groups[1].Length
431 }
432 }
433 }
434
435 return $actions
436}
437
438function Get-LatestCommitSHA {
439 param(
440 [Parameter(Mandatory)]
441 [string]$Owner,
442
443 [Parameter(Mandatory)]
444 [string]$Repo,
445
446 [Parameter()]
447 [string]$Branch
448 )
449
450 try {
451 $headers = @{
452 'Accept' = 'application/vnd.github+json'
453 'User-Agent' = 'hve-core-sha-pinning-updater'
454 }
455
456 # Check GitHub token and validate it
457 $githubToken = $env:GITHUB_TOKEN
458 if ($githubToken) {
459 $tokenStatus = Test-GitHubToken -Token $githubToken
460 if ($tokenStatus.Valid) {
461 $headers['Authorization'] = "Bearer $githubToken"
462 }
463 else {
464 Write-SecurityLog "Token validation failed, proceeding without authentication" -Level Warning
465 Write-SecurityLog "CAUSE: Invalid or expired GitHub token" -Level Warning
466 Write-SecurityLog "SOLUTION: Generate new token at https://github.com/settings/tokens" -Level Warning
467 }
468 }
469
470 # If no branch specified, detect the repository's default branch
471 if (-not $Branch) {
472 $repoApiUrl = "https://api.github.com/repos/$Owner/$Repo"
473 $repoInfo = Invoke-GitHubAPIWithRetry -Uri $repoApiUrl -Method GET -Headers $headers
474 $Branch = $repoInfo.default_branch
475 Write-SecurityLog "Detected default branch for $Owner/$Repo : $Branch" -Level 'Info'
476 }
477
478 $apiUrl = "https://api.github.com/repos/$Owner/$Repo/commits/$Branch"
479 $response = Invoke-GitHubAPIWithRetry -Uri $apiUrl -Method GET -Headers $headers
480 return $response.sha
481 }
482 catch {
483 $statusCode = $null
484 if ($_.Exception.PSObject.Properties.Name -contains 'Response') {
485 $response = $_.Exception.Response
486 if ($response -and $response.PSObject.Properties.Name -contains 'StatusCode') {
487 $statusCode = [int]$response.StatusCode
488 }
489 }
490
491 if ($statusCode -eq 404) {
492 Write-SecurityLog "Failed to fetch latest SHA for $Owner/$Repo : Repository or branch not found" -Level 'Warning'
493 Write-SecurityLog "CAUSE: Repository does not exist, is private, or branch name is incorrect" -Level 'Warning'
494 Write-SecurityLog "SOLUTION: Verify repository exists and branch name is correct" -Level 'Warning'
495 }
496 else {
497 Write-SecurityLog "Failed to fetch latest SHA for $Owner/$Repo : $($_.Exception.Message)" -Level 'Warning'
498 Write-SecurityLog "CAUSE: Network connectivity issue or GitHub API unavailable" -Level 'Warning'
499 }
500 return $null
501 }
502}
503
504function Get-SHAForAction {
505 param(
506 [Parameter(Mandatory)]
507 [string]$ActionRef
508 )
509
510 # Check if already SHA-pinned (40-character hex string)
511 if ($ActionRef -match '@[a-fA-F0-9]{40}$') {
512 # If UpdateStale is enabled, fetch the latest SHA and compare
513 if ($UpdateStale) {
514 # Extract owner/repo from action reference (supports subpaths)
515 if ($ActionRef -match '^([^@]+)@([a-fA-F0-9]{40})$') {
516 $actionPath = $matches[1]
517 $currentSHA = $matches[2]
518
519 # Handle actions with subpaths (e.g., github/codeql-action/init)
520 $parts = $actionPath -split '/'
521
522 # Validate action reference format
523 if ($parts.Count -lt 2) {
524 Write-SecurityLog "Invalid action reference format: $ActionRef - must be 'owner/repo' or 'owner/repo/path'" -Level 'Warning'
525 Write-SecurityLog "CAUSE: Malformed action path missing owner or repository name" -Level 'Warning'
526 Write-SecurityLog "SOLUTION: Verify action reference follows GitHub Actions format (e.g., actions/checkout@v4)" -Level 'Warning'
527 return $null
528 }
529
530 $owner = $parts[0]
531 $repo = $parts[1]
532
533 Write-SecurityLog "Checking for updates: $actionPath (current: $($currentSHA.Substring(0,8))...)" -Level 'Info'
534
535 # Fetch latest SHA from GitHub
536 $latestSHA = Get-LatestCommitSHA -Owner $owner -Repo $repo
537
538 if ($latestSHA -and $latestSHA -ne $currentSHA) {
539 Write-SecurityLog "Update available: $actionPath ($($currentSHA.Substring(0,8))... -> $($latestSHA.Substring(0,8))...)" -Level 'Success'
540 return "$actionPath@$latestSHA"
541 }
542 elseif ($latestSHA -eq $currentSHA) {
543 Write-SecurityLog "Already up-to-date: $actionPath" -Level 'Info'
544 }
545 elseif (-not $latestSHA) {
546 Write-SecurityLog "Failed to fetch latest SHA for $actionPath - keeping current SHA (likely rate limited)" -Level 'Warning'
547 }
548
549 return $ActionRef
550 }
551 }
552
553 Write-SecurityLog "Action already SHA-pinned: $ActionRef" -Level 'Info'
554 return $ActionRef
555 }
556
557 # Look up in pre-defined SHA map
558 if ($ActionSHAMap.ContainsKey($ActionRef)) {
559 $pinnedRef = $ActionSHAMap[$ActionRef]
560
561 # If UpdateStale is enabled, check if we should fetch the latest SHA instead
562 if ($UpdateStale) {
563 # Extract owner/repo from the pinned reference
564 if ($pinnedRef -match '^([^/]+/[^/@]+)@([a-fA-F0-9]{40})$') {
565 $actionPath = $matches[1]
566 $mappedSHA = $matches[2]
567
568 $parts = $actionPath -split '/'
569 $owner = $parts[0]
570 $repo = $parts[1]
571
572 Write-SecurityLog "Checking ActionSHAMap entry for updates: $ActionRef (mapped: $($mappedSHA.Substring(0,8))...)" -Level 'Info'
573
574 # Fetch latest SHA from GitHub
575 $latestSHA = Get-LatestCommitSHA -Owner $owner -Repo $repo
576
577 if ($latestSHA -and $latestSHA -ne $mappedSHA) {
578 Write-SecurityLog "Update available for mapping: $ActionRef ($($mappedSHA.Substring(0,8))... -> $($latestSHA.Substring(0,8))...)" -Level 'Success' | Out-Null
579 return "$actionPath@$latestSHA"
580 }
581 elseif ($latestSHA -eq $mappedSHA) {
582 Write-SecurityLog "ActionSHAMap entry up-to-date: $ActionRef" -Level 'Info' | Out-Null
583 }
584 elseif (-not $latestSHA) {
585 Write-SecurityLog "Failed to fetch latest SHA for $ActionRef mapping - keeping mapped SHA (likely rate limited)" -Level 'Warning' | Out-Null
586 }
587 }
588 }
589
590 Write-SecurityLog "Found SHA mapping: $ActionRef -> $pinnedRef" -Level 'Success'
591 return $pinnedRef
592 }
593
594 # For unmapped actions, suggest manual review
595 Write-SecurityLog "No SHA mapping found for: $ActionRef - requires manual review" -Level 'Warning'
596 return $null
597}
598
599function Update-WorkflowFile {
600 [CmdletBinding(SupportsShouldProcess)]
601 [OutputType([PSCustomObject])]
602 param(
603 [Parameter(Mandatory)]
604 [string]$FilePath
605 )
606
607 Write-SecurityLog "Processing workflow: $FilePath" -Level 'Info'
608
609 try {
610 $content = Get-Content -Path $FilePath -Raw
611 $originalContent = $content
612 $actions = Get-ActionReference -WorkflowContent $content
613
614 if (@($actions).Count -eq 0) {
615 Write-SecurityLog "No GitHub Actions found in $FilePath" -Level 'Info'
616 return [PSCustomObject]@{
617 FilePath = $FilePath
618 ActionsProcessed = 0
619 ActionsPinned = 0
620 ActionsSkipped = 0
621 Changes = @()
622 }
623 }
624
625 $changes = @()
626 $actionsPinned = 0
627 $actionsSkipped = 0
628
629 # Sort by StartIndex in descending order to avoid offset issues
630 $sortedActions = $actions | Sort-Object StartIndex -Descending
631
632 foreach ($action in $sortedActions) {
633 $originalRef = $action.OriginalRef
634 $pinnedRef = Get-SHAForAction -ActionRef $originalRef
635
636 if ($pinnedRef -and $pinnedRef -ne $originalRef) {
637 # Replace the action reference
638 $content = $content.Substring(0, $action.StartIndex) + $pinnedRef + $content.Substring($action.StartIndex + $action.Length)
639
640 $changes += @{
641 LineNumber = $action.LineNumber
642 Original = $originalRef
643 Pinned = $pinnedRef
644 ChangeType = 'SHA-Pinned'
645 }
646 $actionsPinned++
647 Write-SecurityLog "Pinned: $originalRef -> $pinnedRef" -Level 'Success' | Out-Null
648 }
649 elseif ($pinnedRef -eq $originalRef) {
650 $changes += @{
651 LineNumber = $action.LineNumber
652 Original = $originalRef
653 Pinned = $originalRef
654 ChangeType = 'Already-Pinned'
655 }
656 }
657 else {
658 $changes += @{
659 LineNumber = $action.LineNumber
660 Original = $originalRef
661 Pinned = $null
662 ChangeType = 'Requires-Manual-Review'
663 }
664 $actionsSkipped++
665 }
666 }
667
668 # Write updated content if changes were made and not in WhatIf mode
669 if ($content -ne $originalContent) {
670 if ($PSCmdlet.ShouldProcess($FilePath, "Update SHA pinning")) {
671 Set-ContentPreservePermission -Path $FilePath -Value $content -NoNewline
672 Write-SecurityLog "Updated workflow file: $FilePath" -Level 'Success'
673 }
674 }
675
676 return [PSCustomObject]@{
677 FilePath = $FilePath
678 ActionsProcessed = @($actions).Count
679 ActionsPinned = $actionsPinned
680 ActionsSkipped = $actionsSkipped
681 Changes = $changes
682 ContentChanged = ($content -ne $originalContent)
683 }
684 }
685 catch {
686 Write-SecurityLog "Error processing $FilePath : $($_.Exception.Message)" -Level 'Error'
687 return [PSCustomObject]@{
688 FilePath = $FilePath
689 ActionsProcessed = 0
690 ActionsPinned = 0
691 ActionsSkipped = 0
692 Changes = @()
693 ContentChanged = $false
694 Error = $_.Exception.Message
695 }
696 }
697}
698
699function Export-SecurityReport {
700 param(
701 [Parameter(Mandatory)]
702 [array]$Results
703 )
704
705 $reportPath = "scripts/security/sha-pinning-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
706
707 $sumActionsProcessed = 0
708 $sumActionsPinned = 0
709 $sumActionsSkipped = 0
710 foreach ($result in $Results) {
711 if ($result -is [hashtable]) {
712 if ($result.ContainsKey('ActionsProcessed') -and $null -ne $result['ActionsProcessed']) {
713 $sumActionsProcessed += [int]$result['ActionsProcessed']
714 }
715 if ($result.ContainsKey('ActionsPinned') -and $null -ne $result['ActionsPinned']) {
716 $sumActionsPinned += [int]$result['ActionsPinned']
717 }
718 if ($result.ContainsKey('ActionsSkipped') -and $null -ne $result['ActionsSkipped']) {
719 $sumActionsSkipped += [int]$result['ActionsSkipped']
720 }
721 }
722 else {
723 if ($result.PSObject.Properties.Name -contains 'ActionsProcessed' -and $null -ne $result.ActionsProcessed) {
724 $sumActionsProcessed += [int]$result.ActionsProcessed
725 }
726 if ($result.PSObject.Properties.Name -contains 'ActionsPinned' -and $null -ne $result.ActionsPinned) {
727 $sumActionsPinned += [int]$result.ActionsPinned
728 }
729 if ($result.PSObject.Properties.Name -contains 'ActionsSkipped' -and $null -ne $result.ActionsSkipped) {
730 $sumActionsSkipped += [int]$result.ActionsSkipped
731 }
732 }
733 }
734
735 $report = @{
736 GeneratedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC"
737 Summary = @{
738 TotalWorkflows = @($Results).Count
739 WorkflowsChanged = @($Results | Where-Object { $_.PSObject.Properties.Name -contains 'ContentChanged' -and $_.ContentChanged }).Count
740 TotalActions = $sumActionsProcessed
741 ActionsPinned = $sumActionsPinned
742 ActionsSkipped = $sumActionsSkipped
743 }
744 WorkflowResults = $Results
745 SHAMappings = $ActionSHAMap
746 }
747
748 $report | ConvertTo-Json -Depth 10 | Set-Content -Path $reportPath
749 Write-SecurityLog "Security report exported to: $reportPath" -Level 'Success'
750
751 return $reportPath
752}
753
754# Add Set-ContentPreservePermission function for cross-platform compatibility
755function Set-ContentPreservePermission {
756 [CmdletBinding(SupportsShouldProcess)]
757 param(
758 [Parameter(Mandatory = $true)]
759 [string]$Path,
760
761 [Parameter(Mandatory = $true)]
762 [string]$Value,
763
764 [Parameter(Mandatory = $false)]
765 [switch]$NoNewline
766 )
767
768 # Get original file permissions before writing
769 $OriginalMode = $null
770 if (Test-Path $Path) {
771 try {
772 # Get file mode using Get-Item (cross-platform)
773 $item = Get-Item -Path $Path -ErrorAction SilentlyContinue
774 if ($item -and $item.Mode) {
775 $OriginalMode = $item.Mode
776 }
777 }
778 catch {
779 Write-SecurityLog "Warning: Could not determine original file permissions for $Path" -Level 'Warning'
780 }
781 }
782
783 # Write content
784 if ($NoNewline) {
785 Set-Content -Path $Path -Value $Value -NoNewline
786 }
787 else {
788 Set-Content -Path $Path -Value $Value
789 }
790
791 # Restore original permissions if they were executable
792 if ($OriginalMode -and $OriginalMode -match '^-rwxr-xr-x') {
793 try {
794 & chmod +x $Path 2>$null
795 if ($LASTEXITCODE -eq 0) {
796 Write-SecurityLog "Restored execute permissions for $Path" -Level 'Info'
797 }
798 }
799 catch {
800 Write-SecurityLog "Warning: Could not restore execute permissions for $Path" -Level 'Warning'
801 }
802 }
803}
804
805#region Main Execution
806
807function Invoke-ActionSHAPinningUpdate {
808 [CmdletBinding(SupportsShouldProcess)]
809 [OutputType([void])]
810 param(
811 [Parameter()]
812 [string]$WorkflowPath = ".github/workflows",
813
814 [Parameter()]
815 [switch]$OutputReport,
816
817 [Parameter()]
818 [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
819 [string]$OutputFormat = "console",
820
821 [Parameter()]
822 [switch]$UpdateStale
823 )
824
825 Set-StrictMode -Version Latest
826
827 if ($UpdateStale) {
828 Write-SecurityLog "Starting GitHub Actions SHA update process (updating stale pins)..." -Level 'Info'
829 }
830 else {
831 Write-SecurityLog "Starting GitHub Actions SHA pinning process..." -Level 'Info'
832 }
833
834 if (-not (Test-Path -Path $WorkflowPath)) {
835 throw "Workflow path not found: $WorkflowPath"
836 }
837
838 $workflowFiles = Get-ChildItem -Path $WorkflowPath -Filter "*.yml" -File
839
840 if (@($workflowFiles).Count -eq 0) {
841 Write-SecurityLog "No YAML workflow files found in $WorkflowPath" -Level 'Warning'
842 return
843 }
844
845 Write-SecurityLog "Found $(@($workflowFiles).Count) workflow files" -Level 'Info'
846
847 $results = @()
848 foreach ($workflowFile in $workflowFiles) {
849 $result = Update-WorkflowFile -FilePath $workflowFile.FullName
850 $results += $result
851 }
852
853 $totalActions = ($results | Measure-Object ActionsProcessed -Sum).Sum
854 $totalPinned = ($results | Measure-Object ActionsPinned -Sum).Sum
855 $totalSkipped = ($results | Measure-Object ActionsSkipped -Sum).Sum
856 $workflowsChanged = @($results | Where-Object { $_.PSObject.Properties.Name -contains 'ContentChanged' -and $_.ContentChanged }).Count
857
858 Write-SecurityLog "" -Level 'Info'
859 Write-SecurityLog "=== SHA Pinning Summary ===" -Level 'Info'
860 Write-SecurityLog "Workflows processed: $(@($workflowFiles).Count)" -Level 'Info'
861 Write-SecurityLog "Workflows changed: $workflowsChanged" -Level 'Success'
862 Write-SecurityLog "Total actions found: $totalActions" -Level 'Info'
863 Write-SecurityLog "Actions SHA-pinned: $totalPinned" -Level 'Success'
864 Write-SecurityLog "Actions requiring manual review: $totalSkipped" -Level 'Warning'
865
866 if ($OutputReport) {
867 $reportPath = Export-SecurityReport -Results $results
868 Write-SecurityLog "Detailed report available at: $reportPath" -Level 'Info'
869 }
870
871 $manualReviewActions = @()
872 foreach ($result in $results) {
873 if ($result.PSObject.Properties.Name -contains 'Changes') {
874 foreach ($change in $result.Changes) {
875 if ($change.ChangeType -eq 'Requires-Manual-Review') {
876 $manualReviewActions += @{
877 Original = $change.Original
878 WorkflowFile = $result.FilePath
879 LineNumber = $change.LineNumber
880 }
881 }
882 }
883 }
884 }
885
886 if ($manualReviewActions) {
887 Write-SecurityLog "" -Level 'Info'
888 Write-SecurityLog "=== Actions Requiring Manual SHA Pinning ===" -Level 'Warning'
889 foreach ($action in $manualReviewActions) {
890 Write-SecurityLog " - $($action.Original)" -Level 'Warning'
891
892 $SecurityIssues.Add((New-SecurityIssue -Type "GitHub Actions Security" `
893 -Severity "Medium" `
894 -Title "Unpinned GitHub Action" `
895 -Description "Action '$($action.Original)' requires manual SHA pinning for supply chain security" `
896 -File $action.WorkflowFile `
897 -Recommendation "Research the action's repository and add SHA mapping to ActionSHAMap"))
898 }
899 Write-SecurityLog "Please research and add SHA mappings for these actions manually." -Level 'Warning'
900 }
901
902 $summaryText = "Processed $(@($workflowFiles).Count) workflows, pinned $totalPinned actions, $totalSkipped require manual review"
903 Write-SecurityOutput -OutputFormat $OutputFormat -Results $SecurityIssues -Summary $summaryText
904
905 if ($WhatIfPreference) {
906 Write-SecurityLog "" -Level 'Info'
907 Write-SecurityLog "WhatIf mode: No files were modified. Run without -WhatIf to apply changes." -Level 'Info'
908 }
909}
910
911if ($MyInvocation.InvocationName -ne '.') {
912 try {
913 Invoke-ActionSHAPinningUpdate -WorkflowPath $WorkflowPath -OutputReport:$OutputReport -OutputFormat $OutputFormat -UpdateStale:$UpdateStale
914 exit 0
915 }
916 catch {
917 Write-Error -ErrorAction Continue "Update-ActionSHAPinning failed: $($_.Exception.Message)"
918 Write-CIAnnotation -Message $_.Exception.Message -Level Error
919 exit 1
920 }
921}
922
923#endregion Main Execution
924