microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/621-ai-artifacts

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-SHAStaleness.ps1

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