microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/sub-pr-185

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

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