microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/address-powershell-test-comments

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

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