microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
402c9e5aa3f862b2b1597016594bc6145d788386

Branches

Tags

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

Clone

HTTPS

Download ZIP

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()]
54param(
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
79Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
80Import-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
89function 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
344function 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
477function 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
606function 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
643function 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
727function 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
816if ($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