microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/fix-copilot-code-review

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

910lines · modecode

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