microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/fix-copilot-code-review

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/security/Test-SHAStaleness.ps1

780lines · modecode

1#!/usr/bin/env pwsh
2<#
3.SYNOPSIS
4 Monitors SHA-pinned dependencies for staleness and security vulnerabilities.
5
6.DESCRIPTION
7 This script scans all SHA-pinned dependencies across GitHub Actions workflows
8 to identify stale or potentially vulnerable dependencies. It outputs results in structured formats
9 that can be consumed by CI/CD systems to generate build warnings.
10
11 Key features:
12 - Detects outdated GitHub Actions SHAs
13 - Outputs results for CI/CD integration
14 - Supports multiple output formats (JSON, Azure DevOps, GitHub Actions)
15
16.PARAMETER OutputFormat
17 Output format: 'json', 'azdo', 'github', or 'console' (default: console)
18
19.PARAMETER MaxAge
20 Maximum age in days before considering a dependency stale (default: 30)
21
22.PARAMETER LogPath
23 Path for security logging (default: ./logs/sha-staleness-monitoring.log)
24
25.PARAMETER OutputPath
26 Path to write structured output file (default: ./logs/stale-dependencies.json)
27
28.EXAMPLE
29 ./Test-SHAStaleness.ps1 -OutputFormat github
30 Check for stale SHAs and output GitHub Actions warnings
31
32.EXAMPLE
33 ./Test-SHAStaleness.ps1 -OutputFormat azdo -MaxAge 14
34 Check for stale SHAs and output Azure DevOps warnings for dependencies older than 14 days
35
36.EXAMPLE
37 ./Test-SHAStaleness.ps1 -OutputFormat json -OutputPath ./security-report.json
38 Generate JSON report of all stale dependencies
39
40.EXAMPLE
41 ./Test-SHAStaleness.ps1 -FailOnStale
42 Fail the build if stale dependencies are found
43
44.EXAMPLE
45 ./Test-SHAStaleness.ps1 -GraphQLBatchSize 10
46 Use smaller GraphQL batch size for rate-limited environments
47#>
48
49[CmdletBinding()]
50param(
51 [Parameter(Mandatory = $false)]
52 [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
53 [string]$OutputFormat = "console",
54
55 [Parameter(Mandatory = $false)]
56 [int]$MaxAge = 30,
57
58 [Parameter(Mandatory = $false)]
59 [string]$LogPath = "./logs/sha-staleness-monitoring.log",
60
61 [Parameter(Mandatory = $false)]
62 [string]$OutputPath = "./logs/sha-staleness-results.json",
63
64 [Parameter(Mandatory = $false)]
65 [switch]$FailOnStale,
66
67 [Parameter(Mandatory = $false)]
68 [ValidateRange(1, 50)]
69 [int]$GraphQLBatchSize = 20
70)
71
72# Ensure logging directory exists
73$LogDir = Split-Path -Parent $LogPath
74if (!(Test-Path $LogDir)) {
75 New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
76}
77
78function Write-SecurityLog {
79 param(
80 [Parameter(Mandatory = $true)]
81 [string]$Message,
82
83 [Parameter(Mandatory = $false)]
84 [ValidateSet("Info", "Warning", "Error", "Success")]
85 [string]$Level = "Info"
86 )
87
88 if ([string]::IsNullOrWhiteSpace($Message)) {
89 $Message = "Empty log message"
90 }
91
92 $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
93 $logEntry = "[$timestamp] [$Level] $Message"
94
95 # Console output with colors (only in console mode)
96 if ($OutputFormat -eq "console") {
97 switch ($Level) {
98 "Info" { Write-Host $logEntry -ForegroundColor Cyan }
99 "Warning" { Write-Host $logEntry -ForegroundColor Yellow }
100 "Error" { Write-Host $logEntry -ForegroundColor Red }
101 "Success" { Write-Host $logEntry -ForegroundColor Green }
102 }
103 }
104
105 # File logging
106 try {
107 Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
108 }
109 catch {
110 Write-Error "Failed to write to log file: $($_.Exception.Message)" -ErrorAction SilentlyContinue
111 }
112}
113
114# Structure to hold stale dependency information
115$StaleDependencies = @()
116
117function Test-GitHubToken {
118 param(
119 [Parameter(Mandatory = $false)]
120 [string]$Token
121 )
122
123 if (-not $Token) {
124 Write-SecurityLog "No GitHub token found" -Level Warning
125 Write-SecurityLog "SOLUTION: Set GITHUB_TOKEN environment variable for higher rate limits (5,000 vs 60 points/hour)" -Level Warning
126 Write-SecurityLog "CAUSE: Unauthenticated GitHub GraphQL API requests are heavily rate limited" -Level Info
127 return @{
128 Valid = $false
129 Authenticated = $false
130 RateLimit = 60
131 Message = "No token provided - using unauthenticated API with 60 points/hour rate limit"
132 }
133 }
134
135 try {
136 $headers = @{
137 "Authorization" = "Bearer $Token"
138 "Content-Type" = "application/json"
139 }
140
141 $query = @{
142 query = "query { viewer { login } rateLimit { limit remaining resetAt } }"
143 } | ConvertTo-Json
144
145 $response = Invoke-RestMethod -Uri "https://api.github.com/graphql" -Method POST -Headers $headers -Body $query -ErrorAction Stop
146
147 if ($response.data.viewer) {
148 $rateLimit = $response.data.rateLimit
149 Write-SecurityLog "GitHub token validated - User: $($response.data.viewer.login), Rate limit: $($rateLimit.remaining)/$($rateLimit.limit)" -Level Success
150
151 if ($rateLimit.remaining -lt 100) {
152 Write-SecurityLog "WARNING: GitHub API rate limit running low ($($rateLimit.remaining) remaining)" -Level Warning
153 Write-SecurityLog "Rate limit resets at: $($rateLimit.resetAt)" -Level Info
154 }
155
156 return @{
157 Valid = $true
158 Authenticated = $true
159 RateLimit = $rateLimit.limit
160 Remaining = $rateLimit.remaining
161 ResetAt = $rateLimit.resetAt
162 User = $response.data.viewer.login
163 Message = "Token valid - authenticated as $($response.data.viewer.login)"
164 }
165 }
166 }
167 catch {
168 Write-SecurityLog "GitHub token validation failed: $($_.Exception.Message)" -Level Error
169 Write-SecurityLog "CAUSE: Token may be expired, revoked, or have insufficient permissions" -Level Warning
170 Write-SecurityLog "SOLUTION: Generate a new GitHub token with 'repo' scope at https://github.com/settings/tokens" -Level Warning
171 return @{
172 Valid = $false
173 Authenticated = $false
174 Message = "Token validation failed: $($_.Exception.Message)"
175 }
176 }
177
178 return @{
179 Valid = $false
180 Authenticated = $false
181 Message = "Token validation returned unexpected response"
182 }
183}
184
185function Invoke-GitHubAPIWithRetry {
186 param(
187 [Parameter(Mandatory = $true)]
188 [string]$Uri,
189
190 [Parameter(Mandatory = $true)]
191 [string]$Method,
192
193 [Parameter(Mandatory = $true)]
194 [hashtable]$Headers,
195
196 [Parameter(Mandatory = $false)]
197 [string]$Body,
198
199 [Parameter(Mandatory = $false)]
200 [int]$MaxRetries = 3,
201
202 [Parameter(Mandatory = $false)]
203 [int]$InitialDelaySeconds = 5
204 )
205
206 $attempt = 0
207 $delay = $InitialDelaySeconds
208
209 while ($attempt -lt $MaxRetries) {
210 try {
211 if ($Body) {
212 $response = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers -Body $Body -ContentType "application/json" -ErrorAction Stop
213 }
214 else {
215 $response = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers -ErrorAction Stop
216 }
217 return $response
218 }
219 catch {
220 $statusCode = $null
221 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
222 $statusCode = [int]$_.Exception.Response.StatusCode
223 }
224
225 # Check if it's a rate limit error (403 or 429)
226 if ($statusCode -in 403, 429) {
227 $attempt++
228 if ($attempt -lt $MaxRetries) {
229 Write-SecurityLog "GitHub API rate limit hit (HTTP $statusCode). Retrying in $delay seconds (attempt $attempt/$MaxRetries)..." -Level Warning
230 Start-Sleep -Seconds $delay
231 $delay = $delay * 2 # Exponential backoff
232 }
233 else {
234 Write-SecurityLog "GitHub API rate limit exceeded after $MaxRetries attempts" -Level Error
235 Write-SecurityLog "CAUSE: Too many API requests in a short time period" -Level Warning
236 Write-SecurityLog "SOLUTION: Wait for rate limit to reset or provide a GitHub token with higher limits" -Level Warning
237 throw
238 }
239 }
240 else {
241 # Non-rate-limit error, throw immediately
242 Write-SecurityLog "GitHub API request failed: $($_.Exception.Message)" -Level Error
243 throw
244 }
245 }
246 }
247
248 throw "Failed to complete GitHub API request after $MaxRetries retries"
249}
250
251function Get-BulkGitHubActionsStaleness {
252 param(
253 [Parameter(Mandatory = $true)]
254 [array]$ActionRepos,
255
256 [Parameter(Mandatory = $true)]
257 [hashtable]$ShaToActionMap,
258
259 [int]$BatchSize = 20
260 )
261
262 # Setup headers with authentication
263 $headers = @{
264 "Content-Type" = "application/json"
265 }
266
267 # Check multiple potential sources for GitHub token
268 $githubToken = $null
269 if ($env:GITHUB_TOKEN) {
270 $githubToken = $env:GITHUB_TOKEN
271 }
272 elseif ($env:SYSTEM_ACCESSTOKEN -and $env:BUILD_REPOSITORY_PROVIDER -eq "GitHub") {
273 $githubToken = $env:SYSTEM_ACCESSTOKEN
274 }
275 elseif ($env:GH_TOKEN) {
276 $githubToken = $env:GH_TOKEN
277 }
278
279 # Validate token if provided
280 $tokenStatus = Test-GitHubToken -Token $githubToken
281 if ($tokenStatus.Valid) {
282 $headers['Authorization'] = "Bearer $githubToken"
283 }
284 elseif ($githubToken) {
285 Write-SecurityLog "Token validation failed, proceeding without authentication" -Level Warning
286 }
287
288 # Build GraphQL query for multiple repositories (batch 1: get default branches)
289 $repoQueries = @()
290 $aliasMap = @{}
291
292 foreach ($i in 0..($ActionRepos.Count - 1)) {
293 $repo = $ActionRepos[$i]
294 $alias = "repo$i"
295 $aliasMap[$alias] = $repo
296
297 # Parse owner/repo (handle actions with subpaths like github/codeql-action/upload-sarif)
298 $parts = $repo.Split('/')
299 if ($parts.Count -lt 2) { continue }
300 $owner = $parts[0]
301 $repoName = $parts[1]
302
303 $repoQueries += @"
304 $alias`: repository(owner: "$owner", name: "$repoName") {
305 name
306 defaultBranchRef {
307 target {
308 ... on Commit {
309 oid
310 committedDate
311 }
312 }
313 }
314 }
315"@
316 }
317
318 # Single GraphQL query for all repository default branches
319 $graphqlQuery = @{
320 query = @"
321 query {
322 $($repoQueries -join "`n ")
323 rateLimit {
324 limit
325 remaining
326 used
327 resetAt
328 }
329 }
330"@
331 } | ConvertTo-Json -Depth 10
332
333 try {
334 $repoResponse = Invoke-GitHubAPIWithRetry -Uri "https://api.github.com/graphql" -Method POST -Headers $headers -Body $graphqlQuery
335
336 Write-SecurityLog "GraphQL Rate Limit: $($repoResponse.data.rateLimit.remaining)/$($repoResponse.data.rateLimit.limit) remaining" -Level Info
337
338 if ($repoResponse.errors) {
339 Write-SecurityLog "GraphQL errors: $($repoResponse.errors | ConvertTo-Json)" -Level Warning
340 }
341 }
342 catch {
343 $statusCode = $null
344 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
345 $statusCode = [int]$_.Exception.Response.StatusCode
346 }
347
348 if ($statusCode -in 403, 429) {
349 Write-SecurityLog "Repository GraphQL query hit rate limit ($statusCode). Falling back to REST checks." -Level Warning
350 Write-SecurityLog "SOLUTION: Provide a GitHub token via GITHUB_TOKEN environment variable for higher rate limits" -Level Warning
351 }
352 else {
353 Write-SecurityLog "Repository GraphQL query failed: $($_.Exception.Message)" -Level Error
354 Write-SecurityLog "CAUSE: Network connectivity issue or GitHub API unavailable" -Level Warning
355 }
356
357 throw
358 }
359
360 # Collect commit queries for all current SHAs
361 $commitQueries = @()
362 $commitAliasMap = @{}
363 $commitIndex = 0
364
365 foreach ($key in $ShaToActionMap.Keys) {
366 $action = $ShaToActionMap[$key]
367 $alias = "commit$commitIndex"
368 $commitAliasMap[$alias] = $key
369
370 # Parse owner/repo (handle actions with subpaths like github/codeql-action/upload-sarif)
371 $parts = $action.Repo.Split('/')
372 if ($parts.Count -lt 2) {
373 Write-SecurityLog "Invalid action repository format: $($action.Repo) - must be 'owner/repo'" -Level Warning
374 Write-SecurityLog "SOLUTION: Verify action reference in workflow file follows correct format" -Level Warning
375 continue
376 }
377 $owner = $parts[0]
378 $repoName = $parts[1]
379
380 $commitQueries += @"
381 $alias`: repository(owner: "$owner", name: "$repoName") {
382 object(oid: "$($action.SHA)") {
383 ... on Commit {
384 oid
385 committedDate
386 }
387 }
388 }
389"@
390 $commitIndex++
391 }
392
393 # Use configurable batch size from script parameter
394 $allCommitResults = @{}
395
396 for ($i = 0; $i -lt $commitQueries.Count; $i += $BatchSize) {
397 $endIndex = [Math]::Min($i + $BatchSize - 1, $commitQueries.Count - 1)
398 $batchQueries = $commitQueries[$i..$endIndex]
399
400 $commitGraphqlQuery = @{
401 query = @"
402 query {
403 $($batchQueries -join "`n ")
404 rateLimit {
405 remaining
406 cost
407 }
408 }
409"@
410 } | ConvertTo-Json -Depth 10
411
412 try {
413 $commitResponse = Invoke-GitHubAPIWithRetry -Uri "https://api.github.com/graphql" -Method POST -Headers $headers -Body $commitGraphqlQuery
414
415 # Merge results
416 foreach ($property in $commitResponse.data.PSObject.Properties) {
417 if ($property.Name -ne "rateLimit") {
418 $allCommitResults[$property.Name] = $property.Value
419 }
420 }
421
422 Write-SecurityLog "GraphQL batch $([Math]::Floor($i / $BatchSize) + 1): Cost $($commitResponse.data.rateLimit.cost), $($commitResponse.data.rateLimit.remaining) remaining" -Level Info
423 }
424 catch {
425 Write-SecurityLog "Commit GraphQL batch query failed: $($_.Exception.Message)" -Level Warning
426 Write-SecurityLog "CAUSE: Network connectivity issue, rate limit exhausted, or malformed query" -Level Warning
427 Write-SecurityLog "SOLUTION: Check GitHub API status or reduce -GraphQLBatchSize parameter (current: $BatchSize)" -Level Warning
428 }
429 }
430
431 # Process results and return staleness information
432 $results = @()
433
434 foreach ($key in $ShaToActionMap.Keys) {
435 $action = $ShaToActionMap[$key]
436
437 # Find repository data
438 $repoAlias = $null
439 for ($i = 0; $i -lt $ActionRepos.Count; $i++) {
440 if ($ActionRepos[$i] -eq $action.Repo) {
441 $repoAlias = "repo$i"
442 break
443 }
444 }
445
446 if (-not $repoAlias -or -not $repoResponse.data.$repoAlias) {
447 Write-SecurityLog "No repository data found for $($action.Repo)" -Level Warning
448 continue
449 }
450
451 $repoData = $repoResponse.data.$repoAlias
452 if (-not $repoData.defaultBranchRef) {
453 Write-SecurityLog "No default branch found for $($action.Repo)" -Level Warning
454 continue
455 }
456
457 $latestSHA = $repoData.defaultBranchRef.target.oid
458 $latestDate = [DateTime]::Parse($repoData.defaultBranchRef.target.committedDate)
459
460 # Find current commit data
461 $commitAlias = $null
462 foreach ($alias in $commitAliasMap.Keys) {
463 if ($commitAliasMap[$alias] -eq $key) {
464 $commitAlias = $alias
465 break
466 }
467 }
468
469 if ($commitAlias -and $allCommitResults[$commitAlias] -and $allCommitResults[$commitAlias].object) {
470 $currentCommit = $allCommitResults[$commitAlias].object
471 $currentDate = [DateTime]::Parse($currentCommit.committedDate)
472 $daysOld = [Math]::Round((Get-Date).Subtract($currentDate).TotalDays)
473
474 $results += @{
475 ActionRepo = $action.Repo
476 CurrentSHA = $action.SHA
477 LatestSHA = $latestSHA
478 CurrentDate = $currentDate
479 LatestDate = $latestDate
480 DaysOld = $daysOld
481 IsStale = $action.SHA -ne $latestSHA -and $daysOld -gt $MaxAge
482 File = $action.File
483 }
484 }
485 else {
486 Write-SecurityLog "No commit data found for $($action.Repo)@$($action.SHA)" -Level Warning
487 }
488 }
489
490 $totalCalls = 1 + [Math]::Ceiling($commitQueries.Count / $BatchSize)
491 $originalCalls = $ShaToActionMap.Count * 3
492 $reduction = [Math]::Round((1 - ($totalCalls / $originalCalls)) * 100, 1)
493
494 Write-SecurityLog "GraphQL optimization: Reduced from ~$originalCalls REST calls to $totalCalls GraphQL calls ($reduction% reduction)" -Level Success
495
496 return $results
497}
498
499function Test-GitHubActionsForStaleness {
500 Write-SecurityLog "Scanning GitHub Actions workflows for stale SHAs..." -Level Info
501
502 $WorkflowFiles = Get-ChildItem -Path ".github/workflows" -Filter "*.yml" -ErrorAction SilentlyContinue
503 $allActionRepos = @()
504 $shaToActionMap = @{}
505
506 # First pass: collect all unique repositories and SHAs
507 foreach ($File in $WorkflowFiles) {
508 $Content = Get-Content -Path $File.FullName -Raw
509 $SHAMatches = [regex]::Matches($Content, "uses:\s*([^@\s]+)@([a-fA-F0-9]{40})")
510
511 foreach ($Match in $SHAMatches) {
512 $ActionRepo = $Match.Groups[1].Value
513 $CurrentSHA = $Match.Groups[2].Value
514
515 if ($ActionRepo -notin $allActionRepos) {
516 $allActionRepos += $ActionRepo
517 }
518
519 $shaToActionMap["$ActionRepo@$CurrentSHA"] = @{
520 Repo = $ActionRepo
521 SHA = $CurrentSHA
522 File = $File.FullName
523 }
524 }
525 }
526
527 if ($allActionRepos.Count -eq 0) {
528 Write-SecurityLog "No SHA-pinned GitHub Actions found" -Level Info
529 return
530 }
531
532 Write-SecurityLog "Found $($allActionRepos.Count) unique repositories with $($shaToActionMap.Count) SHA-pinned actions" -Level Info
533
534 # Bulk query for all actions using GraphQL optimization
535 try {
536 $bulkResults = Get-BulkGitHubActionsStaleness -ActionRepos $allActionRepos -ShaToActionMap $shaToActionMap -BatchSize $GraphQLBatchSize
537
538 foreach ($result in $bulkResults) {
539 if ($result.IsStale) {
540 $script:StaleDependencies += [PSCustomObject]@{
541 Type = "GitHubAction"
542 File = $result.File
543 Name = $result.ActionRepo
544 CurrentVersion = $result.CurrentSHA
545 LatestVersion = $result.LatestSHA
546 DaysOld = $result.DaysOld
547 Severity = if ($result.DaysOld -gt 90) { "High" } elseif ($result.DaysOld -gt 60) { "Medium" } else { "Low" }
548 Message = "GitHub Action is $($result.DaysOld) days old (current: $($result.CurrentSHA.Substring(0,8)), latest: $($result.LatestSHA.Substring(0,8)))"
549 }
550
551 Write-SecurityLog "Found stale GitHub Action: $($result.ActionRepo) ($($result.DaysOld) days old)" -Level Warning
552 }
553 else {
554 Write-SecurityLog "GitHub Action is up-to-date: $($result.ActionRepo)" -Level Info
555 }
556 }
557 }
558 catch {
559 Write-SecurityLog "Bulk GraphQL check failed, falling back to individual checks: $($_.Exception.Message)" -Level Warning
560
561 # Fallback to individual REST API calls if GraphQL fails
562 $defaultBranchCache = @{}
563 $rateLimitExceeded = $false
564 foreach ($key in $shaToActionMap.Keys) {
565 $action = $shaToActionMap[$key]
566
567 Write-SecurityLog "Checking GitHub Action (fallback): $($action.Repo)@$($action.SHA)" -Level Info
568
569 # Individual REST API call as fallback
570 try {
571 $headers = @{}
572 if ($env:GITHUB_TOKEN) {
573 $headers['Authorization'] = "token $env:GITHUB_TOKEN"
574 }
575
576 $repoSegments = $action.Repo.Split('/')
577 if ($repoSegments.Count -lt 2) {
578 Write-SecurityLog "Invalid GitHub Action repository format: $($action.Repo)" -Level Warning
579 continue
580 }
581
582 $owner = $repoSegments[0]
583 $repoName = $repoSegments[1]
584 $repoLookup = "$owner/$repoName"
585
586 if (-not $defaultBranchCache.ContainsKey($repoLookup)) {
587 try {
588 $repoInfo = Invoke-RestMethod -Uri "https://api.github.com/repos/$repoLookup" -Headers $headers -ErrorAction Stop
589 $defaultBranch = if ($repoInfo.default_branch) { $repoInfo.default_branch } else { "main" }
590 $defaultBranchCache[$repoLookup] = $defaultBranch
591 }
592 catch {
593 Write-SecurityLog "Failed to discover default branch for $repoLookup, defaulting to 'main': $($_.Exception.Message)" -Level Warning
594 $defaultBranchCache[$repoLookup] = "main"
595 }
596 }
597
598 $branchName = $defaultBranchCache[$repoLookup]
599
600 $BranchInfo = Invoke-RestMethod -Uri "https://api.github.com/repos/$repoLookup/branches/$branchName" -Headers $headers -ErrorAction Stop
601 $LatestSHA = $BranchInfo.commit.sha
602
603 if ($action.SHA -ne $LatestSHA) {
604 $CurrentCommit = Invoke-RestMethod -Uri "https://api.github.com/repos/$repoLookup/commits/$($action.SHA)" -Headers $headers -ErrorAction Stop
605 $CurrentDate = [DateTime]::Parse($CurrentCommit.commit.author.date)
606 $DaysOld = [Math]::Round((Get-Date).Subtract($CurrentDate).TotalDays)
607
608 if ($DaysOld -gt $MaxAge) {
609 $script:StaleDependencies += [PSCustomObject]@{
610 Type = "GitHubAction"
611 File = $action.File
612 Name = $action.Repo
613 CurrentVersion = $action.SHA
614 LatestVersion = $LatestSHA
615 DaysOld = $DaysOld
616 Severity = if ($DaysOld -gt 90) { "High" } elseif ($DaysOld -gt 60) { "Medium" } else { "Low" }
617 Message = "GitHub Action is $DaysOld days old (current: $($action.SHA.Substring(0,8)), latest: $($LatestSHA.Substring(0,8)))"
618 }
619
620 Write-SecurityLog "Found stale GitHub Action (fallback): $($action.Repo) ($DaysOld days old)" -Level Warning
621 }
622 }
623 }
624 catch {
625 $statusCode = $null
626 if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
627 $statusCode = [int]$_.Exception.Response.StatusCode
628 }
629 elseif ($_.Exception.StatusCode) {
630 $statusCode = [int]$_.Exception.StatusCode
631 }
632
633 if ($statusCode -eq 403 -or $statusCode -eq 429) {
634 Write-SecurityLog "GitHub API rate limit exceeded for $($action.Repo) - skipping remaining GitHub Action checks" -Level Warning
635 $rateLimitExceeded = $true
636 }
637 else {
638 Write-SecurityLog "Failed to check GitHub Action $($action.Repo): $($_.Exception.Message)" -Level Warning
639 }
640 }
641
642 if ($rateLimitExceeded) {
643 break
644 }
645 }
646
647 if ($rateLimitExceeded) {
648 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
649 }
650 }
651}
652
653function Write-OutputResult {
654 param(
655 [Parameter(Mandatory = $false)]
656 [array]$Dependencies = @(),
657
658 [Parameter(Mandatory)]
659 [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")]
660 [string]$OutputFormat,
661
662 [Parameter()]
663 [string]$OutputPath
664 )
665
666 switch ($OutputFormat) {
667 "json" {
668 $JsonOutput = @{
669 Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"
670 MaxAgeThreshold = $MaxAge
671 TotalStaleItems = $Dependencies.Count
672 Dependencies = $Dependencies
673 } | ConvertTo-Json -Depth 10
674
675 try {
676 # Ensure output directory exists
677 $OutputDir = Split-Path -Parent $OutputPath
678 if (!(Test-Path $OutputDir)) {
679 New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
680 Write-SecurityLog "Created output directory: $OutputDir" -Level Info
681 }
682
683 Set-Content -Path $OutputPath -Value $JsonOutput
684 Write-SecurityLog "JSON report written to: $OutputPath" -Level Success
685 }
686 catch {
687 Write-SecurityLog "Failed to write JSON output: $($_.Exception.Message)" -Level Error
688 Write-SecurityLog "CAUSE: Insufficient permissions or invalid path" -Level Warning
689 Write-SecurityLog "SOLUTION: Verify OutputPath is writable: $OutputPath" -Level Warning
690 }
691 }
692
693 "github" {
694 foreach ($Dep in $Dependencies) {
695 $Message = "::warning file=$($Dep.File.Replace('\', '/'))::[$($Dep.Severity)] $($Dep.Message)"
696 Write-Output $Message
697 }
698
699 if ($Dependencies.Count -eq 0) {
700 Write-Output "::notice::No stale dependencies detected"
701 }
702 else {
703 Write-Output "::error::Found $($Dependencies.Count) stale dependencies that may pose security risks"
704 }
705 }
706
707 "azdo" {
708 foreach ($Dep in $Dependencies) {
709 $Message = "##vso[task.logissue type=warning;sourcepath=$($Dep.File);][$($Dep.Severity)] $($Dep.Message)"
710 Write-Output $Message
711 }
712
713 if ($Dependencies.Count -eq 0) {
714 Write-Output "##vso[task.logissue type=info]No stale dependencies detected"
715 }
716 else {
717 Write-Output "##vso[task.logissue type=error]Found $($Dependencies.Count) stale dependencies that may pose security risks"
718 Write-Output "##vso[task.complete 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
754# Main execution
755Write-SecurityLog "Starting SHA staleness monitoring..." -Level Info
756Write-SecurityLog "Max age threshold: $MaxAge days" -Level Info
757Write-SecurityLog "GraphQL batch size: $GraphQLBatchSize queries per request" -Level Info
758Write-SecurityLog "Output format: $OutputFormat" -Level Info
759
760# Run staleness check for GitHub Actions
761Test-GitHubActionsForStaleness
762
763# Output results
764Write-OutputResult -Dependencies $StaleDependencies -OutputFormat $OutputFormat -OutputPath $OutputPath
765
766Write-SecurityLog "SHA staleness monitoring completed" -Level Success
767Write-SecurityLog "Stale dependencies found: $($StaleDependencies.Count)" -Level Info
768
769# Exit with appropriate code based on findings and -FailOnStale parameter
770if ($StaleDependencies.Count -gt 0) {
771 if ($FailOnStale) {
772 Write-SecurityLog "Exiting with status 1 due to stale dependencies (-FailOnStale specified)" -Level Warning
773 exit 1
774 }
775 else {
776 Write-SecurityLog "Stale dependencies found but exiting with status 0 (use -FailOnStale to fail build)" -Level Warning
777 exit 0
778 }
779}
780exit 0 # All good
781