microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/security/Test-SHAStaleness.ps1
973lines · 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 | Monitors SHA-pinned dependencies for staleness and security vulnerabilities. |
| 9 | |
| 10 | .DESCRIPTION |
| 11 | This script scans all SHA-pinned dependencies across GitHub Actions workflows |
| 12 | to identify stale or potentially vulnerable dependencies. It outputs results in structured formats |
| 13 | that can be consumed by CI/CD systems to generate build warnings. |
| 14 | |
| 15 | Key features: |
| 16 | - Detects outdated GitHub Actions SHAs |
| 17 | - Outputs results for CI/CD integration |
| 18 | - Supports multiple output formats (JSON, Azure DevOps, GitHub Actions) |
| 19 | |
| 20 | .PARAMETER OutputFormat |
| 21 | Output format: 'json', 'azdo', 'github', or 'console' (default: console) |
| 22 | |
| 23 | .PARAMETER MaxAge |
| 24 | Maximum age in days before considering a dependency stale (default: 30) |
| 25 | |
| 26 | .PARAMETER LogPath |
| 27 | Path for security logging (default: ./logs/sha-staleness-monitoring.log) |
| 28 | |
| 29 | .PARAMETER OutputPath |
| 30 | Path to write structured output file (default: ./logs/stale-dependencies.json) |
| 31 | |
| 32 | .EXAMPLE |
| 33 | ./Test-SHAStaleness.ps1 -OutputFormat github |
| 34 | Check for stale SHAs and output GitHub Actions warnings |
| 35 | |
| 36 | .EXAMPLE |
| 37 | ./Test-SHAStaleness.ps1 -OutputFormat azdo -MaxAge 14 |
| 38 | Check for stale SHAs and output Azure DevOps warnings for dependencies older than 14 days |
| 39 | |
| 40 | .EXAMPLE |
| 41 | ./Test-SHAStaleness.ps1 -OutputFormat json -OutputPath ./security-report.json |
| 42 | Generate JSON report of all stale dependencies |
| 43 | |
| 44 | .EXAMPLE |
| 45 | ./Test-SHAStaleness.ps1 -FailOnStale |
| 46 | Fail the build if stale dependencies are found |
| 47 | |
| 48 | .EXAMPLE |
| 49 | ./Test-SHAStaleness.ps1 -GraphQLBatchSize 10 |
| 50 | Use smaller GraphQL batch size for rate-limited environments |
| 51 | #> |
| 52 | |
| 53 | [CmdletBinding()] |
| 54 | param( |
| 55 | [Parameter(Mandatory = $false)] |
| 56 | [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")] |
| 57 | [string]$OutputFormat = "console", |
| 58 | |
| 59 | [Parameter(Mandatory = $false)] |
| 60 | [int]$MaxAge = 30, |
| 61 | |
| 62 | [Parameter(Mandatory = $false)] |
| 63 | [string]$LogPath = "./logs/sha-staleness-monitoring.log", |
| 64 | |
| 65 | [Parameter(Mandatory = $false)] |
| 66 | [string]$OutputPath = "./logs/sha-staleness-results.json", |
| 67 | |
| 68 | [Parameter(Mandatory = $false)] |
| 69 | [switch]$FailOnStale, |
| 70 | |
| 71 | [Parameter(Mandatory = $false)] |
| 72 | [ValidateRange(1, 50)] |
| 73 | [int]$GraphQLBatchSize = 20 |
| 74 | ) |
| 75 | |
| 76 | $ErrorActionPreference = 'Stop' |
| 77 | |
| 78 | # Import CIHelpers for workflow command escaping |
| 79 | Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force |
| 80 | Import-Module (Join-Path $PSScriptRoot 'Modules/SecurityHelpers.psm1') -Force |
| 81 | |
| 82 | # Route Write-SecurityLog output through script-scoped format and log path |
| 83 | $PSDefaultParameterValues['Write-SecurityLog:OutputFormat'] = $OutputFormat |
| 84 | $PSDefaultParameterValues['Write-SecurityLog:LogPath'] = $LogPath |
| 85 | |
| 86 | # Script-scope collection of stale dependencies (used by multiple functions) |
| 87 | $script:StaleDependencies = [System.Collections.Generic.List[PSCustomObject]]::new() |
| 88 | |
| 89 | function Test-GitHubToken { |
| 90 | param( |
| 91 | [Parameter(Mandatory = $false)] |
| 92 | [string]$Token |
| 93 | ) |
| 94 | |
| 95 | if (-not $Token) { |
| 96 | Write-SecurityLog "No GitHub token found" -Level Warning |
| 97 | Write-SecurityLog "SOLUTION: Set GITHUB_TOKEN environment variable for higher rate limits (5,000 vs 60 points/hour)" -Level Warning |
| 98 | Write-SecurityLog "CAUSE: Unauthenticated GitHub GraphQL API requests are heavily rate limited" -Level Info |
| 99 | return @{ |
| 100 | Valid = $false |
| 101 | Authenticated = $false |
| 102 | RateLimit = 60 |
| 103 | Message = "No token provided - using unauthenticated API with 60 points/hour rate limit" |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | try { |
| 108 | $headers = @{ |
| 109 | "Authorization" = "Bearer $Token" |
| 110 | "Content-Type" = "application/json" |
| 111 | } |
| 112 | |
| 113 | $query = @{ |
| 114 | query = "query { viewer { login } rateLimit { limit remaining resetAt } }" |
| 115 | } | ConvertTo-Json |
| 116 | |
| 117 | $response = Invoke-RestMethod -Uri "https://api.github.com/graphql" -Method POST -Headers $headers -Body $query -ErrorAction Stop |
| 118 | |
| 119 | if ($response.data.viewer) { |
| 120 | $rateLimit = $response.data.rateLimit |
| 121 | Write-SecurityLog "GitHub token validated - User: $($response.data.viewer.login), Rate limit: $($rateLimit.remaining)/$($rateLimit.limit)" -Level Success |
| 122 | |
| 123 | if ($rateLimit.remaining -lt 100) { |
| 124 | Write-SecurityLog "WARNING: GitHub API rate limit running low ($($rateLimit.remaining) remaining)" -Level Warning |
| 125 | Write-SecurityLog "Rate limit resets at: $($rateLimit.resetAt)" -Level Info |
| 126 | } |
| 127 | |
| 128 | return @{ |
| 129 | Valid = $true |
| 130 | Authenticated = $true |
| 131 | RateLimit = $rateLimit.limit |
| 132 | Remaining = $rateLimit.remaining |
| 133 | ResetAt = $rateLimit.resetAt |
| 134 | User = $response.data.viewer.login |
| 135 | Message = "Token valid - authenticated as $($response.data.viewer.login)" |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | catch { |
| 140 | Write-SecurityLog "GitHub token validation failed: $($_.Exception.Message)" -Level Error |
| 141 | Write-SecurityLog "CAUSE: Token may be expired, revoked, or have insufficient permissions" -Level Warning |
| 142 | Write-SecurityLog "SOLUTION: Generate a new GitHub token with 'repo' scope at https://github.com/settings/tokens" -Level Warning |
| 143 | return @{ |
| 144 | Valid = $false |
| 145 | Authenticated = $false |
| 146 | Message = "Token validation failed: $($_.Exception.Message)" |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | return @{ |
| 151 | Valid = $false |
| 152 | Authenticated = $false |
| 153 | Message = "Token validation returned unexpected response" |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | function Invoke-GitHubAPIWithRetry { |
| 158 | param( |
| 159 | [Parameter(Mandatory = $true)] |
| 160 | [string]$Uri, |
| 161 | |
| 162 | [Parameter(Mandatory = $true)] |
| 163 | [string]$Method, |
| 164 | |
| 165 | [Parameter(Mandatory = $true)] |
| 166 | [hashtable]$Headers, |
| 167 | |
| 168 | [Parameter(Mandatory = $false)] |
| 169 | [string]$Body, |
| 170 | |
| 171 | [Parameter(Mandatory = $false)] |
| 172 | [int]$MaxRetries = 3, |
| 173 | |
| 174 | [Parameter(Mandatory = $false)] |
| 175 | [int]$InitialDelaySeconds = 5 |
| 176 | ) |
| 177 | |
| 178 | $attempt = 0 |
| 179 | $delay = $InitialDelaySeconds |
| 180 | |
| 181 | while ($attempt -lt $MaxRetries) { |
| 182 | try { |
| 183 | if ($Body) { |
| 184 | $response = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers -Body $Body -ContentType "application/json" -ErrorAction Stop |
| 185 | } |
| 186 | else { |
| 187 | $response = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers -ErrorAction Stop |
| 188 | } |
| 189 | return $response |
| 190 | } |
| 191 | catch { |
| 192 | $statusCode = $null |
| 193 | if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { |
| 194 | $statusCode = [int]$_.Exception.Response.StatusCode |
| 195 | } |
| 196 | |
| 197 | # Check if it's a rate limit error (403 or 429) |
| 198 | if ($statusCode -in 403, 429) { |
| 199 | $attempt++ |
| 200 | if ($attempt -lt $MaxRetries) { |
| 201 | Write-SecurityLog "GitHub API rate limit hit (HTTP $statusCode). Retrying in $delay seconds (attempt $attempt/$MaxRetries)..." -Level Warning |
| 202 | Start-Sleep -Seconds $delay |
| 203 | $delay = $delay * 2 # Exponential backoff |
| 204 | } |
| 205 | else { |
| 206 | Write-SecurityLog "GitHub API rate limit exceeded after $MaxRetries attempts" -Level Error |
| 207 | Write-SecurityLog "CAUSE: Too many API requests in a short time period" -Level Warning |
| 208 | Write-SecurityLog "SOLUTION: Wait for rate limit to reset or provide a GitHub token with higher limits" -Level Warning |
| 209 | throw |
| 210 | } |
| 211 | } |
| 212 | else { |
| 213 | # Non-rate-limit error, throw immediately |
| 214 | Write-SecurityLog "GitHub API request failed: $($_.Exception.Message)" -Level Error |
| 215 | throw |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | throw "Failed to complete GitHub API request after $MaxRetries retries" |
| 221 | } |
| 222 | |
| 223 | function Get-BulkGitHubActionsStaleness { |
| 224 | param( |
| 225 | [Parameter(Mandatory = $true)] |
| 226 | [array]$ActionRepos, |
| 227 | |
| 228 | [Parameter(Mandatory = $true)] |
| 229 | [hashtable]$ShaToActionMap, |
| 230 | |
| 231 | [int]$BatchSize = 20 |
| 232 | ) |
| 233 | |
| 234 | # Setup headers with authentication |
| 235 | $headers = @{ |
| 236 | "Content-Type" = "application/json" |
| 237 | } |
| 238 | |
| 239 | # Check multiple potential sources for GitHub token |
| 240 | $githubToken = $null |
| 241 | if ($env:GITHUB_TOKEN) { |
| 242 | $githubToken = $env:GITHUB_TOKEN |
| 243 | } |
| 244 | elseif ($env:SYSTEM_ACCESSTOKEN -and $env:BUILD_REPOSITORY_PROVIDER -eq "GitHub") { |
| 245 | $githubToken = $env:SYSTEM_ACCESSTOKEN |
| 246 | } |
| 247 | elseif ($env:GH_TOKEN) { |
| 248 | $githubToken = $env:GH_TOKEN |
| 249 | } |
| 250 | |
| 251 | # Validate token if provided |
| 252 | $tokenStatus = Test-GitHubToken -Token $githubToken |
| 253 | if ($tokenStatus.Valid) { |
| 254 | $headers['Authorization'] = "Bearer $githubToken" |
| 255 | } |
| 256 | elseif ($githubToken) { |
| 257 | Write-SecurityLog "Token validation failed, proceeding without authentication" -Level Warning |
| 258 | } |
| 259 | |
| 260 | # Build GraphQL query for multiple repositories (batch 1: get default branches) |
| 261 | $repoQueries = @() |
| 262 | $aliasMap = @{} |
| 263 | |
| 264 | foreach ($i in 0..($ActionRepos.Count - 1)) { |
| 265 | $repo = $ActionRepos[$i] |
| 266 | $alias = "repo$i" |
| 267 | $aliasMap[$alias] = $repo |
| 268 | |
| 269 | # Parse owner/repo (handle actions with subpaths like github/codeql-action/upload-sarif) |
| 270 | $parts = $repo.Split('/') |
| 271 | if ($parts.Count -lt 2) { continue } |
| 272 | $owner = $parts[0] |
| 273 | $repoName = $parts[1] |
| 274 | |
| 275 | $repoQueries += @" |
| 276 | $alias`: repository(owner: "$owner", name: "$repoName") { |
| 277 | name |
| 278 | defaultBranchRef { |
| 279 | target { |
| 280 | ... on Commit { |
| 281 | oid |
| 282 | committedDate |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | "@ |
| 288 | } |
| 289 | |
| 290 | # Single GraphQL query for all repository default branches |
| 291 | $graphqlQuery = @{ |
| 292 | query = @" |
| 293 | query { |
| 294 | $($repoQueries -join "`n ") |
| 295 | rateLimit { |
| 296 | limit |
| 297 | remaining |
| 298 | used |
| 299 | resetAt |
| 300 | } |
| 301 | } |
| 302 | "@ |
| 303 | } | ConvertTo-Json -Depth 10 |
| 304 | |
| 305 | try { |
| 306 | $repoResponse = Invoke-GitHubAPIWithRetry -Uri "https://api.github.com/graphql" -Method POST -Headers $headers -Body $graphqlQuery |
| 307 | |
| 308 | Write-SecurityLog "GraphQL Rate Limit: $($repoResponse.data.rateLimit.remaining)/$($repoResponse.data.rateLimit.limit) remaining" -Level Info |
| 309 | |
| 310 | if ($repoResponse.errors) { |
| 311 | Write-SecurityLog "GraphQL errors: $($repoResponse.errors | ConvertTo-Json)" -Level Warning |
| 312 | } |
| 313 | } |
| 314 | catch { |
| 315 | $statusCode = $null |
| 316 | if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { |
| 317 | $statusCode = [int]$_.Exception.Response.StatusCode |
| 318 | } |
| 319 | |
| 320 | if ($statusCode -in 403, 429) { |
| 321 | Write-SecurityLog "Repository GraphQL query hit rate limit ($statusCode). Falling back to REST checks." -Level Warning |
| 322 | Write-SecurityLog "SOLUTION: Provide a GitHub token via GITHUB_TOKEN environment variable for higher rate limits" -Level Warning |
| 323 | } |
| 324 | else { |
| 325 | Write-SecurityLog "Repository GraphQL query failed: $($_.Exception.Message)" -Level Error |
| 326 | Write-SecurityLog "CAUSE: Network connectivity issue or GitHub API unavailable" -Level Warning |
| 327 | } |
| 328 | |
| 329 | throw |
| 330 | } |
| 331 | |
| 332 | # Collect commit queries for all current SHAs |
| 333 | $commitQueries = @() |
| 334 | $commitAliasMap = @{} |
| 335 | $commitIndex = 0 |
| 336 | |
| 337 | foreach ($key in $ShaToActionMap.Keys) { |
| 338 | $action = $ShaToActionMap[$key] |
| 339 | $alias = "commit$commitIndex" |
| 340 | $commitAliasMap[$alias] = $key |
| 341 | |
| 342 | # Parse owner/repo (handle actions with subpaths like github/codeql-action/upload-sarif) |
| 343 | $parts = $action.Repo.Split('/') |
| 344 | if ($parts.Count -lt 2) { |
| 345 | Write-SecurityLog "Invalid action repository format: $($action.Repo) - must be 'owner/repo'" -Level Warning |
| 346 | Write-SecurityLog "SOLUTION: Verify action reference in workflow file follows correct format" -Level Warning |
| 347 | continue |
| 348 | } |
| 349 | $owner = $parts[0] |
| 350 | $repoName = $parts[1] |
| 351 | |
| 352 | $commitQueries += @" |
| 353 | $alias`: repository(owner: "$owner", name: "$repoName") { |
| 354 | object(oid: "$($action.SHA)") { |
| 355 | ... on Commit { |
| 356 | oid |
| 357 | committedDate |
| 358 | } |
| 359 | } |
| 360 | } |
| 361 | "@ |
| 362 | $commitIndex++ |
| 363 | } |
| 364 | |
| 365 | # Use configurable batch size from script parameter |
| 366 | $allCommitResults = @{} |
| 367 | |
| 368 | for ($i = 0; $i -lt $commitQueries.Count; $i += $BatchSize) { |
| 369 | $endIndex = [Math]::Min($i + $BatchSize - 1, $commitQueries.Count - 1) |
| 370 | $batchQueries = $commitQueries[$i..$endIndex] |
| 371 | |
| 372 | $commitGraphqlQuery = @{ |
| 373 | query = @" |
| 374 | query { |
| 375 | $($batchQueries -join "`n ") |
| 376 | rateLimit { |
| 377 | remaining |
| 378 | cost |
| 379 | } |
| 380 | } |
| 381 | "@ |
| 382 | } | ConvertTo-Json -Depth 10 |
| 383 | |
| 384 | try { |
| 385 | $commitResponse = Invoke-GitHubAPIWithRetry -Uri "https://api.github.com/graphql" -Method POST -Headers $headers -Body $commitGraphqlQuery |
| 386 | |
| 387 | # Merge results |
| 388 | foreach ($property in $commitResponse.data.PSObject.Properties) { |
| 389 | if ($property.Name -ne "rateLimit") { |
| 390 | $allCommitResults[$property.Name] = $property.Value |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | Write-SecurityLog "GraphQL batch $([Math]::Floor($i / $BatchSize) + 1): Cost $($commitResponse.data.rateLimit.cost), $($commitResponse.data.rateLimit.remaining) remaining" -Level Info |
| 395 | } |
| 396 | catch { |
| 397 | Write-SecurityLog "Commit GraphQL batch query failed: $($_.Exception.Message)" -Level Warning |
| 398 | Write-SecurityLog "CAUSE: Network connectivity issue, rate limit exhausted, or malformed query" -Level Warning |
| 399 | Write-SecurityLog "SOLUTION: Check GitHub API status or reduce -GraphQLBatchSize parameter (current: $BatchSize)" -Level Warning |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | # Process results and return staleness information |
| 404 | $results = @() |
| 405 | |
| 406 | foreach ($key in $ShaToActionMap.Keys) { |
| 407 | $action = $ShaToActionMap[$key] |
| 408 | |
| 409 | # Find repository data |
| 410 | $repoAlias = $null |
| 411 | for ($i = 0; $i -lt $ActionRepos.Count; $i++) { |
| 412 | if ($ActionRepos[$i] -eq $action.Repo) { |
| 413 | $repoAlias = "repo$i" |
| 414 | break |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | if (-not $repoAlias -or -not $repoResponse.data.$repoAlias) { |
| 419 | Write-SecurityLog "No repository data found for $($action.Repo)" -Level Warning |
| 420 | continue |
| 421 | } |
| 422 | |
| 423 | $repoData = $repoResponse.data.$repoAlias |
| 424 | if (-not $repoData.defaultBranchRef) { |
| 425 | Write-SecurityLog "No default branch found for $($action.Repo)" -Level Warning |
| 426 | continue |
| 427 | } |
| 428 | |
| 429 | $latestSHA = $repoData.defaultBranchRef.target.oid |
| 430 | $latestDate = [DateTime]::Parse($repoData.defaultBranchRef.target.committedDate) |
| 431 | |
| 432 | # Find current commit data |
| 433 | $commitAlias = $null |
| 434 | foreach ($alias in $commitAliasMap.Keys) { |
| 435 | if ($commitAliasMap[$alias] -eq $key) { |
| 436 | $commitAlias = $alias |
| 437 | break |
| 438 | } |
| 439 | } |
| 440 | |
| 441 | if ($commitAlias -and $allCommitResults[$commitAlias] -and $allCommitResults[$commitAlias].object) { |
| 442 | $currentCommit = $allCommitResults[$commitAlias].object |
| 443 | $currentDate = [DateTime]::Parse($currentCommit.committedDate) |
| 444 | $daysOld = [Math]::Round((Get-Date).Subtract($currentDate).TotalDays) |
| 445 | |
| 446 | $results += @{ |
| 447 | ActionRepo = $action.Repo |
| 448 | CurrentSHA = $action.SHA |
| 449 | LatestSHA = $latestSHA |
| 450 | CurrentDate = $currentDate |
| 451 | LatestDate = $latestDate |
| 452 | DaysOld = $daysOld |
| 453 | IsStale = $action.SHA -ne $latestSHA -and $daysOld -gt $MaxAge |
| 454 | File = $action.File |
| 455 | } |
| 456 | } |
| 457 | else { |
| 458 | Write-SecurityLog "No commit data found for $($action.Repo)@$($action.SHA)" -Level Warning |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | $totalCalls = 1 + [Math]::Ceiling($commitQueries.Count / $BatchSize) |
| 463 | $originalCalls = $ShaToActionMap.Count * 3 |
| 464 | $reduction = [Math]::Round((1 - ($totalCalls / $originalCalls)) * 100, 1) |
| 465 | |
| 466 | Write-SecurityLog "GraphQL optimization: Reduced from ~$originalCalls REST calls to $totalCalls GraphQL calls ($reduction% reduction)" -Level Success |
| 467 | |
| 468 | return $results |
| 469 | } |
| 470 | |
| 471 | function Test-GitHubActionsForStaleness { |
| 472 | Write-SecurityLog "Scanning GitHub Actions workflows for stale SHAs..." -Level Info |
| 473 | |
| 474 | $WorkflowFiles = Get-ChildItem -Path ".github/workflows" -Filter "*.yml" -ErrorAction SilentlyContinue |
| 475 | $allActionRepos = @() |
| 476 | $shaToActionMap = @{} |
| 477 | |
| 478 | # First pass: collect all unique repositories and SHAs |
| 479 | foreach ($File in $WorkflowFiles) { |
| 480 | $Content = Get-Content -Path $File.FullName -Raw |
| 481 | $SHAMatches = [regex]::Matches($Content, "uses:\s*([^@\s]+)@([a-fA-F0-9]{40})") |
| 482 | |
| 483 | foreach ($Match in $SHAMatches) { |
| 484 | $ActionRepo = $Match.Groups[1].Value |
| 485 | $CurrentSHA = $Match.Groups[2].Value |
| 486 | |
| 487 | if ($ActionRepo -notin $allActionRepos) { |
| 488 | $allActionRepos += $ActionRepo |
| 489 | } |
| 490 | |
| 491 | $shaToActionMap["$ActionRepo@$CurrentSHA"] = @{ |
| 492 | Repo = $ActionRepo |
| 493 | SHA = $CurrentSHA |
| 494 | File = $File.FullName |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | if (@($allActionRepos).Count -eq 0) { |
| 500 | Write-SecurityLog "No SHA-pinned GitHub Actions found" -Level Info |
| 501 | return |
| 502 | } |
| 503 | |
| 504 | Write-SecurityLog "Found $(@($allActionRepos).Count) unique repositories with $(@($shaToActionMap.Keys).Count) SHA-pinned actions" -Level Info |
| 505 | |
| 506 | # Bulk query for all actions using GraphQL optimization |
| 507 | try { |
| 508 | $bulkResults = Get-BulkGitHubActionsStaleness -ActionRepos $allActionRepos -ShaToActionMap $shaToActionMap -BatchSize $GraphQLBatchSize |
| 509 | |
| 510 | foreach ($result in $bulkResults) { |
| 511 | if ($result.IsStale) { |
| 512 | $script:StaleDependencies.Add([PSCustomObject]@{ |
| 513 | Type = "GitHubAction" |
| 514 | File = $result.File |
| 515 | Name = $result.ActionRepo |
| 516 | CurrentVersion = $result.CurrentSHA |
| 517 | LatestVersion = $result.LatestSHA |
| 518 | DaysOld = $result.DaysOld |
| 519 | Severity = if ($result.DaysOld -gt 90) { "High" } elseif ($result.DaysOld -gt 60) { "Medium" } else { "Low" } |
| 520 | Message = "GitHub Action is $($result.DaysOld) days old (current: $($result.CurrentSHA.Substring(0,8)), latest: $($result.LatestSHA.Substring(0,8)))" |
| 521 | }) |
| 522 | |
| 523 | Write-SecurityLog "Found stale GitHub Action: $($result.ActionRepo) ($($result.DaysOld) days old)" -Level Warning |
| 524 | } |
| 525 | else { |
| 526 | Write-SecurityLog "GitHub Action is up-to-date: $($result.ActionRepo)" -Level Info |
| 527 | } |
| 528 | } |
| 529 | } |
| 530 | catch { |
| 531 | Write-SecurityLog "Bulk GraphQL check failed, falling back to individual checks: $($_.Exception.Message)" -Level Warning |
| 532 | |
| 533 | # Fallback to individual REST API calls if GraphQL fails |
| 534 | $defaultBranchCache = @{} |
| 535 | $rateLimitExceeded = $false |
| 536 | foreach ($key in $shaToActionMap.Keys) { |
| 537 | $action = $shaToActionMap[$key] |
| 538 | |
| 539 | Write-SecurityLog "Checking GitHub Action (fallback): $($action.Repo)@$($action.SHA)" -Level Info |
| 540 | |
| 541 | # Individual REST API call as fallback |
| 542 | try { |
| 543 | $headers = @{} |
| 544 | if ($env:GITHUB_TOKEN) { |
| 545 | $headers['Authorization'] = "token $env:GITHUB_TOKEN" |
| 546 | } |
| 547 | |
| 548 | $repoSegments = $action.Repo.Split('/') |
| 549 | if ($repoSegments.Count -lt 2) { |
| 550 | Write-SecurityLog "Invalid GitHub Action repository format: $($action.Repo)" -Level Warning |
| 551 | continue |
| 552 | } |
| 553 | |
| 554 | $owner = $repoSegments[0] |
| 555 | $repoName = $repoSegments[1] |
| 556 | $repoLookup = "$owner/$repoName" |
| 557 | |
| 558 | if (-not $defaultBranchCache.ContainsKey($repoLookup)) { |
| 559 | try { |
| 560 | $repoInfo = Invoke-RestMethod -Uri "https://api.github.com/repos/$repoLookup" -Headers $headers -ErrorAction Stop |
| 561 | $defaultBranch = if ($repoInfo.default_branch) { $repoInfo.default_branch } else { "main" } |
| 562 | $defaultBranchCache[$repoLookup] = $defaultBranch |
| 563 | } |
| 564 | catch { |
| 565 | Write-SecurityLog "Failed to discover default branch for $repoLookup, defaulting to 'main': $($_.Exception.Message)" -Level Warning |
| 566 | $defaultBranchCache[$repoLookup] = "main" |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | $branchName = $defaultBranchCache[$repoLookup] |
| 571 | |
| 572 | $BranchInfo = Invoke-RestMethod -Uri "https://api.github.com/repos/$repoLookup/branches/$branchName" -Headers $headers -ErrorAction Stop |
| 573 | $LatestSHA = $BranchInfo.commit.sha |
| 574 | |
| 575 | if ($action.SHA -ne $LatestSHA) { |
| 576 | $CurrentCommit = Invoke-RestMethod -Uri "https://api.github.com/repos/$repoLookup/commits/$($action.SHA)" -Headers $headers -ErrorAction Stop |
| 577 | $CurrentDate = [DateTime]::Parse($CurrentCommit.commit.author.date) |
| 578 | $DaysOld = [Math]::Round((Get-Date).Subtract($CurrentDate).TotalDays) |
| 579 | |
| 580 | if ($DaysOld -gt $MaxAge) { |
| 581 | $script:StaleDependencies.Add([PSCustomObject]@{ |
| 582 | Type = "GitHubAction" |
| 583 | File = $action.File |
| 584 | Name = $action.Repo |
| 585 | CurrentVersion = $action.SHA |
| 586 | LatestVersion = $LatestSHA |
| 587 | DaysOld = $DaysOld |
| 588 | Severity = if ($DaysOld -gt 90) { "High" } elseif ($DaysOld -gt 60) { "Medium" } else { "Low" } |
| 589 | Message = "GitHub Action is $DaysOld days old (current: $($action.SHA.Substring(0,8)), latest: $($LatestSHA.Substring(0,8)))" |
| 590 | }) |
| 591 | |
| 592 | Write-SecurityLog "Found stale GitHub Action (fallback): $($action.Repo) ($DaysOld days old)" -Level Warning |
| 593 | } |
| 594 | } |
| 595 | } |
| 596 | catch { |
| 597 | $statusCode = $null |
| 598 | if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { |
| 599 | $statusCode = [int]$_.Exception.Response.StatusCode |
| 600 | } |
| 601 | elseif ($_.Exception.StatusCode) { |
| 602 | $statusCode = [int]$_.Exception.StatusCode |
| 603 | } |
| 604 | |
| 605 | if ($statusCode -eq 403 -or $statusCode -eq 429) { |
| 606 | Write-SecurityLog "GitHub API rate limit exceeded for $($action.Repo) - skipping remaining GitHub Action checks" -Level Warning |
| 607 | $rateLimitExceeded = $true |
| 608 | } |
| 609 | else { |
| 610 | Write-SecurityLog "Failed to check GitHub Action $($action.Repo): $($_.Exception.Message)" -Level Warning |
| 611 | } |
| 612 | } |
| 613 | |
| 614 | if ($rateLimitExceeded) { |
| 615 | break |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | if ($rateLimitExceeded) { |
| 620 | Write-SecurityLog "GitHub Action staleness results are incomplete due to API rate limiting. Provide a token via GITHUB_TOKEN to enable full coverage." -Level Warning |
| 621 | } |
| 622 | } |
| 623 | } |
| 624 | |
| 625 | function Write-SecurityOutput { |
| 626 | param( |
| 627 | [Parameter(Mandatory = $false)] |
| 628 | [array]$Dependencies = @(), |
| 629 | |
| 630 | [Parameter(Mandatory)] |
| 631 | [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")] |
| 632 | [string]$OutputFormat, |
| 633 | |
| 634 | [Parameter()] |
| 635 | [string]$OutputPath |
| 636 | ) |
| 637 | |
| 638 | switch ($OutputFormat) { |
| 639 | "json" { |
| 640 | $JsonOutput = @{ |
| 641 | Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ" |
| 642 | MaxAgeThreshold = $MaxAge |
| 643 | TotalStaleItems = @($Dependencies).Count |
| 644 | Dependencies = $Dependencies |
| 645 | } | ConvertTo-Json -Depth 10 |
| 646 | |
| 647 | try { |
| 648 | $OutputDir = Split-Path -Parent $OutputPath |
| 649 | if (!(Test-Path $OutputDir)) { |
| 650 | New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null |
| 651 | Write-SecurityLog "Created output directory: $OutputDir" -Level Info |
| 652 | } |
| 653 | |
| 654 | Set-Content -Path $OutputPath -Value $JsonOutput |
| 655 | Write-SecurityLog "JSON report written to: $OutputPath" -Level Success |
| 656 | } |
| 657 | catch { |
| 658 | Write-SecurityLog "Failed to write JSON output: $($_.Exception.Message)" -Level Error |
| 659 | Write-SecurityLog "CAUSE: Insufficient permissions or invalid path" -Level Warning |
| 660 | Write-SecurityLog "SOLUTION: Verify OutputPath is writable: $OutputPath" -Level Warning |
| 661 | } |
| 662 | } |
| 663 | |
| 664 | "github" { |
| 665 | foreach ($Dep in $Dependencies) { |
| 666 | Write-CIAnnotation -Message "[$($Dep.Severity)] $($Dep.Message)" -Level Warning -File $Dep.File |
| 667 | } |
| 668 | |
| 669 | if (@($Dependencies).Count -eq 0) { |
| 670 | Write-CIAnnotation -Message "No stale dependencies detected" -Level Notice |
| 671 | } |
| 672 | else { |
| 673 | Write-CIAnnotation -Message "Found $(@($Dependencies).Count) stale dependencies that may pose security risks" -Level Error |
| 674 | } |
| 675 | |
| 676 | # Build step summary markdown table |
| 677 | $totalCount = @($Dependencies).Count |
| 678 | |
| 679 | if ($totalCount -eq 0) { |
| 680 | $summaryContent = @" |
| 681 | # SHA Staleness Analysis |
| 682 | |
| 683 | **All Clear:** No stale dependencies detected. |
| 684 | |
| 685 | **Found:** 0 | **Stale:** 0 |
| 686 | "@ |
| 687 | } |
| 688 | else { |
| 689 | $tableRows = foreach ($Dep in $Dependencies) { |
| 690 | $status = 'Stale' |
| 691 | "| $($Dep.Name) | $($Dep.DaysOld) | $MaxAge | $status |" |
| 692 | } |
| 693 | |
| 694 | $summaryContent = @" |
| 695 | # SHA Staleness Analysis |
| 696 | |
| 697 | **Found:** $totalCount | **Stale:** $totalCount |
| 698 | |
| 699 | | Dependency | SHA Age (days) | Threshold (days) | Status | |
| 700 | |------------|----------------|-------------------|--------| |
| 701 | $($tableRows -join "`n") |
| 702 | "@ |
| 703 | } |
| 704 | |
| 705 | Write-CIStepSummary -Content $summaryContent |
| 706 | } |
| 707 | |
| 708 | "azdo" { |
| 709 | foreach ($Dep in $Dependencies) { |
| 710 | Write-CIAnnotation -Message "[$($Dep.Severity)] $($Dep.Message)" -Level Warning -File $Dep.File |
| 711 | } |
| 712 | |
| 713 | if (@($Dependencies).Count -eq 0) { |
| 714 | Write-CIAnnotation -Message "No stale dependencies detected" -Level Notice |
| 715 | } |
| 716 | else { |
| 717 | Write-CIAnnotation -Message "Found $(@($Dependencies).Count) stale dependencies that may pose security risks" -Level Error |
| 718 | Set-CITaskResult -Result SucceededWithIssues |
| 719 | } |
| 720 | } |
| 721 | |
| 722 | "console" { |
| 723 | if (@($Dependencies).Count -eq 0) { |
| 724 | Write-SecurityLog "No stale dependencies detected!" -Level Success |
| 725 | } |
| 726 | else { |
| 727 | Write-SecurityLog "=== STALE DEPENDENCIES DETECTED ===" -Level Warning |
| 728 | foreach ($Dep in $Dependencies) { |
| 729 | Write-SecurityLog "[$($Dep.Severity)] $($Dep.Type): $($Dep.Name)" -Level Warning |
| 730 | Write-SecurityLog " File: $($Dep.File)" -Level Info |
| 731 | Write-SecurityLog " Message: $($Dep.Message)" -Level Info |
| 732 | Write-Information "" -InformationAction Continue |
| 733 | } |
| 734 | Write-SecurityLog "Total stale dependencies: $(@($Dependencies).Count)" -Level Warning |
| 735 | } |
| 736 | } |
| 737 | |
| 738 | "Summary" { |
| 739 | if (@($Dependencies).Count -eq 0) { |
| 740 | Write-Output "No stale dependencies detected!" |
| 741 | } |
| 742 | else { |
| 743 | Write-Output "=== SHA Staleness Summary ===" |
| 744 | Write-Output "Total stale dependencies: $(@($Dependencies).Count)" |
| 745 | $ByType = @($Dependencies | Group-Object Type) |
| 746 | foreach ($Group in $ByType) { |
| 747 | Write-Output "$($Group.Name): $($Group.Count)" |
| 748 | } |
| 749 | } |
| 750 | } |
| 751 | } |
| 752 | } |
| 753 | |
| 754 | function Compare-ToolVersion { |
| 755 | <# |
| 756 | .SYNOPSIS |
| 757 | Compares two version strings using semantic versioning rules. |
| 758 | .DESCRIPTION |
| 759 | Normalizes version strings by removing v-prefix and pre-release metadata, |
| 760 | then compares using System.Version when possible. |
| 761 | .OUTPUTS |
| 762 | Returns $true if Latest is newer than Current, $false otherwise. |
| 763 | #> |
| 764 | [CmdletBinding()] |
| 765 | [OutputType([bool])] |
| 766 | param( |
| 767 | [Parameter(Mandatory)] |
| 768 | [string]$Current, |
| 769 | |
| 770 | [Parameter(Mandatory)] |
| 771 | [string]$Latest |
| 772 | ) |
| 773 | |
| 774 | # Normalize: strip v prefix, remove pre-release/build metadata |
| 775 | $normCurrent = $Current -replace '^v', '' -replace '[-+].*$', '' |
| 776 | $normLatest = $Latest -replace '^v', '' -replace '[-+].*$', '' |
| 777 | |
| 778 | $currentVersion = $null |
| 779 | $latestVersion = $null |
| 780 | |
| 781 | if ([System.Version]::TryParse($normCurrent, [ref]$currentVersion) -and |
| 782 | [System.Version]::TryParse($normLatest, [ref]$latestVersion)) { |
| 783 | return $latestVersion -gt $currentVersion |
| 784 | } |
| 785 | |
| 786 | # Fallback: string comparison (not ideal but better than nothing) |
| 787 | Write-Verbose "Version parsing failed, falling back to string comparison" |
| 788 | return $normLatest -ne $normCurrent |
| 789 | } |
| 790 | |
| 791 | function Get-ToolStaleness { |
| 792 | <# |
| 793 | .SYNOPSIS |
| 794 | Checks tool versions against their latest GitHub releases. |
| 795 | |
| 796 | .DESCRIPTION |
| 797 | Reads the tool-checksums.json manifest and queries the GitHub Releases API |
| 798 | to detect when tracked tools have newer versions available. |
| 799 | |
| 800 | .PARAMETER ManifestPath |
| 801 | Path to the tool-checksums.json manifest file. |
| 802 | |
| 803 | .PARAMETER GitHubToken |
| 804 | GitHub API token for authenticated requests (higher rate limits). |
| 805 | #> |
| 806 | [CmdletBinding()] |
| 807 | param( |
| 808 | [Parameter()] |
| 809 | [string]$ManifestPath = (Join-Path $PSScriptRoot "tool-checksums.json"), |
| 810 | |
| 811 | [Parameter()] |
| 812 | [string]$GitHubToken = $env:GITHUB_TOKEN |
| 813 | ) |
| 814 | |
| 815 | if (-not (Test-Path $ManifestPath)) { |
| 816 | Write-Warning "Tool manifest not found: $ManifestPath" |
| 817 | return @() |
| 818 | } |
| 819 | |
| 820 | $manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json |
| 821 | $results = @() |
| 822 | |
| 823 | $headers = @{ |
| 824 | 'Accept' = 'application/vnd.github+json' |
| 825 | 'X-GitHub-Api-Version' = '2022-11-28' |
| 826 | } |
| 827 | if ($GitHubToken) { |
| 828 | $headers['Authorization'] = "Bearer $GitHubToken" |
| 829 | } |
| 830 | |
| 831 | foreach ($tool in $manifest.tools) { |
| 832 | try { |
| 833 | $uri = "https://api.github.com/repos/$($tool.repo)/releases/latest" |
| 834 | $latestRelease = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get |
| 835 | $latestVersion = $latestRelease.tag_name -replace '^v', '' |
| 836 | |
| 837 | $isStale = Compare-ToolVersion -Current $tool.version -Latest $latestVersion |
| 838 | |
| 839 | $results += [PSCustomObject]@{ |
| 840 | Tool = $tool.name |
| 841 | Repository = $tool.repo |
| 842 | CurrentVersion = $tool.version |
| 843 | LatestVersion = $latestVersion |
| 844 | IsStale = $isStale |
| 845 | CurrentSHA256 = $tool.sha256 |
| 846 | Notes = $tool.notes |
| 847 | Error = $null |
| 848 | } |
| 849 | } |
| 850 | catch { |
| 851 | $errorMsg = "Failed to check $($tool.name): $_" |
| 852 | Write-Warning $errorMsg |
| 853 | |
| 854 | $results += [PSCustomObject]@{ |
| 855 | Tool = $tool.name |
| 856 | Repository = $tool.repo |
| 857 | CurrentVersion = $tool.version |
| 858 | LatestVersion = $null |
| 859 | IsStale = $null # Unknown due to error |
| 860 | CurrentSHA256 = $tool.sha256 |
| 861 | Notes = $tool.notes |
| 862 | Error = $errorMsg |
| 863 | } |
| 864 | } |
| 865 | } |
| 866 | |
| 867 | return $results |
| 868 | } |
| 869 | |
| 870 | #region Main Execution |
| 871 | |
| 872 | function Invoke-SHAStalenessCheck { |
| 873 | [CmdletBinding()] |
| 874 | [OutputType([void])] |
| 875 | param( |
| 876 | [Parameter(Mandatory = $false)] |
| 877 | [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")] |
| 878 | [string]$OutputFormat = "console", |
| 879 | |
| 880 | [Parameter(Mandatory = $false)] |
| 881 | [int]$MaxAge = 30, |
| 882 | |
| 883 | [Parameter(Mandatory = $false)] |
| 884 | [string]$LogPath = "./logs/sha-staleness-monitoring.log", |
| 885 | |
| 886 | [Parameter(Mandatory = $false)] |
| 887 | [string]$OutputPath = "./logs/sha-staleness-results.json", |
| 888 | |
| 889 | [Parameter(Mandatory = $false)] |
| 890 | [switch]$FailOnStale, |
| 891 | |
| 892 | [Parameter(Mandatory = $false)] |
| 893 | [ValidateRange(1, 50)] |
| 894 | [int]$GraphQLBatchSize = 20 |
| 895 | ) |
| 896 | |
| 897 | # Ensure logging directory exists (relocated from script scope) |
| 898 | $LogDir = Split-Path -Parent $LogPath |
| 899 | if (!(Test-Path $LogDir)) { |
| 900 | New-Item -ItemType Directory -Path $LogDir -Force | Out-Null |
| 901 | } |
| 902 | |
| 903 | Write-SecurityLog "Starting SHA staleness monitoring..." -Level Info |
| 904 | Write-SecurityLog "Max age threshold: $MaxAge days" -Level Info |
| 905 | Write-SecurityLog "GraphQL batch size: $GraphQLBatchSize queries per request" -Level Info |
| 906 | Write-SecurityLog "Output format: $OutputFormat" -Level Info |
| 907 | |
| 908 | # Reset stale dependencies for this run |
| 909 | $script:StaleDependencies = [System.Collections.Generic.List[PSCustomObject]]::new() |
| 910 | |
| 911 | # Run staleness check for GitHub Actions |
| 912 | Test-GitHubActionsForStaleness |
| 913 | |
| 914 | # Run staleness check for tools from tool-checksums.json |
| 915 | Write-SecurityLog "Checking tool staleness from tool-checksums.json" -Level Info |
| 916 | |
| 917 | $toolResults = @(Get-ToolStaleness) |
| 918 | if (@($toolResults).Count -gt 0) { |
| 919 | $staleTools = @($toolResults | Where-Object { $_.IsStale -eq $true }) |
| 920 | if (@($staleTools).Count -gt 0) { |
| 921 | Write-SecurityLog "Found $(@($staleTools).Count) stale tool(s):" -Level Warning |
| 922 | foreach ($tool in $staleTools) { |
| 923 | Write-SecurityLog " - $($tool.Tool): $($tool.CurrentVersion) -> $($tool.LatestVersion)" -Level Warning |
| 924 | |
| 925 | $script:StaleDependencies.Add([PSCustomObject]@{ |
| 926 | Type = "Tool" |
| 927 | File = "scripts/security/tool-checksums.json" |
| 928 | Name = $tool.Tool |
| 929 | CurrentVersion = $tool.CurrentVersion |
| 930 | LatestVersion = $tool.LatestVersion |
| 931 | DaysOld = $null |
| 932 | Severity = "Medium" |
| 933 | Message = "Tool has newer version available: $($tool.CurrentVersion) -> $($tool.LatestVersion)" |
| 934 | }) |
| 935 | } |
| 936 | } |
| 937 | else { |
| 938 | Write-SecurityLog "All tools are up to date" -Level Info |
| 939 | } |
| 940 | |
| 941 | $errorTools = @($toolResults | Where-Object { $null -ne $_.Error }) |
| 942 | if (@($errorTools).Count -gt 0) { |
| 943 | Write-SecurityLog "Failed to check $(@($errorTools).Count) tool(s)" -Level Warning |
| 944 | } |
| 945 | } |
| 946 | |
| 947 | Write-SecurityOutput -Dependencies $script:StaleDependencies -OutputFormat $OutputFormat -OutputPath $OutputPath |
| 948 | |
| 949 | Write-SecurityLog "SHA staleness monitoring completed" -Level Success |
| 950 | Write-SecurityLog "Stale dependencies found: $(@($script:StaleDependencies).Count)" -Level Info |
| 951 | |
| 952 | if (@($script:StaleDependencies).Count -gt 0 -and $FailOnStale) { |
| 953 | throw "Stale dependencies detected ($(@($script:StaleDependencies).Count) found)" |
| 954 | } |
| 955 | |
| 956 | if (@($script:StaleDependencies).Count -gt 0) { |
| 957 | Write-SecurityLog "Stale dependencies found but not failing (use -FailOnStale to fail build)" -Level Warning |
| 958 | } |
| 959 | } |
| 960 | |
| 961 | if ($MyInvocation.InvocationName -ne '.') { |
| 962 | try { |
| 963 | Invoke-SHAStalenessCheck -OutputFormat $OutputFormat -MaxAge $MaxAge -LogPath $LogPath -OutputPath $OutputPath -FailOnStale:$FailOnStale -GraphQLBatchSize $GraphQLBatchSize |
| 964 | exit 0 |
| 965 | } |
| 966 | catch { |
| 967 | Write-Error -ErrorAction Continue "Test-SHAStaleness failed: $($_.Exception.Message)" |
| 968 | Write-CIAnnotation -Message $_.Exception.Message -Level Error |
| 969 | exit 1 |
| 970 | } |
| 971 | } |
| 972 | |
| 973 | #endregion Main Execution |
| 974 | |