microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
chore/694-pr-skill-tests

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Update-ActionSHAPinning.ps1

1027lines · modecode

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