microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/devcontainer-python-uv-887

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

945lines · 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# Common GitHub Actions and their current SHA references
286$ActionSHAMap = @{
287 "actions/checkout@v4" = "actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332" # v4.1.7
288 "actions/checkout@v3" = "actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744" # v3.6.0
289 "actions/setup-node@v4" = "actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b" # v4.0.3
290 "actions/setup-node@v3" = "actions/setup-node@5e21ff4d9bc06a74674ebf3f11c5d9bb6f561e3b" # v3.8.2
291 "actions/setup-python@v5" = "actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f" # v5.1.1
292 "actions/setup-python@v4" = "actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236" # v4.8.0
293 "actions/setup-dotnet@v4" = "actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee" # v4.0.1
294 "actions/setup-dotnet@v3" = "actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3" # v3.2.0
295 "actions/cache@v4" = "actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9" # v4.0.2
296 "actions/cache@v3" = "actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8" # v3.3.1
297 "actions/upload-artifact@v4" = "actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808" # v4.3.6
298 "actions/upload-artifact@v3" = "actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3" # v3.1.3
299 "actions/download-artifact@v7" = "actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131" # v7.0.0
300 "actions/download-artifact@v4" = "actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16" # v4.1.8
301 "actions/download-artifact@v3" = "actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a" # v3.0.2
302 "actions/attest-build-provenance@v4" = "actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" # v4.1.0
303 "actions/attest-build-provenance@v3" = "actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f" # v3.2.0
304 "actions/attest-build-provenance@v2" = "actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2" # v2.2.3
305 "github/super-linter@v6" = "github/super-linter@4ac6c1e9bce95c4e5e456c8c2c6b468998248097" # v6.8.0
306 "github/super-linter@v5" = "github/super-linter@45fc0d88288beee4701c62761281edfee85655d7" # v5.7.2
307 "hashicorp/setup-terraform@v3" = "hashicorp/setup-terraform@651471c36a6092792c552e8b1bef71e592b462d8" # v3.1.1
308 "hashicorp/setup-terraform@v2" = "hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1" # v2.0.3
309 "azure/login@v2" = "azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a" # v2.1.1
310 "azure/login@v1" = "azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2" # v1.6.1
311 "azure/CLI@v2" = "azure/CLI@965c8d7571d2231a54e321ddd07f7b10317f34d9" # v2.0.0
312 "azure/CLI@v1" = "azure/CLI@4db43908b9df2e7ac93d6dcbdb02c7e9a4429c2a" # v1.0.9
313 "docker/setup-buildx-action@v3" = "docker/setup-buildx-action@4fd812986e6c8c2a69e18311145f9371337f27d4" # v3.4.0
314 "docker/setup-buildx-action@v2" = "docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55" # v2.10.0
315 "docker/build-push-action@v6" = "docker/build-push-action@5176d81f87c23d6fc96624dfdbcd9f3830bbe445" # v6.6.1
316 "docker/build-push-action@v5" = "docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0" # v5.4.0
317 "docker/login-action@v3" = "docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567" # v3.3.0
318 "docker/login-action@v2" = "docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc" # v2.2.0
319 "peaceiris/actions-gh-pages@v4" = "peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e" # v4.0.0
320 "peaceiris/actions-gh-pages@v3" = "peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847" # v3.9.3
321 "coverallsapp/github-action@v2" = "coverallsapp/github-action@643bc377ffa44ace6a3b31e8fd2cbb982c5f04f3" # v2.3.0
322 "codecov/codecov-action@v4" = "codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673" # v4.5.0
323 "codecov/codecov-action@v3" = "codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d" # v3.1.4
324 "microsoft/setup-msbuild@v2" = "microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce" # v2.0.0
325 "microsoft/setup-msbuild@v1" = "microsoft/setup-msbuild@ab534842b4bdf384b8aaf93765dc6f721d9f5fab" # v1.3.1
326 "dorny/paths-filter@v3" = "dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36" # v3.0.2
327 "dorny/paths-filter@v2" = "dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50" # v2.11.1
328
329 # Additional actions requiring SHA pinning
330 "actions/github-script@v7" = "actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea" # v7.0.1
331 "actions/dependency-review-action@v3" = "actions/dependency-review-action@72eb03d02c7872a771aacd928f3123ac62ad6d3a" # v3.1.0
332 "actions/dependency-review-action@v4" = "actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c" # v4.3.4
333 "github/codeql-action/init@v3" = "github/codeql-action/init@294a9d92911152fe08befb9ec03e240add280cb3" # v3.26.8
334 "github/codeql-action/autobuild@v3" = "github/codeql-action/autobuild@294a9d92911152fe08befb9ec03e240add280cb3" # v3.26.8
335 "github/codeql-action/analyze@v3" = "github/codeql-action/analyze@294a9d92911152fe08befb9ec03e240add280cb3" # v3.26.8
336 "github/codeql-action/upload-sarif@v3" = "github/codeql-action/upload-sarif@294a9d92911152fe08befb9ec03e240add280cb3" # v3.26.8
337 "oxsecurity/megalinter@v8" = "oxsecurity/megalinter@c217fe8f7bc9207062a084e989bd97efd56e7b9a" # v8.0.0
338 "actions/deploy-pages@v4" = "actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e" # v4.0.5
339 "actions/upload-pages-artifact@v3" = "actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa" # v3.0.1
340 "actions/configure-pages@v4" = "actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b" # v4.0.0
341 "azure/powershell@v1" = "azure/powershell@1c589a2e445c71fe2cea92c69f7b80b572760c3b" # v1.5.0
342 "azure/get-keyvault-secrets@v1" = "azure/get-keyvault-secrets@b5c723b9ac7870c022b8c35befe620b7009b336f" # v1.2
343}
344
345# Initialize security issues collection
346$SecurityIssues = [System.Collections.Generic.List[PSCustomObject]]::new()
347
348function Write-SecurityOutput {
349 <#
350 .SYNOPSIS
351 Formats and emits security scan results in the requested CI or local format.
352 #>
353 [CmdletBinding()]
354 param(
355 [Parameter(Mandatory)]
356 [ValidateSet('json', 'azdo', 'github', 'console', 'BuildWarning', 'Summary')]
357 [string]$OutputFormat,
358
359 [Parameter()]
360 [array]$Results = @(),
361
362 [Parameter()]
363 [string]$Summary = '',
364
365 [Parameter()]
366 [string]$OutputPath
367 )
368
369 switch ($OutputFormat) {
370 'json' {
371 Write-SecurityReport -Results $Results -Summary $Summary -OutputFormat json -OutputPath $OutputPath
372 return
373 }
374 'console' {
375 Write-SecurityReport -Results $Results -Summary $Summary -OutputFormat console
376 return
377 }
378 'BuildWarning' {
379 if (@($Results).Count -eq 0) {
380 Write-Output '##[section]No GitHub Actions security issues found'
381 return
382 }
383 Write-Output '##[section]GitHub Actions Security Issues Found:'
384 foreach ($issue in $Results) {
385 $message = "$($issue.Title) - $($issue.Description)"
386 if ($issue.File) { $message += " (File: $($issue.File))" }
387 if ($issue.Recommendation) { $message += " Recommendation: $($issue.Recommendation)" }
388 Write-Output "##[warning]$message"
389 }
390 return
391 }
392 'github' {
393 if (@($Results).Count -eq 0) {
394 Write-CIAnnotation -Message 'No GitHub Actions security issues found' -Level Notice
395 return
396 }
397 foreach ($issue in $Results) {
398 $message = "[$($issue.Severity)] $($issue.Title) - $($issue.Description)"
399 $file = if ($issue.File) { $issue.File -replace '\\', '/' } else { $null }
400 Write-CIAnnotation -Message $message -Level Warning -File $file
401 }
402 return
403 }
404 'azdo' {
405 if (@($Results).Count -eq 0) {
406 Write-CIAnnotation -Message 'No GitHub Actions security issues found' -Level Notice
407 return
408 }
409 foreach ($issue in $Results) {
410 $message = "[$($issue.Severity)] $($issue.Title) - $($issue.Description)"
411 $file = if ($issue.File) { $issue.File } else { $null }
412 Write-CIAnnotation -Message $message -Level Warning -File $file
413 }
414 Set-CITaskResult -Result SucceededWithIssues
415 return
416 }
417 'Summary' {
418 if (@($Results).Count -eq 0) {
419 Write-SecurityLog -Message 'No security issues found' -Level Success
420 return
421 }
422 $Results | Group-Object -Property Type | ForEach-Object {
423 Write-Output "=== $($_.Name) ==="
424 foreach ($issue in $_.Group) {
425 Write-Output " [$($issue.Severity)] $($issue.Title): $($issue.Description)"
426 }
427 }
428 return
429 }
430 }
431}
432
433function Get-ActionReference {
434 param(
435 [Parameter(Mandatory)]
436 [string]$WorkflowContent
437 )
438
439 # Match GitHub Actions usage patterns with uses: keyword
440 $actionPattern = '(?m)^\s*uses:\s*([^\s@]+@[^\s]+)'
441 $actionMatches = [regex]::Matches($WorkflowContent, $actionPattern)
442
443 $actions = @()
444 foreach ($match in $actionMatches) {
445 $actionRef = $match.Groups[1].Value.Trim()
446 # Skip local actions (starting with ./)
447 if (-not $actionRef.StartsWith('./')) {
448 $actions += @{
449 OriginalRef = $actionRef
450 LineNumber = ($WorkflowContent.Substring(0, $match.Index).Split("`n").Count)
451 StartIndex = $match.Groups[1].Index
452 Length = $match.Groups[1].Length
453 }
454 }
455 }
456
457 return $actions
458}
459
460function Get-LatestCommitSHA {
461 param(
462 [Parameter(Mandatory)]
463 [string]$Owner,
464
465 [Parameter(Mandatory)]
466 [string]$Repo,
467
468 [Parameter()]
469 [string]$Branch
470 )
471
472 try {
473 $headers = @{
474 'Accept' = 'application/vnd.github+json'
475 'User-Agent' = 'hve-core-sha-pinning-updater'
476 }
477
478 # Check GitHub token and validate it
479 $githubToken = $env:GITHUB_TOKEN
480 if ($githubToken) {
481 $tokenStatus = Test-GitHubToken -Token $githubToken
482 if ($tokenStatus.Valid) {
483 $headers['Authorization'] = "Bearer $githubToken"
484 }
485 else {
486 Write-SecurityLog "Token validation failed, proceeding without authentication" -Level Warning
487 Write-SecurityLog "CAUSE: Invalid or expired GitHub token" -Level Warning
488 Write-SecurityLog "SOLUTION: Generate new token at https://github.com/settings/tokens" -Level Warning
489 }
490 }
491
492 # If no branch specified, detect the repository's default branch
493 if (-not $Branch) {
494 $repoApiUrl = "https://api.github.com/repos/$Owner/$Repo"
495 $repoInfo = Invoke-GitHubAPIWithRetry -Uri $repoApiUrl -Method GET -Headers $headers
496 $Branch = $repoInfo.default_branch
497 Write-SecurityLog "Detected default branch for $Owner/$Repo : $Branch" -Level 'Info'
498 }
499
500 $apiUrl = "https://api.github.com/repos/$Owner/$Repo/commits/$Branch"
501 $response = Invoke-GitHubAPIWithRetry -Uri $apiUrl -Method GET -Headers $headers
502 return $response.sha
503 }
504 catch {
505 $statusCode = $null
506 if ($_.Exception.PSObject.Properties.Name -contains 'Response') {
507 $response = $_.Exception.Response
508 if ($response -and $response.PSObject.Properties.Name -contains 'StatusCode') {
509 $statusCode = [int]$response.StatusCode
510 }
511 }
512
513 if ($statusCode -eq 404) {
514 Write-SecurityLog "Failed to fetch latest SHA for $Owner/$Repo : Repository or branch not found" -Level 'Warning'
515 Write-SecurityLog "CAUSE: Repository does not exist, is private, or branch name is incorrect" -Level 'Warning'
516 Write-SecurityLog "SOLUTION: Verify repository exists and branch name is correct" -Level 'Warning'
517 }
518 else {
519 Write-SecurityLog "Failed to fetch latest SHA for $Owner/$Repo : $($_.Exception.Message)" -Level 'Warning'
520 Write-SecurityLog "CAUSE: Network connectivity issue or GitHub API unavailable" -Level 'Warning'
521 }
522 return $null
523 }
524}
525
526function Get-SHAForAction {
527 param(
528 [Parameter(Mandatory)]
529 [string]$ActionRef
530 )
531
532 # Check if already SHA-pinned (40-character hex string)
533 if ($ActionRef -match '@[a-fA-F0-9]{40}$') {
534 # If UpdateStale is enabled, fetch the latest SHA and compare
535 if ($UpdateStale) {
536 # Extract owner/repo from action reference (supports subpaths)
537 if ($ActionRef -match '^([^@]+)@([a-fA-F0-9]{40})$') {
538 $actionPath = $matches[1]
539 $currentSHA = $matches[2]
540
541 # Handle actions with subpaths (e.g., github/codeql-action/init)
542 $parts = $actionPath -split '/'
543
544 # Validate action reference format
545 if ($parts.Count -lt 2) {
546 Write-SecurityLog "Invalid action reference format: $ActionRef - must be 'owner/repo' or 'owner/repo/path'" -Level 'Warning'
547 Write-SecurityLog "CAUSE: Malformed action path missing owner or repository name" -Level 'Warning'
548 Write-SecurityLog "SOLUTION: Verify action reference follows GitHub Actions format (e.g., actions/checkout@v4)" -Level 'Warning'
549 return $null
550 }
551
552 $owner = $parts[0]
553 $repo = $parts[1]
554
555 Write-SecurityLog "Checking for updates: $actionPath (current: $($currentSHA.Substring(0,8))...)" -Level 'Info'
556
557 # Fetch latest SHA from GitHub
558 $latestSHA = Get-LatestCommitSHA -Owner $owner -Repo $repo
559
560 if ($latestSHA -and $latestSHA -ne $currentSHA) {
561 Write-SecurityLog "Update available: $actionPath ($($currentSHA.Substring(0,8))... -> $($latestSHA.Substring(0,8))...)" -Level 'Success'
562 return "$actionPath@$latestSHA"
563 }
564 elseif ($latestSHA -eq $currentSHA) {
565 Write-SecurityLog "Already up-to-date: $actionPath" -Level 'Info'
566 }
567 elseif (-not $latestSHA) {
568 Write-SecurityLog "Failed to fetch latest SHA for $actionPath - keeping current SHA (likely rate limited)" -Level 'Warning'
569 }
570
571 return $ActionRef
572 }
573 }
574
575 Write-SecurityLog "Action already SHA-pinned: $ActionRef" -Level 'Info'
576 return $ActionRef
577 }
578
579 # Look up in pre-defined SHA map
580 if ($ActionSHAMap.ContainsKey($ActionRef)) {
581 $pinnedRef = $ActionSHAMap[$ActionRef]
582
583 # If UpdateStale is enabled, check if we should fetch the latest SHA instead
584 if ($UpdateStale) {
585 # Extract owner/repo from the pinned reference
586 if ($pinnedRef -match '^([^/]+/[^/@]+)@([a-fA-F0-9]{40})$') {
587 $actionPath = $matches[1]
588 $mappedSHA = $matches[2]
589
590 $parts = $actionPath -split '/'
591 $owner = $parts[0]
592 $repo = $parts[1]
593
594 Write-SecurityLog "Checking ActionSHAMap entry for updates: $ActionRef (mapped: $($mappedSHA.Substring(0,8))...)" -Level 'Info'
595
596 # Fetch latest SHA from GitHub
597 $latestSHA = Get-LatestCommitSHA -Owner $owner -Repo $repo
598
599 if ($latestSHA -and $latestSHA -ne $mappedSHA) {
600 Write-SecurityLog "Update available for mapping: $ActionRef ($($mappedSHA.Substring(0,8))... -> $($latestSHA.Substring(0,8))...)" -Level 'Success' | Out-Null
601 return "$actionPath@$latestSHA"
602 }
603 elseif ($latestSHA -eq $mappedSHA) {
604 Write-SecurityLog "ActionSHAMap entry up-to-date: $ActionRef" -Level 'Info' | Out-Null
605 }
606 elseif (-not $latestSHA) {
607 Write-SecurityLog "Failed to fetch latest SHA for $ActionRef mapping - keeping mapped SHA (likely rate limited)" -Level 'Warning' | Out-Null
608 }
609 }
610 }
611
612 Write-SecurityLog "Found SHA mapping: $ActionRef -> $pinnedRef" -Level 'Success'
613 return $pinnedRef
614 }
615
616 # For unmapped actions, suggest manual review
617 Write-SecurityLog "No SHA mapping found for: $ActionRef - requires manual review" -Level 'Warning'
618 return $null
619}
620
621function Update-WorkflowFile {
622 [CmdletBinding(SupportsShouldProcess)]
623 [OutputType([PSCustomObject])]
624 param(
625 [Parameter(Mandatory)]
626 [string]$FilePath
627 )
628
629 Write-SecurityLog "Processing workflow: $FilePath" -Level 'Info'
630
631 try {
632 $content = Get-Content -Path $FilePath -Raw
633 $originalContent = $content
634 $actions = Get-ActionReference -WorkflowContent $content
635
636 if (@($actions).Count -eq 0) {
637 Write-SecurityLog "No GitHub Actions found in $FilePath" -Level 'Info'
638 return [PSCustomObject]@{
639 FilePath = $FilePath
640 ActionsProcessed = 0
641 ActionsPinned = 0
642 ActionsSkipped = 0
643 Changes = @()
644 }
645 }
646
647 $changes = @()
648 $actionsPinned = 0
649 $actionsSkipped = 0
650
651 # Sort by StartIndex in descending order to avoid offset issues
652 $sortedActions = $actions | Sort-Object StartIndex -Descending
653
654 foreach ($action in $sortedActions) {
655 $originalRef = $action.OriginalRef
656 $pinnedRef = Get-SHAForAction -ActionRef $originalRef
657
658 if ($pinnedRef -and $pinnedRef -ne $originalRef) {
659 # Replace the action reference
660 $content = $content.Substring(0, $action.StartIndex) + $pinnedRef + $content.Substring($action.StartIndex + $action.Length)
661
662 $changes += @{
663 LineNumber = $action.LineNumber
664 Original = $originalRef
665 Pinned = $pinnedRef
666 ChangeType = 'SHA-Pinned'
667 }
668 $actionsPinned++
669 Write-SecurityLog "Pinned: $originalRef -> $pinnedRef" -Level 'Success' | Out-Null
670 }
671 elseif ($pinnedRef -eq $originalRef) {
672 $changes += @{
673 LineNumber = $action.LineNumber
674 Original = $originalRef
675 Pinned = $originalRef
676 ChangeType = 'Already-Pinned'
677 }
678 }
679 else {
680 $changes += @{
681 LineNumber = $action.LineNumber
682 Original = $originalRef
683 Pinned = $null
684 ChangeType = 'Requires-Manual-Review'
685 }
686 $actionsSkipped++
687 }
688 }
689
690 # Write updated content if changes were made and not in WhatIf mode
691 if ($content -ne $originalContent) {
692 if ($PSCmdlet.ShouldProcess($FilePath, "Update SHA pinning")) {
693 Set-ContentPreservePermission -Path $FilePath -Value $content -NoNewline
694 Write-SecurityLog "Updated workflow file: $FilePath" -Level 'Success'
695 }
696 }
697
698 return [PSCustomObject]@{
699 FilePath = $FilePath
700 ActionsProcessed = @($actions).Count
701 ActionsPinned = $actionsPinned
702 ActionsSkipped = $actionsSkipped
703 Changes = $changes
704 ContentChanged = ($content -ne $originalContent)
705 }
706 }
707 catch {
708 Write-SecurityLog "Error processing $FilePath : $($_.Exception.Message)" -Level 'Error'
709 return [PSCustomObject]@{
710 FilePath = $FilePath
711 ActionsProcessed = 0
712 ActionsPinned = 0
713 ActionsSkipped = 0
714 Changes = @()
715 ContentChanged = $false
716 Error = $_.Exception.Message
717 }
718 }
719}
720
721function Export-SecurityReport {
722 param(
723 [Parameter(Mandatory)]
724 [array]$Results
725 )
726
727 $reportPath = "scripts/security/sha-pinning-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
728
729 $sumActionsProcessed = 0
730 $sumActionsPinned = 0
731 $sumActionsSkipped = 0
732 foreach ($result in $Results) {
733 if ($result -is [hashtable]) {
734 if ($result.ContainsKey('ActionsProcessed') -and $null -ne $result['ActionsProcessed']) {
735 $sumActionsProcessed += [int]$result['ActionsProcessed']
736 }
737 if ($result.ContainsKey('ActionsPinned') -and $null -ne $result['ActionsPinned']) {
738 $sumActionsPinned += [int]$result['ActionsPinned']
739 }
740 if ($result.ContainsKey('ActionsSkipped') -and $null -ne $result['ActionsSkipped']) {
741 $sumActionsSkipped += [int]$result['ActionsSkipped']
742 }
743 }
744 else {
745 if ($result.PSObject.Properties.Name -contains 'ActionsProcessed' -and $null -ne $result.ActionsProcessed) {
746 $sumActionsProcessed += [int]$result.ActionsProcessed
747 }
748 if ($result.PSObject.Properties.Name -contains 'ActionsPinned' -and $null -ne $result.ActionsPinned) {
749 $sumActionsPinned += [int]$result.ActionsPinned
750 }
751 if ($result.PSObject.Properties.Name -contains 'ActionsSkipped' -and $null -ne $result.ActionsSkipped) {
752 $sumActionsSkipped += [int]$result.ActionsSkipped
753 }
754 }
755 }
756
757 $report = @{
758 GeneratedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC"
759 Summary = @{
760 TotalWorkflows = @($Results).Count
761 WorkflowsChanged = @($Results | Where-Object { $_.PSObject.Properties.Name -contains 'ContentChanged' -and $_.ContentChanged }).Count
762 TotalActions = $sumActionsProcessed
763 ActionsPinned = $sumActionsPinned
764 ActionsSkipped = $sumActionsSkipped
765 }
766 WorkflowResults = $Results
767 SHAMappings = $ActionSHAMap
768 }
769
770 $report | ConvertTo-Json -Depth 10 | Set-Content -Path $reportPath
771 Write-SecurityLog "Security report exported to: $reportPath" -Level 'Success'
772
773 return $reportPath
774}
775
776# Add Set-ContentPreservePermission function for cross-platform compatibility
777function Set-ContentPreservePermission {
778 [CmdletBinding(SupportsShouldProcess)]
779 param(
780 [Parameter(Mandatory = $true)]
781 [string]$Path,
782
783 [Parameter(Mandatory = $true)]
784 [string]$Value,
785
786 [Parameter(Mandatory = $false)]
787 [switch]$NoNewline
788 )
789
790 # Get original file permissions before writing
791 $OriginalMode = $null
792 if (Test-Path $Path) {
793 try {
794 # Get file mode using Get-Item (cross-platform)
795 $item = Get-Item -Path $Path -ErrorAction SilentlyContinue
796 if ($item -and $item.Mode) {
797 $OriginalMode = $item.Mode
798 }
799 }
800 catch {
801 Write-SecurityLog "Warning: Could not determine original file permissions for $Path" -Level 'Warning'
802 }
803 }
804
805 # Write content
806 if ($NoNewline) {
807 Set-Content -Path $Path -Value $Value -NoNewline
808 }
809 else {
810 Set-Content -Path $Path -Value $Value
811 }
812
813 # Restore original permissions if they were executable
814 if ($OriginalMode -and $OriginalMode -match '^-rwxr-xr-x') {
815 try {
816 & chmod +x $Path 2>$null
817 if ($LASTEXITCODE -eq 0) {
818 Write-SecurityLog "Restored execute permissions for $Path" -Level 'Info'
819 }
820 }
821 catch {
822 Write-SecurityLog "Warning: Could not restore execute permissions for $Path" -Level 'Warning'
823 }
824 }
825}
826
827#region Main Execution
828
829function Invoke-ActionSHAPinningUpdate {
830 [CmdletBinding(SupportsShouldProcess)]
831 [OutputType([void])]
832 param(
833 [Parameter()]
834 [string]$WorkflowPath = ".github/workflows",
835
836 [Parameter()]
837 [switch]$OutputReport,
838
839 [Parameter()]
840 [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
841 [string]$OutputFormat = "console",
842
843 [Parameter()]
844 [switch]$UpdateStale
845 )
846
847 Set-StrictMode -Version Latest
848
849 if ($UpdateStale) {
850 Write-SecurityLog "Starting GitHub Actions SHA update process (updating stale pins)..." -Level 'Info'
851 }
852 else {
853 Write-SecurityLog "Starting GitHub Actions SHA pinning process..." -Level 'Info'
854 }
855
856 if (-not (Test-Path -Path $WorkflowPath)) {
857 throw "Workflow path not found: $WorkflowPath"
858 }
859
860 $workflowFiles = Get-ChildItem -Path $WorkflowPath -Filter "*.yml" -File
861
862 if (@($workflowFiles).Count -eq 0) {
863 Write-SecurityLog "No YAML workflow files found in $WorkflowPath" -Level 'Warning'
864 return
865 }
866
867 Write-SecurityLog "Found $(@($workflowFiles).Count) workflow files" -Level 'Info'
868
869 $results = @()
870 foreach ($workflowFile in $workflowFiles) {
871 $result = Update-WorkflowFile -FilePath $workflowFile.FullName
872 $results += $result
873 }
874
875 $totalActions = ($results | Measure-Object ActionsProcessed -Sum).Sum
876 $totalPinned = ($results | Measure-Object ActionsPinned -Sum).Sum
877 $totalSkipped = ($results | Measure-Object ActionsSkipped -Sum).Sum
878 $workflowsChanged = @($results | Where-Object { $_.PSObject.Properties.Name -contains 'ContentChanged' -and $_.ContentChanged }).Count
879
880 Write-SecurityLog "" -Level 'Info'
881 Write-SecurityLog "=== SHA Pinning Summary ===" -Level 'Info'
882 Write-SecurityLog "Workflows processed: $(@($workflowFiles).Count)" -Level 'Info'
883 Write-SecurityLog "Workflows changed: $workflowsChanged" -Level 'Success'
884 Write-SecurityLog "Total actions found: $totalActions" -Level 'Info'
885 Write-SecurityLog "Actions SHA-pinned: $totalPinned" -Level 'Success'
886 Write-SecurityLog "Actions requiring manual review: $totalSkipped" -Level 'Warning'
887
888 if ($OutputReport) {
889 $reportPath = Export-SecurityReport -Results $results
890 Write-SecurityLog "Detailed report available at: $reportPath" -Level 'Info'
891 }
892
893 $manualReviewActions = @()
894 foreach ($result in $results) {
895 if ($result.PSObject.Properties.Name -contains 'Changes') {
896 foreach ($change in $result.Changes) {
897 if ($change.ChangeType -eq 'Requires-Manual-Review') {
898 $manualReviewActions += @{
899 Original = $change.Original
900 WorkflowFile = $result.FilePath
901 LineNumber = $change.LineNumber
902 }
903 }
904 }
905 }
906 }
907
908 if ($manualReviewActions) {
909 Write-SecurityLog "" -Level 'Info'
910 Write-SecurityLog "=== Actions Requiring Manual SHA Pinning ===" -Level 'Warning'
911 foreach ($action in $manualReviewActions) {
912 Write-SecurityLog " - $($action.Original)" -Level 'Warning'
913
914 $SecurityIssues.Add((New-SecurityIssue -Type "GitHub Actions Security" `
915 -Severity "Medium" `
916 -Title "Unpinned GitHub Action" `
917 -Description "Action '$($action.Original)' requires manual SHA pinning for supply chain security" `
918 -File $action.WorkflowFile `
919 -Recommendation "Research the action's repository and add SHA mapping to ActionSHAMap"))
920 }
921 Write-SecurityLog "Please research and add SHA mappings for these actions manually." -Level 'Warning'
922 }
923
924 $summaryText = "Processed $(@($workflowFiles).Count) workflows, pinned $totalPinned actions, $totalSkipped require manual review"
925 Write-SecurityOutput -OutputFormat $OutputFormat -Results $SecurityIssues -Summary $summaryText
926
927 if ($WhatIfPreference) {
928 Write-SecurityLog "" -Level 'Info'
929 Write-SecurityLog "WhatIf mode: No files were modified. Run without -WhatIf to apply changes." -Level 'Info'
930 }
931}
932
933if ($MyInvocation.InvocationName -ne '.') {
934 try {
935 Invoke-ActionSHAPinningUpdate -WorkflowPath $WorkflowPath -OutputReport:$OutputReport -OutputFormat $OutputFormat -UpdateStale:$UpdateStale
936 exit 0
937 }
938 catch {
939 Write-Error -ErrorAction Continue "Update-ActionSHAPinning failed: $($_.Exception.Message)"
940 Write-CIAnnotation -Message $_.Exception.Message -Level Error
941 exit 1
942 }
943}
944
945#endregion Main Execution
946