microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/address-powershell-test-comments

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-SHAStaleness.ps1

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