microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3e2dac35fe558d8815fb3594a2e87238732f8ecd

Branches

Tags

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

Clone

HTTPS

Download ZIP

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()]
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 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
157function 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
223function 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
471function 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
625function 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
754function 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
791function 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
872function 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
961if ($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