microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v2.0.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

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