microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/fix-broken-file-references

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

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