microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/security/Test-SHAStaleness.ps1
828lines · 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 Get-BulkGitHubActionsStaleness { |
| 90 | param( |
| 91 | [Parameter(Mandatory = $true)] |
| 92 | [array]$ActionRepos, |
| 93 | |
| 94 | [Parameter(Mandatory = $true)] |
| 95 | [hashtable]$ShaToActionMap, |
| 96 | |
| 97 | [int]$BatchSize = 20 |
| 98 | ) |
| 99 | |
| 100 | # Setup headers with authentication |
| 101 | $headers = @{ |
| 102 | "Content-Type" = "application/json" |
| 103 | } |
| 104 | |
| 105 | # Check multiple potential sources for GitHub token |
| 106 | $githubToken = $null |
| 107 | if ($env:GITHUB_TOKEN) { |
| 108 | $githubToken = $env:GITHUB_TOKEN |
| 109 | } |
| 110 | elseif ($env:SYSTEM_ACCESSTOKEN -and $env:BUILD_REPOSITORY_PROVIDER -eq "GitHub") { |
| 111 | $githubToken = $env:SYSTEM_ACCESSTOKEN |
| 112 | } |
| 113 | elseif ($env:GH_TOKEN) { |
| 114 | $githubToken = $env:GH_TOKEN |
| 115 | } |
| 116 | |
| 117 | # Validate token if provided |
| 118 | $tokenStatus = Test-GitHubToken -Token $githubToken |
| 119 | if ($tokenStatus.Valid) { |
| 120 | $headers['Authorization'] = "Bearer $githubToken" |
| 121 | } |
| 122 | elseif ($githubToken) { |
| 123 | Write-SecurityLog "Token validation failed, proceeding without authentication" -Level Warning |
| 124 | } |
| 125 | |
| 126 | $apiBase = Get-GitHubApiBase |
| 127 | |
| 128 | # Build GraphQL query for multiple repositories (batch 1: get default branches) |
| 129 | $repoQueries = @() |
| 130 | $aliasMap = @{} |
| 131 | |
| 132 | foreach ($i in 0..($ActionRepos.Count - 1)) { |
| 133 | $repo = $ActionRepos[$i] |
| 134 | $alias = "repo$i" |
| 135 | $aliasMap[$alias] = $repo |
| 136 | |
| 137 | # Parse owner/repo (handle actions with subpaths like github/codeql-action/upload-sarif) |
| 138 | $parts = $repo.Split('/') |
| 139 | if ($parts.Count -lt 2) { continue } |
| 140 | $owner = $parts[0] |
| 141 | $repoName = $parts[1] |
| 142 | |
| 143 | $repoQueries += @" |
| 144 | $alias`: repository(owner: "$owner", name: "$repoName") { |
| 145 | name |
| 146 | defaultBranchRef { |
| 147 | target { |
| 148 | ... on Commit { |
| 149 | oid |
| 150 | committedDate |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | "@ |
| 156 | } |
| 157 | |
| 158 | # Single GraphQL query for all repository default branches |
| 159 | $graphqlQuery = @{ |
| 160 | query = @" |
| 161 | query { |
| 162 | $($repoQueries -join "`n ") |
| 163 | rateLimit { |
| 164 | limit |
| 165 | remaining |
| 166 | used |
| 167 | resetAt |
| 168 | } |
| 169 | } |
| 170 | "@ |
| 171 | } | ConvertTo-Json -Depth 10 |
| 172 | |
| 173 | try { |
| 174 | $repoResponse = Invoke-GitHubAPIWithRetry -Uri "$apiBase/graphql" -Method POST -Headers $headers -Body $graphqlQuery |
| 175 | if ($null -eq $repoResponse) { throw "GitHub GraphQL API returned no response" } |
| 176 | |
| 177 | Write-SecurityLog "GraphQL Rate Limit: $($repoResponse.data.rateLimit.remaining)/$($repoResponse.data.rateLimit.limit) remaining" -Level Info |
| 178 | |
| 179 | if ($repoResponse.errors) { |
| 180 | Write-SecurityLog "GraphQL errors: $($repoResponse.errors | ConvertTo-Json)" -Level Warning |
| 181 | } |
| 182 | } |
| 183 | catch { |
| 184 | $statusCode = $null |
| 185 | if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { |
| 186 | $statusCode = [int]$_.Exception.Response.StatusCode |
| 187 | } |
| 188 | |
| 189 | if ($statusCode -in 403, 429) { |
| 190 | Write-SecurityLog "Repository GraphQL query hit rate limit ($statusCode). Falling back to REST checks." -Level Warning |
| 191 | Write-SecurityLog "SOLUTION: Provide a GitHub token via GITHUB_TOKEN environment variable for higher rate limits" -Level Warning |
| 192 | } |
| 193 | else { |
| 194 | Write-SecurityLog "Repository GraphQL query failed: $($_.Exception.Message)" -Level Error |
| 195 | Write-SecurityLog "CAUSE: Network connectivity issue or GitHub API unavailable" -Level Warning |
| 196 | } |
| 197 | |
| 198 | throw |
| 199 | } |
| 200 | |
| 201 | # Collect commit queries for all current SHAs |
| 202 | $commitQueries = @() |
| 203 | $commitAliasMap = @{} |
| 204 | $commitIndex = 0 |
| 205 | |
| 206 | foreach ($key in $ShaToActionMap.Keys) { |
| 207 | $action = $ShaToActionMap[$key] |
| 208 | $alias = "commit$commitIndex" |
| 209 | $commitAliasMap[$alias] = $key |
| 210 | |
| 211 | # Parse owner/repo (handle actions with subpaths like github/codeql-action/upload-sarif) |
| 212 | $parts = $action.Repo.Split('/') |
| 213 | if ($parts.Count -lt 2) { |
| 214 | Write-SecurityLog "Invalid action repository format: $($action.Repo) - must be 'owner/repo'" -Level Warning |
| 215 | Write-SecurityLog "SOLUTION: Verify action reference in workflow file follows correct format" -Level Warning |
| 216 | continue |
| 217 | } |
| 218 | $owner = $parts[0] |
| 219 | $repoName = $parts[1] |
| 220 | |
| 221 | $commitQueries += @" |
| 222 | $alias`: repository(owner: "$owner", name: "$repoName") { |
| 223 | object(oid: "$($action.SHA)") { |
| 224 | ... on Commit { |
| 225 | oid |
| 226 | committedDate |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | "@ |
| 231 | $commitIndex++ |
| 232 | } |
| 233 | |
| 234 | # Use configurable batch size from script parameter |
| 235 | $allCommitResults = @{} |
| 236 | |
| 237 | for ($i = 0; $i -lt $commitQueries.Count; $i += $BatchSize) { |
| 238 | $endIndex = [Math]::Min($i + $BatchSize - 1, $commitQueries.Count - 1) |
| 239 | $batchQueries = $commitQueries[$i..$endIndex] |
| 240 | |
| 241 | $commitGraphqlQuery = @{ |
| 242 | query = @" |
| 243 | query { |
| 244 | $($batchQueries -join "`n ") |
| 245 | rateLimit { |
| 246 | remaining |
| 247 | cost |
| 248 | } |
| 249 | } |
| 250 | "@ |
| 251 | } | ConvertTo-Json -Depth 10 |
| 252 | |
| 253 | try { |
| 254 | $commitResponse = Invoke-GitHubAPIWithRetry -Uri "$apiBase/graphql" -Method POST -Headers $headers -Body $commitGraphqlQuery |
| 255 | if ($null -eq $commitResponse) { |
| 256 | Write-SecurityLog "GitHub GraphQL API returned no response for commit batch query" -Level Warning |
| 257 | continue |
| 258 | } |
| 259 | |
| 260 | # Merge results |
| 261 | foreach ($property in $commitResponse.data.PSObject.Properties) { |
| 262 | if ($property.Name -ne "rateLimit") { |
| 263 | $allCommitResults[$property.Name] = $property.Value |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | Write-SecurityLog "GraphQL batch $([Math]::Floor($i / $BatchSize) + 1): Cost $($commitResponse.data.rateLimit.cost), $($commitResponse.data.rateLimit.remaining) remaining" -Level Info |
| 268 | } |
| 269 | catch { |
| 270 | Write-SecurityLog "Commit GraphQL batch query failed: $($_.Exception.Message)" -Level Warning |
| 271 | Write-SecurityLog "CAUSE: Network connectivity issue, rate limit exhausted, or malformed query" -Level Warning |
| 272 | Write-SecurityLog "SOLUTION: Check GitHub API status or reduce -GraphQLBatchSize parameter (current: $BatchSize)" -Level Warning |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | # Process results and return staleness information |
| 277 | $results = @() |
| 278 | |
| 279 | foreach ($key in $ShaToActionMap.Keys) { |
| 280 | $action = $ShaToActionMap[$key] |
| 281 | |
| 282 | # Find repository data |
| 283 | $repoAlias = $null |
| 284 | for ($i = 0; $i -lt $ActionRepos.Count; $i++) { |
| 285 | if ($ActionRepos[$i] -eq $action.Repo) { |
| 286 | $repoAlias = "repo$i" |
| 287 | break |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | if (-not $repoAlias -or -not $repoResponse.data.$repoAlias) { |
| 292 | Write-SecurityLog "No repository data found for $($action.Repo)" -Level Warning |
| 293 | continue |
| 294 | } |
| 295 | |
| 296 | $repoData = $repoResponse.data.$repoAlias |
| 297 | if (-not $repoData.defaultBranchRef) { |
| 298 | Write-SecurityLog "No default branch found for $($action.Repo)" -Level Warning |
| 299 | continue |
| 300 | } |
| 301 | |
| 302 | $latestSHA = $repoData.defaultBranchRef.target.oid |
| 303 | $latestDate = [DateTime]::Parse($repoData.defaultBranchRef.target.committedDate) |
| 304 | |
| 305 | # Find current commit data |
| 306 | $commitAlias = $null |
| 307 | foreach ($alias in $commitAliasMap.Keys) { |
| 308 | if ($commitAliasMap[$alias] -eq $key) { |
| 309 | $commitAlias = $alias |
| 310 | break |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | if ($commitAlias -and $allCommitResults[$commitAlias] -and $allCommitResults[$commitAlias].object) { |
| 315 | $currentCommit = $allCommitResults[$commitAlias].object |
| 316 | $currentDate = [DateTime]::Parse($currentCommit.committedDate) |
| 317 | $daysOld = [Math]::Round((Get-Date).Subtract($currentDate).TotalDays) |
| 318 | |
| 319 | $results += @{ |
| 320 | ActionRepo = $action.Repo |
| 321 | CurrentSHA = $action.SHA |
| 322 | LatestSHA = $latestSHA |
| 323 | CurrentDate = $currentDate |
| 324 | LatestDate = $latestDate |
| 325 | DaysOld = $daysOld |
| 326 | IsStale = $action.SHA -ne $latestSHA -and $daysOld -gt $MaxAge |
| 327 | File = $action.File |
| 328 | } |
| 329 | } |
| 330 | else { |
| 331 | Write-SecurityLog "No commit data found for $($action.Repo)@$($action.SHA)" -Level Warning |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | $totalCalls = 1 + [Math]::Ceiling($commitQueries.Count / $BatchSize) |
| 336 | $originalCalls = $ShaToActionMap.Count * 3 |
| 337 | $reduction = [Math]::Round((1 - ($totalCalls / $originalCalls)) * 100, 1) |
| 338 | |
| 339 | Write-SecurityLog "GraphQL optimization: Reduced from ~$originalCalls REST calls to $totalCalls GraphQL calls ($reduction% reduction)" -Level Success |
| 340 | |
| 341 | return $results |
| 342 | } |
| 343 | |
| 344 | function Test-GitHubActionsForStaleness { |
| 345 | Write-SecurityLog "Scanning GitHub Actions workflows for stale SHAs..." -Level Info |
| 346 | |
| 347 | $WorkflowFiles = Get-ChildItem -Path ".github/workflows" -Filter "*.yml" -ErrorAction SilentlyContinue |
| 348 | $allActionRepos = @() |
| 349 | $shaToActionMap = @{} |
| 350 | |
| 351 | # First pass: collect all unique repositories and SHAs |
| 352 | foreach ($File in $WorkflowFiles) { |
| 353 | $Content = Get-Content -Path $File.FullName -Raw |
| 354 | $SHAMatches = [regex]::Matches($Content, "uses:\s*([^@\s]+)@([a-fA-F0-9]{40})") |
| 355 | |
| 356 | foreach ($Match in $SHAMatches) { |
| 357 | $ActionRepo = $Match.Groups[1].Value |
| 358 | $CurrentSHA = $Match.Groups[2].Value |
| 359 | |
| 360 | if ($ActionRepo -notin $allActionRepos) { |
| 361 | $allActionRepos += $ActionRepo |
| 362 | } |
| 363 | |
| 364 | $shaToActionMap["$ActionRepo@$CurrentSHA"] = @{ |
| 365 | Repo = $ActionRepo |
| 366 | SHA = $CurrentSHA |
| 367 | File = $File.FullName |
| 368 | } |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | if (@($allActionRepos).Count -eq 0) { |
| 373 | Write-SecurityLog "No SHA-pinned GitHub Actions found" -Level Info |
| 374 | return |
| 375 | } |
| 376 | |
| 377 | Write-SecurityLog "Found $(@($allActionRepos).Count) unique repositories with $(@($shaToActionMap.Keys).Count) SHA-pinned actions" -Level Info |
| 378 | |
| 379 | # Bulk query for all actions using GraphQL optimization |
| 380 | try { |
| 381 | $bulkResults = Get-BulkGitHubActionsStaleness -ActionRepos $allActionRepos -ShaToActionMap $shaToActionMap -BatchSize $GraphQLBatchSize |
| 382 | |
| 383 | foreach ($result in $bulkResults) { |
| 384 | if ($result.IsStale) { |
| 385 | $script:StaleDependencies.Add([PSCustomObject]@{ |
| 386 | Type = "GitHubAction" |
| 387 | File = $result.File |
| 388 | Name = $result.ActionRepo |
| 389 | CurrentVersion = $result.CurrentSHA |
| 390 | LatestVersion = $result.LatestSHA |
| 391 | DaysOld = $result.DaysOld |
| 392 | Severity = if ($result.DaysOld -gt 90) { "High" } elseif ($result.DaysOld -gt 60) { "Medium" } else { "Low" } |
| 393 | Message = "GitHub Action is $($result.DaysOld) days old (current: $($result.CurrentSHA.Substring(0,8)), latest: $($result.LatestSHA.Substring(0,8)))" |
| 394 | }) |
| 395 | |
| 396 | Write-SecurityLog "Found stale GitHub Action: $($result.ActionRepo) ($($result.DaysOld) days old)" -Level Warning |
| 397 | } |
| 398 | else { |
| 399 | Write-SecurityLog "GitHub Action is up-to-date: $($result.ActionRepo)" -Level Info |
| 400 | } |
| 401 | } |
| 402 | } |
| 403 | catch { |
| 404 | Write-SecurityLog "Bulk GraphQL check failed, falling back to individual checks: $($_.Exception.Message)" -Level Warning |
| 405 | |
| 406 | # Fallback to individual REST API calls via Invoke-GitHubAPIWithRetry |
| 407 | $defaultBranchCache = @{} |
| 408 | foreach ($key in $shaToActionMap.Keys) { |
| 409 | $action = $shaToActionMap[$key] |
| 410 | |
| 411 | Write-SecurityLog "Checking GitHub Action (fallback): $($action.Repo)@$($action.SHA)" -Level Info |
| 412 | |
| 413 | $headers = @{} |
| 414 | if ($env:GITHUB_TOKEN) { |
| 415 | $headers['Authorization'] = "token $env:GITHUB_TOKEN" |
| 416 | } |
| 417 | |
| 418 | $apiBase = Get-GitHubApiBase |
| 419 | $repoSegments = $action.Repo.Split('/') |
| 420 | if ($repoSegments.Count -lt 2) { |
| 421 | Write-SecurityLog "Invalid GitHub Action repository format: $($action.Repo)" -Level Warning |
| 422 | continue |
| 423 | } |
| 424 | |
| 425 | $owner = $repoSegments[0] |
| 426 | $repoName = $repoSegments[1] |
| 427 | $repoLookup = "$owner/$repoName" |
| 428 | |
| 429 | if (-not $defaultBranchCache.ContainsKey($repoLookup)) { |
| 430 | $repoInfo = Invoke-GitHubAPIWithRetry -Uri "$apiBase/repos/$repoLookup" -Method GET -Headers $headers |
| 431 | if ($repoInfo) { |
| 432 | $defaultBranchCache[$repoLookup] = if ($repoInfo.default_branch) { $repoInfo.default_branch } else { "main" } |
| 433 | } |
| 434 | else { |
| 435 | Write-SecurityLog "Failed to discover default branch for $repoLookup, defaulting to 'main'" -Level Warning |
| 436 | $defaultBranchCache[$repoLookup] = "main" |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | $branchName = $defaultBranchCache[$repoLookup] |
| 441 | |
| 442 | $BranchInfo = Invoke-GitHubAPIWithRetry -Uri "$apiBase/repos/$repoLookup/branches/$branchName" -Method GET -Headers $headers |
| 443 | if (-not $BranchInfo) { |
| 444 | Write-SecurityLog "Failed to check GitHub Action $($action.Repo): could not fetch branch info" -Level Warning |
| 445 | continue |
| 446 | } |
| 447 | $LatestSHA = $BranchInfo.commit.sha |
| 448 | |
| 449 | if ($action.SHA -ne $LatestSHA) { |
| 450 | $CurrentCommit = Invoke-GitHubAPIWithRetry -Uri "$apiBase/repos/$repoLookup/commits/$($action.SHA)" -Method GET -Headers $headers |
| 451 | if (-not $CurrentCommit) { |
| 452 | Write-SecurityLog "Failed to check GitHub Action $($action.Repo): could not fetch commit info" -Level Warning |
| 453 | continue |
| 454 | } |
| 455 | $CurrentDate = [DateTime]::Parse($CurrentCommit.commit.author.date) |
| 456 | $DaysOld = [Math]::Round((Get-Date).Subtract($CurrentDate).TotalDays) |
| 457 | |
| 458 | if ($DaysOld -gt $MaxAge) { |
| 459 | $script:StaleDependencies.Add([PSCustomObject]@{ |
| 460 | Type = "GitHubAction" |
| 461 | File = $action.File |
| 462 | Name = $action.Repo |
| 463 | CurrentVersion = $action.SHA |
| 464 | LatestVersion = $LatestSHA |
| 465 | DaysOld = $DaysOld |
| 466 | Severity = if ($DaysOld -gt 90) { "High" } elseif ($DaysOld -gt 60) { "Medium" } else { "Low" } |
| 467 | Message = "GitHub Action is $DaysOld days old (current: $($action.SHA.Substring(0,8)), latest: $($LatestSHA.Substring(0,8)))" |
| 468 | }) |
| 469 | |
| 470 | Write-SecurityLog "Found stale GitHub Action (fallback): $($action.Repo) ($DaysOld days old)" -Level Warning |
| 471 | } |
| 472 | } |
| 473 | } |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | function Write-SecurityOutput { |
| 478 | param( |
| 479 | [Parameter(Mandatory = $false)] |
| 480 | [array]$Dependencies = @(), |
| 481 | |
| 482 | [Parameter(Mandatory)] |
| 483 | [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")] |
| 484 | [string]$OutputFormat, |
| 485 | |
| 486 | [Parameter()] |
| 487 | [string]$OutputPath |
| 488 | ) |
| 489 | |
| 490 | switch ($OutputFormat) { |
| 491 | "json" { |
| 492 | $JsonOutput = @{ |
| 493 | Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ" |
| 494 | MaxAgeThreshold = $MaxAge |
| 495 | TotalStaleItems = @($Dependencies).Count |
| 496 | Dependencies = $Dependencies |
| 497 | } | ConvertTo-Json -Depth 10 |
| 498 | |
| 499 | try { |
| 500 | $OutputDir = Split-Path -Parent $OutputPath |
| 501 | if (!(Test-Path $OutputDir)) { |
| 502 | New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null |
| 503 | Write-SecurityLog "Created output directory: $OutputDir" -Level Info |
| 504 | } |
| 505 | |
| 506 | Set-Content -Path $OutputPath -Value $JsonOutput |
| 507 | Write-SecurityLog "JSON report written to: $OutputPath" -Level Success |
| 508 | } |
| 509 | catch { |
| 510 | Write-SecurityLog "Failed to write JSON output: $($_.Exception.Message)" -Level Error |
| 511 | Write-SecurityLog "CAUSE: Insufficient permissions or invalid path" -Level Warning |
| 512 | Write-SecurityLog "SOLUTION: Verify OutputPath is writable: $OutputPath" -Level Warning |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | "github" { |
| 517 | foreach ($Dep in $Dependencies) { |
| 518 | Write-CIAnnotation -Message "[$($Dep.Severity)] $($Dep.Message)" -Level Warning -File $Dep.File |
| 519 | } |
| 520 | |
| 521 | if (@($Dependencies).Count -eq 0) { |
| 522 | Write-CIAnnotation -Message "No stale dependencies detected" -Level Notice |
| 523 | } |
| 524 | else { |
| 525 | Write-CIAnnotation -Message "Found $(@($Dependencies).Count) stale dependencies that may pose security risks" -Level Error |
| 526 | } |
| 527 | |
| 528 | # Build step summary markdown table |
| 529 | $totalCount = @($Dependencies).Count |
| 530 | |
| 531 | if ($totalCount -eq 0) { |
| 532 | $summaryContent = @" |
| 533 | # SHA Staleness Analysis |
| 534 | |
| 535 | **All Clear:** No stale dependencies detected. |
| 536 | |
| 537 | **Found:** 0 | **Stale:** 0 |
| 538 | "@ |
| 539 | } |
| 540 | else { |
| 541 | $tableRows = foreach ($Dep in $Dependencies) { |
| 542 | $status = 'Stale' |
| 543 | "| $($Dep.Name) | $($Dep.DaysOld) | $MaxAge | $status |" |
| 544 | } |
| 545 | |
| 546 | $summaryContent = @" |
| 547 | # SHA Staleness Analysis |
| 548 | |
| 549 | **Found:** $totalCount | **Stale:** $totalCount |
| 550 | |
| 551 | | Dependency | SHA Age (days) | Threshold (days) | Status | |
| 552 | |------------|----------------|-------------------|--------| |
| 553 | $($tableRows -join "`n") |
| 554 | "@ |
| 555 | } |
| 556 | |
| 557 | Write-CIStepSummary -Content $summaryContent |
| 558 | } |
| 559 | |
| 560 | "azdo" { |
| 561 | foreach ($Dep in $Dependencies) { |
| 562 | Write-CIAnnotation -Message "[$($Dep.Severity)] $($Dep.Message)" -Level Warning -File $Dep.File |
| 563 | } |
| 564 | |
| 565 | if (@($Dependencies).Count -eq 0) { |
| 566 | Write-CIAnnotation -Message "No stale dependencies detected" -Level Notice |
| 567 | } |
| 568 | else { |
| 569 | Write-CIAnnotation -Message "Found $(@($Dependencies).Count) stale dependencies that may pose security risks" -Level Error |
| 570 | Set-CITaskResult -Result SucceededWithIssues |
| 571 | } |
| 572 | } |
| 573 | |
| 574 | "console" { |
| 575 | if (@($Dependencies).Count -eq 0) { |
| 576 | Write-SecurityLog "No stale dependencies detected!" -Level Success |
| 577 | } |
| 578 | else { |
| 579 | Write-SecurityLog "=== STALE DEPENDENCIES DETECTED ===" -Level Warning |
| 580 | foreach ($Dep in $Dependencies) { |
| 581 | Write-SecurityLog "[$($Dep.Severity)] $($Dep.Type): $($Dep.Name)" -Level Warning |
| 582 | Write-SecurityLog " File: $($Dep.File)" -Level Info |
| 583 | Write-SecurityLog " Message: $($Dep.Message)" -Level Info |
| 584 | Write-Information "" -InformationAction Continue |
| 585 | } |
| 586 | Write-SecurityLog "Total stale dependencies: $(@($Dependencies).Count)" -Level Warning |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | "Summary" { |
| 591 | if (@($Dependencies).Count -eq 0) { |
| 592 | Write-Output "No stale dependencies detected!" |
| 593 | } |
| 594 | else { |
| 595 | Write-Output "=== SHA Staleness Summary ===" |
| 596 | Write-Output "Total stale dependencies: $(@($Dependencies).Count)" |
| 597 | $ByType = @($Dependencies | Group-Object Type) |
| 598 | foreach ($Group in $ByType) { |
| 599 | Write-Output "$($Group.Name): $($Group.Count)" |
| 600 | } |
| 601 | } |
| 602 | } |
| 603 | } |
| 604 | } |
| 605 | |
| 606 | function Compare-ToolVersion { |
| 607 | <# |
| 608 | .SYNOPSIS |
| 609 | Compares two version strings using semantic versioning rules. |
| 610 | .DESCRIPTION |
| 611 | Normalizes version strings by removing v-prefix and pre-release metadata, |
| 612 | then compares using System.Version when possible. |
| 613 | .OUTPUTS |
| 614 | Returns $true if Latest is newer than Current, $false otherwise. |
| 615 | #> |
| 616 | [CmdletBinding()] |
| 617 | [OutputType([bool])] |
| 618 | param( |
| 619 | [Parameter(Mandatory)] |
| 620 | [string]$Current, |
| 621 | |
| 622 | [Parameter(Mandatory)] |
| 623 | [string]$Latest |
| 624 | ) |
| 625 | |
| 626 | # Normalize: strip v prefix, remove pre-release/build metadata |
| 627 | $normCurrent = $Current -replace '^v', '' -replace '[-+].*$', '' |
| 628 | $normLatest = $Latest -replace '^v', '' -replace '[-+].*$', '' |
| 629 | |
| 630 | $currentVersion = $null |
| 631 | $latestVersion = $null |
| 632 | |
| 633 | if ([System.Version]::TryParse($normCurrent, [ref]$currentVersion) -and |
| 634 | [System.Version]::TryParse($normLatest, [ref]$latestVersion)) { |
| 635 | return $latestVersion -gt $currentVersion |
| 636 | } |
| 637 | |
| 638 | # Fallback: string comparison (not ideal but better than nothing) |
| 639 | Write-Verbose "Version parsing failed, falling back to string comparison" |
| 640 | return $normLatest -ne $normCurrent |
| 641 | } |
| 642 | |
| 643 | function Get-ToolStaleness { |
| 644 | <# |
| 645 | .SYNOPSIS |
| 646 | Checks tool versions against their latest GitHub releases. |
| 647 | |
| 648 | .DESCRIPTION |
| 649 | Reads the tool-checksums.json manifest and queries the GitHub Releases API |
| 650 | to detect when tracked tools have newer versions available. |
| 651 | |
| 652 | .PARAMETER ManifestPath |
| 653 | Path to the tool-checksums.json manifest file. |
| 654 | |
| 655 | .PARAMETER GitHubToken |
| 656 | GitHub API token for authenticated requests (higher rate limits). |
| 657 | #> |
| 658 | [CmdletBinding()] |
| 659 | param( |
| 660 | [Parameter()] |
| 661 | [string]$ManifestPath = (Join-Path $PSScriptRoot "tool-checksums.json"), |
| 662 | |
| 663 | [Parameter()] |
| 664 | [string]$GitHubToken = $env:GITHUB_TOKEN |
| 665 | ) |
| 666 | |
| 667 | if (-not (Test-Path $ManifestPath)) { |
| 668 | Write-Warning "Tool manifest not found: $ManifestPath" |
| 669 | return @() |
| 670 | } |
| 671 | |
| 672 | $manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json |
| 673 | $results = @() |
| 674 | |
| 675 | $headers = @{ |
| 676 | 'Accept' = 'application/vnd.github+json' |
| 677 | 'X-GitHub-Api-Version' = '2022-11-28' |
| 678 | } |
| 679 | if ($GitHubToken) { |
| 680 | $headers['Authorization'] = "Bearer $GitHubToken" |
| 681 | } |
| 682 | |
| 683 | $apiBase = Get-GitHubApiBase |
| 684 | |
| 685 | foreach ($tool in $manifest.tools) { |
| 686 | $uri = "$apiBase/repos/$($tool.repo)/releases/latest" |
| 687 | $latestRelease = Invoke-GitHubAPIWithRetry -Uri $uri -Method GET -Headers $headers |
| 688 | |
| 689 | if ($latestRelease) { |
| 690 | $latestVersion = $latestRelease.tag_name -replace '^v', '' |
| 691 | |
| 692 | $isStale = Compare-ToolVersion -Current $tool.version -Latest $latestVersion |
| 693 | |
| 694 | $results += [PSCustomObject]@{ |
| 695 | Tool = $tool.name |
| 696 | Repository = $tool.repo |
| 697 | CurrentVersion = $tool.version |
| 698 | LatestVersion = $latestVersion |
| 699 | IsStale = $isStale |
| 700 | CurrentSHA256 = $tool.sha256 |
| 701 | Notes = $tool.notes |
| 702 | Error = $null |
| 703 | } |
| 704 | } |
| 705 | else { |
| 706 | $errorMsg = "Failed to check $($tool.name): API returned no response" |
| 707 | Write-Warning $errorMsg |
| 708 | |
| 709 | $results += [PSCustomObject]@{ |
| 710 | Tool = $tool.name |
| 711 | Repository = $tool.repo |
| 712 | CurrentVersion = $tool.version |
| 713 | LatestVersion = $null |
| 714 | IsStale = $null |
| 715 | CurrentSHA256 = $tool.sha256 |
| 716 | Notes = $tool.notes |
| 717 | Error = $errorMsg |
| 718 | } |
| 719 | } |
| 720 | } |
| 721 | |
| 722 | return $results |
| 723 | } |
| 724 | |
| 725 | #region Main Execution |
| 726 | |
| 727 | function Invoke-SHAStalenessCheck { |
| 728 | [CmdletBinding()] |
| 729 | [OutputType([void])] |
| 730 | param( |
| 731 | [Parameter(Mandatory = $false)] |
| 732 | [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")] |
| 733 | [string]$OutputFormat = "console", |
| 734 | |
| 735 | [Parameter(Mandatory = $false)] |
| 736 | [int]$MaxAge = 30, |
| 737 | |
| 738 | [Parameter(Mandatory = $false)] |
| 739 | [string]$LogPath = "./logs/sha-staleness-monitoring.log", |
| 740 | |
| 741 | [Parameter(Mandatory = $false)] |
| 742 | [string]$OutputPath = "./logs/sha-staleness-results.json", |
| 743 | |
| 744 | [Parameter(Mandatory = $false)] |
| 745 | [switch]$FailOnStale, |
| 746 | |
| 747 | [Parameter(Mandatory = $false)] |
| 748 | [ValidateRange(1, 50)] |
| 749 | [int]$GraphQLBatchSize = 20 |
| 750 | ) |
| 751 | |
| 752 | # Ensure logging directory exists (relocated from script scope) |
| 753 | $LogDir = Split-Path -Parent $LogPath |
| 754 | if (!(Test-Path $LogDir)) { |
| 755 | New-Item -ItemType Directory -Path $LogDir -Force | Out-Null |
| 756 | } |
| 757 | |
| 758 | Write-SecurityLog "Starting SHA staleness monitoring..." -Level Info |
| 759 | Write-SecurityLog "Max age threshold: $MaxAge days" -Level Info |
| 760 | Write-SecurityLog "GraphQL batch size: $GraphQLBatchSize queries per request" -Level Info |
| 761 | Write-SecurityLog "Output format: $OutputFormat" -Level Info |
| 762 | |
| 763 | # Reset stale dependencies for this run |
| 764 | $script:StaleDependencies = [System.Collections.Generic.List[PSCustomObject]]::new() |
| 765 | |
| 766 | # Run staleness check for GitHub Actions |
| 767 | Test-GitHubActionsForStaleness |
| 768 | |
| 769 | # Run staleness check for tools from tool-checksums.json |
| 770 | Write-SecurityLog "Checking tool staleness from tool-checksums.json" -Level Info |
| 771 | |
| 772 | $toolResults = @(Get-ToolStaleness) |
| 773 | if (@($toolResults).Count -gt 0) { |
| 774 | $staleTools = @($toolResults | Where-Object { $_.IsStale -eq $true }) |
| 775 | if (@($staleTools).Count -gt 0) { |
| 776 | Write-SecurityLog "Found $(@($staleTools).Count) stale tool(s):" -Level Warning |
| 777 | foreach ($tool in $staleTools) { |
| 778 | Write-SecurityLog " - $($tool.Tool): $($tool.CurrentVersion) -> $($tool.LatestVersion)" -Level Warning |
| 779 | |
| 780 | $script:StaleDependencies.Add([PSCustomObject]@{ |
| 781 | Type = "Tool" |
| 782 | File = "scripts/security/tool-checksums.json" |
| 783 | Name = $tool.Tool |
| 784 | CurrentVersion = $tool.CurrentVersion |
| 785 | LatestVersion = $tool.LatestVersion |
| 786 | DaysOld = $null |
| 787 | Severity = "Medium" |
| 788 | Message = "Tool has newer version available: $($tool.CurrentVersion) -> $($tool.LatestVersion)" |
| 789 | }) |
| 790 | } |
| 791 | } |
| 792 | else { |
| 793 | Write-SecurityLog "All tools are up to date" -Level Info |
| 794 | } |
| 795 | |
| 796 | $errorTools = @($toolResults | Where-Object { $null -ne $_.Error }) |
| 797 | if (@($errorTools).Count -gt 0) { |
| 798 | Write-SecurityLog "Failed to check $(@($errorTools).Count) tool(s)" -Level Warning |
| 799 | } |
| 800 | } |
| 801 | |
| 802 | Write-SecurityOutput -Dependencies $script:StaleDependencies -OutputFormat $OutputFormat -OutputPath $OutputPath |
| 803 | |
| 804 | Write-SecurityLog "SHA staleness monitoring completed" -Level Success |
| 805 | Write-SecurityLog "Stale dependencies found: $(@($script:StaleDependencies).Count)" -Level Info |
| 806 | |
| 807 | if (@($script:StaleDependencies).Count -gt 0 -and $FailOnStale) { |
| 808 | throw "Stale dependencies detected ($(@($script:StaleDependencies).Count) found)" |
| 809 | } |
| 810 | |
| 811 | if (@($script:StaleDependencies).Count -gt 0) { |
| 812 | Write-SecurityLog "Stale dependencies found but not failing (use -FailOnStale to fail build)" -Level Warning |
| 813 | } |
| 814 | } |
| 815 | |
| 816 | if ($MyInvocation.InvocationName -ne '.') { |
| 817 | try { |
| 818 | Invoke-SHAStalenessCheck -OutputFormat $OutputFormat -MaxAge $MaxAge -LogPath $LogPath -OutputPath $OutputPath -FailOnStale:$FailOnStale -GraphQLBatchSize $GraphQLBatchSize |
| 819 | exit 0 |
| 820 | } |
| 821 | catch { |
| 822 | Write-Error -ErrorAction Continue "Test-SHAStaleness failed: $($_.Exception.Message)" |
| 823 | Write-CIAnnotation -Message $_.Exception.Message -Level Error |
| 824 | exit 1 |
| 825 | } |
| 826 | } |
| 827 | |
| 828 | #endregion Main Execution |
| 829 | |